Remove NavItemBlueprint in favor of page-based nav discovery

Drop the deprecated NavItemBlueprint from the public API and migrate core
plugins to set title and icon on PageBlueprint instead. AppNav keeps
backward compatibility for legacy nav-item extensions via an internal
core.nav-item.target data ref.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Patrik Oldsberg
2026-05-19 11:00:46 +02:00
parent 6651f2be06
commit 10e5d6f8aa
45 changed files with 165 additions and 905 deletions
@@ -6,12 +6,6 @@ app:
packages: all
extensions:
# Disable the nav items that we're manually rendering in packages/app/src/modules/nav/Sidebar.tsx
- nav-item:search: false
- nav-item:user-settings: false
- nav-item:catalog: false
- nav-item:scaffolder: false
# Configure the catalog index page to be the root page, this is normally mounted on /catalog
- page:catalog:
config:
@@ -1821,43 +1821,6 @@ export const microsoftAuthApiRef: ApiRef_2<
readonly $$type: '@backstage/ApiRef';
};
// @public @deprecated
export const NavItemBlueprint: ExtensionBlueprint_2<{
kind: 'nav-item';
params: {
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
};
output: ExtensionDataRef_2<
{
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
{}
>;
inputs: {};
config: {
title: string | undefined;
};
configInput: {
title?: string | undefined;
};
dataRefs: {
target: ConfigurableExtensionDataRef_2<
{
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
{}
>;
};
}>;
// @public (undocumented)
export const NotFoundErrorPage: {
(props: NotFoundErrorPageProps): JSX.Element | null;
@@ -1,98 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { createRouteRef } from '../routing';
import { NavItemBlueprint } from './NavItemBlueprint';
describe('NavItemBlueprint', () => {
const mockRouteRef = createRouteRef();
const MockIcon = () => null;
it('should return an extension with sensible defaults', () => {
const extension = NavItemBlueprint.make({
params: {
icon: MockIcon,
routeRef: mockRouteRef,
title: 'TEST',
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "app/nav",
"input": "items",
},
"configSchema": {
"parse": [Function],
"schema": [Function],
},
"disabled": false,
"factory": [Function],
"if": undefined,
"inputs": {},
"kind": "nav-item",
"name": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return the correct extension data', () => {
const extension = NavItemBlueprint.make({
params: {
icon: MockIcon,
routeRef: mockRouteRef,
title: 'TEST',
},
});
const tester = createExtensionTester(extension);
expect(tester.get(NavItemBlueprint.dataRefs.target)).toEqual({
title: 'TEST',
icon: MockIcon,
routeRef: mockRouteRef,
});
});
it('should allow overriding of the title using config', () => {
const extension = NavItemBlueprint.make({
params: {
icon: MockIcon,
routeRef: mockRouteRef,
title: 'TEST',
},
});
const tester = createExtensionTester(extension, {
config: { title: 'OVERRIDDEN' },
});
expect(tester.get(NavItemBlueprint.dataRefs.target)).toEqual({
title: 'OVERRIDDEN',
icon: MockIcon,
routeRef: mockRouteRef,
});
});
});
@@ -1,66 +0,0 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { z } from 'zod/v4';
import { IconComponent } from '../icons/types';
import { RouteRef } from '../routing';
import { createExtensionBlueprint, createExtensionDataRef } from '../wiring';
// TODO(Rugvip): Should this be broken apart into separate refs? title/icon/routeRef
const targetDataRef = createExtensionDataRef<{
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
}>().with({ id: 'core.nav-item.target' });
/**
* Creates extensions that make up the items of the nav bar.
*
* @public
* @deprecated Nav items are now automatically inferred from `PageBlueprint`
* extensions based on their `title` and `icon` params. You can remove your
* `NavItemBlueprint` usage and instead pass `title` and `icon` directly to
* the `PageBlueprint`.
*/
export const NavItemBlueprint = createExtensionBlueprint({
kind: 'nav-item',
attachTo: { id: 'app/nav', input: 'items' },
output: [targetDataRef],
dataRefs: {
target: targetDataRef,
},
factory: (
{
icon,
routeRef,
title,
}: {
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
},
{ config },
) => [
targetDataRef({
title: config.title ?? title,
icon,
routeRef,
}),
],
configSchema: {
title: z.string().optional(),
},
});
@@ -20,7 +20,6 @@ export {
} from './AnalyticsImplementationBlueprint';
export { ApiBlueprint } from './ApiBlueprint';
export { AppRootElementBlueprint } from './AppRootElementBlueprint';
export { NavItemBlueprint } from './NavItemBlueprint';
export { PageBlueprint } from './PageBlueprint';
export { SubPageBlueprint } from './SubPageBlueprint';
export { PluginHeaderActionBlueprint } from './PluginHeaderActionBlueprint';
@@ -22,12 +22,12 @@ import { ConfigReader } from '@backstage/config';
import { JsonObject } from '@backstage/types';
import {
createExtension,
createExtensionDataRef,
ExtensionDefinition,
coreExtensionData,
RouteRef,
useRouteRef,
IconComponent,
NavItemBlueprint,
createFrontendPlugin,
FrontendFeature,
createFrontendModule,
@@ -49,6 +49,13 @@ const DEFAULT_MOCK_CONFIG = {
backend: { baseUrl: 'http://localhost:7007' },
};
// Must match the data ref in @backstage/plugin-app/src/extensions/legacyNavItem.ts
const legacyNavItemTargetDataRef = createExtensionDataRef<{
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
}>().with({ id: 'core.nav-item.target' });
/**
* Options to customize the behavior of the test app.
* @public
@@ -143,7 +150,7 @@ const appPluginOverride = appPlugin.withOverrides({
{inputs.items.map(
(item: (typeof inputs.items)[number], index: number) => {
const { icon, title, routeRef } = item.get(
NavItemBlueprint.dataRefs.target,
legacyNavItemTargetDataRef,
);
return (