From dd696296255d8e232054b65054c90a9e6c538bfa Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Mon, 21 Jun 2021 11:28:09 -0600 Subject: [PATCH 1/2] Use query params with useEntityListProvider Signed-off-by: Tim Hansen --- .../EntityKindPicker/EntityKindPicker.tsx | 7 +++++-- plugins/catalog-react/src/filters.ts | 4 ++++ .../src/hooks/useEntityListProvider.tsx | 21 +++++++++++++++++++ plugins/catalog-react/src/types.ts | 7 +++++++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index 92831ea252..5b3f95b7ba 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -28,8 +28,11 @@ export const EntityKindPicker = ({ initialFilter, hidden, }: EntityKindFilterProps) => { - const [selectedKind] = useState(initialFilter); - const { updateFilters } = useEntityListProvider(); + const { updateFilters, queryParameters } = useEntityListProvider(); + const [selectedKind] = useState( + // TODO Cast here is not great 🤔 + (queryParameters.kind as string) ?? initialFilter, + ); useEffect(() => { updateFilters({ diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts index 9b57e0f560..2b23031cc9 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -29,6 +29,10 @@ export class EntityKindFilter implements EntityFilter { getCatalogFilters(): Record { return { kind: this.value }; } + + toQueryValue(): string { + return this.value; + } } export class EntityTypeFilter implements EntityFilter { diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx index 33c951acce..253184a851 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx @@ -77,6 +77,11 @@ export type EntityListContextProps< | ((prevFilters: EntityFilters) => Partial), ) => void; + /** + * Filter values from query parameters. + */ + queryParameters: Record; + loading: boolean; error?: Error; }; @@ -89,6 +94,7 @@ type OutputState = { appliedFilters: EntityFilters; entities: Entity[]; backendEntities: Entity[]; + queryParameters: Record; }; export const EntityListProvider = ({ @@ -102,6 +108,7 @@ export const EntityListProvider = ({ appliedFilters: {} as EntityFilters, entities: [], backendEntities: [], + queryParameters: {}, // TODO: Load (once!!) from query parameters }); // The main async filter worker. Note that while it has a lot of dependencies @@ -116,6 +123,15 @@ export const EntityListProvider = ({ compact(Object.values(outputState.appliedFilters)), ); + const queryParams = Object.keys(requestedFilters).reduce((params, key) => { + const filter: EntityFilter | undefined = + requestedFilters[key as keyof EntityFilters]; + if (filter?.toQueryValue) { + params[key] = filter.toQueryValue(); + } + return params; + }, {} as Record); + // TODO(mtlewis): currently entities will never be requested unless // there's at least one filter, we should allow an initial request // to happen with no filters. @@ -129,14 +145,18 @@ export const EntityListProvider = ({ appliedFilters: requestedFilters, backendEntities: response.items, entities: response.items.filter(entityFilter), + queryParameters: queryParams, }); } else { setOutputState({ appliedFilters: requestedFilters, backendEntities: outputState.backendEntities, entities: outputState.backendEntities.filter(entityFilter), + queryParameters: queryParams, }); } + + // TODO: write queryParams to query string }, [catalogApi, requestedFilters, outputState], { loading: true }, @@ -168,6 +188,7 @@ export const EntityListProvider = ({ entities: outputState.entities, backendEntities: outputState.backendEntities, updateFilters, + queryParameters: outputState.queryParameters, loading, error, }} diff --git a/plugins/catalog-react/src/types.ts b/plugins/catalog-react/src/types.ts index 68c11d7f38..4ac7644ee1 100644 --- a/plugins/catalog-react/src/types.ts +++ b/plugins/catalog-react/src/types.ts @@ -34,6 +34,13 @@ export type EntityFilter = { * @param env */ filterEntity?: (entity: Entity) => boolean; + + /** + * Serialize the filter value to a string for query params. The UI component responsible for + * handling this filter should retrieve this from useEntityListProvider.queryParameters. The + * value restored should be in the precedence: queryParameters > initialValue prop > default. + */ + toQueryValue?: () => string | string[]; }; export type UserListFilterKind = 'owned' | 'starred' | 'all'; From d84778c257b0b4ca1263cb9ada9d070d59d12e53 Mon Sep 17 00:00:00 2001 From: Phil Kuang Date: Tue, 6 Jul 2021 12:31:00 -0400 Subject: [PATCH 2/2] feat(EntityListProvider): store filter values in query params Signed-off-by: Phil Kuang --- .changeset/curly-badgers-sit.md | 5 ++ plugins/catalog-react/package.json | 1 + .../EntityKindPicker.test.tsx | 19 ++++++ .../EntityKindPicker/EntityKindPicker.tsx | 3 +- .../EntityLifecyclePicker.test.tsx | 29 ++++++++- .../EntityLifecyclePicker.tsx | 39 ++++++++---- .../EntityOwnerPicker.test.tsx | 29 ++++++++- .../EntityOwnerPicker/EntityOwnerPicker.tsx | 35 ++++++++--- .../EntityTagPicker/EntityTagPicker.test.tsx | 29 ++++++++- .../EntityTagPicker/EntityTagPicker.tsx | 33 +++++++--- .../EntityTypePicker/EntityTypePicker.tsx | 2 +- .../UserListPicker/UserListPicker.test.tsx | 18 ++++++ .../UserListPicker/UserListPicker.tsx | 19 ++++-- plugins/catalog-react/src/filters.ts | 20 ++++++ .../src/hooks/useEntityListProvider.test.tsx | 43 ++++++++++--- .../src/hooks/useEntityListProvider.tsx | 35 +++++++---- .../src/hooks/useEntityTypeFilter.tsx | 62 ++++++++++--------- .../catalog-react/src/testUtils/providers.tsx | 1 + 18 files changed, 330 insertions(+), 92 deletions(-) create mode 100644 .changeset/curly-badgers-sit.md 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 }) => ( {