mirror of
https://github.com/discourse/discourse.git
synced 2024-11-25 19:03:45 +08:00
FEATURE: Pie chart option for poll results (#8352)
This commit is contained in:
parent
720101b3ee
commit
b92a8131c0
|
@ -32,6 +32,11 @@ class Poll < ActiveRecord::Base
|
||||||
everyone: 1,
|
everyone: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum chart_type: {
|
||||||
|
bar: 0,
|
||||||
|
pie: 1
|
||||||
|
}
|
||||||
|
|
||||||
validates :min, numericality: { allow_nil: true, only_integer: true, greater_than_or_equal_to: 0 }
|
validates :min, numericality: { allow_nil: true, only_integer: true, greater_than_or_equal_to: 0 }
|
||||||
validates :max, numericality: { allow_nil: true, only_integer: true, greater_than: 0 }
|
validates :max, numericality: { allow_nil: true, only_integer: true, greater_than: 0 }
|
||||||
validates :step, numericality: { allow_nil: true, only_integer: true, greater_than: 0 }
|
validates :step, numericality: { allow_nil: true, only_integer: true, greater_than: 0 }
|
||||||
|
|
|
@ -12,7 +12,8 @@ class PollSerializer < ApplicationSerializer
|
||||||
:options,
|
:options,
|
||||||
:voters,
|
:voters,
|
||||||
:close,
|
:close,
|
||||||
:preloaded_voters
|
:preloaded_voters,
|
||||||
|
:chart_type
|
||||||
|
|
||||||
def public
|
def public
|
||||||
true
|
true
|
||||||
|
|
|
@ -2,9 +2,12 @@ import Controller from "@ember/controller";
|
||||||
import {
|
import {
|
||||||
default as computed,
|
default as computed,
|
||||||
observes
|
observes
|
||||||
} from "ember-addons/ember-computed-decorators";
|
} from "discourse-common/utils/decorators";
|
||||||
import EmberObject from "@ember/object";
|
import EmberObject from "@ember/object";
|
||||||
|
|
||||||
|
export const BAR_CHART_TYPE = "bar";
|
||||||
|
export const PIE_CHART_TYPE = "pie";
|
||||||
|
|
||||||
export default Controller.extend({
|
export default Controller.extend({
|
||||||
regularPollType: "regular",
|
regularPollType: "regular",
|
||||||
numberPollType: "number",
|
numberPollType: "number",
|
||||||
|
@ -14,6 +17,10 @@ export default Controller.extend({
|
||||||
votePollResult: "on_vote",
|
votePollResult: "on_vote",
|
||||||
closedPollResult: "on_close",
|
closedPollResult: "on_close",
|
||||||
staffPollResult: "staff_only",
|
staffPollResult: "staff_only",
|
||||||
|
pollChartTypes: [
|
||||||
|
{ name: BAR_CHART_TYPE.capitalize(), value: BAR_CHART_TYPE },
|
||||||
|
{ name: PIE_CHART_TYPE.capitalize(), value: PIE_CHART_TYPE }
|
||||||
|
],
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
|
@ -38,6 +45,11 @@ export default Controller.extend({
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@computed("chartType", "pollType", "numberPollType")
|
||||||
|
isPie(chartType, pollType, numberPollType) {
|
||||||
|
return pollType !== numberPollType && chartType === PIE_CHART_TYPE;
|
||||||
|
},
|
||||||
|
|
||||||
@computed(
|
@computed(
|
||||||
"alwaysPollResult",
|
"alwaysPollResult",
|
||||||
"votePollResult",
|
"votePollResult",
|
||||||
|
@ -173,6 +185,7 @@ export default Controller.extend({
|
||||||
"pollMax",
|
"pollMax",
|
||||||
"pollStep",
|
"pollStep",
|
||||||
"autoClose",
|
"autoClose",
|
||||||
|
"chartType",
|
||||||
"date",
|
"date",
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
@ -187,6 +200,7 @@ export default Controller.extend({
|
||||||
pollMax,
|
pollMax,
|
||||||
pollStep,
|
pollStep,
|
||||||
autoClose,
|
autoClose,
|
||||||
|
chartType,
|
||||||
date,
|
date,
|
||||||
time
|
time
|
||||||
) {
|
) {
|
||||||
|
@ -212,6 +226,8 @@ export default Controller.extend({
|
||||||
if (pollMax) pollHeader += ` max=${pollMax}`;
|
if (pollMax) pollHeader += ` max=${pollMax}`;
|
||||||
if (isNumber) pollHeader += ` step=${step}`;
|
if (isNumber) pollHeader += ` step=${step}`;
|
||||||
if (publicPoll) pollHeader += ` public=true`;
|
if (publicPoll) pollHeader += ` public=true`;
|
||||||
|
if (chartType && pollType !== "number")
|
||||||
|
pollHeader += ` chartType=${chartType}`;
|
||||||
if (autoClose) {
|
if (autoClose) {
|
||||||
let closeDate = moment(
|
let closeDate = moment(
|
||||||
date + " " + time,
|
date + " " + time,
|
||||||
|
@ -306,6 +322,7 @@ export default Controller.extend({
|
||||||
pollMax: null,
|
pollMax: null,
|
||||||
pollStep: 1,
|
pollStep: 1,
|
||||||
autoClose: false,
|
autoClose: false,
|
||||||
|
chartType: BAR_CHART_TYPE,
|
||||||
date: moment()
|
date: moment()
|
||||||
.add(1, "day")
|
.add(1, "day")
|
||||||
.format("YYYY-MM-DD"),
|
.format("YYYY-MM-DD"),
|
||||||
|
|
|
@ -17,6 +17,15 @@
|
||||||
valueAttribute="value"}}
|
valueAttribute="value"}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#unless isNumber}}
|
||||||
|
<div class="input-group poll-select">
|
||||||
|
<label class="input-group-label">{{i18n 'poll.ui_builder.poll_chart_type.label'}}</label>
|
||||||
|
{{combo-box content=pollChartTypes
|
||||||
|
value=chartType
|
||||||
|
valueAttribute="value"}}
|
||||||
|
</div>
|
||||||
|
{{/unless}}
|
||||||
|
|
||||||
{{#if showMinMax}}
|
{{#if showMinMax}}
|
||||||
<div class="input-group poll-number">
|
<div class="input-group poll-number">
|
||||||
<label class="input-group-label">{{i18n 'poll.ui_builder.poll_config.min'}}</label>
|
<label class="input-group-label">{{i18n 'poll.ui_builder.poll_config.min'}}</label>
|
||||||
|
@ -56,12 +65,14 @@
|
||||||
{{input-tip validation=minNumOfOptionsValidation}}
|
{{input-tip validation=minNumOfOptionsValidation}}
|
||||||
{{/unless}}
|
{{/unless}}
|
||||||
|
|
||||||
<div class="input-group poll-checkbox">
|
{{#unless isPie}}
|
||||||
<label>
|
<div class="input-group poll-checkbox">
|
||||||
{{input type='checkbox' checked=publicPoll}}
|
<label>
|
||||||
{{i18n "poll.ui_builder.poll_public.label"}}
|
{{input type='checkbox' checked=publicPoll}}
|
||||||
</label>
|
{{i18n "poll.ui_builder.poll_public.label"}}
|
||||||
</div>
|
</label>
|
||||||
|
</div>
|
||||||
|
{{/unless}}
|
||||||
|
|
||||||
<div class="input-group poll-checkbox">
|
<div class="input-group poll-checkbox">
|
||||||
<label>
|
<label>
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { withPluginApi } from "discourse/lib/plugin-api";
|
import { withPluginApi } from "discourse/lib/plugin-api";
|
||||||
import { observes } from "ember-addons/ember-computed-decorators";
|
import { observes } from "discourse-common/utils/decorators";
|
||||||
import { getRegister } from "discourse-common/lib/get-owner";
|
import { getRegister } from "discourse-common/lib/get-owner";
|
||||||
import WidgetGlue from "discourse/widgets/glue";
|
import WidgetGlue from "discourse/widgets/glue";
|
||||||
|
|
||||||
|
@ -88,12 +88,19 @@ function initializePolls(api) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (poll) {
|
if (poll) {
|
||||||
const glue = new WidgetGlue("discourse-poll", register, {
|
const attrs = {
|
||||||
id: `${pollName}-${post.id}`,
|
id: `${pollName}-${post.id}`,
|
||||||
post,
|
post,
|
||||||
poll,
|
poll,
|
||||||
vote
|
vote,
|
||||||
});
|
groupableUserFields: (
|
||||||
|
api.container.lookup("site-settings:main")
|
||||||
|
.poll_groupable_user_fields || ""
|
||||||
|
)
|
||||||
|
.split("|")
|
||||||
|
.filter(Boolean)
|
||||||
|
};
|
||||||
|
const glue = new WidgetGlue("discourse-poll", register, attrs);
|
||||||
glue.appendTo(pollElem);
|
glue.appendTo(pollElem);
|
||||||
_glued.push(glue);
|
_glued.push(glue);
|
||||||
}
|
}
|
||||||
|
|
69
plugins/poll/assets/javascripts/lib/chart-colors.js.es6
Normal file
69
plugins/poll/assets/javascripts/lib/chart-colors.js.es6
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
export function getColors(count, palette) {
|
||||||
|
palette = palette || "cool";
|
||||||
|
let gradient;
|
||||||
|
|
||||||
|
switch (palette) {
|
||||||
|
case "cool":
|
||||||
|
gradient = {
|
||||||
|
0: [255, 255, 255],
|
||||||
|
25: [220, 237, 200],
|
||||||
|
50: [66, 179, 213],
|
||||||
|
75: [26, 39, 62],
|
||||||
|
100: [0, 0, 0]
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case "warm":
|
||||||
|
gradient = {
|
||||||
|
0: [255, 255, 255],
|
||||||
|
25: [254, 235, 101],
|
||||||
|
50: [228, 82, 27],
|
||||||
|
75: [77, 52, 47],
|
||||||
|
100: [0, 0, 0]
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gradientKeys = Object.keys(gradient);
|
||||||
|
let colors = [];
|
||||||
|
let currentGradientValue;
|
||||||
|
let previousGradientIndex;
|
||||||
|
|
||||||
|
for (let colorIndex = 0; colorIndex < count; colorIndex++) {
|
||||||
|
currentGradientValue = (colorIndex + 1) * (100 / (count + 1));
|
||||||
|
previousGradientIndex = previousGradientIndex || 0;
|
||||||
|
let baseGradientKeyIndex;
|
||||||
|
|
||||||
|
for (let y = previousGradientIndex; y < gradientKeys.length; y++) {
|
||||||
|
if (!gradientKeys[y + 1]) {
|
||||||
|
baseGradientKeyIndex = y - 1;
|
||||||
|
break;
|
||||||
|
} else if (
|
||||||
|
currentGradientValue >= gradientKeys[y] &&
|
||||||
|
currentGradientValue < gradientKeys[y + 1]
|
||||||
|
) {
|
||||||
|
baseGradientKeyIndex = y;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let differenceMultiplier =
|
||||||
|
(currentGradientValue - gradientKeys[baseGradientKeyIndex]) /
|
||||||
|
(gradientKeys[baseGradientKeyIndex + 1] -
|
||||||
|
gradientKeys[baseGradientKeyIndex]);
|
||||||
|
|
||||||
|
let color = [];
|
||||||
|
for (let k = 0; k < 3; k++) {
|
||||||
|
color.push(
|
||||||
|
Math.round(
|
||||||
|
gradient[gradientKeys[baseGradientKeyIndex]][k] -
|
||||||
|
(gradient[gradientKeys[baseGradientKeyIndex]][k] -
|
||||||
|
gradient[gradientKeys[baseGradientKeyIndex + 1]][k]) *
|
||||||
|
differenceMultiplier
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
colors.push(`rgb(${color.toString()})`);
|
||||||
|
previousGradientIndex = baseGradientKeyIndex;
|
||||||
|
}
|
||||||
|
return colors;
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ const WHITELISTED_ATTRIBUTES = [
|
||||||
"order",
|
"order",
|
||||||
"public",
|
"public",
|
||||||
"results",
|
"results",
|
||||||
|
"chartType",
|
||||||
"status",
|
"status",
|
||||||
"step",
|
"step",
|
||||||
"type"
|
"type"
|
||||||
|
|
|
@ -8,6 +8,11 @@ import evenRound from "discourse/plugins/poll/lib/even-round";
|
||||||
import { avatarFor } from "discourse/widgets/post";
|
import { avatarFor } from "discourse/widgets/post";
|
||||||
import round from "discourse/lib/round";
|
import round from "discourse/lib/round";
|
||||||
import { relativeAge } from "discourse/lib/formatter";
|
import { relativeAge } from "discourse/lib/formatter";
|
||||||
|
import loadScript from "discourse/lib/load-script";
|
||||||
|
import { getColors } from "../lib/chart-colors";
|
||||||
|
import { later } from "@ember/runloop";
|
||||||
|
import { classify } from "@ember/string";
|
||||||
|
import { PIE_CHART_TYPE } from "../controllers/poll-ui-builder";
|
||||||
|
|
||||||
function optionHtml(option) {
|
function optionHtml(option) {
|
||||||
const $node = $(`<span>${option.html}</span>`);
|
const $node = $(`<span>${option.html}</span>`);
|
||||||
|
@ -323,7 +328,11 @@ createWidget("discourse-poll-container", {
|
||||||
|
|
||||||
if (attrs.showResults) {
|
if (attrs.showResults) {
|
||||||
const type = poll.get("type") === "number" ? "number" : "standard";
|
const type = poll.get("type") === "number" ? "number" : "standard";
|
||||||
return this.attach(`discourse-poll-${type}-results`, attrs);
|
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) {
|
} else if (options) {
|
||||||
return h(
|
return h(
|
||||||
"ul",
|
"ul",
|
||||||
|
@ -415,6 +424,196 @@ createWidget("discourse-poll-info", {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function transformUserFieldToLabel(fieldName) {
|
||||||
|
let transformed = fieldName.split("_").filter(Boolean);
|
||||||
|
transformed[0] = classify(transformed[0]);
|
||||||
|
return transformed.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
createWidget("discourse-poll-grouped-pies", {
|
||||||
|
tagName: "div.poll-grouped-pies",
|
||||||
|
buildAttributes(attrs) {
|
||||||
|
return {
|
||||||
|
id: `poll-results-grouped-pie-charts-${attrs.id}`
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
html(attrs) {
|
||||||
|
const fields = Object.assign({}, attrs.groupableUserFields);
|
||||||
|
const fieldSelectId = `field-select-${attrs.id}`;
|
||||||
|
attrs.groupedBy = attrs.groupedBy || fields[0];
|
||||||
|
|
||||||
|
let contents = [];
|
||||||
|
|
||||||
|
const btn = this.attach("button", {
|
||||||
|
className: "btn-default poll-group-by-toggle",
|
||||||
|
label: "poll.ungroup-results.label",
|
||||||
|
title: "poll.ungroup-results.title",
|
||||||
|
icon: "far-eye-slash",
|
||||||
|
action: "toggleGroupedPieCharts"
|
||||||
|
});
|
||||||
|
const select = h(
|
||||||
|
`select#${fieldSelectId}.poll-group-by-selector`,
|
||||||
|
{ value: attrs.groupBy },
|
||||||
|
attrs.groupableUserFields.map(field => {
|
||||||
|
return h("option", { value: field }, transformUserFieldToLabel(field));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
contents.push(h("div.poll-grouped-pies-controls", [btn, select]));
|
||||||
|
|
||||||
|
ajax("/polls/grouped_poll_results.json", {
|
||||||
|
data: {
|
||||||
|
post_id: attrs.post.id,
|
||||||
|
poll_name: attrs.poll.name,
|
||||||
|
user_field_name: attrs.groupedBy
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error) {
|
||||||
|
popupAjaxError(error);
|
||||||
|
} else {
|
||||||
|
bootbox.alert(I18n.t("poll.error_while_fetching_voters"));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
let groupBySelect = document.getElementById(fieldSelectId);
|
||||||
|
if (!groupBySelect) return;
|
||||||
|
|
||||||
|
groupBySelect.value = attrs.groupedBy;
|
||||||
|
const parent = document.getElementById(
|
||||||
|
`poll-results-grouped-pie-charts-${attrs.id}`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (
|
||||||
|
let chartIdx = 0;
|
||||||
|
chartIdx < result.grouped_results.length;
|
||||||
|
chartIdx++
|
||||||
|
) {
|
||||||
|
const data = result.grouped_results[chartIdx].options.mapBy("votes");
|
||||||
|
const labels = result.grouped_results[chartIdx].options.mapBy("html");
|
||||||
|
const chartConfig = pieChartConfig(data, labels, 1.2);
|
||||||
|
const canvasId = `pie-${attrs.id}-${chartIdx}`;
|
||||||
|
let el = document.querySelector(`#${canvasId}`);
|
||||||
|
if (!el) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.classList.add("poll-grouped-pie-container");
|
||||||
|
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.classList.add("poll-pie-label");
|
||||||
|
label.textContent = result.grouped_results[chartIdx].group;
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.classList.add(`poll-grouped-pie-${attrs.id}`);
|
||||||
|
canvas.id = canvasId;
|
||||||
|
|
||||||
|
container.appendChild(label);
|
||||||
|
container.appendChild(canvas);
|
||||||
|
parent.appendChild(container);
|
||||||
|
// eslint-disable-next-line
|
||||||
|
new Chart(canvas.getContext("2d"), chartConfig);
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
Chart.helpers.each(Chart.instances, function(instance) {
|
||||||
|
if (instance.chart.canvas.id === canvasId && el.$chartjs) {
|
||||||
|
instance.destroy();
|
||||||
|
// eslint-disable-next-line
|
||||||
|
new Chart(el.getContext("2d"), chartConfig);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return contents;
|
||||||
|
},
|
||||||
|
|
||||||
|
click(e) {
|
||||||
|
let select = $(e.target).closest("select");
|
||||||
|
if (select.length) {
|
||||||
|
this.sendWidgetAction("refreshCharts", select[0].value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
|
||||||
|
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(this.attrs.id);
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
let btn;
|
||||||
|
let chart;
|
||||||
|
if (attrs.groupResults && attrs.groupableUserFields.length > 0) {
|
||||||
|
chart = this.attach("discourse-poll-grouped-pies", attrs);
|
||||||
|
clearPieChart(this.attrs.id);
|
||||||
|
} else {
|
||||||
|
if (attrs.groupableUserFields.length) {
|
||||||
|
btn = this.attach("button", {
|
||||||
|
className: "btn-default poll-group-by-toggle",
|
||||||
|
label: "poll.group-results.label",
|
||||||
|
title: "poll.group-results.title",
|
||||||
|
icon: "far-eye",
|
||||||
|
action: "toggleGroupedPieCharts"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const data = attrs.poll.options.mapBy("votes");
|
||||||
|
const labels = attrs.poll.options.mapBy("html");
|
||||||
|
loadScript("/javascripts/Chart.min.js").then(() => {
|
||||||
|
later(() => {
|
||||||
|
const el = document.querySelector(
|
||||||
|
`#poll-results-chart-${this.attrs.id}`
|
||||||
|
);
|
||||||
|
const config = pieChartConfig(data, labels);
|
||||||
|
// eslint-disable-next-line
|
||||||
|
new Chart(el.getContext("2d"), config);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
chart = this.attach("discourse-poll-pie-canvas", attrs);
|
||||||
|
}
|
||||||
|
contents.push(btn);
|
||||||
|
contents.push(chart);
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function pieChartConfig(data, labels, aspectRatio = 2.0) {
|
||||||
|
return {
|
||||||
|
type: PIE_CHART_TYPE,
|
||||||
|
data: {
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data,
|
||||||
|
backgroundColor: getColors(data.length)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
labels
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
aspectRatio,
|
||||||
|
animation: { duration: 400 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
createWidget("discourse-poll-buttons", {
|
createWidget("discourse-poll-buttons", {
|
||||||
tagName: "div.poll-buttons",
|
tagName: "div.poll-buttons",
|
||||||
|
|
||||||
|
@ -433,7 +632,7 @@ createWidget("discourse-poll-buttons", {
|
||||||
const castVotesDisabled = !attrs.canCastVotes;
|
const castVotesDisabled = !attrs.canCastVotes;
|
||||||
contents.push(
|
contents.push(
|
||||||
this.attach("button", {
|
this.attach("button", {
|
||||||
className: `btn cast-votes ${
|
className: `cast-votes ${
|
||||||
castVotesDisabled ? "btn-default" : "btn-primary"
|
castVotesDisabled ? "btn-default" : "btn-primary"
|
||||||
}`,
|
}`,
|
||||||
label: "poll.cast-votes.label",
|
label: "poll.cast-votes.label",
|
||||||
|
@ -448,7 +647,7 @@ createWidget("discourse-poll-buttons", {
|
||||||
if (attrs.showResults || hideResultsDisabled) {
|
if (attrs.showResults || hideResultsDisabled) {
|
||||||
contents.push(
|
contents.push(
|
||||||
this.attach("button", {
|
this.attach("button", {
|
||||||
className: "btn btn-default toggle-results",
|
className: "btn-default toggle-results",
|
||||||
label: "poll.hide-results.label",
|
label: "poll.hide-results.label",
|
||||||
title: "poll.hide-results.title",
|
title: "poll.hide-results.title",
|
||||||
icon: "far-eye-slash",
|
icon: "far-eye-slash",
|
||||||
|
@ -466,7 +665,7 @@ createWidget("discourse-poll-buttons", {
|
||||||
} else {
|
} else {
|
||||||
contents.push(
|
contents.push(
|
||||||
this.attach("button", {
|
this.attach("button", {
|
||||||
className: "btn btn-default toggle-results",
|
className: "btn-default toggle-results",
|
||||||
label: "poll.show-results.label",
|
label: "poll.show-results.label",
|
||||||
title: "poll.show-results.title",
|
title: "poll.show-results.title",
|
||||||
icon: "far-eye",
|
icon: "far-eye",
|
||||||
|
@ -521,7 +720,7 @@ createWidget("discourse-poll-buttons", {
|
||||||
if (!attrs.isAutomaticallyClosed) {
|
if (!attrs.isAutomaticallyClosed) {
|
||||||
contents.push(
|
contents.push(
|
||||||
this.attach("button", {
|
this.attach("button", {
|
||||||
className: "btn btn-default toggle-status",
|
className: "btn-default toggle-status",
|
||||||
label: "poll.open.label",
|
label: "poll.open.label",
|
||||||
title: "poll.open.title",
|
title: "poll.open.title",
|
||||||
icon: "unlock-alt",
|
icon: "unlock-alt",
|
||||||
|
@ -532,7 +731,7 @@ createWidget("discourse-poll-buttons", {
|
||||||
} else {
|
} else {
|
||||||
contents.push(
|
contents.push(
|
||||||
this.attach("button", {
|
this.attach("button", {
|
||||||
className: "btn toggle-status btn-danger",
|
className: "toggle-status btn-danger",
|
||||||
label: "poll.close.label",
|
label: "poll.close.label",
|
||||||
title: "poll.close.title",
|
title: "poll.close.title",
|
||||||
icon: "lock",
|
icon: "lock",
|
||||||
|
@ -547,11 +746,14 @@ createWidget("discourse-poll-buttons", {
|
||||||
});
|
});
|
||||||
|
|
||||||
export default createWidget("discourse-poll", {
|
export default createWidget("discourse-poll", {
|
||||||
tagName: "div.poll",
|
tagName: "div",
|
||||||
buildKey: attrs => `poll-${attrs.id}`,
|
buildKey: attrs => `poll-${attrs.id}`,
|
||||||
|
|
||||||
buildAttributes(attrs) {
|
buildAttributes(attrs) {
|
||||||
|
let cssClasses = "poll";
|
||||||
|
if (attrs.poll.chart_type === PIE_CHART_TYPE) cssClasses += " pie";
|
||||||
return {
|
return {
|
||||||
|
class: cssClasses,
|
||||||
"data-poll-name": attrs.poll.get("name"),
|
"data-poll-name": attrs.poll.get("name"),
|
||||||
"data-poll-type": attrs.poll.get("type")
|
"data-poll-type": attrs.poll.get("type")
|
||||||
};
|
};
|
||||||
|
@ -808,5 +1010,21 @@ export default createWidget("discourse-poll", {
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleGroupedPieCharts() {
|
||||||
|
this.attrs.groupResults = !this.attrs.groupResults;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshCharts(newGroupedByValue) {
|
||||||
|
let el = document.getElementById(
|
||||||
|
`poll-results-grouped-pie-charts-${this.attrs.id}`
|
||||||
|
);
|
||||||
|
Array.from(el.getElementsByClassName("poll-grouped-pie-container")).forEach(
|
||||||
|
container => {
|
||||||
|
el.removeChild(container);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
this.attrs.groupedBy = newGroupedByValue;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -119,6 +119,53 @@ div.poll {
|
||||||
margin-right: 0.25em;
|
margin-right: 0.25em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.poll-grouped-pies-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-results-chart {
|
||||||
|
height: 310px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-group-by-toggle {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-group-by-selector {
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.poll-grouped-pie-container {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
padding: 15px 0;
|
||||||
|
|
||||||
|
.poll-pie-label {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.poll.pie {
|
||||||
|
.poll-container {
|
||||||
|
display: inline-block;
|
||||||
|
height: 310px;
|
||||||
|
max-height: 310px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
.poll-grouped-pie-container {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.poll-info {
|
||||||
|
display: inline-block;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// hides 0 vote count in crawler and print view
|
// hides 0 vote count in crawler and print view
|
||||||
|
|
|
@ -8,7 +8,6 @@ div.poll {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-left: 1px solid $primary-low;
|
|
||||||
|
|
||||||
.info-number {
|
.info-number {
|
||||||
font-size: 3.5em;
|
font-size: 3.5em;
|
||||||
|
@ -26,6 +25,7 @@ div.poll {
|
||||||
.poll-container {
|
.poll-container {
|
||||||
display: table-cell;
|
display: table-cell;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
border-right: 1px solid $primary-low;
|
||||||
}
|
}
|
||||||
|
|
||||||
.poll-buttons {
|
.poll-buttons {
|
||||||
|
@ -46,6 +46,20 @@ div.poll {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.poll.pie {
|
||||||
|
.poll-container {
|
||||||
|
width: calc(100% - 181px);
|
||||||
|
|
||||||
|
.poll-grouped-pie-container {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.poll-info {
|
||||||
|
display: inline-block;
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.d-editor-preview {
|
.d-editor-preview {
|
||||||
.poll-buttons {
|
.poll-buttons {
|
||||||
a:not(:first-child) {
|
a:not(:first-child) {
|
||||||
|
|
|
@ -11,7 +11,6 @@ div.poll {
|
||||||
padding: 0 1em;
|
padding: 0 1em;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
border-top: 1px solid $primary-low;
|
|
||||||
.info-text {
|
.info-text {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
@ -27,3 +26,14 @@ div.poll {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div.poll.pie {
|
||||||
|
.poll-container {
|
||||||
|
width: calc(100% - 30px);
|
||||||
|
border-bottom: 1px solid $primary-low;
|
||||||
|
|
||||||
|
.poll-grouped-pie-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -63,6 +63,14 @@ en:
|
||||||
title: "Back to your votes"
|
title: "Back to your votes"
|
||||||
label: "Show vote"
|
label: "Show vote"
|
||||||
|
|
||||||
|
group-results:
|
||||||
|
title: "Group votes by user field"
|
||||||
|
label: "Show breakdown"
|
||||||
|
|
||||||
|
ungroup-results:
|
||||||
|
title: "Combine all votes"
|
||||||
|
label: "Hide breakdown"
|
||||||
|
|
||||||
export-results:
|
export-results:
|
||||||
title: "Export the poll results"
|
title: "Export the poll results"
|
||||||
label: "Export"
|
label: "Export"
|
||||||
|
@ -104,6 +112,8 @@ en:
|
||||||
vote: On vote
|
vote: On vote
|
||||||
closed: When closed
|
closed: When closed
|
||||||
staff: Staff only
|
staff: Staff only
|
||||||
|
poll_chart_type:
|
||||||
|
label: Chart type
|
||||||
poll_config:
|
poll_config:
|
||||||
max: Max
|
max: Max
|
||||||
min: Min
|
min: Min
|
||||||
|
|
|
@ -20,6 +20,7 @@ en:
|
||||||
poll_maximum_options: "Maximum number of options allowed in a poll."
|
poll_maximum_options: "Maximum number of options allowed in a poll."
|
||||||
poll_edit_window_mins: "Number of minutes after post creation during which polls can be edited."
|
poll_edit_window_mins: "Number of minutes after post creation during which polls can be edited."
|
||||||
poll_minimum_trust_level_to_create: "Define the minimum trust level needed to create polls."
|
poll_minimum_trust_level_to_create: "Define the minimum trust level needed to create polls."
|
||||||
|
poll_groupable_user_fields: "A set of user field names that can be used to group and filter poll results."
|
||||||
poll_export_data_explorer_query_id: "ID of the Data Explorer Query to use for exporting poll results (0 to disable)."
|
poll_export_data_explorer_query_id: "ID of the Data Explorer Query to use for exporting poll results (0 to disable)."
|
||||||
|
|
||||||
poll:
|
poll:
|
||||||
|
@ -67,3 +68,6 @@ en:
|
||||||
|
|
||||||
email:
|
email:
|
||||||
link_to_poll: "Click to view the poll."
|
link_to_poll: "Click to view the poll."
|
||||||
|
|
||||||
|
user_field:
|
||||||
|
no_data: "No Data"
|
||||||
|
|
|
@ -14,6 +14,10 @@ plugins:
|
||||||
default: 1
|
default: 1
|
||||||
client: true
|
client: true
|
||||||
enum: 'TrustLevelSetting'
|
enum: 'TrustLevelSetting'
|
||||||
|
poll_groupable_user_fields:
|
||||||
|
default: ""
|
||||||
|
type: list
|
||||||
|
client: true
|
||||||
poll_export_data_explorer_query_id:
|
poll_export_data_explorer_query_id:
|
||||||
default: -16
|
default: -16
|
||||||
min: -9999
|
min: -9999
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class AddGraphToPolls < ActiveRecord::Migration[6.0]
|
||||||
|
def change
|
||||||
|
add_column :polls, :chart_type, :integer, default: 0, null: false
|
||||||
|
end
|
||||||
|
end
|
|
@ -175,7 +175,7 @@ after_initialize do
|
||||||
elsif option_digest.present?
|
elsif option_digest.present?
|
||||||
poll_option = PollOption.find_by(poll: poll, digest: option_digest)
|
poll_option = PollOption.find_by(poll: poll, digest: option_digest)
|
||||||
|
|
||||||
raise Discourse::InvalidParameters.new("option_id is invalid") unless poll_option
|
raise Discourse::InvalidParameters.new(:option_id) unless poll_option
|
||||||
|
|
||||||
user_ids = PollVote
|
user_ids = PollVote
|
||||||
.where(poll: poll, poll_option: poll_option)
|
.where(poll: poll, poll_option: poll_option)
|
||||||
|
@ -222,14 +222,74 @@ after_initialize do
|
||||||
|
|
||||||
def voters(post_id, poll_name, user, opts = {})
|
def voters(post_id, poll_name, user, opts = {})
|
||||||
post = Post.find_by(id: post_id)
|
post = Post.find_by(id: post_id)
|
||||||
raise Discourse::InvalidParameters.new("post_id is invalid") unless post
|
raise Discourse::InvalidParameters.new(:post_id) unless post
|
||||||
|
|
||||||
poll = Poll.find_by(post_id: post_id, name: poll_name)
|
poll = Poll.find_by(post_id: post_id, name: poll_name)
|
||||||
raise Discourse::InvalidParameters.new("poll_name is invalid") unless poll&.can_see_voters?(user)
|
raise Discourse::InvalidParameters.new(:poll_name) unless poll&.can_see_voters?(user)
|
||||||
|
|
||||||
serialized_voters(poll, opts)
|
serialized_voters(poll, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def transform_for_user_field_override(custom_user_field)
|
||||||
|
existing_field = UserField.find_by(name: custom_user_field)
|
||||||
|
existing_field ? "user_field_#{existing_field.id}" : custom_user_field
|
||||||
|
end
|
||||||
|
|
||||||
|
def grouped_poll_results(post_id, poll_name, user_field_name, user)
|
||||||
|
post = Post.find_by(id: post_id)
|
||||||
|
raise Discourse::InvalidParameters.new(:post_id) unless post
|
||||||
|
|
||||||
|
poll = Poll.includes(:poll_options).includes(:poll_votes).find_by(post_id: post_id, name: poll_name)
|
||||||
|
raise Discourse::InvalidParameters.new(:poll_name) unless poll
|
||||||
|
|
||||||
|
raise Discourse::InvalidParameters.new(:user_field_name) unless SiteSetting.poll_groupable_user_fields.split('|').include?(user_field_name)
|
||||||
|
|
||||||
|
poll_votes = poll.poll_votes
|
||||||
|
|
||||||
|
poll_options = {}
|
||||||
|
poll.poll_options.each do |option|
|
||||||
|
poll_options[option.id.to_s] = { html: option.html, digest: option.digest }
|
||||||
|
end
|
||||||
|
|
||||||
|
user_ids = poll_votes.map(&:user_id).uniq
|
||||||
|
user_fields = UserCustomField.where(user_id: user_ids, name: transform_for_user_field_override(user_field_name))
|
||||||
|
|
||||||
|
user_field_map = {}
|
||||||
|
user_fields.each do |f|
|
||||||
|
# Build hash, so we can quickly look up field values for each user.
|
||||||
|
user_field_map[f.user_id] = f.value
|
||||||
|
end
|
||||||
|
|
||||||
|
votes_with_field = poll_votes.map do |vote|
|
||||||
|
v = vote.attributes
|
||||||
|
v[:field_value] = user_field_map[vote.user_id]
|
||||||
|
v
|
||||||
|
end
|
||||||
|
|
||||||
|
chart_data = []
|
||||||
|
votes_with_field.group_by { |vote| vote[:field_value] }.each do |field_answer, votes|
|
||||||
|
grouped_selected_options = {}
|
||||||
|
|
||||||
|
# Create all the options with 0 votes. This ensures all the charts will have the same order of options, and same colors per option.
|
||||||
|
poll_options.each do |id, option|
|
||||||
|
grouped_selected_options[id] = {
|
||||||
|
digest: option[:digest],
|
||||||
|
html: option[:html],
|
||||||
|
votes: 0
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Now go back and update the vote counts. Using hashes so we dont have n^2
|
||||||
|
votes.group_by { |v| v["poll_option_id"] }.each do |option_id, votes_for_option|
|
||||||
|
grouped_selected_options[option_id.to_s][:votes] = votes_for_option.length
|
||||||
|
end
|
||||||
|
|
||||||
|
group_label = field_answer ? field_answer.titleize : I18n.t("poll.user_field.no_data")
|
||||||
|
chart_data << { group: group_label, options: grouped_selected_options.values }
|
||||||
|
end
|
||||||
|
chart_data
|
||||||
|
end
|
||||||
|
|
||||||
def schedule_jobs(post)
|
def schedule_jobs(post)
|
||||||
Poll.where(post: post).find_each do |poll|
|
Poll.where(post: post).find_each do |poll|
|
||||||
job_args = {
|
job_args = {
|
||||||
|
@ -261,7 +321,8 @@ after_initialize do
|
||||||
results: poll["results"].presence || "always",
|
results: poll["results"].presence || "always",
|
||||||
min: poll["min"],
|
min: poll["min"],
|
||||||
max: poll["max"],
|
max: poll["max"],
|
||||||
step: poll["step"]
|
step: poll["step"],
|
||||||
|
chart_type: poll["charttype"] || "bar"
|
||||||
)
|
)
|
||||||
|
|
||||||
poll["options"].each do |option|
|
poll["options"].each do |option|
|
||||||
|
@ -303,7 +364,7 @@ after_initialize do
|
||||||
class DiscoursePoll::PollsController < ::ApplicationController
|
class DiscoursePoll::PollsController < ::ApplicationController
|
||||||
requires_plugin PLUGIN_NAME
|
requires_plugin PLUGIN_NAME
|
||||||
|
|
||||||
before_action :ensure_logged_in, except: [:voters]
|
before_action :ensure_logged_in, except: [:voters, :grouped_poll_results]
|
||||||
|
|
||||||
def vote
|
def vote
|
||||||
post_id = params.require(:post_id)
|
post_id = params.require(:post_id)
|
||||||
|
@ -343,12 +404,36 @@ after_initialize do
|
||||||
render_json_error e.message
|
render_json_error e.message
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def grouped_poll_results
|
||||||
|
post_id = params.require(:post_id)
|
||||||
|
poll_name = params.require(:poll_name)
|
||||||
|
user_field_name = params.require(:user_field_name)
|
||||||
|
|
||||||
|
begin
|
||||||
|
render json: {
|
||||||
|
grouped_results: DiscoursePoll::Poll.grouped_poll_results(post_id, poll_name, user_field_name, current_user)
|
||||||
|
}
|
||||||
|
rescue StandardError => e
|
||||||
|
render_json_error e.message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def groupable_user_fields
|
||||||
|
render json: {
|
||||||
|
fields: SiteSetting.poll_groupable_user_fields.split('|').map do |field|
|
||||||
|
{ name: field.humanize.capitalize, value: field }
|
||||||
|
end
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
DiscoursePoll::Engine.routes.draw do
|
DiscoursePoll::Engine.routes.draw do
|
||||||
put "/vote" => "polls#vote"
|
put "/vote" => "polls#vote"
|
||||||
put "/toggle_status" => "polls#toggle_status"
|
put "/toggle_status" => "polls#toggle_status"
|
||||||
get "/voters" => 'polls#voters'
|
get "/voters" => 'polls#voters'
|
||||||
|
get "/grouped_poll_results" => 'polls#grouped_poll_results'
|
||||||
|
get "/groupable_user_fields" => 'polls#groupable_user_fields'
|
||||||
end
|
end
|
||||||
|
|
||||||
Discourse::Application.routes.append do
|
Discourse::Application.routes.append do
|
||||||
|
|
|
@ -73,7 +73,7 @@ describe "DiscoursePoll endpoints" do
|
||||||
poll_name: DiscoursePoll::DEFAULT_POLL_NAME
|
poll_name: DiscoursePoll::DEFAULT_POLL_NAME
|
||||||
}
|
}
|
||||||
expect(response.status).to eq(422)
|
expect(response.status).to eq(422)
|
||||||
expect(response.body).to include('post_id is invalid')
|
expect(response.body).to include('post_id')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -88,7 +88,7 @@ describe "DiscoursePoll endpoints" do
|
||||||
it 'should raise the right error' do
|
it 'should raise the right error' do
|
||||||
get "/polls/voters.json", params: { post_id: post.id, poll_name: 'wrongpoll' }
|
get "/polls/voters.json", params: { post_id: post.id, poll_name: 'wrongpoll' }
|
||||||
expect(response.status).to eq(422)
|
expect(response.status).to eq(422)
|
||||||
expect(response.body).to include('poll_name is invalid')
|
expect(response.body).to include('poll_name')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -119,4 +119,71 @@ describe "DiscoursePoll endpoints" do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#grouped_poll_results" do
|
||||||
|
fab!(:user1) { Fabricate(:user) }
|
||||||
|
fab!(:user2) { Fabricate(:user) }
|
||||||
|
fab!(:user3) { Fabricate(:user) }
|
||||||
|
fab!(:user4) { Fabricate(:user) }
|
||||||
|
fab!(:post) { Fabricate(:post, raw: "[poll public=true]\n- A\n- B\n[/poll]") }
|
||||||
|
let(:option_a) { "5c24fc1df56d764b550ceae1b9319125" }
|
||||||
|
let(:option_b) { "e89dec30bbd9bf50fabf6a05b4324edf" }
|
||||||
|
|
||||||
|
before do
|
||||||
|
user_votes = {
|
||||||
|
user_0: option_a,
|
||||||
|
user_1: option_a,
|
||||||
|
user_2: option_b,
|
||||||
|
}
|
||||||
|
[user1, user2, user3].each_with_index do |user, index|
|
||||||
|
DiscoursePoll::Poll.vote(
|
||||||
|
post.id,
|
||||||
|
DiscoursePoll::DEFAULT_POLL_NAME,
|
||||||
|
[user_votes["user_#{index}".to_sym]],
|
||||||
|
user
|
||||||
|
)
|
||||||
|
UserCustomField.create(user_id: user.id, name: "something", value: "value#{index}")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add another user to one of the fields to prove it groups users properly
|
||||||
|
DiscoursePoll::Poll.vote(
|
||||||
|
post.id,
|
||||||
|
DiscoursePoll::DEFAULT_POLL_NAME,
|
||||||
|
[option_a, option_b],
|
||||||
|
user4
|
||||||
|
)
|
||||||
|
UserCustomField.create(user_id: user4.id, name: "something", value: "value1")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns grouped poll results based on user field" do
|
||||||
|
SiteSetting.poll_groupable_user_fields = "something"
|
||||||
|
|
||||||
|
get "/polls/grouped_poll_results.json", params: {
|
||||||
|
post_id: post.id,
|
||||||
|
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
|
||||||
|
user_field_name: "something"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(200)
|
||||||
|
expect(JSON.parse(response.body).deep_symbolize_keys).to eq(
|
||||||
|
grouped_results: [
|
||||||
|
{ group: "Value0", options: [{ digest: option_a, html: "A", votes: 1 }, { digest: option_b, html: "B", votes: 0 }] },
|
||||||
|
{ group: "Value1", options: [{ digest: option_a, html: "A", votes: 2 }, { digest: option_b, html: "B", votes: 1 }] },
|
||||||
|
{ group: "Value2", options: [{ digest: option_a, html: "A", votes: 0 }, { digest: option_b, html: "B", votes: 1 }] },
|
||||||
|
]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns an error when poll_groupable_user_fields is empty" do
|
||||||
|
SiteSetting.poll_groupable_user_fields = ""
|
||||||
|
get "/polls/grouped_poll_results.json", params: {
|
||||||
|
post_id: post.id,
|
||||||
|
poll_name: DiscoursePoll::DEFAULT_POLL_NAME,
|
||||||
|
user_field_name: "something"
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response.status).to eq(422)
|
||||||
|
expect(response.body).to include('user_field_name')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
19
plugins/poll/spec/models/poll_spec.rb
Normal file
19
plugins/poll/spec/models/poll_spec.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe ::DiscoursePoll::Poll do
|
||||||
|
describe ".transform_for_user_field_override" do
|
||||||
|
it "Transforms UserField name if a matching CustomUserField is present" do
|
||||||
|
user_field_name = "Something Cool"
|
||||||
|
user_field = Fabricate(:user_field, name: user_field_name)
|
||||||
|
expect(::DiscoursePoll::Poll.transform_for_user_field_override(user_field_name)).to eq("user_field_#{user_field.id}")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not transform UserField name if a matching CustomUserField is not present" do
|
||||||
|
user_field_name = "Something Cool"
|
||||||
|
user_field = Fabricate(:user_field, name: "Something Else!")
|
||||||
|
expect(::DiscoursePoll::Poll.transform_for_user_field_override(user_field_name)).to eq(user_field_name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { acceptance } from "helpers/qunit-helpers";
|
||||||
|
import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer";
|
||||||
|
import { Promise } from "rsvp";
|
||||||
|
|
||||||
|
acceptance("Rendering polls with pie charts - desktop", {
|
||||||
|
loggedIn: true,
|
||||||
|
settings: { poll_enabled: true, poll_groupable_user_fields: "something" },
|
||||||
|
beforeEach() {
|
||||||
|
clearPopupMenuOptionsCallback();
|
||||||
|
},
|
||||||
|
pretend(server, helper) {
|
||||||
|
server.get("/polls/grouped_poll_results.json", () => {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
resolve(
|
||||||
|
helper.response({
|
||||||
|
grouped_results: [
|
||||||
|
{
|
||||||
|
group: "Engineering",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
digest: "687a1ccf3c6a260f9aeeb7f68a1d463c",
|
||||||
|
html: "This Is",
|
||||||
|
votes: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
digest: "9377906763a1221d31d656ea0c4a4495",
|
||||||
|
html: "A test for sure",
|
||||||
|
votes: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
digest: "ecf47c65a85a0bb20029072b1b721977",
|
||||||
|
html: "Why not give it some more",
|
||||||
|
votes: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: "Marketing",
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
digest: "687a1ccf3c6a260f9aeeb7f68a1d463c",
|
||||||
|
html: "This Is",
|
||||||
|
votes: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
digest: "9377906763a1221d31d656ea0c4a4495",
|
||||||
|
html: "A test for sure",
|
||||||
|
votes: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
digest: "ecf47c65a85a0bb20029072b1b721977",
|
||||||
|
html: "Why not give it some more",
|
||||||
|
votes: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Polls", async assert => {
|
||||||
|
await visit("/t/-/topic_with_pie_chart_poll");
|
||||||
|
|
||||||
|
const poll = find(".poll")[0];
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
find(".info-number", poll)[0].innerHTML,
|
||||||
|
"2",
|
||||||
|
"it should display the right number of voters"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
find(".info-number", poll)[1].innerHTML,
|
||||||
|
"5",
|
||||||
|
"it should display the right number of votes"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
poll.classList.contains("pie"),
|
||||||
|
true,
|
||||||
|
"pie class is present on poll div"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
find(".poll-results-chart", poll).length,
|
||||||
|
1,
|
||||||
|
"Renders the chart div instead of bar container"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
find(".poll-group-by-toggle").text(),
|
||||||
|
"Show breakdown",
|
||||||
|
"Shows the breakdown button when poll_groupable_user_fields is non-empty"
|
||||||
|
);
|
||||||
|
|
||||||
|
await click(".poll-group-by-toggle:first");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
find(".poll-group-by-toggle").text(),
|
||||||
|
"Hide breakdown",
|
||||||
|
"Shows the combine breakdown button after toggle is clicked"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Double click to make sure the state toggles back to combined view
|
||||||
|
await click(".toggle-results:first");
|
||||||
|
await click(".toggle-results:first");
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
find(".poll-group-by-toggle").text(),
|
||||||
|
"Hide breakdown",
|
||||||
|
"Returns to the grouped view, after toggling results shown"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
find(".poll-grouped-pie-container").length,
|
||||||
|
2,
|
||||||
|
"Renders a chart for each of the groups in group_results response"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
find(".poll-grouped-pie-container > canvas")[0].$chartjs,
|
||||||
|
"$chartjs is defined on the pie charts"
|
||||||
|
);
|
||||||
|
});
|
|
@ -1,7 +1,7 @@
|
||||||
import { acceptance } from "helpers/qunit-helpers";
|
import { acceptance } from "helpers/qunit-helpers";
|
||||||
import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer";
|
import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer";
|
||||||
|
|
||||||
acceptance("Rendering polls - desktop", {
|
acceptance("Rendering polls with bar charts - desktop", {
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
settings: { poll_enabled: true },
|
settings: { poll_enabled: true },
|
||||||
beforeEach() {
|
beforeEach() {
|
|
@ -1,7 +1,7 @@
|
||||||
import { acceptance } from "helpers/qunit-helpers";
|
import { acceptance } from "helpers/qunit-helpers";
|
||||||
import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer";
|
import { clearPopupMenuOptionsCallback } from "discourse/controllers/composer";
|
||||||
|
|
||||||
acceptance("Rendering polls - mobile", {
|
acceptance("Rendering polls with bar charts - mobile", {
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
mobileView: true,
|
mobileView: true,
|
||||||
settings: { poll_enabled: true },
|
settings: { poll_enabled: true },
|
|
@ -101,10 +101,7 @@ test("pollMinOptions", function(assert) {
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
controller.get("pollMinOptions"),
|
controller.get("pollMinOptions"),
|
||||||
[
|
[{ name: 1, value: 1 }, { name: 2, value: 2 }],
|
||||||
{ name: 1, value: 1 },
|
|
||||||
{ name: 2, value: 2 }
|
|
||||||
],
|
|
||||||
"it should return the right options"
|
"it should return the right options"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -113,10 +110,7 @@ test("pollMinOptions", function(assert) {
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
controller.get("pollMinOptions"),
|
controller.get("pollMinOptions"),
|
||||||
[
|
[{ name: 1, value: 1 }, { name: 2, value: 2 }],
|
||||||
{ name: 1, value: 1 },
|
|
||||||
{ name: 2, value: 2 }
|
|
||||||
],
|
|
||||||
"it should return the right options"
|
"it should return the right options"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -183,11 +177,7 @@ test("pollStepOptions", function(assert) {
|
||||||
|
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
controller.get("pollStepOptions"),
|
controller.get("pollStepOptions"),
|
||||||
[
|
[{ name: 1, value: 1 }, { name: 2, value: 2 }, { name: 3, value: 3 }],
|
||||||
{ name: 1, value: 1 },
|
|
||||||
{ name: 2, value: 2 },
|
|
||||||
{ name: 3, value: 3 }
|
|
||||||
],
|
|
||||||
"it should return the right options"
|
"it should return the right options"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -272,7 +262,7 @@ test("regular pollOutput", function(assert) {
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
controller.get("pollOutput"),
|
controller.get("pollOutput"),
|
||||||
"[poll type=regular]\n* 1\n* 2\n[/poll]\n",
|
"[poll type=regular chartType=bar]\n* 1\n* 2\n[/poll]\n",
|
||||||
"it should return the right output"
|
"it should return the right output"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -280,7 +270,7 @@ test("regular pollOutput", function(assert) {
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
controller.get("pollOutput"),
|
controller.get("pollOutput"),
|
||||||
"[poll type=regular public=true]\n* 1\n* 2\n[/poll]\n",
|
"[poll type=regular public=true chartType=bar]\n* 1\n* 2\n[/poll]\n",
|
||||||
"it should return the right output"
|
"it should return the right output"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -299,7 +289,7 @@ test("multiple pollOutput", function(assert) {
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
controller.get("pollOutput"),
|
controller.get("pollOutput"),
|
||||||
"[poll type=multiple min=1 max=2]\n* 1\n* 2\n[/poll]\n",
|
"[poll type=multiple min=1 max=2 chartType=bar]\n* 1\n* 2\n[/poll]\n",
|
||||||
"it should return the right output"
|
"it should return the right output"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -307,7 +297,7 @@ test("multiple pollOutput", function(assert) {
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
controller.get("pollOutput"),
|
controller.get("pollOutput"),
|
||||||
"[poll type=multiple min=1 max=2 public=true]\n* 1\n* 2\n[/poll]\n",
|
"[poll type=multiple min=1 max=2 public=true chartType=bar]\n* 1\n* 2\n[/poll]\n",
|
||||||
"it should return the right output"
|
"it should return the right output"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -544,5 +544,191 @@ export default {
|
||||||
],
|
],
|
||||||
chunk_size: 20,
|
chunk_size: 20,
|
||||||
bookmarked: false
|
bookmarked: false
|
||||||
|
},
|
||||||
|
"/t/topic_with_pie_chart_poll.json": {
|
||||||
|
post_stream: {
|
||||||
|
posts: [
|
||||||
|
{
|
||||||
|
id: 294,
|
||||||
|
name: "",
|
||||||
|
username: "markvanlan",
|
||||||
|
avatar_template: "/user_avatar/localhost/markvanlan/{size}/11_2.png",
|
||||||
|
created_at: "2019-11-22T18:55:41.439Z",
|
||||||
|
cooked:
|
||||||
|
'\u003cdiv class="poll" data-poll-status="open" data-poll-max="3" data-poll-min="1" data-poll-results="always" data-poll-charttype="pie" data-poll-type="multiple" data-poll-name="poll"\u003e\n\u003cdiv\u003e\n\u003cdiv class="poll-container"\u003e\n\u003cul\u003e\n\u003cli data-poll-option-id="687a1ccf3c6a260f9aeeb7f68a1d463c"\u003eThis Is\u003c/li\u003e\n\u003cli data-poll-option-id="9377906763a1221d31d656ea0c4a4495"\u003eA test for sure\u003c/li\u003e\n\u003cli data-poll-option-id="ecf47c65a85a0bb20029072b1b721977"\u003eWhy not give it some more\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/div\u003e\n\u003cdiv class="poll-info"\u003e\n\u003cp\u003e\n\u003cspan class="info-number"\u003e0\u003c/span\u003e\n\u003cspan class="info-label"\u003evoters\u003c/span\u003e\n\u003c/p\u003e\n\u003c/div\u003e\n\u003c/div\u003e\n\u003c/div\u003e',
|
||||||
|
post_number: 1,
|
||||||
|
post_type: 1,
|
||||||
|
updated_at: "2019-11-22T18:55:41.439Z",
|
||||||
|
reply_count: 0,
|
||||||
|
reply_to_post_number: null,
|
||||||
|
quote_count: 0,
|
||||||
|
incoming_link_count: 0,
|
||||||
|
reads: 2,
|
||||||
|
readers_count: 1,
|
||||||
|
score: 0.2,
|
||||||
|
yours: false,
|
||||||
|
topic_id: 256,
|
||||||
|
topic_slug: "14-the-title-must-be-longer-i-guess",
|
||||||
|
display_username: "",
|
||||||
|
primary_group_name: "Team",
|
||||||
|
primary_group_flair_url: null,
|
||||||
|
primary_group_flair_bg_color: "",
|
||||||
|
primary_group_flair_color: "",
|
||||||
|
version: 1,
|
||||||
|
can_edit: false,
|
||||||
|
can_delete: false,
|
||||||
|
can_recover: false,
|
||||||
|
can_wiki: false,
|
||||||
|
read: true,
|
||||||
|
user_title: "You are a member of the team",
|
||||||
|
actions_summary: [
|
||||||
|
{ id: 2, can_act: true },
|
||||||
|
{ id: 3, can_act: true },
|
||||||
|
{ id: 4, can_act: true },
|
||||||
|
{ id: 8, can_act: true },
|
||||||
|
{ id: 6, can_act: true },
|
||||||
|
{ id: 7, can_act: true }
|
||||||
|
],
|
||||||
|
moderator: true,
|
||||||
|
admin: true,
|
||||||
|
staff: true,
|
||||||
|
user_id: 1,
|
||||||
|
hidden: false,
|
||||||
|
trust_level: 4,
|
||||||
|
deleted_at: null,
|
||||||
|
user_deleted: false,
|
||||||
|
edit_reason: null,
|
||||||
|
can_view_edit_history: true,
|
||||||
|
wiki: false,
|
||||||
|
user_custom_fields: { team: "Engineering", votes: [247, 251, 248] },
|
||||||
|
can_accept_answer: false,
|
||||||
|
can_unaccept_answer: false,
|
||||||
|
accepted_answer: false,
|
||||||
|
can_translate: false,
|
||||||
|
can_vote: true,
|
||||||
|
polls: [
|
||||||
|
{
|
||||||
|
name: "poll",
|
||||||
|
type: "multiple",
|
||||||
|
status: "open",
|
||||||
|
results: "always",
|
||||||
|
min: 1,
|
||||||
|
max: 3,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
id: "687a1ccf3c6a260f9aeeb7f68a1d463c",
|
||||||
|
html: "This Is",
|
||||||
|
votes: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "9377906763a1221d31d656ea0c4a4495",
|
||||||
|
html: "A test for sure",
|
||||||
|
votes: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ecf47c65a85a0bb20029072b1b721977",
|
||||||
|
html: "Why not give it some more",
|
||||||
|
votes: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
voters: 2,
|
||||||
|
chart_type: "pie"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
polls_votes: {
|
||||||
|
poll: [
|
||||||
|
"687a1ccf3c6a260f9aeeb7f68a1d463c",
|
||||||
|
"9377906763a1221d31d656ea0c4a4495"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
stream: [294]
|
||||||
|
},
|
||||||
|
timeline_lookup: [[1, 2]],
|
||||||
|
suggested_topics: [],
|
||||||
|
tags: [],
|
||||||
|
id: 256,
|
||||||
|
title: "14 the title must be longer i guess",
|
||||||
|
fancy_title: "14 the title must be longer i guess",
|
||||||
|
posts_count: 1,
|
||||||
|
created_at: "2019-11-22T18:55:41.259Z",
|
||||||
|
views: 3,
|
||||||
|
reply_count: 0,
|
||||||
|
like_count: 0,
|
||||||
|
last_posted_at: "2019-11-22T18:55:41.439Z",
|
||||||
|
visible: true,
|
||||||
|
closed: false,
|
||||||
|
archived: false,
|
||||||
|
has_summary: false,
|
||||||
|
archetype: "regular",
|
||||||
|
slug: "14-the-title-must-be-longer-i-guess",
|
||||||
|
category_id: 1,
|
||||||
|
word_count: 24,
|
||||||
|
deleted_at: null,
|
||||||
|
user_id: 1,
|
||||||
|
featured_link: null,
|
||||||
|
pinned_globally: false,
|
||||||
|
pinned_at: null,
|
||||||
|
pinned_until: null,
|
||||||
|
image_url: null,
|
||||||
|
draft: null,
|
||||||
|
draft_key: "topic_256",
|
||||||
|
draft_sequence: 0,
|
||||||
|
posted: false,
|
||||||
|
unpinned: null,
|
||||||
|
pinned: false,
|
||||||
|
current_post_number: 1,
|
||||||
|
highest_post_number: 1,
|
||||||
|
last_read_post_number: 1,
|
||||||
|
last_read_post_id: 294,
|
||||||
|
deleted_by: null,
|
||||||
|
actions_summary: [
|
||||||
|
{ id: 4, count: 0, hidden: false, can_act: true },
|
||||||
|
{ id: 8, count: 0, hidden: false, can_act: true },
|
||||||
|
{ id: 7, count: 0, hidden: false, can_act: true }
|
||||||
|
],
|
||||||
|
chunk_size: 20,
|
||||||
|
bookmarked: false,
|
||||||
|
topic_timer: null,
|
||||||
|
private_topic_timer: null,
|
||||||
|
message_bus_last_id: 1,
|
||||||
|
participant_count: 1,
|
||||||
|
show_read_indicator: false,
|
||||||
|
can_vote: true,
|
||||||
|
vote_count: 0,
|
||||||
|
user_voted: false,
|
||||||
|
details: {
|
||||||
|
notification_level: 1,
|
||||||
|
notifications_reason_id: null,
|
||||||
|
can_create_post: true,
|
||||||
|
can_reply_as_new_topic: true,
|
||||||
|
can_flag_topic: true,
|
||||||
|
participants: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
username: "markvanlan",
|
||||||
|
name: "",
|
||||||
|
avatar_template: "/user_avatar/localhost/markvanlan/{size}/11_2.png",
|
||||||
|
post_count: 1,
|
||||||
|
primary_group_name: "Team",
|
||||||
|
primary_group_flair_url: null,
|
||||||
|
primary_group_flair_color: "",
|
||||||
|
primary_group_flair_bg_color: ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
created_by: {
|
||||||
|
id: 1,
|
||||||
|
username: "markvanlan",
|
||||||
|
name: "",
|
||||||
|
avatar_template: "/user_avatar/localhost/markvanlan/{size}/11_2.png"
|
||||||
|
},
|
||||||
|
last_poster: {
|
||||||
|
id: 1,
|
||||||
|
username: "markvanlan",
|
||||||
|
name: "",
|
||||||
|
avatar_template: "/user_avatar/localhost/markvanlan/{size}/11_2.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in New Issue
Block a user