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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-react': patch
---
Update to documentation regarding TechDocs redirects.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs': minor
---
Adding redirect handling for TechDocs URLs that reference entities that now reference an external entity for TechDocs. Including tests and documentation.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-backend': minor
---
Adding new entity that specifies an external entity in the techdocs-entity annotation and updates to documentation regarding TechDocs redirects.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-techdocs-addons-test-utils': minor
---
Adding catalogApiRef to test-utils to support catalog API usage by TechDocs reader page.
@@ -460,6 +460,7 @@ subfolders
subheader
subheaders
subkey
subpage
subpath
subroutes
substring
@@ -126,7 +126,7 @@ metadata:
The value of this annotation informs of the path to this component's TechDocs within an external entity that owns the TechDocs.
In conjunction with [backstage.io/techdocs-entity](#backstageiotechdocs-entity) this allows for deep linking into the TechDocs of
another entity, not just linking to the root of another entities TechDocs.
another entity, not just linking to the root of another entity's TechDocs.
### backstage.io/view-url, backstage.io/edit-url
+4
View File
@@ -46,6 +46,10 @@ annotation should still be present in entity descriptor file (e.g.
`catalog-info.yaml`) for Backstage to know that TechDocs is enabled for the
entity.
#### What happens when you navigate to a TechDocs URL for an entity uses the `backstage.io/techdocs-entity` annotation?
If you navigate to a TechDocs URL in the format `docs/{namespace}/{kind}/{name}` for an entity that has the `backstage.io/techdocs-entity` annotation (instead of the `backstage.io/techdocs-ref` annotation), then Backstage will redirect to the TechDocs page of the entity referenced in the value of that annotation.
#### Is it possible for users to suggest changes or provide feedback on a TechDocs page?
This is supported for TechDocs sites whose source code is hosted in either
@@ -39,10 +39,12 @@ import {
} from '@backstage/plugin-techdocs-react';
import { TechDocsReaderPage, techdocsPlugin } from '@backstage/plugin-techdocs';
import {
catalogApiRef,
EntityPresentationApi,
entityPresentationApiRef,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
import { searchApiRef } from '@backstage/plugin-search-react';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
@@ -235,8 +237,19 @@ export class TechDocsAddonTester {
}),
};
const catalogApi = catalogApiMock({
entities: [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { namespace: 'default', name: 'docs' },
},
],
});
const apis: TechdocsAddonTesterApis<any[]> = [
[fetchApiRef, fetchApi],
[catalogApiRef, catalogApi],
[entityPresentationApiRef, entityPresentationApi],
[discoveryApiRef, discoveryApi],
[techdocsApiRef, techdocsApi],
@@ -10,3 +10,17 @@ spec:
type: service
lifecycle: experimental
owner: user:guest
---
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: techdocs-entity-documented-component
title: Example Entity Documented By TechDocs Entity Annotation
description: A Service with TechDocs documentation via the `backstage.io/techdocs-entity` annotation.
annotations:
backstage.io/techdocs-entity: component:default/documented-component
backstage.io/techdocs-entity-path: /inner-component-docs
spec:
type: service
lifecycle: experimental
owner: user:guest
@@ -0,0 +1,5 @@
# Inner Component Docs
This is a basic example of documentation within a larger suite of TechDocs that can be referenced by whatever entities necessary. It is intended as a showcase of the `backstage.io/techdocs-entity-path` annotation for linking to a "subpage" in TechDocs that are declared by another entity.
Please review the [How-To Guides - Deep Linking Into TechDocs](../../../../../../docs/features/techdocs/how-to-guides.md#deep-linking-into-techdocs) section for more information and you can view the example usage on the "Example Entity Documented By TechDocs Entity Annotation" component in this [catalog-info.yaml](../../catalog-info.yaml) file.
@@ -7,6 +7,7 @@ nav:
- Subpage: sub-page.md
- 'Code Sample': code/code-sample.md
- Extensions: extensions.md
- 'Inner Component Docs': inner-component-docs/index.md
plugins:
- techdocs-core
+2 -2
View File
@@ -64,7 +64,7 @@ export function getEntityRootTechDocsPath(entity: Entity): string {
/**
* Build the TechDocs URL for the given entity. This helper should be used anywhere there
* is a link to an entities TechDocs.
* is a link to an entity's TechDocs.
*
* @public
*/
@@ -104,7 +104,7 @@ export const buildTechDocsURL = (
});
// Add on the external entity path to the url if one exists. This allows deep linking into another
// entities TechDocs.
// entity's TechDocs.
const path = getEntityRootTechDocsPath(entity);
return `${url}${path}`;
@@ -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,