refactor(api-docs): rework ApiExplorerPage to use EntityListProvider

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2021-06-09 15:30:42 -04:00
parent 64989b4b6d
commit 2ebc430c4d
11 changed files with 81 additions and 358 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-api-docs': minor
---
Rework `ApiExplorerPage` to utilize `EntityListProvider` to provide a consistent UI with the `CatalogIndexPage` which now exposes support for starring entities, pagination, and customizing columns.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Export `EntityRow` type and `CreateComponentButton` component
+1
View File
@@ -32,6 +32,7 @@
"@asyncapi/react-component": "^0.23.0",
"@backstage/catalog-model": "^0.8.2",
"@backstage/core": "^0.7.11",
"@backstage/plugin-catalog": "^0.6.2",
"@backstage/plugin-catalog-react": "^0.2.2",
"@backstage/theme": "^0.2.8",
"@material-icons/font": "^1.0.2",
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Entity, RELATION_MEMBER_OF } from '@backstage/catalog-model';
import {
ApiProvider,
ApiRegistry,
@@ -55,6 +55,19 @@ describe('ApiCatalogPage', () => {
}),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
getEntityByName: async entityName => {
return {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: { name: entityName.name },
relations: [
{
type: RELATION_MEMBER_OF,
target: { namespace: 'default', kind: 'Group', name: 'tools' },
},
],
};
},
};
const configApi: ConfigApi = new ConfigReader({
@@ -18,58 +18,64 @@ import {
Content,
ContentHeader,
SupportButton,
useApi,
useRouteRef,
TableColumn,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Button } from '@material-ui/core';
import {
EntityKindPicker,
EntityListProvider,
EntityTagPicker,
EntityTypePicker,
UserListFilterKind,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import {
CatalogTable,
CreateComponentButton,
EntityRow,
} from '@backstage/plugin-catalog';
import { makeStyles } from '@material-ui/core';
import React from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { createComponentRouteRef } from '../../routes';
import { ApiExplorerTable } from '../ApiExplorerTable';
import { ApiExplorerLayout } from './ApiExplorerLayout';
export const ApiExplorerPage = () => {
const createComponentLink = useRouteRef(createComponentRouteRef);
const catalogApi = useApi(catalogApiRef);
const { loading, error, value: catalogResponse } = useAsync(() => {
return catalogApi.getEntities({
filter: { kind: 'API' },
fields: [
'apiVersion',
'kind',
'metadata',
'relations',
'spec.lifecycle',
'spec.owner',
'spec.type',
'spec.system',
],
});
}, [catalogApi]);
const useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
}));
export type ApiExplorerPageProps = {
initiallySelectedFilter?: UserListFilterKind;
columns?: TableColumn<EntityRow>[];
};
export const ApiExplorerPage = ({
initiallySelectedFilter = 'owned',
columns,
}: ApiExplorerPageProps) => {
const styles = useStyles();
return (
<ApiExplorerLayout>
<Content>
<ContentHeader title="">
{createComponentLink && (
<Button
variant="contained"
color="primary"
component={RouterLink}
to={createComponentLink()}
>
Register Existing API
</Button>
)}
<CreateComponentButton buttonLabel="Register Existing API" />
<SupportButton>All your APIs</SupportButton>
</ContentHeader>
<ApiExplorerTable
entities={catalogResponse?.items ?? []}
loading={loading}
error={error}
/>
<div className={styles.contentWrapper}>
<EntityListProvider>
<div>
<EntityKindPicker initialFilter="api" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityTagPicker />
</div>
<CatalogTable columns={columns} />
</EntityListProvider>
</div>
</Content>
</ApiExplorerLayout>
);
@@ -1,81 +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 { Entity } from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import * as React from 'react';
import { apiDocsConfigRef } from '../../config';
import { ApiExplorerTable } from './ApiExplorerTable';
const entities: Entity[] = [
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api1' },
spec: { type: 'openapi' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api2' },
spec: { type: 'openapi' },
},
{
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api3' },
spec: { type: 'grpc' },
},
];
const apiRegistry = ApiRegistry.with(apiDocsConfigRef, {
getApiDefinitionWidget: () => undefined,
});
describe('ApiCatalogTable component', () => {
it('should render error message when error is passed in props', async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable
entities={[]}
loading={false}
error={{ code: 'error' }}
/>
</ApiProvider>,
),
);
const errorMessage = await rendered.findByText(
/Could not fetch catalog entities./,
);
expect(errorMessage).toBeInTheDocument();
});
it('should display entity names when loading has finished and no error occurred', async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apiRegistry}>
<ApiExplorerTable entities={entities} loading={false} />
</ApiProvider>,
),
);
expect(rendered.getByText(/api1/)).toBeInTheDocument();
expect(rendered.getByText(/api2/)).toBeInTheDocument();
expect(rendered.getByText(/api3/)).toBeInTheDocument();
});
});
@@ -1,216 +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 {
ApiEntityV1alpha1,
Entity,
EntityName,
RELATION_OWNED_BY,
RELATION_PART_OF,
} from '@backstage/catalog-model';
import {
CodeSnippet,
OverflowTooltip,
Table,
TableColumn,
TableFilter,
TableState,
useQueryParamState,
WarningPanel,
} from '@backstage/core';
import {
EntityRefLink,
EntityRefLinks,
formatEntityRefTitle,
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import { Chip } from '@material-ui/core';
import React from 'react';
import { ApiTypeTitle } from '../ApiDefinitionCard';
type EntityRow = {
entity: ApiEntityV1alpha1;
resolved: {
name: string;
partOfSystemRelationTitle?: string;
partOfSystemRelations: EntityName[];
ownedByRelationsTitle?: string;
ownedByRelations: EntityName[];
};
};
const columns: TableColumn<EntityRow>[] = [
{
title: 'Name',
field: 'resolved.name',
highlight: true,
render: ({ entity }) => (
<EntityRefLink entityRef={entity} defaultKind="API" />
),
},
{
title: 'System',
field: 'resolved.partOfSystemRelationTitle',
render: ({ resolved }) => (
<EntityRefLinks
entityRefs={resolved.partOfSystemRelations}
defaultKind="system"
/>
),
},
{
title: 'Owner',
field: 'resolved.ownedByRelationsTitle',
render: ({ resolved }) => (
<EntityRefLinks
entityRefs={resolved.ownedByRelations}
defaultKind="group"
/>
),
},
{
title: 'Lifecycle',
field: 'entity.spec.lifecycle',
},
{
title: 'Type',
field: 'entity.spec.type',
render: ({ entity }) => <ApiTypeTitle apiEntity={entity} />,
},
{
title: 'Description',
field: 'entity.metadata.description',
render: ({ entity }) => (
<OverflowTooltip
text={entity.metadata.description}
placement="bottom-start"
/>
),
width: 'auto',
},
{
title: 'Tags',
field: 'entity.metadata.tags',
cellStyle: {
padding: '0px 16px 0px 20px',
},
render: ({ entity }) => (
<>
{entity.metadata.tags &&
entity.metadata.tags.map(t => (
<Chip
key={t}
label={t}
size="small"
variant="outlined"
style={{ marginBottom: '0px' }}
/>
))}
</>
),
},
];
const filters: TableFilter[] = [
{
column: 'Owner',
type: 'select',
},
{
column: 'Type',
type: 'multiple-select',
},
{
column: 'Lifecycle',
type: 'multiple-select',
},
{
column: 'Tags',
type: 'checkbox-tree',
},
];
type ExplorerTableProps = {
entities: Entity[];
loading: boolean;
error?: any;
};
export const ApiExplorerTable = ({
entities,
loading,
error,
}: ExplorerTableProps) => {
const [queryParamState, setQueryParamState] = useQueryParamState<TableState>(
'apiTable',
);
if (error) {
return (
<WarningPanel severity="error" title="Could not fetch catalog entities.">
<CodeSnippet language="text" text={error.toString()} />
</WarningPanel>
);
}
const rows = entities.map(entity => {
const partOfSystemRelations = getEntityRelations(entity, RELATION_PART_OF, {
kind: 'system',
});
const ownedByRelations = getEntityRelations(entity, RELATION_OWNED_BY);
return {
entity: entity as ApiEntityV1alpha1,
resolved: {
name: formatEntityRefTitle(entity, {
defaultKind: 'API',
}),
ownedByRelationsTitle: ownedByRelations
.map(r => formatEntityRefTitle(r, { defaultKind: 'group' }))
.join(', '),
ownedByRelations,
partOfSystemRelationTitle: partOfSystemRelations
.map(r =>
formatEntityRefTitle(r, {
defaultKind: 'system',
}),
)
.join(', '),
partOfSystemRelations,
},
};
});
return (
<Table<EntityRow>
isLoading={loading}
columns={columns}
options={{
paging: true,
pageSize: 20,
pageSizeOptions: [20, 50, 100],
actionsColumnIndex: -1,
loadingType: 'linear',
padding: 'dense',
showEmptyDataSourceMessage: !loading,
}}
data={rows}
filters={filters}
initialState={queryParamState}
onStateChange={setQueryParamState}
/>
);
};
@@ -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 { ApiExplorerTable } from './ApiExplorerTable';
@@ -15,3 +15,4 @@
*/
export { CatalogTable } from './CatalogTable';
export type { EntityRow } from './types';
@@ -20,7 +20,12 @@ import { Button } from '@material-ui/core';
import { useRouteRef } from '@backstage/core';
import { createComponentRouteRef } from '../../routes';
export const CreateComponentButton = () => {
type CreateComponentButtonProps = {
buttonLabel?: string;
};
export const CreateComponentButton = ({
buttonLabel,
}: CreateComponentButtonProps) => {
const createComponentLink = useRouteRef(createComponentRouteRef);
if (!createComponentLink) return null;
@@ -32,7 +37,7 @@ export const CreateComponentButton = () => {
color="primary"
to={createComponentLink()}
>
Create Component
{buttonLabel ?? 'Create Component'}
</Button>
);
};
+1
View File
@@ -18,6 +18,7 @@ export * from './components/AboutCard';
export { CatalogLayout } from './components/CatalogPage';
export { CatalogResultListItem } from './components/CatalogResultListItem';
export { CatalogTable } from './components/CatalogTable';
export type { EntityRow } from './components/CatalogTable';
export { CreateComponentButton } from './components/CreateComponentButton';
export { EntityLayout } from './components/EntityLayout';
export * from './components/EntityOrphanWarning';