app-react: new API for consuming nav items that detects page extensions

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-14 17:19:33 +01:00
parent a63eee6a73
commit 1fcffa72e3
5 changed files with 283 additions and 47 deletions
+19
View File
@@ -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
@@ -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: () => <div>Nav content</div>,
},
});
const tester = createExtensionTester(extension);
expect(
tester.get(NavContentBlueprint.dataRefs.component)({ items: [] }),
).toEqual(<div>Nav content</div>);
});
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', () => {
</div>,
);
});
it('should return a valid component with navItems', () => {
const items: NavItem[] = [
{
node: mockNode('page:home'),
href: '/',
title: 'Home',
icon: <span>home</span>,
routeRef,
},
{
node: mockNode('page:catalog'),
href: '/catalog',
title: 'Catalog',
icon: <span>catalog</span>,
routeRef,
},
{
node: mockNode('page:docs'),
href: '/docs',
title: 'Docs',
icon: <span>docs</span>,
routeRef,
},
];
const extension = NavContentBlueprint.make({
name: 'test',
params: {
component: ({ navItems }) => (
<div>
{navItems.rest().map(item => (
<a key={item.node.spec.id} href={item.href}>
{item.title}
</a>
))}
</div>
),
},
});
const tester = createExtensionTester(extension);
expect(
tester.get(NavContentBlueprint.dataRefs.component)({
navItems: mockNavItems(items),
items: [],
}),
).toEqual(
<div>
{[
<a key="page:home" href="/">
Home
</a>,
<a key="page:catalog" href="/catalog">
Catalog
</a>,
<a key="page:docs" href="/docs">
Docs
</a>,
]}
</div>,
);
});
});
@@ -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<undefined>;
// Additional props to simplify item rendering
to: string;
text: string;
}>;
@@ -20,6 +20,8 @@ export { NavContentBlueprint } from './NavContentBlueprint';
export type {
NavContentComponent,
NavContentComponentProps,
NavItem,
NavItems,
} from './NavContentBlueprint';
export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
+132 -20
View File
@@ -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<string, NavItem>;
readonly #taken: Set<string>;
constructor(items: NavItem[], taken?: Iterable<string>) {
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 (
<Sidebar>
{props.items.map((item, index) => (
{items.map(item => (
<SidebarItem
to={item.to}
icon={item.icon}
text={item.text}
key={index}
to={item.href}
icon={() => item.icon}
text={item.title}
key={item.node.spec.id}
/>
))}
</Sidebar>
);
}
// 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<undefined>;
}>;
}) {
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 <props.Content items={items} />;
// 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 = <NavItemIcon />;
} 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 <props.Content navItems={navItems} items={legacyItems} />;
}
export const AppNav = createExtension({
@@ -103,7 +215,7 @@ export const AppNav = createExtension({
yield coreExtensionData.reactElement(
<NavContentRenderer
items={inputs.items.map(item =>
legacyNavItems={inputs.items.map(item =>
item.get(NavItemBlueprint.dataRefs.target),
)}
Content={Content}