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
+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';