Align NFS headers with existing BUI page patterns

Update migrated NFS pages to use the existing HeaderPage contract instead of extending Backstage UI, and move DevTools to real subpages with the legacy DevTools content blueprint removed.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-07 20:53:52 +01:00
parent aa29b508d1
commit f4a1edd2b0
18 changed files with 245 additions and 269 deletions
@@ -0,0 +1,7 @@
---
'@backstage/plugin-catalog-unprocessed-entities': patch
'@backstage/plugin-devtools': patch
'@backstage/plugin-devtools-react': minor
---
Removed the deprecated `DevToolsContentBlueprint` from `@backstage/plugin-devtools-react`. DevTools pages in the new frontend system now use `SubPageBlueprint` tabs instead, and the catalog unprocessed entities alpha extension now attaches to DevTools as a subpage.
@@ -195,11 +195,7 @@ export const NfsApiExplorerPage = (props: DefaultApiExplorerPageProps) => {
return (
<>
<HeaderPage
title={t('defaultApiExplorerPage.title')}
subtitle={generatedSubtitle}
customActions={headerActions}
/>
<HeaderPage title={generatedSubtitle} customActions={headerActions} />
<Content>
<ApiExplorerPageContent
initiallySelectedFilter={initiallySelectedFilter}
@@ -277,8 +277,7 @@ function CatalogGraphPageContent(
return (
<>
<HeaderPage
title={t('catalogGraphPage.title')}
subtitle={subtitle}
title={subtitle || t('catalogGraphPage.title')}
customActions={headerActions}
/>
<Content stretch className={classes.content}>
@@ -56,7 +56,6 @@
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-catalog-unprocessed-entities-common": "workspace:^",
"@backstage/plugin-devtools-react": "workspace:^",
"@backstage/ui": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/icons": "^4.9.1",
@@ -14,23 +14,25 @@
* limitations under the License.
*/
import { DevToolsContentBlueprint } from '@backstage/plugin-devtools-react';
import { SubPageBlueprint } from '@backstage/frontend-plugin-api';
import { Content } from '@backstage/core-components';
/**
* DevTools content for catalog unprocessed entities.
*
* @alpha
*/
export const unprocessedEntitiesDevToolsContent = DevToolsContentBlueprint.make(
{
disabled: true,
params: {
path: 'unprocessed-entities',
title: 'Unprocessed Entities',
loader: () =>
import('../components/UnprocessedEntities').then(m => (
export const unprocessedEntitiesDevToolsContent = SubPageBlueprint.make({
attachTo: { id: 'page:devtools', input: 'pages' },
name: 'unprocessed-entities',
params: {
path: 'unprocessed-entities',
title: 'Unprocessed Entities',
loader: () =>
import('../components/UnprocessedEntities').then(m => (
<Content>
<m.UnprocessedEntitiesContent />
)),
},
</Content>
)),
},
);
});
@@ -49,7 +49,6 @@ import {
EntityRefLink,
InspectEntityDialog,
UnregisterEntityDialog,
EntityDisplayName,
FavoriteEntity,
} from '@backstage/plugin-catalog-react';
@@ -104,25 +103,6 @@ const useStyles = makeStyles(theme => ({
},
}));
function EntityHeaderTitle() {
const { entity } = useAsyncEntity();
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const { headerTitle: title } = headerProps(kind, namespace, name, entity);
return (
<Box display="inline-flex" alignItems="center" height="1em" maxWidth="100%">
<Box
component="span"
textOverflow="ellipsis"
whiteSpace="nowrap"
overflow="hidden"
>
{entity ? <EntityDisplayName entityRef={entity} hideIcon /> : title}
</Box>
{entity && <FavoriteEntity entity={entity} />}
</Box>
);
}
function EntityHeaderSubtitle(props: { parentEntityRelations?: string[] }) {
const { parentEntityRelations } = props;
const classes = useStyles();
@@ -194,6 +174,8 @@ export function EntityHeader(props: {
subtitle,
} = props;
const { entity } = useAsyncEntity();
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const { headerTitle } = headerProps(kind, namespace, name, entity);
const location = useLocation();
const navigate = useNavigate();
@@ -250,40 +232,40 @@ export function EntityHeader(props: {
);
const inspectDialogOpen = typeof selectedInspectEntityDialogTab === 'string';
const headerTitle = (
<Box display="flex" flexDirection="column">
{title ?? <EntityHeaderTitle />}
{entity && (
<Box mt={1}>
<EntityLabels entity={entity} />
</Box>
)}
</Box>
const headerSubtitle = subtitle ?? (
<EntityHeaderSubtitle parentEntityRelations={parentEntityRelations} />
);
const renderedTitle = typeof title === 'string' ? title : headerTitle;
return (
<>
<HeaderPage
title={headerTitle}
subtitle={
subtitle ?? (
<EntityHeaderSubtitle
parentEntityRelations={parentEntityRelations}
/>
)
}
title={renderedTitle}
customActions={
entity ? (
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
contextMenuItems={contextMenuItems}
onInspectEntity={openInspectEntityDialog}
onUnregisterEntity={openUnregisterEntityDialog}
/>
<>
<FavoriteEntity entity={entity} />
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
contextMenuItems={contextMenuItems}
onInspectEntity={openInspectEntityDialog}
onUnregisterEntity={openUnregisterEntityDialog}
/>
</>
) : undefined
}
/>
{(headerSubtitle || entity) && (
<Box mt={2}>
{headerSubtitle}
{entity && (
<Box mt={headerSubtitle ? 1 : 0}>
<EntityLabels entity={entity} />
</Box>
)}
</Box>
)}
{entity && (
<>
<InspectEntityDialog
@@ -1,87 +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 {
coreExtensionData,
createExtensionBlueprint,
ExtensionBoundary,
RouteRef,
} from '@backstage/frontend-plugin-api';
import { JSX } from 'react';
/**
* Parameters for creating a DevTools route extension
* @public
*/
export interface DevToolsContentBlueprintParams {
path: string;
title: string;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef;
}
/**
* Extension blueprint for creating DevTools content pages (appearing as tabs)
*
* @example
* ```tsx
* const myDevToolsContent = DevToolsContentBlueprint.make({
* {
* params: {
* path: 'my-dev-tools',
* title: 'My DevTools',
* loader: () =>
* import('../components/MyDevTools').then(m =>
* compatWrapper(<m.MyDevTools />),
* ),
* },
* },
* });
* ```
* @public
*/
export const DevToolsContentBlueprint = createExtensionBlueprint({
kind: 'devtools-content',
attachTo: { id: 'page:devtools', input: 'contents' },
output: [
coreExtensionData.reactElement,
coreExtensionData.routePath,
coreExtensionData.routeRef.optional(),
coreExtensionData.title,
],
config: {
schema: {
path: z => z.string().optional(),
title: z => z.string().optional(),
},
},
*factory(params: DevToolsContentBlueprintParams, { node, config }) {
const path = config.path ?? params.path;
const title = config.title ?? params.title;
yield coreExtensionData.reactElement(
ExtensionBoundary.lazy(node, params.loader),
);
yield coreExtensionData.routePath(path);
yield coreExtensionData.title(title);
if (params.routeRef) {
yield coreExtensionData.routeRef(params.routeRef);
}
},
});
-4
View File
@@ -19,7 +19,3 @@
*
* @packageDocumentation
*/
export {
type DevToolsContentBlueprintParams,
DevToolsContentBlueprint,
} from './devToolsContentBlueprint';
-1
View File
@@ -59,7 +59,6 @@
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-devtools-common": "workspace:^",
"@backstage/plugin-devtools-react": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
"@backstage/ui": "workspace:^",
"@material-ui/core": "^4.9.13",
+71 -31
View File
@@ -21,13 +21,19 @@ import {
ApiBlueprint,
PageBlueprint,
NavItemBlueprint,
createExtensionInput,
coreExtensionData,
SubPageBlueprint,
} from '@backstage/frontend-plugin-api';
import { devToolsApiRef, DevToolsClient } from '../api';
import BuildIcon from '@material-ui/icons/Build';
import { Content } from '@backstage/core-components';
import { rootRouteRef } from '../routes';
import {
devToolsConfigReadPermission,
devToolsInfoReadPermission,
} from '@backstage/plugin-devtools-common';
import { devToolsTaskSchedulerReadPermission } from '@backstage/plugin-devtools-common/alpha';
import { RequirePermission } from '@backstage/plugin-permission-react';
/** @alpha */
export const devToolsApi = ApiBlueprint.make({
@@ -44,35 +50,62 @@ export const devToolsApi = ApiBlueprint.make({
});
/** @alpha */
export const devToolsPage = PageBlueprint.makeWithOverrides({
inputs: {
contents: createExtensionInput(
[
coreExtensionData.reactElement,
coreExtensionData.routePath,
coreExtensionData.routeRef.optional(),
coreExtensionData.title,
],
{
optional: true,
},
),
export const devToolsPage = PageBlueprint.make({
params: {
path: '/devtools',
routeRef: rootRouteRef,
title: 'DevTools',
},
factory(originalFactory, { inputs }) {
return originalFactory({
path: '/devtools',
routeRef: rootRouteRef,
loader: () => {
const contents = inputs.contents.map(content => ({
path: content.get(coreExtensionData.routePath),
title: content.get(coreExtensionData.title),
children: content.get(coreExtensionData.reactElement),
}));
return import('../components/DevToolsPage/DevToolsPage').then(m => (
<m.NfsDevToolsPage contents={contents} />
));
},
});
});
/** @alpha */
export const devToolsInfoPage = SubPageBlueprint.make({
name: 'info',
params: {
path: 'info',
title: 'Info',
loader: () =>
import('../components/Content').then(m => (
<Content>
<RequirePermission permission={devToolsInfoReadPermission}>
<m.InfoContent />
</RequirePermission>
</Content>
)),
},
});
/** @alpha */
export const devToolsConfigPage = SubPageBlueprint.make({
name: 'config',
params: {
path: 'config',
title: 'Config',
loader: () =>
import('../components/Content').then(m => (
<Content>
<RequirePermission permission={devToolsConfigReadPermission}>
<m.ConfigContent />
</RequirePermission>
</Content>
)),
},
});
/** @alpha */
export const devToolsScheduledTasksPage = SubPageBlueprint.make({
name: 'scheduled-tasks',
params: {
path: 'scheduled-tasks',
title: 'Scheduled Tasks',
loader: () =>
import('../components/Content').then(m => (
<Content>
<RequirePermission permission={devToolsTaskSchedulerReadPermission}>
<m.ScheduledTasksContent />
</RequirePermission>
</Content>
)),
},
});
@@ -94,5 +127,12 @@ export default createFrontendPlugin({
routes: {
root: rootRouteRef,
},
extensions: [devToolsApi, devToolsPage, devToolsNavItem],
extensions: [
devToolsApi,
devToolsPage,
devToolsInfoPage,
devToolsConfigPage,
devToolsScheduledTasksPage,
devToolsNavItem,
],
});
@@ -21,10 +21,7 @@ import {
import { ConfigContent } from '../Content';
import { devToolsTaskSchedulerReadPermission } from '@backstage/plugin-devtools-common/alpha';
import {
DevToolsLayout,
NfsDevToolsLayout,
} from '../DevToolsLayout/DevToolsLayout';
import { DevToolsLayout } from '../DevToolsLayout/DevToolsLayout';
import { InfoContent } from '../Content';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { ScheduledTasksContent } from '../Content/ScheduledTasksContent';
@@ -59,32 +56,3 @@ export const DefaultDevToolsPage = ({ contents }: DevToolsPageProps) => (
))}
</DevToolsLayout>
);
export const NfsDefaultDevToolsPage = ({ contents }: DevToolsPageProps) => (
<NfsDevToolsLayout>
<DevToolsLayout.Route path="info" title="Info">
<RequirePermission permission={devToolsInfoReadPermission}>
<InfoContent />
</RequirePermission>
</DevToolsLayout.Route>
<DevToolsLayout.Route path="config" title="Config">
<RequirePermission permission={devToolsConfigReadPermission}>
<ConfigContent />
</RequirePermission>
</DevToolsLayout.Route>
<DevToolsLayout.Route path="scheduled-tasks" title="Scheduled Tasks">
<RequirePermission permission={devToolsTaskSchedulerReadPermission}>
<ScheduledTasksContent />
</RequirePermission>
</DevToolsLayout.Route>
{contents?.map((content, index) => (
<DevToolsLayout.Route
key={`extension-${index}`}
path={content.path}
title={content.title}
>
{content.children}
</DevToolsLayout.Route>
))}
</NfsDevToolsLayout>
);
@@ -15,7 +15,6 @@
*/
import { Header, Page, RoutedTabs } from '@backstage/core-components';
import { HeaderPage } from '@backstage/ui';
import {
attachComponentData,
useElementFilter,
@@ -83,28 +82,4 @@ export const DevToolsLayout = ({
);
};
export const NfsDevToolsLayout = ({
children,
title,
subtitle,
}: DevToolsLayoutProps) => {
const routes = useElementFilter(children, elements =>
elements
.selectByComponentData({
key: dataKey,
withStrictError:
'Child of DevToolsLayout must be an DevToolsLayout.Route',
})
.getElements<SubRoute>()
.map(child => child.props),
);
return (
<>
<HeaderPage title={title ?? 'Backstage DevTools'} subtitle={subtitle} />
<RoutedTabs routes={routes} />
</>
);
};
DevToolsLayout.Route = Route;
@@ -16,7 +16,6 @@
import { useOutlet } from 'react-router-dom';
import { DefaultDevToolsPage } from '../DefaultDevToolsPage';
import { NfsDefaultDevToolsPage } from '../DefaultDevToolsPage/DefaultDevToolsPage';
import { ReactElement } from 'react';
/**
@@ -40,9 +39,3 @@ export const DevToolsPage = ({ contents }: DevToolsPageProps) => {
return <>{outlet || <DefaultDevToolsPage contents={contents} />}</>;
};
export const NfsDevToolsPage = ({ contents }: DevToolsPageProps) => {
const outlet = useOutlet();
return <>{outlet || <NfsDefaultDevToolsPage contents={contents} />}</>;
};
@@ -235,7 +235,7 @@ function NotificationsPageContent(
if (headerVariant === 'bui') {
return (
<>
<HeaderPage title={title} subtitle={subtitle} />
<HeaderPage title={subtitle || title} />
{pageContent}
</>
);
@@ -56,13 +56,16 @@ export const ScaffolderPageLayout = (props: ScaffolderPageLayoutProps) => {
);
if (headerVariant === 'bui') {
let buiTitle: string | undefined;
if (typeof title === 'string') {
buiTitle = title;
} else if (typeof subtitle === 'string') {
buiTitle = subtitle;
}
return (
<>
<HeaderPage
title={title}
subtitle={subtitle}
customActions={headerActions}
/>
<HeaderPage title={buiTitle} customActions={headerActions} />
{pageContent}
</>
);
@@ -31,7 +31,7 @@ const NfsTechDocsPageWrapper: FC<{ children?: ReactNode }> = ({ children }) => {
return (
<>
<HeaderPage title="Documentation" subtitle={generatedSubtitle} />
<HeaderPage title={generatedSubtitle} />
{children}
</>
);
@@ -17,6 +17,7 @@
import { PropsWithChildren, useEffect } from 'react';
import Helmet from 'react-helmet';
import Grid from '@material-ui/core/Grid';
import Box from '@material-ui/core/Box';
import Skeleton from '@material-ui/lab/Skeleton';
import CodeIcon from '@material-ui/icons/Code';
import capitalize from 'lodash/capitalize';
@@ -160,13 +161,7 @@ const NfsTechDocsReaderPageHeader = (props: PropsWithChildren<{}>) => {
<title>{tabTitle}</title>
</Helmet>
<HeaderPage
title={
<>
<div>{title || skeleton}</div>
<div>{labels}</div>
</>
}
subtitle={subtitle === '' ? undefined : subtitle || skeleton}
title={title || ''}
customActions={
<>
{children}
@@ -174,6 +169,15 @@ const NfsTechDocsReaderPageHeader = (props: PropsWithChildren<{}>) => {
</>
}
/>
{(subtitle ||
metadataLoading ||
entityMetadataLoading ||
entityMetadata) && (
<Box mt={2}>
{subtitle !== '' ? subtitle || skeleton : null}
{entityMetadata && <Box mt={subtitle === '' ? 0 : 1}>{labels}</Box>}
</Box>
)}
</>
);
};
@@ -14,9 +14,10 @@
* limitations under the License.
*/
import { ElementType, ReactNode } from 'react';
import { ElementType, ReactNode, useMemo } from 'react';
import { TabProps } from '@material-ui/core/Tab';
import {
Content,
Header,
Page,
RoutedTabs,
@@ -28,6 +29,13 @@ import {
useElementFilter,
} from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { Helmet } from 'react-helmet';
import {
matchRoutes,
useLocation,
useParams,
useRoutes,
} from 'react-router-dom';
import { userSettingsTranslationRef } from '../../translation';
/** @public */
@@ -54,6 +62,78 @@ export type SettingsLayoutProps = {
children?: ReactNode;
};
const normalizePath = (path: string) =>
path !== '/' && path.endsWith('/') ? path.slice(0, -1) : path;
const getTabsBasePath = (
pathname: string,
routes: SettingsLayoutRouteProps[],
) => {
const normalizedPathname = normalizePath(pathname);
const relativeRoutePaths = routes
.map(route => route.path.replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
.sort((a, b) => b.length - a.length);
for (const routePath of relativeRoutePaths) {
const marker = `/${routePath}`;
const matchIndex = normalizedPathname.lastIndexOf(marker);
if (matchIndex === -1) {
continue;
}
const matchEndIndex = matchIndex + marker.length;
if (
matchEndIndex !== normalizedPathname.length &&
normalizedPathname[matchEndIndex] !== '/'
) {
continue;
}
return normalizedPathname.slice(0, matchIndex) || '/';
}
return normalizedPathname || '/';
};
const useSelectedSubRoute = (
subRoutes: SettingsLayoutRouteProps[],
): {
route?: SettingsLayoutRouteProps;
element?: JSX.Element;
} => {
const params = useParams();
const routes = subRoutes.map(({ path, children }) => ({
caseSensitive: false,
path: `${path}/*`,
element: children,
}));
const sortedRoutes = routes.sort((a, b) =>
b.path.replace(/\/\*$/, '').localeCompare(a.path.replace(/\/\*$/, '')),
);
const element = useRoutes(sortedRoutes) ?? subRoutes[0]?.children;
let currentRoute = params['*'] ?? '';
if (!currentRoute.startsWith('/')) {
currentRoute = `/${currentRoute}`;
}
const [matchedRoute] = matchRoutes(sortedRoutes, currentRoute) ?? [];
const foundIndex = matchedRoute
? subRoutes.findIndex(t => `${t.path}/*` === matchedRoute.route.path)
: 0;
const route = subRoutes[foundIndex === -1 ? 0 : foundIndex] ?? subRoutes[0];
return {
route,
element,
};
};
/**
* @public
*/
@@ -85,6 +165,7 @@ export const NfsSettingsLayout = (props: SettingsLayoutProps) => {
const { title, children } = props;
const { isMobile } = useSidebarPinState();
const { t } = useTranslationRef(userSettingsTranslationRef);
const location = useLocation();
const routes = useElementFilter(children, elements =>
elements
@@ -96,11 +177,30 @@ export const NfsSettingsLayout = (props: SettingsLayoutProps) => {
.getElements<SettingsLayoutRouteProps>()
.map(child => child.props),
);
const { route, element } = useSelectedSubRoute(routes);
const tabs = useMemo(() => {
const basePath = getTabsBasePath(location.pathname, routes);
return routes.map(subRoute => ({
id: subRoute.path,
label: subRoute.title,
href: subRoute.path.startsWith('/')
? subRoute.path
: `${basePath}/${subRoute.path}`.replace(/\/{2,}/g, '/'),
matchStrategy: 'prefix' as const,
}));
}, [location.pathname, routes]);
return (
<>
{!isMobile && <HeaderPage title={title ?? t('settingsLayout.title')} />}
<RoutedTabs routes={routes} />
{!isMobile && <HeaderPage tabs={tabs} />}
{isMobile && <RoutedTabs routes={routes} />}
{!isMobile && (
<Content>
<Helmet title={route?.title} />
{element}
</Content>
)}
</>
);
};