Merge pull request #33437 from backstage/techdocs-bui-header-migration

feat(techdocs): migrate alpha plugin pages to BUI header system
This commit is contained in:
Patrik Oldsberg
2026-03-20 09:06:57 +01:00
committed by GitHub
35 changed files with 2212 additions and 190 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Made `SearchAutocomplete` background-aware. The input now adapts its background color based on its parent container's background level.
**Affected components:** SearchAutocomplete
+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`.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added `aria-hidden` to the `PluginHeader` icon to prevent screen readers from announcing decorative plugin icons.
**Affected components:** PluginHeader
+1
View File
@@ -0,0 +1 @@
Migrates TechDocs alpha plugin pages to BUI header system, fixing double scrollbar issue with the new plugin header.
+1
View File
@@ -2159,6 +2159,7 @@ export const SearchAutocompleteDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly bg: 'consumer';
readonly classNames: {
readonly root: 'bui-SearchAutocomplete';
readonly searchField: 'bui-SearchAutocompleteSearchField';
@@ -125,7 +125,9 @@ export const PluginHeader = (props: PluginHeaderProps) => {
const titleContent = (
<>
<div className={classes.toolbarIcon}>{icon || <RiShapesLine />}</div>
<div className={classes.toolbarIcon} aria-hidden="true">
{icon || <RiShapesLine />}
</div>
<Text variant="body-medium">{title || 'Your plugin'}</Text>
</>
);
@@ -38,6 +38,18 @@
--search-autocomplete-item-height: 2.5rem;
height: 2.5rem;
}
&[data-on-bg='neutral-1'] {
background-color: var(--bui-bg-neutral-2);
}
&[data-on-bg='neutral-2'] {
background-color: var(--bui-bg-neutral-3);
}
&[data-on-bg='neutral-3'] {
background-color: var(--bui-bg-neutral-4);
}
}
.bui-SearchAutocompleteSearchField {
@@ -124,7 +124,7 @@ export function SearchAutocomplete(props: SearchAutocompleteProps) {
<div
ref={triggerRef}
className={classes.root}
data-size={dataAttributes['data-size']}
{...dataAttributes}
style={style}
>
<div aria-hidden="true">
@@ -28,6 +28,7 @@ import styles from './SearchAutocomplete.module.css';
export const SearchAutocompleteDefinition =
defineComponent<SearchAutocompleteOwnProps>()({
styles,
bg: 'consumer',
classNames: {
root: 'bui-SearchAutocomplete',
searchField: 'bui-SearchAutocompleteSearchField',
+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,56 @@
/*
* 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 { TechDocsShadowDom } from '@backstage/plugin-techdocs-react';
import { Progress } from '@backstage/core-components';
import { TechDocsStateIndicator } from '../../reader/components/TechDocsStateIndicator';
import { withTechDocsReaderProvider } from '../../reader/components/TechDocsReaderProvider';
import { TechDocsReaderPageContentAddons } from '../../reader/components/TechDocsReaderPageContent/TechDocsReaderPageContentAddons';
import type { TechDocsReaderPageContentProps } from '../../reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent';
import { useTechDocsReaderContentData } from '../../hooks/useTechDocsReaderContentData';
export const TechDocsReaderContent = withTechDocsReaderProvider(
(props: TechDocsReaderPageContentProps) => {
const {
dom,
handleAppend,
isNotFound,
isDomReady,
showProgress,
NotFoundErrorPage,
} = useTechDocsReaderContentData({
defaultPath: props.defaultPath,
onReady: props.onReady,
});
if (isNotFound) return <NotFoundErrorPage />;
if (!isDomReady) {
return <TechDocsStateIndicator />;
}
return (
<>
<TechDocsStateIndicator />
{showProgress && <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,69 @@
/*
* 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 Helmet from 'react-helmet';
import { Header, ButtonLink } from '@backstage/ui';
import { RiCodeLine } from '@remixicon/react';
import { TechDocsAddonLocations as locations } from '@backstage/plugin-techdocs-react';
import { TechDocsReaderSearch } from './TechDocsReaderSearch';
import { useTechDocsReaderHeaderData } from '../../hooks/useTechDocsReaderHeaderData';
export type TechDocsReaderHeaderProps = {
withSearch?: boolean;
};
export const TechDocsReaderHeader = (props: TechDocsReaderHeaderProps) => {
const { withSearch = true } = props;
const {
title,
entityRef,
tabTitle,
hidden,
showSourceLink,
sourceLink,
addons,
} = useTechDocsReaderHeaderData();
if (hidden) return null;
return (
<>
<Helmet titleTemplate="%s">
<title>{tabTitle}</title>
</Helmet>
<Header
title={title || ''}
customActions={
<>
{withSearch && <TechDocsReaderSearch entityId={entityRef} />}
{showSourceLink && (
<ButtonLink
href={sourceLink!}
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,120 @@
/*
* 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 { useNavigate } from 'react-router-dom';
import {
Text,
Flex,
SearchAutocomplete,
SearchAutocompleteItem,
} from '@backstage/ui';
import { SearchContextProvider } from '@backstage/plugin-search-react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { HighlightedSearchResultText } from '@backstage/plugin-search-react';
import { useTechDocsSearch } from '../../hooks/useTechDocsSearch';
type TechDocsReaderSearchBarProps = {
entityId: CompoundEntityRef;
};
const TechDocsReaderSearchBar = (props: TechDocsReaderSearchBarProps) => {
const { entityId } = props;
const navigate = useNavigate();
const { results, term, setTerm, deferredLoading } =
useTechDocsSearch(entityId);
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,
@@ -0,0 +1,184 @@
/*
* 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, useState, useEffect } from 'react';
import { waitFor, screen } from '@testing-library/react';
import { CompoundEntityRef } 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 useTechDocsReader = jest.fn();
jest.mock('../reader/components/TechDocsReaderProvider', () => ({
...jest.requireActual('../reader/components/TechDocsReaderProvider'),
useTechDocsReader: (...args: any[]) => useTechDocsReader(...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 { useTechDocsReaderContentData } from './useTechDocsReaderContentData';
const mockEntityMetadata = {
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 Wrapper = ({
entityRef = {
kind: mockEntityMetadata.kind,
name: mockEntityMetadata.metadata.name,
namespace: mockEntityMetadata.metadata.namespace!!,
},
children,
}: {
entityRef?: CompoundEntityRef;
children: ReactNode;
}) => (
<TestApiProvider
apis={[
[techdocsApiRef, { getEntityMetadata, getTechDocsMetadata }],
[configApiRef, mockApis.config()],
]}
>
<TechDocsReaderPageProvider entityRef={entityRef}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
);
function HookRenderer({ defaultPath }: { defaultPath?: string }) {
const data = useTechDocsReaderContentData({ defaultPath });
const [, setRender] = useState(0);
useEffect(() => setRender(r => r + 1), [data.isNotFound, data.isDomReady]);
return (
<div>
<span data-testid="isNotFound">{String(data.isNotFound)}</span>
<span data-testid="isDomReady">{String(data.isDomReady)}</span>
<span data-testid="showProgress">{String(data.showProgress)}</span>
</div>
);
}
describe('useTechDocsReaderContentData', () => {
const useShadowRootElementsMock = useShadowRootElements as jest.Mock;
beforeEach(() => {
useShadowRootElementsMock.mockReturnValue([]);
jest.clearAllMocks();
});
it('should return ready state when DOM is loaded', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useTechDocsReader.mockReturnValue({ state: 'cached' });
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('isDomReady')).toHaveTextContent('true');
});
expect(screen.getByTestId('isNotFound')).toHaveTextContent('false');
expect(screen.getByTestId('showProgress')).toHaveTextContent('false');
});
it('should return not found when entity metadata is missing', async () => {
getEntityMetadata.mockResolvedValue(undefined);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useTechDocsReader.mockReturnValue({ state: 'cached' });
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('isNotFound')).toHaveTextContent('true');
});
});
it('should show progress when checking state', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useTechDocsReader.mockReturnValue({ state: 'CHECKING' });
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('showProgress')).toHaveTextContent('true');
});
});
it('should show progress when styles are loading', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useTechDocsReaderDom.mockReturnValue(document.createElement('html'));
useTechDocsReader.mockReturnValue({ state: 'cached' });
useShadowDomStylesLoading.mockReturnValue(true);
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('showProgress')).toHaveTextContent('true');
});
});
});
@@ -0,0 +1,94 @@
/*
* 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 {
useShadowDomStylesLoading,
useShadowRootElements,
useTechDocsReaderPage,
} from '@backstage/plugin-techdocs-react';
import { useApp } from '@backstage/core-plugin-api';
import { useTechDocsReaderDom } from '../reader/components/TechDocsReaderPageContent/dom';
import { useTechDocsReader } from '../reader/components/TechDocsReaderProvider';
/**
* Shared hook for TechDocs reader content data.
* Encapsulates DOM setup, hash scrolling, shadow root handling,
* 404 detection, and loading state.
*/
export function useTechDocsReaderContentData(options: {
defaultPath?: string;
onReady?: () => void;
}) {
const { defaultPath, onReady } = options;
const {
entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
entityRef,
setShadowRoot,
} = useTechDocsReaderPage();
const { state } = useTechDocsReader();
const dom = useTechDocsReaderDom(entityRef, 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],
);
const isNotFound = entityMetadataLoading === false && !entityMetadata;
const isDomReady = !!dom;
const showProgress = state === 'CHECKING' || isStyleLoading;
return {
entityRef,
entityMetadata,
dom,
handleAppend,
isNotFound,
isDomReady,
showProgress,
NotFoundErrorPage,
};
}
@@ -0,0 +1,206 @@
/*
* 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, useState, useEffect } from 'react';
import { waitFor, screen } from '@testing-library/react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { entityPresentationApiRef } 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 { useTechDocsReaderHeaderData } from './useTechDocsReaderHeaderData';
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 forEntity = jest.fn().mockReturnValue({
snapshot: { primaryTitle: 'Test Entity' },
});
const Wrapper = ({
entityRef = {
kind: mockEntityMetadata.kind,
name: mockEntityMetadata.metadata.name,
namespace: mockEntityMetadata.metadata.namespace!!,
},
children,
}: {
entityRef?: CompoundEntityRef;
children: ReactNode;
}) => (
<TestApiProvider
apis={[
[techdocsApiRef, { getEntityMetadata, getTechDocsMetadata }],
[entityPresentationApiRef, { forEntity }],
[configApiRef, mockApis.config()],
]}
>
<TechDocsReaderPageProvider entityRef={entityRef}>
{children}
</TechDocsReaderPageProvider>
</TestApiProvider>
);
function HookRenderer() {
const data = useTechDocsReaderHeaderData();
const [, setRender] = useState(0);
useEffect(() => setRender(r => r + 1), [data.hidden, data.title]);
return (
<div>
<span data-testid="hidden">{String(data.hidden)}</span>
<span data-testid="title">{data.title || ''}</span>
<span data-testid="tabTitle">{data.tabTitle}</span>
<span data-testid="showSourceLink">{String(data.showSourceLink)}</span>
<span data-testid="sourceLink">{data.sourceLink || ''}</span>
</div>
);
}
describe('useTechDocsReaderHeaderData', () => {
beforeEach(() => {
useParamsPath = '/';
jest.clearAllMocks();
forEntity.mockReturnValue({
snapshot: { primaryTitle: 'Test Entity' },
});
});
it('should return header data when metadata is loaded', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('hidden')).toHaveTextContent('false');
});
expect(screen.getByTestId('title')).toHaveTextContent('test-site-name');
expect(screen.getByTestId('showSourceLink')).toHaveTextContent('true');
expect(screen.getByTestId('sourceLink')).toHaveTextContent(
'https://example.com/',
);
});
it('should be hidden when entity metadata is missing', async () => {
getEntityMetadata.mockResolvedValue(undefined);
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('hidden')).toHaveTextContent('true');
});
});
it('should be hidden when techdocs metadata is missing', async () => {
getTechDocsMetadata.mockResolvedValue(undefined);
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('hidden')).toHaveTextContent('true');
});
});
it('should not show source link for local docs', async () => {
getEntityMetadata.mockResolvedValue({
...mockEntityMetadata,
locationMetadata: { type: 'dir', target: '/local/path' },
});
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('hidden')).toHaveTextContent('false');
});
expect(screen.getByTestId('showSourceLink')).toHaveTextContent('false');
});
it('should compute tab title from URL path segments', async () => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
useParamsPath = 'foo/bar/baz/';
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('hidden')).toHaveTextContent('false');
});
expect(screen.getByTestId('tabTitle')).toHaveTextContent(
'Test Entity | Foo | Bar | Baz | Backstage',
);
});
});
@@ -0,0 +1,107 @@
/*
* 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 { useParams } from 'react-router-dom';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { configApiRef, useApi } from '@backstage/core-plugin-api';
import {
useTechDocsAddons,
useTechDocsReaderPage,
} from '@backstage/plugin-techdocs-react';
import { entityPresentationApiRef } from '@backstage/plugin-catalog-react';
import capitalize from 'lodash/capitalize';
/**
* Shared hook for TechDocs reader header data.
* Encapsulates title/subtitle sync, tab title computation,
* 404 detection, and source link visibility.
*/
export function useTechDocsReaderHeaderData() {
const addons = useTechDocsAddons();
const configApi = useApi(configApiRef);
const entityPresentationApi = useApi(entityPresentationApiRef);
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.getOptionalString('app.title') || 'Backstage';
const locationMetadata = entityMetadata?.locationMetadata;
const showSourceLink =
!!locationMetadata &&
locationMetadata.type !== 'dir' &&
locationMetadata.type !== 'file';
const noEntMetadata = !entityMetadataLoading && entityMetadata === undefined;
const noTdMetadata = !metadataLoading && metadata === undefined;
const hidden = noEntMetadata || noTdMetadata;
const removeTrailingSlash = (str: string) => str.replace(/\/$/, '');
const normalizeAndSpace = (str: string) =>
str.replace(/[-_]/g, ' ').split(' ').map(capitalize).join(' ');
let tabTitle = appTitle;
if (!hidden) {
const stringEntityRef = stringifyEntityRef(entityRef);
const entityDisplayName =
entityPresentationApi.forEntity(stringEntityRef).snapshot.primaryTitle;
let techdocsTabTitleItems: string[] = [];
if (path !== '')
techdocsTabTitleItems = removeTrailingSlash(path)
.split('/')
.map(normalizeAndSpace);
const tabTitleItems = [
entityDisplayName,
...techdocsTabTitleItems,
appTitle,
];
tabTitle = tabTitleItems.join(' | ');
}
return {
title,
subtitle,
entityRef,
entityMetadata,
tabTitle,
hidden,
showSourceLink,
sourceLink: locationMetadata?.target,
addons,
};
}
@@ -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 { ReactNode, useState, useEffect } from 'react';
import { waitFor, screen } from '@testing-library/react';
import {
searchApiRef,
MockSearchApi,
SearchContextProvider,
} from '@backstage/plugin-search-react';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { useTechDocsSearch } from './useTechDocsSearch';
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.',
},
},
],
});
function HookRenderer() {
const data = useTechDocsSearch(entityId);
const [, setRender] = useState(0);
useEffect(() => setRender(r => r + 1), [data.results.length, data.loading]);
return (
<div>
<span data-testid="resultCount">{data.results.length}</span>
<span data-testid="loading">{String(data.loading)}</span>
<span data-testid="deferredLoading">{String(data.deferredLoading)}</span>
<span data-testid="term">{data.term}</span>
{data.results.map((r, i) => (
<span key={i} data-testid={`result-${i}`}>
{r.document.title}
</span>
))}
</div>
);
}
const Wrapper = ({ children }: { children: ReactNode }) => (
<TestApiProvider apis={[[searchApiRef, mockSearchApi]]}>
<SearchContextProvider
initialState={{
term: '',
types: ['techdocs'],
pageCursor: '',
filters: entityId,
}}
>
{children}
</SearchContextProvider>
</TestApiProvider>
);
describe('useTechDocsSearch', () => {
it('should return search results', async () => {
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
await waitFor(() => {
expect(screen.getByTestId('resultCount')).toHaveTextContent('1');
});
expect(screen.getByTestId('result-0')).toHaveTextContent('Getting Started');
});
it('should not show deferred loading initially', async () => {
await renderInTestApp(
<Wrapper>
<HookRenderer />
</Wrapper>,
);
expect(screen.getByTestId('deferredLoading')).toHaveTextContent('false');
});
});
@@ -0,0 +1,86 @@
/*
* 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 { useSearch } from '@backstage/plugin-search-react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { ResultHighlight } from '@backstage/plugin-search-common';
export type TechDocsDoc = {
namespace: string;
kind: string;
name: string;
path: string;
location: string;
title: string;
text: string;
};
export type TechDocsSearchResult = {
type: string;
document: TechDocsDoc;
highlight?: ResultHighlight;
};
/**
* Shared hook for TechDocs search logic.
* Encapsulates entity filter sync, results slicing,
* and deferred loading state.
*/
export function useTechDocsSearch(entityId: CompoundEntityRef) {
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 {
results,
term,
setTerm,
loading,
deferredLoading,
};
}
@@ -233,12 +233,13 @@ describe('<TechDocsReaderPageContent />', () => {
useTechDocsReaderDom.mockReturnValue(mockTechDocsPage);
useReaderState.mockReturnValue({ state: 'cached' });
window.location.hash = '#emojis';
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPageContent withSearch={false} />
</Wrapper>,
{
routeEntries: ['/#emojis'],
},
);
await waitFor(() => {
@@ -248,8 +249,6 @@ describe('<TechDocsReaderPageContent />', () => {
expect(mockScrollIntoView).toHaveBeenCalled();
expect(document.querySelector).not.toHaveBeenCalledWith('header');
});
window.location.hash = '';
});
it('should render progress bar when content is loading', async () => {
@@ -14,30 +14,19 @@
* limitations under the License.
*/
import { useCallback, useEffect } from 'react';
import Grid from '@material-ui/core/Grid';
import { makeStyles } from '@material-ui/core/styles';
import {
TechDocsShadowDom,
useShadowDomStylesLoading,
useShadowRootElements,
useTechDocsReaderPage,
} from '@backstage/plugin-techdocs-react';
import { TechDocsShadowDom } from '@backstage/plugin-techdocs-react';
import { CompoundEntityRef } from '@backstage/catalog-model';
import { Content, Progress } from '@backstage/core-components';
import { TechDocsSearch } from '../../../search';
import { TechDocsStateIndicator } from '../TechDocsStateIndicator';
import { useTechDocsReaderDom } from './dom';
import {
useTechDocsReader,
withTechDocsReaderProvider,
} from '../TechDocsReaderProvider';
import { withTechDocsReaderProvider } from '../TechDocsReaderProvider';
import { TechDocsReaderPageContentAddons } from './TechDocsReaderPageContentAddons';
import { useApp } from '@backstage/core-plugin-api';
import { useTechDocsReaderContentData } from '../../../hooks/useTechDocsReaderContentData';
const useStyles = makeStyles({
search: {
@@ -89,52 +78,26 @@ export type TechDocsReaderPageContentProps = {
*/
export const TechDocsReaderPageContent = withTechDocsReaderProvider(
(props: TechDocsReaderPageContentProps) => {
const { withSearch = true, searchResultUrlMapper, onReady } = props;
const { withSearch = true, searchResultUrlMapper } = props;
const classes = useStyles();
const {
entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
entityRef,
setShadowRoot,
} = useTechDocsReaderPage();
const { state } = useTechDocsReader();
const dom = useTechDocsReaderDom(entityRef, props.defaultPath);
const path = window.location.pathname;
const hash = window.location.hash;
const isStyleLoading = useShadowDomStylesLoading(dom);
const [hashElement] = useShadowRootElements([`[id="${hash.slice(1)}"]`]);
const app = useApp();
const { NotFoundErrorPage } = app.getComponents();
entityMetadata,
dom,
handleAppend,
isNotFound,
isDomReady,
showProgress,
NotFoundErrorPage,
} = useTechDocsReaderContentData({
defaultPath: props.defaultPath,
onReady: props.onReady,
});
useEffect(() => {
if (isStyleLoading) return;
if (isNotFound) return <NotFoundErrorPage />;
if (hash) {
if (hashElement) {
hashElement.scrollIntoView();
}
} 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) {
if (!isDomReady) {
return (
<Content>
<Grid container>
@@ -162,10 +125,9 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider(
</Grid>
)}
<Grid xs={12} item>
{/* Centers the styles loaded event to avoid having multiple locations setting the opacity style in Shadow Dom causing the screen to flash multiple times */}
{(state === 'CHECKING' || isStyleLoading) && <Progress />}
{showProgress && <Progress />}
<TechDocsShadowDom element={dom} onAppend={handleAppend}>
<TechDocsShadowDom element={dom!} onAppend={handleAppend}>
<TechDocsReaderPageContentAddons />
</TechDocsShadowDom>
</Grid>
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { PropsWithChildren, useEffect } from 'react';
import { PropsWithChildren } from 'react';
import Helmet from 'react-helmet';
import Grid from '@material-ui/core/Grid';
@@ -23,29 +23,22 @@ import CodeIcon from '@material-ui/icons/Code';
import {
TechDocsAddonLocations as locations,
useTechDocsAddons,
useTechDocsReaderPage,
TechDocsEntityMetadata,
TechDocsMetadata,
} from '@backstage/plugin-techdocs-react';
import {
entityPresentationApiRef,
EntityRefLink,
EntityRefLinks,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import {
RELATION_OWNED_BY,
CompoundEntityRef,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { RELATION_OWNED_BY, CompoundEntityRef } from '@backstage/catalog-model';
import { Header, HeaderLabel } from '@backstage/core-components';
import { useRouteRef, configApiRef, useApi } from '@backstage/core-plugin-api';
import { useRouteRef } from '@backstage/core-plugin-api';
import capitalize from 'lodash/capitalize';
import { rootRouteRef } from '../../../routes';
import { useParams } from 'react-router-dom';
import { useTechDocsReaderHeaderData } from '../../../hooks/useTechDocsReaderHeaderData';
const skeleton = <Skeleton animation="wave" variant="text" height={40} />;
@@ -71,45 +64,29 @@ export const TechDocsReaderPageHeader = (
props: TechDocsReaderPageHeaderProps,
) => {
const { children } = props;
const addons = useTechDocsAddons();
const configApi = useApi(configApiRef);
const entityPresentationApi = useApi(entityPresentationApiRef);
const { '*': path = '' } = useParams();
const {
title,
setTitle,
subtitle,
setSubtitle,
entityRef,
metadata: { value: metadata, loading: metadataLoading },
entityMetadata: { value: entityMetadata, loading: entityMetadataLoading },
} = useTechDocsReaderPage();
entityMetadata,
tabTitle,
hidden,
showSourceLink,
sourceLink,
addons,
} = useTechDocsReaderHeaderData();
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 docsRootLink = useRouteRef(rootRouteRef)();
const appTitle = configApi.getOptional('app.title') || 'Backstage';
if (hidden) return null;
const { locationMetadata, spec } = entityMetadata || {};
const { spec } = entityMetadata || {};
const lifecycle = spec?.lifecycle;
const ownedByRelations = entityMetadata
? getEntityRelations(entityMetadata, RELATION_OWNED_BY)
: [];
const docsRootLink = useRouteRef(rootRouteRef)();
const labels = (
<>
<HeaderLabel
@@ -138,9 +115,7 @@ export const TechDocsReaderPageHeader = (
{lifecycle ? (
<HeaderLabel label="Lifecycle" value={String(lifecycle)} />
) : null}
{locationMetadata &&
locationMetadata.type !== 'dir' &&
locationMetadata.type !== 'file' ? (
{showSourceLink ? (
<HeaderLabel
label=""
value={
@@ -153,37 +128,12 @@ export const TechDocsReaderPageHeader = (
</Grid>
</Grid>
}
url={locationMetadata.target}
url={sourceLink}
/>
) : null}
</>
);
// If there is no entity or techdocs metadata, there's no reason to show the
// header (hides the header on 404 error 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 (
<Header
type="Documentation"
@@ -15,15 +15,17 @@
*/
import { CompoundEntityRef } from '@backstage/catalog-model';
import { ResultHighlight } from '@backstage/plugin-search-common';
import {
SearchAutocomplete,
SearchContextProvider,
useSearch,
} from '@backstage/plugin-search-react';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TechDocsSearchResultListItem } from './TechDocsSearchResultListItem';
import {
useTechDocsSearch,
TechDocsSearchResult,
} from '../../hooks/useTechDocsSearch';
/**
* Props for {@link TechDocsSearch}
@@ -37,21 +39,6 @@ export type TechDocsSearchProps = {
searchResultUrlMapper?: (url: string) => string;
};
type TechDocsDoc = {
namespace: string;
kind: string;
name: string;
path: string;
location: string;
title: string;
};
type TechDocsSearchResult = {
type: string;
document: TechDocsDoc;
highlight?: ResultHighlight;
};
const isTechDocsSearchResult = (
option: any,
): option is TechDocsSearchResult => {
@@ -67,40 +54,7 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
} = props;
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const {
setFilters,
term,
result: { loading, value: searchVal },
} = useSearch();
const [options, setOptions] = useState<any[]>([]);
useEffect(() => {
let mounted = true;
if (mounted && searchVal) {
// TODO: Change this into getting only subset of search results from the BE in the first place
// once pagination is implemented for search engines
// See: https://github.com/backstage/backstage/issues/6062
const searchResults = searchVal.results.slice(0, 10);
setOptions(searchResults);
}
return () => {
mounted = false;
};
}, [loading, searchVal]);
// Update the filter context when the entityId changes, e.g. when the search
// bar continues to be rendered, navigating between different TechDocs sites.
const { kind, name, namespace } = entityId;
useEffect(() => {
setFilters(prevFilters => {
return {
...prevFilters,
kind,
namespace,
name,
};
});
}, [kind, namespace, name, setFilters]);
const { results, term, loading } = useTechDocsSearch(entityId);
const handleSelection = (
_: any,
@@ -133,7 +87,7 @@ const TechDocsSearchBar = (props: TechDocsSearchProps) => {
blurOnSelect
noOptionsText="No results found"
value={null}
options={options}
options={results}
renderOption={({ document, highlight }) => (
<TechDocsSearchResultListItem
result={document}
+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"