feat(EntityListProvider): store filter values in query params
Signed-off-by: Phil Kuang <pkuang@factset.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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(() => {
|
||||
|
||||
+27
-2
@@ -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();
|
||||
|
||||
|
||||
+27
-12
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user