From 5920062fcc36f62a77c216734ca6a570d4e34957 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 16 Feb 2026 11:19:09 +0100 Subject: [PATCH] app-react: API cleanup and component utility for nav items Signed-off-by: Patrik Oldsberg --- .changeset/nav-items-page-discovery.md | 2 +- .../building-apps/08-migrating.md | 29 ++--- packages/app/src/modules/appModuleNav.tsx | 30 ++--- .../packages/app/src/modules/nav/Sidebar.tsx | 34 +++--- plugins/app-react/report.api.md | 21 +++- .../blueprints/NavContentBlueprint.test.tsx | 103 +++++++++++++++++- .../src/blueprints/NavContentBlueprint.ts | 30 ++++- plugins/app-react/src/blueprints/index.ts | 5 +- plugins/app/src/extensions/AppNav.tsx | 38 +++++-- 9 files changed, 205 insertions(+), 87 deletions(-) diff --git a/.changeset/nav-items-page-discovery.md b/.changeset/nav-items-page-discovery.md index 11e9ee431d..d1f60b342b 100644 --- a/.changeset/nav-items-page-discovery.md +++ b/.changeset/nav-items-page-discovery.md @@ -3,4 +3,4 @@ '@backstage/plugin-app': patch --- -Added new `NavItem`, `NavItems`, and `navItems` prop to `NavContentComponentProps` for auto-discovering navigation items from page extensions. The new `navItems` collection supports `take(id)` and `rest()` methods for placing specific items in custom sidebar positions. The existing `items` prop is now deprecated in favor of `navItems`. +Added new `NavContentNavItem`, `NavContentNavItems`, and `navItems` prop to `NavContentComponentProps` for auto-discovering navigation items from page extensions. The new `navItems` collection supports `take(id)` and `rest()` methods for placing specific items in custom sidebar positions, as well as `withComponent(Component)` which returns a `NavContentNavItemsWithComponent` for rendering items directly as elements. The existing `items` prop is now deprecated in favor of `navItems`. diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index e71e171af1..2849b7d68b 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -702,7 +702,7 @@ export const navModule = createFrontendModule({ Then in the actual implementation for the `SidebarContent` extension, you can provide something like the following, where you implement the entire `Sidebar` component. -The component receives a `navItems` prop with `take(id)` and `rest()` methods for placing specific items in custom positions. Use `navItems.take('page:home')` to take a specific item by extension ID (e.g. for a header slot), and `navItems.rest()` to get all remaining items. +The component receives a `navItems` prop with `take(id)` and `rest()` methods for placing specific items in custom positions. The recommended approach is to use `navItems.withComponent(...)` to define a component for rendering each nav item, and then use the returned `take(id)` and `rest()` methods to get pre-rendered elements directly. Items taken from the renderer are also taken from the main list. Keys are automatically assigned when rendering via `rest()`. ```tsx title="in packages/app/src/modules/nav/Sidebar.tsx" import { NavContentBlueprint } from '@backstage/plugin-app-react'; @@ -710,34 +710,23 @@ import { NavContentBlueprint } from '@backstage/plugin-app-react'; export const SidebarContent = NavContentBlueprint.make({ params: { component: ({ navItems }) => { - // Take specific items for custom placement - const home = navItems.take('page:home'); - // Get all remaining items - const rest = navItems.rest(); + const nav = navItems.withComponent(item => ( + item.icon} to={item.href} text={item.title} /> + )); return ( - {home && ( - - )} } to="/search"> }> - ... - - + {nav.take('page:catalog')} + {nav.take('page:scaffolder')} + - {rest.map(item => ( - - ))} + {nav.rest({ sortBy: 'title' })} @@ -749,7 +738,7 @@ export const SidebarContent = NavContentBlueprint.make({ The deprecated `items` prop (a flat list compatible with ``) remains supported for backward compatibility. If you don't want to auto-populate the list, simply remove the rendering of that `SidebarGroup`. -You might also notice that when you're rendering additional fixed icons for plugins (e.g. Search in a dedicated group) these might become duplicated, since that page is also included in `navItems.rest()`. To exclude an item from the remaining list, use `navItems.take('page:search')` before calling `navItems.rest()`. Items that have been taken will not appear in `rest()`. +You might also notice that when you're rendering additional fixed icons for plugins (e.g. Search in a dedicated group) these might become duplicated, since that page is also included in `nav.rest()`. To exclude an item from the remaining list, call `nav.take('page:search')` before calling `nav.rest()` — you can discard the return value. Items that have been taken will not appear in `rest()`. You can also use the old `NavItemBlueprint`-based nav item extensions to disable items from the nav bar, these can be disabled in config without affecting the page itself: diff --git a/packages/app/src/modules/appModuleNav.tsx b/packages/app/src/modules/appModuleNav.tsx index 3ce2bc3cd7..a507679c73 100644 --- a/packages/app/src/modules/appModuleNav.tsx +++ b/packages/app/src/modules/appModuleNav.tsx @@ -29,7 +29,7 @@ import SearchIcon from '@material-ui/icons/Search'; import MenuIcon from '@material-ui/icons/Menu'; import BuildIcon from '@material-ui/icons/Build'; import { createFrontendModule } from '@backstage/frontend-plugin-api'; -import { NavContentBlueprint, NavItem } from '@backstage/plugin-app-react'; +import { NavContentBlueprint } from '@backstage/plugin-app-react'; import { SidebarSearchModal } from '@backstage/plugin-search'; import { NotificationsSidebarItem } from '@backstage/plugin-notifications'; import { @@ -98,22 +98,20 @@ const SidebarLogo = () => { ); }; -function item(props?: NavItem) { - if (!props) { - return null; - } - return ( - props.icon} to={props.href} text={props.title} /> - ); -} - export const appModuleNav = createFrontendModule({ pluginId: 'app', extensions: [ NavContentBlueprint.make({ params: { component: ({ navItems }) => { - navItems.take('page:home'); // Skip home + const nav = navItems.withComponent(item => ( + item.icon} + to={item.href} + text={item.title} + /> + )); + nav.take('page:home'); // Skip home return ( @@ -122,15 +120,11 @@ export const appModuleNav = createFrontendModule({ }> - {item(navItems.take('page:home'))} - {item(navItems.take('page:catalog'))} - {item(navItems.take('page:scaffolder'))} + {nav.take('page:catalog')} + {nav.take('page:scaffolder')} - {navItems - .rest() - .sort((a, b) => a.title.localeCompare(b.title)) - .map(item)} + {nav.rest({ sortBy: 'title' })} diff --git a/packages/create-app/templates/next-app/packages/app/src/modules/nav/Sidebar.tsx b/packages/create-app/templates/next-app/packages/app/src/modules/nav/Sidebar.tsx index b3883aded3..d436252edf 100644 --- a/packages/create-app/templates/next-app/packages/app/src/modules/nav/Sidebar.tsx +++ b/packages/create-app/templates/next-app/packages/app/src/modules/nav/Sidebar.tsx @@ -7,7 +7,7 @@ import { SidebarSpace, } from '@backstage/core-components'; import { compatWrapper } from '@backstage/core-compat-api'; -import { NavContentBlueprint, NavItem } from '@backstage/plugin-app-react'; +import { NavContentBlueprint } from '@backstage/plugin-app-react'; import { SidebarLogo } from './SidebarLogo'; import MenuIcon from '@material-ui/icons/Menu'; import SearchIcon from '@material-ui/icons/Search'; @@ -15,19 +15,17 @@ import { SidebarSearchModal } from '@backstage/plugin-search'; import { UserSettingsSignInAvatar, Settings as SidebarSettings } from '@backstage/plugin-user-settings'; import { NotificationsSidebarItem } from '@backstage/plugin-notifications'; -function item(props?: NavItem) { - if (!props) { - return null; - } - return ( - props.icon} to={props.href} text={props.title} /> - ); -} - export const SidebarContent = NavContentBlueprint.make({ params: { - component: ({ navItems }) => - compatWrapper( + component: ({ navItems }) => { + const nav = navItems.withComponent(item => ( + item.icon} + to={item.href} + text={item.title} + /> + )); + return compatWrapper( } to="/search"> @@ -35,14 +33,11 @@ export const SidebarContent = NavContentBlueprint.make({ }> - {item(navItems.take('page:catalog'))} - {item(navItems.take('page:scaffolder'))} + {nav.take('page:catalog')} + {nav.take('page:scaffolder')} - {navItems - .rest() - .sort((a, b) => a.title.localeCompare(b.title)) - .map(item)} + {nav.rest({ sortBy: 'title' })} @@ -57,6 +52,7 @@ export const SidebarContent = NavContentBlueprint.make({ , - ), + ); + }, }, }); diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index 7539944883..98e977ef20 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -108,11 +108,11 @@ export interface NavContentComponentProps { to: string; text: string; }>; - navItems: NavItems; + navItems: NavContentNavItems; } // @public -export interface NavItem { +export interface NavContentNavItem { href: string; icon: IconElement; node: AppNode; @@ -121,10 +121,19 @@ export interface NavItem { } // @public -export interface NavItems { - clone(): NavItems; - rest(): NavItem[]; - take(id: string): NavItem | undefined; +export interface NavContentNavItems { + clone(): NavContentNavItems; + rest(): NavContentNavItem[]; + take(id: string): NavContentNavItem | undefined; + withComponent( + Component: ComponentType, + ): NavContentNavItemsWithComponent; +} + +// @public +export interface NavContentNavItemsWithComponent { + rest(options?: { sortBy?: 'title' }): JSX.Element[]; + take(id: string): JSX.Element | null; } // @public diff --git a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx index deae9858c9..cb782409b1 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx @@ -15,8 +15,13 @@ */ import { AppNode, createRouteRef } from '@backstage/frontend-plugin-api'; -import { NavContentBlueprint, NavItem, NavItems } from './NavContentBlueprint'; +import { + NavContentBlueprint, + NavContentNavItem, + NavContentNavItems, +} from './NavContentBlueprint'; import { createExtensionTester } from '@backstage/frontend-test-utils'; +import { render, screen } from '@testing-library/react'; const routeRef = createRouteRef(); @@ -24,13 +29,41 @@ function mockNode(id: string): AppNode { return { spec: { id } } as AppNode; } -function mockNavItems(items: NavItem[]): NavItems { +function mockNavItems(items: NavContentNavItem[]): NavContentNavItems { + const taken = new Set(); return { - take: () => undefined, - rest: () => items, + take(id: string) { + const item = items.find(i => i.node.spec.id === id); + if (item) { + taken.add(id); + } + return item; + }, + rest: () => items.filter(i => !taken.has(i.node.spec.id)), clone() { return mockNavItems(items); }, + withComponent(Component: (props: NavContentNavItem) => JSX.Element) { + return { + take: (id: string) => { + const item = items.find(i => i.node.spec.id === id); + if (item) { + taken.add(id); + return ; + } + return null; + }, + rest: (options?: { sortBy?: 'title' }) => { + const remaining = items.filter(i => !taken.has(i.node.spec.id)); + if (options?.sortBy === 'title') { + remaining.sort((a, b) => a.title.localeCompare(b.title)); + } + return remaining.map(item => ( + + )); + }, + }; + }, }; } @@ -111,7 +144,7 @@ describe('NavContentBlueprint', () => { }); it('should return a valid component with navItems', () => { - const items: NavItem[] = [ + const items: NavContentNavItem[] = [ { node: mockNode('page:home'), href: '/', @@ -173,4 +206,64 @@ describe('NavContentBlueprint', () => { , ); }); + + it('should support withComponent for take and rest', () => { + const items: NavContentNavItem[] = [ + { + node: mockNode('page:home'), + href: '/', + title: 'Home', + icon: home, + routeRef, + }, + { + node: mockNode('page:catalog'), + href: '/catalog', + title: 'Catalog', + icon: catalog, + routeRef, + }, + { + node: mockNode('page:docs'), + href: '/docs', + title: 'Docs', + icon: docs, + routeRef, + }, + ]; + + const extension = NavContentBlueprint.make({ + name: 'test', + params: { + component: ({ navItems }) => { + const nav = navItems.withComponent(item => ( + {item.title} + )); + return ( +
+
{nav.take('page:home')}
+ +
+ ); + }, + }, + }); + + const tester = createExtensionTester(extension); + const Component = tester.get(NavContentBlueprint.dataRefs.component); + + render(); + + const homeLink = screen.getByText('Home'); + expect(homeLink).toBeInTheDocument(); + expect(homeLink.closest('header')).toBeTruthy(); + + const catalogLink = screen.getByText('Catalog'); + expect(catalogLink).toBeInTheDocument(); + expect(catalogLink.closest('nav')).toBeTruthy(); + + const docsLink = screen.getByText('Docs'); + expect(docsLink).toBeInTheDocument(); + expect(docsLink.closest('nav')).toBeTruthy(); + }); }); diff --git a/plugins/app-react/src/blueprints/NavContentBlueprint.ts b/plugins/app-react/src/blueprints/NavContentBlueprint.ts index 2dbdc3da70..f88ba48a38 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.ts +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { ComponentType } from 'react'; import { AppNode, IconComponent, @@ -30,7 +31,7 @@ import { * * @public */ -export interface NavItem { +export interface NavContentNavItem { /** The app node of the page extension that this nav item points to */ node: AppNode; /** The resolved route path */ @@ -43,19 +44,36 @@ export interface NavItem { routeRef: RouteRef; } +/** + * A pre-bound renderer that wraps {@link NavContentNavItems} with a component, + * so that `take` and `rest` return rendered elements directly. + * + * @public + */ +export interface NavContentNavItemsWithComponent { + /** Render and take a specific item by extension ID. Returns null if not found. */ + take(id: string): JSX.Element | null; + /** Render all remaining items not yet taken, optionally sorted. */ + rest(options?: { sortBy?: 'title' }): JSX.Element[]; +} + /** * A collection of nav items that supports picking specific items by ID * and retrieving whatever remains. Created fresh for each render. * * @public */ -export interface NavItems { +export interface NavContentNavItems { /** Take an item by extension ID, removing it from the collection. */ - take(id: string): NavItem | undefined; + take(id: string): NavContentNavItem | undefined; /** All items not yet taken. */ - rest(): NavItem[]; + rest(): NavContentNavItem[]; /** Create a copy of the collection preserving the current taken state. */ - clone(): NavItems; + clone(): NavContentNavItems; + /** Create a renderer that wraps take/rest to return pre-rendered elements. */ + withComponent( + Component: ComponentType, + ): NavContentNavItemsWithComponent; } /** @@ -68,7 +86,7 @@ export interface NavContentComponentProps { * Nav items auto-discovered from page extensions, with take/rest semantics * for placing specific items in specific positions. */ - navItems: NavItems; + navItems: NavContentNavItems; /** * Flat list of nav items for simple rendering. Use `navItems` for more diff --git a/plugins/app-react/src/blueprints/index.ts b/plugins/app-react/src/blueprints/index.ts index 8a3e518749..318691c8db 100644 --- a/plugins/app-react/src/blueprints/index.ts +++ b/plugins/app-react/src/blueprints/index.ts @@ -20,8 +20,9 @@ export { NavContentBlueprint } from './NavContentBlueprint'; export type { NavContentComponent, NavContentComponentProps, - NavItem, - NavItems, + NavContentNavItem, + NavContentNavItemsWithComponent, + NavContentNavItems, } from './NavContentBlueprint'; export { RouterBlueprint } from './RouterBlueprint'; export { SignInPageBlueprint } from './SignInPageBlueprint'; diff --git a/plugins/app/src/extensions/AppNav.tsx b/plugins/app/src/extensions/AppNav.tsx index 858fda15da..ffa6ced7b0 100644 --- a/plugins/app/src/extensions/AppNav.tsx +++ b/plugins/app/src/extensions/AppNav.tsx @@ -31,24 +31,24 @@ import { NavContentBlueprint, NavContentComponent, NavContentComponentProps, - NavItem, - NavItems, + NavContentNavItem, + NavContentNavItems, } from '@backstage/plugin-app-react'; import { Sidebar, SidebarItem } from '@backstage/core-components'; import { useMemo } from 'react'; -class NavItemBag implements NavItems { - readonly #items: NavItem[]; - readonly #index: Map; +class NavItemBag implements NavContentNavItems { + readonly #items: NavContentNavItem[]; + readonly #index: Map; readonly #taken: Set; - constructor(items: NavItem[], taken?: Iterable) { + constructor(items: NavContentNavItem[], taken?: Iterable) { this.#items = items; this.#index = new Map(items.map(item => [item.node.spec.id, item])); this.#taken = new Set(taken); } - take(id: string): NavItem | undefined { + take(id: string): NavContentNavItem | undefined { const item = this.#index.get(id); if (item) { this.#taken.add(id); @@ -56,13 +56,31 @@ class NavItemBag implements NavItems { return item; } - rest(): NavItem[] { + rest(): NavContentNavItem[] { return this.#items.filter(item => !this.#taken.has(item.node.spec.id)); } - clone(): NavItems { + clone(): NavContentNavItems { return new NavItemBag(this.#items, this.#taken); } + + withComponent(Component: (props: NavContentNavItem) => JSX.Element) { + return { + take: (id: string) => { + const item = this.take(id); + return item ? : null; + }, + rest: (options?: { sortBy?: 'title' }) => { + const items = this.rest(); + if (options?.sortBy === 'title') { + items.sort((a, b) => a.title.localeCompare(b.title)); + } + return items.map(item => ( + + )); + }, + }; + } } function DefaultNavContent(props: NavContentComponentProps) { @@ -138,7 +156,7 @@ function NavContentRenderer(props: { >(props.legacyNavItems.map(item => [item.routeRef, item])); const pageNodes = routesNode.edges.attachments.get('routes') ?? []; - const items = pageNodes.flatMap((node): NavItem[] => { + const items = pageNodes.flatMap((node): NavContentNavItem[] => { if (!node.instance || node.spec.disabled) { return []; }