Merge pull request #30984 from Frueber/tech-docs/external-tech-docs-redirect/add-handling-tests-and-documentation
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-techdocs-react': patch
|
||||
---
|
||||
|
||||
Update to documentation regarding TechDocs redirects.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
+154
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user