Respect initial filter

Signed-off-by: Crevil <bjoern.soerensen@gmail.com>
This commit is contained in:
Crevil
2022-08-11 13:34:08 +02:00
parent c008f74f8f
commit 4efadb6968
9 changed files with 321 additions and 130 deletions
+8
View File
@@ -0,0 +1,8 @@
---
'@backstage/plugin-catalog-react': minor
'@backstage/plugin-catalog': patch
---
Implement `EntityKindPicker` which allows users to filter the catalog kinds much like the `CatalogKindHeader`.
The new picker is more accessible though listed as any other filter options in the catalog.
+2 -2
View File
@@ -161,7 +161,7 @@ export type EntityFilter = {
export class EntityKindFilter implements EntityFilter {
constructor(value: string);
// (undocumented)
getCatalogFilters(): Record<string, string | string[]>;
getCatalogFilters(): Record<string, string>;
// (undocumented)
toQueryValue(): string;
// (undocumented)
@@ -176,7 +176,7 @@ export const EntityKindPicker: (
// @public
export interface EntityKindPickerProps {
// (undocumented)
hidden: boolean;
hidden?: boolean;
// (undocumented)
initialFilter?: string;
}
@@ -14,46 +14,170 @@
* limitations under the License.
*/
import { render } from '@testing-library/react';
import React from 'react';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { GetEntityFacetsResponse } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import { ApiProvider } from '@backstage/core-app-api';
import { alertApiRef } from '@backstage/core-plugin-api';
import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils';
import { fireEvent, waitFor } from '@testing-library/react';
import { capitalize } from 'lodash';
import { default as React } from 'react';
import { catalogApiRef } from '../../api';
import { EntityKindFilter } from '../../filters';
import { MockEntityListContextProvider } from '../../testUtils/providers';
import { EntityKindPicker } from './EntityKindPicker';
const entities: Entity[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
name: 'component',
},
},
{
apiVersion: '1',
kind: 'Domain',
metadata: {
name: 'domain',
},
},
{
apiVersion: '1',
kind: 'Group',
metadata: {
name: 'group',
},
},
];
describe('<EntityKindPicker/>', () => {
const apis = TestApiRegistry.from(
[
catalogApiRef,
{
getEntityFacets: jest.fn().mockResolvedValue({
facets: {
kind: entities.map(e => ({
value: e.kind,
count: 1,
})),
},
} as GetEntityFacetsResponse),
},
],
[
alertApiRef,
{
post: jest.fn(),
},
],
);
it('renders available entity kinds', async () => {
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{ filters: { kind: new EntityKindFilter('component') } }}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(rendered.getByText('Kind')).toBeInTheDocument();
const input = rendered.getByTestId('select');
fireEvent.click(input);
await waitFor(() => rendered.getByText('Domain'));
entities.forEach(entity => {
expect(
rendered.getByRole('option', {
name: capitalize(entity.kind as string),
}),
).toBeInTheDocument();
});
});
it('sets the selected kind filter', async () => {
const updateFilters = jest.fn();
render(
<MockEntityListContextProvider
value={{
updateFilters,
}}
>
<EntityKindPicker initialFilter="component" hidden />
</MockEntityListContextProvider>,
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
filters: { kind: new EntityKindFilter('component') },
updateFilters,
}}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
const input = rendered.getByTestId('select');
fireEvent.click(input);
await waitFor(() => rendered.getByText('Domain'));
fireEvent.click(rendered.getByText('Domain'));
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('domain'),
});
});
it('respects the query parameter filter value', async () => {
const updateFilters = jest.fn();
const queryParameters = { kind: 'group' };
await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters,
}}
>
<EntityKindPicker initialFilter="group" hidden />
</MockEntityListContextProvider>
,
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('group'),
});
});
it('responds to external queryParameters changes', async () => {
const updateFilters = jest.fn();
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { kind: 'component' },
}}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
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>,
rendered.rerender(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
value={{
updateFilters,
queryParameters: { kind: 'domain' },
}}
>
<EntityKindPicker />
</MockEntityListContextProvider>
</ApiProvider>,
);
expect(updateFilters).toHaveBeenLastCalledWith({
kind: new EntityKindFilter('API'),
kind: new EntityKindFilter('domain'),
});
});
});
@@ -14,115 +14,168 @@
* limitations under the License.
*/
import { useApi } from '@backstage/core-plugin-api';
import {
Box,
Checkbox,
FormControlLabel,
makeStyles,
TextField,
Typography,
} from '@material-ui/core';
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, { useEffect, useMemo, useState } from 'react';
import { Select } from '@backstage/core-components';
import { alertApiRef, useApi } from '@backstage/core-plugin-api';
import { Box } from '@material-ui/core';
import capitalize from 'lodash/capitalize';
import sortBy from 'lodash/sortBy';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import useAsync from 'react-use/lib/useAsync';
import { catalogApiRef } from '../../api';
import { EntityKindFilter } from '../../filters';
import { useEntityList } from '../../hooks';
const useStyles = makeStyles(
{
input: {},
},
{
name: 'CatalogReactEntityKindPicker',
},
);
function useAvailableKinds() {
const catalogApi = useApi(catalogApiRef);
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
const checkedIcon = <CheckBoxIcon fontSize="small" />;
const [availableKinds, setAvailableKinds] = useState<string[]>([]);
/** @public */
export const EntityKindPicker = () => {
const classes = useStyles();
const {
updateFilters,
error,
loading,
value: facets,
} = useAsync(async () => {
const facet = 'kind';
const items = await catalogApi
.getEntityFacets({
facets: [facet],
})
.then(response => response.facets[facet] || []);
return items;
}, [catalogApi]);
const facetsRef = useRef(facets);
useEffect(() => {
const oldFacets = facetsRef.current;
facetsRef.current = facets;
// Delay processing hook until facets load updates have settled to generate list of kinds;
// This prevents resetting the kind filter due to saved kind value from query params not matching the
// empty set of kind values while values are still being loaded; also only run this hook on changes
// to facets
if (loading || oldFacets === facets || !facets) {
return;
}
const newKinds = [
...new Set(
sortBy(facets, f => f.value).map(f =>
f.value.toLocaleLowerCase('en-US'),
),
),
];
setAvailableKinds(newKinds);
}, [loading, facets, setAvailableKinds]);
return { loading, error, availableKinds };
}
function useEntityKindFilter(opts: { initialFilter: string }): {
loading: boolean;
error?: Error;
availableKinds: string[];
selectedKind: string;
setSelectedKind: (kind: string) => void;
} {
const {
filters,
queryParameters: { kind: kindsParameter },
queryParameters: { kind: kindParameter },
updateFilters,
} = useEntityList();
const catalogApi = useApi(catalogApiRef);
const { value: availableKinds } = useAsync(async () => {
const facet = 'kind';
const { facets } = await catalogApi.getEntityFacets({
facets: [facet],
});
return facets[facet].map(({ value }) => value);
}, [filters.kind]);
const queryParamKinds = useMemo(
() => [kindsParameter].flat().filter(Boolean) as string[],
[kindsParameter],
const flattenedQueryKind = useMemo(
() => [kindParameter].flat()[0],
[kindParameter],
);
const [selectedKinds, setSelectedKinds] = useState(
queryParamKinds.length ? queryParamKinds : filters.kind?.getKinds() ?? [],
const [selectedKind, setSelectedKind] = useState(
flattenedQueryKind ?? filters.kind?.value ?? opts.initialFilter,
);
// Set selected kinds on query parameter updates; this happens at initial page load and from
// external updates to the page location.
useEffect(() => {
if (queryParamKinds.length) {
setSelectedKinds(queryParamKinds);
if (flattenedQueryKind) {
setSelectedKind(flattenedQueryKind);
}
}, [queryParamKinds]);
}, [flattenedQueryKind]);
// Set selected kind from filters; this happens when the kind filter is
// updated from another component
useEffect(() => {
if (filters.kind?.value) {
setSelectedKind(filters.kind?.value);
}
}, [filters.kind]);
const { availableKinds, loading, error } = useAvailableKinds();
useEffect(() => {
updateFilters({
kind: selectedKinds.length
? new EntityKindFilter(selectedKinds)
: undefined,
kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined,
});
}, [selectedKinds, updateFilters]);
}, [selectedKind, updateFilters]);
if (!availableKinds?.length) return null;
return {
loading,
error,
availableKinds,
selectedKind,
setSelectedKind,
};
}
return (
/**
* Props for {@link EntityKindPicker}.
*
* @public
*/
export interface EntityKindPickerProps {
initialFilter?: string;
hidden?: boolean;
}
/** @public */
export const EntityKindPicker = (props: EntityKindPickerProps) => {
const { hidden, initialFilter = 'component' } = props;
const alertApi = useApi(alertApiRef);
const { error, availableKinds, selectedKind, setSelectedKind } =
useEntityKindFilter({
initialFilter: initialFilter,
});
useEffect(() => {
if (error) {
alertApi.post({
message: `Failed to load entity kinds`,
severity: 'error',
});
}
if (initialFilter) {
setSelectedKind(initialFilter);
}
}, [error, alertApi, initialFilter, setSelectedKind]);
if (availableKinds?.length === 0 || error) return null;
const items = [
...availableKinds.map((kind: string) => ({
value: kind,
label: capitalize(kind),
})),
];
return hidden ? null : (
<Box pb={1} pt={1}>
<Typography variant="button" component="label">
Kinds
<Autocomplete
multiple
options={availableKinds}
value={selectedKinds}
onChange={(_: object, value: string[]) => setSelectedKinds(value)}
renderOption={(option, { selected }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
label={option}
/>
)}
size="small"
popupIcon={<ExpandMoreIcon data-testid="kind-picker-expand" />}
renderInput={params => (
<TextField
{...params}
className={classes.input}
variant="outlined"
/>
)}
/>
</Typography>
<Select
label="Kind"
items={items}
selected={selectedKind}
onChange={value => setSelectedKind(String(value))}
/>
</Box>
);
};
+5 -10
View File
@@ -28,19 +28,14 @@ import { getEntityRelations } from './utils';
* @public
*/
export class EntityKindFilter implements EntityFilter {
constructor(readonly value: string | string[]) {}
constructor(readonly value: string) {}
// Simplify `string | string[]` for consumers, always returns an array
getKinds(): string[] {
return Array.isArray(this.value) ? this.value : [this.value];
getCatalogFilters(): Record<string, string> {
return { kind: this.value };
}
getCatalogFilters(): Record<string, string | string[]> {
return { kind: this.getKinds() };
}
toQueryValue(): string[] {
return this.getKinds();
toQueryValue(): string {
return this.value;
}
}
@@ -17,6 +17,7 @@
import { CatalogApi } from '@backstage/catalog-client';
import { Entity } from '@backstage/catalog-model';
import {
alertApiRef,
ConfigApi,
configApiRef,
IdentityApi,
@@ -91,6 +92,12 @@ const wrapper = ({
[identityApiRef, mockIdentityApi],
[storageApiRef, MockStorageApi.create()],
[starredEntitiesApiRef, new MockStarredEntitiesApi()],
[
alertApiRef,
{
post: jest.fn(),
},
],
]}
>
<EntityListProvider>
@@ -69,6 +69,7 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
.then(response => response.facets.kind?.map(f => f.value).sort() || []);
});
const {
filters,
updateFilters,
queryParameters: { kind: kindParameter },
} = useEntityList();
@@ -81,6 +82,14 @@ export function CatalogKindHeader(props: CatalogKindHeaderProps) {
queryParamKind ?? initialFilter,
);
// Set selected kind from filters; this happens when the kind filter is
// updated from another component
useEffect(() => {
if (filters.kind?.value) {
setSelectedKind(filters.kind?.value);
}
}, [filters.kind]);
useEffect(() => {
updateFilters({
kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined,
@@ -84,7 +84,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) {
</ContentHeader>
<CatalogFilterLayout>
<CatalogFilterLayout.Filters>
<EntityKindPicker />
<EntityKindPicker initialFilter={initialKind} />
<EntityTypePicker />
<UserListPicker initialFilter={initiallySelectedFilter} />
<EntityOwnerPicker />
@@ -70,12 +70,7 @@ export const CatalogTable = (props: CatalogTableProps) => {
const defaultColumns: TableColumn<CatalogTableRow>[] = useMemo(() => {
return [
columnFactories.createTitleColumn({ hidden: true }),
columnFactories.createNameColumn({
defaultKind:
filters.kind?.getKinds()?.length === 1
? filters.kind?.getKinds()[0]
: undefined,
}),
columnFactories.createNameColumn({ defaultKind: filters.kind?.value }),
...createEntitySpecificColumns(),
columnFactories.createMetadataDescriptionColumn(),
columnFactories.createTagsColumn(),
@@ -105,7 +100,7 @@ export const CatalogTable = (props: CatalogTableProps) => {
];
}
}
}, [filters.kind]);
}, [filters.kind?.value]);
const showTypeColumn = filters.type === undefined;
// TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar