From 0a5b73b29265a8dd010348766a15bb002e441b36 Mon Sep 17 00:00:00 2001 From: Ilya Savich Date: Thu, 8 Dec 2022 15:37:35 +0100 Subject: [PATCH 1/5] Refactor entity picker, make it re-usable Signed-off-by: Ilya Savich --- .changeset/new-jobs-deny.md | 5 + plugins/catalog-react/api-report.md | 14 +- plugins/catalog-react/package.json | 1 + .../EntityAutocompletePicker.test.tsx | 272 ++++++++++++++++++ .../EntityAutocompletePicker.tsx | 149 ++++++++++ .../EntityAutocompletePickerInput.tsx | 35 +++ .../EntityAutocompletePickerOption.tsx | 45 +++ .../EntityAutocompletePicker/index.ts | 18 ++ .../EntityTagPicker/EntityTagPicker.test.tsx | 10 +- .../EntityTagPicker/EntityTagPicker.tsx | 123 +------- .../catalog-react/src/testUtils/providers.tsx | 24 +- yarn.lock | 1 + 12 files changed, 556 insertions(+), 141 deletions(-) create mode 100644 .changeset/new-jobs-deny.md create mode 100644 plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx create mode 100644 plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx create mode 100644 plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx create mode 100644 plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerOption.tsx create mode 100644 plugins/catalog-react/src/components/EntityAutocompletePicker/index.ts 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:^" From 0dd9b5ef072a2d12199ec62d353159e7379a3223 Mon Sep 17 00:00:00 2001 From: Ilya Savich Date: Tue, 20 Dec 2022 12:56:22 +0100 Subject: [PATCH 2/5] Rename EntityAutocompletePickerInput styling name Signed-off-by: Ilya Savich --- .changeset/new-jobs-deny.md | 5 +++-- plugins/catalog-react/package.json | 1 - .../EntityAutocompletePicker.test.tsx | 18 +++++++++--------- .../EntityAutocompletePicker.tsx | 2 +- .../EntityAutocompletePickerInput.tsx | 2 +- yarn.lock | 1 - 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.changeset/new-jobs-deny.md b/.changeset/new-jobs-deny.md index 656d141330..ff656adfc3 100644 --- a/.changeset/new-jobs-deny.md +++ b/.changeset/new-jobs-deny.md @@ -1,5 +1,6 @@ --- -'@backstage/plugin-catalog-react': patch +'@backstage/plugin-catalog-react': minor --- -Add possibility to re-use EntityPicker for filters with multiple select +Add possibility to re-use EntityPicker for filters with multiple select. +**BREAKING** Style name `CatalogReactEntityTagPicker` was changed to `EntityAutocompletePickerInput` diff --git a/plugins/catalog-react/package.json b/plugins/catalog-react/package.json index 2507e9bf8d..705c1b544a 100644 --- a/plugins/catalog-react/package.json +++ b/plugins/catalog-react/package.json @@ -40,7 +40,6 @@ "@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 index 3d23a9ec79..38edb9b064 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.test.tsx @@ -46,7 +46,7 @@ class EntityOptionFilter implements EntityFilter { } describe('', () => { - const mockCatalogApiRef = { + const mockCatalogApi = { getEntityFacets: async () => ({ facets: { 'spec.options': options.map((value, idx) => ({ value, count: idx })), @@ -56,7 +56,7 @@ describe('', () => { it('renders all options', async () => { render( - + label="Options" @@ -79,7 +79,7 @@ describe('', () => { it('renders unique options in alphabetical order', async () => { render( - + label="Options" @@ -106,7 +106,7 @@ describe('', () => { it('renders options with counts', async () => { render( - + label="Options" @@ -136,7 +136,7 @@ describe('', () => { const updateFilters = jest.fn(); const queryParameters = { options: ['option3'] }; render( - + value={{ updateFilters, @@ -163,7 +163,7 @@ describe('', () => { it('adds options to filters', async () => { const updateFilters = jest.fn(); render( - + ', () => { it('removes options from filters', async () => { const updateFilters = jest.fn(); render( - + value={{ updateFilters, @@ -227,7 +227,7 @@ describe('', () => { it('responds to external queryParameters changes', async () => { const updateFilters = jest.fn(); const rendered = render( - + value={{ updateFilters, @@ -249,7 +249,7 @@ describe('', () => { }), ); rendered.rerender( - + value={{ updateFilters, diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index f3cb589c50..7994571e8f 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -27,7 +27,7 @@ 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'; +import { EntityFilter } from '../../types'; type KeysMatchingCondition = T extends V ? K : never; type KeysMatching = { diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx index 586485fab1..8069f40871 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx @@ -22,7 +22,7 @@ const useStyles = makeStyles( input: {}, }, { - name: 'CatalogReactEntityTagPicker', + name: 'EntityAutocompletePickerInput', }, ); diff --git a/yarn.lock b/yarn.lock index 1988f9bcf0..6e252cb1c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5425,7 +5425,6 @@ __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:^" From 7c6a7b799ae6a1e3b7c5976ff7962ec0e1ab3fd8 Mon Sep 17 00:00:00 2001 From: Ilya Savich Date: Fri, 13 Jan 2023 18:00:10 +0100 Subject: [PATCH 3/5] add BC Signed-off-by: Ilya Savich --- .changeset/new-jobs-deny.md | 1 - .../EntityAutocompletePicker.tsx | 17 ++++++++++------- .../EntityAutocompletePickerInput.tsx | 18 +++++++++++------- .../EntityTagPicker/EntityTagPicker.tsx | 9 +++++++++ 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/.changeset/new-jobs-deny.md b/.changeset/new-jobs-deny.md index ff656adfc3..e7b5bff71a 100644 --- a/.changeset/new-jobs-deny.md +++ b/.changeset/new-jobs-deny.md @@ -3,4 +3,3 @@ --- Add possibility to re-use EntityPicker for filters with multiple select. -**BREAKING** Style name `CatalogReactEntityTagPicker` was changed to `EntityAutocompletePickerInput` diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index 7994571e8f..6a1467f87d 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -14,19 +14,19 @@ * limitations under the License. */ -import { Box, Typography } from '@material-ui/core'; +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 { - 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 { + DefaultEntityFilters, + useEntityList, +} from '../../hooks/useEntityListProvider'; import { EntityFilter } from '../../types'; type KeysMatchingCondition = T extends V ? K : never; @@ -53,6 +53,7 @@ export type EntityAutocompletePickerProps< path: string; showCounts?: boolean; Filter: ConstructableFilter>; + InputProps?: TextFieldProps; }; /** @public */ @@ -60,7 +61,7 @@ export function EntityAutocompletePicker< T extends DefaultEntityFilters = DefaultEntityFilters, Name extends AllowedEntityFilters = AllowedEntityFilters, >(props: EntityAutocompletePickerProps) { - const { label, name, path, showCounts, Filter } = props; + const { label, name, path, showCounts, Filter, InputProps } = props; const { updateFilters, @@ -141,7 +142,9 @@ export function EntityAutocompletePicker< popupIcon={ } - renderInput={EntityAutocompletePickerInput} + renderInput={params => ( + + )} /> diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx index 8069f40871..4140feae01 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx @@ -13,23 +13,27 @@ * 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 { makeStyles, TextField, TextFieldProps } from '@material-ui/core'; import React from 'react'; +import classnames from 'classnames'; const useStyles = makeStyles( { input: {}, }, { - name: 'EntityAutocompletePickerInput', + name: 'CatalogReactEntityAutocompletePickerInput', }, ); -export function EntityAutocompletePickerInput( - params: AutocompleteRenderInputParams, -) { +export function EntityAutocompletePickerInput(params: TextFieldProps) { const classes = useStyles(); - return ; + return ( + + ); } diff --git a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx index 7b08ddf745..0521bb49f8 100644 --- a/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx +++ b/plugins/catalog-react/src/components/EntityTagPicker/EntityTagPicker.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { makeStyles } from '@material-ui/core'; import React from 'react'; import { EntityTagFilter } from '../../filters'; import { EntityAutocompletePicker } from '../EntityAutocompletePicker/EntityAutocompletePicker'; @@ -26,8 +27,15 @@ export type EntityTagPickerProps = { showCounts?: boolean; }; +const useStyles = makeStyles( + { input: {} }, + { name: 'CatalogReactEntityTagPicker' }, +); + /** @public */ export const EntityTagPicker = (props: EntityTagPickerProps) => { + const classes = useStyles(); + return ( { path="metadata.tags" Filter={EntityTagFilter} showCounts={props.showCounts} + InputProps={{ className: classes.input }} /> ); }; From f5a9b0ac7fc5db0d358519a55974b3a59fb8d375 Mon Sep 17 00:00:00 2001 From: Ilya Savich Date: Tue, 7 Feb 2023 11:26:10 +0100 Subject: [PATCH 4/5] Fix many re-renderings Signed-off-by: Ilya Savich --- .../EntityAutocompletePicker.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index 6a1467f87d..09d5a45246 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -28,6 +28,7 @@ import { useEntityList, } from '../../hooks/useEntityListProvider'; import { EntityFilter } from '../../types'; +import _ from 'lodash'; type KeysMatchingCondition = T extends V ? K : never; type KeysMatching = { @@ -96,21 +97,22 @@ export function EntityAutocompletePicker< // 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) { + if ( + queryParameters.length && + !_.isEqual(selectedOptions, queryParameters) + ) { setSelectedOptions(queryParameters); } - }, [queryParameters]); + }, [selectedOptions, queryParameters]); const availableOptions = Object.keys(availableValues ?? {}); + const shouldAddFilter = selectedOptions.length && availableOptions.length; useEffect(() => { updateFilters({ - [name]: - selectedOptions.length && availableOptions.length - ? new Filter(selectedOptions) - : undefined, + [name]: shouldAddFilter ? new Filter(selectedOptions) : undefined, } as Partial); - }, [name, Filter, selectedOptions, availableOptions, updateFilters]); + }, [name, shouldAddFilter, selectedOptions, Filter, updateFilters]); if ( (filters[name] && !('values' in filters[name])) || @@ -127,7 +129,7 @@ export function EntityAutocompletePicker< multiple options={availableOptions} value={selectedOptions} - onChange={(_: object, options: string[]) => + onChange={(_event: object, options: string[]) => setSelectedOptions(options) } renderOption={(option, { selected }) => ( From f49c61de9ab1fe5ad64c9dde88c7aa9806ae8805 Mon Sep 17 00:00:00 2001 From: Ilya Savich Date: Tue, 7 Feb 2023 15:26:32 +0100 Subject: [PATCH 5/5] Add types to package to fix build Signed-off-by: Ilya Savich --- packages/backend-common/package.json | 1 + yarn.lock | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 9f1069f322..9f77f82a49 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index 6e252cb1c2..adcc15e13b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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 @@ -15443,6 +15444,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"