From a7370904423602594f3695c64fc218f4f580404e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9gis=20Hanol?= Date: Thu, 23 Apr 2015 19:33:29 +0200 Subject: [PATCH] - FEATURE: revamped poll plugin - add User.staff scope - inject MessageBus into Ember views (so it can be used by the poll plugin) - REFACTOR: use more accurate is_first_post? method instead of post_number == 1 - FEATURE: add support for JSON-typed custom fields - FEATURE: allow plugins to add validation - FEATURE: add post_custom_fields to PostSerializer - FEATURE: allow plugins to whitelist post_custom_fields - FIX: don't bump when post did not save successfully - FEATURE: polls are supported in any post - FEATURE: allow for multiple polls in the same post - FEATURE: multiple choice polls - FEATURE: rating polls - FEATURE: new dialect allowing users to preview polls in the composer --- .../initializers/inject-objects.js.es6 | 2 +- .../javascripts/discourse/lib/markdown.js | 4 +- .../discourse/views/composer.js.es6 | 8 +- .../javascripts/discourse/views/post.js.es6 | 7 +- .../common/components/buttons.css.scss | 2 - .../common/foundation/variables.scss | 24 +- app/controllers/posts_controller.rb | 4 +- app/models/concerns/has_custom_fields.rb | 48 +- app/models/post.rb | 12 +- app/models/search_observer.rb | 2 +- app/models/user.rb | 2 + app/models/user_action_observer.rb | 2 +- app/serializers/post_serializer.rb | 23 +- app/services/user_destroyer.rb | 2 +- lib/cooked_post_processor.rb | 2 +- lib/guardian/post_guardian.rb | 2 +- lib/plugin/instance.rb | 29 +- lib/post_creator.rb | 4 +- lib/post_destroyer.rb | 8 +- lib/post_revisor.rb | 4 +- lib/pretty_text.rb | 10 +- lib/topic_view.rb | 19 +- plugins/poll/README.md | 39 -- .../javascripts/components/poll-option.js.es6 | 28 ++ .../components/poll-results-number.js.es6 | 22 + .../components/poll-results-standard.js.es6 | 25 ++ .../javascripts/controllers/poll.js.es6 | 202 +++++++-- .../components/poll-results-number.hbs | 1 + .../components/poll-results-standard.hbs | 13 + .../javascripts/discourse/templates/poll.hbs | 63 ++- .../initializers/extend-for-poll.js.es6 | 65 +++ .../javascripts/initializers/poll.js.es6 | 51 --- .../javascripts/lib/decimal-adjust.js.es6 | 16 + .../poll/assets/javascripts/lib/round.js.es6 | 5 + .../assets/javascripts/models/poll.js.es6 | 42 -- .../poll/assets/javascripts/poll_bbcode.js | 9 - .../poll/assets/javascripts/poll_dialect.js | 149 +++++++ .../poll/assets/javascripts/views/poll.js.es6 | 16 +- plugins/poll/assets/stylesheets/poll.scss | 105 +++++ plugins/poll/config/locales/client.ar.yml | 22 - plugins/poll/config/locales/client.ca.yml | 11 - plugins/poll/config/locales/client.de.yml | 18 - plugins/poll/config/locales/client.en.yml | 61 +-- plugins/poll/config/locales/client.es.yml | 18 - plugins/poll/config/locales/client.fa_IR.yml | 17 - plugins/poll/config/locales/client.fi.yml | 18 - plugins/poll/config/locales/client.fr.yml | 18 - plugins/poll/config/locales/client.he.yml | 18 - plugins/poll/config/locales/client.it.yml | 18 - plugins/poll/config/locales/client.ko.yml | 17 - plugins/poll/config/locales/client.pl_PL.yml | 19 - plugins/poll/config/locales/client.pt.yml | 18 - plugins/poll/config/locales/client.pt_BR.yml | 18 - plugins/poll/config/locales/client.ru.yml | 19 - plugins/poll/config/locales/client.sq.yml | 18 - plugins/poll/config/locales/client.te.yml | 18 - plugins/poll/config/locales/client.tr_TR.yml | 17 - plugins/poll/config/locales/client.zh_CN.yml | 17 - plugins/poll/config/locales/server.ar.yml | 18 - plugins/poll/config/locales/server.ca.yml | 11 - plugins/poll/config/locales/server.de.yml | 18 - plugins/poll/config/locales/server.en.yml | 49 +- plugins/poll/config/locales/server.es.yml | 18 - plugins/poll/config/locales/server.fa_IR.yml | 18 - plugins/poll/config/locales/server.fi.yml | 18 - plugins/poll/config/locales/server.fr.yml | 18 - plugins/poll/config/locales/server.he.yml | 18 - plugins/poll/config/locales/server.it.yml | 18 - plugins/poll/config/locales/server.ko.yml | 18 - plugins/poll/config/locales/server.pl_PL.yml | 18 - plugins/poll/config/locales/server.pt.yml | 18 - plugins/poll/config/locales/server.pt_BR.yml | 18 - plugins/poll/config/locales/server.ru.yml | 18 - plugins/poll/config/locales/server.sq.yml | 18 - plugins/poll/config/locales/server.te.yml | 18 - plugins/poll/config/locales/server.tr_TR.yml | 18 - plugins/poll/config/locales/server.zh_CN.yml | 18 - plugins/poll/config/settings.yml | 3 + plugins/poll/plugin.rb | 420 ++++++++++-------- plugins/poll/poll.rb | 168 ------- .../spec/controllers/polls_controller_spec.rb | 99 +++++ .../spec/controllers/posts_controller_spec.rb | 137 ++++++ .../spec/poll_plugin/poll_controller_spec.rb | 92 ---- plugins/poll/spec/poll_plugin/poll_spec.rb | 97 ---- plugins/poll/spec/post_creator_spec.rb | 37 -- .../concern/has_custom_fields_spec.rb | 15 +- spec/components/cooked_post_processor_spec.rb | 8 +- spec/components/post_creator_spec.rb | 8 + spec/models/user_spec.rb | 2 +- 89 files changed, 1334 insertions(+), 1569 deletions(-) delete mode 100644 plugins/poll/README.md create mode 100644 plugins/poll/assets/javascripts/components/poll-option.js.es6 create mode 100644 plugins/poll/assets/javascripts/components/poll-results-number.js.es6 create mode 100644 plugins/poll/assets/javascripts/components/poll-results-standard.js.es6 create mode 100644 plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs create mode 100644 plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs create mode 100644 plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 delete mode 100644 plugins/poll/assets/javascripts/initializers/poll.js.es6 create mode 100644 plugins/poll/assets/javascripts/lib/decimal-adjust.js.es6 create mode 100644 plugins/poll/assets/javascripts/lib/round.js.es6 delete mode 100644 plugins/poll/assets/javascripts/models/poll.js.es6 delete mode 100644 plugins/poll/assets/javascripts/poll_bbcode.js create mode 100644 plugins/poll/assets/javascripts/poll_dialect.js create mode 100644 plugins/poll/assets/stylesheets/poll.scss delete mode 100644 plugins/poll/config/locales/client.ar.yml delete mode 100644 plugins/poll/config/locales/client.ca.yml delete mode 100644 plugins/poll/config/locales/client.de.yml delete mode 100644 plugins/poll/config/locales/client.es.yml delete mode 100644 plugins/poll/config/locales/client.fa_IR.yml delete mode 100644 plugins/poll/config/locales/client.fi.yml delete mode 100644 plugins/poll/config/locales/client.fr.yml delete mode 100644 plugins/poll/config/locales/client.he.yml delete mode 100644 plugins/poll/config/locales/client.it.yml delete mode 100644 plugins/poll/config/locales/client.ko.yml delete mode 100644 plugins/poll/config/locales/client.pl_PL.yml delete mode 100644 plugins/poll/config/locales/client.pt.yml delete mode 100644 plugins/poll/config/locales/client.pt_BR.yml delete mode 100644 plugins/poll/config/locales/client.ru.yml delete mode 100644 plugins/poll/config/locales/client.sq.yml delete mode 100644 plugins/poll/config/locales/client.te.yml delete mode 100644 plugins/poll/config/locales/client.tr_TR.yml delete mode 100644 plugins/poll/config/locales/client.zh_CN.yml delete mode 100644 plugins/poll/config/locales/server.ar.yml delete mode 100644 plugins/poll/config/locales/server.ca.yml delete mode 100644 plugins/poll/config/locales/server.de.yml delete mode 100644 plugins/poll/config/locales/server.es.yml delete mode 100644 plugins/poll/config/locales/server.fa_IR.yml delete mode 100644 plugins/poll/config/locales/server.fi.yml delete mode 100644 plugins/poll/config/locales/server.fr.yml delete mode 100644 plugins/poll/config/locales/server.he.yml delete mode 100644 plugins/poll/config/locales/server.it.yml delete mode 100644 plugins/poll/config/locales/server.ko.yml delete mode 100644 plugins/poll/config/locales/server.pl_PL.yml delete mode 100644 plugins/poll/config/locales/server.pt.yml delete mode 100644 plugins/poll/config/locales/server.pt_BR.yml delete mode 100644 plugins/poll/config/locales/server.ru.yml delete mode 100644 plugins/poll/config/locales/server.sq.yml delete mode 100644 plugins/poll/config/locales/server.te.yml delete mode 100644 plugins/poll/config/locales/server.tr_TR.yml delete mode 100644 plugins/poll/config/locales/server.zh_CN.yml create mode 100644 plugins/poll/config/settings.yml delete mode 100644 plugins/poll/poll.rb create mode 100644 plugins/poll/spec/controllers/polls_controller_spec.rb create mode 100644 plugins/poll/spec/controllers/posts_controller_spec.rb delete mode 100644 plugins/poll/spec/poll_plugin/poll_controller_spec.rb delete mode 100644 plugins/poll/spec/poll_plugin/poll_spec.rb delete mode 100644 plugins/poll/spec/post_creator_spec.rb diff --git a/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 b/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 index be33aed1096..66592eb59d7 100644 --- a/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 +++ b/app/assets/javascripts/discourse/initializers/inject-objects.js.es6 @@ -42,7 +42,7 @@ export default { inject(app, 'currentUser', 'component', 'route', 'controller'); app.register('message-bus:main', window.MessageBus, { instantiate: false }); - inject(app, 'messageBus', 'route', 'controller'); + inject(app, 'messageBus', 'route', 'controller', 'view'); app.register('store:main', Store); inject(app, 'store', 'route', 'controller'); diff --git a/app/assets/javascripts/discourse/lib/markdown.js b/app/assets/javascripts/discourse/lib/markdown.js index 4eb053733ea..6acbe9462a0 100644 --- a/app/assets/javascripts/discourse/lib/markdown.js +++ b/app/assets/javascripts/discourse/lib/markdown.js @@ -259,14 +259,14 @@ Discourse.Markdown = { // The first time, let's add some more whitelisted tags if (!_decoratedCaja) { - // Add anything whitelisted to the list of elements if it's not in there - // already. + // Add anything whitelisted to the list of elements if it's not in there already. var elements = window.html4.ELEMENTS; Object.keys(_validTags).forEach(function(t) { if (!elements[t]) { elements[t] = 0; } }); + _decoratedCaja = true; } diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index 95138ebc951..239616e0512 100644 --- a/app/assets/javascripts/discourse/views/composer.js.es6 +++ b/app/assets/javascripts/discourse/views/composer.js.es6 @@ -234,12 +234,8 @@ const ComposerView = Discourse.View.extend(Ember.Evented, { }, key: "@", transformComplete(v) { - if (v.username) { - return v.username; - } else { - return v.usernames.join(", @"); - } - } + return v.username ? v.username : v.usernames.join(", @"); + } }); this.editor = editor = Discourse.Markdown.createEditor({ diff --git a/app/assets/javascripts/discourse/views/post.js.es6 b/app/assets/javascripts/discourse/views/post.js.es6 index 42bf9dfc4ef..6f767825a35 100644 --- a/app/assets/javascripts/discourse/views/post.js.es6 +++ b/app/assets/javascripts/discourse/views/post.js.es6 @@ -263,13 +263,12 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, { }.on('willDestroyElement'), _postViewInserted: function() { - var $post = this.$(), - post = this.get('post'), - postNumber = post.get('post_number'); + const $post = this.$(), + postNumber = this.get('post').get('post_number'); this._showLinkCounts(); - Discourse.ScreenTrack.current().track(this.$().prop('id'), postNumber); + Discourse.ScreenTrack.current().track($post.prop('id'), postNumber); this.trigger('postViewInserted', $post); diff --git a/app/assets/stylesheets/common/components/buttons.css.scss b/app/assets/stylesheets/common/components/buttons.css.scss index 87ae96a6cb6..c4106aa28ee 100644 --- a/app/assets/stylesheets/common/components/buttons.css.scss +++ b/app/assets/stylesheets/common/components/buttons.css.scss @@ -54,8 +54,6 @@ background: dark-light-diff($primary, $secondary, 65%, -75%); color: #fff; } - &:active { - } &[disabled] { background: scale-color-diff(); &:hover { color: scale-color($primary, $lightness: 70%); } diff --git a/app/assets/stylesheets/common/foundation/variables.scss b/app/assets/stylesheets/common/foundation/variables.scss index bc9ec29b5a4..f16f13f4feb 100644 --- a/app/assets/stylesheets/common/foundation/variables.scss +++ b/app/assets/stylesheets/common/foundation/variables.scss @@ -23,33 +23,31 @@ $github: #6d6d6d !default; // -------------------------------------------------- $base-font-size: 14px !default; -$base-line-height: 19px !default; +$base-line-height: 19px !default; $base-font-family: Helvetica, Arial, sans-serif !default; /* These files don't actually exist. They're injected by DiscourseSassImporter. */ @import "theme_variables"; @import "plugins_variables"; - +// w3c definition of color brightness @function brightness($color) { - @return ((red($color) * .299) + (green($color) * .587) + (blue($color) * .114)); //w3c definition of color brightness + @return ((red($color) * .299) + (green($color) * .587) + (blue($color) * .114)); } @function dark-light-diff($adjusted-color, $comparison-color, $lightness, $darkness) { @if brightness($adjusted-color) < brightness($comparison-color) { - @return scale-color($adjusted-color, $lightness: $lightness) + @return scale-color($adjusted-color, $lightness: $lightness); } @else { - @return scale-color($adjusted-color, $lightness: $darkness) + @return scale-color($adjusted-color, $lightness: $darkness); } } -//phasing out scale-color-diff for dark-light-diff - +// phasing out scale-color-diff for dark-light-diff @function scale-color-diff() { - @if lightness($primary) < lightness($secondary) { - @return scale-color($primary, $lightness: 90%) - } - @else { - @return scale-color($primary, $lightness: -60%) - } + @if lightness($primary) < lightness($secondary) { + @return scale-color($primary, $lightness: 90%); + } @else { + @return scale-color($primary, $lightness: -60%); + } } diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 370eae68c36..46748cf1272 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -116,7 +116,7 @@ class PostsController < ApplicationController } # to stay consistent with the create api, we allow for title & category changes here - if post.post_number == 1 + if post.is_first_post? changes[:title] = params[:title] if params[:title] changes[:category_id] = params[:post][:category_id] if params[:post][:category_id] end @@ -135,7 +135,7 @@ class PostsController < ApplicationController link_counts = TopicLink.counts_for(guardian,post.topic, [post]) post_serializer.single_post_link_counts = link_counts[post.id] if link_counts.present? - result = {post: post_serializer.as_json} + result = { post: post_serializer.as_json } if revisor.category_changed.present? result[:category] = BasicCategorySerializer.new(revisor.category_changed, scope: guardian, root: false).as_json end diff --git a/app/models/concerns/has_custom_fields.rb b/app/models/concerns/has_custom_fields.rb index 7b687619021..7a61a589540 100644 --- a/app/models/concerns/has_custom_fields.rb +++ b/app/models/concerns/has_custom_fields.rb @@ -12,14 +12,29 @@ module HasCustomFields end end - CUSTOM_FIELD_TRUE = ['1', 't', 'true', 'T', 'True', 'TRUE'].freeze unless defined? CUSTOM_FIELD_TRUE + CUSTOM_FIELD_TRUE ||= ['1', 't', 'true', 'T', 'True', 'TRUE'].freeze + + def self.get_custom_field_type(types, key) + return unless types + + sorted_types = types.keys.select { |k| k.end_with?("*") } + .sort_by(&:length) + .reverse + + sorted_types.each do |t| + return types[t] if key =~ /^#{t}/i + end + + types[key] + end def self.cast_custom_field(key, value, types) - return value unless types && type = types[key] + return value unless type = get_custom_field_type(types, key) case type when :boolean then !!CUSTOM_FIELD_TRUE.include?(value) when :integer then value.to_i + when :json then ::JSON.parse(value) else value end @@ -30,8 +45,8 @@ module HasCustomFields has_many :_custom_fields, dependent: :destroy, :class_name => "#{name}CustomField" after_save :save_custom_fields - # To avoid n+1 queries, we have this function to retrieve lots of custom fields in one - # go and create a "sideloaded" version for easy querying by id. + # To avoid n+1 queries, use this function to retrieve lots of custom fields in one go + # and create a "sideloaded" version for easy querying by id. def self.custom_fields_for_ids(ids, whitelisted_fields) klass = "#{name}CustomField".constantize foreign_key = "#{name.underscore}_id".to_sym @@ -39,15 +54,18 @@ module HasCustomFields result = {} return result if whitelisted_fields.blank? - klass.where(foreign_key => ids, :name => whitelisted_fields).pluck(foreign_key, :name, :value).each do |cf| + + klass.where(foreign_key => ids, :name => whitelisted_fields) + .pluck(foreign_key, :name, :value).each do |cf| result[cf[0]] ||= {} append_custom_field(result[cf[0]], cf[1], cf[2]) end + result end def self.append_custom_field(target, key, value) - HasCustomFields::Helpers.append_field(target,key,value,@custom_field_types) + HasCustomFields::Helpers.append_field(target, key, value, @custom_field_types) end def self.register_custom_field_type(name, type) @@ -63,7 +81,6 @@ module HasCustomFields super end - def custom_fields @custom_fields ||= refresh_custom_fields_from_db.dup end @@ -73,8 +90,7 @@ module HasCustomFields end def custom_fields_clean? - # Check whether the cached version has been - # changed on this model + # Check whether the cached version has been changed on this model !@custom_fields || @custom_fields_orig == @custom_fields end @@ -86,9 +102,8 @@ module HasCustomFields _custom_fields.each do |f| if dup[f.name].is_a? Array - # we need to collect Arrays fully before - # we can compare them - if !array_fields.has_key? f.name + # we need to collect Arrays fully before we can compare them + if !array_fields.has_key?(f.name) array_fields[f.name] = [f] else array_fields[f.name] << f @@ -104,17 +119,18 @@ module HasCustomFields # let's iterate through our arrays and compare them array_fields.each do |field_name, fields| - if fields.length == dup[field_name].length && - fields.map{|f| f.value} == dup[field_name] + if fields.length == dup[field_name].length && fields.map(&:value) == dup[field_name] dup.delete(field_name) else - fields.each{|f| f.destroy } + fields.each(&:destroy) end end dup.each do |k,v| if v.is_a? Array - v.each {|subv| _custom_fields.create(name: k, value: subv)} + v.each { |subv| _custom_fields.create(name: k, value: subv) } + elsif v.is_a? Hash + _custom_fields.create(name: k, value: v.to_json) else _custom_fields.create(name: k, value: v) end diff --git a/app/models/post.rb b/app/models/post.rb index 7dc8ab166fd..0f24c597cfa 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -315,7 +315,9 @@ class Post < ActiveRecord::Base end def is_first_post? - post_number == 1 + post_number.blank? ? + topic.try(:highest_post_number) == 0 : + post_number == 1 end def is_flagged? @@ -324,7 +326,7 @@ class Post < ActiveRecord::Base def unhide! self.update_attributes(hidden: false, hidden_at: nil, hidden_reason_id: nil) - self.topic.update_attributes(visible: true) if post_number == 1 + self.topic.update_attributes(visible: true) if is_first_post? save(validate: false) publish_change_to_clients!(:acted) end @@ -372,11 +374,7 @@ class Post < ActiveRecord::Base def rebake!(opts=nil) opts ||= {} - new_cooked = cook( - raw, - topic_id: topic_id, - invalidate_oneboxes: opts.fetch(:invalidate_oneboxes, false) - ) + new_cooked = cook(raw, topic_id: topic_id, invalidate_oneboxes: opts.fetch(:invalidate_oneboxes, false)) old_cooked = cooked update_columns(cooked: new_cooked, baked_at: Time.new, baked_version: BAKED_VERSION) diff --git a/app/models/search_observer.rb b/app/models/search_observer.rb index 5d60df8f635..c04166c55ac 100644 --- a/app/models/search_observer.rb +++ b/app/models/search_observer.rb @@ -60,7 +60,7 @@ class SearchObserver < ActiveRecord::Observer if obj.topic category_name = obj.topic.category.name if obj.topic.category SearchObserver.update_posts_index(obj.id, obj.cooked, obj.topic.title, category_name) - SearchObserver.update_topics_index(obj.topic_id, obj.topic.title, obj.cooked) if obj.post_number == 1 + SearchObserver.update_topics_index(obj.topic_id, obj.topic.title, obj.cooked) if obj.is_first_post? else Rails.logger.warn("Orphan post skipped in search_observer, topic_id: #{obj.topic_id} post_id: #{obj.id} raw: #{obj.raw}") end diff --git a/app/models/user.rb b/app/models/user.rb index 53bc343b5e3..d0fd152fbc4 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -111,6 +111,8 @@ class User < ActiveRecord::Base # excluding fake users like the system user scope :real, -> { where('id > 0') } + scope :staff, -> { where("admin OR moderator") } + # TODO-PERF: There is no indexes on any of these # and NotifyMailingListSubscribers does a select-all-and-loop # may want to create an index on (active, blocked, suspended_till, mailing_list_mode)? diff --git a/app/models/user_action_observer.rb b/app/models/user_action_observer.rb index a7e79ac9287..665a1efdfce 100644 --- a/app/models/user_action_observer.rb +++ b/app/models/user_action_observer.rb @@ -45,7 +45,7 @@ class UserActionObserver < ActiveRecord::Observer def log_post(model) # first post gets nada - return if model.post_number == 1 + return if model.is_first_post? row = { action_type: UserAction::REPLY, diff --git a/app/serializers/post_serializer.rb b/app/serializers/post_serializer.rb index 412aea761a5..59f557799d2 100644 --- a/app/serializers/post_serializer.rb +++ b/app/serializers/post_serializer.rb @@ -1,13 +1,15 @@ class PostSerializer < BasicPostSerializer # To pass in additional information we might need - INSTANCE_VARS = [:topic_view, - :parent_post, - :add_raw, - :single_post_link_counts, - :draft_sequence, - :post_actions, - :all_post_actions] + INSTANCE_VARS = [ + :topic_view, + :parent_post, + :add_raw, + :single_post_link_counts, + :draft_sequence, + :post_actions, + :all_post_actions + ] INSTANCE_VARS.each do |v| self.send(:attr_accessor, v) @@ -268,7 +270,7 @@ class PostSerializer < BasicPostSerializer end def include_static_doc? - object.post_number == 1 && Discourse.static_doc_topic_ids.include?(object.topic_id) + object.is_first_post? && Discourse.static_doc_topic_ids.include?(object.topic_id) end def include_via_email? @@ -289,4 +291,9 @@ class PostSerializer < BasicPostSerializer @active_flags ||= (@topic_view.present? && @topic_view.all_active_flags.present?) ? @topic_view.all_active_flags[object.id] : nil end + def post_custom_fields + @post_custom_fields ||= (@topic_view.present? && @topic_view.post_custom_fields.present?) ? @topic_view.post_custom_fields[object.id] : nil + @post_custom_fields ||= object.custom_fields + end + end diff --git a/app/services/user_destroyer.rb b/app/services/user_destroyer.rb index 4c0b34741c8..7f7a8c825d3 100644 --- a/app/services/user_destroyer.rb +++ b/app/services/user_destroyer.rb @@ -35,7 +35,7 @@ class UserDestroyer PostDestroyer.new(@actor.staff? ? @actor : Discourse.system_user, post).destroy - if post.topic and post.post_number == 1 + if post.topic and post.is_first_post? Topic.unscoped.where(id: post.topic.id).update_all(user_id: nil) end end diff --git a/lib/cooked_post_processor.rb b/lib/cooked_post_processor.rb index 02686aaf11a..c9a53e34a5f 100644 --- a/lib/cooked_post_processor.rb +++ b/lib/cooked_post_processor.rb @@ -205,7 +205,7 @@ class CookedPostProcessor end def update_topic_image(images) - if @post.post_number == 1 + if @post.is_first_post? img = images.first @post.topic.update_column(:image_url, img["src"]) if img["src"].present? end diff --git a/lib/guardian/post_guardian.rb b/lib/guardian/post_guardian.rb index 4c0292048b1..e3f1a030b8b 100644 --- a/lib/guardian/post_guardian.rb +++ b/lib/guardian/post_guardian.rb @@ -116,7 +116,7 @@ module PostGuardian # Deleting Methods def can_delete_post?(post) # Can't delete the first post - return false if post.post_number == 1 + return false if post.is_first_post? # Can't delete after post_edit_time_limit minutes have passed return false if !is_staff? && post.edit_time_limit_expired? diff --git a/lib/plugin/instance.rb b/lib/plugin/instance.rb index 99de257293c..f4c549dbae4 100644 --- a/lib/plugin/instance.rb +++ b/lib/plugin/instance.rb @@ -45,29 +45,33 @@ class Plugin::Instance end def enabled? - return @enabled_site_setting ? SiteSetting.send(@enabled_site_setting) : true + @enabled_site_setting ? SiteSetting.send(@enabled_site_setting) : true end delegate :name, to: :metadata - def add_to_serializer(serializer, attr, &block) + def add_to_serializer(serializer, attr, define_include_method=true, &block) klass = "#{serializer.to_s.classify}Serializer".constantize - klass.attributes(attr) + + klass.attributes(attr) unless attr.to_s.start_with?("include_") + klass.send(:define_method, attr, &block) + return unless define_include_method + # Don't include serialized methods if the plugin is disabled plugin = self - klass.send(:define_method, "include_#{attr}?") do - plugin.enabled? - end + klass.send(:define_method, "include_#{attr}?") { plugin.enabled? } end # Extend a class but check that the plugin is enabled def add_to_class(klass, attr, &block) klass = klass.to_s.classify.constantize - hidden_method_name = "#{attr}_without_enable_check".to_sym - klass.send(:define_method, hidden_method_name, &block) + hidden_method_name = :"#{attr}_without_enable_check" + klass.send(:define_method, hidden_method_name) do |*args| + block.call(*args) + end plugin = self klass.send(:define_method, attr) do |*args| @@ -75,6 +79,15 @@ class Plugin::Instance end end + # Add validation method but check that the plugin is enabled + def validate(klass, attr, &block) + klass = klass.to_s.classify.constantize + klass.send(:define_method, attr, &block) + + plugin = self + klass.validate(attr, if: -> { plugin.enabled? }) + end + # will make sure all the assets this plugin needs are registered def generate_automatic_assets! paths = [] diff --git a/lib/post_creator.rb b/lib/post_creator.rb index c9cac0374ee..0dc39952eef 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -211,7 +211,7 @@ class PostCreator return unless @post && @post.errors.count == 0 && @topic && @topic.category_id Category.where(id: @topic.category_id).update_all(latest_post_id: @post.id) - Category.where(id: @topic.category_id).update_all(latest_topic_id: @topic.id) if @post.post_number == 1 + Category.where(id: @topic.category_id).update_all(latest_topic_id: @topic.id) if @post.is_first_post? end def ensure_in_allowed_users @@ -293,7 +293,7 @@ class PostCreator end @user.user_stat.post_count += 1 - @user.user_stat.topic_count += 1 if @post.post_number == 1 + @user.user_stat.topic_count += 1 if @post.is_first_post? # We don't count replies to your own topics if !@opts[:import_mode] && @user.id != @topic.user_id diff --git a/lib/post_destroyer.rb b/lib/post_destroyer.rb index 42be744991a..7a093629d0c 100644 --- a/lib/post_destroyer.rb +++ b/lib/post_destroyer.rb @@ -55,7 +55,7 @@ class PostDestroyer user_recovered end topic = Topic.with_deleted.find @post.topic_id - topic.recover! if @post.post_number == 1 + topic.recover! if @post.is_first_post? topic.update_statistics end @@ -80,7 +80,7 @@ class PostDestroyer @post.update_flagged_posts_count remove_associated_replies remove_associated_notifications - if @post.topic && @post.post_number == 1 + if @post.topic && @post.is_first_post? StaffActionLogger.new(@user).log_topic_deletion(@post.topic, @opts.slice(:context)) if @user.id != @post.user_id @post.topic.trash!(@user) elsif @user.id != @post.user_id @@ -179,7 +179,7 @@ class PostDestroyer def update_associated_category_latest_topic return unless @post.topic && @post.topic.category - return unless @post.id == @post.topic.category.latest_post_id || (@post.post_number == 1 && @post.topic_id == @post.topic.category.latest_topic_id) + return unless @post.id == @post.topic.category.latest_post_id || (@post.is_first_post? && @post.topic_id == @post.topic.category.latest_topic_id) @post.topic.category.update_latest end @@ -196,7 +196,7 @@ class PostDestroyer end author.user_stat.post_count -= 1 - author.user_stat.topic_count -= 1 if @post.post_number == 1 + author.user_stat.topic_count -= 1 if @post.is_first_post? # We don't count replies to your own topics if @topic && author.id != @topic.user_id diff --git a/lib/post_revisor.rb b/lib/post_revisor.rb index af0ae277e2f..3d85fc25c89 100644 --- a/lib/post_revisor.rb +++ b/lib/post_revisor.rb @@ -332,7 +332,7 @@ class PostRevisor end def bypass_bump? - @opts[:bypass_bump] == true + !@post_successfully_saved || @opts[:bypass_bump] == true end def is_last_post? @@ -347,7 +347,7 @@ class PostRevisor end def revise_topic - return unless @post.post_number == 1 + return unless @post.is_first_post? update_topic_excerpt update_category_description diff --git a/lib/pretty_text.rb b/lib/pretty_text.rb index e298ca9e8b7..4ef8150991f 100644 --- a/lib/pretty_text.rb +++ b/lib/pretty_text.rb @@ -209,11 +209,13 @@ module PrettyText end def self.cook(text, opts={}) - cloned = opts.dup + options = opts.dup + # we have a minor inconsistency - cloned[:topicId] = opts[:topic_id] - sanitized = markdown(text.dup, cloned) - sanitized = add_rel_nofollow_to_user_content(sanitized) if !cloned[:omit_nofollow] && SiteSetting.add_rel_nofollow_to_user_content + options[:topicId] = opts[:topic_id] + + sanitized = markdown(text.dup, options) + sanitized = add_rel_nofollow_to_user_content(sanitized) if !options[:omit_nofollow] && SiteSetting.add_rel_nofollow_to_user_content sanitized end diff --git a/lib/topic_view.rb b/lib/topic_view.rb index 657c985b1f0..041e837b900 100644 --- a/lib/topic_view.rb +++ b/lib/topic_view.rb @@ -6,7 +6,7 @@ require_dependency 'gaps' class TopicView attr_reader :topic, :posts, :guardian, :filtered_posts, :chunk_size - attr_accessor :draft, :draft_key, :draft_sequence, :user_custom_fields + attr_accessor :draft, :draft_key, :draft_sequence, :user_custom_fields, :post_custom_fields def self.slow_chunk_size 10 @@ -16,6 +16,18 @@ class TopicView 20 end + def self.post_custom_fields_whitelisters + @post_custom_fields_whitelisters ||= Set.new + end + + def self.add_post_custom_fields_whitelister(&block) + post_custom_fields_whitelisters << block + end + + def self.whitelisted_post_custom_fields(user) + post_custom_fields_whitelisters.map { |w| w.call(user) }.flatten.uniq + end + def initialize(topic_id, user=nil, options={}) @user = user @guardian = Guardian.new(@user) @@ -47,6 +59,11 @@ class TopicView @user_custom_fields.deep_merge!(User.custom_fields_for_ids(@posts.map(&:user_id), SiteSetting.staff_user_custom_fields.split('|'))) end + whitelisted_fields = TopicView.whitelisted_post_custom_fields(@user) + if whitelisted_fields.present? && @posts + @post_custom_fields = Post.custom_fields_for_ids(@posts.map(&:id), whitelisted_fields) + end + @draft_key = @topic.draft_key @draft_sequence = DraftSequence.current(@user, @draft_key) end diff --git a/plugins/poll/README.md b/plugins/poll/README.md deleted file mode 100644 index 0af9a148778..00000000000 --- a/plugins/poll/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Poll plugin - -Allows you to add a poll to the first post of a topic. - -# Usage - -1. Make your topic title start with "Poll: " -2. Include a list in your post (the **first list** will be used) - -Important note: - -Make sure you have the "Poll: " prefix in the title right from the start. -Editing the title to include it later is not possible atm. - -## Closing the poll - -Change the start of the topic title from "Poll: " to "Closed Poll: ". This feature uses the locale of the user who started the topic. - -_Note: closing a topic will also close the poll._ - -## Specifying the list to be used for the poll - -If you have multiple lists in your post and the first list is _not_ -the one you want to use for the poll, you can identify the -list to be used like this: - -``` -Intro Text - -- Item one -- Item two - -Here are your choices: - -[poll] -- Option 1 -- Option 2 -[/poll] -``` diff --git a/plugins/poll/assets/javascripts/components/poll-option.js.es6 b/plugins/poll/assets/javascripts/components/poll-option.js.es6 new file mode 100644 index 00000000000..aec6e6c79ea --- /dev/null +++ b/plugins/poll/assets/javascripts/components/poll-option.js.es6 @@ -0,0 +1,28 @@ +export default Em.Component.extend({ + tagName: "li", + attributeBindings: ["data-poll-option-id", "data-poll-selected", "style"], + + "data-poll-option-id": Em.computed.alias("option.id"), + + "data-poll-selected": function() { + return this.get("option.selected") ? "selected" : false; + }.property("option.selected"), + + style: function() { + var styles = []; + if (this.get("color")) { styles.push("color:" + this.get("color")); } + if (this.get("background")) { styles.push("background:" + this.get("background")); } + return styles.length > 0 ? styles.join(";") : false; + }.property("color", "background"), + + render(buffer) { + buffer.push(this.get("option.html")); + }, + + click(e) { + // ensure we're not clicking on a link + if ($(e.target).closest("a").length === 0) { + this.sendAction("toggle", this.get("option")); + } + } +}); diff --git a/plugins/poll/assets/javascripts/components/poll-results-number.js.es6 b/plugins/poll/assets/javascripts/components/poll-results-number.js.es6 new file mode 100644 index 00000000000..46a904f279b --- /dev/null +++ b/plugins/poll/assets/javascripts/components/poll-results-number.js.es6 @@ -0,0 +1,22 @@ +import round from "discourse/plugins/poll/lib/round"; + +export default Em.Component.extend({ + tagName: "span", + + totalScore: function() { + return _.reduce(this.get("poll.options"), function(total, o) { + const value = parseInt(o.get("html"), 10), + votes = parseInt(o.get("votes"), 10); + return total + value * votes; + }, 0); + }.property("poll.options.@each.{html,votes}"), + + average: function() { + return round(this.get("totalScore") / this.get("poll.total_votes"), -2); + }.property("totalScore", "poll.total_votes"), + + averageRating: function() { + return I18n.t("poll.average_rating", { average: this.get("average") }); + }.property("average"), + +}); diff --git a/plugins/poll/assets/javascripts/components/poll-results-standard.js.es6 b/plugins/poll/assets/javascripts/components/poll-results-standard.js.es6 new file mode 100644 index 00000000000..49fe8b88dfb --- /dev/null +++ b/plugins/poll/assets/javascripts/components/poll-results-standard.js.es6 @@ -0,0 +1,25 @@ +export default Em.Component.extend({ + tagName: "table", + classNames: ["results"], + + options: function() { + const totalVotes = this.get("poll.total_votes"), + backgroundColor = this.get("poll.background"); + + this.get("poll.options").forEach(option => { + const percentage = Math.floor(100 * option.get("votes") / totalVotes), + styles = ["width: " + percentage + "%"]; + + if (backgroundColor) { styles.push("background: " + backgroundColor); } + + option.setProperties({ + percentage: percentage, + title: I18n.t("poll.option_title", { count: option.get("votes") }), + style: styles.join(";") + }); + }); + + return this.get("poll.options"); + }.property("poll.total_votes", "poll.options.[]") + +}); diff --git a/plugins/poll/assets/javascripts/controllers/poll.js.es6 b/plugins/poll/assets/javascripts/controllers/poll.js.es6 index 93330cf3fb8..a948ad3cb0a 100644 --- a/plugins/poll/assets/javascripts/controllers/poll.js.es6 +++ b/plugins/poll/assets/javascripts/controllers/poll.js.es6 @@ -1,48 +1,188 @@ -import DiscourseController from 'discourse/controllers/controller'; +export default Em.Controller.extend({ + isMultiple: Em.computed.equal("poll.type", "multiple"), + isNumber: Em.computed.equal("poll.type", "number"), + isRandom : Em.computed.equal("poll.order", "random"), + isClosed: Em.computed.equal("poll.status", "closed"), -export default DiscourseController.extend({ - poll: null, - showResults: Em.computed.oneWay('poll.closed'), - disableRadio: Em.computed.any('poll.closed', 'loading'), - showToggleClosePoll: Em.computed.alias('poll.post.topic.details.can_edit'), + // immediately shows the results when the user has already voted + showResults: Em.computed.gt("vote.length", 0), + + // shows the results when + // - poll is closed + // - topic is archived/closed + // - user wants to see the results + showingResults: Em.computed.or("isClosed", "post.topic.closed", "post.topic.archived", "showResults"), + + showResultsDisabled: Em.computed.equal("poll.total_votes", 0), + hideResultsDisabled: Em.computed.alias("isClosed"), + + poll: function() { + const poll = this.get("model"), + vote = this.get("vote"); + + if (poll) { + const options = _.map(poll.get("options"), o => Em.Object.create(o)); + + if (vote) { + options.forEach(o => o.set("selected", vote.indexOf(o.get("id")) >= 0)); + } + + poll.set("options", options); + } + + return poll; + }.property("model"), + + selectedOptions: function() { + return _.map(this.get("poll.options").filterBy("selected"), o => o.get("id")); + }.property("poll.options.@each.selected"), + + totalVotesText: function() { + return I18n.t("poll.total_votes", { count: this.get("poll.total_votes") }); + }.property("poll.total_votes"), + + min: function() { + let min = parseInt(this.get("poll.min"), 10); + if (isNaN(min) || min < 1) { min = 1; } + return min; + }.property("poll.min"), + + max: function() { + let options = this.get("poll.options.length"), + max = parseInt(this.get("poll.max"), 10); + if (isNaN(max) || max > options) { max = options; } + return max; + }.property("poll.max", "poll.options.length"), + + multipleHelpText: function() { + const options = this.get("poll.options.length"), + min = this.get("min"), + max = this.get("max"); + + if (max > 0) { + if (min === max) { + if (min > 1) { + return I18n.t("poll.multiple.help.x_options", { count: min }); + } + } else if (min > 1) { + if (max < options) { + return I18n.t("poll.multiple.help.between_min_and_max_options", { min: min, max: max }); + } else { + return I18n.t("poll.multiple.help.at_least_min_options", { count: min }); + } + } else if (max <= options) { + return I18n.t("poll.multiple.help.up_to_max_options", { count: max }); + } + } + }.property("min", "max", "poll.options.length"), + + canCastVotes: function() { + if (this.get("isClosed") || + this.get("showingResults") || + this.get("loading")) { + return false; + } + + const selectedOptionCount = this.get("selectedOptions.length"); + + if (this.get("isMultiple")) { + return selectedOptionCount >= this.get("min") && selectedOptionCount <= this.get("max"); + } else { + return selectedOptionCount > 0; + } + }.property("isClosed", "showingResults", "loading", + "selectedOptions.length", + "isMultiple", "min", "max"), + + castVotesDisabled: Em.computed.not("canCastVotes"), + + canToggleStatus: function() { + return this.currentUser && + (this.currentUser.get("id") === this.get("post.user_id") || this.currentUser.get("staff")) && + !this.get("loading") && + !this.get("post.topic.closed") && + !this.get("post.topic.archived"); + }.property("loading", "post.user_id", "post.topic.{closed,archived}"), actions: { - selectOption(option) { - if (this.get('disableRadio')) { - return; + + toggleOption(option) { + if (this.get("isClosed")) { return; } + if (!this.currentUser) { return this.send("showLogin"); } + + const wasSelected = option.get("selected"); + + if (!this.get("isMultiple")) { + this.get("poll.options").forEach(o => o.set("selected", false)); } - if (!this.get('postController.currentUser.id')) { - this.get('postController').send('showLogin'); - return; - } + option.toggleProperty("selected"); - this.set('loading', true); + if (!this.get("isMultiple") && !wasSelected) { this.send("castVotes"); } + }, + + castVotes() { + if (!this.get("canCastVotes")) { return; } + if (!this.currentUser) { return this.send("showLogin"); } const self = this; - this.get('poll').saveVote(option).then(function() { - self.setProperties({ loading: false, showResults: true}); + + this.set("loading", true); + + Discourse.ajax("/polls/vote", { + type: "PUT", + data: { + post_id: this.get("post.id"), + poll_name: this.get("poll.name"), + options: this.get("selectedOptions"), + } + }).then(function(results) { + self.setProperties({ vote: results.vote, showingResults: true }); + self.set("model", Em.Object.create(results.poll)); + }).catch(function() { + bootbox.alert(I18n.t("poll.error_while_casting_votes")); + }).finally(function() { + self.set("loading", false); }); }, - toggleShowResults() { - this.toggleProperty('showResults'); + toggleResults() { + this.toggleProperty("showResults"); }, - toggleClosePoll() { - const self = this; + toggleStatus() { + if (!this.get("canToggleStatus")) { return; } - this.set('loading', true); + const self = this, + confirm = this.get("isClosed") ? "poll.open.confirm" : "poll.close.confirm"; - return Discourse.ajax('/poll/toggle_close', { - type: 'PUT', - data: { post_id: this.get('poll.post.id') } - }).then(function(result) { - self.set('poll.post.topic.title', result.basic_topic.title); - self.set('poll.post.topic.fancy_title', result.basic_topic.title); - self.set('loading', false); - }); - } + bootbox.confirm( + I18n.t(confirm), + I18n.t("no_value"), + I18n.t("yes_value"), + function(confirmed) { + if (confirmed) { + self.set("loading", true); + + Discourse.ajax("/polls/toggle_status", { + type: "PUT", + data: { + post_id: self.get("post.id"), + poll_name: self.get("poll.name"), + status: self.get("isClosed") ? "open" : "closed", + } + }).then(function(results) { + self.set("model", Em.Object.create(results.poll)); + }).catch(function() { + bootbox.alert(I18n.t("poll.error_while_toggling_status")); + }).finally(function() { + self.set("loading", false); + }); + } + } + ); + + }, } -}); +}); diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs new file mode 100644 index 00000000000..b6cf23314d9 --- /dev/null +++ b/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs @@ -0,0 +1 @@ +{{{averageRating}}} diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs new file mode 100644 index 00000000000..ccd3b1beb48 --- /dev/null +++ b/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs @@ -0,0 +1,13 @@ + + {{#each option in options}} + + {{{option.html}}} + {{option.percentage}}% + + + +
+ + + {{/each}} + diff --git a/plugins/poll/assets/javascripts/discourse/templates/poll.hbs b/plugins/poll/assets/javascripts/discourse/templates/poll.hbs index 1df25e9f3c4..22bedde107c 100644 --- a/plugins/poll/assets/javascripts/discourse/templates/poll.hbs +++ b/plugins/poll/assets/javascripts/discourse/templates/poll.hbs @@ -1,37 +1,36 @@ - - {{#each po in poll.options}} - - - - - {{/each}} -
- - -
{{{po.option}}}
- {{#if showResults}} -
{{i18n 'poll.voteCount' count=po.votes}}
- {{/if}} -
- -
- - - {{#if showToggleClosePoll}} - + {{else}} + {{/if}}
-{{loading-spinner condition=loading}} +

{{totalVotesText}}

+ +{{#if isMultiple}} +

{{multipleHelpText}}

+ {{d-button class="cast-votes" title="poll.cast-votes.title" label="poll.cast-votes.label" disabled=castVotesDisabled action="castVotes"}} +{{/if}} + +{{#if showingResults}} + {{d-button class="toggle-results" title="poll.hide-results.title" label="poll.hide-results.label" icon="eye-slash" disabled=hideResultsDisabled action="toggleResults"}} +{{else}} + {{d-button class="toggle-results" title="poll.show-results.title" label="poll.show-results.label" icon="eye" disabled=showResultsDisabled action="toggleResults"}} +{{/if}} + +{{#if canToggleStatus}} + {{#if isClosed}} + {{d-button class="toggle-status" title="poll.open.title" label="poll.open.label" icon="unlock-alt" action="toggleStatus"}} + {{else}} + {{d-button class="toggle-status btn-danger" title="poll.close.title" label="poll.close.label" icon="lock" action="toggleStatus"}} + {{/if}} +{{/if}} diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 new file mode 100644 index 00000000000..dfd5d4f62d5 --- /dev/null +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -0,0 +1,65 @@ +import PostView from "discourse/views/post"; + +function createPollView(container, post, poll, vote) { + const controller = container.lookup("controller:poll", { singleton: false }), + view = container.lookup("view:poll"); + + controller.set("vote", vote); + + controller.setProperties({ + model: Em.Object.create(poll), + post: post, + }); + + view.set("controller", controller); + + return view; +} + +export default { + name: "extend-for-poll", + + initialize(container) { + + // overwrite polls + PostView.reopen({ + _createPollViews: function($post) { + const self = this, + post = this.get("post"), + polls = post.get("polls"), + votes = post.get("polls_votes") || {}; + + // don't even bother when there's no poll + if (!polls) { return; } + + const pollViews = {}; + + // iterate over all polls + $(".poll", $post).each(function() { + const $div = $("
"), + $poll = $(this), + pollName = $poll.data("poll-name"), + pollView = createPollView(container, post, polls[pollName], votes[pollName]); + + $poll.replaceWith($div); + pollView.constructor.renderer.replaceIn(pollView, $div[0]); + pollViews[pollName] = pollView; + }); + + this.messageBus.subscribe("/polls/" + this.get("post.id"), results => { + pollViews[results.poll.name].get("controller").set("model", Em.Object.create(results.poll)); + }); + + this.set("pollViews", pollViews); + }.on("postViewInserted"), + + _cleanUpPollViews: function() { + this.messageBus.unsubscribe("/polls/*"); + + if (this.get("pollViews")) { + _.forEach(this.get("pollViews"), v => v.destroy()); + } + }.on("willClearRender") + }); + } +} diff --git a/plugins/poll/assets/javascripts/initializers/poll.js.es6 b/plugins/poll/assets/javascripts/initializers/poll.js.es6 deleted file mode 100644 index 45b102311a9..00000000000 --- a/plugins/poll/assets/javascripts/initializers/poll.js.es6 +++ /dev/null @@ -1,51 +0,0 @@ -import Poll from "discourse/plugins/poll/models/poll"; -import PollView from "discourse/plugins/poll/views/poll"; -import PollController from "discourse/plugins/poll/controllers/poll"; - -import PostView from "discourse/views/post"; - -function initializePollView(self) { - const post = self.get('post'), - pollDetails = post.get('poll_details'); - - let poll = Poll.create({ post: post }); - poll.updateFromJson(pollDetails); - - const pollController = PollController.create({ - poll: poll, - showResults: pollDetails["selected"], - postController: self.get('controller') - }); - - return self.createChildView(PollView, { controller: pollController }); -} - -export default { - name: 'poll', - - initialize: function() { - PostView.reopen({ - createPollUI: function($post) { - if (!this.get('post').get('poll_details')) { - return; - } - - let view = initializePollView(this), - pollContainer = $post.find(".poll-ui:first"); - - if (pollContainer.length === 0) { - pollContainer = $post.find("ul:first"); - } - - let $div = $('
'); - pollContainer.replaceWith($div); - view.constructor.renderer.appendTo(view, $div[0]); - this.set('pollView', view); - }.on('postViewInserted'), - - clearPollView: function() { - if (this.get('pollView')) { this.get('pollView').destroy(); } - }.on('willClearRender') - }); - } -}; diff --git a/plugins/poll/assets/javascripts/lib/decimal-adjust.js.es6 b/plugins/poll/assets/javascripts/lib/decimal-adjust.js.es6 new file mode 100644 index 00000000000..b6da75350ef --- /dev/null +++ b/plugins/poll/assets/javascripts/lib/decimal-adjust.js.es6 @@ -0,0 +1,16 @@ +// from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/floor + +export default function(type, value, exp) { + // If the exp is undefined or zero... + if (typeof exp === 'undefined' || +exp === 0) { return Math[type](value); } + value = +value; + exp = +exp; + // If the value is not a number or the exp is not an integer... + if (isNaN(value) || !(typeof exp === 'number' && exp % 1 === 0)) { return NaN; } + // Shift + value = value.toString().split('e'); + value = Math[type](+(value[0] + 'e' + (value[1] ? (+value[1] - exp) : -exp))); + // Shift back + value = value.toString().split('e'); + return +(value[0] + 'e' + (value[1] ? (+value[1] + exp) : exp)); +} diff --git a/plugins/poll/assets/javascripts/lib/round.js.es6 b/plugins/poll/assets/javascripts/lib/round.js.es6 new file mode 100644 index 00000000000..b26eed0736a --- /dev/null +++ b/plugins/poll/assets/javascripts/lib/round.js.es6 @@ -0,0 +1,5 @@ +import decimalAdjust from "discourse/plugins/poll/lib/decimal-adjust"; + +export default function(value, exp) { + return decimalAdjust("round", value, exp); +} diff --git a/plugins/poll/assets/javascripts/models/poll.js.es6 b/plugins/poll/assets/javascripts/models/poll.js.es6 deleted file mode 100644 index af5abdb9498..00000000000 --- a/plugins/poll/assets/javascripts/models/poll.js.es6 +++ /dev/null @@ -1,42 +0,0 @@ -export default Discourse.Model.extend({ - post: null, - options: [], - closed: false, - - postObserver: function() { - this.updateFromJson(this.get('post.poll_details')); - }.observes('post.poll_details'), - - fetchNewPostDetails: Discourse.debounce(function() { - this.get('post.topic.postStream').triggerChangedPost(this.get('post.id'), this.get('post.topic.updated_at')); - }, 250).observes('post.topic.title'), - - updateFromJson(json) { - const selectedOption = json["selected"]; - let options = []; - - Object.keys(json["options"]).forEach(function(option) { - options.push(Ember.Object.create({ - option: option, - votes: json["options"][option], - checked: option === selectedOption - })); - }); - - this.setProperties({ options: options, closed: json.closed }); - }, - - saveVote(option) { - this.get('options').forEach(function(opt) { - opt.set('checked', opt.get('option') === option); - }); - - const self = this; - return Discourse.ajax("/poll", { - type: "PUT", - data: { post_id: this.get('post.id'), option: option } - }).then(function(newJSON) { - self.updateFromJson(newJSON); - }); - } -}); diff --git a/plugins/poll/assets/javascripts/poll_bbcode.js b/plugins/poll/assets/javascripts/poll_bbcode.js deleted file mode 100644 index 4e0c6f86c5f..00000000000 --- a/plugins/poll/assets/javascripts/poll_bbcode.js +++ /dev/null @@ -1,9 +0,0 @@ -Discourse.Dialect.inlineBetween({ - start: '[poll]', - stop: '[/poll]', - rawContents: true, - emitter: function(contents) { - var list = Discourse.Dialect.cook(contents, {}); - return ['div', { class: 'poll-ui' }, list]; - } -}); diff --git a/plugins/poll/assets/javascripts/poll_dialect.js b/plugins/poll/assets/javascripts/poll_dialect.js new file mode 100644 index 00000000000..e3b88d6f3a2 --- /dev/null +++ b/plugins/poll/assets/javascripts/poll_dialect.js @@ -0,0 +1,149 @@ +(function() { + + const DATA_PREFIX = "data-poll-"; + const DEFAULT_POLL_NAME = "poll"; + + const WHITELISTED_ATTRIBUTES = ["type", "name", "min", "max", "step", "order", "color", "background", "status"]; + const WHITELISTED_STYLES = ["color", "background"]; + + const ATTRIBUTES_REGEX = new RegExp("(" + WHITELISTED_ATTRIBUTES.join("|") + ")=[^\\s\\]]+", "g"); + + Discourse.Dialect.replaceBlock({ + start: /\[poll([^\]]*)\]([\s\S]*)/igm, + stop: /\[\/poll\]/igm, + + emitter: function(blockContents, matches, options) { + // post-process inside block contents + var contents = []; + + if (blockContents.length) { + var self = this, b; + while ((b = blockContents.shift()) !== undefined) { + this.processBlock(b, blockContents).forEach(function (bc) { + if (typeof bc === "string" || bc instanceof String) { + var processed = self.processInline(String(bc)); + if (processed.length) { + contents.push(["p"].concat(processed)); + } + } else { + contents.push(bc); + } + }); + } + } + + // default poll attributes + var attributes = { "class": "poll" }; + attributes[DATA_PREFIX + "status"] = "open"; + attributes[DATA_PREFIX + "name"] = DEFAULT_POLL_NAME; + + // extract poll attributes + (matches[1].match(ATTRIBUTES_REGEX) || []).forEach(function(m) { + var attr = m.split("="); + attributes[DATA_PREFIX + attr[0]] = attr[1]; + }); + + // we might need these values later... + var min = parseInt(attributes[DATA_PREFIX + "min"], 10), + max = parseInt(attributes[DATA_PREFIX + "max"], 10), + step = parseInt(attributes[DATA_PREFIX + "step"], 10); + + // generate the options when the type is "number" + if (attributes[DATA_PREFIX + "type"] === "number") { + // default values + if (isNaN(min)) { min = 1; } + if (isNaN(max)) { max = 10; } + if (isNaN(step)) { step = 1; } + // dynamically generate options + contents.push(["bulletlist"]); + for (var o = min; o <= max; o += step) { + contents[0].push(["listitem", String(o)]); + } + } + + // make sure the first child is a list with at least 1 option + if (contents.length === 0 || contents[0].length <= 1 || (contents[0][0] !== "numberlist" && contents[0][0] !== "bulletlist")) { + return ["div"].concat(contents); + } + + // TODO: remove non whitelisted content + + // generate
  • styles (if any) + var styles = []; + WHITELISTED_STYLES.forEach(function(style) { + if (attributes[DATA_PREFIX + style]) { + styles.push(style + ":" + attributes[DATA_PREFIX + style]); + } + }); + + var style = styles.join(";"); + + // add option id (hash) + style + for (var o = 1; o < contents[0].length; o++) { + // break as soon as the list is done + if (contents[0][o][0] !== "listitem") { break; } + + var attr = {}; + // apply styles if any + if (style.length > 0) { attr["style"] = style; } + // compute md5 hash of the content of the option + attr[DATA_PREFIX + "option-id"] = md5(JSON.stringify(contents[0][o].slice(1))); + // store options attributes + contents[0][o].splice(1, 0, attr); + } + + // that's our poll! + var result = ["div", attributes].concat(contents); + + // add a small paragraph displaying the total number of votes + result.push(["p", I18n.t("poll.total_votes", { count: 0 })]); + + // add some information when type is "multiple" + if (attributes[DATA_PREFIX + "type"] === "multiple") { + var optionCount = contents[0].length - 1; + + // default values + if (isNaN(min) || min < 1) { min = 1; } + if (isNaN(max) || max > optionCount) { max = optionCount; } + + // add some help text + var help; + + if (max > 0) { + if (min === max) { + if (min > 1) { + help = I18n.t("poll.multiple.help.x_options", { count: min }); + } + } else if (min > 1) { + if (max < optionCount) { + help = I18n.t("poll.multiple.help.between_min_and_max_options", { min: min, max: max }); + } else { + help = I18n.t("poll.multiple.help.at_least_min_options", { count: min }); + } + } else if (max <= optionCount) { + help = I18n.t("poll.multiple.help.up_to_max_options", { count: max }); + } + } + + if (help) { result.push(["p", help]); } + + // add "cast-votes" button + result.push(["a", { "class": "button cast-votes", "title": I18n.t("poll.cast-votes.title") }, I18n.t("poll.cast-votes.label")]); + } + + // add "toggle-results" button + result.push(["a", { "class": "button toggle-results", "title": I18n.t("poll.show-results.title") }, I18n.t("poll.show-results.label")]); + + return result; + } + }); + + Discourse.Markdown.whiteListTag("div", "class", "poll"); + Discourse.Markdown.whiteListTag("div", "data-*"); + + Discourse.Markdown.whiteListTag("a", "class", /^button (cast-votes|toggle-results)/); + + Discourse.Markdown.whiteListTag("li", "data-*"); + Discourse.Markdown.whiteListTag("li", "style"); + +})(); diff --git a/plugins/poll/assets/javascripts/views/poll.js.es6 b/plugins/poll/assets/javascripts/views/poll.js.es6 index 74efb078ab3..5ee730eaf3c 100644 --- a/plugins/poll/assets/javascripts/views/poll.js.es6 +++ b/plugins/poll/assets/javascripts/views/poll.js.es6 @@ -1,4 +1,16 @@ -export default Ember.View.extend({ +export default Em.View.extend({ templateName: "poll", - classNames: ['poll-ui'], + classNames: ["poll"], + attributeBindings: ["data-poll-type", "data-poll-name", "data-poll-status"], + + poll: Em.computed.alias("controller.poll"), + + "data-poll-type": Em.computed.alias("poll.type"), + "data-poll-name": Em.computed.alias("poll.name"), + "data-poll-status": Em.computed.alias("poll.status"), + + _fixPollContainerHeight: function() { + const pollContainer = this.$(".poll-container"); + pollContainer.height(pollContainer.height()); + }.on("didInsertElement") }); diff --git a/plugins/poll/assets/stylesheets/poll.scss b/plugins/poll/assets/stylesheets/poll.scss new file mode 100644 index 00000000000..74982c5b439 --- /dev/null +++ b/plugins/poll/assets/stylesheets/poll.scss @@ -0,0 +1,105 @@ +div.poll { + + ul, ol { + margin: 0; + padding: 0; + list-style: none; + display: inline-block; + max-width: 90%; + } + + li, .option { + cursor: pointer; + font-size: 1.125em; + line-height: 2; + } + + li[data-poll-option-id] { + color: $secondary; + background: $primary; + padding: 0 .8em; + margin-bottom: .7em; + border-radius: .25rem; + box-shadow: inset 0 -.2em 0 0 rgba(0,0,0,.2), + inset 0 0 0 100px rgba(0,0,0,0), + 0 .2em 0 0 rgba(0,0,0,.2); + + &:hover { + box-shadow: inset 0 -.2em 0 0 rgba(0,0,0,.25), + inset 0 0 0 100px rgba(0,0,0,.1), + 0 .2em 0 0 rgba(0,0,0,.2); + } + + &:active { + -webkit-transform: translate(0,2px); + transform: translate(0,2px); + box-shadow: inset 0 -.1em 0 0 rgba(0,0,0,.25), + inset 0 0 0 100px rgba(0,0,0,.1), + 0 .1em 0 0 rgba(0,0,0,.2); + } + + &[data-poll-selected="selected"] { + background: green !important; + } + } + + .button { + display: inline-block; + padding: 6px 12px; + margin-right: 5px; + text-align: center; + cursor: pointer; + color: $primary; + background: dark-light-diff($primary, $secondary, 90%, -65%); + + &:hover { + background: dark-light-diff($primary, $secondary, 65%, -75%); + color: #fff; + } + } + + .poll-container { + margin: 0; + span { + font-size: 1.125em; + line-height: 2 + } + } + + .results { + + .option { + max-width: 90%; + padding-right: 1.6em; + } + + .percentage { + width: 10%; + font-size: 1.7em; + text-align: right; + vertical-align: middle; + color: #9E9E9E; + } + + .bar-back { + background: rgb(219,219,219); + } + + .bar { + height: 10px; + background: $primary; + transition: all 0.25s; + } + + } + + &[data-poll-type="number"] { + + li { + display: inline-block; + margin-right: .7em; + } + + } + +} diff --git a/plugins/poll/config/locales/client.ar.yml b/plugins/poll/config/locales/client.ar.yml deleted file mode 100644 index 45eff0a0486..00000000000 --- a/plugins/poll/config/locales/client.ar.yml +++ /dev/null @@ -1,22 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ar: - js: - poll: - voteCount: - zero: "صوت 1" - one: "صوت 1" - two: "صوت 1" - few: "صوت 1" - many: "%{احسب} الأصوات" - other: "%{احسب} الأصوات" - results: - show: إظهار النتائج - hide: إخفاء النتائج - close_poll: "إغلاق التصويت" - open_poll: "فتح التصويت" diff --git a/plugins/poll/config/locales/client.ca.yml b/plugins/poll/config/locales/client.ca.yml deleted file mode 100644 index a620da27724..00000000000 --- a/plugins/poll/config/locales/client.ca.yml +++ /dev/null @@ -1,11 +0,0 @@ -ca: - js: - poll: - voteCount: - one: "1 vot" - other: "%{count} vots" - results: - show: Mostra resultats - hide: Amaga resultats - close_poll: "Tanca enquesta" - open_poll: "Obre enquesta" diff --git a/plugins/poll/config/locales/client.de.yml b/plugins/poll/config/locales/client.de.yml deleted file mode 100644 index 1021d665373..00000000000 --- a/plugins/poll/config/locales/client.de.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -de: - js: - poll: - voteCount: - one: "1 Stimme" - other: "%{count} Stimmen" - results: - show: Ergebnisse anzeigen - hide: Ergebnisse ausblenden - close_poll: "Umfrage beenden" - open_poll: "Umfrage starten" diff --git a/plugins/poll/config/locales/client.en.yml b/plugins/poll/config/locales/client.en.yml index fbe425be74a..1ef1300c1c4 100644 --- a/plugins/poll/config/locales/client.en.yml +++ b/plugins/poll/config/locales/client.en.yml @@ -1,30 +1,41 @@ -# encoding: utf-8 -# This file contains content for the client portion of Discourse, sent out -# to the Javascript app. -# -# To work with us on translations, see: -# https://www.transifex.com/projects/p/discourse-org/ -# -# This is a "source" file, which is used by Transifex to get translations for other languages. -# After this file is changed, it needs to be pushed by a maintainer to Transifex: -# -# tx push -s -# -# Read more here: https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882 -# -# To validate this YAML file after you change it, please paste it into -# http://yamllint.com/ - en: js: poll: - voteCount: - one: "1 vote" - other: "%{count} votes" + total_votes: + zero: "No votes yet. Want to be the first?" + one: "There's only 1 vote." + other: "There are %{count} total votes." - results: - show: Show Results - hide: Hide Results + average_rating: "Average rating: %{average}." - close_poll: "Close Poll" - open_poll: "Open Poll" + multiple: + help: + at_least_min_options: "You may choose at least %{count} options." + up_to_max_options: "You may choose up to %{count} options." + x_options: "You may choose %{count} options." + between_min_and_max_options: "You may choose between %{min} and %{max} options." + + cast-votes: + title: "Cast your votes" + label: "Vote now!" + + show-results: + title: "Display the poll results" + label: "Show results" + + hide-results: + title: "Back to your votes" + label: "Hide results" + + open: + title: "Open the poll" + label: "Open" + confirm: "Are you sure you want to open this poll?" + + close: + title: "Close the poll" + label: "Close" + confirm: "Are you sure you want to close this poll?" + + error_while_toggling_status: "There was an error while toggling the status of this poll." + error_while_casting_votes: "There was an error while casting your votes." diff --git a/plugins/poll/config/locales/client.es.yml b/plugins/poll/config/locales/client.es.yml deleted file mode 100644 index ec158c6eb18..00000000000 --- a/plugins/poll/config/locales/client.es.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -es: - js: - poll: - voteCount: - one: "1 voto" - other: "%{count} votos" - results: - show: Mostrar resultados - hide: Ocultar resultados - close_poll: "Cerrar encuesta" - open_poll: "Abrir encuesta" diff --git a/plugins/poll/config/locales/client.fa_IR.yml b/plugins/poll/config/locales/client.fa_IR.yml deleted file mode 100644 index 73b8c0869ae..00000000000 --- a/plugins/poll/config/locales/client.fa_IR.yml +++ /dev/null @@ -1,17 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fa_IR: - js: - poll: - voteCount: - other: "%{count} آرا" - results: - show: نمایش نتایج - hide: پنهان کرد نتایج - close_poll: "بستن نظرسنجی" - open_poll: "باز کردن نظرسنجی" diff --git a/plugins/poll/config/locales/client.fi.yml b/plugins/poll/config/locales/client.fi.yml deleted file mode 100644 index 7d8db18d58d..00000000000 --- a/plugins/poll/config/locales/client.fi.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fi: - js: - poll: - voteCount: - one: "1 ääni" - other: "%{count} ääntä" - results: - show: Näytä tulokset - hide: Piilota tulokset - close_poll: "Sulje kysely" - open_poll: "Avaa kysely" diff --git a/plugins/poll/config/locales/client.fr.yml b/plugins/poll/config/locales/client.fr.yml deleted file mode 100644 index 784ccac332e..00000000000 --- a/plugins/poll/config/locales/client.fr.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fr: - js: - poll: - voteCount: - one: "1 vote" - other: "%{count} votes" - results: - show: Voir les résultats - hide: Cacher les résultats - close_poll: "Fermer le sondage" - open_poll: "Réouvrir le sondage" diff --git a/plugins/poll/config/locales/client.he.yml b/plugins/poll/config/locales/client.he.yml deleted file mode 100644 index ec8a5155747..00000000000 --- a/plugins/poll/config/locales/client.he.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -he: - js: - poll: - voteCount: - one: "הצבעה אחת" - other: "%{count} הצבעות" - results: - show: הצגת תוצאות - hide: הסתרת תוצאות - close_poll: "סגירת הצבעה" - open_poll: "פתיחת הצבעה" diff --git a/plugins/poll/config/locales/client.it.yml b/plugins/poll/config/locales/client.it.yml deleted file mode 100644 index 1e76d884e41..00000000000 --- a/plugins/poll/config/locales/client.it.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -it: - js: - poll: - voteCount: - one: "1 voto" - other: "%{count} voti" - results: - show: Mostra Risultati - hide: Nascondi Risultati - close_poll: "Chiudi Sondaggio" - open_poll: "Apri Sondaggio" diff --git a/plugins/poll/config/locales/client.ko.yml b/plugins/poll/config/locales/client.ko.yml deleted file mode 100644 index cce2f01902f..00000000000 --- a/plugins/poll/config/locales/client.ko.yml +++ /dev/null @@ -1,17 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ko: - js: - poll: - voteCount: - other: "%{count} 표" - results: - show: 결과 보기 - hide: 결과 숨기기 - close_poll: "투표 끝내기" - open_poll: "투표 시작하기" diff --git a/plugins/poll/config/locales/client.pl_PL.yml b/plugins/poll/config/locales/client.pl_PL.yml deleted file mode 100644 index 066bfe67c59..00000000000 --- a/plugins/poll/config/locales/client.pl_PL.yml +++ /dev/null @@ -1,19 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pl_PL: - js: - poll: - voteCount: - one: "1 głos" - few: "%{count} głosy" - other: "%{count} głosów" - results: - show: Pokaż wyniki - hide: Ukryj wyniki - close_poll: "Zamknij ankietę" - open_poll: "Otwórz ankietę" diff --git a/plugins/poll/config/locales/client.pt.yml b/plugins/poll/config/locales/client.pt.yml deleted file mode 100644 index d3ebf652b8b..00000000000 --- a/plugins/poll/config/locales/client.pt.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pt: - js: - poll: - voteCount: - one: "1 voto" - other: "%{count} votos" - results: - show: Mostrar resultados - hide: Esconder resultados - close_poll: "Encerrar votação" - open_poll: "Abrir votação" diff --git a/plugins/poll/config/locales/client.pt_BR.yml b/plugins/poll/config/locales/client.pt_BR.yml deleted file mode 100644 index 42280740afd..00000000000 --- a/plugins/poll/config/locales/client.pt_BR.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pt_BR: - js: - poll: - voteCount: - one: "1 voto" - other: "%{count} votos" - results: - show: Mostrar Resultados - hide: Esconder Resultados - close_poll: "Fechar Enquete " - open_poll: "Enquete aberta" diff --git a/plugins/poll/config/locales/client.ru.yml b/plugins/poll/config/locales/client.ru.yml deleted file mode 100644 index b7af9a300c5..00000000000 --- a/plugins/poll/config/locales/client.ru.yml +++ /dev/null @@ -1,19 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ru: - js: - poll: - voteCount: - one: "проголосовал 1" - few: "проголосовало %{count}" - other: "проголосовало %{count}" - results: - show: Показать результаты - hide: Скрыть результаты - close_poll: "Завершить опрос" - open_poll: "Запустить опрос снова" diff --git a/plugins/poll/config/locales/client.sq.yml b/plugins/poll/config/locales/client.sq.yml deleted file mode 100644 index 025a2b0c26b..00000000000 --- a/plugins/poll/config/locales/client.sq.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -sq: - js: - poll: - voteCount: - one: "1 votë" - other: "%{count} vota" - results: - show: Shfaq Rezultatet - hide: Fsheh Rezultate - close_poll: "Mbyll Sondazhin" - open_poll: "Hap Sondazhin" diff --git a/plugins/poll/config/locales/client.te.yml b/plugins/poll/config/locales/client.te.yml deleted file mode 100644 index bc2b7210ed3..00000000000 --- a/plugins/poll/config/locales/client.te.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -te: - js: - poll: - voteCount: - one: "ఒక ఓటు" - other: "%{count} ఓట్లు" - results: - show: ఫలితాలు చూపించు - hide: ఫలితాలు దాయు - close_poll: "ఓటు ముగించు" - open_poll: "ఓటు తెరువు" diff --git a/plugins/poll/config/locales/client.tr_TR.yml b/plugins/poll/config/locales/client.tr_TR.yml deleted file mode 100644 index 721807dd241..00000000000 --- a/plugins/poll/config/locales/client.tr_TR.yml +++ /dev/null @@ -1,17 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -tr_TR: - js: - poll: - voteCount: - other: "%{count} oy" - results: - show: Sonuçları göster - hide: Sonuçları gizle - close_poll: "Anketi Bitir" - open_poll: "Anket Başlat" diff --git a/plugins/poll/config/locales/client.zh_CN.yml b/plugins/poll/config/locales/client.zh_CN.yml deleted file mode 100644 index 9bd2ba19b7f..00000000000 --- a/plugins/poll/config/locales/client.zh_CN.yml +++ /dev/null @@ -1,17 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -zh_CN: - js: - poll: - voteCount: - other: "%{count} 次投票" - results: - show: 显示结果 - hide: 隐藏结果 - close_poll: "关闭投票" - open_poll: "开始投票" diff --git a/plugins/poll/config/locales/server.ar.yml b/plugins/poll/config/locales/server.ar.yml deleted file mode 100644 index a66bdc8860a..00000000000 --- a/plugins/poll/config/locales/server.ar.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ar: - activerecord: - attributes: - post: - poll_options: "خيارات التصويت" - poll: - must_contain_poll_options: "يجب أن يحتوي على قائمة خيارات التصويت" - cannot_have_modified_options: "التعديل غير ممكن بعد مضي 5 دقائق. اتصل بالمسؤول إذا كنت بحاجة لتغييرها." - cannot_add_or_remove_options: "تستطيع تعديله فقط ولا يمكنك إضافته أو حذفه. إذا كنت بحاجة لإضافة أو حذف خيارات يجب أن تُقفل هذا العنوان وتنشئ عنوان جديد." - prefix: "تصويت" - closed_prefix: "هذا التصويت مغلق" diff --git a/plugins/poll/config/locales/server.ca.yml b/plugins/poll/config/locales/server.ca.yml deleted file mode 100644 index 469f282d07e..00000000000 --- a/plugins/poll/config/locales/server.ca.yml +++ /dev/null @@ -1,11 +0,0 @@ -ca: - activerecord: - attributes: - post: - poll_options: "Opcions d'enquesta" - poll: - must_contain_poll_options: "cal que contingui una llista d'opcions" - cannot_have_modified_options: "no es pot modificar quan hagin passat els primers cinc minuts. Contacta un moderador si necessites fer-hi canvis." - cannot_add_or_remove_options: "només es pot editar, no afegir o treure. Si necessites afegir o treure opcions, hauries de tancar aquest tema i crear-ne un de nou." - prefix: "Enquesta" - closed_prefix: "Enquesta tancada" diff --git a/plugins/poll/config/locales/server.de.yml b/plugins/poll/config/locales/server.de.yml deleted file mode 100644 index b70cfc0b469..00000000000 --- a/plugins/poll/config/locales/server.de.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -de: - activerecord: - attributes: - post: - poll_options: "Umfrageoptionen" - poll: - must_contain_poll_options: "muss eine Liste mit Umfrageoptionen enthalten" - cannot_have_modified_options: "können nach den ersten 5 Minuten nicht mehr geändert werden. Kontaktiere einen Moderator, wenn du sie ändern möchtest." - cannot_add_or_remove_options: "können nur bearbeitet, jedoch nicht hinzugefügt oder entfernt werden. Wenn du Optionen hinzufügen oder entfernen möchtest, solltest du dieses Thema sperren und ein neues erstellen." - prefix: "Umfrage" - closed_prefix: "Beendete Umfrage" diff --git a/plugins/poll/config/locales/server.en.yml b/plugins/poll/config/locales/server.en.yml index d203242cac4..6ce9e22ebed 100644 --- a/plugins/poll/config/locales/server.en.yml +++ b/plugins/poll/config/locales/server.en.yml @@ -1,28 +1,25 @@ -# encoding: utf-8 -# -# This file contains content for the server portion of Discourse used by Ruby -# -# To work with us on translations, see: -# https://www.transifex.com/projects/p/discourse-org/ -# -# This is a "source" file, which is used by Transifex to get translations for other languages. -# After this file is changed, it needs to be pushed by a maintainer to Transifex: -# -# tx push -s -# -# Read more here: https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882 -# -# To validate this YAML file after you change it, please paste it into -# http://yamllint.com/ - en: - activerecord: - attributes: - post: - poll_options: "Poll options" + site_settings: + poll_enabled: "Allow users to create polls?" + poll: - must_contain_poll_options: "must contain a list of poll options" - cannot_have_modified_options: "cannot be modified after the first five minutes. Contact a moderator if you need to change them." - cannot_add_or_remove_options: "can only be edited, not added or removed. If you need to add or remove options you should lock this topic and create a new one." - prefix: "Poll" - closed_prefix: "Closed Poll" + multiple_polls_without_name: "There are multiple polls without a name. Use the 'name' attribute to uniquely identify your polls." + multiple_polls_with_same_name: "There are multiple polls with the same name: %{name}. Use the 'name' attribute to uniquely identify your polls." + + default_poll_must_have_at_least_2_options: "Poll must have at least 2 options." + named_poll_must_have_at_least_2_options: "Poll named %{name} must have at least 2 options." + + default_poll_must_have_different_options: "Poll must have different options." + named_poll_must_have_different_options: "Poll name %{name} must have different options." + + cannot_change_polls_after_5_minutes: "Polls cannot be changed after the first 5 minutes. Contact a moderator if you need to change them." + staff_cannot_add_or_remove_options_after_5_minutes: "Poll options can only be edited after the first 5 minutes. If you need to add or remove options, you should close this topic and create a new one." + + no_polls_associated_with_this_post: "No polls are associated with this post." + no_poll_with_this_name: "No poll named %{name} associated with this post." + + topic_must_be_open_to_vote: "The topic must be open to vote." + poll_must_be_open_to_vote: "Poll must be open to vote." + + topic_must_be_open_to_toggle_status: "The topic must be open to toggle status." + only_staff_or_op_can_toggle_status: "Only a staff member or the original poster can toggle a poll status." diff --git a/plugins/poll/config/locales/server.es.yml b/plugins/poll/config/locales/server.es.yml deleted file mode 100644 index a2a783d67ff..00000000000 --- a/plugins/poll/config/locales/server.es.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -es: - activerecord: - attributes: - post: - poll_options: "Opciones de la encuesta" - poll: - must_contain_poll_options: "debe contener una lista con las opciones de la encuesta" - cannot_have_modified_options: "pasados 5 minutos, no se pueden modificar las opciones de la encuesta. Contacta un moderador si necesitas cambiarlas" - cannot_add_or_remove_options: "solo se pueden modificar, no añadir ni eliminar. Si necesitas añadir o eliminar opciones deberías cerrar este tema y crear una encuesta nueva." - prefix: "Encuesta" - closed_prefix: "Encuesta cerrada" diff --git a/plugins/poll/config/locales/server.fa_IR.yml b/plugins/poll/config/locales/server.fa_IR.yml deleted file mode 100644 index bfcb72fcb45..00000000000 --- a/plugins/poll/config/locales/server.fa_IR.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fa_IR: - activerecord: - attributes: - post: - poll_options: "گزینه‌های نظرسنجی" - poll: - must_contain_poll_options: "باید فهرستی شامل گزینه‌های نظرسنجی باشد" - cannot_have_modified_options: "پس از گذشت ۵ دقیقه دیگر نمی‌توان ویرایش کرد. اگر تغییری در آن‌ها نیاز است با یکی از ناظمان تماس بگیرید." - cannot_add_or_remove_options: "تنها می‌تواند ویرایش شود، نه افزودنی و نه پاک کردنی. اگر به گزینه‌های اضافه و پاک کردن نیاز دارید، باید این جستار را قفل کنید و یکی دیگر بسازید." - prefix: "نظرسنجی" - closed_prefix: "اتمام نظرسنجی" diff --git a/plugins/poll/config/locales/server.fi.yml b/plugins/poll/config/locales/server.fi.yml deleted file mode 100644 index deb66801023..00000000000 --- a/plugins/poll/config/locales/server.fi.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fi: - activerecord: - attributes: - post: - poll_options: "Kyselyn vaihtoehtoja" - poll: - must_contain_poll_options: "täytyy sisältää lista vastausvaihtoehdoista" - cannot_have_modified_options: "ei voi muokata kun viisi minuuttia on kulunut kyselyn luomisesta. Ota yhteyttä valvojaan jos sinun tarvitsee muokata vaihtoehtoja." - cannot_add_or_remove_options: "voi vain muokata, ei lisätä tai poistaa. Jos sinun tarvitsee lisätä tai poistaa vaihtoehtoja, sinun tulee lukita tämä ketju ja luoda uusi." - prefix: "Kysely" - closed_prefix: "Suljettu kysely" diff --git a/plugins/poll/config/locales/server.fr.yml b/plugins/poll/config/locales/server.fr.yml deleted file mode 100644 index a9a0dd0bad4..00000000000 --- a/plugins/poll/config/locales/server.fr.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -fr: - activerecord: - attributes: - post: - poll_options: "Les options du sondage" - poll: - must_contain_poll_options: "doit contenir une liste d'options pour le sondage" - cannot_have_modified_options: "ne peuvent pas être modifiés après 5 minutes. Merci de contacter un moderateur, si vous souhaitez les modifier" - cannot_add_or_remove_options: "peuvent seulement être modifiés. Si vous souhaitez en supprimer ou en ajouter, veuillez créer un nouveau sujet." - prefix: "Sondage " - closed_prefix: "Sondage fermé " diff --git a/plugins/poll/config/locales/server.he.yml b/plugins/poll/config/locales/server.he.yml deleted file mode 100644 index aee492e7b75..00000000000 --- a/plugins/poll/config/locales/server.he.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -he: - activerecord: - attributes: - post: - poll_options: "אפשרויות הצבעה" - poll: - must_contain_poll_options: "חובה להכיל רשימה של אפשרויות הצבעה" - cannot_have_modified_options: "לא ניתן לשנות את האפשרויות לאחר 5 הדקות הראשונות. יש לפנות למנהל כדי לבצע שינויים אלו." - cannot_add_or_remove_options: "ניתן רק לערוך, לא להוסיף או להסיר אפשרויות. כדי להוסיף או להסיר אפשרויות יש לנעול את נושא זה ולפתוח אחד חדש." - prefix: "הצבעה" - closed_prefix: "הצבעה סגורה" diff --git a/plugins/poll/config/locales/server.it.yml b/plugins/poll/config/locales/server.it.yml deleted file mode 100644 index 393ea643912..00000000000 --- a/plugins/poll/config/locales/server.it.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -it: - activerecord: - attributes: - post: - poll_options: "Opzioni sondaggio" - poll: - must_contain_poll_options: "deve contenere una lista di opzioni per il sondaggio" - cannot_have_modified_options: "non possono essere modificate dopo i primi cinque minuti. Contatta un moderatore se devi cambiarle." - cannot_add_or_remove_options: "possono essere solo modificate, ma non aggiunte o rimosse. Se devi aggiungere o rimuovere opzioni, devi prima bloccare questo argomento e crearne uno nuovo." - prefix: "Sondaggio" - closed_prefix: "Sondaggio Chiuso" diff --git a/plugins/poll/config/locales/server.ko.yml b/plugins/poll/config/locales/server.ko.yml deleted file mode 100644 index c9c6621c35c..00000000000 --- a/plugins/poll/config/locales/server.ko.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ko: - activerecord: - attributes: - post: - poll_options: "투표 옵션" - poll: - must_contain_poll_options: "투표 옵션 목록 포함 필수" - cannot_have_modified_options: "5분 뒤에는 수정할 수 없습니다. 바꾸고 싶다면 관리자에게 문의하세요." - cannot_add_or_remove_options: "수정만 가능하고 추가나 삭제가 불가능 합니다. 선택사항을 추가하거나 삭제하고 싶다면 이 토픽을 잠그고 다른 토픽을 생성해야합니다." - prefix: "투표" - closed_prefix: "투표 닫기" diff --git a/plugins/poll/config/locales/server.pl_PL.yml b/plugins/poll/config/locales/server.pl_PL.yml deleted file mode 100644 index 0a753c30296..00000000000 --- a/plugins/poll/config/locales/server.pl_PL.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pl_PL: - activerecord: - attributes: - post: - poll_options: "Opcje ankiety" - poll: - must_contain_poll_options: "musi zawierać listę możliwych wyborów ankiety" - cannot_have_modified_options: "nie mogą być zmienione po pierwszych pięciu minutach. Skontaktuj się z moderatorem, jeżeli musisz je zmienić." - cannot_add_or_remove_options: "mogą tylko być edytowane, nie dodawane ani usuwane. Jeśli musisz dodać lub usunąć opcje, powinieneś zamknąć ten temat i utworzyć nowy." - prefix: "Ankieta" - closed_prefix: "Zamknięta ankieta" diff --git a/plugins/poll/config/locales/server.pt.yml b/plugins/poll/config/locales/server.pt.yml deleted file mode 100644 index 61af6213602..00000000000 --- a/plugins/poll/config/locales/server.pt.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pt: - activerecord: - attributes: - post: - poll_options: "Opções da votação" - poll: - must_contain_poll_options: "tem que conter uma lista de opções de votação" - cannot_have_modified_options: "não podem ser modificadas depois dos primeiros cinco minutos. Contacte um moderador se precisar de alterá-las." - cannot_add_or_remove_options: "podem apenas ser editadas, não podendo ser adicionadas ou removidas. Se precisar de adicionar ou remover opções, deverá bloquear este tópico e criar um novo." - prefix: "Votação" - closed_prefix: "Votação encerrada" diff --git a/plugins/poll/config/locales/server.pt_BR.yml b/plugins/poll/config/locales/server.pt_BR.yml deleted file mode 100644 index 67b526d1b99..00000000000 --- a/plugins/poll/config/locales/server.pt_BR.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -pt_BR: - activerecord: - attributes: - post: - poll_options: "Opções de votação " - poll: - must_contain_poll_options: "deve conter uma lista de opções de votação" - cannot_have_modified_options: "não pode ser modificado após os primeiros cinco minutos. Contate o moderador se necessitar fazer alguma mudança." - cannot_add_or_remove_options: "Só pode ser editado, mas não adicionar nem remover. Se precisar das opções para adicionar ou remover, você deve bloquear este tópico e criar um novo." - prefix: "Votação" - closed_prefix: "Votação encerrada" diff --git a/plugins/poll/config/locales/server.ru.yml b/plugins/poll/config/locales/server.ru.yml deleted file mode 100644 index cf161bdb5b5..00000000000 --- a/plugins/poll/config/locales/server.ru.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -ru: - activerecord: - attributes: - post: - poll_options: "Варианты ответов" - poll: - must_contain_poll_options: "должен содержать варианты ответов (список)" - cannot_have_modified_options: "нельзя изменять после первых пяти минут. Если все же нужно их отредактировать, свяжитесь с модератором." - cannot_add_or_remove_options: "можно редактировать, но не добавлять или удалять. Если нужно добавить или удалить, закройте эту тему и создайте новую." - prefix: "Опрос" - closed_prefix: "Завершившийся опрос" diff --git a/plugins/poll/config/locales/server.sq.yml b/plugins/poll/config/locales/server.sq.yml deleted file mode 100644 index 7aae7c32a32..00000000000 --- a/plugins/poll/config/locales/server.sq.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -sq: - activerecord: - attributes: - post: - poll_options: "Opsionet e sondazhit" - poll: - must_contain_poll_options: "duhet të përmbajë një listë me pyetje" - cannot_have_modified_options: "nuk mund të ndryshohet pasi kanë kaluar pesë minuta. Kontakto një moderator nëse nevojiten ndryshime." - cannot_add_or_remove_options: "nuk mund të redaktohet, shtosh apo fshini pyetje. Nëse dëshironi të shtoni apo fshini pyetje ju duhet ta mbyllni këtë temë dhe të krijoni një të re." - prefix: "Sondazh" - closed_prefix: "Sondazh i Mbyllur" diff --git a/plugins/poll/config/locales/server.te.yml b/plugins/poll/config/locales/server.te.yml deleted file mode 100644 index 50b7c548c87..00000000000 --- a/plugins/poll/config/locales/server.te.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -te: - activerecord: - attributes: - post: - poll_options: "ఓటు ఐచ్చికాలు" - poll: - must_contain_poll_options: "తప్పనిసరి ఓటు ఐచ్చికాల జాబితా కలిగి ఉండాలి" - cannot_have_modified_options: "మొదటి ఐదు నిమిషాల తర్వాత మార్చైత కాదు. వీటిని మార్చాలంటే ఒక నిర్వాహకుడిని సంప్రదించండి. " - cannot_add_or_remove_options: "కేవలం సవరించవచ్చు, కలపైత కాదు, తొలగించైత కాదు. మీరు కలపడం లేదా తొలగించడం చేయాలంటే ఈ విషయానికి తాళం వేసి మరో కొత్త విషయం సృష్టించాలి" - prefix: "ఓటు" - closed_prefix: "మూసేసిన ఓటు" diff --git a/plugins/poll/config/locales/server.tr_TR.yml b/plugins/poll/config/locales/server.tr_TR.yml deleted file mode 100644 index d32284a4b92..00000000000 --- a/plugins/poll/config/locales/server.tr_TR.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -tr_TR: - activerecord: - attributes: - post: - poll_options: "Anket seçenekleri" - poll: - must_contain_poll_options: "anket seçenekleri listesini içermeli" - cannot_have_modified_options: "ilk beş dakikadan sonra değişiklik yapılamaz. Değişiklik yapmanız gerekiyorsa, bir moderatör ile iletişime geçin." - cannot_add_or_remove_options: "sadece düzenlenebilir, ekleme veya çıkarma yapılamaz. Seçenek ekleme veya çıkarmanız gerekiyorsa, bu konuyu kitlemeli ve yeni bir konu oluşturmalısınız." - prefix: "Anket" - closed_prefix: "Bitmiş Anket" diff --git a/plugins/poll/config/locales/server.zh_CN.yml b/plugins/poll/config/locales/server.zh_CN.yml deleted file mode 100644 index 64e2a1ea244..00000000000 --- a/plugins/poll/config/locales/server.zh_CN.yml +++ /dev/null @@ -1,18 +0,0 @@ -# encoding: utf-8 -# -# Never edit this file. It will be overwritten when translations are pulled from Transifex. -# -# To work with us on translations, join this project: -# https://www.transifex.com/projects/p/discourse-org/ - -zh_CN: - activerecord: - attributes: - post: - poll_options: "投票选项" - poll: - must_contain_poll_options: "必须包含投票选项" - cannot_have_modified_options: "在开始的五分钟后不能修改。如果需要修改他们,请联系一位版主。" - cannot_add_or_remove_options: "只能被编辑,不能添加或者删除。如果您需要添加或者删除选项,你需要锁定这个投票并创建新的投票。" - prefix: "投票" - closed_prefix: "已关闭的投票:" diff --git a/plugins/poll/config/settings.yml b/plugins/poll/config/settings.yml new file mode 100644 index 00000000000..eb5cc0aafe1 --- /dev/null +++ b/plugins/poll/config/settings.yml @@ -0,0 +1,3 @@ +plugins: + poll_enabled: + default: true diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index 9754629585e..7e835e59781 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -1,205 +1,267 @@ # name: poll -# about: adds poll support to Discourse -# version: 0.2 -# authors: Vikhyat Korrapati +# about: Official poll plugin for Discourse +# version: 0.9 +# authors: Vikhyat Korrapati (vikhyat), Régis Hanol (zogstrip) # url: https://github.com/discourse/discourse/tree/master/plugins/poll -load File.expand_path("../poll.rb", __FILE__) +register_asset "stylesheets/poll.scss" +register_asset "javascripts/poll_dialect.js", :server_side -# Without this line we can't lookup the constant inside the after_initialize blocks, -# because all of this is instance_eval'd inside an instance of Plugin::Instance. -PollPlugin = PollPlugin +PLUGIN_NAME ||= "discourse_poll".freeze + +POLLS_CUSTOM_FIELD ||= "polls".freeze +VOTES_CUSTOM_FIELD ||= "polls-votes".freeze after_initialize do - # Rails Engine for accepting votes. - module PollPlugin + + module ::DiscoursePoll class Engine < ::Rails::Engine - engine_name "poll_plugin" - isolate_namespace PollPlugin - end - - class PollController < ActionController::Base - include CurrentUser - - def vote - if current_user.nil? - render status: :forbidden, json: false - return - end - - if params[:post_id].nil? or params[:option].nil? - render status: 400, json: false - return - end - - post = Post.find(params[:post_id]) - poll = PollPlugin::Poll.new(post) - unless poll.has_poll_details? - render status: 400, json: false - return - end - - options = poll.details - - unless options.keys.include? params[:option] - render status: 400, json: false - return - end - - poll.set_vote!(current_user, params[:option]) - - MessageBus.publish("/topic/#{post.topic_id}", { - id: post.id, - post_number: post.post_number, - updated_at: Time.now, - type: "revised" - }, - group_ids: post.topic.secure_group_ids - ) - - render json: poll.serialize(current_user) - end - - def toggle_close - post = Post.find(params[:post_id]) - topic = post.topic - poll = PollPlugin::Poll.new(post) - - # Make sure the user is allowed to close the poll. - Guardian.new(current_user).ensure_can_edit!(topic) - - # Make sure this is actually a poll. - unless poll.has_poll_details? - render status: 400, json: false - return - end - - # Make sure the topic is not closed. - if topic.closed? - render status: 400, json: false - return - end - - # Modify topic title. - I18n.with_locale(topic.user.effective_locale) do - if topic.title =~ /^(#{I18n.t('poll.prefix').strip})\s?:/i - topic.title = topic.title.gsub(/^(#{I18n.t('poll.prefix').strip})\s?:/i, I18n.t('poll.closed_prefix') + ':') - elsif topic.title =~ /^(#{I18n.t('poll.closed_prefix').strip})\s?:/i - topic.title = topic.title.gsub(/^(#{I18n.t('poll.closed_prefix').strip})\s?:/i, I18n.t('poll.prefix') + ':') - end - end - - topic.acting_user = current_user - topic.save! - - render json: topic, serializer: BasicTopicSerializer - end + engine_name PLUGIN_NAME + isolate_namespace DiscoursePoll end end - PollPlugin::Engine.routes.draw do - put '/' => 'poll#vote' - put '/toggle_close' => 'poll#toggle_close' + require_dependency "application_controller" + class DiscoursePoll::PollsController < ::ApplicationController + requires_plugin PLUGIN_NAME + + before_filter :ensure_logged_in + + def vote + post_id = params.require(:post_id) + poll_name = params.require(:poll_name) + options = params.require(:options) + user_id = current_user.id + + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do + post = Post.find(post_id) + + # topic must be open + if post.topic.try(:closed) || post.topic.try(:archived) + return render_json_error I18n.t("poll.topic_must_be_open_to_vote") + end + + polls = post.custom_fields[POLLS_CUSTOM_FIELD] + + return render_json_error I18n.t("poll.no_polls_associated_with_this_post") if polls.blank? + + poll = polls[poll_name] + + return render_json_error I18n.t("poll.no_poll_with_this_name", name: poll_name) if poll.blank? + return render_json_error I18n.t("poll.poll_must_be_open_to_vote") if poll["status"] != "open" + + votes = post.custom_fields["#{VOTES_CUSTOM_FIELD}-#{user_id}"] || {} + vote = votes[poll_name] || [] + + poll["total_votes"] += 1 if vote.size == 0 + + poll["options"].each do |option| + option["votes"] -= 1 if vote.include?(option["id"]) + option["votes"] += 1 if options.include?(option["id"]) + end + + votes[poll_name] = options + + post.custom_fields[POLLS_CUSTOM_FIELD] = polls + post.custom_fields["#{VOTES_CUSTOM_FIELD}-#{user_id}"] = votes + post.save_custom_fields + + MessageBus.publish("/polls/#{post_id}", { poll: poll }) + + render json: { poll: poll, vote: options } + end + end + + def toggle_status + post_id = params.require(:post_id) + poll_name = params.require(:poll_name) + status = params.require(:status) + + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post_id}") do + post = Post.find(post_id) + + # either staff member or OP + unless current_user.try(:staff?) || current_user.try(:id) == post.user_id + return render_json_error I18n.t("poll.only_staff_or_op_can_toggle_status") + end + + # topic must be open + if post.topic.try(:closed) || post.topic.try(:archived) + return render_json_error I18n.t("poll.topic_must_be_open_to_toggle_status") + end + + polls = post.custom_fields[POLLS_CUSTOM_FIELD] + + return render_json_error I18n.t("poll.no_polls_associated_with_this_post") if polls.blank? + return render_json_error I18n.t("poll.no_poll_with_this_name", name: poll_name) if polls[poll_name].blank? + + polls[poll_name]["status"] = status + + post.custom_fields[POLLS_CUSTOM_FIELD] = polls + post.save_custom_fields + + MessageBus.publish("/polls/#{post_id}", { poll: polls[poll_name] }) + + render json: { poll: polls[poll_name] } + end + end + + end + + DiscoursePoll::Engine.routes.draw do + put "/vote" => "polls#vote" + put "/toggle_status" => "polls#toggle_status" end Discourse::Application.routes.append do - mount ::PollPlugin::Engine, at: '/poll' + mount ::DiscoursePoll::Engine, at: "/polls" end - # Starting a topic title with "Poll:" will create a poll topic. If the title - # starts with "poll:" but the first post doesn't contain a list of options in - # it we need to raise an error. Post.class_eval do - validate :poll_options - def poll_options - poll = PollPlugin::Poll.new(self) + attr_accessor :polls - return unless poll.is_poll? + # save the polls when the post is created + after_save do + next if self.polls.blank? || !self.polls.is_a?(Hash) - if poll.options.length == 0 - self.errors.add(:raw, I18n.t('poll.must_contain_poll_options')) + post = self + polls = self.polls + + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do + post.custom_fields[POLLS_CUSTOM_FIELD] = polls + post.save_custom_fields + end + end + end + + DATA_PREFIX ||= "data-poll-".freeze + DEFAULT_POLL_NAME ||= "poll".freeze + + validate(:post, :polls) do + # only care when raw has changed! + return unless self.raw_changed? + + # TODO: we should fix the callback mess so that the cooked version is available + # in the validators instead of cooking twice + cooked = PrettyText.cook(self.raw, topic_id: self.topic_id) + parsed = Nokogiri::HTML(cooked) + + polls = {} + extracted_polls = [] + + # extract polls + parsed.css("div.poll").each do |p| + poll = { "options" => [], "total_votes" => 0 } + + # extract attributes + p.attributes.values.each do |attribute| + if attribute.name.start_with?(DATA_PREFIX) + poll[attribute.name[DATA_PREFIX.length..-1]] = attribute.value + end end - poll.ensure_can_be_edited! + # extract options + p.css("li[#{DATA_PREFIX}option-id]").each do |o| + option_id = o.attributes[DATA_PREFIX + "option-id"].value + poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => 0 } + end + + # add the poll + extracted_polls << poll + end + + # validate polls + extracted_polls.each do |poll| + # polls should have a unique name + if polls.has_key?(poll["name"]) + poll["name"] == DEFAULT_POLL_NAME ? + self.errors.add(:base, I18n.t("poll.multiple_polls_without_name")) : + self.errors.add(:base, I18n.t("poll.multiple_polls_with_same_name", name: poll["name"])) + return + end + + # options must be unique + if poll["options"].map { |o| o["id"] }.uniq.size != poll["options"].size + poll["name"] == DEFAULT_POLL_NAME ? + self.errors.add(:base, I18n.t("poll.default_poll_must_have_different_options")) : + self.errors.add(:base, I18n.t("poll.named_poll_must_have_different_options", name: poll["name"])) + return + end + + # at least 2 options + if poll["options"].size < 2 + poll["name"] == DEFAULT_POLL_NAME ? + self.errors.add(:base, I18n.t("poll.default_poll_must_have_at_least_2_options")) : + self.errors.add(:base, I18n.t("poll.named_poll_must_have_at_least_2_options", name: poll["name"])) + return + end + + # store the valid poll + polls[poll["name"]] = poll + end + + # are we updating a post outside the 5-minute edit window? + if self.id.present? && self.created_at < 5.minutes.ago + post = self + DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do + # load previous polls + previous_polls = post.custom_fields[POLLS_CUSTOM_FIELD] || {} + + # are the polls different? + if polls.keys != previous_polls.keys || + polls.values.map { |p| p["options"] } != previous_polls.values.map { |p| p["options"] } + + # cannot add/remove/change/re-order polls + if polls.keys != previous_polls.keys + post.errors.add(:base, I18n.t("poll.cannot_change_polls_after_5_minutes")) + return + end + + # deal with option changes + if User.staff.pluck(:id).include?(post.last_editor_id) + # staff can only edit options + polls.each_key do |poll_name| + if polls[poll_name]["options"].size != previous_polls[poll_name]["options"].size + post.errors.add(:base, I18n.t("poll.staff_cannot_add_or_remove_options_after_5_minutes")) + return + end + end + # merge votes + polls.each_key do |poll_name| + polls[poll_name]["total_votes"] = previous_polls[poll_name]["total_votes"] + for o in 0...polls[poll_name]["options"].size + polls[poll_name]["options"][o]["votes"] = previous_polls[poll_name]["options"][o]["votes"] + end + end + else + # OP cannot change polls after 5 minutes + post.errors.add(:base, I18n.t("poll.cannot_change_polls_after_5_minutes")) + return + end + end + + # immediately store the polls + post.custom_fields[POLLS_CUSTOM_FIELD] = polls + post.save_custom_fields + end + else + # polls will be saved once we have a post id + self.polls = polls end end - # Save the list of options to PluginStore after the post is saved. - Post.class_eval do - after_save :save_poll_options_to_plugin_store - def save_poll_options_to_plugin_store - PollPlugin::Poll.new(self).update_options! - end + Post.register_custom_field_type(POLLS_CUSTOM_FIELD, :json) + Post.register_custom_field_type("#{VOTES_CUSTOM_FIELD}-*", :json) + + TopicView.add_post_custom_fields_whitelister do |user| + whitelisted = [POLLS_CUSTOM_FIELD] + whitelisted << "#{VOTES_CUSTOM_FIELD}-#{user.id}" if user + whitelisted end - # Add poll details into the post serializer. - PostSerializer.class_eval do - attributes :poll_details - def poll_details - PollPlugin::Poll.new(object).serialize(scope.user) - end - def include_poll_details? - PollPlugin::Poll.new(object).has_poll_details? - end - end + add_to_serializer(:post, :polls, false) { post_custom_fields[POLLS_CUSTOM_FIELD] } + add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[POLLS_CUSTOM_FIELD].present? } + + add_to_serializer(:post, :polls_votes, false) { post_custom_fields["#{VOTES_CUSTOM_FIELD}-#{scope.user.id}"] } + add_to_serializer(:post, :include_polls_votes?) { scope.user && post_custom_fields.present? && post_custom_fields["#{VOTES_CUSTOM_FIELD}-#{scope.user.id}"].present? } end - -# Poll UI. -register_asset "javascripts/models/poll.js.es6" -register_asset "javascripts/controllers/poll.js.es6" -register_asset "javascripts/views/poll.js.es6" -register_asset "javascripts/discourse/templates/poll.hbs" -register_asset "javascripts/initializers/poll.js.es6" -register_asset "javascripts/poll_bbcode.js", :server_side - -register_css < 1 - - topic = @post.topic - - # Topic is not set in a couple of cases in the Discourse test suite. - return false if topic.nil? || topic.user.nil? - - # New post, but not the first post in the topic. - return false if @post.post_number.nil? && topic.highest_post_number > 0 - - I18n.with_locale(topic.user.effective_locale) do - topic.title =~ /^(#{I18n.t('poll.prefix').strip}|#{I18n.t('poll.closed_prefix').strip})\s?:/i - end - end - - def has_poll_details? - self.is_poll? - end - - # Called during validation of poll posts. Discourse already restricts edits to - # the OP and staff, we want to make sure that: - # - # * OP cannot edit options after 5 minutes. - # * Staff can only edit options after 5 minutes, not add/remove. - def ensure_can_be_edited! - # Return if this is a new post or the options were not modified. - return if @post.id.nil? || (options.sort == details.keys.sort) - - # First 5 minutes -- allow any modification. - return unless @post.created_at < 5.minutes.ago - - if User.find(@post.last_editor_id).staff? - # Allow editing options, but not adding or removing. - if options.length != details.keys.length - @post.errors.add(:poll_options, I18n.t('poll.cannot_add_or_remove_options')) - end - else - # not staff, tell them to contact one. - @post.errors.add(:poll_options, I18n.t('poll.cannot_have_modified_options')) - end - end - - def is_closed? - topic = @post.topic - topic.closed? || topic.archived? || (topic.title =~ /^#{I18n.t('poll.closed_prefix', locale: topic.user.effective_locale)}/i) === 0 - end - - def options - cooked = PrettyText.cook(@post.raw, topic_id: @post.topic_id) - parsed = Nokogiri::HTML(cooked) - poll_list = parsed.css(".poll-ui ul").first || parsed.css("ul").first - if poll_list - poll_list.css("li").map {|x| x.children.to_s.strip }.uniq - else - [] - end - end - - def update_options! - return unless self.is_poll? - return if details && details.keys.sort == options.sort - - if details.try(:length) == options.length - - # Assume only renaming, no reordering. Preserve votes. - old_details = self.details - old_options = old_details.keys - new_details = {} - new_options = self.options - rename = {} - - 0.upto(options.length-1) do |i| - new_details[ new_options[i] ] = old_details[ old_options[i] ] - - if new_options[i] != old_options[i] - rename[ old_options[i] ] = new_options[i] - end - end - self.set_details! new_details - - # Update existing user votes. - # Accessing PluginStoreRow directly isn't a very nice approach but there's - # no way around it unfortunately. - # TODO: Probably want to move this to a background job. - PluginStoreRow.where(plugin_name: "poll", value: rename.keys).where('key LIKE ?', vote_key_prefix+"%").find_each do |row| - # This could've been done more efficiently using `update_all` instead of - # iterating over each individual vote, however this will be needed in the - # future once we support multiple choice polls. - row.value = rename[ row.value ] - row.save - end - - else - - # Options were added or removed. - new_options = self.options - new_details = self.details || {} - new_details.each do |key, value| - unless new_options.include? key - new_details.delete(key) - end - end - new_options.each do |key| - new_details[key] ||= 0 - end - self.set_details! new_details - - end - end - - def details - @details ||= ::PluginStore.get("poll", details_key) - end - - def set_details!(new_details) - ::PluginStore.set("poll", details_key, new_details) - @details = new_details - end - - def get_vote(user) - user.nil? ? nil : ::PluginStore.get("poll", vote_key(user)) - end - - def set_vote!(user, option) - return if is_closed? - - # Get the user's current vote. - DistributedMutex.new(details_key).synchronize do - vote = get_vote(user) - vote = nil unless details.keys.include? vote - - new_details = details.dup - new_details[vote] -= 1 if vote - new_details[option] += 1 - - ::PluginStore.set("poll", vote_key(user), option) - set_details! new_details - end - end - - def serialize(user) - return nil if details.nil? - {options: details, selected: get_vote(user), closed: is_closed?} - end - - private - def details_key - "poll_options_#{@post.id}" - end - - def vote_key_prefix - "poll_vote_#{@post.id}_" - end - - def vote_key(user) - "#{vote_key_prefix}#{user.id}" - end - end -end diff --git a/plugins/poll/spec/controllers/polls_controller_spec.rb b/plugins/poll/spec/controllers/polls_controller_spec.rb new file mode 100644 index 00000000000..360efc4575d --- /dev/null +++ b/plugins/poll/spec/controllers/polls_controller_spec.rb @@ -0,0 +1,99 @@ +require "spec_helper" + +describe ::DiscoursePoll::PollsController do + routes { ::DiscoursePoll::Engine.routes } + + let!(:user) { log_in } + let(:topic) { Fabricate(:topic) } + let(:poll) { Fabricate(:post, topic_id: topic.id, user_id: user.id, raw: "[poll]\n- A\n- B\n[/poll]") } + + describe "#vote" do + + it "works" do + MessageBus.expects(:publish) + + xhr :put, :vote, { post_id: poll.id, poll_name: "poll", options: ["A"] } + + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["poll"]["name"]).to eq("poll") + expect(json["poll"]["total_votes"]).to eq(1) + expect(json["vote"]).to eq(["A"]) + end + + it "supports vote changes" do + xhr :put, :vote, { post_id: poll.id, poll_name: "poll", options: ["5c24fc1df56d764b550ceae1b9319125"] } + expect(response).to be_success + + xhr :put, :vote, { post_id: poll.id, poll_name: "poll", options: ["e89dec30bbd9bf50fabf6a05b4324edf"] } + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["poll"]["total_votes"]).to eq(1) + expect(json["poll"]["options"][0]["votes"]).to eq(0) + expect(json["poll"]["options"][1]["votes"]).to eq(1) + end + + it "ensures topic is not closed" do + topic.update_attribute(:closed, true) + xhr :put, :vote, { post_id: poll.id, poll_name: "poll", options: ["A"] } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.topic_must_be_open_to_vote")) + end + + it "ensures topic is not archived" do + topic.update_attribute(:archived, true) + xhr :put, :vote, { post_id: poll.id, poll_name: "poll", options: ["A"] } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.topic_must_be_open_to_vote")) + end + + it "ensures polls are associated with the post" do + xhr :put, :vote, { post_id: Fabricate(:post).id, poll_name: "foobar", options: ["A"] } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.no_polls_associated_with_this_post")) + end + + it "checks the name of the poll" do + xhr :put, :vote, { post_id: poll.id, poll_name: "foobar", options: ["A"] } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.no_poll_with_this_name", name: "foobar")) + end + + it "ensures poll is open" do + closed_poll = Fabricate(:post, raw: "[poll status=closed]\n- A\n- B\n[/poll]") + xhr :put, :vote, { post_id: closed_poll.id, poll_name: "poll", options: ["A"] } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.poll_must_be_open_to_vote")) + end + + end + + describe "#toggle_status" do + + it "works for OP" do + MessageBus.expects(:publish) + + xhr :put, :toggle_status, { post_id: poll.id, poll_name: "poll", status: "closed" } + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["poll"]["status"]).to eq("closed") + end + + it "works for staff" do + log_in(:moderator) + MessageBus.expects(:publish) + + xhr :put, :toggle_status, { post_id: poll.id, poll_name: "poll", status: "closed" } + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["poll"]["status"]).to eq("closed") + end + + end + +end diff --git a/plugins/poll/spec/controllers/posts_controller_spec.rb b/plugins/poll/spec/controllers/posts_controller_spec.rb new file mode 100644 index 00000000000..c36befe22f4 --- /dev/null +++ b/plugins/poll/spec/controllers/posts_controller_spec.rb @@ -0,0 +1,137 @@ +require "spec_helper" + +describe PostsController do + let!(:user) { log_in } + let!(:title) { "Testing Poll Plugin" } + + describe "polls" do + + it "works" do + xhr :post, :create, { title: title, raw: "[poll]\n- A\n- B\n[/poll]" } + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["cooked"]).to match("data-poll-") + expect(json["polls"]["poll"]).to be + end + + it "works on any post" do + post = Fabricate(:post) + xhr :post, :create, { topic_id: post.topic.id, raw: "[poll]\n- A\n- B\n[/poll]" } + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["cooked"]).to match("data-poll-") + expect(json["polls"]["poll"]).to be + end + + it "should have different options" do + xhr :post, :create, { title: title, raw: "[poll]\n- A\n- A[/poll]" } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_different_options")) + end + + it "should have at least 2 options" do + xhr :post, :create, { title: title, raw: "[poll]\n- A[/poll]" } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.default_poll_must_have_at_least_2_options")) + end + + describe "edit window" do + + describe "within the first 5 minutes" do + + let(:post_id) do + Timecop.freeze(3.minutes.ago) do + xhr :post, :create, { title: title, raw: "[poll]\n- A\n- B\n[/poll]" } + ::JSON.parse(response.body)["id"] + end + end + + it "can be changed" do + xhr :put, :update, { id: post_id, post: { raw: "[poll]\n- A\n- B\n- C\n[/poll]" } } + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["post"]["polls"]["poll"]["options"][2]["html"]).to eq("C") + end + + end + + describe "after the first 5 minutes" do + + let(:post_id) do + Timecop.freeze(6.minutes.ago) do + xhr :post, :create, { title: title, raw: "[poll]\n- A\n- B\n[/poll]" } + ::JSON.parse(response.body)["id"] + end + end + + let(:new_raw) { "[poll]\n- A\n- C[/poll]" } + + it "cannot be changed by OP" do + xhr :put, :update, { id: post_id, post: { raw: new_raw } } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.cannot_change_polls_after_5_minutes")) + end + + it "can be edited by staff" do + log_in_user(Fabricate(:moderator)) + xhr :put, :update, { id: post_id, post: { raw: new_raw } } + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["post"]["polls"]["poll"]["options"][1]["html"]).to eq("C") + end + + end + + end + + end + + describe "named polls" do + + it "should have different options" do + xhr :post, :create, { title: title, raw: "[poll name=foo]\n- A\n- A[/poll]" } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_different_options", name: "foo")) + end + + it "should have at least 2 options" do + xhr :post, :create, { title: title, raw: "[poll name=foo]\n- A[/poll]" } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.named_poll_must_have_at_least_2_options", name: "foo")) + end + + end + + describe "multiple polls" do + + it "works" do + xhr :post, :create, { title: title, raw: "[poll]\n- A\n- B\n[/poll]\n[poll name=foo]\n- A\n- B\n[/poll]" } + expect(response).to be_success + json = ::JSON.parse(response.body) + expect(json["cooked"]).to match("data-poll-") + expect(json["polls"]["poll"]).to be + expect(json["polls"]["foo"]).to be + end + + it "should have a name" do + xhr :post, :create, { title: title, raw: "[poll]\n- A\n- B\n[/poll]\n[poll]\n- A\n- B\n[/poll]" } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_without_name")) + end + + it "should have unique name" do + xhr :post, :create, { title: title, raw: "[poll name=foo]\n- A\n- B\n[/poll]\n[poll name=foo]\n- A\n- B\n[/poll]" } + expect(response).not_to be_success + json = ::JSON.parse(response.body) + expect(json["errors"][0]).to eq(I18n.t("poll.multiple_polls_with_same_name", name: "foo")) + end + + end + +end diff --git a/plugins/poll/spec/poll_plugin/poll_controller_spec.rb b/plugins/poll/spec/poll_plugin/poll_controller_spec.rb deleted file mode 100644 index 8f912d84920..00000000000 --- a/plugins/poll/spec/poll_plugin/poll_controller_spec.rb +++ /dev/null @@ -1,92 +0,0 @@ -require 'spec_helper' - -describe PollPlugin::PollController, type: :controller do - routes { PollPlugin::Engine.routes } - - let(:topic) { create_topic(title: "Poll: Chitoge vs Onodera") } - let!(:post) { create_post(topic: topic, raw: "Pick one.\n\n[poll]\n* Chitoge\n* Onodera\n[/poll]") } - let(:user1) { Fabricate(:user) } - let(:user2) { Fabricate(:user) } - let(:admin) { Fabricate(:admin) } - - describe 'vote' do - it "returns 403 if no user is logged in" do - xhr :put, :vote, post_id: post.id, option: "Chitoge" - response.should be_forbidden - end - - it "returns 400 if post_id or invalid option is not specified" do - log_in_user user1 - xhr :put, :vote - response.status.should eq(400) - xhr :put, :vote, post_id: post.id - response.status.should eq(400) - xhr :put, :vote, option: "Chitoge" - response.status.should eq(400) - xhr :put, :vote, post_id: post.id, option: "Tsugumi" - response.status.should eq(400) - end - - it "returns 400 if post_id doesn't correspond to a poll post" do - log_in_user user1 - post2 = create_post(topic: topic, raw: "Generic reply") - xhr :put, :vote, post_id: post2.id, option: "Chitoge" - end - - it "saves votes correctly" do - MessageBus.expects(:publish).times(3) - - log_in_user user1 - xhr :put, :vote, post_id: post.id, option: "Chitoge" - PollPlugin::Poll.new(post).get_vote(user1).should eq("Chitoge") - - log_in_user user2 - xhr :put, :vote, post_id: post.id, option: "Onodera" - PollPlugin::Poll.new(post).get_vote(user2).should eq("Onodera") - - PollPlugin::Poll.new(post).details["Chitoge"].should eq(1) - PollPlugin::Poll.new(post).details["Onodera"].should eq(1) - - xhr :put, :vote, post_id: post.id, option: "Chitoge" - PollPlugin::Poll.new(post).get_vote(user2).should eq("Chitoge") - - PollPlugin::Poll.new(post).details["Chitoge"].should eq(2) - PollPlugin::Poll.new(post).details["Onodera"].should eq(0) - end - end - - describe 'toggle_close' do - it "returns 400 if post_id doesn't correspond to a poll post" do - log_in_user admin - post2 = create_post(topic: topic, raw: "Generic reply") - xhr :put, :toggle_close, post_id: post2.id - response.status.should eq(400) - end - - it "returns 400 if the topic is locked" do - log_in_user admin - topic.update_attributes closed: true - xhr :put, :toggle_close, post_id: post.id - response.status.should eq(400) - end - - it "raises Discourse::InvalidAccess is the user is not authorized" do - log_in_user user1 - expect do - xhr :put, :toggle_close, post_id: post.id - end.to raise_error(Discourse::InvalidAccess) - end - - it "renames the topic" do - I18n.stubs(:t).with('poll.prefix').returns("Poll ") - I18n.stubs(:t).with('poll.closed_prefix').returns("Closed Poll ") - log_in_user admin - xhr :put, :toggle_close, post_id: post.id - response.status.should eq(200) - topic.reload.title.should == "Closed Poll : Chitoge vs Onodera" - xhr :put, :toggle_close, post_id: post.id - response.status.should eq(200) - topic.reload.title.should == "Poll : Chitoge vs Onodera" - end - end -end diff --git a/plugins/poll/spec/poll_plugin/poll_spec.rb b/plugins/poll/spec/poll_plugin/poll_spec.rb deleted file mode 100644 index 7455f8856a1..00000000000 --- a/plugins/poll/spec/poll_plugin/poll_spec.rb +++ /dev/null @@ -1,97 +0,0 @@ -require 'spec_helper' - -describe PollPlugin::Poll do - let(:topic) { create_topic(title: "Poll: Chitoge vs Onodera") } - let(:post) { create_post(topic: topic, raw: "Pick one.\n\n[poll]\n* Chitoge\n* Onodera\n[/poll]") } - let(:poll) { PollPlugin::Poll.new(post) } - let(:user) { Fabricate(:user) } - - it "should detect poll post correctly" do - expect(poll.is_poll?).to be_truthy - post2 = create_post(topic: topic, raw: "This is a generic reply.") - expect(PollPlugin::Poll.new(post2).is_poll?).to be_falsey - post.topic.title = "Not a poll" - expect(poll.is_poll?).to be_falsey - end - - it "strips whitespace from the prefix translation" do - topic.title = "Polll: This might be a poll" - topic.save - expect(PollPlugin::Poll.new(post).is_poll?).to be_falsey - I18n.expects(:t).with('poll.prefix').returns("Polll ") - I18n.expects(:t).with('poll.closed_prefix').returns("Closed Poll ") - expect(PollPlugin::Poll.new(post).is_poll?).to be_truthy - end - - it "should get options correctly" do - expect(poll.options).to eq(["Chitoge", "Onodera"]) - end - - it "should fall back to using the first list if [poll] markup is not present" do - topic = create_topic(title: "This is not a poll topic") - post = create_post(topic: topic, raw: "Pick one.\n\n* Chitoge\n* Onodera") - poll = PollPlugin::Poll.new(post) - expect(poll.options).to eq(["Chitoge", "Onodera"]) - end - - it "should get details correctly" do - expect(poll.details).to eq({"Chitoge" => 0, "Onodera" => 0}) - end - - it "should set details correctly" do - poll.set_details!({}) - poll.details.should eq({}) - PollPlugin::Poll.new(post).details.should eq({}) - end - - it "should get and set votes correctly" do - poll.get_vote(user).should eq(nil) - poll.set_vote!(user, "Onodera") - poll.get_vote(user).should eq("Onodera") - poll.details["Onodera"].should eq(1) - end - - it "should not set votes on closed polls" do - poll.set_vote!(user, "Onodera") - post.topic.closed = true - post.topic.save! - poll.set_vote!(user, "Chitoge") - poll.get_vote(user).should eq("Onodera") - end - - it "should serialize correctly" do - poll.serialize(user).should eq({options: poll.details, selected: nil, closed: false}) - poll.set_vote!(user, "Onodera") - poll.serialize(user).should eq({options: poll.details, selected: "Onodera", closed: false}) - poll.serialize(nil).should eq({options: poll.details, selected: nil, closed: false}) - - topic.title = "Closed Poll: my poll" - topic.save - - post.topic.reload - poll = PollPlugin::Poll.new(post) - poll.serialize(nil).should eq({options: poll.details, selected: nil, closed: true}) - end - - it "should serialize to nil if there are no poll options" do - topic = create_topic(title: "This is not a poll topic") - post = create_post(topic: topic, raw: "no options in the content") - poll = PollPlugin::Poll.new(post) - poll.serialize(user).should eq(nil) - end - - it "stores poll options to plugin store" do - poll.set_vote!(user, "Onodera") - poll.stubs(:options).returns(["Chitoge", "Onodera", "Inferno Cop"]) - poll.update_options! - poll.details.keys.sort.should eq(["Chitoge", "Inferno Cop", "Onodera"]) - poll.details["Inferno Cop"].should eq(0) - poll.details["Onodera"].should eq(1) - - poll.stubs(:options).returns(["Chitoge", "Onodera v2", "Inferno Cop"]) - poll.update_options! - poll.details.keys.sort.should eq(["Chitoge", "Inferno Cop", "Onodera v2"]) - poll.details["Onodera v2"].should eq(1) - poll.get_vote(user).should eq("Onodera v2") - end -end diff --git a/plugins/poll/spec/post_creator_spec.rb b/plugins/poll/spec/post_creator_spec.rb deleted file mode 100644 index d409bc8731a..00000000000 --- a/plugins/poll/spec/post_creator_spec.rb +++ /dev/null @@ -1,37 +0,0 @@ -require 'spec_helper' -require 'post_creator' - -describe PostCreator do - let(:user) { Fabricate(:user) } - let(:admin) { Fabricate(:admin) } - - context "poll topic" do - let(:poll_post) { PostCreator.create(user, {title: "Poll: This is a poll", raw: "[poll]\n* option 1\n* option 2\n* option 3\n* option 4\n[/poll]"}) } - - it "cannot be created without a list of options" do - post = PostCreator.create(user, {title: "Poll: This is a poll", raw: "body does not contain a list"}) - post.errors[:raw].should be_present - end - - it "cannot have options changed after 5 minutes" do - poll_post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n[/poll]" - poll_post.valid?.should == true - poll_post.save - Timecop.freeze(Time.now + 6.minutes) do - poll_post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n* option 4\n[/poll]" - poll_post.valid?.should == false - poll_post.errors[:poll_options].should be_present - end - end - - it "allows staff to edit options after 5 minutes" do - poll_post.last_editor_id = admin.id - Timecop.freeze(Time.now + 6.minutes) do - poll_post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n* option 4.1\n[/poll]" - poll_post.valid?.should == true - poll_post.raw = "[poll]\n* option 1\n* option 2\n* option 3\n[/poll]" - poll_post.valid?.should == false - end - end - end -end diff --git a/spec/components/concern/has_custom_fields_spec.rb b/spec/components/concern/has_custom_fields_spec.rb index aa36db51099..48ef6110088 100644 --- a/spec/components/concern/has_custom_fields_spec.rb +++ b/spec/components/concern/has_custom_fields_spec.rb @@ -5,7 +5,6 @@ describe HasCustomFields do context "custom_fields" do before do - Topic.exec_sql("create temporary table custom_fields_test_items(id SERIAL primary key)") Topic.exec_sql("create temporary table custom_fields_test_item_custom_fields(id SERIAL primary key, custom_fields_test_item_id int, name varchar(256) not null, value text)") @@ -85,10 +84,9 @@ describe HasCustomFields do # refresh loads from database expect(test_item.reload.custom_fields["a"]).to eq("1") expect(test_item.custom_fields["a"]).to eq("1") - end - it "double save actually saves" do + it "double save actually saves" do test_item = CustomFieldsTestItem.new test_item.custom_fields = {"a" => "b"} test_item.save @@ -98,12 +96,9 @@ describe HasCustomFields do db_item = CustomFieldsTestItem.find(test_item.id) expect(db_item.custom_fields).to eq({"a" => "b", "c" => "d"}) - end - it "handles arrays properly" do - test_item = CustomFieldsTestItem.new test_item.custom_fields = {"a" => ["b", "c", "d"]} test_item.save @@ -125,11 +120,9 @@ describe HasCustomFields do db_item.custom_fields.delete('a') expect(db_item.custom_fields).to eq({}) - end it "casts integers in arrays properly without error" do - test_item = CustomFieldsTestItem.new test_item.custom_fields = {"a" => ["b", 10, "d"]} test_item.save @@ -137,19 +130,19 @@ describe HasCustomFields do db_item = CustomFieldsTestItem.find(test_item.id) expect(db_item.custom_fields).to eq({"a" => ["b", "10", "d"]}) - end it "supportes type coersion" do test_item = CustomFieldsTestItem.new CustomFieldsTestItem.register_custom_field_type("bool", :boolean) CustomFieldsTestItem.register_custom_field_type("int", :integer) + CustomFieldsTestItem.register_custom_field_type("json", :json) - test_item.custom_fields = {"bool" => true, "int" => 1} + test_item.custom_fields = {"bool" => true, "int" => 1, "json" => { "foo" => "bar" }} test_item.save test_item.reload - expect(test_item.custom_fields).to eq({"bool" => true, "int" => 1}) + expect(test_item.custom_fields).to eq({"bool" => true, "int" => 1, "json" => { "foo" => "bar" }}) end it "simple modifications don't interfere" do diff --git a/spec/components/cooked_post_processor_spec.rb b/spec/components/cooked_post_processor_spec.rb index 02d50f82fce..e9b70107cab 100644 --- a/spec/components/cooked_post_processor_spec.rb +++ b/spec/components/cooked_post_processor_spec.rb @@ -51,7 +51,7 @@ describe CookedPostProcessor do end context "with image_sizes" do - let(:post) { build(:post_with_image_urls) } + let(:post) { Fabricate(:post_with_image_urls) } let(:cpp) { CookedPostProcessor.new(post, image_sizes: image_sizes) } before { cpp.post_process_images } @@ -87,7 +87,7 @@ describe CookedPostProcessor do context "with unsized images" do - let(:post) { build(:post_with_unsized_images) } + let(:post) { Fabricate(:post_with_unsized_images) } let(:cpp) { CookedPostProcessor.new(post) } it "adds the width and height to images that don't have them" do @@ -102,7 +102,7 @@ describe CookedPostProcessor do context "with large images" do let(:upload) { Fabricate(:upload) } - let(:post) { build(:post_with_large_image) } + let(:post) { Fabricate(:post_with_large_image) } let(:cpp) { CookedPostProcessor.new(post) } before do @@ -129,7 +129,7 @@ describe CookedPostProcessor do context "with title" do let(:upload) { Fabricate(:upload) } - let(:post) { build(:post_with_large_image_and_title) } + let(:post) { Fabricate(:post_with_large_image_and_title) } let(:cpp) { CookedPostProcessor.new(post) } before do diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index bc7137c7037..411e8d71551 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -62,6 +62,14 @@ describe PostCreator do expect(creator.spam?).to eq(false) end + it "triggers extensibility events" do + DiscourseEvent.expects(:trigger).with(:before_create_post, anything).once + DiscourseEvent.expects(:trigger).with(:validate_post, anything).once + DiscourseEvent.expects(:trigger).with(:topic_created, anything, anything, user).once + DiscourseEvent.expects(:trigger).with(:post_created, anything, anything, user).once + creator.create + end + it "does not notify on system messages" do admin = Fabricate(:admin) messages = MessageBus.track_publish do diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index d3d3001711b..c857c519e25 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -111,7 +111,7 @@ describe User do @user.delete_all_posts!(@guardian) expect(Post.where(id: @posts.map(&:id))).to be_empty @posts.each do |p| - if p.post_number == 1 + if p.is_first_post? expect(Topic.find_by(id: p.topic_id)).to be_nil end end