Merge pull request #4254 from tgxworld/public_polls

FEATURE: Add public type to polls.
This commit is contained in:
Guo Xiang Tan 2016-06-08 16:20:36 +08:00
commit e82e72568e
17 changed files with 322 additions and 30 deletions

View File

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

View File

@ -1,23 +1,27 @@
import round from "discourse/lib/round"; import round from "discourse/lib/round";
import computed from 'ember-addons/ember-computed-decorators';
export default Em.Component.extend({ export default Em.Component.extend({
tagName: "span", tagName: "span",
totalScore: function() { @computed("poll.options.@each.{html,votes}")
totalScore() {
return _.reduce(this.get("poll.options"), function(total, o) { return _.reduce(this.get("poll.options"), function(total, o) {
const value = parseInt(o.get("html"), 10), const value = parseInt(o.get("html"), 10),
votes = parseInt(o.get("votes"), 10); votes = parseInt(o.get("votes"), 10);
return total + value * votes; return total + value * votes;
}, 0); }, 0);
}.property("poll.options.@each.{html,votes}"), },
average: function() { @computed("totalScore", "poll.voters")
average() {
const voters = this.get("poll.voters"); const voters = this.get("poll.voters");
return voters === 0 ? 0 : round(this.get("totalScore") / voters, -2); 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") }); return I18n.t("poll.average_rating", { average: this.get("average") });
}.property("average"), },
}); });

View File

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

View File

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

View File

@ -6,6 +6,7 @@ export default Ember.Controller.extend({
isNumber: Ember.computed.equal("poll.type", "number"), isNumber: Ember.computed.equal("poll.type", "number"),
isRandom : Ember.computed.equal("poll.order", "random"), isRandom : Ember.computed.equal("poll.order", "random"),
isClosed: Ember.computed.equal("poll.status", "closed"), isClosed: Ember.computed.equal("poll.status", "closed"),
pollsVoters: Ember.computed.alias("post.polls_voters"),
// shows the results when // shows the results when
// - poll is closed // - poll is closed
@ -145,8 +146,16 @@ export default Ember.Controller.extend({
options: this.get("selectedOptions"), options: this.get("selectedOptions"),
} }
}).then(results => { }).then(results => {
this.setProperties({ vote: results.vote, showResults: true }); const poll = results.poll;
this.set("model", Em.Object.create(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(() => { }).catch(() => {
bootbox.alert(I18n.t("poll.error_while_casting_votes")); bootbox.alert(I18n.t("poll.error_while_casting_votes"));
}).finally(() => { }).finally(() => {

View File

@ -1 +1,5 @@
{{{averageRating}}} {{{averageRating}}}
{{#if poll.public}}
{{poll-results-number-voters poll=poll pollsVoters=pollsVoters}}
{{/if}}

View File

@ -9,5 +9,9 @@
<div class="bar-back"> <div class="bar-back">
<div class="bar" style={{option.style}}></div> <div class="bar" style={{option.style}}></div>
</div> </div>
{{#if poll.public}}
{{poll-results-standard-voters option=option pollsVoters=pollsVoters}}
{{/if}}
</li> </li>
{{/each}} {{/each}}

View File

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

View File

@ -2,9 +2,9 @@
<div class="poll-container"> <div class="poll-container">
{{#if showingResults}} {{#if showingResults}}
{{#if isNumber}} {{#if isNumber}}
{{poll-results-number poll=poll}} {{poll-results-number poll=poll pollsVoters=pollsVoters}}
{{else}} {{else}}
{{poll-results-standard poll=poll}} {{poll-results-standard poll=poll pollsVoters=pollsVoters}}
{{/if}} {{/if}}
{{else}} {{else}}
<ul> <ul>

View File

@ -1,11 +1,17 @@
import { withPluginApi } from 'discourse/lib/plugin-api'; 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 controller = container.lookup("controller:poll", { singleton: false });
const view = container.lookup("view:poll"); const view = container.lookup("view:poll");
controller.set("vote", vote); controller.setProperties({
controller.setProperties({ model: poll, post }); model: poll,
vote: vote,
public: publicPoll,
post
});
view.set("controller", controller); view.set("controller", controller);
return view; return view;
@ -23,6 +29,10 @@ function initializePolls(api) {
const post = this.get('model.postStream').findLoadedPost(msg.post_id); const post = this.get('model.postStream').findLoadedPost(msg.post_id);
if (post) { if (post) {
post.set('polls', msg.polls); 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, pollsObject: null,
// we need a proper ember object so it is bindable // we need a proper ember object so it is bindable
pollsChanged: function(){ @observes("polls")
pollsChanged() {
const polls = this.get("polls"); const polls = this.get("polls");
if (polls) { if (polls) {
this._polls = this._polls || {}; this._polls = this._polls || {};
@ -52,7 +63,7 @@ function initializePolls(api) {
}); });
this.set("pollsObject", this._polls); this.set("pollsObject", this._polls);
} }
}.observes("polls") }
}); });
function cleanUpPollViews() { function cleanUpPollViews() {
@ -69,6 +80,7 @@ function initializePolls(api) {
const post = helper.getModel(); const post = helper.getModel();
api.preventCloak(post.id); api.preventCloak(post.id);
const votes = post.get('polls_votes') || {}; const votes = post.get('polls_votes') || {};
post.set("polls_voters", (post.get("polls_voters") || {}));
post.pollsChanged(); post.pollsChanged();
@ -82,8 +94,16 @@ function initializePolls(api) {
const $poll = $(pollElem); const $poll = $(pollElem);
const pollName = $poll.data("poll-name"); const pollName = $poll.data("poll-name");
const publicPoll = $poll.data("poll-public");
const pollId = `${pollName}-${post.id}`; 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); $poll.replaceWith($div);
Em.run.next(() => pollView.renderer.replaceIn(pollView, $div[0])); Em.run.next(() => pollView.renderer.replaceIn(pollView, $div[0]));

View File

@ -5,7 +5,7 @@
var DATA_PREFIX = "data-poll-"; var DATA_PREFIX = "data-poll-";
var DEFAULT_POLL_NAME = "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"); var ATTRIBUTES_REGEX = new RegExp("(" + WHITELISTED_ATTRIBUTES.join("|") + ")=['\"]?[^\\s\\]]+['\"]?", "g");

View File

@ -3,17 +3,12 @@ import { on } from "ember-addons/ember-computed-decorators";
export default Em.View.extend({ export default Em.View.extend({
templateName: "poll", templateName: "poll",
classNames: ["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"), poll: Em.computed.alias("controller.poll"),
"data-poll-type": Em.computed.alias("poll.type"), "data-poll-type": Em.computed.alias("poll.type"),
"data-poll-name": Em.computed.alias("poll.name"), "data-poll-name": Em.computed.alias("poll.name"),
"data-poll-status": Em.computed.alias("poll.status"), "data-poll-status": Em.computed.alias("poll.status"),
"data-poll-public": Em.computed.alias("poll.public")
@on("didInsertElement")
_fixPollContainerHeight() {
const pollContainer = this.$(".poll-container");
pollContainer.height(pollContainer.height());
}
}); });

View File

@ -92,6 +92,18 @@ div.poll {
} }
} }
.poll-voters-list {
li {
display: inline;
}
margin: 5px 0;
}
.poll-voters-toggle-expand {
text-align: center;
}
.results { .results {
.option { .option {
@ -120,9 +132,11 @@ div.poll {
&[data-poll-type="number"] { &[data-poll-type="number"] {
li { li[data-poll-option-id] {
display: inline-block; display: inline-block;
margin: 0 12px 15px 5px; text-align: center;
width: 25px;
margin-right: 5px;
} }
} }

View File

@ -1,6 +1,6 @@
module DiscoursePoll module DiscoursePoll
class PollsUpdater 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) def self.update(post, polls)
# load previous 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") 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"] previous_options = previous_polls[poll_name]["options"]
public_poll = polls[poll_name]["public"] == "true"
polls[poll_name]["options"].each_with_index do |option, index| polls[poll_name]["options"].each_with_index do |option, index|
previous_option = previous_options[index] previous_option = previous_options[index]
option["votes"] = previous_option["votes"] option["votes"] = previous_option["votes"]
option["anonymous_votes"] = previous_option["anonymous_votes"] if previous_option.has_key?("anonymous_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
end end

View File

@ -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.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" 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 # remove options that aren't available in the poll
available_options = poll["options"].map { |o| o["id"] }.to_set available_options = poll["options"].map { |o| o["id"] }.to_set
@ -80,12 +81,30 @@ after_initialize do
poll["options"].each do |option| poll["options"].each do |option|
anonymous_votes = option["anonymous_votes"] || 0 anonymous_votes = option["anonymous_votes"] || 0
option["votes"] = all_options[option["id"]] + anonymous_votes 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 end
post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls post.custom_fields[DiscoursePoll::POLLS_CUSTOM_FIELD] = polls
post.save_custom_fields(true) 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] return [poll, options]
end end
@ -195,7 +214,6 @@ after_initialize do
render_json_error e.message render_json_error e.message
end end
end end
end end
DiscoursePoll::Engine.routes.draw do 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, :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, :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 add_to_serializer(:post, :include_polls_votes?) do
return unless scope.user return unless scope.user
return unless post_custom_fields.present? return unless post_custom_fields.present?
return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present? return unless post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].present?
post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}") post_custom_fields[DiscoursePoll::VOTES_CUSTOM_FIELD].has_key?("#{scope.user.id}")
end 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 end

View File

@ -98,6 +98,49 @@ describe ::DiscoursePoll::PollsController do
expect(json["poll"]["options"][0]["votes"]).to eq(12) expect(json["poll"]["options"][0]["votes"]).to eq(12)
expect(json["poll"]["options"][1]["votes"]).to eq(6) expect(json["poll"]["options"][1]["votes"]).to eq(6)
end 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 end
describe "#toggle_status" do describe "#toggle_status" do

View File

@ -89,6 +89,69 @@ describe DiscoursePoll::PollsUpdater do
end end
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 context "polls of type 'multiple'" do
let(:min_2_post) do let(:min_2_post) do
raw = <<-RAW.strip_heredoc raw = <<-RAW.strip_heredoc