Respect initial filter
Signed-off-by: Crevil <bjoern.soerensen@gmail.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user