fix: restore NFS header migration regressions

Restore page theming and header metadata for the migrated NFS pages so the new BUI headers preserve the same context and navigation as before. This also makes the DevTools landing tab deterministic and adds focused regression coverage for the scaffolder and TechDocs fixes.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 10:13:29 +01:00
parent c104960da7
commit aa22de55f4
7 changed files with 205 additions and 18 deletions
@@ -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 (
<>
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
{header ?? (
<EntityHeader
parentEntityRelations={parentEntityRelations}
@@ -194,7 +195,7 @@ export const EntityLayout = (props: EntityLayoutProps) => {
)}
</Content>
)}
</>
</Page>
);
};
+29 -5
View File
@@ -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,
},
},
);
},
});
@@ -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(
<ScaffolderPageLayout
headerVariant="bui"
pageTitleOverride="Task page"
title={
<div>
Task <code>My template</code>
</div>
}
subtitle="Task ID: 123"
type="Scaffolder"
typeLink="/create"
>
<div>Body content</div>
</ScaffolderPageLayout>,
);
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',
);
});
});
@@ -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 (
<>
<HeaderPage title={buiTitle} customActions={headerActions} />
<Page themeId={themeId}>
<HeaderPage
title={buiTitle}
breadcrumbs={breadcrumbs}
customActions={headerActions}
/>
{hasHeaderDetails && (
<Box mt={2}>
{customTitle}
{subtitle && <Box mt={customTitle ? 1 : 0}>{subtitle}</Box>}
</Box>
)}
{pageContent}
</>
</Page>
);
}
@@ -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(
<ApiProvider apis={apiRegistry}>
<NfsTechDocsIndexPage />
</ApiProvider>,
{
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();
});
});
@@ -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 (
<>
<HeaderPage title={generatedSubtitle} />
<Page themeId="documentation">
<HeaderPage title="Documentation" />
<Box mt={2}>{generatedSubtitle}</Box>
{children}
</>
</Page>
);
};
@@ -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 = <Skeleton animation="wave" variant="text" height={40} />;
@@ -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<{}>) => {
<title>{tabTitle}</title>
</Helmet>
<HeaderPage
title={title || ''}
title={title || 'Documentation'}
breadcrumbs={[{ label: 'Documentation', href: docsRootLink }]}
customActions={
<>
{children}
@@ -193,10 +196,10 @@ export const NfsTechDocsReaderLayout = (
const { withSearch, withHeader = true } = props;
return (
<>
<Page themeId="documentation">
{withHeader && <NfsTechDocsReaderPageHeader />}
<TechDocsReaderPageSubheader />
<TechDocsReaderPageContent withSearch={withSearch} />
</>
</Page>
);
};