diff --git a/app/assets/javascripts/discourse/app/components/glimmer-header.gjs b/app/assets/javascripts/discourse/app/components/glimmer-header.gjs index ef716bbf7da..7d5be71afa4 100644 --- a/app/assets/javascripts/discourse/app/components/glimmer-header.gjs +++ b/app/assets/javascripts/discourse/app/components/glimmer-header.gjs @@ -5,7 +5,8 @@ import { action } from "@ember/object"; import didInsert from "@ember/render-modifiers/modifiers/did-insert"; import { service } from "@ember/service"; import { modifier } from "ember-modifier"; -import { and, not, or } from "truth-helpers"; +import { and, eq, not, or } from "truth-helpers"; +import DAG from "discourse/lib/dag"; import scrollLock from "discourse/lib/scroll-lock"; import DiscourseURL from "discourse/lib/url"; import { scrollTop } from "discourse/mixins/scroll-top"; @@ -15,10 +16,25 @@ import HamburgerDropdownWrapper from "./glimmer-header/hamburger-dropdown-wrappe import Icons from "./glimmer-header/icons"; import SearchMenuWrapper from "./glimmer-header/search-menu-wrapper"; import UserMenuWrapper from "./glimmer-header/user-menu-wrapper"; -import PluginOutlet from "./plugin-outlet"; const SEARCH_BUTTON_ID = "search-button"; +let headerButtons; +resetHeaderButtons(); + +function resetHeaderButtons() { + headerButtons = new DAG({ defaultPosition: { before: "auth" } }); + headerButtons.add("auth"); +} + +export function headerButtonsDAG() { + return headerButtons; +} + +export function clearExtraHeaderButtons() { + resetHeaderButtons(); +} + export default class GlimmerHeader extends Component { @service router; @service search; @@ -166,17 +182,17 @@ export default class GlimmerHeader extends Component { > - - - {{#unless this.currentUser}} - - {{/unless}} - - + {{#each (headerButtons.resolve) as |entry|}} + {{#if (and (eq entry.key "auth") (not this.currentUser))}} + + {{else if entry.value}} + + {{/if}} + {{/each}} {{#if diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs index 1904e7baef4..9eaf8fb363c 100644 --- a/app/assets/javascripts/discourse/app/lib/plugin-api.gjs +++ b/app/assets/javascripts/discourse/app/lib/plugin-api.gjs @@ -9,6 +9,7 @@ import { import { addPluginDocumentTitleCounter } from "discourse/components/d-document"; import { addToolbarCallback } from "discourse/components/d-editor"; import { addCategorySortCriteria } from "discourse/components/edit-category-settings"; +import { headerButtonsDAG } from "discourse/components/glimmer-header"; 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"; @@ -143,7 +144,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.28.0"; +export const PLUGIN_API_VERSION = "1.29.0"; const DEPRECATED_HEADER_WIDGETS = [ "header", @@ -1864,6 +1865,40 @@ class PluginApi { return headerIconsDAG(); } + /** + * Allows for manipulation of the header buttons. This includes, adding, removing, or modifying the order of buttons. + * + * Only the passing of components is supported, and by default the buttons are added to the left of exisiting buttons. + * + * Example: Add a `foo` button to the header buttons after the auth buttons + * ``` + * api.headerButtons.add( + * "foo", + * FooComponent, + * { after: "auth" } + * ) + * ``` + * + * Example: Remove the `foo` button from the header buttons + * ``` + * api.headerButtons.delete("foo") + * ``` + * + * Example: Reposition the `foo` button to be before the `bar` and after the `baz` button + * ``` + * api.headerButtons.reposition("foo", { before: "bar", after: "baz" }) + * ``` + * + * Example: Check if the `foo` button is present in the header buttons (returns true of false) + * ``` + * api.headerButtons.has("foo") + * ``` + * + **/ + get headerButtons() { + return headerButtonsDAG(); + } + /** * 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. diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js index 839b7870358..279fda8355c 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 { headerButtonsDAG } from "discourse/components/glimmer-header"; import { headerIconsDAG } from "discourse/components/glimmer-header/icons"; import { addExtraUserClasses } from "discourse/helpers/user-avatar"; import { wantsNewWindow } from "discourse/lib/intercept-click"; @@ -26,10 +27,17 @@ export const PANEL_WRAPPER_ID = "additional-panel-wrapper"; let _extraHeaderIcons; clearExtraHeaderIcons(); +let _extraHeaderButtons; +clearExtraHeaderButtons(); + export function clearExtraHeaderIcons() { _extraHeaderIcons = headerIconsDAG(); } +export function clearExtraHeaderButtons() { + _extraHeaderButtons = headerButtonsDAG(); +} + export const dropdown = { buildClasses(attrs) { let classes = attrs.classNames || []; @@ -496,6 +504,10 @@ export default createWidget("header", { buildKey: () => `header`, services: ["router", "search"], + init() { + registerWidgetShim("extra-button", "div.wrapper", hbs`<@data.component />`); + }, + defaultState() { let states = { searchVisible: false, @@ -531,22 +543,19 @@ export default createWidget("header", { return headerIcons; } - const panels = [ - h("span.header-buttons", [ - new RenderGlimmer( - this, - "span.before-header-buttons", - hbs`` - ), - this.attach("header-buttons", attrs), - new RenderGlimmer( - this, - "span.after-header-buttons", - hbs`` - ), - ]), - headerIcons, - ]; + const buttons = []; + const resolvedButtons = _extraHeaderButtons.resolve(); + resolvedButtons.forEach((button) => { + if (button.key === "auth") { + return; + } + buttons.push(this.attach("extra-button", { component: button.value })); + }); + + buttons.push(this.attach("header-buttons", attrs)); + + const panels = []; + panels.push(h("span.header-buttons", buttons), headerIcons); if (this.search.visible) { this.search.inTopicContext = this.search.inTopicContext && inTopicRoute; diff --git a/app/assets/javascripts/discourse/tests/acceptance/header-api-test.gjs b/app/assets/javascripts/discourse/tests/acceptance/header-api-test.gjs new file mode 100644 index 00000000000..6b2584a000c --- /dev/null +++ b/app/assets/javascripts/discourse/tests/acceptance/header-api-test.gjs @@ -0,0 +1,93 @@ +import { visit } from "@ember/test-helpers"; +import { test } from "qunit"; +import { AUTO_GROUPS } from "discourse/lib/constants"; +import { withPluginApi } from "discourse/lib/plugin-api"; +import { acceptance } from "discourse/tests/helpers/qunit-helpers"; + +// TODO: Consolidate these tests into a single acceptance test once the Glimmer +// header is the default. +acceptance("Header API - authenticated", function (needs) { + needs.user(); + + test("can add buttons to the header", async function (assert) { + withPluginApi("1.29.0", (api) => { + api.headerButtons.add("test", ); + }); + + await visit("/"); + assert.dom("button.test-button").exists("button is displayed"); + }); +}); + +acceptance("Header API - anonymous", function () { + test("can add buttons to the header", async function (assert) { + withPluginApi("1.29.0", (api) => { + api.headerButtons.add("test", ); + }); + + await visit("/"); + assert.dom("button.test-button").exists("button is displayed"); + }); + + test("buttons are positioned to the left of the auth buttons by default", async function (assert) { + withPluginApi("1.29.0", (api) => { + api.headerButtons.add("test", ); + }); + + await visit("/"); + const testButton = document.querySelector(".test-button"); + const authButtons = document.querySelector(".auth-buttons"); + assert.equal( + testButton.compareDocumentPosition(authButtons), + Node.DOCUMENT_POSITION_FOLLOWING, + "Test button is positioned before auth-buttons" + ); + }); +}); + +acceptance("Glimmer Header API - authenticated", function (needs) { + needs.user({ groups: AUTO_GROUPS.everyone }); + needs.settings({ + experimental_glimmer_header_groups: AUTO_GROUPS.everyone, + }); + + test("can add buttons to the header", async function (assert) { + withPluginApi("1.29.0", (api) => { + api.headerButtons.add("test", ); + }); + + await visit("/"); + assert.dom("button.test-button").exists("button is displayed"); + }); + + test("buttons can be repositioned", async function (assert) { + withPluginApi("1.29.0", (api) => { + api.headerButtons.add("test1", ); + + api.headerButtons.add( + "test2", + , + { before: "test1" } + ); + }); + + await visit("/"); + const test1 = document.querySelector(".test1-button"); + const test2 = document.querySelector(".test2-button"); + assert.equal( + test2.compareDocumentPosition(test1), + Node.DOCUMENT_POSITION_FOLLOWING, + "Test2 button is positioned before Test1 button" + ); + }); +}); diff --git a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js index dc547ad412c..35788564694 100644 --- a/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js +++ b/app/assets/javascripts/discourse/tests/helpers/qunit-helpers.js @@ -18,6 +18,7 @@ import { cleanUpComposerUploadPreProcessor, } from "discourse/components/composer-editor"; import { clearToolbarCallbacks } from "discourse/components/d-editor"; +import { clearExtraHeaderButtons as clearExtraGlimmerHeaderButtons } from "discourse/components/glimmer-header"; import { clearExtraHeaderIcons as clearExtraGlimmerHeaderIcons } from "discourse/components/glimmer-header/icons"; import { clearBulkButtons } from "discourse/components/modal/topic-bulk-actions"; import { resetWidgetCleanCallbacks } from "discourse/components/mount-widget"; @@ -83,7 +84,10 @@ import { currentSettings, mergeSettings, } from "discourse/tests/helpers/site-settings"; -import { clearExtraHeaderIcons } from "discourse/widgets/header"; +import { + clearExtraHeaderButtons, + clearExtraHeaderIcons, +} from "discourse/widgets/header"; import { resetDecorators as resetPostCookedDecorators } from "discourse/widgets/post-cooked"; import { resetPostMenuExtraButtons } from "discourse/widgets/post-menu"; import { resetDecorators } from "discourse/widgets/widget"; @@ -225,7 +229,9 @@ export function testCleanup(container, app) { resetNotificationTypeRenderers(); resetSidebarPanels(); clearExtraGlimmerHeaderIcons(); + clearExtraGlimmerHeaderButtons(); clearExtraHeaderIcons(); + clearExtraHeaderButtons(); resetOnKeyUpCallbacks(); resetItemSelectCallbacks(); resetUserMenuTabs(); diff --git a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md index 09611b30d45..bad33cf7984 100644 --- a/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md +++ b/docs/CHANGELOG-JAVASCRIPT-PLUGIN-API.md @@ -7,6 +7,10 @@ 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.29.0] - 2024-03-05 + +- added `headerButtons` which allows for manipulation of the header butttons. This includes, adding, removing, or modifying the order of buttons. + ## [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.