diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx index b84f066681..fdb0be680e 100644 --- a/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderContent.tsx @@ -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 ; - if (hash) { - if (hashElement) { - hashElement.scrollIntoView(); - const link = hashElement.querySelector('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 ; - - // 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 ; } return ( <> - {(state === 'CHECKING' || isStyleLoading) && } - + {showProgress && } + diff --git a/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx index 2b6651cfb3..becec9612d 100644 --- a/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx +++ b/plugins/techdocs/src/alpha/components/TechDocsReaderHeader.tsx @@ -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 && } {showSourceLink && ( { const { entityId } = props; const navigate = useNavigate(); - const { - setFilters, - setTerm, - term, - result: { loading, value: searchVal }, - } = useSearch(); - const [results, setResults] = useState([]); - const [deferredLoading, setDeferredLoading] = useState(false); - const loadingTimer = useRef>(); - - 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 ( ({ + ...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; +}) => ( + + + {children} + + +); + +function HookRenderer({ defaultPath }: { defaultPath?: string }) { + const data = useTechDocsReaderContentData({ defaultPath }); + const [, setRender] = useState(0); + useEffect(() => setRender(r => r + 1), [data.isNotFound, data.isDomReady]); + return ( +
+ {String(data.isNotFound)} + {String(data.isDomReady)} + {String(data.showProgress)} +
+ ); +} + +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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('showProgress')).toHaveTextContent('true'); + }); + }); +}); diff --git a/plugins/techdocs/src/hooks/useTechDocsReaderContentData.ts b/plugins/techdocs/src/hooks/useTechDocsReaderContentData.ts new file mode 100644 index 0000000000..02a3422617 --- /dev/null +++ b/plugins/techdocs/src/hooks/useTechDocsReaderContentData.ts @@ -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('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, + }; +} diff --git a/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.test.tsx b/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.test.tsx new file mode 100644 index 0000000000..6e1c14627d --- /dev/null +++ b/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.test.tsx @@ -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; +}) => ( + + + {children} + + +); + +function HookRenderer() { + const data = useTechDocsReaderHeaderData(); + const [, setRender] = useState(0); + useEffect(() => setRender(r => r + 1), [data.hidden, data.title]); + return ( +
+ {String(data.hidden)} + {data.title || ''} + {data.tabTitle} + {String(data.showSourceLink)} + {data.sourceLink || ''} +
+ ); +} + +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( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('hidden')).toHaveTextContent('true'); + }); + }); + + it('should be hidden when techdocs metadata is missing', async () => { + getTechDocsMetadata.mockResolvedValue(undefined); + + await renderInTestApp( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('hidden')).toHaveTextContent('false'); + }); + + expect(screen.getByTestId('tabTitle')).toHaveTextContent( + 'Test Entity | Foo | Bar | Baz | Backstage', + ); + }); +}); diff --git a/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.ts b/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.ts new file mode 100644 index 0000000000..56958d05d0 --- /dev/null +++ b/plugins/techdocs/src/hooks/useTechDocsReaderHeaderData.ts @@ -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, + }; +} diff --git a/plugins/techdocs/src/hooks/useTechDocsSearch.test.tsx b/plugins/techdocs/src/hooks/useTechDocsSearch.test.tsx new file mode 100644 index 0000000000..ff5fd7418e --- /dev/null +++ b/plugins/techdocs/src/hooks/useTechDocsSearch.test.tsx @@ -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 ( +
+ {data.results.length} + {String(data.loading)} + {String(data.deferredLoading)} + {data.term} + {data.results.map((r, i) => ( + + {r.document.title} + + ))} +
+ ); +} + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +describe('useTechDocsSearch', () => { + it('should return search results', async () => { + await renderInTestApp( + + + , + ); + + 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( + + + , + ); + + expect(screen.getByTestId('deferredLoading')).toHaveTextContent('false'); + }); +}); diff --git a/plugins/techdocs/src/hooks/useTechDocsSearch.ts b/plugins/techdocs/src/hooks/useTechDocsSearch.ts new file mode 100644 index 0000000000..86fa89e864 --- /dev/null +++ b/plugins/techdocs/src/hooks/useTechDocsSearch.ts @@ -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([]); + const [deferredLoading, setDeferredLoading] = useState(false); + const loadingTimer = useRef>(); + + 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, + }; +} diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx index 77bb3d134f..cc4735ed02 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.test.tsx @@ -233,12 +233,13 @@ describe('', () => { useTechDocsReaderDom.mockReturnValue(mockTechDocsPage); useReaderState.mockReturnValue({ state: 'cached' }); - window.location.hash = '#emojis'; - const rendered = await renderInTestApp( , + { + routeEntries: ['/#emojis'], + }, ); await waitFor(() => { @@ -248,8 +249,6 @@ describe('', () => { expect(mockScrollIntoView).toHaveBeenCalled(); expect(document.querySelector).not.toHaveBeenCalledWith('header'); }); - - window.location.hash = ''; }); it('should render progress bar when content is loading', async () => { diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx index fe72b09073..0ee13703c3 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageContent/TechDocsReaderPageContent.tsx @@ -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 ; - 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 ; - - // 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 ( @@ -162,10 +125,9 @@ export const TechDocsReaderPageContent = withTechDocsReaderProvider( )} - {/* 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) && } + {showProgress && } - + diff --git a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx index 6328becc73..b646c5bfe8 100644 --- a/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx +++ b/plugins/techdocs/src/reader/components/TechDocsReaderPageHeader/TechDocsReaderPageHeader.tsx @@ -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 = ; @@ -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 = ( <> ) : null} - {locationMetadata && - locationMetadata.type !== 'dir' && - locationMetadata.type !== 'file' ? ( + {showSourceLink ? ( } - 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 (
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([]); - 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 }) => (