FEATURE: Pie chart option for poll results (#8352)

This commit is contained in:
Mark VanLandingham
2019-11-25 11:51:01 -06:00
committed by GitHub
parent 720101b3ee
commit b92a8131c0
23 changed files with 947 additions and 47 deletions

View File

@@ -2,9 +2,12 @@ import Controller from "@ember/controller";
import {
default as computed,
observes
} from "ember-addons/ember-computed-decorators";
} from "discourse-common/utils/decorators";
import EmberObject from "@ember/object";
export const BAR_CHART_TYPE = "bar";
export const PIE_CHART_TYPE = "pie";
export default Controller.extend({
regularPollType: "regular",
numberPollType: "number",
@@ -14,6 +17,10 @@ export default Controller.extend({
votePollResult: "on_vote",
closedPollResult: "on_close",
staffPollResult: "staff_only",
pollChartTypes: [
{ name: BAR_CHART_TYPE.capitalize(), value: BAR_CHART_TYPE },
{ name: PIE_CHART_TYPE.capitalize(), value: PIE_CHART_TYPE }
],
init() {
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(
"alwaysPollResult",
"votePollResult",
@@ -173,6 +185,7 @@ export default Controller.extend({
"pollMax",
"pollStep",
"autoClose",
"chartType",
"date",
"time"
)
@@ -187,6 +200,7 @@ export default Controller.extend({
pollMax,
pollStep,
autoClose,
chartType,
date,
time
) {
@@ -212,6 +226,8 @@ export default Controller.extend({
if (pollMax) pollHeader += ` max=${pollMax}`;
if (isNumber) pollHeader += ` step=${step}`;
if (publicPoll) pollHeader += ` public=true`;
if (chartType && pollType !== "number")
pollHeader += ` chartType=${chartType}`;
if (autoClose) {
let closeDate = moment(
date + " " + time,
@@ -306,6 +322,7 @@ export default Controller.extend({
pollMax: null,
pollStep: 1,
autoClose: false,
chartType: BAR_CHART_TYPE,
date: moment()
.add(1, "day")
.format("YYYY-MM-DD"),

View File

@@ -17,6 +17,15 @@
valueAttribute="value"}}
</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}}
<div class="input-group poll-number">
<label class="input-group-label">{{i18n 'poll.ui_builder.poll_config.min'}}</label>
@@ -56,12 +65,14 @@
{{input-tip validation=minNumOfOptionsValidation}}
{{/unless}}
<div class="input-group poll-checkbox">
<label>
{{input type='checkbox' checked=publicPoll}}
{{i18n "poll.ui_builder.poll_public.label"}}
</label>
</div>
{{#unless isPie}}
<div class="input-group poll-checkbox">
<label>
{{input type='checkbox' checked=publicPoll}}
{{i18n "poll.ui_builder.poll_public.label"}}
</label>
</div>
{{/unless}}
<div class="input-group poll-checkbox">
<label>

View File

@@ -1,5 +1,5 @@
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 WidgetGlue from "discourse/widgets/glue";
@@ -88,12 +88,19 @@ function initializePolls(api) {
}
if (poll) {
const glue = new WidgetGlue("discourse-poll", register, {
const attrs = {
id: `${pollName}-${post.id}`,
post,
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);
_glued.push(glue);
}

View 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;
}

View File

@@ -10,6 +10,7 @@ const WHITELISTED_ATTRIBUTES = [
"order",
"public",
"results",
"chartType",
"status",
"step",
"type"

View File

@@ -8,6 +8,11 @@ import evenRound from "discourse/plugins/poll/lib/even-round";
import { avatarFor } from "discourse/widgets/post";
import round from "discourse/lib/round";
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) {
const $node = $(`<span>${option.html}</span>`);
@@ -323,7 +328,11 @@ createWidget("discourse-poll-container", {
if (attrs.showResults) {
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) {
return h(
"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", {
tagName: "div.poll-buttons",
@@ -433,7 +632,7 @@ createWidget("discourse-poll-buttons", {
const castVotesDisabled = !attrs.canCastVotes;
contents.push(
this.attach("button", {
className: `btn cast-votes ${
className: `cast-votes ${
castVotesDisabled ? "btn-default" : "btn-primary"
}`,
label: "poll.cast-votes.label",
@@ -448,7 +647,7 @@ createWidget("discourse-poll-buttons", {
if (attrs.showResults || hideResultsDisabled) {
contents.push(
this.attach("button", {
className: "btn btn-default toggle-results",
className: "btn-default toggle-results",
label: "poll.hide-results.label",
title: "poll.hide-results.title",
icon: "far-eye-slash",
@@ -466,7 +665,7 @@ createWidget("discourse-poll-buttons", {
} else {
contents.push(
this.attach("button", {
className: "btn btn-default toggle-results",
className: "btn-default toggle-results",
label: "poll.show-results.label",
title: "poll.show-results.title",
icon: "far-eye",
@@ -521,7 +720,7 @@ createWidget("discourse-poll-buttons", {
if (!attrs.isAutomaticallyClosed) {
contents.push(
this.attach("button", {
className: "btn btn-default toggle-status",
className: "btn-default toggle-status",
label: "poll.open.label",
title: "poll.open.title",
icon: "unlock-alt",
@@ -532,7 +731,7 @@ createWidget("discourse-poll-buttons", {
} else {
contents.push(
this.attach("button", {
className: "btn toggle-status btn-danger",
className: "toggle-status btn-danger",
label: "poll.close.label",
title: "poll.close.title",
icon: "lock",
@@ -547,11 +746,14 @@ createWidget("discourse-poll-buttons", {
});
export default createWidget("discourse-poll", {
tagName: "div.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")
};
@@ -808,5 +1010,21 @@ export default createWidget("discourse-poll", {
.finally(() => {
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;
}
});

View File

@@ -119,6 +119,53 @@ div.poll {
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

View File

@@ -8,7 +8,6 @@ div.poll {
width: 100%;
display: table-cell;
text-align: center;
border-left: 1px solid $primary-low;
.info-number {
font-size: 3.5em;
@@ -26,6 +25,7 @@ div.poll {
.poll-container {
display: table-cell;
width: 100%;
border-right: 1px solid $primary-low;
}
.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 {
.poll-buttons {
a:not(:first-child) {

View File

@@ -11,7 +11,6 @@ div.poll {
padding: 0 1em;
display: flex;
flex-wrap: wrap;
border-top: 1px solid $primary-low;
.info-text {
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%;
}
}
}