diff --git a/plugins/poll/assets/javascripts/components/poll-results-number-voters.js.es6 b/plugins/poll/assets/javascripts/components/poll-results-number-voters.js.es6 new file mode 100644 index 00000000000..63449af066a --- /dev/null +++ b/plugins/poll/assets/javascripts/components/poll-results-number-voters.js.es6 @@ -0,0 +1,31 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import User from 'discourse/models/user'; +import PollVoters from 'discourse/plugins/poll/components/poll-voters'; + +export default PollVoters.extend({ + @computed("pollsVoters", "poll.options", "showMore", "isExpanded", "numOfVotersToShow") + users(pollsVoters, options, showMore, isExpanded, numOfVotersToShow) { + var users = []; + var voterIds = []; + const shouldLimit = showMore && !isExpanded; + + options.forEach(option => { + option.voter_ids.forEach(voterId => { + if (shouldLimit) { + if (!(users.length > numOfVotersToShow - 1)) { + users.push(pollsVoters[voterId]); + } + } else { + users.push(pollsVoters[voterId]); + } + }) + }); + + return users; + }, + + @computed("pollsVoters", "numOfVotersToShow") + showMore(pollsVoters, numOfVotersToShow) { + return !(Object.keys(pollsVoters).length < numOfVotersToShow); + } +}); diff --git a/plugins/poll/assets/javascripts/components/poll-results-number.js.es6 b/plugins/poll/assets/javascripts/components/poll-results-number.js.es6 index 42087e2933b..5718642b327 100644 --- a/plugins/poll/assets/javascripts/components/poll-results-number.js.es6 +++ b/plugins/poll/assets/javascripts/components/poll-results-number.js.es6 @@ -1,23 +1,27 @@ import round from "discourse/lib/round"; +import computed from 'ember-addons/ember-computed-decorators'; export default Em.Component.extend({ tagName: "span", - totalScore: function() { + @computed("poll.options.@each.{html,votes}") + totalScore() { 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() { + @computed("totalScore", "poll.voters") + average() { const voters = this.get("poll.voters"); return voters === 0 ? 0 : round(this.get("totalScore") / voters, -2); - }.property("totalScore", "poll.voters"), + }, - averageRating: function() { + @computed("average") + averageRating() { return I18n.t("poll.average_rating", { average: this.get("average") }); - }.property("average"), + }, }); diff --git a/plugins/poll/assets/javascripts/components/poll-results-standard-voters.js.es6 b/plugins/poll/assets/javascripts/components/poll-results-standard-voters.js.es6 new file mode 100644 index 00000000000..1e51dc23c44 --- /dev/null +++ b/plugins/poll/assets/javascripts/components/poll-results-standard-voters.js.es6 @@ -0,0 +1,25 @@ +import computed from 'ember-addons/ember-computed-decorators'; +import User from 'discourse/models/user'; +import PollVoters from 'discourse/plugins/poll/components/poll-voters'; + +export default PollVoters.extend({ + @computed("pollsVoters", "option.voter_ids", "showMore", "isExpanded", "numOfVotersToShow") + users(pollsVoters, voterIds, showMore, isExpanded, numOfVotersToShow) { + var users = []; + + if (showMore && !isExpanded) { + voterIds = voterIds.slice(0, numOfVotersToShow); + } + + voterIds.forEach(voterId => { + users.push(pollsVoters[voterId]); + }); + + return users; + }, + + @computed("option.votes", "numOfVotersToShow") + showMore(numOfVotes, numOfVotersToShow) { + return !(numOfVotes < numOfVotersToShow); + } +}); diff --git a/plugins/poll/assets/javascripts/components/poll-voters.js.es6 b/plugins/poll/assets/javascripts/components/poll-voters.js.es6 new file mode 100644 index 00000000000..b580f111821 --- /dev/null +++ b/plugins/poll/assets/javascripts/components/poll-voters.js.es6 @@ -0,0 +1,13 @@ +export default Ember.Component.extend({ + layoutName: "components/poll-voters", + tagName: 'ul', + classNames: ["poll-voters-list"], + isExpanded: false, + numOfVotersToShow: 20, + + actions: { + toggleExpand() { + this.toggleProperty("isExpanded"); + } + } +}); diff --git a/plugins/poll/assets/javascripts/controllers/poll.js.es6 b/plugins/poll/assets/javascripts/controllers/poll.js.es6 index 14a5079408f..b986c4c9fc2 100644 --- a/plugins/poll/assets/javascripts/controllers/poll.js.es6 +++ b/plugins/poll/assets/javascripts/controllers/poll.js.es6 @@ -6,6 +6,7 @@ export default Ember.Controller.extend({ isNumber: Ember.computed.equal("poll.type", "number"), isRandom : Ember.computed.equal("poll.order", "random"), isClosed: Ember.computed.equal("poll.status", "closed"), + pollsVoters: Ember.computed.alias("post.polls_voters"), // shows the results when // - poll is closed @@ -145,8 +146,16 @@ export default Ember.Controller.extend({ options: this.get("selectedOptions"), } }).then(results => { - this.setProperties({ vote: results.vote, showResults: true }); - this.set("model", Em.Object.create(results.poll)); + const poll = results.poll; + const votes = results.vote; + const currentUser = this.currentUser; + + this.setProperties({ vote: votes, showResults: true }); + this.set("model", Em.Object.create(poll)); + + if (poll.public) { + this.get("pollsVoters")[currentUser.get("id")] = currentUser; + } }).catch(() => { bootbox.alert(I18n.t("poll.error_while_casting_votes")); }).finally(() => { diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs index b6cf23314d9..48e73ff2fc8 100644 --- a/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs +++ b/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs @@ -1 +1,5 @@ {{{averageRating}}} + +{{#if poll.public}} + {{poll-results-number-voters poll=poll pollsVoters=pollsVoters}} +{{/if}} diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs index d625b76cd2d..e412d61a772 100644 --- a/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs +++ b/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs @@ -9,5 +9,9 @@ <div class="bar-back"> <div class="bar" style={{option.style}}></div> </div> + + {{#if poll.public}} + {{poll-results-standard-voters option=option pollsVoters=pollsVoters}} + {{/if}} </li> {{/each}} diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/poll-voters.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/poll-voters.hbs new file mode 100644 index 00000000000..afa3f26c4b9 --- /dev/null +++ b/plugins/poll/assets/javascripts/discourse/templates/components/poll-voters.hbs @@ -0,0 +1,19 @@ +<div class="poll-voters"> + {{#each users as |user|}} + <li> + <a data-user-card={{unbound user.username}}> + {{avatar user imageSize="tiny"}} + </a> + </li> + {{/each}} + + <div class="poll-voters-toggle-expand"> + {{#if showMore}} + {{#if isExpanded}} + <a {{action "toggleExpand"}}>{{fa-icon "chevron-up"}}</a> + {{else}} + <a {{action "toggleExpand"}}>{{fa-icon "chevron-down"}}</a> + {{/if}} + {{/if}} + </div> +</div> diff --git a/plugins/poll/assets/javascripts/discourse/templates/poll.hbs b/plugins/poll/assets/javascripts/discourse/templates/poll.hbs index 418c5b6355f..1d1e22ad182 100644 --- a/plugins/poll/assets/javascripts/discourse/templates/poll.hbs +++ b/plugins/poll/assets/javascripts/discourse/templates/poll.hbs @@ -2,9 +2,9 @@ <div class="poll-container"> {{#if showingResults}} {{#if isNumber}} - {{poll-results-number poll=poll}} + {{poll-results-number poll=poll pollsVoters=pollsVoters}} {{else}} - {{poll-results-standard poll=poll}} + {{poll-results-standard poll=poll pollsVoters=pollsVoters}} {{/if}} {{else}} <ul> diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 index aae84ce3f76..0b7919258bf 100644 --- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 +++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 @@ -1,11 +1,17 @@ import { withPluginApi } from 'discourse/lib/plugin-api'; +import { observes } from "ember-addons/ember-computed-decorators"; -function createPollView(container, post, poll, vote) { +function createPollView(container, post, poll, vote, publicPoll) { const controller = container.lookup("controller:poll", { singleton: false }); const view = container.lookup("view:poll"); - controller.set("vote", vote); - controller.setProperties({ model: poll, post }); + controller.setProperties({ + model: poll, + vote: vote, + public: publicPoll, + post + }); + view.set("controller", controller); return view; @@ -23,6 +29,10 @@ function initializePolls(api) { const post = this.get('model.postStream').findLoadedPost(msg.post_id); if (post) { post.set('polls', msg.polls); + + if (msg.user) { + post.set(`polls_voters.${msg.user.id}`, msg.user); + } } }); }, @@ -38,7 +48,8 @@ function initializePolls(api) { pollsObject: null, // we need a proper ember object so it is bindable - pollsChanged: function(){ + @observes("polls") + pollsChanged() { const polls = this.get("polls"); if (polls) { this._polls = this._polls || {}; @@ -52,7 +63,7 @@ function initializePolls(api) { }); this.set("pollsObject", this._polls); } - }.observes("polls") + } }); function cleanUpPollViews() { @@ -69,6 +80,7 @@ function initializePolls(api) { const post = helper.getModel(); api.preventCloak(post.id); const votes = post.get('polls_votes') || {}; + post.set("polls_voters", (post.get("polls_voters") || {})); post.pollsChanged(); @@ -82,8 +94,16 @@ function initializePolls(api) { const $poll = $(pollElem); const pollName = $poll.data("poll-name"); + const publicPoll = $poll.data("poll-public"); const pollId = `${pollName}-${post.id}`; - const pollView = createPollView(helper.container, post, polls[pollName], votes[pollName]); + + const pollView = createPollView( + helper.container, + post, + polls[pollName], + votes[pollName], + publicPoll + ); $poll.replaceWith($div); Em.run.next(() => pollView.renderer.replaceIn(pollView, $div[0])); diff --git a/plugins/poll/assets/javascripts/poll_dialect.js b/plugins/poll/assets/javascripts/poll_dialect.js index bc9b6585ede..538518fe7dd 100644 --- a/plugins/poll/assets/javascripts/poll_dialect.js +++ b/plugins/poll/assets/javascripts/poll_dialect.js @@ -5,7 +5,7 @@ var DATA_PREFIX = "data-poll-"; var DEFAULT_POLL_NAME = "poll"; - var WHITELISTED_ATTRIBUTES = ["type", "name", "min", "max", "step", "order", "status"]; + var WHITELISTED_ATTRIBUTES = ["type", "name", "min", "max", "step", "order", "status", "public"]; var ATTRIBUTES_REGEX = new RegExp("(" + WHITELISTED_ATTRIBUTES.join("|") + ")=['\"]?[^\\s\\]]+['\"]?", "g"); diff --git a/plugins/poll/assets/javascripts/views/poll.js.es6 b/plugins/poll/assets/javascripts/views/poll.js.es6 index bad40cfebff..ac739b3f429 100644 --- a/plugins/poll/assets/javascripts/views/poll.js.es6 +++ b/plugins/poll/assets/javascripts/views/poll.js.es6 @@ -3,17 +3,12 @@ import { on } from "ember-addons/ember-computed-decorators"; export default Em.View.extend({ templateName: "poll", classNames: ["poll"], - attributeBindings: ["data-poll-type", "data-poll-name", "data-poll-status"], + attributeBindings: ["data-poll-type", "data-poll-name", "data-poll-status", "data-poll-public"], 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"), - - @on("didInsertElement") - _fixPollContainerHeight() { - const pollContainer = this.$(".poll-container"); - pollContainer.height(pollContainer.height()); - } + "data-poll-public": Em.computed.alias("poll.public") }); diff --git a/plugins/poll/assets/stylesheets/common/poll.scss b/plugins/poll/assets/stylesheets/common/poll.scss index b15a6574009..5db25555550 100644 --- a/plugins/poll/assets/stylesheets/common/poll.scss +++ b/plugins/poll/assets/stylesheets/common/poll.scss @@ -92,6 +92,18 @@ div.poll { } } + .poll-voters-list { + li { + display: inline; + } + + margin: 5px 0; + } + + .poll-voters-toggle-expand { + text-align: center; + } + .results { .option { @@ -120,9 +132,11 @@ div.poll { &[data-poll-type="number"] { - li { + li[data-poll-option-id] { display: inline-block; - margin: 0 12px 15px 5px; + text-align: center; + width: 25px; + margin-right: 5px; } } diff --git a/plugins/poll/lib/polls_updater.rb b/plugins/poll/lib/polls_updater.rb index 817071b7ab1..2f05f91c706 100644 --- a/plugins/poll/lib/polls_updater.rb +++ b/plugins/poll/lib/polls_updater.rb @@ -1,6 +1,6 @@ module DiscoursePoll class PollsUpdater - VALID_POLLS_CONFIGS = %w{type min max}.map(&:freeze) + VALID_POLLS_CONFIGS = %w{type min max public}.map(&:freeze) def self.update(post, polls) # load previous polls @@ -53,11 +53,16 @@ module DiscoursePoll polls[poll_name]["anonymous_voters"] = previous_polls[poll_name]["anonymous_voters"] if previous_polls[poll_name].has_key?("anonymous_voters") previous_options = previous_polls[poll_name]["options"] + public_poll = polls[poll_name]["public"] == "true" polls[poll_name]["options"].each_with_index do |option, index| previous_option = previous_options[index] option["votes"] = previous_option["votes"] option["anonymous_votes"] = previous_option["anonymous_votes"] if previous_option.has_key?("anonymous_votes") + + if public_poll && previous_option.has_key?("voter_ids") + option["voter_ids"] = previous_option["voter_ids"] + end end end diff --git a/plugins/poll/plugin.rb b/plugins/poll/plugin.rb index 251ec78b0cc..26b825dac6e 100644 --- a/plugins/poll/plugin.rb +++ b/plugins/poll/plugin.rb @@ -57,6 +57,7 @@ after_initialize do raise StandardError.new I18n.t("poll.no_poll_with_this_name", name: poll_name) if poll.blank? raise StandardError.new I18n.t("poll.poll_must_be_open_to_vote") if poll["status"] != "open" + public_poll = (poll["public"] == "true") # remove options that aren't available in the poll available_options = poll["options"].map { |o| o["id"] }.to_set @@ -80,12 +81,30 @@ after_initialize do poll["options"].each do |option| anonymous_votes = option["anonymous_votes"] || 0 option["votes"] = all_options[option["id"]] + anonymous_votes + + if public_poll + option["voter_ids"] ||= [] + + if options.include?(option["id"]) + option["voter_ids"] << user_id if !option["voter_ids"].include?(user_id) + else + option["voter_ids"].delete(user_id) + end + end end post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls post.save_custom_fields(true) - MessageBus.publish("/polls/#{post.topic_id}", { post_id: post_id, polls: polls }) + payload = { post_id: post_id, polls: polls } + + if public_poll + payload.merge!( + user: UserNameSerializer.new(User.find(user_id)).serializable_hash + ) + end + + MessageBus.publish("/polls/#{post.topic_id}", payload) return [poll, options] end @@ -195,7 +214,6 @@ after_initialize do render_json_error e.message end end - end DiscoursePoll::Engine.routes.draw do @@ -271,11 +289,36 @@ after_initialize do add_to_serializer(:post, :polls, false) { post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] } add_to_serializer(:post, :include_polls?) { post_custom_fields.present? && post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].present? } - add_to_serializer(:post, :polls_votes, false) { post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{scope.user.id}"] } + add_to_serializer(:post, :polls_votes, false) do + post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD]["#{scope.user.id}"] + end + add_to_serializer(:post, :include_polls_votes?) do return unless scope.user return unless post_custom_fields.present? return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present? post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}") end + + add_to_serializer(:post, :polls_voters) do + voters = {} + + user_ids = post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].keys + + User.where(id: user_ids).map do |user| + voters[user.id] = UserNameSerializer.new(user).serializable_hash + end + + voters + end + + add_to_serializer(:post, :include_polls_voters?) do + return unless post_custom_fields.present? + return unless post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].present? + return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present? + + post_custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD].any? do |_, value| + value["public"] == "true" + end + end end diff --git a/plugins/poll/spec/controllers/polls_controller_spec.rb b/plugins/poll/spec/controllers/polls_controller_spec.rb index 8a8ce7190e5..8dc6a66cfa6 100644 --- a/plugins/poll/spec/controllers/polls_controller_spec.rb +++ b/plugins/poll/spec/controllers/polls_controller_spec.rb @@ -98,6 +98,49 @@ describe ::DiscoursePoll::PollsController do expect(json["poll"]["options"][0]["votes"]).to eq(12) expect(json["poll"]["options"][1]["votes"]).to eq(6) end + + it "tracks the users ids for public polls" do + public_poll = Fabricate(:post, topic_id: topic.id, user_id: user.id, raw: "[poll public=true]\n- A\n- B\n[/poll]") + body = { post_id: public_poll.id, poll_name: "poll" } + + message = MessageBus.track_publish do + xhr :put, :vote, body.merge(options: ["5c24fc1df56d764b550ceae1b9319125"]) + end.first + + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json["poll"]["voters"]).to eq(1) + expect(json["poll"]["options"][0]["votes"]).to eq(1) + expect(json["poll"]["options"][1]["votes"]).to eq(0) + expect(json["poll"]["options"][0]["voter_ids"]).to eq([user.id]) + expect(json["poll"]["options"][1]["voter_ids"]).to eq([]) + expect(message.data[:post_id].to_i).to eq(public_poll.id) + expect(message.data[:user][:id].to_i).to eq(user.id) + + xhr :put, :vote, body.merge(options: ["e89dec30bbd9bf50fabf6a05b4324edf"]) + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json["poll"]["voters"]).to eq(1) + expect(json["poll"]["options"][0]["votes"]).to eq(0) + expect(json["poll"]["options"][1]["votes"]).to eq(1) + expect(json["poll"]["options"][0]["voter_ids"]).to eq([]) + expect(json["poll"]["options"][1]["voter_ids"]).to eq([user.id]) + + another_user = Fabricate(:user) + log_in_user(another_user) + + xhr :put, :vote, body.merge(options: ["e89dec30bbd9bf50fabf6a05b4324edf", "5c24fc1df56d764b550ceae1b9319125"]) + expect(response).to be_success + + json = ::JSON.parse(response.body) + expect(json["poll"]["voters"]).to eq(2) + expect(json["poll"]["options"][0]["votes"]).to eq(1) + expect(json["poll"]["options"][1]["votes"]).to eq(2) + expect(json["poll"]["options"][0]["voter_ids"]).to eq([another_user.id]) + expect(json["poll"]["options"][1]["voter_ids"]).to eq([user.id, another_user.id]) + end end describe "#toggle_status" do diff --git a/plugins/poll/spec/lib/polls_updater_spec.rb b/plugins/poll/spec/lib/polls_updater_spec.rb index 125d5884581..d2b89c0911a 100644 --- a/plugins/poll/spec/lib/polls_updater_spec.rb +++ b/plugins/poll/spec/lib/polls_updater_spec.rb @@ -89,6 +89,69 @@ describe DiscoursePoll::PollsUpdater do end end + context "public polls" do + let(:post) do + raw = <<-RAW.strip_heredoc + [poll public=true] + - A + - B + [/poll] + RAW + + Fabricate(:post, raw: raw) + end + + let(:private_poll) do + raw = <<-RAW.strip_heredoc + [poll] + - A + - B + [/poll] + RAW + + DiscoursePoll::PollsValidator.new(Fabricate(:post, raw: raw)).validate_polls + end + + let(:public_poll) do + raw = <<-RAW.strip_heredoc + [poll public=true] + - A + - C + [/poll] + RAW + + DiscoursePoll::PollsValidator.new(Fabricate(:post, raw: raw)).validate_polls + end + + let(:user) { Fabricate(:user) } + + before do + DiscoursePoll::Poll.vote(post.id, "poll", ["5c24fc1df56d764b550ceae1b9319125"], user.id) + post.reload + end + + it "should retain voter_ids when options have been edited" do + described_class.update(post, public_poll) + + polls = post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] + + expect(polls["poll"]["options"][0]["voter_ids"]).to eq([user.id]) + expect(polls["poll"]["options"][1]["voter_ids"]).to eq([]) + end + + it "should delete voter_ids when poll is set to private" do + described_class.update(post, private_poll) + + polls = post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] + + expect(post.reload.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD]) + .to eq(private_poll) + + expect(polls["poll"]["options"][0]["voter_ids"]).to eq(nil) + expect(polls["poll"]["options"][1]["voter_ids"]).to eq(nil) + end + end + context "polls of type 'multiple'" do let(:min_2_post) do raw = <<-RAW.strip_heredoc