mirror of
https://github.com/discourse/discourse.git
synced 2025-01-31 13:31:03 +08:00
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:
parent
d0d61e4118
commit
babbebfb35
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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("What’s up?")
|
||||
end
|
||||
|
||||
describe "edit window" do
|
||||
|
||||
describe "within the first 5 minutes" do
|
||||
|
|
|
@ -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">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
|
||||
|
|
Loading…
Reference in New Issue
Block a user