Merge pull request #17888 from backstage/vinzscam/decouple-entity-lifecycle-picker

EntityLifecyclePicker: loads data using the facets endpoint
This commit is contained in:
Patrik Oldsberg
2023-05-24 11:35:02 +02:00
committed by GitHub
6 changed files with 161 additions and 244 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
`EntityAutocompletePicker` add `initialSelectedOptions` prop
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
`EntityLifecycleFilter` loads data using the facets endpoint
+1 -1
View File
@@ -195,7 +195,7 @@ export class EntityLifecycleFilter implements EntityFilter {
// @public (undocumented)
export const EntityLifecyclePicker: (props: {
initialFilter?: string[];
}) => JSX.Element | null;
}) => JSX.Element;
// @public
export const EntityListContext: React_2.Context<
@@ -54,6 +54,7 @@ export type EntityAutocompletePickerProps<
showCounts?: boolean;
Filter: ConstructableFilter<NonNullable<T[Name]>>;
InputProps?: TextFieldProps;
initialSelectedOptions?: string[];
};
/** @public */
@@ -61,7 +62,15 @@ export function EntityAutocompletePicker<
T extends DefaultEntityFilters = DefaultEntityFilters,
Name extends AllowedEntityFilters<T> = AllowedEntityFilters<T>,
>(props: EntityAutocompletePickerProps<T, Name>) {
const { label, name, path, showCounts, Filter, InputProps } = props;
const {
label,
name,
path,
showCounts,
Filter,
InputProps,
initialSelectedOptions = [],
} = props;
const {
updateFilters,
@@ -90,7 +99,8 @@ export function EntityAutocompletePicker<
const [selectedOptions, setSelectedOptions] = useState(
queryParameters.length
? queryParameters
: (filters[name] as unknown as { values: string[] })?.values ?? [],
: (filters[name] as unknown as { values: string[] })?.values ??
initialSelectedOptions,
);
// Set selected options on query parameter updates; this happens at initial page load and from
@@ -14,146 +14,116 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { EntityLifecycleFilter } from '../../filters';
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',
},
},
];
import { TestApiProvider } from '@backstage/test-utils';
import { catalogApiRef } from '../../api';
import { CatalogApi } from '@backstage/catalog-client';
describe('<EntityLifecyclePicker/>', () => {
it('renders all lifecycles', () => {
render(
<MockEntityListContextProvider
value={{ entities: sampleEntities, backendEntities: sampleEntities }}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(screen.getByText('Lifecycle')).toBeInTheDocument();
const catalogApi = {
getEntityFacets: jest.fn(),
} as unknown as jest.Mocked<CatalogApi>;
fireEvent.click(screen.getByTestId('lifecycle-picker-expand'));
sampleEntities
.map(e => e.spec?.lifecycle!)
.forEach(lifecycle => {
expect(screen.getByText(lifecycle as string)).toBeInTheDocument();
});
beforeEach(() => {
catalogApi.getEntityFacets.mockResolvedValue({
facets: {
'spec.lifecycle': [
{ count: 1, value: 'experimental' },
{ count: 1, value: 'production' },
],
},
});
});
it('renders unique lifecycles in alphabetical order', () => {
render(
<MockEntityListContextProvider
value={{ entities: sampleEntities, backendEntities: sampleEntities }}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(screen.getByText('Lifecycle')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('lifecycle-picker-expand'));
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
'experimental',
'production',
]);
afterEach(() => {
jest.resetAllMocks();
});
it('respects the query parameter filter value', () => {
it('renders all lifecycles', async () => {
render(
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<MockEntityListContextProvider value={{}}>
<EntityLifecyclePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
expect(await screen.findByText('Lifecycle')).toBeInTheDocument();
fireEvent.click(await screen.findByTestId('lifecycles-picker-expand'));
expect(screen.getByText('experimental')).toBeInTheDocument();
expect(screen.getByText('production')).toBeInTheDocument();
});
it('respects the query parameter filter value', async () => {
const updateFilters = jest.fn();
const queryParameters = { lifecycles: ['experimental'] };
render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
queryParameters,
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters,
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() => expect(catalogApi.getEntityFacets).toHaveBeenCalled());
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['experimental']),
});
});
it('adds lifecycles to filters', () => {
it('adds lifecycles to filters', async () => {
const updateFilters = jest.fn();
render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<MockEntityListContextProvider
value={{
updateFilters,
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: undefined,
});
fireEvent.click(screen.getByTestId('lifecycle-picker-expand'));
fireEvent.click(await screen.findByTestId('lifecycles-picker-expand'));
fireEvent.click(screen.getByText('production'));
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['production']),
});
});
it('removes lifecycles from filters', () => {
it('removes lifecycles from filters', async () => {
const updateFilters = jest.fn();
render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
filters: { lifecycles: new EntityLifecycleFilter(['production']) },
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<MockEntityListContextProvider
value={{
updateFilters,
filters: { lifecycles: new EntityLifecycleFilter(['production']) },
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() => expect(catalogApi.getEntityFacets).toHaveBeenCalled());
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['production']),
});
fireEvent.click(screen.getByTestId('lifecycle-picker-expand'));
fireEvent.click(screen.getByTestId('lifecycles-picker-expand'));
expect(screen.getByLabelText('production')).toBeChecked();
fireEvent.click(screen.getByLabelText('production'));
@@ -162,67 +132,85 @@ describe('<EntityLifecyclePicker/>', () => {
});
});
it('responds to external queryParameters changes', () => {
it('responds to external queryParameters changes', async () => {
const updateFilters = jest.fn();
const rendered = render(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { lifecycles: ['experimental'] },
backendEntities: sampleEntities,
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { lifecycles: ['experimental'] },
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() => expect(catalogApi.getEntityFacets).toHaveBeenCalled());
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['experimental']),
});
rendered.rerender(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { lifecycles: ['production'] },
backendEntities: sampleEntities,
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { lifecycles: ['production'] },
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['production']),
});
});
it('removes lifecycles from filters if there are no available lifecycles', () => {
it('removes lifecycles from filters if there are no available lifecycles', async () => {
catalogApi.getEntityFacets.mockResolvedValue({
facets: {
'spec.lifecycle': [],
},
});
const updateFilters = jest.fn();
render(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { lifecycles: ['experimental'] },
backendEntities: [],
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { lifecycles: ['experimental'] },
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() => expect(catalogApi.getEntityFacets).toHaveBeenCalled());
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: undefined,
});
});
it('responds to initialFilter prop', () => {
it('responds to initialFilter prop', async () => {
const updateFilters = jest.fn();
render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
}}
>
<EntityLifecyclePicker initialFilter={['production']} />
</MockEntityListContextProvider>,
<TestApiProvider apis={[[catalogApiRef, catalogApi]]}>
<MockEntityListContextProvider
value={{
updateFilters,
}}
>
<EntityLifecyclePicker initialFilter={['production']} />
</MockEntityListContextProvider>
</TestApiProvider>,
);
await waitFor(() => expect(catalogApi.getEntityFacets).toHaveBeenCalled());
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['production']),
});
@@ -14,22 +14,10 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
Box,
Checkbox,
FormControlLabel,
makeStyles,
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, { useEffect, useMemo, useState } from 'react';
import { useEntityList } from '../../hooks/useEntityListProvider';
import { makeStyles } from '@material-ui/core';
import React from 'react';
import { EntityLifecycleFilter } from '../../filters';
import { EntityAutocompletePicker } from '../EntityAutocompletePicker';
/** @public */
export type CatalogReactEntityLifecyclePickerClassKey = 'input';
@@ -43,98 +31,19 @@ const useStyles = makeStyles(
},
);
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
/** @public */
export const EntityLifecyclePicker = (props: { initialFilter?: string[] }) => {
const { initialFilter = [] } = props;
const classes = useStyles();
const {
updateFilters,
backendEntities,
filters,
queryParameters: { lifecycles: lifecyclesParameter },
} = useEntityList();
const queryParamLifecycles = useMemo(
() => [lifecyclesParameter].flat().filter(Boolean) as string[],
[lifecyclesParameter],
);
const [selectedLifecycles, setSelectedLifecycles] = useState(
queryParamLifecycles.length
? queryParamLifecycles
: filters.lifecycles?.values ?? initialFilter,
);
// Set selected lifecycles on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamLifecycles.length) {
setSelectedLifecycles(queryParamLifecycles);
}
}, [queryParamLifecycles]);
const availableLifecycles = useMemo(
() =>
[
...new Set(
backendEntities
.map((e: Entity) => e.spec?.lifecycle)
.filter(Boolean) as string[],
),
].sort(),
[backendEntities],
);
useEffect(() => {
updateFilters({
lifecycles:
selectedLifecycles.length && availableLifecycles.length
? new EntityLifecycleFilter(selectedLifecycles)
: undefined,
});
}, [selectedLifecycles, updateFilters, availableLifecycles]);
if (!availableLifecycles.length) return null;
return (
<Box pb={1} pt={1}>
<Typography variant="button" component="label">
Lifecycle
<Autocomplete
multiple
disableCloseOnSelect
options={availableLifecycles}
value={selectedLifecycles}
onChange={(_: object, value: string[]) =>
setSelectedLifecycles(value)
}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
onClick={event => event.preventDefault()}
label={option}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="lifecycle-picker-expand" />}
renderInput={params => (
<TextField
{...params}
className={classes.input}
variant="outlined"
/>
)}
/>
</Typography>
</Box>
<EntityAutocompletePicker
label="Lifecycle"
name="lifecycles"
path="spec.lifecycle"
Filter={EntityLifecycleFilter}
InputProps={{ className: classes.input }}
initialSelectedOptions={initialFilter}
/>
);
};