FEATURE: Introduce APIs for manipulating header icons (#25916)

This commit is contained in:
David Taylor 2024-03-04 19:51:49 +00:00 committed by GitHub
parent 405bfba3c0
commit b788c08712
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 378 additions and 143 deletions

View File

@ -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";
<template>
<ul class="icons d-header-icons">
{{#each _extraHeaderIcons as |Icon|}}
{{#if (this._isStringType Icon)}}
<MountWidget @widget={{Icon}} />
{{else}}
{{#let
(component PanelPortal panelElement=@panelElement)
as |panelPortal|
}}
<Icon @panelPortal={{panelPortal}} />
{{/let}}
{{#each (headerIcons.resolve) as |entry|}}
{{#if (eq entry.key "search")}}
<Dropdown
@title="search.title"
@icon="search"
@iconId={{@searchButtonId}}
@onClick={{@toggleSearchMenu}}
@active={{this.search.visible}}
@href={{getURL "/search"}}
@className="search-dropdown"
@targetSelector=".search-menu-panel"
/>
{{else if (eq entry.key "hamburger")}}
{{#if (or (not @sidebarEnabled) this.site.mobileView)}}
<Dropdown
@title="hamburger_menu"
@icon="bars"
@iconId="toggle-hamburger-menu"
@active={{this.header.hamburgerVisible}}
@onClick={{@toggleHamburger}}
@className="hamburger-dropdown"
/>
{{/if}}
{{else if (eq entry.key "user-menu")}}
{{#if this.currentUser}}
<UserDropdown
@active={{this.header.userVisible}}
@toggleUserMenu={{@toggleUserMenu}}
/>
{{/if}}
{{else if entry.value}}
<entry.value
@panelPortal={{component PanelPortal panelElement=@panelElement}}
/>
{{/if}}
{{/each}}
<Dropdown
@title="search.title"
@icon="search"
@iconId={{@searchButtonId}}
@onClick={{@toggleSearchMenu}}
@active={{this.search.visible}}
@href={{getURL "/search"}}
@className="search-dropdown"
@targetSelector=".search-menu-panel"
/>
{{#if (or (not @sidebarEnabled) this.site.mobileView)}}
<Dropdown
@title="hamburger_menu"
@icon="bars"
@iconId="toggle-hamburger-menu"
@active={{this.header.hamburgerVisible}}
@onClick={{@toggleHamburger}}
@className="hamburger-dropdown"
/>
{{/if}}
{{#if this.currentUser}}
<UserDropdown
@active={{this.header.userVisible}}
@toggleUserMenu={{@toggleUserMenu}}
/>
{{/if}}
</ul>
</template>
}

View File

@ -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;
}
}

View File

@ -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: "<p>bar</p>" })
*
**/
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 = <template>
* <DButton @icon="icon" @onClick={{this.toggleVisible}} />
* {{#if this.visible}}
* <@panelPortal>
* <div>Panel</div>
* </@panelPortal>
* {{/if}}
* </template>;
*
* 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(
<template>
<span>Icon</span>
<@panelPortal>
<div>Panel</div>
</@panelPortal>
</template>
);
* ```
*
**/
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,
<template><MountWidget @widget={{icon}} /></template>,
{ 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.

View File

@ -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",

View File

@ -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/
);
});
});

View File

@ -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

View File

@ -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?.({