discourse/plugins/poll/assets/javascripts/widgets/discourse-poll.js.es6
Jarek Radosz babbebfb35
FEATURE: Add the title attribute to polls (#10759)
Adds an optional title attribute to polls. The rationale for this addition is that polls themselves didn't contain context/question and relied on post body to explain them. That context wasn't always obvious (e.g. when there are multiple polls in a single post) or available (e.g. when you display the poll breakdown - you see the answers, but not the question)

As a side note, here's a word on how the poll plugin works:

> We have a markdown poll renderer, which we use in the builder UI and the composer preview, but… when you submit a post, raw markdown is cooked into html (twice), then we extract data from the generated html and save it to the database. When it's render time, we first display the cooked html poll, and then extract some data from that html, get the data from the post's JSON (and identify that poll using the extracted html stuff) to then render the poll using widgets and the JSON data.
2020-10-02 09:21:24 +02:00

989 lines
25 KiB
JavaScript

import I18n from "I18n";
import { h } from "virtual-dom";
import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { relativeAge } from "discourse/lib/formatter";
import loadScript from "discourse/lib/load-script";
import round from "discourse/lib/round";
import showModal from "discourse/lib/show-modal";
import { avatarFor } from "discourse/widgets/post";
import RawHtml from "discourse/widgets/raw-html";
import { createWidget } from "discourse/widgets/widget";
import { iconNode } from "discourse-common/lib/icon-library";
import { PIE_CHART_TYPE } from "discourse/plugins/poll/controllers/poll-ui-builder";
import { getColors } from "discourse/plugins/poll/lib/chart-colors";
import evenRound from "discourse/plugins/poll/lib/even-round";
function optionHtml(option) {
const $node = $(`<span>${option.html}</span>`);
$node.find(".discourse-local-date").each((_index, elem) => {
$(elem).applyLocalDates();
});
return new RawHtml({ html: `<span>${$node.html()}</span>` });
}
function infoTextHtml(text) {
return new RawHtml({
html: `<span class="info-text">${text}</span>`,
});
}
function _fetchVoters(data) {
return ajax("/polls/voters.json", { data }).catch((error) => {
if (error) {
popupAjaxError(error);
} else {
bootbox.alert(I18n.t("poll.error_while_fetching_voters"));
}
});
}
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 { "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));
return contents;
},
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) => `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 } = 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) => `poll-voters-${attrs.optionId}`,
defaultState() {
return {
loaded: "new",
voters: [],
page: 1,
};
},
fetchVoters() {
const { attrs, state } = this;
if (state.loaded === "loading") {
return;
}
state.loaded = "loading";
return _fetchVoters({
post_id: attrs.postId,
poll_name: attrs.pollName,
option_id: attrs.optionId,
page: state.page,
}).then((result) => {
state.loaded = "loaded";
state.page += 1;
const newVoters =
attrs.pollType === "number"
? result.voters
: result.voters[attrs.optionId];
const existingVoters = new Set(
state.voters.map((voter) => voter.username)
);
newVoters.forEach((voter) => {
if (!existingVoters.has(voter.username)) {
existingVoters.add(voter.username);
state.voters.push(voter);
}
});
this.scheduleRerender();
});
},
loadMore() {
return this.fetchVoters();
},
html(attrs, state) {
if (attrs.voters && state.loaded === "new") {
state.voters = attrs.voters;
}
const contents = state.voters.map((user) => {
return h("li", [
avatarFor("tiny", {
username: user.username,
template: user.avatar_template,
}),
" ",
]);
});
if (state.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}`,
defaultState() {
return { loaded: false };
},
fetchVoters() {
const { attrs, state } = this;
return _fetchVoters({
post_id: attrs.post.id,
poll_name: attrs.poll.get("name"),
}).then((result) => {
state.voters = result.voters;
this.scheduleRerender();
});
},
html(attrs, state) {
const { poll } = attrs;
const options = poll.get("options");
if (options) {
const voters = poll.get("voters");
const isPublic = poll.get("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;
}
});
if (isPublic && !state.loaded) {
state.voters = poll.get("preloaded_voters");
state.loaded = true;
}
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)])
)
);
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.get("name"),
totalVotes: option.votes,
voters: (state.voters && state.voters[option.id]) || [],
})
);
}
return h("li", { className: `${chosen ? "chosen" : ""}` }, contents);
});
}
},
});
createWidget("discourse-poll-number-results", {
buildKey: (attrs) => `poll-number-results-${attrs.id}`,
defaultState() {
return { loaded: false };
},
fetchVoters() {
const { attrs, state } = this;
return _fetchVoters({
post_id: attrs.post.id,
poll_name: attrs.poll.get("name"),
}).then((result) => {
state.voters = result.voters;
this.scheduleRerender();
});
},
html(attrs, state) {
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.get("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: `<span>${averageRating}</span>` })
),
];
if (poll.get("public")) {
if (!state.loaded) {
state.voters = poll.get("preloaded_voters");
state.loaded = true;
}
contents.push(
this.attach("discourse-poll-voters", {
totalVotes: poll.get("voters"),
voters: state.voters || [],
postId: attrs.post.id,
pollName: poll.get("name"),
pollType: poll.get("type"),
})
);
}
return contents;
},
});
createWidget("discourse-poll-container", {
tagName: "div.poll-container",
html(attrs) {
const { poll } = attrs;
const options = poll.get("options");
if (attrs.showResults) {
const type = poll.get("type") === "number" ? "number" : "standard";
const resultsWidget =
type === "number" || attrs.poll.chart_type !== PIE_CHART_TYPE
? `discourse-poll-${type}-results`
: "discourse-poll-pie-chart";
return this.attach(resultsWidget, attrs);
} else if (options) {
const contents = [];
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;
}
},
});
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 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.get("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.get("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);
const el = document.getElementById(`poll-results-chart-${attrs.id}`);
// eslint-disable-next-line
let chart = new Chart(el.getContext("2d"), config);
document.getElementById(
`poll-results-legend-${attrs.id}`
).innerHTML = chart.generateLegend();
});
},
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;
}
if (attrs.groupableUserFields.length) {
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);
}
contents.push(new RawHtml({ html: attrs.titleHTML }));
const chart = this.attach("discourse-poll-pie-canvas", attrs);
contents.push(chart);
contents.push(h(`div#poll-results-legend-${attrs.id}.pie-chart-legends`));
return contents;
},
});
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,
},
options: {
responsive: true,
aspectRatio,
animation: { duration: 0 },
legend: { display: false },
legendCallback: function (chart) {
let legends = "";
for (let i = 0; i < labels.length; i++) {
legends += `<div class="legend"><span class="swatch" style="background-color:
${chart.data.datasets[0].backgroundColor[i]}"></span>${labels[i]}</div>`;
}
return legends;
},
},
};
}
function stripHtml(html) {
var 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 {
if (poll.get("results") === "on_vote" && !attrs.hasVoted && !isMe) {
contents.push(infoTextHtml(I18n.t("poll.results.vote.title")));
} else if (poll.get("results") === "on_close" && !closed) {
contents.push(infoTextHtml(I18n.t("poll.results.closed.title")));
} else if (poll.results === "staff_only" && !isStaff) {
contents.push(infoTextHtml(I18n.t("poll.results.staff.title")));
} else {
contents.push(
this.attach("button", {
className: "btn-default toggle-results",
label: "poll.show-results.label",
title: "poll.show-results.title",
icon: "far-eye",
disabled: poll.get("voters") === 0,
action: "toggleResults",
})
);
}
}
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.get("close")) {
const closeDate = moment(poll.get("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: `<span class="info-text" title="${title}">${label}</span>`,
})
);
}
}
if (
this.currentUser &&
(this.currentUser.get("id") === post.get("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}`,
buildAttributes(attrs) {
let cssClasses = "poll";
if (attrs.poll.chart_type === PIE_CHART_TYPE) {
cssClasses += " pie";
}
return {
class: cssClasses,
"data-poll-name": attrs.poll.get("name"),
"data-poll-type": attrs.poll.get("type"),
};
},
defaultState(attrs) {
const { post, poll } = attrs;
const staffOnly = attrs.poll.results === "staff_only";
const showResults =
(post.get("topic.archived") && !staffOnly) ||
(this.isClosed() && !staffOnly) ||
(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 = jQuery.extend({}, 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.get("min"), 10);
if (isNaN(min) || min < 0) {
min = 0;
}
return min;
},
max() {
let max = parseInt(this.attrs.poll.get("max"), 10);
const numOptions = this.attrs.poll.get("options.length");
if (isNaN(max) || max > numOptions) {
max = numOptions;
}
return max;
},
isAutomaticallyClosed() {
const { poll } = this.attrs;
return poll.get("close") && moment.utc(poll.get("close")) <= moment();
},
isClosed() {
const { poll } = this.attrs;
return poll.get("status") === "closed" || this.isAutomaticallyClosed();
},
isMultiple() {
const { poll } = this.attrs;
return poll.get("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;
}
bootbox.confirm(
I18n.t(this.isClosed() ? "poll.open.confirm" : "poll.close.confirm"),
I18n.t("no_value"),
I18n.t("yes_value"),
(confirmed) => {
if (confirmed) {
state.loading = true;
const status = this.isClosed() ? "open" : "closed";
ajax("/polls/toggle_status", {
type: "PUT",
data: {
post_id: post.get("id"),
poll_name: poll.get("name"),
status,
},
})
.then(() => {
poll.set("status", status);
if (poll.get("results") === "on_close") {
state.showResults = status === "closed";
}
this.scheduleRerender();
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
bootbox.alert(I18n.t("poll.error_while_toggling_status"));
}
})
.finally(() => {
state.loading = false;
});
}
}
);
},
toggleResults() {
this.state.showResults = !this.state.showResults;
},
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 {
bootbox.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 = 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.get("name"),
options: attrs.vote,
},
})
.then(({ poll }) => {
attrs.poll.setProperties(poll);
if (attrs.poll.get("results") !== "on_close") {
state.showResults = true;
}
if (attrs.poll.results === "staff_only") {
if (this.currentUser && this.currentUser.get("staff")) {
state.showResults = true;
} else {
state.showResults = false;
}
}
})
.catch((error) => {
if (error) {
popupAjaxError(error);
} else {
bootbox.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" },
],
});
},
});