diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx index 1ddfa9053c3..05e9a184a01 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx @@ -408,6 +408,10 @@ describe('', () => { }); it('should allow customized rendering of suggesting item', () => { + const SuggestionItem = ({ record }: { record?: any }) => ( +
+ ); + const { getByLabelText } = render(
', () => { { id: 't', name: 'Technical' }, { id: 'p', name: 'Programming' }, ]} - suggestionComponent={React.forwardRef( - ( - { - suggestion, - query, - isHighlighted, - ...props - }, - ref - ) => ( -
- ) - )} + optionText={} + matchSuggestion={(filter, choice) => true} /> )} /> diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx index 06146c8e0da..5e40408d895 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx @@ -3,16 +3,23 @@ import React, { useEffect, useRef, FunctionComponent, + useMemo, + isValidElement, } 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 { + useInput, + FieldTitle, + InputProps, + useSuggestions, + warning, +} from 'ra-core'; import InputHelperText from './InputHelperText'; -import getSuggestionsFactory from './getSuggestions'; import AutocompleteSuggestionList from './AutocompleteSuggestionList'; import AutocompleteSuggestionItem from './AutocompleteSuggestionItem'; @@ -56,6 +63,18 @@ interface Options { * const optionRenderer = choice => `${choice.first_name} ${choice.last_name}`; * * + * `optionText` also accepts a React Element, that will be cloned and receive + * the related choice as the `record` prop. You can use Field components there. + * Note that you must also specify the `matchSuggestion` prop + * @example + * const choices = [ + * { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, + * { id: 456, first_name: 'Jane', last_name: 'Austen' }, + * ]; + * const matchSuggestion = (filterValue, choice) => choice.first_name.match(filterValue) || choice.last_name.match(filterValue); + * const FullNameField = ({ record }) => {record.first_name} {record.last_name}; + * } matchSuggestion={matchSuggestion} /> + * * The choices are translated by default, so you can use translation identifiers as choices: * @example * const choices = [ @@ -79,12 +98,15 @@ const AutocompleteArrayInput: FunctionComponent< allowEmpty, classes: classesOverride, choices = [], + emptyText, + emptyValue, helperText, id: idOverride, input: inputOverride, isRequired: isRequiredOverride, limitChoicesToValue, margin, + matchSuggestion, meta: metaOverride, onBlur, onChange, @@ -108,7 +130,15 @@ const AutocompleteArrayInput: FunctionComponent< variant = 'filled', ...rest }) => { - const translate = useTranslate(); + warning( + isValidElement(optionText) && !matchSuggestion, + `If the optionText prop is a React element, you must also specify the matchSuggestion prop: + true} +/> + ` + ); + const classes = useStyles({ classes: classesOverride }); let inputEl = useRef(); @@ -135,6 +165,30 @@ const AutocompleteArrayInput: FunctionComponent< const [filterValue, setFilterValue] = React.useState(''); + const getSuggestionFromValue = useCallback( + value => choices.find(choice => get(choice, optionValue) === value), + [choices, optionValue] + ); + + const selectedItems = useMemo( + () => (input.value || []).map(getSuggestionFromValue), + [input.value, getSuggestionFromValue] + ); + + const { getChoiceText, getChoiceValue, getSuggestions } = useSuggestions({ + allowEmpty, + choices, + emptyText, + emptyValue, + limitChoicesToValue, + matchSuggestion, + optionText, + optionValue, + selectedItem: selectedItems, + suggestionLimit, + translateChoice, + }); + const handleFilterChange = useCallback( (eventOrValue: React.ChangeEvent<{ value: string }> | string) => { const event = eventOrValue as React.ChangeEvent<{ value: string }>; @@ -158,34 +212,6 @@ const AutocompleteArrayInput: FunctionComponent< 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 @@ -198,10 +224,10 @@ const AutocompleteArrayInput: FunctionComponent< 0, selectedItems.length - 1 ); - input.onChange(newSelectedItems.map(getSuggestionValue)); + input.onChange(newSelectedItems.map(getChoiceValue)); } }, - [filterValue.length, getSuggestionValue, input, selectedItems] + [filterValue.length, getChoiceValue, input, selectedItems] ); const handleChange = useCallback( @@ -210,9 +236,9 @@ const AutocompleteArrayInput: FunctionComponent< ? [...selectedItems] : [...selectedItems, item]; setFilterValue(''); - input.onChange(newSelectedItems.map(getSuggestionValue)); + input.onChange(newSelectedItems.map(getChoiceValue)); }, - [getSuggestionValue, input, selectedItems] + [getChoiceValue, input, selectedItems] ); const handleDelete = useCallback( @@ -220,12 +246,12 @@ const AutocompleteArrayInput: FunctionComponent< const newSelectedItems = [...selectedItems]; const value = event.target.getAttribute('data-item'); const item = choices.find( - choice => getSuggestionValue(choice) == value // eslint-disable-line eqeqeq + choice => getChoiceValue(choice) == value // eslint-disable-line eqeqeq ); newSelectedItems.splice(newSelectedItems.indexOf(item), 1); - input.onChange(newSelectedItems.map(getSuggestionValue)); + input.onChange(newSelectedItems.map(getChoiceValue)); }, - [choices, getSuggestionValue, input, selectedItems] + [choices, getChoiceValue, input, selectedItems] ); // This function ensures that the suggestion list stay aligned to the @@ -256,28 +282,6 @@ const AutocompleteArrayInput: FunctionComponent< } }; - 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(); @@ -316,7 +320,7 @@ const AutocompleteArrayInput: FunctionComponent< inputValue={filterValue} onChange={handleChange} selectedItem={selectedItems} - itemToString={item => getSuggestionValue(item)} + itemToString={item => getChoiceValue(item)} {...rest} > {({ @@ -332,6 +336,7 @@ const AutocompleteArrayInput: FunctionComponent< const isMenuOpen = isOpen && shouldRenderSuggestions(suggestionFilter); const { + id: idFromDownshift, onBlur, onChange, onFocus, @@ -345,10 +350,9 @@ const AutocompleteArrayInput: FunctionComponent< return (
))}
@@ -427,18 +429,17 @@ const AutocompleteArrayInput: FunctionComponent< {getSuggestions(suggestionFilter).map( (suggestion, index) => ( - * - * 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 AutocompleteInput = ({ - classes: classesOverride, - allowEmpty, - choices, - fullWidth, - helperText, - // id may have been initialized before (from ReferenceInput for example) - id: idOverride, - // input may have been initialized before (from ReferenceInput for example) - input: inputOverride, - // isRequired may have been initialized before (from ReferenceInput for example) - isRequired: isRequiredOverride, - label, - limitChoicesToValue, - margin = 'dense', - // input may have been initialized before (from ReferenceInput for example) - meta: metaOverride, - onBlur, - onChange, - onFocus, - options: { suggestionsContainerProps, labelProps, InputProps, ...options }, - optionText, - optionValue, - pagination, - resource, - setFilter, - setPagination, - setSort, - shouldRenderSuggestions: shouldRenderSuggestionsOverride, - sort, - source, - suggestionComponent, - translateChoice, - validate, - variant = 'filled', - ...rest -}) => { - const translate = useTranslate(); - const classes = useStyles({ classes: classesOverride }); - const { - id, - input, - isRequired, - meta: { touched, error }, - } = useInput({ - id: idOverride, - input: inputOverride, - isRequired: isRequiredOverride, - meta: metaOverride, - onBlur, - onChange, - onFocus, - resource, - source, - validate, - ...rest, - }); - - let inputEl = useRef(); - let anchorEl = useRef(); - - const handleFilterChange = useCallback( - eventOrValue => { - const value = eventOrValue.target - ? eventOrValue.target.value - : eventOrValue; - - if (setFilter) { - setFilter(value); - } - }, - [setFilter] - ); - - // We must reset the filter every time the value change to ensures 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, ''); - - // We explicitly call toString here because AutoSuggest expect a string - return translateChoice - ? translate(suggestionLabel, { _: suggestionLabel }).toString() - : suggestionLabel.toString(); - }, - [optionText, translate, translateChoice] - ); - - const handleSuggestionSelected = useCallback( - suggestion => { - input.onChange(getSuggestionValue(suggestion)); - }, - [getSuggestionValue, input] - ); - - const updateAnchorEl = () => { - if (!inputEl.current) { - return; - } - - const inputPosition = inputEl.current.getBoundingClientRect(); - - 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 shouldRenderSuggestions = val => { - if ( - shouldRenderSuggestionsOverride !== undefined && - typeof shouldRenderSuggestionsOverride === 'function' - ) { - return shouldRenderSuggestionsOverride(val); - } - - return true; - }; - - const storeInputRef = input => { - inputEl.current = input; - updateAnchorEl(); - }; - - const initialSelectedItem = getSuggestionFromValue(input.value); - - const getSuggestions = useCallback( - getSuggestionsFactory({ - choices, - allowEmpty, - optionText, - optionValue, - limitChoicesToValue, - getSuggestionText, - initialSelectedItem: initialSelectedItem, - }), - [ - choices, - allowEmpty, - optionText, - optionValue, - limitChoicesToValue, - getSuggestionText, - input, - ] - ); - - const handleFocus = useCallback( - openMenu => event => { - openMenu(event); - input.onFocus(event); - }, - [input] - ); - - return ( - getSuggestionText(item)} - {...rest} - > - {({ - getInputProps, - getLabelProps, - getItemProps, - getMenuProps, - highlightedIndex, - isOpen, - inputValue: suggestionFilter, - selectedItem, - openMenu, - }) => { - const isMenuOpen = - isOpen && shouldRenderSuggestions(suggestionFilter); - return ( -
- - } - InputProps={getInputProps({ - ...InputProps, - inputRef: storeInputRef, - id, - name: input.name, - onBlur: input.onBlur, - onFocus: handleFocus(openMenu), - onChange: handleFilterChange, - })} - helperText={ - (touched && error) || helperText ? ( - - ) : null - } - error={!!(touched && error)} - variant={variant} - margin={margin} - {...options} - /> - - {getSuggestions(suggestionFilter).map( - (suggestion, index) => ( - - ) - )} - -
- ); - }} -
- ); -}; - -AutocompleteInput.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, - label: PropTypes.string, - limitChoicesToValue: PropTypes.bool, - 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, - ]), - translateChoice: PropTypes.bool.isRequired, -}; - -AutocompleteInput.defaultProps = { - choices: [], - options: {}, - optionText: 'name', - optionValue: 'id', - limitChoicesToValue: false, - translateChoice: true, -}; - -export default AutocompleteInput; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx similarity index 91% rename from packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js rename to packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index b0e9f865a46..7cefe21b5d6 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.js +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -3,6 +3,7 @@ import { render, cleanup, fireEvent, + wait, waitForDomChange, } from '@testing-library/react'; @@ -390,7 +391,7 @@ describe('', () => { ); const input = getByLabelText('resources.users.fields.role'); fireEvent.change(input, { target: { value: 'bar' } }); - expect(setFilter).toHaveBeenCalledTimes(2); + expect(setFilter).toHaveBeenCalledTimes(3); expect(setFilter).toHaveBeenCalledWith('bar'); rerender( @@ -409,34 +410,23 @@ describe('', () => { )} /> ); - expect(setFilter).toHaveBeenCalledTimes(3); + expect(setFilter).toHaveBeenCalledTimes(5); expect(setFilter).toHaveBeenCalledWith(''); }); it('should allow customized rendering of suggesting item', () => { + const SuggestionItem = ({ record }: { record?: any }) => ( +
+ ); + const { getByLabelText, queryByLabelText } = render( ( ( -
- ) - )} + optionText={} + matchSuggestion={(filter, choice) => true} choices={[ { id: 1, name: 'bar' }, { id: 2, name: 'foo' }, @@ -514,7 +504,6 @@ describe('', () => { const { queryAllByRole, getByLabelText } = render( ( ', () => { /> ); const input = getByLabelText('resources.users.fields.role'); + fireEvent.focus(input); fireEvent.change(input, { target: { value: 'a' } }); expect(queryAllByRole('option').length).toEqual(2); @@ -539,29 +529,7 @@ describe('', () => { }); }); - it('does not automatically select a matched choice if there are more than one', () => { - const { queryAllByRole, getByLabelText } = render( - ( - - )} - /> - ); - const input = getByLabelText('resources.users.fields.role'); - fireEvent.focus(input); - fireEvent.change(input, { target: { value: 'ab' } }); - expect(queryAllByRole('option').length).toEqual(2); - }); - - it('does not automatically select a matched choice if there is only one', () => { + it('does not automatically select a matched choice if there is only one', async () => { const { queryAllByRole, getByLabelText } = render( ', () => { const input = getByLabelText('resources.users.fields.role'); fireEvent.focus(input); fireEvent.change(input, { target: { value: 'abc' } }); - expect(queryAllByRole('option').length).toEqual(1); + await wait(() => expect(queryAllByRole('option').length).toEqual(1)); }); it('passes options.suggestionsContainerProps to the suggestions container', () => { diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx new file mode 100644 index 00000000000..99d28f36895 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -0,0 +1,457 @@ +import React, { + useCallback, + useEffect, + useRef, + FunctionComponent, + useMemo, + isValidElement, +} from 'react'; +import Downshift, { DownshiftProps } from 'downshift'; +import classNames from 'classnames'; +import get from 'lodash/get'; +import { makeStyles, TextField } from '@material-ui/core'; +import { TextFieldProps } from '@material-ui/core/TextField'; +import { + useInput, + FieldTitle, + InputProps, + useSuggestions, + warning, +} from 'ra-core'; + +import InputHelperText from './InputHelperText'; +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}`; + * + * + * `optionText` also accepts a React Element, that will be cloned and receive + * the related choice as the `record` prop. You can use Field components there. + * Note that you must also specify the `matchSuggestion` prop + * @example + * const choices = [ + * { id: 123, first_name: 'Leo', last_name: 'Tolstoi' }, + * { id: 456, first_name: 'Jane', last_name: 'Austen' }, + * ]; + * const matchSuggestion = (filterValue, choice) => choice.first_name.match(filterValue) || choice.last_name.match(filterValue); + * const FullNameField = ({ record }) => {record.first_name} {record.last_name}; + * } matchSuggestion={matchSuggestion} /> + * + * 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 AutocompleteInput: FunctionComponent< + Props & InputProps & DownshiftProps +> = ({ + allowEmpty, + classes: classesOverride, + choices = [], + emptyText, + emptyValue, + helperText, + id: idOverride, + input: inputOverride, + isRequired: isRequiredOverride, + limitChoicesToValue, + margin, + matchSuggestion, + 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 +}) => { + warning( + isValidElement(optionText) && !matchSuggestion, + `If the optionText prop is a React element, you must also specify the matchSuggestion prop: + true} +/> + ` + ); + + 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 getSuggestionFromValue = useCallback( + value => choices.find(choice => get(choice, optionValue) === value), + [choices, optionValue] + ); + + const selectedItem = useMemo( + () => getSuggestionFromValue(input.value) || null, + [input.value, getSuggestionFromValue] + ); + + const { getChoiceText, getChoiceValue, getSuggestions } = useSuggestions({ + allowEmpty, + choices, + emptyText, + emptyValue, + limitChoicesToValue, + matchSuggestion, + optionText, + optionValue, + selectedItem, + suggestionLimit, + translateChoice, + }); + + 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); + + 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(''); + + // If we have a value, set the filter to its text so that + // Downshift displays it correctly + setFilterValue(input.value ? getChoiceText(selectedItem) : ''); + }, [input.value, handleFilterChange, selectedItem, getChoiceText]); + + const handleChange = useCallback( + (item: any) => { + input.onChange(getChoiceValue(item)); + }, + [getChoiceValue, input] + ); + + // 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 storeInputRef = input => { + inputEl.current = input; + updateAnchorEl(); + }; + + const handleBlur = useCallback( + event => { + handleFilterChange(''); + // If we had a value before, set the filter back to its text so that + // Downshift displays it correctly + setFilterValue(input.value ? getChoiceText(selectedItem) : ''); + input.onBlur(event); + }, + [getChoiceText, handleFilterChange, input, selectedItem] + ); + + 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 ( + getChoiceValue(item)} + {...rest} + > + {({ + getInputProps, + getItemProps, + getLabelProps, + getMenuProps, + isOpen, + inputValue, + highlightedIndex, + openMenu, + }) => { + const isMenuOpen = + isOpen && shouldRenderSuggestions(filterValue); + const { + id: downshiftId, // We want to ignore this to correctly link our label and the input + value, + onBlur, + onChange, + onFocus, + ref, + ...inputProps + } = getInputProps({ + onBlur: handleBlur, + onFocus: handleFocus(openMenu), + }); + const suggestions = getSuggestions(filterValue); + + return ( +
+ { + handleFilterChange(event); + setFilterValue(event.target.value); + onChange!(event as React.ChangeEvent< + HTMLInputElement + >); + }, + onFocus, + }} + label={ + + } + InputLabelProps={getLabelProps({ + htmlFor: id, + })} + helperText={ + (touched && error) || helperText ? ( + + ) : null + } + variant={variant} + margin={margin} + value={filterValue} + {...inputProps} + {...options} + /> + + {suggestions.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 AutocompleteInput; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx b/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx index 0e328ceed9c..2e2280f322a 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, isValidElement, cloneElement } from 'react'; import parse from 'autosuggest-highlight/parse'; import match from 'autosuggest-highlight/match'; import { makeStyles, MenuItem } from '@material-ui/core'; @@ -21,12 +21,11 @@ const useStyles = makeStyles(theme => ({ })); interface Props { - component: any; suggestion: any; index: number; highlightedIndex: number; isSelected: boolean; - inputValue: string; + filterValue: string; classes?: any; getSuggestionText: (suggestion: any) => string; } @@ -34,12 +33,11 @@ interface Props { const AutocompleteSuggestionItem: FunctionComponent< Props & MenuItemProps<'li', { button?: true }> > = ({ - component, suggestion, index, highlightedIndex, isSelected, - inputValue, + filterValue, classes: classesOverride, getSuggestionText, ...rest @@ -47,47 +45,46 @@ const AutocompleteSuggestionItem: FunctionComponent< const classes = useStyles({ classes: classesOverride }); const isHighlighted = highlightedIndex === index; const suggestionText = getSuggestionText(suggestion); - const matches = match(suggestionText, inputValue); - const parts = parse(suggestionText, matches); + let matches; + let parts; - let additionalPropsForOverrides = {}; - - if (!!component) { - additionalPropsForOverrides = { - isHighlighted, - query: inputValue, - suggestion, - }; + if (!isValidElement(suggestionText)) { + matches = match(suggestionText, filterValue); + parts = parse(suggestionText, matches); } + return ( -
- {parts.map((part, index) => { - return part.highlight ? ( - - {part.text} - - ) : ( - - {part.text} - - ); - })} -
+ {isValidElement<{ filterValue }>(suggestionText) ? ( + cloneElement<{ filterValue }>(suggestionText, { filterValue }) + ) : ( +
+ {parts.map((part, index) => { + return part.highlight ? ( + + {part.text} + + ) : ( + + {part.text} + + ); + })} +
+ )}
); };