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 {
>
{{#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.