feat(EntityListProvider): store filter values in query params

Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
Phil Kuang
2021-07-06 12:31:00 -04:00
parent dd69629625
commit d84778c257
18 changed files with 330 additions and 92 deletions
+5
View File
@@ -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.
+1
View File
@@ -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",
@@ -37,4 +37,23 @@ describe('<EntityKindPicker/>', () => {
kind: new EntityKindFilter('component'),
});
});
it('respects the query parameter filter value', () => {
const updateFilters = jest.fn();
const queryParameters = { kind: 'API' };
render(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters,
}}
>
<EntityKindPicker initialFilter="component" hidden />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('API'),
});
});
});
@@ -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(() => {
@@ -91,6 +91,27 @@ describe('<EntityLifecyclePicker/>', () => {
]);
});
it('respects the query parameter filter value', () => {
const updateFilters = jest.fn();
const queryParameters = { lifecycles: ['experimental'] };
render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
queryParameters,
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['experimental']),
});
});
it('adds lifecycles to filters', () => {
const updateFilters = jest.fn();
const rendered = render(
@@ -104,7 +125,9 @@ describe('<EntityLifecyclePicker/>', () => {
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
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('<EntityLifecyclePicker/>', () => {
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).not.toHaveBeenCalled();
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['production']),
});
fireEvent.click(rendered.getByTestId('lifecycle-picker-expand'));
expect(rendered.getByLabelText('production')).toBeChecked();
@@ -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 = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
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 (
<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)}
value={selectedLifecycles}
onChange={(_: object, value: string[]) => setSelectedLifecycles(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
@@ -121,6 +121,27 @@ describe('<EntityOwnerPicker/>', () => {
]);
});
it('respects the query parameter filter value', () => {
const updateFilters = jest.fn();
const queryParameters = { owners: ['another-owner'] };
render(
<MockEntityListContextProvider
value={{
entities: sampleEntities,
backendEntities: sampleEntities,
updateFilters,
queryParameters,
}}
>
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
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('<EntityOwnerPicker/>', () => {
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
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('<EntityOwnerPicker/>', () => {
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
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();
@@ -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 = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
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 (
<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)}
value={selectedOwners}
onChange={(_: object, value: string[]) => setSelectedOwners(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
@@ -79,6 +79,27 @@ describe('<EntityTagPicker/>', () => {
]);
});
it('respects the query parameter filter value', () => {
const updateFilters = jest.fn();
const queryParameters = { tags: ['tag3'] };
render(
<MockEntityListContextProvider
value={{
entities: taggedEntities,
backendEntities: taggedEntities,
updateFilters,
queryParameters,
}}
>
<EntityTagPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
tags: new EntityTagFilter(['tag3']),
});
});
it('adds tags to filters', () => {
const updateFilters = jest.fn();
const rendered = render(
@@ -92,7 +113,9 @@ describe('<EntityTagPicker/>', () => {
<EntityTagPicker />
</MockEntityListContextProvider>,
);
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('<EntityTagPicker/>', () => {
<EntityTagPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).not.toHaveBeenCalled();
expect(updateFilters).toHaveBeenLastCalledWith({
tags: new EntityTagFilter(['tag1']),
});
fireEvent.click(rendered.getByTestId('tag-picker-expand'));
expect(rendered.getByLabelText('tag1')).toBeChecked();
@@ -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 = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
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 (
<Box pb={1} pt={1}>
<Typography variant="button">Tags</Typography>
<Autocomplete<string>
multiple
options={availableTags}
value={filters.tags?.values ?? []}
onChange={(_: object, value: string[]) => onChange(value)}
value={selectedTags}
onChange={(_: object, value: string[]) => setSelectedTags(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
@@ -55,7 +55,7 @@ export const EntityTypePicker = () => {
<Select
label="Type"
items={items}
selected={selectedTypes.length ? selectedTypes[0] : 'all'}
selected={(items.length > 1 ? selectedTypes[0] : undefined) ?? 'all'}
onChange={value =>
setSelectedTypes(value === 'all' ? [] : [String(value)])
}
@@ -200,6 +200,24 @@ describe('<UserListPicker />', () => {
).toEqual(['1', '0', '2']);
});
it('respects the query parameter filter value', () => {
const updateFilters = jest.fn();
const queryParameters = { user: 'owned' };
render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ backendEntities, updateFilters, queryParameters }}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockUser, mockIsStarredEntity),
});
});
it('updates user filter when a menuitem is selected', () => {
const updateFilters = jest.fn();
const { getByText } = render(
@@ -129,9 +129,18 @@ export const UserListPicker = ({
}))
.filter(({ items }) => !!items.length);
const {
filters,
updateFilters,
backendEntities,
queryParameters,
} = useEntityListProvider();
const { value: user } = useOwnUser();
const { isStarredEntity } = useStarredEntities();
const [selectedUserFilter, setSelectedUserFilter] = useState(initialFilter);
const [selectedUserFilter, setSelectedUserFilter] = useState(
[queryParameters.user].flat()[0] ?? initialFilter,
);
// Static filters; used for generating counts of potentially unselected kinds
const ownedFilter = useMemo(
@@ -143,12 +152,14 @@ export const UserListPicker = ({
[user, isStarredEntity],
);
const { filters, updateFilters, backendEntities } = useEntityListProvider();
useEffect(() => {
updateFilters({
user: selectedUserFilter
? new UserListFilter(selectedUserFilter, user, isStarredEntity)
? new UserListFilter(
selectedUserFilter as UserListFilterKind,
user,
isStarredEntity,
)
: undefined,
});
}, [selectedUserFilter, user, isStarredEntity, updateFilters]);
+20
View File
@@ -46,6 +46,10 @@ export class EntityTypeFilter implements EntityFilter {
getCatalogFilters(): Record<string, string | string[]> {
return { 'spec.type': this.getTypes() };
}
toQueryValue(): string[] {
return this.getTypes();
}
}
export class EntityTagFilter implements EntityFilter {
@@ -54,6 +58,10 @@ export class EntityTagFilter implements EntityFilter {
filterEntity(entity: Entity): boolean {
return this.values.every(v => (entity.metadata.tags ?? []).includes(v));
}
toQueryValue(): string[] {
return this.values;
}
}
export class EntityTextFilter implements EntityFilter {
@@ -87,6 +95,10 @@ export class EntityOwnerFilter implements EntityFilter {
),
);
}
toQueryValue(): string[] {
return this.values;
}
}
export class EntityLifecycleFilter implements EntityFilter {
@@ -95,6 +107,10 @@ export class EntityLifecycleFilter implements EntityFilter {
filterEntity(entity: Entity): boolean {
return this.values.some(v => entity.spec?.lifecycle === v);
}
toQueryValue(): string[] {
return this.values;
}
}
export class UserListFilter implements EntityFilter {
@@ -114,4 +130,8 @@ export class UserListFilter implements EntityFilter {
return true;
}
}
toQueryValue(): string {
return this.value;
}
}
@@ -15,6 +15,8 @@
*/
import React, { PropsWithChildren } from 'react';
import qs from 'qs';
import { MemoryRouter as Router } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react-hooks';
import { MockStorageApi } from '@backstage/test-utils';
import { CatalogApi } from '@backstage/catalog-client';
@@ -96,16 +98,22 @@ const apis = ApiRegistry.from([
const wrapper = ({
userFilter,
queryParams,
children,
}: PropsWithChildren<{ userFilter: UserListFilterKind }>) => {
}: PropsWithChildren<{
userFilter?: UserListFilterKind;
queryParams?: string;
}>) => {
return (
<ApiProvider apis={apis}>
<EntityListProvider>
<EntityKindPicker initialFilter="component" hidden />
<UserListPicker initialFilter={userFilter} />
{children}
</EntityListProvider>
</ApiProvider>
<Router initialEntries={[`/?${queryParams ?? ''}`]}>
<ApiProvider apis={apis}>
<EntityListProvider>
<EntityKindPicker initialFilter="component" hidden />
<UserListPicker initialFilter={userFilter} />
{children}
</EntityListProvider>
</ApiProvider>
</Router>
);
};
@@ -140,6 +148,25 @@ describe('<EntityListProvider/>', () => {
expect(result.current.entities.length).toBe(1);
});
it('resolves query param filter values', async () => {
const { result, waitFor } = renderHook(() => useEntityListProvider(), {
wrapper,
initialProps: {
queryParams: qs.stringify({
filters: {
kind: 'component',
type: 'service',
},
}),
},
});
await waitFor(() => !!result.current.queryParameters);
expect(result.current.queryParameters).toEqual({
kind: 'component',
type: 'service',
});
});
it('does not fetch when only frontend filters change', async () => {
const { result, waitFor } = renderHook(() => useEntityListProvider(), {
wrapper,
@@ -16,6 +16,7 @@
import { Entity } from '@backstage/catalog-model';
import { compact, isEqual } from 'lodash';
import qs from 'qs';
import React, {
createContext,
PropsWithChildren,
@@ -23,6 +24,7 @@ import React, {
useContext,
useState,
} from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAsyncFn, useDebounce } from 'react-use';
import { catalogApiRef } from '../api';
import {
@@ -80,7 +82,7 @@ export type EntityListContextProps<
/**
* Filter values from query parameters.
*/
queryParameters: Record<keyof EntityFilters, string | string[]>;
queryParameters: Partial<Record<keyof EntityFilters, string | string[]>>;
loading: boolean;
error?: Error;
@@ -101,6 +103,8 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
children,
}: PropsWithChildren<{}>) => {
const catalogApi = useApi(catalogApiRef);
const [searchParams, setSearchParams] = useSearchParams();
const allQueryParams = qs.parse(searchParams.toString());
const [requestedFilters, setRequestedFilters] = useState<EntityFilters>(
{} as EntityFilters,
);
@@ -108,7 +112,8 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
appliedFilters: {} as EntityFilters,
entities: [],
backendEntities: [],
queryParameters: {}, // TODO: Load (once!!) from query parameters
queryParameters:
(allQueryParams.filters as Record<string, string | string[]>) ?? {},
});
// The main async filter worker. Note that while it has a lot of dependencies
@@ -123,14 +128,17 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
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<string, string | string[]>);
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<string, string | string[]>,
);
// TODO(mtlewis): currently entities will never be requested unless
// there's at least one filter, we should allow an initial request
@@ -156,7 +164,12 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
});
}
// TODO: write queryParams to query string
setSearchParams(
qs.stringify({ ...allQueryParams, filters: queryParams }),
{
replace: true,
},
);
},
[catalogApi, requestedFilters, outputState],
{ loading: true },
@@ -14,14 +14,11 @@
* limitations under the License.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useAsync } from 'react-use';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '../api';
import {
DefaultEntityFilters,
useEntityListProvider,
} from './useEntityListProvider';
import { useEntityListProvider } from './useEntityListProvider';
import { EntityTypeFilter } from '../filters';
type EntityTypeReturn = {
@@ -40,9 +37,17 @@ export function useEntityTypeFilter(): EntityTypeReturn {
const catalogApi = useApi(catalogApiRef);
const {
filters: { kind: kindFilter, type: typeFilter },
queryParameters,
updateFilters,
} = useEntityListProvider();
const queryParamTypes = [queryParameters.type]
.flat()
.filter(Boolean) as string[];
const [selectedTypes, setSelectedTypes] = useState(
queryParamTypes.length ? queryParamTypes : typeFilter?.getTypes() ?? [],
);
const [availableTypes, setAvailableTypes] = useState<string[]>([]);
const kind = useMemo(() => kindFilter?.value, [kindFilter]);
@@ -61,7 +66,18 @@ export function useEntityTypeFilter(): EntityTypeReturn {
return [];
}, [kind, catalogApi]);
const entitiesRef = useRef(entities);
useEffect(() => {
const oldEntities = entitiesRef.current;
entitiesRef.current = entities;
// Delay processing hook until kind and entity load updates have settled to generate list of types;
// This prevents reseting the type filter due to saved type value from query params not matching the
// empty set of type values while values are still being loaded; also only run this hook on changes
// to entities
if (loading || !kind || oldEntities === entities) {
return;
}
// Resolve the unique set of types from returned entities; could be optimized by a new endpoint
// in the catalog-backend that does this, rather than loading entities with redundant types.
if (!entities) return;
@@ -83,35 +99,25 @@ export function useEntityTypeFilter(): EntityTypeReturn {
setAvailableTypes(newTypes);
// Update type filter to only valid values when the list of available types has changed
updateFilters((oldFilters: DefaultEntityFilters) => {
// No filter previously set; no-op
if (!oldFilters.type) {
return {};
}
const stillValidTypes = oldFilters.type
.getTypes()
.filter(value => newTypes.includes(value));
if (!stillValidTypes.length) {
// None of the previously selected types are present any more; clear the filter
return { type: undefined };
}
return { type: new EntityTypeFilter(stillValidTypes) };
});
}, [updateFilters, entities]);
const stillValidTypes = selectedTypes.filter(value =>
newTypes.includes(value),
);
setSelectedTypes(stillValidTypes);
}, [loading, kind, selectedTypes, setSelectedTypes, entities]);
const setSelectedTypes = useCallback(
(types: string[]) =>
updateFilters({
type: types.length ? new EntityTypeFilter(types) : undefined,
}),
[updateFilters],
);
useEffect(() => {
updateFilters({
type: selectedTypes.length
? new EntityTypeFilter(selectedTypes)
: undefined,
});
}, [selectedTypes, updateFilters]);
return {
loading,
error,
availableTypes,
selectedTypes: typeFilter?.getTypes() ?? [],
selectedTypes,
setSelectedTypes,
};
}
@@ -55,6 +55,7 @@ export const MockEntityListContextProvider = ({
updateFilters: updateFilters,
filters: filters,
loading: false,
queryParameters: {},
};
// Extract value.filters to avoid overwriting it; some tests exercise filter updates. The value