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 = + *