From 47eaafaf8554776a15b48f92c728fe28fb619e74 Mon Sep 17 00:00:00 2001 From: Debsmita Santra Date: Thu, 23 Jul 2026 22:18:38 +0530 Subject: [PATCH 1/4] chore(global-header): optimize the plugin --- .../plugins/global-header/report-alpha.api.md | 346 ++++++++++++++++++ .../ApplicationLauncherDropdown.tsx | 16 +- .../src/components/HelpDropdown.tsx | 14 +- .../src/components/ProfileDropdown.tsx | 22 +- .../src/defaults/menuItemExtensions.tsx | 21 +- .../src/defaults/toolbarExtensions.tsx | 96 +++-- .../src/extensions/blueprints.tsx | 206 +++++++---- 7 files changed, 593 insertions(+), 128 deletions(-) diff --git a/workspaces/global-header/plugins/global-header/report-alpha.api.md b/workspaces/global-header/plugins/global-header/report-alpha.api.md index 6b0b631af19..23255fe3b4a 100644 --- a/workspaces/global-header/plugins/global-header/report-alpha.api.md +++ b/workspaces/global-header/plugins/global-header/report-alpha.api.md @@ -49,4 +49,350 @@ export const globalHeaderTranslationRef: TranslationRef< // @public export const globalHeaderTranslations: TranslationResource<'plugin.global-header'>; + +// @alpha +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; +} + +// @alpha (undocumented) +export const helpDropdownExtension: OverridableExtensionDefinition<{ + kind: 'gh-component'; + name: 'help-dropdown'; + config: { + priority: number | undefined; + }; + configInput: { + priority?: number | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderComponentData, + 'global-header.component', + {} + >; + inputs: {}; + params: ToolbarComponentParams; +}>; + +// @alpha (undocumented) +export const logoutMenuItemExtension: OverridableExtensionDefinition<{ + kind: 'gh-menu-item'; + name: 'logout'; + config: { + priority: number | undefined; + title: string | undefined; + titleKey: string | undefined; + icon: string | undefined; + link: string | undefined; + sectionLabel: string | undefined; + sectionLink: string | undefined; + sectionLinkLabel: string | undefined; + }; + configInput: { + link?: string | undefined; + title?: string | undefined; + icon?: string | undefined; + titleKey?: string | undefined; + priority?: number | undefined; + sectionLabel?: string | undefined; + sectionLink?: string | undefined; + sectionLinkLabel?: string | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderMenuItemData, + 'global-header.menu-item', + {} + >; + inputs: {}; + params: MenuItemParams; +}>; + +// @alpha +export interface MenuItemParams { + component?: ComponentType; + // (undocumented) + icon?: string; + // (undocumented) + link?: string; + loader?: () => Promise>; + // (undocumented) + onClick?: () => void; + // (undocumented) + priority?: number; + sectionLabel?: string; + sectionLink?: string; + sectionLinkLabel?: string; + // (undocumented) + subTitle?: string; + // (undocumented) + subTitleKey?: string; + // (undocumented) + target: string; + // (undocumented) + title?: string; + // (undocumented) + titleKey?: string; +} + +// @alpha (undocumented) +export const myProfileMenuItemExtension: OverridableExtensionDefinition<{ + kind: 'gh-menu-item'; + name: 'my-profile'; + config: { + priority: number | undefined; + title: string | undefined; + titleKey: string | undefined; + icon: string | undefined; + link: string | undefined; + sectionLabel: string | undefined; + sectionLink: string | undefined; + sectionLinkLabel: string | undefined; + }; + configInput: { + link?: string | undefined; + title?: string | undefined; + icon?: string | undefined; + titleKey?: string | undefined; + priority?: number | undefined; + sectionLabel?: string | undefined; + sectionLink?: string | undefined; + sectionLinkLabel?: string | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderMenuItemData, + 'global-header.menu-item', + {} + >; + inputs: {}; + params: MenuItemParams; +}>; + +// @alpha (undocumented) +export const notificationButtonExtension: OverridableExtensionDefinition<{ + kind: 'gh-component'; + name: 'notification-button'; + config: { + priority: number | undefined; + }; + configInput: { + priority?: number | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderComponentData, + 'global-header.component', + {} + >; + inputs: {}; + params: ToolbarComponentParams; +}>; + +// @alpha (undocumented) +export const profileDropdownExtension: OverridableExtensionDefinition<{ + kind: 'gh-component'; + name: 'profile-dropdown'; + config: { + priority: number | undefined; + }; + configInput: { + priority?: number | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderComponentData, + 'global-header.component', + {} + >; + inputs: {}; + params: ToolbarComponentParams; +}>; + +// @alpha (undocumented) +export const searchExtension: OverridableExtensionDefinition<{ + kind: 'gh-component'; + name: 'search'; + config: { + priority: number | undefined; + }; + configInput: { + priority?: number | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderComponentData, + 'global-header.component', + {} + >; + inputs: {}; + params: ToolbarComponentParams; +}>; + +// @alpha (undocumented) +export const selfServiceButtonExtension: OverridableExtensionDefinition<{ + kind: 'gh-component'; + name: 'self-service-button'; + config: { + priority: number | undefined; + }; + configInput: { + priority?: number | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderComponentData, + 'global-header.component', + {} + >; + inputs: {}; + params: ToolbarComponentParams; +}>; + +// @alpha (undocumented) +export const settingsMenuItemExtension: OverridableExtensionDefinition<{ + kind: 'gh-menu-item'; + name: 'settings'; + config: { + priority: number | undefined; + title: string | undefined; + titleKey: string | undefined; + icon: string | undefined; + link: string | undefined; + sectionLabel: string | undefined; + sectionLink: string | undefined; + sectionLinkLabel: string | undefined; + }; + configInput: { + link?: string | undefined; + title?: string | undefined; + icon?: string | undefined; + titleKey?: string | undefined; + priority?: number | undefined; + sectionLabel?: string | undefined; + sectionLink?: string | undefined; + sectionLinkLabel?: string | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderMenuItemData, + 'global-header.menu-item', + {} + >; + inputs: {}; + params: MenuItemParams; +}>; + +// @alpha (undocumented) +export const spacerExtension: OverridableExtensionDefinition<{ + kind: 'gh-component'; + name: 'spacer'; + config: { + priority: number | undefined; + }; + configInput: { + priority?: number | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderComponentData, + 'global-header.component', + {} + >; + inputs: {}; + params: ToolbarComponentParams; +}>; + +// @alpha (undocumented) +export const starredDropdownExtension: OverridableExtensionDefinition<{ + kind: 'gh-component'; + name: 'starred-dropdown'; + config: { + priority: number | undefined; + }; + configInput: { + priority?: number | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderComponentData, + 'global-header.component', + {} + >; + inputs: {}; + params: ToolbarComponentParams; +}>; + +// @alpha (undocumented) +export const supportButtonMenuItemExtension: OverridableExtensionDefinition<{ + kind: 'gh-menu-item'; + name: 'support-button'; + config: { + priority: number | undefined; + title: string | undefined; + titleKey: string | undefined; + icon: string | undefined; + link: string | undefined; + sectionLabel: string | undefined; + sectionLink: string | undefined; + sectionLinkLabel: string | undefined; + }; + configInput: { + link?: string | undefined; + title?: string | undefined; + icon?: string | undefined; + titleKey?: string | undefined; + priority?: number | undefined; + sectionLabel?: string | undefined; + sectionLink?: string | undefined; + sectionLinkLabel?: string | undefined; + }; + output: ExtensionDataRef< + GlobalHeaderMenuItemData, + 'global-header.menu-item', + {} + >; + inputs: {}; + params: MenuItemParams; +}>; + +// @alpha +export interface ToolbarComponentParams { + component?: ComponentType; + // (undocumented) + icon?: string; + layout?: Record; + // (undocumented) + link?: string; + loader?: () => Promise>; + // (undocumented) + onClick?: () => void; + // (undocumented) + priority?: number; + // (undocumented) + title?: string; + // (undocumented) + titleKey?: string; + // (undocumented) + tooltip?: string; +} + +// @alpha +export function useGlobalHeaderComponents(): GlobalHeaderComponentData[]; + +// @alpha +export function useGlobalHeaderMenuItems( + target: string, +): GlobalHeaderMenuItemData[]; + +// (No @packageDocumentation comment for this package) ``` 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..7535d513b54 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 { useTranslation } from '../hooks/useTranslation'; -import { DropdownEmptyState } from './HeaderDropdownComponent/DropdownEmptyState'; +import { HeaderIcon } from '../../components/HeaderIcon/HeaderIcon'; +import { useTranslation } from '../../hooks/useTranslation'; +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/HelpDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx index 9c9f9e42e00..66141a5483f 100644 --- a/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx @@ -14,11 +14,11 @@ * 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 { useTranslation } from '../hooks/useTranslation'; -import { DropdownEmptyState } from './HeaderDropdownComponent/DropdownEmptyState'; +import { HeaderIcon } from '../../components/HeaderIcon/HeaderIcon'; +import { useTranslation } from '../../hooks/useTranslation'; +import { DropdownEmptyState } from '../../components/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/ProfileDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/ProfileDropdown.tsx index c9595827c19..4daf3ea28a9 100644 --- a/workspaces/global-header/plugins/global-header/src/components/ProfileDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/ProfileDropdown.tsx @@ -21,14 +21,13 @@ import { useApi } from '@backstage/core-plugin-api'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import type { UserEntity } from '@backstage/catalog-model'; -import AccountCircleOutlinedIcon from '@mui/icons-material/AccountCircleOutlined'; -import KeyboardArrowDownOutlinedIcon from '@mui/icons-material/KeyboardArrowDownOutlined'; import Avatar from '@mui/material/Avatar'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import { useTheme } from '@mui/material/styles'; -import { useTranslation } from '../hooks/useTranslation'; +import { HeaderIcon } from '../../components/HeaderIcon/HeaderIcon'; +import { useTranslation } from '../../hooks/useTranslation'; import { GlobalHeaderDropdown } from './GlobalHeaderDropdown'; /** @@ -91,7 +90,9 @@ const ProfileButtonContent = () => { alt={t('profile.picture')} /> ) : ( - + + + )} { )} - + + + ); }; 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..5196093f9a5 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..60f38ce9752 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,66 @@ 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..fab6e718613 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, From 102c02cfbc4578cff2d41ad29542cceec2950caa Mon Sep 17 00:00:00 2001 From: Debsmita Santra Date: Mon, 27 Jul 2026 16:49:55 +0530 Subject: [PATCH 2/4] Move building-block components from alpha to components --- .../components-entry-lazy-appbar.md | 7 ++ .../global-header/docs/new-frontend-system.md | 111 +++++++++++------- .../global-header/packages/app/src/App.tsx | 32 +++-- .../plugins/global-header/README.md | 2 +- .../plugins/global-header/package.json | 5 + .../plugins/global-header/report-alpha.api.md | 20 ---- .../global-header/report-components.api.md | 75 ++++++++++++ .../global-header/src/alpha/components.ts | 50 ++++++++ .../plugins/global-header/src/alpha/index.ts | 70 +++++++++++ .../src/components/GlobalHeaderMenuItem.tsx | 10 +- .../HeaderDropdownComponent/MenuSection.tsx | 12 +- .../components/LogoutButton/LogoutButton.tsx | 4 + .../MenuItemLink/MenuItemLinkContent.tsx | 6 +- .../src/components/MyProfileMenuItem.tsx | 10 +- .../SupportButton/SupportButton.tsx | 10 +- .../src/extensions/globalHeaderModule.tsx | 13 +- .../src/utils/readConfigComponents.tsx | 28 +++-- 17 files changed, 373 insertions(+), 92 deletions(-) create mode 100644 workspaces/global-header/.changeset/components-entry-lazy-appbar.md create mode 100644 workspaces/global-header/plugins/global-header/report-components.api.md create mode 100644 workspaces/global-header/plugins/global-header/src/alpha/components.ts 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..25d03d5029e --- /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`) from `@red-hat-developer-hub/backstage-plugin-global-header/components` (not `/alpha`). Prefer dynamic import inside blueprint loaders. + +Move building-block components off `/alpha` to `/components`, and lazy-load the AppBar shell so MUI stays out of the NFS sync chunk. `/components` is intentional so the path stays stable after graduation from alpha. diff --git a/workspaces/global-header/docs/new-frontend-system.md b/workspaces/global-header/docs/new-frontend-system.md index 4d3af29960b..2e687e1e592 100644 --- a/workspaces/global-header/docs/new-frontend-system.md +++ b/workspaces/global-header/docs/new-frontend-system.md @@ -76,26 +76,30 @@ 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 main `/alpha` 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/alpha'; 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 +120,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 @@ -160,26 +165,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 main `/alpha` 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/alpha'; 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 ( + + ); + }; + }, + }, }); ``` @@ -346,7 +363,7 @@ 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 main `/alpha` entry): | Component | Key props | Purpose | | ------------------------ | -------------------------------- | ---------------------------------------------------------------------------- | @@ -354,6 +371,14 @@ 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'; +``` + **Context hooks** (direct access to collected extension data): | Hook | Returns | @@ -363,4 +388,4 @@ 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`. +Blueprints, modules, and hooks are available from `@red-hat-developer-hub/backstage-plugin-global-header/alpha`. Building-block components are available from `@red-hat-developer-hub/backstage-plugin-global-header/components`. 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..18392cc184e 100644 --- a/workspaces/global-header/plugins/global-header/README.md +++ b/workspaces/global-header/plugins/global-header/README.md @@ -38,7 +38,7 @@ 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. +Other plugins can contribute toolbar items and dropdown menu items using `GlobalHeaderComponentBlueprint` and `GlobalHeaderMenuItemBlueprint`. Building-block UI (`GlobalHeaderMenuItem`, `GlobalHeaderDropdown`, …) is imported from `/components` so it stays off the main `/alpha` sync chunk. See the [New Frontend System documentation](../../docs/new-frontend-system.md) for detailed examples and API reference. ### Legacy (Mount Points) diff --git a/workspaces/global-header/plugins/global-header/package.json b/workspaces/global-header/plugins/global-header/package.json index 7c8b80cd05a..7d0e44c4deb 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/alpha/components.ts", "./package.json": "./package.json" }, "typesVersions": { @@ -24,6 +25,10 @@ ], "global-header-translations-module": [ "src/globalHeaderTranslationsModuleExport.ts" + + ], + "components": [ + "src/alpha/components.ts" ], "package.json": [ "package.json" diff --git a/workspaces/global-header/plugins/global-header/report-alpha.api.md b/workspaces/global-header/plugins/global-header/report-alpha.api.md index 23255fe3b4a..d82605b3aee 100644 --- a/workspaces/global-header/plugins/global-header/report-alpha.api.md +++ b/workspaces/global-header/plugins/global-header/report-alpha.api.md @@ -53,26 +53,6 @@ export const globalHeaderTranslations: TranslationResource<'plugin.global-header // @alpha 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; -} - // @alpha (undocumented) export const helpDropdownExtension: OverridableExtensionDefinition<{ kind: 'gh-component'; 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..b170aa0bab9 --- /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'; + +// @alpha +const _default: FrontendModule; +export default _default; + +// @alpha +export const GlobalHeaderDropdown: ( + input: GlobalHeaderDropdownProps, +) => JSX_2.Element | null; + +// @alpha +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; + +// @alpha +export const GlobalHeaderMenuItem: ( + input: GlobalHeaderMenuItemProps, +) => JSX_2.Element; + +// @alpha +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/src/alpha/components.ts b/workspaces/global-header/plugins/global-header/src/alpha/components.ts new file mode 100644 index 00000000000..c8160c7e89a --- /dev/null +++ b/workspaces/global-header/plugins/global-header/src/alpha/components.ts @@ -0,0 +1,50 @@ +/* + * 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 main NFS sync chunk (`/alpha` today; the stable root + * export after graduation). Path is `./components` (not `./alpha/components`) + * because Module Federation entry mounts may not contain nested slashes. + * + * @alpha + * @packageDocumentation + */ + +import { createFrontendModule } from '@backstage/frontend-plugin-api'; + +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. + * + * @alpha + */ +export default createFrontendModule({ + pluginId: 'global-header', + extensions: [], +}); diff --git a/workspaces/global-header/plugins/global-header/src/alpha/index.ts b/workspaces/global-header/plugins/global-header/src/alpha/index.ts index 0b0fd1ec516..86838a4ed84 100644 --- a/workspaces/global-header/plugins/global-header/src/alpha/index.ts +++ b/workspaces/global-header/plugins/global-header/src/alpha/index.ts @@ -20,3 +20,73 @@ */ export * from '../translations'; +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { TranslationBlueprint } from '@backstage/plugin-app-react'; +import { globalHeaderTranslations } from '../translations'; + +// ── Core: plugin + module ────────────────────────────────────────────── + +export { default } from './plugin'; +export { globalHeaderModule } from './extensions/globalHeaderModule'; + +// ── Blueprints: for other plugins to contribute header items ─────────── + +export { + GlobalHeaderComponentBlueprint, + GlobalHeaderMenuItemBlueprint, +} from './extensions/blueprints'; +export type { + ToolbarComponentParams, + MenuItemParams, +} from './extensions/blueprints'; + +// ── Data refs ────────────────────────────────────────────────────────── + +export { + globalHeaderComponentDataRef, + globalHeaderMenuItemDataRef, +} from './extensions/dataRefs'; + +// ── Context hooks for custom dropdown components ─────────────────────── + +export { + useGlobalHeaderComponents, + useGlobalHeaderMenuItems, +} from './extensions/GlobalHeaderContext'; + +// ── Types ────────────────────────────────────────────────────────────── + +export type { + GlobalHeaderComponentData, + GlobalHeaderMenuItemData, +} from './types'; + +// Building-block components (GlobalHeaderIconButton, GlobalHeaderMenuItem, +// GlobalHeaderDropdown) live at +// `@red-hat-developer-hub/backstage-plugin-global-header/components` +// so they do not inflate the main federation sync chunk. That path stays +// `/components` after this plugin graduates from `/alpha` to stable. + +// ── Default extensions (collections + individual for cherry-picking) ─── + +export * from './defaults'; + +// ── Translations ─────────────────────────────────────────────────────── + +export { globalHeaderTranslationRef } from '../translations/ref'; +export { globalHeaderTranslations } from '../translations'; + +const globalHeaderTranslation = TranslationBlueprint.make({ + params: { + resource: globalHeaderTranslations, + }, +}); + +/** + * App module that registers global header translations. + * @alpha + */ +export const globalHeaderTranslationsModule = createFrontendModule({ + pluginId: 'app', + extensions: [globalHeaderTranslation], +}); 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/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', + }} > 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..298e52bc0ee 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 ./alpha 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/utils/readConfigComponents.tsx b/workspaces/global-header/plugins/global-header/src/utils/readConfigComponents.tsx index 61c6476e0c8..84e3f68cc3e 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 `./alpha` federation sync chunk. */ export function readConfigComponents( configApi: Config, @@ -42,13 +50,15 @@ export function readConfigComponents( const priority = item.getOptionalNumber('priority'); const ConfigComponent = () => ( - + + + ); return { component: ConfigComponent, priority }; From 5801cd8d91de4106b26e9baaf8f17de55b9d98a3 Mon Sep 17 00:00:00 2001 From: Debsmita Santra Date: Thu, 30 Jul 2026 16:23:51 +0530 Subject: [PATCH 3/4] settle lazy menu items before showing empty state --- .../components/GlobalHeaderDropdown.test.tsx | 354 ++++++++++++++++++ .../src/components/GlobalHeaderDropdown.tsx | 104 ++++- 2 files changed, 438 insertions(+), 20 deletions(-) create mode 100644 workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx 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..aceca924a2a --- /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} + )} ); From 8a105fd915ce73e6c6b3369abf31001600ba4e3a Mon Sep 17 00:00:00 2001 From: Debsmita Santra Date: Fri, 31 Jul 2026 21:40:23 +0530 Subject: [PATCH 4/4] rebased with master --- .../components-entry-lazy-appbar.md | 4 +- workspaces/global-header/docs/index.md | 13 +- .../global-header/docs/new-frontend-system.md | 41 ++- .../plugins/global-header/README.md | 19 +- .../plugins/global-header/package.json | 6 +- .../plugins/global-header/report-alpha.api.md | 326 ------------------ .../global-header/report-components.api.md | 10 +- .../global-header/report-legacy.api.md | 2 - .../plugins/global-header/report.api.md | 66 +--- .../plugins/global-header/src/alpha/index.ts | 70 ---- .../ApplicationLauncherDropdown.tsx | 6 +- .../src/components/GlobalHeader.test.tsx | 7 +- .../src/components/GlobalHeader.tsx | 1 + .../components/GlobalHeaderDropdown.test.tsx | 8 +- .../src/components/HelpDropdown.tsx | 6 +- .../src/components/ProfileDropdown.tsx | 4 +- .../components.ts => componentsExport.ts} | 15 +- .../src/configureMuiClassName.ts | 26 ++ .../src/defaults/menuItemExtensions.tsx | 4 +- .../src/defaults/toolbarExtensions.tsx | 13 +- .../src/extensions/blueprints.tsx | 8 +- .../src/extensions/globalHeaderModule.tsx | 2 +- .../plugins/global-header/src/index.ts | 21 +- .../plugins/global-header/src/legacy.ts | 8 +- .../src/utils/readConfigComponents.tsx | 4 +- 25 files changed, 136 insertions(+), 554 deletions(-) rename workspaces/global-header/plugins/global-header/src/{alpha/components.ts => componentsExport.ts} (76%) create mode 100644 workspaces/global-header/plugins/global-header/src/configureMuiClassName.ts diff --git a/workspaces/global-header/.changeset/components-entry-lazy-appbar.md b/workspaces/global-header/.changeset/components-entry-lazy-appbar.md index 25d03d5029e..822442a2f02 100644 --- a/workspaces/global-header/.changeset/components-entry-lazy-appbar.md +++ b/workspaces/global-header/.changeset/components-entry-lazy-appbar.md @@ -2,6 +2,6 @@ '@red-hat-developer-hub/backstage-plugin-global-header': minor --- -**BREAKING**: Import building blocks (`GlobalHeaderIconButton`, `GlobalHeaderMenuItem`, `GlobalHeaderDropdown`) from `@red-hat-developer-hub/backstage-plugin-global-header/components` (not `/alpha`). Prefer dynamic import inside blueprint loaders. +**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. -Move building-block components off `/alpha` to `/components`, and lazy-load the AppBar shell so MUI stays out of the NFS sync chunk. `/components` is intentional so the path stays stable after graduation from alpha. +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 2e687e1e592..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', @@ -77,10 +85,11 @@ export const myButton = GlobalHeaderComponentBlueprint.make({ ### Option B: Use building-block components For dropdowns or more control, provide a `loader` (preferred) that dynamically -imports building blocks from `/components` (kept off the main `/alpha` sync chunk): +imports building blocks from `/components` (kept off the root NFS sync +chunk): ```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 myDropdown = GlobalHeaderComponentBlueprint.make({ name: 'my-dropdown', @@ -148,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', @@ -166,11 +175,11 @@ export const docsItem = GlobalHeaderMenuItemBlueprint.make({ ### Custom component item using building blocks Use `GlobalHeaderMenuItem` from `/components` inside a blueprint `loader` so the -UI stays off the main `/alpha` sync chunk. The component receives `handleClose` -and `hideDivider` as props from the dropdown. +UI stays off the root NFS sync chunk. The component receives +`handleClose` and `hideDivider` as props from the dropdown. ```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 myDocsItem = GlobalHeaderMenuItemBlueprint.make({ name: 'my-docs-link', @@ -241,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', @@ -363,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** (import from `/components` — not the main `/alpha` entry): +**Building-block components** (import from `/components` — not the root +entry — so MUI stays off the main NFS sync chunk): | Component | Key props | Purpose | | ------------------------ | -------------------------------- | ---------------------------------------------------------------------------- | @@ -379,6 +389,8 @@ import { } 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 | @@ -388,4 +400,9 @@ import { **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. -Blueprints, modules, and hooks are available from `@red-hat-developer-hub/backstage-plugin-global-header/alpha`. Building-block components are available from `@red-hat-developer-hub/backstage-plugin-global-header/components`. +| 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/plugins/global-header/README.md b/workspaces/global-header/plugins/global-header/README.md index 18392cc184e..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`. Building-block UI (`GlobalHeaderMenuItem`, `GlobalHeaderDropdown`, …) is imported from `/components` so it stays off the main `/alpha` sync chunk. 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 7d0e44c4deb..98a82e8218f 100644 --- a/workspaces/global-header/plugins/global-header/package.json +++ b/workspaces/global-header/plugins/global-header/package.json @@ -9,7 +9,7 @@ "./legacy": "./src/legacy.ts", "./global-header-module": "./src/globalHeaderModuleExport.ts", "./global-header-translations-module": "./src/globalHeaderTranslationsModuleExport.ts", - "./components": "./src/alpha/components.ts", + "./components": "./src/componentsExport.ts", "./package.json": "./package.json" }, "typesVersions": { @@ -25,10 +25,9 @@ ], "global-header-translations-module": [ "src/globalHeaderTranslationsModuleExport.ts" - ], "components": [ - "src/alpha/components.ts" + "src/componentsExport.ts" ], "package.json": [ "package.json" @@ -128,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-alpha.api.md b/workspaces/global-header/plugins/global-header/report-alpha.api.md index d82605b3aee..6b0b631af19 100644 --- a/workspaces/global-header/plugins/global-header/report-alpha.api.md +++ b/workspaces/global-header/plugins/global-header/report-alpha.api.md @@ -49,330 +49,4 @@ export const globalHeaderTranslationRef: TranslationRef< // @public export const globalHeaderTranslations: TranslationResource<'plugin.global-header'>; - -// @alpha -export const globalHeaderTranslationsModule: FrontendModule; - -// @alpha (undocumented) -export const helpDropdownExtension: OverridableExtensionDefinition<{ - kind: 'gh-component'; - name: 'help-dropdown'; - config: { - priority: number | undefined; - }; - configInput: { - priority?: number | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderComponentData, - 'global-header.component', - {} - >; - inputs: {}; - params: ToolbarComponentParams; -}>; - -// @alpha (undocumented) -export const logoutMenuItemExtension: OverridableExtensionDefinition<{ - kind: 'gh-menu-item'; - name: 'logout'; - config: { - priority: number | undefined; - title: string | undefined; - titleKey: string | undefined; - icon: string | undefined; - link: string | undefined; - sectionLabel: string | undefined; - sectionLink: string | undefined; - sectionLinkLabel: string | undefined; - }; - configInput: { - link?: string | undefined; - title?: string | undefined; - icon?: string | undefined; - titleKey?: string | undefined; - priority?: number | undefined; - sectionLabel?: string | undefined; - sectionLink?: string | undefined; - sectionLinkLabel?: string | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderMenuItemData, - 'global-header.menu-item', - {} - >; - inputs: {}; - params: MenuItemParams; -}>; - -// @alpha -export interface MenuItemParams { - component?: ComponentType; - // (undocumented) - icon?: string; - // (undocumented) - link?: string; - loader?: () => Promise>; - // (undocumented) - onClick?: () => void; - // (undocumented) - priority?: number; - sectionLabel?: string; - sectionLink?: string; - sectionLinkLabel?: string; - // (undocumented) - subTitle?: string; - // (undocumented) - subTitleKey?: string; - // (undocumented) - target: string; - // (undocumented) - title?: string; - // (undocumented) - titleKey?: string; -} - -// @alpha (undocumented) -export const myProfileMenuItemExtension: OverridableExtensionDefinition<{ - kind: 'gh-menu-item'; - name: 'my-profile'; - config: { - priority: number | undefined; - title: string | undefined; - titleKey: string | undefined; - icon: string | undefined; - link: string | undefined; - sectionLabel: string | undefined; - sectionLink: string | undefined; - sectionLinkLabel: string | undefined; - }; - configInput: { - link?: string | undefined; - title?: string | undefined; - icon?: string | undefined; - titleKey?: string | undefined; - priority?: number | undefined; - sectionLabel?: string | undefined; - sectionLink?: string | undefined; - sectionLinkLabel?: string | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderMenuItemData, - 'global-header.menu-item', - {} - >; - inputs: {}; - params: MenuItemParams; -}>; - -// @alpha (undocumented) -export const notificationButtonExtension: OverridableExtensionDefinition<{ - kind: 'gh-component'; - name: 'notification-button'; - config: { - priority: number | undefined; - }; - configInput: { - priority?: number | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderComponentData, - 'global-header.component', - {} - >; - inputs: {}; - params: ToolbarComponentParams; -}>; - -// @alpha (undocumented) -export const profileDropdownExtension: OverridableExtensionDefinition<{ - kind: 'gh-component'; - name: 'profile-dropdown'; - config: { - priority: number | undefined; - }; - configInput: { - priority?: number | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderComponentData, - 'global-header.component', - {} - >; - inputs: {}; - params: ToolbarComponentParams; -}>; - -// @alpha (undocumented) -export const searchExtension: OverridableExtensionDefinition<{ - kind: 'gh-component'; - name: 'search'; - config: { - priority: number | undefined; - }; - configInput: { - priority?: number | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderComponentData, - 'global-header.component', - {} - >; - inputs: {}; - params: ToolbarComponentParams; -}>; - -// @alpha (undocumented) -export const selfServiceButtonExtension: OverridableExtensionDefinition<{ - kind: 'gh-component'; - name: 'self-service-button'; - config: { - priority: number | undefined; - }; - configInput: { - priority?: number | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderComponentData, - 'global-header.component', - {} - >; - inputs: {}; - params: ToolbarComponentParams; -}>; - -// @alpha (undocumented) -export const settingsMenuItemExtension: OverridableExtensionDefinition<{ - kind: 'gh-menu-item'; - name: 'settings'; - config: { - priority: number | undefined; - title: string | undefined; - titleKey: string | undefined; - icon: string | undefined; - link: string | undefined; - sectionLabel: string | undefined; - sectionLink: string | undefined; - sectionLinkLabel: string | undefined; - }; - configInput: { - link?: string | undefined; - title?: string | undefined; - icon?: string | undefined; - titleKey?: string | undefined; - priority?: number | undefined; - sectionLabel?: string | undefined; - sectionLink?: string | undefined; - sectionLinkLabel?: string | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderMenuItemData, - 'global-header.menu-item', - {} - >; - inputs: {}; - params: MenuItemParams; -}>; - -// @alpha (undocumented) -export const spacerExtension: OverridableExtensionDefinition<{ - kind: 'gh-component'; - name: 'spacer'; - config: { - priority: number | undefined; - }; - configInput: { - priority?: number | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderComponentData, - 'global-header.component', - {} - >; - inputs: {}; - params: ToolbarComponentParams; -}>; - -// @alpha (undocumented) -export const starredDropdownExtension: OverridableExtensionDefinition<{ - kind: 'gh-component'; - name: 'starred-dropdown'; - config: { - priority: number | undefined; - }; - configInput: { - priority?: number | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderComponentData, - 'global-header.component', - {} - >; - inputs: {}; - params: ToolbarComponentParams; -}>; - -// @alpha (undocumented) -export const supportButtonMenuItemExtension: OverridableExtensionDefinition<{ - kind: 'gh-menu-item'; - name: 'support-button'; - config: { - priority: number | undefined; - title: string | undefined; - titleKey: string | undefined; - icon: string | undefined; - link: string | undefined; - sectionLabel: string | undefined; - sectionLink: string | undefined; - sectionLinkLabel: string | undefined; - }; - configInput: { - link?: string | undefined; - title?: string | undefined; - icon?: string | undefined; - titleKey?: string | undefined; - priority?: number | undefined; - sectionLabel?: string | undefined; - sectionLink?: string | undefined; - sectionLinkLabel?: string | undefined; - }; - output: ExtensionDataRef< - GlobalHeaderMenuItemData, - 'global-header.menu-item', - {} - >; - inputs: {}; - params: MenuItemParams; -}>; - -// @alpha -export interface ToolbarComponentParams { - component?: ComponentType; - // (undocumented) - icon?: string; - layout?: Record; - // (undocumented) - link?: string; - loader?: () => Promise>; - // (undocumented) - onClick?: () => void; - // (undocumented) - priority?: number; - // (undocumented) - title?: string; - // (undocumented) - titleKey?: string; - // (undocumented) - tooltip?: string; -} - -// @alpha -export function useGlobalHeaderComponents(): GlobalHeaderComponentData[]; - -// @alpha -export function useGlobalHeaderMenuItems( - target: string, -): GlobalHeaderMenuItemData[]; - -// (No @packageDocumentation comment for this package) ``` diff --git a/workspaces/global-header/plugins/global-header/report-components.api.md b/workspaces/global-header/plugins/global-header/report-components.api.md index b170aa0bab9..6d40acdd61c 100644 --- a/workspaces/global-header/plugins/global-header/report-components.api.md +++ b/workspaces/global-header/plugins/global-header/report-components.api.md @@ -9,16 +9,16 @@ import { FrontendModule } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import type { ReactNode } from 'react'; -// @alpha +// @public const _default: FrontendModule; export default _default; -// @alpha +// @public export const GlobalHeaderDropdown: ( input: GlobalHeaderDropdownProps, ) => JSX_2.Element | null; -// @alpha +// @public export interface GlobalHeaderDropdownProps { buttonContent: ReactNode; buttonProps?: ComponentProps; @@ -34,12 +34,12 @@ export const GlobalHeaderIconButton: ( input: HeaderIconButtonProps, ) => JSX_2.Element; -// @alpha +// @public export const GlobalHeaderMenuItem: ( input: GlobalHeaderMenuItemProps, ) => JSX_2.Element; -// @alpha +// @public export interface GlobalHeaderMenuItemProps { icon?: string; onClick?: () => void; 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/alpha/index.ts b/workspaces/global-header/plugins/global-header/src/alpha/index.ts index 86838a4ed84..0b0fd1ec516 100644 --- a/workspaces/global-header/plugins/global-header/src/alpha/index.ts +++ b/workspaces/global-header/plugins/global-header/src/alpha/index.ts @@ -20,73 +20,3 @@ */ export * from '../translations'; -import { createFrontendModule } from '@backstage/frontend-plugin-api'; -import { TranslationBlueprint } from '@backstage/plugin-app-react'; -import { globalHeaderTranslations } from '../translations'; - -// ── Core: plugin + module ────────────────────────────────────────────── - -export { default } from './plugin'; -export { globalHeaderModule } from './extensions/globalHeaderModule'; - -// ── Blueprints: for other plugins to contribute header items ─────────── - -export { - GlobalHeaderComponentBlueprint, - GlobalHeaderMenuItemBlueprint, -} from './extensions/blueprints'; -export type { - ToolbarComponentParams, - MenuItemParams, -} from './extensions/blueprints'; - -// ── Data refs ────────────────────────────────────────────────────────── - -export { - globalHeaderComponentDataRef, - globalHeaderMenuItemDataRef, -} from './extensions/dataRefs'; - -// ── Context hooks for custom dropdown components ─────────────────────── - -export { - useGlobalHeaderComponents, - useGlobalHeaderMenuItems, -} from './extensions/GlobalHeaderContext'; - -// ── Types ────────────────────────────────────────────────────────────── - -export type { - GlobalHeaderComponentData, - GlobalHeaderMenuItemData, -} from './types'; - -// Building-block components (GlobalHeaderIconButton, GlobalHeaderMenuItem, -// GlobalHeaderDropdown) live at -// `@red-hat-developer-hub/backstage-plugin-global-header/components` -// so they do not inflate the main federation sync chunk. That path stays -// `/components` after this plugin graduates from `/alpha` to stable. - -// ── Default extensions (collections + individual for cherry-picking) ─── - -export * from './defaults'; - -// ── Translations ─────────────────────────────────────────────────────── - -export { globalHeaderTranslationRef } from '../translations/ref'; -export { globalHeaderTranslations } from '../translations'; - -const globalHeaderTranslation = TranslationBlueprint.make({ - params: { - resource: globalHeaderTranslations, - }, -}); - -/** - * App module that registers global header translations. - * @alpha - */ -export const globalHeaderTranslationsModule = createFrontendModule({ - pluginId: 'app', - extensions: [globalHeaderTranslation], -}); 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 7535d513b54..aeea038e319 100644 --- a/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/ApplicationLauncherDropdown.tsx @@ -16,9 +16,9 @@ import Box from '@mui/material/Box'; -import { HeaderIcon } from '../../components/HeaderIcon/HeaderIcon'; -import { useTranslation } from '../../hooks/useTranslation'; -import { DropdownEmptyState } from '../../components/HeaderDropdownComponent/DropdownEmptyState'; +import { HeaderIcon } from '../components/HeaderIcon/HeaderIcon'; +import { useTranslation } from '../hooks/useTranslation'; +import { DropdownEmptyState } from '../components/HeaderDropdownComponent/DropdownEmptyState'; import { GlobalHeaderDropdown } from './GlobalHeaderDropdown'; /** 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 index aceca924a2a..ede62cce266 100644 --- a/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/GlobalHeaderDropdown.test.tsx @@ -29,13 +29,13 @@ import { GlobalHeaderProvider } from '../extensions/GlobalHeaderContext'; import type { GlobalHeaderMenuItemData } from '../types'; import { GlobalHeaderDropdown } from './GlobalHeaderDropdown'; -jest.mock('../../hooks/useTranslation', () => { - const { mockUseTranslation } = require('../../test-utils/mockTranslations'); +jest.mock('../hooks/useTranslation', () => { + const { mockUseTranslation } = require('../test-utils/mockTranslations'); return { useTranslation: mockUseTranslation }; }); -jest.mock('../../components/Trans', () => { - const { MockTrans } = require('../../test-utils/mockTranslations'); +jest.mock('../components/Trans', () => { + const { MockTrans } = require('../test-utils/mockTranslations'); return { Trans: MockTrans }; }); 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 66141a5483f..18a8c1fec6e 100644 --- a/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/HelpDropdown.tsx @@ -16,9 +16,9 @@ import Box from '@mui/material/Box'; -import { HeaderIcon } from '../../components/HeaderIcon/HeaderIcon'; -import { useTranslation } from '../../hooks/useTranslation'; -import { DropdownEmptyState } from '../../components/HeaderDropdownComponent/DropdownEmptyState'; +import { HeaderIcon } from './HeaderIcon/HeaderIcon'; +import { useTranslation } from '../hooks/useTranslation'; +import { DropdownEmptyState } from './HeaderDropdownComponent/DropdownEmptyState'; import { GlobalHeaderDropdown } from './GlobalHeaderDropdown'; /** diff --git a/workspaces/global-header/plugins/global-header/src/components/ProfileDropdown.tsx b/workspaces/global-header/plugins/global-header/src/components/ProfileDropdown.tsx index 4daf3ea28a9..7cee9861d19 100644 --- a/workspaces/global-header/plugins/global-header/src/components/ProfileDropdown.tsx +++ b/workspaces/global-header/plugins/global-header/src/components/ProfileDropdown.tsx @@ -26,8 +26,8 @@ import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; import { useTheme } from '@mui/material/styles'; -import { HeaderIcon } from '../../components/HeaderIcon/HeaderIcon'; -import { useTranslation } from '../../hooks/useTranslation'; +import { HeaderIcon } from '../components/HeaderIcon/HeaderIcon'; +import { useTranslation } from '../hooks/useTranslation'; import { GlobalHeaderDropdown } from './GlobalHeaderDropdown'; /** diff --git a/workspaces/global-header/plugins/global-header/src/alpha/components.ts b/workspaces/global-header/plugins/global-header/src/componentsExport.ts similarity index 76% rename from workspaces/global-header/plugins/global-header/src/alpha/components.ts rename to workspaces/global-header/plugins/global-header/src/componentsExport.ts index c8160c7e89a..e015ce46ea7 100644 --- a/workspaces/global-header/plugins/global-header/src/alpha/components.ts +++ b/workspaces/global-header/plugins/global-header/src/componentsExport.ts @@ -18,18 +18,19 @@ * 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 main NFS sync chunk (`/alpha` today; the stable root - * export after graduation). Path is `./components` (not `./alpha/components`) - * because Module Federation entry mounts may not contain nested slashes. + * so these stay off the root NFS sync chunk. Prefer dynamic `import()` + * inside blueprint loaders. * - * @alpha + * @public * @packageDocumentation */ import { createFrontendModule } from '@backstage/frontend-plugin-api'; -export { HeaderIconButton as GlobalHeaderIconButton } from '../components/HeaderIconButton/HeaderIconButton'; -export type { HeaderIconButtonProps } from '../components/HeaderIconButton/HeaderIconButton'; +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'; @@ -42,7 +43,7 @@ export type { GlobalHeaderDropdownProps } from './components/GlobalHeaderDropdow * expose. Backstage only federates entry points whose default export is a * recognized feature type. * - * @alpha + * @public */ export default createFrontendModule({ pluginId: 'global-header', 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 5196093f9a5..7419e23cc94 100644 --- a/workspaces/global-header/plugins/global-header/src/defaults/menuItemExtensions.tsx +++ b/workspaces/global-header/plugins/global-header/src/defaults/menuItemExtensions.tsx @@ -62,7 +62,7 @@ export const logoutMenuItemExtension = GlobalHeaderMenuItemBlueprint.make({ target: 'profile', priority: 10, loader: () => - import('../../components/LogoutButton/LogoutButton').then( + import('../components/LogoutButton/LogoutButton').then( m => m.LogoutButton, ), }, @@ -80,7 +80,7 @@ export const supportButtonMenuItemExtension = target: 'help', priority: 10, loader: () => - import('../../components/SupportButton/SupportButton').then( + 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 60f38ce9752..3ec942f1f60 100644 --- a/workspaces/global-header/plugins/global-header/src/defaults/toolbarExtensions.tsx +++ b/workspaces/global-header/plugins/global-header/src/defaults/toolbarExtensions.tsx @@ -33,7 +33,7 @@ export const companyLogoExtension = GlobalHeaderComponentBlueprint.make({ priority: 200, loader: async () => { const [{ CompanyLogo }, { rhdhLogo }] = await Promise.all([ - import('../../components/CompanyLogo/CompanyLogo'), + import('../components/CompanyLogo/CompanyLogo'), import('./rhdhLogo'), ]); return () => ; @@ -48,7 +48,7 @@ export const searchExtension = GlobalHeaderComponentBlueprint.make({ priority: 100, layout: { flexGrow: 1 }, loader: () => - import('../../components/SearchComponent/SearchComponent').then( + import('../components/SearchComponent/SearchComponent').then( m => m.SearchComponent, ), }, @@ -60,7 +60,7 @@ export const spacerExtension = GlobalHeaderComponentBlueprint.make({ params: { priority: 99, layout: { flexGrow: 0 }, - loader: () => import('../../components/Spacer/Spacer').then(m => m.Spacer), + loader: () => import('../components/Spacer/Spacer').then(m => m.Spacer), }, }); @@ -82,7 +82,7 @@ export const starredDropdownExtension = GlobalHeaderComponentBlueprint.make({ params: { priority: 85, loader: () => - import('../../components/HeaderDropdownComponent/StarredDropdown').then( + import('../components/HeaderDropdownComponent/StarredDropdown').then( m => m.StarredDropdown, ), }, @@ -117,7 +117,7 @@ export const notificationButtonExtension = GlobalHeaderComponentBlueprint.make({ params: { priority: 70, loader: () => - import('../../components/NotificationButton/NotificationButton').then( + import('../components/NotificationButton/NotificationButton').then( m => m.NotificationButton, ), }, @@ -128,8 +128,7 @@ export const dividerExtension = GlobalHeaderComponentBlueprint.make({ name: 'divider', params: { priority: 50, - loader: () => - import('../../components/Divider/Divider').then(m => m.Divider), + loader: () => import('../components/Divider/Divider').then(m => m.Divider), }, }); 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 fab6e718613..3097460ee70 100644 --- a/workspaces/global-header/plugins/global-header/src/extensions/blueprints.tsx +++ b/workspaces/global-header/plugins/global-header/src/extensions/blueprints.tsx @@ -132,7 +132,7 @@ function createDataDrivenToolbarLoader( return async () => { if (params.link) { const { HeaderIconButton } = await import( - '../../components/HeaderIconButton/HeaderIconButton' + '../components/HeaderIconButton/HeaderIconButton' ); return () => ( { 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 298e52bc0ee..f93839beccb 100644 --- a/workspaces/global-header/plugins/global-header/src/extensions/globalHeaderModule.tsx +++ b/workspaces/global-header/plugins/global-header/src/extensions/globalHeaderModule.tsx @@ -37,7 +37,7 @@ import { readConfigMenuItems } from '../utils/readConfigMenuItems'; import { readConfigComponents } from '../utils/readConfigComponents'; // AppRootWrapperBlueprint has no loader — lazy the AppBar shell so MUI stays -// off the ./alpha federation sync chunk (same idea as PageBlueprint loaders). +// off the root federation sync chunk (same idea as PageBlueprint loaders). const LazyGlobalHeader = lazy(() => import('../components/GlobalHeader').then(m => ({ default: m.GlobalHeader })), ); 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 84e3f68cc3e..a63a33d8cd1 100644 --- a/workspaces/global-header/plugins/global-header/src/utils/readConfigComponents.tsx +++ b/workspaces/global-header/plugins/global-header/src/utils/readConfigComponents.tsx @@ -21,7 +21,7 @@ import type { Config } from '@backstage/config'; import type { GlobalHeaderComponentData } from '../types'; const LazyHeaderIconButton = lazy(() => - import('../../components/HeaderIconButton/HeaderIconButton').then(m => ({ + import('../components/HeaderIconButton/HeaderIconButton').then(m => ({ default: m.HeaderIconButton, })), ); @@ -33,7 +33,7 @@ const LazyHeaderIconButton = lazy(() => * Config-driven components are always rendered as a `HeaderIconButton` * (icon + link), matching the data-driven tier of * `GlobalHeaderComponentBlueprint`. The button is lazy-loaded so it stays - * off the `./alpha` federation sync chunk. + * off the root federation sync chunk when loaded via blueprint loaders. */ export function readConfigComponents( configApi: Config,