Merge pull request #30984 from Frueber/tech-docs/external-tech-docs-redirect/add-handling-tests-and-documentation

This commit is contained in:
Fredrik Adelöw
2025-09-10 19:24:28 +02:00
committed by GitHub
14 changed files with 303 additions and 20 deletions
@@ -18,6 +18,7 @@ import { ReactNode } from 'react';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import {
catalogApiRef,
entityPresentationApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
@@ -26,13 +27,15 @@ import {
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { catalogApiMock as catalogApiMockFactory } from '@backstage/plugin-catalog-react/testUtils';
import { techdocsApiRef, techdocsStorageApiRef } from '../../../api';
import { rootRouteRef, rootDocsRouteRef } from '../../../routes';
import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common';
import { TechDocsReaderPage } from './TechDocsReaderPage';
import { Route, useParams } from 'react-router-dom';
import { Route, useNavigate, useParams } from 'react-router-dom';
import { TechDocsAddons } from '@backstage/plugin-techdocs-react';
import { ReportIssue } from '@backstage/plugin-techdocs-module-addons-contrib';
import { FlatRoutes } from '@backstage/core-app-api';
@@ -94,6 +97,8 @@ const entityPresentationApiMock: jest.Mocked<
}),
};
const catalogApiMock = catalogApiMockFactory.mock();
const fetchApiMock = {
fetch: jest.fn().mockResolvedValue({
ok: true,
@@ -114,6 +119,11 @@ jest.mock('@backstage/core-components', () => ({
Page: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
const configApi = mockApis.config({
data: { app: { baseUrl: 'http://localhost:3000' } },
});
@@ -129,6 +139,7 @@ const Wrapper = ({ children }: { children: ReactNode }) => {
[techdocsApiRef, techdocsApiMock],
[techdocsStorageApiRef, techdocsStorageApiMock],
[entityPresentationApiRef, entityPresentationApiMock],
[catalogApiRef, catalogApiMock],
]}
>
{children}
@@ -143,6 +154,8 @@ const mountedRoutes = {
};
describe('<TechDocsReaderPage />', () => {
const mockNavigate = jest.fn();
beforeEach(() => {
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
@@ -150,6 +163,8 @@ describe('<TechDocsReaderPage />', () => {
// Expires in 10 minutes
expiresAt: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
});
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
});
afterEach(() => {
@@ -285,4 +300,142 @@ describe('<TechDocsReaderPage />', () => {
expect(text).toHaveStyle('fontFamily: Comic Sans MS');
});
describe('external TechDocs redirect', () => {
beforeEach(() => {
mockNavigate.mockClear();
catalogApiMock.getEntityByRef.mockReset();
catalogApiMock.getEntityByRef.mockResolvedValue(mockEntityMetadata);
});
it('should navigate to external URL when entity has external techdocs annotation', async () => {
const mockEntityWithExternalAnnotation = {
...mockEntityMetadata,
metadata: {
...mockEntityMetadata.metadata,
annotations: {
[TECHDOCS_EXTERNAL_ANNOTATION]:
'component:external-namespace/external-docs',
},
},
};
catalogApiMock.getEntityByRef.mockResolvedValue(
mockEntityWithExternalAnnotation,
);
await renderInTestApp(
<Wrapper>
<TechDocsReaderPage
entityRef={{
name: 'test-name',
namespace: 'test-namespace',
kind: 'test',
}}
/>
</Wrapper>,
{
mountedRoutes,
},
);
expect(mockNavigate).toHaveBeenCalledWith(
'/docs/external-namespace/component/external-docs',
{ replace: true },
);
});
it('should render normally when entity has no external techdocs annotation', async () => {
const mockEntityWithoutExternalAnnotation = {
...mockEntityMetadata,
metadata: {
...mockEntityMetadata.metadata,
annotations: undefined,
},
};
catalogApiMock.getEntityByRef.mockResolvedValue(
mockEntityWithoutExternalAnnotation,
);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPage
entityRef={{
name: 'test-name',
namespace: 'test-namespace',
kind: 'test-kind',
}}
/>
</Wrapper>,
{
mountedRoutes,
},
);
expect(rendered.container.querySelector('header')).toBeInTheDocument();
expect(rendered.container.querySelector('article')).toBeInTheDocument();
expect(mockNavigate).not.toHaveBeenCalled();
});
it('should render normally when entity has external annotation but no value', async () => {
const mockEntityWithEmptyExternalAnnotation = {
...mockEntityMetadata,
metadata: {
...mockEntityMetadata.metadata,
annotations: {
[TECHDOCS_EXTERNAL_ANNOTATION]: '',
},
},
};
catalogApiMock.getEntityByRef.mockResolvedValue(
mockEntityWithEmptyExternalAnnotation,
);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPage
entityRef={{
name: 'test-name',
namespace: 'test-namespace',
kind: 'test-kind',
}}
/>
</Wrapper>,
{
mountedRoutes,
},
);
expect(rendered.container.querySelector('header')).toBeInTheDocument();
expect(rendered.container.querySelector('article')).toBeInTheDocument();
expect(mockNavigate).not.toHaveBeenCalled();
});
it('should render normally when catalog API throws an error', async () => {
catalogApiMock.getEntityByRef.mockRejectedValue(
new Error('Catalog API error'),
);
const rendered = await renderInTestApp(
<Wrapper>
<TechDocsReaderPage
entityRef={{
name: 'test-name',
namespace: 'test-namespace',
kind: 'test-kind',
}}
/>
</Wrapper>,
{
mountedRoutes,
},
);
expect(rendered.container.querySelector('header')).toBeInTheDocument();
expect(rendered.container.querySelector('article')).toBeInTheDocument();
expect(mockNavigate).not.toHaveBeenCalled();
});
});
});
@@ -14,19 +14,26 @@
* limitations under the License.
*/
import { Children, ReactElement, ReactNode } from 'react';
import { useOutlet } from 'react-router-dom';
import {
Children,
ReactElement,
ReactNode,
useEffect,
useMemo,
useCallback,
} from 'react';
import { useOutlet, useNavigate } from 'react-router-dom';
import { Page } from '@backstage/core-components';
import { CompoundEntityRef } from '@backstage/catalog-model';
import {
TECHDOCS_ADDONS_KEY,
TECHDOCS_ADDONS_WRAPPER_KEY,
TechDocsReaderPageProvider,
buildTechDocsURL,
} from '@backstage/plugin-techdocs-react';
import { TECHDOCS_EXTERNAL_ANNOTATION } from '@backstage/plugin-techdocs-common';
import useAsync from 'react-use/esm/useAsync';
import { TechDocsReaderPageRenderFunction } from '../../../types';
import { TechDocsReaderPageContent } from '../TechDocsReaderPageContent';
import { TechDocsReaderPageHeader } from '../TechDocsReaderPageHeader';
import { TechDocsReaderPageSubheader } from '../TechDocsReaderPageSubheader';
@@ -34,9 +41,11 @@ import { rootDocsRouteRef } from '../../../routes';
import {
getComponentData,
useRouteRefParams,
useApi,
useRouteRef,
} from '@backstage/core-plugin-api';
import { CookieAuthRefreshProvider } from '@backstage/plugin-auth-react';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import {
createTheme,
styled,
@@ -44,6 +53,7 @@ import {
ThemeProvider,
useTheme,
} from '@material-ui/core/styles';
import { Progress } from '@backstage/core-components';
/* An explanation for the multiple ways of customizing the TechDocs reader page
@@ -177,44 +187,106 @@ const StyledPage = styled(Page)({
export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
const currentTheme = useTheme();
const readerPageTheme = createTheme({
...currentTheme,
...(props.overrideThemeOptions || {}),
});
const readerPageTheme = useMemo(
() =>
createTheme({
...currentTheme,
...(props.overrideThemeOptions || {}),
}),
[currentTheme, props.overrideThemeOptions],
);
const { kind, name, namespace } = useRouteRefParams(rootDocsRouteRef);
const { children, entityRef = { kind, name, namespace } } = props;
const outlet = useOutlet();
if (!children) {
const catalogApi = useApi(catalogApiRef);
const navigate = useNavigate();
const viewTechdocLink = useRouteRef(rootDocsRouteRef);
const memoizedEntityRef = useMemo(
() => ({
kind: entityRef.kind,
name: entityRef.name,
namespace: entityRef.namespace,
}),
[entityRef.kind, entityRef.name, entityRef.namespace],
);
const externalEntityTechDocsUrl = useAsync(async () => {
try {
const catalogEntity = await catalogApi.getEntityByRef(memoizedEntityRef);
if (
catalogEntity?.metadata?.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
) {
return buildTechDocsURL(catalogEntity, viewTechdocLink);
}
} catch (error) {
// Ignore error and allow an attempt at loading the current entity's TechDocs when unable to fetch an external entity from the catalog.
}
return undefined;
}, [memoizedEntityRef, catalogApi, viewTechdocLink]);
const handleNavigation = useCallback(
(url: string) => {
navigate(url, { replace: true });
},
[navigate],
);
useEffect(() => {
if (!externalEntityTechDocsUrl.loading && externalEntityTechDocsUrl.value) {
handleNavigation(externalEntityTechDocsUrl.value);
}
}, [
externalEntityTechDocsUrl.loading,
externalEntityTechDocsUrl.value,
handleNavigation,
]);
const page: ReactNode = useMemo(() => {
if (children) {
return null;
}
const childrenList = outlet ? Children.toArray(outlet.props.children) : [];
const grandChildren = childrenList.flatMap<ReactElement>(
child => (child as ReactElement)?.props?.children ?? [],
);
const page: ReactNode = grandChildren.find(
return grandChildren.find(
grandChild =>
!getComponentData(grandChild, TECHDOCS_ADDONS_WRAPPER_KEY) &&
!getComponentData(grandChild, TECHDOCS_ADDONS_KEY),
);
}, [children, outlet]);
// As explained above, "page" is configuration 4 and <TechDocsReaderLayout> is 1
if (externalEntityTechDocsUrl.loading || externalEntityTechDocsUrl.value) {
return <Progress />;
}
// As explained above, "page" is configuration 4 and <TechDocsReaderLayout> is 1
if (!children) {
return (
<ThemeProvider theme={readerPageTheme}>
<CookieAuthRefreshProvider pluginId="techdocs">
<TechDocsReaderPageProvider entityRef={entityRef}>
<TechDocsReaderPageProvider entityRef={memoizedEntityRef}>
{(page as JSX.Element) || <TechDocsReaderLayout />}
</TechDocsReaderPageProvider>
</CookieAuthRefreshProvider>
</ThemeProvider>
);
}
// As explained above, a render function is configuration 3 and React element is 2
return (
<ThemeProvider theme={readerPageTheme}>
<CookieAuthRefreshProvider pluginId="techdocs">
<TechDocsReaderPageProvider entityRef={entityRef}>
<TechDocsReaderPageProvider entityRef={memoizedEntityRef}>
{({ metadata, entityMetadata, onReady }) => (
<StyledPage
themeId="documentation"
@@ -222,7 +294,7 @@ export const TechDocsReaderPage = (props: TechDocsReaderPageProps) => {
>
{children instanceof Function
? children({
entityRef,
entityRef: memoizedEntityRef,
techdocsMetadataValue: metadata.value,
entityMetadataValue: entityMetadata.value,
onReady,