From 773e198cb3d18150af59bfd590c9d44c975bfb21 Mon Sep 17 00:00:00 2001
From: David Taylor <david@taylorhq.com>
Date: Tue, 4 Jul 2023 15:25:34 +0100
Subject: [PATCH] DEV: Convert poll modals to new component-based API (#22164)

---
 .../components/modal/poll-breakdown.hbs       |  76 ++++++
 .../modal}/poll-breakdown.js                  |  29 +-
 .../components/modal/poll-ui-builder.hbs      | 251 ++++++++++++++++++
 .../modal}/poll-ui-builder.js                 |  49 +---
 .../components/poll-breakdown-chart.js        |   2 +-
 .../initializers/add-poll-ui-builder.js       |   9 +-
 .../templates/modal/poll-breakdown.hbs        |  55 ----
 .../templates/modal/poll-ui-builder.hbs       | 244 -----------------
 .../discourse/widgets/discourse-poll.js       |  11 +-
 .../component/poll-ui-builder-test.js         | 214 +++++++++++++++
 .../unit/controllers/poll-ui-builder-test.js  | 205 --------------
 11 files changed, 579 insertions(+), 566 deletions(-)
 create mode 100644 plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.hbs
 rename plugins/poll/assets/javascripts/discourse/{controllers => components/modal}/poll-breakdown.js (88%)
 create mode 100644 plugins/poll/assets/javascripts/discourse/components/modal/poll-ui-builder.hbs
 rename plugins/poll/assets/javascripts/discourse/{controllers => components/modal}/poll-ui-builder.js (90%)
 delete mode 100644 plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs
 delete mode 100644 plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs
 create mode 100644 plugins/poll/test/javascripts/component/poll-ui-builder-test.js
 delete mode 100644 plugins/poll/test/javascripts/unit/controllers/poll-ui-builder-test.js

diff --git a/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.hbs b/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.hbs
new file mode 100644
index 00000000000..218405dd995
--- /dev/null
+++ b/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.hbs
@@ -0,0 +1,76 @@
+{{! template-lint-disable no-invalid-interactive }}
+<DModal @title={{i18n "poll.breakdown.title"}} class="has-tabs">
+  <:headerBelowTitle>
+    <ul class="modal-tabs">
+      <li
+        class={{concat-class
+          "modal-tab percentage"
+          (if (eq this.displayMode "percentage") "active")
+        }}
+        {{on "click" (fn (mut this.displayMode) "percentage")}}
+      >{{i18n "poll.breakdown.percentage"}}</li>
+      <li
+        class={{concat-class
+          "modal-tab count"
+          (if (eq this.displayMode "count") "active")
+        }}
+        {{on "click" (fn (mut this.displayMode) "count")}}
+      >{{i18n "poll.breakdown.count"}}</li>
+    </ul>
+  </:headerBelowTitle>
+  <:body>
+    <div class="poll-breakdown-sidebar">
+      <p class="poll-breakdown-title">
+        {{this.title}}
+      </p>
+
+      <div class="poll-breakdown-total-votes">{{i18n
+          "poll.breakdown.votes"
+          count=this.model.poll.voters
+        }}</div>
+
+      <ul class="poll-breakdown-options">
+        {{#each this.model.poll.options as |option index|}}
+          <PollBreakdownOption
+            @option={{option}}
+            @index={{index}}
+            @totalVotes={{this.totalVotes}}
+            @optionsCount={{this.model.poll.options.length}}
+            @displayMode={{this.displayMode}}
+            @highlightedOption={{this.highlightedOption}}
+            @onMouseOver={{fn (mut this.highlightedOption) index}}
+            @onMouseOut={{fn (mut this.highlightedOption) null}}
+          />
+        {{/each}}
+      </ul>
+    </div>
+
+    <div class="poll-breakdown-body">
+      <div class="poll-breakdown-body-header">
+        <label class="poll-breakdown-body-header-label">{{i18n
+            "poll.breakdown.breakdown"
+          }}</label>
+
+        <ComboBox
+          @content={{this.groupableUserFields}}
+          @value={{this.groupedBy}}
+          @nameProperty="label"
+          @class="poll-breakdown-dropdown"
+          @onChange={{action this.setGrouping}}
+        />
+      </div>
+
+      <div class="poll-breakdown-charts">
+        {{#each this.charts as |chart|}}
+          <PollBreakdownChart
+            @group={{get chart "group"}}
+            @options={{get chart "options"}}
+            @displayMode={{this.displayMode}}
+            @highlightedOption={{this.highlightedOption}}
+            @setHighlightedOption={{fn (mut this.highlightedOption)}}
+          />
+        {{/each}}
+      </div>
+    </div>
+  </:body>
+</DModal>
\ No newline at end of file
diff --git a/plugins/poll/assets/javascripts/discourse/controllers/poll-breakdown.js b/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.js
similarity index 88%
rename from plugins/poll/assets/javascripts/discourse/controllers/poll-breakdown.js
rename to plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.js
index ba2e2514e8c..0cd13fa67fa 100644
--- a/plugins/poll/assets/javascripts/discourse/controllers/poll-breakdown.js
+++ b/plugins/poll/assets/javascripts/discourse/components/modal/poll-breakdown.js
@@ -1,7 +1,6 @@
 import { inject as service } from "@ember/service";
-import Controller from "@ember/controller";
+import Component from "@ember/component";
 import I18n from "I18n";
-import ModalFunctionality from "discourse/mixins/modal-functionality";
 import { action } from "@ember/object";
 import { ajax } from "discourse/lib/ajax";
 import { classify } from "@ember/string";
@@ -10,9 +9,7 @@ import { htmlSafe } from "@ember/template";
 import loadScript from "discourse/lib/load-script";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 
-export default class PollBreakdownController extends Controller.extend(
-  ModalFunctionality
-) {
+export default class PollBreakdownModal extends Component {
   @service dialog;
 
   model = null;
@@ -21,6 +18,16 @@ export default class PollBreakdownController extends Controller.extend(
   highlightedOption = null;
   displayMode = "percentage";
 
+  init() {
+    this.set("groupedBy", this.model.groupableUserFields[0]);
+    loadScript("/javascripts/Chart.min.js")
+      .then(() => loadScript("/javascripts/chartjs-plugin-datalabels.min.js"))
+      .then(() => {
+        this.fetchGroupedPollData();
+      });
+    super.init(...arguments);
+  }
+
   @discourseComputed("model.poll.title", "model.post.topic.title")
   title(pollTitle, topicTitle) {
     return pollTitle ? htmlSafe(pollTitle) : topicTitle;
@@ -44,18 +51,6 @@ export default class PollBreakdownController extends Controller.extend(
     return options.reduce((sum, option) => sum + option.votes, 0);
   }
 
-  onShow() {
-    this.set("charts", null);
-    this.set("displayMode", "percentage");
-    this.set("groupedBy", this.model.groupableUserFields[0]);
-
-    loadScript("/javascripts/Chart.min.js")
-      .then(() => loadScript("/javascripts/chartjs-plugin-datalabels.min.js"))
-      .then(() => {
-        this.fetchGroupedPollData();
-      });
-  }
-
   fetchGroupedPollData() {
     return ajax("/polls/grouped_poll_results.json", {
       data: {
diff --git a/plugins/poll/assets/javascripts/discourse/components/modal/poll-ui-builder.hbs b/plugins/poll/assets/javascripts/discourse/components/modal/poll-ui-builder.hbs
new file mode 100644
index 00000000000..0955f70b3ff
--- /dev/null
+++ b/plugins/poll/assets/javascripts/discourse/components/modal/poll-ui-builder.hbs
@@ -0,0 +1,251 @@
+<DModal
+  @title={{i18n "poll.ui_builder.title"}}
+  @closeModal={{@closeModal}}
+  @inline={{@inline}}
+  class="poll-ui-builder"
+>
+  <:body>
+    <div class="input-group poll-type">
+      <a
+        href
+        {{on "click" (fn this.updatePollType "regular")}}
+        class="poll-type-value poll-type-value-regular
+          {{if this.isRegular 'active'}}"
+      >
+        {{i18n "poll.ui_builder.poll_type.regular"}}
+      </a>
+
+      <a
+        href
+        {{on "click" (fn this.updatePollType "multiple")}}
+        class="poll-type-value poll-type-value-multiple
+          {{if this.isMultiple 'active'}}"
+      >
+        {{i18n "poll.ui_builder.poll_type.multiple"}}
+      </a>
+
+      {{#if this.showNumber}}
+        <a
+          href
+          {{on "click" (fn this.updatePollType "number")}}
+          class="poll-type-value poll-type-value-number
+            {{if this.isNumber 'active'}}"
+        >
+          {{i18n "poll.ui_builder.poll_type.number"}}
+        </a>
+      {{/if}}
+    </div>
+
+    {{#if this.showAdvanced}}
+      <div class="input-group poll-title">
+        <label class="input-group-label">{{i18n
+            "poll.ui_builder.poll_title.label"
+          }}</label>
+        <Input @value={{this.pollTitle}} />
+      </div>
+    {{/if}}
+
+    {{#unless this.isNumber}}
+      <div class="poll-options">
+        {{#if this.showAdvanced}}
+          <label class="input-group-label">{{i18n
+              "poll.ui_builder.poll_options.label"
+            }}</label>
+          <Textarea
+            @value={{this.pollOptionsText}}
+            {{on "input" (action "onOptionsTextChange")}}
+          />
+          {{#if this.showMinNumOfOptionsValidation}}
+            {{#unless this.minNumOfOptionsValidation.ok}}
+              <InputTip @validation={{this.minNumOfOptionsValidation}} />
+            {{/unless}}
+          {{/if}}
+        {{else}}
+          {{#each this.pollOptions as |option|}}
+            <div class="input-group poll-option-value">
+              <Input
+                @value={{option.value}}
+                @enter={{action "addOption" option}}
+              />
+              {{#if this.canRemoveOption}}
+                <DButton
+                  @icon="trash-alt"
+                  @action={{action "removeOption" option}}
+                />
+              {{/if}}
+            </div>
+          {{/each}}
+
+          <div class="poll-option-controls">
+            <DButton
+              class="btn-default poll-option-add"
+              @icon="plus"
+              @label="poll.ui_builder.poll_options.add"
+              @action={{action "addOption" this.pollOptions.lastObject}}
+            />
+            {{#if
+              (and
+                this.showMinNumOfOptionsValidation
+                (not this.minNumOfOptionsValidation.ok)
+              )
+            }}
+              <InputTip @validation={{this.minNumOfOptionsValidation}} />
+            {{/if}}
+          </div>
+        {{/if}}
+      </div>
+    {{/unless}}
+
+    {{#unless this.isRegular}}
+      <div class="options">
+        <div class="input-group poll-number">
+          <label class="input-group-label">{{i18n
+              "poll.ui_builder.poll_config.min"
+            }}</label>
+          <Input
+            @type="number"
+            @value={{this.pollMin}}
+            class="poll-options-min"
+            min="1"
+          />
+        </div>
+
+        <div class="input-group poll-number">
+          <label class="input-group-label">{{i18n
+              "poll.ui_builder.poll_config.max"
+            }}</label>
+          <Input
+            @type="number"
+            @value={{this.pollMax}}
+            class="poll-options-max"
+            min="1"
+          />
+        </div>
+
+        {{#if this.isNumber}}
+          <div class="input-group poll-number">
+            <label class="input-group-label">{{i18n
+                "poll.ui_builder.poll_config.step"
+              }}</label>
+            <Input
+              @type="number"
+              @value={{this.pollStep}}
+              min="1"
+              class="poll-options-step"
+            />
+          </div>
+        {{/if}}
+      </div>
+
+      {{#unless this.minMaxValueValidation.ok}}
+        <InputTip @validation={{this.minMaxValueValidation}} />
+      {{/unless}}
+    {{/unless}}
+
+    {{#if this.showAdvanced}}
+      <div class="input-group poll-allowed-groups">
+        <label class="input-group-label">{{i18n
+            "poll.ui_builder.poll_groups.label"
+          }}</label>
+        <GroupChooser
+          @content={{this.siteGroups}}
+          @value={{this.pollGroups}}
+          @onChange={{action (mut this.pollGroups)}}
+          @labelProperty="name"
+          @valueProperty="name"
+        />
+      </div>
+
+      <div class="input-group poll-date">
+        <label class="input-group-label">{{i18n
+            "poll.ui_builder.automatic_close.label"
+          }}</label>
+        <DateTimeInput
+          @date={{this.pollAutoClose}}
+          @onChange={{action (mut this.pollAutoClose)}}
+          @clearable={{true}}
+          @useGlobalPickerContainer={{true}}
+        />
+      </div>
+
+      <div class="input-group poll-select">
+        <label class="input-group-label">{{i18n
+            "poll.ui_builder.poll_result.label"
+          }}</label>
+        <ComboBox
+          @content={{this.pollResults}}
+          @value={{this.pollResult}}
+          @class="poll-result"
+          @valueProperty="value"
+          @onChange={{action (mut this.pollResult)}}
+        />
+      </div>
+
+      {{#unless this.isNumber}}
+        <div class="input-group poll-select column">
+          <label class="input-group-label">{{i18n
+              "poll.ui_builder.poll_chart_type.label"
+            }}</label>
+
+          <div class="radio-group">
+            <RadioButton
+              @id="poll-chart-type-bar"
+              @name="poll-chart-type"
+              @value="bar"
+              @selection={{this.chartType}}
+            />
+            <label for="poll-chart-type-bar">{{d-icon "chart-bar"}}
+              {{i18n "poll.ui_builder.poll_chart_type.bar"}}</label>
+          </div>
+
+          <div class="radio-group">
+            <RadioButton
+              @id="poll-chart-type-pie"
+              @name="poll-chart-type"
+              @value="pie"
+              @selection={{this.chartType}}
+            />
+            <label for="poll-chart-type-pie">{{d-icon "chart-pie"}}
+              {{i18n "poll.ui_builder.poll_chart_type.pie"}}</label>
+          </div>
+        </div>
+      {{/unless}}
+
+      {{#unless this.isPie}}
+        <div class="input-group poll-checkbox column">
+          <label>
+            <Input
+              @type="checkbox"
+              @checked={{this.publicPoll}}
+              class="poll-toggle-public"
+            />
+            {{i18n "poll.ui_builder.poll_public.label"}}
+          </label>
+        </div>
+      {{/unless}}
+    {{/if}}
+  </:body>
+  <:footer>
+    <DButton
+      @action={{action "insertPoll"}}
+      @icon="chart-bar"
+      class="btn-primary insert-poll"
+      @label="poll.ui_builder.insert"
+      @disabled={{this.disableInsert}}
+    />
+
+    <DButton @label="cancel" @class="btn-flat" @action={{@closeModal}} />
+
+    <DButton
+      @action={{action "toggleAdvanced"}}
+      class="btn-default show-advanced"
+      @icon="cog"
+      @title={{if
+        this.showAdvanced
+        "poll.ui_builder.hide_advanced"
+        "poll.ui_builder.show_advanced"
+      }}
+    />
+
+  </:footer>
+</DModal>
\ No newline at end of file
diff --git a/plugins/poll/assets/javascripts/discourse/controllers/poll-ui-builder.js b/plugins/poll/assets/javascripts/discourse/components/modal/poll-ui-builder.js
similarity index 90%
rename from plugins/poll/assets/javascripts/discourse/controllers/poll-ui-builder.js
rename to plugins/poll/assets/javascripts/discourse/components/modal/poll-ui-builder.js
index df707bac268..f05f3f5ece9 100644
--- a/plugins/poll/assets/javascripts/discourse/controllers/poll-ui-builder.js
+++ b/plugins/poll/assets/javascripts/discourse/components/modal/poll-ui-builder.js
@@ -1,10 +1,9 @@
 import { gt, or } from "@ember/object/computed";
-import Controller from "@ember/controller";
+import Component from "@ember/component";
 import EmberObject, { action } from "@ember/object";
 import { next } from "@ember/runloop";
 import discourseComputed from "discourse-common/utils/decorators";
 import { observes } from "@ember-decorators/object";
-import ModalFunctionality from "discourse/mixins/modal-functionality";
 import I18n from "I18n";
 
 export const BAR_CHART_TYPE = "bar";
@@ -19,46 +18,26 @@ const VOTE_POLL_RESULT = "on_vote";
 const CLOSED_POLL_RESULT = "on_close";
 const STAFF_POLL_RESULT = "staff_only";
 
-export default class PollUiBuilderController extends Controller.extend(
-  ModalFunctionality
-) {
+export default class PollUiBuilderModal extends Component {
   showAdvanced = false;
   pollType = REGULAR_POLL_TYPE;
-  pollTitle = "";
-  pollOptions = null;
-  pollOptionsText = null;
+  pollTitle;
+  pollOptions = [EmberObject.create({ value: "" })];
+  pollOptionsText = "";
   pollMin = 1;
   pollMax = 2;
   pollStep = 1;
-  pollGroups = null;
-  pollAutoClose = null;
+  pollGroups;
+  pollAutoClose;
   pollResult = ALWAYS_POLL_RESULT;
   chartType = BAR_CHART_TYPE;
-  publicPoll = null;
+  publicPoll = false;
 
   @or("showAdvanced", "isNumber") showNumber;
   @gt("pollOptions.length", 1) canRemoveOption;
 
-  onShow() {
-    this.setProperties({
-      showAdvanced: false,
-      pollType: REGULAR_POLL_TYPE,
-      pollTitle: null,
-      pollOptions: [EmberObject.create({ value: "" })],
-      pollOptionsText: "",
-      pollMin: 1,
-      pollMax: 2,
-      pollStep: 1,
-      pollGroups: null,
-      pollAutoClose: null,
-      pollResult: ALWAYS_POLL_RESULT,
-      chartType: BAR_CHART_TYPE,
-      publicPoll: false,
-    });
-  }
-
-  @discourseComputed
-  pollResults() {
+  @discourseComputed("currentUser.staff")
+  pollResults(staff) {
     const options = [
       {
         name: I18n.t("poll.ui_builder.poll_result.always"),
@@ -74,7 +53,7 @@ export default class PollUiBuilderController extends Controller.extend(
       },
     ];
 
-    if (this.get("currentUser.staff")) {
+    if (staff) {
       options.push({
         name: I18n.t("poll.ui_builder.poll_result.staff"),
         value: STAFF_POLL_RESULT,
@@ -168,7 +147,7 @@ export default class PollUiBuilderController extends Controller.extend(
     let pollHeader = "[poll";
     let output = "";
 
-    const match = this.toolbarEvent
+    const match = this.model.toolbarEvent
       .getText()
       .match(/\[poll(\s+name=[^\s\]]+)*.*\]/gim);
 
@@ -354,8 +333,8 @@ export default class PollUiBuilderController extends Controller.extend(
 
   @action
   insertPoll() {
-    this.toolbarEvent.addText(this.pollOutput);
-    this.send("closeModal");
+    this.model.toolbarEvent.addText(this.pollOutput);
+    this.closeModal();
   }
 
   @action
diff --git a/plugins/poll/assets/javascripts/discourse/components/poll-breakdown-chart.js b/plugins/poll/assets/javascripts/discourse/components/poll-breakdown-chart.js
index 72d5c0ab824..17d0aa6932d 100644
--- a/plugins/poll/assets/javascripts/discourse/components/poll-breakdown-chart.js
+++ b/plugins/poll/assets/javascripts/discourse/components/poll-breakdown-chart.js
@@ -2,7 +2,7 @@ import { classNames } from "@ember-decorators/component";
 import { mapBy } from "@ember/object/computed";
 import Component from "@ember/component";
 import I18n from "I18n";
-import { PIE_CHART_TYPE } from "../controllers/poll-ui-builder";
+import { PIE_CHART_TYPE } from "../components/modal/poll-ui-builder";
 import discourseComputed from "discourse-common/utils/decorators";
 import { getColors } from "discourse/plugins/poll/lib/chart-colors";
 import { htmlSafe } from "@ember/template";
diff --git a/plugins/poll/assets/javascripts/discourse/initializers/add-poll-ui-builder.js b/plugins/poll/assets/javascripts/discourse/initializers/add-poll-ui-builder.js
index ab5c59cfeae..41dea414b41 100644
--- a/plugins/poll/assets/javascripts/discourse/initializers/add-poll-ui-builder.js
+++ b/plugins/poll/assets/javascripts/discourse/initializers/add-poll-ui-builder.js
@@ -1,6 +1,7 @@
 import discourseComputed from "discourse-common/utils/decorators";
-import showModal from "discourse/lib/show-modal";
 import { withPluginApi } from "discourse/lib/plugin-api";
+import PollUiBuilder from "../components/modal/poll-ui-builder";
+import { getOwner } from "@ember/application";
 
 function initializePollUIBuilder(api) {
   api.modifyClass("controller:composer", {
@@ -22,7 +23,11 @@ function initializePollUIBuilder(api) {
 
     actions: {
       showPollBuilder() {
-        showModal("poll-ui-builder").set("toolbarEvent", this.toolbarEvent);
+        getOwner(this)
+          .lookup("service:modal")
+          .show(PollUiBuilder, {
+            model: { toolbarEvent: this.toolbarEvent },
+          });
       },
     },
   });
diff --git a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs
deleted file mode 100644
index 4753fa89d56..00000000000
--- a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs
+++ /dev/null
@@ -1,55 +0,0 @@
-<DModalBody @title="poll.breakdown.title">
-  <div class="poll-breakdown-sidebar">
-    <p class="poll-breakdown-title">
-      {{this.title}}
-    </p>
-
-    <div class="poll-breakdown-total-votes">{{i18n
-        "poll.breakdown.votes"
-        count=this.model.poll.voters
-      }}</div>
-
-    <ul class="poll-breakdown-options">
-      {{#each this.model.poll.options as |option index|}}
-        <PollBreakdownOption
-          @option={{option}}
-          @index={{index}}
-          @totalVotes={{this.totalVotes}}
-          @optionsCount={{this.model.poll.options.length}}
-          @displayMode={{this.displayMode}}
-          @highlightedOption={{this.highlightedOption}}
-          @onMouseOver={{fn (mut this.highlightedOption) index}}
-          @onMouseOut={{fn (mut this.highlightedOption) null}}
-        />
-      {{/each}}
-    </ul>
-  </div>
-
-  <div class="poll-breakdown-body">
-    <div class="poll-breakdown-body-header">
-      <label class="poll-breakdown-body-header-label">{{i18n
-          "poll.breakdown.breakdown"
-        }}</label>
-
-      <ComboBox
-        @content={{this.groupableUserFields}}
-        @value={{this.groupedBy}}
-        @nameProperty="label"
-        @class="poll-breakdown-dropdown"
-        @onChange={{action this.setGrouping}}
-      />
-    </div>
-
-    <div class="poll-breakdown-charts">
-      {{#each this.charts as |chart|}}
-        <PollBreakdownChart
-          @group={{get chart "group"}}
-          @options={{get chart "options"}}
-          @displayMode={{this.displayMode}}
-          @highlightedOption={{this.highlightedOption}}
-          @setHighlightedOption={{fn (mut this.highlightedOption)}}
-        />
-      {{/each}}
-    </div>
-  </div>
-</DModalBody>
\ No newline at end of file
diff --git a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs
deleted file mode 100644
index 41a2fff5d05..00000000000
--- a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs
+++ /dev/null
@@ -1,244 +0,0 @@
-<DModalBody @title="poll.ui_builder.title" @class="poll-ui-builder">
-  <div class="input-group poll-type">
-    <a
-      href
-      {{on "click" (fn this.updatePollType "regular")}}
-      class="poll-type-value poll-type-value-regular
-        {{if this.isRegular 'active'}}"
-    >
-      {{i18n "poll.ui_builder.poll_type.regular"}}
-    </a>
-
-    <a
-      href
-      {{on "click" (fn this.updatePollType "multiple")}}
-      class="poll-type-value poll-type-value-multiple
-        {{if this.isMultiple 'active'}}"
-    >
-      {{i18n "poll.ui_builder.poll_type.multiple"}}
-    </a>
-
-    {{#if this.showNumber}}
-      <a
-        href
-        {{on "click" (fn this.updatePollType "number")}}
-        class="poll-type-value poll-type-value-number
-          {{if this.isNumber 'active'}}"
-      >
-        {{i18n "poll.ui_builder.poll_type.number"}}
-      </a>
-    {{/if}}
-  </div>
-
-  {{#if this.showAdvanced}}
-    <div class="input-group poll-title">
-      <label class="input-group-label">{{i18n
-          "poll.ui_builder.poll_title.label"
-        }}</label>
-      <Input @value={{this.pollTitle}} />
-    </div>
-  {{/if}}
-
-  {{#unless this.isNumber}}
-    <div class="poll-options">
-      {{#if this.showAdvanced}}
-        <label class="input-group-label">{{i18n
-            "poll.ui_builder.poll_options.label"
-          }}</label>
-        <Textarea
-          @value={{this.pollOptionsText}}
-          {{on "input" (action "onOptionsTextChange")}}
-        />
-        {{#if this.showMinNumOfOptionsValidation}}
-          {{#unless this.minNumOfOptionsValidation.ok}}
-            <InputTip @validation={{this.minNumOfOptionsValidation}} />
-          {{/unless}}
-        {{/if}}
-      {{else}}
-        {{#each this.pollOptions as |option|}}
-          <div class="input-group poll-option-value">
-            <Input
-              @value={{option.value}}
-              @enter={{action "addOption" option}}
-            />
-            {{#if this.canRemoveOption}}
-              <DButton
-                @icon="trash-alt"
-                @action={{action "removeOption" option}}
-              />
-            {{/if}}
-          </div>
-        {{/each}}
-
-        <div class="poll-option-controls">
-          <DButton
-            @class="btn-default"
-            @icon="plus"
-            @label="poll.ui_builder.poll_options.add"
-            @action={{action "addOption" this.pollOptions.lastObject}}
-          />
-          {{#if
-            (and
-              this.showMinNumOfOptionsValidation
-              (not this.minNumOfOptionsValidation.ok)
-            )
-          }}
-            <InputTip @validation={{this.minNumOfOptionsValidation}} />
-          {{/if}}
-        </div>
-      {{/if}}
-    </div>
-  {{/unless}}
-
-  {{#unless this.isRegular}}
-    <div class="options">
-      <div class="input-group poll-number">
-        <label class="input-group-label">{{i18n
-            "poll.ui_builder.poll_config.min"
-          }}</label>
-        <Input
-          @type="number"
-          @value={{this.pollMin}}
-          class="poll-options-min"
-          min="1"
-        />
-      </div>
-
-      <div class="input-group poll-number">
-        <label class="input-group-label">{{i18n
-            "poll.ui_builder.poll_config.max"
-          }}</label>
-        <Input
-          @type="number"
-          @value={{this.pollMax}}
-          class="poll-options-max"
-          min="1"
-        />
-      </div>
-
-      {{#if this.isNumber}}
-        <div class="input-group poll-number">
-          <label class="input-group-label">{{i18n
-              "poll.ui_builder.poll_config.step"
-            }}</label>
-          <Input
-            @type="number"
-            @value={{this.pollStep}}
-            min="1"
-            class="poll-options-step"
-          />
-        </div>
-      {{/if}}
-    </div>
-
-    {{#unless this.minMaxValueValidation.ok}}
-      <InputTip @validation={{this.minMaxValueValidation}} />
-    {{/unless}}
-  {{/unless}}
-
-  {{#if this.showAdvanced}}
-    <div class="input-group poll-allowed-groups">
-      <label class="input-group-label">{{i18n
-          "poll.ui_builder.poll_groups.label"
-        }}</label>
-      <GroupChooser
-        @content={{this.siteGroups}}
-        @value={{this.pollGroups}}
-        @onChange={{action (mut this.pollGroups)}}
-        @labelProperty="name"
-        @valueProperty="name"
-      />
-    </div>
-
-    <div class="input-group poll-date">
-      <label class="input-group-label">{{i18n
-          "poll.ui_builder.automatic_close.label"
-        }}</label>
-      <DateTimeInput
-        @date={{this.pollAutoClose}}
-        @onChange={{action (mut this.pollAutoClose)}}
-        @clearable={{true}}
-        @useGlobalPickerContainer={{true}}
-      />
-    </div>
-
-    <div class="input-group poll-select">
-      <label class="input-group-label">{{i18n
-          "poll.ui_builder.poll_result.label"
-        }}</label>
-      <ComboBox
-        @content={{this.pollResults}}
-        @value={{this.pollResult}}
-        @class="poll-result"
-        @valueProperty="value"
-        @onChange={{action (mut this.pollResult)}}
-      />
-    </div>
-
-    {{#unless this.isNumber}}
-      <div class="input-group poll-select column">
-        <label class="input-group-label">{{i18n
-            "poll.ui_builder.poll_chart_type.label"
-          }}</label>
-
-        <div class="radio-group">
-          <RadioButton
-            @id="poll-chart-type-bar"
-            @name="poll-chart-type"
-            @value="bar"
-            @selection={{this.chartType}}
-          />
-          <label for="poll-chart-type-bar">{{d-icon "chart-bar"}}
-            {{i18n "poll.ui_builder.poll_chart_type.bar"}}</label>
-        </div>
-
-        <div class="radio-group">
-          <RadioButton
-            @id="poll-chart-type-pie"
-            @name="poll-chart-type"
-            @value="pie"
-            @selection={{this.chartType}}
-          />
-          <label for="poll-chart-type-pie">{{d-icon "chart-pie"}}
-            {{i18n "poll.ui_builder.poll_chart_type.pie"}}</label>
-        </div>
-      </div>
-    {{/unless}}
-
-    {{#unless this.isPie}}
-      <div class="input-group poll-checkbox column">
-        <label>
-          <Input @type="checkbox" @checked={{this.publicPoll}} />
-          {{i18n "poll.ui_builder.poll_public.label"}}
-        </label>
-      </div>
-    {{/unless}}
-  {{/if}}
-</DModalBody>
-
-<div class="modal-footer">
-  <DButton
-    @action={{action "insertPoll"}}
-    @icon="chart-bar"
-    @class="btn-primary"
-    @label="poll.ui_builder.insert"
-    @disabled={{this.disableInsert}}
-  />
-
-  <DButton
-    @label="cancel"
-    @class="btn-flat"
-    @action={{route-action "closeModal"}}
-  />
-
-  <DButton
-    @action={{action "toggleAdvanced"}}
-    @class="btn-default show-advanced"
-    @icon="cog"
-    @title={{if
-      this.showAdvanced
-      "poll.ui_builder.hide_advanced"
-      "poll.ui_builder.show_advanced"
-    }}
-  />
-</div>
\ No newline at end of file
diff --git a/plugins/poll/assets/javascripts/discourse/widgets/discourse-poll.js b/plugins/poll/assets/javascripts/discourse/widgets/discourse-poll.js
index e31cd53cf1a..012ec03fdbf 100644
--- a/plugins/poll/assets/javascripts/discourse/widgets/discourse-poll.js
+++ b/plugins/poll/assets/javascripts/discourse/widgets/discourse-poll.js
@@ -1,5 +1,5 @@
 import I18n from "I18n";
-import { PIE_CHART_TYPE } from "../controllers/poll-ui-builder";
+import { PIE_CHART_TYPE } from "../components/modal/poll-ui-builder";
 import RawHtml from "discourse/widgets/raw-html";
 import { ajax } from "discourse/lib/ajax";
 import { avatarFor } from "discourse/widgets/post";
@@ -12,8 +12,9 @@ import loadScript from "discourse/lib/load-script";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import { relativeAge } from "discourse/lib/formatter";
 import round from "discourse/lib/round";
-import showModal from "discourse/lib/show-modal";
 import { applyLocalDates } from "discourse/lib/local-dates";
+import PollBreakdownModal from "../components/modal/poll-breakdown";
+import { getOwner } from "@ember/application";
 
 const FETCH_VOTERS_COUNT = 25;
 
@@ -1070,12 +1071,8 @@ export default createWidget("discourse-poll", {
   },
 
   showBreakdown() {
-    showModal("poll-breakdown", {
+    getOwner(this).lookup("service:modal").show(PollBreakdownModal, {
       model: this.attrs,
-      panels: [
-        { id: "percentage", title: "poll.breakdown.percentage" },
-        { id: "count", title: "poll.breakdown.count" },
-      ],
     });
   },
 });
diff --git a/plugins/poll/test/javascripts/component/poll-ui-builder-test.js b/plugins/poll/test/javascripts/component/poll-ui-builder-test.js
new file mode 100644
index 00000000000..79fa2681804
--- /dev/null
+++ b/plugins/poll/test/javascripts/component/poll-ui-builder-test.js
@@ -0,0 +1,214 @@
+import { module, test } from "qunit";
+import { click, fillIn, render } from "@ember/test-helpers";
+import { setupRenderingTest } from "discourse/tests/helpers/component-test";
+import hbs from "htmlbars-inline-precompile";
+import selectKit from "discourse/tests/helpers/select-kit-helper";
+
+async function setupBuilder(context) {
+  const results = [];
+  const model = {
+    toolbarEvent: { getText: () => "", addText: (t) => results.push(t) },
+  };
+  context.model = model;
+  await render(
+    hbs`<Modal::PollUiBuilder @inline={{true}} @model={{this.model}} @closeModal={{fn (mut this.closeCalled) true}} />`
+  );
+  return results;
+}
+
+module("Poll | Component | poll-ui-builder", function (hooks) {
+  setupRenderingTest(hooks);
+
+  test("Can switch poll type", async function (assert) {
+    await setupBuilder(this);
+
+    assert.dom(".poll-type-value-regular").hasClass("active");
+
+    await click(".poll-type-value-multiple");
+    assert
+      .dom(".poll-type-value-multiple")
+      .hasClass("active", "can switch to 'multiple' type");
+
+    assert
+      .dom(".poll-type-value-number")
+      .doesNotExist("number type is hidden by default");
+
+    await click(".show-advanced");
+    assert
+      .dom(".poll-type-value-number")
+      .exists("number type appears in advanced mode");
+
+    await click(".poll-type-value-number");
+    assert
+      .dom(".poll-type-value-number")
+      .hasClass("active", "can switch to 'number' type");
+  });
+
+  test("Automatically updates min/max when number of options change", async function (assert) {
+    await setupBuilder(this);
+
+    await click(".poll-type-value-multiple");
+    assert.dom(".poll-options-min").hasValue("0");
+    assert.dom(".poll-options-max").hasValue("0");
+
+    await fillIn(".poll-option-value input", "a");
+    assert.dom(".poll-options-min").hasValue("1");
+    assert.dom(".poll-options-max").hasValue("1");
+
+    await click(".poll-option-add");
+
+    await fillIn(".poll-option-value:nth-of-type(2) input", "b");
+    assert.dom(".poll-options-min").hasValue("1");
+    assert.dom(".poll-options-max").hasValue("2");
+  });
+
+  test("disables save button", async function (assert) {
+    this.siteSettings.poll_maximum_options = 3;
+
+    await setupBuilder(this);
+    assert
+      .dom(".insert-poll")
+      .isDisabled("Insert button disabled when no options specified");
+
+    await fillIn(".poll-option-value input", "a");
+    assert
+      .dom(".insert-poll")
+      .isEnabled("Insert button enabled once an option is specified");
+
+    await click(".poll-option-add");
+    await fillIn(".poll-option-value:nth-of-type(2) input", "b");
+    await click(".poll-option-add");
+    await fillIn(".poll-option-value:nth-of-type(3) input", "c");
+    await click(".poll-option-add");
+    await fillIn(".poll-option-value:nth-of-type(4) input", "d");
+
+    assert
+      .dom(".insert-poll")
+      .isDisabled("Insert button disabled when too many options");
+  });
+
+  test("number mode", async function (assert) {
+    const results = await setupBuilder(this);
+
+    await click(".show-advanced");
+    await click(".poll-type-value-number");
+
+    await click(".insert-poll");
+    assert.strictEqual(
+      results[results.length - 1],
+      "[poll type=number results=always min=1 max=20 step=1]\n[/poll]\n"
+    );
+
+    await fillIn(".poll-options-step", "2");
+    await click(".insert-poll");
+    assert.strictEqual(
+      results[results.length - 1],
+      "[poll type=number results=always min=1 max=20 step=2]\n[/poll]\n",
+      "includes step value"
+    );
+
+    await click(".poll-toggle-public");
+    await click(".insert-poll");
+    assert.strictEqual(
+      results[results.length - 1],
+      "[poll type=number results=always min=1 max=20 step=2 public=true]\n[/poll]\n",
+      "includes public boolean"
+    );
+
+    await fillIn(".poll-options-step", "0");
+    assert
+      .dom(".insert-poll")
+      .isDisabled("Insert button disabled when step is 0");
+  });
+
+  test("regular mode", async function (assert) {
+    const results = await setupBuilder(this);
+
+    await fillIn(".poll-option-value input", "a");
+    await click(".poll-option-add");
+    await fillIn(".poll-option-value:nth-of-type(2) input", "b");
+
+    await click(".insert-poll");
+    assert.strictEqual(
+      results[results.length - 1],
+      "[poll type=regular results=always chartType=bar]\n* a\n* b\n[/poll]\n",
+      "has correct output"
+    );
+
+    await click(".show-advanced");
+
+    await click(".poll-toggle-public");
+
+    await click(".insert-poll");
+    assert.strictEqual(
+      results[results.length - 1],
+      "[poll type=regular results=always public=true chartType=bar]\n* a\n* b\n[/poll]\n",
+      "has public boolean"
+    );
+
+    const groupChooser = selectKit(".group-chooser");
+    await groupChooser.expand();
+    await groupChooser.selectRowByName("custom_group");
+    await groupChooser.collapse();
+
+    await click(".insert-poll");
+    assert.strictEqual(
+      results[results.length - 1],
+      "[poll type=regular results=always public=true chartType=bar groups=custom_group]\n* a\n* b\n[/poll]\n",
+      "has groups"
+    );
+  });
+
+  test("multi-choice mode", async function (assert) {
+    const results = await setupBuilder(this);
+
+    await click(".poll-type-value-multiple");
+
+    await fillIn(".poll-option-value input", "a");
+    await click(".poll-option-add");
+    await fillIn(".poll-option-value:nth-of-type(2) input", "b");
+
+    await click(".insert-poll");
+    assert.strictEqual(
+      results[results.length - 1],
+      "[poll type=multiple results=always min=1 max=2 chartType=bar]\n* a\n* b\n[/poll]\n",
+      "has correct output"
+    );
+
+    await click(".show-advanced");
+
+    await click(".poll-toggle-public");
+
+    await click(".insert-poll");
+    assert.strictEqual(
+      results[results.length - 1],
+      "[poll type=multiple results=always min=1 max=2 public=true chartType=bar]\n* a\n* b\n[/poll]\n",
+      "has public boolean"
+    );
+  });
+
+  test("staff_only option is not present for non-staff", async function (assert) {
+    await setupBuilder(this);
+
+    await click(".show-advanced");
+    const resultVisibility = selectKit(".poll-result");
+
+    assert.strictEqual(resultVisibility.header().value(), "always");
+
+    await resultVisibility.expand();
+    assert.false(
+      resultVisibility.rowByValue("staff_only").exists(),
+      "staff_only is not visible to normal users"
+    );
+    await resultVisibility.collapse();
+
+    this.currentUser.setProperties({ admin: true });
+
+    await resultVisibility.expand();
+    assert.true(
+      resultVisibility.rowByValue("staff_only").exists(),
+      "staff_only is visible to staff"
+    );
+    await resultVisibility.collapse();
+  });
+});
diff --git a/plugins/poll/test/javascripts/unit/controllers/poll-ui-builder-test.js b/plugins/poll/test/javascripts/unit/controllers/poll-ui-builder-test.js
deleted file mode 100644
index 5a810bb5e94..00000000000
--- a/plugins/poll/test/javascripts/unit/controllers/poll-ui-builder-test.js
+++ /dev/null
@@ -1,205 +0,0 @@
-import { module, test } from "qunit";
-import { setupTest } from "ember-qunit";
-import {
-  MULTIPLE_POLL_TYPE,
-  NUMBER_POLL_TYPE,
-  REGULAR_POLL_TYPE,
-} from "discourse/plugins/poll/discourse/controllers/poll-ui-builder";
-import { settled } from "@ember/test-helpers";
-
-function setupController(ctx) {
-  const controller = ctx.owner.lookup("controller:poll-ui-builder");
-  controller.set("toolbarEvent", { getText: () => "" });
-  controller.onShow();
-  return controller;
-}
-
-module("Unit | Controller | poll-ui-builder", function (hooks) {
-  setupTest(hooks);
-
-  test("isMultiple", function (assert) {
-    const controller = setupController(this);
-
-    controller.setProperties({
-      pollType: MULTIPLE_POLL_TYPE,
-      pollOptions: [{ value: "a" }],
-    });
-    assert.strictEqual(controller.isMultiple, true, "it should be true");
-
-    controller.setProperties({
-      pollType: "random",
-      pollOptions: [{ value: "b" }],
-    });
-    assert.strictEqual(controller.isMultiple, false, "it should be false");
-  });
-
-  test("isNumber", function (assert) {
-    const controller = setupController(this);
-
-    controller.set("pollType", REGULAR_POLL_TYPE);
-    assert.strictEqual(controller.isNumber, false, "it should be false");
-
-    controller.set("pollType", NUMBER_POLL_TYPE);
-    assert.strictEqual(controller.isNumber, true, "it should be true");
-  });
-
-  test("pollOptionsCount", function (assert) {
-    const controller = setupController(this);
-
-    controller.set("pollOptions", [{ value: "1" }, { value: "2" }]);
-    assert.strictEqual(controller.pollOptionsCount, 2, "it should equal 2");
-
-    controller.set("pollOptions", []);
-    assert.strictEqual(controller.pollOptionsCount, 0, "it should equal 0");
-  });
-
-  test("disableInsert", function (assert) {
-    const controller = setupController(this);
-
-    controller.siteSettings.poll_maximum_options = 20;
-    assert.strictEqual(controller.disableInsert, true, "it should be true");
-
-    controller.set("pollOptions", [{ value: "a" }, { value: "b" }]);
-    assert.strictEqual(controller.disableInsert, false, "it should be false");
-
-    controller.set("pollType", NUMBER_POLL_TYPE);
-    assert.strictEqual(controller.disableInsert, false, "it should be false");
-
-    controller.setProperties({
-      pollType: REGULAR_POLL_TYPE,
-      pollOptions: [{ value: "a" }, { value: "b" }, { value: "c" }],
-    });
-    assert.strictEqual(controller.disableInsert, false, "it should be false");
-
-    controller.setProperties({
-      pollType: REGULAR_POLL_TYPE,
-      pollOptions: [],
-    });
-    assert.strictEqual(controller.disableInsert, true, "it should be true");
-
-    controller.setProperties({
-      pollType: REGULAR_POLL_TYPE,
-      pollOptions: [{ value: "w" }],
-    });
-    assert.strictEqual(controller.disableInsert, false, "it should be false");
-  });
-
-  test("number pollOutput", async function (assert) {
-    const controller = setupController(this);
-    controller.siteSettings.poll_maximum_options = 20;
-
-    controller.setProperties({
-      pollType: NUMBER_POLL_TYPE,
-      pollMin: 1,
-    });
-    await settled();
-    assert.strictEqual(
-      controller.pollOutput,
-      "[poll type=number results=always min=1 max=20 step=1]\n[/poll]\n",
-      "it should return the right output"
-    );
-
-    controller.set("pollStep", 2);
-    await settled();
-    assert.strictEqual(
-      controller.pollOutput,
-      "[poll type=number results=always min=1 max=20 step=2]\n[/poll]\n",
-      "it should return the right output"
-    );
-
-    controller.set("publicPoll", true);
-    assert.strictEqual(
-      controller.pollOutput,
-      "[poll type=number results=always min=1 max=20 step=2 public=true]\n[/poll]\n",
-      "it should return the right output"
-    );
-
-    controller.set("pollStep", 0);
-    assert.strictEqual(
-      controller.pollOutput,
-      "[poll type=number results=always min=1 max=20 step=1 public=true]\n[/poll]\n",
-      "it should return the right output"
-    );
-  });
-
-  test("regular pollOutput", function (assert) {
-    const controller = setupController(this);
-    controller.siteSettings.poll_maximum_options = 20;
-
-    controller.setProperties({
-      pollOptions: [{ value: "1" }, { value: "2" }],
-      pollType: REGULAR_POLL_TYPE,
-    });
-    assert.strictEqual(
-      controller.pollOutput,
-      "[poll type=regular results=always chartType=bar]\n* 1\n* 2\n[/poll]\n",
-      "it should return the right output"
-    );
-
-    controller.set("publicPoll", "true");
-    assert.strictEqual(
-      controller.pollOutput,
-      "[poll type=regular results=always public=true chartType=bar]\n* 1\n* 2\n[/poll]\n",
-      "it should return the right output"
-    );
-
-    controller.set("pollGroups", "test");
-    assert.strictEqual(
-      controller.get("pollOutput"),
-      "[poll type=regular results=always public=true chartType=bar groups=test]\n* 1\n* 2\n[/poll]\n",
-      "it should return the right output"
-    );
-  });
-
-  test("multiple pollOutput", function (assert) {
-    const controller = setupController(this);
-    controller.siteSettings.poll_maximum_options = 20;
-
-    controller.setProperties({
-      pollType: MULTIPLE_POLL_TYPE,
-      pollMin: 1,
-      pollOptions: [{ value: "1" }, { value: "2" }],
-    });
-    assert.strictEqual(
-      controller.pollOutput,
-      "[poll type=multiple results=always min=1 max=2 chartType=bar]\n* 1\n* 2\n[/poll]\n",
-      "it should return the right output"
-    );
-
-    controller.set("publicPoll", "true");
-    assert.strictEqual(
-      controller.pollOutput,
-      "[poll type=multiple results=always min=1 max=2 public=true chartType=bar]\n* 1\n* 2\n[/poll]\n",
-      "it should return the right output"
-    );
-  });
-
-  test("staff_only option is not present for non-staff", async function (assert) {
-    const controller = setupController(this);
-    controller.currentUser = { staff: false };
-    controller.notifyPropertyChange("pollResults");
-
-    assert.strictEqual(
-      controller.pollResults.filterBy("value", "staff_only").length,
-      0,
-      "staff_only is not present"
-    );
-  });
-
-  test("poll result is always by default", function (assert) {
-    const controller = setupController(this);
-    assert.strictEqual(controller.pollResult, "always");
-  });
-
-  test("staff_only option is present for staff", async function (assert) {
-    const controller = setupController(this);
-    controller.currentUser = { staff: true };
-    controller.notifyPropertyChange("pollResults");
-
-    assert.strictEqual(
-      controller.pollResults.filterBy("value", "staff_only").length,
-      1,
-      "staff_only is present"
-    );
-  });
-});