Merge branch 'master' into canon-website

This commit is contained in:
Charles de Dreuille
2025-01-09 16:53:31 +00:00
9 changed files with 500 additions and 237 deletions
+5
View File
@@ -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.
@@ -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(<Story />)],
};
export const Default = (args: any) => {
return <CatalogAutocomplete {...args} />;
};
Default.args = {
multiple: true,
label: 'Default',
name: 'default',
options: ['test 1', 'test 2', 'test 3'],
};
@@ -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(
<CatalogAutocomplete
name="test-autocomplete"
options={mockOptions}
label="Test Label"
/>,
);
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
it('renders the expand icon', () => {
render(
<CatalogAutocomplete
name="test-autocomplete"
options={mockOptions}
label="Test Label"
/>,
);
const expandIcon = screen.getByTestId('test-autocomplete-expand');
expect(expandIcon).toBeInTheDocument();
});
it('displays options when clicked', async () => {
render(
<CatalogAutocomplete
name="test-autocomplete"
options={mockOptions}
label="Test Label"
/>,
);
const input = screen.getByRole('textbox');
await user.click(input);
mockOptions.forEach(option => {
expect(screen.getByText(option)).toBeInTheDocument();
});
});
it('supports required input', () => {
render(
<CatalogAutocomplete
name="test-autocomplete"
options={mockOptions}
label="Test Label"
TextFieldProps={{ required: true }}
/>,
);
const input = screen.getByRole('textbox');
expect(input).toBeRequired();
});
it('displays helper text when provided', () => {
render(
<CatalogAutocomplete
name="test-autocomplete"
options={mockOptions}
label="Test Label"
TextFieldProps={{ helperText: 'Helper text' }}
/>,
);
expect(screen.getByText('Helper text')).toBeInTheDocument();
});
it('renders without label', () => {
render(
<CatalogAutocomplete name="test-autocomplete" options={mockOptions} />,
);
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
});
it('displays correct option on selection', async () => {
render(
<CatalogAutocomplete
name="test-autocomplete"
options={mockOptions}
label="Test Label"
/>,
);
const input = screen.getByRole('textbox');
await user.click(input);
const optionToSelect = screen.getByText('Option 1');
await user.click(optionToSelect);
expect(input).toHaveValue('Option 1');
});
});
@@ -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) => (
<Popper {...props} transition placement="bottom-start">
{({ TransitionProps }) => (
<Grow {...TransitionProps} style={{ transformOrigin: '0 0 0' }}>
<Box>{props.children as ReactNode}</Box>
</Grow>
)}
</Popper>
);
const PaperComponent = (props: PaperProps) => (
<Paper {...props} elevation={8} />
);
export type CatalogAutocompleteProps<
T,
Multiple extends boolean | undefined = undefined,
DisableClearable extends boolean | undefined = undefined,
FreeSolo extends boolean | undefined = undefined,
> = Omit<
AutocompleteProps<T, Multiple, DisableClearable, FreeSolo>,
'PopperComponent' | 'PaperComponent' | 'popupIcon' | 'renderInput'
> & {
name: string;
label?: string;
LabelProps?: TypographyProps<'label'>;
TextFieldProps?: Omit<OutlinedTextFieldProps, 'variant'>;
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<T, Multiple, DisableClearable, FreeSolo>) {
const { label, name, LabelProps, TextFieldProps, ...rest } = props;
const classes = useStyles();
const renderInput = useCallback(
(params: AutocompleteRenderInputParams) => (
<TextField {...merge(params, TextFieldProps)} variant="outlined" />
),
[TextFieldProps],
);
const autocomplete = (
<BootstrapAutocomplete
size="small"
{...rest}
renderInput={rest.renderInput ?? renderInput}
popupIcon={<ExpandMoreIcon data-testid={`${name}-expand`} />}
PaperComponent={PaperComponent}
PopperComponent={PopperComponent}
/>
);
return (
<Box className={classes.root}>
{label ? (
<Typography
{...LabelProps}
className={classNames(classes.label, LabelProps?.className)}
component="label"
>
<Box component="span">{label}</Box>
{autocomplete}
</Typography>
) : (
autocomplete
)}
</Box>
);
}
@@ -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';
@@ -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<T extends DefaultEntityFilters> = {
@@ -86,7 +83,6 @@ export function EntityAutocompletePicker<
initialSelectedOptions = [],
filtersForAvailableValues = ['kind'],
} = props;
const classes = useStyles();
const {
@@ -152,36 +148,26 @@ export function EntityAutocompletePicker<
return (
<Box className={classes.root} pb={1} pt={1}>
<Typography className={classes.label} variant="button" component="label">
{label}
<Autocomplete<string, true>
PopperComponent={popperProps => (
<div {...popperProps}>{popperProps.children as ReactNode}</div>
)}
multiple
disableCloseOnSelect
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>
<CatalogAutocomplete<string, true>
multiple
disableCloseOnSelect
label={label}
name={`${String(name)}-picker`}
options={availableOptions}
value={selectedOptions}
TextFieldProps={InputProps}
onChange={(_event: object, options: string[]) =>
setSelectedOptions(options)
}
renderOption={(option, { selected }) => (
<EntityAutocompletePickerOption
selected={selected}
value={option}
availableOptions={availableValues}
showCounts={!!showCounts}
/>
)}
/>
</Box>
);
}
@@ -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 (
<TextField
variant="outlined"
{...params}
className={classnames(classes.input, params.className)}
/>
);
}
@@ -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<string[]>(
queryParamOwners.length ? queryParamOwners : filters.owners?.values ?? [],
);
@@ -186,84 +177,69 @@ export const EntityOwnerPicker = (props?: EntityOwnerPickerProps) => {
return (
<Box className={classes.root} pb={1} pt={1}>
<Typography className={classes.label} variant="button" component="label">
{t('entityOwnerPicker.title')}
<Autocomplete
PopperComponent={Popper}
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);
<CatalogAutocomplete<Entity, true>
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 <RenderOptionLabel entity={entity} isSelected={selected} />;
}}
size="small"
popupIcon={<ExpandMoreIcon data-testid="owner-picker-expand" />}
renderInput={params => (
<TextField
{...params}
className={classes.input}
onChange={e => {
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',
}}
/>
</Typography>
return entityRef;
}),
);
}}
filterOptions={x => x}
renderOption={(entity, { selected }) => {
return <RenderOptionLabel entity={entity} isSelected={selected} />;
}}
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 }}
/>
</Box>
);
};
function Popper({ children }: PopperProps) {
return <div>{children as ReactNode}</div>;
}
@@ -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 (
<Box className={classes.root} pb={1} pt={1}>
<Typography className={classes.label} variant="button" component="label">
{t('entityProcessingStatusPicker.title')}
<Autocomplete<string, true>
PopperComponent={popperProps => (
<div {...popperProps}>{popperProps.children as ReactNode}</div>
)}
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 }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
onClick={event => event.preventDefault()}
label={option}
/>
)}
size="small"
popupIcon={
<ExpandMoreIcon data-testid="processing-status-picker-expand" />
}
renderInput={params => (
<TextField
{...params}
className={classes.input}
variant="outlined"
/>
)}
/>
</Typography>
<CatalogAutocomplete<string, true>
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 }) => (
<FormControlLabel
control={
<Checkbox
icon={icon}
checkedIcon={checkedIcon}
checked={selected}
/>
}
onClick={event => event.preventDefault()}
label={option}
/>
)}
name="processing-status-picker"
LabelProps={{ className: classes.label }}
TextFieldProps={{ className: classes.input }}
/>
</Box>
);
};