Merge pull request #5984 from kuangp/refactor/api-page

refactor(api-docs): rework ApiExplorerPage to use EntityListProvider
This commit is contained in:
Patrik Oldsberg
2021-06-16 00:10:18 +02:00
committed by GitHub
21 changed files with 620 additions and 331 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Add `EntityLifecyclePicker` and `EntityOwnerPicker` UI components to allow filtering by `spec.lifecycle` and `spec.owner` on catalog-related pages.
+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 `CatalogTableRow` type
+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,36 +18,46 @@ import {
Content,
ContentHeader,
SupportButton,
useApi,
TableColumn,
useRouteRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Button } from '@material-ui/core';
import {
EntityKindPicker,
EntityLifecyclePicker,
EntityListProvider,
EntityOwnerPicker,
EntityTagPicker,
EntityTypePicker,
UserListFilterKind,
UserListPicker,
} from '@backstage/plugin-catalog-react';
import { CatalogTable, CatalogTableRow } from '@backstage/plugin-catalog';
import { Button, 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 useStyles = makeStyles(theme => ({
contentWrapper: {
display: 'grid',
gridTemplateAreas: "'filters' 'table'",
gridTemplateColumns: '250px 1fr',
gridColumnGap: theme.spacing(2),
},
}));
export type ApiExplorerPageProps = {
initiallySelectedFilter?: UserListFilterKind;
columns?: TableColumn<CatalogTableRow>[];
};
export const ApiExplorerPage = ({
initiallySelectedFilter = 'all',
columns,
}: ApiExplorerPageProps) => {
const styles = useStyles();
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]);
return (
<ApiExplorerLayout>
@@ -65,11 +75,19 @@ export const ApiExplorerPage = () => {
)}
<SupportButton>All your APIs</SupportButton>
</ContentHeader>
<ApiExplorerTable
entities={catalogResponse?.items ?? []}
loading={loading}
error={error}
/>
<div className={styles.contentWrapper}>
<EntityListProvider>
<div>
<EntityKindPicker initialFilter="api" hidden />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityTypePicker />
<EntityOwnerPicker />
<EntityLifecyclePicker />
<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}
/>
);
};
@@ -0,0 +1,139 @@
/*
* Copyright 2021 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 { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { EntityLifecycleFilter } from '../../types';
import { EntityLifecyclePicker } from './EntityLifecyclePicker';
const sampleEntities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-1',
},
spec: {
lifecycle: 'production',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-2',
},
spec: {
lifecycle: 'experimental',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-3',
},
spec: {
lifecycle: 'experimental',
},
},
];
describe('<EntityLifecyclePicker/>', () => {
it('renders all lifecycles', () => {
const rendered = render(
<MockEntityListContextProvider
value={{ entities: sampleEntities, backendEntities: sampleEntities }}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(rendered.getByText('Lifecycle')).toBeInTheDocument();
fireEvent.click(rendered.getByTestId('lifecycle-picker-expand'));
sampleEntities
.map(e => e.spec?.lifecycle!)
.forEach(lifecycle => {
expect(rendered.getByText(lifecycle as string)).toBeInTheDocument();
});
});
it('renders unique lifecycles in alphabetical order', () => {
const rendered = render(
<MockEntityListContextProvider
value={{ entities: sampleEntities, backendEntities: sampleEntities }}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(rendered.getByText('Lifecycle')).toBeInTheDocument();
fireEvent.click(rendered.getByTestId('lifecycle-picker-expand'));
expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([
'experimental',
'production',
]);
});
it('adds lifecycles to filters', () => {
const updateFilters = jest.fn();
const rendered = render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).not.toHaveBeenCalled();
fireEvent.click(rendered.getByTestId('lifecycle-picker-expand'));
fireEvent.click(rendered.getByText('production'));
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['production']),
});
});
it('removes lifecycles from filters', () => {
const updateFilters = jest.fn();
const rendered = render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
filters: { lifecycles: new EntityLifecycleFilter(['production']) },
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).not.toHaveBeenCalled();
fireEvent.click(rendered.getByTestId('lifecycle-picker-expand'));
expect(rendered.getByLabelText('production')).toBeChecked();
fireEvent.click(rendered.getByLabelText('production'));
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: undefined,
});
});
});
@@ -0,0 +1,86 @@
/*
* Copyright 2021 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 {
Box,
Checkbox,
FormControlLabel,
TextField,
Typography,
} from '@material-ui/core';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Autocomplete } from '@material-ui/lab';
import React, { useMemo } from 'react';
import { useEntityListProvider } from '../../hooks/useEntityListProvider';
import { EntityLifecycleFilter } from '../../types';
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
export const EntityLifecyclePicker = () => {
const { updateFilters, backendEntities, filters } = useEntityListProvider();
const availableLifecycles = useMemo(
() =>
[
...new Set(
backendEntities
.map((e: Entity) => e.spec?.lifecycle)
.filter(Boolean) as string[],
),
].sort(),
[backendEntities],
);
if (!availableLifecycles.length) return null;
const onChange = (lifecycles: string[]) => {
updateFilters({
lifecycles: lifecycles.length
? new EntityLifecycleFilter(lifecycles)
: undefined,
});
};
return (
<Box pb={1} pt={1}>
<Typography variant="button">Lifecycle</Typography>
<Autocomplete<string>
multiple
options={availableLifecycles}
value={filters.lifecycles?.values ?? []}
onChange={(_: object, value: string[]) => onChange(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
label={option}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="lifecycle-picker-expand" />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</Box>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 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 { EntityLifecyclePicker } from './EntityLifecyclePicker';
@@ -0,0 +1,169 @@
/*
* Copyright 2021 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 { fireEvent, render } from '@testing-library/react';
import React from 'react';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { EntityOwnerFilter } from '../../types';
import { EntityOwnerPicker } from './EntityOwnerPicker';
const sampleEntities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-1',
},
relations: [
{
type: 'ownedBy',
target: {
name: 'some-owner',
namespace: 'default',
kind: 'Group',
},
},
{
type: 'ownedBy',
target: {
name: 'some-owner-2',
namespace: 'default',
kind: 'Group',
},
},
],
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-2',
},
relations: [
{
type: 'ownedBy',
target: {
name: 'another-owner',
namespace: 'default',
kind: 'Group',
},
},
],
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-3',
},
relations: [
{
type: 'ownedBy',
target: {
name: 'some-owner',
namespace: 'default',
kind: 'Group',
},
},
],
},
];
describe('<EntityOwnerPicker/>', () => {
it('renders all owners', () => {
const rendered = render(
<MockEntityListContextProvider
value={{ entities: sampleEntities, backendEntities: sampleEntities }}
>
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
expect(rendered.getByText('Owner')).toBeInTheDocument();
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
sampleEntities
.flatMap(e => e.relations?.map(r => r.target.name))
.forEach(owner => {
expect(rendered.getByText(owner as string)).toBeInTheDocument();
});
});
it('renders unique owners in alphabetical order', () => {
const rendered = render(
<MockEntityListContextProvider
value={{ entities: sampleEntities, backendEntities: sampleEntities }}
>
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
expect(rendered.getByText('Owner')).toBeInTheDocument();
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
expect(rendered.getAllByRole('option').map(o => o.textContent)).toEqual([
'another-owner',
'some-owner',
'some-owner-2',
]);
});
it('adds owners to filters', () => {
const updateFilters = jest.fn();
const rendered = render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
}}
>
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).not.toHaveBeenCalled();
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
fireEvent.click(rendered.getByText('some-owner'));
expect(updateFilters).toHaveBeenLastCalledWith({
owners: new EntityOwnerFilter(['some-owner']),
});
});
it('removes owners from filters', () => {
const updateFilters = jest.fn();
const rendered = render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
filters: { owners: new EntityOwnerFilter(['some-owner']) },
}}
>
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).not.toHaveBeenCalled();
fireEvent.click(rendered.getByTestId('owner-picker-expand'));
expect(rendered.getByLabelText('some-owner')).toBeChecked();
fireEvent.click(rendered.getByLabelText('some-owner'));
expect(updateFilters).toHaveBeenLastCalledWith({
owner: undefined,
});
});
});
@@ -0,0 +1,90 @@
/*
* Copyright 2021 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, RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
Box,
Checkbox,
FormControlLabel,
TextField,
Typography,
} from '@material-ui/core';
import CheckBoxIcon from '@material-ui/icons/CheckBox';
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import { Autocomplete } from '@material-ui/lab';
import React, { useMemo } from 'react';
import { useEntityListProvider } from '../../hooks/useEntityListProvider';
import { EntityOwnerFilter } from '../../types';
import { getEntityRelations } from '../../utils';
import { formatEntityRefTitle } from '../EntityRefLink';
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
export const EntityOwnerPicker = () => {
const { updateFilters, backendEntities, filters } = useEntityListProvider();
const availableOwners = useMemo(
() =>
[
...new Set(
backendEntities
.flatMap((e: Entity) =>
getEntityRelations(e, RELATION_OWNED_BY).map(o =>
formatEntityRefTitle(o, { defaultKind: 'group' }),
),
)
.filter(Boolean) as string[],
),
].sort(),
[backendEntities],
);
if (!availableOwners.length) return null;
const onChange = (owners: string[]) => {
updateFilters({
owners: owners.length ? new EntityOwnerFilter(owners) : undefined,
});
};
return (
<Box pb={1} pt={1}>
<Typography variant="button">Owner</Typography>
<Autocomplete<string>
multiple
options={availableOwners}
value={filters.owners?.values ?? []}
onChange={(_: object, value: string[]) => onChange(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
label={option}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="owner-picker-expand" />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</Box>
);
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 Spotify AB
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { ApiExplorerTable } from './ApiExplorerTable';
export { EntityOwnerPicker } from './EntityOwnerPicker';
@@ -16,6 +16,7 @@
import { Entity } from '@backstage/catalog-model';
import {
Box,
Checkbox,
FormControlLabel,
TextField,
@@ -55,7 +56,7 @@ export const EntityTagPicker = () => {
};
return (
<>
<Box pb={1} pt={1}>
<Typography variant="button">Tags</Typography>
<Autocomplete<string>
multiple
@@ -78,6 +79,6 @@ export const EntityTagPicker = () => {
popupIcon={<ExpandMoreIcon data-testid="tag-picker-expand" />}
renderInput={params => <TextField {...params} variant="outlined" />}
/>
</>
</Box>
);
};
@@ -14,6 +14,8 @@
* limitations under the License.
*/
export * from './EntityKindPicker';
export * from './EntityLifecyclePicker';
export * from './EntityOwnerPicker';
export * from './EntityProvider';
export * from './EntityRefLink';
export * from './EntityTable';
@@ -29,6 +29,8 @@ import { catalogApiRef } from '../api';
import {
EntityFilter,
EntityKindFilter,
EntityLifecycleFilter,
EntityOwnerFilter,
EntityTagFilter,
EntityTypeFilter,
UserListFilter,
@@ -39,6 +41,8 @@ export type DefaultEntityFilters = {
kind?: EntityKindFilter;
type?: EntityTypeFilter;
user?: UserListFilter;
owners?: EntityOwnerFilter;
lifecycles?: EntityLifecycleFilter;
tags?: EntityTagFilter;
};
+27 -2
View File
@@ -14,8 +14,13 @@
* limitations under the License.
*/
import { Entity, UserEntity } from '@backstage/catalog-model';
import { isOwnerOf } from './utils';
import {
Entity,
RELATION_OWNED_BY,
UserEntity,
} from '@backstage/catalog-model';
import { getEntityRelations, isOwnerOf } from './utils';
import { formatEntityRefTitle } from './components/EntityRefLink';
export type EntityFilter = {
/**
@@ -61,6 +66,26 @@ export class EntityTagFilter implements EntityFilter {
}
}
export class EntityOwnerFilter implements EntityFilter {
constructor(readonly values: string[]) {}
filterEntity(entity: Entity): boolean {
return this.values.some(v =>
getEntityRelations(entity, RELATION_OWNED_BY).some(
o => formatEntityRefTitle(o, { defaultKind: 'group' }) === v,
),
);
}
}
export class EntityLifecycleFilter implements EntityFilter {
constructor(readonly values: string[]) {}
filterEntity(entity: Entity): boolean {
return this.values.some(v => entity.spec?.lifecycle === v);
}
}
export type UserListFilterKind = 'owned' | 'starred' | 'all';
export class UserListFilter implements EntityFilter {
constructor(
@@ -24,7 +24,9 @@ import {
} from '@backstage/core';
import {
EntityKindPicker,
EntityLifecyclePicker,
EntityListProvider,
EntityOwnerPicker,
EntityTagPicker,
EntityTypePicker,
UserListFilterKind,
@@ -72,6 +74,8 @@ export const CatalogPage = ({
<EntityKindPicker initialFilter="component" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
<EntityLifecyclePicker />
<EntityTagPicker />
</div>
<CatalogTable columns={columns} />
@@ -15,3 +15,4 @@
*/
export { CatalogTable } from './CatalogTable';
export type { EntityRow } from './types';
+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 as CatalogTableRow } from './components/CatalogTable';
export { CreateComponentButton } from './components/CreateComponentButton';
export { EntityLayout } from './components/EntityLayout';
export * from './components/EntityOrphanWarning';