diff --git a/.changeset/fluffy-jars-protect.md b/.changeset/fluffy-jars-protect.md new file mode 100644 index 0000000000..89ff39d9af --- /dev/null +++ b/.changeset/fluffy-jars-protect.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +Creates new CatalogAutocomplete component in catalog-react that aligns with Select component UI for consistent a dropdown UI for all catalog filters. diff --git a/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.stories.tsx b/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.stories.tsx new file mode 100644 index 0000000000..3dee44d27a --- /dev/null +++ b/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.stories.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2024 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 React, { ComponentType } from 'react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { CatalogAutocomplete } from './CatalogAutocomplete'; + +export default { + title: 'Catalog /CatalogAutocomplete', + decorators: [(Story: ComponentType<{}>) => wrapInTestApp()], +}; + +export const Default = (args: any) => { + return ; +}; + +Default.args = { + multiple: true, + label: 'Default', + name: 'default', + options: ['test 1', 'test 2', 'test 3'], +}; diff --git a/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.test.tsx b/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.test.tsx new file mode 100644 index 0000000000..e574919611 --- /dev/null +++ b/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright 2024 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 React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CatalogAutocomplete } from './CatalogAutocomplete'; + +describe('CatalogAutocomplete', () => { + const user = userEvent.setup(); + const mockOptions = ['Option 1', 'Option 2', 'Option 3']; + + it('renders without exploding', () => { + render( + , + ); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders the expand icon', () => { + render( + , + ); + const expandIcon = screen.getByTestId('test-autocomplete-expand'); + expect(expandIcon).toBeInTheDocument(); + }); + + it('displays options when clicked', async () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + mockOptions.forEach(option => { + expect(screen.getByText(option)).toBeInTheDocument(); + }); + }); + + it('supports required input', () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeRequired(); + }); + + it('displays helper text when provided', () => { + render( + , + ); + + expect(screen.getByText('Helper text')).toBeInTheDocument(); + }); + + it('renders without label', () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + expect(input).toBeInTheDocument(); + }); + + it('displays correct option on selection', async () => { + render( + , + ); + + const input = screen.getByRole('textbox'); + await user.click(input); + + const optionToSelect = screen.getByText('Option 1'); + await user.click(optionToSelect); + + expect(input).toHaveValue('Option 1'); + }); +}); diff --git a/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.tsx b/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.tsx new file mode 100644 index 0000000000..af41db8626 --- /dev/null +++ b/plugins/catalog-react/src/components/CatalogAutocomplete/CatalogAutocomplete.tsx @@ -0,0 +1,189 @@ +/* + * Copyright 2024 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 from '@material-ui/core/Box'; +import Typography, { TypographyProps } from '@material-ui/core/Typography'; +import Paper, { PaperProps } from '@material-ui/core/Paper'; +import Popper, { PopperProps } from '@material-ui/core/Popper'; +import TextField, { OutlinedTextFieldProps } from '@material-ui/core/TextField'; +import Grow from '@material-ui/core/Grow'; +import { + createStyles, + makeStyles, + Theme, + withStyles, +} from '@material-ui/core/styles'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import Autocomplete, { + AutocompleteProps, + AutocompleteRenderInputParams, +} from '@material-ui/lab/Autocomplete'; +import React, { ReactNode, useCallback } from 'react'; +import { merge } from 'lodash'; +import classNames from 'classnames'; + +const useStyles = makeStyles( + theme => ({ + root: { + margin: theme.spacing(1, 0), + }, + label: { + position: 'relative', + fontWeight: 'bold', + fontSize: theme.typography.body2.fontSize, + fontFamily: theme.typography.fontFamily, + color: theme.palette.text.primary, + '& > span': { + top: 0, + left: 0, + position: 'absolute', + }, + }, + }), + { name: 'BackstageAutocomplete' }, +); + +const BootstrapAutocomplete = withStyles( + (theme: Theme) => + createStyles({ + root: {}, + paper: { + margin: 0, + }, + hasClearIcon: {}, + hasPopupIcon: {}, + focused: {}, + inputRoot: { + marginTop: 24, + backgroundColor: theme.palette.background.paper, + '$root$hasClearIcon$hasPopupIcon &': { + paddingBlock: theme.spacing(0.75), + paddingInlineStart: theme.spacing(0.75), + }, + '$root$focused &': { + outline: 'none', + }, + '$root &:hover > fieldset': { + borderColor: '#ced4da', + }, + '$root$focused & > fieldset': { + borderWidth: 1, + borderColor: theme.palette.primary.main, + }, + }, + popupIndicator: { + padding: 0, + margin: 0, + color: '#616161', + '&:hover': { + backgroundColor: 'unset', + }, + '& [class*="MuiTouchRipple-root"]': { + display: 'none', + }, + }, + endAdornment: { + '$root$hasClearIcon$hasPopupIcon &': { + right: 4, + }, + }, + input: { + '$root$hasClearIcon$hasPopupIcon &': { + fontSize: theme.typography.body1.fontSize, + paddingBlock: theme.spacing(0.8125), + }, + }, + }), + { name: 'BackstageAutocompleteBase' }, +)(Autocomplete) as typeof Autocomplete; + +const PopperComponent = (props: PopperProps) => ( + + {({ TransitionProps }) => ( + + {props.children as ReactNode} + + )} + +); + +const PaperComponent = (props: PaperProps) => ( + +); + +export type CatalogAutocompleteProps< + T, + Multiple extends boolean | undefined = undefined, + DisableClearable extends boolean | undefined = undefined, + FreeSolo extends boolean | undefined = undefined, +> = Omit< + AutocompleteProps, + 'PopperComponent' | 'PaperComponent' | 'popupIcon' | 'renderInput' +> & { + name: string; + label?: string; + LabelProps?: TypographyProps<'label'>; + TextFieldProps?: Omit; + renderInput?: AutocompleteProps< + T, + Multiple, + DisableClearable, + FreeSolo + >['renderInput']; +}; + +export function CatalogAutocomplete< + T, + Multiple extends boolean | undefined = undefined, + DisableClearable extends boolean | undefined = undefined, + FreeSolo extends boolean | undefined = undefined, +>(props: CatalogAutocompleteProps) { + const { label, name, LabelProps, TextFieldProps, ...rest } = props; + const classes = useStyles(); + const renderInput = useCallback( + (params: AutocompleteRenderInputParams) => ( + + ), + [TextFieldProps], + ); + const autocomplete = ( + } + PaperComponent={PaperComponent} + PopperComponent={PopperComponent} + /> + ); + + return ( + + {label ? ( + + {label} + {autocomplete} + + ) : ( + autocomplete + )} + + ); +} diff --git a/plugins/catalog-react/src/components/CatalogAutocomplete/index.tsx b/plugins/catalog-react/src/components/CatalogAutocomplete/index.tsx new file mode 100644 index 0000000000..7f1552c5bb --- /dev/null +++ b/plugins/catalog-react/src/components/CatalogAutocomplete/index.tsx @@ -0,0 +1,19 @@ +/* + * Copyright 2024 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 { + CatalogAutocomplete, + type CatalogAutocompleteProps, +} from './CatalogAutocomplete'; diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx index fd88a7a31e..09b5c1e908 100644 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx +++ b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePicker.tsx @@ -16,22 +16,19 @@ import Box from '@material-ui/core/Box'; import { TextFieldProps } from '@material-ui/core/TextField'; -import Typography from '@material-ui/core/Typography'; import { makeStyles } from '@material-ui/core/styles'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import Autocomplete from '@material-ui/lab/Autocomplete'; -import React, { useEffect, useMemo, useState, ReactNode } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useApi } from '@backstage/core-plugin-api'; import useAsync from 'react-use/esm/useAsync'; import { catalogApiRef } from '../../api'; import { EntityAutocompletePickerOption } from './EntityAutocompletePickerOption'; -import { EntityAutocompletePickerInput } from './EntityAutocompletePickerInput'; import { DefaultEntityFilters, useEntityList, } from '../../hooks/useEntityListProvider'; import { EntityFilter } from '../../types'; import { reduceBackendCatalogFilters } from '../../utils/filters'; +import { CatalogAutocomplete } from '../CatalogAutocomplete'; /** @public */ export type AllowedEntityFilters = { @@ -86,7 +83,6 @@ export function EntityAutocompletePicker< initialSelectedOptions = [], filtersForAvailableValues = ['kind'], } = props; - const classes = useStyles(); const { @@ -152,36 +148,26 @@ export function EntityAutocompletePicker< return ( - - {label} - - PopperComponent={popperProps => ( -
{popperProps.children as ReactNode}
- )} - multiple - disableCloseOnSelect - options={availableOptions} - value={selectedOptions} - onChange={(_event: object, options: string[]) => - setSelectedOptions(options) - } - renderOption={(option, { selected }) => ( - - )} - size="small" - popupIcon={ - - } - renderInput={params => ( - - )} - /> -
+ + multiple + disableCloseOnSelect + label={label} + name={`${String(name)}-picker`} + options={availableOptions} + value={selectedOptions} + TextFieldProps={InputProps} + onChange={(_event: object, options: string[]) => + setSelectedOptions(options) + } + renderOption={(option, { selected }) => ( + + )} + />
); } diff --git a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx b/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx deleted file mode 100644 index 133024330c..0000000000 --- a/plugins/catalog-react/src/components/EntityAutocompletePicker/EntityAutocompletePickerInput.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 TextField, { TextFieldProps } from '@material-ui/core/TextField'; -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; -import React from 'react'; -import classnames from 'classnames'; - -const useStyles = makeStyles( - (theme: Theme) => - createStyles({ - input: { - backgroundColor: theme.palette.background.paper, - }, - }), - { - name: 'CatalogReactEntityAutocompletePickerInput', - }, -); - -export function EntityAutocompletePickerInput(params: TextFieldProps) { - const classes = useStyles(); - - return ( - - ); -} diff --git a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx index ecbdae1d63..b70ec0bcb5 100644 --- a/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx +++ b/plugins/catalog-react/src/components/EntityOwnerPicker/EntityOwnerPicker.tsx @@ -22,15 +22,12 @@ import { import Box from '@material-ui/core/Box'; import Checkbox from '@material-ui/core/Checkbox'; import FormControlLabel from '@material-ui/core/FormControlLabel'; -import TextField from '@material-ui/core/TextField'; import Typography from '@material-ui/core/Typography'; import Tooltip from '@material-ui/core/Tooltip'; -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import { makeStyles } from '@material-ui/core/styles'; 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/Autocomplete'; -import React, { useEffect, useMemo, useState, ReactNode } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { useEntityList } from '../../hooks/useEntityListProvider'; import { EntityOwnerFilter } from '../../filters'; import { useDebouncedEffect } from '@react-hookz/web'; @@ -42,29 +39,23 @@ import { withStyles } from '@material-ui/core/styles'; import { useEntityPresentation } from '../../apis'; import { catalogReactTranslationRef } from '../../translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; -import { PopperProps } from '@material-ui/core/Popper'; +import { CatalogAutocomplete } from '../CatalogAutocomplete'; /** @public */ export type CatalogReactEntityOwnerPickerClassKey = 'input'; const useStyles = makeStyles( - (theme: Theme) => - createStyles({ - root: {}, - label: { - textTransform: 'none', - fontWeight: 'bold', - }, - input: { - backgroundColor: theme.palette.background.paper, - }, - fullWidth: { width: '100%' }, - boxLabel: { - width: '100%', - textOverflow: 'ellipsis', - overflow: 'hidden', - }, - }), + { + root: {}, + label: {}, + input: {}, + fullWidth: { width: '100%' }, + boxLabel: { + width: '100%', + textOverflow: 'ellipsis', + overflow: 'hidden', + }, + }, { name: 'CatalogReactEntityOwnerPicker' }, ); @@ -147,7 +138,7 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { [ownersParameter], ); - const [selectedOwners, setSelectedOwners] = useState( + const [selectedOwners, setSelectedOwners] = useState( queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [], ); @@ -186,84 +177,69 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => { return ( - - {t('entityOwnerPicker.title')} - { - if (typeof v === 'string') { - return stringifyEntityRef(o) === v; - } - return o === v; - }} - getOptionLabel={o => { - const entity = - typeof o === 'string' - ? cache.getEntity(o) || - parseEntityRef(o, { - defaultKind: 'group', - defaultNamespace: 'default', - }) - : o; - return humanizeEntity(entity, humanizeEntityRef(entity)); - }} - onChange={(_: object, owners) => { - setText(''); - setSelectedOwners( - owners.map(e => { - const entityRef = - typeof e === 'string' ? e : stringifyEntityRef(e); + + label={t('entityOwnerPicker.title')} + multiple + disableCloseOnSelect + loading={loading} + options={availableOwners} + value={selectedOwners as unknown as Entity[]} + getOptionSelected={(o, v) => { + if (typeof v === 'string') { + return stringifyEntityRef(o) === v; + } + return o === v; + }} + getOptionLabel={o => { + const entity = + typeof o === 'string' + ? cache.getEntity(o) || + parseEntityRef(o, { + defaultKind: 'group', + defaultNamespace: 'default', + }) + : o; + return humanizeEntity(entity, humanizeEntityRef(entity)); + }} + onChange={(_: object, owners) => { + setText(''); + setSelectedOwners( + owners.map(e => { + const entityRef = + typeof e === 'string' ? e : stringifyEntityRef(e); - if (typeof e !== 'string') { - cache.setEntity(e); - } - return entityRef; - }), - ); - }} - filterOptions={x => x} - renderOption={(entity, { selected }) => { - return ; - }} - size="small" - popupIcon={} - renderInput={params => ( - { - setText(e.currentTarget.value); - }} - variant="outlined" - /> - )} - ListboxProps={{ - onScroll: (e: React.MouseEvent) => { - const element = e.currentTarget; - const hasReachedEnd = - Math.abs( - element.scrollHeight - - element.clientHeight - - element.scrollTop, - ) < 1; - - if (hasReachedEnd && value?.cursor) { - handleFetch({ items: value.items, cursor: value.cursor }); + if (typeof e !== 'string') { + cache.setEntity(e); } - }, - 'data-testid': 'owner-picker-listbox', - }} - /> - + return entityRef; + }), + ); + }} + filterOptions={x => x} + renderOption={(entity, { selected }) => { + return ; + }} + name="owner-picker" + onInputChange={(_e, inputValue) => { + setText(inputValue); + }} + ListboxProps={{ + onScroll: (e: React.MouseEvent) => { + const element = e.currentTarget; + const hasReachedEnd = + Math.abs( + element.scrollHeight - element.clientHeight - element.scrollTop, + ) < 1; + + if (hasReachedEnd && value?.cursor) { + handleFetch({ items: value.items, cursor: value.cursor }); + } + }, + 'data-testid': 'owner-picker-listbox', + }} + LabelProps={{ className: classes.label }} + TextFieldProps={{ className: classes.input }} + /> ); }; - -function Popper({ children }: PopperProps) { - return
{children as ReactNode}
; -} diff --git a/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.tsx b/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.tsx index a2f0c8f729..a1b6e5b562 100644 --- a/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.tsx +++ b/plugins/catalog-react/src/components/EntityProcessingStatusPicker/EntityProcessingStatusPicker.tsx @@ -18,33 +18,24 @@ import { EntityErrorFilter, EntityOrphanFilter } from '../../filters'; import Box from '@material-ui/core/Box'; import Checkbox from '@material-ui/core/Checkbox'; import FormControlLabel from '@material-ui/core/FormControlLabel'; -import TextField from '@material-ui/core/TextField'; -import Typography from '@material-ui/core/Typography'; -import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import { makeStyles } from '@material-ui/core/styles'; import CheckBoxIcon from '@material-ui/icons/CheckBox'; import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import React, { useState, ReactNode } from 'react'; +import React, { useState } from 'react'; import { useEntityList } from '../../hooks'; -import Autocomplete from '@material-ui/lab/Autocomplete'; import { catalogReactTranslationRef } from '../../translation'; import { useTranslationRef } from '@backstage/core-plugin-api/alpha'; +import { CatalogAutocomplete } from '../CatalogAutocomplete'; /** @public */ export type CatalogReactEntityProcessingStatusPickerClassKey = 'input'; const useStyles = makeStyles( - (theme: Theme) => - createStyles({ - root: {}, - input: { - backgroundColor: theme.palette.background.paper, - }, - label: { - textTransform: 'none', - fontWeight: 'bold', - }, - }), + { + root: {}, + input: {}, + label: {}, + }, { name: 'CatalogReactEntityProcessingStatusPickerPicker' }, ); @@ -77,47 +68,34 @@ export const EntityProcessingStatusPicker = () => { return ( - - {t('entityProcessingStatusPicker.title')} - - PopperComponent={popperProps => ( -
{popperProps.children as ReactNode}
- )} - multiple - disableCloseOnSelect - options={availableAdvancedItems} - value={selectedAdvancedItems} - onChange={(_: object, value: string[]) => { - setSelectedAdvancedItems(value); - orphanChange(value.includes('Is Orphan')); - errorChange(value.includes('Has Error')); - }} - renderOption={(option, { selected }) => ( - - } - onClick={event => event.preventDefault()} - label={option} - /> - )} - size="small" - popupIcon={ - - } - renderInput={params => ( - - )} - /> -
+ + label={t('entityProcessingStatusPicker.title')} + multiple + disableCloseOnSelect + options={availableAdvancedItems} + value={selectedAdvancedItems} + onChange={(_: object, value: string[]) => { + setSelectedAdvancedItems(value); + orphanChange(value.includes('Is Orphan')); + errorChange(value.includes('Has Error')); + }} + renderOption={(option, { selected }) => ( + + } + onClick={event => event.preventDefault()} + label={option} + /> + )} + name="processing-status-picker" + LabelProps={{ className: classes.label }} + TextFieldProps={{ className: classes.input }} + />
); };