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
+1
View File
@@ -83,6 +83,7 @@ end
# updated_at :datetime not null
# chart_type :integer default("bar"), not null
# groups :string
# title :string
#
# Indexes
#
@@ -14,7 +14,8 @@ class PollSerializer < ApplicationSerializer
:close,
:preloaded_voters,
:chart_type,
:groups
:groups,
:title
def public
true
@@ -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) => {
@@ -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"),
});
@@ -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>
@@ -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>
@@ -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 || ""
@@ -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",
@@ -356,6 +356,8 @@ createWidget("discourse-poll-container", {
} else if (options) {
const contents = [];
contents.push(new RawHtml({ html: attrs.titleHTML }));
if (!checkUserGroups(this.currentUser, poll)) {
contents.push(
h(
@@ -511,6 +513,8 @@ createWidget("discourse-poll-pie-chart", {
contents.push(button);
}
contents.push(new RawHtml({ html: attrs.titleHTML }));
const chart = this.attach("discourse-poll-pie-canvas", attrs);
contents.push(chart);
@@ -56,17 +56,26 @@ $poll-margin: 10px;
}
}
.poll-textarea {
.poll-textarea,
.poll-title {
flex-direction: column;
}
.poll-title input {
width: 100%;
}
.poll-textarea textarea {
width: 100%;
height: 90px;
box-sizing: border-box;
}
.poll-select + .poll-textarea {
.poll-select + .poll-title {
margin-top: $poll-margin;
}
.poll-textarea {
margin-top: $poll-margin;
}
@@ -149,21 +149,21 @@ div.poll {
}
.poll-results-chart {
height: 320px;
height: 340px;
overflow-y: auto;
overflow-x: hidden;
}
.poll-show-breakdown {
margin-bottom: 10px;
margin-bottom: 0.25em;
}
}
div.poll.pie {
.poll-container {
display: inline-block;
height: 320px;
max-height: 320px;
height: 340px;
max-height: 340px;
overflow-y: auto;
}
.poll-info {
@@ -28,6 +28,11 @@ div.poll {
border-right: 1px solid var(--primary-low);
}
.poll-title {
border-bottom: 1px solid var(--primary-low);
padding: 0.5em 0;
}
.poll-buttons {
border-top: 1px solid var(--primary-low);
padding: 1em;
@@ -112,6 +112,8 @@ en:
step: Step
poll_public:
label: Show who voted
poll_title:
label: Title (optional)
poll_options:
label: Enter one poll option per line
automatic_close:
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTitleToPolls < ActiveRecord::Migration[6.0]
def change
add_column :polls, :title, :string
end
end
+1 -1
View File
@@ -3,7 +3,7 @@
module DiscoursePoll
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)
::Poll.transaction do
+7
View File
@@ -329,6 +329,7 @@ after_initialize do
type: poll["type"].presence || "regular",
status: poll["status"].presence || "open",
visibility: poll["public"] == "true" ? "everyone" : "secret",
title: poll["title"],
results: poll["results"].presence || "always",
min: poll["min"],
max: poll["max"],
@@ -367,6 +368,12 @@ after_initialize do
poll["options"] << { "id" => option_id, "html" => o.inner_html.strip }
end
# title
title_element = p.css(".poll-title").first
if title_element
poll["title"] = title_element.inner_html.strip
end
poll
end
end
@@ -139,6 +139,17 @@ describe PostsController do
expect(Poll.where(post_id: json["id"]).count).to eq(1)
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("Whats up?")
end
describe "edit window" do
describe "within the first 5 minutes" do
+18 -2
View File
@@ -95,7 +95,7 @@ describe PrettyText do
cooked = PrettyText.cook md
expected = <<~MD
expected = <<~HTML
<div class="poll" data-poll-status="open" data-poll-type="multiple" data-poll-name="poll">
<div>
<div class="poll-container">
@@ -113,7 +113,7 @@ describe PrettyText do
</div>
</div>
</div>
MD
HTML
# note, hashes should remain stable even if emoji changes cause text content is hashed
expect(n cooked).to eq(n expected)
@@ -153,4 +153,20 @@ describe PrettyText do
excerpt = PrettyText.excerpt(post.cooked, SiteSetting.post_onebox_maxlength)
expect(excerpt).to eq("A post with a poll \npoll")
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">Whats 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