app-react: API cleanup and component utility for nav items

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2026-02-16 11:19:09 +01:00
parent 98fe69ae6a
commit 5920062fcc
9 changed files with 205 additions and 87 deletions
+1 -1
View File
@@ -3,4 +3,4 @@
'@backstage/plugin-app': patch
---
Added new `NavItem`, `NavItems`, and `navItems` prop to `NavContentComponentProps` for auto-discovering navigation items from page extensions. The new `navItems` collection supports `take(id)` and `rest()` methods for placing specific items in custom sidebar positions. The existing `items` prop is now deprecated in favor of `navItems`.
Added new `NavContentNavItem`, `NavContentNavItems`, and `navItems` prop to `NavContentComponentProps` for auto-discovering navigation items from page extensions. The new `navItems` collection supports `take(id)` and `rest()` methods for placing specific items in custom sidebar positions, as well as `withComponent(Component)` which returns a `NavContentNavItemsWithComponent` for rendering items directly as elements. The existing `items` prop is now deprecated in favor of `navItems`.
@@ -702,7 +702,7 @@ export const navModule = createFrontendModule({
Then in the actual implementation for the `SidebarContent` extension, you can provide something like the following, where you implement the entire `Sidebar` component.
The component receives a `navItems` prop with `take(id)` and `rest()` methods for placing specific items in custom positions. Use `navItems.take('page:home')` to take a specific item by extension ID (e.g. for a header slot), and `navItems.rest()` to get all remaining items.
The component receives a `navItems` prop with `take(id)` and `rest()` methods for placing specific items in custom positions. The recommended approach is to use `navItems.withComponent(...)` to define a component for rendering each nav item, and then use the returned `take(id)` and `rest()` methods to get pre-rendered elements directly. Items taken from the renderer are also taken from the main list. Keys are automatically assigned when rendering via `rest()`.
```tsx title="in packages/app/src/modules/nav/Sidebar.tsx"
import { NavContentBlueprint } from '@backstage/plugin-app-react';
@@ -710,34 +710,23 @@ import { NavContentBlueprint } from '@backstage/plugin-app-react';
export const SidebarContent = NavContentBlueprint.make({
params: {
component: ({ navItems }) => {
// Take specific items for custom placement
const home = navItems.take('page:home');
// Get all remaining items
const rest = navItems.rest();
const nav = navItems.withComponent(item => (
<SidebarItem icon={() => item.icon} to={item.href} text={item.title} />
));
return (
<Sidebar>
<SidebarLogo />
{home && (
<SidebarItem to={home.href} text={home.title} icon={home.icon} />
)}
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
<SidebarSearchModal />
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
...
</SidebarGroup>
<SidebarGroup label="Plugins">
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
<SidebarDivider />
<SidebarScrollWrapper>
{rest.map(item => (
<SidebarItem
key={item.node.spec.id}
to={item.href}
text={item.title}
icon={item.icon}
/>
))}
{nav.rest({ sortBy: 'title' })}
</SidebarScrollWrapper>
</SidebarGroup>
</Sidebar>
@@ -749,7 +738,7 @@ export const SidebarContent = NavContentBlueprint.make({
The deprecated `items` prop (a flat list compatible with `<SidebarItem {...item} />`) remains supported for backward compatibility. If you don't want to auto-populate the list, simply remove the rendering of that `SidebarGroup`.
You might also notice that when you're rendering additional fixed icons for plugins (e.g. Search in a dedicated group) these might become duplicated, since that page is also included in `navItems.rest()`. To exclude an item from the remaining list, use `navItems.take('page:search')` before calling `navItems.rest()`. Items that have been taken will not appear in `rest()`.
You might also notice that when you're rendering additional fixed icons for plugins (e.g. Search in a dedicated group) these might become duplicated, since that page is also included in `nav.rest()`. To exclude an item from the remaining list, call `nav.take('page:search')` before calling `nav.rest()` — you can discard the return value. Items that have been taken will not appear in `rest()`.
You can also use the old `NavItemBlueprint`-based nav item extensions to disable items from the nav bar, these can be disabled in config without affecting the page itself:
+12 -18
View File
@@ -29,7 +29,7 @@ import SearchIcon from '@material-ui/icons/Search';
import MenuIcon from '@material-ui/icons/Menu';
import BuildIcon from '@material-ui/icons/Build';
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import { NavContentBlueprint, NavItem } from '@backstage/plugin-app-react';
import { NavContentBlueprint } from '@backstage/plugin-app-react';
import { SidebarSearchModal } from '@backstage/plugin-search';
import { NotificationsSidebarItem } from '@backstage/plugin-notifications';
import {
@@ -98,22 +98,20 @@ const SidebarLogo = () => {
);
};
function item(props?: NavItem) {
if (!props) {
return null;
}
return (
<SidebarItem icon={() => props.icon} to={props.href} text={props.title} />
);
}
export const appModuleNav = createFrontendModule({
pluginId: 'app',
extensions: [
NavContentBlueprint.make({
params: {
component: ({ navItems }) => {
navItems.take('page:home'); // Skip home
const nav = navItems.withComponent(item => (
<SidebarItem
icon={() => item.icon}
to={item.href}
text={item.title}
/>
));
nav.take('page:home'); // Skip home
return (
<Sidebar>
<SidebarLogo />
@@ -122,15 +120,11 @@ export const appModuleNav = createFrontendModule({
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{item(navItems.take('page:home'))}
{item(navItems.take('page:catalog'))}
{item(navItems.take('page:scaffolder'))}
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
<SidebarDivider />
<SidebarScrollWrapper>
{navItems
.rest()
.sort((a, b) => a.title.localeCompare(b.title))
.map(item)}
{nav.rest({ sortBy: 'title' })}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarDivider />
@@ -7,7 +7,7 @@ import {
SidebarSpace,
} from '@backstage/core-components';
import { compatWrapper } from '@backstage/core-compat-api';
import { NavContentBlueprint, NavItem } from '@backstage/plugin-app-react';
import { NavContentBlueprint } from '@backstage/plugin-app-react';
import { SidebarLogo } from './SidebarLogo';
import MenuIcon from '@material-ui/icons/Menu';
import SearchIcon from '@material-ui/icons/Search';
@@ -15,19 +15,17 @@ import { SidebarSearchModal } from '@backstage/plugin-search';
import { UserSettingsSignInAvatar, Settings as SidebarSettings } from '@backstage/plugin-user-settings';
import { NotificationsSidebarItem } from '@backstage/plugin-notifications';
function item(props?: NavItem) {
if (!props) {
return null;
}
return (
<SidebarItem icon={() => props.icon} to={props.href} text={props.title} />
);
}
export const SidebarContent = NavContentBlueprint.make({
params: {
component: ({ navItems }) =>
compatWrapper(
component: ({ navItems }) => {
const nav = navItems.withComponent(item => (
<SidebarItem
icon={() => item.icon}
to={item.href}
text={item.title}
/>
));
return compatWrapper(
<Sidebar>
<SidebarLogo />
<SidebarGroup label="Search" icon={<SearchIcon />} to="/search">
@@ -35,14 +33,11 @@ export const SidebarContent = NavContentBlueprint.make({
</SidebarGroup>
<SidebarDivider />
<SidebarGroup label="Menu" icon={<MenuIcon />}>
{item(navItems.take('page:catalog'))}
{item(navItems.take('page:scaffolder'))}
{nav.take('page:catalog')}
{nav.take('page:scaffolder')}
<SidebarDivider />
<SidebarScrollWrapper>
{navItems
.rest()
.sort((a, b) => a.title.localeCompare(b.title))
.map(item)}
{nav.rest({ sortBy: 'title' })}
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarSpace />
@@ -57,6 +52,7 @@ export const SidebarContent = NavContentBlueprint.make({
<SidebarSettings />
</SidebarGroup>
</Sidebar>,
),
);
},
},
});
+15 -6
View File
@@ -108,11 +108,11 @@ export interface NavContentComponentProps {
to: string;
text: string;
}>;
navItems: NavItems;
navItems: NavContentNavItems;
}
// @public
export interface NavItem {
export interface NavContentNavItem {
href: string;
icon: IconElement;
node: AppNode;
@@ -121,10 +121,19 @@ export interface NavItem {
}
// @public
export interface NavItems {
clone(): NavItems;
rest(): NavItem[];
take(id: string): NavItem | undefined;
export interface NavContentNavItems {
clone(): NavContentNavItems;
rest(): NavContentNavItem[];
take(id: string): NavContentNavItem | undefined;
withComponent(
Component: ComponentType<NavContentNavItem>,
): NavContentNavItemsWithComponent;
}
// @public
export interface NavContentNavItemsWithComponent {
rest(options?: { sortBy?: 'title' }): JSX.Element[];
take(id: string): JSX.Element | null;
}
// @public
@@ -15,8 +15,13 @@
*/
import { AppNode, createRouteRef } from '@backstage/frontend-plugin-api';
import { NavContentBlueprint, NavItem, NavItems } from './NavContentBlueprint';
import {
NavContentBlueprint,
NavContentNavItem,
NavContentNavItems,
} from './NavContentBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { render, screen } from '@testing-library/react';
const routeRef = createRouteRef();
@@ -24,13 +29,41 @@ function mockNode(id: string): AppNode {
return { spec: { id } } as AppNode;
}
function mockNavItems(items: NavItem[]): NavItems {
function mockNavItems(items: NavContentNavItem[]): NavContentNavItems {
const taken = new Set<string>();
return {
take: () => undefined,
rest: () => items,
take(id: string) {
const item = items.find(i => i.node.spec.id === id);
if (item) {
taken.add(id);
}
return item;
},
rest: () => items.filter(i => !taken.has(i.node.spec.id)),
clone() {
return mockNavItems(items);
},
withComponent(Component: (props: NavContentNavItem) => JSX.Element) {
return {
take: (id: string) => {
const item = items.find(i => i.node.spec.id === id);
if (item) {
taken.add(id);
return <Component {...item} />;
}
return null;
},
rest: (options?: { sortBy?: 'title' }) => {
const remaining = items.filter(i => !taken.has(i.node.spec.id));
if (options?.sortBy === 'title') {
remaining.sort((a, b) => a.title.localeCompare(b.title));
}
return remaining.map(item => (
<Component key={item.node.spec.id} {...item} />
));
},
};
},
};
}
@@ -111,7 +144,7 @@ describe('NavContentBlueprint', () => {
});
it('should return a valid component with navItems', () => {
const items: NavItem[] = [
const items: NavContentNavItem[] = [
{
node: mockNode('page:home'),
href: '/',
@@ -173,4 +206,64 @@ describe('NavContentBlueprint', () => {
</div>,
);
});
it('should support withComponent for take and rest', () => {
const items: NavContentNavItem[] = [
{
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 }) => {
const nav = navItems.withComponent(item => (
<a href={item.href}>{item.title}</a>
));
return (
<div>
<header>{nav.take('page:home')}</header>
<nav>{nav.rest()}</nav>
</div>
);
},
},
});
const tester = createExtensionTester(extension);
const Component = tester.get(NavContentBlueprint.dataRefs.component);
render(<Component navItems={mockNavItems(items)} items={[]} />);
const homeLink = screen.getByText('Home');
expect(homeLink).toBeInTheDocument();
expect(homeLink.closest('header')).toBeTruthy();
const catalogLink = screen.getByText('Catalog');
expect(catalogLink).toBeInTheDocument();
expect(catalogLink.closest('nav')).toBeTruthy();
const docsLink = screen.getByText('Docs');
expect(docsLink).toBeInTheDocument();
expect(docsLink.closest('nav')).toBeTruthy();
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ComponentType } from 'react';
import {
AppNode,
IconComponent,
@@ -30,7 +31,7 @@ import {
*
* @public
*/
export interface NavItem {
export interface NavContentNavItem {
/** The app node of the page extension that this nav item points to */
node: AppNode;
/** The resolved route path */
@@ -43,19 +44,36 @@ export interface NavItem {
routeRef: RouteRef;
}
/**
* A pre-bound renderer that wraps {@link NavContentNavItems} with a component,
* so that `take` and `rest` return rendered elements directly.
*
* @public
*/
export interface NavContentNavItemsWithComponent {
/** Render and take a specific item by extension ID. Returns null if not found. */
take(id: string): JSX.Element | null;
/** Render all remaining items not yet taken, optionally sorted. */
rest(options?: { sortBy?: 'title' }): JSX.Element[];
}
/**
* 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 {
export interface NavContentNavItems {
/** Take an item by extension ID, removing it from the collection. */
take(id: string): NavItem | undefined;
take(id: string): NavContentNavItem | undefined;
/** All items not yet taken. */
rest(): NavItem[];
rest(): NavContentNavItem[];
/** Create a copy of the collection preserving the current taken state. */
clone(): NavItems;
clone(): NavContentNavItems;
/** Create a renderer that wraps take/rest to return pre-rendered elements. */
withComponent(
Component: ComponentType<NavContentNavItem>,
): NavContentNavItemsWithComponent;
}
/**
@@ -68,7 +86,7 @@ export interface NavContentComponentProps {
* Nav items auto-discovered from page extensions, with take/rest semantics
* for placing specific items in specific positions.
*/
navItems: NavItems;
navItems: NavContentNavItems;
/**
* Flat list of nav items for simple rendering. Use `navItems` for more
+3 -2
View File
@@ -20,8 +20,9 @@ export { NavContentBlueprint } from './NavContentBlueprint';
export type {
NavContentComponent,
NavContentComponentProps,
NavItem,
NavItems,
NavContentNavItem,
NavContentNavItemsWithComponent,
NavContentNavItems,
} from './NavContentBlueprint';
export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
+28 -10
View File
@@ -31,24 +31,24 @@ import {
NavContentBlueprint,
NavContentComponent,
NavContentComponentProps,
NavItem,
NavItems,
NavContentNavItem,
NavContentNavItems,
} 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>;
class NavItemBag implements NavContentNavItems {
readonly #items: NavContentNavItem[];
readonly #index: Map<string, NavContentNavItem>;
readonly #taken: Set<string>;
constructor(items: NavItem[], taken?: Iterable<string>) {
constructor(items: NavContentNavItem[], 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 {
take(id: string): NavContentNavItem | undefined {
const item = this.#index.get(id);
if (item) {
this.#taken.add(id);
@@ -56,13 +56,31 @@ class NavItemBag implements NavItems {
return item;
}
rest(): NavItem[] {
rest(): NavContentNavItem[] {
return this.#items.filter(item => !this.#taken.has(item.node.spec.id));
}
clone(): NavItems {
clone(): NavContentNavItems {
return new NavItemBag(this.#items, this.#taken);
}
withComponent(Component: (props: NavContentNavItem) => JSX.Element) {
return {
take: (id: string) => {
const item = this.take(id);
return item ? <Component {...item} /> : null;
},
rest: (options?: { sortBy?: 'title' }) => {
const items = this.rest();
if (options?.sortBy === 'title') {
items.sort((a, b) => a.title.localeCompare(b.title));
}
return items.map(item => (
<Component key={item.node.spec.id} {...item} />
));
},
};
}
}
function DefaultNavContent(props: NavContentComponentProps) {
@@ -138,7 +156,7 @@ function NavContentRenderer(props: {
>(props.legacyNavItems.map(item => [item.routeRef, item]));
const pageNodes = routesNode.edges.attachments.get('routes') ?? [];
const items = pageNodes.flatMap((node): NavItem[] => {
const items = pageNodes.flatMap((node): NavContentNavItem[] => {
if (!node.instance || node.spec.disabled) {
return [];
}