FEATURE: Poll breakdown 2.0 (#10345)

FEATURE: Poll breakdown 2.0 (#10345)

The poll breakdown modal replaces the grouped pie charts feature.

Includes:

  • MODAL: Untangle onSelectPanel Previously modal-tab component would call on click the onSelectPanel callback with itself (modal-tab) as this which severely limited its usefulness. Now showModal binds the callback to its controller.

“The PR includes a fix/change to d-modal (b7f6ec6) that hasn’t been extracted to a separate PR because it’s not currently possible to test a change like this in abstract, i.e. with dynamically created controllers/components in tests. The percentage/count toggle test for the poll breakdown feature is essentially a test for that d-modal modification.”

diff --git a/app/assets/javascripts/discourse/app/components/modal-tab.js b/app/assets/javascripts/discourse/app/components/modal-tab.js
index 3211fbb..a798e22 100644
--- a/app/assets/javascripts/discourse/app/components/modal-tab.js
+++ b/app/assets/javascripts/discourse/app/components/modal-tab.js
@@ -20,6 +20,10 @@ export default Component.extend({
   },
 
   click() {
-    this.onSelectPanel(this.panel);
+    this.set("selectedPanel", this.panel);
+
+    if (this.onSelectPanel) {
+      this.onSelectPanel(this.panel);
+    }
   }
 });
diff --git a/app/assets/javascripts/discourse/app/lib/show-modal.js b/app/assets/javascripts/discourse/app/lib/show-modal.js
index 37c6b8e..6a6fb17 100644
--- a/app/assets/javascripts/discourse/app/lib/show-modal.js
+++ b/app/assets/javascripts/discourse/app/lib/show-modal.js
@@ -50,7 +50,10 @@ export default function(name, opts) {
     });
 
     if (controller.actions.onSelectPanel) {
-      modalController.set("onSelectPanel", controller.actions.onSelectPanel);
+      modalController.set(
+        "onSelectPanel",
+        controller.actions.onSelectPanel.bind(controller)
+      );
     }
 
     modalController.set(
diff --git a/app/assets/javascripts/discourse/app/mixins/modal-functionality.js b/app/assets/javascripts/discourse/app/mixins/modal-functionality.js
index 70b5bd0..ac40b16 100644
--- a/app/assets/javascripts/discourse/app/mixins/modal-functionality.js
+++ b/app/assets/javascripts/discourse/app/mixins/modal-functionality.js
@@ -18,10 +18,6 @@ export default Mixin.create({
     closeModal() {
       this.modal.send("closeModal");
       this.set("panels", []);
-    },
-
-    onSelectPanel(panel) {
-      this.set("selectedPanel", panel);
     }
   }
 });
diff --git a/app/assets/stylesheets/common/base/modal.scss b/app/assets/stylesheets/common/base/modal.scss
index 178be0a..fc09f89 100644
--- a/app/assets/stylesheets/common/base/modal.scss
+++ b/app/assets/stylesheets/common/base/modal.scss
@@ -148,7 +148,7 @@
   }
 
   &:not(.history-modal) {
-    .modal-body:not(.reorder-categories):not(.poll-ui-builder) {
+    .modal-body:not(.reorder-categories):not(.poll-ui-builder):not(.poll-breakdown) {
       max-height: 80vh !important;
       @media screen and (max-height: 500px) {
         max-height: 65vh !important;
diff --git a/lib/tasks/javascript.rake b/lib/tasks/javascript.rake
index c7c7452..25392df 100644
--- a/lib/tasks/javascript.rake
+++ b/lib/tasks/javascript.rake
@@ -70,6 +70,9 @@ task 'javascript:update' do
       source: 'chart.js/dist/Chart.min.js',
       public: true
     }, {
+      source: 'chartjs-plugin-datalabels/dist/chartjs-plugin-datalabels.min.js',
+      public: true
+    }, {
       source: 'magnific-popup/dist/jquery.magnific-popup.min.js',
       public: true
     }, {
diff --git a/package.json b/package.json
index 9ce74d6..e73fee2 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
     "bootbox": "3.2.0",
     "bootstrap": "v3.4.1",
     "chart.js": "2.9.3",
+    "chartjs-plugin-datalabels": "^0.7.0",
     "eslint-plugin-lodash": "^6.0.0",
     "favcount": "https://github.com/chrishunt/favcount",
     "handlebars": "^4.7.0",
diff --git a/plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6 b/plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6
new file mode 100644
index 0000000..ea9a345
--- /dev/null
+++ b/plugins/poll/assets/javascripts/components/poll-breakdown-chart.js.es6
@@ -0,0 +1,182 @@
+import I18n from "I18n";
+import Component from "@ember/component";
+import { mapBy } from "@ember/object/computed";
+import { htmlSafe } from "@ember/template";
+import { PIE_CHART_TYPE } from "discourse/plugins/poll/controllers/poll-ui-builder";
+import { getColors } from "discourse/plugins/poll/lib/chart-colors";
+import discourseComputed from "discourse-common/utils/decorators";
+
+export default Component.extend({
+  // Arguments:
+  group: null,
+  options: null,
+  displayMode: null,
+  highlightedOption: null,
+  setHighlightedOption: null,
+
+  classNames: "poll-breakdown-chart-container",
+
+  _optionToSlice: null,
+  _previousHighlightedSliceIndex: null,
+  _previousDisplayMode: null,
+
+  data: mapBy("options", "votes"),
+
+  init() {
+    this._super(...arguments);
+    this._optionToSlice = {};
+  },
+
+  didInsertElement() {
+    this._super(...arguments);
+
+    const canvas = this.element.querySelector("canvas");
+    this._chart = new window.Chart(canvas.getContext("2d"), this.chartConfig);
+  },
+
+  didReceiveAttrs() {
+    this._super(...arguments);
+
+    if (this._chart) {
+      this._updateDisplayMode();
+      this._updateHighlight();
+    }
+  },
+
+  willDestroy() {
+    this._super(...arguments);
+
+    if (this._chart) {
+      this._chart.destroy();
+    }
+  },
+
+  @discourseComputed("optionColors", "index")
+  colorStyle(optionColors, index) {
+    return htmlSafe(`background: ${optionColors[index]};`);
+  },
+
+  @discourseComputed("data", "displayMode")
+  chartConfig(data, displayMode) {
+    const transformedData = [];
+    let counter = 0;
+
+    this._optionToSlice = {};
+
+    data.forEach((votes, index) => {
+      if (votes > 0) {
+        transformedData.push(votes);
+        this._optionToSlice[index] = counter++;
+      }
+    });
+
+    const totalVotes = transformedData.reduce((sum, votes) => sum + votes, 0);
+    const colors = getColors(data.length).filter(
+      (color, index) => data[index] > 0
+    );
+
+    return {
+      type: PIE_CHART_TYPE,
+      plugins: [window.ChartDataLabels],
+      data: {
+        datasets: [
+          {
+            data: transformedData,
+            backgroundColor: colors,
+            // TODO: It's a workaround for Chart.js' terrible hover styling.
+            // It will break on non-white backgrounds.
+            // Should be updated after #10341 lands
+            hoverBorderColor: "#fff"
+          }
+        ]
+      },
+      options: {
+        plugins: {
+          datalabels: {
+            color: "#333",
+            backgroundColor: "rgba(255, 255, 255, 0.5)",
+            borderRadius: 2,
+            font: {
+              family: getComputedStyle(document.body).fontFamily,
+              size: 16
+            },
+            padding: {
+              top: 2,
+              right: 6,
+              bottom: 2,
+              left: 6
+            },
+            formatter(votes) {
+              if (displayMode !== "percentage") {
+                return votes;
+              }
+
+              const percent = I18n.toNumber((votes / totalVotes) * 100.0, {
+                precision: 1
+              });
+
+              return `${percent}%`;
+            }
+          }
+        },
+        responsive: true,
+        aspectRatio: 1.1,
+        animation: { duration: 0 },
+        tooltips: false,
+        onHover: (event, activeElements) => {
+          if (!activeElements.length) {
+            this.setHighlightedOption(null);
+            return;
+          }
+
+          const sliceIndex = activeElements[0]._index;
+          const optionIndex = Object.keys(this._optionToSlice).find(
+            option => this._optionToSlice[option] === sliceIndex
+          );
+
+          // Clear the array to avoid issues in Chart.js
+          activeElements.length = 0;
+
+          this.setHighlightedOption(Number(optionIndex));
+        }
+      }
+    };
+  },
+
+  _updateDisplayMode() {
+    if (this.displayMode !== this._previousDisplayMode) {
+      const config = this.chartConfig;
+      this._chart.data.datasets = config.data.datasets;
+      this._chart.options = config.options;
+
+      this._chart.update();
+      this._previousDisplayMode = this.displayMode;
+    }
+  },
+
+  _updateHighlight() {
+    const meta = this._chart.getDatasetMeta(0);
+
+    if (this._previousHighlightedSliceIndex !== null) {
+      const slice = meta.data[this._previousHighlightedSliceIndex];
+      meta.controller.removeHoverStyle(slice);

[... diff too long, it was truncated ...]

GitHub sha: cd4f2518

1 Like

This commit appears in #10345 which was approved by awesomerobot. It was merged by CvX.