Adding handling which checks if the current entity (the catalog entity being loaded) has an annotation for an external entity's TechDocs. If it does then we will redirect there rather than allowing a 404 (mic drop). This helps keep older URLs routing to the updated locations.
Adding changesets. Adding test coverage for external TechDocs entitiy redirect. Signed-off-by: Owen Shartle <timeloveinvent+github@gmail.com>
This commit is contained in:
+130
-1
@@ -18,6 +18,7 @@ import { ReactNode } from 'react';
|
||||
import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
|
||||
import {
|
||||
catalogApiRef,
|
||||
entityPresentationApiRef,
|
||||
entityRouteRef,
|
||||
} from '@backstage/plugin-catalog-react';
|
||||
@@ -30,9 +31,10 @@ import {
|
||||
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 +96,10 @@ const entityPresentationApiMock: jest.Mocked<
|
||||
}),
|
||||
};
|
||||
|
||||
const catalogApiMock = {
|
||||
getEntityByRef: jest.fn().mockResolvedValue(mockEntityMetadata),
|
||||
};
|
||||
|
||||
const fetchApiMock = {
|
||||
fetch: jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -114,6 +120,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 +140,7 @@ const Wrapper = ({ children }: { children: ReactNode }) => {
|
||||
[techdocsApiRef, techdocsApiMock],
|
||||
[techdocsStorageApiRef, techdocsStorageApiMock],
|
||||
[entityPresentationApiRef, entityPresentationApiMock],
|
||||
[catalogApiRef, catalogApiMock],
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
@@ -143,6 +155,8 @@ const mountedRoutes = {
|
||||
};
|
||||
|
||||
describe('<TechDocsReaderPage />', () => {
|
||||
const mockNavigate = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
getEntityMetadata.mockResolvedValue(mockEntityMetadata);
|
||||
getTechDocsMetadata.mockResolvedValue(mockTechDocsMetadata);
|
||||
@@ -150,6 +164,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 +301,117 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,100 @@ 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 () => {
|
||||
const catalogEntity = await catalogApi.getEntityByRef(memoizedEntityRef);
|
||||
|
||||
if (catalogEntity?.metadata?.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]) {
|
||||
return buildTechDocsURL(catalogEntity, viewTechdocLink);
|
||||
}
|
||||
|
||||
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 +288,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