Finish TechDocs migration to composability API.

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-08-12 14:25:40 +02:00
parent 52547371fb
commit 787bc08262
21 changed files with 293 additions and 35 deletions
@@ -0,0 +1,9 @@
---
'@backstage/plugin-catalog': patch
---
The entity `<AboutCard />` now uses an external route ref to link to TechDocs
sites. This external route must now be bound in order for the "View TechDocs"
link to continue working. See the [create-app changelog][cacl] for details.
[cacl]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md
@@ -0,0 +1,28 @@
---
'@backstage/create-app': patch
---
Wire up TechDocs, which now relies on the composability API for routing.
First, ensure you've mounted `<TechDocsReaderPage />`. If you already updated
to use the composable `<TechDocsIndexPage />` (see below), no action is
necessary. Otherwise, update your `App.tsx` so that `<TechDocsReaderPage />` is
mounted:
```diff
<Route path="/docs" element={<TechdocsPage />} />
+ <Route
+ path="/docs/:namespace/:kind/:name/*"
+ element={<TechDocsReaderPage />}
+ />
```
Next, ensure links from the Catalog Entity Page to its TechDocs site are bound:
```diff
bindRoutes({ bind }) {
bind(catalogPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
+ viewTechDoc: techdocsPlugin.routes.docRoot,
});
```
@@ -0,0 +1,9 @@
---
'@backstage/plugin-techdocs': minor
---
The TechDocs plugin has completed the migration to the Composability API. In
order to update to this version, please ensure you've made all necessary
changes to your `App.tsx` file as outlined in the [create-app changelog][cacl].
[cacl]: https://github.com/backstage/backstage/blob/master/packages/create-app/CHANGELOG.md
+2
View File
@@ -55,6 +55,7 @@ import { TechRadarPage } from '@backstage/plugin-tech-radar';
import {
DefaultTechDocsHome,
TechDocsIndexPage,
techdocsPlugin,
TechDocsReaderPage,
} from '@backstage/plugin-techdocs';
import { UserSettingsPage } from '@backstage/plugin-user-settings';
@@ -93,6 +94,7 @@ const app = createApp({
bindRoutes({ bind }) {
bind(catalogPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
viewTechDoc: techdocsPlugin.routes.docRoot,
});
bind(apiDocsPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
@@ -16,6 +16,7 @@ import { TechRadarPage } from '@backstage/plugin-tech-radar';
import {
DefaultTechDocsHome,
TechDocsIndexPage,
techdocsPlugin,
TechDocsReaderPage,
} from '@backstage/plugin-techdocs';
import { UserSettingsPage } from '@backstage/plugin-user-settings';
@@ -31,6 +32,7 @@ const app = createApp({
bindRoutes({ bind }) {
bind(catalogPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
viewTechDoc: techdocsPlugin.routes.docRoot,
});
bind(apiDocsPlugin.externalRoutes, {
createComponent: scaffolderPlugin.routes.root,
+9 -1
View File
@@ -128,6 +128,14 @@ const catalogPlugin: BackstagePlugin<
},
{
createComponent: ExternalRouteRef<undefined, true>;
viewTechDoc: ExternalRouteRef<
{
name: string;
kind: string;
namespace: string;
},
true
>;
}
>;
export { catalogPlugin };
@@ -399,7 +407,7 @@ export const Router: ({
// src/components/EntityLayout/EntityLayout.d.ts:43:5 - (ae-forgotten-export) The symbol "EntityLayoutProps" needs to be exported by the entry point index.d.ts
// src/components/EntityLayout/EntityLayout.d.ts:44:5 - (ae-forgotten-export) The symbol "SubRoute" needs to be exported by the entry point index.d.ts
// src/components/EntityPageLayout/EntityPageLayout.d.ts:17:5 - (ae-forgotten-export) The symbol "EntityPageLayoutProps" needs to be exported by the entry point index.d.ts
// src/plugin.d.ts:17:5 - (ae-forgotten-export) The symbol "ColumnBreakpoints" needs to be exported by the entry point index.d.ts
// src/plugin.d.ts:22:5 - (ae-forgotten-export) The symbol "ColumnBreakpoints" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
```
@@ -29,6 +29,7 @@ import {
ApiRegistry,
ConfigReader,
} from '@backstage/core-app-api';
import { viewTechDocRouteRef } from '../../routes';
describe('<AboutCard />', () => {
it('renders info', async () => {
@@ -203,4 +204,138 @@ describe('<AboutCard />', () => {
);
expect(getByText('View Source').closest('a')).not.toHaveAttribute('href');
});
it('renders techdocs link', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/techdocs-ref': './',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const apis = ApiRegistry.with(
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name': viewTechDocRouteRef,
},
},
);
expect(getByText('View TechDocs').closest('a')).toHaveAttribute(
'href',
'/docs/default/Component/software',
);
});
it('renders disabled techdocs link when no docs exist', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const apis = ApiRegistry.with(
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
expect(getByText('View TechDocs').closest('a')).not.toHaveAttribute('href');
});
it('renders disbaled techdocs link when route is not bound', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/techdocs-ref': './',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const apis = ApiRegistry.with(
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
expect(getByText('View TechDocs').closest('a')).not.toHaveAttribute('href');
});
});
@@ -49,7 +49,8 @@ import {
IconLinkVerticalProps,
InfoCardVariants,
} from '@backstage/core-components';
import { useApi } from '@backstage/core-plugin-api';
import { useApi, useRouteRef } from '@backstage/core-plugin-api';
import { viewTechDocRouteRef } from '../../routes';
const useStyles = makeStyles({
gridItemCard: {
@@ -81,6 +82,8 @@ export function AboutCard({ variant }: AboutCardProps) {
const classes = useStyles();
const { entity } = useEntity();
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
const viewTechdocLink = useRouteRef(viewTechDocRouteRef);
const entitySourceLocation = getEntitySourceLocation(
entity,
scmIntegrationsApi,
@@ -105,11 +108,17 @@ export function AboutCard({ variant }: AboutCardProps) {
};
const viewInTechDocs: IconLinkVerticalProps = {
label: 'View TechDocs',
disabled: !entity.metadata.annotations?.['backstage.io/techdocs-ref'],
disabled:
!entity.metadata.annotations?.['backstage.io/techdocs-ref'] ||
!viewTechdocLink,
icon: <DocsIcon />,
href: `/docs/${entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE}/${
entity.kind
}/${entity.metadata.name}`,
href:
viewTechdocLink &&
viewTechdocLink({
namespace: entity.metadata.namespace || ENTITY_DEFAULT_NAMESPACE,
kind: entity.kind,
name: entity.metadata.name,
}),
};
const viewApi: IconLinkVerticalProps = {
title: hasApis ? '' : 'No APIs available',
+2 -1
View File
@@ -21,7 +21,7 @@ import {
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { CatalogClientWrapper } from './CatalogClientWrapper';
import { createComponentRouteRef } from './routes';
import { createComponentRouteRef, viewTechDocRouteRef } from './routes';
import {
createApiFactory,
createComponentExtension,
@@ -50,6 +50,7 @@ export const catalogPlugin = createPlugin({
},
externalRoutes: {
createComponent: createComponentRouteRef,
viewTechDoc: viewTechDocRouteRef,
},
});
+6
View File
@@ -20,3 +20,9 @@ export const createComponentRouteRef = createExternalRouteRef({
id: 'create-component',
optional: true,
});
export const viewTechDocRouteRef = createExternalRouteRef({
id: 'view-techdoc',
optional: true,
params: ['namespace', 'kind', 'name'],
});
+6 -1
View File
@@ -271,6 +271,11 @@ export const TechDocsPicker: () => null;
const techdocsPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
docRoot: RouteRef<{
name: string;
kind: string;
namespace: string;
}>;
entityContent: RouteRef<undefined>;
},
{}
@@ -374,7 +379,7 @@ export class TechDocsStorageClient implements TechDocsStorageApi {
//
// src/home/components/EntityListDocsTable.d.ts:11:5 - (ae-forgotten-export) The symbol "columnFactories" needs to be exported by the entry point index.d.ts
// src/home/components/EntityListDocsTable.d.ts:12:5 - (ae-forgotten-export) The symbol "actionFactories" needs to be exported by the entry point index.d.ts
// src/plugin.d.ts:24:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts
// src/plugin.d.ts:29:5 - (ae-forgotten-export) The symbol "TabsConfig" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
```
+3 -11
View File
@@ -18,11 +18,6 @@ import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { useEntity } from '@backstage/plugin-catalog-react';
import { Route, Routes } from 'react-router-dom';
import {
rootRouteRef,
rootDocsRouteRef,
rootCatalogDocsRouteRef,
} from './routes';
import { TechDocsIndexPage } from './home/components/TechDocsIndexPage';
import { TechDocsPage as TechDocsReaderPage } from './reader/components/TechDocsPage';
import { EntityPageDocs } from './EntityPageDocs';
@@ -33,9 +28,9 @@ const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
export const Router = () => {
return (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<TechDocsIndexPage />} />
<Route path="/" element={<TechDocsIndexPage />} />
<Route
path={`/${rootDocsRouteRef.path}`}
path="/:namespace/:kind/:name/*"
element={<TechDocsReaderPage />}
/>
</Routes>
@@ -58,10 +53,7 @@ export const EmbeddedDocsRouter = (_props: Props) => {
return (
<Routes>
<Route
path={`/${rootCatalogDocsRouteRef.path}`}
element={<EntityPageDocs entity={entity} />}
/>
<Route path="/*" element={<EntityPageDocs entity={entity} />} />
</Routes>
);
};
@@ -30,6 +30,7 @@ import {
configApiRef,
storageApiRef,
} from '@backstage/core-plugin-api';
import { rootDocsRouteRef } from '../../routes';
jest.mock('@backstage/plugin-catalog-react', () => {
const actual = jest.requireActual('@backstage/plugin-catalog-react');
@@ -73,6 +74,11 @@ describe('TechDocs Home', () => {
<ApiProvider apis={apiRegistry}>
<DefaultTechDocsHome />
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
// Header
@@ -19,6 +19,7 @@ import { render } from '@testing-library/react';
import React from 'react';
import { configApiRef } from '@backstage/core-plugin-api';
import { DocsCardGrid } from './DocsCardGrid';
import { rootDocsRouteRef } from '../../routes';
// Hacky way to mock a specific boolean config value.
const getOptionalBooleanMock = jest.fn().mockReturnValue(false);
@@ -71,16 +72,21 @@ describe('Entity Docs Card Grid', () => {
},
]}
/>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
),
);
expect(await findByText('testName')).toBeInTheDocument();
expect(await findByText('testName2')).toBeInTheDocument();
const [button1, button2] = await findAllByRole('button');
expect(button1.getAttribute('href')).toContain(
'/default/testkind/testname',
'/docs/default/testkind/testname',
);
expect(button2.getAttribute('href')).toContain(
'/default/testkind2/testname2',
'/docs/default/testkind2/testname2',
);
});
@@ -104,6 +110,11 @@ describe('Entity Docs Card Grid', () => {
},
]}
/>,
{
mountedRoutes: {
'/techdocs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
),
);
@@ -112,7 +123,7 @@ describe('Entity Docs Card Grid', () => {
'techdocs.legacyUseCaseSensitiveTripletPaths',
);
expect(button.getAttribute('href')).toContain(
'/SomeNamespace/TestKind/testName',
'/techdocs/SomeNamespace/TestKind/testName',
);
});
});
@@ -15,10 +15,9 @@
*/
import React from 'react';
import { generatePath } from 'react-router-dom';
import { Entity } from '@backstage/catalog-model';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import { Card, CardActions, CardContent, CardMedia } from '@material-ui/core';
import { rootDocsRouteRef } from '../../routes';
@@ -33,6 +32,8 @@ export const DocsCardGrid = ({
}: {
entities: Entity[] | undefined;
}) => {
const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef);
// Lower-case entity triplets by default, but allow override.
const toLowerMaybe = useApi(configApiRef).getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
@@ -53,7 +54,7 @@ export const DocsCardGrid = ({
<CardContent>{entity.metadata.description}</CardContent>
<CardActions>
<Button
to={generatePath(rootDocsRouteRef.path, {
to={getRouteToReaderPageFor({
namespace: toLowerMaybe(
entity.metadata.namespace ?? 'default',
),
@@ -18,6 +18,7 @@ import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { configApiRef } from '@backstage/core-plugin-api';
import { DocsTable } from './DocsTable';
import { rootDocsRouteRef } from '../../routes';
// Hacky way to mock a specific boolean config value.
const getOptionalBooleanMock = jest.fn().mockReturnValue(false);
@@ -90,16 +91,23 @@ describe('DocsTable test', () => {
},
]}
/>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
),
);
const link1 = await findByText('testName');
const link2 = await findByText('testName2');
expect(link1).toBeInTheDocument();
expect(link1.getAttribute('href')).toContain('/default/testkind/testname');
expect(link1.getAttribute('href')).toContain(
'/docs/default/testkind/testname',
);
expect(link2).toBeInTheDocument();
expect(link2.getAttribute('href')).toContain(
'/default/testkind2/testname2',
'/docs/default/testkind2/testname2',
);
});
@@ -133,6 +141,11 @@ describe('DocsTable test', () => {
},
]}
/>,
{
mountedRoutes: {
'/techdocs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
),
);
@@ -141,12 +154,18 @@ describe('DocsTable test', () => {
'techdocs.legacyUseCaseSensitiveTripletPaths',
);
expect(button.getAttribute('href')).toContain(
'/SomeNamespace/TestKind/testName',
'/techdocs/SomeNamespace/TestKind/testName',
);
});
it('should render empty state if no owned documents exist', async () => {
const { findByText } = render(wrapInTestApp(<DocsTable entities={[]} />));
const { findByText } = render(
wrapInTestApp(<DocsTable entities={[]} />, {
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
}),
);
expect(await findByText('No documents to show')).toBeInTheDocument();
});
@@ -16,9 +16,8 @@
import React from 'react';
import { useCopyToClipboard } from 'react-use';
import { generatePath } from 'react-router-dom';
import { useApi, configApiRef } from '@backstage/core-plugin-api';
import { configApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
formatEntityRefTitle,
@@ -50,6 +49,8 @@ export const DocsTable = ({
actions?: TableProps<DocsTableRow>['actions'];
}) => {
const [, copyToClipboard] = useCopyToClipboard();
const getRouteToReaderPageFor = useRouteRef(rootDocsRouteRef);
// Lower-case entity triplets by default, but allow override.
const toLowerMaybe = useApi(configApiRef).getOptionalBoolean(
'techdocs.legacyUseCaseSensitiveTripletPaths',
@@ -65,7 +66,7 @@ export const DocsTable = ({
return {
entity,
resolved: {
docsUrl: generatePath(rootDocsRouteRef.path, {
docsUrl: getRouteToReaderPageFor({
namespace: toLowerMaybe(entity.metadata.namespace ?? 'default'),
kind: toLowerMaybe(entity.kind),
name: toLowerMaybe(entity.metadata.name),
@@ -26,6 +26,7 @@ import {
ConfigReader,
} from '@backstage/core-app-api';
import { ConfigApi, configApiRef } from '@backstage/core-plugin-api';
import { rootDocsRouteRef } from '../../routes';
jest.mock('@backstage/plugin-catalog-react', () => {
const actual = jest.requireActual('@backstage/plugin-catalog-react');
@@ -68,6 +69,11 @@ describe('Legacy TechDocs Home', () => {
<ApiProvider apis={apiRegistry}>
<LegacyTechDocsHome />
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
// Header
@@ -20,6 +20,7 @@ import { screen } from '@testing-library/react';
import React from 'react';
import { TechDocsCustomHome, PanelType } from './TechDocsCustomHome';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { rootDocsRouteRef } from '../../routes';
jest.mock('@backstage/plugin-catalog-react', () => {
const actual = jest.requireActual('@backstage/plugin-catalog-react');
@@ -78,6 +79,11 @@ describe('TechDocsCustomHome', () => {
<ApiProvider apis={apiRegistry}>
<TechDocsCustomHome tabsConfig={tabsConfig} />
</ApiProvider>,
{
mountedRoutes: {
'/docs/:namespace/:kind/:name/*': rootDocsRouteRef,
},
},
);
// Header
+1
View File
@@ -65,6 +65,7 @@ export const techdocsPlugin = createPlugin({
],
routes: {
root: rootRouteRef,
docRoot: rootDocsRouteRef,
entityContent: rootCatalogDocsRouteRef,
},
});
+4 -3
View File
@@ -17,16 +17,17 @@
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
path: '',
id: 'techdocs-index-page',
title: 'TechDocs Landing Page',
});
export const rootDocsRouteRef = createRouteRef({
path: ':namespace/:kind/:name/*',
id: 'techdocs-reader-page',
title: 'Docs',
params: ['namespace', 'kind', 'name'],
});
export const rootCatalogDocsRouteRef = createRouteRef({
path: '*',
id: 'catalog-techdocs-reader-view',
title: 'Docs',
});