From f4a1edd2b0f450ac669e1b383ff0dfbcdadff03d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 7 Mar 2026 20:53:52 +0100 Subject: [PATCH] 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 Made-with: Cursor --- .../remove-devtools-content-blueprint.md | 7 ++ .../DefaultApiExplorerPage.tsx | 6 +- .../CatalogGraphPage/CatalogGraphPage.tsx | 3 +- .../catalog-unprocessed-entities/package.json | 1 - .../src/alpha/devToolsContent.tsx | 26 +++-- .../components/EntityHeader/EntityHeader.tsx | 70 +++++------- .../src/devToolsContentBlueprint.tsx | 87 -------------- plugins/devtools-react/src/index.ts | 4 - plugins/devtools/package.json | 1 - plugins/devtools/src/alpha/plugin.tsx | 102 ++++++++++++----- .../DefaultDevToolsPage.tsx | 34 +----- .../DevToolsLayout/DevToolsLayout.tsx | 25 ----- .../components/DevToolsPage/DevToolsPage.tsx | 7 -- .../NotificationsPage/NotificationsPage.tsx | 2 +- .../src/components/ScaffolderPageLayout.tsx | 13 ++- .../src/alpha/NfsTechDocsIndexPage.tsx | 2 +- .../src/alpha/NfsTechDocsReaderLayout.tsx | 18 +-- .../SettingsLayout/SettingsLayout.tsx | 106 +++++++++++++++++- 18 files changed, 245 insertions(+), 269 deletions(-) create mode 100644 .changeset/remove-devtools-content-blueprint.md delete mode 100644 plugins/devtools-react/src/devToolsContentBlueprint.tsx diff --git a/.changeset/remove-devtools-content-blueprint.md b/.changeset/remove-devtools-content-blueprint.md new file mode 100644 index 0000000000..5cb5c0687e --- /dev/null +++ b/.changeset/remove-devtools-content-blueprint.md @@ -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. diff --git a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx index ae20aa07a9..20a8a1a7d0 100644 --- a/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx +++ b/plugins/api-docs/src/components/ApiExplorerPage/DefaultApiExplorerPage.tsx @@ -195,11 +195,7 @@ export const NfsApiExplorerPage = (props: DefaultApiExplorerPageProps) => { return ( <> - + diff --git a/plugins/catalog-unprocessed-entities/package.json b/plugins/catalog-unprocessed-entities/package.json index e9d483970a..7ffca76ef7 100644 --- a/plugins/catalog-unprocessed-entities/package.json +++ b/plugins/catalog-unprocessed-entities/package.json @@ -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", diff --git a/plugins/catalog-unprocessed-entities/src/alpha/devToolsContent.tsx b/plugins/catalog-unprocessed-entities/src/alpha/devToolsContent.tsx index 3dcdc0ad6b..3d88c5299d 100644 --- a/plugins/catalog-unprocessed-entities/src/alpha/devToolsContent.tsx +++ b/plugins/catalog-unprocessed-entities/src/alpha/devToolsContent.tsx @@ -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 => ( + - )), - }, + + )), }, -); +}); diff --git a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx index 47b449b8f8..6278bf9590 100644 --- a/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx +++ b/plugins/catalog/src/alpha/components/EntityHeader/EntityHeader.tsx @@ -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 ( - - - {entity ? : title} - - {entity && } - - ); -} - 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 = ( - - {title ?? } - {entity && ( - - - - )} - + const headerSubtitle = subtitle ?? ( + ); + const renderedTitle = typeof title === 'string' ? title : headerTitle; return ( <> - ) - } + title={renderedTitle} customActions={ entity ? ( - + <> + + + ) : undefined } /> + {(headerSubtitle || entity) && ( + + {headerSubtitle} + {entity && ( + + + + )} + + )} {entity && ( <> Promise; - 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(), - * ), - * }, - * }, - * }); - * ``` - * @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); - } - }, -}); diff --git a/plugins/devtools-react/src/index.ts b/plugins/devtools-react/src/index.ts index 70b4915ae8..2cf60407cd 100644 --- a/plugins/devtools-react/src/index.ts +++ b/plugins/devtools-react/src/index.ts @@ -19,7 +19,3 @@ * * @packageDocumentation */ -export { - type DevToolsContentBlueprintParams, - DevToolsContentBlueprint, -} from './devToolsContentBlueprint'; diff --git a/plugins/devtools/package.json b/plugins/devtools/package.json index 8818992168..6b6761a879 100644 --- a/plugins/devtools/package.json +++ b/plugins/devtools/package.json @@ -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", diff --git a/plugins/devtools/src/alpha/plugin.tsx b/plugins/devtools/src/alpha/plugin.tsx index 93bcc0bb94..4d81d43e17 100644 --- a/plugins/devtools/src/alpha/plugin.tsx +++ b/plugins/devtools/src/alpha/plugin.tsx @@ -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 => ( - - )); - }, - }); +}); + +/** @alpha */ +export const devToolsInfoPage = SubPageBlueprint.make({ + name: 'info', + params: { + path: 'info', + title: 'Info', + loader: () => + import('../components/Content').then(m => ( + + + + + + )), + }, +}); + +/** @alpha */ +export const devToolsConfigPage = SubPageBlueprint.make({ + name: 'config', + params: { + path: 'config', + title: 'Config', + loader: () => + import('../components/Content').then(m => ( + + + + + + )), + }, +}); + +/** @alpha */ +export const devToolsScheduledTasksPage = SubPageBlueprint.make({ + name: 'scheduled-tasks', + params: { + path: 'scheduled-tasks', + title: 'Scheduled Tasks', + loader: () => + import('../components/Content').then(m => ( + + + + + + )), }, }); @@ -94,5 +127,12 @@ export default createFrontendPlugin({ routes: { root: rootRouteRef, }, - extensions: [devToolsApi, devToolsPage, devToolsNavItem], + extensions: [ + devToolsApi, + devToolsPage, + devToolsInfoPage, + devToolsConfigPage, + devToolsScheduledTasksPage, + devToolsNavItem, + ], }); diff --git a/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx b/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx index 952d180ed2..06d7c2ed4e 100644 --- a/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx +++ b/plugins/devtools/src/components/DefaultDevToolsPage/DefaultDevToolsPage.tsx @@ -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) => ( ))} ); - -export const NfsDefaultDevToolsPage = ({ contents }: DevToolsPageProps) => ( - - - - - - - - - - - - - - - - - {contents?.map((content, index) => ( - - {content.children} - - ))} - -); diff --git a/plugins/devtools/src/components/DevToolsLayout/DevToolsLayout.tsx b/plugins/devtools/src/components/DevToolsLayout/DevToolsLayout.tsx index d56f09a1d4..7ffb6dfc73 100644 --- a/plugins/devtools/src/components/DevToolsLayout/DevToolsLayout.tsx +++ b/plugins/devtools/src/components/DevToolsLayout/DevToolsLayout.tsx @@ -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() - .map(child => child.props), - ); - - return ( - <> - - - - ); -}; - DevToolsLayout.Route = Route; diff --git a/plugins/devtools/src/components/DevToolsPage/DevToolsPage.tsx b/plugins/devtools/src/components/DevToolsPage/DevToolsPage.tsx index b31b56d81f..89305b2c1a 100644 --- a/plugins/devtools/src/components/DevToolsPage/DevToolsPage.tsx +++ b/plugins/devtools/src/components/DevToolsPage/DevToolsPage.tsx @@ -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 || }; }; - -export const NfsDevToolsPage = ({ contents }: DevToolsPageProps) => { - const outlet = useOutlet(); - - return <>{outlet || }; -}; diff --git a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx index d7e8281d77..61dcb15e5a 100644 --- a/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx +++ b/plugins/notifications/src/components/NotificationsPage/NotificationsPage.tsx @@ -235,7 +235,7 @@ function NotificationsPageContent( if (headerVariant === 'bui') { return ( <> - + {pageContent} ); diff --git a/plugins/scaffolder/src/components/ScaffolderPageLayout.tsx b/plugins/scaffolder/src/components/ScaffolderPageLayout.tsx index 1d301465ad..fafddb2590 100644 --- a/plugins/scaffolder/src/components/ScaffolderPageLayout.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPageLayout.tsx @@ -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 ( <> - + {pageContent} ); diff --git a/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.tsx b/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.tsx index bcbc698405..31ffe55ca3 100644 --- a/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.tsx +++ b/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.tsx @@ -31,7 +31,7 @@ const NfsTechDocsPageWrapper: FC<{ children?: ReactNode }> = ({ children }) => { return ( <> - + {children} ); diff --git a/plugins/techdocs/src/alpha/NfsTechDocsReaderLayout.tsx b/plugins/techdocs/src/alpha/NfsTechDocsReaderLayout.tsx index 04da2f0ed4..1bbf5f8673 100644 --- a/plugins/techdocs/src/alpha/NfsTechDocsReaderLayout.tsx +++ b/plugins/techdocs/src/alpha/NfsTechDocsReaderLayout.tsx @@ -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<{}>) => { {tabTitle} -
{title || skeleton}
-
{labels}
- - } - subtitle={subtitle === '' ? undefined : subtitle || skeleton} + title={title || ''} customActions={ <> {children} @@ -174,6 +169,15 @@ const NfsTechDocsReaderPageHeader = (props: PropsWithChildren<{}>) => { } /> + {(subtitle || + metadataLoading || + entityMetadataLoading || + entityMetadata) && ( + + {subtitle !== '' ? subtitle || skeleton : null} + {entityMetadata && {labels}} + + )} ); }; diff --git a/plugins/user-settings/src/components/SettingsLayout/SettingsLayout.tsx b/plugins/user-settings/src/components/SettingsLayout/SettingsLayout.tsx index 7fdf2ee420..a059be505c 100644 --- a/plugins/user-settings/src/components/SettingsLayout/SettingsLayout.tsx +++ b/plugins/user-settings/src/components/SettingsLayout/SettingsLayout.tsx @@ -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() .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 && } - + {!isMobile && } + {isMobile && } + {!isMobile && ( + + + {element} + + )} ); };