feat(techdocs): migrate alpha plugin pages to BUI header system

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-19 09:49:26 +01:00
parent 423d675d97
commit dc3cc87599
17 changed files with 1504 additions and 8 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': patch
---
Migrated the TechDocs alpha plugin pages to use BUI components. The index page and reader page now use BUI `Header` and `Container` instead of legacy `Page`/`Content`/`ContentHeader` wrappers. Added a `SupportButton` as a plugin header action. Changed plugin title to "Documentation" and icon to `RiArticleLine`.
+2
View File
@@ -75,11 +75,13 @@
"@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",
"@material-ui/styles": "^4.10.0",
"@microsoft/fetch-event-source": "^2.0.1",
"@remixicon/react": "^4.6.0",
"dompurify": "^3.3.2",
"git-url-parse": "^15.0.0",
"lodash": "^4.17.21",
+13
View File
@@ -448,6 +448,19 @@ const _default: OverridableFrontendPlugin<
noHeader?: boolean;
};
}>;
'plugin-header-action:techdocs': OverridableExtensionDefinition<{
kind: 'plugin-header-action';
name: undefined;
config: {};
configInput: {};
output: ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>;
inputs: {};
params: (params: {
loader: () => Promise<JSX.Element>;
}) => ExtensionBlueprintParams<{
loader: () => Promise<JSX.Element>;
}>;
}>;
'search-result-list-item:techdocs': OverridableExtensionDefinition<{
config: {
title: string | undefined;
@@ -0,0 +1,75 @@
/*
* 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 { TechDocsIndexPageContent } from './TechDocsIndexPageContent';
const mockCatalogApi = catalogApiMock({
entities: [
{
apiVersion: 'version',
kind: 'User',
metadata: {
name: 'owned',
namespace: 'default',
},
},
],
});
describe('<TechDocsIndexPageContent />', () => {
const configApi = mockApis.config({
data: { organization: { name: 'My Company' } },
});
const apiRegistry = TestApiRegistry.from(
[catalogApiRef, mockCatalogApi],
[configApiRef, configApi],
[storageApiRef, mockApis.storage()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
);
it('should render the filter layout with pickers', async () => {
await renderInTestApp(
<ApiProvider apis={apiRegistry}>
<TechDocsIndexPageContent />
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
// The UserListPicker renders filter options
expect(await screen.findByTestId('user-picker-owned')).toBeInTheDocument();
expect(screen.getByTestId('user-picker-all')).toBeInTheDocument();
});
});
@@ -0,0 +1,60 @@
/*
* 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 { Container } from '@backstage/ui';
import {
CatalogFilterLayout,
EntityListProvider,
EntityOwnerPicker,
EntityTagPicker,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import { TechDocsPicker } from '../../home/components/TechDocsPicker';
import { EntityListDocsTable } from '../../home/components/Tables';
import type { TechDocsIndexPageProps } from '../../home/components/TechDocsIndexPage';
export const TechDocsIndexPageContent = (props: TechDocsIndexPageProps) => {
const {
initialFilter = 'owned',
columns,
actions,
ownerPickerMode,
pagination,
options,
} = props;
return (
<Container mt="6">
<EntityListProvider pagination={pagination}>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<TechDocsPicker />
<UserListPicker initialFilter={initialFilter} />
<EntityOwnerPicker mode={ownerPickerMode} />
<EntityTagPicker />
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<EntityListDocsTable
actions={actions}
columns={columns}
options={options}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
</EntityListProvider>
</Container>
);
};
@@ -0,0 +1,238 @@
/*
* 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 { ReactNode } from 'react';
import { waitFor } from '@testing-library/react';
import {
CompoundEntityRef,
getCompoundEntityRef,
} from '@backstage/catalog-model';
import {
techdocsApiRef,
TechDocsReaderPageProvider,
useShadowRootElements,
} from '@backstage/plugin-techdocs-react';
import {
renderInTestApp,
TestApiProvider,
mockApis,
} from '@backstage/test-utils';
import { configApiRef } from '@backstage/core-plugin-api';
const useTechDocsReaderDom = jest.fn();
jest.mock('../../reader/components/TechDocsReaderPageContent/dom', () => ({
...jest.requireActual(
'../../reader/components/TechDocsReaderPageContent/dom',
),
useTechDocsReaderDom: (...args: any[]) => useTechDocsReaderDom(...args),
}));
const useReaderState = jest.fn();
jest.mock('../../reader/components/useReaderState', () => ({
...jest.requireActual('../../reader/components/useReaderState'),
useReaderState: (...args: any[]) => useReaderState(...args),
}));
const useShadowDomStylesLoading = jest.fn().mockReturnValue(false);
jest.mock('@backstage/plugin-techdocs-react', () => ({
...jest.requireActual('@backstage/plugin-techdocs-react'),
useShadowDomStylesLoading: (...args: any[]) =>
useShadowDomStylesLoading(...args),
useShadowRootElements: jest.fn(),
}));
import { TechDocsReaderContent } from './TechDocsReaderContent';
const mockEntityMetadata = {
locationMetadata: {
type: 'github',
target: 'https://example.com/',
},
apiVersion: 'v1',
kind: 'test',
metadata: {
name: 'test-name',
namespace: 'test-namespace',
},
spec: {
owner: 'test',
},
};
const mockTechDocsMetadata = {
site_name: 'test-site-name',
site_description: 'test-site-desc',
};
const getEntityMetadata = jest.fn();
const getTechDocsMetadata = jest.fn();
const techdocsApiMock = {
getEntityMetadata,
getTechDocsMetadata,
};
const Wrapper = ({
entityRef = {
kind: mockEntityMetadata.kind,
name: mockEntityMetadata.metadata.name,
namespace: mockEntityMetadata.metadata.namespace!!,
},
children,
}: {
entityRef?: CompoundEntityRef;
children: ReactNode;
}) => (
<TestApiProvider
apis={[
[techdocsApiRef, techdocsApiMock],
[configApiRef, mockApis.config()],
]}
>
<TechDocsReaderPageProvider entityRef={entityRef}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
);
describe('<TechDocsReaderContent />', () => {
const useShadowRootElementsMock = useShadowRootElements as jest.Mock;
beforeEach(() => {
useShadowRootElementsMock.mockReturnValue([]);
});
afterEach(() => {
jest.clearAllMocks();
});
it('should render techdocs content', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useReaderState.mockReturnValue({ state: 'cached' });
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderContent />
</Wrapper>,
);
await waitFor(() => {
expect(
rendered.getByTestId('techdocs-native-shadowroot'),
).toBeInTheDocument();
});
});
it('should render with default path', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useReaderState.mockReturnValue({ state: 'cached' });
const defaultPath = '/some/path';
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderContent defaultPath={defaultPath} />
</Wrapper>,
);
await waitFor(() => {
expect(
rendered.getByTestId('techdocs-native-shadowroot'),
).toBeInTheDocument();
});
const entityRef = getCompoundEntityRef(mockEntityMetadata);
expect(useTechDocsReaderDom).toHaveBeenCalledWith(entityRef, defaultPath);
});
it('should not render content if entity metadata is missing', async () => {
getEntityMetadata.mockResolvedValue(undefined);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useReaderState.mockReturnValue({ state: 'cached' });
await expect(
renderInTestApp(
<Wrapper>
<TechDocsReaderContent />
</Wrapper>,
),
).rejects.toThrow('Reached NotFound Page');
const shadowRoot = document.querySelector(
'[data-testid="techdocs-native-shadowroot"]',
);
expect(shadowRoot).not.toBeInTheDocument();
});
it('should render 404 if there is no dom and reader state is not found', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useTechDocsReaderDom.mockReturnValue(undefined);
useReaderState.mockReturnValue({ state: 'CONTENT_NOT_FOUND' });
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderContent />
</Wrapper>,
);
await waitFor(() => {
expect(
rendered.queryByTestId('techdocs-native-shadowroot'),
).not.toBeInTheDocument();
expect(
rendered.getByText('ERROR 404: Documentation not found'),
).toBeInTheDocument();
});
});
it('should render progress bar when content is loading', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useReaderState.mockReturnValue({ state: 'CHECKING' });
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderContent />
</Wrapper>,
);
await waitFor(() => {
expect(rendered.queryByRole('progressbar')).toBeInTheDocument();
});
});
it('should render progress bar when styles are loading', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useReaderState.mockReturnValue({ state: 'cached' });
useShadowDomStylesLoading.mockReturnValue(true);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderContent />
</Wrapper>,
);
await waitFor(() => {
expect(rendered.queryByRole('progressbar')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,104 @@
/*
* 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 { useCallback, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import {
TechDocsShadowDom,
useShadowDomStylesLoading,
useShadowRootElements,
useTechDocsReaderPage,
} from '@backstage/plugin-techdocs-react';
import { Progress } from '@backstage/core-components';
import { TechDocsStateIndicator } from '../../reader/components/TechDocsStateIndicator';
import { useTechDocsReaderDom } from '../../reader/components/TechDocsReaderPageContent/dom';
import {
useTechDocsReader,
withTechDocsReaderProvider,
} from '../../reader/components/TechDocsReaderProvider';
import { TechDocsReaderPageContentAddons } from '../../reader/components/TechDocsReaderPageContent/TechDocsReaderPageContentAddons';
import { useApp } from '@backstage/core-plugin-api';
import type { TechDocsReaderPageContentProps } from '../../reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent';
export const TechDocsReaderContent = withTechDocsReaderProvider(
(props: TechDocsReaderPageContentProps) => {
const { onReady } = props;
const {
entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
entityRef,
setShadowRoot,
} = useTechDocsReaderPage();
const { state } = useTechDocsReader();
const dom = useTechDocsReaderDom(entityRef, props.defaultPath);
const location = useLocation();
const path = location.pathname;
const hash = location.hash;
const isStyleLoading = useShadowDomStylesLoading(dom);
const [hashElement] = useShadowRootElements([`[id="${hash.slice(1)}"]`]);
const app = useApp();
const { NotFoundErrorPage } = app.getComponents();
useEffect(() => {
if (isStyleLoading) return;
if (hash) {
if (hashElement) {
hashElement.scrollIntoView();
const link = hashElement.querySelector<HTMLElement>('a.headerlink');
if (link) {
link.focus();
}
}
} else {
document?.querySelector('header')?.scrollIntoView();
}
}, [path, hash, hashElement, isStyleLoading]);
const handleAppend = useCallback(
(newShadowRoot: ShadowRoot) => {
setShadowRoot(newShadowRoot);
if (onReady instanceof Function) {
onReady();
}
},
[setShadowRoot, onReady],
);
// No entity metadata = 404. Don't render content at all.
if (entityMetadataLoading === false && !entityMetadata)
return <NotFoundErrorPage />;
// Do not return content until dom is ready; instead, render a state
// indicator, which handles progress and content errors on our behalf.
if (!dom) {
return <TechDocsStateIndicator />;
}
return (
<>
<TechDocsStateIndicator />
{(state === 'CHECKING' || isStyleLoading) && <Progress />}
<TechDocsShadowDom element={dom} onAppend={handleAppend}>
<TechDocsReaderPageContentAddons />
</TechDocsShadowDom>
</>
);
},
);
@@ -0,0 +1,36 @@
.entityCard {
margin-bottom: var(--bui-space-6);
}
.cardBody {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--bui-space-4);
}
.definitionList {
display: flex;
flex-wrap: wrap;
gap: var(--bui-space-6);
margin: 0;
padding: 0;
flex: 1 1 0;
align-self: center;
}
.definitionGroup {
display: flex;
align-items: baseline;
gap: var(--bui-space-2);
}
.definitionValue {
margin: 0;
}
.searchWrapper {
flex: 1 1 200px;
max-width: 240px;
}
@@ -0,0 +1,215 @@
/*
* 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 { ReactNode } from 'react';
import { CompoundEntityRef, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { entityRouteRef } from '@backstage/plugin-catalog-react';
import {
techdocsApiRef,
TechDocsReaderPageProvider,
} from '@backstage/plugin-techdocs-react';
import {
renderInTestApp,
TestApiProvider,
mockApis,
} from '@backstage/test-utils';
import { configApiRef } from '@backstage/core-plugin-api';
import { searchApiRef, MockSearchApi } from '@backstage/plugin-search-react';
import { TechDocsReaderEntityCard } from './TechDocsReaderEntityCard';
const mockEntityMetadata = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test-component',
namespace: 'default',
title: 'Test Component Title',
},
spec: {
owner: 'team-a',
lifecycle: 'production',
},
relations: [
{
type: RELATION_OWNED_BY,
targetRef: 'group:default/team-a',
},
],
};
const mockTechDocsMetadata = {
site_name: 'test-site-name',
site_description: 'test-site-desc',
};
const getEntityMetadata = jest.fn();
const getTechDocsMetadata = jest.fn();
const techdocsApiMock = {
getEntityMetadata,
getTechDocsMetadata,
};
const Wrapper = ({
entityRef = {
kind: mockEntityMetadata.kind,
name: mockEntityMetadata.metadata.name,
namespace: mockEntityMetadata.metadata.namespace!!,
},
children,
}: {
entityRef?: CompoundEntityRef;
children: ReactNode;
}) => (
<TestApiProvider
apis={[
[techdocsApiRef, techdocsApiMock],
[configApiRef, mockApis.config()],
[searchApiRef, new MockSearchApi()],
]}
>
<TechDocsReaderPageProvider entityRef={entityRef}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
);
const mountedRoutes = {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
};
describe('<TechDocsReaderEntityCard />', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should render entity kind, owner, and lifecycle', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderEntityCard />
</Wrapper>,
{ mountedRoutes },
);
expect(await rendered.findByText('Component:')).toBeInTheDocument();
expect(rendered.getByText('Owner:')).toBeInTheDocument();
expect(rendered.getByText('Lifecycle:')).toBeInTheDocument();
expect(rendered.getByText('production')).toBeInTheDocument();
});
it('should not render when entity metadata is loading', async () => {
// Don't resolve the metadata — it stays in loading state
getEntityMetadata.mockReturnValue(new Promise(() => {}));
getTechDocsMetadata.mockReturnValue(new Promise(() => {}));
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderEntityCard />
</Wrapper>,
{ mountedRoutes },
);
expect(rendered.queryByText('Component:')).not.toBeInTheDocument();
});
it('should not render when entity metadata is missing', async () => {
getEntityMetadata.mockResolvedValue(undefined);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderEntityCard />
</Wrapper>,
{ mountedRoutes },
);
expect(rendered.queryByText('Component:')).not.toBeInTheDocument();
});
it('should not render lifecycle when not present', async () => {
getEntityMetadata.mockResolvedValue({
...mockEntityMetadata,
spec: { owner: 'team-a' },
});
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderEntityCard />
</Wrapper>,
{ mountedRoutes },
);
expect(await rendered.findByText('Component:')).toBeInTheDocument();
expect(rendered.queryByText('Lifecycle:')).not.toBeInTheDocument();
});
it('should not render owner when no owned-by relations exist', async () => {
getEntityMetadata.mockResolvedValue({
...mockEntityMetadata,
relations: [],
});
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderEntityCard />
</Wrapper>,
{ mountedRoutes },
);
expect(await rendered.findByText('Component:')).toBeInTheDocument();
expect(rendered.queryByText('Owner:')).not.toBeInTheDocument();
});
it('should render search when withSearch is true', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderEntityCard withSearch />
</Wrapper>,
{ mountedRoutes },
);
expect(await rendered.findByText('Component:')).toBeInTheDocument();
expect(
rendered.getByRole('searchbox', { name: 'Search docs' }),
).toBeInTheDocument();
});
it('should not render search when withSearch is false', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderEntityCard />
</Wrapper>,
{ mountedRoutes },
);
expect(await rendered.findByText('Component:')).toBeInTheDocument();
expect(
rendered.queryByRole('searchbox', { name: 'Search docs' }),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,105 @@
/*
* 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 { useTechDocsReaderPage } from '@backstage/plugin-techdocs-react';
import {
EntityRefLink,
EntityRefLinks,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import { Card, CardBody, Text } from '@backstage/ui';
import capitalize from 'lodash/capitalize';
import styles from './TechDocsReaderEntityCard.module.css';
import { TechDocsReaderSearch } from './TechDocsReaderSearch';
export type TechDocsReaderEntityCardProps = {
withSearch?: boolean;
};
export const TechDocsReaderEntityCard = (
props: TechDocsReaderEntityCardProps,
) => {
const { withSearch } = props;
const {
entityRef,
entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
} = useTechDocsReaderPage();
if (entityMetadataLoading || !entityMetadata) return null;
const { spec } = entityMetadata;
const lifecycle = spec?.lifecycle;
const ownedByRelations = getEntityRelations(
entityMetadata,
RELATION_OWNED_BY,
);
return (
<Card className={styles.entityCard}>
<CardBody className={styles.cardBody}>
<dl className={styles.definitionList}>
<div className={styles.definitionGroup}>
<dt>
<Text variant="body-small" weight="bold">
{capitalize(entityMetadata.kind)}:
</Text>
</dt>
<dd className={styles.definitionValue}>
<EntityRefLink
entityRef={entityRef}
title={entityMetadata.metadata.title}
defaultKind="Component"
/>
</dd>
</div>
{ownedByRelations.length > 0 && (
<div className={styles.definitionGroup}>
<dt>
<Text variant="body-small" weight="bold">
Owner:
</Text>
</dt>
<dd className={styles.definitionValue}>
<EntityRefLinks
entityRefs={ownedByRelations}
defaultKind="group"
/>
</dd>
</div>
)}
{lifecycle && (
<div className={styles.definitionGroup}>
<dt>
<Text variant="body-small" weight="bold">
Lifecycle:
</Text>
</dt>
<dd className={styles.definitionValue}>
<Text variant="body-small">{String(lifecycle)}</Text>
</dd>
</div>
)}
</dl>
{withSearch && (
<div className={styles.searchWrapper}>
<TechDocsReaderSearch entityId={entityRef} />
</div>
)}
</CardBody>
</Card>
);
};
@@ -0,0 +1,224 @@
/*
* 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 { ReactNode } from 'react';
import { waitFor } from '@testing-library/react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import {
entityPresentationApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import {
techdocsApiRef,
TechDocsReaderPageProvider,
} from '@backstage/plugin-techdocs-react';
import {
renderInTestApp,
TestApiProvider,
mockApis,
} from '@backstage/test-utils';
import { configApiRef } from '@backstage/core-plugin-api';
import { rootRouteRef } from '../../routes';
import { TechDocsReaderHeader } from './TechDocsReaderHeader';
const mockEntityMetadata = {
locationMetadata: {
type: 'github',
target: 'https://example.com/',
},
apiVersion: 'v1',
kind: 'test',
metadata: {
name: 'test-name',
namespace: 'test-namespace',
},
spec: {
owner: 'test',
},
};
const mockTechDocsMetadata = {
site_name: 'test-site-name',
site_description: 'test-site-desc',
};
let useParamsPath = '/';
jest.mock('react-router-dom', () => {
return {
...(jest.requireActual('react-router-dom') as any),
useParams: () => ({ '*': useParamsPath }),
};
});
const getEntityMetadata = jest.fn();
const getTechDocsMetadata = jest.fn();
const techdocsApiMock = {
getEntityMetadata,
getTechDocsMetadata,
};
const forEntity = jest.fn();
forEntity.mockReturnValue({
snapshot: {
primaryTitle: 'Test Entity',
},
});
const entityPresentationApiMock = {
forEntity,
};
const configApiMock = mockApis.config();
const Wrapper = ({
entityRef = {
kind: mockEntityMetadata.kind,
name: mockEntityMetadata.metadata.name,
namespace: mockEntityMetadata.metadata.namespace!!,
},
children,
}: {
entityRef?: CompoundEntityRef;
children: ReactNode;
}) => (
<TestApiProvider
apis={[
[techdocsApiRef, techdocsApiMock],
[entityPresentationApiRef, entityPresentationApiMock],
[configApiRef, configApiMock],
]}
>
<TechDocsReaderPageProvider entityRef={entityRef}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
);
const mountedRoutes = {
'/catalog/:namespace/:kind/:name/*': entityRouteRef,
'/docs': rootRouteRef,
};
describe('<TechDocsReaderHeader withSearch={false} />', () => {
beforeEach(() => {
useParamsPath = '/';
jest.clearAllMocks();
forEntity.mockReturnValue({
snapshot: { primaryTitle: 'Test Entity' },
});
});
it('should render the header with site name as title', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderHeader withSearch={false} />
</Wrapper>,
{ mountedRoutes },
);
expect(await rendered.findByText('test-site-name')).toBeInTheDocument();
});
it('should not render the header if entity metadata is missing', async () => {
getEntityMetadata.mockResolvedValue(undefined);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderHeader withSearch={false} />
</Wrapper>,
{ mountedRoutes },
);
expect(rendered.container.innerHTML).not.toContain('header');
});
it('should not render the header if techdocs metadata is missing', async () => {
getTechDocsMetadata.mockResolvedValue(undefined);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderHeader withSearch={false} />
</Wrapper>,
{ mountedRoutes },
);
expect(rendered.container.innerHTML).not.toContain('header');
});
it('should render a source link for remote docs', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderHeader withSearch={false} />
</Wrapper>,
{ mountedRoutes },
);
const sourceLink = await rendered.findByRole('link', {
name: 'View source',
});
expect(sourceLink).toHaveAttribute('href', 'https://example.com/');
expect(sourceLink).toHaveAttribute('target', '_blank');
});
it('should not render a source link for local docs', async () => {
getEntityMetadata.mockResolvedValue({
...mockEntityMetadata,
locationMetadata: { type: 'dir', target: '/local/path' },
});
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderHeader withSearch={false} />
</Wrapper>,
{ mountedRoutes },
);
await rendered.findByText('test-site-name');
expect(
rendered.queryByRole('link', { name: 'View source' }),
).not.toBeInTheDocument();
});
it('should set document title based on URL path segments', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useParamsPath = 'foo/bar/baz/';
await renderInTestApp(
<Wrapper>
<TechDocsReaderHeader withSearch={false} />
</Wrapper>,
{ mountedRoutes },
);
await waitFor(() => {
expect(document.title).toEqual(
'Test Entity | Foo | Bar | Baz | Backstage',
);
});
});
});
@@ -0,0 +1,121 @@
/*
* 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 { useEffect } from 'react';
import Helmet from 'react-helmet';
import { Header, ButtonLink } from '@backstage/ui';
import { RiCodeLine } from '@remixicon/react';
import {
TechDocsAddonLocations as locations,
useTechDocsAddons,
useTechDocsReaderPage,
} from '@backstage/plugin-techdocs-react';
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import capitalize from 'lodash/capitalize';
import { useParams } from 'react-router-dom';
import { TechDocsReaderSearch } from './TechDocsReaderSearch';
export type TechDocsReaderHeaderProps = {
withSearch?: boolean;
};
export const TechDocsReaderHeader = (props: TechDocsReaderHeaderProps) => {
const { withSearch = true } = props;
const addons = useTechDocsAddons();
const configApi = useApi(configApiRef);
const entityPresentationApi = useApi(entityPresentationApiRef);
const { '*': path = '' } = useParams();
const {
title,
setTitle,
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 = entityMetadata?.locationMetadata;
const showSourceLink =
locationMetadata &&
locationMetadata.type !== 'dir' &&
locationMetadata.type !== 'file';
// Hide header on 404 pages
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>
<Header
title={title || ''}
customActions={
<>
{withSearch && <TechDocsReaderSearch entityId={entityRef} />}
{showSourceLink && (
<ButtonLink
href={locationMetadata.target}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
size="small"
iconStart={<RiCodeLine />}
aria-label="View source"
/>
)}
{addons.renderComponentsByLocation(locations.Header)}
</>
}
/>
</>
);
};
@@ -0,0 +1,40 @@
/*
* 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 { Container } from '@backstage/ui';
import { TechDocsReaderHeader } from './TechDocsReaderHeader';
import { TechDocsReaderEntityCard } from './TechDocsReaderEntityCard';
import { TechDocsReaderPageSubheader } from '../../reader/components/TechDocsReaderPageSubheader';
import { TechDocsReaderContent } from './TechDocsReaderContent';
export type TechDocsReaderLayoutProps = {
withHeader?: boolean;
withSearch?: boolean;
};
export const TechDocsReaderLayout = (props: TechDocsReaderLayoutProps) => {
const { withSearch, withHeader = true } = props;
return (
<>
{withHeader && <TechDocsReaderHeader withSearch={withSearch} />}
<Container mt="6">
<TechDocsReaderEntityCard withSearch={!withHeader && withSearch} />
<TechDocsReaderPageSubheader />
<TechDocsReaderContent />
</Container>
</>
);
};
@@ -0,0 +1,73 @@
/*
* 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 { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { searchApiRef, MockSearchApi } from '@backstage/plugin-search-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { TechDocsReaderSearch } from './TechDocsReaderSearch';
const entityId = {
kind: 'Component',
name: 'test-component',
namespace: 'default',
};
const mockSearchApi = new MockSearchApi({
results: [
{
type: 'techdocs',
document: {
location: '/docs/default/component/test-component/getting-started',
title: 'Getting Started',
text: 'This guide helps you get started.',
},
},
],
});
describe('<TechDocsReaderSearch />', () => {
it('should render the search input', async () => {
await renderInTestApp(
<TestApiProvider apis={[[searchApiRef, mockSearchApi]]}>
<TechDocsReaderSearch entityId={entityId} />
</TestApiProvider>,
);
expect(
screen.getByRole('searchbox', { name: 'Search docs' }),
).toBeInTheDocument();
});
it('should render search results when user types', async () => {
const user = userEvent.setup();
await renderInTestApp(
<TestApiProvider apis={[[searchApiRef, mockSearchApi]]}>
<TechDocsReaderSearch entityId={entityId} />
</TestApiProvider>,
);
const input = screen.getByRole('searchbox', { name: 'Search docs' });
await user.type(input, 'getting');
expect(await screen.findByText('Getting Started')).toBeInTheDocument();
expect(
screen.getByText('This guide helps you get started.'),
).toBeInTheDocument();
});
});
@@ -0,0 +1,172 @@
/*
* 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 { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Text,
Flex,
SearchAutocomplete,
SearchAutocompleteItem,
} from '@backstage/ui';
import {
SearchContextProvider,
useSearch,
} from '@backstage/plugin-search-react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { ResultHighlight } from '@backstage/plugin-search-common';
import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
type TechDocsDoc = {
namespace: string;
kind: string;
name: string;
path: string;
location: string;
title: string;
text: string;
};
type TechDocsSearchResult = {
type: string;
document: TechDocsDoc;
highlight?: ResultHighlight;
};
type TechDocsReaderSearchBarProps = {
entityId: CompoundEntityRef;
};
const TechDocsReaderSearchBar = (props: TechDocsReaderSearchBarProps) => {
const { entityId } = props;
const navigate = useNavigate();
const {
setFilters,
setTerm,
term,
result: { loading, value: searchVal },
} = useSearch();
const [results, setResults] = useState<TechDocsSearchResult[]>([]);
const [deferredLoading, setDeferredLoading] = useState(false);
const loadingTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (searchVal) {
setResults(searchVal.results.slice(0, 10) as TechDocsSearchResult[]);
}
}, [loading, searchVal]);
useEffect(() => {
clearTimeout(loadingTimer.current);
setDeferredLoading(false);
if (loading) {
loadingTimer.current = setTimeout(() => setDeferredLoading(true), 200);
}
return () => clearTimeout(loadingTimer.current);
}, [term, loading]);
const { kind, name, namespace } = entityId;
useEffect(() => {
setFilters(prevFilters => ({
...prevFilters,
kind,
namespace,
name,
}));
}, [kind, namespace, name, setFilters]);
return (
<SearchAutocomplete
aria-label="Search docs"
placeholder="Search docs"
size="small"
inputValue={term}
onInputChange={setTerm}
isLoading={deferredLoading}
popoverWidth="min(720px, calc(100vw - 32px))"
popoverPlacement="bottom end"
>
{results.map((result, index) => (
<SearchAutocompleteItem
key={index}
id={String(index)}
textValue={result.document.title}
onAction={() => {
setTerm('');
navigate(result.document.location);
requestAnimationFrame(() => {
(document.activeElement as HTMLElement)?.blur();
});
}}
>
<Flex direction="column" gap="1">
<Text weight="bold">
{result.highlight?.fields.title ? (
<HighlightedSearchResultText
text={result.highlight.fields.title}
preTag={result.highlight.preTag}
postTag={result.highlight.postTag}
/>
) : (
result.document.title
)}
</Text>
{(result.highlight?.fields.text || result.document.text) && (
<Text
variant="body-small"
color="secondary"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{result.highlight?.fields.text ? (
<HighlightedSearchResultText
text={result.highlight.fields.text}
preTag={result.highlight.preTag}
postTag={result.highlight.postTag}
/>
) : (
result.document.text
)}
</Text>
)}
</Flex>
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
);
};
export type TechDocsReaderSearchProps = {
entityId: CompoundEntityRef;
};
export const TechDocsReaderSearch = (props: TechDocsReaderSearchProps) => {
const initialState = {
term: '',
types: ['techdocs'],
pageCursor: '',
filters: props.entityId,
};
return (
<SearchContextProvider initialState={initialState}>
<TechDocsReaderSearchBar {...props} />
</SearchContextProvider>
);
};
+19 -8
View File
@@ -15,12 +15,13 @@
*/
import { Suspense } from 'react';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import { RiArticleLine } from '@remixicon/react';
import {
createFrontendPlugin,
ApiBlueprint,
PageBlueprint,
NavItemBlueprint,
PluginHeaderActionBlueprint,
createExtensionInput,
coreExtensionData,
createExtension,
@@ -46,7 +47,7 @@ import {
rootDocsRouteRef,
rootRouteRef,
} from '../routes';
import { TechDocsReaderLayout } from '../reader';
import { TechDocsReaderLayout } from './components/TechDocsReaderLayout';
import {
TechDocsAddons,
techdocsApiRef,
@@ -54,7 +55,7 @@ import {
} from '@backstage/plugin-techdocs-react';
import { useTechdocsReaderIconLinkProps } from './hooks/useTechdocsReaderIconLinkProps';
import { DocsIcon } from '@backstage/core-components';
import { DocsIcon, SupportButton } from '@backstage/core-components';
/** @alpha */
const techdocsEntityIconLink = EntityIconLinkBlueprint.make({
@@ -140,8 +141,8 @@ const techDocsPage = PageBlueprint.make({
path: '/docs',
routeRef: rootRouteRef,
loader: () =>
import('../home/components/TechDocsIndexPage').then(m => (
<m.TechDocsIndexPage />
import('./components/TechDocsIndexPageContent').then(m => (
<m.TechDocsIndexPageContent />
)),
},
});
@@ -270,23 +271,33 @@ const techDocsEntityContentEmptyState = createExtension({
/** @alpha */
const techDocsNavItem = NavItemBlueprint.make({
params: {
icon: LibraryBooks,
icon: () => <RiArticleLine />,
title: 'Docs',
routeRef: rootRouteRef,
},
});
const techDocsSupportAction = PluginHeaderActionBlueprint.make({
params: defineParams =>
defineParams({
loader: async () => (
<SupportButton>Discover documentation in your ecosystem.</SupportButton>
),
}),
});
/** @alpha */
export default createFrontendPlugin({
pluginId: 'techdocs',
title: 'Docs',
icon: <LibraryBooks fontSize="inherit" />,
title: 'Documentation',
icon: <RiArticleLine />,
info: { packageJson: () => import('../../package.json') },
extensions: [
techDocsClientApi,
techDocsStorageApi,
TechDocsAddonsApiExtension,
techDocsNavItem,
techDocsSupportAction,
techDocsPage,
techDocsReaderPage,
techdocsEntityIconLink,
+2
View File
@@ -7657,11 +7657,13 @@ __metadata:
"@backstage/plugin-techdocs-react": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/ui": "workspace:^"
"@material-ui/core": "npm:^4.12.2"
"@material-ui/icons": "npm:^4.9.1"
"@material-ui/lab": "npm:4.0.0-alpha.61"
"@material-ui/styles": "npm:^4.10.0"
"@microsoft/fetch-event-source": "npm:^2.0.1"
"@remixicon/react": "npm:^4.6.0"
"@testing-library/dom": "npm:^10.0.0"
"@testing-library/jest-dom": "npm:^6.0.0"
"@testing-library/react": "npm:^16.0.0"