diff --git a/app/assets/javascripts/discourse/app/components/user-menu/menu.js b/app/assets/javascripts/discourse/app/components/user-menu/menu.js index 7fd6991cbce..e75a48d77e8 100644 --- a/app/assets/javascripts/discourse/app/components/user-menu/menu.js +++ b/app/assets/javascripts/discourse/app/components/user-menu/menu.js @@ -2,7 +2,7 @@ import GlimmerComponent from "discourse/components/glimmer"; import { tracked } from "@glimmer/tracking"; import { action } from "@ember/object"; 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_PANEL_COMPONENT = "user-menu/notifications-list"; @@ -123,12 +123,31 @@ export default class UserMenu extends GlimmerComponent { get _topTabs() { const tabs = []; + CORE_TOP_TABS.forEach((tabClass) => { const tab = new tabClass(this.currentUser, this.siteSettings, this.site); if (tab.shouldDisplay) { 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) => { tab.position = index; return tab; diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js index 2f1489866b1..6bbfe815721 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js @@ -99,6 +99,7 @@ import { addSectionLink as addCustomCommunitySectionLink } from "discourse/lib/s import { addSidebarSection } from "discourse/lib/sidebar/custom-sections"; import DiscourseURL from "discourse/lib/url"; 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 // based on Semantic Versioning 2.0.0. Please update the changelog at @@ -1882,6 +1883,49 @@ class PluginApi { 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 diff --git a/app/assets/javascripts/discourse/app/lib/user-menu/tab.js b/app/assets/javascripts/discourse/app/lib/user-menu/tab.js index 11d2cacef51..629ff2bff64 100644 --- a/app/assets/javascripts/discourse/app/lib/user-menu/tab.js +++ b/app/assets/javascripts/discourse/app/lib/user-menu/tab.js @@ -1,3 +1,6 @@ +/** + * abstract class representing a tab in the user menu + */ export default class UserMenuTab { constructor(currentUser, siteSettings, site) { this.currentUser = currentUser; @@ -5,22 +8,37 @@ export default class UserMenuTab { this.site = site; } + /** + * @returns {boolean} Controls whether the tab should be rendered or not. + */ get shouldDisplay() { 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() { 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() { throw new Error("not implemented"); } + /** + * @returns {string} ID for the tab. Must be unique across all visible tabs. + */ get id() { throw new Error("not implemented"); } + /** + * @returns {string} Icon for the tab. + */ get icon() { throw new Error("not implemented"); } @@ -34,3 +52,13 @@ export default class UserMenuTab { 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(); +} diff --git a/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js b/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js index 5e64d2b62d8..8633d9c9865 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/user-menu-test.js @@ -5,16 +5,21 @@ import { loggedInUser, publishToMessageBus, query, + queryAll, } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; 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 UserMenuFixtures from "discourse/tests/fixtures/user-menu"; import TopicFixtures from "discourse/tests/fixtures/topic"; import I18n from "I18n"; 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 = {}; 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" ); }); + + 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) { diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index 205e7f61925..35660ee8c47 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -74,6 +74,7 @@ import { clearToolbarCallbacks } from "discourse/components/d-editor"; import { clearExtraHeaderIcons } from "discourse/widgets/header"; import { resetSidebarSection } from "discourse/lib/sidebar/custom-sections"; import { resetNotificationTypeRenderers } from "discourse/lib/notification-item"; +import { resetUserMenuTabs } from "discourse/lib/user-menu/tab"; export function currentUser() { return User.create(sessionFixtures["/session/current.json"].current_user); @@ -204,6 +205,7 @@ export function testCleanup(container, app) { resetSidebarSection(); resetNotificationTypeRenderers(); clearExtraHeaderIcons(); + resetUserMenuTabs(); } export function discourseModule(name, options) {