From 1fcffa72e3ed3a620341a9f068b4a7c7b15b18f3 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 14 Feb 2026 17:19:33 +0100 Subject: [PATCH] app-react: new API for consuming nav items that detects page extensions Signed-off-by: Patrik Oldsberg --- plugins/app-react/report.api.md | 19 +++ .../blueprints/NavContentBlueprint.test.tsx | 100 +++++++++--- .../src/blueprints/NavContentBlueprint.ts | 57 +++++-- plugins/app-react/src/blueprints/index.ts | 2 + plugins/app/src/extensions/AppNav.tsx | 152 +++++++++++++++--- 5 files changed, 283 insertions(+), 47 deletions(-) diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index c29ef32264..7539944883 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { AppNode } from '@backstage/frontend-plugin-api'; import { AppTheme } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; @@ -99,6 +100,7 @@ export type NavContentComponent = ( // @public export interface NavContentComponentProps { + // @deprecated items: Array<{ icon: IconComponent; title: string; @@ -106,6 +108,23 @@ export interface NavContentComponentProps { to: string; text: string; }>; + navItems: NavItems; +} + +// @public +export interface NavItem { + href: string; + icon: IconElement; + node: AppNode; + routeRef: RouteRef; + title: string; +} + +// @public +export interface NavItems { + clone(): NavItems; + rest(): NavItem[]; + take(id: string): NavItem | undefined; } // @public diff --git a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx index a113a2f21d..deae9858c9 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.test.tsx @@ -14,12 +14,26 @@ * limitations under the License. */ -import { createRouteRef } from '@backstage/frontend-plugin-api'; -import { NavContentBlueprint } from './NavContentBlueprint'; +import { AppNode, createRouteRef } from '@backstage/frontend-plugin-api'; +import { NavContentBlueprint, NavItem, NavItems } from './NavContentBlueprint'; import { createExtensionTester } from '@backstage/frontend-test-utils'; const routeRef = createRouteRef(); +function mockNode(id: string): AppNode { + return { spec: { id } } as AppNode; +} + +function mockNavItems(items: NavItem[]): NavItems { + return { + take: () => undefined, + rest: () => items, + clone() { + return mockNavItems(items); + }, + }; +} + describe('NavContentBlueprint', () => { it('should create an extension with sensible defaults', () => { const extension = NavContentBlueprint.make({ @@ -52,22 +66,7 @@ describe('NavContentBlueprint', () => { `); }); - it('should return a valid component', () => { - const extension = NavContentBlueprint.make({ - name: 'test', - params: { - component: () =>
Nav content
, - }, - }); - - const tester = createExtensionTester(extension); - - expect( - tester.get(NavContentBlueprint.dataRefs.component)({ items: [] }), - ).toEqual(
Nav content
); - }); - - it('should return a valid component with items', () => { + it('should return a valid component with legacy items', () => { const extension = NavContentBlueprint.make({ name: 'test', params: { @@ -88,6 +87,7 @@ describe('NavContentBlueprint', () => { expect( tester.get(NavContentBlueprint.dataRefs.component)({ + navItems: mockNavItems([]), items: [ { to: '/', @@ -109,4 +109,68 @@ describe('NavContentBlueprint', () => { , ); }); + + it('should return a valid component with navItems', () => { + const items: NavItem[] = [ + { + 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 }) => ( +
+ {navItems.rest().map(item => ( + + {item.title} + + ))} +
+ ), + }, + }); + + const tester = createExtensionTester(extension); + + expect( + tester.get(NavContentBlueprint.dataRefs.component)({ + navItems: mockNavItems(items), + items: [], + }), + ).toEqual( +
+ {[ + + Home + , + + Catalog + , + + Docs + , + ]} +
, + ); + }); }); diff --git a/plugins/app-react/src/blueprints/NavContentBlueprint.ts b/plugins/app-react/src/blueprints/NavContentBlueprint.ts index 0f21ed832a..2dbdc3da70 100644 --- a/plugins/app-react/src/blueprints/NavContentBlueprint.ts +++ b/plugins/app-react/src/blueprints/NavContentBlueprint.ts @@ -14,12 +14,50 @@ * limitations under the License. */ -import { IconComponent, RouteRef } from '@backstage/frontend-plugin-api'; +import { + AppNode, + IconComponent, + IconElement, + RouteRef, +} from '@backstage/frontend-plugin-api'; import { createExtensionBlueprint, createExtensionDataRef, } from '@backstage/frontend-plugin-api'; +/** + * A navigation item auto-discovered from a page extension in the app. + * + * @public + */ +export interface NavItem { + /** The app node of the page extension that this nav item points to */ + node: AppNode; + /** The resolved route path */ + href: string; + /** The display title */ + title: string; + /** The display icon */ + icon: IconElement; + /** The route ref of the source page */ + routeRef: RouteRef; +} + +/** + * 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 { + /** Take an item by extension ID, removing it from the collection. */ + take(id: string): NavItem | undefined; + /** All items not yet taken. */ + rest(): NavItem[]; + /** Create a copy of the collection preserving the current taken state. */ + clone(): NavItems; +} + /** * The props for the {@link NavContentComponent}. * @@ -27,20 +65,21 @@ import { */ export interface NavContentComponentProps { /** - * The nav items available to the component. These are all the items created - * with the {@link @backstage/frontend-plugin-api#NavItemBlueprint} in the app. + * Nav items auto-discovered from page extensions, with take/rest semantics + * for placing specific items in specific positions. + */ + navItems: NavItems; + + /** + * Flat list of nav items for simple rendering. Use `navItems` for more + * control over item placement. * - * In addition to the original properties from the nav items, these also - * include a resolved route path as `to`, and duplicated `title` as `text` to - * simplify rendering. + * @deprecated Use `navItems` instead. */ items: Array<{ - // Original props from nav items icon: IconComponent; title: string; routeRef: RouteRef; - - // Additional props to simplify item rendering to: string; text: string; }>; diff --git a/plugins/app-react/src/blueprints/index.ts b/plugins/app-react/src/blueprints/index.ts index 15a0fdcd47..8a3e518749 100644 --- a/plugins/app-react/src/blueprints/index.ts +++ b/plugins/app-react/src/blueprints/index.ts @@ -20,6 +20,8 @@ export { NavContentBlueprint } from './NavContentBlueprint'; export type { NavContentComponent, NavContentComponentProps, + NavItem, + NavItems, } 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 12e0c1a5c8..858fda15da 100644 --- a/plugins/app/src/extensions/AppNav.tsx +++ b/plugins/app/src/extensions/AppNav.tsx @@ -20,55 +20,99 @@ import { createExtensionInput, NavItemBlueprint, routeResolutionApiRef, + appTreeApiRef, IconComponent, + IconElement, RouteRef, + RouteResolutionApi, useApi, } from '@backstage/frontend-plugin-api'; import { NavContentBlueprint, NavContentComponent, NavContentComponentProps, + NavItem, + NavItems, } 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; + readonly #taken: Set; + + constructor(items: NavItem[], 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 { + const item = this.#index.get(id); + if (item) { + this.#taken.add(id); + } + return item; + } + + rest(): NavItem[] { + return this.#items.filter(item => !this.#taken.has(item.node.spec.id)); + } + + clone(): NavItems { + return new NavItemBag(this.#items, this.#taken); + } +} + function DefaultNavContent(props: NavContentComponentProps) { + const items = props.navItems.rest(); return ( - {props.items.map((item, index) => ( + {items.map(item => ( item.icon} + text={item.title} + key={item.node.spec.id} /> ))} ); } -// This helps defer rendering until the app is being rendered, which is needed -// because the RouteResolutionApi can't be called until the app has been fully initialized. +// Tries to resolve a routeRef to a link path, returning undefined if it +// can't be resolved (e.g. parameterized routes). +function tryResolveLink( + routeResolutionApi: RouteResolutionApi, + routeRef: RouteRef, +): string | undefined { + try { + const link = routeResolutionApi.resolve(routeRef); + return link?.(); + } catch { + return undefined; + } +} + +// Defers rendering until the app is fully initialized so that APIs like +// RouteResolutionApi and AppTreeApi are available. function NavContentRenderer(props: { Content: NavContentComponent; - items: Array<{ + legacyNavItems: Array<{ title: string; icon: IconComponent; routeRef: RouteRef; }>; }) { + const appTreeApi = useApi(appTreeApiRef); const routeResolutionApi = useApi(routeResolutionApiRef); - const items = useMemo(() => { - return props.items.flatMap(item => { + // Deprecated items: just resolve nav item routeRefs to paths, no page discovery. + const legacyItems = useMemo(() => { + return props.legacyNavItems.flatMap(item => { const link = routeResolutionApi.resolve(item.routeRef); - if (!link) { - // eslint-disable-next-line no-console - console.warn( - `NavItemBlueprint: unable to resolve route ref ${item.routeRef}`, - ); - return []; - } + if (!link) return []; return [ { to: link(), @@ -79,9 +123,77 @@ function NavContentRenderer(props: { }, ]; }); - }, [props.items, routeResolutionApi]); + }, [props.legacyNavItems, routeResolutionApi]); - return ; + // New navItems: discover pages from the extension tree, merged with nav items. + const navItems = useMemo(() => { + const { tree } = appTreeApi.getTree(); + const routesNode = tree.nodes.get('app/routes'); + if (!routesNode) return new NavItemBag([]); + + // Index nav items by routeRef for matching against pages + const navItemsByRouteRef = new Map< + RouteRef, + { title: string; icon: IconComponent } + >(props.legacyNavItems.map(item => [item.routeRef, item])); + + const pageNodes = routesNode.edges.attachments.get('routes') ?? []; + const items = pageNodes.flatMap((node): NavItem[] => { + if (!node.instance || node.spec.disabled) { + return []; + } + + const routeRef = node.instance.getData(coreExtensionData.routeRef); + if (!routeRef) { + return []; + } + + const matchingNavItem = navItemsByRouteRef.get(routeRef); + + // PageBlueprint resolves title as: config.title ?? params.title ?? plugin.title ?? pluginId + // We want the priority: page (config/params) -> nav item -> plugin -> pluginId + const resolvedTitle = node.instance.getData(coreExtensionData.title); + const pluginTitle = node.spec.plugin.title; + const pluginId = node.spec.plugin.pluginId; + const hasExplicitPageTitle = + resolvedTitle !== undefined && + resolvedTitle !== pluginTitle && + resolvedTitle !== pluginId; + const title = hasExplicitPageTitle + ? resolvedTitle + : matchingNavItem?.title ?? pluginTitle ?? pluginId; + + // PageBlueprint resolves icon as: params.icon ?? plugin.icon + // We want the priority: page (params) -> nav item -> plugin -> (excluded) + const resolvedIcon = node.instance.getData(coreExtensionData.icon); + const hasExplicitPageIcon = resolvedIcon && !node.spec.plugin.icon; + const NavItemIcon = matchingNavItem?.icon; + + let icon: IconElement | undefined; + if (hasExplicitPageIcon) { + icon = resolvedIcon; + } else if (NavItemIcon) { + icon = ; + } else if (resolvedIcon) { + icon = resolvedIcon; + } + + if (!title || !icon) { + return []; + } + + const to = tryResolveLink(routeResolutionApi, routeRef); + if (!to) { + return []; + } + + return [{ node, href: to, title, icon, routeRef }]; + }); + + return new NavItemBag(items); + }, [appTreeApi, routeResolutionApi, props.legacyNavItems]); + + return ; } export const AppNav = createExtension({ @@ -103,7 +215,7 @@ export const AppNav = createExtension({ yield coreExtensionData.reactElement( + legacyNavItems={inputs.items.map(item => item.get(NavItemBlueprint.dataRefs.target), )} Content={Content}