Make api-docs customizable

This commit is contained in:
Oliver Sand
2020-10-20 10:40:58 +02:00
parent 8aba3e4c47
commit f3bb55ee31
10 changed files with 106 additions and 280 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-api-docs': minor
---
APIs now have real entity pages that are customizable in the app.
Therefore the old entity page from this plugin is removed.
See the `packages/app` on how to create and customize the API entity page.
@@ -13,63 +13,66 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiEntity, Entity } from '@backstage/catalog-model';
import { EmptyState } from '@backstage/core';
import {
isPluginApplicableToEntity as isTravisCIAvailable,
RecentTravisCIBuildsWidget,
Router as TravisCIRouter,
} from '@roadiehq/backstage-plugin-travis-ci';
ApiDefinitionCard,
Router as ApiDocsRouter,
} from '@backstage/plugin-api-docs';
import {
AboutCard,
EntityPageLayout,
useEntity,
} from '@backstage/plugin-catalog';
import {
isPluginApplicableToEntity as isCircleCIAvailable,
Router as CircleCIRouter,
} from '@backstage/plugin-circleci';
import {
isPluginApplicableToEntity as isCloudbuildAvailable,
Router as CloudbuildRouter,
} from '@backstage/plugin-cloudbuild';
import {
isPluginApplicableToEntity as isGitHubActionsAvailable,
RecentWorkflowRunsCard,
Router as GitHubActionsRouter,
} from '@backstage/plugin-github-actions';
import {
Router as CloudbuildRouter,
isPluginApplicableToEntity as isCloudbuildAvailable,
} from '@backstage/plugin-cloudbuild';
import {
Router as JenkinsRouter,
isPluginApplicableToEntity as isJenkinsAvailable,
LatestRunCard as JenkinsLatestRunCard,
Router as JenkinsRouter,
} from '@backstage/plugin-jenkins';
import {
isPluginApplicableToEntity as isCircleCIAvailable,
Router as CircleCIRouter,
} from '@backstage/plugin-circleci';
import { Router as ApiDocsRouter } from '@backstage/plugin-api-docs';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as KubernetesRouter } from '@backstage/plugin-kubernetes';
import {
Router as GitHubInsightsRouter,
isPluginApplicableToEntity as isGitHubAvailable,
ReadMeCard,
LanguagesCard,
ReleasesCard,
} from '@roadiehq/backstage-plugin-github-insights';
import React, { ReactNode } from 'react';
import {
AboutCard,
EntityPageLayout,
useEntity,
} from '@backstage/plugin-catalog';
import { Entity } from '@backstage/catalog-model';
import { Button, Grid } from '@material-ui/core';
import { EmptyState } from '@backstage/core';
import {
EmbeddedRouter as LighthouseRouter,
LastLighthouseAuditCard,
isPluginApplicableToEntity as isLighthouseAvailable,
LastLighthouseAuditCard,
} from '@backstage/plugin-lighthouse/';
import { Router as SentryRouter } from '@backstage/plugin-sentry';
import { EmbeddedDocsRouter as DocsRouter } from '@backstage/plugin-techdocs';
import { Button, Grid } from '@material-ui/core';
import {
isPluginApplicableToEntity as isBuildkiteAvailable,
Router as BuildkiteRouter,
} from '@roadiehq/backstage-plugin-buildkite';
import {
isPluginApplicableToEntity as isGitHubAvailable,
LanguagesCard,
ReadMeCard,
ReleasesCard,
Router as GitHubInsightsRouter,
} from '@roadiehq/backstage-plugin-github-insights';
import {
Router as PullRequestsRouter,
isPluginApplicableToEntity as isPullRequestsAvailable,
PullRequestsStatsCard,
Router as PullRequestsRouter,
} from '@roadiehq/backstage-plugin-github-pull-requests';
import {
Router as BuildkiteRouter,
isPluginApplicableToEntity as isBuildkiteAvailable,
} from '@roadiehq/backstage-plugin-buildkite';
isPluginApplicableToEntity as isTravisCIAvailable,
RecentTravisCIBuildsWidget,
Router as TravisCIRouter,
} from '@roadiehq/backstage-plugin-travis-ci';
import React, { ReactNode } from 'react';
export const CICDSwitcher = ({ entity }: { entity: Entity }) => {
// This component is just an example of how you can implement your company's logic in entity page.
@@ -134,7 +137,7 @@ const RecentCICDRunsSwitcher = ({ entity }: { entity: Entity }) => {
);
};
const OverviewContent = ({ entity }: { entity: Entity }) => (
const ComponentOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3} alignItems="stretch">
<Grid item md={6}>
<AboutCard entity={entity} variant="gridItem" />
@@ -169,7 +172,7 @@ const ServiceEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewContent entity={entity} />}
element={<ComponentOverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
@@ -214,7 +217,7 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout.Content
path="/"
title="Overview"
element={<OverviewContent entity={entity} />}
element={<ComponentOverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/ci-cd/*"
@@ -253,12 +256,13 @@ const WebsiteEntityPage = ({ entity }: { entity: Entity }) => (
/>
</EntityPageLayout>
);
const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<OverviewContent entity={entity} />}
element={<ComponentOverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/docs/*"
@@ -268,8 +272,7 @@ const DefaultEntityPage = ({ entity }: { entity: Entity }) => (
</EntityPageLayout>
);
export const EntityPage = () => {
const { entity } = useEntity();
export const ComponentEntityPage = ({ entity }: { entity: Entity }) => {
switch (entity?.spec?.type) {
case 'service':
return <ServiceEntityPage entity={entity} />;
@@ -279,3 +282,47 @@ export const EntityPage = () => {
return <DefaultEntityPage entity={entity} />;
}
};
const ApiOverviewContent = ({ entity }: { entity: Entity }) => (
<Grid container spacing={3}>
<Grid item md={6}>
<AboutCard entity={entity} />
</Grid>
</Grid>
);
const ApiDefinitionContent = ({ entity }: { entity: ApiEntity }) => (
<Grid container spacing={3}>
<Grid item xs={12}>
<ApiDefinitionCard apiEntity={entity} />
</Grid>
</Grid>
);
const ApiEntityPage = ({ entity }: { entity: Entity }) => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<ApiOverviewContent entity={entity} />}
/>
<EntityPageLayout.Content
path="/definition/*"
title="Definition"
element={<ApiDefinitionContent entity={entity as ApiEntity} />}
/>
</EntityPageLayout>
);
export const EntityPage = () => {
const { entity } = useEntity();
switch (entity?.kind?.toLowerCase()) {
case 'component':
return <ComponentEntityPage entity={entity} />;
case 'api':
return <ApiEntityPage entity={entity} />;
default:
return <DefaultEntityPage entity={entity} />;
}
};
+1 -1
View File
@@ -29,6 +29,7 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"graphiql": "^1.0.0-alpha.10",
"graphql": "^15.3.0",
"react": "^16.13.1",
@@ -47,7 +48,6 @@
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^26.0.7",
"@types/node": "^12.0.0",
"@types/react": "^16.9",
"@types/swagger-ui-react": "^3.23.3",
"cross-fetch": "^3.0.6",
"msw": "^0.21.2"
@@ -1,71 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { ApiEntityPage } from './ApiEntityPage';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigate = jest.fn();
return {
...actual,
useNavigate: jest.fn(() => mockNavigate),
useParams: jest.fn(),
};
});
const {
useParams,
useNavigate,
}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
'react-router-dom',
);
const errorApi = { post: () => {} };
describe('ApiEntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
useParams.mockReturnValue({
kind: 'Component',
optionalNamespaceAndName: '',
});
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, errorApi],
[
catalogApiRef,
({
async getEntityByName() {},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
<ApiEntityPage />
</ApiProvider>,
),
);
await waitFor(() =>
expect(useNavigate()).toHaveBeenCalledWith('/api-docs'),
);
});
});
@@ -1,127 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiEntity, Entity } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
Header,
Page,
Progress,
useApi,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard';
const REDIRECT_DELAY = 1000;
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
type EntityPageTitleProps = {
title: string;
entity: Entity | undefined;
};
const EntityPageTitle = ({ title }: EntityPageTitleProps) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
</Box>
);
export const ApiEntityPage = () => {
const { optionalNamespaceAndName } = useParams() as {
optionalNamespaceAndName: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind: 'API', namespace, name }),
[catalogApi, namespace, name],
);
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
navigate('/api-docs');
return null;
}
const { headerTitle, headerType } = headerProps(
'API',
namespace,
name,
entity,
);
return (
<Page themeId={entity?.spec?.type?.toString() ?? 'home'}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
/>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<Content>
<ApiDefinitionCard apiEntity={entity as ApiEntity} />
</Content>
</>
)}
</Page>
);
};
@@ -1,17 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ApiEntityPage } from './ApiEntityPage';
@@ -23,12 +23,12 @@ import {
useApi,
useQueryParamState,
} from '@backstage/core';
import { entityRoute, entityRouteParams } from '@backstage/plugin-catalog';
import { Chip, Link } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React from 'react';
import { generatePath, Link as RouterLink } from 'react-router-dom';
import { apiDocsConfigRef } from '../../config';
import { entityRoute } from '../../routes';
const ApiTypeTitle = ({ apiEntity }: { apiEntity: ApiEntityV1alpha1 }) => {
const config = useApi(apiDocsConfigRef);
@@ -46,16 +46,10 @@ const columns: TableColumn<Entity>[] = [
render: (entity: any) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
selectedTabId: 'overview',
})}
to={generatePath(
`/catalog/${entityRoute.path}`,
entityRouteParams(entity),
)}
>
{entity.metadata.name}
</Link>
+2 -1
View File
@@ -14,5 +14,6 @@
* limitations under the License.
*/
export { Router } from './catalog';
export * from './catalog';
export * from './components';
export { plugin } from './plugin';
+1 -3
View File
@@ -18,8 +18,7 @@ import { ApiEntity } from '@backstage/catalog-model';
import { createApiFactory, createPlugin } from '@backstage/core';
import { ApiExplorerPage } from './components/ApiExplorerPage/ApiExplorerPage';
import { defaultDefinitionWidgets } from './components/ApiDefinitionCard';
import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage';
import { entityRoute, rootRoute } from './routes';
import { rootRoute } from './routes';
import { apiDocsConfigRef } from './config';
export const plugin = createPlugin({
@@ -40,6 +39,5 @@ export const plugin = createPlugin({
],
register({ router }) {
router.addRoute(rootRoute, ApiExplorerPage);
router.addRoute(entityRoute, ApiEntityPage);
},
});
-6
View File
@@ -24,12 +24,6 @@ export const rootRoute = createRouteRef({
title: 'APIs',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/api-docs/:optionalNamespaceAndName/',
title: 'API',
});
export const catalogRoute = createRouteRef({
icon: NoIcon,
path: '',