Migrate Discourse Polls to use vdom instead of embedded ember

This commit is contained in:
Robin Ward 2016-12-07 15:48:47 -05:00
parent 846597f563
commit f07443b488
23 changed files with 702 additions and 620 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,61 +0,0 @@
<div>
<div class="poll-container">
{{#if showingResults}}
{{#if isNumber}}
{{poll-results-number isPublic=isPublic poll=poll}}
{{else}}
{{poll-results-standard isPublic=isPublic poll=poll}}
{{/if}}
{{else}}
<ul>
{{#each poll.options as |option|}}
{{poll-option option=option toggle="toggleOption" isMultiple=isMultiple}}
{{/each}}
</ul>
{{/if}}
</div>
<div class="poll-info">
<p>
<span class="info-number">{{poll.voters}}</span>
<span class="info-text">{{votersText}}</span>
</p>
{{#if isMultiple}}
{{#if showingResults}}
<p>
<span class="info-number">{{totalVotes}}</span>
<span class="info-text">{{totalVotesText}}</span>
</p>
{{else}}
<p>{{{multipleHelpText}}}</p>
{{/if}}
{{/if}}
{{#if isPublic}}
{{#unless showingResults}}
<p>{{i18n "poll.public.title"}}</p>
{{/unless}}
{{/if}}
</div>
</div>
<div class="poll-buttons">
{{#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}}
</div>

View File

@ -1,2 +0,0 @@
{{{optionIcon}}}
{{{option.html}}}

View File

@ -1,7 +0,0 @@
<div class="poll-results-number-rating">
{{{averageRating}}}
</div>
{{#if isPublic}}
{{poll-results-number-voters poll=poll}}
{{/if}}

View File

@ -1,17 +0,0 @@
{{#each options as |option|}}
<li>
<div class="option">
<p>
<span class="percentage">{{option.percentage}}%</span>
{{{option.html}}}
</p>
</div>
<div class="bar-back">
<div class="bar" style={{option.style}}></div>
</div>
{{#if isPublic}}
{{poll-results-standard-voters option=option}}
{{/if}}
</li>
{{/each}}

View File

@ -1,17 +0,0 @@
<div class="poll-voters">
{{#each pollsVoters as |user|}}
<li>
<a data-user-card={{unbound user.username}}>
{{avatar user imageSize="tiny" ignoreTitle="true"}}
</a>
</li>
{{/each}}
<div class="poll-voters-toggle-expand">
{{#if canLoadMore}}
{{#conditional-loading-spinner condition=loading size="small"}}
<a {{action "loadMore"}}>{{fa-icon "chevron-down"}}</a>
{{/conditional-loading-spinner}}
{{/if}}
</div>
</div>

View File

@ -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 = $("<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 {

View File

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

View File

@ -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: `<span>${option.html}</span>` });
}
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: `<span>${averageRating}</span>` }))];
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: `<span>${help}</span>` }));
}
}
}
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;
});
}
});

View File

@ -109,7 +109,6 @@ div.poll {
height: 10px;
background: $primary;
}
}
&[data-poll-type="number"] {

View File

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

View File

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

View File

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

View File

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