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 }) => (
+
+ ),
+ },
+ });
+
+ const tester = createExtensionTester(extension);
+
+ expect(
+ tester.get(NavContentBlueprint.dataRefs.component)({
+ navItems: mockNavItems(items),
+ items: [],
+ }),
+ ).toEqual(
+ ,
+ );
+ });
});
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}