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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 129 additions and 13 deletions

View File

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

View File

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

View File

@ -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) => {

View File

@ -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"),
});

View File

@ -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>

View File

@ -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>

View File

@ -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 || ""

View File

@ -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",

View File

@ -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);

View File

@ -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;
}

View File

@ -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 {

View File

@ -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;

View File

@ -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:

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
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

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

View File

@ -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

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