refactor(techdocs): extract shared hooks from reader components
Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
+3
-4
@@ -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 () => {
|
||||
|
||||
+19
-57
@@ -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>
|
||||
|
||||
+16
-66
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user