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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user