diff --git a/app/assets/javascripts/discourse/components/mount-widget.js.es6 b/app/assets/javascripts/discourse/components/mount-widget.js.es6
index 3a4fb8bce5a..3c56961ff8c 100644
--- a/app/assets/javascripts/discourse/components/mount-widget.js.es6
+++ b/app/assets/javascripts/discourse/components/mount-widget.js.es6
@@ -110,6 +110,7 @@ export default Ember.Component.extend({
const opts = { model: this.get('model') };
const newTree = new this._widgetClass(args, this.register, opts);
+ newTree._rerenderable = this;
newTree._emberView = this;
const patches = diff(this._tree || this._rootNode, newTree);
diff --git a/app/assets/javascripts/discourse/widgets/glue.js.es6 b/app/assets/javascripts/discourse/widgets/glue.js.es6
new file mode 100644
index 00000000000..6351bc0710b
--- /dev/null
+++ b/app/assets/javascripts/discourse/widgets/glue.js.es6
@@ -0,0 +1,43 @@
+import { diff, patch } from 'virtual-dom';
+import { queryRegistry } from 'discourse/widgets/widget';
+
+export default class WidgetGlue {
+
+ constructor(name, register, attrs) {
+ this._tree = null;
+ this._rootNode = null;
+ this.register = register;
+ this.attrs = attrs;
+ this._timeout = null;
+
+ this._widgetClass = queryRegistry(name) || this.register.lookupFactory(`widget:${name}`);
+ if (!this._widgetClass) {
+ console.error(`Error: Could not find widget: ${name}`);
+ }
+ }
+
+ appendTo(elem) {
+ this._rootNode = elem;
+ this.queueRerender();
+ }
+
+ queueRerender() {
+ this._timeout = Ember.run.scheduleOnce('render', this, this.rerenderWidget);
+ }
+
+ rerenderWidget() {
+ Ember.run.cancel(this._timeout);
+ const newTree = new this._widgetClass(this.attrs, this.register);
+ const patches = diff(this._tree || this._rootNode, newTree);
+
+ newTree._rerenderable = this;
+ this._rootNode = patch(this._rootNode, patches);
+ this._tree = newTree;
+ }
+
+ cleanUp() {
+ Ember.run.cancel(this._timeout);
+ }
+}
+
+
diff --git a/app/assets/javascripts/discourse/widgets/widget.js.es6 b/app/assets/javascripts/discourse/widgets/widget.js.es6
index 1b6b436e807..8cf85e2555f 100644
--- a/app/assets/javascripts/discourse/widgets/widget.js.es6
+++ b/app/assets/javascripts/discourse/widgets/widget.js.es6
@@ -246,10 +246,11 @@ export default class Widget {
keyDirty(widget.key);
}
- const emberView = widget._emberView;
- if (emberView) {
- return emberView.queueRerender();
+ const rerenderable = widget._rerenderable;
+ if (rerenderable) {
+ return rerenderable.queueRerender();
}
+
widget = widget.parentWidget;
}
}
diff --git a/plugins/poll/assets/javascripts/components/discourse-poll.js.es6 b/plugins/poll/assets/javascripts/components/discourse-poll.js.es6
deleted file mode 100644
index 7b3cae9dceb..00000000000
--- a/plugins/poll/assets/javascripts/components/discourse-poll.js.es6
+++ /dev/null
@@ -1,213 +0,0 @@
-import { default as computed, observes } from "ember-addons/ember-computed-decorators";
-import { ajax } from 'discourse/lib/ajax';
-
-export default Ember.Component.extend({
- layoutName: 'components/discourse-poll',
- classNames: ["poll"],
- attributeBindings: ["data-poll-type", "data-poll-name", "data-poll-status", "data-poll-public"],
-
- "data-poll-type": Ember.computed.alias("poll.type"),
- "data-poll-name": Ember.computed.alias("poll.name"),
- "data-poll-status": Ember.computed.alias("poll.status"),
- "data-poll-public": Ember.computed.alias("poll.public"),
-
- isMultiple: Ember.computed.equal("poll.type", "multiple"),
- isNumber: Ember.computed.equal("poll.type", "number"),
- isClosed: Ember.computed.equal("poll.status", "closed"),
- isPublic: Ember.computed.equal("poll.public", "true"),
-
- // shows the results when
- // - poll is closed
- // - topic is archived
- // - user wants to see the results
- showingResults: Ember.computed.or("isClosed", "post.topic.archived", "showResults"),
-
- showResultsDisabled: Ember.computed.equal("poll.voters", 0),
- hideResultsDisabled: Ember.computed.or("isClosed", "post.topic.archived"),
-
- @observes("post.polls")
- _updatePoll() {
- this.set("model", this.get("post.pollsObject")[this.get("model.name")]);
- },
-
- @computed("model", "vote", "model.voters", "model.options", "model.status")
- poll(poll, 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;
- },
-
- @computed("poll.options.@each.selected")
- selectedOptions() {
- return _.map(this.get("poll.options").filterBy("selected"), o => o.get("id"));
- },
-
- @computed("poll.min")
- min(min) {
- min = parseInt(min, 10);
- if (isNaN(min) || min < 1) { min = 1; }
- return min;
- },
-
- @computed("poll.max", "poll.options.length")
- max(max, options) {
- max = parseInt(max, 10);
- if (isNaN(max) || max > options) { max = options; }
- return max;
- },
-
- @computed("poll.voters")
- votersText(count) {
- return I18n.t("poll.voters", { count });
- },
-
- @computed("poll.options.@each.votes")
- totalVotes() {
- return _.reduce(this.get("poll.options"), function(total, o) {
- return total + parseInt(o.get("votes"), 10);
- }, 0);
- },
-
- @computed("totalVotes")
- totalVotesText(count) {
- return I18n.t("poll.total_votes", { count });
- },
-
- @computed("min", "max", "poll.options.length")
- multipleHelpText(min, max, options) {
- 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, 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 });
- }
- }
- },
-
- @computed("isClosed", "showResults", "loading", "isMultiple", "selectedOptions.length", "min", "max")
- canCastVotes(isClosed, showResults, loading, isMultiple, selectedOptionCount, min, max) {
- if (isClosed || showResults || loading) {
- return false;
- }
-
- if (isMultiple) {
- return selectedOptionCount >= min && selectedOptionCount <= max;
- } else {
- return selectedOptionCount > 0;
- }
- },
-
- castVotesDisabled: Em.computed.not("canCastVotes"),
-
- @computed("castVotesDisabled")
- castVotesButtonClass(castVotesDisabled) {
- return `cast-votes ${castVotesDisabled ? '' : 'btn-primary'}`;
- },
-
- @computed("loading", "post.user_id", "post.topic.archived")
- canToggleStatus(loading, userId, topicArchived) {
- return this.currentUser &&
- (this.currentUser.get("id") === userId || this.currentUser.get("staff")) &&
- !loading &&
- !topicArchived;
- },
-
- actions: {
-
- toggleOption(option) {
- if (this.get("isClosed")) { return; }
- if (!this.currentUser) { return this.send("showLogin"); }
-
- const wasSelected = option.get("selected");
-
- if (!this.get("isMultiple")) {
- this.get("poll.options").forEach(o => o.set("selected", false));
- }
-
- option.toggleProperty("selected");
-
- if (!this.get("isMultiple") && !wasSelected) { this.send("castVotes"); }
- },
-
- castVotes() {
- if (!this.get("canCastVotes")) { return; }
- if (!this.currentUser) { return this.send("showLogin"); }
-
- this.set("loading", true);
-
- ajax("/polls/vote", {
- type: "PUT",
- data: {
- post_id: this.get("post.id"),
- poll_name: this.get("poll.name"),
- options: this.get("selectedOptions"),
- }
- }).then(results => {
- const poll = results.poll;
- const votes = results.vote;
-
- this.setProperties({ vote: votes, showResults: true });
- this.set("model", Em.Object.create(poll));
- }).catch(() => {
- bootbox.alert(I18n.t("poll.error_while_casting_votes"));
- }).finally(() => {
- this.set("loading", false);
- });
- },
-
- toggleResults() {
- this.toggleProperty("showResults");
- },
-
- toggleStatus() {
- if (!this.get("canToggleStatus")) { return; }
-
- const self = this,
- confirm = this.get("isClosed") ? "poll.open.confirm" : "poll.close.confirm";
-
- bootbox.confirm(
- I18n.t(confirm),
- I18n.t("no_value"),
- I18n.t("yes_value"),
- function(confirmed) {
- if (confirmed) {
- self.set("loading", true);
-
- 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(results => {
- self.set("model", Em.Object.create(results.poll));
- }).catch(() => {
- bootbox.alert(I18n.t("poll.error_while_toggling_status"));
- }).finally(() => {
- self.set("loading", false);
- });
- }
- }
- );
-
- },
- }
-
-});
diff --git a/plugins/poll/assets/javascripts/components/poll-option.js.es6 b/plugins/poll/assets/javascripts/components/poll-option.js.es6
deleted file mode 100644
index c31e7f5f277..00000000000
--- a/plugins/poll/assets/javascripts/components/poll-option.js.es6
+++ /dev/null
@@ -1,25 +0,0 @@
-import computed from 'ember-addons/ember-computed-decorators';
-import { iconHTML } from 'discourse-common/helpers/fa-icon';
-
-export default Em.Component.extend({
- tagName: "li",
- attributeBindings: ["data-poll-option-id"],
-
- "data-poll-option-id": Em.computed.alias("option.id"),
-
- @computed("option.selected", "isMultiple")
- optionIcon(selected, isMultiple) {
- if (isMultiple) {
- return iconHTML(selected ? 'check-square-o' : 'square-o');
- } else {
- return iconHTML(selected ? 'dot-circle-o' : 'circle-o');
- }
- },
-
- click(e) {
- // ensure we're not clicking on a link
- if ($(e.target).closest("a").length === 0) {
- this.sendAction("toggle", this.get("option"));
- }
- }
-});
diff --git a/plugins/poll/assets/javascripts/components/poll-results-number-voters.js.es6 b/plugins/poll/assets/javascripts/components/poll-results-number-voters.js.es6
deleted file mode 100644
index 412cc8acd20..00000000000
--- a/plugins/poll/assets/javascripts/components/poll-results-number-voters.js.es6
+++ /dev/null
@@ -1,15 +0,0 @@
-import computed from 'ember-addons/ember-computed-decorators';
-import PollVoters from 'discourse/plugins/poll/components/poll-voters';
-
-export default PollVoters.extend({
- @computed("poll.voters", "pollsVoters")
- canLoadMore(voters, pollsVoters) {
- return pollsVoters.length < voters;
- },
-
- @computed("poll.options", "offset")
- voterIds(options) {
- const ids = [].concat(...(options.map(option => option.voter_ids)));
- return this._getIds(ids);
- }
-});
diff --git a/plugins/poll/assets/javascripts/components/poll-results-number.js.es6 b/plugins/poll/assets/javascripts/components/poll-results-number.js.es6
deleted file mode 100644
index bbaf1813bc1..00000000000
--- a/plugins/poll/assets/javascripts/components/poll-results-number.js.es6
+++ /dev/null
@@ -1,25 +0,0 @@
-import round from "discourse/lib/round";
-import computed from 'ember-addons/ember-computed-decorators';
-
-export default Em.Component.extend({
- @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);
- },
-
- @computed("totalScore", "poll.voters")
- average() {
- const voters = this.get("poll.voters");
- return voters === 0 ? 0 : round(this.get("totalScore") / voters, -2);
- },
-
- @computed("average")
- averageRating() {
- return I18n.t("poll.average_rating", { average: this.get("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
deleted file mode 100644
index 50332f4aeff..00000000000
--- a/plugins/poll/assets/javascripts/components/poll-results-standard-voters.js.es6
+++ /dev/null
@@ -1,14 +0,0 @@
-import computed from 'ember-addons/ember-computed-decorators';
-import PollVoters from 'discourse/plugins/poll/components/poll-voters';
-
-export default PollVoters.extend({
- @computed("option.votes", "pollsVoters")
- canLoadMore(voters, pollsVoters) {
- return pollsVoters.length < voters;
- },
-
- @computed("option.voter_ids", "offset")
- voterIds(ids) {
- return this._getIds(ids);
- }
-});
diff --git a/plugins/poll/assets/javascripts/components/poll-results-standard.js.es6 b/plugins/poll/assets/javascripts/components/poll-results-standard.js.es6
deleted file mode 100644
index 588a9d94c57..00000000000
--- a/plugins/poll/assets/javascripts/components/poll-results-standard.js.es6
+++ /dev/null
@@ -1,40 +0,0 @@
-import evenRound from "discourse/plugins/poll/lib/even-round";
-import computed from "ember-addons/ember-computed-decorators";
-
-export default Em.Component.extend({
- tagName: "ul",
- classNames: ["results"],
-
- @computed("poll.voters", "poll.type", "poll.options.[]")
- options(voters, type) {
- const options = this.get("poll.options").slice(0).sort((a, b) => {
- return b.get("votes") - a.get("votes");
- });
-
- let percentages = voters === 0 ?
- Array(options.length).fill(0) :
- _.map(options, o => 100 * o.get("votes") / voters);
-
- // properly round percentages
- if (type === "multiple") {
- // when the poll is multiple choices, just "round down"
- percentages = percentages.map(p => Math.floor(p));
- } else {
- // when the poll is single choice, adds up to 100%
- percentages = evenRound(percentages);
- }
-
- options.forEach((option, i) => {
- const percentage = percentages[i];
- const style = new Handlebars.SafeString(`width: ${percentage}%`);
-
- option.setProperties({
- percentage,
- style,
- title: I18n.t("poll.option_title", { count: option.get("votes") })
- });
- });
-
- return options;
- }
-});
diff --git a/plugins/poll/assets/javascripts/components/poll-voters.js.es6 b/plugins/poll/assets/javascripts/components/poll-voters.js.es6
deleted file mode 100644
index ef756e20acb..00000000000
--- a/plugins/poll/assets/javascripts/components/poll-voters.js.es6
+++ /dev/null
@@ -1,54 +0,0 @@
-import { ajax } from 'discourse/lib/ajax';
-export default Ember.Component.extend({
- layoutName: "components/poll-voters",
- tagName: 'ul',
- classNames: ["poll-voters-list"],
- isExpanded: false,
- numOfVotersToShow: 0,
- offset: 0,
- loading: false,
- pollsVoters: null,
-
- init() {
- this._super();
- this.set("pollsVoters", []);
- },
-
- _fetchUsers() {
- this.set("loading", true);
-
- ajax("/polls/voters.json", {
- type: "get",
- data: { user_ids: this.get("voterIds") }
- }).then(result => {
- if (this.isDestroyed) return;
- this.set("pollsVoters", this.get("pollsVoters").concat(result.users));
- this.incrementProperty("offset");
- this.set("loading", false);
- }).catch((error) => {
- Ember.logger.log(error);
- bootbox.alert(I18n.t('poll.error_while_fetching_voters'));
- });
- },
-
- _getIds(ids) {
- const numOfVotersToShow = this.get("numOfVotersToShow");
- const offset = this.get("offset");
- return ids.slice(numOfVotersToShow * offset, numOfVotersToShow * (offset + 1));
- },
-
- didInsertElement() {
- this._super();
-
- Ember.run.scheduleOnce('afterRender', () => {
- this.set("numOfVotersToShow", Math.round(this.$().width() / 25) * 2);
- if (this.get("voterIds").length > 0) this._fetchUsers();
- });
- },
-
- actions: {
- loadMore() {
- this._fetchUsers();
- }
- }
-});
diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/discourse-poll.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/discourse-poll.hbs
deleted file mode 100644
index a5355def1e6..00000000000
--- a/plugins/poll/assets/javascripts/discourse/templates/components/discourse-poll.hbs
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
- {{#if showingResults}}
- {{#if isNumber}}
- {{poll-results-number isPublic=isPublic poll=poll}}
- {{else}}
- {{poll-results-standard isPublic=isPublic poll=poll}}
- {{/if}}
- {{else}}
-
- {{#each poll.options as |option|}}
- {{poll-option option=option toggle="toggleOption" isMultiple=isMultiple}}
- {{/each}}
-
- {{/if}}
-
-
-
- {{poll.voters}}
- {{votersText}}
-
- {{#if isMultiple}}
- {{#if showingResults}}
-
- {{totalVotes}}
- {{totalVotesText}}
-
- {{else}}
-
{{{multipleHelpText}}}
- {{/if}}
- {{/if}}
-
- {{#if isPublic}}
- {{#unless showingResults}}
-
{{i18n "poll.public.title"}}
- {{/unless}}
- {{/if}}
-
-
-
-
- {{#if isMultiple}}
- {{#unless hideResultsDisabled}}
- {{d-button class=castVotesButtonClass title="poll.cast-votes.title" label="poll.cast-votes.label" disabled=castVotesDisabled action="castVotes"}}
- {{/unless}}
- {{/if}}
-
- {{#if showingResults}}
- {{d-button class="toggle-results" title="poll.hide-results.title" label="poll.hide-results.label" icon="eye-slash" disabled=hideResultsDisabled action="toggleResults"}}
- {{else}}
- {{d-button class="toggle-results" title="poll.show-results.title" label="poll.show-results.label" icon="eye" disabled=showResultsDisabled action="toggleResults"}}
- {{/if}}
-
- {{#if canToggleStatus}}
- {{#if isClosed}}
- {{d-button class="toggle-status" title="poll.open.title" label="poll.open.label" icon="unlock-alt" action="toggleStatus"}}
- {{else}}
- {{d-button class="toggle-status btn-danger" title="poll.close.title" label="poll.close.label" icon="lock" action="toggleStatus"}}
- {{/if}}
- {{/if}}
-
diff --git a/plugins/poll/assets/javascripts/discourse/templates/components/poll-option.hbs b/plugins/poll/assets/javascripts/discourse/templates/components/poll-option.hbs
deleted file mode 100644
index 091a898b61d..00000000000
--- a/plugins/poll/assets/javascripts/discourse/templates/components/poll-option.hbs
+++ /dev/null
@@ -1,2 +0,0 @@
-{{{optionIcon}}}
-{{{option.html}}}
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
deleted file mode 100644
index ace81e9979f..00000000000
--- a/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-number.hbs
+++ /dev/null
@@ -1,7 +0,0 @@
-
- {{{averageRating}}}
-
-
-{{#if isPublic}}
- {{poll-results-number-voters poll=poll}}
-{{/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
deleted file mode 100644
index d5180480874..00000000000
--- a/plugins/poll/assets/javascripts/discourse/templates/components/poll-results-standard.hbs
+++ /dev/null
@@ -1,17 +0,0 @@
-{{#each options as |option|}}
-
-
-
- {{option.percentage}}%
- {{{option.html}}}
-
-
-
-
- {{#if isPublic}}
- {{poll-results-standard-voters option=option}}
- {{/if}}
-
-{{/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
deleted file mode 100644
index 53f6fcdc7b5..00000000000
--- a/plugins/poll/assets/javascripts/discourse/templates/components/poll-voters.hbs
+++ /dev/null
@@ -1,17 +0,0 @@
-
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 77a25aaf03c..09b8fdfa319 100644
--- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
+++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
@@ -1,15 +1,10 @@
import { withPluginApi } from 'discourse/lib/plugin-api';
import { observes } from "ember-addons/ember-computed-decorators";
-
-function createPollComponent(register, post, poll, vote) {
- const component = register.lookup("component:discourse-poll");
- component.setProperties({ model: poll, vote, post });
- return component;
-}
-
-let _pollViews;
+import { getRegister } from 'discourse-common/lib/get-owner';
+import WidgetGlue from 'discourse/widgets/glue';
function initializePolls(api) {
+ const register = getRegister(api);
const TopicController = api.container.lookupFactory('controller:topic');
TopicController.reopen({
@@ -52,14 +47,8 @@ function initializePolls(api) {
}
});
- function cleanUpPollViews() {
- if (_pollViews) {
- Object.keys(_pollViews).forEach(pollName => _pollViews[pollName].destroy());
- }
- _pollViews = null;
- }
-
- function createPollViews($elem, helper) {
+ const _glued = [];
+ function attachPolls($elem, helper) {
const $polls = $('.poll', $elem);
if (!$polls.length) { return; }
@@ -72,41 +61,33 @@ function initializePolls(api) {
const polls = post.get("pollsObject");
if (!polls) { return; }
- const postPollViews = {};
-
$polls.each((idx, pollElem) => {
- const $div = $("");
const $poll = $(pollElem);
-
const pollName = $poll.data("poll-name");
- const pollId = `${pollName}-${post.id}`;
+ const poll = polls[pollName];
+ if (poll) {
+ const isMultiple = poll.get('type') === 'multiple';
- const pollComponent = createPollComponent(
- helper.register,
- post,
- polls[pollName],
- votes[pollName]
- );
-
- // Destroy a poll view if we're replacing it
- if (_pollViews && _pollViews[pollId]) {
- _pollViews[pollId].destroy();
+ const glue = new WidgetGlue('discourse-poll', register, {
+ id: `${pollName}-${post.id}`,
+ post,
+ poll,
+ vote: votes[pollName] || [],
+ isMultiple,
+ });
+ glue.appendTo(pollElem);
+ _glued.push(glue);
}
-
- $poll.replaceWith($div);
- Ember.run.scheduleOnce('afterRender', () => {
- pollComponent.renderer.appendTo(pollComponent, $div[0]);
- });
-
- postPollViews[pollId] = pollComponent;
});
+ }
- _pollViews = postPollViews;
+ function cleanUpPolls() {
+ _glued.forEach(g => g.cleanUp());
}
api.includePostAttributes("polls", "polls_votes");
- api.decorateCooked(createPollViews, { onlyStream: true });
- api.cleanupStream(cleanUpPollViews);
+ api.decorateCooked(attachPolls, { onlyStream: true });
+ api.cleanupStream(cleanUpPolls);
}
export default {
diff --git a/plugins/poll/assets/javascripts/lib/even-round.js.es6 b/plugins/poll/assets/javascripts/lib/even-round.js.es6
index 5446c874c58..12806e2611f 100644
--- a/plugins/poll/assets/javascripts/lib/even-round.js.es6
+++ b/plugins/poll/assets/javascripts/lib/even-round.js.es6
@@ -3,7 +3,7 @@ function sumsUpTo100(percentages) {
return percentages.map(p => Math.floor(p)).reduce((a, b) => a + b) === 100;
}
-export default (percentages) => {
+export default function(percentages) {
var decimals = percentages.map(a => a % 1);
const sumOfDecimals = Math.ceil(decimals.reduce((a, b) => a + b));
// compensate error by adding 1 to n items with the greatest decimal part
diff --git a/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6 b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6
new file mode 100644
index 00000000000..1f2fe22c323
--- /dev/null
+++ b/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6
@@ -0,0 +1,504 @@
+import { createWidget } from 'discourse/widgets/widget';
+import { h } from 'virtual-dom';
+import { iconNode } from 'discourse/helpers/fa-icon-node';
+import RawHtml from 'discourse/widgets/raw-html';
+import { ajax } from 'discourse/lib/ajax';
+import evenRound from "discourse/plugins/poll/lib/even-round";
+import { avatarFor } from 'discourse/widgets/post';
+import round from "discourse/lib/round";
+
+function optionHtml(option) {
+ return new RawHtml({ html: `${option.html}` });
+}
+
+createWidget('discourse-poll-option', {
+ tagName: 'li',
+
+ buildAttributes(attrs) {
+ return { 'data-poll-option-id': attrs.option.id };
+ },
+
+ html(attrs) {
+ const result = [];
+
+ const { option, vote } = attrs;
+ const chosen = vote.indexOf(option.id) !== -1;
+
+ if (attrs.isMultiple) {
+ result.push(iconNode(chosen ? 'check-square-o' : 'square-o'));
+ } else {
+ result.push(iconNode(chosen ? 'dot-circle-o' : 'circle-o'));
+ }
+ result.push(' ');
+ result.push(optionHtml(option));
+ return result;
+ },
+
+ click(e) {
+ if ($(e.target).closest("a").length === 0) {
+ this.sendWidgetAction('toggleOption', this.attrs.option);
+ }
+ }
+});
+
+createWidget('discourse-poll-load-more', {
+ tagName: 'div.poll-voters-toggle-expand',
+ buildKey: attrs => `${attrs.id}-load-more`,
+
+ defaultState() {
+ return { loading: false };
+ },
+
+ html(attrs, state) {
+ return state.loading ? h('div.spinner.small') : h('a', iconNode('chevron-down'));
+ },
+
+ click() {
+ const { state } = this;
+ if (state.loading) { return; }
+
+ state.loading = true;
+ return this.sendWidgetAction('loadMore').finally(() => state.loading = false);
+ }
+
+});
+
+createWidget('discourse-poll-voters', {
+ tagName: 'ul.poll-voters-list',
+ buildKey: attrs => attrs.id(),
+
+ defaultState() {
+ return {
+ loaded: 'new',
+ pollVoters: [],
+ offset: 0,
+ canLoadMore: false
+ };
+ },
+
+ fetchVoters() {
+ const { attrs, state } = this;
+
+ const { voterIds } = attrs;
+ if (!voterIds.length) { return; }
+
+ const windowSize = Math.round(($('.poll-container:eq(0)').width() / 25) * 2);
+ const index = state.offset * windowSize;
+ const ids = voterIds.slice(index, index + windowSize);
+
+ state.loaded = 'loading';
+ return ajax("/polls/voters.json", {
+ type: "get",
+ data: { user_ids: ids }
+ }).then(result => {
+ state.loaded = 'loaded';
+ state.pollVoters = state.pollVoters.concat(result.users);
+ state.canLoadMore = state.pollVoters.length < attrs.totalVotes;
+ this.scheduleRerender();
+ }).catch(() => {
+ bootbox.alert(I18n.t('poll.error_while_fetching_voters'));
+ });
+ },
+
+ loadMore() {
+ this.state.offset += 1;
+ return this.fetchVoters();
+ },
+
+ html(attrs, state) {
+ if (state.loaded === 'new') {
+ this.fetchVoters();
+ return;
+ }
+
+ const contents = state.pollVoters.map(user => {
+ return h('li', [avatarFor('tiny', {
+ username: user.username,
+ template: user.avatar_template
+ }), ' ']);
+ });
+
+ if (state.canLoadMore) {
+ contents.push(this.attach('discourse-poll-load-more', { id: attrs.id() }));
+ }
+
+ return h('div.poll-voters', contents);
+ }
+
+});
+
+createWidget('discourse-poll-standard-results', {
+ tagName: 'ul.results',
+
+ html(attrs) {
+ const { poll } = attrs;
+ const options = poll.get('options');
+ if (options) {
+
+ const voters = poll.get('voters');
+ const ordered = options.sort((a, b) => b.votes - a.votes);
+
+ const percentages = voters === 0 ?
+ Array(ordered.length).fill(0) :
+ ordered.map(o => 100 * o.votes / voters);
+
+ const rounded = attrs.isMultiple ? percentages.map(Math.floor) : evenRound(percentages);
+
+ return ordered.map((option, idx) => {
+ const contents = [];
+
+ const per = rounded[idx].toString();
+ contents.push(h('div.option',
+ h('p', [ h('span.percentage', `${per}%`), optionHtml(option) ])
+ ));
+
+ contents.push(h('div.bar-back',
+ h('div.bar', { attributes: { style: `width:${per}%` }})
+ ));
+
+ if (poll.get('public')) {
+ contents.push(this.attach('discourse-poll-voters', {
+ id: () => `poll-voters-${option.id}`,
+ totalVotes: option.votes,
+ voterIds: option.voter_ids
+ }));
+ }
+
+ return h('li', contents);
+ });
+ }
+ }
+});
+
+createWidget('discourse-poll-number-results', {
+ html(attrs) {
+ const { poll } = attrs;
+
+ const totalScore = poll.get('options').reduce((total, o) => {
+ return total + parseInt(o.html, 10) * parseInt(o.votes, 10);
+ }, 0);
+
+ const voters = poll.voters;
+ const average = voters === 0 ? 0 : round(totalScore / voters, -2);
+ const averageRating = I18n.t("poll.average_rating", { average });
+ const results = [h('div.poll-results-number-rating',
+ new RawHtml({ html: `${averageRating}` }))];
+
+ if (poll.get('public')) {
+ const options = poll.get('options');
+ results.push(this.attach('discourse-poll-voters', {
+ id: () => `poll-voters-${poll.get('name')}`,
+ totalVotes: poll.get('voters'),
+ voterIds: [].concat(...(options.map(option => option.voter_ids)))
+ }));
+ }
+
+ return results;
+ }
+});
+
+createWidget('discourse-poll-container', {
+ tagName: 'div.poll-container',
+ html(attrs) {
+ const { poll } = attrs;
+
+ if (attrs.showResults) {
+ const type = poll.get('type') === 'number' ? 'number' : 'standard';
+ return this.attach(`discourse-poll-${type}-results`, attrs);
+ }
+
+ const options = poll.get('options');
+ if (options) {
+ return h('ul', options.map(option => {
+ return this.attach('discourse-poll-option', {
+ option,
+ isMultiple: attrs.isMultiple,
+ vote: attrs.vote
+ });
+ }));
+ }
+ }
+});
+
+createWidget('discourse-poll-info', {
+ tagName: 'div.poll-info',
+
+ multipleHelpText(min, max, options) {
+ 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, 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 });
+ }
+ }
+ },
+
+ html(attrs) {
+ const { poll } = attrs;
+ const count = poll.get('voters');
+ const result = [h('p', [
+ h('span.info-number', count.toString()),
+ h('span.info-text', I18n.t('poll.voters', { count }))
+ ])];
+
+ if (attrs.isMultiple) {
+ if (attrs.showResults) {
+ const totalVotes = poll.get('options').reduce((total, o) => {
+ return total + parseInt(o.votes, 10);
+ }, 0);
+
+ result.push(h('p', [
+ h('span.info-number', totalVotes.toString()),
+ h('span.info-text', I18n.t("poll.total_votes", { count: totalVotes }))
+ ]));
+ } else {
+ const help = this.multipleHelpText(attrs.min, attrs.max, poll.get('options.length'));
+ if (help) {
+ result.push(new RawHtml({ html: `${help}` }));
+ }
+ }
+ }
+
+ if (!attrs.showResults && attrs.poll.get('public')) {
+ result.push(h('p', I18n.t('poll.public.title')));
+ }
+
+ return result;
+ }
+});
+
+createWidget('discourse-poll-buttons', {
+ tagName: 'div.poll-buttons',
+
+ html(attrs) {
+ const results = [];
+ const { poll, post } = attrs;
+ const topicArchived = post.get('topic.archived');
+ const isClosed = poll.get('status') === 'closed';
+ const hideResultsDisabled = isClosed || topicArchived;
+
+ if (attrs.isMultiple && !hideResultsDisabled) {
+ const castVotesDisabled = !attrs.canCastVotes;
+ results.push(this.attach('button', {
+ className: `btn cast-votes ${castVotesDisabled ? '' : 'btn-primary'}`,
+ label: 'poll.cast-votes.label',
+ title: 'poll.cast-votes.title',
+ disabled: castVotesDisabled,
+ action: 'castVotes'
+ }));
+ results.push(' ');
+ }
+
+ if (attrs.showResults) {
+ results.push(this.attach('button', {
+ className: 'btn toggle-results',
+ label: 'poll.hide-results.label',
+ title: 'poll.hide-results.title',
+ icon: 'eye-slash',
+ disabled: hideResultsDisabled,
+ action: 'toggleResults'
+ }));
+ } else {
+ results.push(this.attach('button', {
+ className: 'btn toggle-results',
+ label: 'poll.show-results.label',
+ title: 'poll.show-results.title',
+ icon: 'eye',
+ disabled: poll.get('voters') === 0,
+ action: 'toggleResults'
+ }));
+ }
+
+ if (this.currentUser &&
+ (this.currentUser.get("id") === post.get('user_id') ||
+ this.currentUser.get("staff")) &&
+ !topicArchived) {
+
+ if (isClosed) {
+ results.push(this.attach('button', {
+ className: 'btn toggle-status',
+ label: 'poll.open.label',
+ title: 'poll.open.title',
+ icon: 'unlock-alt',
+ action: 'toggleStatus'
+ }));
+ } else {
+ results.push(this.attach('button', {
+ className: 'btn toggle-status btn-danger',
+ label: 'poll.close.label',
+ title: 'poll.close.title',
+ icon: 'lock',
+ action: 'toggleStatus'
+ }));
+ }
+ }
+
+
+ return results;
+ }
+});
+
+export default createWidget('discourse-poll', {
+ tagName: 'div.poll',
+ buildKey: attrs => attrs.id,
+
+ buildAttributes(attrs) {
+ const { poll } = attrs;
+ return {
+ "data-poll-type": poll.get('type'),
+ "data-poll-name": poll.get('name'),
+ "data-poll-status": poll.get('status'),
+ "data-poll-public": poll.get('public')
+ };
+ },
+
+ defaultState(attrs) {
+ const { poll, post } = attrs;
+ return { loading: false,
+ showResults: poll.get('isClosed') || post.get('topic.archived') };
+ },
+
+ html(attrs, state) {
+ const { showResults } = state;
+ const newAttrs = jQuery.extend({}, attrs, {
+ showResults,
+ canCastVotes: this.canCastVotes(),
+ min: this.min(),
+ max: this.max()
+ });
+ return h('div', [
+ this.attach('discourse-poll-container', newAttrs),
+ this.attach('discourse-poll-info', newAttrs),
+ this.attach('discourse-poll-buttons', newAttrs)
+ ]);
+ },
+
+ isClosed() {
+ return this.attrs.poll.get('status') === "closed";
+ },
+
+ min() {
+ let min = parseInt(this.attrs.poll.min, 10);
+ if (isNaN(min) || min < 1) { min = 1; }
+ return min;
+ },
+
+ max() {
+ let max = parseInt(this.attrs.poll.max, 10);
+ const numOptions = this.attrs.poll.options.length;
+ if (isNaN(max) || max > numOptions) { max = numOptions; }
+ return max;
+ },
+
+ canCastVotes() {
+ const { state, attrs } = this;
+ if (this.isClosed() || state.showResults || state.loading) {
+ return false;
+ }
+
+ const selectedOptionCount = attrs.vote.length;
+ if (attrs.isMultiple) {
+ return selectedOptionCount >= this.min() && selectedOptionCount <= this.max();
+ }
+ return selectedOptionCount > 0;
+ },
+
+ toggleStatus() {
+
+ const { state, attrs } = this;
+ const { poll } = attrs;
+ const isClosed = poll.get('status') === 'closed';
+
+ bootbox.confirm(
+ I18n.t(isClosed ? "poll.open.confirm" : "poll.close.confirm"),
+ I18n.t("no_value"),
+ I18n.t("yes_value"),
+ confirmed => {
+ if (confirmed) {
+ state.loading = true;
+
+ const status = isClosed ? "open" : "closed";
+ ajax("/polls/toggle_status", {
+ type: "PUT",
+ data: {
+ post_id: attrs.post.get('id'),
+ poll_name: poll.get('name'),
+ status,
+ }
+ }).then(() => {
+ poll.set('status', status);
+ this.scheduleRerender();
+ }).catch(() => {
+ bootbox.alert(I18n.t("poll.error_while_toggling_status"));
+ }).finally(() => {
+ state.loading = false;
+ });
+ }
+ }
+ );
+ },
+
+ toggleResults() {
+ this.state.showResults = !this.state.showResults;
+ },
+
+ showLogin() {
+ const appRoute = this.register.lookup('route:application');
+ appRoute.send('showLogin');
+ },
+
+ toggleOption(option) {
+ if (this.isClosed()) { return; }
+ if (!this.currentUser) { this.showLogin(); }
+
+ const { attrs } = this;
+ const { vote } = attrs;
+
+ const chosenIdx = vote.indexOf(option.id);
+ if (!attrs.isMultiple) {
+ vote.length = 0;
+ }
+
+ if (chosenIdx !== -1) {
+ vote.splice(chosenIdx, 1);
+ } else {
+ vote.push(option.id);
+ }
+
+ if (!attrs.isMultiple) {
+ return this.castVotes();
+ }
+ },
+
+ castVotes() {
+ if (!this.canCastVotes()) { return; }
+ if (!this.currentUser) { return this.showLogin(); }
+
+ const { attrs, state } = this;
+
+ state.loading = true;
+
+ return ajax("/polls/vote", {
+ type: "PUT",
+ data: {
+ post_id: attrs.post.id,
+ poll_name: attrs.poll.name,
+ options: attrs.vote
+ }
+ }).then(() => {
+ state.showResults = true;
+ }).catch(() => {
+ bootbox.alert(I18n.t("poll.error_while_casting_votes"));
+ }).finally(() => {
+ state.loading = false;
+ });
+ }
+});
diff --git a/plugins/poll/assets/stylesheets/common/poll.scss b/plugins/poll/assets/stylesheets/common/poll.scss
index 86c8785c67d..762ce7638f8 100644
--- a/plugins/poll/assets/stylesheets/common/poll.scss
+++ b/plugins/poll/assets/stylesheets/common/poll.scss
@@ -109,7 +109,6 @@ div.poll {
height: 10px;
background: $primary;
}
-
}
&[data-poll-type="number"] {
diff --git a/plugins/poll/test/javascripts/components/poll-option-test.js.es6 b/plugins/poll/test/javascripts/components/poll-option-test.js.es6
deleted file mode 100644
index fd2a50ae374..00000000000
--- a/plugins/poll/test/javascripts/components/poll-option-test.js.es6
+++ /dev/null
@@ -1,26 +0,0 @@
-import componentTest from 'helpers/component-test';
-moduleForComponent('poll-option', { integration: true });
-
-componentTest('test poll option', {
- template: '{{poll-option option=option isMultiple=isMultiple}}',
-
- setup(store) {
- this.set('option', Em.Object.create({ id: 1, selected: false }));
- },
-
- test(assert) {
- assert.ok(this.$('li .fa-circle-o:eq(0)').length === 1);
-
- this.set('option.selected', true);
-
- assert.ok(this.$('li .fa-dot-circle-o:eq(0)').length === 1);
-
- this.set('isMultiple', true);
-
- assert.ok(this.$('li .fa-check-square-o:eq(0)').length === 1);
-
- this.set('option.selected', false);
-
- assert.ok(this.$('li .fa-square-o:eq(0)').length === 1);
- }
-});
diff --git a/plugins/poll/test/javascripts/components/poll-results-standard-test.js.es6 b/plugins/poll/test/javascripts/components/poll-results-standard-test.js.es6
deleted file mode 100644
index 9c982dd478a..00000000000
--- a/plugins/poll/test/javascripts/components/poll-results-standard-test.js.es6
+++ /dev/null
@@ -1,58 +0,0 @@
-import componentTest from 'helpers/component-test';
-moduleForComponent('poll-results-standard', { integration: true });
-
-componentTest('options in descending order', {
- template: '{{poll-results-standard poll=poll}}',
-
- setup(store) {
- this.set('poll', {
- options: [Em.Object.create({ votes: 5 }), Em.Object.create({ votes: 4 })],
- voters: 9
- });
- },
-
- test(assert) {
- assert.equal(this.$('.option .percentage:eq(0)').text(), '56%');
- assert.equal(this.$('.option .percentage:eq(1)').text(), '44%');
- }
-});
-
-componentTest('options in ascending order', {
- template: '{{poll-results-standard poll=poll sortResults=sortResults}}',
-
- setup() {
- this.set('poll', {
- options: [Em.Object.create({ votes: 4 }), Em.Object.create({ votes: 5 })],
- voters: 9
- });
- },
-
- test(assert) {
- assert.equal(this.$('.option .percentage:eq(0)').text(), '56%');
- assert.equal(this.$('.option .percentage:eq(1)').text(), '44%');
- }
-});
-
-componentTest('multiple options in descending order', {
- template: '{{poll-results-standard poll=poll}}',
-
- setup(store) {
- this.set('poll', {
- type: 'multiple',
- options: [
- Em.Object.create({ votes: 5}),
- Em.Object.create({ votes: 2}),
- Em.Object.create({ votes: 4}),
- Em.Object.create({ votes: 1})
- ],
- voters: 12
- });
- },
-
- test(assert) {
- assert.equal(this.$('.option .percentage:eq(0)').text(), '41%');
- assert.equal(this.$('.option .percentage:eq(1)').text(), '33%');
- assert.equal(this.$('.option .percentage:eq(2)').text(), '16%');
- assert.equal(this.$('.option .percentage:eq(3)').text(), '8%');
- }
-});
diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6
new file mode 100644
index 00000000000..e32c9836719
--- /dev/null
+++ b/plugins/poll/test/javascripts/widgets/discourse-poll-option-test.js.es6
@@ -0,0 +1,64 @@
+import { moduleForWidget, widgetTest } from 'helpers/widget-test';
+moduleForWidget('discourse-poll-option');
+
+const template = `{{mount-widget
+ widget="discourse-poll-option"
+ args=(hash option=option isMultiple=isMultiple vote=vote)}}`;
+
+widgetTest('single, not selected', {
+ template,
+
+ setup() {
+ this.set('option', { id: 'opt-id' });
+ this.set('vote', []);
+ },
+
+ test(assert) {
+ assert.ok(find('li .fa-circle-o:eq(0)').length === 1);
+ }
+});
+
+widgetTest('single, selected', {
+ template,
+
+ setup() {
+ this.set('option', { id: 'opt-id' });
+ this.set('vote', ['opt-id']);
+ },
+
+ test(assert) {
+ assert.ok(find('li .fa-dot-circle-o:eq(0)').length === 1);
+ }
+});
+
+widgetTest('multi, not selected', {
+ template,
+
+ setup() {
+ this.setProperties({
+ option: { id: 'opt-id' },
+ isMultiple: true,
+ vote: []
+ });
+ },
+
+ test(assert) {
+ assert.ok(find('li .fa-square-o:eq(0)').length === 1);
+ }
+});
+
+widgetTest('multi, selected', {
+ template,
+
+ setup() {
+ this.setProperties({
+ option: { id: 'opt-id' },
+ isMultiple: true,
+ vote: ['opt-id']
+ });
+ },
+
+ test(assert) {
+ assert.ok(find('li .fa-check-square-o:eq(0)').length === 1);
+ }
+});
diff --git a/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6 b/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6
new file mode 100644
index 00000000000..a4172adcee1
--- /dev/null
+++ b/plugins/poll/test/javascripts/widgets/discourse-poll-standard-results-test.js.es6
@@ -0,0 +1,63 @@
+import { moduleForWidget, widgetTest } from 'helpers/widget-test';
+moduleForWidget('discourse-poll-standard-results');
+
+const template = `{{mount-widget
+ widget="discourse-poll-standard-results"
+ args=(hash poll=poll isMultiple=isMultiple)}}`;
+
+widgetTest('options in descending order', {
+ template,
+
+ setup() {
+ this.set('poll', Ember.Object.create({
+ options: [{ votes: 5 }, { votes: 4 }],
+ voters: 9
+ }));
+ },
+
+ test(assert) {
+ assert.equal(this.$('.option .percentage:eq(0)').text(), '56%');
+ assert.equal(this.$('.option .percentage:eq(1)').text(), '44%');
+ }
+});
+
+widgetTest('options in ascending order', {
+ template,
+
+ setup() {
+ this.set('poll', Ember.Object.create({
+ options: [{ votes: 4 }, { votes: 5 }],
+ voters: 9
+ }));
+ },
+
+ test(assert) {
+ assert.equal(this.$('.option .percentage:eq(0)').text(), '56%');
+ assert.equal(this.$('.option .percentage:eq(1)').text(), '44%');
+ }
+});
+
+widgetTest('multiple options in descending order', {
+ template,
+
+ setup() {
+ this.set('isMultiple', true);
+ this.set('poll', Ember.Object.create({
+ type: 'multiple',
+ options: [
+ { votes: 5 },
+ { votes: 2 },
+ { votes: 4 },
+ { votes: 1 }
+ ],
+ voters: 12
+ }));
+ },
+
+ test(assert) {
+ assert.equal(this.$('.option .percentage:eq(0)').text(), '41%');
+ assert.equal(this.$('.option .percentage:eq(1)').text(), '33%');
+ assert.equal(this.$('.option .percentage:eq(2)').text(), '16%');
+ assert.equal(this.$('.option .percentage:eq(3)').text(), '8%');
+ }
+});