From f6b1341750a636ba25d24b451a7bef472136a633 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Tue, 13 Apr 2021 18:16:52 +0200 Subject: [PATCH 01/17] Add Initial Support For Creation in Choices Inputs --- examples/simple/src/posts/PostEdit.tsx | 324 +++++++++++------- examples/simple/src/posts/PostTitle.tsx | 4 +- packages/ra-core/src/form/useChoices.ts | 13 +- .../ra-core/src/form/useSuggestions.spec.ts | 20 +- packages/ra-core/src/form/useSuggestions.ts | 122 +++++-- packages/ra-language-english/src/index.ts | 1 + packages/ra-language-french/src/index.ts | 1 + .../src/input/AutocompleteInput.spec.tsx | 2 +- .../src/input/AutocompleteInput.tsx | 305 +++++++++-------- .../src/input/SelectInput.tsx | 225 +++++++----- packages/ra-ui-materialui/src/input/index.ts | 5 +- .../src/input/useSupportCreateSuggestion.tsx | 120 +++++++ 12 files changed, 731 insertions(+), 411 deletions(-) create mode 100644 packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx diff --git a/examples/simple/src/posts/PostEdit.tsx b/examples/simple/src/posts/PostEdit.tsx index 948b25ce895..8eab61934bf 100644 --- a/examples/simple/src/posts/PostEdit.tsx +++ b/examples/simple/src/posts/PostEdit.tsx @@ -28,13 +28,58 @@ import { number, required, FormDataConsumer, + useCreateSuggestion, + EditActionsProps, } from 'react-admin'; // eslint-disable-line import/no-unresolved -import { Box, BoxProps } from '@material-ui/core'; +import { + Box, + BoxProps, + Button, + Dialog, + DialogActions, + DialogContent, + TextField as MuiTextField, +} from '@material-ui/core'; import PostTitle from './PostTitle'; import TagReferenceInput from './TagReferenceInput'; -const EditActions = ({ basePath, data, hasShow }: any) => ( +const CreateCategory = ({ + onAddChoice, +}: { + onAddChoice: (record: any) => void; +}) => { + const { filter, onCancel, onCreate } = useCreateSuggestion(); + const [value, setValue] = React.useState(filter || ''); + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + const choice = { name: value, id: value }; + onAddChoice(choice); + onCreate(value, choice); + setValue(''); + return false; + }; + return ( + +
+ + setValue(event.target.value)} + autoFocus + /> + + + + + +
+
+ ); +}; + +const EditActions = ({ basePath, data, hasShow }: EditActionsProps) => ( ; -const PostEdit = ({ permissions, ...props }) => ( - } actions={} {...props}> - - - - +const categories = [ + { name: 'Tech', id: 'tech' }, + { name: 'Lifestyle', id: 'lifestyle' }, +]; + +const PostEdit = ({ permissions, ...props }) => { + return ( + } actions={} {...props}> + + + + + + - - - - - - - {permissions === 'admin' && ( - + + + + + {permissions === 'admin' && ( + + + + + + + {({ + formData, + scopedFormData, + getSource, + ...rest + }) => + scopedFormData && + scopedFormData.user_id ? ( + + ) : null + } + + + + )} + + + + + + + - - - - - {({ - formData, - scopedFormData, - getSource, - ...rest - }) => - scopedFormData && scopedFormData.user_id ? ( - - ) : null - } - + + - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -); + + categories.push(choice)} + /> + } + allowEmpty + resettable + source="category" + choices={categories} + /> + + + + + + + + + + + + + + + + + ); +}; export default PostEdit; diff --git a/examples/simple/src/posts/PostTitle.tsx b/examples/simple/src/posts/PostTitle.tsx index 25d3f287e0d..e9fd6836350 100644 --- a/examples/simple/src/posts/PostTitle.tsx +++ b/examples/simple/src/posts/PostTitle.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { useTranslate, Record } from 'react-admin'; +import { TitleProps, useTranslate } from 'react-admin'; -export default ({ record }: { record?: Record }) => { +export default ({ record }: TitleProps) => { const translate = useTranslate(); return ( diff --git a/packages/ra-core/src/form/useChoices.ts b/packages/ra-core/src/form/useChoices.ts index d54115bd1c4..cf8afd20e97 100644 --- a/packages/ra-core/src/form/useChoices.ts +++ b/packages/ra-core/src/form/useChoices.ts @@ -8,7 +8,8 @@ import { InputProps } from '.'; export type OptionTextElement = ReactElement<{ record: Record; }>; -export type OptionText = (choice: object) => string | OptionTextElement; +export type OptionTextFunc = (choice: object) => string | OptionTextElement; +export type OptionText = OptionTextElement | OptionTextFunc | string; export interface ChoicesInputProps extends Omit, 'source'> { @@ -22,13 +23,13 @@ export interface ChoicesInputProps export interface ChoicesProps { choices: object[]; optionValue?: string; - optionText?: OptionTextElement | OptionText | string; + optionText?: OptionText; translateChoice?: boolean; } export interface UseChoicesOptions { optionValue?: string; - optionText?: OptionTextElement | OptionText | string; + optionText?: OptionText; disableValue?: string; translateChoice?: boolean; } @@ -64,6 +65,12 @@ const useChoices = ({ ? optionText(choice) : get(choice, optionText); + if (isValidElement<{ record: any }>(choiceName)) { + return cloneElement<{ record: any }>(choiceName, { + record: choice, + }); + } + return translateChoice ? translate(choiceName, { _: choiceName }) : choiceName; diff --git a/packages/ra-core/src/form/useSuggestions.spec.ts b/packages/ra-core/src/form/useSuggestions.spec.ts index 76f87ef7479..32cbc6b3f7d 100644 --- a/packages/ra-core/src/form/useSuggestions.spec.ts +++ b/packages/ra-core/src/form/useSuggestions.spec.ts @@ -10,15 +10,10 @@ describe('getSuggestions', () => { const defaultOptions = { choices, - allowEmpty: false, - emptyText: '', - emptyValue: null, getChoiceText: ({ value }) => value, getChoiceValue: ({ id }) => id, - limitChoicesToValue: false, matchSuggestion: undefined, optionText: 'value', - optionValue: 'id', selectedItem: undefined, }; @@ -86,6 +81,7 @@ describe('getSuggestions', () => { })('one') ).toEqual([choices[0]]); }); + it('should add emptySuggestion if allowEmpty is true', () => { expect( getSuggestions({ @@ -100,6 +96,20 @@ describe('getSuggestions', () => { ]); }); + it('should add createSuggestion if allowCreate is true', () => { + expect( + getSuggestions({ + ...defaultOptions, + allowCreate: true, + })('') + ).toEqual([ + { id: 1, value: 'one' }, + { id: 2, value: 'two' }, + { id: 3, value: 'three' }, + { id: '@@create', value: 'ra.action.create' }, + ]); + }); + it('should limit the number of choices', () => { expect( getSuggestions({ diff --git a/packages/ra-core/src/form/useSuggestions.ts b/packages/ra-core/src/form/useSuggestions.ts index 3b57073c7c8..e258b2cd3a4 100644 --- a/packages/ra-core/src/form/useSuggestions.ts +++ b/packages/ra-core/src/form/useSuggestions.ts @@ -1,6 +1,6 @@ import { useCallback, isValidElement } from 'react'; import set from 'lodash/set'; -import useChoices, { UseChoicesOptions } from './useChoices'; +import useChoices, { OptionText, UseChoicesOptions } from './useChoices'; import { useTranslate } from '../i18n'; /* @@ -25,9 +25,12 @@ import { useTranslate } from '../i18n'; * - getSuggestions: A function taking a filter value (string) and returning the matching suggestions */ const useSuggestions = ({ + allowCreate, allowDuplicates, allowEmpty, choices, + createText = 'ra.action.create', + createValue = '@@create', emptyText = '', emptyValue = null, limitChoicesToValue, @@ -37,7 +40,7 @@ const useSuggestions = ({ selectedItem, suggestionLimit = 0, translateChoice, -}: Options) => { +}: UseSuggestionsOptions) => { const translate = useTranslate(); const { getChoiceText, getChoiceValue } = useChoices({ optionText, @@ -48,9 +51,12 @@ const useSuggestions = ({ // eslint-disable-next-line react-hooks/exhaustive-deps const getSuggestions = useCallback( getSuggestionsFactory({ + allowCreate, allowDuplicates, allowEmpty, choices, + createText, + createValue, emptyText: translate(emptyText, { _: emptyText }), emptyValue, getChoiceText, @@ -63,9 +69,12 @@ const useSuggestions = ({ suggestionLimit, }), [ + allowCreate, allowDuplicates, allowEmpty, choices, + createText, + createValue, emptyText, emptyValue, getChoiceText, @@ -92,14 +101,21 @@ export default useSuggestions; const escapeRegExp = value => value ? value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : ''; // $& means the whole matched string -interface Options extends UseChoicesOptions { - choices: any[]; +export interface UseSuggestionsOptions extends UseChoicesOptions { + allowCreate?: boolean; allowDuplicates?: boolean; allowEmpty?: boolean; + choices: any[]; + createText?: string; + createValue?: any; emptyText?: string; emptyValue?: any; limitChoicesToValue?: boolean; - matchSuggestion?: (filter: string, suggestion: any) => boolean; + matchSuggestion?: ( + filter: string, + suggestion: any, + exact?: boolean + ) => boolean; suggestionLimit?: number; selectedItem?: any | any[]; } @@ -107,10 +123,15 @@ interface Options extends UseChoicesOptions { /** * Default matcher implementation which check whether the suggestion text matches the filter. */ -const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => { +const defaultMatchSuggestion = getChoiceText => ( + filter, + suggestion, + exact = false +) => { const suggestionText = getChoiceText(suggestion); const isReactElement = isValidElement(suggestionText); + const regex = escapeRegExp(filter); return isReactElement ? false @@ -118,7 +139,7 @@ const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => { 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') + new RegExp(exact ? `^${regex}$` : regex, 'i') ); }; @@ -145,19 +166,25 @@ const defaultMatchSuggestion = getChoiceText => (filter, suggestion) => { * // Will return [{ id: 2, name: 'publisher' }] */ export const getSuggestionsFactory = ({ + allowCreate = false, + allowDuplicates = false, + allowEmpty = false, choices = [], - allowDuplicates, - allowEmpty, - emptyText, - emptyValue, - optionText, - optionValue, + createText = 'ra.action.create', + createValue = '@@create', + emptyText = '', + emptyValue = null, + optionText = 'name', + optionValue = 'id', getChoiceText, getChoiceValue, limitChoicesToValue = false, matchSuggestion = defaultMatchSuggestion(getChoiceText), selectedItem, suggestionLimit = 0, +}: UseSuggestionsOptions & { + getChoiceText: (choice: any) => string; + getChoiceValue: (choice: any) => string; }) => filter => { let suggestions = []; // if an item is selected and matches the filter @@ -195,15 +222,37 @@ export const getSuggestionsFactory = ({ suggestions = limitSuggestions(suggestions, suggestionLimit); - if (allowEmpty) { - suggestions = addEmptySuggestion(suggestions, { - optionText, - optionValue, - emptyText, - emptyValue, - }); + const hasExactMatch = suggestions.some(suggestion => + matchSuggestion(filter, suggestion, true) + ); + + const filterIsSelectedItem = !!selectedItem + ? matchSuggestion(filter, selectedItem, true) + : false; + + if (allowCreate) { + if (!hasExactMatch && !filterIsSelectedItem) { + suggestions.push( + getSuggestion({ + optionText, + optionValue, + text: createText, + value: createValue, + }) + ); + } } + if (allowEmpty) { + suggestions.unshift( + getSuggestion({ + optionText, + optionValue, + text: emptyText, + value: emptyValue, + }) + ); + } return suggestions; }; @@ -257,7 +306,7 @@ const limitSuggestions = (suggestions: any[], limit: any = 0) => : suggestions; /** - * addEmptySuggestion( + * addSuggestion( * [{ id: 1, name: 'foo'}, { id: 2, name: 'bar' }], * ); * @@ -265,23 +314,24 @@ const limitSuggestions = (suggestions: any[], limit: any = 0) => * * @param suggestions List of suggestions * @param options + * @param options.optionText */ -const addEmptySuggestion = ( - suggestions: any[], - { - optionText = 'name', - optionValue = 'id', - emptyText = '', - emptyValue = null, - } -) => { - let newSuggestions = suggestions; - - const emptySuggestion = {}; - set(emptySuggestion, optionValue, emptyValue); +const getSuggestion = ({ + optionText = 'name', + optionValue = 'id', + text = '', + value = null, +}: { + optionText: OptionText; + optionValue: string; + text: string; + value: any; +}) => { + const suggestion = {}; + set(suggestion, optionValue, value); if (typeof optionText === 'string') { - set(emptySuggestion, optionText, emptyText); + set(suggestion, optionText, text); } - return [].concat(emptySuggestion, newSuggestions); + return suggestion; }; diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index 233b5a70ea5..9e9d0c5ca13 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -12,6 +12,7 @@ const englishMessages: TranslationMessages = { clone: 'Clone', confirm: 'Confirm', create: 'Create', + create_item: 'Create %{item}', delete: 'Delete', edit: 'Edit', export: 'Export', diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index 90e70b58ef5..d396751e943 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -13,6 +13,7 @@ const frenchMessages: TranslationMessages = { clone: 'Dupliquer', confirm: 'Confirmer', create: 'Créer', + create_item: 'Créer %{item}', delete: 'Supprimer', edit: 'Éditer', export: 'Exporter', diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index 54927f90990..2d7739ef666 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { render, fireEvent, waitFor } from '@testing-library/react'; -import AutocompleteInput from './AutocompleteInput'; +import { AutocompleteInput } from './AutocompleteInput'; import { Form } from 'react-final-form'; import { TestTranslationProvider } from 'ra-core'; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index 90091fd8fd8..f0975623733 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -3,7 +3,6 @@ import React, { useEffect, useRef, useState, - FunctionComponent, useMemo, isValidElement, } from 'react'; @@ -27,6 +26,10 @@ import InputHelperText from './InputHelperText'; import AutocompleteSuggestionList from './AutocompleteSuggestionList'; import AutocompleteSuggestionItem from './AutocompleteSuggestionItem'; import { AutocompleteInputLoader } from './AutocompleteInputLoader'; +import { + SupportCreateSuggestionOptions, + useSupportCreateSuggestion, +} from './useSupportCreateSuggestion'; interface Options { suggestionsContainerProps?: any; @@ -95,13 +98,17 @@ interface Options { * @example * */ -const AutocompleteInput: FunctionComponent = props => { +export const AutocompleteInput = (props: AutocompleteInputProps) => { const { allowEmpty, className, classes: classesOverride, clearAlwaysVisible, choices = [], + createLabel, + createItemLabel, + createValue = '@@ra-create', + create, disabled, emptyText, emptyValue, @@ -120,6 +127,7 @@ const AutocompleteInput: FunctionComponent = props => { meta: metaOverride, onBlur, onChange, + onCreate, onFocus, options: { suggestionsContainerProps, @@ -177,7 +185,6 @@ const AutocompleteInput: FunctionComponent = props => { let inputEl = useRef(); let anchorEl = useRef(); - const translate = useTranslate(); const { @@ -212,9 +219,26 @@ const AutocompleteInput: FunctionComponent = props => { [input.value, getSuggestionFromValue] ); + const { + getCreateItemLabel, + handleChange, + createElement, + } = useSupportCreateSuggestion({ + create, + createLabel, + createItemLabel, + createValue, + filter: filterValue, + onCreate, + source, + }); + const { getChoiceText, getChoiceValue, getSuggestions } = useSuggestions({ + allowCreate: !!onCreate || !!create, allowEmpty, choices, + createText: getCreateItemLabel(), + createValue, emptyText, emptyValue, limitChoicesToValue, @@ -266,15 +290,18 @@ const AutocompleteInput: FunctionComponent = props => { inputText, ]); - const handleChange = useCallback( - (item: any) => { - if (getChoiceValue(item) == null && filterValue) { - setFilterValue(''); - } + const handleDownshiftChange = useCallback( + async (item: any) => { + const value = getChoiceValue(item); + handleChange(value, () => { + if (value == null && filterValue) { + setFilterValue(''); + } - input.onChange(getChoiceValue(item)); + input.onChange(value); + }); }, - [filterValue, getChoiceValue, input] + [filterValue, getChoiceValue, input, handleChange] ); // This function ensures that the suggestion list stay aligned to the @@ -432,133 +459,136 @@ const AutocompleteInput: FunctionComponent = props => { }; return ( - getChoiceValue(item)} - {...rest} - > - {({ - getInputProps, - getItemProps, - getLabelProps, - getMenuProps, - isOpen, - 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, - size, - color, - ...inputProps - } = getInputProps({ - onBlur: handleBlur, - onFocus: handleFocus(openMenu), - ...InputProps, - }); - const suggestions = getSuggestions(filterValue); + <> + getChoiceValue(item)} + {...rest} + > + {({ + getInputProps, + getItemProps, + getLabelProps, + getMenuProps, + isOpen, + 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, + size, + color, + ...inputProps + } = getInputProps({ + onBlur: handleBlur, + onFocus: handleFocus(openMenu), + ...InputProps, + }); + const suggestions = getSuggestions(filterValue); - return ( -
- { - handleFilterChange(event); - setFilterValue(event.target.value); - onChange!( - event as React.ChangeEvent< - HTMLInputElement - > - ); - }, - onFocus, - ...InputPropsWithoutEndAdornment, - }} - error={!!(touched && (error || submitError))} - label={ - - } - InputLabelProps={getLabelProps({ - htmlFor: id, - })} - helperText={ - - } - disabled={disabled} - variant={variant} - margin={margin} - fullWidth={fullWidth} - value={filterValue} - className={className} - size={size as any} - color={color as any} - {...inputProps} - {...options} - /> - - {suggestions.map((suggestion, index) => ( - - ))} - -
- ); - }} -
+ return ( +
+ { + handleFilterChange(event); + setFilterValue(event.target.value); + onChange!( + event as React.ChangeEvent< + HTMLInputElement + > + ); + }, + onFocus, + ...InputPropsWithoutEndAdornment, + }} + error={!!(touched && (error || submitError))} + label={ + + } + InputLabelProps={getLabelProps({ + htmlFor: id, + })} + helperText={ + + } + disabled={disabled} + variant={variant} + margin={margin} + fullWidth={fullWidth} + value={filterValue} + className={className} + size={size as any} + color={color as any} + {...inputProps} + {...options} + /> + + {suggestions.map((suggestion, index) => ( + + ))} + +
+ ); + }} +
+ {createElement} + ); }; @@ -600,11 +630,10 @@ const useStyles = makeStyles( export interface AutocompleteInputProps extends ChoicesInputProps, + Omit, Omit, 'onChange'> { clearAlwaysVisible?: boolean; resettable?: boolean; loaded?: boolean; loading?: boolean; } - -export default AutocompleteInput; diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 6a817feabd2..a023a2c0c42 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useCallback, FunctionComponent } from 'react'; +import { useCallback } from 'react'; import PropTypes from 'prop-types'; import get from 'lodash/get'; import MenuItem from '@material-ui/core/MenuItem'; @@ -19,49 +19,15 @@ import InputHelperText from './InputHelperText'; import sanitizeInputRestProps from './sanitizeInputRestProps'; import Labeled from './Labeled'; import { LinearProgress } from '../layout'; +import { + useSupportCreateSuggestion, + SupportCreateSuggestionOptions, +} from './useSupportCreateSuggestion'; -const sanitizeRestProps = ({ - addLabel, - afterSubmit, - allowNull, - beforeSubmit, - choices, - className, - crudGetMatching, - crudGetOne, - data, - filter, - filterToQuery, - formatOnBlur, - isEqual, - limitChoicesToValue, - multiple, - name, - pagination, - perPage, - ref, - reference, - render, - setFilter, - setPagination, - setSort, - sort, - subscription, - type, - validateFields, - validation, - value, - ...rest -}: any) => sanitizeInputRestProps(rest); - -const useStyles = makeStyles( - theme => ({ - input: { - minWidth: theme.spacing(20), - }, - }), - { name: 'RaSelectInput' } -); +interface SelectInputProps + extends ChoicesInputProps, + Omit, + Omit {} /** * An Input component for a select box, using an array of objects for the options @@ -137,12 +103,15 @@ const useStyles = makeStyles( * * */ -const SelectInput: FunctionComponent = props => { +const SelectInput = (props: SelectInputProps) => { const { allowEmpty, choices = [], classes: classesOverride, className, + create, + createLabel, + createValue = '@@ra-create', disableValue, emptyText, emptyValue, @@ -153,6 +122,7 @@ const SelectInput: FunctionComponent = props => { loading, onBlur, onChange, + onCreate, onFocus, options, optionText, @@ -183,6 +153,17 @@ const SelectInput: FunctionComponent = props => { translateChoice, }); + const { + getCreateItemLabel, + handleChange, + createElement, + } = useSupportCreateSuggestion({ + create, + createLabel, + createValue, + onCreate, + source, + }); const { id, input, isRequired, meta } = useInput({ format, onBlur, @@ -209,6 +190,15 @@ const SelectInput: FunctionComponent = props => { getChoiceText, ]); + const handleInputChange = useCallback( + async (event: React.ChangeEvent) => { + handleChange(event, () => { + input.onChange(event); + }); + }, + [handleChange, input] + ); + if (loading) { return ( = props => { } return ( - + + ) + } + className={`${classes.input} ${className}`} + clearAlwaysVisible + error={!!(touched && (error || submitError))} + helperText={ + - ) - } - className={`${classes.input} ${className}`} - clearAlwaysVisible - error={!!(touched && (error || submitError))} - helperText={ - - } - {...options} - {...sanitizeRestProps(rest)} - > - {allowEmpty ? ( - - {renderEmptyItemOption()} - - ) : null} - {choices.map(choice => ( - - {renderMenuItemOption(choice)} - - ))} - + } + {...options} + {...sanitizeRestProps(rest)} + > + {allowEmpty ? ( + + {renderEmptyItemOption()} + + ) : null} + {choices.map(choice => ( + + {renderMenuItemOption(choice)} + + ))} + {onCreate || create ? ( + + {getCreateItemLabel()} + + ) : null} + + {createElement} + ); }; @@ -310,7 +309,47 @@ SelectInput.defaultProps = { disableValue: 'disabled', }; -export type SelectInputProps = ChoicesInputProps & - Omit; +const sanitizeRestProps = ({ + addLabel, + afterSubmit, + allowNull, + beforeSubmit, + choices, + className, + crudGetMatching, + crudGetOne, + data, + filter, + filterToQuery, + formatOnBlur, + isEqual, + limitChoicesToValue, + multiple, + name, + pagination, + perPage, + ref, + reference, + render, + setFilter, + setPagination, + setSort, + sort, + subscription, + type, + validateFields, + validation, + value, + ...rest +}: any) => sanitizeInputRestProps(rest); + +const useStyles = makeStyles( + theme => ({ + input: { + minWidth: theme.spacing(20), + }, + }), + { name: 'RaSelectInput' } +); export default SelectInput; diff --git a/packages/ra-ui-materialui/src/input/index.ts b/packages/ra-ui-materialui/src/input/index.ts index 741c558300a..ebd395c4029 100644 --- a/packages/ra-ui-materialui/src/input/index.ts +++ b/packages/ra-ui-materialui/src/input/index.ts @@ -2,7 +2,6 @@ import ArrayInput, { ArrayInputProps } from './ArrayInput'; import AutocompleteArrayInput, { AutocompleteArrayInputProps, } from './AutocompleteArrayInput'; -import AutocompleteInput, { AutocompleteInputProps } from './AutocompleteInput'; import BooleanInput from './BooleanInput'; import CheckboxGroupInput, { CheckboxGroupInputProps, @@ -34,6 +33,8 @@ import SelectArrayInput, { SelectArrayInputProps } from './SelectArrayInput'; import SelectInput, { SelectInputProps } from './SelectInput'; import TextInput, { TextInputProps } from './TextInput'; import sanitizeInputRestProps from './sanitizeInputRestProps'; +export * from './AutocompleteInput'; +export * from './useSupportCreateSuggestion'; export * from './TranslatableInputs'; export * from './TranslatableInputsTabContent'; export * from './TranslatableInputsTabs'; @@ -42,7 +43,6 @@ export * from './TranslatableInputsTab'; export { ArrayInput, AutocompleteArrayInput, - AutocompleteInput, BooleanInput, CheckboxGroupInput, DateInput, @@ -68,7 +68,6 @@ export { export type { ArrayInputProps, - AutocompleteInputProps, AutocompleteArrayInputProps, CheckboxGroupInputProps, DateInputProps, diff --git a/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx b/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx new file mode 100644 index 00000000000..489459c6ff9 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx @@ -0,0 +1,120 @@ +import * as React from 'react'; +import { + ChangeEvent, + createContext, + isValidElement, + ReactElement, + useContext, + useState, +} from 'react'; +import { useTranslate } from 'ra-core'; +import { useForm } from 'react-final-form'; + +/** + * This hook provides support for suggestion creation in inputs which have choices. + * + * @param options The hook option + * @param {ReactElement} options.create A react element which will be rendered when users choose to create a new choice. This component must call the `useCreateSuggestion` hook which provides `onCancel`, `onCreate` and `filter`. See the examples. + * @param {String} options.createLabel Optional. The label for the choice item allowing users to create a new choice. Can be a translation key. Defaults to `ra.action.create`. + * @param {String} options.createItemLabel Optional. The label for the choice item allowing users to create a new choice when they already entered a filter. Can be a translation key. The translation will receive an `item` parameter. Defaults to `ra.action.create_item`. + * @param {any} options.createValue Optional. The value for the choice item allowing users to create a new choice. Defaults to `@@ra-create`. + * @param {String} options.filter Optional. The filter users may have already entered. Useful for autocomplete inputs for example. + * @param {OnCreateHandler} options.onCreate Optional. A function which will be called when users choose to create a new choice, if the `create` option wasn't provided. + * @param {String} options.source The input source. Used to trigger a form change upon successful creation. + * @returns {UseSupportCreateValue} An object with the following properties: + * - getCreateItemLabel: a function which will return the label of the choice for create a new choice. + * - handleChange: a function to pass to the input. Accept the event or the value selected and the original onChange handler of the input. This original handler will be called if users haven't asked to create a new choice. + * - createElement: a React element to render after the input. It will be rendered when users choose to create a new choice. It renders null otherwise. + */ +export const useSupportCreateSuggestion = ( + options: SupportCreateSuggestionOptions +): UseSupportCreateValue => { + const { + create, + createLabel = 'ra.action.create', + createItemLabel = 'ra.action.create_item', + createValue = '@@ra-create', + filter, + onCreate, + source, + } = options; + const translate = useTranslate(); + const form = useForm(); + const [renderOnCreate, setRenderOnCreate] = useState(false); + + const context = { + filter, + onCancel: () => setRenderOnCreate(false), + onCreate: (value, item) => { + setRenderOnCreate(false); + form.change(source, value); + }, + }; + + return { + getCreateItemLabel: () => { + return filter && createItemLabel + ? translate(createItemLabel, { + item: filter, + _: createItemLabel, + }) + : translate(createLabel, { _: createLabel }); + }, + handleChange: async (eventOrValue, handleChange) => { + const value = eventOrValue.target?.value || eventOrValue; + + if (value === createValue) { + if (!isValidElement(create)) { + const newSuggestion = await onCreate(filter); + + if (newSuggestion) { + form.change(source, value); + return; + } + } else { + setRenderOnCreate(true); + return; + } + } + handleChange(); + }, + createElement: + renderOnCreate && isValidElement(create) ? ( + + {create} + + ) : null, + }; +}; + +export interface SupportCreateSuggestionOptions { + create?: ReactElement; + createValue?: string; + createLabel?: string; + createItemLabel?: string; + filter?: string; + onCreate?: OnCreateHandler; + source: string; +} + +export interface UseSupportCreateValue { + getCreateItemLabel: () => string; + handleChange: ( + eventOrValue: ChangeEvent | any, + handleChange: (value?: any) => void + ) => Promise; + createElement: ReactElement | null; +} + +const CreateSuggestionContext = createContext( + undefined +); + +interface CreateSuggestionContextValue { + filter?: string; + onCreate: (value: any, choice: any) => void; + onCancel: () => void; +} +export const useCreateSuggestion = () => useContext(CreateSuggestionContext); + +export type OnCreateHandler = (filter?: string) => any | Promise; From e5a0535acdd90f897b3141ac13525942e09c3328 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Wed, 14 Apr 2021 11:14:51 +0200 Subject: [PATCH 02/17] Fix wrong rebase --- packages/ra-ui-materialui/src/detail/editFieldTypes.tsx | 2 +- packages/ra-ui-materialui/src/input/SelectInput.spec.tsx | 2 +- packages/ra-ui-materialui/src/input/SelectInput.tsx | 6 ++---- packages/ra-ui-materialui/src/input/index.ts | 4 +--- .../ra-ui-materialui/src/list/filter/FilterForm.spec.tsx | 2 +- 5 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx b/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx index d2a9568c98d..9af5b891529 100644 --- a/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx +++ b/packages/ra-ui-materialui/src/detail/editFieldTypes.tsx @@ -10,7 +10,7 @@ import ReferenceInput from '../input/ReferenceInput'; import ReferenceArrayInput, { ReferenceArrayInputProps, } from '../input/ReferenceArrayInput'; -import SelectInput from '../input/SelectInput'; +import { SelectInput } from '../input/SelectInput'; import TextInput from '../input/TextInput'; import { InferredElement, InferredTypeMap, InputProps } from 'ra-core'; diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx index 2a55b98135f..0f52dbd00f4 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx @@ -3,7 +3,7 @@ import { render, fireEvent } from '@testing-library/react'; import { Form } from 'react-final-form'; import { TestTranslationProvider } from 'ra-core'; -import SelectInput from './SelectInput'; +import { SelectInput } from './SelectInput'; import { required } from 'ra-core'; describe('', () => { diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index a023a2c0c42..6698f7f9c85 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -24,7 +24,7 @@ import { SupportCreateSuggestionOptions, } from './useSupportCreateSuggestion'; -interface SelectInputProps +export interface SelectInputProps extends ChoicesInputProps, Omit, Omit {} @@ -103,7 +103,7 @@ interface SelectInputProps * * */ -const SelectInput = (props: SelectInputProps) => { +export const SelectInput = (props: SelectInputProps) => { const { allowEmpty, choices = [], @@ -351,5 +351,3 @@ const useStyles = makeStyles( }), { name: 'RaSelectInput' } ); - -export default SelectInput; diff --git a/packages/ra-ui-materialui/src/input/index.ts b/packages/ra-ui-materialui/src/input/index.ts index ebd395c4029..d035541db21 100644 --- a/packages/ra-ui-materialui/src/input/index.ts +++ b/packages/ra-ui-materialui/src/input/index.ts @@ -30,10 +30,10 @@ import ResettableTextField, { } from './ResettableTextField'; import SearchInput, { SearchInputProps } from './SearchInput'; import SelectArrayInput, { SelectArrayInputProps } from './SelectArrayInput'; -import SelectInput, { SelectInputProps } from './SelectInput'; import TextInput, { TextInputProps } from './TextInput'; import sanitizeInputRestProps from './sanitizeInputRestProps'; export * from './AutocompleteInput'; +export * from './SelectInput'; export * from './useSupportCreateSuggestion'; export * from './TranslatableInputs'; export * from './TranslatableInputsTabContent'; @@ -61,7 +61,6 @@ export { ResettableTextField, SearchInput, SelectArrayInput, - SelectInput, TextInput, sanitizeInputRestProps, }; @@ -85,6 +84,5 @@ export type { ResettableTextFieldProps, SearchInputProps, SelectArrayInputProps, - SelectInputProps, TextInputProps, }; diff --git a/packages/ra-ui-materialui/src/list/filter/FilterForm.spec.tsx b/packages/ra-ui-materialui/src/list/filter/FilterForm.spec.tsx index e1ccffe04ce..fab87692672 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterForm.spec.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterForm.spec.tsx @@ -5,7 +5,7 @@ import { renderWithRedux } from 'ra-test'; import FilterForm, { mergeInitialValuesWithDefaultValues } from './FilterForm'; import TextInput from '../../input/TextInput'; -import SelectInput from '../../input/SelectInput'; +import { SelectInput } from '../../input/SelectInput'; describe('', () => { const defaultProps = { From fdb8552156756bbb7d5e6b87f611cd5e1558331f Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 15 Apr 2021 15:33:10 +0200 Subject: [PATCH 03/17] Make useSupportCreateSuggestion compatible with ReferenceInput --- examples/simple/src/comments/CommentEdit.tsx | 76 ++++++++++++++-- examples/simple/src/posts/PostEdit.tsx | 2 +- .../field/ReferenceFieldController.spec.tsx | 8 ++ .../input/ReferenceInputController.spec.tsx | 1 + .../input/useReferenceInputController.ts | 4 + .../src/controller/useReference.spec.tsx | 2 + .../ra-core/src/controller/useReference.ts | 8 +- .../src/dataProvider/useGetMany.spec.tsx | 3 + .../ra-core/src/dataProvider/useGetMany.ts | 86 ++++++++++++------- packages/ra-core/src/dataProvider/useQuery.ts | 4 +- .../src/dataProvider/useQueryWithStore.ts | 4 +- .../src/field/sanitizeFieldRestProps.ts | 1 + .../src/input/AutocompleteInput.tsx | 84 +++++++++--------- .../src/input/SelectInput.tsx | 60 +++++++------ .../src/input/useSupportCreateSuggestion.tsx | 49 +++++------ 15 files changed, 254 insertions(+), 138 deletions(-) diff --git a/examples/simple/src/comments/CommentEdit.tsx b/examples/simple/src/comments/CommentEdit.tsx index 7986f9817c0..432e5f7e2ec 100644 --- a/examples/simple/src/comments/CommentEdit.tsx +++ b/examples/simple/src/comments/CommentEdit.tsx @@ -1,4 +1,12 @@ -import { Card, Typography } from '@material-ui/core'; +import { + Card, + Typography, + Dialog, + DialogContent, + TextField as MuiTextField, + DialogActions, + Button, +} from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import * as React from 'react'; import { @@ -14,6 +22,8 @@ import { Title, minLength, Record, + useCreateSuggestion, + useCreate, } from 'react-admin'; // eslint-disable-line import/no-unresolved const LinkToRelatedPost = ({ record }: { record?: Record }) => ( @@ -34,13 +44,64 @@ const useEditStyles = makeStyles({ }, }); -const OptionRenderer = ({ record }: { record?: Record }) => ( - - {record?.title} - {record?.id} - -); +const OptionRenderer = ({ record }: { record?: Record }) => { + return record.id === '@@ra-create' ? ( + {record.name} + ) : ( + + {record?.title} - {record?.id} + + ); +}; + +const inputText = record => + record.id === '@@ra-create' + ? record.name + : `${record.title} - ${record.id}`; -const inputText = record => `${record.title} - ${record.id}`; +const CreatePost = () => { + const { filter, onCancel, onCreate } = useCreateSuggestion(); + const [value, setValue] = React.useState(filter || ''); + const [create] = useCreate('posts'); + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + create( + { + payload: { + data: { + title: value, + }, + }, + }, + { + onSuccess: ({ data }) => { + setValue(''); + const choice = data; + onCreate(value, choice); + }, + } + ); + return false; + }; + return ( + +
+ + setValue(event.target.value)} + autoFocus + /> + + + + + +
+
+ ); +}; const CommentEdit = props => { const classes = useEditStyles(); @@ -86,6 +147,7 @@ const CommentEdit = props => { fullWidth > } matchSuggestion={( filterValue, suggestion diff --git a/examples/simple/src/posts/PostEdit.tsx b/examples/simple/src/posts/PostEdit.tsx index 8eab61934bf..dcce251c4ec 100644 --- a/examples/simple/src/posts/PostEdit.tsx +++ b/examples/simple/src/posts/PostEdit.tsx @@ -53,7 +53,7 @@ const CreateCategory = ({ const [value, setValue] = React.useState(filter || ''); const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); - const choice = { name: value, id: value }; + const choice = { name: value, id: value.toLowerCase() }; onAddChoice(choice); onCreate(value, choice); setValue(''); diff --git a/packages/ra-core/src/controller/field/ReferenceFieldController.spec.tsx b/packages/ra-core/src/controller/field/ReferenceFieldController.spec.tsx index 919f19662b9..863db77a44f 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldController.spec.tsx @@ -84,6 +84,7 @@ describe('', () => { referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/posts/123', error: null, + refetch: expect.any(Function), }); }); @@ -116,6 +117,7 @@ describe('', () => { referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/prefix/posts/123', error: null, + refetch: expect.any(Function), }); }); @@ -148,6 +150,7 @@ describe('', () => { referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/edit/123', error: null, + refetch: expect.any(Function), }); }); @@ -180,6 +183,7 @@ describe('', () => { referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/show/123', error: null, + refetch: expect.any(Function), }); }); @@ -205,6 +209,7 @@ describe('', () => { referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/posts/123/show', error: null, + refetch: expect.any(Function), }); }); @@ -238,6 +243,7 @@ describe('', () => { referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/edit/123/show', error: null, + refetch: expect.any(Function), }); }); @@ -271,6 +277,7 @@ describe('', () => { referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/show/123/show', error: null, + refetch: expect.any(Function), }); }); @@ -296,6 +303,7 @@ describe('', () => { referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: false, error: null, + refetch: expect.any(Function), }); }); }); diff --git a/packages/ra-core/src/controller/input/ReferenceInputController.spec.tsx b/packages/ra-core/src/controller/input/ReferenceInputController.spec.tsx index a73db2deaa2..efd32ec5425 100644 --- a/packages/ra-core/src/controller/input/ReferenceInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/ReferenceInputController.spec.tsx @@ -170,6 +170,7 @@ describe('', () => { 'possibleValues.showFilter', ]) ).toEqual({ + refetch: expect.any(Function), possibleValues: { basePath: '/comments', currentSort: { diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts index 948fe59623d..2a70e2fb773 100644 --- a/packages/ra-core/src/controller/input/useReferenceInputController.ts +++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts @@ -17,6 +17,7 @@ import { useSortState } from '..'; import useFilterState from '../useFilterState'; import useSelectionState from '../useSelectionState'; import { useResourceContext } from '../../core'; +import { Refetch } from '../../dataProvider'; const defaultReferenceSource = (resource: string, source: string) => `${resource}@${source}`; @@ -126,6 +127,7 @@ export const useReferenceInputController = ( // fetch current value const { referenceRecord, + refetch, error: referenceError, loading: referenceLoading, loaded: referenceLoaded, @@ -202,6 +204,7 @@ export const useReferenceInputController = ( loading: possibleValuesLoading || referenceLoading, loaded: possibleValuesLoaded && referenceLoaded, filter: filterValues, + refetch, setFilter, pagination, setPagination, @@ -238,6 +241,7 @@ export interface ReferenceInputValue { setSort: (sort: SortPayload) => void; sort: SortPayload; warning?: string; + refetch: Refetch; } interface Option { diff --git a/packages/ra-core/src/controller/useReference.spec.tsx b/packages/ra-core/src/controller/useReference.spec.tsx index db4379078c0..5464b525aa1 100644 --- a/packages/ra-core/src/controller/useReference.spec.tsx +++ b/packages/ra-core/src/controller/useReference.spec.tsx @@ -143,6 +143,7 @@ describe('useReference', () => { loading: true, loaded: true, error: null, + refetch: expect.any(Function), }); }); @@ -171,6 +172,7 @@ describe('useReference', () => { loading: true, loaded: false, error: null, + refetch: expect.any(Function), }); }); }); diff --git a/packages/ra-core/src/controller/useReference.ts b/packages/ra-core/src/controller/useReference.ts index ecfd7b31c19..be89f3a7b07 100644 --- a/packages/ra-core/src/controller/useReference.ts +++ b/packages/ra-core/src/controller/useReference.ts @@ -1,5 +1,5 @@ import { Record } from '../types'; -import { useGetMany } from '../dataProvider'; +import { Refetch, useGetMany } from '../dataProvider'; interface Option { id: string; @@ -11,6 +11,7 @@ export interface UseReferenceProps { loaded: boolean; referenceRecord?: Record; error?: any; + refetch: Refetch; } /** @@ -41,9 +42,12 @@ export interface UseReferenceProps { * @returns {ReferenceProps} The reference record */ export const useReference = ({ reference, id }: Option): UseReferenceProps => { - const { data, error, loading, loaded } = useGetMany(reference, [id]); + const { data, error, loading, loaded, refetch } = useGetMany(reference, [ + id, + ]); return { referenceRecord: error ? undefined : data[0], + refetch, error, loading, loaded, diff --git a/packages/ra-core/src/dataProvider/useGetMany.spec.tsx b/packages/ra-core/src/dataProvider/useGetMany.spec.tsx index 74b159a3d68..4a043531040 100644 --- a/packages/ra-core/src/dataProvider/useGetMany.spec.tsx +++ b/packages/ra-core/src/dataProvider/useGetMany.spec.tsx @@ -217,6 +217,7 @@ describe('useGetMany', () => { loading: true, loaded: true, error: null, + refetch: expect.any(Function), }); }); @@ -257,6 +258,7 @@ describe('useGetMany', () => { loading: false, loaded: true, error: null, + refetch: expect.any(Function), }); }); }); @@ -310,6 +312,7 @@ describe('useGetMany', () => { loading: true, loaded: false, error: null, + refetch: expect.any(Function), }); }); diff --git a/packages/ra-core/src/dataProvider/useGetMany.ts b/packages/ra-core/src/dataProvider/useGetMany.ts index 3d96ebc8f36..c76c5bd3465 100644 --- a/packages/ra-core/src/dataProvider/useGetMany.ts +++ b/packages/ra-core/src/dataProvider/useGetMany.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import ReactDOM from 'react-dom'; import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; @@ -12,6 +12,8 @@ import { Identifier, Record, ReduxState, DataProviderProxy } from '../types'; import { useSafeSetState } from '../util/hooks'; import useDataProvider from './useDataProvider'; import { useEffect } from 'react'; +import { useVersion } from '../controller'; +import { Refetch } from './useQueryWithStore'; type Callback = (args?: any) => void; type SetState = (args: any) => void; @@ -34,6 +36,7 @@ interface UseGetManyResult { error?: any; loading: boolean; loaded: boolean; + refetch: Refetch; } let queriesToCall: QueriesToCall = {}; let dataProvider: DataProviderProxy; @@ -99,6 +102,14 @@ const useGetMany = ( const data = useSelector((state: ReduxState) => selectMany(state, resource, ids) ); + const version = useVersion(); // used to allow force reload + // used to force a refetch without relying on version + // which might trigger other queries as well + const [innerVersion, setInnerVersion] = useState(0); + + const refetch = useCallback(() => { + setInnerVersion(prevInnerVersion => prevInnerVersion + 1); + }, []); const [state, setState] = useSafeSetState({ data, error: null, @@ -106,6 +117,7 @@ const useGetMany = ( loaded: ids.length === 0 || (data.length !== 0 && !data.includes(undefined)), + refetch, }); if (!isEqual(state.data, data)) { setState({ @@ -115,36 +127,50 @@ const useGetMany = ( }); } dataProvider = useDataProvider(); // not the best way to pass the dataProvider to a function outside the hook, but I couldn't find a better one - useEffect(() => { - if (options.enabled === false) { - return; - } + useEffect( + () => { + if (options.enabled === false) { + return; + } - if (!queriesToCall[resource]) { - queriesToCall[resource] = []; - } - /** - * queriesToCall stores the queries to call under the following shape: - * - * { - * 'posts': [ - * { ids: [1, 2], setState } - * { ids: [2, 3], setState, onSuccess } - * { ids: [4, 5], setState } - * ], - * 'comments': [ - * { ids: [345], setState, onFailure } - * ] - * } - */ - queriesToCall[resource] = queriesToCall[resource].concat({ - ids, - setState, - onSuccess: options && options.onSuccess, - onFailure: options && options.onFailure, - }); - callQueries(); // debounced by lodash - }, [JSON.stringify({ resource, ids, options }), dataProvider]); // eslint-disable-line react-hooks/exhaustive-deps + if (!queriesToCall[resource]) { + queriesToCall[resource] = []; + } + /** + * queriesToCall stores the queries to call under the following shape: + * + * { + * 'posts': [ + * { ids: [1, 2], setState } + * { ids: [2, 3], setState, onSuccess } + * { ids: [4, 5], setState } + * ], + * 'comments': [ + * { ids: [345], setState, onFailure } + * ] + * } + */ + queriesToCall[resource] = queriesToCall[resource].concat({ + ids, + setState, + onSuccess: options && options.onSuccess, + onFailure: options && options.onFailure, + }); + callQueries(); // debounced by lodash + }, + /* eslint-disable react-hooks/exhaustive-deps */ + [ + JSON.stringify({ + resource, + ids, + options, + version, + innerVersion, + }), + dataProvider, + ] + /* eslint-enable react-hooks/exhaustive-deps */ + ); return state; }; diff --git a/packages/ra-core/src/dataProvider/useQuery.ts b/packages/ra-core/src/dataProvider/useQuery.ts index ca0b0184ec9..23fab41695e 100644 --- a/packages/ra-core/src/dataProvider/useQuery.ts +++ b/packages/ra-core/src/dataProvider/useQuery.ts @@ -6,7 +6,7 @@ import useDataProvider from './useDataProvider'; import useDataProviderWithDeclarativeSideEffects from './useDataProviderWithDeclarativeSideEffects'; import { DeclarativeSideEffect } from './useDeclarativeSideEffects'; import useVersion from '../controller/useVersion'; -import { DataProviderQuery } from './useQueryWithStore'; +import { DataProviderQuery, Refetch } from './useQueryWithStore'; /** * Call the data provider on mount @@ -165,5 +165,5 @@ export type UseQueryValue = { error?: any; loading: boolean; loaded: boolean; - refetch: () => void; + refetch: Refetch; }; diff --git a/packages/ra-core/src/dataProvider/useQueryWithStore.ts b/packages/ra-core/src/dataProvider/useQueryWithStore.ts index bd70b208be6..602088c8705 100644 --- a/packages/ra-core/src/dataProvider/useQueryWithStore.ts +++ b/packages/ra-core/src/dataProvider/useQueryWithStore.ts @@ -14,13 +14,15 @@ export interface DataProviderQuery { payload: object; } +export type Refetch = () => void; + export interface UseQueryWithStoreValue { data?: any; total?: number; error?: any; loading: boolean; loaded: boolean; - refetch: () => void; + refetch: Refetch; } export interface QueryOptions { diff --git a/packages/ra-ui-materialui/src/field/sanitizeFieldRestProps.ts b/packages/ra-ui-materialui/src/field/sanitizeFieldRestProps.ts index 36e0da3790f..2ef5e2cfc61 100644 --- a/packages/ra-ui-materialui/src/field/sanitizeFieldRestProps.ts +++ b/packages/ra-ui-materialui/src/field/sanitizeFieldRestProps.ts @@ -13,6 +13,7 @@ const sanitizeFieldRestProps: (props: any) => any = ({ link, locale, record, + refetch, resource, sortable, sortBy, diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index f0975623733..df083ce15b8 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -31,11 +31,6 @@ import { useSupportCreateSuggestion, } from './useSupportCreateSuggestion'; -interface Options { - suggestionsContainerProps?: any; - labelProps?: any; -} - /** * An Input component for an autocomplete field, using an array of objects for the options * @@ -107,7 +102,7 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { choices = [], createLabel, createItemLabel, - createValue = '@@ra-create', + createValue, create, disabled, emptyText, @@ -117,6 +112,7 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { helperText, id: idOverride, input: inputOverride, + inputText, isRequired: isRequiredOverride, label, limitChoicesToValue, @@ -140,9 +136,9 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { InputProps: undefined, }, optionText = 'name', - inputText, optionValue = 'id', parse, + refetch, resettable, resource, setFilter, @@ -182,7 +178,6 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { ); const classes = useStyles(props); - let inputEl = useRef(); let anchorEl = useRef(); const translate = useTranslate(); @@ -219,26 +214,9 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { [input.value, getSuggestionFromValue] ); - const { - getCreateItemLabel, - handleChange, - createElement, - } = useSupportCreateSuggestion({ - create, - createLabel, - createItemLabel, - createValue, - filter: filterValue, - onCreate, - source, - }); - const { getChoiceText, getChoiceValue, getSuggestions } = useSuggestions({ - allowCreate: !!onCreate || !!create, allowEmpty, choices, - createText: getCreateItemLabel(), - createValue, emptyText, emptyValue, limitChoicesToValue, @@ -250,6 +228,32 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { translateChoice, }); + const handleChange = useCallback( + async (item: any, newItem: any) => { + const value = getChoiceValue(newItem || item); + if (value == null && filterValue) { + setFilterValue(''); + } + + input.onChange(value); + }, + [filterValue, getChoiceValue, input] + ); + + const { + getCreateItem, + handleChange: handleChangeWithCreateSupport, + createElement, + } = useSupportCreateSuggestion({ + create, + createLabel, + createItemLabel, + createValue, + handleChange, + filter: filterValue, + onCreate, + }); + const handleFilterChange = useCallback( (eventOrValue: React.ChangeEvent<{ value: string }> | string) => { const event = eventOrValue as React.ChangeEvent<{ value: string }>; @@ -290,20 +294,6 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { inputText, ]); - const handleDownshiftChange = useCallback( - async (item: any) => { - const value = getChoiceValue(item); - handleChange(value, () => { - if (value == null && filterValue) { - setFilterValue(''); - } - - input.onChange(value); - }); - }, - [filterValue, getChoiceValue, input, handleChange] - ); - // This function ensures that the suggestion list stay aligned to the // input element even if it moves (because user scrolled for example) const updateAnchorEl = () => { @@ -462,7 +452,7 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { <> getChoiceValue(item)} {...rest} @@ -493,7 +483,10 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { onFocus: handleFocus(openMenu), ...InputProps, }); - const suggestions = getSuggestions(filterValue); + const suggestions = [ + ...getSuggestions(filterValue), + ...(onCreate || create ? [getCreateItem()] : []), + ]; return (
@@ -505,8 +498,8 @@ export const AutocompleteInput = (props: AutocompleteInputProps) => { endAdornment: getEndAdornment(openMenu), onBlur, onChange: event => { - handleFilterChange(event); setFilterValue(event.target.value); + handleFilterChange(event); onChange!( event as React.ChangeEvent< HTMLInputElement @@ -628,9 +621,14 @@ const useStyles = makeStyles( { name: 'RaAutocompleteInput' } ); +interface Options { + suggestionsContainerProps?: any; + labelProps?: any; +} + export interface AutocompleteInputProps extends ChoicesInputProps, - Omit, + Omit, Omit, 'onChange'> { clearAlwaysVisible?: boolean; resettable?: boolean; diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 6698f7f9c85..7bf3252cec1 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -23,11 +23,7 @@ import { useSupportCreateSuggestion, SupportCreateSuggestionOptions, } from './useSupportCreateSuggestion'; - -export interface SelectInputProps - extends ChoicesInputProps, - Omit, - Omit {} +import { useForm } from 'react-final-form'; /** * An Input component for a select box, using an array of objects for the options @@ -111,7 +107,7 @@ export const SelectInput = (props: SelectInputProps) => { className, create, createLabel, - createValue = '@@ra-create', + createValue, disableValue, emptyText, emptyValue, @@ -153,17 +149,6 @@ export const SelectInput = (props: SelectInputProps) => { translateChoice, }); - const { - getCreateItemLabel, - handleChange, - createElement, - } = useSupportCreateSuggestion({ - create, - createLabel, - createValue, - onCreate, - source, - }); const { id, input, isRequired, meta } = useInput({ format, onBlur, @@ -190,15 +175,31 @@ export const SelectInput = (props: SelectInputProps) => { getChoiceText, ]); - const handleInputChange = useCallback( - async (event: React.ChangeEvent) => { - handleChange(event, () => { - input.onChange(event); - }); + const form = useForm(); + const handleChange = useCallback( + async (event: React.ChangeEvent, newItem) => { + if (newItem) { + const value = getChoiceValue(newItem); + form.change(source, value); + return; + } + + input.onChange(event); }, - [handleChange, input] + [input, form, getChoiceValue, source] ); + const { + getCreateItem, + handleChange: handleChangeWithCreateSupport, + createElement, + } = useSupportCreateSuggestion({ + create, + createLabel, + createValue, + handleChange, + onCreate, + }); if (loading) { return ( { ); } + const createItem = getCreateItem(); + return ( <> { ))} {onCreate || create ? ( - - {getCreateItemLabel()} + + {createItem.name} ) : null} @@ -351,3 +354,8 @@ const useStyles = makeStyles( }), { name: 'RaSelectInput' } ); + +export interface SelectInputProps + extends ChoicesInputProps, + Omit, + Omit {} diff --git a/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx b/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx index 489459c6ff9..22edb4b8853 100644 --- a/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx +++ b/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx @@ -7,8 +7,7 @@ import { useContext, useState, } from 'react'; -import { useTranslate } from 'ra-core'; -import { useForm } from 'react-final-form'; +import { Identifier, useTranslate } from 'ra-core'; /** * This hook provides support for suggestion creation in inputs which have choices. @@ -20,10 +19,9 @@ import { useForm } from 'react-final-form'; * @param {any} options.createValue Optional. The value for the choice item allowing users to create a new choice. Defaults to `@@ra-create`. * @param {String} options.filter Optional. The filter users may have already entered. Useful for autocomplete inputs for example. * @param {OnCreateHandler} options.onCreate Optional. A function which will be called when users choose to create a new choice, if the `create` option wasn't provided. - * @param {String} options.source The input source. Used to trigger a form change upon successful creation. + * @param handleChange: a function to pass to the input. Receives the same parameter as the original event handler and an additional newItem parameter if a new item was create. * @returns {UseSupportCreateValue} An object with the following properties: - * - getCreateItemLabel: a function which will return the label of the choice for create a new choice. - * - handleChange: a function to pass to the input. Accept the event or the value selected and the original onChange handler of the input. This original handler will be called if users haven't asked to create a new choice. + * - getCreateItem: a function which will return the label of the choice for create a new choice. * - createElement: a React element to render after the input. It will be rendered when users choose to create a new choice. It renders null otherwise. */ export const useSupportCreateSuggestion = ( @@ -35,11 +33,10 @@ export const useSupportCreateSuggestion = ( createItemLabel = 'ra.action.create_item', createValue = '@@ra-create', filter, + handleChange, onCreate, - source, } = options; const translate = useTranslate(); - const form = useForm(); const [renderOnCreate, setRenderOnCreate] = useState(false); const context = { @@ -47,28 +44,31 @@ export const useSupportCreateSuggestion = ( onCancel: () => setRenderOnCreate(false), onCreate: (value, item) => { setRenderOnCreate(false); - form.change(source, value); + handleChange(undefined, item); }, }; return { - getCreateItemLabel: () => { - return filter && createItemLabel - ? translate(createItemLabel, { - item: filter, - _: createItemLabel, - }) - : translate(createLabel, { _: createLabel }); + getCreateItem: () => { + return { + id: createValue, + name: + filter && createItemLabel + ? translate(createItemLabel, { + item: filter, + _: createItemLabel, + }) + : translate(createLabel, { _: createLabel }), + }; }, - handleChange: async (eventOrValue, handleChange) => { + handleChange: async eventOrValue => { const value = eventOrValue.target?.value || eventOrValue; - - if (value === createValue) { + if (value?.id === createValue || value === createValue) { if (!isValidElement(create)) { const newSuggestion = await onCreate(filter); if (newSuggestion) { - form.change(source, value); + handleChange(eventOrValue, newSuggestion); return; } } else { @@ -76,7 +76,7 @@ export const useSupportCreateSuggestion = ( return; } } - handleChange(); + handleChange(eventOrValue, undefined); }, createElement: renderOnCreate && isValidElement(create) ? ( @@ -93,16 +93,13 @@ export interface SupportCreateSuggestionOptions { createLabel?: string; createItemLabel?: string; filter?: string; + handleChange: (value: any, newChoice: any) => void; onCreate?: OnCreateHandler; - source: string; } export interface UseSupportCreateValue { - getCreateItemLabel: () => string; - handleChange: ( - eventOrValue: ChangeEvent | any, - handleChange: (value?: any) => void - ) => Promise; + getCreateItem: () => { id: Identifier; name: string }; + handleChange: (eventOrValue: ChangeEvent | any) => Promise; createElement: ReactElement | null; } From 034d856892694e1ac4d0ad844c632a8b54ad056c Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Thu, 15 Apr 2021 16:46:13 +0200 Subject: [PATCH 04/17] Add create support in AutocompleteArrayInput --- .../simple/src/posts/TagReferenceInput.tsx | 66 +++- .../src/input/AutocompleteArrayInput.tsx | 317 ++++++++++-------- .../src/input/AutocompleteSuggestionItem.tsx | 11 +- 3 files changed, 249 insertions(+), 145 deletions(-) diff --git a/examples/simple/src/posts/TagReferenceInput.tsx b/examples/simple/src/posts/TagReferenceInput.tsx index 1083e911048..f7f692d764b 100644 --- a/examples/simple/src/posts/TagReferenceInput.tsx +++ b/examples/simple/src/posts/TagReferenceInput.tsx @@ -1,8 +1,19 @@ import * as React from 'react'; import { useState } from 'react'; import { useForm } from 'react-final-form'; -import { AutocompleteArrayInput, ReferenceArrayInput } from 'react-admin'; -import { Button } from '@material-ui/core'; +import { + AutocompleteArrayInput, + ReferenceArrayInput, + useCreate, + useCreateSuggestion, +} from 'react-admin'; +import { + Button, + Dialog, + DialogContent, + DialogActions, + TextField as MuiTextField, +} from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; const useStyles = makeStyles({ @@ -37,7 +48,10 @@ const TagReferenceInput = ({ return (
- + } + optionText="name.en" + /> + + + + + ); +}; + export default TagReferenceInput; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx index a3f109b0f4f..b1e7620b976 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.tsx @@ -24,6 +24,10 @@ import InputHelperText from './InputHelperText'; import AutocompleteSuggestionList from './AutocompleteSuggestionList'; import AutocompleteSuggestionItem from './AutocompleteSuggestionItem'; import { AutocompleteInputLoader } from './AutocompleteInputLoader'; +import { + SupportCreateSuggestionOptions, + useSupportCreateSuggestion, +} from './useSupportCreateSuggestion'; /** * An Input component for an autocomplete field, using an array of objects for the options @@ -93,6 +97,10 @@ const AutocompleteArrayInput = (props: AutocompleteArrayInputProps) => { allowEmpty, classes: classesOverride, choices = [], + create, + createLabel, + createItemLabel, + createValue, debounce: debounceDelay = 250, disabled, emptyText, @@ -112,6 +120,7 @@ const AutocompleteArrayInput = (props: AutocompleteArrayInputProps) => { meta: metaOverride, onBlur, onChange, + onCreate, onFocus, options: { suggestionsContainerProps, @@ -253,17 +262,32 @@ const AutocompleteArrayInput = (props: AutocompleteArrayInputProps) => { ); const handleChange = useCallback( - (item: any) => { - let newSelectedItems = - !allowDuplicates && selectedItems.includes(item) + (item: any, newItem) => { + const finalItem = newItem || item; + const newSelectedItems = + !allowDuplicates && selectedItems.includes(finalItem) ? [...selectedItems] - : [...selectedItems, item]; + : [...selectedItems, finalItem]; setFilterValue(''); input.onChange(newSelectedItems.map(getChoiceValue)); }, [allowDuplicates, getChoiceValue, input, selectedItems, setFilterValue] ); + const { + getCreateItem, + handleChange: handleChangeWithCreateSupport, + createElement, + } = useSupportCreateSuggestion({ + create, + createLabel, + createItemLabel, + createValue, + handleChange, + filter: filterValue, + onCreate, + }); + const handleDelete = useCallback( item => () => { const newSelectedItems = [...selectedItems]; @@ -344,138 +368,151 @@ const AutocompleteArrayInput = (props: AutocompleteArrayInputProps) => { }; return ( - getChoiceValue(item)} - {...rest} - > - {({ - getInputProps, - getItemProps, - getLabelProps, - getMenuProps, - isOpen, - inputValue: suggestionFilter, - highlightedIndex, - openMenu, - }) => { - const isMenuOpen = - isOpen && shouldRenderSuggestions(suggestionFilter); - const { - id: idFromDownshift, - onBlur, - onChange, - onFocus, - ref, - color, - size, - ...inputProps - } = getInputProps({ - onBlur: handleBlur, - onFocus: handleFocus(openMenu), - onClick: handleClick(openMenu), - onKeyDown: handleKeyDown, - }); - return ( -
- + getChoiceValue(item)} + {...rest} + > + {({ + getInputProps, + getItemProps, + getLabelProps, + getMenuProps, + isOpen, + inputValue: suggestionFilter, + highlightedIndex, + openMenu, + }) => { + const isMenuOpen = + isOpen && shouldRenderSuggestions(suggestionFilter); + const { + id: idFromDownshift, + onBlur, + onChange, + onFocus, + ref, + color, + size, + ...inputProps + } = getInputProps({ + onBlur: handleBlur, + onFocus: handleFocus(openMenu), + onClick: handleClick(openMenu), + onKeyDown: handleKeyDown, + }); + + const createItem = getCreateItem(); + const suggestions = [ + ...getSuggestions(suggestionFilter), + ...(onCreate || create ? [createItem] : []), + ]; + return ( +
+ - {selectedItems.map((item, index) => ( - - ))} -
- ), - endAdornment: loading && ( - - ), - onBlur, - onChange: event => { - handleFilterChange(event); - onChange!( - event as React.ChangeEvent< - HTMLInputElement + }), + input: classes.inputInput, + }, + startAdornment: ( +
- ); - }, - onFocus, - }} - error={!!(touched && (error || submitError))} - label={ - - } - InputLabelProps={getLabelProps({ - htmlFor: id, - })} - helperText={ - - } - variant={variant} - margin={margin} - color={color as any} - size={size as any} - disabled={disabled} - {...inputProps} - {...options} - /> - - {getSuggestions(suggestionFilter).map( - (suggestion, index) => ( + {selectedItems.map( + (item, index) => ( + + ) + )} +
+ ), + endAdornment: loading && ( + + ), + onBlur, + onChange: event => { + handleFilterChange(event); + onChange!( + event as React.ChangeEvent< + HTMLInputElement + > + ); + }, + onFocus, + }} + error={!!(touched && (error || submitError))} + label={ + + } + InputLabelProps={getLabelProps({ + htmlFor: id, + })} + helperText={ + + } + variant={variant} + margin={margin} + color={color as any} + size={size as any} + disabled={disabled} + {...inputProps} + {...options} + /> + + {suggestions.map((suggestion, index) => ( { item: suggestion, })} /> - ) - )} - -
- ); - }} -
+ ))} + +
+ ); + }} + + {createElement} + ); }; @@ -549,6 +587,7 @@ interface Options { export interface AutocompleteArrayInputProps extends ChoicesInputProps, + Omit, Omit, 'onChange'> {} export default AutocompleteArrayInput; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx b/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx index 20ff56be327..bc861648fe8 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteSuggestionItem.tsx @@ -26,7 +26,8 @@ const useStyles = makeStyles( { name: 'RaAutocompleteSuggestionItem' } ); -interface Props { +export interface AutocompleteSuggestionItemProps { + createValue?: any; suggestion: any; index: number; highlightedIndex: number; @@ -37,9 +38,10 @@ interface Props { } const AutocompleteSuggestionItem: FunctionComponent< - Props & MenuItemProps<'li', { button?: true }> + AutocompleteSuggestionItemProps & MenuItemProps<'li', { button?: true }> > = props => { const { + createValue, suggestion, index, highlightedIndex, @@ -51,7 +53,10 @@ const AutocompleteSuggestionItem: FunctionComponent< } = props; const classes = useStyles(props); const isHighlighted = highlightedIndex === index; - const suggestionText = getSuggestionText(suggestion); + const suggestionText = + suggestion?.id === createValue + ? suggestion.name + : getSuggestionText(suggestion); let matches; let parts; From 7bbb91bda47b913ecffc232dcb848380929c0492 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 09:26:47 +0200 Subject: [PATCH 05/17] Add support for create on SelectArrayInput --- .../src/input/SelectArrayInput.tsx | 282 ++++++++++-------- .../src/input/SelectInput.tsx | 6 +- .../src/input/useSupportCreateSuggestion.tsx | 8 +- 3 files changed, 170 insertions(+), 126 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx index b5af44deb78..a4147f120f3 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx @@ -1,11 +1,5 @@ import * as React from 'react'; -import { - FunctionComponent, - useCallback, - useRef, - useState, - useEffect, -} from 'react'; +import { useCallback, useRef, useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { Select, @@ -29,63 +23,10 @@ import { SelectProps } from '@material-ui/core/Select'; import { FormControlProps } from '@material-ui/core/FormControl'; import Labeled from './Labeled'; import { LinearProgress } from '../layout'; - -const sanitizeRestProps = ({ - addLabel, - allowEmpty, - alwaysOn, - basePath, - choices, - classNamInputWithOptionsPropse, - componenInputWithOptionsPropst, - crudGetMInputWithOptionsPropsatching, - crudGetOInputWithOptionsPropsne, - defaultValue, - disableValue, - filter, - filterToQuery, - formClassName, - initializeForm, - input, - isRequired, - label, - limitChoicesToValue, - loaded, - locale, - meta, - onChange, - options, - optionValue, - optionText, - perPage, - record, - reference, - resource, - setFilter, - setPagination, - setSort, - sort, - source, - textAlign, - translate, - translateChoice, - validation, - ...rest -}: any) => rest; - -const useStyles = makeStyles( - theme => ({ - root: {}, - chips: { - display: 'flex', - flexWrap: 'wrap', - }, - chip: { - margin: theme.spacing(1 / 4), - }, - }), - { name: 'RaSelectArrayInput' } -); +import { + SupportCreateSuggestionOptions, + useSupportCreateSuggestion, +} from './useSupportCreateSuggestion'; /** * An Input component for a select box allowing multiple selections, using an array of objects for the options @@ -139,11 +80,14 @@ const useStyles = makeStyles( * { id: 'photography', name: 'myroot.tags.photography' }, * ]; */ -const SelectArrayInput: FunctionComponent = props => { +const SelectArrayInput = (props: SelectArrayInputProps) => { const { choices = [], classes: classesOverride, className, + create, + createLabel, + createValue, disableValue, format, helperText, @@ -153,6 +97,7 @@ const SelectArrayInput: FunctionComponent = props => { margin = 'dense', onBlur, onChange, + onCreate, onFocus, options, optionText, @@ -165,9 +110,11 @@ const SelectArrayInput: FunctionComponent = props => { variant = 'filled', ...rest } = props; + const classes = useStyles(props); const inputLabel = useRef(null); const [labelWidth, setLabelWidth] = useState(0); + useEffect(() => { // Will be null while loading and we don't need this fix in that case if (inputLabel.current) { @@ -197,6 +144,33 @@ const SelectArrayInput: FunctionComponent = props => { ...rest, }); + const handleChange = useCallback( + (event, newItem) => { + if (newItem) { + input.onChange([...input.value, getChoiceValue(newItem)]); + return; + } + input.onChange(event); + }, + [input, getChoiceValue] + ); + + const { + getCreateItem, + handleChange: handleChangeWithCreateSupport, + createElement, + } = useSupportCreateSuggestion({ + create, + createLabel, + createValue, + handleChange, + onCreate, + }); + + const createItem = getCreateItem(); + const finalChoices = + create || onCreate ? [...choices, createItem] : choices; + const renderMenuItemOption = useCallback(choice => getChoiceText(choice), [ getChoiceText, ]); @@ -209,11 +183,13 @@ const SelectArrayInput: FunctionComponent = props => { value={getChoiceValue(choice)} disabled={getDisableValue(choice)} > - {renderMenuItemOption(choice)} + {choice?.id === createItem.id + ? createItem.name + : renderMenuItemOption(choice)} ) : null; }, - [getChoiceValue, getDisableValue, renderMenuItemOption] + [getChoiceValue, getDisableValue, renderMenuItemOption, createItem] ); if (loading) { @@ -231,68 +207,75 @@ const SelectArrayInput: FunctionComponent = props => { } return ( - - + - - - ( +
+ {selected + .map(item => + choices.find( + choice => + getChoiceValue(choice) === item + ) ) - ) - .map(item => ( - - ))} -
- )} - data-testid="selectArray" - {...input} - value={input.value || []} - {...options} - labelWidth={labelWidth} - > - {choices.map(renderMenuItem)} - - - - -
+ .filter(item => !!item) + .map(item => ( + + ))} +
+ )} + data-testid="selectArray" + {...input} + onChange={handleChangeWithCreateSupport} + value={input.value || []} + {...options} + labelWidth={labelWidth} + > + {finalChoices.map(renderMenuItem)} + + + + + + {createElement} + ); }; export interface SelectArrayInputProps extends Omit, + Omit, Omit, 'source'>, Omit< FormControlProps, @@ -329,4 +312,61 @@ SelectArrayInput.defaultProps = { translateChoice: true, }; +const sanitizeRestProps = ({ + addLabel, + allowEmpty, + alwaysOn, + basePath, + choices, + classNamInputWithOptionsPropse, + componenInputWithOptionsPropst, + crudGetMInputWithOptionsPropsatching, + crudGetOInputWithOptionsPropsne, + defaultValue, + disableValue, + filter, + filterToQuery, + formClassName, + initializeForm, + input, + isRequired, + label, + limitChoicesToValue, + loaded, + locale, + meta, + onChange, + options, + optionValue, + optionText, + perPage, + record, + reference, + resource, + setFilter, + setPagination, + setSort, + sort, + source, + textAlign, + translate, + translateChoice, + validation, + ...rest +}: any) => rest; + +const useStyles = makeStyles( + theme => ({ + root: {}, + chips: { + display: 'flex', + flexWrap: 'wrap', + }, + chip: { + margin: theme.spacing(1 / 4), + }, + }), + { name: 'RaSelectArrayInput' } +); + export default SelectArrayInput; diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 7bf3252cec1..f842622ef24 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -23,7 +23,6 @@ import { useSupportCreateSuggestion, SupportCreateSuggestionOptions, } from './useSupportCreateSuggestion'; -import { useForm } from 'react-final-form'; /** * An Input component for a select box, using an array of objects for the options @@ -175,18 +174,17 @@ export const SelectInput = (props: SelectInputProps) => { getChoiceText, ]); - const form = useForm(); const handleChange = useCallback( async (event: React.ChangeEvent, newItem) => { if (newItem) { const value = getChoiceValue(newItem); - form.change(source, value); + input.onChange(value); return; } input.onChange(event); }, - [input, form, getChoiceValue, source] + [input, getChoiceValue] ); const { diff --git a/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx b/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx index 22edb4b8853..bef6ac9abab 100644 --- a/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx +++ b/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx @@ -63,7 +63,13 @@ export const useSupportCreateSuggestion = ( }, handleChange: async eventOrValue => { const value = eventOrValue.target?.value || eventOrValue; - if (value?.id === createValue || value === createValue) { + const finalValue = Array.isArray(value) ? [...value].pop() : value; + + if (eventOrValue?.preventDefault) { + eventOrValue.preventDefault(); + eventOrValue.stopPropagation(); + } + if (finalValue?.id === createValue || finalValue === createValue) { if (!isValidElement(create)) { const newSuggestion = await onCreate(filter); From e6b81d236f3c8e020d7c275f2218d7b1d03beb60 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 11:42:57 +0200 Subject: [PATCH 06/17] Add Tests for SelectInput --- .../src/input/SelectInput.spec.tsx | 130 +++++++++++++++++- 1 file changed, 126 insertions(+), 4 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx index 0f52dbd00f4..3f6a5c7682c 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import { Form } from 'react-final-form'; import { TestTranslationProvider } from 'ra-core'; import { SelectInput } from './SelectInput'; import { required } from 'ra-core'; +import { useCreateSuggestion } from './useSupportCreateSuggestion'; describe('', () => { const defaultProps = { @@ -492,19 +493,140 @@ describe('', () => { it('should not render a LinearProgress if loading is false', () => { const { queryByRole } = render( +
} + /> + ); + + expect(queryByRole('progressbar')).toBeNull(); + }); + + test('should support creation of a new choice through the onCreate event', async () => { + const choices = [...defaultProps.choices]; + const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + + const { getByLabelText, getByRole, getByText, queryByText } = render( ( { + choices.push(newChoice); + return newChoice; }} /> )} /> ); - expect(queryByRole('progressbar')).toBeNull(); + const input = getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + input.focus(); + const select = getByRole('button'); + fireEvent.mouseDown(select); + + fireEvent.click(getByText('ra.action.create')); + await new Promise(resolve => setImmediate(resolve)); + input.blur(); + + expect( + // The selector ensure we don't get the options from the menu but the select value + queryByText(newChoice.name, { selector: '[role=button]' }) + ).not.toBeNull(); + }); + + test('should support creation of a new choice through the onCreate event with a promise', async () => { + const choices = [...defaultProps.choices]; + const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + + const { getByLabelText, getByRole, getByText, queryByText } = render( + ( + { + return new Promise(resolve => { + setTimeout(() => { + choices.push(newChoice); + resolve(newChoice); + }, 200); + }); + }} + /> + )} + /> + ); + + const input = getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + input.focus(); + const select = getByRole('button'); + fireEvent.mouseDown(select); + + fireEvent.click(getByText('ra.action.create')); + await new Promise(resolve => setImmediate(resolve)); + input.blur(); + + await waitFor(() => { + expect( + // The selector ensure we don't get the options from the menu but the select value + queryByText(newChoice.name, { selector: '[role=button]' }) + ).not.toBeNull(); + }); + }); + + test('should support creation of a new choice through the create element', async () => { + const choices = [...defaultProps.choices]; + const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + + const Create = () => { + const context = useCreateSuggestion(); + const handleClick = () => { + choices.push(newChoice); + context.onCreate(newChoice.id, newChoice); + }; + + return ; + }; + + const { getByLabelText, getByRole, getByText, queryByText } = render( + ( + } + /> + )} + /> + ); + + const input = getByLabelText( + 'resources.posts.fields.language' + ) as HTMLInputElement; + input.focus(); + const select = getByRole('button'); + fireEvent.mouseDown(select); + + fireEvent.click(getByText('ra.action.create')); + fireEvent.click(getByText('Get the kid')); + input.blur(); + + expect( + // The selector ensure we don't get the options from the menu but the select value + queryByText(newChoice.name, { selector: '[role=button]' }) + ).not.toBeNull(); }); }); From 618b2058a33f87aee389578c7e21d97a44d2e21b Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 12:00:33 +0200 Subject: [PATCH 07/17] Add Tests for SelectArrayInput --- .../src/input/SelectArrayInput.spec.tsx | 137 +++++++++++++++++- 1 file changed, 129 insertions(+), 8 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx index 29a79343737..902df63397d 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx @@ -1,10 +1,11 @@ import * as React from 'react'; import expect from 'expect'; -import { render, fireEvent } from '@testing-library/react'; +import { render, fireEvent, waitFor } from '@testing-library/react'; import { Form } from 'react-final-form'; import { TestTranslationProvider } from 'ra-core'; import SelectArrayInput from './SelectArrayInput'; +import { useCreateSuggestion } from './useSupportCreateSuggestion'; describe('', () => { const defaultProps = { @@ -303,17 +304,137 @@ describe('', () => { ( - - )} + render={() => } /> ); expect(queryByRole('progressbar')).toBeNull(); }); }); + + test('should support creation of a new choice through the onCreate event', async () => { + const choices = [...defaultProps.choices]; + const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + + const { + debug, + getByLabelText, + getByRole, + getByText, + queryAllByText, + } = render( + ( + { + choices.push(newChoice); + return newChoice; + }} + /> + )} + /> + ); + + const input = getByLabelText( + 'resources.posts.fields.categories' + ) as HTMLInputElement; + input.focus(); + const select = getByRole('button'); + fireEvent.mouseDown(select); + + fireEvent.click(getByText('ra.action.create')); + await new Promise(resolve => setImmediate(resolve)); + input.blur(); + // 2 because there is both the chip for the new selected item and the option (event if hidden) + expect(queryAllByText(newChoice.name).length).toEqual(2); + }); + + test('should support creation of a new choice through the onCreate event with a promise', async () => { + const choices = [...defaultProps.choices]; + const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + + const { getByLabelText, getByRole, getByText, queryAllByText } = render( + ( + { + return new Promise(resolve => { + setTimeout(() => { + choices.push(newChoice); + resolve(newChoice); + }, 200); + }); + }} + /> + )} + /> + ); + + const input = getByLabelText( + 'resources.posts.fields.categories' + ) as HTMLInputElement; + input.focus(); + const select = getByRole('button'); + fireEvent.mouseDown(select); + + fireEvent.click(getByText('ra.action.create')); + await new Promise(resolve => setImmediate(resolve)); + input.blur(); + + await waitFor(() => { + // 2 because there is both the chip for the new selected item and the option (event if hidden) + expect(queryAllByText(newChoice.name).length).toEqual(2); + }); + }); + + test('should support creation of a new choice through the create element', async () => { + const choices = [...defaultProps.choices]; + const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + + const Create = () => { + const context = useCreateSuggestion(); + const handleClick = () => { + choices.push(newChoice); + context.onCreate(newChoice.id, newChoice); + }; + + return ; + }; + + const { getByLabelText, getByRole, getByText, queryAllByText } = render( + ( + } + /> + )} + /> + ); + + const input = getByLabelText( + 'resources.posts.fields.categories' + ) as HTMLInputElement; + input.focus(); + const select = getByRole('button'); + fireEvent.mouseDown(select); + + fireEvent.click(getByText('ra.action.create')); + fireEvent.click(getByText('Get the kid')); + input.blur(); + + // 2 because there is both the chip for the new selected item and the option (event if hidden) + expect(queryAllByText(newChoice.name).length).toEqual(2); + }); }); From 1f46f69300eb53a34c327a10b0227850eea2867b Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 15:51:05 +0200 Subject: [PATCH 08/17] Add Tests for AutocompleteInput --- .../src/input/AutocompleteInput.spec.tsx | 186 +++++++++++++++++- .../src/input/SelectArrayInput.spec.tsx | 8 +- 2 files changed, 183 insertions(+), 11 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index 2d7739ef666..b3a3de09cb2 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -4,6 +4,7 @@ import { render, fireEvent, waitFor } from '@testing-library/react'; import { AutocompleteInput } from './AutocompleteInput'; import { Form } from 'react-final-form'; import { TestTranslationProvider } from 'ra-core'; +import { useCreateSuggestion } from './useSupportCreateSuggestion'; describe('', () => { // Fix document.createRange is not a function error on fireEvent usage (Fixed in jsdom v16.0.0) @@ -684,19 +685,196 @@ describe('', () => { it('should not render a LinearProgress if loading is false', () => { const { queryByRole } = render( + } + /> + ); + + expect(queryByRole('progressbar')).toBeNull(); + }); + + test('should support creation of a new choice through the onCreate event', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { + const newChoice = { + id: 'js_fatigue', + name: filter, + }; + choices.push(newChoice); + return newChoice; + }; + + const { getByLabelText, getByText, queryByText, rerender } = render( ( )} /> ); - expect(queryByRole('progressbar')).toBeNull(); + const input = getByLabelText('resources.posts.fields.language', { + selector: 'input', + }) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); + fireEvent.click(getByText('ra.action.create_item')); + await new Promise(resolve => setImmediate(resolve)); + rerender( + ( + + )} + /> + ); + fireEvent.click(getByLabelText('ra.action.clear_input_value')); + + expect( + // The selector ensure we don't get the options from the menu but the select value + queryByText('New Kid On The Block') + ).not.toBeNull(); + }); + + test('should support creation of a new choice through the onCreate event with a promise', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { + return new Promise(resolve => { + const newChoice = { + id: 'js_fatigue', + name: filter, + }; + choices.push(newChoice); + setTimeout(() => resolve(newChoice), 100); + }); + }; + + const { getByLabelText, getByText, queryByText, rerender } = render( + ( + + )} + /> + ); + + const input = getByLabelText('resources.posts.fields.language', { + selector: 'input', + }) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); + fireEvent.click(getByText('ra.action.create_item')); + await new Promise(resolve => setImmediate(resolve)); + rerender( + ( + + )} + /> + ); + fireEvent.click(getByLabelText('ra.action.clear_input_value')); + + expect( + // The selector ensure we don't get the options from the menu but the select value + queryByText('New Kid On The Block') + ).not.toBeNull(); + }); + + test('should support creation of a new choice through the create element', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + + const Create = () => { + const context = useCreateSuggestion(); + const handleClick = () => { + choices.push(newChoice); + context.onCreate(newChoice.id, newChoice); + }; + + return ; + }; + + const { getByLabelText, rerender, getByText, queryByText } = render( + ( + } + /> + )} + /> + ); + + const input = getByLabelText('resources.posts.fields.language', { + selector: 'input', + }) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); + fireEvent.click(getByText('ra.action.create_item')); + fireEvent.click(getByText('Get the kid')); + await new Promise(resolve => setImmediate(resolve)); + rerender( + ( + } + /> + )} + /> + ); + fireEvent.click(getByLabelText('ra.action.clear_input_value')); + + expect( + // The selector ensure we don't get the options from the menu but the select value + queryByText('New Kid On The Block') + ).not.toBeNull(); }); }); diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx index 902df63397d..dff22a2d7ab 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx @@ -316,13 +316,7 @@ describe('', () => { const choices = [...defaultProps.choices]; const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; - const { - debug, - getByLabelText, - getByRole, - getByText, - queryAllByText, - } = render( + const { getByLabelText, getByRole, getByText, queryAllByText } = render( Date: Fri, 16 Apr 2021 15:52:03 +0200 Subject: [PATCH 09/17] Apply review --- examples/simple/src/posts/PostEdit.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/examples/simple/src/posts/PostEdit.tsx b/examples/simple/src/posts/PostEdit.tsx index dcce251c4ec..1f595be9c9b 100644 --- a/examples/simple/src/posts/PostEdit.tsx +++ b/examples/simple/src/posts/PostEdit.tsx @@ -213,9 +213,8 @@ const PostEdit = ({ permissions, ...props }) => { categories.push(choice)} /> } From 917d028c188e28b2fb1047a83ac2908b9a9427dd Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 15:52:27 +0200 Subject: [PATCH 10/17] Fix Usage in CommentEdit --- examples/simple/src/comments/CommentEdit.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/simple/src/comments/CommentEdit.tsx b/examples/simple/src/comments/CommentEdit.tsx index 432e5f7e2ec..01b6f9dd5b0 100644 --- a/examples/simple/src/comments/CommentEdit.tsx +++ b/examples/simple/src/comments/CommentEdit.tsx @@ -77,7 +77,7 @@ const CreatePost = () => { onSuccess: ({ data }) => { setValue(''); const choice = data; - onCreate(value, choice); + onCreate(data.id, choice); }, } ); From 024d264170f3973b84079c75c33d421001510e15 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 16:08:13 +0200 Subject: [PATCH 11/17] Add Tests for AutocompleteArrayInput --- .../src/input/AutocompleteArrayInput.spec.tsx | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx index 8d3e6aab97e..550b298f2db 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx @@ -5,6 +5,7 @@ import expect from 'expect'; import AutocompleteArrayInput from './AutocompleteArrayInput'; import { TestTranslationProvider } from 'ra-core'; +import { useCreateSuggestion } from './useSupportCreateSuggestion'; describe('', () => { const defaultProps = { @@ -750,4 +751,177 @@ describe('', () => { expect(queryByRole('progressbar')).toBeNull(); }); + + test('should support creation of a new choice through the onCreate event', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { + const newChoice = { + id: 'js_fatigue', + name: filter, + }; + choices.push(newChoice); + return newChoice; + }; + + const { getByLabelText, getByText, queryByText, rerender } = render( + ( + + )} + /> + ); + + const input = getByLabelText('resources.posts.fields.language', { + selector: 'input', + }) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); + fireEvent.click(getByText('ra.action.create_item')); + await new Promise(resolve => setImmediate(resolve)); + rerender( + ( + + )} + /> + ); + + expect(queryByText('New Kid On The Block')).not.toBeNull(); + }); + + test('should support creation of a new choice through the onCreate event with a promise', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const handleCreate = filter => { + return new Promise(resolve => { + const newChoice = { + id: 'js_fatigue', + name: filter, + }; + choices.push(newChoice); + setImmediate(() => resolve(newChoice)); + }); + }; + + const { getByLabelText, getByText, queryByText, rerender } = render( + ( + + )} + /> + ); + + const input = getByLabelText('resources.posts.fields.language', { + selector: 'input', + }) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); + fireEvent.click(getByText('ra.action.create_item')); + await new Promise(resolve => setImmediate(resolve)); + rerender( + ( + + )} + /> + ); + + expect(queryByText('New Kid On The Block')).not.toBeNull(); + }); + + test('should support creation of a new choice through the create element', async () => { + const choices = [ + { id: 'ang', name: 'Angular' }, + { id: 'rea', name: 'React' }, + ]; + const newChoice = { id: 'js_fatigue', name: 'New Kid On The Block' }; + + const Create = () => { + const context = useCreateSuggestion(); + const handleClick = () => { + choices.push(newChoice); + context.onCreate(newChoice.id, newChoice); + }; + + return ; + }; + + const { getByLabelText, rerender, getByText, queryByText } = render( + ( + } + resettable + /> + )} + /> + ); + + const input = getByLabelText('resources.posts.fields.language', { + selector: 'input', + }) as HTMLInputElement; + input.focus(); + fireEvent.change(input, { target: { value: 'New Kid On The Block' } }); + fireEvent.click(getByText('ra.action.create_item')); + fireEvent.click(getByText('Get the kid')); + await new Promise(resolve => setImmediate(resolve)); + rerender( + ( + } + /> + )} + /> + ); + + expect(queryByText('New Kid On The Block')).not.toBeNull(); + }); }); From 0cc36d484bab8dfb8766bd32154dbe8ec1cd2a46 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 16:23:57 +0200 Subject: [PATCH 12/17] Simplify usage & cleanup --- examples/simple/src/comments/CommentEdit.tsx | 2 +- examples/simple/src/posts/PostEdit.tsx | 2 +- examples/simple/src/posts/TagReferenceInput.tsx | 2 +- .../src/input/AutocompleteInput.spec.tsx | 17 +++++------------ .../src/input/useSupportCreateSuggestion.tsx | 4 ++-- 5 files changed, 10 insertions(+), 17 deletions(-) diff --git a/examples/simple/src/comments/CommentEdit.tsx b/examples/simple/src/comments/CommentEdit.tsx index 01b6f9dd5b0..83040a17d79 100644 --- a/examples/simple/src/comments/CommentEdit.tsx +++ b/examples/simple/src/comments/CommentEdit.tsx @@ -77,7 +77,7 @@ const CreatePost = () => { onSuccess: ({ data }) => { setValue(''); const choice = data; - onCreate(data.id, choice); + onCreate(choice); }, } ); diff --git a/examples/simple/src/posts/PostEdit.tsx b/examples/simple/src/posts/PostEdit.tsx index 1f595be9c9b..e83a254c50a 100644 --- a/examples/simple/src/posts/PostEdit.tsx +++ b/examples/simple/src/posts/PostEdit.tsx @@ -55,7 +55,7 @@ const CreateCategory = ({ event.preventDefault(); const choice = { name: value, id: value.toLowerCase() }; onAddChoice(choice); - onCreate(value, choice); + onCreate(choice); setValue(''); return false; }; diff --git a/examples/simple/src/posts/TagReferenceInput.tsx b/examples/simple/src/posts/TagReferenceInput.tsx index f7f692d764b..57d4a043871 100644 --- a/examples/simple/src/posts/TagReferenceInput.tsx +++ b/examples/simple/src/posts/TagReferenceInput.tsx @@ -84,7 +84,7 @@ const CreateTag = () => { onSuccess: ({ data }) => { setValue(''); const choice = data; - onCreate(value, choice); + onCreate(choice); }, } ); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index b3a3de09cb2..2f040b431d7 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -748,10 +748,7 @@ describe('', () => { ); fireEvent.click(getByLabelText('ra.action.clear_input_value')); - expect( - // The selector ensure we don't get the options from the menu but the select value - queryByText('New Kid On The Block') - ).not.toBeNull(); + expect(queryByText('New Kid On The Block')).not.toBeNull(); }); test('should support creation of a new choice through the onCreate event with a promise', async () => { @@ -780,6 +777,7 @@ describe('', () => { resource="posts" choices={choices} onCreate={handleCreate} + resettable /> )} /> @@ -809,10 +807,7 @@ describe('', () => { ); fireEvent.click(getByLabelText('ra.action.clear_input_value')); - expect( - // The selector ensure we don't get the options from the menu but the select value - queryByText('New Kid On The Block') - ).not.toBeNull(); + expect(queryByText('New Kid On The Block')).not.toBeNull(); }); test('should support creation of a new choice through the create element', async () => { @@ -842,6 +837,7 @@ describe('', () => { resource="posts" choices={choices} create={} + resettable /> )} /> @@ -872,9 +868,6 @@ describe('', () => { ); fireEvent.click(getByLabelText('ra.action.clear_input_value')); - expect( - // The selector ensure we don't get the options from the menu but the select value - queryByText('New Kid On The Block') - ).not.toBeNull(); + expect(queryByText('New Kid On The Block')).not.toBeNull(); }); }); diff --git a/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx b/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx index bef6ac9abab..c5f3a335513 100644 --- a/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx +++ b/packages/ra-ui-materialui/src/input/useSupportCreateSuggestion.tsx @@ -42,7 +42,7 @@ export const useSupportCreateSuggestion = ( const context = { filter, onCancel: () => setRenderOnCreate(false), - onCreate: (value, item) => { + onCreate: item => { setRenderOnCreate(false); handleChange(undefined, item); }, @@ -115,7 +115,7 @@ const CreateSuggestionContext = createContext( interface CreateSuggestionContextValue { filter?: string; - onCreate: (value: any, choice: any) => void; + onCreate: (choice: any) => void; onCancel: () => void; } export const useCreateSuggestion = () => useContext(CreateSuggestionContext); From 3bb266a580202f808a9c2ad2d83110692d1dc0cf Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 16:26:21 +0200 Subject: [PATCH 13/17] Fix tests --- .../ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx | 2 +- packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx | 2 +- packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx | 2 +- packages/ra-ui-materialui/src/input/SelectInput.spec.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx index 550b298f2db..ed904b0c93e 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx @@ -876,7 +876,7 @@ describe('', () => { const context = useCreateSuggestion(); const handleClick = () => { choices.push(newChoice); - context.onCreate(newChoice.id, newChoice); + context.onCreate(newChoice); }; return ; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index 2f040b431d7..e0c21faf8c9 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -821,7 +821,7 @@ describe('', () => { const context = useCreateSuggestion(); const handleClick = () => { choices.push(newChoice); - context.onCreate(newChoice.id, newChoice); + context.onCreate(newChoice); }; return ; diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx index dff22a2d7ab..5cb66e99b88 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx @@ -397,7 +397,7 @@ describe('', () => { const context = useCreateSuggestion(); const handleClick = () => { choices.push(newChoice); - context.onCreate(newChoice.id, newChoice); + context.onCreate(newChoice); }; return ; diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx index 3f6a5c7682c..b4efaf9611c 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx @@ -593,7 +593,7 @@ describe('', () => { const context = useCreateSuggestion(); const handleClick = () => { choices.push(newChoice); - context.onCreate(newChoice.id, newChoice); + context.onCreate(newChoice); }; return ; From 8e85dbac78366594c63570c96a268d60b631ed84 Mon Sep 17 00:00:00 2001 From: Gildas Garcia <1122076+djhi@users.noreply.github.com> Date: Fri, 16 Apr 2021 17:03:54 +0200 Subject: [PATCH 14/17] Documentation --- docs/Inputs.md | 504 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 504 insertions(+) diff --git a/docs/Inputs.md b/docs/Inputs.md index d26623a3063..686a5682417 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -524,9 +524,13 @@ import { AutocompleteInput } from 'react-admin'; | `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`. | | `clearAlwaysVisible` | Optional | `boolean` | `false` | When `resettable` is true, set this prop to `true` to have the Reset button visible even when the field is empty | | `choices` | Required | `Object[]` | `-` | List of items to autosuggest | +| `create` | Optional | `Element` | `-` | A React Element to render when users want to create a new choice | +| `createLabel` | Optional | `string` | `ra.action.create` | The label for the menu item allowing users to create a new choice. Used when the filter is empty | +| `createItemLabel` | Optional | `string` | `ra.action.create_item` | The label for the menu item allowing users to create a new choice. Used when the filter is not empty | | `emptyValue` | Optional | `any` | `''` | 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` | +| `onCreate` | Optional | `Function` | `-` | A function called with the current filter value when users choose to create a new choice. | | `optionText` | Optional | `string` | `Function` | `Component` | `name` | Field name of record to display in the suggestion item or function which accepts the correct record as argument (`(record)=> {string}`) | | `optionValue` | Optional | `string` | `id` | Field name of record containing the value to use as input value | | `inputText` | Optional | `Function` | `-` | If `optionText` is a custom Component, this function is needed to determine the text displayed for the current selection. | @@ -645,6 +649,124 @@ Lastly, would you need to override the props of the suggestion's container (a `P **Tip**: `` is a stateless component, so it only allows to *filter* the list of choices, not to *extend* it. If you need to populate the list of choices based on the result from a `fetch` call (and if [``](#referenceinput) doesn't cover your need), you'll have to [write your own Input component](#writing-your-own-input-component) based on material-ui `` component. +#### Creating New Choices + +The `` can allow users to create a new choice if either the `create` or `onCreate` prop is provided. + +Use the `onCreate` prop when you only require users to provide a simple string and a `prompt` is enough. You can return either the new choice directly or a Promise resolving to the new choice. + +{% raw %} +```js +import { AutocompleteInput, Create, SimpleForm, TextInput } from 'react-admin'; + +const PostCreate = (props) => { + const categories = [ + { name: 'Tech', id: 'tech' }, + { name: 'Lifestyle', id: 'lifestyle' }, + ]; + return ( + + + + { + const newCategoryName = prompt('Enter a new category'); + const newCategory = { id: newCategoryName.toLowerCase(), name: newCategoryName }; + categories.push(newCategory); + return newCategory; + }} + source="category" + choices={categories} + /> + + + ); +} +``` +{% endraw %} + +Use the `create` prop when you want a more polished or complex UI. For example a Material UI `` asking for multiple fields because the choices are from a referenced resource. + +{% raw %} +```js +import { + AutocompleteInput, + Create, + ReferenceInput, + SimpleForm, + TextInput, + useCreateSuggestion +} from 'react-admin'; + +import { + Box, + BoxProps, + Button, + Dialog, + DialogActions, + DialogContent, + TextField, +} from '@material-ui/core'; + +const PostCreate = (props) => { + return ( + + + + + } /> + + + + ); +} + +const CreateCategory = () => { + const { filter, onCancel, onCreate } = useCreateSuggestion(); + const [value, setValue] = React.useState(filter || ''); + const [create] = useCreate('categories'); + + const handleSubmit = (event) => { + event.preventDefault(); + create( + { + payload: { + data: { + title: value, + }, + }, + }, + { + onSuccess: ({ data }) => { + setValue(''); + onCreate(data); + }, + } + ); + }; + + return ( + + + + setValue(event.target.value)} + autoFocus + /> + + + + + + + + ); +}; +``` +{% endraw %} + ### `` If you want to let the user choose a value among a list of possible values that are always shown (instead of hiding them behind a dropdown list, as in [``](#selectinput)), `` is the right component. Set the `choices` attribute to determine the options (with `id`, `name` tuples): @@ -775,7 +897,10 @@ import { SelectInput } from 'react-admin'; | ----------------- | -------- | -------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `allowEmpty` | Optional | `boolean` | `false` | If true, the first option is an empty one | | `choices` | Required | `Object[]` | - | List of items to show as options | +| `create` | Optional | `Element` | `-` | A React Element to render when users want to create a new choice | +| `createLabel` | Optional | `string` | `ra.action.create` | The label for the menu item allowing users to create a new choice. Used when the filter is empty | | `emptyText` | Optional | `string` | '' | The text to display for the empty option | +| `onCreate` | Optional | `Function` | `-` | A function called with the current filter value when users choose to create a new choice. | | `options` | Optional | `Object` | - | Props to pass to the underlying `` element | | `optionText` | Optional | `string` | `Function` | `name` | Field name of record to display in the suggestion item or function which accepts the current record as argument (`record => {string}`) | | `optionValue` | Optional | `string` | `id` | Field name of record containing the value to use as input value | @@ -903,6 +1028,124 @@ const choices = [ ``` +#### Creating New Choices + +The `` can allow users to create a new choice if either the `create` or `onCreate` prop is provided. + +Use the `onCreate` prop when you only require users to provide a simple string and a `prompt` is enough. You can return either the new choice directly or a Promise resolving to the new choice. + +{% raw %} +```js +import { SelectInput, Create, SimpleForm, TextInput } from 'react-admin'; + +const PostCreate = (props) => { + const categories = [ + { name: 'Tech', id: 'tech' }, + { name: 'Lifestyle', id: 'lifestyle' }, + ]; + return ( + + + + { + const newCategoryName = prompt('Enter a new category'); + const newCategory = { id: newCategoryName.toLowerCase(), name: newCategoryName }; + categories.push(newCategory); + return newCategory; + }} + source="category" + choices={categories} + /> + + + ); +} +``` +{% endraw %} + +Use the `create` prop when you want a more polished or complex UI. For example a Material UI `` asking for multiple fields because the choices are from a referenced resource. + +{% raw %} +```js +import { + SelectInput, + Create, + ReferenceInput, + SimpleForm, + TextInput, + useCreateSuggestion +} from 'react-admin'; + +import { + Box, + BoxProps, + Button, + Dialog, + DialogActions, + DialogContent, + TextField, +} from '@material-ui/core'; + +const PostCreate = (props) => { + return ( + + + + + } /> + + + + ); +} + +const CreateCategory = () => { + const { filter, onCancel, onCreate } = useCreateSuggestion(); + const [value, setValue] = React.useState(filter || ''); + const [create] = useCreate('categories'); + + const handleSubmit = (event) => { + event.preventDefault(); + create( + { + payload: { + data: { + title: value, + }, + }, + }, + { + onSuccess: ({ data }) => { + setValue(''); + onCreate(data); + }, + } + ); + }; + + return ( + +
+ + setValue(event.target.value)} + autoFocus + /> + + + + + +
+
+ ); +}; +``` +{% endraw %} + ## Array Inputs ### `` @@ -1016,9 +1259,13 @@ import { AutocompleteArrayInput } from 'react-admin'; | ------------------------- | -------- | -------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `allowEmpty` | Optional | `boolean` | `false` | If `true`, the first option is an empty one | | `allowDuplicates` | Optional | `boolean` | `false` | If `true`, the options can be selected several times | +| `create` | Optional | `Element` | `-` | A React Element to render when users want to create a new choice | +| `createLabel` | Optional | `string` | `ra.action.create` | The label for the menu item allowing users to create a new choice. Used when the filter is empty | +| `createItemLabel` | Optional | `string` | `ra.action.create_item` | The label for the menu item allowing users to create a new choice. Used when the filter is not empty | | `debounce` | Optional | `number` | `250` | The delay to wait before calling the setFilter function injected when used in a ReferenceInput. | | `choices` | Required | `Object[]` | - | List of items to autosuggest | | `matchSuggestion` | Optional | `Function` | - | Required if `optionText` is a React element. Function returning a boolean indicating whether a choice matches the filter. `(filter, choice) => boolean` | +| `onCreate` | Optional | `Function` | `-` | A function called with the current filter value when users choose to create a new choice. | | `optionValue` | Optional | `string` | `id` | Field name of record containing the value to use as input value | | `optionText` | Optional | `string` | `Function` | `name` | Field name of record to display in the suggestion item or function which accepts the current record as argument (`record => {string}`) | | `setFilter` | Optional | `Function` | `null` | A callback to inform the `searchText` has changed and new `choices` can be retrieved based on this `searchText`. Signature `searchText => void`. This function is automatically setup when using `ReferenceInput`. | @@ -1121,6 +1368,124 @@ If you need to override the props of the suggestion's container (a `Popper` elem **Tip**: React-admin's `` has only a capital A, while material-ui's `` has a capital A and a capital C. Don't mix up the components! +#### Creating New Choices + +The `` can allow users to create a new choice if either the `create` or `onCreate` prop is provided. + +Use the `onCreate` prop when you only require users to provide a simple string and a `prompt` is enough. You can return either the new choice directly or a Promise resolving to the new choice. + +{% raw %} +```js +import { AutocompleteArrayInput, Create, SimpleForm, TextInput } from 'react-admin'; + +const PostCreate = (props) => { + const tags = [ + { name: 'Tech', id: 'tech' }, + { name: 'Lifestyle', id: 'lifestyle' }, + ]; + return ( + + + + { + const newTagName = prompt('Enter a new tag'); + const newTag = { id: newTagName.toLowerCase(), name: newTagName }; + categories.push(newTag); + return newTag; + }} + source="tags" + choices={tags} + /> + + + ); +} +``` +{% endraw %} + +Use the `create` prop when you want a more polished or complex UI. For example a Material UI `` asking for multiple fields because the choices are from a referenced resource. + +{% raw %} +```js +import { + AutocompleteArrayInput, + Create, + ReferenceArrayInput, + SimpleForm, + TextInput, + useCreateSuggestion +} from 'react-admin'; + +import { + Box, + BoxProps, + Button, + Dialog, + DialogActions, + DialogContent, + TextField, +} from '@material-ui/core'; + +const PostCreate = (props) => { + return ( + + + + + } /> + + + + ); +} + +const CreateTag = () => { + const { filter, onCancel, onCreate } = useCreateSuggestion(); + const [value, setValue] = React.useState(filter || ''); + const [create] = useCreate('tags'); + + const handleSubmit = (event) => { + event.preventDefault(); + create( + { + payload: { + data: { + title: value, + }, + }, + }, + { + onSuccess: ({ data }) => { + setValue(''); + onCreate(data); + }, + } + ); + }; + + return ( + +
+ + setValue(event.target.value)} + autoFocus + /> + + + + + +
+
+ ); +}; +``` +{% endraw %} + ### `` If you want to let the user choose multiple values among a list of possible values by showing them all, `` is the right component. Set the `choices` attribute to determine the options (with `id`, `name` tuples): @@ -1243,6 +1608,25 @@ Check [the `ra-relationships` documentation](https://marmelab.com/ra-enterprise/ To let users choose several values in a list using a dropdown, use ``. It renders using [Material ui's `