diff --git a/.changeset/seven-apes-shave.md b/.changeset/seven-apes-shave.md
new file mode 100644
index 0000000000..356fd377dd
--- /dev/null
+++ b/.changeset/seven-apes-shave.md
@@ -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.
diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx
index f7d915ef5b..47e85292e2 100644
--- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx
+++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.test.tsx
@@ -161,4 +161,34 @@ describe('', () => {
lifecycles: undefined,
});
});
+
+ it('responds to external queryParameters changes', () => {
+ const updateFilters = jest.fn();
+ const rendered = render(
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ lifecycles: new EntityLifecycleFilter(['experimental']),
+ });
+ rendered.rerender(
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ lifecycles: new EntityLifecycleFilter(['production']),
+ });
+ });
});
diff --git a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx
index f5f973b829..77691ab7c5 100644
--- a/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx
+++ b/plugins/catalog-react/src/components/EntityLifecyclePicker/EntityLifecyclePicker.tsx
@@ -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
diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx
index 277a691a84..14abc358db 100644
--- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx
+++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.test.tsx
@@ -191,4 +191,34 @@ describe('', () => {
owner: undefined,
});
});
+
+ it('responds to external queryParameters changes', () => {
+ const updateFilters = jest.fn();
+ const rendered = render(
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ owners: new EntityOwnerFilter(['team-a']),
+ });
+ rendered.rerender(
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ owners: new EntityOwnerFilter(['team-b']),
+ });
+ });
});
diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx
index e807155b0c..acb5ee9279 100644
--- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx
+++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx
@@ -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
diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx
index 770c187b4e..c6b985bc8b 100644
--- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx
+++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx
@@ -149,4 +149,34 @@ describe('', () => {
tags: undefined,
});
});
+
+ it('responds to external queryParameters changes', () => {
+ const updateFilters = jest.fn();
+ const rendered = render(
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ tags: new EntityTagFilter(['tag1']),
+ });
+ rendered.rerender(
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ tags: new EntityTagFilter(['tag2']),
+ });
+ });
});
diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx
index 9f51a5df68..a91389a653 100644
--- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx
+++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx
@@ -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,
diff --git a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx
index f6e9d296df..9ed27bbef8 100644
--- a/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx
+++ b/plugins/catalog-react/src/components/EntityTypePicker/EntityTypePicker.test.tsx
@@ -151,4 +151,38 @@ describe('', () => {
type: new EntityTypeFilter(['tool']),
});
});
+
+ it('responds to external queryParameters changes', async () => {
+ const updateFilters = jest.fn();
+ const rendered = await renderWithEffects(
+
+
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ type: new EntityTypeFilter(['service']),
+ });
+ rendered.rerender(
+
+
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ type: new EntityTypeFilter(['tool']),
+ });
+ });
});
diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx
index b776f26926..d4cfedecbf 100644
--- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx
+++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.test.tsx
@@ -253,6 +253,42 @@ describe('', () => {
});
});
+ it('responds to external queryParameters changes', () => {
+ const updateFilters = jest.fn();
+ const rendered = render(
+
+
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ user: new UserListFilter('all', mockIsOwnedEntity, mockIsStarredEntity),
+ });
+ rendered.rerender(
+
+
+
+
+ ,
+ );
+ expect(updateFilters).toHaveBeenLastCalledWith({
+ user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
+ });
+ });
+
describe.each`
type | filterFn
${'owned'} | ${mockIsOwnedEntity}
diff --git a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
index d731a185fc..cbf6089f2e 100644
--- a/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
+++ b/plugins/catalog-react/src/components/UserListPicker/UserListPicker.tsx
@@ -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 &&
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
index a1e722bc6d..548561108d 100644
--- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx
@@ -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 = {
const wrapper = ({
userFilter,
+ location,
children,
}: PropsWithChildren<{
userFilter?: UserListFilterKind;
+ location?: string;
}>) => {
return (
-
-
-
-
- {children}
-
-
+
+
+
+
+
+ {children}
+
+
+
);
};
@@ -168,10 +173,11 @@ describe('', () => {
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({
diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
index fb8eb7279f..9b4d850208 100644
--- a/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
+++ b/plugins/catalog-react/src/hooks/useEntityListProvider.tsx
@@ -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 = {
appliedFilters: EntityFilters;
entities: Entity[];
backendEntities: Entity[];
- queryParameters: Record;
};
export const EntityListProvider = ({
@@ -109,19 +109,26 @@ export const EntityListProvider = ({
const [requestedFilters, setRequestedFilters] = useState(
{} 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,
+ [location],
+ );
+
const [outputState, setOutputState] = useState>(
() => {
- 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 = ({
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 = ({
window.history?.replaceState(null, document.title, newUrl);
}
},
- [catalogApi, requestedFilters, outputState],
+ [catalogApi, queryParameters, requestedFilters, outputState],
{ loading: true },
);
@@ -220,11 +225,11 @@ export const EntityListProvider = ({
entities: outputState.entities,
backendEntities: outputState.backendEntities,
updateFilters,
- queryParameters: outputState.queryParameters,
+ queryParameters,
loading,
error,
}),
- [outputState, updateFilters, loading, error],
+ [outputState, updateFilters, queryParameters, loading, error],
);
return (
diff --git a/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx
index 5c0139dd26..c4531d9117 100644
--- a/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx
+++ b/plugins/catalog-react/src/hooks/useEntityTypeFilter.tsx
@@ -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([]);
const kind = useMemo(() => kindFilter?.value, [kindFilter]);
diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/providers.tsx
index 2172f6c5a9..3fb295298c 100644
--- a/plugins/catalog-react/src/testUtils/providers.tsx
+++ b/plugins/catalog-react/src/testUtils/providers.tsx
@@ -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(
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 `?? ` 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 (
-
+
{children}
);