FEATURE: Add Instant Run-off Voting to Poll Plugin (Part 1: migrate existing plugin to Glimmer only) (#27204)

The "migration to Glimmer" has been broken out here from #27155 to make the review process less onerous and reduce change risk: 

* DEV: migrates most of the widget code to Glimmer in prep for IRV additions
* NB This already incorporates significant amounts of review and feedback from the prior PR.
* NB because there was significant additional feedback relating to older Poll code that I've improved with feedback, there are some additional changes here that are general improvements to the plugin and not specific to IRV nor Glimmer!
* There should be no trace of IRV code here.

Once this is finalised and merged we can continue to progress with #27155.
This commit is contained in:
Robert 2024-07-04 12:34:48 +01:00 committed by GitHub
parent 32c8bcc3af
commit e3b6be15b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2169 additions and 1711 deletions

View File

@ -17,6 +17,7 @@ en:
kb: KB
mb: MB
tb: TB
percent: "%{count}%"
short:
thousands: "%{number}k"
millions: "%{number}M"

View File

@ -1,7 +1,8 @@
# frozen_string_literal: true
class PollSerializer < ApplicationSerializer
attributes :name,
attributes :id,
:name,
:type,
:status,
:public,

View File

@ -173,4 +173,9 @@ export default class PollBreakdownChart extends Component {
this._chart.setActiveElements(activeElements);
this._chart.update();
}
<template>
<label class="poll-breakdown-chart-label">{{@group}}</label>
<canvas class="poll-breakdown-chart-chart"></canvas>
</template>
}

View File

@ -1,2 +0,0 @@
<label class="poll-breakdown-chart-label">{{@group}}</label>
<canvas class="poll-breakdown-chart-chart"></canvas>

View File

@ -1,8 +1,10 @@
import Component from "@ember/component";
import { on } from "@ember/modifier";
import { equal } from "@ember/object/computed";
import { htmlSafe } from "@ember/template";
import { tagName } from "@ember-decorators/component";
import { propertyEqual } from "discourse/lib/computed";
import i18n from "discourse-common/helpers/i18n";
import discourseComputed from "discourse-common/utils/decorators";
import I18n from "discourse-i18n";
import { getColors } from "discourse/plugins/poll/lib/chart-colors";
@ -48,4 +50,28 @@ export default class PollBreakdownOption extends Component {
return htmlSafe(`background: ${color};`);
}
<template>
<li
class="poll-breakdown-option"
style={{this.colorBackgroundStyle}}
{{on "mouseover" @onMouseOver}}
{{on "mouseout" @onMouseOut}}
role="button"
>
<span
class="poll-breakdown-option-color"
style={{this.colorPreviewStyle}}
></span>
<span class="poll-breakdown-option-count">
{{#if this.showPercentage}}
{{i18n "number.percent" count=this.percent}}
{{else}}
{{@option.votes}}
{{/if}}
</span>
<span class="poll-breakdown-option-text">{{htmlSafe @option.html}}</span>
</li>
</template>
}

View File

@ -1,21 +0,0 @@
<li
class="poll-breakdown-option"
style={{this.colorBackgroundStyle}}
{{on "mouseover" @onMouseOver}}
{{on "mouseout" @onMouseOut}}
role="button"
>
<span
class="poll-breakdown-option-color"
style={{this.colorPreviewStyle}}
></span>
<span class="poll-breakdown-option-count">
{{#if this.showPercentage}}
{{this.percent}}%
{{else}}
{{@option.votes}}
{{/if}}
</span>
<span class="poll-breakdown-option-text">{{html-safe @option.html}}</span>
</li>

View File

@ -0,0 +1,132 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import DButton from "discourse/components/d-button";
import DropdownMenu from "discourse/components/dropdown-menu";
import icon from "discourse-common/helpers/d-icon";
import DMenu from "float-kit/components/d-menu";
const buttonOptionsMap = {
exportResults: {
className: "btn-default export-results",
label: "poll.export-results.label",
title: "poll.export-results.title",
icon: "download",
action: "exportResults",
},
showBreakdown: {
className: "btn-default show-breakdown",
label: "poll.breakdown.breakdown",
icon: "chart-pie",
action: "showBreakdown",
},
openPoll: {
className: "btn-default toggle-status",
label: "poll.open.label",
title: "poll.open.title",
icon: "unlock-alt",
action: "toggleStatus",
},
closePoll: {
className: "btn-default toggle-status",
label: "poll.close.label",
title: "poll.close.title",
icon: "lock",
action: "toggleStatus",
},
};
export default class PollButtonsDropdownComponent extends Component {
@service currentUser;
@service siteSettings;
constructor() {
super(...arguments);
this.getDropdownButtonState = false;
}
@action
dropDownClick(dropDownAction) {
this.args.dropDownClick(dropDownAction);
}
get getDropdownContent() {
const contents = [];
const isAdmin = this.currentUser && this.currentUser.admin;
const dataExplorerEnabled = this.siteSettings.data_explorer_enabled;
const exportQueryID = this.siteSettings.poll_export_data_explorer_query_id;
const {
closed,
voters,
isStaff,
isMe,
topicArchived,
groupableUserFields,
isAutomaticallyClosed,
} = this.args;
if (groupableUserFields.length && voters > 0) {
const option = { ...buttonOptionsMap.showBreakdown };
option.id = option.action;
contents.push(option);
}
if (isAdmin && dataExplorerEnabled && voters > 0 && exportQueryID) {
const option = { ...buttonOptionsMap.exportResults };
option.id = option.action;
contents.push(option);
}
if (this.currentUser && (isMe || isStaff) && !topicArchived) {
if (closed) {
if (!isAutomaticallyClosed) {
const option = { ...buttonOptionsMap.openPoll };
option.id = option.action;
contents.push(option);
}
} else {
const option = { ...buttonOptionsMap.closePoll };
option.id = option.action;
contents.push(option);
}
}
return contents;
}
get showDropdown() {
return this.getDropdownContent.length > 1;
}
get showDropdownAsButton() {
return this.getDropdownContent.length === 1;
}
<template>
<div class="poll-buttons-dropdown">
<DMenu class="widget-dropdown-header">
<:trigger>
{{icon "cog"}}
</:trigger>
<:content>
<DropdownMenu as |dropdown|>
{{#each this.getDropdownContent as |content|}}
<dropdown.item>
<DButton
class="widget-button {{content.className}}"
@icon={{content.icon}}
@label={{content.label}}
@action={{fn this.dropDownClick content.action}}
/>
</dropdown.item>
<dropdown.divider />
{{/each}}
</DropdownMenu>
</:content>
</DMenu>
</div>
</template>
}

View File

@ -0,0 +1,209 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { relativeAge } from "discourse/lib/formatter";
import icon from "discourse-common/helpers/d-icon";
import I18n from "I18n";
const ON_VOTE = "on_vote";
const ON_CLOSE = "on_close";
const STAFF_ONLY = "staff_only";
export default class PollInfoComponent extends Component {
@service currentUser;
get multipleHelpText() {
const { min, max, options } = this.args;
const optionsCount = options.length;
if (max > 0) {
if (min === max && min > 1) {
return htmlSafe(I18n.t("poll.multiple.help.x_options", { count: min }));
}
if (min > 1) {
if (max < optionsCount) {
return htmlSafe(
I18n.t("poll.multiple.help.between_min_and_max_options", {
min,
max,
})
);
}
return htmlSafe(
I18n.t("poll.multiple.help.at_least_min_options", { count: min })
);
}
if (max <= optionsCount) {
return htmlSafe(
I18n.t("poll.multiple.help.up_to_max_options", { count: max })
);
}
}
}
get votersLabel() {
return I18n.t("poll.voters", { count: this.args.voters });
}
get showTotalVotes() {
return this.args.isMultiple && (this.args.showResults || this.args.closed);
}
get totalVotes() {
return this.args.options.reduce((total, o) => {
return total + parseInt(o.votes, 10);
}, 0);
}
get totalVotesLabel() {
return I18n.t("poll.total_votes", { count: this.totalVotes });
}
get automaticCloseAgeLabel() {
return I18n.t("poll.automatic_close.age", this.age);
}
get automaticCloseClosesInLabel() {
return I18n.t("poll.automatic_close.closes_in", this.timeLeft);
}
get showMultipleHelpText() {
return this.args.isMultiple && !this.args.showResults && !this.args.closed;
}
get closeTitle() {
const closeDate = moment.utc(this.args.close, "YYYY-MM-DD HH:mm:ss Z");
if (closeDate.isValid()) {
return closeDate.format("LLL");
} else {
return "";
}
}
get age() {
const closeDate = moment.utc(this.args.close, "YYYY-MM-DD HH:mm:ss Z");
if (closeDate.isValid()) {
return relativeAge(closeDate.toDate(), { addAgo: true });
} else {
return 0;
}
}
get timeLeft() {
const closeDate = moment.utc(this.args.close, "YYYY-MM-DD HH:mm:ss Z");
if (closeDate.isValid()) {
return moment().to(closeDate, true);
} else {
return 0;
}
}
get resultsOnVote() {
return (
this.args.results === ON_VOTE &&
!this.args.hasVoted &&
!(this.currentUser && this.args.postUserId === this.currentUser.id)
);
}
get resultsOnClose() {
return this.args.results === ON_CLOSE && !this.args.closed;
}
get resultsStaffOnly() {
return (
this.args.results === STAFF_ONLY &&
!(this.currentUser && this.currentUser.staff)
);
}
get publicTitle() {
return (
!this.args.closed &&
!this.args.showResults &&
this.args.isPublic &&
this.args.results !== STAFF_ONLY
);
}
get publicTitleLabel() {
return htmlSafe(I18n.t("poll.public.title"));
}
get showInstructionsSection() {
return (
this.showMultipleHelpText ||
this.args.close ||
this.resultsOnVote ||
this.resultsOnClose ||
this.resultsStaffOnly ||
this.publicTitle
);
}
<template>
<div class="poll-info">
<div class="poll-info_counts">
<div class="poll-info_counts-count">
<span class="info-number">{{@voters}}</span>
<span class="info-label">{{this.votersLabel}}</span>
</div>
{{#if this.showTotalVotes}}
<div class="poll-info_counts-count">
<span class="info-number">{{this.totalVotes}}</span>
<span class="info-label">{{this.totalVotesLabel}}</span>
</div>
{{/if}}
</div>
{{#if this.showInstructionsSection}}
<ul class="poll-info_instructions">
{{#if this.showMultipleHelpText}}
<li class="multiple-help-text">
{{icon "list-ul"}}
<span>{{this.multipleHelpText}}</span>
</li>
{{/if}}
{{#if this.poll.close}}
{{#if this.isAutomaticallyClosed}}
<li title={{this.title}}>
{{icon "lock"}}
<span>{{this.automaticCloseAgeLabel}}</span>
</li>
{{else}}
<li title={{this.title}}>
{{icon "far-clock"}}
<span>{{this.automaticCloseClosesInLabel}}</span>
</li>
{{/if}}
{{/if}}
{{#if this.resultsOnVote}}
<li>
{{icon "check"}}
<span>{{I18n "poll.results.vote.title"}}</span>
</li>
{{/if}}
{{#if this.resultsOnClose}}
<li>
{{icon "lock"}}
<span>{{I18n "poll.results.closed.title"}}</span>
</li>
{{/if}}
{{#if this.resultsStaffOnly}}
<li>
{{icon "shield-alt"}}
<span>{{I18n "poll.results.staff.title"}}</span>
</li>
{{/if}}
{{#if this.publicTitle}}
<li class="is-public">
{{icon "far-eye"}}
<span>{{this.publicTitleLabel}}</span>
</li>
{{/if}}
</ul>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,64 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import routeAction from "discourse/helpers/route-action";
import icon from "discourse-common/helpers/d-icon";
export default class PollOptionsComponent extends Component {
@service currentUser;
isChosen = (option) => {
return this.args.votes.includes(option.id);
};
@action
sendClick(option) {
this.args.sendOptionSelect(option);
}
<template>
<ul>
{{#each @options as |option|}}
<li tabindex="0" data-poll-option-id={{option.id}}>
{{#if this.currentUser}}
<button {{on "click" (fn this.sendClick option)}}>
{{#if (this.isChosen option)}}
{{#if @isCheckbox}}
{{icon "far-check-square"}}
{{else}}
{{icon "circle"}}
{{/if}}
{{else}}
{{#if @isCheckbox}}
{{icon "far-square"}}
{{else}}
{{icon "far-circle"}}
{{/if}}
{{/if}}
<span class="option-text">{{option.html}}</span>
</button>
{{else}}
<button onclick={{routeAction "showLogin"}}>
{{#if (this.isChosen option)}}
{{#if @isCheckbox}}
{{icon "far-check-square"}}
{{else}}
{{icon "circle"}}
{{/if}}
{{else}}
{{#if @isCheckbox}}
{{icon "far-square"}}
{{else}}
{{icon "far-circle"}}
{{/if}}
{{/if}}
<span class="option-text">{{option.html}}</span>
</button>
{{/if}}
</li>
{{/each}}
</ul>
</template>
}

View File

@ -0,0 +1,140 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import didInsert from "@ember/render-modifiers/modifiers/did-insert";
import { htmlSafe } from "@ember/template";
import { modifier } from "ember-modifier";
import loadScript from "discourse/lib/load-script";
import { getColors } from "discourse/plugins/poll/lib/chart-colors";
import { PIE_CHART_TYPE } from "../components/modal/poll-ui-builder";
export default class PollResultsPieComponent extends Component {
htmlLegendPlugin = {
id: "htmlLegend",
afterUpdate(chart, args, options) {
const ul = document.getElementById(options.containerID);
ul.innerHTML = "";
const items = chart.options.plugins.legend.labels.generateLabels(chart);
items.forEach((item) => {
const li = document.createElement("li");
li.classList.add("legend");
li.onclick = () => {
chart.toggleDataVisibility(item.index);
chart.update();
};
const boxSpan = document.createElement("span");
boxSpan.classList.add("swatch");
boxSpan.style.background = item.fillStyle;
const textContainer = document.createElement("span");
textContainer.style.color = item.fontColor;
textContainer.innerHTML = item.text;
if (!chart.getDataVisibility(item.index)) {
li.style.opacity = 0.2;
} else {
li.style.opacity = 1.0;
}
li.appendChild(boxSpan);
li.appendChild(textContainer);
ul.appendChild(li);
});
},
};
stripHtml = (html) => {
let doc = new DOMParser().parseFromString(html, "text/html");
return doc.body.textContent || "";
};
pieChartConfig = (data, labels, opts = {}) => {
const aspectRatio = "aspectRatio" in opts ? opts.aspectRatio : 2.2;
const strippedLabels = labels.map((l) => this.stripHtml(l));
return {
type: PIE_CHART_TYPE,
data: {
datasets: [
{
data,
backgroundColor: getColors(data.length),
},
],
labels: strippedLabels,
},
plugins: [this.htmlLegendPlugin],
options: {
responsive: true,
aspectRatio,
animation: { duration: 0 },
plugins: {
legend: {
labels: {
generateLabels() {
return labels.map((text, index) => {
return {
fillStyle: getColors(data.length)[index],
text,
index,
};
});
},
},
display: false,
},
htmlLegend: {
containerID: opts?.legendContainerId,
},
},
},
};
};
registerLegendElement = modifier((element) => {
this.legendElement = element;
});
registerCanvasElement = modifier((element) => {
this.canvasElement = element;
});
get canvasId() {
return htmlSafe(`poll-results-chart-${this.args.id}`);
}
get legendId() {
return htmlSafe(`poll-results-legend-${this.args.id}`);
}
@action
async drawPie() {
await loadScript("/javascripts/Chart.min.js");
const data = this.args.options.mapBy("votes");
const labels = this.args.options.mapBy("html");
const config = this.pieChartConfig(data, labels, {
legendContainerId: this.legendElement.id,
});
const el = this.canvasElement;
// eslint-disable-next-line no-undef
this._chart = new Chart(el.getContext("2d"), config);
}
<template>
<div class="poll-results-chart">
<canvas
{{didInsert this.drawPie}}
{{didInsert this.registerCanvasElement}}
id={{this.canvasId}}
class="poll-results-canvas"
></canvas>
<ul
{{didInsert this.registerLegendElement}}
id={{this.legendId}}
class="pie-chart-legends"
>
</ul>
</div>
</template>
}

View File

@ -0,0 +1,100 @@
import Component from "@glimmer/component";
import { concat } from "@ember/helper";
import { htmlSafe } from "@ember/template";
import i18n from "discourse-common/helpers/i18n";
import evenRound from "discourse/plugins/poll/lib/even-round";
import PollVoters from "./poll-voters";
export default class PollResultsStandardComponent extends Component {
orderOptions = (options) => {
return options.sort((a, b) => {
if (a.votes < b.votes) {
return 1;
} else if (a.votes === b.votes) {
if (a.html < b.html) {
return -1;
} else {
return 1;
}
} else {
return -1;
}
});
};
getPercentages = (ordered, votersCount) => {
return votersCount === 0
? Array(ordered.length).fill(0)
: ordered.map((o) => (100 * o.votes) / votersCount);
};
roundPercentages = (percentages) => {
return this.isMultiple
? percentages.map(Math.floor)
: evenRound(percentages);
};
enrichOptions = (ordered, rounded) => {
ordered.forEach((option, idx) => {
const per = rounded[idx].toString();
const chosen = (this.args.vote || []).includes(option.id);
option.percentage = per;
option.chosen = chosen;
let voters = this.args.voters[option.id] || [];
option.voters = [...voters];
});
return ordered;
};
get votersCount() {
return this.args.votersCount || 0;
}
get orderedOptions() {
const ordered = this.orderOptions([...this.args.options]);
const percentages = this.getPercentages(ordered, this.votersCount);
const roundedPercentages = this.roundPercentages(percentages);
return this.enrichOptions(ordered, roundedPercentages);
}
get isMultiple() {
return this.args.pollType === "multiple";
}
<template>
<ul class="results">
{{#each this.orderedOptions key="voters" as |option|}}
<li class={{if option.chosen "chosen" ""}}>
<div class="option">
<p>
<span class="percentage">{{i18n
"number.percent"
count=option.percentage
}}</span>
<span class="option-text">{{option.html}}</span>
</p>
<div class="bar-back">
<div
class="bar"
style={{htmlSafe (concat "width:" option.percentage "%")}}
/>
</div>
<PollVoters
@postId={{@postId}}
@pollType={{@pollType}}
@optionId={{option.id}}
@pollName={{@pollName}}
@totalVotes={{option.votes}}
@voters={{option.voters}}
@fetchVoters={{@fetchVoters}}
@loading={{option.loading}}
/>
</div>
</li>
{{/each}}
</ul>
</template>
}

View File

@ -0,0 +1,32 @@
import Component from "@glimmer/component";
import { fn } from "@ember/helper";
import ConditionalLoadingSpinner from "discourse/components/conditional-loading-spinner";
import DButton from "discourse/components/d-button";
import avatar from "discourse/helpers/bound-avatar-template";
export default class PollVotersComponent extends Component {
get showMore() {
return this.args.voters.length < this.args.totalVotes;
}
<template>
<div class="poll-voters">
<ul class="poll-voters-list">
{{#each @voters as |user|}}
<li>
{{avatar user.avatar_template "tiny"}}
</li>
{{/each}}
</ul>
{{#if this.showMore}}
<ConditionalLoadingSpinner @condition={{@loading}}>
<DButton
@action={{fn @fetchVoters @optionId}}
@icon="chevron-down"
class="poll-voters-toggle-expand"
/>
</ConditionalLoadingSpinner>
{{/if}}
</div>
</template>
}

View File

@ -0,0 +1,609 @@
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";
import { on } from "@ember/modifier";
import { action } from "@ember/object";
import didUpdate from "@ember/render-modifiers/modifiers/did-update";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import round from "discourse/lib/round";
import icon from "discourse-common/helpers/d-icon";
import i18n from "discourse-common/helpers/i18n";
import I18n from "discourse-i18n";
import PollBreakdownModal from "../components/modal/poll-breakdown";
import { PIE_CHART_TYPE } from "../components/modal/poll-ui-builder";
import PollButtonsDropdown from "../components/poll-buttons-dropdown";
import PollInfo from "../components/poll-info";
import PollOptions from "../components/poll-options";
import PollResultsPie from "../components/poll-results-pie";
import PollResultsStandard from "../components/poll-results-standard";
const FETCH_VOTERS_COUNT = 25;
const STAFF_ONLY = "staff_only";
const MULTIPLE = "multiple";
const NUMBER = "number";
const REGULAR = "regular";
const ON_VOTE = "on_vote";
const ON_CLOSE = "on_close";
export default class PollComponent extends Component {
@service currentUser;
@service siteSettings;
@service appEvents;
@service dialog;
@service router;
@service modal;
@tracked isStaff = this.currentUser && this.currentUser.staff;
@tracked vote = this.args.attrs.vote || [];
@tracked titleHTML = htmlSafe(this.args.attrs.titleHTML);
@tracked topicArchived = this.args.attrs.post.get("topic.archived");
@tracked options = [];
@tracked poll = this.args.attrs.poll;
@tracked voters = this.poll.voters || 0;
@tracked preloadedVoters = this.args.preloadedVoters || [];
@tracked staffOnly = this.poll.results === STAFF_ONLY;
@tracked isMultiple = this.poll.type === MULTIPLE;
@tracked isNumber = this.poll.type === NUMBER;
@tracked showingResults = false;
@tracked hasSavedVote = this.args.attrs.hasSavedVote;
@tracked status = this.poll.status;
@tracked
showResults =
this.hasSavedVote ||
this.showingResults ||
(this.topicArchived && !this.staffOnly) ||
(this.closed && !this.staffOnly);
post = this.args.attrs.post;
isMe =
this.currentUser && this.args.attrs.post.user_id === this.currentUser.id;
checkUserGroups = (user, poll) => {
const pollGroups =
poll && poll.groups && poll.groups.split(",").map((g) => g.toLowerCase());
if (!pollGroups) {
return true;
}
const userGroups =
user && user.groups && user.groups.map((g) => g.name.toLowerCase());
return userGroups && pollGroups.some((g) => userGroups.includes(g));
};
castVotes = (option) => {
if (!this.canCastVotes) {
return;
}
if (!this.currentUser) {
return;
}
return ajax("/polls/vote", {
type: "PUT",
data: {
post_id: this.args.attrs.post.id,
poll_name: this.poll.name,
options: this.vote,
},
})
.then(({ poll }) => {
this.options = [...poll.options];
this.hasSavedVote = true;
this.poll.setProperties(poll);
this.appEvents.trigger(
"poll:voted",
poll,
this.args.attrs.post,
this.args.attrs.vote
);
const voters = poll.voters;
this.voters = [Number(voters)][0];
if (this.poll.results !== "on_close") {
this.showResults = true;
}
if (this.poll.results === "staff_only") {
if (this.currentUser && this.currentUser.staff) {
this.showResults = true;
} else {
this.showResults = false;
}
}
})
.catch((error) => {
if (error) {
if (!this.isMultiple) {
this._toggleOption(option);
}
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_casting_votes"));
}
});
};
_toggleOption = (option) => {
let options = this.options;
let vote = this.vote;
if (this.isMultiple) {
const chosenIdx = vote.indexOf(option.id);
if (chosenIdx !== -1) {
vote.splice(chosenIdx, 1);
} else {
vote.push(option.id);
}
} else {
vote = [option.id];
}
this.vote = [...vote];
this.options = [...options];
};
constructor() {
super(...arguments);
this.id = this.args.attrs.id;
this.post = this.args.attrs.post;
this.options = this.poll.options;
this.groupableUserFields = this.args.attrs.groupableUserFields;
}
get min() {
let min = parseInt(this.args.attrs.poll.min, 10);
if (isNaN(min) || min < 0) {
min = 1;
}
return min;
}
get max() {
let max = parseInt(this.args.attrs.poll.max, 10);
const numOptions = this.args.attrs.poll.options.length;
if (isNaN(max) || max > numOptions) {
max = numOptions;
}
return max;
}
get closed() {
return this.status === "closed" || this.isAutomaticallyClosed;
}
get isAutomaticallyClosed() {
const poll = this.poll;
return (
(poll.close ?? false) &&
moment.utc(poll.close, "YYYY-MM-DD HH:mm:ss Z") <= moment()
);
}
get hasVoted() {
return this.vote && this.vote.length > 0;
}
get hideResultsDisabled() {
return !this.staffOnly && (this.closed || this.topicArchived);
}
@action
toggleOption(option) {
if (this.closed) {
return;
}
if (!this.currentUser) {
// unlikely, handled by template logic
return;
}
if (!this.checkUserGroups(this.currentUser, this.poll)) {
return;
}
if (
!this.isMultiple &&
this.vote.length === 1 &&
this.vote[0] === option.id
) {
return this.removeVote();
}
if (!this.isMultiple) {
this.vote.length = 0;
}
this._toggleOption(option);
if (!this.isMultiple) {
this.castVotes(option);
}
}
@action
toggleResults() {
const showResults = !this.showResults;
this.showResults = showResults;
}
get canCastVotes() {
if (this.closed || this.showingResults || !this.currentUser) {
return false;
}
const selectedOptionCount = this.vote?.length || 0;
if (this.isMultiple) {
return selectedOptionCount >= this.min && selectedOptionCount <= this.max;
}
return selectedOptionCount > 0;
}
get notInVotingGroup() {
return !this.checkUserGroups(this.currentUser, this.poll);
}
get pollGroups() {
return I18n.t("poll.results.groups.title", { groups: this.poll.groups });
}
get showCastVotesButton() {
return this.isMultiple && !this.showResults;
}
get castVotesButtonClass() {
return `btn cast-votes ${
this.canCastVotes ? "btn-primary" : "btn-default"
}`;
}
get castVotesButtonIcon() {
return !this.castVotesDisabled ? "check" : "far-square";
}
get castVotesDisabled() {
return !this.canCastVotes;
}
get showHideResultsButton() {
return this.showResults && !this.hideResultsDisabled;
}
get showShowResultsButton() {
return (
!this.showResults &&
!this.hideResultsDisabled &&
!(this.poll.results === ON_VOTE && !this.hasSavedVote && !this.isMe) &&
!(this.poll.results === ON_CLOSE && !this.closed) &&
!(this.poll.results === STAFF_ONLY && !this.isStaff) &&
this.voters > 0
);
}
get showRemoveVoteButton() {
return (
!this.showResults &&
!this.closed &&
!this.hideResultsDisabled &&
this.hasSavedVote
);
}
get isCheckbox() {
if (this.isMultiple) {
return true;
} else {
return false;
}
}
get resultsWidgetTypeClass() {
const type = this.poll.type;
return this.isNumber || this.poll.chart_type !== PIE_CHART_TYPE
? `discourse-poll-${type}-results`
: "discourse-poll-pie-chart";
}
get resultsPie() {
return this.poll.chart_type === PIE_CHART_TYPE;
}
get averageRating() {
const totalScore = this.options.reduce((total, o) => {
return total + parseInt(o.html, 10) * parseInt(o.votes, 10);
}, 0);
const average = this.voters === 0 ? 0 : round(totalScore / this.voters, -2);
return htmlSafe(I18n.t("poll.average_rating", { average }));
}
@action
updatedVoters() {
this.preloadedVoters = this.args.preloadedVoters;
this.options = [...this.args.options];
}
@action
fetchVoters(optionId) {
let votersCount;
this.loading = true;
let options = this.options;
options.find((option) => option.id === optionId).loading = true;
this.options = [...options];
votersCount = this.options.find((option) => option.id === optionId).votes;
return ajax("/polls/voters.json", {
data: {
post_id: this.post.id,
poll_name: this.poll.name,
option_id: optionId,
page: Math.floor(votersCount / FETCH_VOTERS_COUNT) + 1,
limit: FETCH_VOTERS_COUNT,
},
})
.then((result) => {
const voters = optionId
? this.preloadedVoters[optionId]
: this.preloadedVoters;
const newVoters = optionId ? result.voters[optionId] : result.voters;
const votersSet = new Set(voters.map((voter) => voter.username));
newVoters.forEach((voter) => {
if (!votersSet.has(voter.username)) {
votersSet.add(voter.username);
voters.push(voter);
}
});
// remove users who changed their vote
if (this.poll.type === REGULAR) {
Object.keys(this.preloadedVoters).forEach((otherOptionId) => {
if (optionId !== otherOptionId) {
this.preloadedVoters[otherOptionId] = this.preloadedVoters[
otherOptionId
].filter((voter) => !votersSet.has(voter.username));
}
});
}
this.preloadedVoters[optionId] = [
...new Set([...this.preloadedVoters[optionId], ...newVoters]),
];
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_fetching_voters"));
}
})
.finally(() => {
options.find((option) => option.id === optionId).loading = false;
this.options = [...options];
});
}
@action
dropDownClick(dropDownAction) {
this[dropDownAction]();
}
@action
removeVote() {
return ajax("/polls/vote", {
type: "DELETE",
data: {
post_id: this.post.id,
poll_name: this.poll.name,
},
})
.then(({ poll }) => {
this.options = [...poll.options];
this.poll.setProperties(poll);
this.vote = [];
this.voters = poll.voters;
this.hasSavedVote = false;
this.appEvents.trigger("poll:voted", poll, this.post, this.vote);
})
.catch((error) => popupAjaxError(error));
}
@action
toggleStatus() {
if (this.isAutomaticallyClosed) {
return;
}
this.dialog.yesNoConfirm({
message: I18n.t(this.closed ? "poll.open.confirm" : "poll.close.confirm"),
didConfirm: () => {
const status = this.closed ? "open" : "closed";
ajax("/polls/toggle_status", {
type: "PUT",
data: {
post_id: this.post.id,
poll_name: this.poll.name,
status,
},
})
.then(() => {
this.poll.status = status;
this.status = status;
if (
this.poll.results === "on_close" ||
this.poll.results === "always"
) {
this.showResults = this.status === "closed";
}
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_toggling_status"));
}
});
},
});
}
@action
showBreakdown() {
this.modal.show(PollBreakdownModal, {
model: this.args.attrs,
});
}
@action
exportResults() {
const queryID = this.siteSettings.poll_export_data_explorer_query_id;
// This uses the Data Explorer plugin export as CSV route
// There is detection to check if the plugin is enabled before showing the button
ajax(`/admin/plugins/explorer/queries/${queryID}/run.csv`, {
type: "POST",
data: {
// needed for data-explorer route compatibility
params: JSON.stringify({
poll_name: this.poll.name,
post_id: this.post.id.toString(), // needed for data-explorer route compatibility
}),
explain: false,
limit: 1000000,
download: 1,
},
})
.then((csvContent) => {
const downloadLink = document.createElement("a");
const blob = new Blob([csvContent], {
type: "text/csv;charset=utf-8;",
});
downloadLink.href = URL.createObjectURL(blob);
downloadLink.setAttribute(
"download",
`poll-export-${this.poll.name}-${this.post.id}.csv`
);
downloadLink.click();
downloadLink.remove();
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_exporting_results"));
}
});
}
<template>
<div
{{didUpdate this.updatedVoters @preloadedVoters}}
class="poll-container"
>
{{this.titleHTML}}
{{#if this.notInVotingGroup}}
<div class="alert alert-danger">{{this.pollGroups}}</div>
{{/if}}
{{#if this.showResults}}
<div class={{this.resultsWidgetTypeClass}}>
{{#if this.isNumber}}
<span>{{this.averageRating}}</span>
{{else}}
{{#if this.resultsPie}}
<PollResultsPie @id={{this.id}} @options={{this.options}} />
{{else}}
<PollResultsStandard
@options={{this.options}}
@pollName={{this.poll.name}}
@pollType={{this.poll.type}}
@isPublic={{this.poll.public}}
@postId={{this.post.id}}
@vote={{this.vote}}
@voters={{this.preloadedVoters}}
@votersCount={{this.poll.voters}}
@fetchVoters={{this.fetchVoters}}
/>
{{/if}}
{{/if}}
</div>
{{else}}
<PollOptions
@isCheckbox={{this.isCheckbox}}
@options={{this.options}}
@votes={{this.vote}}
@sendOptionSelect={{this.toggleOption}}
/>
{{/if}}
</div>
<PollInfo
@options={{this.options}}
@min={{this.min}}
@max={{this.max}}
@isMultiple={{this.isMultiple}}
@close={{this.close}}
@closed={{this.closed}}
@results={{this.poll.results}}
@showResults={{this.showResults}}
@postUserId={{this.poll.post.user_id}}
@isPublic={{this.poll.public}}
@hasVoted={{this.hasVoted}}
@voters={{this.voters}}
/>
<div class="poll-buttons">
{{#if this.showCastVotesButton}}
<button
class={{this.castVotesButtonClass}}
title="poll.cast-votes.title"
disabled={{this.castVotesDisabled}}
{{on "click" this.castVotes}}
>
{{icon this.castVotesButtonIcon}}
<span class="d-button-label">{{i18n "poll.cast-votes.label"}}</span>
</button>
{{/if}}
{{#if this.showHideResultsButton}}
<button
class="btn btn-default toggle-results"
title="poll.hide-results.title"
{{on "click" this.toggleResults}}
>
{{icon "chevron-left"}}
<span class="d-button-label">{{i18n "poll.hide-results.label"}}</span>
</button>
{{/if}}
{{#if this.showShowResultsButton}}
<button
class="btn btn-default toggle-results"
title="poll.show-results.title"
{{on "click" this.toggleResults}}
>
{{icon "chart-bar"}}
<span class="d-button-label">{{i18n "poll.show-results.label"}}</span>
</button>
{{/if}}
{{#if this.showRemoveVoteButton}}
<button
class="btn btn-default remove-vote"
title="poll.remove-vote.title"
{{on "click" this.removeVote}}
>
{{icon "undo"}}
<span class="d-button-label">{{i18n "poll.remove-vote.label"}}</span>
</button>
{{/if}}
<PollButtonsDropdown
@closed={{this.closed}}
@voters={{this.voters}}
@isStaff={{this.isStaff}}
@isMe={{this.isMe}}
@topicArchived={{this.topicArchived}}
@groupableUserFields={{this.groupableUserFields}}
@isAutomaticallyClosed={{this.isAutomaticallyClosed}}
@dropDownClick={{this.dropDownClick}}
/>
</div>
</template>
}

View File

@ -1,354 +1,432 @@
div.poll {
margin: 1em 0;
border: 1px solid var(--primary-low);
display: grid;
grid-template-areas: "poll" "info" "buttons";
@include breakpoint("mobile-extra-large", min-width) {
grid-template-columns: 1fr 10em;
grid-template-areas: "poll info" "buttons buttons";
}
ul,
ol {
margin: 0;
padding: 0;
list-style: none;
display: inline-block;
width: 100%;
}
li[data-poll-option-id] {
color: var(--primary);
padding: 0.5em 0;
word-break: break-word;
}
img {
// Hacky way to stop images without width/height
// from causing abrupt unintended scrolling
&:not([width]):not(.emoji),
&:not([height]):not(.emoji) {
width: 200px !important;
height: 200px !important;
object-fit: contain;
}
}
.poll-info {
box-sizing: border-box;
grid-area: info;
display: flex;
line-height: var(--line-height-medium);
color: var(--primary-medium);
@include breakpoint("mobile-extra-large") {
border-top: 1px solid var(--primary-low);
flex-direction: row-reverse;
&_counts,
&_instructions {
padding: 1em;
}
}
div.poll-outer {
div.poll {
margin: 1em 0;
border: 1px solid var(--primary-low);
display: grid;
grid-template-areas: "poll" "info" "buttons";
@include breakpoint("mobile-extra-large", min-width) {
gap: 0 1em;
padding: 1em;
border-left: 1px solid var(--primary-low);
flex-direction: column;
justify-content: center;
align-items: center;
grid-template-columns: 1fr 10em;
grid-template-areas: "poll info" "buttons buttons";
}
&_counts {
display: flex;
flex-wrap: wrap;
align-items: center;
ul,
ol {
margin: 0;
padding: 0;
list-style: none;
display: inline-block;
width: 100%;
gap: 0.25em 0;
}
@include breakpoint("mobile-extra-large", min-width) {
justify-content: center;
li[data-poll-option-id] {
color: var(--primary);
padding: 0.5em 0;
word-break: break-word;
button {
background-color: var(--secondary);
border: none;
}
}
img {
// Hacky way to stop images without width/height
// from causing abrupt unintended scrolling
&:not([width]):not(.emoji),
&:not([height]):not(.emoji) {
width: 200px !important;
height: 200px !important;
object-fit: contain;
}
}
.poll-info {
box-sizing: border-box;
grid-area: info;
display: flex;
line-height: var(--line-height-medium);
color: var(--primary-medium);
@include breakpoint("mobile-extra-large") {
flex: 1 1 auto;
border-top: 1px solid var(--primary-low);
flex-direction: row-reverse;
&_counts,
&_instructions {
padding: 1em;
}
}
&-count {
gap: 0.25em;
line-height: 1;
white-space: nowrap;
text-align: left;
@include breakpoint("mobile-extra-large", min-width) {
gap: 0 1em;
padding: 1em;
border-left: 1px solid var(--primary-low);
flex-direction: column;
justify-content: center;
align-items: center;
}
.info-label,
.info-number {
display: inline;
margin-right: 0.25em;
text-align: center;
&_counts {
display: flex;
flex-wrap: wrap;
align-items: center;
width: 100%;
gap: 0.25em 0;
@include breakpoint("mobile-extra-large", min-width) {
justify-content: center;
}
@include breakpoint("mobile-extra-large") {
&:not(:last-child) {
margin-right: 0.75em;
}
flex: 1 1 auto;
}
@include breakpoint("mobile-extra-large", min-width) {
&:not(:last-child) {
margin-bottom: 0.25em;
}
display: flex;
flex-direction: column;
align-items: center;
&-count {
gap: 0.25em;
line-height: 1;
white-space: nowrap;
text-align: left;
.info-label,
.info-number {
margin: 0;
display: inline;
margin-right: 0.25em;
text-align: center;
}
+ .poll-info_counts-count {
@include breakpoint("mobile-extra-large") {
&:not(:last-child) {
margin-right: 0.75em;
}
}
@include breakpoint("mobile-extra-large", min-width) {
&:not(:last-child) {
margin-bottom: 0.25em;
}
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
min-width: 0;
gap: 0 0.33em;
margin: 0.5em;
.info-number,
.info-label {
font-size: var(--font-up-1);
flex-direction: column;
align-items: center;
.info-label,
.info-number {
margin: 0;
}
+ .poll-info_counts-count {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
min-width: 0;
white-space: normal;
line-height: var(--line-height-medium);
gap: 0 0.33em;
margin: 0.5em;
.info-number,
.info-label {
font-size: var(--font-up-1);
min-width: 0;
white-space: normal;
line-height: var(--line-height-medium);
}
}
}
}
@include breakpoint("mobile-extra-large", min-width) {
+ .poll-info_instructions:not(:empty) {
margin-top: 1.25em;
}
}
}
&_instructions {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-self: start;
&:empty {
display: none;
}
@include breakpoint("mobile-extra-large", min-width) {
padding-top: 1.25em;
&:not(:empty) {
border-top: 1px solid var(--primary-low);
}
}
@include breakpoint("mobile-extra-large") {
padding-right: 1em;
height: 100%;
flex: 1 1 auto;
&:not(:empty) {
border-right: 1px solid var(--primary-low);
}
}
li {
display: flex;
gap: 0.5em;
&:not(:last-child) {
margin-bottom: 0.5em;
}
@include breakpoint("mobile-extra-large", min-width) {
&:not(:last-child) {
margin-bottom: 1em;
}
&:not(:first-child:last-child) {
// only applied when there are multiple items
.d-icon {
width: 15%;
}
span {
width: 85%;
}
}
&:first-child:last-child {
// when there's a single item, it looks better centered
display: inline;
text-align: center;
}
}
}
.d-icon {
font-size: var(--font-down-1);
margin-top: 0.2em;
}
}
.info-text {
margin: 0.25em 0;
display: block;
}
@include breakpoint("mobile-extra-large", min-width) {
+ .poll-info_instructions:not(:empty) {
margin-top: 1.25em;
.info-label {
font-size: var(--font-up-2);
}
.info-number {
font-size: var(--font-up-6);
}
}
}
&_instructions {
.poll-container {
box-sizing: border-box;
grid-area: poll;
padding: 1em;
width: 100%;
overflow: visible;
align-self: center;
li {
cursor: pointer;
font-size: var(--font-up-1);
}
.poll-results-number-rating {
font-size: var(--font-up-5);
}
}
.poll-title {
border-bottom: 1px solid var(--primary-low);
margin-bottom: 0.5em;
padding-bottom: 0.5em;
}
.poll-buttons {
box-sizing: border-box;
grid-area: buttons;
display: flex;
flex-direction: column;
justify-content: center;
align-self: start;
flex-wrap: wrap;
gap: 0.5em;
width: 100%;
padding: 1em;
border-top: 1px solid var(--primary-low);
button {
white-space: nowrap;
align-self: start;
.d-button-label {
@include ellipsis;
}
@include breakpoint("tablet") {
flex: 1 1 0;
&:first-child:last-child {
// if there's only one button,
// don't expand the width
flex: 0 1 auto;
}
&.toggle-results:first-child {
// don't expand the back button
flex: 0 1 auto;
margin-right: auto;
}
}
@include breakpoint("mobile-large") {
&:first-child:last-child,
&.cast-votes {
// ok to expand button width on smaller screens
flex: 1 1 100%;
}
}
}
&:empty {
display: none;
}
}
@include breakpoint("mobile-extra-large", min-width) {
padding-top: 1.25em;
&:not(:empty) {
border-top: 1px solid var(--primary-low);
}
}
@include breakpoint("mobile-extra-large") {
padding-right: 1em;
height: 100%;
flex: 1 1 auto;
&:not(:empty) {
border-right: 1px solid var(--primary-low);
}
}
.poll-voters:not(:empty) {
min-height: 30px;
margin-bottom: 0.25em;
li {
display: flex;
gap: 0.5em;
&:not(:last-child) {
margin-bottom: 0.5em;
}
display: inline;
}
}
@include breakpoint("mobile-extra-large", min-width) {
&:not(:last-child) {
margin-bottom: 1em;
}
&:not(:first-child:last-child) {
// only applied when there are multiple items
.d-icon {
width: 15%;
}
span {
width: 85%;
}
}
&:first-child:last-child {
// when there's a single item, it looks better centered
display: inline;
text-align: center;
}
.poll-voters-toggle-expand {
width: 100%;
text-align: center;
.spinner {
margin-top: 0.25em;
}
}
// .poll-buttons-dropdown {
// align-self: stretch;
// position: relative;
// .label {
// display: none;
// }
// .widget-dropdown {
// &.closed {
// :not(:first-child) {
// display: none;
// }
// }
// &.opened {
// display: flex;
// flex-direction: column;
// position: relative;
// overflow-y: visible;
// .widget-dropdown-body {
// display: block;
// position: absolute;
// z-index: 300;
// overflow-y: visible;
// transform: translate(-44px, 38px);
// .widget-dropdown-item {
// width: 100%;
// padding: 0;
// margin: 0;
// float: left;
// &:hover {
// color: var(--tertiary);
// background-color: var(--primary-low);
// }
// button {
// width: 100%;
// padding: 0.5em;
// display: flex;
// flex-direction: row;
// background-color: var(--secondary);
// &:hover {
// background-color: var(--primary-low);
// }
// border: none;
// }
// }
// }
// }
// &-header {
// height: 100%;
// .d-icon {
// margin: 0;
// }
// }
// }
// }
.poll-buttons-dropdown,
.export-results,
.toggle-status,
.show-breakdown {
// we want these controls to be separated
// from voting controls
margin-left: auto;
}
.results {
> li {
cursor: default;
padding: 0.25em 0;
&:last-child {
padding-bottom: 0;
}
}
.d-icon {
font-size: var(--font-down-1);
margin-top: 0.2em;
}
}
.info-text {
margin: 0.25em 0;
display: block;
}
@include breakpoint("mobile-extra-large", min-width) {
.info-label {
font-size: var(--font-up-2);
}
.info-number {
font-size: var(--font-up-6);
}
}
}
.poll-container {
box-sizing: border-box;
grid-area: poll;
padding: 1em;
width: 100%;
overflow: hidden;
align-self: center;
li {
cursor: pointer;
font-size: var(--font-up-1);
}
.poll-results-number-rating {
font-size: var(--font-up-5);
}
}
.poll-title {
border-bottom: 1px solid var(--primary-low);
margin-bottom: 0.5em;
padding-bottom: 0.5em;
}
.poll-buttons {
box-sizing: border-box;
grid-area: buttons;
display: flex;
flex-wrap: wrap;
gap: 0.5em;
width: 100%;
padding: 1em;
border-top: 1px solid var(--primary-low);
button {
white-space: nowrap;
align-self: start;
.d-button-label {
@include ellipsis;
}
@include breakpoint("tablet") {
flex: 1 1 0;
&:first-child:last-child {
// if there's only one button,
// don't expand the width
flex: 0 1 auto;
}
&.toggle-results:first-child {
// don't expand the back button
flex: 0 1 auto;
margin-right: auto;
}
}
@include breakpoint("mobile-large") {
&:first-child:last-child,
&.cast-votes {
// ok to expand button width on smaller screens
flex: 1 1 100%;
}
}
}
&:empty {
display: none;
}
}
.poll-voters:not(:empty) {
min-height: 30px;
margin-bottom: 0.25em;
li {
display: inline;
}
}
.poll-voters-toggle-expand {
width: 100%;
text-align: center;
.spinner {
margin-top: 0.25em;
}
}
.poll-buttons-dropdown {
align-self: stretch;
.label {
display: none;
}
.widget-dropdown {
height: 100%;
&-header {
height: 100%;
.d-icon {
.option {
p {
margin: 0;
}
}
}
}
.poll-buttons-dropdown,
.export-results,
.toggle-status,
.show-breakdown {
// we want these controls to be separated
// from voting controls
margin-left: auto;
}
.results {
> li {
cursor: default;
padding: 0.25em 0;
&:last-child {
padding-bottom: 0;
.percentage {
float: right;
color: var(--primary-medium);
margin-left: 0.25em;
}
}
.option {
p {
margin: 0;
.bar-back {
background: var(--primary-low);
}
.bar {
height: 0.75em;
background: var(--primary-medium);
}
.chosen .bar {
background: var(--tertiary);
}
}
.percentage {
float: right;
color: var(--primary-medium);
margin-left: 0.25em;
.pie-chart-legends {
justify-content: center;
text-align: center;
margin-top: 0.5em;
.legend {
align-items: center;
cursor: pointer;
display: inline-flex;
flex-direction: row;
margin-left: 1em;
font-size: var(--font-down-0);
.swatch {
margin-right: 0.5em;
display: inline-block;
height: 16px;
width: 16px;
}
}
}
.bar-back {
background: var(--primary-low);
.poll-grouped-pies-controls {
display: flex;
justify-content: space-between;
}
.bar {
height: 0.75em;
background: var(--primary-medium);
}
.chosen .bar {
background: var(--tertiary);
.poll-results-chart {
overflow-y: auto;
overflow-x: hidden;
}
}
@ -359,38 +437,6 @@ div.poll {
margin-right: 0.25em;
}
}
.pie-chart-legends {
justify-content: center;
text-align: center;
margin-top: 0.5em;
.legend {
align-items: center;
cursor: pointer;
display: inline-flex;
flex-direction: row;
margin-left: 1em;
font-size: var(--font-down-0);
.swatch {
margin-right: 0.5em;
display: inline-block;
height: 16px;
width: 16px;
}
}
}
.poll-grouped-pies-controls {
display: flex;
justify-content: space-between;
}
.poll-results-chart {
overflow-y: auto;
overflow-x: hidden;
}
}
.d-editor-preview {

View File

@ -22,7 +22,6 @@ en:
title: "Results will be shown once <strong>closed</strong>."
staff:
title: "Results are only shown to <strong>staff</strong> members."
multiple:
help:
at_least_min_options:
@ -80,7 +79,7 @@ en:
breakdown: "Breakdown"
percentage: "Percentage"
count: "Count"
options:
label: "Options"

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true
class DiscoursePoll::Poll
MULTIPLE = "multiple"
REGULAR = "regular"
def self.vote(user, post_id, poll_name, options)
poll_id = nil
@ -9,6 +12,7 @@ class DiscoursePoll::Poll
poll_id = poll.id
# remove options that aren't available in the poll
available_options = poll.poll_options.map { |o| o.digest }.to_set
options.select! { |o| available_options.include?(o) }
if options.empty?
@ -35,17 +39,21 @@ class DiscoursePoll::Poll
PollVote.where(poll: poll, user: user).where.not(poll_option_id: new_option_ids).delete_all
# create missing votes
(new_option_ids - old_option_ids).each do |option_id|
creation_set = new_option_ids - old_option_ids
creation_set.each do |option_id|
PollVote.create!(poll: poll, user: user, poll_option_id: option_id)
end
end
# Ensure consistency here as we do not have a unique index to limit the
# number of votes per the poll's configuration.
is_multiple = serialized_poll[:type] == "multiple"
is_multiple = serialized_poll[:type] == MULTIPLE
offset = is_multiple ? (serialized_poll[:max] || serialized_poll[:options].length) : 1
DB.query(<<~SQL, poll_id: poll_id, user_id: user.id, offset: offset)
params = { poll_id: poll_id, offset: offset, user_id: user.id }
DB.query(<<~SQL, params)
DELETE FROM poll_votes
USING (
SELECT
@ -61,6 +69,18 @@ class DiscoursePoll::Poll
AND poll_votes.user_id = to_delete_poll_votes.user_id
SQL
if serialized_poll[:type] == MULTIPLE
serialized_poll[:options].each do |option|
option.merge!(
chosen:
PollVote
.joins(:poll_option)
.where(poll_options: { digest: option[:id] }, user_id: user.id, poll_id: poll_id)
.exists?,
)
end
end
[serialized_poll, options]
end
@ -159,7 +179,9 @@ class DiscoursePoll::Poll
result = { option_digest => user_hashes }
else
votes = DB.query <<~SQL
params = { poll_id: poll.id, offset: offset, offset_plus_limit: offset + limit }
votes = DB.query(<<~SQL, params)
SELECT digest, user_id
FROM (
SELECT digest
@ -167,10 +189,10 @@ class DiscoursePoll::Poll
, ROW_NUMBER() OVER (PARTITION BY poll_option_id ORDER BY pv.created_at) AS row
FROM poll_votes pv
JOIN poll_options po ON pv.poll_option_id = po.id
WHERE pv.poll_id = #{poll.id}
AND po.poll_id = #{poll.id}
WHERE pv.poll_id = :poll_id
AND po.poll_id = :poll_id
) v
WHERE row BETWEEN #{offset} AND #{offset + limit}
WHERE row BETWEEN :offset AND :offset_plus_limit
SQL
user_ids = votes.map(&:user_id).uniq
@ -292,7 +314,7 @@ class DiscoursePoll::Poll
post_id: post_id,
name: poll["name"].presence || "poll",
close_at: close_at,
type: poll["type"].presence || "regular",
type: poll["type"].presence || REGULAR,
status: poll["status"].presence || "open",
visibility: poll["public"] == "true" ? "everyone" : "secret",
title: poll["title"],

View File

@ -65,14 +65,15 @@ acceptance("Poll breakdown", function (needs) {
test("Displaying the poll breakdown modal", async function (assert) {
await visit("/t/-/topic_with_pie_chart_poll");
await click(".widget-dropdown-header");
assert.ok(
exists(".item-showBreakdown"),
exists("button.show-breakdown"),
"shows the breakdown button when poll_groupable_user_fields is non-empty"
);
await click(".item-showBreakdown");
await click("button.show-breakdown");
assert.ok(exists(".poll-breakdown-total-votes"), "displays the vote count");
@ -91,7 +92,8 @@ acceptance("Poll breakdown", function (needs) {
test("Changing the display mode from percentage to count", async function (assert) {
await visit("/t/-/topic_with_pie_chart_poll");
await click(".widget-dropdown-header");
await click(".item-showBreakdown");
await click("button.show-breakdown");
assert.strictEqual(
query(".poll-breakdown-option-count").textContent.trim(),

View File

@ -20,7 +20,7 @@ acceptance("Rendering polls with pie charts", function (needs) {
.dom(".poll .poll-info_counts-count:last-child .info-number")
.hasText("5", "it should display the right number of votes");
assert.dom(".poll").hasClass("pie", "pie class is present on poll div");
assert.dom(".poll-outer").hasClass("pie", "pie class is present on poll div");
assert
.dom(".poll .poll-results-chart")

View File

@ -552,7 +552,26 @@ acceptance("Poll results", function (needs) {
}
});
server.delete("/polls/vote", () => helper.response({ success: "OK" }));
server.delete("/polls/vote", () =>
helper.response({
success: "OK",
poll: {
options: [
{
id: "db753fe0bc4e72869ac1ad8765341764",
html: 'Option <span class="hashtag">#1</span>',
votes: 0,
},
{
id: "d8c22ff912e03740d9bc19e133e581e0",
html: 'Option <span class="hashtag">#2</span>',
votes: 0,
},
],
voters: 0,
},
})
);
});
test("can load more voters", async function (assert) {
@ -620,12 +639,13 @@ acceptance("Poll results", function (needs) {
count(".poll-container .results li:nth-child(1) .poll-voters li"),
1
);
assert.strictEqual(
count(".poll-container .results li:nth-child(2) .poll-voters li"),
1
);
await click(".poll-voters-toggle-expand a");
await click(".poll-voters-toggle-expand");
await visit("/t/load-more-poll-voters/134");
assert.strictEqual(
@ -640,6 +660,7 @@ acceptance("Poll results", function (needs) {
test("can unvote", async function (assert) {
await visit("/t/load-more-poll-voters/134");
await click(".toggle-results");
assert.strictEqual(count(".poll-container .d-icon-circle"), 1);

View File

@ -0,0 +1,44 @@
import { click, render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { count, query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
module("Poll | Component | poll-buttons-dropdown", function (hooks) {
setupRenderingTest(hooks);
test("Renders a clickable dropdown menu with a close option", async function (assert) {
this.setProperties({
closed: false,
voters: [],
isStaff: true,
isMe: false,
topicArchived: false,
groupableUserFields: [],
isAutomaticallyClosed: false,
dropDownClick: () => {},
});
await render(hbs`<PollButtonsDropdown
@closed={{this.closed}}
@voters={{this.voters}}
@isStaff={{this.isStaff}}
@isMe={{this.isMe}}
@topicArchived={{this.topicArchived}}
@groupableUserFields={{this.groupableUserFields}}
@isAutomaticallyClosed={{this.isAutomaticallyClosed}}
@dropDownClick={{this.dropDownClick}}
/>`);
await click(".widget-dropdown-header");
assert.strictEqual(count("li.dropdown-menu__item"), 1);
assert.strictEqual(
query("li.dropdown-menu__item span").textContent.trim(),
I18n.t("poll.close.label"),
"displays the poll Close action"
);
});
});

View File

@ -0,0 +1,62 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { query } from "discourse/tests/helpers/qunit-helpers";
import I18n from "I18n";
const OPTIONS = [
{ id: "1ddc47be0d2315b9711ee8526ca9d83f", html: "This", votes: 2, rank: 0 },
{ id: "70e743697dac09483d7b824eaadb91e1", html: "That", votes: 3, rank: 0 },
{ id: "6c986ebcde3d5822a6e91a695c388094", html: "Other", votes: 5, rank: 0 },
];
module("Poll | Component | poll-info", function (hooks) {
setupRenderingTest(hooks);
test("multiple poll", async function (assert) {
this.setProperties({
isMultiple: true,
min: 1,
max: 2,
options: OPTIONS,
close: null,
closed: false,
results: [],
showResults: false,
postUserId: 59,
isPublic: true,
hasVoted: true,
voters: [],
});
await render(hbs`<PollInfo
@options={{this.options}}
@min={{this.min}}
@max={{this.max}}
@isMultiple={{this.isMultiple}}
@close={{this.close}}
@closed={{this.closed}}
@results={{this.results}}
@showResults={{this.showResults}}
@postUserId={{this.postUserId}}
@isPublic={{this.isPublic}}
@hasVoted={{this.hasVoted}}
@voters={{this.voters}}
/>`);
assert.strictEqual(
query(".poll-info_instructions li.multiple-help-text").textContent.trim(),
I18n.t("poll.multiple.help.up_to_max_options", {
count: this.max,
}).replace(/<\/?[^>]+(>|$)/g, ""),
"displays the multiple help text"
);
assert.strictEqual(
query(".poll-info_instructions li.is-public").textContent.trim(),
I18n.t("poll.public.title").replace(/<\/?[^>]+(>|$)/g, ""),
"displays the public label"
);
});
});

View File

@ -0,0 +1,83 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { count } from "discourse/tests/helpers/qunit-helpers";
const OPTIONS = [
{ id: "1ddc47be0d2315b9711ee8526ca9d83f", html: "This", votes: 0, rank: 0 },
{ id: "70e743697dac09483d7b824eaadb91e1", html: "That", votes: 0, rank: 0 },
{ id: "6c986ebcde3d5822a6e91a695c388094", html: "Other", votes: 0, rank: 0 },
];
module("Poll | Component | poll-options", function (hooks) {
setupRenderingTest(hooks);
test("single, not selected", async function (assert) {
this.setProperties({
isCheckbox: false,
options: OPTIONS,
votes: [],
});
await render(hbs`<PollOptions
@isCheckbox={{this.isCheckbox}}
@options={{this.options}}
@votes={{this.votes}}
@sendRadioClick={{this.toggleOption}}
/>`);
assert.strictEqual(count("li .d-icon-far-circle:nth-of-type(1)"), 3);
});
test("single, selected", async function (assert) {
this.setProperties({
isCheckbox: false,
options: OPTIONS,
votes: ["6c986ebcde3d5822a6e91a695c388094"],
});
await render(hbs`<PollOptions
@isCheckbox={{this.isCheckbox}}
@options={{this.options}}
@votes={{this.votes}}
@sendRadioClick={{this.toggleOption}}
/>`);
assert.strictEqual(count("li .d-icon-circle:nth-of-type(1)"), 1);
});
test("multi, not selected", async function (assert) {
this.setProperties({
isCheckbox: true,
options: OPTIONS,
votes: [],
});
await render(hbs`<PollOptions
@isCheckbox={{this.isCheckbox}}
@options={{this.options}}
@votes={{this.votes}}
@sendRadioClick={{this.toggleOption}}
/>`);
assert.strictEqual(count("li .d-icon-far-square:nth-of-type(1)"), 3);
});
test("multi, selected", async function (assert) {
this.setProperties({
isCheckbox: true,
options: OPTIONS,
votes: ["6c986ebcde3d5822a6e91a695c388094"],
});
await render(hbs`<PollOptions
@isCheckbox={{this.isCheckbox}}
@options={{this.options}}
@votes={{this.votes}}
@sendRadioClick={{this.toggleOption}}
/>`);
assert.strictEqual(count("li .d-icon-far-check-square:nth-of-type(1)"), 1);
});
});

View File

@ -0,0 +1,32 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { count } from "discourse/tests/helpers/qunit-helpers";
const OPTIONS = [
{ id: "1ddc47be0d2315b9711ee8526ca9d83f", html: "This", votes: 3, rank: 0 },
{ id: "70e743697dac09483d7b824eaadb91e1", html: "That", votes: 1, rank: 0 },
{ id: "6c986ebcde3d5822a6e91a695c388094", html: "Other", votes: 2, rank: 0 },
];
const ID = "23";
module("Poll | Component | poll-results-pie", function (hooks) {
setupRenderingTest(hooks);
test("Renders the pie chart Component correctly", async function (assert) {
this.setProperties({
id: ID,
options: OPTIONS,
});
await render(
hbs`<PollResultsPie @id={{this.id}} @options={{this.options}} />`
);
assert.strictEqual(count("li.legend"), 3);
assert.strictEqual(count("canvas.poll-results-canvas"), 1);
});
});

View File

@ -0,0 +1,132 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { queryAll } from "discourse/tests/helpers/qunit-helpers";
const TWO_OPTIONS = [
{ id: "1ddc47be0d2315b9711ee8526ca9d83f", html: "This", votes: 5, rank: 0 },
{ id: "70e743697dac09483d7b824eaadb91e1", html: "That", votes: 4, rank: 0 },
];
const TWO_OPTIONS_REVERSED = [
{ id: "1ddc47be0d2315b9711ee8526ca9d83f", html: "This", votes: 4, rank: 0 },
{ id: "70e743697dac09483d7b824eaadb91e1", html: "That", votes: 5, rank: 0 },
];
const FIVE_OPTIONS = [
{ id: "1ddc47be0d2315b9711ee8526ca9d83f", html: "a", votes: 5, rank: 0 },
{ id: "70e743697dac09483d7b824eaadb91e1", html: "b", votes: 2, rank: 0 },
{ id: "6c986ebcde3d5822a6e91a695c388094", html: "c", votes: 4, rank: 0 },
{ id: "3e4a8f7e5f9d02f2bd7aebdb345c6ec2", html: "b", votes: 1, rank: 0 },
{ id: "a5e3e8c77ac2b1f43dd98ee6ff9d34e3", html: "a", votes: 1, rank: 0 },
];
const PRELOADEDVOTERS = {
db753fe0bc4e72869ac1ad8765341764: [
{
id: 1,
username: "bianca",
name: null,
avatar_template: "/letter_avatar_proxy/v4/letter/b/3be4f8/{size}.png",
},
],
};
module("Poll | Component | poll-results-standard", function (hooks) {
setupRenderingTest(hooks);
test("Renders the standard results Component correctly", async function (assert) {
this.setProperties({
options: TWO_OPTIONS,
pollName: "Two Choice Poll",
pollType: "single",
postId: 123,
vote: ["1ddc47be0d2315b9711ee8526ca9d83f"],
voters: PRELOADEDVOTERS,
votersCount: 9,
fetchVoters: () => {},
});
await render(hbs`<PollResultsStandard
@options={{this.options}}
@pollName={{this.pollName}}
@pollType={{this.pollType}}
@postId={{this.postId}}
@vote={{this.vote}}
@voters={{this.voters}}
@votersCount={{this.votersCount}}
@fetchVoters={{this.fetchVoters}}
/>`);
assert.strictEqual(queryAll(".option .percentage")[0].innerText, "56%");
assert.strictEqual(queryAll(".option .percentage")[1].innerText, "44%");
});
test("options in ascending order", async function (assert) {
this.setProperties({
options: TWO_OPTIONS_REVERSED,
pollName: "Two Choice Poll",
pollType: "single",
postId: 123,
vote: ["1ddc47be0d2315b9711ee8526ca9d83f"],
voters: PRELOADEDVOTERS,
votersCount: 9,
fetchVoters: () => {},
});
await render(hbs`<PollResultsStandard
@options={{this.options}}
@pollName={{this.pollName}}
@pollType={{this.pollType}}
@postId={{this.postId}}
@vote={{this.vote}}
@voters={{this.voters}}
@votersCount={{this.votersCount}}
@fetchVoters={{this.fetchVoters}}
/>`);
assert.strictEqual(queryAll(".option .percentage")[0].innerText, "56%");
assert.strictEqual(queryAll(".option .percentage")[1].innerText, "44%");
});
test("options in ascending order", async function (assert) {
this.setProperties({
options: FIVE_OPTIONS,
pollName: "Five Multi Option Poll",
pollType: "multiple",
postId: 123,
vote: ["1ddc47be0d2315b9711ee8526ca9d83f"],
voters: PRELOADEDVOTERS,
votersCount: 12,
fetchVoters: () => {},
});
await render(hbs`<PollResultsStandard
@options={{this.options}}
@pollName={{this.pollName}}
@pollType={{this.pollType}}
@postId={{this.postId}}
@vote={{this.vote}}
@voters={{this.voters}}
@votersCount={{this.votersCount}}
@fetchVoters={{this.fetchVoters}}
/>`);
let percentages = queryAll(".option .percentage");
assert.strictEqual(percentages[0].innerText, "41%");
assert.strictEqual(percentages[1].innerText, "33%");
assert.strictEqual(percentages[2].innerText, "16%");
assert.strictEqual(percentages[3].innerText, "8%");
assert.strictEqual(
queryAll(".option")[3].querySelectorAll("span")[1].innerText,
"a"
);
assert.strictEqual(percentages[4].innerText, "8%");
assert.strictEqual(
queryAll(".option")[4].querySelectorAll("span")[1].innerText,
"b"
);
});
});

View File

@ -14,7 +14,7 @@ import I18n from "discourse-i18n";
let requests = 0;
module("Integration | Component | Widget | discourse-poll", function (hooks) {
module("Poll | Component | poll", function (hooks) {
setupRenderingTest(hooks);
hooks.beforeEach(function () {
@ -47,14 +47,14 @@ module("Integration | Component | Widget | discourse-poll", function (hooks) {
});
test("can vote", async function (assert) {
this.set(
"args",
EmberObject.create({
this.setProperties({
attributes: EmberObject.create({
post: EmberObject.create({
id: 42,
topic: {
archived: false,
},
user_id: 29,
}),
poll: EmberObject.create({
name: "poll",
@ -70,16 +70,23 @@ module("Integration | Component | Widget | discourse-poll", function (hooks) {
}),
vote: [],
groupableUserFields: [],
})
);
}),
preloadedVoters: [],
options: [
{ id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 },
{ id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 },
],
});
await render(
hbs`<MountWidget @widget="discourse-poll" @args={{this.args}} />`
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} @options={{this.options}} />`
);
requests = 0;
await click("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']");
await click(
"li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29'] button"
);
assert.strictEqual(requests, 1);
assert.strictEqual(count(".chosen"), 1);
assert.deepEqual(
@ -95,14 +102,14 @@ module("Integration | Component | Widget | discourse-poll", function (hooks) {
});
test("cannot vote if not member of the right group", async function (assert) {
this.set(
"args",
EmberObject.create({
this.setProperties({
attributes: EmberObject.create({
post: EmberObject.create({
id: 42,
topic: {
archived: false,
},
user_id: 29,
}),
poll: EmberObject.create({
name: "poll",
@ -119,16 +126,23 @@ module("Integration | Component | Widget | discourse-poll", function (hooks) {
}),
vote: [],
groupableUserFields: [],
})
);
}),
preloadedVoters: [],
options: [
{ id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 },
{ id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 },
],
});
await render(
hbs`<MountWidget @widget="discourse-poll" @args={{this.args}} />`
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} @options={{this.options}} />`
);
requests = 0;
await click("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']");
await click(
"li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29'] button"
);
assert.strictEqual(
query(".poll-container .alert").innerText,
I18n.t("poll.results.groups.title", { groups: "foo" })
@ -138,14 +152,14 @@ module("Integration | Component | Widget | discourse-poll", function (hooks) {
});
test("voting on a multiple poll with no min attribute", async function (assert) {
this.set(
"args",
EmberObject.create({
this.setProperties({
attributes: EmberObject.create({
post: EmberObject.create({
id: 42,
topic: {
archived: false,
},
user_id: 29,
}),
poll: EmberObject.create({
name: "poll",
@ -162,15 +176,23 @@ module("Integration | Component | Widget | discourse-poll", function (hooks) {
}),
vote: [],
groupableUserFields: [],
})
);
}),
preloadedVoters: [],
options: [
{ id: "1f972d1df351de3ce35a787c89faad29", html: "yes", votes: 0 },
{ id: "d7ebc3a9beea2e680815a1e4f57d6db6", html: "no", votes: 0 },
],
});
await render(
hbs`<MountWidget @widget="discourse-poll" @args={{this.args}} />`
hbs`<Poll @attrs={{this.attributes}} @preloadedVoters={{this.preloadedVoters}} @options={{this.options}} />`
);
assert.ok(exists(".poll-buttons .cast-votes:disabled"));
await click(
"li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29'] button"
);
assert.ok(exists(".poll-buttons .cast-votes[disabled=true]"));
await click("li[data-poll-option-id='1f972d1df351de3ce35a787c89faad29']");
await click(".poll-buttons .cast-votes");
assert.ok(exists(".chosen"));
});

View File

@ -1,68 +0,0 @@
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { count } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | Widget | discourse-poll-option",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`
<MountWidget
@widget="discourse-poll-option"
@args={{hash
option=this.option
isMultiple=this.isMultiple
vote=this.vote
}}
/>
`;
test("single, not selected", async function (assert) {
this.set("option", { id: "opt-id" });
this.set("vote", []);
await render(template);
assert.strictEqual(count("li .d-icon-far-circle:nth-of-type(1)"), 1);
});
test("single, selected", async function (assert) {
this.set("option", { id: "opt-id" });
this.set("vote", ["opt-id"]);
await render(template);
assert.strictEqual(count("li .d-icon-circle:nth-of-type(1)"), 1);
});
test("multi, not selected", async function (assert) {
this.setProperties({
option: { id: "opt-id" },
isMultiple: true,
vote: [],
});
await render(template);
assert.strictEqual(count("li .d-icon-far-square:nth-of-type(1)"), 1);
});
test("multi, selected", async function (assert) {
this.setProperties({
option: { id: "opt-id" },
isMultiple: true,
vote: ["opt-id"],
});
await render(template);
assert.strictEqual(
count("li .d-icon-far-check-square:nth-of-type(1)"),
1
);
});
}
);

View File

@ -1,86 +0,0 @@
import EmberObject from "@ember/object";
import { render } from "@ember/test-helpers";
import hbs from "htmlbars-inline-precompile";
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { queryAll } from "discourse/tests/helpers/qunit-helpers";
module(
"Integration | Component | Widget | discourse-poll-standard-results",
function (hooks) {
setupRenderingTest(hooks);
const template = hbs`
<MountWidget
@widget="discourse-poll-standard-results"
@args={{hash poll=this.poll isMultiple=this.isMultiple}}
/>
`;
test("options in descending order", async function (assert) {
this.set(
"poll",
EmberObject.create({
options: [{ votes: 5 }, { votes: 4 }],
voters: 9,
})
);
await render(template);
assert.strictEqual(queryAll(".option .percentage")[0].innerText, "56%");
assert.strictEqual(queryAll(".option .percentage")[1].innerText, "44%");
});
test("options in ascending order", async function (assert) {
this.set(
"poll",
EmberObject.create({
options: [{ votes: 4 }, { votes: 5 }],
voters: 9,
})
);
await render(template);
assert.strictEqual(queryAll(".option .percentage")[0].innerText, "56%");
assert.strictEqual(queryAll(".option .percentage")[1].innerText, "44%");
});
test("multiple options in descending order", async function (assert) {
this.set("isMultiple", true);
this.set(
"poll",
EmberObject.create({
type: "multiple",
options: [
{ votes: 5, html: "a" },
{ votes: 2, html: "b" },
{ votes: 4, html: "c" },
{ votes: 1, html: "b" },
{ votes: 1, html: "a" },
],
voters: 12,
})
);
await render(template);
let percentages = queryAll(".option .percentage");
assert.strictEqual(percentages[0].innerText, "41%");
assert.strictEqual(percentages[1].innerText, "33%");
assert.strictEqual(percentages[2].innerText, "16%");
assert.strictEqual(percentages[3].innerText, "8%");
assert.strictEqual(
queryAll(".option")[3].querySelectorAll("span")[1].innerText,
"a"
);
assert.strictEqual(percentages[4].innerText, "8%");
assert.strictEqual(
queryAll(".option")[4].querySelectorAll("span")[1].innerText,
"b"
);
});
}
);