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..822442a2f02 --- /dev/null +++ b/workspaces/global-header/.changeset/components-entry-lazy-appbar.md @@ -0,0 +1,7 @@ +--- +'@red-hat-developer-hub/backstage-plugin-global-header': minor +--- + +**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`. Prefer dynamic `import()` inside blueprint loaders. + +Building-block UI lives solely on the `/components` Module Federation expose (`src/componentsExport.ts`) so MUI stays off the root NFS sync chunk. MUI `ClassNameGenerator` setup is also moved off the root sync path: it runs from `configureMuiClassName.ts` when the lazy AppBar / `/components` / `/legacy` UI loads, not from the root entry. 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..a6a59299bba 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,26 +174,38 @@ 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. ```typescript -import { - GlobalHeaderMenuItemBlueprint, - GlobalHeaderMenuItem, -} from '@red-hat-developer-hub/backstage-plugin-global-header/alpha'; - -const MyDocsLink = ({ handleClose }: { handleClose?: () => void }) => ( - -); +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: async () => { + const { GlobalHeaderMenuItem } = await import( + '@red-hat-developer-hub/backstage-plugin-global-header/components' + ); + return function MyDocsLink({ + handleClose, + }: { + handleClose?: () => void; + }) { + return ( + + ); + }; + }, + }, }); ``` @@ -224,7 +250,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', @@ -346,7 +372,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 +381,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 +400,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..f713e5aca30 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 main `/alpha` NFS 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..98a82e8218f 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" ] @@ -123,6 +127,7 @@ "PluginRoot": "./src/index.ts", "Alpha": "./src/alpha/index.ts", "Legacy": "./src/legacy.ts", + "Components": "./src/componentsExport.ts", "GlobalHeaderModule": "./src/globalHeaderModuleExport.ts", "GlobalHeaderTranslationsModule": "./src/globalHeaderTranslationsModuleExport.ts" } 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..b88fb6ebdd7 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'; @@ -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'; @@ -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; @@ -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'; @@ -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) @@ -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/ApplicationLauncherDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx index 6461234af52..aeea038e319 100644 --- a/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx @@ -14,11 +14,11 @@ * limitations under the License. */ -import AppsIcon from '@mui/icons-material/Apps'; -import AppRegistrationIcon from '@mui/icons-material/AppRegistration'; +import Box from '@mui/material/Box'; +import { HeaderIcon } from '../components/HeaderIcon/HeaderIcon'; import { useTranslation } from '../hooks/useTranslation'; -import { DropdownEmptyState } from './HeaderDropdownComponent/DropdownEmptyState'; +import { DropdownEmptyState } from '../components/HeaderDropdownComponent/DropdownEmptyState'; import { GlobalHeaderDropdown } from './GlobalHeaderDropdown'; /** @@ -34,15 +34,15 @@ export const ApplicationLauncherDropdown = () => { target="app-launcher" isIconButton tooltip={t('applicationLauncher.tooltip')} - buttonContent={} + buttonContent={} emptyState={ + + + } /> } 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..ede62cce266 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx @@ -0,0 +1,354 @@ +/* + * 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('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..bbdaa4a5288 100644 --- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.tsx @@ -14,7 +14,7 @@ * 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'; @@ -24,6 +24,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 +51,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 +80,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'); + } + }; - const showEmpty = entries.length === 0 || (trackValidity && !hasVisibleItems); + // 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 showEmptyState = + hasNoContributions || (trackValidity && menuValidity === 'empty'); return ( - {showEmpty ? ( + {hasNoContributions ? ( emptyState ) : ( - + <> + {/* + * Keep the menu content mounted while showing the empty state so + * lazy ExtensionBoundary items can still render and recover. + * This prevents permanently latching into the empty state if a + * menu item appears after the initial validity check. + */} + + + {trackValidity && showEmptyState ? emptyState : null} + )} ); 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..c2d4ee3e8e5 100644 --- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderMenuItem.tsx @@ -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/HelpDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx index 9c9f9e42e00..18a8c1fec6e 100644 --- a/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx @@ -14,9 +14,9 @@ * limitations under the License. */ -import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; -import SupportAgentIcon from '@mui/icons-material/SupportAgent'; +import Box from '@mui/material/Box'; +import { HeaderIcon } from './HeaderIcon/HeaderIcon'; import { useTranslation } from '../hooks/useTranslation'; import { DropdownEmptyState } from './HeaderDropdownComponent/DropdownEmptyState'; import { GlobalHeaderDropdown } from './GlobalHeaderDropdown'; @@ -34,14 +34,16 @@ export const HelpDropdown = () => { trackValidity isIconButton tooltip={t('help.tooltip')} - buttonContent={} + buttonContent={} buttonProps={{ color: 'inherit' }} emptyState={ + + + } /> } 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..e314029cb67 100644 --- a/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/MyProfileMenuItem.tsx @@ -71,7 +71,15 @@ 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', + }} > { alt={t('profile.picture')} /> ) : ( - + + + )} { )} - + + + ); }; 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..e015ce46ea7 --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/componentsExport.ts @@ -0,0 +1,51 @@ +/* + * 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` + * so these stay off the root NFS sync chunk. Prefer dynamic `import()` + * inside blueprint loaders. + * + * @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 this package export is published as a Module Federation + * expose. Backstage only federates entry points whose default export is a + * recognized feature type. + * + * @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..7419e23cc94 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 // --------------------------------------------------------------------------- @@ -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, + ), }, }); 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..3ec942f1f60 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: 'addCircleOutline', + 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..3097460ee70 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,11 @@ */ 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 { + createExtensionBlueprint, + ExtensionBoundary, + type AppNode, +} from '@backstage/frontend-plugin-api'; import { globalHeaderComponentDataRef, @@ -33,9 +29,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 +43,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 +60,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 +80,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 +101,111 @@ export interface MenuItemParams { // Data-driven component factories // --------------------------------------------------------------------------- -function createDataDrivenToolbarComponent( +function resolveLazyComponent( + node: AppNode, + loader: () => Promise>, +): ComponentType { + 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 +218,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`: * @@ -171,12 +248,9 @@ export const GlobalHeaderComponentBlueprint = createExtensionBlueprint({ priority: z => 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 +263,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. * @@ -228,17 +303,18 @@ export const GlobalHeaderMenuItemBlueprint = createExtensionBlueprint({ sectionLinkLabel: z => 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..f93839beccb 100644 --- a/workspaces/global-header/plugins/global-header/src/extensions/globalHeaderModule.tsx +++ b/workspaces/global-header/plugins/global-header/src/extensions/globalHeaderModule.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useMemo } from 'react'; +import { lazy, Suspense, useMemo } from 'react'; import type { PropsWithChildren } from 'react'; import { @@ -25,7 +25,6 @@ import { AppRootWrapperBlueprint } from '@backstage/plugin-app-react'; import { configApiRef, useApi } from '@backstage/core-plugin-api'; import { GlobalHeaderProvider } from './GlobalHeaderContext'; -import { GlobalHeader } from '../components/GlobalHeader'; import { globalHeaderComponentDataRef, globalHeaderMenuItemDataRef, @@ -37,6 +36,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 +75,9 @@ function GlobalHeaderWrapper({ ); return ( - + + + {children} ); 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/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 };