DEV: Add `registerUserMenuTab` plugin API (#17851)

Co-authored-by: OsamaSayegh <asooomaasoooma90@gmail.com>
This commit is contained in:
Alan Guo Xiang Tan 2022-08-10 13:21:37 +08:00 committed by GitHub
parent 424e968538
commit 23520b88c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 227 additions and 2 deletions

View File

@ -2,7 +2,7 @@ import GlimmerComponent from "discourse/components/glimmer";
import { tracked } from "@glimmer/tracking"; import { tracked } from "@glimmer/tracking";
import { action } from "@ember/object"; import { action } from "@ember/object";
import { NO_REMINDER_ICON } from "discourse/models/bookmark"; import { NO_REMINDER_ICON } from "discourse/models/bookmark";
import UserMenuTab from "discourse/lib/user-menu/tab"; import UserMenuTab, { CUSTOM_TABS_CLASSES } from "discourse/lib/user-menu/tab";
const DEFAULT_TAB_ID = "all-notifications"; const DEFAULT_TAB_ID = "all-notifications";
const DEFAULT_PANEL_COMPONENT = "user-menu/notifications-list"; const DEFAULT_PANEL_COMPONENT = "user-menu/notifications-list";
@ -123,12 +123,31 @@ export default class UserMenu extends GlimmerComponent {
get _topTabs() { get _topTabs() {
const tabs = []; const tabs = [];
CORE_TOP_TABS.forEach((tabClass) => { CORE_TOP_TABS.forEach((tabClass) => {
const tab = new tabClass(this.currentUser, this.siteSettings, this.site); const tab = new tabClass(this.currentUser, this.siteSettings, this.site);
if (tab.shouldDisplay) { if (tab.shouldDisplay) {
tabs.push(tab); tabs.push(tab);
} }
}); });
let reviewQueueTabIndex = tabs.findIndex(
(tab) => tab.id === REVIEW_QUEUE_TAB_ID
);
CUSTOM_TABS_CLASSES.forEach((tabClass) => {
const tab = new tabClass(this.currentUser, this.siteSettings, this.site);
if (tab.shouldDisplay) {
// ensure the review queue tab is always last
if (reviewQueueTabIndex === -1) {
tabs.push(tab);
} else {
tabs.insertAt(reviewQueueTabIndex, tab);
reviewQueueTabIndex++;
}
}
});
return tabs.map((tab, index) => { return tabs.map((tab, index) => {
tab.position = index; tab.position = index;
return tab; return tab;

View File

@ -99,6 +99,7 @@ import { addSectionLink as addCustomCommunitySectionLink } from "discourse/lib/s
import { addSidebarSection } from "discourse/lib/sidebar/custom-sections"; import { addSidebarSection } from "discourse/lib/sidebar/custom-sections";
import DiscourseURL from "discourse/lib/url"; import DiscourseURL from "discourse/lib/url";
import { registerNotificationTypeRenderer } from "discourse/lib/notification-item"; import { registerNotificationTypeRenderer } from "discourse/lib/notification-item";
import { registerUserMenuTab } from "discourse/lib/user-menu/tab";
// If you add any methods to the API ensure you bump up the version number // If you add any methods to the API ensure you bump up the version number
// based on Semantic Versioning 2.0.0. Please update the changelog at // based on Semantic Versioning 2.0.0. Please update the changelog at
@ -1882,6 +1883,49 @@ class PluginApi {
registerNotificationTypeRenderer(notificationType, func) { registerNotificationTypeRenderer(notificationType, func) {
registerNotificationTypeRenderer(notificationType, func); registerNotificationTypeRenderer(notificationType, func);
} }
/**
* EXPERIMENTAL. Do not use.
* Registers a new tab in the user menu. This API method expects a callback
* that should return a class inheriting from the class (UserMenuTab) that's
* passed to the callback. See discourse/app/lib/user-menu/tab.js for
* documentation of UserMenuTab.
*
* ```
* api.registerUserMenuTab((UserMenuTab) => {
* return class extends UserMenuTab {
* get id() {
* return "custom-tab-id";
* }
*
* get shouldDisplay() {
* return this.siteSettings.enable_custom_tab && this.currentUser.admin;
* }
*
* get count() {
* return this.currentUser.my_custom_notification_count;
* }
*
* get panelComponent() {
* return "your-custom-glimmer-component";
* }
*
* get icon() {
* return "some-fa5-icon";
* }
* }
* });
* ```
*
* @callback customTabRegistererCallback
* @param {UserMenuTab} The base class from which the returned class should inherit.
* @returns {UserMenuTab} A class that inherits from UserMenuTab.
*
* @param {customTabRegistererCallback} func - Callback function that returns a subclass from the class it receives as its argument.
*/
registerUserMenuTab(func) {
registerUserMenuTab(func);
}
} }
// from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number // from http://stackoverflow.com/questions/6832596/how-to-compare-software-version-number-using-js-only-number

View File

@ -1,3 +1,6 @@
/**
* abstract class representing a tab in the user menu
*/
export default class UserMenuTab { export default class UserMenuTab {
constructor(currentUser, siteSettings, site) { constructor(currentUser, siteSettings, site) {
this.currentUser = currentUser; this.currentUser = currentUser;
@ -5,22 +8,37 @@ export default class UserMenuTab {
this.site = site; this.site = site;
} }
/**
* @returns {boolean} Controls whether the tab should be rendered or not.
*/
get shouldDisplay() { get shouldDisplay() {
return true; return true;
} }
/**
* @returns {number} Controls the blue badge (aka bubble) count that's rendered on top of the tab. If count is zero, no badge is shown.
*/
get count() { get count() {
return 0; return 0;
} }
/**
* @returns {string} Dasherized version of the component name that should be rendered in the panel area when the tab is active.
*/
get panelComponent() { get panelComponent() {
throw new Error("not implemented"); throw new Error("not implemented");
} }
/**
* @returns {string} ID for the tab. Must be unique across all visible tabs.
*/
get id() { get id() {
throw new Error("not implemented"); throw new Error("not implemented");
} }
/**
* @returns {string} Icon for the tab.
*/
get icon() { get icon() {
throw new Error("not implemented"); throw new Error("not implemented");
} }
@ -34,3 +52,13 @@ export default class UserMenuTab {
return this.currentUser.get(key) || 0; return this.currentUser.get(key) || 0;
} }
} }
export const CUSTOM_TABS_CLASSES = [];
export function registerUserMenuTab(func) {
CUSTOM_TABS_CLASSES.push(func(UserMenuTab));
}
export function resetUserMenuTabs() {
CUSTOM_TABS_CLASSES.clear();
}

View File

@ -5,16 +5,21 @@ import {
loggedInUser, loggedInUser,
publishToMessageBus, publishToMessageBus,
query, query,
queryAll,
} from "discourse/tests/helpers/qunit-helpers"; } from "discourse/tests/helpers/qunit-helpers";
import { test } from "qunit"; import { test } from "qunit";
import { cloneJSON } from "discourse-common/lib/object"; import { cloneJSON } from "discourse-common/lib/object";
import { withPluginApi } from "discourse/lib/plugin-api";
import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types"; import { NOTIFICATION_TYPES } from "discourse/tests/fixtures/concerns/notification-types";
import UserMenuFixtures from "discourse/tests/fixtures/user-menu"; import UserMenuFixtures from "discourse/tests/fixtures/user-menu";
import TopicFixtures from "discourse/tests/fixtures/topic"; import TopicFixtures from "discourse/tests/fixtures/topic";
import I18n from "I18n"; import I18n from "I18n";
acceptance("User menu", function (needs) { acceptance("User menu", function (needs) {
needs.user({ redesigned_user_menu_enabled: true }); needs.user({
redesigned_user_menu_enabled: true,
unread_high_priority_notifications: 73,
});
let requestHeaders = {}; let requestHeaders = {};
needs.pretender((server, helper) => { needs.pretender((server, helper) => {
@ -44,6 +49,133 @@ acceptance("User menu", function (needs) {
"the Discourse-Clear-Notifications request header is set to the notification id in the next ajax request" "the Discourse-Clear-Notifications request header is set to the notification id in the next ajax request"
); );
}); });
test("tabs added via the plugin API", async function (assert) {
withPluginApi("0.1", (api) => {
api.registerUserMenuTab((UserMenuTab) => {
return class extends UserMenuTab {
get id() {
return "custom-tab-1";
}
get count() {
return this.currentUser.get("unread_high_priority_notifications");
}
get icon() {
return "wrench";
}
get panelComponent() {
return "d-button";
}
};
});
api.registerUserMenuTab((UserMenuTab) => {
return class extends UserMenuTab {
get id() {
return "custom-tab-2";
}
get count() {
return 29;
}
get icon() {
return "plus";
}
get panelComponent() {
return "d-button";
}
};
});
});
await visit("/");
await click(".d-header-icons .current-user");
const customTab1 = query("#user-menu-button-custom-tab-1");
const customTab2 = query("#user-menu-button-custom-tab-2");
assert.ok(customTab1, "first custom tab is rendered");
assert.ok(customTab2, "second custom tab is rendered");
assert.strictEqual(
customTab1.dataset.tabNumber,
"5",
"custom tab has the right tab number"
);
assert.strictEqual(
customTab2.dataset.tabNumber,
"6",
"custom tab has the right tab number"
);
const reviewQueueTab = query("#user-menu-button-review-queue");
assert.strictEqual(
reviewQueueTab.dataset.tabNumber,
"7",
"review queue tab comes after the custom tabs"
);
const tabs = [...queryAll(".tabs-list .btn")]; // top and bottom tabs
assert.deepEqual(
tabs.map((t) => t.dataset.tabNumber),
["0", "1", "2", "3", "4", "5", "6", "7", "8"],
"data-tab-number of the tabs has no gaps when custom tabs are added"
);
let customTab1Bubble = query(
"#user-menu-button-custom-tab-1 .badge-notification"
);
assert.strictEqual(
customTab1Bubble.textContent.trim(),
"73",
"bubble shows the right count"
);
const customTab2Bubble = query(
"#user-menu-button-custom-tab-2 .badge-notification"
);
assert.strictEqual(
customTab2Bubble.textContent.trim(),
"29",
"bubble shows the right count"
);
await publishToMessageBus(`/notification/${loggedInUser().id}`, {
unread_high_priority_notifications: 18,
});
customTab1Bubble = query(
"#user-menu-button-custom-tab-1 .badge-notification"
);
assert.strictEqual(
customTab1Bubble.textContent.trim(),
"18",
"displayed bubble count updates when the value is changed"
);
await click("#user-menu-button-custom-tab-1");
assert.ok(
exists("#user-menu-button-custom-tab-1.active"),
"custom tabs can be clicked on and become active"
);
assert.ok(
exists("#quick-access-custom-tab-1 button.btn"),
"the tab's content is now displayed in the panel"
);
});
}); });
acceptance("User menu - Dismiss button", function (needs) { acceptance("User menu - Dismiss button", function (needs) {

View File

@ -74,6 +74,7 @@ import { clearToolbarCallbacks } from "discourse/components/d-editor";
import { clearExtraHeaderIcons } from "discourse/widgets/header"; import { clearExtraHeaderIcons } from "discourse/widgets/header";
import { resetSidebarSection } from "discourse/lib/sidebar/custom-sections"; import { resetSidebarSection } from "discourse/lib/sidebar/custom-sections";
import { resetNotificationTypeRenderers } from "discourse/lib/notification-item"; import { resetNotificationTypeRenderers } from "discourse/lib/notification-item";
import { resetUserMenuTabs } from "discourse/lib/user-menu/tab";
export function currentUser() { export function currentUser() {
return User.create(sessionFixtures["/session/current.json"].current_user); return User.create(sessionFixtures["/session/current.json"].current_user);
@ -204,6 +205,7 @@ export function testCleanup(container, app) {
resetSidebarSection(); resetSidebarSection();
resetNotificationTypeRenderers(); resetNotificationTypeRenderers();
clearExtraHeaderIcons(); clearExtraHeaderIcons();
resetUserMenuTabs();
} }
export function discourseModule(name, options) { export function discourseModule(name, options) {