feat(pickers): implement EntityLifecyclePicker and EntityOwnerPicker

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2021-06-10 13:00:43 -04:00
parent 2ebc430c4d
commit 172c973247
11 changed files with 511 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Implement a `EntityLifecyclePicker` and `EntityOwnerPicker`
@@ -22,7 +22,9 @@ import {
} from '@backstage/core';
import {
EntityKindPicker,
EntityLifecyclePicker,
EntityListProvider,
EntityOwnerPicker,
EntityTagPicker,
EntityTypePicker,
UserListFilterKind,
@@ -71,6 +73,8 @@ export const ApiExplorerPage = ({
<EntityKindPicker initialFilter="api" hidden />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
<EntityLifecyclePicker />
<EntityTagPicker />
</div>
<CatalogTable columns={columns} />
@@ -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,85 @@
/*
* 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 {
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 (
<>
<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" />}
/>
</>
);
};
@@ -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,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 { EntityOwnerFilter } from '../../types';
import { EntityOwnerPicker } from './EntityOwnerPicker';
const sampleEntities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-1',
},
spec: {
owner: 'some-owner',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-2',
},
spec: {
owner: 'another-owner',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component-3',
},
spec: {
owner: 'some-owner',
},
},
];
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
.map(e => e.spec?.owner!)
.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',
]);
});
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,83 @@
/*
* 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 {
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';
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
export const EntityOwnerPicker = () => {
const { updateFilters, backendEntities, filters } = useEntityListProvider();
const availableOwners = useMemo(
() =>
[
...new Set(
backendEntities
.map((e: Entity) => e.spec?.owner)
.filter(Boolean) as string[],
),
].sort(),
[backendEntities],
);
if (!availableOwners.length) return null;
const onChange = (owners: string[]) => {
updateFilters({
owners: owners.length ? new EntityOwnerFilter(owners) : undefined,
});
};
return (
<>
<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" />}
/>
</>
);
};
@@ -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 { EntityOwnerPicker } from './EntityOwnerPicker';
@@ -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;
};
+16
View File
@@ -61,6 +61,22 @@ export class EntityTagFilter implements EntityFilter {
}
}
export class EntityOwnerFilter implements EntityFilter {
constructor(readonly values: string[]) {}
filterEntity(entity: Entity): boolean {
return this.values.some(v => entity.spec?.owner === 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(