update NFS header migration scope

Roll back the TechDocs, catalog graph, and catalog import migrations for now while keeping the shared sub-page routing fix and the page-level header cleanup for search, notifications, and catalog entity pages.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Made-with: Cursor
This commit is contained in:
Patrik Oldsberg
2026-03-17 16:52:45 +01:00
parent 5f3f5d298b
commit f6a5144861
18 changed files with 343 additions and 704 deletions
-3
View File
@@ -2,13 +2,10 @@
'@backstage/plugin-api-docs': patch
'@backstage/plugin-auth': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-graph': patch
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-catalog-unprocessed-entities': patch
'@backstage/plugin-devtools': patch
'@backstage/plugin-notifications': patch
'@backstage/plugin-search': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-user-settings': patch
---
@@ -24,7 +24,7 @@ import {
createExtensionBlueprint,
createExtensionInput,
} from '../wiring';
import { waitFor } from '@testing-library/react';
import { waitFor, screen } from '@testing-library/react';
describe('PageBlueprint', () => {
const mockRouteRef = createRouteRef();
@@ -279,4 +279,50 @@ describe('PageBlueprint', () => {
}
`);
});
it('should resolve sub-page tab hrefs relative to the parent page', async () => {
const myPage = PageBlueprint.make({
name: 'test-page',
params: {
path: '/test',
routeRef: mockRouteRef,
title: 'Test',
},
});
const SubPageBlueprint = createExtensionBlueprint({
kind: 'sub-page',
attachTo: { id: 'page:test-page', input: 'pages' },
output: [
coreExtensionData.routePath,
coreExtensionData.reactElement,
coreExtensionData.title.optional(),
],
factory() {
return [
coreExtensionData.routePath('config'),
coreExtensionData.title('Config'),
coreExtensionData.reactElement(<div>Config page</div>),
];
},
});
const tester = createExtensionTester(myPage).add(
SubPageBlueprint.make({ name: 'config', params: {} }),
);
renderInTestApp(tester.reactElement(), {
mountedRoutes: {
'/test/*': mockRouteRef,
},
initialRouteEntries: ['/test'],
});
await waitFor(() =>
expect(screen.getByRole('tab', { name: 'Config' })).toHaveAttribute(
'href',
'/test/config',
),
);
});
});
@@ -15,7 +15,7 @@
*/
import { JSX } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Routes, Route, Navigate, useResolvedPath } from 'react-router-dom';
import { IconElement } from '../icons/types';
import { RouteRef } from '../routing';
import {
@@ -72,6 +72,7 @@ export const PageBlueprint = createExtensionBlueprint({
{ config, node, inputs },
) {
const title = config.title ?? params.title;
const routePath = config.path ?? params.path;
const icon = params.icon;
const pluginId = node.spec.plugin.pluginId;
const noHeader = params.noHeader ?? false;
@@ -79,7 +80,7 @@ export const PageBlueprint = createExtensionBlueprint({
title ?? node.spec.plugin.title ?? node.spec.plugin.pluginId;
const resolvedIcon = icon ?? node.spec.plugin.icon;
yield coreExtensionData.routePath(config.path ?? params.path);
yield coreExtensionData.routePath(routePath);
if (params.loader) {
const loader = params.loader;
const PageContent = () => {
@@ -99,24 +100,34 @@ export const PageBlueprint = createExtensionBlueprint({
};
yield coreExtensionData.reactElement(<PageContent />);
} else if (inputs.pages.length > 0) {
// Parent page with sub-pages - render header with tabs
const tabs: PageLayoutTab[] = inputs.pages.map(page => {
const path = page.get(coreExtensionData.routePath);
const tabTitle = page.get(coreExtensionData.title);
const tabIcon = page.get(coreExtensionData.icon);
return {
id: path,
label: tabTitle || path,
icon: tabIcon,
href: path,
};
});
const PageContent = () => {
const firstPagePath = inputs.pages[0]?.get(coreExtensionData.routePath);
const headerActionsApi = useApi(pluginHeaderActionsApiRef);
const headerActions = headerActionsApi.getPluginHeaderActions(pluginId);
const parentPath = useResolvedPath('.').pathname.replace(/\/$/, '');
const staticParentPath =
routePath.startsWith('/') &&
!routePath.includes('/:') &&
!routePath.includes('*')
? routePath.replace(/\/$/, '')
: undefined;
const tabs: PageLayoutTab[] = inputs.pages.map(page => {
const path = page.get(coreExtensionData.routePath);
const tabTitle = page.get(coreExtensionData.title);
const tabIcon = page.get(coreExtensionData.icon);
const tabPath = path.replace(/^\/+/, '');
const basePath = staticParentPath ?? parentPath ?? '';
const href = path.startsWith('/')
? path
: `${basePath}/${tabPath}`.replace(/\/{2,}/g, '/');
return {
id: path,
label: tabTitle || path,
icon: tabIcon,
href,
};
});
return (
<PageLayout
+2 -2
View File
@@ -79,8 +79,8 @@ const CatalogGraphPage = PageBlueprint.makeWithOverrides({
path: '/catalog-graph',
routeRef: catalogGraphRouteRef,
loader: () =>
import('./components/CatalogGraphPage/CatalogGraphPage').then(m => (
<m.NfsCatalogGraphPage {...config} />
import('./components/CatalogGraphPage').then(m => (
<m.CatalogGraphPage {...config} />
)),
});
},
@@ -23,7 +23,6 @@ import {
SupportButton,
} from '@backstage/core-components';
import { useAnalytics, useRouteRef } from '@backstage/core-plugin-api';
import { HeaderPage } from '@backstage/ui';
import {
entityRouteRef,
humanizeEntityRef,
@@ -117,23 +116,21 @@ const useStyles = makeStyles(
{ name: 'PluginCatalogGraphCatalogGraphPage' },
);
type CatalogGraphPageProps = {
initialState?: {
selectedRelations?: string[];
selectedKinds?: string[];
rootEntityRefs?: string[];
maxDepth?: number;
unidirectional?: boolean;
mergeRelations?: boolean;
direction?: Direction;
showFilters?: boolean;
curve?: 'curveStepBefore' | 'curveMonotoneX';
};
} & Partial<EntityRelationsGraphProps>;
function CatalogGraphPageContent(
props: CatalogGraphPageProps & { headerVariant: 'legacy' | 'bui' },
) {
export const CatalogGraphPage = (
props: {
initialState?: {
selectedRelations?: string[];
selectedKinds?: string[];
rootEntityRefs?: string[];
maxDepth?: number;
unidirectional?: boolean;
mergeRelations?: boolean;
direction?: Direction;
showFilters?: boolean;
curve?: 'curveStepBefore' | 'curveMonotoneX';
};
} & Partial<EntityRelationsGraphProps>,
) => {
const { relationPairs, initialState, entityFilter } = props;
const { t } = useTranslationRef(catalogGraphTranslationRef);
const navigate = useNavigate();
@@ -188,124 +185,93 @@ function CatalogGraphPageContent(
[catalogEntityRoute, navigate, setRootEntityNames, analytics],
);
const pageBody = (
<Grid container alignItems="stretch" className={classes.container}>
{showFilters && (
<Grid item xs={12} lg={2} className={classes.filters}>
<MaxDepthFilter value={maxDepth} onChange={setMaxDepth} />
<SelectedKindsFilter
value={selectedKinds}
onChange={setSelectedKinds}
/>
<SelectedRelationsFilter
value={selectedRelations}
onChange={setSelectedRelations}
/>
<DirectionFilter value={direction} onChange={setDirection} />
<CurveFilter value={curve} onChange={setCurve} />
<SwitchFilter
value={unidirectional}
onChange={setUnidirectional}
label={t('catalogGraphPage.simplifiedSwitchLabel')}
/>
<SwitchFilter
value={mergeRelations}
onChange={setMergeRelations}
label={t('catalogGraphPage.mergeRelationsSwitchLabel')}
/>
</Grid>
)}
<Grid item xs className={classes.fullHeight}>
<Paper className={classes.graphWrapper}>
<Typography
variant="caption"
color="textSecondary"
display="block"
className={classes.legend}
>
<ZoomOutMap className="icon" />{' '}
{t('catalogGraphPage.zoomOutDescription')}
</Typography>
<EntityRelationsGraph
{...props}
rootEntityNames={rootEntityNames}
maxDepth={maxDepth}
kinds={
selectedKinds && selectedKinds.length > 0
? selectedKinds
: undefined
}
relations={
selectedRelations && selectedRelations.length > 0
? selectedRelations
: undefined
}
mergeRelations={mergeRelations}
unidirectional={unidirectional}
onNodeClick={onNodeClick}
direction={direction}
relationPairs={relationPairs}
entityFilter={entityFilter}
className={classes.graph}
zoom="enabled"
curve={curve}
/>
</Paper>
</Grid>
</Grid>
);
const filterAction = (
<ToggleButton
value="show filters"
selected={showFilters}
onChange={() => toggleShowFilters()}
>
<FilterListIcon /> {t('catalogGraphPage.filterToggleButtonTitle')}
</ToggleButton>
);
const headerActions = (
<>
{filterAction}
<SupportButton>
{t('catalogGraphPage.supportButtonDescription')}
</SupportButton>
</>
);
const subtitle = rootEntityNames.map(e => humanizeEntityRef(e)).join(', ');
if (props.headerVariant === 'bui') {
return (
<>
<HeaderPage
title={subtitle || t('catalogGraphPage.title')}
customActions={headerActions}
/>
<Content stretch className={classes.content}>
{pageBody}
</Content>
</>
);
}
return (
<Page themeId="home">
<Header title={t('catalogGraphPage.title')} subtitle={subtitle} />
<Header
title={t('catalogGraphPage.title')}
subtitle={rootEntityNames.map(e => humanizeEntityRef(e)).join(', ')}
/>
<Content stretch className={classes.content}>
<ContentHeader titleComponent={filterAction}>
<ContentHeader
titleComponent={
<ToggleButton
value="show filters"
selected={showFilters}
onChange={() => toggleShowFilters()}
>
<FilterListIcon /> {t('catalogGraphPage.filterToggleButtonTitle')}
</ToggleButton>
}
>
<SupportButton>
{t('catalogGraphPage.supportButtonDescription')}
</SupportButton>
</ContentHeader>
{pageBody}
<Grid container alignItems="stretch" className={classes.container}>
{showFilters && (
<Grid item xs={12} lg={2} className={classes.filters}>
<MaxDepthFilter value={maxDepth} onChange={setMaxDepth} />
<SelectedKindsFilter
value={selectedKinds}
onChange={setSelectedKinds}
/>
<SelectedRelationsFilter
value={selectedRelations}
onChange={setSelectedRelations}
/>
<DirectionFilter value={direction} onChange={setDirection} />
<CurveFilter value={curve} onChange={setCurve} />
<SwitchFilter
value={unidirectional}
onChange={setUnidirectional}
label={t('catalogGraphPage.simplifiedSwitchLabel')}
/>
<SwitchFilter
value={mergeRelations}
onChange={setMergeRelations}
label={t('catalogGraphPage.mergeRelationsSwitchLabel')}
/>
</Grid>
)}
<Grid item xs className={classes.fullHeight}>
<Paper className={classes.graphWrapper}>
<Typography
variant="caption"
color="textSecondary"
display="block"
className={classes.legend}
>
<ZoomOutMap className="icon" />{' '}
{t('catalogGraphPage.zoomOutDescription')}
</Typography>
<EntityRelationsGraph
{...props}
rootEntityNames={rootEntityNames}
maxDepth={maxDepth}
kinds={
selectedKinds && selectedKinds.length > 0
? selectedKinds
: undefined
}
relations={
selectedRelations && selectedRelations.length > 0
? selectedRelations
: undefined
}
mergeRelations={mergeRelations}
unidirectional={unidirectional}
onNodeClick={onNodeClick}
direction={direction}
relationPairs={relationPairs}
entityFilter={entityFilter}
className={classes.graph}
zoom="enabled"
curve={curve}
/>
</Paper>
</Grid>
</Grid>
</Content>
</Page>
);
}
export const CatalogGraphPage = (props: CatalogGraphPageProps) => (
<CatalogGraphPageContent {...props} headerVariant="legacy" />
);
export const NfsCatalogGraphPage = (props: CatalogGraphPageProps) => (
<CatalogGraphPageContent {...props} headerVariant="bui" />
);
};
-1
View File
@@ -66,7 +66,6 @@
"@backstage/plugin-catalog-common": "workspace:^",
"@backstage/plugin-catalog-react": "workspace:^",
"@backstage/plugin-permission-react": "workspace:^",
"@backstage/ui": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
+2 -2
View File
@@ -43,9 +43,9 @@ const catalogImportPage = PageBlueprint.make({
path: '/catalog-import',
routeRef: rootRouteRef,
loader: () =>
import('./components/ImportPage/ImportPage').then(m => (
import('./components/ImportPage').then(m => (
<RequirePermission permission={catalogEntityCreatePermission}>
<m.NfsImportPage />
<m.ImportPage />
</RequirePermission>
)),
},
@@ -23,7 +23,6 @@ import {
} from '@backstage/core-components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { HeaderPage } from '@backstage/ui';
import Grid from '@material-ui/core/Grid';
import { useTheme } from '@material-ui/core/styles';
import useMediaQuery from '@material-ui/core/useMediaQuery';
@@ -53,22 +52,17 @@ export const DefaultImportPage = () => {
<ImportStepper />
</Grid>,
];
const headerTitle = t('defaultImportPage.headerTitle');
const supportAction = (
<SupportButton>
{t('defaultImportPage.supportTitle', { appTitle })}
</SupportButton>
);
const contentHeaderTitle = t('defaultImportPage.contentHeaderTitle', {
appTitle,
});
return (
<Page themeId="home">
<Header title={headerTitle} />
<Header title={t('defaultImportPage.headerTitle')} />
<Content>
<ContentHeader title={contentHeaderTitle}>
{supportAction}
<ContentHeader
title={t('defaultImportPage.contentHeaderTitle', { appTitle })}
>
<SupportButton>
{t('defaultImportPage.supportTitle', { appTitle })}
</SupportButton>
</ContentHeader>
<Grid container spacing={2}>
@@ -78,39 +72,3 @@ export const DefaultImportPage = () => {
</Page>
);
};
export const NfsDefaultImportPage = () => {
const { t } = useTranslationRef(catalogImportTranslationRef);
const theme = useTheme();
const configApi = useApi(configApiRef);
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
const appTitle = configApi.getOptionalString('app.title') || 'Backstage';
const contentItems = [
<Grid key={0} item xs={12} md={4} lg={6} xl={8}>
<ImportInfoCard />
</Grid>,
<Grid key={1} item xs={12} md={8} lg={6} xl={4}>
<ImportStepper />
</Grid>,
];
return (
<>
<HeaderPage
title={t('defaultImportPage.contentHeaderTitle', { appTitle })}
customActions={
<SupportButton>
{t('defaultImportPage.supportTitle', { appTitle })}
</SupportButton>
}
/>
<Content>
<Grid container spacing={2}>
{isMobile ? contentItems : contentItems.reverse()}
</Grid>
</Content>
</>
);
};
@@ -16,7 +16,6 @@
import { useOutlet } from 'react-router-dom';
import { DefaultImportPage } from '../DefaultImportPage';
import { NfsDefaultImportPage } from '../DefaultImportPage/DefaultImportPage';
/**
* The whole catalog import page.
@@ -28,9 +27,3 @@ export const ImportPage = () => {
return outlet || <DefaultImportPage />;
};
export const NfsImportPage = () => {
const outlet = useOutlet();
return outlet || <NfsDefaultImportPage />;
};
@@ -27,14 +27,13 @@ import useAsync from 'react-use/esm/useAsync';
import { makeStyles } from '@material-ui/core/styles';
import Box from '@material-ui/core/Box';
import { Breadcrumbs } from '@backstage/core-components';
import { Header, Breadcrumbs } from '@backstage/core-components';
import {
useApi,
useRouteRef,
useRouteRefParams,
} from '@backstage/core-plugin-api';
import { IconComponent } from '@backstage/frontend-plugin-api';
import { HeaderPage } from '@backstage/ui';
import {
Entity,
@@ -49,6 +48,7 @@ import {
EntityRefLink,
InspectEntityDialog,
UnregisterEntityDialog,
EntityDisplayName,
FavoriteEntity,
} from '@backstage/plugin-catalog-react';
@@ -57,11 +57,12 @@ import { EntityContextMenu } from '../../../components/EntityContextMenu';
import { rootRouteRef, unregisterRedirectRouteRef } from '../../../routes';
function headerProps(
_paramKind: string | undefined,
paramKind: string | undefined,
paramNamespace: string | undefined,
paramName: string | undefined,
entity: Entity | undefined,
): { headerTitle: string } {
): { headerTitle: string; headerType: string } {
const kind = paramKind ?? entity?.kind ?? '';
const namespace = paramNamespace ?? entity?.metadata.namespace ?? '';
const name =
entity?.metadata.title ?? paramName ?? entity?.metadata.name ?? '';
@@ -70,6 +71,14 @@ function headerProps(
headerTitle: `${name}${
namespace && namespace !== DEFAULT_NAMESPACE ? ` in ${namespace}` : ''
}`,
headerType: (() => {
let t = kind.toLocaleLowerCase('en-US');
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLocaleLowerCase('en-US');
}
return t;
})(),
};
}
@@ -103,12 +112,30 @@ 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();
const { entity } = useAsyncEntity();
const { name } = useRouteRefParams(entityRouteRef);
const entityName = entity?.metadata.title ?? name;
const parentEntity = findParentRelation(
entity?.relations ?? [],
parentEntityRelations ?? [],
@@ -132,7 +159,7 @@ function EntityHeaderSubtitle(props: { parentEntityRelations?: string[] }) {
<EntityRefLink entityRef={ancestorEntity.targetRef} disableTooltip />
)}
<EntityRefLink entityRef={parentEntity.targetRef} disableTooltip />
{entityName}
{name}
</Breadcrumbs>
) : null;
}
@@ -176,7 +203,12 @@ export function EntityHeader(props: {
} = props;
const { entity } = useAsyncEntity();
const { kind, namespace, name } = useRouteRefParams(entityRouteRef);
const { headerTitle } = headerProps(kind, namespace, name, entity);
const { headerTitle: entityFallbackText, headerType: type } = headerProps(
kind,
namespace,
name,
entity,
);
const location = useLocation();
const navigate = useNavigate();
@@ -233,44 +265,28 @@ export function EntityHeader(props: {
);
const inspectDialogOpen = typeof selectedInspectEntityDialogTab === 'string';
const customTitle = typeof title === 'string' ? undefined : title;
const headerSubtitle = subtitle ?? (
<EntityHeaderSubtitle parentEntityRelations={parentEntityRelations} />
);
const renderedTitle = typeof title === 'string' ? title : headerTitle;
return (
<>
<HeaderPage
title={renderedTitle}
customActions={
entity ? (
<>
<FavoriteEntity entity={entity} />
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
contextMenuItems={contextMenuItems}
onInspectEntity={openInspectEntityDialog}
onUnregisterEntity={openUnregisterEntityDialog}
/>
</>
) : undefined
}
/>
{(customTitle || headerSubtitle || entity) && (
<Box mt={2}>
{customTitle}
{headerSubtitle}
{entity && (
<Box mt={customTitle || headerSubtitle ? 1 : 0}>
<EntityLabels entity={entity} />
</Box>
)}
</Box>
)}
<Header
pageTitleOverride={entityFallbackText}
type={type}
title={title ?? <EntityHeaderTitle />}
subtitle={
subtitle ?? (
<EntityHeaderSubtitle parentEntityRelations={parentEntityRelations} />
)
}
>
{entity && (
<>
<EntityLabels entity={entity} />
<EntityContextMenu
UNSTABLE_extraContextMenuItems={UNSTABLE_extraContextMenuItems}
UNSTABLE_contextMenuOptions={UNSTABLE_contextMenuOptions}
contextMenuItems={contextMenuItems}
onInspectEntity={openInspectEntityDialog}
onUnregisterEntity={openUnregisterEntityDialog}
/>
<InspectEntityDialog
entity={entity!}
initialTab={
@@ -290,6 +306,6 @@ export function EntityHeader(props: {
/>
</>
)}
</>
</Header>
);
}
@@ -21,7 +21,6 @@ import {
PageWithHeader,
ResponseErrorPanel,
} from '@backstage/core-components';
import { HeaderPage } from '@backstage/ui';
import Grid from '@material-ui/core/Grid';
import { ConfirmProvider } from 'material-ui-confirm';
import { useSignal } from '@backstage/plugin-signals-react';
@@ -233,12 +232,7 @@ function NotificationsPageContent(
);
if (headerVariant === 'bui') {
return (
<>
<HeaderPage title={subtitle || title} />
{pageContent}
</>
);
return pageContent;
}
return (
+98 -102
View File
@@ -25,7 +25,6 @@ import {
DocsIcon,
useSidebarPinState,
} from '@backstage/core-components';
import { HeaderPage } from '@backstage/ui';
import {
useApi,
discoveryApiRef,
@@ -142,113 +141,110 @@ export const searchPage = PageBlueprint.makeWithOverrides({
const configApi = useApi(configApiRef);
return (
<>
{!isMobile && <HeaderPage title="Search" />}
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBar debounceTime={100} />
</Grid>
{!isMobile && (
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue={configApi.getOptionalString(
'search.defaultType',
)}
showCounts
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
].concat(resultTypes)}
/>
<Paper className={classes.filters}>
{types.includes('techdocs') && (
<SearchFilter.Select
className={classes.filter}
label="Entity"
name="name"
values={async () => {
// Return a list of entities which are documented.
const { items } = await catalogApi.getEntities({
fields: ['metadata.name'],
filter: {
'metadata.annotations.backstage.io/techdocs-ref':
CATALOG_FILTER_EXISTS,
},
});
const names = items.map(
entity => entity.metadata.name,
);
names.sort();
return names;
}}
/>
)}
<Content>
<Grid container direction="row">
<Grid item xs={12}>
<SearchBar debounceTime={100} />
</Grid>
{!isMobile && (
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue={configApi.getOptionalString(
'search.defaultType',
)}
showCounts
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
].concat(resultTypes)}
/>
<Paper className={classes.filters}>
{types.includes('techdocs') && (
<SearchFilter.Select
className={classes.filter}
label="Kind"
name="kind"
values={[
'API',
'Component',
'Domain',
'Group',
'Location',
'Resource',
'System',
'Template',
'User',
]}
/>
<SearchFilter.Checkbox
className={classes.filter}
label="Lifecycle"
name="lifecycle"
values={['experimental', 'production']}
/>
{additionalSearchFilters.map(SearchFilterComponent => (
<SearchFilterComponent className={classes.filter} />
))}
</Paper>
</Grid>
)}
<Grid item xs>
<SearchPagination />
<SearchResults>
{({ results }) => (
<>
{results.map((result, index) => {
const { noTrack } = config;
const { document, ...rest } = result;
const SearchResultListItem =
getResultItemComponent(result);
return (
<SearchResultListItem
{...rest}
key={index}
result={document}
noTrack={noTrack}
/>
label="Entity"
name="name"
values={async () => {
// Return a list of entities which are documented.
const { items } = await catalogApi.getEntities({
fields: ['metadata.name'],
filter: {
'metadata.annotations.backstage.io/techdocs-ref':
CATALOG_FILTER_EXISTS,
},
});
const names = items.map(
entity => entity.metadata.name,
);
})}
</>
names.sort();
return names;
}}
/>
)}
</SearchResults>
<SearchResultPager />
<SearchFilter.Select
className={classes.filter}
label="Kind"
name="kind"
values={[
'API',
'Component',
'Domain',
'Group',
'Location',
'Resource',
'System',
'Template',
'User',
]}
/>
<SearchFilter.Checkbox
className={classes.filter}
label="Lifecycle"
name="lifecycle"
values={['experimental', 'production']}
/>
{additionalSearchFilters.map(SearchFilterComponent => (
<SearchFilterComponent className={classes.filter} />
))}
</Paper>
</Grid>
)}
<Grid item xs>
<SearchPagination />
<SearchResults>
{({ results }) => (
<>
{results.map((result, index) => {
const { noTrack } = config;
const { document, ...rest } = result;
const SearchResultListItem =
getResultItemComponent(result);
return (
<SearchResultListItem
{...rest}
key={index}
result={document}
noTrack={noTrack}
/>
);
})}
</>
)}
</SearchResults>
<SearchResultPager />
</Grid>
</Content>
</>
</Grid>
</Content>
);
};
-1
View File
@@ -75,7 +75,6 @@
"@backstage/plugin-techdocs-common": "workspace:^",
"@backstage/plugin-techdocs-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/ui": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
@@ -1,79 +0,0 @@
/*
* 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();
});
});
@@ -1,51 +0,0 @@
/*
* 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 { 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,
DefaultTechDocsHome,
} from '../home/components';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
const NfsTechDocsPageWrapper: FC<{ children?: ReactNode }> = ({ children }) => {
const configApi = useApi(configApiRef);
const generatedSubtitle = `Documentation available in ${
configApi.getOptionalString('organization.name') ?? 'Backstage'
}`;
return (
<Page themeId="documentation">
<HeaderPage title="Documentation" />
<Box mt={2}>{generatedSubtitle}</Box>
{children}
</Page>
);
};
export const NfsTechDocsIndexPage = (props: TechDocsIndexPageProps) => {
const outlet = useOutlet();
return (
outlet || (
<DefaultTechDocsHome {...props} PageWrapper={NfsTechDocsPageWrapper} />
)
);
};
@@ -1,205 +0,0 @@
/*
* 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 { 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';
import { useParams } from 'react-router-dom';
import { HeaderLabel, Page } from '@backstage/core-components';
import { HeaderPage } from '@backstage/ui';
import {
useTechDocsAddons,
TechDocsAddonLocations as locations,
useTechDocsReaderPage,
} from '@backstage/plugin-techdocs-react';
import {
entityPresentationApiRef,
EntityRefLink,
EntityRefLinks,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import {
RELATION_OWNED_BY,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import { TechDocsReaderPageContent } from '../reader/components/TechDocsReaderPageContent';
import { TechDocsReaderPageSubheader } from '../reader/components/TechDocsReaderPageSubheader';
import { rootRouteRef } from '../routes';
const skeleton = <Skeleton animation="wave" variant="text" height={40} />;
const NfsTechDocsReaderPageHeader = (props: PropsWithChildren<{}>) => {
const { children } = props;
const addons = useTechDocsAddons();
const configApi = useApi(configApiRef);
const entityPresentationApi = useApi(entityPresentationApiRef);
const docsRootLink = useRouteRef(rootRouteRef)();
const { '*': path = '' } = useParams();
const {
title,
setTitle,
subtitle,
setSubtitle,
entityRef,
metadata: { value: metadata, loading: metadataLoading },
entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
} = useTechDocsReaderPage();
useEffect(() => {
if (!metadata) {
return;
}
setTitle(metadata.site_name);
setSubtitle(() => {
let { site_description } = metadata;
if (!site_description || site_description === 'None') {
site_description = '';
}
return site_description;
});
}, [metadata, setTitle, setSubtitle]);
const appTitle = configApi.getOptional('app.title') || 'Backstage';
const { locationMetadata, spec } = entityMetadata || {};
const lifecycle = spec?.lifecycle;
const ownedByRelations = entityMetadata
? getEntityRelations(entityMetadata, RELATION_OWNED_BY)
: [];
const labels = (
<>
<HeaderLabel
label={capitalize(entityMetadata?.kind || 'entity')}
value={
<EntityRefLink
color="inherit"
entityRef={entityRef}
title={entityMetadata?.metadata.title}
defaultKind="Component"
/>
}
/>
{ownedByRelations.length > 0 && (
<HeaderLabel
label="Owner"
value={
<EntityRefLinks
color="inherit"
entityRefs={ownedByRelations}
defaultKind="group"
/>
}
/>
)}
{lifecycle ? (
<HeaderLabel label="Lifecycle" value={String(lifecycle)} />
) : null}
{locationMetadata &&
locationMetadata.type !== 'dir' &&
locationMetadata.type !== 'file' ? (
<HeaderLabel
label=""
value={
<Grid container direction="column" alignItems="center">
<Grid style={{ padding: 0 }} item>
<CodeIcon style={{ marginTop: '-25px' }} />
</Grid>
<Grid style={{ padding: 0 }} item>
Source
</Grid>
</Grid>
}
url={locationMetadata.target}
/>
) : null}
</>
);
const noEntMetadata = !entityMetadataLoading && entityMetadata === undefined;
const noTdMetadata = !metadataLoading && metadata === undefined;
if (noEntMetadata || noTdMetadata) {
return null;
}
const stringEntityRef = stringifyEntityRef(entityRef);
const entityDisplayName =
entityPresentationApi.forEntity(stringEntityRef).snapshot.primaryTitle;
const removeTrailingSlash = (str: string) => str.replace(/\/$/, '');
const normalizeAndSpace = (str: string) =>
str.replace(/[-_]/g, ' ').split(' ').map(capitalize).join(' ');
let techdocsTabTitleItems: string[] = [];
if (path !== '') {
techdocsTabTitleItems = removeTrailingSlash(path)
.split('/')
.map(normalizeAndSpace);
}
const tabTitleItems = [entityDisplayName, ...techdocsTabTitleItems, appTitle];
const tabTitle = tabTitleItems.join(' | ');
return (
<>
<Helmet titleTemplate="%s">
<title>{tabTitle}</title>
</Helmet>
<HeaderPage
title={title || 'Documentation'}
breadcrumbs={[{ label: 'Documentation', href: docsRootLink }]}
customActions={
<>
{children}
{addons.renderComponentsByLocation(locations.Header)}
</>
}
/>
{(subtitle ||
metadataLoading ||
entityMetadataLoading ||
entityMetadata) && (
<Box mt={2}>
{subtitle !== '' ? subtitle || skeleton : null}
{entityMetadata && <Box mt={subtitle === '' ? 0 : 1}>{labels}</Box>}
</Box>
)}
</>
);
};
export type NfsTechDocsReaderLayoutProps = {
withHeader?: boolean;
withSearch?: boolean;
};
export const NfsTechDocsReaderLayout = (
props: NfsTechDocsReaderLayoutProps,
) => {
const { withSearch, withHeader = true } = props;
return (
<Page themeId="documentation">
{withHeader && <NfsTechDocsReaderPageHeader />}
<TechDocsReaderPageSubheader />
<TechDocsReaderPageContent withSearch={withSearch} />
</Page>
);
};
+6 -6
View File
@@ -46,6 +46,7 @@ import {
rootDocsRouteRef,
rootRouteRef,
} from '../routes';
import { TechDocsReaderLayout } from '../reader';
import {
TechDocsAddons,
techdocsApiRef,
@@ -139,7 +140,9 @@ const techDocsPage = PageBlueprint.make({
path: '/docs',
routeRef: rootRouteRef,
loader: () =>
import('./NfsTechDocsIndexPage').then(m => <m.NfsTechDocsIndexPage />),
import('../home/components/TechDocsIndexPage').then(m => (
<m.TechDocsIndexPage />
)),
},
});
@@ -183,12 +186,9 @@ const techDocsReaderPage = PageBlueprint.makeWithOverrides({
);
});
return Promise.all([
import('../Router'),
import('./NfsTechDocsReaderLayout'),
]).then(([{ TechDocsReaderRouter }, { NfsTechDocsReaderLayout }]) => (
return import('../Router').then(({ TechDocsReaderRouter }) => (
<TechDocsReaderRouter>
<NfsTechDocsReaderLayout
<TechDocsReaderLayout
withSearch={!config.withoutSearch}
withHeader={!config.withoutHeader}
/>
@@ -140,7 +140,6 @@ export type TechDocsReaderLayoutProps = {
*/
export const TechDocsReaderLayout = (props: TechDocsReaderLayoutProps) => {
const { withSearch, withHeader = true } = props;
return (
<Page themeId="documentation">
{withHeader && <TechDocsReaderPageHeader />}