From 7a527ad81c3894d2371304cfbbb4b0cf06146772 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Thu, 12 Sep 2019 18:20:36 +0200 Subject: [PATCH 01/16] Introduce useChoices hook --- packages/ra-core/src/form/index.ts | 11 +++ packages/ra-core/src/form/useChoices.spec.tsx | 90 +++++++++++++++++++ packages/ra-core/src/form/useChoices.ts | 48 ++++++++++ 3 files changed, 149 insertions(+) create mode 100644 packages/ra-core/src/form/useChoices.spec.tsx create mode 100644 packages/ra-core/src/form/useChoices.ts diff --git a/packages/ra-core/src/form/index.ts b/packages/ra-core/src/form/index.ts index bdbc61afadf..a812ca69655 100644 --- a/packages/ra-core/src/form/index.ts +++ b/packages/ra-core/src/form/index.ts @@ -4,12 +4,23 @@ import FormField from './FormField'; import useInput, { InputProps } from './useInput'; import ValidationError from './ValidationError'; import useInitializeFormWithRecord from './useInitializeFormWithRecord'; +import useChoices, { + Choice, + ChoicesProps, + OptionTextElement, + OptionText, +} from './useChoices'; export { addField, + Choice, + ChoicesProps, FormDataConsumer, FormField, InputProps, + OptionTextElement, + OptionText, + useChoices, useInput, useInitializeFormWithRecord, ValidationError, diff --git a/packages/ra-core/src/form/useChoices.spec.tsx b/packages/ra-core/src/form/useChoices.spec.tsx new file mode 100644 index 00000000000..658ff8e43d0 --- /dev/null +++ b/packages/ra-core/src/form/useChoices.spec.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import expect from 'expect'; +import { render, cleanup } from '@testing-library/react'; +import useChoices from './useChoices'; +import { renderWithRedux } from '../util'; +import { TranslationProvider } from '../i18n'; + +describe('useChoices hook', () => { + afterEach(cleanup); + const defaultProps = { + choice: { id: 42, name: 'test' }, + optionValue: 'id', + optionText: 'name', + translateChoice: true, + }; + + const Component = ({ + choice, + optionText, + optionValue, + translateChoice, + }) => { + const { getChoiceText, getChoiceValue } = useChoices({ + optionText, + optionValue, + translateChoice, + }); + + return ( +
+ {getChoiceText(choice)} +
+ ); + }; + + it('should use optionValue as value identifier', () => { + const { getByText } = render(); + expect(getByText('test').getAttribute('data-value')).toEqual('42'); + }); + + it('should use optionText with a string value as text identifier', () => { + const { queryAllByText } = render(); + expect(queryAllByText('test')).toHaveLength(1); + }); + + it('should use optionText with a function value as text identifier', () => { + const { queryAllByText } = render( + choice.foobar} + choice={{ id: 42, foobar: 'test' }} + /> + ); + expect(queryAllByText('test')).toHaveLength(1); + }); + + it('should use optionText with an element value as text identifier', () => { + const Foobar = ({ record }: { record?: any }) => ( + {record.foobar} + ); + const { queryAllByText } = render( + } + choice={{ id: 42, foobar: 'test' }} + /> + ); + expect(queryAllByText('test')).toHaveLength(1); + }); + + it('should translate the choice by default', () => { + const { queryAllByText } = renderWithRedux( + ({ test: 'bonjour' })}> + + + ); + expect(queryAllByText('test')).toHaveLength(0); + expect(queryAllByText('bonjour')).toHaveLength(1); + }); + + it('should not translate the choice if translateChoice is false', () => { + const { queryAllByText } = renderWithRedux( + ({ hello: 'bonjour' })}> + + + ); + expect(queryAllByText('test')).toHaveLength(1); + expect(queryAllByText('bonjour')).toHaveLength(0); + }); +}); diff --git a/packages/ra-core/src/form/useChoices.ts b/packages/ra-core/src/form/useChoices.ts new file mode 100644 index 00000000000..aea505aba7a --- /dev/null +++ b/packages/ra-core/src/form/useChoices.ts @@ -0,0 +1,48 @@ +import { ReactElement, isValidElement, cloneElement } from 'react'; +import get from 'lodash/get'; + +import { Identifier } from '../types'; +import { useTranslate } from '../i18n'; + +export interface Choice { + [key: string]: string | Identifier; +} + +export type OptionTextElement = ReactElement<{ record: Choice }>; +export type OptionText = (choice: Choice) => string | OptionTextElement; + +export interface ChoicesProps { + choices: Choice[]; + optionValue: string; + optionText: OptionTextElement | OptionText | string; + translateChoice: boolean; +} + +const useChoices = ({ optionText, optionValue, translateChoice }) => { + const translate = useTranslate(); + + const getChoiceText = choice => { + if (isValidElement<{ record: any }>(optionText)) { + return cloneElement<{ record: any }>(optionText, { + record: choice, + }); + } + const choiceName = + typeof optionText === 'function' + ? optionText(choice) + : get(choice, optionText); + + return translateChoice + ? translate(choiceName, { _: choiceName }) + : choiceName; + }; + + const getChoiceValue = choice => get(choice, optionValue); + + return { + getChoiceText, + getChoiceValue, + }; +}; + +export default useChoices; From f0c93bb96840b01dbec0d6d230fd80d5be9abb8b Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Thu, 12 Sep 2019 18:20:46 +0200 Subject: [PATCH 02/16] Migrate SelectField --- .../src/field/SelectField.tsx | 49 ++++++------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/packages/ra-ui-materialui/src/field/SelectField.tsx b/packages/ra-ui-materialui/src/field/SelectField.tsx index e4506aef63c..898c6d70572 100644 --- a/packages/ra-ui-materialui/src/field/SelectField.tsx +++ b/packages/ra-ui-materialui/src/field/SelectField.tsx @@ -1,27 +1,13 @@ -import React, { SFC, ReactElement } from 'react'; +import React, { SFC } from 'react'; import PropTypes from 'prop-types'; import get from 'lodash/get'; import pure from 'recompose/pure'; -import { useTranslate, Identifier } from 'ra-core'; +import { ChoicesProps, useChoices } from 'ra-core'; import Typography from '@material-ui/core/Typography'; import sanitizeRestProps from './sanitizeRestProps'; import { FieldProps, InjectedFieldProps, fieldPropTypes } from './types'; -interface Choice { - [key: string]: string | Identifier; -} - -type OptionTextElement = ReactElement<{ record: Choice }>; -type OptionText = (choice: Choice) => string | OptionTextElement; - -interface Props extends FieldProps { - choices: Choice[]; - optionValue: string; - optionText: OptionTextElement | OptionText | string; - translateChoice: boolean; -} - /** * Display a value in an enumeration * @@ -81,7 +67,9 @@ interface Props extends FieldProps { * * **Tip**: sets `translateChoice` to false by default. */ -export const SelectField: SFC = ({ +export const SelectField: SFC< + ChoicesProps & FieldProps & InjectedFieldProps +> = ({ className, source, record, @@ -91,23 +79,20 @@ export const SelectField: SFC = ({ translateChoice, ...rest }) => { - const translate = useTranslate(); const value = get(record, source); - const choice = choices.find(c => c[optionValue] === value); + const { getChoiceText, getChoiceValue } = useChoices({ + optionText, + optionValue, + translateChoice, + }); + + const choice = choices.find(choice => getChoiceValue(choice) === value); + if (!choice) { return null; } - let choiceIsElement = false; - let choiceName; - if (React.isValidElement(optionText)) { - choiceIsElement = true; - choiceName = React.cloneElement(optionText, { record: choice }); - } else { - choiceName = - typeof optionText === 'function' - ? optionText(choice) - : choice[optionText]; - } + + let choiceText = getChoiceText(choice); return ( = ({ className={className} {...sanitizeRestProps(rest)} > - {translateChoice && !choiceIsElement - ? translate(choiceName, { _: choiceName }) - : choiceName} + {choiceText} ); }; From e0e7994faaae5218eabfab9a6e2951c55a505a64 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 09:58:17 +0200 Subject: [PATCH 03/16] Migrate SelectInput --- packages/ra-core/src/form/useChoices.ts | 4 +- .../src/input/SelectInput.tsx | 44 +++++++++---------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/packages/ra-core/src/form/useChoices.ts b/packages/ra-core/src/form/useChoices.ts index aea505aba7a..e52a9837ad7 100644 --- a/packages/ra-core/src/form/useChoices.ts +++ b/packages/ra-core/src/form/useChoices.ts @@ -4,9 +4,9 @@ import get from 'lodash/get'; import { Identifier } from '../types'; import { useTranslate } from '../i18n'; -export interface Choice { +export type Choice = { [key: string]: string | Identifier; -} +}; export type OptionTextElement = ReactElement<{ record: Choice }>; export type OptionText = (choice: Choice) => string | OptionTextElement; diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 560a111dd8b..9fe080d4be9 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -3,7 +3,14 @@ import PropTypes from 'prop-types'; import get from 'lodash/get'; import MenuItem from '@material-ui/core/MenuItem'; import { makeStyles } from '@material-ui/core/styles'; -import { useInput, FieldTitle, useTranslate, InputProps } from 'ra-core'; +import { + useInput, + FieldTitle, + useTranslate, + InputProps, + ChoicesProps, + useChoices, +} from 'ra-core'; import ResettableTextField from './ResettableTextField'; import InputHelperText from './InputHelperText'; @@ -131,7 +138,9 @@ const useStyles = makeStyles(theme => ({ * */ const SelectInput: FunctionComponent< - InputProps & Omit + ChoicesProps & + InputProps & + Omit > = ({ allowEmpty, choices, @@ -155,6 +164,11 @@ const SelectInput: FunctionComponent< }) => { const translate = useTranslate(); const classes = useStyles({}); + const { getChoiceText, getChoiceValue } = useChoices({ + optionText, + optionValue, + translateChoice, + }); const { id, @@ -177,25 +191,9 @@ const SelectInput: FunctionComponent< : translate(emptyText, { _: emptyText }); }, [emptyText, translate]); - const renderMenuItemOption = useCallback( - choice => { - if (React.isValidElement<{ record: any }>(optionText)) { - return React.cloneElement<{ record: any }>(optionText, { - record: choice, - }); - } - - const choiceName = - typeof optionText === 'function' - ? optionText(choice) - : get(choice, optionText); - - return translateChoice - ? translate(choiceName, { _: choiceName }) - : choiceName; - }, - [optionText, translate, translateChoice] - ); + const renderMenuItemOption = useCallback(choice => getChoiceText(choice), [ + getChoiceText, + ]); return ( ( {renderMenuItemOption(choice)} From ac49db58198d143fbfcb5fa58c9aefdc15357e3f Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 10:11:24 +0200 Subject: [PATCH 04/16] Fix Types --- packages/ra-ui-materialui/src/input/SelectInput.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 9fe080d4be9..716b8a1c55e 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -15,6 +15,7 @@ import { import ResettableTextField from './ResettableTextField'; import InputHelperText from './InputHelperText'; import { TextFieldProps } from '@material-ui/core/TextField'; +import { Choice } from 'ra-core'; const sanitizeRestProps = ({ addLabel, @@ -143,7 +144,7 @@ const SelectInput: FunctionComponent< Omit > = ({ allowEmpty, - choices, + choices = [], className, disableValue, emptyText, @@ -245,7 +246,7 @@ SelectInput.propTypes = { allowEmpty: PropTypes.bool.isRequired, emptyText: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), emptyValue: PropTypes.any, - choices: PropTypes.arrayOf(PropTypes.object), + choices: PropTypes.arrayOf(PropTypes.any).isRequired, classes: PropTypes.object, className: PropTypes.string, label: PropTypes.string, @@ -266,7 +267,6 @@ SelectInput.defaultProps = { allowEmpty: false, emptyText: '', emptyValue: '', - choices: [], options: {}, optionText: 'name', optionValue: 'id', From b9c25ef77cd4467d7ba243931e84b882b316105f Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 10:11:33 +0200 Subject: [PATCH 05/16] Migrate SelectArrayInput --- .../src/input/SelectArrayInput.tsx | 57 +++++++------------ 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx index 347124e57cb..38b83cc4329 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx @@ -1,6 +1,5 @@ import React, { FunctionComponent, useCallback } from 'react'; import PropTypes from 'prop-types'; -import get from 'lodash/get'; import { makeStyles, Select, @@ -13,21 +12,22 @@ import { Chip, } from '@material-ui/core'; import classnames from 'classnames'; -import { FieldTitle, useInput, useTranslate, InputProps } from 'ra-core'; +import { FieldTitle, useInput, InputProps, ChoicesProps } from 'ra-core'; import InputHelperText from './InputHelperText'; -import { InputWithOptionsProps } from './InputWithOptions'; import { SelectProps } from '@material-ui/core/Select'; import { FormControlProps } from '@material-ui/core/FormControl'; +import { useChoices } from 'ra-core'; +import { Choice } from 'ra-core'; const sanitizeRestProps = ({ addLabel, allowEmpty, basePath, choices, - className, - component, - crudGetMatching, - crudGetOne, + classNamInputWithOptionsPropse, + componenInputWithOptionsPropst, + crudGetMInputWithOptionsPropsatching, + crudGetOInputWithOptionsPropsne, defaultValue, filter, filterToQuery, @@ -127,7 +127,7 @@ const useStyles = makeStyles(theme => ({ * ]; */ const SelectArrayInput: FunctionComponent< - InputWithOptionsProps & InputProps & FormControlProps + ChoicesProps & InputProps & FormControlProps > = ({ choices = [], classes: classesOverride, @@ -149,8 +149,11 @@ const SelectArrayInput: FunctionComponent< ...rest }) => { const classes = useStyles({ classes: classesOverride }); - - const translate = useTranslate(); + const { getChoiceText, getChoiceValue } = useChoices({ + optionText, + optionValue, + translateChoice, + }); const { id, @@ -167,38 +170,22 @@ const SelectArrayInput: FunctionComponent< ...rest, }); - const renderMenuItemOption = useCallback( - choice => { - if (React.isValidElement<{ record: any }>(optionText)) { - return React.cloneElement(optionText, { - record: choice, - }); - } - - const choiceName = - typeof optionText === 'function' - ? optionText(choice) - : get(choice, optionText); - - return translateChoice - ? translate(choiceName, { _: choiceName }) - : choiceName; - }, - [optionText, translate, translateChoice] - ); + const renderMenuItemOption = useCallback(choice => getChoiceText(choice), [ + getChoiceText, + ]); const renderMenuItem = useCallback( choice => { return choice ? ( {renderMenuItemOption(choice)} ) : null; }, - [optionValue, renderMenuItemOption] + [getChoiceValue, renderMenuItemOption] ); return ( @@ -233,12 +220,12 @@ const SelectArrayInput: FunctionComponent< {selected .map(item => choices.find( - choice => get(choice, optionValue) === item + choice => getChoiceValue(choice) === item ) ) .map(item => ( @@ -267,7 +254,7 @@ const SelectArrayInput: FunctionComponent< }; SelectArrayInput.propTypes = { - choices: PropTypes.arrayOf(PropTypes.object), + choices: PropTypes.arrayOf(PropTypes.any).isRequired, classes: PropTypes.object, className: PropTypes.string, children: PropTypes.node, From 70ad9c9a013241a860b8d329abce1f4ba14b72c5 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 10:49:10 +0200 Subject: [PATCH 06/16] Fix types --- packages/ra-core/src/form/index.ts | 2 -- packages/ra-core/src/form/useChoices.ts | 32 ++++++++++++------- .../src/field/SelectField.tsx | 2 +- .../src/input/SelectArrayInput.tsx | 12 ++++--- .../src/input/SelectInput.tsx | 3 +- 5 files changed, 30 insertions(+), 21 deletions(-) diff --git a/packages/ra-core/src/form/index.ts b/packages/ra-core/src/form/index.ts index a812ca69655..19796e570de 100644 --- a/packages/ra-core/src/form/index.ts +++ b/packages/ra-core/src/form/index.ts @@ -5,7 +5,6 @@ import useInput, { InputProps } from './useInput'; import ValidationError from './ValidationError'; import useInitializeFormWithRecord from './useInitializeFormWithRecord'; import useChoices, { - Choice, ChoicesProps, OptionTextElement, OptionText, @@ -13,7 +12,6 @@ import useChoices, { export { addField, - Choice, ChoicesProps, FormDataConsumer, FormField, diff --git a/packages/ra-core/src/form/useChoices.ts b/packages/ra-core/src/form/useChoices.ts index e52a9837ad7..866cae886ef 100644 --- a/packages/ra-core/src/form/useChoices.ts +++ b/packages/ra-core/src/form/useChoices.ts @@ -1,24 +1,32 @@ import { ReactElement, isValidElement, cloneElement } from 'react'; import get from 'lodash/get'; -import { Identifier } from '../types'; import { useTranslate } from '../i18n'; +import { Record } from '../types'; -export type Choice = { - [key: string]: string | Identifier; -}; - -export type OptionTextElement = ReactElement<{ record: Choice }>; -export type OptionText = (choice: Choice) => string | OptionTextElement; +export type OptionTextElement = ReactElement<{ + record: Record; +}>; +export type OptionText = (choice: object) => string | OptionTextElement; export interface ChoicesProps { - choices: Choice[]; - optionValue: string; - optionText: OptionTextElement | OptionText | string; - translateChoice: boolean; + choices: object[]; + optionValue?: string; + optionText?: OptionTextElement | OptionText | string; + translateChoice?: boolean; +} + +interface Options { + optionValue?: string; + optionText?: OptionTextElement | OptionText | string; + translateChoice?: boolean; } -const useChoices = ({ optionText, optionValue, translateChoice }) => { +const useChoices = ({ + optionText = 'name', + optionValue = 'id', + translateChoice = true, +}: Options) => { const translate = useTranslate(); const getChoiceText = choice => { diff --git a/packages/ra-ui-materialui/src/field/SelectField.tsx b/packages/ra-ui-materialui/src/field/SelectField.tsx index 898c6d70572..f6fecd8c9f1 100644 --- a/packages/ra-ui-materialui/src/field/SelectField.tsx +++ b/packages/ra-ui-materialui/src/field/SelectField.tsx @@ -121,7 +121,7 @@ EnhancedSelectField.defaultProps = { EnhancedSelectField.propTypes = { ...Typography.propTypes, ...fieldPropTypes, - choices: PropTypes.arrayOf(PropTypes.any).isRequired, + choices: PropTypes.arrayOf(PropTypes.object).isRequired, optionText: PropTypes.oneOfType([ PropTypes.string, PropTypes.func, diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx index 38b83cc4329..fd51cd9bd56 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx @@ -12,12 +12,16 @@ import { Chip, } from '@material-ui/core'; import classnames from 'classnames'; -import { FieldTitle, useInput, InputProps, ChoicesProps } from 'ra-core'; +import { + FieldTitle, + useInput, + InputProps, + ChoicesProps, + useChoices, +} from 'ra-core'; import InputHelperText from './InputHelperText'; import { SelectProps } from '@material-ui/core/Select'; import { FormControlProps } from '@material-ui/core/FormControl'; -import { useChoices } from 'ra-core'; -import { Choice } from 'ra-core'; const sanitizeRestProps = ({ addLabel, @@ -254,7 +258,7 @@ const SelectArrayInput: FunctionComponent< }; SelectArrayInput.propTypes = { - choices: PropTypes.arrayOf(PropTypes.any).isRequired, + choices: PropTypes.arrayOf(PropTypes.object).isRequired, classes: PropTypes.object, className: PropTypes.string, children: PropTypes.node, diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 716b8a1c55e..0701bbdcf33 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -15,7 +15,6 @@ import { import ResettableTextField from './ResettableTextField'; import InputHelperText from './InputHelperText'; import { TextFieldProps } from '@material-ui/core/TextField'; -import { Choice } from 'ra-core'; const sanitizeRestProps = ({ addLabel, @@ -246,7 +245,7 @@ SelectInput.propTypes = { allowEmpty: PropTypes.bool.isRequired, emptyText: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), emptyValue: PropTypes.any, - choices: PropTypes.arrayOf(PropTypes.any).isRequired, + choices: PropTypes.arrayOf(PropTypes.object).isRequired, classes: PropTypes.object, className: PropTypes.string, label: PropTypes.string, From bcea126e2f19a34b54eee284c69c8a8881fa2cf7 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 10:49:28 +0200 Subject: [PATCH 07/16] Migrate RadioButtonGroupInput --- .../src/input/RadioButtonGroupInput.spec.tsx | 28 +++++++++++++------ .../src/input/RadioButtonGroupInput.tsx | 17 ++++++----- .../src/input/RadioButtonGroupInputItem.tsx | 28 ++++++++----------- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx index 91612470cf5..db0f7a6c523 100644 --- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.spec.tsx @@ -31,11 +31,11 @@ describe('', () => { /> ); expect(queryByText('Credit card')).not.toBeNull(); - const input1 = getByLabelText('VISA'); + const input1 = getByLabelText('VISA') as HTMLInputElement; expect(input1.type).toBe('radio'); expect(input1.name).toBe('type'); expect(input1.checked).toBeFalsy(); - const input2 = getByLabelText('Mastercard'); + const input2 = getByLabelText('Mastercard') as HTMLInputElement; expect(input2.type).toBe('radio'); expect(input2.name).toBe('type'); expect(input2.checked).toBeFalsy(); @@ -49,8 +49,12 @@ describe('', () => { render={() => } /> ); - expect(getByLabelText('VISA').checked).toBeFalsy(); - expect(getByLabelText('Mastercard').checked).toBeTruthy(); + expect( + (getByLabelText('VISA') as HTMLInputElement).checked + ).toBeFalsy(); + expect( + (getByLabelText('Mastercard') as HTMLInputElement).checked + ).toBeTruthy(); }); it('should use optionValue as value identifier', () => { @@ -66,7 +70,9 @@ describe('', () => { )} /> ); - expect(getByLabelText('Mastercard').value).toBe('mc'); + expect((getByLabelText('Mastercard') as HTMLInputElement).value).toBe( + 'mc' + ); }); it('should use optionValue including "." as value identifier', () => { @@ -84,7 +90,9 @@ describe('', () => { )} /> ); - expect(getByLabelText('Mastercard').value).toBe('mc'); + expect((getByLabelText('Mastercard') as HTMLInputElement).value).toBe( + 'mc' + ); }); it('should use optionText with a string value as text identifier', () => { @@ -138,7 +146,9 @@ describe('', () => { }); it('should use optionText with an element value as text identifier', () => { - const Foobar = ({ record }) => {record.longname}; + const Foobar = ({ record }: { record?: any }) => ( + {record.longname} + ); const { queryByText } = render(
', () => { /> ); - const input = getByLabelText('Mastercard'); + const input = getByLabelText('Mastercard') as HTMLInputElement; fireEvent.click(input); expect(input.checked).toBe(true); @@ -268,7 +278,7 @@ describe('', () => { )} /> ); - const input = getByLabelText('Mastercard'); + const input = getByLabelText('Mastercard') as HTMLInputElement; fireEvent.click(input); expect(input.checked).toBe(true); diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx index cc847a7c275..9711842ba55 100644 --- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx @@ -10,12 +10,11 @@ import { import { RadioGroupProps } from '@material-ui/core/RadioGroup'; import { FormControlProps } from '@material-ui/core/FormControl'; import get from 'lodash/get'; -import { useInput, FieldTitle, InputProps } from 'ra-core'; +import { useInput, FieldTitle, InputProps, ChoicesProps } from 'ra-core'; import sanitizeRestProps from './sanitizeRestProps'; import InputHelperText from './InputHelperText'; import RadioButtonGroupInputItem from './RadioButtonGroupInputItem'; -import { InputWithOptionsProps } from './InputWithOptions'; const useStyles = makeStyles({ label: { @@ -81,9 +80,9 @@ const useStyles = makeStyles({ * The object passed as `options` props is passed to the material-ui component */ export const RadioButtonGroupInput: FunctionComponent< - InputWithOptionsProps & InputProps & FormControlProps + ChoicesProps & InputProps & FormControlProps > = ({ - choices, + choices = [], classes: classesOverride, helperText, label, @@ -107,6 +106,7 @@ export const RadioButtonGroupInput: FunctionComponent< isRequired, meta: { error, touched }, } = useInput({ + type: 'radio', onBlur, onChange, onFocus, @@ -157,22 +157,21 @@ export const RadioButtonGroupInput: FunctionComponent< }; RadioButtonGroupInput.propTypes = { - choices: PropTypes.arrayOf(PropTypes.object), + choices: PropTypes.arrayOf(PropTypes.any).isRequired, label: PropTypes.string, options: PropTypes.object, optionText: PropTypes.oneOfType([ PropTypes.string, PropTypes.func, PropTypes.element, - ]).isRequired, - optionValue: PropTypes.string.isRequired, + ]), + optionValue: PropTypes.string, resource: PropTypes.string, source: PropTypes.string, - translateChoice: PropTypes.bool.isRequired, + translateChoice: PropTypes.bool, }; RadioButtonGroupInput.defaultProps = { - choices: [], options: {}, optionText: 'name', optionValue: 'id', diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInputItem.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInputItem.tsx index 383bec3d4b1..a9320e46f5f 100644 --- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInputItem.tsx +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInputItem.tsx @@ -1,8 +1,7 @@ -import React, { isValidElement, cloneElement } from 'react'; +import React from 'react'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Radio from '@material-ui/core/Radio'; -import get from 'lodash/get'; -import { useTranslate } from 'ra-core'; +import { useChoices } from 'ra-core'; const RadioButtonGroupInputItem = ({ choice, @@ -11,26 +10,21 @@ const RadioButtonGroupInputItem = ({ source, translateChoice, }) => { - const translate = useTranslate(); + const { getChoiceText, getChoiceValue } = useChoices({ + optionText, + optionValue, + translateChoice, + }); - const choiceName = isValidElement<{ record: any }>(optionText) - ? cloneElement(optionText, { record: choice }) - : typeof optionText === 'function' - ? optionText(choice) - : get(choice, optionText); - - const nodeId = `${source}_${get(choice, optionValue)}`; + const choiceName = getChoiceText(choice); + const nodeId = `${source}_${getChoiceValue(choice)}`; return ( } - label={ - translateChoice - ? translate(choiceName, { _: choiceName }) - : choiceName - } + label={choiceName} /> ); }; From 1fdfb063f0067cbf920cd5ee67f91a4c2632fcc9 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 10:52:01 +0200 Subject: [PATCH 08/16] Migrate CheckboxGroupInput --- .../src/input/CheckboxGroupInput.tsx | 16 ++++----- .../src/input/CheckboxGroupInputItem.tsx | 34 ++++++++----------- 2 files changed, 21 insertions(+), 29 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx index 82dcfb41062..0e98a8740aa 100644 --- a/packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInput.tsx @@ -6,12 +6,11 @@ import FormControl, { FormControlProps } from '@material-ui/core/FormControl'; import FormGroup from '@material-ui/core/FormGroup'; import FormHelperText from '@material-ui/core/FormHelperText'; import { makeStyles } from '@material-ui/core/styles'; -import { FieldTitle, useInput, InputProps } from 'ra-core'; +import { FieldTitle, useInput, InputProps, ChoicesProps } from 'ra-core'; import defaultSanitizeRestProps from './sanitizeRestProps'; import CheckboxGroupInputItem from './CheckboxGroupInputItem'; import { CheckboxProps } from '@material-ui/core/Checkbox'; -import { InputWithOptionsProps } from './InputWithOptions'; const sanitizeRestProps = ({ setFilter, @@ -91,9 +90,9 @@ const useStyles = makeStyles(theme => ({ * The object passed as `options` props is passed to the material-ui components */ const CheckboxGroupInput: FunctionComponent< - InputWithOptionsProps & InputProps & FormControlProps + ChoicesProps & InputProps & FormControlProps > = ({ - choices, + choices = [], helperText, label, onBlur, @@ -183,7 +182,7 @@ const CheckboxGroupInput: FunctionComponent< }; CheckboxGroupInput.propTypes = { - choices: PropTypes.arrayOf(PropTypes.object), + choices: PropTypes.arrayOf(PropTypes.object).isRequired, className: PropTypes.string, label: PropTypes.string, source: PropTypes.string, @@ -192,15 +191,14 @@ CheckboxGroupInput.propTypes = { PropTypes.string, PropTypes.func, PropTypes.element, - ]).isRequired, - optionValue: PropTypes.string.isRequired, + ]), + optionValue: PropTypes.string, row: PropTypes.bool, resource: PropTypes.string, - translateChoice: PropTypes.bool.isRequired, + translateChoice: PropTypes.bool, }; CheckboxGroupInput.defaultProps = { - choices: [], options: {}, optionText: 'name', optionValue: 'id', diff --git a/packages/ra-ui-materialui/src/input/CheckboxGroupInputItem.tsx b/packages/ra-ui-materialui/src/input/CheckboxGroupInputItem.tsx index 5390e8097cf..ccbea65ba05 100644 --- a/packages/ra-ui-materialui/src/input/CheckboxGroupInputItem.tsx +++ b/packages/ra-ui-materialui/src/input/CheckboxGroupInputItem.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import get from 'lodash/get'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Checkbox from '@material-ui/core/Checkbox'; import { makeStyles } from '@material-ui/core/styles'; -import { useTranslate } from 'ra-core'; +import { useChoices } from 'ra-core'; const useStyles = makeStyles({ checkbox: { @@ -22,40 +21,35 @@ const CheckboxGroupInputItem = ({ ...rest }) => { const classes = useStyles({}); - const translate = useTranslate(); + const { getChoiceText, getChoiceValue } = useChoices({ + optionText, + optionValue, + translateChoice, + }); - const choiceName = React.isValidElement<{ record: any }>(optionText) - ? React.cloneElement<{ record: any }>(optionText, { record: choice }) - : typeof optionText === 'function' - ? optionText(choice) - : get(choice, optionText); + const choiceName = getChoiceText(choice); return ( v == get(choice, optionValue) // eslint-disable-line eqeqeq - ) !== undefined + ? value.find(v => v == getChoiceValue(choice)) !== // eslint-disable-line eqeqeq + undefined : false } - value={String(get(choice, optionValue))} + value={String(getChoiceValue(choice))} {...rest} /> } - label={ - translateChoice - ? translate(choiceName, { _: choiceName }) - : choiceName - } + label={choiceName} /> ); }; From 6ac60338e57114c037608b177088f69b01daea47 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 11:01:22 +0200 Subject: [PATCH 09/16] Fix tests and cleanup --- .../ra-ui-materialui/src/input/InputWithOptions.ts | 12 ------------ .../src/input/RadioButtonGroupInput.tsx | 1 - 2 files changed, 13 deletions(-) delete mode 100644 packages/ra-ui-materialui/src/input/InputWithOptions.ts diff --git a/packages/ra-ui-materialui/src/input/InputWithOptions.ts b/packages/ra-ui-materialui/src/input/InputWithOptions.ts deleted file mode 100644 index a6a3469d3af..00000000000 --- a/packages/ra-ui-materialui/src/input/InputWithOptions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ReactElement } from 'react'; - -type OptionTextFunction = (record: ChoiceType) => string; - -export interface InputWithOptionsProps { - choices: ChoiceType[]; - optionText?: - | string - | OptionTextFunction - | ReactElement<{ record: ChoiceType }>; - optionValue?: string; -} diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx index 9711842ba55..7d829730c50 100644 --- a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.tsx @@ -106,7 +106,6 @@ export const RadioButtonGroupInput: FunctionComponent< isRequired, meta: { error, touched }, } = useInput({ - type: 'radio', onBlur, onChange, onFocus, From 2a75a05cd33d5a39705c80526b3551bebca5afb1 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 16:57:54 +0200 Subject: [PATCH 10/16] Introduce useSuggestions hook --- packages/ra-core/src/form/index.ts | 2 + packages/ra-core/src/form/useChoices.ts | 45 ++-- .../ra-core/src/form/useSuggestions.spec.ts | 111 ++++++++++ packages/ra-core/src/form/useSuggestions.ts | 192 ++++++++++++++++++ 4 files changed, 330 insertions(+), 20 deletions(-) create mode 100644 packages/ra-core/src/form/useSuggestions.spec.ts create mode 100644 packages/ra-core/src/form/useSuggestions.ts diff --git a/packages/ra-core/src/form/index.ts b/packages/ra-core/src/form/index.ts index 19796e570de..93384702922 100644 --- a/packages/ra-core/src/form/index.ts +++ b/packages/ra-core/src/form/index.ts @@ -9,6 +9,7 @@ import useChoices, { OptionTextElement, OptionText, } from './useChoices'; +import useSuggestions from './useSuggestions'; export { addField, @@ -21,6 +22,7 @@ export { useChoices, useInput, useInitializeFormWithRecord, + useSuggestions, ValidationError, }; export { isRequired } from './FormField'; diff --git a/packages/ra-core/src/form/useChoices.ts b/packages/ra-core/src/form/useChoices.ts index 866cae886ef..c1719f1111f 100644 --- a/packages/ra-core/src/form/useChoices.ts +++ b/packages/ra-core/src/form/useChoices.ts @@ -1,4 +1,4 @@ -import { ReactElement, isValidElement, cloneElement } from 'react'; +import { ReactElement, isValidElement, cloneElement, useCallback } from 'react'; import get from 'lodash/get'; import { useTranslate } from '../i18n'; @@ -16,7 +16,7 @@ export interface ChoicesProps { translateChoice?: boolean; } -interface Options { +export interface UseChoicesOptions { optionValue?: string; optionText?: OptionTextElement | OptionText | string; translateChoice?: boolean; @@ -26,26 +26,31 @@ const useChoices = ({ optionText = 'name', optionValue = 'id', translateChoice = true, -}: Options) => { +}: UseChoicesOptions) => { const translate = useTranslate(); - const getChoiceText = choice => { - if (isValidElement<{ record: any }>(optionText)) { - return cloneElement<{ record: any }>(optionText, { - record: choice, - }); - } - const choiceName = - typeof optionText === 'function' - ? optionText(choice) - : get(choice, optionText); - - return translateChoice - ? translate(choiceName, { _: choiceName }) - : choiceName; - }; - - const getChoiceValue = choice => get(choice, optionValue); + const getChoiceText = useCallback( + choice => { + if (isValidElement<{ record: any }>(optionText)) { + return cloneElement<{ record: any }>(optionText, { + record: choice, + }); + } + const choiceName = + typeof optionText === 'function' + ? optionText(choice) + : get(choice, optionText); + + return translateChoice + ? translate(choiceName, { _: choiceName }) + : choiceName; + }, + [optionText, translate, translateChoice] + ); + + const getChoiceValue = useCallback(choice => get(choice, optionValue), [ + optionValue, + ]); return { getChoiceText, diff --git a/packages/ra-core/src/form/useSuggestions.spec.ts b/packages/ra-core/src/form/useSuggestions.spec.ts new file mode 100644 index 00000000000..c916a3be19c --- /dev/null +++ b/packages/ra-core/src/form/useSuggestions.spec.ts @@ -0,0 +1,111 @@ +import expect from 'expect'; +import { getSuggestionsFactory as getSuggestions } from './useSuggestions'; + +describe('getSuggestions', () => { + const choices = [ + { id: 1, value: 'one' }, + { id: 2, value: 'two' }, + { id: 3, value: 'three' }, + ]; + + const defaultOptions = { + choices, + allowEmpty: false, + emptyText: '', + emptyValue: null, + getChoiceText: ({ value }) => value, + getChoiceValue: ({ id }) => id, + limitChoicesToValue: false, + matchSuggestion: undefined, + optionText: 'value', + optionValue: 'id', + selectedItem: undefined, + }; + + it('should return all suggestions when filtered by empty string', () => { + expect(getSuggestions(defaultOptions)('')).toEqual(choices); + }); + + it('should filter choices according to the filter argument', () => { + expect(getSuggestions(defaultOptions)('o')).toEqual([ + { id: 1, value: 'one' }, + { id: 2, value: 'two' }, + ]); + }); + + it('should filter choices according to the filter argument when it contains RegExp reserved characters', () => { + expect( + getSuggestions({ + ...defaultOptions, + choices: [ + { id: 1, value: '**one' }, + { id: 2, value: 'two' }, + { id: 3, value: 'three' }, + ], + })('**o') + ).toEqual([{ id: 1, value: '**one' }]); + }); + + it('should filter choices according to the currently selected values if selectedItem is an array', () => { + expect( + getSuggestions({ + ...defaultOptions, + selectedItem: [choices[0]], + })('') + ).toEqual(choices.slice(1)); + }); + + it('should not filter choices according to the currently selected value if selectedItem is not an array and limitChoicesToValue is false', () => { + expect( + getSuggestions({ + ...defaultOptions, + limitChoicesToValue: false, + selectedItem: choices[0], + })('one') + ).toEqual(choices); + }); + + it('should filter choices according to the currently selected value if selectedItem is not an array and limitChoicesToValue is true', () => { + expect( + getSuggestions({ + ...defaultOptions, + limitChoicesToValue: true, + selectedItem: choices[0], + })('one') + ).toEqual([choices[0]]); + }); + it('should add emptySuggestion if allowEmpty is true', () => { + expect( + getSuggestions({ + ...defaultOptions, + allowEmpty: true, + })('') + ).toEqual([ + { id: 1, value: 'one' }, + { id: 2, value: 'two' }, + { id: 3, value: 'three' }, + { id: null, value: '' }, + ]); + }); + + it('should limit the number of choices', () => { + expect( + getSuggestions({ + ...defaultOptions, + suggestionLimit: 2, + })('') + ).toEqual([{ id: 1, value: 'one' }, { id: 2, value: 'two' }]); + + expect( + getSuggestions({ + ...defaultOptions, + suggestionLimit: 2, + allowEmpty: true, + })('') + ).toEqual([ + { id: 1, value: 'one' }, + { id: 2, value: 'two' }, + { id: null, value: '' }, + ]); + }); +}); diff --git a/packages/ra-core/src/form/useSuggestions.ts b/packages/ra-core/src/form/useSuggestions.ts new file mode 100644 index 00000000000..60a067a0341 --- /dev/null +++ b/packages/ra-core/src/form/useSuggestions.ts @@ -0,0 +1,192 @@ +import { useCallback, isValidElement } from 'react'; +import set from 'lodash/set'; +import useChoices, { UseChoicesOptions } from './useChoices'; +import { useTranslate } from '../i18n'; + +const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => { + const suggestionText = getChoiceText(suggestion); + + const isReactElement = isValidElement(suggestionText); + + return isReactElement + ? false + : suggestionText.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') + ); +}; + +const useSuggestions = ({ + allowEmpty, + choices, + emptyText = '', + emptyValue = null, + limitChoicesToValue, + matchSuggestion, + optionText, + optionValue, + selectedItem, + suggestionLimit = 0, + translateChoice, +}: Options) => { + const translate = useTranslate(); + const { getChoiceText, getChoiceValue } = useChoices({ + optionText, + optionValue, + translateChoice, + }); + + const getSuggestions = useCallback( + filter => + getSuggestionsFactory({ + allowEmpty, + choices, + emptyText: translate(emptyText, { _: emptyText }), + emptyValue, + getChoiceText, + getChoiceValue, + limitChoicesToValue, + matchSuggestion, + optionText, + optionValue, + selectedItem, + suggestionLimit, + })(filter), + [ + allowEmpty, + choices, + emptyText, + emptyValue, + getChoiceText, + getChoiceValue, + limitChoicesToValue, + matchSuggestion, + optionText, + optionValue, + selectedItem, + suggestionLimit, + translate, + ] + ); + + return { + getChoiceText, + getChoiceValue, + getSuggestions, + }; +}; + +export default useSuggestions; + +const escapeRegExp = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string + +interface Options extends UseChoicesOptions { + choices: any[]; + allowEmpty?: boolean; + emptyText?: string; + emptyValue?: any; + limitChoicesToValue?: boolean; + matchSuggestion?: (filter: string) => (suggestion: any) => 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 const getSuggestionsFactory = ({ + choices = [], + allowEmpty, + emptyText, + emptyValue, + optionText, + optionValue, + getChoiceText, + getChoiceValue, + limitChoicesToValue, + matchSuggestion = defaultMatchSuggestion(getChoiceText), + selectedItem, + suggestionLimit = 0, +}) => 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) && + matchSuggestion(filter, selectedItem) + ) { + if (limitChoicesToValue) { + return limitSuggestions( + choices.filter( + choice => + getChoiceValue(choice) === getChoiceValue(selectedItem) + ), + suggestionLimit + ); + } + + return limitSuggestions( + removeAlreadySelectedSuggestions(selectedItem, getChoiceValue)( + choices + ), + suggestionLimit + ); + } + + const filteredChoices = choices.filter(choice => + matchSuggestion(filter, choice) + ); + + const finalChoices = limitSuggestions( + removeAlreadySelectedSuggestions(selectedItem, getChoiceValue)( + filteredChoices + ), + suggestionLimit + ); + + if (allowEmpty) { + const emptySuggestion = {}; + set(emptySuggestion, optionValue, emptyValue); + + if (typeof optionText !== 'function') { + set(emptySuggestion, optionText, emptyText); + } + return finalChoices.concat(emptySuggestion); + } + + return finalChoices; +}; + +const removeAlreadySelectedSuggestions = ( + selectedItem, + getChoiceValue +) => suggestions => { + if (!Array.isArray(selectedItem)) { + return suggestions; + } + + const selectedValues = selectedItem.map(getChoiceValue); + + return suggestions.filter( + suggestion => !selectedValues.includes(getChoiceValue(suggestion)) + ); +}; + +const limitSuggestions = (suggestions, limit = 0) => { + if (Number.isInteger(limit) && limit > 0) { + return suggestions.slice(0, limit); + } + return suggestions; +}; From 71ae1d6509b655ca56f2e13fa4a5eef1d9e8c0a0 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 16:58:09 +0200 Subject: [PATCH 11/16] Migrate Autocomplete inputs --- .../src/input/AutocompleteArrayInput.spec.tsx | 23 +- .../src/input/AutocompleteArrayInput.tsx | 147 +++--- .../src/input/AutocompleteInput.js | 398 --------------- ...put.spec.js => AutocompleteInput.spec.tsx} | 56 +-- .../src/input/AutocompleteInput.tsx | 457 ++++++++++++++++++ .../src/input/AutocompleteSuggestionItem.tsx | 67 ++- 6 files changed, 581 insertions(+), 567 deletions(-) delete mode 100644 packages/ra-ui-materialui/src/input/AutocompleteInput.js rename packages/ra-ui-materialui/src/input/{AutocompleteInput.spec.js => AutocompleteInput.spec.tsx} (91%) create mode 100644 packages/ra-ui-materialui/src/input/AutocompleteInput.tsx diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx index 674a7689603..e1bcfe6bace 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx @@ -400,6 +400,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 4ae5a00654f..8fb3e545267 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'; @@ -382,7 +383,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( @@ -401,34 +402,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' }, @@ -506,7 +496,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); @@ -531,29 +521,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} + + ); + })} +
+ )}
); }; From 6e41a48fd10b46818c4384d65a548f7400dd446e Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 17:06:28 +0200 Subject: [PATCH 12/16] Fix tests after rebase (new i18nProvider) --- packages/ra-core/src/form/useChoices.spec.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/ra-core/src/form/useChoices.spec.tsx b/packages/ra-core/src/form/useChoices.spec.tsx index 658ff8e43d0..5de38f5f641 100644 --- a/packages/ra-core/src/form/useChoices.spec.tsx +++ b/packages/ra-core/src/form/useChoices.spec.tsx @@ -3,7 +3,7 @@ import expect from 'expect'; import { render, cleanup } from '@testing-library/react'; import useChoices from './useChoices'; import { renderWithRedux } from '../util'; -import { TranslationProvider } from '../i18n'; +import { TestTranslationProvider } from '../i18n'; describe('useChoices hook', () => { afterEach(cleanup); @@ -70,21 +70,21 @@ describe('useChoices hook', () => { it('should translate the choice by default', () => { const { queryAllByText } = renderWithRedux( - ({ test: 'bonjour' })}> + `**${x}**`}> - + ); expect(queryAllByText('test')).toHaveLength(0); - expect(queryAllByText('bonjour')).toHaveLength(1); + expect(queryAllByText('**test**')).toHaveLength(1); }); it('should not translate the choice if translateChoice is false', () => { const { queryAllByText } = renderWithRedux( - ({ hello: 'bonjour' })}> + `**${x}**`}> - + ); expect(queryAllByText('test')).toHaveLength(1); - expect(queryAllByText('bonjour')).toHaveLength(0); + expect(queryAllByText('**test**')).toHaveLength(0); }); }); From 366398b5354fac99ee5e17d5007a8c56b3ac05d0 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 17:27:41 +0200 Subject: [PATCH 13/16] Cleanup --- .../src/input/getSuggestions.spec.ts | 155 ------------------ .../src/input/getSuggestions.ts | 117 ------------- 2 files changed, 272 deletions(-) delete mode 100644 packages/ra-ui-materialui/src/input/getSuggestions.spec.ts delete mode 100644 packages/ra-ui-materialui/src/input/getSuggestions.ts diff --git a/packages/ra-ui-materialui/src/input/getSuggestions.spec.ts b/packages/ra-ui-materialui/src/input/getSuggestions.spec.ts deleted file mode 100644 index 4602de6c368..00000000000 --- a/packages/ra-ui-materialui/src/input/getSuggestions.spec.ts +++ /dev/null @@ -1,155 +0,0 @@ -import getSuggestions from './getSuggestions'; - -describe('getSuggestions', () => { - const choices = [ - { id: 1, value: 'one' }, - { id: 2, value: 'two' }, - { id: 3, value: 'three' }, - ]; - - it('should return all suggestions when filtered by empty string', () => { - expect( - getSuggestions({ - choices, - allowEmpty: false, - optionText: 'value', - getSuggestionText: ({ value }) => value, - optionValue: 'id', - limitChoicesToValue: true, - })('') - ).toEqual(choices); - }); - - it('should filter choices according to the filter argument', () => { - expect( - getSuggestions({ - choices, - allowEmpty: false, - optionText: 'value', - getSuggestionText: ({ value }) => value, - optionValue: 'id', - limitChoicesToValue: true, - })('o') - ).toEqual([{ id: 1, value: 'one' }, { id: 2, value: 'two' }]); - }); - - it('should filter choices according to the filter argument when it contains RegExp reserved characters', () => { - expect( - getSuggestions({ - choices: [ - { id: 1, value: '**one' }, - { id: 2, value: 'two' }, - { id: 3, value: 'three' }, - ], - allowEmpty: false, - optionText: 'value', - getSuggestionText: ({ value }) => value, - optionValue: 'id', - limitChoicesToValue: true, - })('**o') - ).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({ - choices, - allowEmpty: false, - optionText: 'value', - getSuggestionText: ({ value }) => value, - optionValue: 'id', - limitChoicesToValue: false, - selectedItem: choices[0], - })('one') - ).toEqual(choices); - }); - - it('should filter choices according to the currently selected value if limitChoicesToValue is true', () => { - expect( - getSuggestions({ - choices, - allowEmpty: false, - optionText: 'value', - getSuggestionText: ({ value }) => value, - optionValue: 'id', - limitChoicesToValue: true, - selectedItem: choices[0], - })('one') - ).toEqual([choices[0]]); - }); - it('should add emptySuggestion if allowEmpty is true', () => { - expect( - getSuggestions({ - choices, - allowEmpty: true, - optionText: 'value', - getSuggestionText: ({ value }) => value, - optionValue: 'id', - limitChoicesToValue: true, - })('') - ).toEqual([ - { id: 1, value: 'one' }, - { id: 2, value: 'two' }, - { id: 3, value: 'three' }, - { 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 deleted file mode 100644 index 1acba7ddff8..00000000000 --- a/packages/ra-ui-materialui/src/input/getSuggestions.ts +++ /dev/null @@ -1,117 +0,0 @@ -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; -}; From 1a9e3f8b98284bf4480894f78ffcdf52875fe2d9 Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 17:49:47 +0200 Subject: [PATCH 14/16] Review --- packages/ra-core/src/form/useChoices.ts | 11 +++ packages/ra-core/src/form/useSuggestions.ts | 80 +++++++++++++-------- 2 files changed, 62 insertions(+), 29 deletions(-) diff --git a/packages/ra-core/src/form/useChoices.ts b/packages/ra-core/src/form/useChoices.ts index c1719f1111f..f85a064ac79 100644 --- a/packages/ra-core/src/form/useChoices.ts +++ b/packages/ra-core/src/form/useChoices.ts @@ -22,6 +22,17 @@ export interface UseChoicesOptions { translateChoice?: boolean; } +/* + * Returns helper functions for choices handling. + * + * @param optionText Either a string defining the property to use to get the choice text, a function or a React element + * @param optionValue The property to use to get the choice value + * @param translateChoice A boolean indicating whether to option text should be translated + * + * @returns An object with helper functions: + * - getChoiceText: Returns the choice text or a React element + * - getChoiceValue: Returns the choice value + */ const useChoices = ({ optionText = 'name', optionValue = 'id', diff --git a/packages/ra-core/src/form/useSuggestions.ts b/packages/ra-core/src/form/useSuggestions.ts index 60a067a0341..af0aab14b8c 100644 --- a/packages/ra-core/src/form/useSuggestions.ts +++ b/packages/ra-core/src/form/useSuggestions.ts @@ -3,20 +3,26 @@ import set from 'lodash/set'; import useChoices, { UseChoicesOptions } from './useChoices'; import { useTranslate } from '../i18n'; -const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => { - const suggestionText = getChoiceText(suggestion); - - const isReactElement = isValidElement(suggestionText); - - return isReactElement - ? false - : suggestionText.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') - ); -}; - +/* + * Returns helper functions for suggestions handling. + * + * @param allowEmpty A boolean indicating whether an empty suggestion should be added + * @param choices An array of available choices + * @param emptyText The text to use for the empty suggestion. Defaults to an empty string + * @param emptyValue The value to use for the empty suggestion. Defaults to `null` + * @param limitChoicesToValue A boolean indicating whether the initial suggestions should be limited to the currently selected one(s) + * @param matchSuggestion Optional unless `optionText` is a React element. Function which check wether a choice matches a filter. Must return a boolean. + * @param optionText Either a string defining the property to use to get the choice text, a function or a React element + * @param optionValue The property to use to get the choice value + * @param selectedItem The currently selected item. May be an array of selected items + * @param suggestionLimit The maximum number of suggestions returned, excluding the empty one if `allowEmpty` is `true` + * @param translateChoice A boolean indicating whether to option text should be translated + * + * @returns An object with helper functions: + * - getChoiceText: Returns the choice text or a React element + * - getChoiceValue: Returns the choice value + * - getSuggestions: A function taking a filter value (string) and returning the matching suggestions + */ const useSuggestions = ({ allowEmpty, choices, @@ -38,21 +44,20 @@ const useSuggestions = ({ }); const getSuggestions = useCallback( - filter => - getSuggestionsFactory({ - allowEmpty, - choices, - emptyText: translate(emptyText, { _: emptyText }), - emptyValue, - getChoiceText, - getChoiceValue, - limitChoicesToValue, - matchSuggestion, - optionText, - optionValue, - selectedItem, - suggestionLimit, - })(filter), + getSuggestionsFactory({ + allowEmpty, + choices, + emptyText: translate(emptyText, { _: emptyText }), + emptyValue, + getChoiceText, + getChoiceValue, + limitChoicesToValue, + matchSuggestion, + optionText, + optionValue, + selectedItem, + suggestionLimit, + }), [ allowEmpty, choices, @@ -92,6 +97,23 @@ interface Options extends UseChoicesOptions { selectedItem?: any | any[]; } +/** + * Default matcher implementation which check whether the suggestion text matches the filter. + */ +const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => { + const suggestionText = getChoiceText(suggestion); + + const isReactElement = isValidElement(suggestionText); + + return isReactElement + ? false + : suggestionText.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') + ); +}; + /** * Get the suggestions to display after applying a fuzzy search on the available choices * From 8cd777be6c91b208b9812872be890e2143c8d0af Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 18:09:38 +0200 Subject: [PATCH 15/16] Cleanup --- packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx | 1 - packages/ra-ui-materialui/src/input/AutocompleteInput.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx index 5e40408d895..8cbdd20908a 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx @@ -123,7 +123,6 @@ const AutocompleteArrayInput: FunctionComponent< setFilter, shouldRenderSuggestions: shouldRenderSuggestionsOverride, source, - suggestionComponent, suggestionLimit, translateChoice = true, validate, diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index 99d28f36895..8d891096a99 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -123,7 +123,6 @@ const AutocompleteInput: FunctionComponent< setFilter, shouldRenderSuggestions: shouldRenderSuggestionsOverride, source, - suggestionComponent, suggestionLimit, translateChoice = true, validate, From c9637dd3633e7e6d94f019752ba9b7ddf0a7bc2a Mon Sep 17 00:00:00 2001 From: Gildas Garcia Date: Fri, 13 Sep 2019 18:09:59 +0200 Subject: [PATCH 16/16] Documentation and upgrade guide --- UPGRADE.md | 25 ++++++++++++++++++++++++- docs/Inputs.md | 18 ++++++++++-------- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 7cca8547b0e..937f5b1c6a7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -893,7 +893,7 @@ const PostFilter = props => 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 `` and `` components: +There are three 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 @@ -921,6 +921,29 @@ There are two breaking changes in the new `` and ` ``` +- The `suggestionComponent` prop is gone. + +Instead, the new `` and `` components use the `optionText` like all other inputs accepting choices. +However, if you pass a React element as the `optionText`, you must now also specify the new `matchSuggestion` prop. +This is required because the inputs use the `optionText` by default to filter suggestions. +This function receives the current filter and a choice, and should return a boolean indicating whether this choice matches the filter. + +```diff +} ++ matchSuggestion={matchSuggestion} +/> + +} ++ matchSuggestion={matchSuggestion} +/> +``` + 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. diff --git a/docs/Inputs.md b/docs/Inputs.md index 14bfc633ca3..f22d1a79241 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -207,14 +207,16 @@ Lastly, would you need to override the props of the suggestions container (a `Po | Prop | Required | Type | Default | Description | | ---|---|---|---|--- | -| `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`. | +| `choices` | Required | `Object[]` | - | List of items to autosuggest | +| `emptyValue` | Optional | anything | `null` | The value to use for the empty element | +| `emptyText` | Optional | `string` | '' | The text to use for the empty element | +| `matchSuggestion` | Optional | `Function` | - | Required if `optionText` is a React element. Function returning a boolean indicating whether a choice matches the filter. `(filter, choice) => boolean` | `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 correct record as argument (`(record)=> {string}`) | +| `resource` | Required | `string` | - | The resource working on. This field is passed down by wrapped components like `Create` and `Edit`. | | `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`. | -| `suggestionComponent` | Optional | Function | `({ suggestion, query, isHighlighted, props }) =>
` | Allows to override how the item is rendered. | +| `source` | Required | `string` | - | Name of field to edit, its type should match the type retrieved from `optionValue` | | `shouldRenderSuggestions` | Optional | Function | `() => true` | A function that returns a `boolean` to determine whether or not suggestions are rendered. Use this when working with large collections of data to improve performance and user experience. This function is passed into the underlying react-autosuggest component. Ex.`(value) => value.trim() > 2` | ## `` @@ -313,15 +315,15 @@ If you need to override the props of the suggestions container (a `Popper` eleme | Prop | Required | Type | Default | Description | | ---|---|---|---|--- | | `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` | +| `fullWith` | Optional | Boolean | If `true`, the input will take all the form width +| `matchSuggestion` | Optional | `Function` | - | Required if `optionText` is a React element. Function returning a boolean indicating whether a choice matches the filter. `(filter, choice) => boolean` | `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}`) | +| `resource` | Required | `string` | - | The resource working on. This field is passed down by wrapped components like `Create` and `Edit`. | | `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`. | -| `suggestionComponent` | Optional | Function | `({ suggestion, query, isHighlighted, props }) =>
` | Allows to override how the item is rendered. | +| `source` | Required | `string` | - | Name of field to edit, its type should match the type retrieved from `optionValue` | | `suggestionLimit` | Optional | Number | null | Limits the numbers of suggestions that are shown in the dropdown list | | `shouldRenderSuggestions` | Optional | Function | `() => true` | A function that returns a `boolean` to determine whether or not suggestions are rendered. Use this when working with large collections of data to improve performance and user experience. This function is passed into the underlying react-autosuggest component. Ex.`(value) => value.trim() > 2` | -| `fullWith` | Optional | Boolean | If `true`, the input will take all the form width ## `` and ``