diff --git a/UPGRADE.md b/UPGRADE.md index 0d8e0732e01..05fa3e57161 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -891,11 +891,11 @@ const PostFilter = props => ; ``` -## Complete rewrite of the AutocompleteInput component +## Complete rewrite of the AutocompleteInput and AutocompleteArrayInput components -We rewrote the `` component from scratch using [`downshift`](https://github.com/downshift-js/downshift), while the previous version was based on [react-autosuggest](http://react-autosuggest.js.org/). The new `` component is more robust and more future-proof, and its API didn't change. +We rewrote the `` and `` components from scratch using [`downshift`](https://github.com/downshift-js/downshift), while the previous version was based on [react-autosuggest](http://react-autosuggest.js.org/). The new components are more robust and more future-proof, and their API didn't change. -There are two breaking changes in the new ``: +There are two breaking changes in the new `` and `` components: - The `inputValueMatcher` prop is gone. We removed a feature many found confusing: the auto-selection of an item when it was matched exactly. So react-admin no longer selects anything automatically, therefore the `inputValueMatcher` prop is obsolete @@ -904,6 +904,10 @@ There are two breaking changes in the new ``: source="role" - inputValueMatcher={() => null} /> + null} +/> ``` - Specific [`react-autosuggest` props](https://github.com/moroshko/react-autosuggest#props) (like `onSuggestionsFetchRequested`, `theme`, or `highlightFirstSuggestion`) are no longer supported, because the component now passes extra props to a `` component. @@ -913,6 +917,23 @@ There are two breaking changes in the new ``: source="role" - highlightFirstSuggestion={true} /> + +``` + +Besides, some props which were applicable to both components did not make sense for the `` component: + +- `allowEmpty`: As the `` deals with arrays, it does not make sense to add an empty choice. This prop is no longer accepted and will be ignored. +- `limitChoicesToValue`: As the `` deals with arrays and only accepts unique items, it does not make sense to show only the already selected items. This prop is no longer accepted and will be ignored. + +```diff + ``` ## The `exporter` function has changed signature diff --git a/docs/Inputs.md b/docs/Inputs.md index c0c643a3916..3cfd11dc90b 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -165,19 +165,6 @@ In that case, set the `translateChoice` prop to false. ``` -By default the component matches choices with the current input searchText: if it finds a match, this choice will be selected. -For example, given the choices `[{ id: 'M', name: 'Male', id: 'F', name: 'Female' }]`, when the user enters the text `male`, then the component will set the input value to `M`. -If you need to change how choices are matched, pass a custom function as `inputValueMatcher` prop. -For example, given the choices: `[{id:1,iso2:'NL',name:'Dutch'},{id:2,iso2:'EN',name:'English'},{id:3,iso2:'FR',name:'French'}]`, if you want to match choices on the iso2 code, you can create the following `inputValueMatcher` function: - -```javascript - - input.toUpperCase().trim() === suggestion.iso2 || - input.toLowerCase().trim() === getOptionText(suggestion).toLowerCase().trim() -}/> -``` - If you want to limit the initial choices shown to the current value only, you can set the `limitChoicesToValue` prop. When dealing with a large amount of `choices` you may need to limit the number of suggestions that are rendered in order to maintain usable performance. The `shouldRenderSuggestions` is an optional prop that allows you to set conditions on when to render suggestions. An easy way to improve performance would be to skip rendering until the user has entered 2 or 3 characters in the search box. This lowers the result set significantly, and might be all you need (depending on your data set). @@ -232,7 +219,9 @@ Lastly, would you need to override the props of the suggestions container (a `Po ## `` -To let users choose multiple values in a list using a dropdown with autocompletion, use ``. It renders using [material-ui-chip-input](https://github.com/TeamWertarbyte/material-ui-chip-input), [react-autosuggest](http://react-autosuggest.js.org/) and a `fuzzySearch` filter. Set the `choices` attribute to determine the options list (with `id`, `name` tuples). +To let users choose multiple values in a list using a dropdown with autocompletion, use ``. +It renders using [downshift](https://github.com/downshift-js/downshift) and a `fuzzySearch` filter. +Set the `choices` attribute to determine the options list (with `id`, `name` tuples). ```jsx import { AutocompleteArrayInput } from 'react-admin'; @@ -280,18 +269,6 @@ However, in some cases (e.g. inside a ``), you may not want the ``` -By default the component matches choices with the current input searchText. For example, given the choices `[{ id: 'M', name: 'Male', id: 'F', name: 'Female' }]`, when the user enters the text `male`, then the component will set the input value to `M`. If you need to change how choices are matched, pass a custom function as `inputValueMatcher` prop. For example, given the choices: `[{id:1,iso2:'NL',name:'Dutch'},{id:2,iso2:'EN',name:'English'},{id:3,iso2:'FR',name:'French'}]`, if you want to match choices on the iso2 code, you can create the following `inputValueMatcher` function: - -```javascript - - input.toUpperCase().trim() === suggestion.iso2 || - input.toLowerCase().trim() === getOptionText(suggestion).toLowerCase().trim() -}/> -``` - -If you want to limit the initial choices shown to the current value only, you can set the `limitChoicesToValue` prop. - When dealing with a large amount of `choices` you may need to limit the number of suggestions that are rendered in order to maintain usable performance. The `shouldRenderSuggestions` is an optional prop that allows you to set conditions on when to render suggestions. An easy way to improve performance would be to skip rendering until the user has entered 2 or 3 characters in the search box. This lowers the result set significantly, and might be all you need (depending on your data set). Ex. ` { return val.trim().length > 2 }} />` would not render any suggestions until the 3rd character was entered. This prop is passed to the underlying `react-autosuggest` component and is documented [here](https://github.com/moroshko/react-autosuggest#should-render-suggestions-prop). @@ -338,8 +315,6 @@ If you need to override the props of the suggestions container (a `Popper` eleme | `choices` | Required | `Object[]` | - | List of items to autosuggest | | `resource` | Required | `string` | - | The resource working on. This field is passed down by wrapped components like `Create` and `Edit`. | | `source` | Required | `string` | - | Name of field to edit, its type should match the type retrieved from `optionValue` | -| `allowEmpty` | Optional | `boolean` | `false` | If `false` and the searchText typed did not match any suggestion, the searchText will revert to the current value when the field is blurred. If `true` and the `searchText` is set to `''` then the field will set the input value to `null`. | -| `inputValueMatcher` | Optional | `Function` | `(input, suggestion, getOptionText) => input.toLowerCase().trim() === getOptionText(suggestion).toLowerCase().trim()` | Allows to define how choices are matched with the searchText while typing. | | `optionValue` | Optional | `string` | `id` | Fieldname of record containing the value to use as input value | | `optionText` | Optional | string | Function | `name` | Fieldname of record to display in the suggestion item or function which accepts the current record as argument (`(record)=> {string}`) | | `setFilter` | Optional | `Function` | null | A callback to inform the `searchText` has changed and new `choices` can be retrieved based on this `searchText`. Signature `searchText => void`. This function is automatically setup when using `ReferenceInput`. | diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index 784d046ae4d..c27cb60210e 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -55,10 +55,8 @@ "inflection": "~1.12.0", "jsonexport": "^2.4.1", "lodash": "~4.17.5", - "material-ui-chip-input": "1.0.0", "prop-types": "^15.6.1", "ra-core": "^3.0.0-alpha.4", - "react-autosuggest": "^9.4.2", "react-dropzone": "^10.1.7", "react-final-form": "^6.3.0", "react-final-form-arrays": "^3.1.1", diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.js b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.js deleted file mode 100644 index 660bb36a05d..00000000000 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.js +++ /dev/null @@ -1,586 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import get from 'lodash/get'; -import isEqual from 'lodash/isEqual'; -import Autosuggest from 'react-autosuggest'; -import Chip from '@material-ui/core/Chip'; -import Paper from '@material-ui/core/Paper'; -import Popper from '@material-ui/core/Popper'; -import MenuItem from '@material-ui/core/MenuItem'; -import { withStyles, createStyles } from '@material-ui/core/styles'; -import parse from 'autosuggest-highlight/parse'; -import match from 'autosuggest-highlight/match'; -import blue from '@material-ui/core/colors/blue'; -import compose from 'recompose/compose'; -import classNames from 'classnames'; - -import { addField, translate, FieldTitle } from 'ra-core'; - -import AutocompleteArrayInputChip from './AutocompleteArrayInputChip'; -import InputHelperText from './InputHelperText'; - -const styles = theme => - createStyles({ - container: { - flexGrow: 1, - position: 'relative', - }, - root: {}, - suggestionsContainerOpen: { - position: 'absolute', - marginBottom: theme.spacing(3), - zIndex: 2, - }, - suggestionsPaper: { - maxHeight: '50vh', - overflowY: 'auto', - }, - suggestion: { - display: 'block', - fontFamily: theme.typography.fontFamily, - }, - suggestionText: { fontWeight: 300 }, - highlightedSuggestionText: { fontWeight: 500 }, - suggestionsList: { - margin: 0, - padding: 0, - listStyleType: 'none', - }, - chip: { - marginRight: theme.spacing(1), - '&.standard': { - marginTop: theme.spacing(1), - }, - }, - chipDisabled: { - pointerEvents: 'none', - }, - chipFocused: { - backgroundColor: blue[300], - }, - }); - -const DefaultSuggestionComponent = React.forwardRef( - ({ suggestion, query, isHighlighted, ...props }, ref) => ( -
- ) -); - -/** - * An Input component for an autocomplete field, using an array of objects for the options - * - * Pass possible options as an array of objects in the 'choices' attribute. - * - * By default, the options are built from: - * - the 'id' property as the option value, - * - the 'name' property an the option text - * @example - * const choices = [ - * { id: 'M', name: 'Male' }, - * { id: 'F', name: 'Female' }, - * ]; - * - * - * You can also customize the properties to use for the option name and value, - * thanks to the 'optionText' and 'optionValue' attributes. - * @example - * const choices = [ - * { _id: 123, full_name: 'Leo Tolstoi', sex: 'M' }, - * { _id: 456, full_name: 'Jane Austen', sex: 'F' }, - * ]; - * - * - * `optionText` also accepts a function, so you can shape the option text at will: - * @example - * const choices = [ - * { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, - * { id: 456, first_name: 'Jane', last_name: 'Austen' }, - * ]; - * const optionRenderer = choice => `${choice.first_name} ${choice.last_name}`; - * - * - * The choices are translated by default, so you can use translation identifiers as choices: - * @example - * const choices = [ - * { id: 'M', name: 'myroot.gender.male' }, - * { id: 'F', name: 'myroot.gender.female' }, - * ]; - * - * However, in some cases (e.g. inside a ``), you may not want - * the choice to be translated. In that case, set the `translateChoice` prop to false. - * @example - * - * - * The object passed as `options` props is passed to the material-ui component - * - * @example - * - */ -export class AutocompleteArrayInput extends React.Component { - initialInputValue = []; - - state = { - dirty: false, - inputValue: this.initialInputValue, - searchText: '', - suggestions: [], - }; - - inputEl = null; - - getInputValue = inputValue => - inputValue === '' ? this.initialInputValue : inputValue; - - componentWillMount() { - this.setState({ - inputValue: this.getInputValue(this.props.input.value), - suggestions: this.limitSuggestions(this.props.choices), - }); - } - - componentWillReceiveProps(nextProps) { - const { choices, input, inputValueMatcher } = nextProps; - if (!isEqual(this.getInputValue(input.value), this.state.inputValue)) { - this.setState({ - inputValue: this.getInputValue(input.value), - dirty: false, - suggestions: this.limitSuggestions(this.props.choices), - }); - // Ensure to reset the filter - this.updateFilter(''); - } else if (!isEqual(choices, this.props.choices)) { - this.setState(({ searchText }) => ({ - suggestions: this.limitSuggestions( - choices.filter(suggestion => - inputValueMatcher( - searchText, - suggestion, - this.getSuggestionText - ) - ) - ), - })); - } - } - - getSuggestionValue = suggestion => get(suggestion, this.props.optionValue); - - getSuggestionText = suggestion => { - if (!suggestion) return ''; - - const { optionText, translate, translateChoice } = this.props; - const suggestionLabel = - typeof optionText === 'function' - ? optionText(suggestion) - : get(suggestion, optionText); - - // We explicitly call toString here because AutoSuggest expect a string - return translateChoice - ? translate(suggestionLabel, { _: suggestionLabel }).toString() - : suggestionLabel.toString(); - }; - - handleSuggestionSelected = (event, { suggestion, method }) => { - const { input } = this.props; - - input.onChange([ - ...(this.state.inputValue || []), - this.getSuggestionValue(suggestion), - ]); - - if (method === 'enter') { - event.preventDefault(); - } - }; - - handleSuggestionsFetchRequested = () => { - const { choices, inputValueMatcher } = this.props; - - this.setState(({ searchText }) => ({ - suggestions: this.limitSuggestions( - choices.filter(suggestion => - inputValueMatcher( - searchText, - suggestion, - this.getSuggestionText - ) - ) - ), - })); - }; - - handleSuggestionsClearRequested = () => { - this.updateFilter(''); - }; - - handleMatchSuggestionOrFilter = inputValue => { - this.setState({ - dirty: true, - searchText: inputValue, - }); - this.updateFilter(inputValue); - }; - handleChange = (event, { newValue, method }) => { - if (['type', 'escape'].includes(method)) { - this.handleMatchSuggestionOrFilter(newValue); - } - }; - - renderInput = inputProps => { - const { - id, - input, - helperText, - fullWidth, - options: { InputProps, suggestionsContainerProps, ...options }, - variant, - } = this.props; - - const { - autoFocus, - className, - classes, - isRequired, - label, - meta, - onChange, - resource, - source, - value, - ref, - ...other - } = inputProps; - if (typeof meta === 'undefined') { - throw new Error( - "The TextInput component wasn't called within a react-final-form . Did you decorate it and forget to add the addField prop to your component? See https://marmelab.com/react-admin/Inputs.html#writing-your-own-input-component for details." - ); - } - - const { touched, error } = meta; - - // We need to store the input reference for our Popper element containing the suggestions - // but Autosuggest also needs this reference (it provides the ref prop) - const storeInputRef = input => { - this.inputEl = input; - ref(input); - }; - - const finalOptions = { - fullWidth, - ...options, - }; - - return ( - - ) : null - } - chipRenderer={this.renderChip} - label={ - - } - variant={variant} - {...other} - {...finalOptions} - /> - ); - }; - - renderChip = ( - { value, isFocused, isDisabled, handleClick, handleDelete }, - key - ) => { - const { classes = {}, choices, variant } = this.props; - - const suggestion = choices.find( - choice => this.getSuggestionValue(choice) === value - ); - - return ( - - ); - }; - - handleAdd = chip => { - const { - choices, - input, - limitChoicesToValue, - inputValueMatcher, - } = this.props; - - const filteredChoices = choices.filter(choice => - inputValueMatcher(chip, choice, this.getSuggestionText) - ); - - const choice = - filteredChoices.length === 1 - ? filteredChoices[0] - : filteredChoices.find( - c => this.getSuggestionValue(c) === chip - ); - - if (choice) { - return input.onChange([ - ...(this.state.inputValue || []), - this.getSuggestionValue(choice), - ]); - } - - if (limitChoicesToValue) { - // Ensure to reset the filter - this.updateFilter(''); - return; - } - - input.onChange([...this.state.inputValue, chip]); - }; - - handleDelete = chip => { - const { input } = this.props; - - input.onChange(this.state.inputValue.filter(value => value !== chip)); - }; - - renderSuggestionsContainer = autosuggestOptions => { - const { - containerProps: { className, ...containerProps }, - children, - } = autosuggestOptions; - const { classes = {}, options } = this.props; - - return ( - - - {children} - - - ); - }; - - renderSuggestion = (suggestion, { query, isHighlighted }) => { - const label = this.getSuggestionText(suggestion); - const matches = match(label, query); - const parts = parse(label, matches); - const { classes = {}, suggestionComponent } = this.props; - - return ( - -
- {parts.map((part, index) => { - return part.highlight ? ( - - {part.text} - - ) : ( - - {part.text} - - ); - })} -
-
- ); - }; - - handleFocus = () => { - const { input } = this.props; - input && input.onFocus && input.onFocus(); - }; - - updateFilter = value => { - const { setFilter, choices } = this.props; - if (this.previousFilterValue !== value) { - if (setFilter) { - setFilter(value); - } else { - this.setState({ - searchText: value, - suggestions: this.limitSuggestions( - choices.filter(choice => - this.getSuggestionText(choice) - .toLowerCase() - .includes(value.toLowerCase()) - ) - ), - }); - } - } - this.previousFilterValue = value; - }; - - shouldRenderSuggestions = val => { - const { shouldRenderSuggestions } = this.props; - if ( - shouldRenderSuggestions !== undefined && - typeof shouldRenderSuggestions === 'function' - ) { - return shouldRenderSuggestions(val); - } - - return true; - }; - - limitSuggestions = suggestions => { - const { suggestionLimit = 0 } = this.props; - if (Number.isInteger(suggestionLimit) && suggestionLimit > 0) { - return suggestions.slice(0, suggestionLimit); - } - return suggestions; - }; - - render() { - const { - alwaysRenderSuggestions, - classes = {}, - isRequired, - label, - meta, - resource, - source, - className, - options, - } = this.props; - const { suggestions, searchText } = this.state; - - return ( - - ); - } -} - -AutocompleteArrayInput.propTypes = { - allowEmpty: PropTypes.bool, - alwaysRenderSuggestions: PropTypes.bool, // used only for unit tests - choices: PropTypes.arrayOf(PropTypes.object), - classes: PropTypes.object, - className: PropTypes.string, - InputProps: PropTypes.object, - input: PropTypes.object, - inputValueMatcher: PropTypes.func, - isRequired: PropTypes.bool, - label: PropTypes.string, - limitChoicesToValue: PropTypes.bool, - meta: PropTypes.object, - options: PropTypes.object, - optionText: PropTypes.oneOfType([PropTypes.string, PropTypes.func]) - .isRequired, - optionValue: PropTypes.string.isRequired, - resource: PropTypes.string, - setFilter: PropTypes.func, - shouldRenderSuggestions: PropTypes.func, - source: PropTypes.string, - suggestionComponent: PropTypes.oneOfType([ - PropTypes.element, - PropTypes.func, - ]), - suggestionLimit: PropTypes.number, - translate: PropTypes.func.isRequired, - translateChoice: PropTypes.bool.isRequired, -}; - -AutocompleteArrayInput.defaultProps = { - choices: [], - options: {}, - optionText: 'name', - optionValue: 'id', - limitChoicesToValue: false, - translateChoice: true, - inputValueMatcher: (input, suggestion, getOptionText) => - getOptionText(suggestion) - .toLowerCase() - .trim() - .includes(input.toLowerCase().trim()), -}; - -export default compose( - addField, - translate, - withStyles(styles) -)(AutocompleteArrayInput); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.js b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.js deleted file mode 100644 index f01d0ce7b47..00000000000 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.js +++ /dev/null @@ -1,531 +0,0 @@ -import React from 'react'; -import { - cleanup, - fireEvent, - render, - waitForDomChange, -} from '@testing-library/react'; - -import { AutocompleteArrayInput } from './AutocompleteArrayInput'; - -describe('', () => { - afterEach(cleanup); - - const defaultProps = { - // We have to specify the id ourselves here because the - // TextInput is not wrapped inside a FormInput - id: 'foo', - source: 'foo', - resource: 'bar', - meta: {}, - input: { onChange: () => {} }, - translate: x => x, - }; - - it('should extract suggestions from choices', () => { - const { getByLabelText, getByText, queryAllByRole } = render( - - ); - - fireEvent.click(getByLabelText('resources.bar.fields.foo')); - - expect(queryAllByRole('option')).toHaveLength(2); - expect(getByText('Male')).toBeDefined(); - expect(getByText('Female')).toBeDefined(); - }); - - it('should use optionText with a string value as text identifier', () => { - const { getByLabelText, getByText, queryAllByRole } = render( - - ); - - fireEvent.click(getByLabelText('resources.bar.fields.foo')); - - expect(queryAllByRole('option')).toHaveLength(2); - expect(getByText('Male')).toBeDefined(); - expect(getByText('Female')).toBeDefined(); - }); - - it('should use optionText with a string value including "." as text identifier', () => { - const { getByLabelText, getByText, queryAllByRole } = render( - - ); - - fireEvent.click(getByLabelText('resources.bar.fields.foo')); - - expect(queryAllByRole('option')).toHaveLength(2); - expect(getByText('Male')).toBeDefined(); - expect(getByText('Female')).toBeDefined(); - }); - - it('should use optionText with a function value as text identifier', () => { - const { getByLabelText, getByText, queryAllByRole } = render( - choice.foobar} - choices={[ - { id: 'M', foobar: 'Male' }, - { id: 'F', foobar: 'Female' }, - ]} - /> - ); - - fireEvent.click(getByLabelText('resources.bar.fields.foo')); - - expect(queryAllByRole('option')).toHaveLength(2); - expect(getByText('Male')).toBeDefined(); - expect(getByText('Female')).toBeDefined(); - }); - - it('should translate the choices by default', () => { - const { getByLabelText, getByText, queryAllByRole } = render( - `**${x}**`} - choices={[ - { id: 'M', name: 'Male' }, - { id: 'F', name: 'Female' }, - ]} - /> - ); - - fireEvent.click(getByLabelText('resources.bar.fields.foo')); - - expect(queryAllByRole('option')).toHaveLength(2); - expect(getByText('**Male**')).toBeDefined(); - expect(getByText('**Female**')).toBeDefined(); - }); - - it('should not translate the choices if translateChoice is false', () => { - const { getByLabelText, getByText, queryAllByRole } = render( - `**${x}**`} - choices={[ - { id: 'M', name: 'Male' }, - { id: 'F', name: 'Female' }, - ]} - translateChoice={false} - /> - ); - - fireEvent.click(getByLabelText('resources.bar.fields.foo')); - - expect(queryAllByRole('option')).toHaveLength(2); - expect(getByText('Male')).toBeDefined(); - expect(getByText('Female')).toBeDefined(); - }); - - it('should respect shouldRenderSuggestions over default if passed in', async () => { - const { getByLabelText, queryAllByRole } = render( - {} }} - choices={[{ id: 'M', name: 'Male' }]} - shouldRenderSuggestions={v => v.length > 2} - /> - ); - const input = getByLabelText('resources.bar.fields.foo'); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'Ma' } }); - expect(queryAllByRole('option')).toHaveLength(0); - - fireEvent.change(input, { target: { value: 'Mal' } }); - expect(queryAllByRole('option')).toHaveLength(1); - }); - - describe('Fix issue #1410', () => { - it('should not fail when value is empty and new choices are applied', () => { - const { getByLabelText, rerender } = render( - {} }} - choices={[{ id: 'M', name: 'Male' }]} - /> - ); - - rerender( - {} }} - choices={[{ id: 'M', name: 'Male' }]} - /> - ); - const input = getByLabelText('resources.bar.fields.foo'); - expect(input.value).toEqual(''); - }); - - it('should repopulate the suggestions after the suggestions are dismissed', () => { - const { getByLabelText, queryAllByRole } = render( - - ); - - const input = getByLabelText('resources.bar.fields.foo'); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'foo' } }); - expect(queryAllByRole('option')).toHaveLength(0); - - fireEvent.blur(input); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: '' } }); - expect(queryAllByRole('option')).toHaveLength(1); - }); - - it('should not rerender searchtext while having focus and new choices arrive', () => { - const optionText = jest.fn(); - const { getByLabelText, queryAllByRole, rerender } = render( - {} }} - meta={{ active: true }} - choices={[{ id: 'M', name: 'Male' }]} - optionText={v => { - optionText(v); - return v.name; - }} - /> - ); - const input = getByLabelText('resources.bar.fields.foo'); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'foo' } }); - expect(queryAllByRole('option')).toHaveLength(0); - - rerender( - {} }} - meta={{ active: true }} - choices={[ - { id: 'M', name: 'Male' }, - { id: 'F', name: 'Female' }, - ]} - optionText={v => { - optionText(v); - return v.name; - }} - /> - ); - expect(getByLabelText('resources.bar.fields.foo').value).toEqual( - 'foo' - ); - }); - - it('should allow input value to be cleared when allowEmpty is true and input text is empty', () => { - const { getByLabelText, queryAllByRole } = render( - {} }} - choices={[{ id: 'M', name: 'Male' }]} - /> - ); - - const input = getByLabelText('resources.bar.fields.foo'); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'foo' } }); - expect(queryAllByRole('option')).toHaveLength(0); - fireEvent.blur(input); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: '' } }); - expect(queryAllByRole('option')).toHaveLength(1); - }); - - it('should revert the searchText when allowEmpty is false', () => { - const { getByLabelText, queryAllByRole } = render( - {} }} - choices={[{ id: 'M', name: 'Male' }]} - /> - ); - - const input = getByLabelText('resources.bar.fields.foo'); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'foo' } }); - expect(queryAllByRole('option')).toHaveLength(0); - fireEvent.blur(input); - expect(getByLabelText('resources.bar.fields.foo').value).toEqual( - '' - ); - }); - - it('should show the suggestions when the input value is empty and the input is focussed and choices arrived late', () => { - const { getByLabelText, queryAllByRole, rerender } = render( - {} }} - /> - ); - rerender( - {} }} - choices={[ - { id: 'M', name: 'Male' }, - { id: 'F', name: 'Female' }, - ]} - /> - ); - - fireEvent.focus(getByLabelText('resources.bar.fields.foo')); - expect(queryAllByRole('option')).toHaveLength(2); - }); - - it('should resolve value from input value', () => { - const onChange = jest.fn(); - const { getByLabelText } = render( - - ); - - const input = getByLabelText('resources.bar.fields.foo'); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'male' } }); - fireEvent.blur(input); - - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(['M']); - }); - - it('should reset filter when input value changed', () => { - const setFilter = jest.fn(); - const { getByLabelText, rerender } = render( - - ); - const input = getByLabelText('resources.bar.fields.foo'); - fireEvent.change(input, { target: { value: 'de' } }); - expect(setFilter).toHaveBeenCalledTimes(1); - expect(setFilter).toHaveBeenCalledWith('de'); - - rerender( - - ); - expect(setFilter).toHaveBeenCalledTimes(2); - }); - - it('should allow customized rendering of suggesting item', () => { - const { getByLabelText } = render( - ( -
- ) - )} - /> - ); - fireEvent.focus(getByLabelText('resources.bar.fields.foo')); - expect(getByLabelText('Male')).toBeDefined(); - expect(getByLabelText('Female')).toBeDefined(); - }); - }); - - it('should display helperText', () => { - const { getByText } = render( - - ); - expect(getByText('Can I help you?')).toBeDefined(); - }); - - describe('error message', () => { - it('should not be displayed if field is pristine', () => { - const { queryByText } = render( - - ); - expect(queryByText('Required')).toBeNull(); - }); - - it('should be displayed if field has been touched and is invalid', () => { - const { queryByText } = render( - - ); - expect(queryByText('Required')).toBeDefined(); - }); - }); - - describe('Fix issue #2121', () => { - it('updates suggestions when input is blurred and refocused', () => { - const { getByLabelText, queryAllByRole } = render( - {} }} - choices={[ - { id: 1, name: 'ab' }, - { id: 2, name: 'abc' }, - { id: 3, name: '123' }, - ]} - /> - ); - const input = getByLabelText('resources.bar.fields.foo'); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'ab' } }); - expect(queryAllByRole('option')).toHaveLength(2); - fireEvent.blur(input); - - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'ab' } }); - expect(queryAllByRole('option')).toHaveLength(2); - }); - }); - - it('does not automatically select a matched choice if there are more than one', () => { - const { getByLabelText, queryAllByRole } = render( - {} }} - choices={[ - { id: 1, name: 'ab' }, - { id: 2, name: 'abc' }, - { id: 3, name: '123' }, - ]} - /> - ); - - const input = getByLabelText('resources.bar.fields.foo'); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'ab' } }); - expect(queryAllByRole('option')).toHaveLength(2); - }); - - it('does not automatically select a matched choice if there is only one', async () => { - const onChange = jest.fn(); - - const { getByLabelText, queryAllByRole } = render( - - ); - const input = getByLabelText('resources.bar.fields.foo'); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'abc' } }); - expect(queryAllByRole('option')).toHaveLength(1); - - expect(onChange).not.toHaveBeenCalled(); - }); - - it('automatically selects a matched choice on blur if there is only one', () => { - const onChange = jest.fn(); - - const { getByLabelText } = render( - - ); - const input = getByLabelText('resources.bar.fields.foo'); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'abc' } }); - fireEvent.blur(input); - - expect(onChange).toHaveBeenCalled(); - }); - - it('passes options.suggestionsContainerProps to the suggestions container', () => { - const { getByLabelText } = render( - - ); - const input = getByLabelText('resources.bar.fields.foo'); - fireEvent.focus(input); - - expect(getByLabelText('Me')).toBeDefined(); - }); - - it('should limit suggestions when suggestionLimit is passed', () => { - const { getByLabelText, queryAllByRole } = render( - - ); - const input = getByLabelText('resources.bar.fields.foo'); - fireEvent.focus(input); - expect(queryAllByRole('option')).toHaveLength(1); - }); -}); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx new file mode 100644 index 00000000000..1ddfa9053c3 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx @@ -0,0 +1,602 @@ +import React from 'react'; +import { + cleanup, + fireEvent, + render, + waitForDomChange, +} from '@testing-library/react'; +import { Form } from 'react-final-form'; +import expect from 'expect'; + +import AutocompleteArrayInput from './AutocompleteArrayInput'; +import { TranslationContext } from 'ra-core'; + +describe('', () => { + afterEach(cleanup); + + const defaultProps = { + source: 'tags', + resource: 'posts', + }; + + it('should extract suggestions from choices', () => { + const { getByLabelText, getByText, queryAllByRole } = render( +
( + + )} + /> + ); + + fireEvent.focus(getByLabelText('resources.posts.fields.tags')); + expect(queryAllByRole('option')).toHaveLength(2); + expect(getByText('Technical')).not.toBeNull(); + expect(getByText('Programming')).not.toBeNull(); + }); + + it('should use optionText with a string value as text identifier', () => { + const { getByLabelText, getByText, queryAllByRole } = render( + ( + + )} + /> + ); + + fireEvent.focus(getByLabelText('resources.posts.fields.tags')); + + expect(queryAllByRole('option')).toHaveLength(2); + expect(getByText('Technical')).not.toBeNull(); + expect(getByText('Programming')).not.toBeNull(); + }); + + it('should use optionText with a string value including "." as text identifier', () => { + const { getByLabelText, getByText, queryAllByRole } = render( + ( + + )} + /> + ); + + fireEvent.focus(getByLabelText('resources.posts.fields.tags')); + + expect(queryAllByRole('option')).toHaveLength(2); + expect(getByText('Technical')).not.toBeNull(); + expect(getByText('Programming')).not.toBeNull(); + }); + + it('should use optionText with a function value as text identifier', () => { + const { getByLabelText, getByText, queryAllByRole } = render( + ( + choice.foobar} + choices={[ + { id: 't', foobar: 'Technical' }, + { id: 'p', foobar: 'Programming' }, + ]} + /> + )} + /> + ); + + fireEvent.focus(getByLabelText('resources.posts.fields.tags')); + + expect(queryAllByRole('option')).toHaveLength(2); + expect(getByText('Technical')).not.toBeNull(); + expect(getByText('Programming')).not.toBeNull(); + }); + + it('should translate the choices by default', () => { + const { getByLabelText, getByText, queryAllByRole } = render( + ( + `**${x}**`, + }} + > + + + )} + /> + ); + + fireEvent.focus(getByLabelText('**resources.posts.fields.tags**')); + + expect(queryAllByRole('option')).toHaveLength(2); + expect(getByText('**Technical**')).not.toBeNull(); + expect(getByText('**Programming**')).not.toBeNull(); + }); + + it('should not translate the choices if translateChoice is false', () => { + const { getByLabelText, getByText, queryAllByRole } = render( + ( + `**${x}**`, + }} + > + + + )} + /> + ); + + fireEvent.focus(getByLabelText('**resources.posts.fields.tags**')); + + expect(queryAllByRole('option')).toHaveLength(2); + expect(getByText('Technical')).not.toBeNull(); + expect(getByText('Programming')).not.toBeNull(); + }); + + it('should respect shouldRenderSuggestions over default if passed in', async () => { + const { getByLabelText, queryAllByRole } = render( + ( + v.length > 2} + /> + )} + /> + ); + const input = getByLabelText('resources.posts.fields.tags'); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'Te' } }); + expect(queryAllByRole('option')).toHaveLength(0); + + fireEvent.change(input, { target: { value: 'Tec' } }); + waitForDomChange(); + expect(queryAllByRole('option')).toHaveLength(1); + }); + + describe('Fix issue #1410', () => { + it('should not fail when value is empty and new choices are applied', () => { + const { getByLabelText, rerender } = render( + ( + + )} + /> + ); + + rerender( + ( + + )} + /> + ); + const input = getByLabelText( + 'resources.posts.fields.tags' + ) as HTMLInputElement; + expect(input.value).toEqual(''); + }); + + it('should repopulate the suggestions after the suggestions are dismissed', () => { + const { getByLabelText, queryAllByRole } = render( + ( + + )} + /> + ); + + const input = getByLabelText('resources.posts.fields.tags'); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'foo' } }); + expect(queryAllByRole('option')).toHaveLength(0); + + fireEvent.blur(input); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '' } }); + expect(queryAllByRole('option')).toHaveLength(1); + }); + + it('should not rerender searchtext while having focus and new choices arrive', () => { + const optionText = jest.fn(); + const { getByLabelText, queryAllByRole, rerender } = render( + ( + { + optionText(v); + return v.name; + }} + /> + )} + /> + ); + const input = getByLabelText( + 'resources.posts.fields.tags' + ) as HTMLInputElement; + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'foo' } }); + expect(queryAllByRole('option')).toHaveLength(0); + + rerender( + ( + { + optionText(v); + return v.name; + }} + /> + )} + /> + ); + + expect(input.value).toEqual('foo'); + }); + + it('should revert the searchText on blur', () => { + const { getByLabelText, queryAllByRole } = render( + ( + + )} + /> + ); + + const input = getByLabelText( + 'resources.posts.fields.tags' + ) as HTMLInputElement; + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'foo' } }); + expect(queryAllByRole('option')).toHaveLength(0); + fireEvent.blur(input); + expect(input.value).toEqual(''); + }); + + it('should show the suggestions when the input value is empty and the input is focused and choices arrived late', () => { + const { getByLabelText, queryAllByRole, rerender } = render( + } + /> + ); + rerender( + ( + + )} + /> + ); + + fireEvent.focus(getByLabelText('resources.posts.fields.tags')); + expect(queryAllByRole('option')).toHaveLength(2); + }); + + it('should resolve value from input value', () => { + const onChange = jest.fn(); + const { getByLabelText, getByRole } = render( + ( + + )} + /> + ); + + const input = getByLabelText('resources.posts.fields.tags'); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'Technical' } }); + fireEvent.click(getByRole('option')); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(['t']); + }); + + it('should reset filter when input value changed', async () => { + const setFilter = jest.fn(); + let formApi; + const { getByLabelText } = render( + { + formApi = form; + return ( + + ); + }} + /> + ); + const input = getByLabelText('resources.posts.fields.tags'); + fireEvent.change(input, { target: { value: 'p' } }); + expect(setFilter).toHaveBeenCalledTimes(2); + expect(setFilter).toHaveBeenCalledWith('p'); + formApi.change('tags', ['p']); + await waitForDomChange(); + expect(setFilter).toHaveBeenCalledTimes(3); + }); + + it('should allow customized rendering of suggesting item', () => { + const { getByLabelText } = render( + ( + ( +
+ ) + )} + /> + )} + /> + ); + fireEvent.focus(getByLabelText('resources.posts.fields.tags')); + expect(getByLabelText('Technical')).not.toBeNull(); + expect(getByLabelText('Programming')).not.toBeNull(); + }); + }); + + it('should display helperText', () => { + const { getByText } = render( + ( + + )} + /> + ); + expect(getByText('Can I help you?')).not.toBeNull(); + }); + + describe('error message', () => { + const failingValidator = () => 'ra.validation.error'; + + it('should not be displayed if field is pristine', () => { + const { queryByText } = render( + ( + + )} + /> + ); + expect(queryByText('ra.validation.error')).toBeNull(); + }); + + it('should be displayed if field has been touched and is invalid', () => { + const { getByLabelText, queryByText } = render( + ( + + )} + /> + ); + const input = getByLabelText('resources.posts.fields.tags'); + fireEvent.focus(input); + fireEvent.blur(input); + + expect(queryByText('ra.validation.error')).not.toBeNull(); + }); + }); + + describe('Fix issue #2121', () => { + it('updates suggestions when input is blurred and refocused', () => { + const { getByLabelText, queryAllByRole } = render( + ( + + )} + /> + ); + const input = getByLabelText('resources.posts.fields.tags'); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'ab' } }); + expect(queryAllByRole('option')).toHaveLength(2); + fireEvent.blur(input); + + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'ab' } }); + expect(queryAllByRole('option')).toHaveLength(2); + }); + }); + + it('does not automatically select a matched choice if there is only one', async () => { + const onChange = jest.fn(); + + const { getByLabelText, queryAllByRole } = render( + ( + + )} + /> + ); + const input = getByLabelText('resources.posts.fields.tags'); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'abc' } }); + expect(queryAllByRole('option')).toHaveLength(1); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('passes options.suggestionsContainerProps to the suggestions container', () => { + const { getByLabelText } = render( + ( + + )} + /> + ); + const input = getByLabelText('resources.posts.fields.tags'); + fireEvent.focus(input); + + expect(getByLabelText('Me')).not.toBeNull(); + }); + + it('should limit suggestions when suggestionLimit is passed', () => { + const { getByLabelText, queryAllByRole } = render( + ( + + )} + /> + ); + const input = getByLabelText('resources.posts.fields.tags'); + fireEvent.focus(input); + expect(queryAllByRole('option')).toHaveLength(1); + }); +}); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx new file mode 100644 index 00000000000..06146c8e0da --- /dev/null +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx @@ -0,0 +1,503 @@ +import React, { + useCallback, + useEffect, + useRef, + FunctionComponent, +} from 'react'; +import Downshift, { DownshiftProps } from 'downshift'; +import classNames from 'classnames'; +import get from 'lodash/get'; +import { makeStyles, TextField, Chip } from '@material-ui/core'; +import { TextFieldProps } from '@material-ui/core/TextField'; +import { useTranslate, useInput, FieldTitle, InputProps } from 'ra-core'; + +import InputHelperText from './InputHelperText'; +import getSuggestionsFactory from './getSuggestions'; +import AutocompleteSuggestionList from './AutocompleteSuggestionList'; +import AutocompleteSuggestionItem from './AutocompleteSuggestionItem'; + +interface Props {} + +interface Options { + suggestionsContainerProps?: any; + labelProps?: any; +} + +/** + * An Input component for an autocomplete field, using an array of objects for the options + * + * Pass possible options as an array of objects in the 'choices' attribute. + * + * By default, the options are built from: + * - the 'id' property as the option value, + * - the 'name' property an the option text + * @example + * const choices = [ + * { id: 'M', name: 'Male' }, + * { id: 'F', name: 'Female' }, + * ]; + * + * + * You can also customize the properties to use for the option name and value, + * thanks to the 'optionText' and 'optionValue' attributes. + * @example + * const choices = [ + * { _id: 123, full_name: 'Leo Tolstoi', sex: 'M' }, + * { _id: 456, full_name: 'Jane Austen', sex: 'F' }, + * ]; + * + * + * `optionText` also accepts a function, so you can shape the option text at will: + * @example + * const choices = [ + * { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, + * { id: 456, first_name: 'Jane', last_name: 'Austen' }, + * ]; + * const optionRenderer = choice => `${choice.first_name} ${choice.last_name}`; + * + * + * The choices are translated by default, so you can use translation identifiers as choices: + * @example + * const choices = [ + * { id: 'M', name: 'myroot.gender.male' }, + * { id: 'F', name: 'myroot.gender.female' }, + * ]; + * + * However, in some cases (e.g. inside a ``), you may not want + * the choice to be translated. In that case, set the `translateChoice` prop to false. + * @example + * + * + * The object passed as `options` props is passed to the material-ui component + * + * @example + * + */ +const AutocompleteArrayInput: FunctionComponent< + Props & InputProps & DownshiftProps +> = ({ + allowEmpty, + classes: classesOverride, + choices = [], + helperText, + id: idOverride, + input: inputOverride, + isRequired: isRequiredOverride, + limitChoicesToValue, + margin, + meta: metaOverride, + onBlur, + onChange, + onFocus, + options: { + suggestionsContainerProps, + labelProps, + InputProps, + ...options + } = {}, + optionText = 'name', + optionValue = 'id', + resource, + setFilter, + shouldRenderSuggestions: shouldRenderSuggestionsOverride, + source, + suggestionComponent, + suggestionLimit, + translateChoice = true, + validate, + variant = 'filled', + ...rest +}) => { + const translate = useTranslate(); + const classes = useStyles({ classes: classesOverride }); + + let inputEl = useRef(); + let anchorEl = useRef(); + + const { + id, + input, + isRequired, + meta: { touched, error }, + } = useInput({ + id: idOverride, + input: inputOverride, + isRequired: isRequiredOverride, + meta: metaOverride, + onBlur, + onChange, + onFocus, + resource, + source, + validate, + ...rest, + }); + + const [filterValue, setFilterValue] = React.useState(''); + + const handleFilterChange = useCallback( + (eventOrValue: React.ChangeEvent<{ value: string }> | string) => { + const event = eventOrValue as React.ChangeEvent<{ value: string }>; + const value = event.target + ? event.target.value + : (eventOrValue as string); + + setFilterValue(value); + if (setFilter) { + setFilter(value); + } + }, + [setFilter] + ); + + // We must reset the filter every time the value changes to ensure we + // display at least some choices even if the input has a value. + // Otherwise, it would only display the currently selected one and the user + // would have to first clear the input before seeing any other choices + useEffect(() => { + handleFilterChange(''); + }, [input.value, handleFilterChange]); + + const getSuggestionValue = useCallback( + suggestion => get(suggestion, optionValue), + [optionValue] + ); + + const getSuggestionFromValue = useCallback( + value => choices.find(choice => get(choice, optionValue) === value), + [choices, optionValue] + ); + + const getSuggestionText = useCallback( + suggestion => { + if (!suggestion) return ''; + + const suggestionLabel = + typeof optionText === 'function' + ? optionText(suggestion) + : get(suggestion, optionText, ''); + + return translateChoice + ? translate(suggestionLabel, { _: suggestionLabel }) + : suggestionLabel; + }, + [optionText, translate, translateChoice] + ); + + const selectedItems = (input.value || []).map(getSuggestionFromValue); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + // Remove latest item from array when user hits backspace with no text + if ( + selectedItems.length && + !filterValue.length && + event.key === 'Backspace' + ) { + const newSelectedItems = selectedItems.slice( + 0, + selectedItems.length - 1 + ); + input.onChange(newSelectedItems.map(getSuggestionValue)); + } + }, + [filterValue.length, getSuggestionValue, input, selectedItems] + ); + + const handleChange = useCallback( + (item: any) => { + let newSelectedItems = selectedItems.includes(item) + ? [...selectedItems] + : [...selectedItems, item]; + setFilterValue(''); + input.onChange(newSelectedItems.map(getSuggestionValue)); + }, + [getSuggestionValue, input, selectedItems] + ); + + const handleDelete = useCallback( + event => { + const newSelectedItems = [...selectedItems]; + const value = event.target.getAttribute('data-item'); + const item = choices.find( + choice => getSuggestionValue(choice) == value // eslint-disable-line eqeqeq + ); + newSelectedItems.splice(newSelectedItems.indexOf(item), 1); + input.onChange(newSelectedItems.map(getSuggestionValue)); + }, + [choices, getSuggestionValue, input, selectedItems] + ); + + // This function ensures that the suggestion list stay aligned to the + // input element even if it moves (because user scrolled for example) + const updateAnchorEl = () => { + if (!inputEl.current) { + return; + } + + const inputPosition = inputEl.current.getBoundingClientRect() as DOMRect; + + // It works by implementing a mock element providing the only method used + // by the PopOver component, getBoundingClientRect, which will return a + // position based on the input position + if (!anchorEl.current) { + anchorEl.current = { getBoundingClientRect: () => inputPosition }; + } else { + const anchorPosition = anchorEl.current.getBoundingClientRect(); + + if ( + anchorPosition.x !== inputPosition.x || + anchorPosition.y !== inputPosition.y + ) { + anchorEl.current = { + getBoundingClientRect: () => inputPosition, + }; + } + } + }; + + const getSuggestions = useCallback( + getSuggestionsFactory({ + choices, + allowEmpty: false, // We do not want to insert an empty choice + optionText, + optionValue, + limitChoicesToValue, + getSuggestionText, + selectedItem: selectedItems, + suggestionLimit, + }), + [ + choices, + optionText, + optionValue, + limitChoicesToValue, + getSuggestionText, + input, + suggestionLimit, + ] + ); + + const storeInputRef = input => { + inputEl.current = input; + updateAnchorEl(); + }; + + const handleBlur = useCallback( + event => { + setFilterValue(''); + handleFilterChange(''); + input.onBlur(event); + }, + [handleFilterChange, input] + ); + + const handleFocus = useCallback( + openMenu => event => { + openMenu(event); + input.onFocus(event); + }, + [input] + ); + + const shouldRenderSuggestions = val => { + if ( + shouldRenderSuggestionsOverride !== undefined && + typeof shouldRenderSuggestionsOverride === 'function' + ) { + return shouldRenderSuggestionsOverride(val); + } + + return true; + }; + + return ( + getSuggestionValue(item)} + {...rest} + > + {({ + getInputProps, + getItemProps, + getLabelProps, + getMenuProps, + isOpen, + inputValue: suggestionFilter, + highlightedIndex, + openMenu, + }) => { + const isMenuOpen = + isOpen && shouldRenderSuggestions(suggestionFilter); + const { + onBlur, + onChange, + onFocus, + ref, + ...inputProps + } = getInputProps({ + onBlur: handleBlur, + onFocus: handleFocus(openMenu), + onKeyDown: handleKeyDown, + }); + return ( +
+ + {selectedItems.map((item, index) => ( + + ))} +
+ ), + onBlur, + onChange: event => { + handleFilterChange(event); + onChange!(event as React.ChangeEvent< + HTMLInputElement + >); + }, + onFocus, + }} + label={ + + } + InputLabelProps={getLabelProps({ + htmlFor: id, + })} + helperText={ + (touched && error) || helperText ? ( + + ) : null + } + variant={variant} + margin={margin} + {...inputProps} + {...options} + /> + + {getSuggestions(suggestionFilter).map( + (suggestion, index) => ( + + ) + )} + +
+ ); + }} + + ); +}; + +const useStyles = makeStyles(theme => { + const chipBackgroundColor = + theme.palette.type === 'light' + ? 'rgba(0, 0, 0, 0.09)' + : 'rgba(255, 255, 255, 0.09)'; + + return { + root: { + flexGrow: 1, + height: 250, + }, + container: { + flexGrow: 1, + position: 'relative', + }, + paper: { + position: 'absolute', + zIndex: 1, + marginTop: theme.spacing(1), + left: 0, + right: 0, + }, + chip: { + margin: theme.spacing(0.5, 0.5, 0.5, 0), + }, + chipContainerFilled: { + margin: '27px 12px 10px 0', + }, + inputRoot: { + flexWrap: 'wrap', + }, + inputRootFilled: { + flexWrap: 'wrap', + '& $chip': { + backgroundColor: chipBackgroundColor, + }, + }, + inputInput: { + width: 'auto', + flexGrow: 1, + }, + divider: { + height: theme.spacing(2), + }, + }; +}); + +export default AutocompleteArrayInput; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInputChip.js b/packages/ra-ui-materialui/src/input/AutocompleteArrayInputChip.js deleted file mode 100644 index 2f2e785e4d3..00000000000 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInputChip.js +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import ChipInput from 'material-ui-chip-input'; -import { withStyles, createStyles } from '@material-ui/core/styles'; - -const chipInputStyles = createStyles({ - label: { - top: 18, - }, - labelShrink: { - top: 8, - }, - chipContainer: { - alignItems: 'center', - display: 'flex', - flexWrap: 'wrap', - minHeight: 50, - paddingBottom: 8, - }, - inputRoot: { - marginTop: 8, - }, -}); - -const AutocompleteArrayInputChip = ({ variant = 'filled', ...props }) => ( - -); - -export default withStyles(chipInputStyles)(AutocompleteArrayInputChip); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput/index.js b/packages/ra-ui-materialui/src/input/AutocompleteInput.js similarity index 81% rename from packages/ra-ui-materialui/src/input/AutocompleteInput/index.js rename to packages/ra-ui-materialui/src/input/AutocompleteInput.js index 760cf6488ed..b39131d31ee 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput/index.js +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.js @@ -1,14 +1,14 @@ import React, { useRef, useEffect, useCallback } from 'react'; import PropTypes from 'prop-types'; import get from 'lodash/get'; -import { makeStyles } from '@material-ui/core/styles'; +import { TextField, makeStyles } from '@material-ui/core'; import Downshift from 'downshift'; -import { useTranslate, useInput } from 'ra-core'; +import { useTranslate, useInput, FieldTitle } from 'ra-core'; -import AutocompleteInputTextField from './AutocompleteInputTextField'; +import InputHelperText from './InputHelperText'; import AutocompleteSuggestionList from './AutocompleteSuggestionList'; +import AutocompleteSuggestionItem from './AutocompleteSuggestionItem'; import getSuggestionsFactory from './getSuggestions'; -import { InputHelperText } from '..'; const useStyles = makeStyles({ container: { @@ -141,8 +141,12 @@ const AutocompleteInput = ({ let inputEl = useRef(); let anchorEl = useRef(); - const updateFilter = useCallback( - value => { + const handleFilterChange = useCallback( + eventOrValue => { + const value = eventOrValue.target + ? eventOrValue.target.value + : eventOrValue; + if (setFilter) { setFilter(value); } @@ -155,8 +159,8 @@ const AutocompleteInput = ({ // Otherwise, it would only display the currently selected one and the user // would have to first clear the input before seeing any other choices useEffect(() => { - updateFilter(''); - }, [input.value, updateFilter]); + handleFilterChange(''); + }, [input.value, handleFilterChange]); const getSuggestionValue = useCallback( suggestion => get(suggestion, optionValue), @@ -281,25 +285,30 @@ const AutocompleteInput = ({ selectedItem, openMenu, }) => { - const isMenuOpen = isOpen && shouldRenderSuggestions(); + const isMenuOpen = + isOpen && shouldRenderSuggestions(suggestionFilter); return (
- + } InputProps={getInputProps({ ...InputProps, + inputRef: storeInputRef, id, name: input.name, onBlur: input.onBlur, onFocus: handleFocus(openMenu), + onChange: handleFilterChange, })} - inputRef={storeInputRef} - source={source} - resource={resource} - isRequired={isRequired} - handleChange={updateFilter} helperText={ (touched && error) || helperText ? ( + > + {getSuggestions(suggestionFilter).map( + (suggestion, index) => ( + + ) + )} +
); }} diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteInput.spec.js b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js similarity index 99% rename from packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteInput.spec.js rename to packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js index f4c6a89e90f..b0e9f865a46 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteInput.spec.js +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js @@ -6,7 +6,7 @@ import { waitForDomChange, } from '@testing-library/react'; -import AutocompleteInput from './index'; +import AutocompleteInput from './AutocompleteInput'; import { Form } from 'react-final-form'; import { TranslationContext } from 'ra-core'; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteInputTextField.js b/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteInputTextField.js deleted file mode 100644 index 04662035db2..00000000000 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteInputTextField.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import TextField from '@material-ui/core/TextField'; -import { FieldTitle } from 'ra-core'; - -const AutoCompleteInputTextField = ({ - InputProps, - classes, - inputRef, - labelProps, - source, - resource, - isRequired, - handleChange, - ...props -}) => { - return ( - - } - InputProps={{ - inputRef, - ...InputProps, - onChange: event => { - InputProps.onChange(event); - handleChange(event.target.value); - }, - }} - {...props} - /> - ); -}; - -export default AutoCompleteInputTextField; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteSuggestionList.js b/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteSuggestionList.js deleted file mode 100644 index 7a63903d430..00000000000 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteSuggestionList.js +++ /dev/null @@ -1,70 +0,0 @@ -import React from 'react'; -import { makeStyles, Paper, Popper } from '@material-ui/core'; - -import AutocompleteSuggestionItem from './AutocompleteSuggestionItem'; - -const useStyles = makeStyles({ - suggestionsContainer: { - zIndex: 2, - }, - suggestionsPaper: { - maxHeight: '50vh', - overflowY: 'auto', - }, -}); - -const AutocompleteSuggestionList = ({ - isOpen, - menuProps, - inputEl, - suggestions, - getSuggestionText, - getSuggestionValue, - selectedItem, - inputValue, - getItemProps, - highlightedIndex, - classes: classesOverride, - suggestionComponent, - suggestionsContainerProps, -}) => { - const classes = useStyles({ classes: classesOverride }); - - return ( - -
- - {suggestions.map((suggestion, index) => ( - - ))} - -
-
- ); -}; - -export default AutocompleteSuggestionList; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput/getSuggestions.js b/packages/ra-ui-materialui/src/input/AutocompleteInput/getSuggestions.js deleted file mode 100644 index 963b7731e06..00000000000 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput/getSuggestions.js +++ /dev/null @@ -1,52 +0,0 @@ -const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string - -export default ({ - choices, - allowEmpty, - optionText, - getSuggestionText, - optionValue, - limitChoicesToValue, - initialSelectedItem, -}) => filter => { - // This is the first display case when the input already has a value. - // Unless limitChoicesToValue was set to true, we want to display more - // choices than just the currently selected one - if ( - initialSelectedItem && - filter === getSuggestionText(initialSelectedItem) - ) { - if (limitChoicesToValue) { - return choices.filter( - choice => - choice[optionValue] === initialSelectedItem[optionValue] - ); - } - - return choices; - } - - const filteredChoices = choices.filter(choice => - getSuggestionText(choice).match( - // We must escape any RegExp reserved characters to avoid errors - // For example, the filter might contains * which must be escaped as \* - new RegExp(escapeRegExp(filter), 'i') - ) - ); - - if (allowEmpty) { - const emptySuggestion = - typeof optionText === 'function' - ? { - [optionValue]: null, - } - : { - [optionText]: '', - [optionValue]: null, - }; - - return filteredChoices.concat(emptySuggestion); - } - - return filteredChoices; -}; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteSuggestionItem.js b/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx similarity index 83% rename from packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteSuggestionItem.js rename to packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx index 9fdbff8eb43..0e328ceed9c 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput/AutocompleteSuggestionItem.js +++ b/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { FunctionComponent } from 'react'; import parse from 'autosuggest-highlight/parse'; import match from 'autosuggest-highlight/match'; import { makeStyles, MenuItem } from '@material-ui/core'; +import { MenuItemProps } from '@material-ui/core/MenuItem'; import classnames from 'classnames'; const useStyles = makeStyles(theme => ({ @@ -19,12 +20,25 @@ const useStyles = makeStyles(theme => ({ highlightedSuggestionText: { fontWeight: 500 }, })); -const AutocompleteSuggestionItem = ({ +interface Props { + component: any; + suggestion: any; + index: number; + highlightedIndex: number; + isSelected: boolean; + inputValue: string; + classes?: any; + getSuggestionText: (suggestion: any) => string; +} + +const AutocompleteSuggestionItem: FunctionComponent< + Props & MenuItemProps<'li', { button?: true }> +> = ({ component, suggestion, index, highlightedIndex, - selectedItem, + isSelected, inputValue, classes: classesOverride, getSuggestionText, @@ -33,8 +47,6 @@ const AutocompleteSuggestionItem = ({ const classes = useStyles({ classes: classesOverride }); const isHighlighted = highlightedIndex === index; const suggestionText = getSuggestionText(suggestion); - const selectedItemText = getSuggestionText(selectedItem); - const isSelected = (selectedItemText || '').indexOf(suggestionText) > -1; const matches = match(suggestionText, inputValue); const parts = parse(suggestionText, matches); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteSuggestionList.tsx b/packages/ra-ui-materialui/src/input/AutocompleteSuggestionList.tsx new file mode 100644 index 00000000000..b20c40ff868 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/AutocompleteSuggestionList.tsx @@ -0,0 +1,56 @@ +import React, { ReactNode, FunctionComponent } from 'react'; +import { makeStyles, Paper, Popper } from '@material-ui/core'; + +const useStyles = makeStyles({ + suggestionsContainer: { + zIndex: 2, + }, + suggestionsPaper: { + maxHeight: '50vh', + overflowY: 'auto', + }, +}); + +interface Props { + children: ReactNode; + isOpen: boolean; + menuProps: any; + inputEl: HTMLElement; + classes?: any; + suggestionsContainerProps?: any; +} + +const AutocompleteSuggestionList: FunctionComponent = ({ + children, + isOpen, + menuProps, + inputEl, + classes: classesOverride = undefined, + suggestionsContainerProps, +}) => { + const classes = useStyles({ classes: classesOverride }); + + return ( + +
+ + {children} + +
+
+ ); +}; + +export default AutocompleteSuggestionList; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput/getSuggestions.spec.js b/packages/ra-ui-materialui/src/input/getSuggestions.spec.ts similarity index 61% rename from packages/ra-ui-materialui/src/input/AutocompleteInput/getSuggestions.spec.js rename to packages/ra-ui-materialui/src/input/getSuggestions.spec.ts index 8fd1b46575e..4602de6c368 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput/getSuggestions.spec.js +++ b/packages/ra-ui-materialui/src/input/getSuggestions.spec.ts @@ -50,6 +50,34 @@ describe('getSuggestions', () => { ).toEqual([{ id: 1, value: '**one' }]); }); + it('should filter choices according to the currently selected values if limitChoicesToValue is false', () => { + expect( + getSuggestions({ + choices, + allowEmpty: false, + optionText: 'value', + getSuggestionText: ({ value }) => value, + optionValue: 'id', + limitChoicesToValue: false, + selectedItem: [choices[0]], + })('') + ).toEqual(choices.slice(1)); + }); + + it('should filter choices according to the currently selected values if limitChoicesToValue is true', () => { + expect( + getSuggestions({ + choices, + allowEmpty: false, + optionText: 'value', + getSuggestionText: ({ value }) => value, + optionValue: 'id', + limitChoicesToValue: true, + selectedItem: [choices[0]], + })('') + ).toEqual(choices.slice(1)); + }); + it('should not filter choices according to the currently selected value if limitChoicesToValue is false', () => { expect( getSuggestions({ @@ -59,7 +87,7 @@ describe('getSuggestions', () => { getSuggestionText: ({ value }) => value, optionValue: 'id', limitChoicesToValue: false, - initialSelectedItem: choices[0], + selectedItem: choices[0], })('one') ).toEqual(choices); }); @@ -73,11 +101,10 @@ describe('getSuggestions', () => { getSuggestionText: ({ value }) => value, optionValue: 'id', limitChoicesToValue: true, - initialSelectedItem: choices[0], + selectedItem: choices[0], })('one') ).toEqual([choices[0]]); }); - it('should add emptySuggestion if allowEmpty is true', () => { expect( getSuggestions({ @@ -95,4 +122,34 @@ describe('getSuggestions', () => { { id: null, value: '' }, ]); }); + + it('should limit the number of choices', () => { + expect( + getSuggestions({ + choices, + allowEmpty: false, + optionText: 'value', + getSuggestionText: ({ value }) => value, + optionValue: 'id', + limitChoicesToValue: true, + suggestionLimit: 2, + })('') + ).toEqual([{ id: 1, value: 'one' }, { id: 2, value: 'two' }]); + + expect( + getSuggestions({ + choices, + allowEmpty: true, + optionText: 'value', + getSuggestionText: ({ value }) => value, + optionValue: 'id', + limitChoicesToValue: true, + suggestionLimit: 2, + })('') + ).toEqual([ + { id: 1, value: 'one' }, + { id: 2, value: 'two' }, + { id: null, value: '' }, + ]); + }); }); diff --git a/packages/ra-ui-materialui/src/input/getSuggestions.ts b/packages/ra-ui-materialui/src/input/getSuggestions.ts new file mode 100644 index 00000000000..1acba7ddff8 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/getSuggestions.ts @@ -0,0 +1,117 @@ +const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + +interface Options { + choices: any[]; + allowEmpty: boolean; + optionText: Function | string; + optionValue: string; + getSuggestionText: (choice: any) => string; + limitChoicesToValue: boolean; + suggestionLimit?: number; + selectedItem?: any | any[]; +} + +/** + * Get the suggestions to display after applying a fuzzy search on the available choices + * + * @example + * getSuggestions({ + * choices: [{ id: 1, name: 'admin' }, { id: 2, name: 'publisher' }], + * optionText: 'name', + * optionValue: 'id', + * getSuggestionText: choice => choice[optionText], + * })('pub') + * + * Will return [{ id: 2, name: 'publisher' }] + */ +export default ({ + choices, + allowEmpty, + optionText, + getSuggestionText, + optionValue, + limitChoicesToValue, + selectedItem, + suggestionLimit = 0, +}: Options) => filter => { + // When we display the suggestions for the first time and the input + // already has a value, we want to display more choices than just the + // currently selected one, unless limitChoicesToValue was set to true + if ( + selectedItem && + !Array.isArray(selectedItem) && + filter === getSuggestionText(selectedItem) + ) { + if (limitChoicesToValue) { + return limitSuggestions( + choices.filter( + choice => choice[optionValue] === selectedItem[optionValue] + ), + suggestionLimit + ); + } + + return limitSuggestions(choices, suggestionLimit); + } + + const filteredChoices = choices.filter(choice => + getSuggestionText(choice).match( + // We must escape any RegExp reserved characters to avoid errors + // For example, the filter might contains * which must be escaped as \* + new RegExp(escapeRegExp(filter), 'i') + ) + ); + + if (allowEmpty) { + const emptySuggestion = + typeof optionText === 'function' + ? { + [optionValue]: null, + } + : { + [optionText]: '', + [optionValue]: null, + }; + + return limitSuggestions( + removeAlreadySelectedSuggestions( + selectedItem, + filteredChoices, + optionValue + ), + suggestionLimit + ).concat(emptySuggestion); + } + + return limitSuggestions( + removeAlreadySelectedSuggestions( + selectedItem, + filteredChoices, + optionValue + ), + suggestionLimit + ); +}; + +const removeAlreadySelectedSuggestions = ( + selectedItem, + suggestions, + optionValue +) => { + if (!Array.isArray(selectedItem)) { + return suggestions; + } + + const selectedValues = selectedItem.map(item => item[optionValue]); + + return suggestions.filter( + suggestion => !selectedValues.includes(suggestion[optionValue]) + ); +}; + +const limitSuggestions = (suggestions, limit = 0) => { + if (Number.isInteger(limit) && limit > 0) { + return suggestions.slice(0, limit); + } + return suggestions; +}; diff --git a/yarn.lock b/yarn.lock index 6836d44aead..ea83bd5130b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3890,7 +3890,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.2.5, classnames@~2.2.5: +classnames@~2.2.5: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -10043,14 +10043,6 @@ matcher@^1.0.0: dependencies: escape-string-regexp "^1.0.4" -material-ui-chip-input@1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/material-ui-chip-input/-/material-ui-chip-input-1.0.0.tgz#96df9d655d2c16c63afd73828aa3f98c778016b1" - integrity sha512-GJ9xkGB+n1SODvflOca4YanHaV8hWkKKBIMrM0YVjo5Jnm+gIom2915CwT86FiDWyoIS6BQOMIof48/l85hZxQ== - dependencies: - classnames "^2.2.5" - prop-types "^15.6.1" - math-random@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c" @@ -10830,11 +10822,6 @@ object-assign@4.1.1, object-assign@^4, object-assign@^4.0.1, object-assign@^4.1. resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= -object-assign@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-3.0.0.tgz#9bedd5ca0897949bca47e7ff408062d549f587f2" - integrity sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I= - object-copy@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c" @@ -12357,7 +12344,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -12640,24 +12627,6 @@ react-app-polyfill@^1.0.2: regenerator-runtime "0.13.3" whatwg-fetch "3.0.0" -react-autosuggest@^9.4.2: - version "9.4.3" - resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.3.tgz#eb46852422a48144ab9f39fb5470319222f26c7c" - integrity sha512-wFbp5QpgFQRfw9cwKvcgLR8theikOUkv8PFsuLYqI2PUgVlx186Cz8MYt5bLxculi+jxGGUUVt+h0esaBZZouw== - dependencies: - prop-types "^15.5.10" - react-autowhatever "^10.1.2" - shallow-equal "^1.0.0" - -react-autowhatever@^10.1.2: - version "10.2.0" - resolved "https://registry.yarnpkg.com/react-autowhatever/-/react-autowhatever-10.2.0.tgz#bdd07bf19ddf78acdb8ce7ae162ac13b646874ab" - integrity sha512-dqHH4uqiJldPMbL8hl/i2HV4E8FMTDEdVlOIbRqYnJi0kTpWseF9fJslk/KS9pGDnm80JkYzVI+nzFjnOG/u+g== - dependencies: - prop-types "^15.5.8" - react-themeable "^1.1.0" - section-iterator "^2.0.0" - react-dev-utils@^9.0.3: version "9.0.3" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-9.0.3.tgz#7607455587abb84599451460eb37cef0b684131a" @@ -12902,13 +12871,6 @@ react-test-renderer@~16.8.6: react-is "^16.8.6" scheduler "^0.13.6" -react-themeable@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e" - integrity sha1-fURm3ZsrX6dQWHJ4JenxUro3mg4= - dependencies: - object-assign "^3.0.0" - react-transition-group@^2.2.1: version "2.9.0" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" @@ -13769,11 +13731,6 @@ seamless-immutable@^7.1.3: resolved "https://registry.yarnpkg.com/seamless-immutable/-/seamless-immutable-7.1.4.tgz#6e9536def083ddc4dea0207d722e0e80d0f372f8" integrity sha512-XiUO1QP4ki4E2PHegiGAlu6r82o5A+6tRh7IkGGTVg/h+UoeX4nFBeCGPOhb4CYjvkqsfm/TUtvOMYC1xmV30A== -section-iterator@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/section-iterator/-/section-iterator-2.0.0.tgz#bf444d7afeeb94ad43c39ad2fb26151627ccba2a" - integrity sha1-v0RNev7rlK1Dw5rS+yYVFifMuio= - select-hose@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" @@ -13965,11 +13922,6 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" -shallow-equal@^1.0.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-1.2.0.tgz#fd828d2029ff4e19569db7e19e535e94e2d1f5cc" - integrity sha512-Z21pVxR4cXsfwpMKMhCEIO1PCi5sp7KEp+CmOpBQ+E8GpHwKOw2sEzk7sgblM3d/j4z4gakoWEoPcjK0VJQogA== - shallowequal@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"