diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index 17c4693bde..895f36150f 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -27,6 +27,7 @@ import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; import { Content, Link, + Page, Progress, WarningPanel, } from '@backstage/core-components'; @@ -149,7 +150,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { const { t } = useTranslationRef(catalogTranslationRef); return ( - <> + {header ?? ( { )} )} - + ); }; diff --git a/plugins/devtools/src/alpha/plugin.tsx b/plugins/devtools/src/alpha/plugin.tsx index 4d81d43e17..0144308069 100644 --- a/plugins/devtools/src/alpha/plugin.tsx +++ b/plugins/devtools/src/alpha/plugin.tsx @@ -16,6 +16,7 @@ import { createFrontendPlugin, + coreExtensionData, discoveryApiRef, fetchApiRef, ApiBlueprint, @@ -50,11 +51,34 @@ export const devToolsApi = ApiBlueprint.make({ }); /** @alpha */ -export const devToolsPage = PageBlueprint.make({ - params: { - path: '/devtools', - routeRef: rootRouteRef, - title: 'DevTools', +export const devToolsPage = PageBlueprint.makeWithOverrides({ + factory(originalFactory, { inputs }) { + const pages = [...inputs.pages].sort((left, right) => { + const leftPath = left.get(coreExtensionData.routePath); + const rightPath = right.get(coreExtensionData.routePath); + + if (leftPath === 'info' && rightPath !== 'info') { + return -1; + } + if (leftPath !== 'info' && rightPath === 'info') { + return 1; + } + + return 0; + }); + + return originalFactory( + { + path: '/devtools', + routeRef: rootRouteRef, + title: 'DevTools', + }, + { + inputs: { + pages, + }, + }, + ); }, }); diff --git a/plugins/scaffolder/src/components/ScaffolderPageLayout.test.tsx b/plugins/scaffolder/src/components/ScaffolderPageLayout.test.tsx new file mode 100644 index 0000000000..510d69d748 --- /dev/null +++ b/plugins/scaffolder/src/components/ScaffolderPageLayout.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright 2026 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 { renderInTestApp } from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import { ScaffolderPageLayout } from './ScaffolderPageLayout'; + +describe('ScaffolderPageLayout', () => { + it('preserves BUI titles, subtitles, and breadcrumb links', async () => { + await renderInTestApp( + + Task My template + + } + subtitle="Task ID: 123" + type="Scaffolder" + typeLink="/create" + > +
Body content
+
, + ); + + expect( + screen.getByRole('heading', { name: 'Task page' }), + ).toBeInTheDocument(); + expect(screen.getByText('My template')).toBeInTheDocument(); + expect(screen.getByText('Task ID: 123')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Scaffolder' })).toHaveAttribute( + 'href', + '/create', + ); + }); +}); diff --git a/plugins/scaffolder/src/components/ScaffolderPageLayout.tsx b/plugins/scaffolder/src/components/ScaffolderPageLayout.tsx index fafddb2590..31d7233435 100644 --- a/plugins/scaffolder/src/components/ScaffolderPageLayout.tsx +++ b/plugins/scaffolder/src/components/ScaffolderPageLayout.tsx @@ -14,9 +14,11 @@ * limitations under the License. */ +import Box from '@material-ui/core/Box'; import { Content, Header, Page } from '@backstage/core-components'; import { HeaderPage } from '@backstage/ui'; import type { ReactNode } from 'react'; +import { useEffect } from 'react'; type HeaderVariant = 'legacy' | 'bui'; @@ -55,19 +57,44 @@ export const ScaffolderPageLayout = (props: ScaffolderPageLayoutProps) => { children ); + useEffect(() => { + if (!pageTitleOverride) { + return; + } + + document.title = pageTitleOverride; + }, [pageTitleOverride]); + if (headerVariant === 'bui') { let buiTitle: string | undefined; if (typeof title === 'string') { buiTitle = title; + } else if (pageTitleOverride) { + buiTitle = pageTitleOverride; } else if (typeof subtitle === 'string') { buiTitle = subtitle; } + const breadcrumbs = + type && typeLink ? [{ label: type, href: typeLink }] : undefined; + const customTitle = typeof title === 'string' ? undefined : title; + const hasHeaderDetails = Boolean(customTitle) || Boolean(subtitle); + return ( - <> - + + + {hasHeaderDetails && ( + + {customTitle} + {subtitle && {subtitle}} + + )} {pageContent} - + ); } diff --git a/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.test.tsx b/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.test.tsx new file mode 100644 index 0000000000..bd39a7c267 --- /dev/null +++ b/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.test.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2026 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 { ApiProvider } from '@backstage/core-app-api'; +import { configApiRef, storageApiRef } from '@backstage/core-plugin-api'; +import { + MockStarredEntitiesApi, + catalogApiRef, + starredEntitiesApiRef, +} from '@backstage/plugin-catalog-react'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import { + TestApiRegistry, + mockApis, + renderInTestApp, +} from '@backstage/test-utils'; +import { screen } from '@testing-library/react'; +import { rootDocsRouteRef } from '../routes'; +import { NfsTechDocsIndexPage } from './NfsTechDocsIndexPage'; + +const mockCatalogApi = catalogApiMock({ + entities: [ + { + apiVersion: 'version', + kind: 'User', + metadata: { + name: 'owned', + namespace: 'default', + }, + }, + ], +}); + +describe('NfsTechDocsIndexPage', () => { + it('renders the documentation heading and subtitle', async () => { + const apiRegistry = TestApiRegistry.from( + [catalogApiRef, mockCatalogApi], + [ + configApiRef, + mockApis.config({ + data: { organization: { name: 'My Company' } }, + }), + ], + [storageApiRef, mockApis.storage()], + [starredEntitiesApiRef, new MockStarredEntitiesApi()], + ); + + await renderInTestApp( + + + , + { + mountedRoutes: { + '/docs/:namespace/:kind/:name/*': rootDocsRouteRef, + }, + }, + ); + + expect( + await screen.findByRole('heading', { name: 'Documentation' }), + ).toBeInTheDocument(); + expect( + await screen.findByText(/Documentation available in My Company/i), + ).toBeInTheDocument(); + }); +}); diff --git a/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.tsx b/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.tsx index 31ffe55ca3..3b2450d404 100644 --- a/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.tsx +++ b/plugins/techdocs/src/alpha/NfsTechDocsIndexPage.tsx @@ -15,7 +15,9 @@ */ import { FC, ReactNode } from 'react'; +import Box from '@material-ui/core/Box'; import { useOutlet } from 'react-router-dom'; +import { Page } from '@backstage/core-components'; import { HeaderPage } from '@backstage/ui'; import { TechDocsIndexPageProps, @@ -30,10 +32,11 @@ const NfsTechDocsPageWrapper: FC<{ children?: ReactNode }> = ({ children }) => { }`; return ( - <> - + + + {generatedSubtitle} {children} - + ); }; diff --git a/plugins/techdocs/src/alpha/NfsTechDocsReaderLayout.tsx b/plugins/techdocs/src/alpha/NfsTechDocsReaderLayout.tsx index 1bbf5f8673..806d286ceb 100644 --- a/plugins/techdocs/src/alpha/NfsTechDocsReaderLayout.tsx +++ b/plugins/techdocs/src/alpha/NfsTechDocsReaderLayout.tsx @@ -22,7 +22,7 @@ import Skeleton from '@material-ui/lab/Skeleton'; import CodeIcon from '@material-ui/icons/Code'; import capitalize from 'lodash/capitalize'; import { useParams } from 'react-router-dom'; -import { HeaderLabel } from '@backstage/core-components'; +import { HeaderLabel, Page } from '@backstage/core-components'; import { HeaderPage } from '@backstage/ui'; import { useTechDocsAddons, @@ -39,9 +39,10 @@ import { RELATION_OWNED_BY, stringifyEntityRef, } from '@backstage/catalog-model'; -import { configApiRef, useApi } from '@backstage/core-plugin-api'; +import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api'; import { TechDocsReaderPageContent } from '../reader/components/TechDocsReaderPageContent'; import { TechDocsReaderPageSubheader } from '../reader/components/TechDocsReaderPageSubheader'; +import { rootDocsRouteRef } from '../routes'; const skeleton = ; @@ -50,6 +51,7 @@ const NfsTechDocsReaderPageHeader = (props: PropsWithChildren<{}>) => { const addons = useTechDocsAddons(); const configApi = useApi(configApiRef); const entityPresentationApi = useApi(entityPresentationApiRef); + const docsRootLink = useRouteRef(rootDocsRouteRef)(); const { '*': path = '' } = useParams(); const { @@ -161,7 +163,8 @@ const NfsTechDocsReaderPageHeader = (props: PropsWithChildren<{}>) => { {tabTitle} {children} @@ -193,10 +196,10 @@ export const NfsTechDocsReaderLayout = ( const { withSearch, withHeader = true } = props; return ( - <> + {withHeader && } - + ); };