refactor(techdocs): extract shared hooks from reader components

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-19 14:36:06 +01:00
parent 7ae5419d44
commit 00d09525ed
13 changed files with 858 additions and 363 deletions
@@ -14,88 +14,40 @@
* 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 { TechDocsShadowDom } 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 { 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';
import { useTechDocsReaderContentData } from '../../hooks/useTechDocsReaderContentData';
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();
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();
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) {
if (!isDomReady) {
return <TechDocsStateIndicator />;
}
return (
<>
<TechDocsStateIndicator />
{(state === 'CHECKING' || isStyleLoading) && <Progress />}
<TechDocsShadowDom element={dom} onAppend={handleAppend}>
{showProgress && <Progress />}
<TechDocsShadowDom element={dom!} onAppend={handleAppend}>
<TechDocsReaderPageContentAddons />
</TechDocsShadowDom>
</>
@@ -14,21 +14,12 @@
* 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 { TechDocsAddonLocations as locations } from '@backstage/plugin-techdocs-react';
import { TechDocsReaderSearch } from './TechDocsReaderSearch';
import { useTechDocsReaderHeaderData } from '../../hooks/useTechDocsReaderHeaderData';
export type TechDocsReaderHeaderProps = {
withSearch?: boolean;
@@ -36,60 +27,17 @@ export type TechDocsReaderHeaderProps = {
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();
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 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(' | ');
if (hidden) return null;
return (
<>
@@ -103,7 +51,7 @@ export const TechDocsReaderHeader = (props: TechDocsReaderHeaderProps) => {
{withSearch && <TechDocsReaderSearch entityId={entityRef} />}
{showSourceLink && (
<ButtonLink
href={locationMetadata.target}
href={sourceLink!}
target="_blank"
rel="noopener noreferrer"
variant="secondary"
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Text,
@@ -22,29 +21,10 @@ import {
SearchAutocomplete,
SearchAutocompleteItem,
} from '@backstage/ui';
import {
SearchContextProvider,
useSearch,
} from '@backstage/plugin-search-react';
import { SearchContextProvider } 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;
};
import { useTechDocsSearch } from '../../hooks/useTechDocsSearch';
type TechDocsReaderSearchBarProps = {
entityId: CompoundEntityRef;
@@ -53,40 +33,8 @@ type TechDocsReaderSearchBarProps = {
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]);
const { results, term, setTerm, deferredLoading } =
useTechDocsSearch(entityId);
return (
<SearchAutocomplete
@@ -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}