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}} - - {{/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 @@ -
    - {{#each pollsVoters as |user|}} -
  • - - {{avatar user imageSize="tiny" ignoreTitle="true"}} - -
  • - {{/each}} - -
    - {{#if canLoadMore}} - {{#conditional-loading-spinner condition=loading size="small"}} - {{fa-icon "chevron-down"}} - {{/conditional-loading-spinner}} - {{/if}} -
    -
    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%'); + } +});