From b788c08712446df9c99ef44e9642c7286e882630 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Mon, 4 Mar 2024 19:51:49 +0000 Subject: [PATCH] FEATURE: Introduce APIs for manipulating header icons (#25916) --- .../app/components/glimmer-header/icons.gjs | 95 ++++---- .../javascripts/discourse/app/lib/dag.js | 109 ++++++++++ .../app/lib/{plugin-api.js => plugin-api.gjs} | 202 +++++++++++------- .../discourse/app/widgets/header.js | 26 +-- .../discourse/tests/unit/lib/dag-test.js | 81 +++++++ docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md | 6 +- .../discourse/initializers/chat-setup.js | 2 +- 7 files changed, 378 insertions(+), 143 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/lib/dag.js rename app/assets/javascripts/discourse/app/lib/{plugin-api.js => plugin-api.gjs} (96%) create mode 100644 app/assets/javascripts/discourse/tests/unit/lib/dag-test.js diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header/icons.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header/icons.gjs index 7a9c01563a6..7bf46c2bfdf 100644 --- a/app/assets/javascripts/discourse/app/components/glimmer-header/icons.gjs +++ b/app/assets/javascripts/discourse/app/components/glimmer-header/icons.gjs @@ -1,20 +1,30 @@ import Component from "@glimmer/component"; import { inject as service } from "@ember/service"; +import DAG from "discourse/lib/dag"; import getURL from "discourse-common/lib/get-url"; +import eq from "truth-helpers/helpers/eq"; import not from "truth-helpers/helpers/not"; import or from "truth-helpers/helpers/or"; -import MountWidget from "../mount-widget"; import Dropdown from "./dropdown"; import PanelPortal from "./panel-portal"; import UserDropdown from "./user-dropdown"; -let _extraHeaderIcons = []; -export function addToHeaderIcons(icon) { - _extraHeaderIcons.push(icon); +let headerIcons; +resetHeaderIcons(); + +function resetHeaderIcons() { + headerIcons = new DAG({ defaultPosition: { before: "search" } }); + headerIcons.add("search"); + headerIcons.add("hamburger", undefined, { after: "search" }); + headerIcons.add("user-menu", undefined, { after: "hamburger" }); +} + +export function headerIconsDAG() { + return headerIcons; } export function clearExtraHeaderIcons() { - _extraHeaderIcons = []; + resetHeaderIcons(); } export default class Icons extends Component { @@ -23,51 +33,44 @@ export default class Icons extends Component { @service header; @service search; - _isStringType = (icon) => typeof icon === "string"; - } diff --git a/app/assets/javascripts/discourse/app/lib/dag.js b/app/assets/javascripts/discourse/app/lib/dag.js new file mode 100644 index 00000000000..4ef958e2256 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/dag.js @@ -0,0 +1,109 @@ +import DAGMap from "dag-map"; +import { bind } from "discourse-common/utils/decorators"; + +function ensureArray(val) { + return Array.isArray(val) ? val : [val]; +} + +export default class DAG { + #defaultPosition; + #rawData = new Map(); + #dag = new DAGMap(); + + constructor(args) { + // allows for custom default positioning of new items added to the DAG, eg + // new DAG({ defaultPosition: { before: "foo", after: "bar" } }); + this.#defaultPosition = args?.defaultPosition || {}; + } + + #defaultPositionForKey(key) { + const pos = { ...this.#defaultPosition }; + if (ensureArray(pos.before).includes(key)) { + delete pos.before; + } + if (ensureArray(pos.after).includes(key)) { + delete pos.after; + } + return pos; + } + + /** + * Adds a key/value pair to the map. Can optionally specify before/after position requirements. + * + * @param {string} key The key of the item to be added. Can be referenced by other member's postition parameters. + * @param {any} value + * @param {Object} position + * @param {string | string[]} position.before A key or array of keys of items which should appear before this one. + * @param {string | string[]} position.after A key or array of keys of items which should appear after this one. + */ + add(key, value, position) { + position ||= this.#defaultPositionForKey(key); + const { before, after } = position; + this.#rawData.set(key, { + value, + before, + after, + }); + this.#dag.add(key, value, before, after); + } + + /** + * Remove an item from the map by key. no-op if the key does not exist. + * + * @param {string} key The key of the item to be removed. + */ + delete(key) { + this.#rawData.delete(key); + this.#refreshDAG(); + } + + /** + * Change the positioning rules of an existing item in the map. Will replace all existing rules. No-op if the key does not exist. + * + * @param {string} key + * @param {string | string[]} position.before A key or array of keys of items which should appear before this one. + * @param {string | string[]} position.after A key or array of keys of items which should appear after this one. + */ + reposition(key, { before, after }) { + const node = this.#rawData.get(key); + if (node) { + node.before = before; + node.after = after; + } + this.#refreshDAG(); + } + + /** + * Check whether an item exists in the map. + * @param {string} key + * @returns {boolean} + * + */ + has(key) { + return this.#rawData.has(key); + } + + /** + * Return the resolved key/value pairs in the map. The order of the pairs is determined by the before/after rules. + * @returns {Array<[key: string, value: any]}>} An array of key/value pairs. + * + */ + @bind + resolve() { + const result = []; + this.#dag.each((key, value) => result.push({ key, value })); + return result; + } + + /** + * DAGMap doesn't support removing or modifying keys, so we + * need to completely recreate it from the raw data + */ + #refreshDAG() { + const newDAG = new DAGMap(); + for (const [key, { value, before, after }] of this.#rawData) { + newDAG.add(key, value, before, after); + } + this.#dag = newDAG; + } +} diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs similarity index 96% rename from app/assets/javascripts/discourse/app/lib/plugin-api.js rename to app/assets/javascripts/discourse/app/lib/plugin-api.gjs index fd1ea432c81..1904e7baef4 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.js +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs @@ -9,11 +9,13 @@ import { import { addPluginDocumentTitleCounter } from "discourse/components/d-document"; import { addToolbarCallback } from "discourse/components/d-editor"; import { addCategorySortCriteria } from "discourse/components/edit-category-settings"; -import { addToHeaderIcons as addToGlimmerHeaderIcons } from "discourse/components/glimmer-header/icons"; +import { headerIconsDAG } from "discourse/components/glimmer-header/icons"; import { forceDropdownForMenuPanels as glimmerForceDropdownForMenuPanels } from "discourse/components/glimmer-site-header"; import { addGlobalNotice } from "discourse/components/global-notice"; import { _addBulkButton } from "discourse/components/modal/topic-bulk-actions"; -import { addWidgetCleanCallback } from "discourse/components/mount-widget"; +import MountWidget, { + addWidgetCleanCallback, +} from "discourse/components/mount-widget"; import { addPluginOutletDecorator } from "discourse/components/plugin-connector"; import { addPluginReviewableParam, @@ -98,10 +100,7 @@ import { import { setNewCategoryDefaultColors } from "discourse/routes/new-category"; import { setNotificationsLimit } from "discourse/routes/user-notifications"; import { addComposerSaveErrorCallback } from "discourse/services/composer"; -import { - addToHeaderIcons, - attachAdditionalPanel, -} from "discourse/widgets/header"; +import { attachAdditionalPanel } from "discourse/widgets/header"; import { addPostClassesCallback } from "discourse/widgets/post"; import { addDecorator } from "discourse/widgets/post-cooked"; import { @@ -144,7 +143,7 @@ import { modifySelectKit } from "select-kit/mixins/plugin-api"; // docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md whenever you change the version // using the format described at https://keepachangelog.com/en/1.0.0/. -export const PLUGIN_API_VERSION = "1.27.0"; +export const PLUGIN_API_VERSION = "1.28.0"; const DEPRECATED_HEADER_WIDGETS = [ "header", @@ -802,16 +801,16 @@ class PluginApi { } /** - Called whenever the "page" changes. This allows us to set up analytics - and other tracking. - - To get notified when the page changes, you can install a hook like so: - - ```javascript - api.onPageChange((url, title) => { - console.log('the page changed to: ' + url + ' and title ' + title); - }); - ``` + * Called whenever the "page" changes. This allows us to set up analytics + * and other tracking. + * + * To get notified when the page changes, you can install a hook like so: + * + * ```javascript + * api.onPageChange((url, title) => { + * console.log('the page changed to: ' + url + ' and title ' + title); + * }); + * ``` **/ onPageChange(fn) { const callback = wrapWithErrorHandler(fn, "broken_page_change_alert"); @@ -819,13 +818,13 @@ class PluginApi { } /** - Listen for a triggered `AppEvent` from Discourse. - - ```javascript - api.onAppEvent('inserted-custom-html', () => { - console.log('a custom footer was rendered'); - }); - ``` + * Listen for a triggered `AppEvent` from Discourse. + * + * ```javascript + * api.onAppEvent('inserted-custom-html', () => { + * console.log('a custom footer was rendered'); + * }); + * ``` **/ onAppEvent(name, fn) { const appEvents = this._lookupContainer("service:app-events"); @@ -833,18 +832,18 @@ class PluginApi { } /** - Registers a function to generate custom avatar CSS classes - for a particular user. - - Takes a function that will accept a user as a parameter - and return an array of CSS classes to apply. - - ```javascript - api.customUserAvatarClasses(user => { - if (get(user, 'primary_group_name') === 'managers') { - return ['managers']; - } - }); + * Registers a function to generate custom avatar CSS classes + * for a particular user. + * + * Takes a function that will accept a user as a parameter + * and return an array of CSS classes to apply. + * + * ```javascript + * api.customUserAvatarClasses(user => { + * if (get(user, 'primary_group_name') === 'managers') { + * return ['managers']; + * } + * }); **/ customUserAvatarClasses(fn) { registerCustomAvatarHelper(fn); @@ -967,7 +966,7 @@ class PluginApi { **/ addHeaderPanel(name, toggle, transformAttrs) { deprecated( - "addHeaderPanel has been removed. Use api.addToHeaderIcons instead.", + "addHeaderPanel will be removed as part of the glimmer header upgrade. Use api.headerIcons instead.", { id: "discourse.add-header-panel", url: "https://meta.discourse.org/t/296544", @@ -1688,11 +1687,12 @@ class PluginApi { * * Example: * + * ```javascript * let aPlugin = { - 'after:highlightElement': ({ el, result, text }) => { - console.log(el); - } - } + * "after:highlightElement": ({ el, result, text }) => { + * console.log(el); + * } + * } * api.registerHighlightJSPlugin(aPlugin); **/ registerHighlightJSPlugin(plugin) { @@ -1705,7 +1705,6 @@ class PluginApi { * Example: * * api.addGlobalNotice("text", "foo", { html: "

bar

" }) - * **/ addGlobalNotice(text, id, options) { addGlobalNotice(text, id, options); @@ -1744,7 +1743,6 @@ class PluginApi { * ``` * * @deprecated because modifying an Ember-rendered DOM tree can lead to very unexpected errors. Use CSS or plugin outlet connectors instead - * **/ decoratePluginOutlet(outletName, callback, opts) { deprecated( @@ -1804,22 +1802,70 @@ class PluginApi { /** * Allows adding icons to the category-link html * - * ``` + * ```javascript * api.addCategoryLinkIcon((category) => { - * if (category.someProperty) { - return "eye" - } + * if (category.someProperty) { + * return "eye" + * } * }); * ``` - * **/ addCategoryLinkIcon(renderer) { addExtraIconRenderer(renderer); } + /** - * Adds a widget or a component to the header-icon ul. + * Allows for manipulation of the header icons. This includes, adding, removing, or modifying the order of icons. * - * If adding a widget it must already be created. You can create new widgets + * Only the passing of components is supported, and by default the icons are added to the left of exisiting icons. + * + * Example: Add the chat icon to the header icons after the search icon + * ``` + * api.headerIcons.add( + * "chat", + * ChatIconComponent, + * { after: "search" } + * ) + * ``` + * + * Example: Remove the chat icon from the header icons + * ``` + * api.headerIcons.delete("chat") + * ``` + * + * Example: Reposition the chat icon to be before the user-menu icon and after the hamburger icon + * ``` + * api.headerIcons.reposition("chat", { before: "user-menu", after: "hamburger" }) + * ``` + * + * Example: Check if the chat icon is present in the header icons (returns true of false) + * ``` + * api.headerIcons.has("chat") + * ``` + * + * Additionally, you can utilize the `@panelPortal` argument to create a dropdown panel. This can be useful when + * you want create a button in the header that opens a dropdown panel with additional content. + * + * ``` + * const IconWithDropdown = ; + * + * api.headerIcons.add("icon-name", IconWithDropdown, { before: "search" }) + * ``` + * + **/ + get headerIcons() { + return headerIconsDAG(); + } + + /** + * Adds a widget to the header-icon ul. The widget must already be created. You can create new widgets * in a theme or plugin via an initializer prior to calling this function. * * ``` @@ -1827,26 +1873,21 @@ class PluginApi { * createWidget("some-widget") * ``` * - * If adding a component you can pass the component directly. Additionally, you can - * utilize the `@panelPortal` argument to create a dropdown panel. This can be useful when - * you want create a button in the header that opens a dropdown panel with additional content. - * - * ``` - * api.addToHeaderIcons( - - ); - * ``` - * **/ addToHeaderIcons(icon) { - addToHeaderIcons(icon); - addToGlimmerHeaderIcons(icon); + deprecated( + "addToHeaderIcons has been deprecated. Use api.headerIcons instead.", + { + id: "discourse.add-header-icons", + url: "https://meta.discourse.org/t/296544", + } + ); + + this.headerIcons.add( + icon, + , + { before: "search" } + ); } /** @@ -2032,17 +2073,17 @@ class PluginApi { /** * Download calendar modal which allow to pick between ICS and Google Calendar. Optionally, recurrence rule can be specified - https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10 * - * ``` - * api.downloadCalendar("title of the event", [ - * { - startsAt: "2021-10-12T15:00:00.000Z", - endsAt: "2021-10-12T16:00:00.000Z", - }, - * ], - * "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" + * ```javascript + * api.downloadCalendar("title of the event", + * [ + * { + * startsAt: "2021-10-12T15:00:00.000Z", + * endsAt: "2021-10-12T16:00:00.000Z", + * }, + * ], + * "FREQ=DAILY;BYDAY=MO,TU,WE,TH,FR" * ); * ``` - * */ downloadCalendar(title, dates, recurrenceRule = null) { downloadCalendar(title, dates, recurrenceRule); @@ -2103,9 +2144,10 @@ class PluginApi { * Add custom user search options. * It is heavily correlated with `register_groups_callback_for_users_search_controller_action` which allows defining custom filter. * Example usage: + * * ``` * api.addUserSearchOption("adminsOnly"); - + * * register_groups_callback_for_users_search_controller_action(:admins_only) do |groups, user| * groups.where(name: "admins") * end @@ -2426,7 +2468,7 @@ class PluginApi { * This is intended to replace the admin-menu plugin outlet from * the old admin horizontal nav. * - * ``` + * ```javascript * api.addAdminSidebarSectionLink("root", { * name: "unique_link_name", * label: "admin.some.i18n.label.key", @@ -2434,7 +2476,7 @@ class PluginApi { * href: "(optional) can be used instead of the route", * } * ``` - + * * @param {String} sectionName - The name of the admin sidebar section to add the link to. * @param {Object} link - A link object representing a section link for the sidebar. * @param {string} link.name - The name of the link. Needs to be dasherized and lowercase. diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js index 85700c9becf..839b7870358 100644 --- a/app/assets/javascripts/discourse/app/widgets/header.js +++ b/app/assets/javascripts/discourse/app/widgets/header.js @@ -2,6 +2,7 @@ import { schedule } from "@ember/runloop"; import { hbs } from "ember-cli-htmlbars"; import $ from "jquery"; import { h } from "virtual-dom"; +import { headerIconsDAG } from "discourse/components/glimmer-header/icons"; import { addExtraUserClasses } from "discourse/helpers/user-avatar"; import { wantsNewWindow } from "discourse/lib/intercept-click"; import scrollLock from "discourse/lib/scroll-lock"; @@ -22,14 +23,11 @@ import I18n from "discourse-i18n"; const SEARCH_BUTTON_ID = "search-button"; export const PANEL_WRAPPER_ID = "additional-panel-wrapper"; -let _extraHeaderIcons = []; - -export function addToHeaderIcons(icon) { - _extraHeaderIcons.push(icon); -} +let _extraHeaderIcons; +clearExtraHeaderIcons(); export function clearExtraHeaderIcons() { - _extraHeaderIcons = []; + _extraHeaderIcons = headerIconsDAG(); } export const dropdown = { @@ -249,15 +247,13 @@ createWidget("header-icons", { const icons = []; - if (_extraHeaderIcons) { - _extraHeaderIcons.forEach((icon) => { - if (typeof icon === "string") { - icons.push(this.attach(icon)); - } else { - icons.push(this.attach("extra-icon", { component: icon })); - } - }); - } + const resolvedIcons = _extraHeaderIcons.resolve(); + resolvedIcons.forEach((icon) => { + if (["search", "user-menu", "hamburger"].includes(icon.key)) { + return; + } + icons.push(this.attach("extra-icon", { component: icon.value })); + }); const search = this.attach("header-dropdown", { title: "search.title", diff --git a/app/assets/javascripts/discourse/tests/unit/lib/dag-test.js b/app/assets/javascripts/discourse/tests/unit/lib/dag-test.js new file mode 100644 index 00000000000..d306c29dd9a --- /dev/null +++ b/app/assets/javascripts/discourse/tests/unit/lib/dag-test.js @@ -0,0 +1,81 @@ +import { setupTest } from "ember-qunit"; +import { module, test } from "qunit"; +import DAG from "discourse/lib/dag"; + +module("Unit | Lib | DAG", function (hooks) { + setupTest(hooks); + + let dag; + + test("should add items to the map", function (assert) { + dag = new DAG(); + dag.add("key1", "value1"); + dag.add("key2", "value2"); + dag.add("key3", "value3"); + + assert.ok(dag.has("key1")); + assert.ok(dag.has("key2")); + assert.ok(dag.has("key3")); + }); + + test("should remove an item from the map", function (assert) { + dag = new DAG(); + dag.add("key1", "value1"); + dag.add("key2", "value2"); + dag.add("key3", "value3"); + + dag.delete("key2"); + + assert.ok(dag.has("key1")); + assert.notOk(dag.has("key2")); + assert.ok(dag.has("key3")); + }); + + test("should reposition an item in the map", function (assert) { + dag = new DAG(); + dag.add("key1", "value1"); + dag.add("key2", "value2"); + dag.add("key3", "value3"); + + dag.reposition("key3", { before: "key1" }); + + const resolved = dag.resolve(); + const keys = resolved.map((pair) => pair.key); + + assert.deepEqual(keys, ["key3", "key1", "key2"]); + }); + + test("should resolve the map in the correct order", function (assert) { + dag = new DAG(); + dag.add("key1", "value1"); + dag.add("key2", "value2"); + dag.add("key3", "value3"); + + const resolved = dag.resolve(); + const keys = resolved.map((pair) => pair.key); + + assert.deepEqual(keys, ["key1", "key2", "key3"]); + }); + + test("allows for custom before and after default positioning", function (assert) { + dag = new DAG({ defaultPosition: { before: "key3", after: "key2" } }); + dag.add("key1", "value1", {}); + dag.add("key2", "value2", { after: "key1" }); + dag.add("key3", "value3", { after: "key2" }); + dag.add("key4", "value4"); + + const resolved = dag.resolve(); + const keys = resolved.map((pair) => pair.key); + + assert.deepEqual(keys, ["key1", "key2", "key4", "key3"]); + }); + + test("throws on bad positioning", function (assert) { + dag = new DAG(); + + assert.throws( + () => dag.add("key1", "value1", { before: "key1" }), + /cycle detected/ + ); + }); +}); diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md index 5dba9560276..09611b30d45 100644 --- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md +++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md @@ -7,9 +7,13 @@ in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.28.0] - 2024-02-21 + +- added `headerIcons` which allows for manipulation of the header icons. This includes, adding, removing, or modifying the order of icons. + ## [1.27.0] - 2024-02-21 -- Updated `addToHeaderIcons` to take a component instead of just a widget (masked a string). Additionally, you can can now utilize the `@panelPortal` argument to create a dropdown panel. This can be useful when * you want create a button in the header that opens a dropdown panel with additional content. +- deprecated `addToHeaderIcons` in favor of `headerIcons` ## [1.26.0] - 2024-02-21 diff --git a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js index 1eca6676d77..b316fa9ae9a 100644 --- a/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js +++ b/plugins/chat/assets/javascripts/discourse/initializers/chat-setup.js @@ -169,7 +169,7 @@ export default { api.addCardClickListenerSelector(".chat-drawer-outlet"); if (this.chatService.userCanChat) { - api.addToHeaderIcons(ChatHeaderIcon); + api.headerIcons.add("chat", ChatHeaderIcon); } api.addStyleguideSection?.({