feat(api-docs): add routes to display all api entities

This commit is contained in:
Dominik Henneke
2020-07-24 15:36:04 +02:00
parent 58f86f992a
commit 3534c0804c
10 changed files with 550 additions and 81 deletions
@@ -14,21 +14,24 @@
* limitations under the License.
*/
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
import { render } from '@testing-library/react';
import mockFetch from 'jest-fetch-mock';
import { Header, Page, pageTheme } from '@backstage/core';
import React from 'react';
import CatalogApiPluginPage from './CatalogApiPluginPage';
describe('CatalogApiPluginPage', () => {
it('should render', () => {
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(
<ThemeProvider theme={lightTheme}>
<CatalogApiPluginPage />
</ThemeProvider>,
);
expect(rendered.getByText('Welcome to catalog-api!')).toBeInTheDocument();
});
});
type Props = {
children?: React.ReactNode;
};
const ApiCatalogLayout = ({ children }: Props) => {
return (
<Page theme={pageTheme.home}>
<Header
title="APIs"
subtitle="Backstage API Catalog"
pageTitleOverride="Home"
/>
{children}
</Page>
);
};
export default ApiCatalogLayout;
@@ -0,0 +1,70 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry, storageApiRef } from '@backstage/core';
// TODO: Circular ref!
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog';
import { MockStorageApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { ApiCatalogPage } from './ApiCatalogPage';
describe('ApiCatalogPage', () => {
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve([
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity1',
},
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: {
name: 'Entity2',
},
},
] as Entity[]),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const renderWrapped = (children: React.ReactNode) =>
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[catalogApiRef, catalogApi],
[storageApiRef, MockStorageApi.create()],
])}
>
{children}
</ApiProvider>,
),
);
// this test right now causes some red lines in the log output when running tests
// related to some theme issues in mui-table
// https://github.com/mbrn/material-table/issues/1293
it('should render', async () => {
const { findByText } = renderWrapped(<ApiCatalogPage />);
expect(await findByText(/APIs \(2\)/)).toBeInTheDocument();
});
});
@@ -0,0 +1,45 @@
/*
* 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 { Content, useApi } from '@backstage/core';
// TODO: Circular ref
import { catalogApiRef } from '@backstage/plugin-catalog';
import React from 'react';
import { useAsync } from 'react-use';
import { ApiCatalogTable } from '../ApiCatalogTable/ApiCatalogTable';
import ApiCatalogLayout from './ApiCatalogLayout';
const CatalogPageContents = () => {
const catalogApi = useApi(catalogApiRef);
const { loading, error, value: matchingEntities } = useAsync(() => {
return catalogApi.getEntities({ kind: 'API' });
}, [catalogApi]);
return (
<ApiCatalogLayout>
<Content>
<ApiCatalogTable
titlePreamble="APIs"
entities={matchingEntities!}
loading={loading}
error={error}
/>
</Content>
</ApiCatalogLayout>
);
};
export const ApiCatalogPage = () => <CatalogPageContents />;
@@ -0,0 +1,74 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import * as React from 'react';
import { ApiCatalogTable } from './ApiCatalogTable';
const entites: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api1' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api2' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api3' },
},
];
describe('ApiCatalogTable component', () => {
it('should render error message when error is passed in props', async () => {
const rendered = render(
wrapInTestApp(
<ApiCatalogTable
titlePreamble="APIs"
entities={[]}
loading={false}
error={{ code: 'error' }}
/>,
),
);
const errorMessage = await rendered.findByText(
/Error encountered while fetching catalog entities./,
);
expect(errorMessage).toBeInTheDocument();
});
it('should display entity names when loading has finished and no error occurred', async () => {
const rendered = render(
wrapInTestApp(
<ApiCatalogTable
titlePreamble="APIs"
entities={entites}
loading={false}
/>,
),
);
expect(rendered.getByText(/APIs \(3\)/)).toBeInTheDocument();
expect(rendered.getByText(/api1/)).toBeInTheDocument();
expect(rendered.getByText(/api2/)).toBeInTheDocument();
expect(rendered.getByText(/api3/)).toBeInTheDocument();
});
});
@@ -0,0 +1,89 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { 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 { entityRoute } from '../../routes';
const columns: TableColumn<Entity>[] = [
{
title: 'Name',
field: 'metadata.name',
highlight: true,
render: (entity: any) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
})}
>
{entity.metadata.name}
</Link>
),
},
{
title: 'Description',
field: 'metadata.description',
},
];
type CatalogTableProps = {
entities: Entity[];
titlePreamble: string;
loading: boolean;
error?: any;
};
export const ApiCatalogTable = ({
entities,
loading,
error,
titlePreamble,
}: CatalogTableProps) => {
if (error) {
return (
<div>
<Alert severity="error">
Error encountered while fetching catalog entities. {error.toString()}
</Alert>
</div>
);
}
return (
<Table<Entity>
isLoading={loading}
columns={columns}
options={{
paging: false,
actionsColumnIndex: -1,
loadingType: 'linear',
showEmptyDataSourceMessage: !loading,
}}
title={`${titlePreamble} (${(entities && entities.length) || 0})`}
data={entities}
/>
);
};
@@ -0,0 +1,102 @@
/*
* 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 { Entity } from '@backstage/catalog-model';
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, getPageTheme } 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'),
);
});
});
describe('getPageTheme', () => {
const defaultPageTheme = getPageTheme();
it.each(['service', 'app', 'library', 'tool', 'documentation', 'website'])(
'should select right theme for predefined type: %p ̰ ',
type => {
const theme = getPageTheme(({
spec: {
type,
},
} as any) as Entity);
expect(theme).toBeDefined();
expect(theme).not.toBe(defaultPageTheme);
},
);
it('should select default theme for unknown/unspecified types', () => {
const theme1 = getPageTheme(({
spec: {
type: 'unknown-type',
},
} as any) as Entity);
const theme2 = getPageTheme(({
spec: {},
} as any) as Entity);
expect(theme1).toBe(defaultPageTheme);
expect(theme2).toBe(defaultPageTheme);
});
});
@@ -0,0 +1,131 @@
/*
* 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 { ApiEntityV1alpha1, Entity } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
Header,
Page,
pageTheme,
PageTheme,
Progress,
useApi,
} from '@backstage/core';
// TODO: Circular ref
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard/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;
})(),
};
}
export const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
title,
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
</Box>
);
export const ApiEntityPage: FC<{}> = () => {
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 theme={getPageTheme(entity)}>
<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 ApiEntityV1alpha1} />
</Content>
</>
)}
</Page>
);
};
@@ -1,56 +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 {
Content,
ContentHeader,
Header,
HeaderLabel,
InfoCard,
Page,
pageTheme,
SupportButton,
} from '@backstage/core';
import { Grid, Typography } from '@material-ui/core';
import React, { FC } from 'react';
const CatalogApiPluginPage: FC<{}> = () => (
<Page theme={pageTheme.tool}>
<Header title="Welcome to catalog-api!" subtitle="Optional subtitle">
<HeaderLabel label="Owner" value="Team X" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Plugin title">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard title="Information card">
<Typography variant="body1">
All content should be wrapped in a card like this.
</Typography>
</InfoCard>
</Grid>
<Grid item>
<p>API Catalog</p>
</Grid>
</Grid>
</Content>
</Page>
);
export default CatalogApiPluginPage;
+6 -8
View File
@@ -14,17 +14,15 @@
* limitations under the License.
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import CatalogApiPluginPage from './components/CatalogApiPluginPage';
export const rootRouteRef = createRouteRef({
path: '/api-docs',
title: 'api-docs',
});
import { createPlugin } from '@backstage/core';
import { ApiCatalogPage } from './components/ApiCatalogPage/ApiCatalogPage';
import { ApiEntityPage } from './components/ApiEntityPage/ApiEntityPage';
import { entityRoute, rootRoute } from './routes';
export const plugin = createPlugin({
id: 'api-docs',
register({ router }) {
router.addRoute(rootRouteRef, CatalogApiPluginPage);
router.addRoute(rootRoute, ApiCatalogPage);
router.addRoute(entityRoute, ApiEntityPage);
},
});
@@ -14,4 +14,17 @@
* limitations under the License.
*/
export { default } from './CatalogApiPluginPage';
import { createRouteRef } from '@backstage/core';
const NoIcon = () => null;
export const rootRoute = createRouteRef({
icon: NoIcon,
path: '/api-docs',
title: 'APIs',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/api-docs/:optionalNamespaceAndName/',
title: 'API',
});