diff --git a/workspaces/global-header/.changeset/components-entry-lazy-appbar.md b/workspaces/global-header/.changeset/components-entry-lazy-appbar.md new file mode 100644 index 00000000000..9434aab1eb6 --- /dev/null +++ b/workspaces/global-header/.changeset/components-entry-lazy-appbar.md @@ -0,0 +1,11 @@ +--- +'@red-hat-developer-hub/backstage-plugin-global-header': major +--- + +**BREAKING**: Import building blocks (`GlobalHeaderIconButton`, `GlobalHeaderMenuItem`, `GlobalHeaderDropdown`) only from `@red-hat-developer-hub/backstage-plugin-global-header/components` — they are no longer re-exported from the root entry or deprecated `/alpha` (which now exports translations only). Prefer dynamic `import()` inside blueprint loaders. + +**BREAKING**: `HeaderIcon` no longer falls back to Material Icons ligatures for unknown ids — unregistered ids render a `HelpOutline` fallback with a tooltip. Register icons via host `app.getSystemIcon`, `globalHeaderModule`'s `IconBundleBlueprint`, or use inline SVG / image URLs. Update `globalHeader.menuItems[].icon` and `globalHeader.components[].icon` values that relied on ligatures (e.g. `menu_book`, `waving_hand`, `hub`) to registered system-icon ids (see docs/components/button-and-icons.md) or SVG/URL. + +Building-block UI lives on the `/components` Module Federation expose (`src/componentsExport.ts`) so the AppBar and heavy MUI UI stay off the root NFS sync chunk. The root entry still registers outlined system icons via `IconBundleBlueprint` (twelve `@mui/icons-material` modules — much smaller than the full AppBar bundle). + +`globalHeaderModule` registers system icon ids (`article`, `bugReport`, `quiz`, `forum`, `dashboard`, etc.) as outlined MUI SVGs for config and default extensions. diff --git a/workspaces/global-header/docs/components/button-and-icons.md b/workspaces/global-header/docs/components/button-and-icons.md index ccf269f309a..22100fbcb7f 100644 --- a/workspaces/global-header/docs/components/button-and-icons.md +++ b/workspaces/global-header/docs/components/button-and-icons.md @@ -66,7 +66,82 @@ Props: \*SVG images must start with `` markup +3. Image URL (`http(s)://`, `/`, or `data:image/`) + +Unregistered icon ids render nothing. Register additional ids on the host (e.g. RHDH `CommonIcons` or NFS `IconBundleBlueprint`) or use inline SVG/URLs. + +### Global-header system icons + +When `globalHeaderModule` is enabled, it registers the following icon ids through +`IconBundleBlueprint` (outlined `@mui/icons-material` components). Use these +string ids in `globalHeader.menuItems`, `globalHeader.components`, mount-point +`icon` props, and anywhere else `HeaderIcon` is used. + +| Icon id | Typical use | +| ---------------- | ------------------------------------- | +| `account` | Profile / my account menu items | +| `add` | Create / self-service toolbar actions | +| `article` | Wiki, documentation links | +| `bugReport` | Issue tracker links | +| `dashboard` | Dashboard or visualizer toolbar links | +| `developerHub` | App launcher / developer hub | +| `forum` | Community forum links | +| `logout` | Sign-out actions | +| `manageAccounts` | Settings, account management | +| `quiz` | FAQ, help menu items | +| `support` | Support menu items | + +Source of truth: [`globalHeaderSystemIcons.ts`](../../../plugins/global-header/src/icons/globalHeaderSystemIcons.ts). + +Example `app-config.yaml`: + +```yaml +globalHeader: + components: + - title: Visualizer Dashboard + icon: dashboard + link: /visualizer/tree + priority: 75 + menuItems: + - target: app-launcher + title: Internal Wiki + icon: article + link: https://wiki.internal.example.com + - target: app-launcher + title: Issue Tracker + icon: bugReport + link: https://issues.internal.example.com + - target: help + title: FAQ + icon: quiz + link: https://faq.example.com + - target: help + title: Community Forum + icon: forum + link: https://forum.example.com +``` + +`dashboard` is also a [Backstage `app-defaults`](https://github.com/backstage/backstage/blob/master/packages/app-defaults/src/defaults/icons.tsx) id; global-header registers an outlined variant for header use. Host icon registration still takes precedence. + +Other Backstage default ids (for example `search`, `github`, `catalog`, `kind:component`) remain available via `app.getSystemIcon` without global-header registration. Icons not registered anywhere render nothing. + +### Host integration + +RHDH loads plugins via Module Federation. **Plugins must not assume the host imported a global icon font.** + +| Approach | Verdict | +| ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| Host registers icons via `app.getSystemIcon` / `CommonIcons` (OFS) or NFS `IconBundleBlueprint` | Preferred | +| `globalHeaderModule` registers the [global-header system icons](#global-header-system-icons) via `IconBundleBlueprint` | Automatic when the module is enabled | +| Inline SVG or image URL in config | Supported without host registration | + +For `globalHeader.menuItems[].icon`, prefer ids from the [global-header system icons](#global-header-system-icons) table, other Backstage defaults, host-registered ids (e.g. RHDH `CommonIcons` for `quickstart`), or inline SVG/URLs. ## HeaderIconButton @@ -93,6 +168,7 @@ Props: | ----------- | ----------------------------------------------------------------------------------- | | `title` | Required information for the button which is used as tooltip and for accessability. | | `to` | Required internal or external link | +| `icon` | Optional icon, see `HeaderIcon` > `icon` for more information | | `tooltip` | Optional | | `color` | Optional, one of `inherit`, `primary`, `secondary`, `default`, default is `inherit` | | `size` | Optional, one of `small`, `medium`, `large`, default is `medium` | diff --git a/workspaces/global-header/docs/index.md b/workspaces/global-header/docs/index.md index 77ef1974133..0914aa2fdb0 100644 --- a/workspaces/global-header/docs/index.md +++ b/workspaces/global-header/docs/index.md @@ -6,10 +6,17 @@ By default it includes a Search input field, Create, Starred[^1], Support[^2] an The plugin supports two integration modes: -- **New Frontend System** -- Extension blueprints (`GlobalHeaderComponentBlueprint`, `GlobalHeaderMenuItemBlueprint`) allow any plugin to contribute toolbar items and dropdown menu items. See the [New Frontend System Guide](new-frontend-system.md) for full details, including architecture, code examples, and API reference. -- **Legacy Mount Points** -- Dynamic plugin mount points for traditional Backstage apps. See [Configuration](configuration.md). +- **New Frontend System (stable)** -- Import from the package root + (`@red-hat-developer-hub/backstage-plugin-global-header`). Extension blueprints + (`GlobalHeaderComponentBlueprint`, `GlobalHeaderMenuItemBlueprint`) allow any + plugin to contribute toolbar items and dropdown menu items. Building-block UI + belongs on `/components` so it stays off the main NFS sync chunk. See the + [New Frontend System Guide](new-frontend-system.md) for full details, + including architecture, code examples, and API reference. +- **Legacy Mount Points** -- Import from `/legacy` for traditional Backstage + apps using dynamic plugin mount points. See [Configuration](configuration.md). -Deployers can also add menu items directly via `app-config.yaml` without writing any plugin code. See [Config-Driven Menu Items](new-frontend-system.md#config-driven-menu-items). +Deployers can also add menu items directly via `app-config.yaml` without writing any plugin code. See [Config-Driven Menu Items](new-frontend-system.md#add-items-from-app-configyaml). [^1]: Only when an entity is starred. [^2]: Only when the Support URL is configured in the `app-config.yaml`. diff --git a/workspaces/global-header/docs/new-frontend-system.md b/workspaces/global-header/docs/new-frontend-system.md index 4d3af29960b..6500029ce93 100644 --- a/workspaces/global-header/docs/new-frontend-system.md +++ b/workspaces/global-header/docs/new-frontend-system.md @@ -2,6 +2,13 @@ The global header is the top-level navigation bar in Red Hat Developer Hub. It ships with sensible defaults -- a company logo, search, notifications, and user profile -- but is designed to be extended by other plugins and configured by deployers. +The NFS surface is **stable** on the package root +(`@red-hat-developer-hub/backstage-plugin-global-header`). Building-block UI +components live on a separate `/components` entry so they stay off the main +Module Federation sync chunk. Prefer `/legacy` only for Old Frontend System +(mount-point) apps. `/alpha` is a deprecated translations-only shim — do not +use it for the plugin, module, or blueprints. + This guide explains how to: - [Set up the header in your app](#setup) @@ -39,7 +46,7 @@ yarn --cwd packages/app add @red-hat-developer-hub/backstage-plugin-global-heade import { createApp } from '@backstage/frontend-defaults'; import globalHeaderPlugin, { globalHeaderModule, -} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; +} from '@red-hat-developer-hub/backstage-plugin-global-header'; export default createApp({ features: [ @@ -54,14 +61,15 @@ Both are required. The **module** provides the wrapper; the **plugin** provides ## Add a toolbar component -Import `GlobalHeaderComponentBlueprint` and call `.make()`. There are three ways to define what renders. +Import `GlobalHeaderComponentBlueprint` from the root and call `.make()`. +There are three ways to define what renders. ### Option A: Provide data, let the framework render Supply `icon`, `title`, and `link` (or `onClick`). The framework renders a styled icon button for you. ```typescript -import { GlobalHeaderComponentBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; +import { GlobalHeaderComponentBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header'; export const myButton = GlobalHeaderComponentBlueprint.make({ name: 'my-button', @@ -76,26 +84,31 @@ export const myButton = GlobalHeaderComponentBlueprint.make({ ### Option B: Use building-block components -For dropdowns or more control, provide a `component` that uses the exported building blocks (`GlobalHeaderIconButton`, `GlobalHeaderDropdown`). +For dropdowns or more control, provide a `loader` (preferred) that dynamically +imports building blocks from `/components` (kept off the root NFS sync +chunk): ```typescript -import { - GlobalHeaderComponentBlueprint, - GlobalHeaderDropdown, -} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; - -const MyDropdown = () => ( - } - /> -); +import { GlobalHeaderComponentBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header'; export const myDropdown = GlobalHeaderComponentBlueprint.make({ name: 'my-dropdown', - params: { component: MyDropdown, priority: 75 }, + params: { + priority: 75, + loader: async () => { + const { GlobalHeaderDropdown } = await import( + '@red-hat-developer-hub/backstage-plugin-global-header/components' + ); + return () => ( + } + /> + ); + }, + }, }); ``` @@ -116,17 +129,18 @@ export const myWidget = GlobalHeaderComponentBlueprint.make({ ### Parameters reference -| Param | Type | Description | -| ----------- | ------------------------- | ----------------------------------------------- | -| `icon` | `string` | Icon name, inline SVG, or URL | -| `title` | `string` | Display title (also tooltip and aria-label) | -| `titleKey` | `string` | i18n translation key for the title | -| `tooltip` | `string` | Explicit tooltip (overrides `title`) | -| `link` | `string` | Navigation URL | -| `onClick` | `() => void` | Click handler (mutually exclusive with `link`) | -| `component` | `ComponentType` | Custom React component (options B/C) | -| `priority` | `number` | Sort order -- higher values appear further left | -| `layout` | `Record` | MUI `sx` overrides on the wrapper | +| Param | Type | Description | +| ----------- | ------------------------------ | ----------------------------------------------------- | +| `icon` | `string` | Icon name, inline SVG, or URL | +| `title` | `string` | Display title (also tooltip and aria-label) | +| `titleKey` | `string` | i18n translation key for the title | +| `tooltip` | `string` | Explicit tooltip (overrides `title`) | +| `link` | `string` | Navigation URL | +| `onClick` | `() => void` | Click handler (mutually exclusive with `link`) | +| `component` | `ComponentType` | Custom React component (options B/C; prefer `loader`) | +| `loader` | `() => Promise` | Async component factory (keeps UI off sync) | +| `priority` | `number` | Sort order -- higher values appear further left | +| `layout` | `Record` | MUI `sx` overrides on the wrapper | ## Add a menu item @@ -143,7 +157,7 @@ Import `GlobalHeaderMenuItemBlueprint`. The `target` field routes the item to th Provide `title`, `link`, and optionally `icon` / `sectionLabel`. Items that share a `sectionLabel` are grouped under that heading. ```typescript -import { GlobalHeaderMenuItemBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; +import { GlobalHeaderMenuItemBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header'; export const docsItem = GlobalHeaderMenuItemBlueprint.make({ name: 'my-docs', @@ -160,29 +174,46 @@ export const docsItem = GlobalHeaderMenuItemBlueprint.make({ ### Custom component item using building blocks -Use the exported `GlobalHeaderMenuItem` to build a complete, clickable menu item with consistent styling. The component receives `handleClose` and `hideDivider` as props from the dropdown. +Use `GlobalHeaderMenuItem` from `/components` inside a blueprint `loader` so the +UI stays off the root NFS sync chunk. The component receives +`handleClose` and `hideDivider` as props from the dropdown. + +Put the menu item UI in its own file so the `/components` import is only pulled +in when the loader runs: ```typescript -import { - GlobalHeaderMenuItemBlueprint, - GlobalHeaderMenuItem, -} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; - -const MyDocsLink = ({ handleClose }: { handleClose?: () => void }) => ( - -); +// docs-item.tsx +import { GlobalHeaderMenuItem } from '@red-hat-developer-hub/backstage-plugin-global-header/components'; + +export function DocsLink({ handleClose }: { handleClose?: () => void }) { + return ( + + ); +} +``` + +```typescript +import { GlobalHeaderMenuItemBlueprint } from '@red-hat-developer-hub/backstage-plugin-global-header'; export const myDocsItem = GlobalHeaderMenuItemBlueprint.make({ name: 'my-docs-link', - params: { target: 'help', component: MyDocsLink, priority: 50 }, + params: { + target: 'help', + priority: 50, + loader: () => import('./docs-item').then(m => m.DocsLink), + }, }); ``` +> For simple links with `title`, `icon`, and `link`, prefer the +> [data-driven item](#data-driven-item) above — no custom component or loader +> required. + ### Fully custom component item For complete control over rendering, provide any React component. The component receives `handleClose` and `hideDivider` as props. @@ -224,7 +255,7 @@ import { createFrontendPlugin } from '@backstage/frontend-plugin-api'; import { GlobalHeaderComponentBlueprint, GlobalHeaderMenuItemBlueprint, -} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; +} from '@red-hat-developer-hub/backstage-plugin-global-header'; export default createFrontendPlugin({ pluginId: 'my-plugin', @@ -244,7 +275,7 @@ export default createFrontendPlugin({ target: 'help', title: 'FAQ', link: '/faq', - icon: 'help', + icon: 'quiz', priority: 50, }, }), @@ -346,7 +377,8 @@ Extension ID pattern: `gh-menu-item:global-header/` For plugin authors building custom toolbar components or dropdowns, the plugin exports lower-level building blocks and React hooks: -**Building-block components** (consistent styling without starting from scratch): +**Building-block components** (import from `/components` — not the root +entry — so MUI stays off the main NFS sync chunk): | Component | Key props | Purpose | | ------------------------ | -------------------------------- | ---------------------------------------------------------------------------- | @@ -354,6 +386,16 @@ For plugin authors building custom toolbar components or dropdowns, the plugin e | `GlobalHeaderMenuItem` | `to`, `title`, `icon`, `onClick` | Complete clickable menu item with link navigation and consistent styling | | `GlobalHeaderDropdown` | `target`, `buttonContent` | Dropdown that auto-collects `gh-menu-item` extensions for the given `target` | +```typescript +import { + GlobalHeaderIconButton, + GlobalHeaderMenuItem, + GlobalHeaderDropdown, +} from '@red-hat-developer-hub/backstage-plugin-global-header/components'; +``` + +Prefer dynamic `import()` of these from inside blueprint `loader`s. + **Context hooks** (direct access to collected extension data): | Hook | Returns | @@ -363,4 +405,9 @@ For plugin authors building custom toolbar components or dropdowns, the plugin e **Translations:** Use `titleKey` / `subTitleKey` for i18n. Keys containing dots (e.g. `'applicationLauncher.sections.documentation'`) are auto-resolved. The plugin exports `globalHeaderTranslationRef` and `globalHeaderTranslations` for overrides. -All exports are available from `@red-hat-developer-hub/backstage-plugin-global-header/alpha`. +| Entry | Use for | +| ------------------ | ----------------------------------------------------------------- | +| Package root (`.`) | NFS plugin, module, blueprints, hooks, translations | +| `/components` | Building-block UI only (`GlobalHeaderMenuItem`, dropdowns, icons) | +| `/legacy` | Deprecated OFS / mount-point API | +| `/alpha` | Deprecated translations re-export only (not an NFS entry) | diff --git a/workspaces/global-header/packages/app/src/App.tsx b/workspaces/global-header/packages/app/src/App.tsx index 3be9e4b28d9..a3a853dd55a 100644 --- a/workspaces/global-header/packages/app/src/App.tsx +++ b/workspaces/global-header/packages/app/src/App.tsx @@ -27,7 +27,6 @@ import { globalHeaderModule, globalHeaderTranslationsModule, GlobalHeaderMenuItemBlueprint, - GlobalHeaderMenuItem, } from '@red-hat-developer-hub/backstage-plugin-global-header'; import { navModule } from './modules/nav'; @@ -57,15 +56,6 @@ const signInModule = createFrontendModule({ ], }); -const CustomHelpMenuItem = ({ handleClose }: { handleClose?: () => void }) => ( - -); - const headerExamplesPlugin = createFrontendPlugin({ pluginId: 'header-examples', extensions: [ @@ -73,8 +63,28 @@ const headerExamplesPlugin = createFrontendPlugin({ name: 'custom-help-docs', params: { target: 'help', - component: CustomHelpMenuItem, priority: 50, + // Import building blocks from `/components` inside the loader so they + // stay off the root Module Federation sync chunk. + loader: async () => { + const { GlobalHeaderMenuItem } = await import( + '@red-hat-developer-hub/backstage-plugin-global-header/components' + ); + return function CustomHelpMenuItem({ + handleClose, + }: { + handleClose?: () => void; + }) { + return ( + + ); + }; + }, }, }), ], diff --git a/workspaces/global-header/plugins/global-header/README.md b/workspaces/global-header/plugins/global-header/README.md index 8c294145479..3c72d612a7a 100644 --- a/workspaces/global-header/plugins/global-header/README.md +++ b/workspaces/global-header/plugins/global-header/README.md @@ -21,13 +21,14 @@ yarn --cwd packages/app add @red-hat-developer-hub/backstage-plugin-global-heade ### New Frontend System -Import the plugin and module in your NFS app: +The NFS plugin is available from the package root (`.`). Import the +plugin and module in your NFS app: ```typescript import { createApp } from '@backstage/frontend-defaults'; import globalHeaderPlugin, { globalHeaderModule, -} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; +} from '@red-hat-developer-hub/backstage-plugin-global-header'; export default createApp({ features: [ @@ -38,11 +39,21 @@ export default createApp({ }); ``` -Other plugins can contribute toolbar items and dropdown menu items using `GlobalHeaderComponentBlueprint` and `GlobalHeaderMenuItemBlueprint`. See the [New Frontend System documentation](../../docs/new-frontend-system.md) for detailed examples and API reference. +> `/alpha` is deprecated and translations-only — use the root import for NFS. +> Legacy (OFS) mounts live under `/legacy`. + +Other plugins can contribute toolbar items and dropdown menu items using +`GlobalHeaderComponentBlueprint` and `GlobalHeaderMenuItemBlueprint` from the +root. Building-block UI (`GlobalHeaderMenuItem`, `GlobalHeaderDropdown`, +…) must be imported from `/components` (ideally inside a blueprint `loader`) so +MUI-heavy UI stays off the main NFS Module Federation sync chunk. See the +[New Frontend System documentation](../../docs/new-frontend-system.md) for +detailed examples and API reference. ### Legacy (Mount Points) -For legacy Backstage apps using dynamic plugin mount points, see the [Configuration documentation](../../docs/configuration.md). +For legacy Backstage apps using dynamic plugin mount points, import from +`/legacy` and see the [Configuration documentation](../../docs/configuration.md). ## Configuration diff --git a/workspaces/global-header/plugins/global-header/package.json b/workspaces/global-header/plugins/global-header/package.json index 7c8b80cd05a..8b7a962f629 100644 --- a/workspaces/global-header/plugins/global-header/package.json +++ b/workspaces/global-header/plugins/global-header/package.json @@ -9,6 +9,7 @@ "./legacy": "./src/legacy.ts", "./global-header-module": "./src/globalHeaderModuleExport.ts", "./global-header-translations-module": "./src/globalHeaderTranslationsModuleExport.ts", + "./components": "./src/componentsExport.ts", "./package.json": "./package.json" }, "typesVersions": { @@ -25,6 +26,9 @@ "global-header-translations-module": [ "src/globalHeaderTranslationsModuleExport.ts" ], + "components": [ + "src/componentsExport.ts" + ], "package.json": [ "package.json" ] @@ -81,7 +85,8 @@ "@mui/material": "5.18.0", "@mui/styled-engine": "5.18.0", "@scalprum/react-core": "0.11.3", - "react-use": "^17.5.0" + "react-use": "^17.5.0", + "zod": "^4.4.3" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0 || ^18.0.0", diff --git a/workspaces/global-header/plugins/global-header/report-components.api.md b/workspaces/global-header/plugins/global-header/report-components.api.md new file mode 100644 index 00000000000..6d40acdd61c --- /dev/null +++ b/workspaces/global-header/plugins/global-header/report-components.api.md @@ -0,0 +1,75 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-global-header" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import type Button from '@mui/material/Button'; +import type { ComponentProps } from 'react'; +import { FrontendModule } from '@backstage/frontend-plugin-api'; +import { JSX as JSX_2 } from 'react/jsx-runtime'; +import type { ReactNode } from 'react'; + +// @public +const _default: FrontendModule; +export default _default; + +// @public +export const GlobalHeaderDropdown: ( + input: GlobalHeaderDropdownProps, +) => JSX_2.Element | null; + +// @public +export interface GlobalHeaderDropdownProps { + buttonContent: ReactNode; + buttonProps?: ComponentProps; + emptyState?: ReactNode; + isIconButton?: boolean; + target: string; + tooltip?: string; + trackValidity?: boolean; +} + +// @public (undocumented) +export const GlobalHeaderIconButton: ( + input: HeaderIconButtonProps, +) => JSX_2.Element; + +// @public +export const GlobalHeaderMenuItem: ( + input: GlobalHeaderMenuItemProps, +) => JSX_2.Element; + +// @public +export interface GlobalHeaderMenuItemProps { + icon?: string; + onClick?: () => void; + subTitle?: string; + subTitleKey?: string; + title?: string; + titleKey?: string; + to?: string; + tooltip?: string; +} + +// @public (undocumented) +export interface HeaderIconButtonProps { + // (undocumented) + ariaLabel?: string; + // (undocumented) + color?: 'inherit' | 'primary' | 'secondary' | 'default'; + // (undocumented) + icon: string; + // (undocumented) + size?: 'small' | 'medium' | 'large'; + // (undocumented) + title: string; + // (undocumented) + titleKey?: string; + // (undocumented) + to: string; + // (undocumented) + tooltip?: string; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/workspaces/global-header/plugins/global-header/report-legacy.api.md b/workspaces/global-header/plugins/global-header/report-legacy.api.md index 33f80a84ce5..7721e0a640e 100644 --- a/workspaces/global-header/plugins/global-header/report-legacy.api.md +++ b/workspaces/global-header/plugins/global-header/report-legacy.api.md @@ -386,6 +386,4 @@ export interface SupportButtonProps { // (undocumented) tooltip?: string; } - -// (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/global-header/plugins/global-header/report.api.md b/workspaces/global-header/plugins/global-header/report.api.md index 34d78f709bc..e9c97dcf43b 100644 --- a/workspaces/global-header/plugins/global-header/report.api.md +++ b/workspaces/global-header/plugins/global-header/report.api.md @@ -3,17 +3,13 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import type Button from '@mui/material/Button'; -import type { ComponentProps } from 'react'; import type { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { FrontendModule } from '@backstage/frontend-plugin-api'; -import { JSX as JSX_2 } from 'react/jsx-runtime'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; -import type { ReactNode } from 'react'; import { TranslationRef } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; @@ -32,11 +28,11 @@ export const appLauncherDevHubMenuItemExtension: OverridableExtensionDefinition< sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -65,11 +61,11 @@ export const appLauncherRhdhLocalMenuItemExtension: OverridableExtensionDefiniti sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -137,11 +133,11 @@ export const defaultMenuItemExtensions: ( sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -168,11 +164,11 @@ export const defaultMenuItemExtensions: ( sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -199,11 +195,11 @@ export const defaultMenuItemExtensions: ( sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -230,11 +226,11 @@ export const defaultMenuItemExtensions: ( sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -261,11 +257,11 @@ export const defaultMenuItemExtensions: ( sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -292,11 +288,11 @@ export const defaultMenuItemExtensions: ( sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -545,32 +541,6 @@ export const globalHeaderComponentDataRef: ConfigurableExtensionDataRef< {} >; -// @public -export const GlobalHeaderDropdown: ( - input: GlobalHeaderDropdownProps, -) => JSX_2.Element | null; - -// @public -export interface GlobalHeaderDropdownProps { - buttonContent: ReactNode; - buttonProps?: ComponentProps; - emptyState?: ReactNode; - isIconButton?: boolean; - target: string; - tooltip?: string; - trackValidity?: boolean; -} - -// @public (undocumented) -export const GlobalHeaderIconButton: ( - input: HeaderIconButtonProps, -) => JSX_2.Element; - -// @public -export const GlobalHeaderMenuItem: ( - input: GlobalHeaderMenuItemProps, -) => JSX_2.Element; - // @public export const GlobalHeaderMenuItemBlueprint: ExtensionBlueprint<{ kind: 'gh-menu-item'; @@ -592,11 +562,11 @@ export const GlobalHeaderMenuItemBlueprint: ExtensionBlueprint<{ sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -637,18 +607,6 @@ export const globalHeaderMenuItemDataRef: ConfigurableExtensionDataRef< {} >; -// @public -export interface GlobalHeaderMenuItemProps { - icon?: string; - onClick?: () => void; - subTitle?: string; - subTitleKey?: string; - title?: string; - titleKey?: string; - to?: string; - tooltip?: string; -} - // @public export const globalHeaderModule: FrontendModule; @@ -841,11 +799,11 @@ const globalHeaderPlugin: OverridableFrontendPlugin< sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -872,11 +830,11 @@ const globalHeaderPlugin: OverridableFrontendPlugin< sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -903,11 +861,11 @@ const globalHeaderPlugin: OverridableFrontendPlugin< sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -934,11 +892,11 @@ const globalHeaderPlugin: OverridableFrontendPlugin< sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -965,11 +923,11 @@ const globalHeaderPlugin: OverridableFrontendPlugin< sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -996,11 +954,11 @@ const globalHeaderPlugin: OverridableFrontendPlugin< sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -1064,26 +1022,6 @@ export const globalHeaderTranslations: TranslationResource<'plugin.global-header // @public export const globalHeaderTranslationsModule: FrontendModule; -// @public (undocumented) -export interface HeaderIconButtonProps { - // (undocumented) - ariaLabel?: string; - // (undocumented) - color?: 'inherit' | 'primary' | 'secondary' | 'default'; - // (undocumented) - icon: string; - // (undocumented) - size?: 'small' | 'medium' | 'large'; - // (undocumented) - title: string; - // (undocumented) - titleKey?: string; - // (undocumented) - to: string; - // (undocumented) - tooltip?: string; -} - // @public (undocumented) export const helpDropdownExtension: OverridableExtensionDefinition<{ kind: 'gh-component'; @@ -1118,11 +1056,11 @@ export const logoutMenuItemExtension: OverridableExtensionDefinition<{ sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -1138,12 +1076,12 @@ export const logoutMenuItemExtension: OverridableExtensionDefinition<{ // @public export interface MenuItemParams { - // (undocumented) component?: ComponentType; // (undocumented) icon?: string; // (undocumented) link?: string; + loader?: () => Promise>; // (undocumented) onClick?: () => void; // (undocumented) @@ -1178,11 +1116,11 @@ export const myProfileMenuItemExtension: OverridableExtensionDefinition<{ sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -1287,11 +1225,11 @@ export const settingsMenuItemExtension: OverridableExtensionDefinition<{ sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -1358,11 +1296,11 @@ export const supportButtonMenuItemExtension: OverridableExtensionDefinition<{ sectionLinkLabel: string | undefined; }; configInput: { - link?: string | undefined; + priority?: number | undefined; title?: string | undefined; - icon?: string | undefined; titleKey?: string | undefined; - priority?: number | undefined; + icon?: string | undefined; + link?: string | undefined; sectionLabel?: string | undefined; sectionLink?: string | undefined; sectionLinkLabel?: string | undefined; @@ -1378,13 +1316,13 @@ export const supportButtonMenuItemExtension: OverridableExtensionDefinition<{ // @public export interface ToolbarComponentParams { - // (undocumented) component?: ComponentType; // (undocumented) icon?: string; layout?: Record; // (undocumented) link?: string; + loader?: () => Promise>; // (undocumented) onClick?: () => void; // (undocumented) diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.test.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.test.tsx index 6f7a063a608..5f6f6493470 100644 --- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.test.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.test.tsx @@ -49,10 +49,9 @@ describe('GlobalHeader', () => { , ); - expect(screen.getByRole('navigation')).toBeInTheDocument(); - expect( - screen.getByRole('navigation').querySelector('.MuiToolbar-root'), - ).toBeEmptyDOMElement(); + const nav = screen.getByRole('navigation'); + expect(nav).toBeInTheDocument(); + expect(nav.firstElementChild).toBeEmptyDOMElement(); }); it('renders the nav element with id="global-header"', () => { diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.tsx index 258926b9ec2..1cce44b4cf7 100644 --- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeader.tsx @@ -20,6 +20,7 @@ import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import Toolbar from '@mui/material/Toolbar'; +import '../configureMuiClassName'; import { useGlobalHeaderComponents } from '../extensions/GlobalHeaderContext'; /** diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx new file mode 100644 index 00000000000..6247ed944d3 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx @@ -0,0 +1,387 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + useEffect, + useState, + type ComponentProps, + type ComponentType, +} from 'react'; +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import MenuItem from '@mui/material/MenuItem'; +import { renderInTestApp } from '@backstage/test-utils'; + +import { GlobalHeaderProvider } from '../extensions/GlobalHeaderContext'; +import type { GlobalHeaderMenuItemData } from '../types'; +import { GlobalHeaderDropdown } from './GlobalHeaderDropdown'; + +jest.mock('../hooks/useTranslation', () => { + const { mockUseTranslation } = require('../test-utils/mockTranslations'); + return { useTranslation: mockUseTranslation }; +}); + +jest.mock('../components/Trans', () => { + const { MockTrans } = require('../test-utils/mockTranslations'); + return { Trans: MockTrans }; +}); + +const emptyState =
No items
; + +const renderDropdown = ( + menuItems: GlobalHeaderMenuItemData[], + props: Partial> = {}, +) => + renderInTestApp( + + Help} + emptyState={emptyState} + {...props} + /> + , + ); + +const openMenu = async () => { + await userEvent.click(screen.getByRole('button', { name: /help/i })); +}; + +describe('GlobalHeaderDropdown', () => { + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('returns null when there are no menu items and no emptyState', async () => { + const { container } = await renderInTestApp( + + Help} /> + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the trigger when emptyState is provided with no menu items', async () => { + await renderDropdown([]); + + expect(screen.getByRole('button', { name: /help/i })).toBeInTheDocument(); + }); + + it('shows emptyState immediately when there are no contributions', async () => { + await renderDropdown([]); + await openMenu(); + + expect(await screen.findByTestId('empty-state')).toBeInTheDocument(); + }); + + it('renders data-driven menu items', async () => { + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'help', + type: 'data', + title: 'Documentation', + link: '/docs', + priority: 10, + }, + ]; + + await renderDropdown(menuItems); + await openMenu(); + + expect( + await screen.findByRole('menuitem', { name: /documentation/i }), + ).toBeInTheDocument(); + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument(); + }); + + it('renders component-type menu items', async () => { + const SupportItem: ComponentType<{ handleClose?: () => void }> = () => ( + Support + ); + + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'help', + type: 'component', + component: SupportItem, + priority: 10, + }, + ]; + + await renderDropdown(menuItems); + await openMenu(); + + expect( + await screen.findByRole('menuitem', { name: /support/i }), + ).toBeInTheDocument(); + }); + + it('ignores menu items for other targets', async () => { + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'create', + type: 'data', + title: 'Create template', + link: '/create', + priority: 10, + }, + ]; + + await renderDropdown(menuItems); + await openMenu(); + + expect(await screen.findByTestId('empty-state')).toBeInTheDocument(); + expect( + screen.queryByRole('menuitem', { name: /create template/i }), + ).not.toBeInTheDocument(); + }); + + describe('trackValidity', () => { + it('shows emptyState after settle timeout when contributed items render nothing', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + const NullItem: ComponentType = () => null; + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'help', + type: 'component', + component: NullItem, + priority: 10, + }, + ]; + + await renderDropdown(menuItems, { trackValidity: true }); + await user.click(screen.getByRole('button', { name: /help/i })); + + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument(); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(await screen.findByTestId('empty-state')).toBeInTheDocument(); + }); + + it('keeps showing menu content when a menuitem is present synchronously', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + const SupportItem: ComponentType = () => Support; + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'help', + type: 'component', + component: SupportItem, + priority: 10, + }, + ]; + + await renderDropdown(menuItems, { trackValidity: true }); + await user.click(screen.getByRole('button', { name: /help/i })); + + expect( + await screen.findByRole('menuitem', { name: /support/i }), + ).toBeInTheDocument(); + + await act(async () => { + jest.advanceTimersByTime(1500); + }); + + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: /support/i }), + ).toBeInTheDocument(); + }); + + it('does not show emptyState when a lazy item appears before settle timeout', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + const LazySupport: ComponentType = () => { + const [ready, setReady] = useState(false); + useEffect(() => { + const id = window.setTimeout(() => setReady(true), 100); + return () => window.clearTimeout(id); + }, []); + if (!ready) return null; + return Support; + }; + + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'help', + type: 'component', + component: LazySupport, + priority: 10, + }, + ]; + + await renderDropdown(menuItems, { trackValidity: true }); + await user.click(screen.getByRole('button', { name: /help/i })); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + expect( + await screen.findByRole('menuitem', { name: /support/i }), + ).toBeInTheDocument(); + + await act(async () => { + jest.advanceTimersByTime(1500); + }); + + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument(); + }); + + it('recovers from emptyState when a lazy item appears after settle timeout', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + const VeryLazySupport: ComponentType = () => { + const [ready, setReady] = useState(false); + useEffect(() => { + const id = window.setTimeout(() => setReady(true), 800); + return () => window.clearTimeout(id); + }, []); + if (!ready) return null; + return Support; + }; + + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'help', + type: 'component', + component: VeryLazySupport, + priority: 10, + }, + ]; + + await renderDropdown(menuItems, { trackValidity: true }); + await user.click(screen.getByRole('button', { name: /help/i })); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(await screen.findByTestId('empty-state')).toBeInTheDocument(); + + await act(async () => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument(); + }); + expect( + screen.getByRole('menuitem', { name: /support/i }), + ).toBeInTheDocument(); + }); + + it('hides contributed content while emptyState is shown, without unmounting it', async () => { + jest.useFakeTimers(); + const user = userEvent.setup({ + advanceTimers: jest.advanceTimersByTime, + }); + + let unmounted = false; + const PersistentNullItem: ComponentType = () => { + useEffect(() => { + return () => { + unmounted = true; + }; + }, []); + return
; + }; + + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'help', + type: 'component', + component: PersistentNullItem, + priority: 10, + }, + ]; + + await renderDropdown(menuItems, { trackValidity: true }); + await user.click(screen.getByRole('button', { name: /help/i })); + + await act(async () => { + jest.advanceTimersByTime(500); + }); + + expect(await screen.findByTestId('empty-state')).toBeInTheDocument(); + expect(screen.getByTestId('persistent-null-host')).not.toBeVisible(); + expect(unmounted).toBe(false); + }); + }); + + it('supports arrow-key navigation between visible menu items', async () => { + const menuItems: GlobalHeaderMenuItemData[] = [ + { + target: 'help', + type: 'data', + title: 'First item', + link: '/first', + priority: 20, + }, + { + target: 'help', + type: 'data', + title: 'Second item', + link: '/second', + priority: 10, + }, + ]; + + await renderDropdown(menuItems); + await openMenu(); + + const firstItem = await screen.findByRole('menuitem', { + name: /first item/i, + }); + const secondItem = screen.getByRole('menuitem', { name: /second item/i }); + + firstItem.focus(); + expect(firstItem).toHaveFocus(); + + await userEvent.keyboard('{ArrowDown}'); + expect(secondItem).toHaveFocus(); + }); + + it('forwards tooltip and icon button props to the trigger', async () => { + await renderDropdown([], { + isIconButton: true, + tooltip: 'Help menu', + buttonContent: icon, + }); + + expect( + screen.getByRole('button', { name: 'Help menu' }), + ).toBeInTheDocument(); + }); +}); diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx index f52137d65b4..4256f32c157 100644 --- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx @@ -14,9 +14,10 @@ * limitations under the License. */ -import { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { ComponentProps, ReactNode } from 'react'; import type Button from '@mui/material/Button'; +import Box from '@mui/material/Box'; import { useGlobalHeaderMenuItems } from '../extensions/GlobalHeaderContext'; import { buildDropdownEntries } from '../utils/menuItemGrouping'; @@ -24,6 +25,14 @@ import { useDropdownManager } from '../hooks'; import { HeaderDropdownComponent } from './HeaderDropdownComponent/HeaderDropdownComponent'; import { GlobalHeaderDropdownContent } from './GlobalHeaderDropdownContent'; +/** + * Settle delays for lazy menu items. + * Matches the legacy OFS HelpDropdown validity tracking behaviour. + */ +const VALIDITY_CHECK_MS = [500, 1500] as const; + +type MenuValidity = 'pending' | 'valid' | 'empty'; + /** * Props for {@link GlobalHeaderDropdown}. * @@ -43,9 +52,8 @@ export interface GlobalHeaderDropdownProps { /** Rendered when no menu items are contributed (or all render empty when `trackValidity` is on). */ emptyState?: ReactNode; /** - * When `true`, the dropdown checks the rendered MenuList for visible - * `[role="menuitem"]` elements after each render. If none are found, - * the `emptyState` is shown instead. + * When enabled, waits for lazy menu items to settle before deciding whether + * to display the empty state. */ trackValidity?: boolean; } @@ -73,21 +81,66 @@ export const GlobalHeaderDropdown = ({ const entries = useMemo(() => buildDropdownEntries(menuItems), [menuItems]); const menuListRef = useRef(null); - const [hasVisibleItems, setHasVisibleItems] = useState(true); + const [menuValidity, setMenuValidity] = useState('pending'); + const isOpen = Boolean(anchorEl); - useLayoutEffect(() => { - if (!trackValidity || !isOpen || !menuListRef.current) return; - const found = - menuListRef.current.querySelector('[role="menuitem"]') !== null; - if (found !== hasVisibleItems) { - setHasVisibleItems(found); + useEffect(() => { + if (!trackValidity) { + return; + } + + if (!isOpen) { + setMenuValidity('pending'); + return; + } + + const list = menuListRef.current; + if (!list) { + return; } - }, [trackValidity, hasVisibleItems, isOpen]); - if (menuItems.length === 0 && !emptyState) return null; + const syncFromDom = () => { + if (list.querySelector('[role="menuitem"]')) { + setMenuValidity('valid'); + } + }; + + // Handle items that rendered synchronously. + syncFromDom(); + + const observer = new MutationObserver(syncFromDom); + + observer.observe(list, { + childList: true, + subtree: true, + }); + + const concludeEmpty = () => { + if (!list.querySelector('[role="menuitem"]')) { + setMenuValidity(prev => (prev === 'valid' ? prev : 'empty')); + } + }; + + const timers = VALIDITY_CHECK_MS.map(ms => + window.setTimeout(concludeEmpty, ms), + ); + + // eslint-disable-next-line consistent-return + return () => { + observer.disconnect(); + timers.forEach(id => window.clearTimeout(id)); + }; + }, [trackValidity, isOpen]); + + if (menuItems.length === 0 && !emptyState) { + return null; + } + + const hasNoContributions = entries.length === 0; - const showEmpty = entries.length === 0 || (trackValidity && !hasVisibleItems); + const showEmptyState = + hasNoContributions || (trackValidity && menuValidity === 'empty'); return ( - {showEmpty ? ( + {hasNoContributions ? ( emptyState ) : ( - + <> + {trackValidity && showEmptyState ? emptyState : null} + {/* + * Keep content mounted for lazy validity recovery. Use display:contents + * when visible so MenuItems stay direct DOM children of the MenuList + * for keyboard navigation; hide the subtree only while emptyState shows. + */} + + + + )} ); diff --git a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx index 3dc81c31a7b..5b261c437b1 100644 --- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx @@ -58,7 +58,7 @@ export interface GlobalHeaderMenuItemProps { * * ); @@ -80,7 +80,15 @@ export const GlobalHeaderMenuItem = ({ disableRipple disableTouchRipple onClick={onClick} - sx={{ py: 0.5, color: 'inherit', textDecoration: 'none' }} + sx={{ + py: 0.5, + px: 0, + width: '100%', + boxSizing: 'border-box', + display: 'flex', + color: 'inherit', + textDecoration: 'none', + }} {...(to ? { component: Link, to } : {})} > = ({ onClick?.(); handleClose(); }} - sx={{ py: 0.5, color: 'inherit', textDecoration: 'none' }} + sx={{ + py: 0.5, + px: 0, + width: '100%', + boxSizing: 'border-box', + display: 'flex', + color: 'inherit', + textDecoration: 'none', + }} + // Fragment when there is no link so nested full MenuItems + // (e.g. SupportButton) are not wrapped in a second menuitem. component={link ? Link : Fragment} to={link} > diff --git a/workspaces/global-header/plugins/global-header/src/components/HeaderIcon/HeaderIcon.test.tsx b/workspaces/global-header/plugins/global-header/src/components/HeaderIcon/HeaderIcon.test.tsx new file mode 100644 index 00000000000..88a56577d28 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/HeaderIcon/HeaderIcon.test.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { render, screen } from '@testing-library/react'; +import HomeIcon from '@mui/icons-material/Home'; +import { HeaderIcon } from './HeaderIcon'; + +const mockGetSystemIcon = jest.fn(); + +jest.mock('@backstage/core-plugin-api', () => ({ + useApp: () => ({ + getSystemIcon: mockGetSystemIcon, + }), +})); + +describe('HeaderIcon', () => { + beforeEach(() => { + mockGetSystemIcon.mockReset(); + }); + + it('returns null when icon is not provided', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders system icon when available', () => { + mockGetSystemIcon.mockReturnValue(() => ( + + )); + + render(); + expect(screen.getByTestId('system-home-icon')).toBeInTheDocument(); + expect(mockGetSystemIcon).toHaveBeenCalledWith('home'); + }); + + it('renders inline svg icons', () => { + const svgString = ''; + const { container } = render(); + expect(container.querySelector('img')).toBeInTheDocument(); + }); + + it('renders remote image urls', () => { + const { container } = render( + , + ); + expect(container.querySelector('img')).toHaveAttribute( + 'src', + 'https://example.com/icon.png', + ); + }); + + it('renders a fallback icon for unknown icon ids without its own tooltip', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const { container } = render(); + + expect(screen.getByTestId('HelpOutlineOutlinedIcon')).toBeInTheDocument(); + expect( + container.querySelector('[aria-label*="Unknown icon"]'), + ).not.toBeInTheDocument(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('unknown_custom_icon'), + ); + + warnSpy.mockRestore(); + }); +}); diff --git a/workspaces/global-header/plugins/global-header/src/components/HeaderIcon/HeaderIcon.tsx b/workspaces/global-header/plugins/global-header/src/components/HeaderIcon/HeaderIcon.tsx index dffbd4cbbbd..0c5a039093d 100644 --- a/workspaces/global-header/plugins/global-header/src/components/HeaderIcon/HeaderIcon.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/HeaderIcon/HeaderIcon.tsx @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import type { CSSProperties } from 'react'; import { useApp } from '@backstage/core-plugin-api'; +import HelpOutlineOutlined from '@mui/icons-material/HelpOutlineOutlined'; import MuiIcon from '@mui/material/Icon'; import Box from '@mui/material/Box'; @@ -27,14 +29,6 @@ export interface HeaderIconProps { layout?: CSSProperties; } -/** - * Converts camelCase icon names to snake_case for Material Icons ligatures. - * e.g. `manageAccounts` -> `manage_accounts`, `accountCircle` -> `account_circle` - */ -function toMaterialIconLigature(name: string): string { - return name.replace(/[A-Z]/g, m => `_${m.toLowerCase()}`); -} - /** * @public */ @@ -69,7 +63,8 @@ export const HeaderIcon = ({ if ( icon.startsWith('https://') || icon.startsWith('http://') || - icon.startsWith('/') + icon.startsWith('/') || + icon.startsWith('data:image/') ) { return ( - {ligature} - + + + ); }; diff --git a/workspaces/global-header/plugins/global-header/src/components/LogoutButton/LogoutButton.tsx b/workspaces/global-header/plugins/global-header/src/components/LogoutButton/LogoutButton.tsx index 0749792a121..bf84af237ca 100644 --- a/workspaces/global-header/plugins/global-header/src/components/LogoutButton/LogoutButton.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/LogoutButton/LogoutButton.tsx @@ -46,7 +46,11 @@ export const LogoutButton = ({ onClick={handleLogout} sx={{ cursor: 'pointer', + py: 0.5, + px: 0, width: '100%', + boxSizing: 'border-box', + display: 'flex', color: 'inherit', }} > diff --git a/workspaces/global-header/plugins/global-header/src/components/MenuItemLink/MenuItemLinkContent.tsx b/workspaces/global-header/plugins/global-header/src/components/MenuItemLink/MenuItemLinkContent.tsx index a6b513d3023..a599e84ee5b 100644 --- a/workspaces/global-header/plugins/global-header/src/components/MenuItemLink/MenuItemLinkContent.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/MenuItemLink/MenuItemLinkContent.tsx @@ -40,7 +40,11 @@ export const MenuItemLinkContent: FC = ({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - margin: '8px 0', + my: 1, + // Pad content (not MenuItem) — RHDH/PatternFly zeros padding on + // menu anchors and list items across help, profile, etc. + px: 2, + boxSizing: 'border-box', color: 'inherit', width: '100%', }} diff --git a/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx b/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx index caa8bbdbf3b..e728c543e86 100644 --- a/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx @@ -71,12 +71,17 @@ export const MyProfileMenuItem = ({ onClick={handleClose} disableRipple disableTouchRipple - sx={{ py: 0.5, color: 'inherit', textDecoration: 'none' }} + sx={{ + py: 0.5, + px: 0, + width: '100%', + boxSizing: 'border-box', + display: 'flex', + color: 'inherit', + textDecoration: 'none', + }} > - + ); }; diff --git a/workspaces/global-header/plugins/global-header/src/components/SupportButton/SupportButton.tsx b/workspaces/global-header/plugins/global-header/src/components/SupportButton/SupportButton.tsx index 0fa42684ccf..f7ea8c67c9b 100644 --- a/workspaces/global-header/plugins/global-header/src/components/SupportButton/SupportButton.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/SupportButton/SupportButton.tsx @@ -61,7 +61,15 @@ export const SupportButton = ({ diff --git a/workspaces/global-header/plugins/global-header/src/componentsExport.ts b/workspaces/global-header/plugins/global-header/src/componentsExport.ts new file mode 100644 index 00000000000..ddd16ce2b18 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/componentsExport.ts @@ -0,0 +1,59 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Building-block React components for custom global-header UI. + * + * Import from `@red-hat-developer-hub/backstage-plugin-global-header/components` + * (not the package root) so MUI-heavy UI stays off the root NFS sync chunk. + * Prefer dynamic `import()` inside blueprint loaders. + * + * **Package export vs Module Federation:** `package.json` `exports["./components"]` + * is the TypeScript/npm subpath. In RHDH dynamic-plugin deployment each plugin is + * a separate federated remote; when another remote (e.g. quickstart) does + * `import('…/global-header/components')`, that subpath must also be a federated + * expose on the global-header remote. The empty default export below satisfies + * Backstage CLI's requirement that federated entry points export a recognized + * feature type (`FrontendModule`). + * + * This is not a Scalprum/OFS surface — see `package.json` `scalprum.exposedModules`. + * + * @public + * @packageDocumentation + */ + +import { createFrontendModule } from '@backstage/frontend-plugin-api'; + +import './configureMuiClassName'; + +export { HeaderIconButton as GlobalHeaderIconButton } from './components/HeaderIconButton/HeaderIconButton'; +export type { HeaderIconButtonProps } from './components/HeaderIconButton/HeaderIconButton'; + +export { GlobalHeaderMenuItem } from './components/GlobalHeaderMenuItem'; +export type { GlobalHeaderMenuItemProps } from './components/GlobalHeaderMenuItem'; + +export { GlobalHeaderDropdown } from './components/GlobalHeaderDropdown'; +export type { GlobalHeaderDropdownProps } from './components/GlobalHeaderDropdown'; + +/** + * Empty module so `./components` is published as a Module Federation expose. + * + * @public + */ +export default createFrontendModule({ + pluginId: 'global-header', + extensions: [], +}); diff --git a/workspaces/global-header/plugins/global-header/src/configureMuiClassName.ts b/workspaces/global-header/plugins/global-header/src/configureMuiClassName.ts new file mode 100644 index 00000000000..4d819293043 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/configureMuiClassName.ts @@ -0,0 +1,26 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/material/className'; + +/** + * Prefix MUI class names so global-header styles do not clash with the host. + * Imported only from lazy UI entry points (`GlobalHeader`, `/components`, + * `/legacy`) so MUI stays off the root NFS sync chunk. + */ +ClassNameGenerator.configure(componentName => + componentName.startsWith('v5-') ? componentName : `v5-${componentName}`, +); diff --git a/workspaces/global-header/plugins/global-header/src/defaults/menuItemExtensions.tsx b/workspaces/global-header/plugins/global-header/src/defaults/menuItemExtensions.tsx index cc7f12602d0..770e3edd35e 100644 --- a/workspaces/global-header/plugins/global-header/src/defaults/menuItemExtensions.tsx +++ b/workspaces/global-header/plugins/global-header/src/defaults/menuItemExtensions.tsx @@ -17,8 +17,8 @@ /** * Default menu item extensions (`gh-menu-item`) for the global header dropdowns. * - * - Items with only `component` (no data fields) are rendered directly by - * the dropdown — they control their own layout and `MenuItem` wrapping. + * - Items with only `loader`/`component` (no data fields) are rendered directly + * by the dropdown — they control their own layout and `MenuItem` wrapping. * - Items with data fields (`title`, `link`, etc.) are grouped by `sectionLabel` * and rendered through `MenuSection`. * @@ -27,10 +27,6 @@ import { GlobalHeaderMenuItemBlueprint } from '../extensions/blueprints'; -import { LogoutButton } from '../components/LogoutButton/LogoutButton'; -import { SupportButton } from '../components/SupportButton/SupportButton'; -import { MyProfileMenuItem } from '../components/MyProfileMenuItem'; - // --------------------------------------------------------------------------- // Profile dropdown items // --------------------------------------------------------------------------- @@ -43,7 +39,7 @@ export const settingsMenuItemExtension = GlobalHeaderMenuItemBlueprint.make({ title: 'Settings', titleKey: 'profile.settings', link: '/settings', - icon: 'manage_accounts', + icon: 'manageAccounts', priority: 100, }, }); @@ -53,8 +49,9 @@ export const myProfileMenuItemExtension = GlobalHeaderMenuItemBlueprint.make({ name: 'my-profile', params: { target: 'profile', - component: MyProfileMenuItem, priority: 90, + loader: () => + import('../components/MyProfileMenuItem').then(m => m.MyProfileMenuItem), }, }); @@ -63,8 +60,11 @@ export const logoutMenuItemExtension = GlobalHeaderMenuItemBlueprint.make({ name: 'logout', params: { target: 'profile', - component: LogoutButton, priority: 10, + loader: () => + import('../components/LogoutButton/LogoutButton').then( + m => m.LogoutButton, + ), }, }); @@ -78,8 +78,11 @@ export const supportButtonMenuItemExtension = name: 'support-button', params: { target: 'help', - component: SupportButton, priority: 10, + loader: () => + import('../components/SupportButton/SupportButton').then( + m => m.SupportButton, + ), }, }); @@ -96,7 +99,7 @@ export const appLauncherDevHubMenuItemExtension = title: 'Developer Hub', titleKey: 'applicationLauncher.developerHub', link: 'https://docs.redhat.com/en/documentation/red_hat_developer_hub', - icon: 'hub', + icon: 'developerHub', sectionLabel: 'applicationLauncher.sections.documentation', priority: 150, }, @@ -111,7 +114,7 @@ export const appLauncherRhdhLocalMenuItemExtension = title: 'RHDH Local', titleKey: 'applicationLauncher.rhdhLocal', link: 'https://github.com/redhat-developer/rhdh-local', - icon: 'hub', + icon: 'developerHub', sectionLabel: 'applicationLauncher.sections.developerTools', priority: 100, }, diff --git a/workspaces/global-header/plugins/global-header/src/defaults/toolbarExtensions.tsx b/workspaces/global-header/plugins/global-header/src/defaults/toolbarExtensions.tsx index 3f300b83808..e68a3965ebb 100644 --- a/workspaces/global-header/plugins/global-header/src/defaults/toolbarExtensions.tsx +++ b/workspaces/global-header/plugins/global-header/src/defaults/toolbarExtensions.tsx @@ -17,61 +17,61 @@ /** * Default toolbar component extensions (`gh-component`) for the global header. * + * Heavy UI uses blueprint `loader` (ExtensionBoundary.lazyComponent). + * Data-driven items (self-service) omit loader and let the blueprint lazy-load + * HeaderIconButton. + * * @internal */ import { GlobalHeaderComponentBlueprint } from '../extensions/blueprints'; -import { SearchComponent } from '../components/SearchComponent/SearchComponent'; -import { Spacer } from '../components/Spacer/Spacer'; -import { StarredDropdown } from '../components/HeaderDropdownComponent/StarredDropdown'; -import { NotificationButton } from '../components/NotificationButton/NotificationButton'; -import { Divider } from '../components/Divider/Divider'; -import { CompanyLogo } from '../components/CompanyLogo/CompanyLogo'; -import { HeaderIconButton } from '../components/HeaderIconButton/HeaderIconButton'; -import { ProfileDropdown } from '../components/ProfileDropdown'; -import { HelpDropdown } from '../components/HelpDropdown'; -import { ApplicationLauncherDropdown } from '../components/ApplicationLauncherDropdown'; -import { rhdhLogo } from './rhdhLogo'; - -const CompanyLogoWrapper = () => ; - -const SelfServiceButton = () => ( - -); - /** @public */ export const companyLogoExtension = GlobalHeaderComponentBlueprint.make({ name: 'company-logo', - params: { component: CompanyLogoWrapper, priority: 200 }, + params: { + priority: 200, + loader: async () => { + const [{ CompanyLogo }, { rhdhLogo }] = await Promise.all([ + import('../components/CompanyLogo/CompanyLogo'), + import('./rhdhLogo'), + ]); + return () => ; + }, + }, }); /** @public */ export const searchExtension = GlobalHeaderComponentBlueprint.make({ name: 'search', params: { - component: SearchComponent, priority: 100, layout: { flexGrow: 1 }, + loader: () => + import('../components/SearchComponent/SearchComponent').then( + m => m.SearchComponent, + ), }, }); /** @public */ export const spacerExtension = GlobalHeaderComponentBlueprint.make({ name: 'spacer', - params: { component: Spacer, priority: 99, layout: { flexGrow: 0 } }, + params: { + priority: 99, + layout: { flexGrow: 0 }, + loader: () => import('../components/Spacer/Spacer').then(m => m.Spacer), + }, }); /** @public */ export const selfServiceButtonExtension = GlobalHeaderComponentBlueprint.make({ name: 'self-service-button', params: { - component: SelfServiceButton, + title: 'Self-service', + titleKey: 'create.title', + icon: 'add', + link: '/create', priority: 90, }, }); @@ -79,36 +79,65 @@ export const selfServiceButtonExtension = GlobalHeaderComponentBlueprint.make({ /** @public */ export const starredDropdownExtension = GlobalHeaderComponentBlueprint.make({ name: 'starred-dropdown', - params: { component: StarredDropdown, priority: 85 }, + params: { + priority: 85, + loader: () => + import('../components/HeaderDropdownComponent/StarredDropdown').then( + m => m.StarredDropdown, + ), + }, }); /** @public */ export const applicationLauncherDropdownExtension = GlobalHeaderComponentBlueprint.make({ name: 'app-launcher-dropdown', - params: { component: ApplicationLauncherDropdown, priority: 82 }, + params: { + priority: 82, + loader: () => + import('../components/ApplicationLauncherDropdown').then( + m => m.ApplicationLauncherDropdown, + ), + }, }); /** @public */ export const helpDropdownExtension = GlobalHeaderComponentBlueprint.make({ name: 'help-dropdown', - params: { component: HelpDropdown, priority: 80 }, + params: { + priority: 80, + loader: () => + import('../components/HelpDropdown').then(m => m.HelpDropdown), + }, }); /** @public */ export const notificationButtonExtension = GlobalHeaderComponentBlueprint.make({ name: 'notification-button', - params: { component: NotificationButton, priority: 70 }, + params: { + priority: 70, + loader: () => + import('../components/NotificationButton/NotificationButton').then( + m => m.NotificationButton, + ), + }, }); /** @public */ export const dividerExtension = GlobalHeaderComponentBlueprint.make({ name: 'divider', - params: { component: Divider, priority: 50 }, + params: { + priority: 50, + loader: () => import('../components/Divider/Divider').then(m => m.Divider), + }, }); /** @public */ export const profileDropdownExtension = GlobalHeaderComponentBlueprint.make({ name: 'profile-dropdown', - params: { component: ProfileDropdown, priority: 10 }, + params: { + priority: 10, + loader: () => + import('../components/ProfileDropdown').then(m => m.ProfileDropdown), + }, }); diff --git a/workspaces/global-header/plugins/global-header/src/extensions/blueprints.tsx b/workspaces/global-header/plugins/global-header/src/extensions/blueprints.tsx index 3657e987147..9c3d604428a 100644 --- a/workspaces/global-header/plugins/global-header/src/extensions/blueprints.tsx +++ b/workspaces/global-header/plugins/global-header/src/extensions/blueprints.tsx @@ -15,15 +15,12 @@ */ import type { ComponentType } from 'react'; -import { createExtensionBlueprint } from '@backstage/frontend-plugin-api'; - -import IconButton from '@mui/material/IconButton'; -import Tooltip from '@mui/material/Tooltip'; - -import { HeaderIconButton } from '../components/HeaderIconButton/HeaderIconButton'; -import { HeaderIcon } from '../components/HeaderIcon/HeaderIcon'; -import { useTranslation } from '../hooks/useTranslation'; -import { translateWithFallback } from '../utils/translationUtils'; +import { z } from 'zod'; +import { + createExtensionBlueprint, + ExtensionBoundary, + type AppNode, +} from '@backstage/frontend-plugin-api'; import { globalHeaderComponentDataRef, @@ -33,9 +30,10 @@ import { /** * Params accepted by {@link GlobalHeaderComponentBlueprint}. * - * Supply `component` for full control (tier 2/3), or provide data fields - * (`icon`, `title`, `link`/`onClick`) and let the framework render a - * consistent `HeaderIconButton` automatically (tier 1). + * Prefer {@link ToolbarComponentParams.loader} so the implementation is loaded + * asynchronously (same pattern as `HomePageWidgetBlueprint` / + * `HomePageLayoutBlueprint`). Supply data fields (`icon`, `title`, `link` / + * `onClick`) with no loader/component for the built-in HeaderIconButton tier. * * @public */ @@ -46,6 +44,14 @@ export interface ToolbarComponentParams { tooltip?: string; link?: string; onClick?: () => void; + /** + * Async component loader. Prefer this over {@link ToolbarComponentParams.component} + * so the module graph stays off the NFS federation sync chunk. + */ + loader?: () => Promise>; + /** + * Sync component. Kept for compatibility; prefer {@link ToolbarComponentParams.loader}. + */ component?: ComponentType; priority?: number; /** MUI `sx`-compatible layout overrides applied by the header wrapper. */ @@ -55,17 +61,14 @@ export interface ToolbarComponentParams { /** * Params accepted by {@link GlobalHeaderMenuItemBlueprint}. * - * Supply `component` for full control, or provide data fields - * (`title`, `icon`, `link`) and let the framework render a - * consistent `MenuItemLink` automatically. + * Prefer {@link MenuItemParams.loader} for custom menu item UI. * - * Items with a `component` but **no** data fields (`title`, `link`, etc.) + * Items with a loader/component but **no** data fields (`title`, `link`, etc.) * are rendered directly by the dropdown — the component controls its own - * layout and `MenuItem` wrapping (e.g. `SoftwareTemplatesSection`, - * `LogoutButton`). + * layout and `MenuItem` wrapping (e.g. `LogoutButton`). * - * Items with data fields (with or without a custom component) are grouped - * by `sectionLabel` and rendered inside `MenuSection`. + * Items with data fields are grouped by `sectionLabel` and rendered inside + * `MenuSection`. * * @public */ @@ -78,6 +81,13 @@ export interface MenuItemParams { icon?: string; link?: string; onClick?: () => void; + /** + * Async component loader. Prefer this over {@link MenuItemParams.component}. + */ + loader?: () => Promise>; + /** + * Sync component. Kept for compatibility; prefer {@link MenuItemParams.loader}. + */ component?: ComponentType; priority?: number; /** Section label used as the grouping key and the displayed section header. */ @@ -92,43 +102,114 @@ export interface MenuItemParams { // Data-driven component factories // --------------------------------------------------------------------------- -function createDataDrivenToolbarComponent( +function resolveLazyComponent( + node: AppNode, + loader: () => Promise>, +): ComponentType { + // ExtensionBoundary.lazyComponent wraps each resolved component in its own + // ExtensionBoundary + Suspense (Progress fallback) — suspending items do not + // bubble to the app-root Suspense around LazyGlobalHeader. + return ExtensionBoundary.lazyComponent(node, async () => { + const Comp = await loader(); + return (props: any) => ; + }); +} + +function resolveSyncComponent( + node: AppNode, + Comp: ComponentType, +): ComponentType { + return (props: any) => ( + + + + ); +} + +/** + * Data-driven toolbar UI is loaded asynchronously so HeaderIconButton / MUI + * stay off the blueprint module's sync graph. + */ +function createDataDrivenToolbarLoader( params: ToolbarComponentParams, +): () => Promise> { + return async () => { + if (params.link) { + const { HeaderIconButton } = await import( + '../components/HeaderIconButton/HeaderIconButton' + ); + return () => ( + + ); + } + + const [ + { default: IconButton }, + { default: Tooltip }, + { HeaderIcon }, + { useTranslation }, + { translateWithFallback }, + ] = await Promise.all([ + import('@mui/material/IconButton'), + import('@mui/material/Tooltip'), + import('../components/HeaderIcon/HeaderIcon'), + import('../hooks/useTranslation'), + import('../utils/translationUtils'), + ]); + + return () => { + const { t } = useTranslation(); + const displayTitle = translateWithFallback( + t, + params.titleKey, + params.title, + ); + return ( + + + {params.icon && } + + + ); + }; + }; +} + +function resolveToolbarComponent( + params: ToolbarComponentParams, + node: AppNode, ): ComponentType { - if (params.link) { - const LinkButton = () => ( - - ); - return LinkButton; + if (params.loader) { + return resolveLazyComponent(node, params.loader); + } + if (params.component) { + return resolveSyncComponent(node, params.component); } + return resolveLazyComponent(node, createDataDrivenToolbarLoader(params)); +} - const ActionButton = () => { - const { t } = useTranslation(); - const displayTitle = translateWithFallback( - t, - params.titleKey, - params.title, - ); - return ( - - - {params.icon && } - - - ); - }; - return ActionButton; +function resolveMenuItemComponent( + params: MenuItemParams, + node: AppNode, +): ComponentType | undefined { + if (params.loader) { + return resolveLazyComponent(node, params.loader); + } + if (params.component) { + return resolveSyncComponent(node, params.component); + } + return undefined; } // --------------------------------------------------------------------------- @@ -141,10 +222,10 @@ function createDataDrivenToolbarComponent( * Supports three tiers: * * 1. **Data-driven** -- provide `icon`, `title`, `link` (or `onClick`) and the - * framework renders a consistent `HeaderIconButton` automatically. - * 2. **Building blocks** -- provide a `component` that uses the exported - * `GlobalHeaderIconButton` / `GlobalHeaderDropdown` for consistent styling. - * 3. **Fully custom** -- provide any arbitrary React component. + * framework lazy-loads a consistent `HeaderIconButton`. + * 2. **Loader** -- provide `loader: () => import(...).then(m => m.Comp)` + * (preferred for custom UI; mirrors `HomePageLayoutBlueprint`). + * 3. **Sync component** -- provide `component` (compatibility only). * * The `priority` can be overridden by deployers via `app-config.yaml`: * @@ -166,17 +247,12 @@ export const GlobalHeaderComponentBlueprint = createExtensionBlueprint({ }, output: [globalHeaderComponentDataRef], dataRefs: { componentData: globalHeaderComponentDataRef }, - config: { - schema: { - priority: z => z.number().optional(), - }, + configSchema: { + priority: z.number().optional(), }, - *factory(params: ToolbarComponentParams, { config }) { - const component = - params.component ?? createDataDrivenToolbarComponent(params); - + *factory(params: ToolbarComponentParams, { config, node }) { yield globalHeaderComponentDataRef({ - component, + component: resolveToolbarComponent(params, node), priority: config.priority ?? params.priority, layout: params.layout, }); @@ -189,6 +265,7 @@ export const GlobalHeaderComponentBlueprint = createExtensionBlueprint({ * The `target` field routes the item to the correct dropdown (e.g. `'create'`, * `'profile'`, `'help'`, `'app-launcher'`, or any custom target). * + * Prefer `loader` for custom components so their modules stay async. * **Custom components** (only `component`, no data fields) are rendered * directly by the dropdown — they control their own layout and wrapping. * @@ -216,29 +293,28 @@ export const GlobalHeaderMenuItemBlueprint = createExtensionBlueprint({ }, output: [globalHeaderMenuItemDataRef], dataRefs: { menuItemData: globalHeaderMenuItemDataRef }, - config: { - schema: { - priority: z => z.number().optional(), - title: z => z.string().optional(), - titleKey: z => z.string().optional(), - icon: z => z.string().optional(), - link: z => z.string().optional(), - sectionLabel: z => z.string().optional(), - sectionLink: z => z.string().optional(), - sectionLinkLabel: z => z.string().optional(), - }, + configSchema: { + priority: z.number().optional(), + title: z.string().optional(), + titleKey: z.string().optional(), + icon: z.string().optional(), + link: z.string().optional(), + sectionLabel: z.string().optional(), + sectionLink: z.string().optional(), + sectionLinkLabel: z.string().optional(), }, - *factory(params: MenuItemParams, { config }) { + *factory(params: MenuItemParams, { config, node }) { const title = config.title ?? params.title; const titleKey = config.titleKey ?? (config.title ? undefined : params.titleKey); const link = config.link ?? params.link; + const component = resolveMenuItemComponent(params, node); const hasDataFields = !!(title || titleKey || link); yield globalHeaderMenuItemDataRef({ target: params.target, - component: params.component, - type: params.component && !hasDataFields ? 'component' : 'data', + component, + type: component && !hasDataFields ? 'component' : 'data', title, titleKey, icon: config.icon ?? params.icon, diff --git a/workspaces/global-header/plugins/global-header/src/extensions/globalHeaderModule.tsx b/workspaces/global-header/plugins/global-header/src/extensions/globalHeaderModule.tsx index a4938a83799..95760617594 100644 --- a/workspaces/global-header/plugins/global-header/src/extensions/globalHeaderModule.tsx +++ b/workspaces/global-header/plugins/global-header/src/extensions/globalHeaderModule.tsx @@ -14,18 +14,23 @@ * limitations under the License. */ -import { useMemo } from 'react'; +import { lazy, Suspense, useMemo } from 'react'; import type { PropsWithChildren } from 'react'; import { createExtensionInput, createFrontendModule, } from '@backstage/frontend-plugin-api'; -import { AppRootWrapperBlueprint } from '@backstage/plugin-app-react'; +import { + AppRootWrapperBlueprint, + IconBundleBlueprint, +} from '@backstage/plugin-app-react'; +import { ErrorBoundary } from '@backstage/core-components'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { globalHeaderSystemIcons } from '../icons/globalHeaderSystemIcons'; + import { GlobalHeaderProvider } from './GlobalHeaderContext'; -import { GlobalHeader } from '../components/GlobalHeader'; import { globalHeaderComponentDataRef, globalHeaderMenuItemDataRef, @@ -37,6 +42,12 @@ import type { import { readConfigMenuItems } from '../utils/readConfigMenuItems'; import { readConfigComponents } from '../utils/readConfigComponents'; +// AppRootWrapperBlueprint has no loader — lazy the AppBar shell so MUI stays +// off the root federation sync chunk (same idea as PageBlueprint loaders). +const LazyGlobalHeader = lazy(() => + import('../components/GlobalHeader').then(m => ({ default: m.GlobalHeader })), +); + function GlobalHeaderWrapper({ extensionComponents, extensionMenuItems, @@ -70,7 +81,11 @@ function GlobalHeaderWrapper({ ); return ( - + + + + + {children} ); @@ -110,6 +125,13 @@ const globalHeaderExtension = AppRootWrapperBlueprint.makeWithOverrides({ }, }); +const globalHeaderIconsExtension = IconBundleBlueprint.make({ + name: 'global-header-icons', + params: { + icons: globalHeaderSystemIcons, + }, +}); + /** * Frontend module that provides the global header system. * Registers a wrapper extension with input slots for toolbar components @@ -123,5 +145,5 @@ const globalHeaderExtension = AppRootWrapperBlueprint.makeWithOverrides({ */ export const globalHeaderModule = createFrontendModule({ pluginId: 'app', - extensions: [globalHeaderExtension], + extensions: [globalHeaderIconsExtension, globalHeaderExtension], }); diff --git a/workspaces/global-header/plugins/global-header/src/icons/globalHeaderSystemIcons.ts b/workspaces/global-header/plugins/global-header/src/icons/globalHeaderSystemIcons.ts new file mode 100644 index 00000000000..3916bd9b0aa --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/icons/globalHeaderSystemIcons.ts @@ -0,0 +1,48 @@ +/* + * Copyright Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AccountCircleOutlined from '@mui/icons-material/AccountCircleOutlined'; +import AddCircleOutline from '@mui/icons-material/AddCircleOutline'; +import ArticleOutlined from '@mui/icons-material/ArticleOutlined'; +import BugReportOutlined from '@mui/icons-material/BugReportOutlined'; +import DashboardOutlined from '@mui/icons-material/DashboardOutlined'; +import ForumOutlined from '@mui/icons-material/ForumOutlined'; +import HubOutlined from '@mui/icons-material/HubOutlined'; +import LogoutOutlined from '@mui/icons-material/LogoutOutlined'; +import ManageAccountsOutlined from '@mui/icons-material/ManageAccountsOutlined'; +import QuizOutlined from '@mui/icons-material/QuizOutlined'; +import SupportAgentOutlined from '@mui/icons-material/SupportAgentOutlined'; + +/** + * System icons registered for global-header via `IconBundleBlueprint`. + * Host registrations override via `app.getSystemIcon`. + * + * Includes default extension ids and common `globalHeader` config icon ids, + * using outlined `@mui/icons-material` components. + */ +export const globalHeaderSystemIcons = { + account: AccountCircleOutlined, + add: AddCircleOutline, + article: ArticleOutlined, + bugReport: BugReportOutlined, + dashboard: DashboardOutlined, + developerHub: HubOutlined, + forum: ForumOutlined, + logout: LogoutOutlined, + manageAccounts: ManageAccountsOutlined, + quiz: QuizOutlined, + support: SupportAgentOutlined, +} as const; diff --git a/workspaces/global-header/plugins/global-header/src/index.ts b/workspaces/global-header/plugins/global-header/src/index.ts index adeb98f314f..93a1473a895 100644 --- a/workspaces/global-header/plugins/global-header/src/index.ts +++ b/workspaces/global-header/plugins/global-header/src/index.ts @@ -17,18 +17,14 @@ /** * New Frontend System API surface for the global header plugin. * + * Building-block UI (`GlobalHeaderIconButton`, `GlobalHeaderMenuItem`, + * `GlobalHeaderDropdown`) is exported only from `/components` so it stays off + * this root Module Federation sync chunk. + * * @public * @packageDocumentation */ -import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/material/className'; - -ClassNameGenerator.configure(componentName => { - return componentName.startsWith('v5-') - ? componentName - : `v5-${componentName}`; -}); - import { createFrontendModule } from '@backstage/frontend-plugin-api'; import { TranslationBlueprint } from '@backstage/plugin-app-react'; import { globalHeaderTranslations } from './translations'; @@ -70,15 +66,6 @@ export type { GlobalHeaderMenuItemData, } from './types'; -// ── Building block components for plugin authors ─────────────────────── - -export { HeaderIconButton as GlobalHeaderIconButton } from './components/HeaderIconButton/HeaderIconButton'; -export type { HeaderIconButtonProps } from './components/HeaderIconButton/HeaderIconButton'; -export { GlobalHeaderMenuItem } from './components/GlobalHeaderMenuItem'; -export type { GlobalHeaderMenuItemProps } from './components/GlobalHeaderMenuItem'; -export { GlobalHeaderDropdown } from './components/GlobalHeaderDropdown'; -export type { GlobalHeaderDropdownProps } from './components/GlobalHeaderDropdown'; - // ── Default extensions (collections + individual for cherry-picking) ─── export * from './defaults'; diff --git a/workspaces/global-header/plugins/global-header/src/legacy.ts b/workspaces/global-header/plugins/global-header/src/legacy.ts index 9ef3c0d01c1..305a048c73b 100644 --- a/workspaces/global-header/plugins/global-header/src/legacy.ts +++ b/workspaces/global-header/plugins/global-header/src/legacy.ts @@ -21,13 +21,7 @@ * @packageDocumentation */ -import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/material/className'; - -ClassNameGenerator.configure(componentName => { - return componentName.startsWith('v5-') - ? componentName - : `v5-${componentName}`; -}); +import './configureMuiClassName'; export * from './legacy/plugin'; diff --git a/workspaces/global-header/plugins/global-header/src/legacy/components/HeaderDropdownComponent/ProfileDropdown.test.tsx b/workspaces/global-header/plugins/global-header/src/legacy/components/HeaderDropdownComponent/ProfileDropdown.test.tsx index c9011d6bab8..f98608bb909 100644 --- a/workspaces/global-header/plugins/global-header/src/legacy/components/HeaderDropdownComponent/ProfileDropdown.test.tsx +++ b/workspaces/global-header/plugins/global-header/src/legacy/components/HeaderDropdownComponent/ProfileDropdown.test.tsx @@ -154,7 +154,7 @@ describe('ProfileDropdown', () => { await renderComponent(catalogApi); - expect(screen.getByText(/Test/i)).toBeInTheDocument(); + expect(screen.getByRole('button')).toHaveTextContent('Test'); }); it('should render the My Profile menu item with the correct dynamic link', async () => { diff --git a/workspaces/global-header/plugins/global-header/src/legacy/defaultMountPoints/defaultMountPoints.tsx b/workspaces/global-header/plugins/global-header/src/legacy/defaultMountPoints/defaultMountPoints.tsx index fe55c86e372..c1bc4e50102 100644 --- a/workspaces/global-header/plugins/global-header/src/legacy/defaultMountPoints/defaultMountPoints.tsx +++ b/workspaces/global-header/plugins/global-header/src/legacy/defaultMountPoints/defaultMountPoints.tsx @@ -80,7 +80,7 @@ export const defaultGlobalHeaderComponentsMountPoints: GlobalHeaderComponentMoun props: { title: 'Self-service', titleKey: 'create.title', - icon: 'addCircleOutline', + icon: 'add', to: '/create', }, }, diff --git a/workspaces/global-header/plugins/global-header/src/nfsExports.test.ts b/workspaces/global-header/plugins/global-header/src/nfsExports.test.ts index 188e5a6afe3..0305d15de23 100644 --- a/workspaces/global-header/plugins/global-header/src/nfsExports.test.ts +++ b/workspaces/global-header/plugins/global-header/src/nfsExports.test.ts @@ -15,6 +15,7 @@ */ import { globalHeaderTranslationsModule } from './index'; import translationsModuleDefault from './globalHeaderTranslationsModuleExport'; +import componentsModuleDefault from './componentsExport'; describe('global-header NFS exports', () => { it('should export a translations module as a FrontendModule', () => { @@ -27,4 +28,10 @@ describe('global-header NFS exports', () => { it('should export the translations module as default for NFS discovery', () => { expect(translationsModuleDefault).toBe(globalHeaderTranslationsModule); }); + + it('should export an empty components module as a FrontendModule for MF', () => { + expect(componentsModuleDefault).toBeDefined(); + expect(componentsModuleDefault.$$type).toBe('@backstage/FrontendModule'); + expect(componentsModuleDefault.pluginId).toBe('global-header'); + }); }); diff --git a/workspaces/global-header/plugins/global-header/src/utils/readConfigComponents.tsx b/workspaces/global-header/plugins/global-header/src/utils/readConfigComponents.tsx index 61c6476e0c8..a63a33d8cd1 100644 --- a/workspaces/global-header/plugins/global-header/src/utils/readConfigComponents.tsx +++ b/workspaces/global-header/plugins/global-header/src/utils/readConfigComponents.tsx @@ -14,18 +14,26 @@ * limitations under the License. */ +import { lazy, Suspense } from 'react'; + import type { Config } from '@backstage/config'; -import { HeaderIconButton } from '../components/HeaderIconButton/HeaderIconButton'; import type { GlobalHeaderComponentData } from '../types'; +const LazyHeaderIconButton = lazy(() => + import('../components/HeaderIconButton/HeaderIconButton').then(m => ({ + default: m.HeaderIconButton, + })), +); + /** * Reads `globalHeader.components` from the app config and maps * each entry into a {@link GlobalHeaderComponentData}. * * Config-driven components are always rendered as a `HeaderIconButton` * (icon + link), matching the data-driven tier of - * `GlobalHeaderComponentBlueprint`. + * `GlobalHeaderComponentBlueprint`. The button is lazy-loaded so it stays + * off the root federation sync chunk when loaded via blueprint loaders. */ export function readConfigComponents( configApi: Config, @@ -42,13 +50,15 @@ export function readConfigComponents( const priority = item.getOptionalNumber('priority'); const ConfigComponent = () => ( - + + + ); return { component: ConfigComponent, priority }; diff --git a/workspaces/global-header/plugins/global-header/src/utils/readConfigMenuItems.test.ts b/workspaces/global-header/plugins/global-header/src/utils/readConfigMenuItems.test.ts index 38c23cc7ace..00c29905644 100644 --- a/workspaces/global-header/plugins/global-header/src/utils/readConfigMenuItems.test.ts +++ b/workspaces/global-header/plugins/global-header/src/utils/readConfigMenuItems.test.ts @@ -30,7 +30,7 @@ describe('readConfigMenuItems', () => { { target: 'help', title: 'Documentation', - icon: 'docs', + icon: 'article', link: 'https://docs.example.com', sectionLabel: 'Resources', sectionLink: '/resources', @@ -48,7 +48,7 @@ describe('readConfigMenuItems', () => { type: 'data', title: 'Documentation', titleKey: undefined, - icon: 'docs', + icon: 'article', link: 'https://docs.example.com', sectionLabel: 'Resources', sectionLink: '/resources', diff --git a/workspaces/global-header/yarn.lock b/workspaces/global-header/yarn.lock index 583332eba2d..3d0ce22e787 100644 --- a/workspaces/global-header/yarn.lock +++ b/workspaces/global-header/yarn.lock @@ -9298,6 +9298,7 @@ __metadata: react: "npm:^16.13.1 || ^17.0.0 || ^18.0.0" react-router-dom: "npm:^6.0.0" react-use: "npm:^17.5.0" + zod: "npm:^4.4.3" peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 @@ -33767,7 +33768,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.25.76 || ^4.0.0, zod@npm:^4.0.0": +"zod@npm:^3.25.76 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.4.3": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3