Merge pull request #15112 from IlyaSavich/entity-filter-refactoring
Refactor entity picker
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-catalog-react': minor
|
||||
---
|
||||
|
||||
Add possibility to re-use EntityPicker for filters with multiple select.
|
||||
@@ -114,6 +114,7 @@
|
||||
"@types/stoppable": "^1.1.0",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/tar": "^6.1.1",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"@types/yauzl": "^2.10.0",
|
||||
"aws-sdk-mock": "^5.2.1",
|
||||
|
||||
@@ -393,9 +393,7 @@ export class EntityTagFilter implements EntityFilter {
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
export const EntityTagPicker: (
|
||||
props: EntityTagPickerProps,
|
||||
) => JSX.Element | null;
|
||||
export const EntityTagPicker: (props: EntityTagPickerProps) => JSX.Element;
|
||||
|
||||
// @public (undocumented)
|
||||
export type EntityTagPickerProps = {
|
||||
@@ -480,12 +478,14 @@ export function InspectEntityDialog(props: {
|
||||
export function isOwnerOf(owner: Entity, entity: Entity): boolean;
|
||||
|
||||
// @public (undocumented)
|
||||
export const MockEntityListContextProvider: ({
|
||||
export function MockEntityListContextProvider<
|
||||
T extends DefaultEntityFilters = DefaultEntityFilters,
|
||||
>({
|
||||
children,
|
||||
value,
|
||||
}: React_2.PropsWithChildren<{
|
||||
value?: Partial<EntityListContextProps<DefaultEntityFilters>> | undefined;
|
||||
}>) => JSX.Element;
|
||||
}: PropsWithChildren<{
|
||||
value?: Partial<EntityListContextProps<T>>;
|
||||
}>): JSX.Element;
|
||||
|
||||
// @public
|
||||
export class MockStarredEntitiesApi implements StarredEntitiesApi {
|
||||
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { fireEvent, render, waitFor, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { MockEntityListContextProvider } from '../../testUtils/providers';
|
||||
import { EntityAutocompletePicker } from './EntityAutocompletePicker';
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { CatalogApi } from '@backstage/catalog-client';
|
||||
import { DefaultEntityFilters } from '../../hooks';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { EntityFilter } from '../../types';
|
||||
|
||||
interface EntityFilters extends DefaultEntityFilters {
|
||||
options?: EntityOptionFilter;
|
||||
}
|
||||
|
||||
const options = ['option1', 'option2', 'option3', 'option4'];
|
||||
|
||||
class EntityOptionFilter implements EntityFilter {
|
||||
constructor(readonly values: string[]) {}
|
||||
|
||||
filterEntity(entity: Entity): boolean {
|
||||
return this.values.every(v =>
|
||||
((entity.spec?.options ?? []) as string[]).includes(v),
|
||||
);
|
||||
}
|
||||
|
||||
toQueryValue(): string[] {
|
||||
return this.values;
|
||||
}
|
||||
}
|
||||
|
||||
describe('<EntityAutocompletePicker/>', () => {
|
||||
const mockCatalogApi = {
|
||||
getEntityFacets: async () => ({
|
||||
facets: {
|
||||
'spec.options': options.map((value, idx) => ({ value, count: idx })),
|
||||
},
|
||||
}),
|
||||
} as unknown as CatalogApi;
|
||||
|
||||
it('renders all options', async () => {
|
||||
render(
|
||||
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
|
||||
<MockEntityListContextProvider value={{}}>
|
||||
<EntityAutocompletePicker<EntityFilters>
|
||||
label="Options"
|
||||
path="spec.options"
|
||||
name="options"
|
||||
Filter={EntityOptionFilter}
|
||||
/>
|
||||
</MockEntityListContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Options')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('options-picker-expand'));
|
||||
options.forEach(option => {
|
||||
expect(screen.getByText(option)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders unique options in alphabetical order', async () => {
|
||||
render(
|
||||
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
|
||||
<MockEntityListContextProvider value={{}}>
|
||||
<EntityAutocompletePicker<EntityFilters>
|
||||
label="Options"
|
||||
path="spec.options"
|
||||
name="options"
|
||||
Filter={EntityOptionFilter}
|
||||
/>
|
||||
</MockEntityListContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Options')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('options-picker-expand'));
|
||||
|
||||
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
|
||||
'option1',
|
||||
'option2',
|
||||
'option3',
|
||||
'option4',
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders options with counts', async () => {
|
||||
render(
|
||||
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
|
||||
<MockEntityListContextProvider value={{}}>
|
||||
<EntityAutocompletePicker<EntityFilters>
|
||||
label="Options"
|
||||
path="spec.options"
|
||||
name="options"
|
||||
Filter={EntityOptionFilter}
|
||||
showCounts
|
||||
/>
|
||||
</MockEntityListContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Options')).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('options-picker-expand'));
|
||||
|
||||
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
|
||||
'option1 (0)',
|
||||
'option2 (1)',
|
||||
'option3 (2)',
|
||||
'option4 (3)',
|
||||
]);
|
||||
});
|
||||
|
||||
it('respects the query parameter filter value', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
const queryParameters = { options: ['option3'] };
|
||||
render(
|
||||
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
|
||||
<MockEntityListContextProvider<EntityFilters>
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters,
|
||||
}}
|
||||
>
|
||||
<EntityAutocompletePicker<EntityFilters>
|
||||
label="Options"
|
||||
path="spec.options"
|
||||
name="options"
|
||||
Filter={EntityOptionFilter}
|
||||
/>
|
||||
</MockEntityListContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
options: new EntityOptionFilter(['option3']),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds options to filters', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
render(
|
||||
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
|
||||
<MockEntityListContextProvider
|
||||
value={{
|
||||
updateFilters,
|
||||
}}
|
||||
>
|
||||
<EntityAutocompletePicker<EntityFilters>
|
||||
label="Options"
|
||||
path="spec.options"
|
||||
name="options"
|
||||
Filter={EntityOptionFilter}
|
||||
/>
|
||||
</MockEntityListContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
options: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('options-picker-expand'));
|
||||
fireEvent.click(screen.getByText('option1'));
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
options: new EntityOptionFilter(['option1']),
|
||||
});
|
||||
});
|
||||
|
||||
it('removes options from filters', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
render(
|
||||
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
|
||||
<MockEntityListContextProvider<EntityFilters>
|
||||
value={{
|
||||
updateFilters,
|
||||
filters: { options: new EntityOptionFilter(['option1']) },
|
||||
}}
|
||||
>
|
||||
<EntityAutocompletePicker<EntityFilters>
|
||||
label="Options"
|
||||
path="spec.options"
|
||||
name="options"
|
||||
Filter={EntityOptionFilter}
|
||||
/>
|
||||
</MockEntityListContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
options: new EntityOptionFilter(['option1']),
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('options-picker-expand'));
|
||||
expect(screen.getByLabelText('option1')).toBeChecked();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('option1'));
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
options: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('responds to external queryParameters changes', async () => {
|
||||
const updateFilters = jest.fn();
|
||||
const rendered = render(
|
||||
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
|
||||
<MockEntityListContextProvider<EntityFilters>
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { options: ['option1'] },
|
||||
}}
|
||||
>
|
||||
<EntityAutocompletePicker<EntityFilters>
|
||||
label="Options"
|
||||
path="spec.options"
|
||||
name="options"
|
||||
Filter={EntityOptionFilter}
|
||||
/>
|
||||
</MockEntityListContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
options: new EntityOptionFilter(['option1']),
|
||||
}),
|
||||
);
|
||||
rendered.rerender(
|
||||
<TestApiProvider apis={[[catalogApiRef, mockCatalogApi]]}>
|
||||
<MockEntityListContextProvider<EntityFilters>
|
||||
value={{
|
||||
updateFilters,
|
||||
queryParameters: { options: ['option2'] },
|
||||
}}
|
||||
>
|
||||
<EntityAutocompletePicker<EntityFilters>
|
||||
label="Options"
|
||||
path="spec.options"
|
||||
name="options"
|
||||
Filter={EntityOptionFilter}
|
||||
/>
|
||||
</MockEntityListContextProvider>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
options: new EntityOptionFilter(['option2']),
|
||||
});
|
||||
});
|
||||
});
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Box, TextFieldProps, Typography } from '@material-ui/core';
|
||||
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
|
||||
import { Autocomplete } from '@material-ui/lab';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { EntityAutocompletePickerOption } from './EntityAutocompletePickerOption';
|
||||
import { EntityAutocompletePickerInput } from './EntityAutocompletePickerInput';
|
||||
import {
|
||||
DefaultEntityFilters,
|
||||
useEntityList,
|
||||
} from '../../hooks/useEntityListProvider';
|
||||
import { EntityFilter } from '../../types';
|
||||
import _ from 'lodash';
|
||||
|
||||
type KeysMatchingCondition<T, V, K> = T extends V ? K : never;
|
||||
type KeysMatching<T, V> = {
|
||||
[K in keyof T]-?: KeysMatchingCondition<T[K], V, K>;
|
||||
}[keyof T];
|
||||
|
||||
type AllowedEntityFilters<T extends DefaultEntityFilters> = KeysMatching<
|
||||
T,
|
||||
EntityFilter & { values: string[] }
|
||||
>;
|
||||
|
||||
interface ConstructableFilter<T> {
|
||||
new (values: string[]): T;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export type EntityAutocompletePickerProps<
|
||||
T extends DefaultEntityFilters = DefaultEntityFilters,
|
||||
Name extends AllowedEntityFilters<T> = AllowedEntityFilters<T>,
|
||||
> = {
|
||||
label: string;
|
||||
name: Name;
|
||||
path: string;
|
||||
showCounts?: boolean;
|
||||
Filter: ConstructableFilter<NonNullable<T[Name]>>;
|
||||
InputProps?: TextFieldProps;
|
||||
};
|
||||
|
||||
/** @public */
|
||||
export function EntityAutocompletePicker<
|
||||
T extends DefaultEntityFilters = DefaultEntityFilters,
|
||||
Name extends AllowedEntityFilters<T> = AllowedEntityFilters<T>,
|
||||
>(props: EntityAutocompletePickerProps<T, Name>) {
|
||||
const { label, name, path, showCounts, Filter, InputProps } = props;
|
||||
|
||||
const {
|
||||
updateFilters,
|
||||
filters,
|
||||
queryParameters: { [name]: queryParameter },
|
||||
} = useEntityList<T>();
|
||||
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value: availableValues } = useAsync(async () => {
|
||||
const facet = path;
|
||||
const { facets } = await catalogApi.getEntityFacets({
|
||||
facets: [facet],
|
||||
filter: filters.kind?.getCatalogFilters(),
|
||||
});
|
||||
|
||||
return Object.fromEntries(
|
||||
facets[facet].map(({ value, count }) => [value, count]),
|
||||
);
|
||||
}, [filters.kind]);
|
||||
|
||||
const queryParameters = useMemo(
|
||||
() => [queryParameter].flat().filter(Boolean) as string[],
|
||||
[queryParameter],
|
||||
);
|
||||
|
||||
const [selectedOptions, setSelectedOptions] = useState(
|
||||
queryParameters.length
|
||||
? queryParameters
|
||||
: (filters[name] as unknown as { values: string[] })?.values ?? [],
|
||||
);
|
||||
|
||||
// Set selected options on query parameter updates; this happens at initial page load and from
|
||||
// external updates to the page location
|
||||
useEffect(() => {
|
||||
if (
|
||||
queryParameters.length &&
|
||||
!_.isEqual(selectedOptions, queryParameters)
|
||||
) {
|
||||
setSelectedOptions(queryParameters);
|
||||
}
|
||||
}, [selectedOptions, queryParameters]);
|
||||
|
||||
const availableOptions = Object.keys(availableValues ?? {});
|
||||
const shouldAddFilter = selectedOptions.length && availableOptions.length;
|
||||
|
||||
useEffect(() => {
|
||||
updateFilters({
|
||||
[name]: shouldAddFilter ? new Filter(selectedOptions) : undefined,
|
||||
} as Partial<T>);
|
||||
}, [name, shouldAddFilter, selectedOptions, Filter, updateFilters]);
|
||||
|
||||
if (
|
||||
(filters[name] && !('values' in filters[name])) ||
|
||||
!availableOptions.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pb={1} pt={1}>
|
||||
<Typography variant="button" component="label">
|
||||
{label}
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={availableOptions}
|
||||
value={selectedOptions}
|
||||
onChange={(_event: object, options: string[]) =>
|
||||
setSelectedOptions(options)
|
||||
}
|
||||
renderOption={(option, { selected }) => (
|
||||
<EntityAutocompletePickerOption
|
||||
selected={selected}
|
||||
value={option}
|
||||
availableOptions={availableValues}
|
||||
showCounts={!!showCounts}
|
||||
/>
|
||||
)}
|
||||
size="small"
|
||||
popupIcon={
|
||||
<ExpandMoreIcon data-testid={`${String(name)}-picker-expand`} />
|
||||
}
|
||||
renderInput={params => (
|
||||
<EntityAutocompletePickerInput {...params} {...InputProps} />
|
||||
)}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { makeStyles, TextField, TextFieldProps } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import classnames from 'classnames';
|
||||
|
||||
const useStyles = makeStyles(
|
||||
{
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
name: 'CatalogReactEntityAutocompletePickerInput',
|
||||
},
|
||||
);
|
||||
|
||||
export function EntityAutocompletePickerInput(params: TextFieldProps) {
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<TextField
|
||||
variant="outlined"
|
||||
{...params}
|
||||
className={classnames(classes.input, params.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Checkbox, FormControlLabel } from '@material-ui/core';
|
||||
import CheckBoxIcon from '@material-ui/icons/CheckBox';
|
||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
|
||||
import React, { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
selected: boolean;
|
||||
value: string;
|
||||
availableOptions?: Record<string, number>;
|
||||
showCounts: boolean;
|
||||
}
|
||||
|
||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
||||
const checkedIcon = <CheckBoxIcon fontSize="small" />;
|
||||
|
||||
function OptionCheckbox({ selected }: { selected: boolean }) {
|
||||
return <Checkbox icon={icon} checkedIcon={checkedIcon} checked={selected} />;
|
||||
}
|
||||
|
||||
export const EntityAutocompletePickerOption = memo((props: Props) => {
|
||||
const { selected, value, availableOptions, showCounts } = props;
|
||||
const label = showCounts ? `${value} (${availableOptions?.[value]})` : value;
|
||||
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={<OptionCheckbox selected={selected} />}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { EntityAutocompletePicker } from './EntityAutocompletePicker';
|
||||
export type { EntityAutocompletePickerProps } from './EntityAutocompletePicker';
|
||||
@@ -44,7 +44,7 @@ describe('<EntityTagPicker/>', () => {
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('tag-picker-expand'));
|
||||
fireEvent.click(screen.getByTestId('tags-picker-expand'));
|
||||
tags.forEach(tag => {
|
||||
expect(screen.getByText(tag)).toBeInTheDocument();
|
||||
});
|
||||
@@ -60,7 +60,7 @@ describe('<EntityTagPicker/>', () => {
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('tag-picker-expand'));
|
||||
fireEvent.click(screen.getByTestId('tags-picker-expand'));
|
||||
|
||||
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
|
||||
'tag1',
|
||||
@@ -80,7 +80,7 @@ describe('<EntityTagPicker/>', () => {
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText('Tags')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByTestId('tag-picker-expand'));
|
||||
fireEvent.click(screen.getByTestId('tags-picker-expand'));
|
||||
|
||||
expect(screen.getAllByRole('option').map(o => o.textContent)).toEqual([
|
||||
'tag1 (0)',
|
||||
@@ -132,7 +132,7 @@ describe('<EntityTagPicker/>', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('tag-picker-expand'));
|
||||
fireEvent.click(screen.getByTestId('tags-picker-expand'));
|
||||
fireEvent.click(screen.getByText('tag1'));
|
||||
expect(updateFilters).toHaveBeenLastCalledWith({
|
||||
tags: new EntityTagFilter(['tag1']),
|
||||
@@ -158,7 +158,7 @@ describe('<EntityTagPicker/>', () => {
|
||||
tags: new EntityTagFilter(['tag1']),
|
||||
}),
|
||||
);
|
||||
fireEvent.click(screen.getByTestId('tag-picker-expand'));
|
||||
fireEvent.click(screen.getByTestId('tags-picker-expand'));
|
||||
expect(screen.getByLabelText('tag1')).toBeChecked();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('tag1'));
|
||||
|
||||
@@ -14,132 +14,36 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
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 { useEntityList } from '../../hooks/useEntityListProvider';
|
||||
import { makeStyles } from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { EntityTagFilter } from '../../filters';
|
||||
import { useApi } from '@backstage/core-plugin-api';
|
||||
import useAsync from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../api';
|
||||
import { EntityAutocompletePicker } from '../EntityAutocompletePicker/EntityAutocompletePicker';
|
||||
|
||||
/** @public */
|
||||
export type CatalogReactEntityTagPickerClassKey = 'input';
|
||||
|
||||
const useStyles = makeStyles(
|
||||
{
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
name: 'CatalogReactEntityTagPicker',
|
||||
},
|
||||
);
|
||||
|
||||
const icon = <CheckBoxOutlineBlankIcon fontSize="small" />;
|
||||
const checkedIcon = <CheckBoxIcon fontSize="small" />;
|
||||
|
||||
/** @public */
|
||||
export type EntityTagPickerProps = {
|
||||
showCounts?: boolean;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles(
|
||||
{ input: {} },
|
||||
{ name: 'CatalogReactEntityTagPicker' },
|
||||
);
|
||||
|
||||
/** @public */
|
||||
export const EntityTagPicker = (props: EntityTagPickerProps) => {
|
||||
const classes = useStyles();
|
||||
const {
|
||||
updateFilters,
|
||||
filters,
|
||||
queryParameters: { tags: tagsParameter },
|
||||
} = useEntityList();
|
||||
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value: availableTags } = useAsync(async () => {
|
||||
const facet = 'metadata.tags';
|
||||
const { facets } = await catalogApi.getEntityFacets({
|
||||
facets: [facet],
|
||||
filter: filters.kind?.getCatalogFilters(),
|
||||
});
|
||||
|
||||
return Object.fromEntries(
|
||||
facets[facet].map(({ value, count }) => [value, count]),
|
||||
);
|
||||
}, [filters.kind]);
|
||||
|
||||
const queryParamTags = useMemo(
|
||||
() => [tagsParameter].flat().filter(Boolean) as string[],
|
||||
[tagsParameter],
|
||||
);
|
||||
|
||||
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(() => {
|
||||
const tags = Object.keys(availableTags ?? {});
|
||||
updateFilters({
|
||||
tags:
|
||||
selectedTags.length && tags.length
|
||||
? new EntityTagFilter(selectedTags)
|
||||
: undefined,
|
||||
});
|
||||
}, [selectedTags, updateFilters, availableTags]);
|
||||
|
||||
if (!Object.keys(availableTags ?? {}).length) return null;
|
||||
|
||||
return (
|
||||
<Box pb={1} pt={1}>
|
||||
<Typography variant="button" component="label">
|
||||
Tags
|
||||
<Autocomplete
|
||||
multiple
|
||||
options={Object.keys(availableTags ?? {})}
|
||||
value={selectedTags}
|
||||
onChange={(_: object, value: string[]) => setSelectedTags(value)}
|
||||
renderOption={(option, { selected }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
icon={icon}
|
||||
checkedIcon={checkedIcon}
|
||||
checked={selected}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
props.showCounts
|
||||
? `${option} (${availableTags?.[option]})`
|
||||
: option
|
||||
}
|
||||
/>
|
||||
)}
|
||||
size="small"
|
||||
popupIcon={<ExpandMoreIcon data-testid="tag-picker-expand" />}
|
||||
renderInput={params => (
|
||||
<TextField
|
||||
{...params}
|
||||
className={classes.input}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Typography>
|
||||
</Box>
|
||||
<EntityAutocompletePicker
|
||||
label="Tags"
|
||||
name="tags"
|
||||
path="metadata.tags"
|
||||
Filter={EntityTagFilter}
|
||||
showCounts={props.showCounts}
|
||||
InputProps={{ className: classes.input }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -27,26 +27,20 @@ import {
|
||||
} from '../hooks/useEntityListProvider';
|
||||
|
||||
/** @public */
|
||||
export const MockEntityListContextProvider = ({
|
||||
export function MockEntityListContextProvider<
|
||||
T extends DefaultEntityFilters = DefaultEntityFilters,
|
||||
>({
|
||||
children,
|
||||
value,
|
||||
}: PropsWithChildren<{
|
||||
value?: Partial<EntityListContextProps>;
|
||||
}>) => {
|
||||
value?: Partial<EntityListContextProps<T>>;
|
||||
}>) {
|
||||
// Provides a default implementation that stores filter state, for testing components that
|
||||
// reflect filter state.
|
||||
const [filters, setFilters] = useState<DefaultEntityFilters>(
|
||||
value?.filters ?? {},
|
||||
);
|
||||
const [filters, setFilters] = useState<T>(value?.filters ?? ({} as T));
|
||||
|
||||
const updateFilters = useCallback(
|
||||
(
|
||||
update:
|
||||
| Partial<DefaultEntityFilters>
|
||||
| ((
|
||||
prevFilters: DefaultEntityFilters,
|
||||
) => Partial<DefaultEntityFilters>),
|
||||
) => {
|
||||
(update: Partial<T> | ((prevFilters: T) => Partial<T>)) => {
|
||||
setFilters(prevFilters => {
|
||||
const newFilters =
|
||||
typeof update === 'function' ? update(prevFilters) : update;
|
||||
@@ -67,7 +61,7 @@ export const MockEntityListContextProvider = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const resolvedValue: EntityListContextProps = useMemo(
|
||||
const resolvedValue: EntityListContextProps<T> = useMemo(
|
||||
() => ({
|
||||
entities: value?.entities ?? defaultValues.entities,
|
||||
backendEntities: value?.backendEntities ?? defaultValues.backendEntities,
|
||||
@@ -85,4 +79,4 @@ export const MockEntityListContextProvider = ({
|
||||
{children}
|
||||
</EntityListContext.Provider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3457,6 +3457,7 @@ __metadata:
|
||||
"@types/stoppable": ^1.1.0
|
||||
"@types/supertest": ^2.0.8
|
||||
"@types/tar": ^6.1.1
|
||||
"@types/triple-beam": ^1.3.2
|
||||
"@types/webpack-env": ^1.15.2
|
||||
"@types/yauzl": ^2.10.0
|
||||
archiver: ^5.0.2
|
||||
@@ -15594,6 +15595,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/triple-beam@npm:^1.3.2":
|
||||
version: 1.3.2
|
||||
resolution: "@types/triple-beam@npm:1.3.2"
|
||||
checksum: dd7b4a563fb710abc992e5d59eac481bed9e303fada2e276e37b00be31c392e03300ee468e57761e616512872e77935f92472877d0704a19688d15a726cee17b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/trusted-types@npm:*":
|
||||
version: 2.0.2
|
||||
resolution: "@types/trusted-types@npm:2.0.2"
|
||||
|
||||
Reference in New Issue
Block a user