- 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
This commit is contained in:
Régis Hanol 2015-04-23 19:33:29 +02:00
parent 17dc8b8e4f
commit a737090442
89 changed files with 1334 additions and 1569 deletions

View file

@ -42,7 +42,7 @@ export default {
inject(app, 'currentUser', 'component', 'route', 'controller'); inject(app, 'currentUser', 'component', 'route', 'controller');
app.register('message-bus:main', window.MessageBus, { instantiate: false }); 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); app.register('store:main', Store);
inject(app, 'store', 'route', 'controller'); inject(app, 'store', 'route', 'controller');

View file

@ -259,14 +259,14 @@ Discourse.Markdown = {
// The first time, let's add some more whitelisted tags // The first time, let's add some more whitelisted tags
if (!_decoratedCaja) { if (!_decoratedCaja) {
// Add anything whitelisted to the list of elements if it's not in there // Add anything whitelisted to the list of elements if it's not in there already.
// already.
var elements = window.html4.ELEMENTS; var elements = window.html4.ELEMENTS;
Object.keys(_validTags).forEach(function(t) { Object.keys(_validTags).forEach(function(t) {
if (!elements[t]) { if (!elements[t]) {
elements[t] = 0; elements[t] = 0;
} }
}); });
_decoratedCaja = true; _decoratedCaja = true;
} }

View file

@ -234,11 +234,7 @@ const ComposerView = Discourse.View.extend(Ember.Evented, {
}, },
key: "@", key: "@",
transformComplete(v) { transformComplete(v) {
if (v.username) { return v.username ? v.username : v.usernames.join(", @");
return v.username;
} else {
return v.usernames.join(", @");
}
} }
}); });

View file

@ -263,13 +263,12 @@ var PostView = Discourse.GroupedView.extend(Ember.Evented, {
}.on('willDestroyElement'), }.on('willDestroyElement'),
_postViewInserted: function() { _postViewInserted: function() {
var $post = this.$(), const $post = this.$(),
post = this.get('post'), postNumber = this.get('post').get('post_number');
postNumber = post.get('post_number');
this._showLinkCounts(); this._showLinkCounts();
Discourse.ScreenTrack.current().track(this.$().prop('id'), postNumber); Discourse.ScreenTrack.current().track($post.prop('id'), postNumber);
this.trigger('postViewInserted', $post); this.trigger('postViewInserted', $post);

View file

@ -54,8 +54,6 @@
background: dark-light-diff($primary, $secondary, 65%, -75%); background: dark-light-diff($primary, $secondary, 65%, -75%);
color: #fff; color: #fff;
} }
&:active {
}
&[disabled] { &[disabled] {
background: scale-color-diff(); background: scale-color-diff();
&:hover { color: scale-color($primary, $lightness: 70%); } &:hover { color: scale-color($primary, $lightness: 70%); }

View file

@ -30,26 +30,24 @@ $base-font-family: Helvetica, Arial, sans-serif !default;
@import "theme_variables"; @import "theme_variables";
@import "plugins_variables"; @import "plugins_variables";
// w3c definition of color brightness
@function brightness($color) { @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) { @function dark-light-diff($adjusted-color, $comparison-color, $lightness, $darkness) {
@if brightness($adjusted-color) < brightness($comparison-color) { @if brightness($adjusted-color) < brightness($comparison-color) {
@return scale-color($adjusted-color, $lightness: $lightness) @return scale-color($adjusted-color, $lightness: $lightness);
} @else { } @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() { @function scale-color-diff() {
@if lightness($primary) < lightness($secondary) { @if lightness($primary) < lightness($secondary) {
@return scale-color($primary, $lightness: 90%) @return scale-color($primary, $lightness: 90%);
} } @else {
@else { @return scale-color($primary, $lightness: -60%);
@return scale-color($primary, $lightness: -60%)
} }
} }

View file

@ -116,7 +116,7 @@ class PostsController < ApplicationController
} }
# to stay consistent with the create api, we allow for title & category changes here # 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[:title] = params[:title] if params[:title]
changes[:category_id] = params[:post][:category_id] if params[:post][:category_id] changes[:category_id] = params[:post][:category_id] if params[:post][:category_id]
end end
@ -135,7 +135,7 @@ class PostsController < ApplicationController
link_counts = TopicLink.counts_for(guardian,post.topic, [post]) link_counts = TopicLink.counts_for(guardian,post.topic, [post])
post_serializer.single_post_link_counts = link_counts[post.id] if link_counts.present? 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? if revisor.category_changed.present?
result[:category] = BasicCategorySerializer.new(revisor.category_changed, scope: guardian, root: false).as_json result[:category] = BasicCategorySerializer.new(revisor.category_changed, scope: guardian, root: false).as_json
end end

View file

@ -12,14 +12,29 @@ module HasCustomFields
end end
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) 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 case type
when :boolean then !!CUSTOM_FIELD_TRUE.include?(value) when :boolean then !!CUSTOM_FIELD_TRUE.include?(value)
when :integer then value.to_i when :integer then value.to_i
when :json then ::JSON.parse(value)
else else
value value
end end
@ -30,8 +45,8 @@ module HasCustomFields
has_many :_custom_fields, dependent: :destroy, :class_name => "#{name}CustomField" has_many :_custom_fields, dependent: :destroy, :class_name => "#{name}CustomField"
after_save :save_custom_fields after_save :save_custom_fields
# To avoid n+1 queries, we have this function to retrieve lots of custom fields in one # To avoid n+1 queries, use this function to retrieve lots of custom fields in one go
# go and create a "sideloaded" version for easy querying by id. # and create a "sideloaded" version for easy querying by id.
def self.custom_fields_for_ids(ids, whitelisted_fields) def self.custom_fields_for_ids(ids, whitelisted_fields)
klass = "#{name}CustomField".constantize klass = "#{name}CustomField".constantize
foreign_key = "#{name.underscore}_id".to_sym foreign_key = "#{name.underscore}_id".to_sym
@ -39,15 +54,18 @@ module HasCustomFields
result = {} result = {}
return result if whitelisted_fields.blank? 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]] ||= {} result[cf[0]] ||= {}
append_custom_field(result[cf[0]], cf[1], cf[2]) append_custom_field(result[cf[0]], cf[1], cf[2])
end end
result result
end end
def self.append_custom_field(target, key, value) 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 end
def self.register_custom_field_type(name, type) def self.register_custom_field_type(name, type)
@ -63,7 +81,6 @@ module HasCustomFields
super super
end end
def custom_fields def custom_fields
@custom_fields ||= refresh_custom_fields_from_db.dup @custom_fields ||= refresh_custom_fields_from_db.dup
end end
@ -73,8 +90,7 @@ module HasCustomFields
end end
def custom_fields_clean? def custom_fields_clean?
# Check whether the cached version has been # Check whether the cached version has been changed on this model
# changed on this model
!@custom_fields || @custom_fields_orig == @custom_fields !@custom_fields || @custom_fields_orig == @custom_fields
end end
@ -86,9 +102,8 @@ module HasCustomFields
_custom_fields.each do |f| _custom_fields.each do |f|
if dup[f.name].is_a? Array if dup[f.name].is_a? Array
# we need to collect Arrays fully before # we need to collect Arrays fully before we can compare them
# we can compare them if !array_fields.has_key?(f.name)
if !array_fields.has_key? f.name
array_fields[f.name] = [f] array_fields[f.name] = [f]
else else
array_fields[f.name] << f array_fields[f.name] << f
@ -104,17 +119,18 @@ module HasCustomFields
# let's iterate through our arrays and compare them # let's iterate through our arrays and compare them
array_fields.each do |field_name, fields| array_fields.each do |field_name, fields|
if fields.length == dup[field_name].length && if fields.length == dup[field_name].length && fields.map(&:value) == dup[field_name]
fields.map{|f| f.value} == dup[field_name]
dup.delete(field_name) dup.delete(field_name)
else else
fields.each{|f| f.destroy } fields.each(&:destroy)
end end
end end
dup.each do |k,v| dup.each do |k,v|
if v.is_a? Array 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 else
_custom_fields.create(name: k, value: v) _custom_fields.create(name: k, value: v)
end end

View file

@ -315,6 +315,8 @@ class Post < ActiveRecord::Base
end end
def is_first_post? def is_first_post?
post_number.blank? ?
topic.try(:highest_post_number) == 0 :
post_number == 1 post_number == 1
end end
@ -324,7 +326,7 @@ class Post < ActiveRecord::Base
def unhide! def unhide!
self.update_attributes(hidden: false, hidden_at: nil, hidden_reason_id: nil) 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) save(validate: false)
publish_change_to_clients!(:acted) publish_change_to_clients!(:acted)
end end
@ -372,11 +374,7 @@ class Post < ActiveRecord::Base
def rebake!(opts=nil) def rebake!(opts=nil)
opts ||= {} opts ||= {}
new_cooked = cook( new_cooked = cook(raw, topic_id: topic_id, invalidate_oneboxes: opts.fetch(:invalidate_oneboxes, false))
raw,
topic_id: topic_id,
invalidate_oneboxes: opts.fetch(:invalidate_oneboxes, false)
)
old_cooked = cooked old_cooked = cooked
update_columns(cooked: new_cooked, baked_at: Time.new, baked_version: BAKED_VERSION) update_columns(cooked: new_cooked, baked_at: Time.new, baked_version: BAKED_VERSION)

View file

@ -60,7 +60,7 @@ class SearchObserver < ActiveRecord::Observer
if obj.topic if obj.topic
category_name = obj.topic.category.name if obj.topic.category 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_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 else
Rails.logger.warn("Orphan post skipped in search_observer, topic_id: #{obj.topic_id} post_id: #{obj.id} raw: #{obj.raw}") Rails.logger.warn("Orphan post skipped in search_observer, topic_id: #{obj.topic_id} post_id: #{obj.id} raw: #{obj.raw}")
end end

View file

@ -111,6 +111,8 @@ class User < ActiveRecord::Base
# excluding fake users like the system user # excluding fake users like the system user
scope :real, -> { where('id > 0') } scope :real, -> { where('id > 0') }
scope :staff, -> { where("admin OR moderator") }
# TODO-PERF: There is no indexes on any of these # TODO-PERF: There is no indexes on any of these
# and NotifyMailingListSubscribers does a select-all-and-loop # and NotifyMailingListSubscribers does a select-all-and-loop
# may want to create an index on (active, blocked, suspended_till, mailing_list_mode)? # may want to create an index on (active, blocked, suspended_till, mailing_list_mode)?

View file

@ -45,7 +45,7 @@ class UserActionObserver < ActiveRecord::Observer
def log_post(model) def log_post(model)
# first post gets nada # first post gets nada
return if model.post_number == 1 return if model.is_first_post?
row = { row = {
action_type: UserAction::REPLY, action_type: UserAction::REPLY,

View file

@ -1,13 +1,15 @@
class PostSerializer < BasicPostSerializer class PostSerializer < BasicPostSerializer
# To pass in additional information we might need # To pass in additional information we might need
INSTANCE_VARS = [:topic_view, INSTANCE_VARS = [
:topic_view,
:parent_post, :parent_post,
:add_raw, :add_raw,
:single_post_link_counts, :single_post_link_counts,
:draft_sequence, :draft_sequence,
:post_actions, :post_actions,
:all_post_actions] :all_post_actions
]
INSTANCE_VARS.each do |v| INSTANCE_VARS.each do |v|
self.send(:attr_accessor, v) self.send(:attr_accessor, v)
@ -268,7 +270,7 @@ class PostSerializer < BasicPostSerializer
end end
def include_static_doc? 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 end
def include_via_email? 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 @active_flags ||= (@topic_view.present? && @topic_view.all_active_flags.present?) ? @topic_view.all_active_flags[object.id] : nil
end 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 end

View file

@ -35,7 +35,7 @@ class UserDestroyer
PostDestroyer.new(@actor.staff? ? @actor : Discourse.system_user, post).destroy 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) Topic.unscoped.where(id: post.topic.id).update_all(user_id: nil)
end end
end end

View file

@ -205,7 +205,7 @@ class CookedPostProcessor
end end
def update_topic_image(images) def update_topic_image(images)
if @post.post_number == 1 if @post.is_first_post?
img = images.first img = images.first
@post.topic.update_column(:image_url, img["src"]) if img["src"].present? @post.topic.update_column(:image_url, img["src"]) if img["src"].present?
end end

View file

@ -116,7 +116,7 @@ module PostGuardian
# Deleting Methods # Deleting Methods
def can_delete_post?(post) def can_delete_post?(post)
# Can't delete the first 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 # Can't delete after post_edit_time_limit minutes have passed
return false if !is_staff? && post.edit_time_limit_expired? return false if !is_staff? && post.edit_time_limit_expired?

View file

@ -45,29 +45,33 @@ class Plugin::Instance
end end
def enabled? def enabled?
return @enabled_site_setting ? SiteSetting.send(@enabled_site_setting) : true @enabled_site_setting ? SiteSetting.send(@enabled_site_setting) : true
end end
delegate :name, to: :metadata 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 = "#{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) klass.send(:define_method, attr, &block)
return unless define_include_method
# Don't include serialized methods if the plugin is disabled # Don't include serialized methods if the plugin is disabled
plugin = self plugin = self
klass.send(:define_method, "include_#{attr}?") do klass.send(:define_method, "include_#{attr}?") { plugin.enabled? }
plugin.enabled?
end
end end
# Extend a class but check that the plugin is enabled # Extend a class but check that the plugin is enabled
def add_to_class(klass, attr, &block) def add_to_class(klass, attr, &block)
klass = klass.to_s.classify.constantize klass = klass.to_s.classify.constantize
hidden_method_name = "#{attr}_without_enable_check".to_sym hidden_method_name = :"#{attr}_without_enable_check"
klass.send(:define_method, hidden_method_name, &block) klass.send(:define_method, hidden_method_name) do |*args|
block.call(*args)
end
plugin = self plugin = self
klass.send(:define_method, attr) do |*args| klass.send(:define_method, attr) do |*args|
@ -75,6 +79,15 @@ class Plugin::Instance
end end
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 # will make sure all the assets this plugin needs are registered
def generate_automatic_assets! def generate_automatic_assets!
paths = [] paths = []

View file

@ -211,7 +211,7 @@ class PostCreator
return unless @post && @post.errors.count == 0 && @topic && @topic.category_id 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_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 end
def ensure_in_allowed_users def ensure_in_allowed_users
@ -293,7 +293,7 @@ class PostCreator
end end
@user.user_stat.post_count += 1 @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 # We don't count replies to your own topics
if !@opts[:import_mode] && @user.id != @topic.user_id if !@opts[:import_mode] && @user.id != @topic.user_id

View file

@ -55,7 +55,7 @@ class PostDestroyer
user_recovered user_recovered
end end
topic = Topic.with_deleted.find @post.topic_id 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 topic.update_statistics
end end
@ -80,7 +80,7 @@ class PostDestroyer
@post.update_flagged_posts_count @post.update_flagged_posts_count
remove_associated_replies remove_associated_replies
remove_associated_notifications 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 StaffActionLogger.new(@user).log_topic_deletion(@post.topic, @opts.slice(:context)) if @user.id != @post.user_id
@post.topic.trash!(@user) @post.topic.trash!(@user)
elsif @user.id != @post.user_id elsif @user.id != @post.user_id
@ -179,7 +179,7 @@ class PostDestroyer
def update_associated_category_latest_topic def update_associated_category_latest_topic
return unless @post.topic && @post.topic.category 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 @post.topic.category.update_latest
end end
@ -196,7 +196,7 @@ class PostDestroyer
end end
author.user_stat.post_count -= 1 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 # We don't count replies to your own topics
if @topic && author.id != @topic.user_id if @topic && author.id != @topic.user_id

View file

@ -332,7 +332,7 @@ class PostRevisor
end end
def bypass_bump? def bypass_bump?
@opts[:bypass_bump] == true !@post_successfully_saved || @opts[:bypass_bump] == true
end end
def is_last_post? def is_last_post?
@ -347,7 +347,7 @@ class PostRevisor
end end
def revise_topic def revise_topic
return unless @post.post_number == 1 return unless @post.is_first_post?
update_topic_excerpt update_topic_excerpt
update_category_description update_category_description

View file

@ -209,11 +209,13 @@ module PrettyText
end end
def self.cook(text, opts={}) def self.cook(text, opts={})
cloned = opts.dup options = opts.dup
# we have a minor inconsistency # we have a minor inconsistency
cloned[:topicId] = opts[:topic_id] options[: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 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 sanitized
end end

View file

@ -6,7 +6,7 @@ require_dependency 'gaps'
class TopicView class TopicView
attr_reader :topic, :posts, :guardian, :filtered_posts, :chunk_size 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 def self.slow_chunk_size
10 10
@ -16,6 +16,18 @@ class TopicView
20 20
end 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={}) def initialize(topic_id, user=nil, options={})
@user = user @user = user
@guardian = Guardian.new(@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('|'))) @user_custom_fields.deep_merge!(User.custom_fields_for_ids(@posts.map(&:user_id), SiteSetting.staff_user_custom_fields.split('|')))
end 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_key = @topic.draft_key
@draft_sequence = DraftSequence.current(@user, @draft_key) @draft_sequence = DraftSequence.current(@user, @draft_key)
end end

View file

@ -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]
```

View file

@ -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"));
}
}
});

View file

@ -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"),
});

View file

@ -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.[]")
});

View file

@ -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({ // immediately shows the results when the user has already voted
poll: null, showResults: Em.computed.gt("vote.length", 0),
showResults: Em.computed.oneWay('poll.closed'),
disableRadio: Em.computed.any('poll.closed', 'loading'), // shows the results when
showToggleClosePoll: Em.computed.alias('poll.post.topic.details.can_edit'), // - 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: { actions: {
selectOption(option) {
if (this.get('disableRadio')) { toggleOption(option) {
return; 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')) { option.toggleProperty("selected");
this.get('postController').send('showLogin');
return;
}
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; 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() { toggleResults() {
this.toggleProperty('showResults'); this.toggleProperty("showResults");
}, },
toggleClosePoll() { toggleStatus() {
const self = this; 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', { bootbox.confirm(
type: 'PUT', I18n.t(confirm),
data: { post_id: this.get('poll.post.id') } I18n.t("no_value"),
}).then(function(result) { I18n.t("yes_value"),
self.set('poll.post.topic.title', result.basic_topic.title); function(confirmed) {
self.set('poll.post.topic.fancy_title', result.basic_topic.title); if (confirmed) {
self.set('loading', false); 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);
}); });
} }
} }
);
},
}
}); });

View file

@ -0,0 +1 @@
{{{averageRating}}}

View file

@ -0,0 +1,13 @@
<tbody>
{{#each option in options}}
<tr>
<td class="option">{{{option.html}}}</td>
<td class="percentage">{{option.percentage}}%</td>
</tr>
<tr>
<td colspan="2" class="bar-back">
<div class="bar" {{bind-attr style=option.style}}></div>
</td>
</tr>
{{/each}}
</tbody>

View file

@ -1,37 +1,36 @@
<table> <div class="poll-container">
{{#each po in poll.options}} {{#if showingResults}}
<tr {{bind-attr class="po.checked:active"}} {{action "selectOption" po.option}}> {{#if isNumber}}
<td class="radio"> {{poll-results-number poll=poll}}
<input type="radio" name="poll" {{bind-attr checked="po.checked" disabled="disableRadio"}}> {{else}}
</td> {{poll-results-standard poll=poll}}
<td class="option">
<div class="option">{{{po.option}}}</div>
{{#if showResults}}
<div class="result">{{i18n 'poll.voteCount' count=po.votes}}</div>
{{/if}} {{/if}}
</td> {{else}}
</tr> <ul>
{{#each option in poll.options}}
{{poll-option option=option color=poll.color background=poll.background toggle="toggleOption"}}
{{/each}} {{/each}}
</table> </ul>
<div class='row'>
<button {{action "toggleShowResults"}} class="btn btn-small show-results">
{{#if showResults}}
{{fa-icon "eye-slash"}} {{i18n 'poll.results.hide'}}
{{else}}
{{fa-icon "eye"}} {{i18n 'poll.results.show'}}
{{/if}}
</button>
{{#if showToggleClosePoll}}
<button {{action "toggleClosePoll"}} class="btn btn-small toggle-poll">
{{#if poll.closed}}
{{fa-icon "unlock-alt"}} {{i18n 'poll.open_poll'}}
{{else}}
{{fa-icon "lock"}} {{i18n 'poll.close_poll'}}
{{/if}}
</button>
{{/if}} {{/if}}
</div> </div>
{{loading-spinner condition=loading}} <p>{{totalVotesText}}</p>
{{#if isMultiple}}
<p>{{multipleHelpText}}</p>
{{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}}

View file

@ -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 = $("<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")
});
}
}

View file

@ -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 = $('<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')
});
}
};

View file

@ -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));
}

View file

@ -0,0 +1,5 @@
import decimalAdjust from "discourse/plugins/poll/lib/decimal-adjust";
export default function(value, exp) {
return decimalAdjust("round", value, exp);
}

View file

@ -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);
});
}
});

View file

@ -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];
}
});

View file

@ -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 <li> 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");
})();

View file

@ -1,4 +1,16 @@
export default Ember.View.extend({ export default Em.View.extend({
templateName: "poll", 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")
}); });

View file

@ -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;
}
}
}

View file

@ -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: "فتح التصويت"

View file

@ -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"

View file

@ -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"

View file

@ -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: en:
js: js:
poll: poll:
voteCount: total_votes:
one: "1 vote" zero: "No votes yet. Want to be the first?"
other: "%{count} votes" one: "There's only 1 vote."
other: "There are %{count} total votes."
results: average_rating: "Average rating: <strong>%{average}</strong>."
show: Show Results
hide: Hide Results
close_poll: "Close Poll" multiple:
open_poll: "Open Poll" 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."

View file

@ -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"

View file

@ -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: "باز کردن نظرسنجی"

View file

@ -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"

View file

@ -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"

View file

@ -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: "פתיחת הצבעה"

View file

@ -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"

View file

@ -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: "투표 시작하기"

View file

@ -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ę"

View file

@ -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"

View file

@ -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"

View file

@ -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: "Запустить опрос снова"

View file

@ -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"

View file

@ -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: "ఓటు తెరువు"

View file

@ -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"

View file

@ -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: "开始投票"

View file

@ -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: "هذا التصويت مغلق"

View file

@ -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"

View file

@ -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"

View file

@ -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: en:
activerecord: site_settings:
attributes: poll_enabled: "Allow users to create polls?"
post:
poll_options: "Poll options"
poll: poll:
must_contain_poll_options: "must contain a list of poll options" multiple_polls_without_name: "There are multiple polls without a name. Use the '<code>name</code>' attribute to uniquely identify your polls."
cannot_have_modified_options: "cannot be modified after the first five minutes. Contact a moderator if you need to change them." multiple_polls_with_same_name: "There are multiple polls with the same name: <strong>%{name}</strong>. Use the '<code>name</code>' attribute to uniquely identify your polls."
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" default_poll_must_have_at_least_2_options: "Poll must have at least 2 options."
closed_prefix: "Closed Poll" named_poll_must_have_at_least_2_options: "Poll named <strong>%{name}</strong> 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 <strong>%{name}</strong> 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 <strong>%{name}</strong> 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."

View file

@ -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"

View file

@ -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: "اتمام نظرسنجی"

View file

@ -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"

View file

@ -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é "

View file

@ -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: "הצבעה סגורה"

View file

@ -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"

View file

@ -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: "투표 닫기"

View file

@ -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"

View file

@ -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"

View file

@ -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"

View file

@ -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: "Завершившийся опрос"

View file

@ -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"

View file

@ -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: "మూసేసిన ఓటు"

View file

@ -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"

View file

@ -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: "已关闭的投票:"

View file

@ -0,0 +1,3 @@
plugins:
poll_enabled:
default: true

View file

@ -1,205 +1,267 @@
# name: poll # name: poll
# about: adds poll support to Discourse # about: Official poll plugin for Discourse
# version: 0.2 # version: 0.9
# authors: Vikhyat Korrapati # authors: Vikhyat Korrapati (vikhyat), Régis Hanol (zogstrip)
# url: https://github.com/discourse/discourse/tree/master/plugins/poll # 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, PLUGIN_NAME ||= "discourse_poll".freeze
# because all of this is instance_eval'd inside an instance of Plugin::Instance.
PollPlugin = PollPlugin POLLS_CUSTOM_FIELD ||= "polls".freeze
VOTES_CUSTOM_FIELD ||= "polls-votes".freeze
after_initialize do after_initialize do
# Rails Engine for accepting votes.
module PollPlugin module ::DiscoursePoll
class Engine < ::Rails::Engine class Engine < ::Rails::Engine
engine_name "poll_plugin" engine_name PLUGIN_NAME
isolate_namespace PollPlugin isolate_namespace DiscoursePoll
end
end end
class PollController < ActionController::Base require_dependency "application_controller"
include CurrentUser class DiscoursePoll::PollsController < ::ApplicationController
requires_plugin PLUGIN_NAME
before_filter :ensure_logged_in
def vote def vote
if current_user.nil? post_id = params.require(:post_id)
render status: :forbidden, json: false poll_name = params.require(:poll_name)
return 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 end
if params[:post_id].nil? or params[:option].nil? polls = post.custom_fields[POLLS_CUSTOM_FIELD]
render status: 400, json: false
return 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 end
post = Post.find(params[:post_id]) votes[poll_name] = options
poll = PollPlugin::Poll.new(post)
unless poll.has_poll_details?
render status: 400, json: false
return
end
options = poll.details post.custom_fields[POLLS_CUSTOM_FIELD] = polls
post.custom_fields["#{VOTES_CUSTOM_FIELD}-#{user_id}"] = votes
post.save_custom_fields
unless options.keys.include? params[:option] MessageBus.publish("/polls/#{post_id}", { poll: poll })
render status: 400, json: false
return
end
poll.set_vote!(current_user, params[:option]) render json: { poll: poll, vote: options }
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
end end
topic.acting_user = current_user def toggle_status
topic.save! post_id = params.require(:post_id)
poll_name = params.require(:poll_name)
status = params.require(:status)
render json: topic, serializer: BasicTopicSerializer 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 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 end
PollPlugin::Engine.routes.draw do end
put '/' => 'poll#vote'
put '/toggle_close' => 'poll#toggle_close' DiscoursePoll::Engine.routes.draw do
put "/vote" => "polls#vote"
put "/toggle_status" => "polls#toggle_status"
end end
Discourse::Application.routes.append do Discourse::Application.routes.append do
mount ::PollPlugin::Engine, at: '/poll' mount ::DiscoursePoll::Engine, at: "/polls"
end 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 Post.class_eval do
validate :poll_options attr_accessor :polls
def poll_options
poll = PollPlugin::Poll.new(self)
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 post = self
self.errors.add(:raw, I18n.t('poll.must_contain_poll_options')) polls = self.polls
DistributedMutex.synchronize("#{PLUGIN_NAME}-#{post.id}") do
post.custom_fields[POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields
end end
poll.ensure_can_be_edited!
end end
end end
# Save the list of options to PluginStore after the post is saved. DATA_PREFIX ||= "data-poll-".freeze
Post.class_eval do DEFAULT_POLL_NAME ||= "poll".freeze
after_save :save_poll_options_to_plugin_store
def save_poll_options_to_plugin_store validate(:post, :polls) do
PollPlugin::Poll.new(self).update_options! # 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
end end
# Add poll details into the post serializer. # extract options
PostSerializer.class_eval do p.css("li[#{DATA_PREFIX}option-id]").each do |o|
attributes :poll_details option_id = o.attributes[DATA_PREFIX + "option-id"].value
def poll_details poll["options"] << { "id" => option_id, "html" => o.inner_html, "votes" => 0 }
PollPlugin::Poll.new(object).serialize(scope.user)
end end
def include_poll_details?
PollPlugin::Poll.new(object).has_poll_details? # 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
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
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_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 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 <<CSS
.poll-ui table {
margin-bottom: 5px;
margin-left: 20px;
}
.poll-ui tr {
cursor: pointer;
}
.poll-ui .row {
padding-left: 15px;
padding-top: 10px;
}
.poll-ui td.radio input {
margin-left: -10px !important;
}
.poll-ui td {
padding: 4px 8px;
}
.poll-ui td.option .option {
float: left;
}
.poll-ui td.option .result {
float: right;
margin-left: 50px;
}
.poll-ui tr.active {
background-color: #FFFFB3;
}
.poll-ui button i.fa {
margin-right: 2px;
}
.poll-ui .radio {
margin-right: 0px;
}
.poll-ui .toggle-poll {
float: right;
}
CSS

View file

@ -1,168 +0,0 @@
module ::PollPlugin
class Poll
def initialize(post)
@post = post
end
def is_poll?
# Not a new post, and also not the first post.
return false if @post.post_number.present? && @post.post_number > 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -5,7 +5,6 @@ describe HasCustomFields do
context "custom_fields" do context "custom_fields" do
before 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_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)") 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 # refresh loads from database
expect(test_item.reload.custom_fields["a"]).to eq("1") expect(test_item.reload.custom_fields["a"]).to eq("1")
expect(test_item.custom_fields["a"]).to eq("1") expect(test_item.custom_fields["a"]).to eq("1")
end end
it "double save actually saves" do
it "double save actually saves" do
test_item = CustomFieldsTestItem.new test_item = CustomFieldsTestItem.new
test_item.custom_fields = {"a" => "b"} test_item.custom_fields = {"a" => "b"}
test_item.save test_item.save
@ -98,12 +96,9 @@ describe HasCustomFields do
db_item = CustomFieldsTestItem.find(test_item.id) db_item = CustomFieldsTestItem.find(test_item.id)
expect(db_item.custom_fields).to eq({"a" => "b", "c" => "d"}) expect(db_item.custom_fields).to eq({"a" => "b", "c" => "d"})
end end
it "handles arrays properly" do it "handles arrays properly" do
test_item = CustomFieldsTestItem.new test_item = CustomFieldsTestItem.new
test_item.custom_fields = {"a" => ["b", "c", "d"]} test_item.custom_fields = {"a" => ["b", "c", "d"]}
test_item.save test_item.save
@ -125,11 +120,9 @@ describe HasCustomFields do
db_item.custom_fields.delete('a') db_item.custom_fields.delete('a')
expect(db_item.custom_fields).to eq({}) expect(db_item.custom_fields).to eq({})
end end
it "casts integers in arrays properly without error" do it "casts integers in arrays properly without error" do
test_item = CustomFieldsTestItem.new test_item = CustomFieldsTestItem.new
test_item.custom_fields = {"a" => ["b", 10, "d"]} test_item.custom_fields = {"a" => ["b", 10, "d"]}
test_item.save test_item.save
@ -137,19 +130,19 @@ describe HasCustomFields do
db_item = CustomFieldsTestItem.find(test_item.id) db_item = CustomFieldsTestItem.find(test_item.id)
expect(db_item.custom_fields).to eq({"a" => ["b", "10", "d"]}) expect(db_item.custom_fields).to eq({"a" => ["b", "10", "d"]})
end end
it "supportes type coersion" do it "supportes type coersion" do
test_item = CustomFieldsTestItem.new test_item = CustomFieldsTestItem.new
CustomFieldsTestItem.register_custom_field_type("bool", :boolean) CustomFieldsTestItem.register_custom_field_type("bool", :boolean)
CustomFieldsTestItem.register_custom_field_type("int", :integer) 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.save
test_item.reload 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 end
it "simple modifications don't interfere" do it "simple modifications don't interfere" do

View file

@ -51,7 +51,7 @@ describe CookedPostProcessor do
end end
context "with image_sizes" do 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) } let(:cpp) { CookedPostProcessor.new(post, image_sizes: image_sizes) }
before { cpp.post_process_images } before { cpp.post_process_images }
@ -87,7 +87,7 @@ describe CookedPostProcessor do
context "with unsized images" 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) } let(:cpp) { CookedPostProcessor.new(post) }
it "adds the width and height to images that don't have them" do 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 context "with large images" do
let(:upload) { Fabricate(:upload) } let(:upload) { Fabricate(:upload) }
let(:post) { build(:post_with_large_image) } let(:post) { Fabricate(:post_with_large_image) }
let(:cpp) { CookedPostProcessor.new(post) } let(:cpp) { CookedPostProcessor.new(post) }
before do before do
@ -129,7 +129,7 @@ describe CookedPostProcessor do
context "with title" do context "with title" do
let(:upload) { Fabricate(:upload) } 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) } let(:cpp) { CookedPostProcessor.new(post) }
before do before do

View file

@ -62,6 +62,14 @@ describe PostCreator do
expect(creator.spam?).to eq(false) expect(creator.spam?).to eq(false)
end 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 it "does not notify on system messages" do
admin = Fabricate(:admin) admin = Fabricate(:admin)
messages = MessageBus.track_publish do messages = MessageBus.track_publish do

View file

@ -111,7 +111,7 @@ describe User do
@user.delete_all_posts!(@guardian) @user.delete_all_posts!(@guardian)
expect(Post.where(id: @posts.map(&:id))).to be_empty expect(Post.where(id: @posts.map(&:id))).to be_empty
@posts.each do |p| @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 expect(Topic.find_by(id: p.topic_id)).to be_nil
end end
end end