Merge pull request #9247 from backstage/filter-query-params
Respect queryParameter updates in catalog pickers
This commit is contained in:
@@ -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.
|
||||
+30
@@ -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']),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-3
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user