import I18n from "I18n";
import { PIE_CHART_TYPE } from "discourse/plugins/poll/controllers/poll-ui-builder";
import RawHtml from "discourse/widgets/raw-html";
import { ajax } from "discourse/lib/ajax";
import { avatarFor } from "discourse/widgets/post";
import { createWidget } from "discourse/widgets/widget";
import evenRound from "discourse/plugins/poll/lib/even-round";
import { getColors } from "discourse/plugins/poll/lib/chart-colors";
import { h } from "virtual-dom";
import { iconNode } from "discourse-common/lib/icon-library";
import loadScript from "discourse/lib/load-script";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { relativeAge } from "discourse/lib/formatter";
import round from "discourse/lib/round";
import showModal from "discourse/lib/show-modal";
import { applyLocalDates } from "discourse/lib/local-dates";
const FETCH_VOTERS_COUNT = 25;
function optionHtml(option, siteSettings = {}) {
const el = document.createElement("span");
el.innerHTML = option.html;
applyLocalDates(el.querySelectorAll(".discourse-local-date"), siteSettings);
return new RawHtml({ html: `${el.innerHTML}` });
}
function infoTextHtml(text) {
return new RawHtml({
html: `${text}`,
});
}
function 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));
}
createWidget("discourse-poll-option", {
tagName: "li",
buildAttributes(attrs) {
return { tabindex: 0, "data-poll-option-id": attrs.option.id };
},
html(attrs) {
const contents = [];
const { option, vote } = attrs;
const chosen = vote.includes(option.id);
if (attrs.isMultiple) {
contents.push(iconNode(chosen ? "far-check-square" : "far-square"));
} else {
contents.push(iconNode(chosen ? "circle" : "far-circle"));
}
contents.push(" ");
contents.push(optionHtml(option, this.siteSettings));
return contents;
},
click(e) {
if (!e.target.closest("a")) {
this.sendWidgetAction("toggleOption", this.attrs.option);
}
},
keyDown(e) {
if (e.key === "Enter") {
this.click(e);
}
},
});
createWidget("discourse-poll-load-more", {
tagName: "div.poll-voters-toggle-expand",
buildKey: (attrs) => `load-more-${attrs.optionId}`,
defaultState() {
return { loading: false };
},
html(attrs, state) {
return state.loading
? h("div.spinner.small")
: h("a", iconNode("chevron-down"));
},
click() {
const { state, attrs } = this;
if (state.loading) {
return;
}
state.loading = true;
return this.sendWidgetAction("fetchVoters", attrs.optionId).finally(
() => (state.loading = false)
);
},
});
createWidget("discourse-poll-voters", {
tagName: "ul.poll-voters-list",
buildKey: (attrs) => `poll-voters-${attrs.optionId}`,
html(attrs) {
const contents = attrs.voters.map((user) =>
h("li", [
avatarFor("tiny", {
username: user.username,
template: user.avatar_template,
}),
" ",
])
);
if (attrs.voters.length < attrs.totalVotes) {
contents.push(this.attach("discourse-poll-load-more", attrs));
}
return h("div.poll-voters", contents);
},
});
createWidget("discourse-poll-standard-results", {
tagName: "ul.results",
buildKey: (attrs) => `poll-standard-results-${attrs.id}`,
html(attrs) {
const { poll } = attrs;
const options = poll.options;
if (options) {
const voters = poll.voters;
const isPublic = poll.public;
const ordered = [...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;
}
});
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();
const chosen = (attrs.vote || []).includes(option.id);
contents.push(
h(
"div.option",
h("p", [
h("span.percentage", `${per}%`),
optionHtml(option, this.siteSettings),
])
)
);
contents.push(
h(
"div.bar-back",
h("div.bar", { attributes: { style: `width:${per}%` } })
)
);
if (isPublic) {
contents.push(
this.attach("discourse-poll-voters", {
postId: attrs.post.id,
optionId: option.id,
pollName: poll.name,
totalVotes: option.votes,
voters: (attrs.voters && attrs.voters[option.id]) || [],
})
);
}
return h("li", { className: `${chosen ? "chosen" : ""}` }, contents);
});
}
},
});
createWidget("discourse-poll-number-results", {
buildKey: (attrs) => `poll-number-results-${attrs.id}`,
html(attrs) {
const { poll } = attrs;
const totalScore = poll.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 contents = [
h(
"div.poll-results-number-rating",
new RawHtml({ html: `${averageRating}` })
),
];
if (poll.public) {
contents.push(
this.attach("discourse-poll-voters", {
totalVotes: poll.voters,
voters: attrs.voters || [],
postId: attrs.post.id,
pollName: poll.name,
pollType: poll.type,
})
);
}
return contents;
},
});
createWidget("discourse-poll-container", {
tagName: "div.poll-container",
buildKey: (attrs) => `poll-container-${attrs.id}`,
services: ["dialog"],
defaultState() {
return { voters: [] };
},
html(attrs, state) {
const { poll } = attrs;
const options = poll.options;
if (attrs.showResults) {
const contents = [];
if (attrs.titleHTML) {
contents.push(new RawHtml({ html: attrs.titleHTML }));
}
if (poll.public) {
state.voters = poll.preloaded_voters;
}
const type = poll.type === "number" ? "number" : "standard";
const resultsWidget =
type === "number" || attrs.poll.chart_type !== PIE_CHART_TYPE
? `discourse-poll-${type}-results`
: "discourse-poll-pie-chart";
contents.push(
this.attach(
resultsWidget,
Object.assign({}, attrs, { voters: state.voters })
)
);
return contents;
} else if (options) {
const contents = [];
if (attrs.titleHTML) {
contents.push(new RawHtml({ html: attrs.titleHTML }));
}
if (!checkUserGroups(this.currentUser, poll)) {
contents.push(
h(
"div.alert.alert-danger",
I18n.t("poll.results.groups.title", { groups: poll.groups })
)
);
}
contents.push(
h(
"ul",
options.map((option) => {
return this.attach("discourse-poll-option", {
option,
isMultiple: attrs.isMultiple,
vote: attrs.vote,
});
})
)
);
return contents;
}
},
fetchVoters(optionId) {
const { attrs, state } = this;
let votersCount;
if (optionId) {
if (!state.voters) {
state.voters = {};
}
if (!state.voters[optionId]) {
state.voters[optionId] = [];
}
votersCount = state.voters[optionId].length;
} else {
if (!state.voters) {
state.voters = [];
}
votersCount = state.voters.length;
}
return ajax("/polls/voters.json", {
data: {
post_id: attrs.post.id,
poll_name: attrs.poll.name,
option_id: optionId,
page: Math.floor(votersCount / FETCH_VOTERS_COUNT) + 1,
limit: FETCH_VOTERS_COUNT,
},
})
.then((result) => {
const voters = optionId ? state.voters[optionId] : state.voters;
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 (attrs.poll.type === "regular") {
Object.keys(state.voters).forEach((otherOptionId) => {
if (optionId !== otherOptionId) {
state.voters[otherOptionId] = state.voters[otherOptionId].filter(
(voter) => !votersSet.has(voter.username)
);
}
});
}
this.scheduleRerender();
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_fetching_voters"));
}
});
},
});
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.voters;
const contents = [
h("p", [
h("span.info-number", count.toString()),
h("span.info-label", I18n.t("poll.voters", { count })),
]),
];
if (attrs.isMultiple) {
if (attrs.showResults || attrs.isClosed) {
const totalVotes = poll.options.reduce((total, o) => {
return total + parseInt(o.votes, 10);
}, 0);
contents.push(
h("p", [
h("span.info-number", totalVotes.toString()),
h(
"span.info-label",
I18n.t("poll.total_votes", { count: totalVotes })
),
])
);
} else {
const help = this.multipleHelpText(
attrs.min,
attrs.max,
poll.options.length
);
if (help) {
contents.push(infoTextHtml(help));
}
}
}
if (
!attrs.isClosed &&
!attrs.showResults &&
poll.public &&
poll.results !== "staff_only"
) {
contents.push(infoTextHtml(I18n.t("poll.public.title")));
}
return contents;
},
});
function clearPieChart(id) {
let el = document.querySelector(`#poll-results-chart-${id}`);
el && el.parentNode.removeChild(el);
}
createWidget("discourse-poll-pie-canvas", {
tagName: "canvas.poll-results-canvas",
init(attrs) {
loadScript("/javascripts/Chart.min.js").then(() => {
const data = attrs.poll.options.mapBy("votes");
const labels = attrs.poll.options.mapBy("html");
const config = pieChartConfig(data, labels, {
legendContainerId: `poll-results-legend-${attrs.id}`,
});
const el = document.getElementById(`poll-results-chart-${attrs.id}`);
// eslint-disable-next-line no-undef
this._chart = new Chart(el.getContext("2d"), config);
});
},
willRerenderWidget() {
this._chart?.destroy();
},
buildAttributes(attrs) {
return {
id: `poll-results-chart-${attrs.id}`,
};
},
});
createWidget("discourse-poll-pie-chart", {
tagName: "div.poll-results-chart",
html(attrs) {
const contents = [];
if (!attrs.showResults) {
clearPieChart(attrs.id);
return contents;
}
const chart = this.attach("discourse-poll-pie-canvas", attrs);
contents.push(chart);
contents.push(h(`ul#poll-results-legend-${attrs.id}.pie-chart-legends`));
return contents;
},
});
const 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);
});
},
};
function pieChartConfig(data, labels, opts = {}) {
const aspectRatio = "aspectRatio" in opts ? opts.aspectRatio : 2.2;
const strippedLabels = labels.map((l) => stripHtml(l));
return {
type: PIE_CHART_TYPE,
data: {
datasets: [
{
data,
backgroundColor: getColors(data.length),
},
],
labels: strippedLabels,
},
plugins: [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,
},
},
},
};
}
function stripHtml(html) {
let doc = new DOMParser().parseFromString(html, "text/html");
return doc.body.textContent || "";
}
createWidget("discourse-poll-buttons", {
tagName: "div.poll-buttons",
html(attrs) {
const contents = [];
const { poll, post } = attrs;
const topicArchived = post.get("topic.archived");
const closed = attrs.isClosed;
const staffOnly = poll.results === "staff_only";
const isStaff = this.currentUser && this.currentUser.staff;
const isAdmin = this.currentUser && this.currentUser.admin;
const isMe = this.currentUser && post.user_id === this.currentUser.id;
const dataExplorerEnabled = this.siteSettings.data_explorer_enabled;
const hideResultsDisabled = !staffOnly && (closed || topicArchived);
const exportQueryID = this.siteSettings.poll_export_data_explorer_query_id;
if (attrs.isMultiple && !hideResultsDisabled) {
const castVotesDisabled = !attrs.canCastVotes;
contents.push(
this.attach("button", {
className: `cast-votes ${
castVotesDisabled ? "btn-default" : "btn-primary"
}`,
label: "poll.cast-votes.label",
title: "poll.cast-votes.title",
disabled: castVotesDisabled,
action: "castVotes",
})
);
contents.push(" ");
}
if (attrs.showResults || hideResultsDisabled) {
contents.push(
this.attach("button", {
className: "btn-default toggle-results",
label: "poll.hide-results.label",
title: "poll.hide-results.title",
icon: "far-eye-slash",
disabled: hideResultsDisabled,
action: "toggleResults",
})
);
} else {
let showResultsButton;
let infoText;
if (poll.results === "on_vote" && !attrs.hasVoted && !isMe) {
infoText = infoTextHtml(I18n.t("poll.results.vote.title"));
} else if (poll.results === "on_close" && !closed) {
infoText = infoTextHtml(I18n.t("poll.results.closed.title"));
} else if (poll.results === "staff_only" && !isStaff) {
infoText = infoTextHtml(I18n.t("poll.results.staff.title"));
} else {
showResultsButton = this.attach("button", {
className: "btn-default toggle-results",
label: "poll.show-results.label",
title: "poll.show-results.title",
icon: "far-eye",
disabled: poll.voters === 0,
action: "toggleResults",
});
}
if (showResultsButton) {
contents.push(showResultsButton);
}
if (attrs.hasSavedVote) {
contents.push(
this.attach("button", {
className: "btn-default remove-vote",
label: "poll.remove-vote.label",
title: "poll.remove-vote.title",
icon: "trash-alt",
action: "removeVote",
})
);
}
if (infoText) {
contents.push(infoText);
}
}
if (attrs.groupableUserFields.length && poll.voters > 0) {
const button = this.attach("button", {
className: "btn-default poll-show-breakdown",
label: "poll.group-results.label",
title: "poll.group-results.title",
icon: "far-eye",
action: "showBreakdown",
});
contents.push(button);
}
if (isAdmin && dataExplorerEnabled && poll.voters > 0 && exportQueryID) {
contents.push(
this.attach("button", {
className: "btn btn-default export-results",
label: "poll.export-results.label",
title: "poll.export-results.title",
icon: "download",
disabled: poll.voters === 0,
action: "exportResults",
})
);
}
if (poll.close) {
const closeDate = moment(poll.close);
if (closeDate.isValid()) {
const title = closeDate.format("LLL");
let label;
if (attrs.isAutomaticallyClosed) {
const age = relativeAge(closeDate.toDate(), { addAgo: true });
label = I18n.t("poll.automatic_close.age", { age });
} else {
const timeLeft = moment().to(closeDate, true);
label = I18n.t("poll.automatic_close.closes_in", { timeLeft });
}
contents.push(
new RawHtml({
html: `${label}`,
})
);
}
}
if (
this.currentUser &&
(this.currentUser.id === post.user_id || isStaff) &&
!topicArchived
) {
if (closed) {
if (!attrs.isAutomaticallyClosed) {
contents.push(
this.attach("button", {
className: "btn-default toggle-status",
label: "poll.open.label",
title: "poll.open.title",
icon: "unlock-alt",
action: "toggleStatus",
})
);
}
} else {
contents.push(
this.attach("button", {
className: "toggle-status btn-danger",
label: "poll.close.label",
title: "poll.close.title",
icon: "lock",
action: "toggleStatus",
})
);
}
}
return contents;
},
});
export default createWidget("discourse-poll", {
tagName: "div",
buildKey: (attrs) => `poll-${attrs.id}`,
services: ["dialog"],
buildAttributes(attrs) {
let cssClasses = "poll";
if (attrs.poll.chart_type === PIE_CHART_TYPE) {
cssClasses += " pie";
}
return {
class: cssClasses,
"data-poll-name": attrs.poll.name,
"data-poll-type": attrs.poll.type,
};
},
defaultState(attrs) {
const { poll } = attrs;
const staffOnly = attrs.poll.results === "staff_only";
const showResults =
poll.results !== "on_close" && this.hasVoted() && !staffOnly;
return { loading: false, showResults };
},
html(attrs, state) {
const staffOnly = attrs.poll.results === "staff_only";
const showResults =
state.showResults ||
(attrs.post.get("topic.archived") && !staffOnly) ||
(this.isClosed() && !staffOnly);
const newAttrs = Object.assign({}, attrs, {
canCastVotes: this.canCastVotes(),
hasVoted: this.hasVoted(),
isAutomaticallyClosed: this.isAutomaticallyClosed(),
isClosed: this.isClosed(),
isMultiple: this.isMultiple(),
max: this.max(),
min: this.min(),
showResults,
});
return h("div", [
this.attach("discourse-poll-container", newAttrs),
this.attach("discourse-poll-info", newAttrs),
this.attach("discourse-poll-buttons", newAttrs),
]);
},
min() {
let min = parseInt(this.attrs.poll.min, 10);
if (isNaN(min) || min < 0) {
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;
},
isAutomaticallyClosed() {
const { poll } = this.attrs;
return poll.close && moment.utc(poll.close) <= moment();
},
isClosed() {
const { poll } = this.attrs;
return poll.status === "closed" || this.isAutomaticallyClosed();
},
isMultiple() {
const { poll } = this.attrs;
return poll.type === "multiple";
},
hasVoted() {
const { vote } = this.attrs;
return vote && vote.length > 0;
},
canCastVotes() {
const { state, attrs } = this;
if (this.isClosed() || state.showResults || state.loading) {
return false;
}
const selectedOptionCount = attrs.vote.length;
if (this.isMultiple()) {
return (
selectedOptionCount >= this.min() && selectedOptionCount <= this.max()
);
}
return selectedOptionCount > 0;
},
toggleStatus() {
const { state, attrs } = this;
const { post, poll } = attrs;
if (this.isAutomaticallyClosed()) {
return;
}
this.dialog.yesNoConfirm({
message: I18n.t(
this.isClosed() ? "poll.open.confirm" : "poll.close.confirm"
),
didConfirm: () => {
state.loading = true;
const status = this.isClosed() ? "open" : "closed";
ajax("/polls/toggle_status", {
type: "PUT",
data: {
post_id: post.id,
poll_name: poll.name,
status,
},
})
.then(() => {
poll.set("status", status);
if (poll.results === "on_close") {
state.showResults = status === "closed";
}
this.scheduleRerender();
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_toggling_status"));
}
})
.finally(() => {
state.loading = false;
});
},
});
},
toggleResults() {
this.state.showResults = !this.state.showResults;
},
removeVote() {
const { attrs, state } = this;
state.loading = true;
return ajax("/polls/vote", {
type: "DELETE",
data: {
post_id: attrs.post.id,
poll_name: attrs.poll.name,
},
})
.then(({ poll }) => {
attrs.poll.setProperties(poll);
attrs.vote.length = 0;
attrs.hasSavedVote = false;
this.appEvents.trigger("poll:voted", poll, attrs.post, attrs.vote);
})
.catch((error) => popupAjaxError(error))
.finally(() => {
state.loading = false;
});
},
exportResults() {
const { attrs } = this;
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: attrs.poll.name,
post_id: attrs.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-${attrs.poll.name}-${attrs.post.id}.csv`
);
downloadLink.click();
downloadLink.remove();
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_exporting_results"));
}
});
},
showLogin() {
this.register.lookup("route:application").send("showLogin");
},
_toggleOption(option) {
const { vote } = this.attrs;
const chosenIdx = vote.indexOf(option.id);
if (chosenIdx !== -1) {
vote.splice(chosenIdx, 1);
} else {
vote.push(option.id);
}
},
toggleOption(option) {
const { attrs } = this;
if (this.isClosed()) {
return;
}
if (!this.currentUser) {
return this.showLogin();
}
if (!checkUserGroups(this.currentUser, this.attrs.poll)) {
return;
}
const { vote } = attrs;
if (!this.isMultiple() && vote.length === 1 && vote[0] === option.id) {
return this.removeVote();
}
if (!this.isMultiple()) {
vote.length = 0;
}
this._toggleOption(option);
if (!this.isMultiple()) {
return this.castVotes().catch(() => this._toggleOption(option));
}
},
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(({ poll }) => {
attrs.hasSavedVote = true;
attrs.poll.setProperties(poll);
this.appEvents.trigger("poll:voted", poll, attrs.post, attrs.vote);
if (attrs.poll.results !== "on_close") {
state.showResults = true;
}
if (attrs.poll.results === "staff_only") {
if (this.currentUser && this.currentUser.staff) {
state.showResults = true;
} else {
state.showResults = false;
}
}
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
this.dialog.alert(I18n.t("poll.error_while_casting_votes"));
}
})
.finally(() => {
state.loading = false;
});
},
showBreakdown() {
showModal("poll-breakdown", {
model: this.attrs,
panels: [
{ id: "percentage", title: "poll.breakdown.percentage" },
{ id: "count", title: "poll.breakdown.count" },
],
});
},
});