Merge pull request #30984 from Frueber/tech-docs/external-tech-docs-redirect/add-handling-tests-and-documentation
This commit is contained in:
+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