('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 }) => (