FEATURE: Add the title attribute to polls (#10759)

FEATURE: Add the title attribute to polls (#10759)

Adds an optional title attribute to polls. The rationale for this addition is that polls themselves didn’t contain context/question and relied on post body to explain them. That context wasn’t always obvious (e.g. when there are multiple polls in a single post) or available (e.g. when you display the poll breakdown - you see the answers, but not the question)

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

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

diff --git a/plugins/poll/app/models/poll.rb b/plugins/poll/app/models/poll.rb
index 2105338..cbb3016 100644
--- a/plugins/poll/app/models/poll.rb
+++ b/plugins/poll/app/models/poll.rb
@@ -83,6 +83,7 @@ end
 #  updated_at       :datetime         not null
 #  chart_type       :integer          default("bar"), not null
 #  groups           :string
+#  title            :string
 #
 # Indexes
 #
diff --git a/plugins/poll/app/serializers/poll_serializer.rb b/plugins/poll/app/serializers/poll_serializer.rb
index 171eb95..6444349 100644
--- a/plugins/poll/app/serializers/poll_serializer.rb
+++ b/plugins/poll/app/serializers/poll_serializer.rb
@@ -14,7 +14,8 @@ class PollSerializer < ApplicationSerializer
              :close,
              :preloaded_voters,
              :chart_type,
-             :groups
+             :groups,
+             :title
 
   def public
     true
diff --git a/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6
index 6a47eee..89effd9 100644
--- a/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6
+++ b/plugins/poll/assets/javascripts/controllers/poll-breakdown.js.es6
@@ -2,6 +2,7 @@ import I18n from "I18n";
 import Controller from "@ember/controller";
 import { action } from "@ember/object";
 import { classify } from "@ember/string";
+import { htmlSafe } from "@ember/template";
 import { ajax } from "discourse/lib/ajax";
 import { popupAjaxError } from "discourse/lib/ajax-error";
 import loadScript from "discourse/lib/load-script";
@@ -15,6 +16,11 @@ export default Controller.extend(ModalFunctionality, {
   highlightedOption: null,
   displayMode: "percentage",
 
+  @discourseComputed("model.poll.title", "model.post.topic.title")
+  title(pollTitle, topicTitle) {
+    return pollTitle ? htmlSafe(pollTitle) : topicTitle;
+  },
+
   @discourseComputed("model.groupableUserFields")
   groupableUserFields(fields) {
     return fields.map((field) => {
diff --git a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6 b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6
index e5aa341..9090e92 100644
--- a/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6
+++ b/plugins/poll/assets/javascripts/controllers/poll-ui-builder.js.es6
@@ -28,6 +28,7 @@ export default Controller.extend({
 
   pollType: null,
   pollResult: null,
+  pollTitle: null,
 
   init() {
     this._super(...arguments);
@@ -214,6 +215,7 @@ export default Controller.extend({
     "pollType",
     "pollResult",
     "publicPoll",
+    "pollTitle",
     "pollOptions",
     "pollMin",
     "pollMax",
@@ -230,6 +232,7 @@ export default Controller.extend({
     pollType,
     pollResult,
     publicPoll,
+    pollTitle,
     pollOptions,
     pollMin,
     pollMax,
@@ -293,6 +296,10 @@ export default Controller.extend({
     pollHeader += "]";
     output += `${pollHeader}\n`;
 
+    if (pollTitle) {
+      output += `# ${pollTitle.trim()}\n`;
+    }
+
     if (pollOptions.length > 0 && !isNumber) {
       pollOptions.split("\n").forEach((option) => {
         if (option.length !== 0) {
@@ -382,6 +389,7 @@ export default Controller.extend({
       chartType: BAR_CHART_TYPE,
       pollResult: this.alwaysPollResult,
       pollGroups: null,
+      pollTitle: null,
       date: moment().add(1, "day").format("YYYY-MM-DD"),
       time: moment().add(1, "hour").format("HH:mm"),
     });
diff --git a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs
index 0cb26c0..693e31a 100644
--- a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs
+++ b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-breakdown.hbs
@@ -1,7 +1,8 @@
 {{#d-modal-body title="poll.breakdown.title"}}
   <div class="poll-breakdown-sidebar">
-    {{!-- TODO: replace with the (optional) poll title --}}
-    <p class="poll-breakdown-title">{{this.model.post.topic.title}}</p>
+    <p class="poll-breakdown-title">
+      {{this.title}}
+    </p>
 
     <div class="poll-breakdown-total-votes">{{i18n "poll.breakdown.votes" count=this.model.poll.voters}}</div>
 
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
index 03e08ac..3679c83 100644
--- a/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs
+++ b/plugins/poll/assets/javascripts/discourse/templates/modal/poll-ui-builder.hbs
@@ -77,6 +77,11 @@
         {{/if}}
       {{/if}}
 
+      <div class="input-group poll-title">
+        <label>{{i18n "poll.ui_builder.poll_title.label"}}</label>
+        {{input value=pollTitle}}
+      </div>
+
       {{#unless isNumber}}
         <div class="input-group poll-textarea">
           <label>{{i18n "poll.ui_builder.poll_options.label"}}</label>
diff --git a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6 b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
index d8a2262..46ec6e8 100644
--- a/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
+++ b/plugins/poll/assets/javascripts/initializers/extend-for-poll.js.es6
@@ -88,11 +88,14 @@ function initializePolls(api) {
       }
 
       if (poll) {
+        const titleElement = pollElem.querySelector(".poll-title");
+
         const attrs = {
           id: `${pollName}-${pollPost.id}`,
           post: pollPost,
           poll,
           vote,
+          titleHTML: titleElement && titleElement.outerHTML,
           groupableUserFields: (
             api.container.lookup("site-settings:main")
               .poll_groupable_user_fields || ""
diff --git a/plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6 b/plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6
index a6b8222..b098a0b 100644
--- a/plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6
+++ b/plugins/poll/assets/javascripts/lib/discourse-markdown/poll.js.es6
@@ -81,6 +81,22 @@ function invalidPoll(state, tag) {
   token.content = "[/" + tag + "]";
 }
 
+function getTitle(tokens) {
+  const open = tokens.findIndex((token) => token.type === "heading_open");
+  const close = tokens.findIndex((token) => token.type === "heading_close");
+
+  if (open === -1 || close === -1) {
+    return;
+  }
+
+  const titleTokens = tokens.slice(open + 1, close);
+
+  // Remove the heading element
+  tokens.splice(open, close - open + 1);
+
+  return titleTokens;
+}
+
 const rule = {
   tag: "poll",
 
@@ -92,7 +108,9 @@ const rule = {
   },
 
   after: function (state, openToken, raw) {
+    const titleTokens = getTitle(state.tokens);
     let items = getListItems(state.tokens, openToken);
+
     if (!items) {
       return invalidPoll(state, raw);
     }
@@ -139,9 +157,19 @@ const rule = {
 
     token = new state.Token("poll_open", "div", 1);
     token.attrs = [["class", "poll-container"]];
-
     header.push(token);
 
+    if (titleTokens) {
+      token = new state.Token("title_open", "div", 1);
+      token.attrs = [["class", "poll-title"]];
+      header.push(token);
+
+      header.push(...titleTokens);
+
+      token = new state.Token("title_close", "div", -1);
+      header.push(token);
+    }
+
     // generate the options when the type is "number"
     if (attrs["type"] === "number") {
       // default values
@@ -175,6 +203,7 @@ const rule = {
         token = new state.Token("list_item_close", "li", -1);
         header.push(token);
       }
+
       token = new state.Token("bullet_item_close", "", -1);
       header.push(token);
     }
@@ -240,6 +269,7 @@ export function setup(helper) {
     "div.poll",
     "div.poll-info",
     "div.poll-container",
+    "div.poll-title",
     "div.poll-buttons",
     "div[data-*]",
     "span.info-number",

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

GitHub sha: babbebfb

This commit appears in #10759 which was approved by danielwaterworth and ZogStriP. It was merged by CvX.

@CvX I believe there is a bug in this feature. Headings outside the poll are incorrectly used as poll titles:

image

An unwanted side effect is that if a post has a heading and a poll with valid votes, it will not allow updating the post due to the error message “Polls can’t be altered after 5 minutes”

@nachocab Thanks for the report, looking into it!

Thanks, @CvX. If it’s going to take a while, could you merge a revert? I’m not able to edit any of my posts with polls :sweat_smile:

This commit has been mentioned on Discourse Meta. There might be relevant details there:

https://meta.discourse.org/t/polls-stop-working-correctly/166324/6

Note was fixed here: https://github.com/discourse/discourse/pull/10832

1 Like