DEV: adds a new topic footer dropdown api (#14747)

This api allows to add a dropdown at the bottom of a topic, note that this API is mobile only for now.

Also included in the commit:
- various doc fixes
- adding tests for both buttons and dropdowns APIs
- uses thrown instead of @ember/error to ensure execution is halted when incorrect parameters are given
This commit is contained in:
Joffrey JAFFEUX 2021-11-12 10:21:34 +01:00 committed by GitHub
parent e0be6ce1ee
commit 362c47ce6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 301 additions and 100 deletions

View File

@ -1,7 +1,9 @@
import { alias, and, or } from "@ember/object/computed";
import { computed } from "@ember/object";
import Component from "@ember/component";
import discourseComputed from "discourse-common/utils/decorators";
import { getTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
import { getTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown";
export default Component.extend({
elementId: "topic-footer-buttons",
@ -18,17 +20,25 @@ export default Component.extend({
return this.siteSettings.enable_personal_messages && isPM;
},
buttons: getTopicFooterButtons(),
inlineButtons: getTopicFooterButtons(),
inlineDropdowns: getTopicFooterDropdowns(),
@discourseComputed("buttons.[]")
inlineButtons(buttons) {
return buttons.filter((button) => !button.dropdown);
},
inlineActionables: computed(
"inlineButtons.[]",
"inlineDropdowns.[]",
function () {
return this.inlineButtons
.filterBy("dropdown", false)
.concat(this.inlineDropdowns)
.sortBy("priority")
.reverse();
}
),
// topic.assigned_to_user is for backward plugin support
@discourseComputed("buttons.[]", "topic.assigned_to_user")
dropdownButtons(buttons) {
return buttons.filter((button) => button.dropdown);
@discourseComputed("inlineButtons.[]", "topic.assigned_to_user")
dropdownButtons(inlineButtons) {
return inlineButtons.filter((button) => button.dropdown);
},
@discourseComputed("topic.isPrivateMessage")

View File

@ -81,6 +81,7 @@ import { registerCustomAvatarHelper } from "discourse/helpers/user-avatar";
import { registerCustomPostMessageCallback as registerCustomPostMessageCallback1 } from "discourse/controllers/topic";
import { registerHighlightJSLanguage } from "discourse/lib/highlight-syntax";
import { registerTopicFooterButton } from "discourse/lib/register-topic-footer-button";
import { registerTopicFooterDropdown } from "discourse/lib/register-topic-footer-dropdown";
import { replaceFormatter } from "discourse/lib/utilities";
import { replaceTagRenderer } from "discourse/lib/render-tag";
import { setNewCategoryDefaultColors } from "discourse/routes/new-category";
@ -93,7 +94,7 @@ import { CUSTOM_USER_SEARCH_OPTIONS } from "select-kit/components/user-chooser";
import { downloadCalendar } from "discourse/lib/download-calendar";
// If you add any methods to the API ensure you bump up this number
const PLUGIN_API_VERSION = "0.13.0";
const PLUGIN_API_VERSION = "0.13.1";
// This helper prevents us from applying the same `modifyClass` over and over in test mode.
function canModify(klass, type, resolverName, changes) {
@ -735,18 +736,33 @@ class PluginApi {
}
/**
* Register a small icon to be used for custom small post actions
* Register a button to display at the bottom of a topic
*
* ```javascript
* api.registerTopicFooterButton({
* key: "flag"
* icon: "flag"
* action: (context) => console.log(context.get("topic.id"))
* id: "flag",
* icon: "flag",
* action(context) { console.log(context.get("topic.id")) },
* });
* ```
**/
registerTopicFooterButton(action) {
registerTopicFooterButton(action);
registerTopicFooterButton(buttonOptions) {
registerTopicFooterButton(buttonOptions);
}
/**
* Register a dropdown to display at the bottom of a topic, desktop only
*
* ```javascript
* api.registerTopicFooterDropdown({
* id: "my-button",
* content() { return [{id: 1, name: "foo"}] },
* action(itemId) { console.log(itemId) },
* });
* ```
**/
registerTopicFooterDropdown(dropdownOptions) {
registerTopicFooterDropdown(dropdownOptions);
}
/**

View File

@ -1,13 +1,11 @@
import I18n from "I18n";
import { computed } from "@ember/object";
import error from "@ember/error";
let _topicFooterButtons = {};
export function registerTopicFooterButton(button) {
if (!button.id) {
error(`Attempted to register a topic button: ${button} with no id.`);
return;
throw new Error(`Attempted to register a topic button with no id.`);
}
if (_topicFooterButtons[button.id]) {
@ -15,6 +13,8 @@ export function registerTopicFooterButton(button) {
}
const defaultButton = {
type: "inline-button",
// id of the button, required
id: null,
@ -60,10 +60,9 @@ export function registerTopicFooterButton(button) {
!normalizedButton.title &&
!normalizedButton.translatedTitle
) {
error(
throw new Error(
`Attempted to register a topic button: ${button.id} with no icon or title.`
);
return;
}
_topicFooterButtons[normalizedButton.id] = normalizedButton;
@ -94,49 +93,48 @@ export function getTopicFooterButtons() {
return Object.values(_topicFooterButtons)
.filter((button) => _compute(button, "displayed"))
.map((button) => {
const discourseComputedButon = {};
const discourseComputedButton = {};
discourseComputedButon.id = button.id;
discourseComputedButton.id = button.id;
discourseComputedButton.type = button.type;
const label = _compute(button, "label");
discourseComputedButon.label = label
discourseComputedButton.label = label
? I18n.t(label)
: _compute(button, "translatedLabel");
const ariaLabel = _compute(button, "ariaLabel");
if (ariaLabel) {
discourseComputedButon.ariaLabel = I18n.t(ariaLabel);
discourseComputedButton.ariaLabel = I18n.t(ariaLabel);
} else {
const translatedAriaLabel = _compute(button, "translatedAriaLabel");
discourseComputedButon.ariaLabel =
translatedAriaLabel || discourseComputedButon.label;
discourseComputedButton.ariaLabel =
translatedAriaLabel || discourseComputedButton.label;
}
const title = _compute(button, "title");
discourseComputedButon.title = title
discourseComputedButton.title = title
? I18n.t(title)
: _compute(button, "translatedTitle");
discourseComputedButon.classNames = (
discourseComputedButton.classNames = (
_compute(button, "classNames") || []
).join(" ");
discourseComputedButon.icon = _compute(button, "icon");
discourseComputedButon.disabled = _compute(button, "disabled");
discourseComputedButon.dropdown = _compute(button, "dropdown");
discourseComputedButon.priority = _compute(button, "priority");
discourseComputedButton.icon = _compute(button, "icon");
discourseComputedButton.disabled = _compute(button, "disabled");
discourseComputedButton.dropdown = _compute(button, "dropdown");
discourseComputedButton.priority = _compute(button, "priority");
if (_isFunction(button.action)) {
discourseComputedButon.action = () => button.action.apply(this);
discourseComputedButton.action = () => button.action.apply(this);
} else {
const actionName = button.action;
discourseComputedButon.action = () => this[actionName]();
discourseComputedButton.action = () => this[actionName]();
}
return discourseComputedButon;
})
.sortBy("priority")
.reverse();
return discourseComputedButton;
});
},
});
}

View File

@ -0,0 +1,108 @@
import { computed } from "@ember/object";
let _topicFooterDropdowns = {};
export function registerTopicFooterDropdown(dropdown) {
if (!dropdown.id) {
throw new Error(`Attempted to register a topic dropdown with no id.`);
}
if (_topicFooterDropdowns[dropdown.id]) {
return;
}
const defaultDropdown = {
type: "inline-dropdown",
// id of the dropdown, required
id: null,
// icon displayed on the dropdown
icon: null,
// dropdowns content
content: null,
// css class appended to the button
classNames: [],
// discourseComputed properties which should force a button state refresh
// eg: ["topic.bookmarked", "topic.category_id"]
dependentKeys: [],
// should we display this dropdown ?
displayed: true,
// is this button disabled ?
disabled: false,
// display order, higher comes first
priority: 0,
// an object used to display the state of the dropdown
// when no value is currectnly set, eg: { id: 1, name: "foo" }
noneItem: null,
};
const normalizedDropdown = Object.assign(defaultDropdown, dropdown);
if (!normalizedDropdown.content) {
throw new Error(
`Attempted to register a topic dropdown: ${dropdown.id} with no content.`
);
}
_topicFooterDropdowns[normalizedDropdown.id] = normalizedDropdown;
}
export function getTopicFooterDropdowns() {
const dependentKeys = [].concat(
...Object.values(_topicFooterDropdowns)
.mapBy("dependentKeys")
.filter(Boolean)
);
return computed(...dependentKeys, {
get() {
const _isFunction = (descriptor) =>
descriptor && typeof descriptor === "function";
const _compute = (dropdown, property) => {
const field = dropdown[property];
if (_isFunction(field)) {
return field.apply(this);
}
return field;
};
return Object.values(_topicFooterDropdowns)
.filter((dropdown) => _compute(dropdown, "displayed"))
.map((dropdown) => {
const discourseComputedDropdown = {};
discourseComputedDropdown.id = dropdown.id;
discourseComputedDropdown.type = dropdown.type;
discourseComputedDropdown.classNames = (
_compute(dropdown, "classNames") || []
).join(" ");
discourseComputedDropdown.icon = _compute(dropdown, "icon");
discourseComputedDropdown.disabled = _compute(dropdown, "disabled");
discourseComputedDropdown.priority = _compute(dropdown, "priority");
discourseComputedDropdown.content = _compute(dropdown, "content");
discourseComputedDropdown.value = _compute(dropdown, "value");
discourseComputedDropdown.action = dropdown.action;
discourseComputedDropdown.noneItem = _compute(dropdown, "noneItem");
return discourseComputedDropdown;
});
},
});
}
export function clearTopicFooterDropdowns() {
_topicFooterDropdowns = {};
}

View File

@ -22,16 +22,32 @@
{{topic-footer-mobile-dropdown topic=topic content=dropdownButtons}}
{{/if}}
{{#each inlineButtons as |button|}}
{{d-button
id=(concat "topic-footer-button-" button.id)
class=(concat "btn-default topic-footer-button " button.classNames)
action=button.action
icon=button.icon
translatedLabel=button.label
translatedTitle=button.title
translatedAriaLabel=button.ariaLabel
disabled=button.disabled}}
{{#each inlineActionables as |actionable|}}
{{#if (eq actionable.type "inline-button")}}
{{d-button
id=(concat "topic-footer-button-" actionable.id)
class=(concat "btn-default topic-footer-button " actionable.classNames)
action=actionable.action
icon=actionable.icon
translatedLabel=actionable.label
translatedTitle=actionable.title
translatedAriaLabel=actionable.ariaLabel
disabled=actionable.disabled
}}
{{else}}
{{dropdown-select-box
id=(concat "topic-footer-dropdown-" actionable.id)
value=actionable.value
class=(concat "topic-footer-dropdown " actionable.classNames)
content=actionable.content
onChange=(action actionable.action)
options=(hash
icon=actionable.icon
none=actionable.noneItem
disabled=actionable.disabled
)
}}
{{/if}}
{{/each}}
{{plugin-outlet name="topic-footer-main-buttons-before-create"

View File

@ -0,0 +1,36 @@
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
import { withPluginApi } from "discourse/lib/plugin-api";
acceptance(
"Topic - Plugin API - registerTopicFooterButton (mobile)",
function (needs) {
needs.user();
needs.mobileView();
test("adds topic footer button as a dropdown through API", async function (assert) {
const done = assert.async();
withPluginApi("0.13.1", (api) => {
api.registerTopicFooterButton({
id: "foo",
icon: "cog",
action() {
assert.step(`action called`);
done();
},
dropdown: true,
});
});
await visit("/t/internationalization-localization/280");
const subject = selectKit(".topic-footer-mobile-dropdown");
await subject.expand();
await subject.selectRowByValue("foo");
assert.verifySteps(["action called"]);
});
}
);

View File

@ -0,0 +1,27 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
import { withPluginApi } from "discourse/lib/plugin-api";
acceptance("Topic - Plugin API - registerTopicFooterButton", function (needs) {
needs.user();
test("adds topic footer button through API", async function (assert) {
const done = assert.async();
withPluginApi("0.13.1", (api) => {
api.registerTopicFooterButton({
id: "my-button",
icon: "cog",
action() {
assert.step("action called");
done();
},
});
});
await visit("/t/internationalization-localization/280");
await click("#topic-footer-button-my-button");
assert.verifySteps(["action called"]);
});
});

View File

@ -1,51 +0,0 @@
import I18n from "I18n";
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { clearTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import { test } from "qunit";
import { visit } from "@ember/test-helpers";
import { withPluginApi } from "discourse/lib/plugin-api";
let _test;
acceptance("Topic footer buttons mobile", function (needs) {
needs.user();
needs.mobileView();
needs.hooks.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;
},
});
});
});
needs.hooks.afterEach(() => {
clearTopicFooterButtons();
_test = undefined;
});
test("default", async function (assert) {
await visit("/t/internationalization-localization/280");
assert.strictEqual(_test, undefined);
const subject = selectKit(".topic-footer-mobile-dropdown");
await subject.expand();
await subject.selectRowByValue("my-button");
assert.strictEqual(_test, 2);
});
});

View File

@ -0,0 +1,36 @@
import { acceptance } from "discourse/tests/helpers/qunit-helpers";
import { visit } from "@ember/test-helpers";
import { test } from "qunit";
import { withPluginApi } from "discourse/lib/plugin-api";
import selectKit from "discourse/tests/helpers/select-kit-helper";
acceptance(
"Topic - Plugin API - registerTopicFooterDropdown",
function (needs) {
needs.user();
test("adds topic footer dropdown through API", async function (assert) {
const done = assert.async();
withPluginApi("0.13.1", (api) => {
api.registerTopicFooterDropdown({
id: "my-button",
content() {
return [{ id: 1, name: "foo" }];
},
action(itemId) {
assert.step(`action ${itemId} called`);
done();
},
});
});
await visit("/t/internationalization-localization/280");
const subject = selectKit("#topic-footer-dropdown-my-button");
await subject.expand();
await subject.selectRowByValue(1);
assert.verifySteps(["action 1 called"]);
});
}
);

View File

@ -52,6 +52,8 @@ import {
} from "discourse/components/composer-editor";
import { resetLastEditNotificationClick } from "discourse/models/post-stream";
import { clearAuthMethods } from "discourse/models/login-method";
import { clearTopicFooterDropdowns } from "discourse/lib/register-topic-footer-dropdown";
import { clearTopicFooterButtons } from "discourse/lib/register-topic-footer-button";
const LEGACY_ENV = !setupApplicationTest;
@ -291,6 +293,8 @@ export function acceptance(name, optionsOrCallback) {
cleanUpComposerUploadProcessor();
cleanUpComposerUploadMarkdownResolver();
cleanUpComposerUploadPreProcessor();
clearTopicFooterDropdowns();
clearTopicFooterButtons();
resetLastEditNotificationClick();
clearAuthMethods();

View File

@ -1269,7 +1269,8 @@ a.mention-group {
display: flex;
flex-wrap: wrap;
align-items: stretch; // aligns items by making them the same height
button {
button,
details {
margin-right: 0.54em;
}
> * {