diff --git a/.changeset/new-jobs-deny.md b/.changeset/new-jobs-deny.md new file mode 100644 index 0000000000..656d141330 --- /dev/null +++ b/.changeset/new-jobs-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Add possibility to re-use EntityPicker for filters with multiple select diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index 095b6832a4..4756432857 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -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> | undefined; -}>) => JSX.Element; +}: PropsWithChildren<{ + value?: Partial>; +}>): JSX.Element; // @public export class MockStarredEntitiesApi implements StarredEntitiesApi { diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 705c1b544a..2507e9bf8d 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -40,6 +40,7 @@ "@backstage/errors": "workspace:^", "@backstage/integration": "workspace:^", "@backstage/plugin-catalog-common": "workspace:^", + "@backstage/plugin-catalog-react": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-react": "workspace:^", "@backstage/theme": "workspace:^", diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx new file mode 100644 index 0000000000..3d23a9ec79 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx @@ -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('', () => { + const mockCatalogApiRef = { + getEntityFacets: async () => ({ + facets: { + 'spec.options': options.map((value, idx) => ({ value, count: idx })), + }, + }), + } as unknown as CatalogApi; + + it('renders all options', async () => { + render( + + + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + 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( + + + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + 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( + + + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + showCounts + /> + + , + ); + 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( + + + value={{ + updateFilters, + queryParameters, + }} + > + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + options: new EntityOptionFilter(['option3']), + }), + ); + }); + + it('adds options to filters', async () => { + const updateFilters = jest.fn(); + render( + + + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + 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( + + + value={{ + updateFilters, + filters: { options: new EntityOptionFilter(['option1']) }, + }} + > + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + 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( + + + value={{ + updateFilters, + queryParameters: { options: ['option1'] }, + }} + > + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + await waitFor(() => + expect(updateFilters).toHaveBeenLastCalledWith({ + options: new EntityOptionFilter(['option1']), + }), + ); + rendered.rerender( + + + value={{ + updateFilters, + queryParameters: { options: ['option2'] }, + }} + > + + label="Options" + path="spec.options" + name="options" + Filter={EntityOptionFilter} + /> + + , + ); + expect(updateFilters).toHaveBeenLastCalledWith({ + options: new EntityOptionFilter(['option2']), + }); + }); +}); diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx new file mode 100644 index 0000000000..f3cb589c50 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -0,0 +1,149 @@ +/* + * 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, 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 { + DefaultEntityFilters, + useEntityList, +} from '../../hooks/useEntityListProvider'; +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 { EntityFilter } from '@backstage/plugin-catalog-react'; + +type KeysMatchingCondition = T extends V ? K : never; +type KeysMatching = { + [K in keyof T]-?: KeysMatchingCondition; +}[keyof T]; + +type AllowedEntityFilters = KeysMatching< + T, + EntityFilter & { values: string[] } +>; + +interface ConstructableFilter { + new (values: string[]): T; +} + +/** @public */ +export type EntityAutocompletePickerProps< + T extends DefaultEntityFilters = DefaultEntityFilters, + Name extends AllowedEntityFilters = AllowedEntityFilters, +> = { + label: string; + name: Name; + path: string; + showCounts?: boolean; + Filter: ConstructableFilter>; +}; + +/** @public */ +export function EntityAutocompletePicker< + T extends DefaultEntityFilters = DefaultEntityFilters, + Name extends AllowedEntityFilters = AllowedEntityFilters, +>(props: EntityAutocompletePickerProps) { + const { label, name, path, showCounts, Filter } = props; + + const { + updateFilters, + filters, + queryParameters: { [name]: queryParameter }, + } = useEntityList(); + + 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) { + setSelectedOptions(queryParameters); + } + }, [queryParameters]); + + const availableOptions = Object.keys(availableValues ?? {}); + + useEffect(() => { + updateFilters({ + [name]: + selectedOptions.length && availableOptions.length + ? new Filter(selectedOptions) + : undefined, + } as Partial); + }, [name, Filter, selectedOptions, availableOptions, updateFilters]); + + if ( + (filters[name] && !('values' in filters[name])) || + !availableOptions.length + ) { + return null; + } + + return ( + + + {label} + + setSelectedOptions(options) + } + renderOption={(option, { selected }) => ( + + )} + size="small" + popupIcon={ + + } + renderInput={EntityAutocompletePickerInput} + /> + + + ); +} diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx new file mode 100644 index 0000000000..586485fab1 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx @@ -0,0 +1,35 @@ +/* + * 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 } from '@material-ui/core'; +import { AutocompleteRenderInputParams } from '@material-ui/lab'; +import React from 'react'; + +const useStyles = makeStyles( + { + input: {}, + }, + { + name: 'CatalogReactEntityTagPicker', + }, +); + +export function EntityAutocompletePickerInput( + params: AutocompleteRenderInputParams, +) { + const classes = useStyles(); + + return ; +} diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerOption.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerOption.tsx new file mode 100644 index 0000000000..51b57257a9 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerOption.tsx @@ -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; + showCounts: boolean; +} + +const icon = ; +const checkedIcon = ; + +function OptionCheckbox({ selected }: { selected: boolean }) { + return ; +} + +export const EntityAutocompletePickerOption = memo((props: Props) => { + const { selected, value, availableOptions, showCounts } = props; + const label = showCounts ? `${value} (${availableOptions?.[value]})` : value; + + return ( + } + label={label} + /> + ); +}); diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts b/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts new file mode 100644 index 0000000000..10ce0123b7 --- /dev/null +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts @@ -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'; diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx index de7bbb89be..c70217f3a4 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.test.tsx @@ -44,7 +44,7 @@ describe('', () => { ); 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('', () => { ); 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('', () => { ); 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('', () => { }), ); - 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('', () => { 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')); diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx index 4a1dc03e96..7b08ddf745 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -14,40 +14,13 @@ * 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 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 = ; -const checkedIcon = ; - /** @public */ export type EntityTagPickerProps = { showCounts?: boolean; @@ -55,91 +28,13 @@ export type EntityTagPickerProps = { /** @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 ( - - - Tags - setSelectedTags(value)} - renderOption={(option, { selected }) => ( - - } - label={ - props.showCounts - ? `${option} (${availableTags?.[option]})` - : option - } - /> - )} - size="small" - popupIcon={} - renderInput={params => ( - - )} - /> - - + ); }; diff --git a/plugins/catalog-react/src/testUtils/providers.tsx b/plugins/catalog-react/src/testUtils/providers.tsx index 5867026a8c..9bf5b21f94 100644 --- a/plugins/catalog-react/src/testUtils/providers.tsx +++ b/plugins/catalog-react/src/testUtils/providers.tsx @@ -27,26 +27,20 @@ import { } from '../hooks/useEntityListProvider'; /** @public */ -export const MockEntityListContextProvider = ({ +export function MockEntityListContextProvider< + T extends DefaultEntityFilters = DefaultEntityFilters, +>({ children, value, }: PropsWithChildren<{ - value?: Partial; -}>) => { + value?: Partial>; +}>) { // Provides a default implementation that stores filter state, for testing components that // reflect filter state. - const [filters, setFilters] = useState( - value?.filters ?? {}, - ); + const [filters, setFilters] = useState(value?.filters ?? ({} as T)); const updateFilters = useCallback( - ( - update: - | Partial - | (( - prevFilters: DefaultEntityFilters, - ) => Partial), - ) => { + (update: Partial | ((prevFilters: T) => Partial)) => { setFilters(prevFilters => { const newFilters = typeof update === 'function' ? update(prevFilters) : update; @@ -67,7 +61,7 @@ export const MockEntityListContextProvider = ({ [], ); - const resolvedValue: EntityListContextProps = useMemo( + const resolvedValue: EntityListContextProps = useMemo( () => ({ entities: value?.entities ?? defaultValues.entities, backendEntities: value?.backendEntities ?? defaultValues.backendEntities, @@ -85,4 +79,4 @@ export const MockEntityListContextProvider = ({ {children} ); -}; +} diff --git a/yarn.lock b/yarn.lock index 6e252cb1c2..1988f9bcf0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5425,6 +5425,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/integration": "workspace:^" "@backstage/plugin-catalog-common": "workspace:^" + "@backstage/plugin-catalog-react": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-react": "workspace:^" "@backstage/plugin-scaffolder-common": "workspace:^"