app-react: API cleanup and component utility for nav items
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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>,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user