Merge pull request #30621 from backstage/rugvip/nav-content

NFS: add NavContentBlueprint
This commit is contained in:
Patrik Oldsberg
2025-07-25 12:06:18 +02:00
committed by GitHub
15 changed files with 507 additions and 238 deletions
+31
View File
@@ -0,0 +1,31 @@
---
'@backstage/frontend-plugin-api': minor
---
**BREAKING**: The `NavLogoBlueprint` has been removed and replaced by `NavContentBlueprint`, which instead replaces the entire navbar. The default navbar has also been switched to a more minimal implementation.
To use `NavContentBlueprint` to install new logos, you can use it as follows:
```tsx
NavContentBlueprint.make({
params: {
component: ({ items }) => {
return compatWrapper(
<Sidebar>
<SidebarLogo />
{/* Other sidebar content */}
<SidebarScrollWrapper>
{items.map((item, index) => (
<SidebarItem {...item} key={index} />
))}
</SidebarScrollWrapper>
{/* Other sidebar content */}
</Sidebar>,
);
},
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': minor
---
Updated the `app/nav` extension to use the new `NavContentBlueprint`, and removed support for extensions created with the now removed `NavLogoBlueprint`.
@@ -288,6 +288,7 @@ namespaced
namespaces
Namespaces
namespacing
navbar
neuro
newrelic
nginx
@@ -161,10 +161,10 @@ Extension responsible for rendering the logo and items in the app's sidebar.
#### Inputs
| Name | Description | Type | Optional | Default | Extension creator |
| ----- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------- | -------------------------------------------------------------------------------------------------------- |
| logos | A nav logos object. | [createNavLogoExtension.logoElementsDataRef](https://backstage.io/docs/reference/frontend-plugin-api.createnavlogoextension.logoelementsdataref) | true | - | [createNavLogoExtension](https://backstage.io/docs/reference/frontend-plugin-api.createnavlogoextension) |
| items | Nav items target objects. | [createNavItemExtension.targetDataRef](https://backstage.io/docs/reference/frontend-plugin-api.createnavitemextension.targetdataref) | true | - | [createNavItemExtension](https://backstage.io/docs/reference/frontend-plugin-api.createnavitemextension) |
| Name | Description | Type | Optional | Default | Extension creator |
| ------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------- | -------------------------------------------------------------------------------------------------------- |
| content | Overrides the default content of the navbar. | [NavContentBlueprint.dataRefs.component](https://backstage.io/docs/reference/frontend-plugin-api.navcontentblueprint) | true | - | [NavContentBlueprint](https://backstage.io/docs/reference/frontend-plugin-api.navcontentblueprint) |
| items | Nav items target objects. | [createNavItemExtension.targetDataRef](https://backstage.io/docs/reference/frontend-plugin-api.createnavitemextension.targetdataref) | true | - | [createNavItemExtension](https://backstage.io/docs/reference/frontend-plugin-api.createnavitemextension) |
### App routes
+7
View File
@@ -26,6 +26,13 @@ app:
# - apis.plugin.graphiql.browse.gitlab: true
# - graphiql-endpoint:graphiql/gitlab: true
- nav-item:search: false
- nav-item:user-settings: false
- nav-item:catalog
- nav-item:api-docs
- nav-item:scaffolder
- nav-item:app-visualizer
# Pages
- page:catalog/entity:
config:
+2
View File
@@ -44,6 +44,7 @@ import { convertLegacyPlugin } from '@backstage/core-compat-api';
import { convertLegacyPageExtension } from '@backstage/core-compat-api';
import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
import { pluginInfoResolver } from './pluginInfoResolver';
import { appModuleNav } from './modules/appModuleNav';
/*
@@ -130,6 +131,7 @@ const app = createApp({
appVisualizerPlugin,
kubernetesPlugin,
notFoundErrorPageModule,
appModuleNav,
customHomePageModule,
...collectedLegacyPlugins,
],
File diff suppressed because one or more lines are too long
+48 -35
View File
@@ -27,6 +27,7 @@ import { bitbucketServerAuthApiRef } from '@backstage/core-plugin-api';
import { ComponentType } from 'react';
import { ConfigApi } from '@backstage/core-plugin-api';
import { configApiRef } from '@backstage/core-plugin-api';
import { ConfigurableExtensionDataRef as ConfigurableExtensionDataRef_2 } from '@backstage/frontend-plugin-api';
import { createApiFactory } from '@backstage/core-plugin-api';
import { createApiRef } from '@backstage/core-plugin-api';
import { createTranslationMessages } from '@backstage/core-plugin-api/alpha';
@@ -39,6 +40,7 @@ import { ErrorApiError } from '@backstage/core-plugin-api';
import { ErrorApiErrorContext } from '@backstage/core-plugin-api';
import { errorApiRef } from '@backstage/core-plugin-api';
import { Expand } from '@backstage/types';
import { ExtensionBlueprint as ExtensionBlueprint_2 } from '@backstage/frontend-plugin-api';
import { FeatureFlag } from '@backstage/core-plugin-api';
import { FeatureFlagsApi } from '@backstage/core-plugin-api';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
@@ -49,7 +51,8 @@ import { fetchApiRef } from '@backstage/core-plugin-api';
import { githubAuthApiRef } from '@backstage/core-plugin-api';
import { gitlabAuthApiRef } from '@backstage/core-plugin-api';
import { googleAuthApiRef } from '@backstage/core-plugin-api';
import { IconComponent as IconComponent_2 } from '@backstage/core-plugin-api';
import { IconComponent as IconComponent_2 } from '@backstage/frontend-plugin-api';
import { IconComponent as IconComponent_3 } from '@backstage/core-plugin-api';
import { IdentityApi } from '@backstage/core-plugin-api';
import { identityApiRef } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
@@ -70,6 +73,7 @@ import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
import { PropsWithChildren } from 'react';
import { ReactNode } from 'react';
import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api';
import { SessionApi } from '@backstage/core-plugin-api';
import { SessionState } from '@backstage/core-plugin-api';
import { SignInPageProps } from '@backstage/core-plugin-api';
@@ -1413,19 +1417,59 @@ export { identityApiRef };
export { microsoftAuthApiRef };
// @public
export const NavContentBlueprint: ExtensionBlueprint_2<{
kind: 'nav-content';
name: undefined;
params: {
component: NavContentComponent;
};
output: ConfigurableExtensionDataRef_2<
NavContentComponent,
'core.nav-content.component',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: {
component: ConfigurableExtensionDataRef_2<
NavContentComponent,
'core.nav-content.component',
{}
>;
};
}>;
// @public
export type NavContentComponent = (
props: NavContentComponentProps,
) => JSX.Element | null;
// @public
export interface NavContentComponentProps {
items: Array<{
icon: IconComponent_2;
title: string;
routeRef: RouteRef_2<undefined>;
to: string;
text: string;
}>;
}
// @public
export const NavItemBlueprint: ExtensionBlueprint<{
kind: 'nav-item';
name: undefined;
params: {
title: string;
icon: IconComponent_2;
icon: IconComponent_3;
routeRef: RouteRef<undefined>;
};
output: ConfigurableExtensionDataRef<
{
title: string;
icon: IconComponent_2;
icon: IconComponent_3;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
@@ -1438,7 +1482,7 @@ export const NavItemBlueprint: ExtensionBlueprint<{
target: ConfigurableExtensionDataRef<
{
title: string;
icon: IconComponent_2;
icon: IconComponent_3;
routeRef: RouteRef<undefined>;
},
'core.nav-item.target',
@@ -1447,37 +1491,6 @@ export const NavItemBlueprint: ExtensionBlueprint<{
};
}>;
// @public
export const NavLogoBlueprint: ExtensionBlueprint<{
kind: 'nav-logo';
name: undefined;
params: {
logoIcon: JSX.Element;
logoFull: JSX.Element;
};
output: ConfigurableExtensionDataRef<
{
logoIcon?: JSX.Element;
logoFull?: JSX.Element;
},
'core.nav-logo.logo-elements',
{}
>;
inputs: {};
config: {};
configInput: {};
dataRefs: {
logoElements: ConfigurableExtensionDataRef<
{
logoIcon?: JSX.Element;
logoFull?: JSX.Element;
},
'core.nav-logo.logo-elements',
{}
>;
};
}>;
export { OAuthApi };
export { OAuthRequestApi };
@@ -0,0 +1,112 @@
/*
* 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 { createRouteRef } from '@backstage/frontend-plugin-api';
import { NavContentBlueprint } from './NavContentBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
const routeRef = createRouteRef();
describe('NavContentBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const extension = NavContentBlueprint.make({
params: {
component: () => <div>Nav content</div>,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "app/nav",
"input": "content",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "nav-content",
"name": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return a valid component', () => {
const extension = NavContentBlueprint.make({
name: 'test',
params: {
component: () => <div>Nav content</div>,
},
});
const tester = createExtensionTester(extension);
expect(
tester.get(NavContentBlueprint.dataRefs.component)({ items: [] }),
).toEqual(<div>Nav content</div>);
});
it('should return a valid component with items', () => {
const extension = NavContentBlueprint.make({
name: 'test',
params: {
component: ({ items }) => (
<div>
Items:
{items.map((item, index) => (
<a key={index} href={item.to}>
{item.title}
</a>
))}
</div>
),
},
});
const tester = createExtensionTester(extension);
expect(
tester.get(NavContentBlueprint.dataRefs.component)({
items: [
{
to: '/',
text: 'Home',
title: 'Home',
icon: () => null,
routeRef,
},
],
}),
).toEqual(
<div>
Items:
{[
<a key={0} href="/">
Home
</a>,
]}
</div>,
);
});
});
@@ -0,0 +1,74 @@
/*
* 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 { IconComponent, RouteRef } from '@backstage/frontend-plugin-api';
import { createExtensionBlueprint, createExtensionDataRef } from '../wiring';
/**
* The props for the {@link NavContentComponent}.
*
* @public
*/
export interface NavContentComponentProps {
/**
* The nav items available to the component. These are all the items created
* with the {@link NavItemBlueprint} in the app.
*
* 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.
*/
items: Array<{
// Original props from nav items
icon: IconComponent;
title: string;
routeRef: RouteRef<undefined>;
// Additional props to simplify item rendering
to: string;
text: string;
}>;
}
/**
* A component that renders the nav bar content, to be passed to the {@link NavContentBlueprint}.
*
* @public
*/
export type NavContentComponent = (
props: NavContentComponentProps,
) => JSX.Element | null;
const componentDataRef = createExtensionDataRef<NavContentComponent>().with({
id: 'core.nav-content.component',
});
/**
* Creates an extension that replaces the entire nav bar with your own component.
*
* @public
*/
export const NavContentBlueprint = createExtensionBlueprint({
kind: 'nav-content',
attachTo: { id: 'app/nav', input: 'content' },
output: [componentDataRef],
dataRefs: {
component: componentDataRef,
},
*factory(params: { component: NavContentComponent }) {
yield componentDataRef(params.component);
},
});
@@ -1,72 +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 { NavLogoBlueprint } from './NavLogoBlueprint';
import { createExtensionTester } from '@backstage/frontend-test-utils';
describe('NavLogoBlueprint', () => {
it('should create an extension with sensible defaults', () => {
const extension = NavLogoBlueprint.make({
params: {
logoFull: <div>Logo Full</div>,
logoIcon: <div>Logo Icon</div>,
},
});
expect(extension).toMatchInlineSnapshot(`
{
"$$type": "@backstage/ExtensionDefinition",
"T": undefined,
"attachTo": {
"id": "app/nav",
"input": "logos",
},
"configSchema": undefined,
"disabled": false,
"factory": [Function],
"inputs": {},
"kind": "nav-logo",
"name": undefined,
"output": [
[Function],
],
"override": [Function],
"toString": [Function],
"version": "v2",
}
`);
});
it('should return a valid component ref', () => {
const logoFull = <div>Logo Full</div>;
const logoIcon = <div>Logo Icon</div>;
const extension = NavLogoBlueprint.make({
name: 'test',
params: {
logoFull,
logoIcon,
},
});
const tester = createExtensionTester(extension);
expect(tester.get(NavLogoBlueprint.dataRefs.logoElements)).toEqual({
logoFull,
logoIcon,
});
});
});
@@ -1,48 +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 { createExtensionBlueprint, createExtensionDataRef } from '../wiring';
const logoElementsDataRef = createExtensionDataRef<{
logoIcon?: JSX.Element;
logoFull?: JSX.Element;
}>().with({ id: 'core.nav-logo.logo-elements' });
/**
* Creates an extension that replaces the logo in the nav bar with your own.
*
* @public
*/
export const NavLogoBlueprint = createExtensionBlueprint({
kind: 'nav-logo',
attachTo: { id: 'app/nav', input: 'logos' },
output: [logoElementsDataRef],
dataRefs: {
logoElements: logoElementsDataRef,
},
*factory({
logoIcon,
logoFull,
}: {
logoIcon: JSX.Element;
logoFull: JSX.Element;
}) {
yield logoElementsDataRef({
logoIcon,
logoFull,
});
},
});
@@ -18,8 +18,12 @@ export { ApiBlueprint } from './ApiBlueprint';
export { AppRootElementBlueprint } from './AppRootElementBlueprint';
export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint';
export { IconBundleBlueprint } from './IconBundleBlueprint';
export {
NavContentBlueprint,
type NavContentComponent,
type NavContentComponentProps,
} from './NavContentBlueprint';
export { NavItemBlueprint } from './NavItemBlueprint';
export { NavLogoBlueprint } from './NavLogoBlueprint';
export { PageBlueprint } from './PageBlueprint';
export { RouterBlueprint } from './RouterBlueprint';
export { SignInPageBlueprint } from './SignInPageBlueprint';
+4 -6
View File
@@ -16,6 +16,7 @@ import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { IconComponent as IconComponent_2 } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { NavContentComponent } from '@backstage/frontend-plugin-api';
import { ReactNode } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SignInPageProps } from '@backstage/core-plugin-api';
@@ -100,13 +101,10 @@ const appPlugin: FrontendPlugin<
optional: false;
}
>;
logos: ExtensionInput<
content: ExtensionInput<
ConfigurableExtensionDataRef<
{
logoIcon?: JSX.Element;
logoFull?: JSX.Element;
},
'core.nav-logo.logo-elements',
NavContentComponent,
'core.nav-content.component',
{}
>,
{
+72 -72
View File
@@ -18,93 +18,93 @@ import {
createExtension,
coreExtensionData,
createExtensionInput,
useRouteRef,
NavItemBlueprint,
NavLogoBlueprint,
NavContentBlueprint,
NavContentComponentProps,
routeResolutionApiRef,
IconComponent,
RouteRef,
useApi,
NavContentComponent,
} from '@backstage/frontend-plugin-api';
import { makeStyles } from '@material-ui/core/styles';
import {
Sidebar,
useSidebarOpenState,
Link,
sidebarConfig,
SidebarDivider,
SidebarItem,
} from '@backstage/core-components';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import LogoIcon from '../../../../packages/app/src/components/Root/LogoIcon';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import LogoFull from '../../../../packages/app/src/components/Root/LogoFull';
const useSidebarLogoStyles = makeStyles({
root: {
width: sidebarConfig.drawerWidthClosed,
height: 3 * sidebarConfig.logoHeight,
display: 'flex',
flexFlow: 'row nowrap',
alignItems: 'center',
marginBottom: -14,
},
link: {
width: sidebarConfig.drawerWidthClosed,
marginLeft: 24,
},
});
const SidebarLogo = (
props: (typeof NavLogoBlueprint.dataRefs.logoElements)['T'],
) => {
const classes = useSidebarLogoStyles();
const { isOpen } = useSidebarOpenState();
import { Sidebar, SidebarItem } from '@backstage/core-components';
import { useMemo } from 'react';
function DefaultNavContent(props: NavContentComponentProps) {
return (
<div className={classes.root}>
<Link to="/" underline="none" className={classes.link} aria-label="Home">
{isOpen
? props?.logoFull ?? <LogoFull />
: props?.logoIcon ?? <LogoIcon />}
</Link>
</div>
<Sidebar>
{props.items.map((item, index) => (
<SidebarItem
to={item.to}
icon={item.icon}
text={item.text}
key={index}
/>
))}
</Sidebar>
);
};
}
const SidebarNavItem = (
props: (typeof NavItemBlueprint.dataRefs.target)['T'],
) => {
const { icon: Icon, title, routeRef } = props;
const link = useRouteRef(routeRef);
if (!link) {
return null;
}
// TODO: Support opening modal, for example, the search one
return <SidebarItem to={link()} icon={Icon} text={title} />;
};
// 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.
function NavContentRenderer(props: {
Content: NavContentComponent;
items: Array<{
title: string;
icon: IconComponent;
routeRef: RouteRef<undefined>;
}>;
}) {
const routeResolutionApi = useApi(routeResolutionApiRef);
const items = useMemo(() => {
return props.items.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 [];
}
return [
{
to: link(),
text: item.title,
icon: item.icon,
title: item.title,
routeRef: item.routeRef,
},
];
});
}, [props.items, routeResolutionApi]);
return <props.Content items={items} />;
}
export const AppNav = createExtension({
name: 'nav',
attachTo: { id: 'app/layout', input: 'nav' },
inputs: {
items: createExtensionInput([NavItemBlueprint.dataRefs.target]),
logos: createExtensionInput([NavLogoBlueprint.dataRefs.logoElements], {
content: createExtensionInput([NavContentBlueprint.dataRefs.component], {
singleton: true,
optional: true,
}),
},
output: [coreExtensionData.reactElement],
factory: ({ inputs }) => [
coreExtensionData.reactElement(
<Sidebar>
<SidebarLogo
{...inputs.logos?.get(NavLogoBlueprint.dataRefs.logoElements)}
/>
<SidebarDivider />
{inputs.items.map((item, index) => (
<SidebarNavItem
{...item.get(NavItemBlueprint.dataRefs.target)}
key={index}
/>
))}
</Sidebar>,
),
],
*factory({ inputs }) {
const Content =
inputs.content?.get(NavContentBlueprint.dataRefs.component) ??
DefaultNavContent;
yield coreExtensionData.reactElement(
<NavContentRenderer
items={inputs.items.map(item =>
item.get(NavItemBlueprint.dataRefs.target),
)}
Content={Content}
/>,
);
},
});