FEATURE: adds an API to register topic footer buttons

This commit is contained in:
Joffrey JAFFEUX 2019-02-07 14:43:33 +01:00 committed by GitHub
parent 92c52c0724
commit 6c195640b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 592 additions and 180 deletions

View File

@ -39,7 +39,12 @@ export default Ember.Component.extend({
click() { click() {
if (typeof this.get("action") === "string") { if (typeof this.get("action") === "string") {
this.sendAction("action", this.get("actionParam")); this.sendAction("action", this.get("actionParam"));
} else { } else if (
typeof this.get("action") === "object" &&
this.get("action").value
) {
this.get("action").value(this.get("actionParam"));
} else if (typeof this.get("action") === "function") {
this.get("action")(this.get("actionParam")); this.get("action")(this.get("actionParam"));
} }

View File

@ -1,4 +1,5 @@
import computed from "ember-addons/ember-computed-decorators"; import computed from "ember-addons/ember-computed-decorators";
import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
export default Ember.Component.extend({ export default Ember.Component.extend({
elementId: "topic-footer-buttons", elementId: "topic-footer-buttons",
@ -11,6 +12,19 @@ export default Ember.Component.extend({
return this.siteSettings.enable_personal_messages && isPM; return this.siteSettings.enable_personal_messages && isPM;
}, },
buttons: getTopicFooterButtons(),
@computed("buttons.[]")
inlineButtons(buttons) {
return buttons.filter(button => !button.dropdown);
},
// topic.assigned_to_user is for backward plugin support
@computed("buttons.[]", "topic.assigned_to_user")
dropdownButtons(buttons) {
return buttons.filter(button => button.dropdown);
},
@computed("topic.isPrivateMessage") @computed("topic.isPrivateMessage")
showNotificationsButton(isPM) { showNotificationsButton(isPM) {
return !isPM || this.siteSettings.enable_personal_messages; return !isPM || this.siteSettings.enable_personal_messages;
@ -50,17 +64,5 @@ export default Ember.Component.extend({
@computed("topic.message_archived") @computed("topic.message_archived")
archiveLabel: archived => archiveLabel: archived =>
archived ? "topic.move_to_inbox.title" : "topic.archive_message.title", archived ? "topic.move_to_inbox.title" : "topic.archive_message.title"
@computed("topic.bookmarked")
bookmarkClass: bookmarked =>
bookmarked ? "bookmark bookmarked" : "bookmark",
@computed("topic.bookmarked")
bookmarkLabel: bookmarked =>
bookmarked ? "bookmarked.clear_bookmarks" : "bookmarked.title",
@computed("topic.bookmarked")
bookmarkTitle: bookmarked =>
bookmarked ? "bookmarked.help.unbookmark" : "bookmarked.help.bookmark"
}); });

View File

@ -0,0 +1,138 @@
import { registerTopicFooterButton } from "discourse/lib/register-topic-footer-button";
export default {
name: "topic-footer-buttons",
initialize() {
registerTopicFooterButton({
id: "share",
icon: "link",
priority: 999,
label: "topic.share.title",
title: "topic.share.help",
action() {
this.appEvents.trigger(
"share:url",
this.get("topic.shareUrl"),
$("#topic-footer-buttons")
);
},
dropdown() {
return this.site.mobileView;
},
classNames: ["share"],
dependentKeys: ["topic.shareUrl", "topic.isPrivateMessage"],
displayed() {
return !this.get("topic.isPrivateMessage");
}
});
registerTopicFooterButton({
id: "flag",
icon: "flag",
priority: 998,
label: "topic.flag_topic.title",
title: "topic.flag_topic.help",
action: "showFlagTopic",
dropdown() {
return this.site.mobileView;
},
classNames: ["flag-topic"],
dependentKeys: ["topic.details.can_flag_topic", "topic.isPrivateMessage"],
displayed() {
return (
this.get("topic.details.can_flag_topic") &&
!this.get("topic.isPrivateMessage")
);
}
});
registerTopicFooterButton({
id: "invite",
icon: "users",
priority: 997,
label: "topic.invite_reply.title",
title: "topic.invite_reply.help",
action: "showInvite",
dropdown() {
return this.site.mobileView;
},
classNames: ["invite-topic"],
dependentKeys: ["canInviteTo", "inviteDisabled"],
displayed() {
return this.get("canInviteTo");
},
disabled() {
return this.get("inviteDisabled");
}
});
registerTopicFooterButton({
dependentKeys: ["topic.bookmarked", "topic.isPrivateMessage"],
id: "bookmark",
icon: "bookmark",
priority: 1000,
classNames() {
const bookmarked = this.get("topic.bookmarked");
return bookmarked ? ["bookmark", "bookmarked"] : ["bookmark"];
},
label() {
const bookmarked = this.get("topic.bookmarked");
return bookmarked ? "bookmarked.clear_bookmarks" : "bookmarked.title";
},
title() {
const bookmarked = this.get("topic.bookmarked");
return bookmarked
? "bookmarked.help.unbookmark"
: "bookmarked.help.bookmark";
},
action: "toggleBookmark",
dropdown() {
return this.site.mobileView;
},
displayed() {
return !this.get("topic.isPrivateMessage");
}
});
registerTopicFooterButton({
id: "archive",
priority: 1001,
icon() {
return this.get("archiveIcon");
},
label() {
return this.get("archiveLabel");
},
title() {
return this.get("archiveTitle");
},
action: "toggleArchiveMessage",
classNames: ["standard", "archive-topic"],
dependentKeys: [
"canArchive",
"archiveIcon",
"archiveLabel",
"archiveTitle",
"toggleArchiveMessage"
],
displayed() {
return this.get("canArchive");
}
});
registerTopicFooterButton({
id: "edit-message",
priority: 750,
icon: "pencil-alt",
label: "topic.edit_message.title",
title: "topic.edit_message.help",
action: "editFirstPost",
classNames: ["edit-message"],
dependentKeys: ["editFirstPost", "showEditOnFooter"],
displayed() {
return this.get("showEditOnFooter");
}
});
}
};

View File

@ -19,6 +19,7 @@ import { addFlagProperty } from "discourse/components/site-header";
import { addPopupMenuOptionsCallback } from "discourse/controllers/composer"; import { addPopupMenuOptionsCallback } from "discourse/controllers/composer";
import { extraConnectorClass } from "discourse/lib/plugin-connectors"; import { extraConnectorClass } from "discourse/lib/plugin-connectors";
import { addPostSmallActionIcon } from "discourse/widgets/post-small-action"; import { addPostSmallActionIcon } from "discourse/widgets/post-small-action";
import { registerTopicFooterButton } from "discourse/lib/register-topic-footer-button";
import { addDiscoveryQueryParam } from "discourse/controllers/discovery-sortable"; import { addDiscoveryQueryParam } from "discourse/controllers/discovery-sortable";
import { addTagsHtmlCallback } from "discourse/lib/render-tags"; import { addTagsHtmlCallback } from "discourse/lib/render-tags";
import { addUserMenuGlyph } from "discourse/widgets/user-menu"; import { addUserMenuGlyph } from "discourse/widgets/user-menu";
@ -41,7 +42,7 @@ import Sharing from "discourse/lib/sharing";
import { addComposerUploadHandler } from "discourse/components/composer-editor"; import { addComposerUploadHandler } from "discourse/components/composer-editor";
// If you add any methods to the API ensure you bump up this number // If you add any methods to the API ensure you bump up this number
const PLUGIN_API_VERSION = "0.8.27"; const PLUGIN_API_VERSION = "0.8.28";
class PluginApi { class PluginApi {
constructor(version, container) { constructor(version, container) {
@ -599,6 +600,21 @@ class PluginApi {
extraConnectorClass(`${outletName}/${connectorName}`, klass); extraConnectorClass(`${outletName}/${connectorName}`, klass);
} }
/**
* Register a small icon to be used for custom small post actions
*
* ```javascript
* api.registerTopicFooterButton({
* key: "flag"
* icon: "flag"
* action: (context) => console.log(context.get("topic.id"))
* });
* ```
**/
registerTopicFooterButton(action) {
registerTopicFooterButton(action);
}
/** /**
* Register a small icon to be used for custom small post actions * Register a small icon to be used for custom small post actions
* *

View File

@ -0,0 +1,125 @@
let _topicFooterButtons = [];
export function registerTopicFooterButton(button) {
const defaultButton = {
// id of the button, required
id: null,
// icon displayed on the button
icon: null,
// local key path for title attribute
title: null,
translatedTitle: null,
// local key path for label
label: null,
translatedLabel: null,
// is this button disaplyed in the mobile dropdown or as an inline button ?
dropdown: false,
// css class appended to the button
classNames: [],
// computed properties which should force a button state refresh
// eg: ["topic.bookmarked", "topic.category_id"]
dependentKeys: [],
// should we display this button ?
displayed: true,
// is this button disabled ?
disabled: false,
// display order, higher comes first
priority: 0
};
const normalizedButton = Object.assign(defaultButton, button);
if (!normalizedButton.id) {
Ember.error(`Attempted to register a topic button: ${button} with no id.`);
return;
}
if (!normalizedButton.icon && !normalizedButton.title) {
Ember.error(
`Attempted to register a topic button: ${
button.id
} with no icon or title.`
);
return;
}
_topicFooterButtons.push(normalizedButton);
_topicFooterButtons = _topicFooterButtons.uniqBy("id");
}
export function getTopicFooterButtons() {
const dependentKeys = [].concat(
..._topicFooterButtons.map(tfb => tfb.dependentKeys).filter(x => x)
);
const computedFunc = Ember.computed({
get() {
const _isFunction = descriptor =>
descriptor && typeof descriptor === "function";
const _compute = (button, property) => {
const field = button[property];
if (_isFunction(field)) {
return field.apply(this);
}
return field;
};
return _topicFooterButtons
.filter(button => _compute(button, "displayed"))
.map(button => {
const computedButon = {};
computedButon.id = button.id;
const label = _compute(button, "label");
computedButon.label = label
? I18n.t(label)
: _compute(button, "translatedLabel");
const title = _compute(button, "title");
computedButon.title = title
? I18n.t(title)
: _compute(button, "translatedTitle");
computedButon.classNames = (
_compute(button, "classNames") || []
).join(" ");
computedButon.icon = _compute(button, "icon");
computedButon.disabled = _compute(button, "disabled");
computedButon.dropdown = _compute(button, "dropdown");
computedButon.priority = _compute(button, "priority");
if (_isFunction(button.action)) {
computedButon.action = () => button.action.apply(this);
} else {
const actionName = button.action;
computedButon.action = () => this[actionName]();
}
return computedButon;
})
.sortBy("priority")
.reverse();
}
});
return computedFunc.property.apply(computedFunc, dependentKeys);
}
export function clearTopicFooterButtons() {
_topicFooterButtons = [];
}

View File

@ -18,55 +18,20 @@
convertToPrivateMessage=convertToPrivateMessage}} convertToPrivateMessage=convertToPrivateMessage}}
{{/if}} {{/if}}
{{#unless topic.isPrivateMessage}} {{#if site.mobileView}}
{{#if site.mobileView}} {{topic-footer-mobile-dropdown topic=topic content=dropdownButtons}}
{{topic-footer-mobile-dropdown topic=topic
showInvite=showInvite
showFlagTopic=showFlagTopic}}
{{else}}
{{d-button class=(concat "btn-default " bookmarkClass)
title=bookmarkTitle
label=bookmarkLabel
icon="bookmark"
action=toggleBookmark}}
{{share-button url=topic.shareUrl}}
{{#if topic.details.can_flag_topic}}
{{d-button class="btn-default flag-topic"
title="topic.flag_topic.help"
label="topic.flag_topic.title"
icon="flag"
action=showFlagTopic}}
{{/if}}
{{/if}}
{{/unless}}
{{#if canInviteTo}}
{{d-button class="btn-default invite-topic"
title="topic.invite_reply.help"
label="topic.invite_reply.title"
icon="users"
action=showInvite
disabled=inviteDisabled}}
{{/if}} {{/if}}
{{#if canArchive}} {{#each inlineButtons as |button|}}
{{d-button class="btn-default standard archive-topic" {{d-button
title=archiveTitle id=(concat "topic-footer-button-" button.id)
label=archiveLabel class=(concat "btn-default topic-footer-button " button.classNames)
icon=archiveIcon action=button.action
action=toggleArchiveMessage}} icon=button.icon
{{/if}} translatedLabel=button.label
translatedTitle=button.title
{{#if showEditOnFooter}} disabled=button.disabled}}
{{d-button class="btn-default edit-message" {{/each}}
title="topic.edit_message.help"
label="topic.edit_message.title"
icon="pencil-alt"
action=editFirstPost}}
{{/if}}
{{plugin-outlet name="topic-footer-main-buttons-before-create" {{plugin-outlet name="topic-footer-main-buttons-before-create"
args=(hash topic=topic) args=(hash topic=topic)

View File

@ -144,18 +144,16 @@ export default Ember.Component.extend(
didComputeAttributes() {}, didComputeAttributes() {},
willComputeContent(content) { willComputeContent(content) {
return content; return applyContentPluginApiCallbacks(
this.get("pluginApiIdentifiers"),
content,
this
);
}, },
computeContent(content) { computeContent(content) {
return content; return content;
}, },
_beforeDidComputeContent(content) { _beforeDidComputeContent(content) {
content = applyContentPluginApiCallbacks(
this.get("pluginApiIdentifiers"),
content,
this
);
let existingCreatedComputedContent = []; let existingCreatedComputedContent = [];
if (!this.get("allowContentReplacement")) { if (!this.get("allowContentReplacement")) {
existingCreatedComputedContent = this.get("computedContent").filterBy( existingCreatedComputedContent = this.get("computedContent").filterBy(

View File

@ -16,7 +16,11 @@ export default Ember.Component.extend(UtilsMixin, {
"ariaLabel:aria-label", "ariaLabel:aria-label",
"guid:data-guid" "guid:data-guid"
], ],
classNameBindings: ["isHighlighted", "isSelected"], classNameBindings: [
"isHighlighted",
"isSelected",
"computedContent.originalContent.classNames"
],
forceEscape: Ember.computed.alias("options.forceEscape"), forceEscape: Ember.computed.alias("options.forceEscape"),

View File

@ -117,7 +117,7 @@ export default SelectKitComponent.extend({
@computed("computedAsyncContent.[]", "computedValue") @computed("computedAsyncContent.[]", "computedValue")
filteredAsyncComputedContent(computedAsyncContent, computedValue) { filteredAsyncComputedContent(computedAsyncContent, computedValue) {
computedAsyncContent = computedAsyncContent.filter(c => { computedAsyncContent = (computedAsyncContent || []).filter(c => {
return computedValue !== get(c, "value"); return computedValue !== get(c, "value");
}); });
@ -268,12 +268,18 @@ export default SelectKitComponent.extend({
if (this.validateSelect(computedContentItem)) { if (this.validateSelect(computedContentItem)) {
this.willSelect(computedContentItem); this.willSelect(computedContentItem);
this.clearFilter(); this.clearFilter();
this.setProperties({
highlighted: null,
computedValue: computedContentItem.value
});
run.next(() => this.mutateAttributes()); const action = computedContentItem.originalContent.action;
if (action) {
action();
} else {
this.setProperties({
highlighted: null,
computedValue: computedContentItem.value
});
run.next(() => this.mutateAttributes());
}
run.schedule("afterRender", () => { run.schedule("afterRender", () => {
this.didSelect(computedContentItem); this.didSelect(computedContentItem);

View File

@ -6,101 +6,29 @@ export default ComboBoxComponent.extend({
filterable: false, filterable: false,
autoFilterable: false, autoFilterable: false,
allowInitialValueMutation: false, allowInitialValueMutation: false,
allowAutoSelectFirst: false,
nameProperty: "label",
computeHeaderContent() { computeHeaderContent() {
let content = this._super(...arguments); const content = this._super(...arguments);
content.name = I18n.t("topic.controls"); content.name = I18n.t("topic.controls");
return content; return content;
}, },
computeContent(content) { mutateAttributes() {},
const topic = this.get("topic");
const details = topic.get("details");
if (details.get("can_invite_to")) { willComputeContent(content) {
content.push({ content = this._super(content);
id: "invite",
icon: "users",
name: I18n.t("topic.invite_reply.title"),
__sk_row_type: "noopRow"
});
}
if ( // TODO: this is for backward compat reasons, should be removed
(topic.get("bookmarked") && !topic.get("bookmarking")) || // when plugins have been updated for long enough
(!topic.get("bookmarked") && topic.get("bookmarking")) content.forEach(c => {
) { if (c.name) {
content.push({ c.label = c.name;
id: "bookmark", }
icon: "bookmark",
name: I18n.t("bookmarked.clear_bookmarks"),
__sk_row_type: "noopRow"
});
} else {
content.push({
id: "bookmark",
icon: "bookmark",
name: I18n.t("bookmarked.title"),
__sk_row_type: "noopRow"
});
}
content.push({
id: "share",
icon: "link",
name: I18n.t("topic.share.title"),
__sk_row_type: "noopRow"
}); });
if (details.get("can_flag_topic")) {
content.push({
id: "flag",
icon: "flag",
name: I18n.t("topic.flag_topic.title"),
__sk_row_type: "noopRow"
});
}
return content; return content;
},
autoHighlight() {},
actions: {
onSelect(value) {
const topic = this.get("topic");
if (!topic.get("id")) {
return;
}
const refresh = () => {
this._compute();
this.deselect();
};
switch (value) {
case "flag":
this.showFlagTopic();
refresh();
break;
case "bookmark":
topic.toggleBookmark().then(refresh());
break;
case "share":
this.appEvents.trigger(
"share:url",
topic.get("shareUrl"),
$("#topic-footer-buttons")
);
refresh();
break;
case "invite":
this.showInvite();
refresh();
break;
default:
}
}
} }
}); });

View File

@ -68,13 +68,15 @@ function onSelect(pluginApiIdentifiers, mutationFunction) {
export function applyContentPluginApiCallbacks(identifiers, content, context) { export function applyContentPluginApiCallbacks(identifiers, content, context) {
identifiers.forEach(key => { identifiers.forEach(key => {
(_prependContentCallbacks[key] || []).forEach(c => { (_prependContentCallbacks[key] || []).forEach(c => {
content = c().concat(content); content = c()
.concat(content)
.uniqBy("id");
}); });
(_appendContentCallbacks[key] || []).forEach(c => { (_appendContentCallbacks[key] || []).forEach(c => {
content = content.concat(c()); content = content.concat(c()).uniqBy("id");
}); });
(_modifyContentCallbacks[key] || []).forEach(c => { (_modifyContentCallbacks[key] || []).forEach(c => {
content = c(context, content); content = c(context, content).uniqBy("id");
}); });
}); });

View File

@ -0,0 +1,9 @@
.topic-footer-mobile-dropdown {
.select-kit-row {
&.bookmarked {
.d-icon {
color: $tertiary;
}
}
}
}

View File

@ -0,0 +1,46 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import { clearTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
import { acceptance } from "helpers/qunit-helpers";
let _test;
acceptance("Topic footer buttons mobile", {
loggedIn: true,
mobileView: true,
beforeEach() {
I18n.translations[I18n.locale].js.test = {
title: "My title",
label: "My Label"
};
withPluginApi("0.8.28", api => {
api.registerTopicFooterButton({
id: "my-button",
icon: "user",
label: "test.label",
title: "test.title",
dropdown: true,
action() {
_test = 2;
}
});
});
},
afterEach() {
clearTopicFooterButtons();
_test = undefined;
}
});
QUnit.test("default", async assert => {
await visit("/t/internationalization-localization/280");
assert.equal(_test, null);
const subject = selectKit(".topic-footer-mobile-dropdown");
await subject.expand();
await subject.selectRowByValue("my-button");
assert.equal(_test, 2);
});

View File

@ -0,0 +1,184 @@
import { withPluginApi } from "discourse/lib/plugin-api";
import componentTest from "helpers/component-test";
import Topic from "discourse/models/topic";
import { clearTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
const buildTopic = function() {
return Topic.create({
id: 1234,
title: "Qunit Test Topic"
});
};
moduleForComponent("topic-footer-buttons-desktop", {
integration: true,
beforeEach() {
I18n.translations[I18n.locale].js.test = {
title: "My title",
label: "My Label"
};
},
afterEach() {
clearTopicFooterButtons();
}
});
componentTest("default", {
template: "{{topic-footer-buttons topic=topic}}",
beforeEach() {
withPluginApi("0.8.28", api => {
api.registerTopicFooterButton({
id: "my-button",
icon: "user",
label: "test.label",
title: "test.title"
});
});
this.set("topic", buildTopic());
},
async test(assert) {
const button = await find("#topic-footer-button-my-button");
assert.ok(exists(button), "it creates an inline button");
const icon = await button.find(".d-icon-user");
assert.ok(exists(icon), "the button has the correct icon");
const label = await button.find(".d-button-label");
assert.ok(exists(label), "the button has a label");
assert.equal(
label.text(),
I18n.t("test.label"),
"the button has the correct label"
);
const title = button.attr("title");
assert.equal(
title,
I18n.t("test.title"),
"the button has the correct title"
);
}
});
componentTest("priority", {
template: "{{topic-footer-buttons topic=topic}}",
beforeEach() {
withPluginApi("0.8.28", api => {
api.registerTopicFooterButton({
id: "my-second-button",
priority: 750,
icon: "user"
});
api.registerTopicFooterButton({
id: "my-third-button",
priority: 500,
icon: "flag"
});
api.registerTopicFooterButton({
id: "my-first-button",
priority: 1000,
icon: "times"
});
});
this.set("topic", buildTopic());
},
async test(assert) {
const buttons = await find(".topic-footer-button");
const firstButton = find("#topic-footer-button-my-first-button");
const secondButton = find("#topic-footer-button-my-second-button");
const thirdButton = find("#topic-footer-button-my-third-button");
assert.ok(buttons.index(firstButton) < buttons.index(secondButton));
assert.ok(buttons.index(secondButton) < buttons.index(thirdButton));
}
});
componentTest("with functions", {
template: "{{topic-footer-buttons topic=topic}}",
beforeEach() {
withPluginApi("0.8.28", api => {
api.registerTopicFooterButton({
id: "my-button",
icon() {
return "user";
},
label() {
return "test.label";
},
title() {
return "test.title";
}
});
});
this.set("topic", buildTopic());
},
async test(assert) {
const button = await find("#topic-footer-button-my-button");
assert.ok(exists(button), "it creates an inline button");
const icon = await button.find(".d-icon-user");
assert.ok(exists(icon), "the button has the correct icon");
const label = await button.find(".d-button-label");
assert.ok(exists(label), "the button has a label");
assert.equal(
label.text(),
I18n.t("test.label"),
"the button has the correct label"
);
const title = button.attr("title");
assert.equal(
title,
I18n.t("test.title"),
"the button has the correct title"
);
}
});
componentTest("action", {
template: "<div id='test-action'></div>{{topic-footer-buttons topic=topic}}",
beforeEach() {
withPluginApi("0.8.28", api => {
api.registerTopicFooterButton({
id: "my-button",
icon: "flag",
action() {
$("#test-action").text(this.get("topic.title"));
}
});
});
this.set("topic", buildTopic());
},
async test(assert) {
await click("#topic-footer-button-my-button");
assert.equal(find("#test-action").text(), this.get("topic.title"));
}
});
componentTest("dropdown", {
template: "{{topic-footer-buttons topic=topic}}",
beforeEach() {
withPluginApi("0.8.28", api => {
api.registerTopicFooterButton({
id: "my-button",
icon: "flag",
dropdown: true
});
});
this.set("topic", buildTopic());
},
async test(assert) {
const button = await find("#topic-footer-button-my-button");
assert.notOk(exists(button), "it doesnt create an inline button");
}
});

View File

@ -36,27 +36,11 @@ componentTest("default", {
.value(), .value(),
null null
); );
assert.equal(
this.get("subject")
.rowByIndex(0)
.name(),
"Bookmark"
);
assert.equal(
this.get("subject")
.rowByIndex(1)
.name(),
"Share"
);
assert.notOk( assert.notOk(
this.get("subject") this.get("subject")
.selectedRow() .selectedRow()
.exists(), .exists(),
"it doesnt preselect first row" "it doesnt preselect first row"
); );
await this.get("subject").selectRowByValue("share");
assert.equal(this.get("value"), null, "it resets the value");
} }
}); });