Merge pull request #9247 from backstage/filter-query-params

Respect queryParameter updates in catalog pickers
This commit is contained in:
Ben Lambert
2022-02-07 13:18:40 +01:00
committed by GitHub
14 changed files with 308 additions and 64 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Updated `useEntityListProvider` and catalog pickers to respond to external changes to query parameters in the URL, such as two sidebar links that apply different catalog filters.
@@ -161,4 +161,34 @@ describe('<EntityLifecyclePicker/>', () => {
lifecycles: undefined,
});
});
it('responds to external queryParameters changes', () => {
const updateFilters = jest.fn();
const rendered = render(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { lifecycles: ['experimental'] },
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['experimental']),
});
rendered.rerender(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { lifecycles: ['production'] },
}}
>
<EntityLifecyclePicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
lifecycles: new EntityLifecycleFilter(['production']),
});
});
});
@@ -52,15 +52,25 @@ export const EntityLifecyclePicker = () => {
const { updateFilters, backendEntities, filters, queryParameters } =
useEntityListProvider();
const queryParamLifecycles = [queryParameters.lifecycles]
.flat()
.filter(Boolean) as string[];
const queryParamLifecycles = useMemo(
() => [queryParameters.lifecycles].flat().filter(Boolean) as string[],
[queryParameters],
);
const [selectedLifecycles, setSelectedLifecycles] = useState(
queryParamLifecycles.length
? queryParamLifecycles
: filters.lifecycles?.values ?? [],
);
// 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]);
useEffect(() => {
updateFilters({
lifecycles: selectedLifecycles.length
@@ -191,4 +191,34 @@ describe('<EntityOwnerPicker/>', () => {
owner: undefined,
});
});
it('responds to external queryParameters changes', () => {
const updateFilters = jest.fn();
const rendered = render(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { owners: ['team-a'] },
}}
>
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
owners: new EntityOwnerFilter(['team-a']),
});
rendered.rerender(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { owners: ['team-b'] },
}}
>
<EntityOwnerPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
owners: new EntityOwnerFilter(['team-b']),
});
});
});
@@ -54,13 +54,23 @@ export const EntityOwnerPicker = () => {
const { updateFilters, backendEntities, filters, queryParameters } =
useEntityListProvider();
const queryParamOwners = [queryParameters.owners]
.flat()
.filter(Boolean) as string[];
const queryParamOwners = useMemo(
() => [queryParameters.owners].flat().filter(Boolean) as string[],
[queryParameters],
);
const [selectedOwners, setSelectedOwners] = useState(
queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [],
);
// Set selected owners on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamOwners.length) {
setSelectedOwners(queryParamOwners);
}
}, [queryParamOwners]);
useEffect(() => {
updateFilters({
owners: selectedOwners.length
@@ -149,4 +149,34 @@ describe('<EntityTagPicker/>', () => {
tags: undefined,
});
});
it('responds to external queryParameters changes', () => {
const updateFilters = jest.fn();
const rendered = render(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { tags: ['tag1'] },
}}
>
<EntityTagPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
tags: new EntityTagFilter(['tag1']),
});
rendered.rerender(
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { tags: ['tag2'] },
}}
>
<EntityTagPicker />
</MockEntityListContextProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
tags: new EntityTagFilter(['tag2']),
});
});
});
@@ -52,13 +52,23 @@ export const EntityTagPicker = () => {
const { updateFilters, backendEntities, filters, queryParameters } =
useEntityListProvider();
const queryParamTags = [queryParameters.tags]
.flat()
.filter(Boolean) as string[];
const queryParamTags = useMemo(
() => [queryParameters.tags].flat().filter(Boolean) as string[],
[queryParameters],
);
const [selectedTags, setSelectedTags] = useState(
queryParamTags.length ? queryParamTags : filters.tags?.values ?? [],
);
// Set selected tags on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamTags.length) {
setSelectedTags(queryParamTags);
}
}, [queryParamTags]);
useEffect(() => {
updateFilters({
tags: selectedTags.length ? new EntityTagFilter(selectedTags) : undefined,
@@ -151,4 +151,38 @@ describe('<EntityTypePicker/>', () => {
type: new EntityTypeFilter(['tool']),
});
});
it('responds to external queryParameters changes', async () => {
const updateFilters = jest.fn();
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { type: 'service' },
}}
>
<EntityTypePicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
type: new EntityTypeFilter(['service']),
});
rendered.rerender(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { type: 'tool' },
}}
>
<EntityTypePicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
type: new EntityTypeFilter(['tool']),
});
});
});
@@ -253,6 +253,42 @@ describe('<UserListPicker />', () => {
});
});
it('responds to external queryParameters changes', () => {
const updateFilters = jest.fn();
const rendered = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
updateFilters,
queryParameters: { user: ['all'] },
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('all', mockIsOwnedEntity, mockIsStarredEntity),
});
rendered.rerender(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
backendEntities,
updateFilters,
queryParameters: { user: ['owned'] },
}}
>
<UserListPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
});
});
describe.each`
type | filterFn
${'owned'} | ${mockIsOwnedEntity}
@@ -161,8 +161,13 @@ export const UserListPicker = ({
[isOwnedEntity, isStarredEntity],
);
const queryParamUserFilter = useMemo(
() => [queryParameters.user].flat()[0],
[queryParameters],
);
const [selectedUserFilter, setSelectedUserFilter] = useState(
[queryParameters.user].flat()[0] ?? initialFilter,
queryParamUserFilter ?? initialFilter,
);
// To show proper counts for each section, apply all other frontend filters _except_ the user
@@ -190,6 +195,14 @@ export const UserListPicker = ({
[entitiesWithoutUserFilter, starredFilter, ownedFilter],
);
// Set selected user filter on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamUserFilter) {
setSelectedUserFilter(queryParamUserFilter as UserListFilterKind);
}
}, [queryParamUserFilter]);
useEffect(() => {
if (
!loading &&
@@ -27,6 +27,7 @@ import { MockStorageApi, TestApiProvider } from '@backstage/test-utils';
import { act, renderHook } from '@testing-library/react-hooks';
import qs from 'qs';
import React, { PropsWithChildren } from 'react';
import { MemoryRouter } from 'react-router';
import { catalogApiRef } from '../api';
import { DefaultStarredEntitiesApi, starredEntitiesApiRef } from '../apis';
import { EntityKindPicker, UserListPicker } from '../components';
@@ -82,31 +83,35 @@ const mockCatalogApi: Partial<CatalogApi> = {
const wrapper = ({
userFilter,
location,
children,
}: PropsWithChildren<{
userFilter?: UserListFilterKind;
location?: string;
}>) => {
return (
<TestApiProvider
apis={[
[configApiRef, mockConfigApi],
[catalogApiRef, mockCatalogApi],
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<EntityListProvider>
<EntityKindPicker initialFilter="component" hidden />
<UserListPicker initialFilter={userFilter} />
{children}
</EntityListProvider>
</TestApiProvider>
<MemoryRouter initialEntries={[location ?? '']}>
<TestApiProvider
apis={[
[configApiRef, mockConfigApi],
[catalogApiRef, mockCatalogApi],
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: MockStorageApi.create(),
}),
],
]}
>
<EntityListProvider>
<EntityKindPicker initialFilter="component" hidden />
<UserListPicker initialFilter={userFilter} />
{children}
</EntityListProvider>
</TestApiProvider>
</MemoryRouter>
);
};
@@ -168,10 +173,11 @@ describe('<EntityListProvider />', () => {
const query = qs.stringify({
filters: { kind: 'component', type: 'service' },
});
delete (window as any).location;
(window as any).location = new URL(`http://localhost/catalog?${query}`);
const { result, waitFor } = renderHook(() => useEntityListProvider(), {
wrapper,
initialProps: {
location: `/catalog?${query}`,
},
});
await waitFor(() => !!result.current.queryParameters);
expect(result.current.queryParameters).toEqual({
@@ -25,6 +25,7 @@ import React, {
useMemo,
useState,
} from 'react';
import { useLocation } from 'react-router';
import useAsyncFn from 'react-use/lib/useAsyncFn';
import useDebounce from 'react-use/lib/useDebounce';
import useMountedState from 'react-use/lib/useMountedState';
@@ -98,7 +99,6 @@ type OutputState<EntityFilters extends DefaultEntityFilters> = {
appliedFilters: EntityFilters;
entities: Entity[];
backendEntities: Entity[];
queryParameters: Record<string, string | string[]>;
};
export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
@@ -109,19 +109,26 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
const [requestedFilters, setRequestedFilters] = useState<EntityFilters>(
{} as EntityFilters,
);
// We use react-router's useLocation hook so updates from external sources trigger an update to
// the queryParameters in outputState. Updates from this hook use replaceState below and won't
// trigger a useLocation change; this would instead come from an external source, such as a manual
// update of the URL or two catalog sidebar links with different catalog filters.
const location = useLocation();
const queryParameters = useMemo(
() =>
(qs.parse(location.search, {
ignoreQueryPrefix: true,
}).filters ?? {}) as Record<string, string | string[]>,
[location],
);
const [outputState, setOutputState] = useState<OutputState<EntityFilters>>(
() => {
const query = qs.parse(window.location.search, {
ignoreQueryPrefix: true,
});
return {
appliedFilters: {} as EntityFilters,
entities: [],
backendEntities: [],
queryParameters: (query.filters ?? {}) as Record<
string,
string | string[]
>,
};
},
);
@@ -163,19 +170,17 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
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,
});
}
if (isMounted()) {
const oldParams = qs.parse(window.location.search, {
const oldParams = qs.parse(location.search, {
ignoreQueryPrefix: true,
});
const newParams = qs.stringify(
@@ -191,7 +196,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
window.history?.replaceState(null, document.title, newUrl);
}
},
[catalogApi, requestedFilters, outputState],
[catalogApi, queryParameters, requestedFilters, outputState],
{ loading: true },
);
@@ -220,11 +225,11 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
entities: outputState.entities,
backendEntities: outputState.backendEntities,
updateFilters,
queryParameters: outputState.queryParameters,
queryParameters,
loading,
error,
}),
[outputState, updateFilters, loading, error],
[outputState, updateFilters, queryParameters, loading, error],
);
return (
@@ -42,13 +42,23 @@ export function useEntityTypeFilter(): EntityTypeReturn {
updateFilters,
} = useEntityListProvider();
const queryParamTypes = [queryParameters.type]
.flat()
.filter(Boolean) as string[];
const queryParamTypes = useMemo(
() => [queryParameters.type].flat().filter(Boolean) as string[],
[queryParameters],
);
const [selectedTypes, setSelectedTypes] = useState(
queryParamTypes.length ? queryParamTypes : typeFilter?.getTypes() ?? [],
);
// Set selected types on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamTypes.length) {
setSelectedTypes(queryParamTypes);
}
}, [queryParamTypes]);
const [availableTypes, setAvailableTypes] = useState<string[]>([]);
const kind = useMemo(() => kindFilter?.value, [kindFilter]);
@@ -14,7 +14,12 @@
* limitations under the License.
*/
import React, { PropsWithChildren, useCallback, useState } from 'react';
import React, {
PropsWithChildren,
useCallback,
useMemo,
useState,
} from 'react';
import {
DefaultEntityFilters,
EntityListContext,
@@ -32,6 +37,7 @@ export const MockEntityListContextProvider = ({
const [filters, setFilters] = useState<DefaultEntityFilters>(
value?.filters ?? {},
);
const updateFilters = useCallback(
(
update:
@@ -49,23 +55,32 @@ export const MockEntityListContextProvider = ({
[],
);
const defaultContext: EntityListContextProps = {
entities: [],
backendEntities: [],
updateFilters,
filters,
loading: false,
queryParameters: {},
};
// Memoize the default values since pickers have useEffect triggers on these; naively defaulting
// below with `?? <X>` breaks referential equality on subsequent updates.
const defaultValues = useMemo(
() => ({
entities: [],
backendEntities: [],
queryParameters: {},
}),
[],
);
// Extract value.filters to avoid overwriting it; some tests exercise filter updates. The value
// provided is used as the initial seed in useState above.
const { filters: _, ...otherContextFields } = value ?? {};
const resolvedValue: EntityListContextProps = useMemo(
() => ({
entities: value?.entities ?? defaultValues.entities,
backendEntities: value?.backendEntities ?? defaultValues.backendEntities,
updateFilters: value?.updateFilters ?? updateFilters,
filters,
loading: value?.loading ?? false,
queryParameters: value?.queryParameters ?? defaultValues.queryParameters,
error: value?.error,
}),
[value, defaultValues, filters, updateFilters],
);
return (
<EntityListContext.Provider
value={{ ...defaultContext, ...otherContextFields }}
>
<EntityListContext.Provider value={resolvedValue}>
{children}
</EntityListContext.Provider>
);