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
+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}