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.
This commit is contained in:
Jarek Radosz
2020-10-02 09:21:24 +02:00
committed by GitHub
parent d0d61e4118
commit babbebfb35
18 changed files with 129 additions and 13 deletions

View File

@ -83,6 +83,7 @@ end
# updated_at :datetime not null # updated_at :datetime not null
# chart_type :integer default("bar"), not null # chart_type :integer default("bar"), not null
# groups :string # groups :string
# title :string
# #
# Indexes # Indexes
# #

View File

@ -14,7 +14,8 @@ class PollSerializer < ApplicationSerializer
:close, :close,
:preloaded_voters, :preloaded_voters,
:chart_type, :chart_type,
:groups :groups,
:title
def public def public
true true

View File

@ -2,6 +2,7 @@ import I18n from "I18n";
import Controller from "@ember/controller"; import Controller from "@ember/controller";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { classify } from "@ember/string"; import { classify } from "@ember/string";
import { htmlSafe } from "@ember/template";
import { ajax } from "discourse/lib/ajax"; import { ajax } from "discourse/lib/ajax";
import { popupAjaxError } from "discourse/lib/ajax-error"; import { popupAjaxError } from "discourse/lib/ajax-error";
import loadScript from "discourse/lib/load-script"; import loadScript from "discourse/lib/load-script";
@ -15,6 +16,11 @@ export default Controller.extend(ModalFunctionality, {
highlightedOption: null, highlightedOption: null,
displayMode: "percentage", displayMode: "percentage",
@discourseComputed("model.poll.title", "model.post.topic.title")
title(pollTitle, topicTitle) {
return pollTitle ? htmlSafe(pollTitle) : topicTitle;
},
@discourseComputed("model.groupableUserFields") @discourseComputed("model.groupableUserFields")
groupableUserFields(fields) { groupableUserFields(fields) {
return fields.map((field) => { return fields.map((field) => {

View File

@ -28,6 +28,7 @@ export default Controller.extend({
pollType: null, pollType: null,
pollResult: null, pollResult: null,
pollTitle: null,
init() { init() {
this._super(...arguments); this._super(...arguments);
@ -214,6 +215,7 @@ export default Controller.extend({
"pollType", "pollType",
"pollResult", "pollResult",
"publicPoll", "publicPoll",
"pollTitle",
"pollOptions", "pollOptions",
"pollMin", "pollMin",
"pollMax", "pollMax",
@ -230,6 +232,7 @@ export default Controller.extend({
pollType, pollType,
pollResult, pollResult,
publicPoll, publicPoll,
pollTitle,
pollOptions, pollOptions,
pollMin, pollMin,
pollMax, pollMax,
@ -293,6 +296,10 @@ export default Controller.extend({
pollHeader += "]"; pollHeader += "]";
output += `${pollHeader}\n`; output += `${pollHeader}\n`;
if (pollTitle) {
output += `# ${pollTitle.trim()}\n`;
}
if (pollOptions.length > 0 && !isNumber) { if (pollOptions.length > 0 && !isNumber) {
pollOptions.split("\n").forEach((option) => { pollOptions.split("\n").forEach((option) => {
if (option.length !== 0) { if (option.length !== 0) {
@ -382,6 +389,7 @@ export default Controller.extend({
chartType: BAR_CHART_TYPE, chartType: BAR_CHART_TYPE,
pollResult: this.alwaysPollResult, pollResult: this.alwaysPollResult,
pollGroups: null, pollGroups: null,
pollTitle: null,
date: moment().add(1, "day").format("YYYY-MM-DD"), date: moment().add(1, "day").format("YYYY-MM-DD"),
time: moment().add(1, "hour").format("HH:mm"), time: moment().add(1, "hour").format("HH:mm"),
}); });

View File

@ -1,7 +1,8 @@
{{#d-modal-body title="poll.breakdown.title"}} {{#d-modal-body title="poll.breakdown.title"}}
<div class="poll-breakdown-sidebar"> <div class="poll-breakdown-sidebar">
{{!-- TODO: replace with the (optional) poll title --}} <p class="poll-breakdown-title">
<p class="poll-breakdown-title">{{this.model.post.topic.title}}</p> {{this.title}}
</p>
<div class="poll-breakdown-total-votes">{{i18n "poll.breakdown.votes" count=this.model.poll.voters}}</div> <div class="poll-breakdown-total-votes">{{i18n "poll.breakdown.votes" count=this.model.poll.voters}}</div>

View File

@ -77,6 +77,11 @@
{{/if}} {{/if}}
{{/if}} {{/if}}
<div class="input-group poll-title">
<label>{{i18n "poll.ui_builder.poll_title.label"}}</label>
{{input value=pollTitle}}
</div>
{{#unless isNumber}} {{#unless isNumber}}
<div class="input-group poll-textarea"> <div class="input-group poll-textarea">
<label>{{i18n "poll.ui_builder.poll_options.label"}}</label> <label>{{i18n "poll.ui_builder.poll_options.label"}}</label>

View File

@ -88,11 +88,14 @@ function initializePolls(api) {
} }
if (poll) { if (poll) {
const titleElement = pollElem.querySelector(".poll-title");
const attrs = { const attrs = {
id: `${pollName}-${pollPost.id}`, id: `${pollName}-${pollPost.id}`,
post: pollPost, post: pollPost,
poll, poll,
vote, vote,
titleHTML: titleElement && titleElement.outerHTML,
groupableUserFields: ( groupableUserFields: (
api.container.lookup("site-settings:main") api.container.lookup("site-settings:main")
.poll_groupable_user_fields || "" .poll_groupable_user_fields || ""

View File

@ -81,6 +81,22 @@ function invalidPoll(state, tag) {
token.content = "[/" + 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 = { const rule = {
tag: "poll", tag: "poll",
@ -92,7 +108,9 @@ const rule = {
}, },
after: function (state, openToken, raw) { after: function (state, openToken, raw) {
const titleTokens = getTitle(state.tokens);
let items = getListItems(state.tokens, openToken); let items = getListItems(state.tokens, openToken);
if (!items) { if (!items) {
return invalidPoll(state, raw); return invalidPoll(state, raw);
} }
@ -139,9 +157,19 @@ const rule = {
token = new state.Token("poll_open", "div", 1); token = new state.Token("poll_open", "div", 1);
token.attrs = [["class", "poll-container"]]; token.attrs = [["class", "poll-container"]];
header.push(token); 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" // generate the options when the type is "number"
if (attrs["type"] === "number") { if (attrs["type"] === "number") {
// default values // default values
@ -175,6 +203,7 @@ const rule = {
token = new state.Token("list_item_close", "li", -1); token = new state.Token("list_item_close", "li", -1);
header.push(token); header.push(token);
} }
token = new state.Token("bullet_item_close", "", -1); token = new state.Token("bullet_item_close", "", -1);
header.push(token); header.push(token);
} }
@ -240,6 +269,7 @@ export function setup(helper) {
"div.poll", "div.poll",
"div.poll-info", "div.poll-info",
"div.poll-container", "div.poll-container",
"div.poll-title",
"div.poll-buttons", "div.poll-buttons",
"div[data-*]", "div[data-*]",
"span.info-number", "span.info-number",

View File

@ -356,6 +356,8 @@ createWidget("discourse-poll-container", {
} else if (options) { } else if (options) {
const contents = []; const contents = [];
contents.push(new RawHtml({ html: attrs.titleHTML }));
if (!checkUserGroups(this.currentUser, poll)) { if (!checkUserGroups(this.currentUser, poll)) {
contents.push( contents.push(
h( h(
@ -511,6 +513,8 @@ createWidget("discourse-poll-pie-chart", {
contents.push(button); contents.push(button);
} }
contents.push(new RawHtml({ html: attrs.titleHTML }));
const chart = this.attach("discourse-poll-pie-canvas", attrs); const chart = this.attach("discourse-poll-pie-canvas", attrs);
contents.push(chart); contents.push(chart);

View File

@ -56,17 +56,26 @@ $poll-margin: 10px;
} }
} }
.poll-textarea { .poll-textarea,
.poll-title {
flex-direction: column; flex-direction: column;
} }
.poll-title input {
width: 100%;
}
.poll-textarea textarea { .poll-textarea textarea {
width: 100%; width: 100%;
height: 90px; height: 90px;
box-sizing: border-box; box-sizing: border-box;
} }
.poll-select + .poll-textarea { .poll-select + .poll-title {
margin-top: $poll-margin;
}
.poll-textarea {
margin-top: $poll-margin; margin-top: $poll-margin;
} }

View File

@ -149,21 +149,21 @@ div.poll {
} }
.poll-results-chart { .poll-results-chart {
height: 320px; height: 340px;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
.poll-show-breakdown { .poll-show-breakdown {
margin-bottom: 10px; margin-bottom: 0.25em;
} }
} }
div.poll.pie { div.poll.pie {
.poll-container { .poll-container {
display: inline-block; display: inline-block;
height: 320px; height: 340px;
max-height: 320px; max-height: 340px;
overflow-y: auto; overflow-y: auto;
} }
.poll-info { .poll-info {

View File

@ -28,6 +28,11 @@ div.poll {
border-right: 1px solid var(--primary-low); border-right: 1px solid var(--primary-low);
} }
.poll-title {
border-bottom: 1px solid var(--primary-low);
padding: 0.5em 0;
}
.poll-buttons { .poll-buttons {
border-top: 1px solid var(--primary-low); border-top: 1px solid var(--primary-low);
padding: 1em; padding: 1em;

View File

@ -112,6 +112,8 @@ en:
step: Step step: Step
poll_public: poll_public:
label: Show who voted label: Show who voted
poll_title:
label: Title (optional)
poll_options: poll_options:
label: Enter one poll option per line label: Enter one poll option per line
automatic_close: automatic_close:

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTitleToPolls < ActiveRecord::Migration[6.0]
def change
add_column :polls, :title, :string
end
end

View File

@ -3,7 +3,7 @@
module DiscoursePoll module DiscoursePoll
class PollsUpdater class PollsUpdater
POLL_ATTRIBUTES ||= %w{close_at max min results status step type visibility groups} POLL_ATTRIBUTES ||= %w{close_at max min results status step type visibility title groups}
def self.update(post, polls) def self.update(post, polls)
::Poll.transaction do ::Poll.transaction do

View File

@ -329,6 +329,7 @@ after_initialize do
type: poll["type"].presence || "regular", type: poll["type"].presence || "regular",
status: poll["status"].presence || "open", status: poll["status"].presence || "open",
visibility: poll["public"] == "true" ? "everyone" : "secret", visibility: poll["public"] == "true" ? "everyone" : "secret",
title: poll["title"],
results: poll["results"].presence || "always", results: poll["results"].presence || "always",
min: poll["min"], min: poll["min"],
max: poll["max"], max: poll["max"],
@ -367,6 +368,12 @@ after_initialize do
poll["options"] << { "id" => option_id, "html" => o.inner_html.strip } poll["options"] << { "id" => option_id, "html" => o.inner_html.strip }
end end
# title
title_element = p.css(".poll-title").first
if title_element
poll["title"] = title_element.inner_html.strip
end
poll poll
end end
end end

View File

@ -139,6 +139,17 @@ describe PostsController do
expect(Poll.where(post_id: json["id"]).count).to eq(1) expect(Poll.where(post_id: json["id"]).count).to eq(1)
end end
it "accepts polls with titles" do
post :create, params: {
title: title, raw: "[poll]\n# What's up?\n- one\n[/poll]"
}, format: :json
expect(response).to be_successful
poll = Poll.last
expect(poll).to_not be_nil
expect(poll.title).to eq("What’s up?")
end
describe "edit window" do describe "edit window" do
describe "within the first 5 minutes" do describe "within the first 5 minutes" do

View File

@ -95,7 +95,7 @@ describe PrettyText do
cooked = PrettyText.cook md cooked = PrettyText.cook md
expected = <<~MD expected = <<~HTML
<div class="poll" data-poll-status="open" data-poll-type="multiple" data-poll-name="poll"> <div class="poll" data-poll-status="open" data-poll-type="multiple" data-poll-name="poll">
<div> <div>
<div class="poll-container"> <div class="poll-container">
@ -113,7 +113,7 @@ describe PrettyText do
</div> </div>
</div> </div>
</div> </div>
MD HTML
# note, hashes should remain stable even if emoji changes cause text content is hashed # note, hashes should remain stable even if emoji changes cause text content is hashed
expect(n cooked).to eq(n expected) expect(n cooked).to eq(n expected)
@ -153,4 +153,20 @@ describe PrettyText do
excerpt = PrettyText.excerpt(post.cooked, SiteSetting.post_onebox_maxlength) excerpt = PrettyText.excerpt(post.cooked, SiteSetting.post_onebox_maxlength)
expect(excerpt).to eq("A post with a poll \npoll") expect(excerpt).to eq("A post with a poll \npoll")
end end
it "supports the title attribute" do
cooked = PrettyText.cook <<~MD
[poll]
# What's your favorite *berry*? :wink: https://google.com/
* Strawberry
* Raspberry
* Blueberry
[/poll]
MD
expect(cooked).to include(<<~HTML)
<div class="poll-title">What’s your favorite <em>berry</em>? <img src="/images/emoji/twitter/wink.png?v=9" title=":wink:" class="emoji" alt=":wink:"> <a href="https://google.com/" rel="noopener nofollow ugc">https://google.com/</a>
</div>
HTML
end
end end