diff --git a/.changeset/curly-badgers-sit.md b/.changeset/curly-badgers-sit.md new file mode 100644 index 0000000000..ca740e907a --- /dev/null +++ b/.changeset/curly-badgers-sit.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Store filter values set in `EntityListProvider` in query parameters. This allows selected filters to be restored when returning to pages that list catalog entities. diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 99552cdb59..c65e06df29 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -39,6 +39,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "@types/react": "^16.9", "lodash": "^4.17.15", + "qs": "^6.9.4", "react": "^16.13.1", "react-router": "6.0.0-beta.0", "react-router-dom": "6.0.0-beta.0", diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index 417b085cce..d6404fcf07 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -37,4 +37,23 @@ describe('', () => { kind: new EntityKindFilter('component'), }); }); + + it('respects the query parameter filter value', () => { + const updateFilters = jest.fn(); + const queryParameters = { kind: 'API' }; + render( + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + kind: new EntityKindFilter('API'), + }); + }); }); diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index 5b3f95b7ba..6f9119c404 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -30,8 +30,7 @@ export const EntityKindPicker = ({ }: EntityKindFilterProps) => { const { updateFilters, queryParameters } = useEntityListProvider(); const [selectedKind] = useState( - // TODO Cast here is not great 🤔 - (queryParameters.kind as string) ?? initialFilter, + [queryParameters.kind].flat()[0] ?? initialFilter, ); useEffect(() => { diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx index 8fa0789c10..f7d915ef5b 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx @@ -91,6 +91,27 @@ describe('', () => { ]); }); + it('respects the query parameter filter value', () => { + const updateFilters = jest.fn(); + const queryParameters = { lifecycles: ['experimental'] }; + render( + + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + lifecycles: new EntityLifecycleFilter(['experimental']), + }); + }); + it('adds lifecycles to filters', () => { const updateFilters = jest.fn(); const rendered = render( @@ -104,7 +125,9 @@ describe('', () => { , ); - expect(updateFilters).not.toHaveBeenCalled(); + expect(updateFilters).toHaveBeenLastCalledWith({ + lifecycles: undefined, + }); fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); fireEvent.click(rendered.getByText('production')); @@ -127,7 +150,9 @@ describe('', () => { , ); - expect(updateFilters).not.toHaveBeenCalled(); + expect(updateFilters).toHaveBeenLastCalledWith({ + lifecycles: new EntityLifecycleFilter(['production']), + }); fireEvent.click(rendered.getByTestId('lifecycle-picker-expand')); expect(rendered.getByLabelText('production')).toBeChecked(); diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx index 31ab2911f7..9a899e161d 100644 --- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx +++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx @@ -26,7 +26,7 @@ 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 React, { useEffect, useMemo, useState } from 'react'; import { useEntityListProvider } from '../../hooks/useEntityListProvider'; import { EntityLifecycleFilter } from '../../filters'; @@ -34,7 +34,30 @@ const icon = ; const checkedIcon = ; export const EntityLifecyclePicker = () => { - const { updateFilters, backendEntities, filters } = useEntityListProvider(); + const { + updateFilters, + backendEntities, + filters, + queryParameters, + } = useEntityListProvider(); + + const queryParamLifecycles = [queryParameters.lifecycles] + .flat() + .filter(Boolean) as string[]; + const [selectedLifecycles, setSelectedLifecycles] = useState( + queryParamLifecycles.length + ? queryParamLifecycles + : filters.lifecycles?.values ?? [], + ); + + useEffect(() => { + updateFilters({ + lifecycles: selectedLifecycles.length + ? new EntityLifecycleFilter(selectedLifecycles) + : undefined, + }); + }, [selectedLifecycles, updateFilters]); + const availableLifecycles = useMemo( () => [ @@ -49,22 +72,14 @@ export const EntityLifecyclePicker = () => { if (!availableLifecycles.length) return null; - const onChange = (lifecycles: string[]) => { - updateFilters({ - lifecycles: lifecycles.length - ? new EntityLifecycleFilter(lifecycles) - : undefined, - }); - }; - return ( Lifecycle multiple options={availableLifecycles} - value={filters.lifecycles?.values ?? []} - onChange={(_: object, value: string[]) => onChange(value)} + value={selectedLifecycles} + onChange={(_: object, value: string[]) => setSelectedLifecycles(value)} renderOption={(option, { selected }) => ( ', () => { ]); }); + it('respects the query parameter filter value', () => { + const updateFilters = jest.fn(); + const queryParameters = { owners: ['another-owner'] }; + render( + + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['another-owner']), + }); + }); + it('adds owners to filters', () => { const updateFilters = jest.fn(); const rendered = render( @@ -134,7 +155,9 @@ describe('', () => { , ); - expect(updateFilters).not.toHaveBeenCalled(); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: undefined, + }); fireEvent.click(rendered.getByTestId('owner-picker-expand')); fireEvent.click(rendered.getByText('some-owner')); @@ -157,7 +180,9 @@ describe('', () => { , ); - expect(updateFilters).not.toHaveBeenCalled(); + expect(updateFilters).toHaveBeenLastCalledWith({ + owners: new EntityOwnerFilter(['some-owner']), + }); fireEvent.click(rendered.getByTestId('owner-picker-expand')); expect(rendered.getByLabelText('some-owner')).toBeChecked(); diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index 955f917c8c..90d94e32b5 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -26,7 +26,7 @@ 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 React, { useEffect, useMemo, useState } from 'react'; import { useEntityListProvider } from '../../hooks/useEntityListProvider'; import { EntityOwnerFilter } from '../../filters'; import { getEntityRelations } from '../../utils'; @@ -36,7 +36,28 @@ const icon = ; const checkedIcon = ; export const EntityOwnerPicker = () => { - const { updateFilters, backendEntities, filters } = useEntityListProvider(); + const { + updateFilters, + backendEntities, + filters, + queryParameters, + } = useEntityListProvider(); + + const queryParamOwners = [queryParameters.owners] + .flat() + .filter(Boolean) as string[]; + const [selectedOwners, setSelectedOwners] = useState( + queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [], + ); + + useEffect(() => { + updateFilters({ + owners: selectedOwners.length + ? new EntityOwnerFilter(selectedOwners) + : undefined, + }); + }, [selectedOwners, updateFilters]); + const availableOwners = useMemo( () => [ @@ -55,20 +76,14 @@ export const EntityOwnerPicker = () => { if (!availableOwners.length) return null; - const onChange = (owners: string[]) => { - updateFilters({ - owners: owners.length ? new EntityOwnerFilter(owners) : undefined, - }); - }; - return ( Owner multiple options={availableOwners} - value={filters.owners?.values ?? []} - onChange={(_: object, value: string[]) => onChange(value)} + value={selectedOwners} + onChange={(_: object, value: string[]) => setSelectedOwners(value)} renderOption={(option, { selected }) => ( ', () => { ]); }); + it('respects the query parameter filter value', () => { + const updateFilters = jest.fn(); + const queryParameters = { tags: ['tag3'] }; + render( + + + , + ); + + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag3']), + }); + }); + it('adds tags to filters', () => { const updateFilters = jest.fn(); const rendered = render( @@ -92,7 +113,9 @@ describe('', () => { , ); - expect(updateFilters).not.toHaveBeenCalled(); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: undefined, + }); fireEvent.click(rendered.getByTestId('tag-picker-expand')); fireEvent.click(rendered.getByText('tag1')); @@ -115,7 +138,9 @@ describe('', () => { , ); - expect(updateFilters).not.toHaveBeenCalled(); + expect(updateFilters).toHaveBeenLastCalledWith({ + tags: new EntityTagFilter(['tag1']), + }); fireEvent.click(rendered.getByTestId('tag-picker-expand')); expect(rendered.getByLabelText('tag1')).toBeChecked(); diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx index 744ad447d8..b7fa8f1186 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -26,7 +26,7 @@ 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 React, { useEffect, useMemo, useState } from 'react'; import { useEntityListProvider } from '../../hooks/useEntityListProvider'; import { EntityTagFilter } from '../../filters'; @@ -34,7 +34,26 @@ const icon = ; const checkedIcon = ; export const EntityTagPicker = () => { - const { updateFilters, backendEntities, filters } = useEntityListProvider(); + const { + updateFilters, + backendEntities, + filters, + queryParameters, + } = useEntityListProvider(); + + const queryParamTags = [queryParameters.tags] + .flat() + .filter(Boolean) as string[]; + const [selectedTags, setSelectedTags] = useState( + queryParamTags.length ? queryParamTags : filters.tags?.values ?? [], + ); + + useEffect(() => { + updateFilters({ + tags: selectedTags.length ? new EntityTagFilter(selectedTags) : undefined, + }); + }, [selectedTags, updateFilters]); + const availableTags = useMemo( () => [ @@ -49,20 +68,14 @@ export const EntityTagPicker = () => { if (!availableTags.length) return null; - const onChange = (tags: string[]) => { - updateFilters({ - tags: tags.length ? new EntityTagFilter(tags) : undefined, - }); - }; - return ( Tags multiple options={availableTags} - value={filters.tags?.values ?? []} - onChange={(_: object, value: string[]) => onChange(value)} + value={selectedTags} + onChange={(_: object, value: string[]) => setSelectedTags(value)} renderOption={(option, { selected }) => ( {