From ff41d83682019146e6408487077408f064746e08 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Thu, 13 Oct 2022 18:19:39 +0200 Subject: [PATCH 01/32] Fix null value support in inputs --- .../ra-core/src/form/getFormInitialValues.ts | 6 -- packages/ra-core/src/form/useInput.spec.tsx | 55 ++++++++++------- packages/ra-core/src/form/useInput.ts | 5 +- .../src/input/TextInput.spec.tsx | 60 ++++++++++++++++++- .../src/input/TextInput.stories.tsx | 20 +++++++ 5 files changed, 116 insertions(+), 30 deletions(-) diff --git a/packages/ra-core/src/form/getFormInitialValues.ts b/packages/ra-core/src/form/getFormInitialValues.ts index a41340d4ce4..a6ae3121a05 100644 --- a/packages/ra-core/src/form/getFormInitialValues.ts +++ b/packages/ra-core/src/form/getFormInitialValues.ts @@ -10,12 +10,6 @@ export default function getFormInitialValues( getValues(defaultValues, record), record ); - // replace null values by empty string to avoid controlled/ uncontrolled input warning - Object.keys(finalInitialValues).forEach(key => { - if (finalInitialValues[key] === null) { - finalInitialValues[key] = ''; - } - }); return finalInitialValues; } diff --git a/packages/ra-core/src/form/useInput.spec.tsx b/packages/ra-core/src/form/useInput.spec.tsx index 905d0d0c81f..9e96b641849 100644 --- a/packages/ra-core/src/form/useInput.spec.tsx +++ b/packages/ra-core/src/form/useInput.spec.tsx @@ -151,28 +151,6 @@ describe('useInput', () => { expect(screen.queryByDisplayValue('99')).toBeNull(); }); - it('does not apply the defaultValue when input has a value of 0', () => { - render( - -
- - {({ id, field }) => { - return ( - - ); - }} - -
-
- ); - expect(screen.queryByDisplayValue('99')).toBeNull(); - }); - const BooleanInput = ({ source, defaultValue, @@ -298,4 +276,37 @@ describe('useInput', () => { ); expect(screen.getByDisplayValue('1000')).not.toBeNull(); }); + + it('should format null values to an empty string to avoid console warnings about controlled/uncontrolled components', () => { + let inputProps; + render( + +
+ + {props => { + inputProps = props; + return
; + }} + + + + ); + expect(inputProps.field.value).toEqual(''); + }); + it('should format null default values to an empty string to avoid console warnings about controlled/uncontrolled components', () => { + let inputProps; + render( + +
+ + {props => { + inputProps = props; + return
; + }} + + + + ); + expect(inputProps.field.value).toEqual(''); + }); }); diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index dfc6f07e5a6..58a76eaca91 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -18,10 +18,13 @@ import { useGetValidationErrorMessage } from './useGetValidationErrorMessage'; import { useFormGroups } from './useFormGroups'; import { useApplyInputDefaultValues } from './useApplyInputDefaultValues'; +// replace null values by empty string to avoid controlled/ uncontrolled input warning +const defaultFormat = (value: any) => (value === null ? '' : value); + export const useInput = (props: InputProps): UseInputValue => { const { defaultValue, - format, + format = defaultFormat, id, isRequired: isRequiredOption, name, diff --git a/packages/ra-ui-materialui/src/input/TextInput.spec.tsx b/packages/ra-ui-materialui/src/input/TextInput.spec.tsx index 508ce5f8960..90ba4e71d72 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.spec.tsx @@ -3,8 +3,9 @@ import { render, fireEvent, screen, waitFor } from '@testing-library/react'; import { required, testDataProvider } from 'ra-core'; import { AdminContext } from '../AdminContext'; -import { SimpleForm } from '../form'; +import { SimpleForm, Toolbar } from '../form'; import { TextInput } from './TextInput'; +import { SaveButton } from '../button'; describe('', () => { const defaultProps = { @@ -112,4 +113,61 @@ describe('', () => { }); }); }); + + it('should work with null values', async () => { + const onSubmit = jest.fn(); + render( + + + + + } + > + (value === '' ? null : value)} + format={value => (value === null ? '' : value)} + /> + + + ); + const input = screen.getByLabelText( + 'resources.posts.fields.title' + ) as HTMLInputElement; + const saveBtn = screen.getByText('ra.action.save'); + + expect(input.value).toEqual(''); + fireEvent.click(saveBtn); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { id: 1, title: null }, + expect.anything() + ); + }); + + fireEvent.change(input, { target: { value: 'test' } }); + expect(input.value).toEqual('test'); + fireEvent.click(saveBtn); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { id: 1, title: 'test' }, + expect.anything() + ); + }); + + fireEvent.change(input, { target: { value: '' } }); + expect(input.value).toEqual(''); + fireEvent.click(saveBtn); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { id: 1, title: null }, + expect.anything() + ); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx index 043620e0b35..69d30888c45 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx @@ -275,3 +275,23 @@ export const FieldState = () => ( ); + +export const WithNullSupport = () => ( + + + + (value === '' ? null : value)} + format={value => (value === null ? '' : value)} + /> + + + + +); From a5d2720e5428ee963e60ee156f75fec4e94946c1 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Thu, 13 Oct 2022 18:40:55 +0200 Subject: [PATCH 02/32] remove useless format prop --- packages/ra-ui-materialui/src/input/TextInput.spec.tsx | 1 - packages/ra-ui-materialui/src/input/TextInput.stories.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/TextInput.spec.tsx b/packages/ra-ui-materialui/src/input/TextInput.spec.tsx index 90ba4e71d72..b8e1091476f 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.spec.tsx @@ -131,7 +131,6 @@ describe('', () => { {...defaultProps} defaultValue={null} parse={value => (value === '' ? null : value)} - format={value => (value === null ? '' : value)} /> diff --git a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx index 69d30888c45..7a36a33d0c8 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx @@ -288,7 +288,6 @@ export const WithNullSupport = () => ( source="title" defaultValue={null} parse={value => (value === '' ? null : value)} - format={value => (value === null ? '' : value)} /> From 39657d31e793e69d2bd14d2596e71480f0027d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Thu, 13 Oct 2022 22:17:41 +0200 Subject: [PATCH 03/32] Add more tests --- .../src/form/useApplyInputDefaultValues.ts | 10 ++- .../src/input/TextInput.spec.tsx | 61 ++++++++--------- .../src/input/TextInput.stories.tsx | 68 +++++++++++++++++-- .../ra-ui-materialui/src/input/TextInput.tsx | 9 ++- 4 files changed, 104 insertions(+), 44 deletions(-) diff --git a/packages/ra-core/src/form/useApplyInputDefaultValues.ts b/packages/ra-core/src/form/useApplyInputDefaultValues.ts index eca673f5405..bc787c143d3 100644 --- a/packages/ra-core/src/form/useApplyInputDefaultValues.ts +++ b/packages/ra-core/src/form/useApplyInputDefaultValues.ts @@ -13,13 +13,19 @@ export const useApplyInputDefaultValues = ( ) => { const { defaultValue, source } = props; const record = useRecordContext(props); - const { getValues, resetField } = useFormContext(); + const { + getValues, + resetField, + getFieldState, + formState, + } = useFormContext(); const recordValue = get(record, source); const formValue = get(getValues(), source); + const { isDirty } = getFieldState(source, formState); useEffect(() => { if (defaultValue == null) return; - if (formValue == null && recordValue == null) { + if (formValue == null && recordValue == null && !isDirty) { // special case for ArrayInput: since we use get(record, source), // if source is like foo.23.bar, this effect will run. // but we only want to set the default value for the subfield bar diff --git a/packages/ra-ui-materialui/src/input/TextInput.spec.tsx b/packages/ra-ui-materialui/src/input/TextInput.spec.tsx index b8e1091476f..1e44477816b 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.spec.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { render, fireEvent, screen, waitFor } from '@testing-library/react'; - import { required, testDataProvider } from 'ra-core'; + import { AdminContext } from '../AdminContext'; -import { SimpleForm, Toolbar } from '../form'; +import { SimpleForm } from '../form'; import { TextInput } from './TextInput'; -import { SaveButton } from '../button'; +import { ValueNull, Parse } from './TextInput.stories'; describe('', () => { const defaultProps = { @@ -114,37 +114,20 @@ describe('', () => { }); }); - it('should work with null values', async () => { - const onSubmit = jest.fn(); - render( - - - - - } - > - (value === '' ? null : value)} - /> - - - ); - const input = screen.getByLabelText( + it('should keep null values', async () => { + const onSuccess = jest.fn(); + render(); + const input = (await screen.findByLabelText( 'resources.posts.fields.title' - ) as HTMLInputElement; + )) as HTMLInputElement; const saveBtn = screen.getByText('ra.action.save'); expect(input.value).toEqual(''); fireEvent.click(saveBtn); await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith( - { id: 1, title: null }, + expect(onSuccess).toHaveBeenCalledWith( + { id: 123, title: null }, + expect.anything(), expect.anything() ); }); @@ -153,8 +136,9 @@ describe('', () => { expect(input.value).toEqual('test'); fireEvent.click(saveBtn); await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith( - { id: 1, title: 'test' }, + expect(onSuccess).toHaveBeenCalledWith( + { id: 123, title: 'test' }, + expect.anything(), expect.anything() ); }); @@ -163,10 +147,23 @@ describe('', () => { expect(input.value).toEqual(''); fireEvent.click(saveBtn); await waitFor(() => { - expect(onSubmit).toHaveBeenCalledWith( - { id: 1, title: null }, + expect(onSuccess).toHaveBeenCalledWith( + { id: 123, title: null }, + expect.anything(), expect.anything() ); }); }); + + describe('parse', () => { + it('should transform the value before storing it in the form state', () => { + render(); + const input = screen.getByLabelText( + 'resources.posts.fields.title' + ) as HTMLInputElement; + expect(input.value).toEqual('Lorem ipsum'); + fireEvent.change(input, { target: { value: 'foo' } }); + expect(input.value).toEqual('bar'); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx index 7a36a33d0c8..ed68ea14024 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx @@ -5,7 +5,9 @@ import { useFormState, useWatch, useFormContext } from 'react-hook-form'; import { TextInput } from './TextInput'; import { AdminContext } from '../AdminContext'; import { Create } from '../detail'; -import { SimpleForm } from '../form'; +import { Edit } from '../detail'; +import { SimpleForm, Toolbar } from '../form'; +import { SaveButton } from '../button'; export default { title: 'ra-ui-materialui/input/TextInput' }; @@ -154,7 +156,7 @@ export const Required = () => ( record={{ id: 123, title: 'Lorem ipsum' }} sx={{ width: 600 }} > - + @@ -276,20 +278,72 @@ export const FieldState = () => ( ); -export const WithNullSupport = () => ( +const AlwaysOnToolbar = ( + + + +); + +export const ValueUndefined = ({ onSuccess = console.log }) => ( + Promise.resolve({ data: { id: 123 } }), + update: (resource, { data }) => Promise.resolve({ data }), + } as any + } + > + + + + + + + +); + +export const ValueNull = ({ onSuccess = console.log }) => ( + + Promise.resolve({ data: { id: 123, title: null } }), + update: (resource, { data }) => Promise.resolve({ data }), + } as any + } + > + + + + + + + +); + +export const Parse = ({ onSuccess = console.log }) => ( (value === '' ? null : value)} + parse={v => (v === 'foo' ? 'bar' : v)} /> - diff --git a/packages/ra-ui-materialui/src/input/TextInput.tsx b/packages/ra-ui-materialui/src/input/TextInput.tsx index b12b96f1288..4a7d9e23608 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.tsx @@ -28,7 +28,7 @@ import { sanitizeInputRestProps } from './sanitizeInputRestProps'; export const TextInput = (props: TextInputProps) => { const { className, - defaultValue = '', + defaultValue, label, format, helperText, @@ -48,8 +48,8 @@ export const TextInput = (props: TextInputProps) => { isRequired, } = useInput({ defaultValue, - format, - parse, + format: format ?? defaultFormat, + parse: parse ?? defaultParse, resource, source, type: 'text', @@ -103,5 +103,8 @@ TextInput.defaultProps = { options: {}, }; +const defaultFormat = (value: any) => (value == null ? '' : value); +const defaultParse = (value: string) => (value === '' ? null : value); + export type TextInputProps = CommonInputProps & Omit; From 95e03aa10a9a6b9e8e208a6e11c6f5efb84ef385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Thu, 13 Oct 2022 22:23:42 +0200 Subject: [PATCH 04/32] Handle undefined values, too --- packages/ra-core/src/form/useInput.spec.tsx | 422 +++++++++++--------- packages/ra-core/src/form/useInput.ts | 2 +- 2 files changed, 236 insertions(+), 188 deletions(-) diff --git a/packages/ra-core/src/form/useInput.spec.tsx b/packages/ra-core/src/form/useInput.spec.tsx index 9e96b641849..d99ad5e9996 100644 --- a/packages/ra-core/src/form/useInput.spec.tsx +++ b/packages/ra-core/src/form/useInput.spec.tsx @@ -106,207 +106,255 @@ describe('useInput', () => { expect(handleBlur).toHaveBeenCalled(); }); - it('applies the defaultValue when input does not have a value', () => { - const onSubmit = jest.fn(); - render( - -
- - {({ id, field }) => { - return ( - - ); - }} - -
-
- ); - expect(screen.queryByDisplayValue('foo')).not.toBeNull(); - }); + describe('defaultValue', () => { + it('applies the defaultValue when input does not have a value', () => { + const onSubmit = jest.fn(); + render( + +
+ + {({ id, field }) => { + return ( + + ); + }} + +
+
+ ); + expect(screen.queryByDisplayValue('foo')).not.toBeNull(); + }); - it('does not apply the defaultValue when input has a value of 0', () => { - render( - -
- - {({ id, field }) => { - return ( - - ); - }} - -
-
+ it('does not apply the defaultValue when input has a value of 0', () => { + render( + +
+ + {({ id, field }) => { + return ( + + ); + }} + +
+
+ ); + expect(screen.queryByDisplayValue('99')).toBeNull(); + }); + + const BooleanInput = ({ + source, + defaultValue, + }: { + source: string; + defaultValue?: boolean; + }) => ( + + {() => } + ); - expect(screen.queryByDisplayValue('99')).toBeNull(); - }); - const BooleanInput = ({ - source, - defaultValue, - }: { - source: string; - defaultValue?: boolean; - }) => ( - - {() => } - - ); + const BooleanInputValue = ({ source }) => { + const values = useFormContext().getValues(); + return ( + <> + {typeof values[source] === 'undefined' + ? 'undefined' + : values[source] + ? 'true' + : 'false'} + + ); + }; - const BooleanInputValue = ({ source }) => { - const values = useFormContext().getValues(); - return ( - <> - {typeof values[source] === 'undefined' - ? 'undefined' - : values[source] - ? 'true' - : 'false'} - - ); - }; + it('does not change the value if the field is of type checkbox and has no value', () => { + render( + +
+ + +
+ ); + expect(screen.queryByText('undefined')).not.toBeNull(); + }); - it('does not change the value if the field is of type checkbox and has no value', () => { - render( - -
- - -
- ); - expect(screen.queryByText('undefined')).not.toBeNull(); - }); + it('applies the defaultValue true when the field is of type checkbox and has no value', () => { + render( + +
+ + +
+ ); + expect(screen.queryByText('true')).not.toBeNull(); + }); - it('applies the defaultValue true when the field is of type checkbox and has no value', () => { - render( - -
- - -
- ); - expect(screen.queryByText('true')).not.toBeNull(); - }); + it('applies the defaultValue false when the field is of type checkbox and has no value', () => { + render( + +
+ + +
+ ); + expect(screen.queryByText('false')).not.toBeNull(); + }); - it('applies the defaultValue false when the field is of type checkbox and has no value', () => { - render( - -
- - -
- ); - expect(screen.queryByText('false')).not.toBeNull(); - }); + it('does not apply the defaultValue true when the field is of type checkbox and has a value', () => { + render( + +
+ + +
+ ); + expect(screen.queryByText('false')).not.toBeNull(); + }); - it('does not apply the defaultValue true when the field is of type checkbox and has a value', () => { - render( - -
- - -
- ); - expect(screen.queryByText('false')).not.toBeNull(); + it('does not apply the defaultValue false when the field is of type checkbox and has a value', () => { + render( + +
+ + +
+ ); + expect(screen.queryByText('true')).not.toBeNull(); + }); }); - it('does not apply the defaultValue false when the field is of type checkbox and has a value', () => { - render( - -
- - -
- ); - expect(screen.queryByText('true')).not.toBeNull(); - }); + describe('format', () => { + it('should format null values to an empty string to avoid console warnings about controlled/uncontrolled components', () => { + let inputProps; + render( + +
+ + {props => { + inputProps = props; + return
; + }} + + + + ); + expect(inputProps.field.value).toEqual(''); + }); - test('should apply the provided format function before passing the value to the real input', () => { - render( - -
- `${value} formatted`} - source="test" - children={({ id, field }) => { - return ; - }} - defaultValue="test" - /> -
-
- ); - expect(screen.getByDisplayValue('test formatted')).not.toBeNull(); - }); + it('should format undefined values to an empty string to avoid console warnings about controlled/uncontrolled components', () => { + let inputProps; + render( + +
+ + {props => { + inputProps = props; + return
; + }} + + + + ); + expect(inputProps.field.value).toEqual(''); + }); - test('should apply the provided parse function before applying the value from the real input', () => { - render( - -
- (value + 1).toString()} - source="test" - children={({ id, field }) => { - useEffect(() => { - field.onChange(999); - }, [field]); + it('should format null default values to an empty string to avoid console warnings about controlled/uncontrolled components', () => { + let inputProps; + render( + + + + {props => { + inputProps = props; + return
; + }} + + + + ); + expect(inputProps.field.value).toEqual(''); + }); - return ; - }} - /> - - - ); - expect(screen.getByDisplayValue('1000')).not.toBeNull(); + test('should apply the provided format function before passing the value to the real input', () => { + render( + +
+ `${value} formatted`} + source="test" + children={({ id, field }) => { + return ; + }} + defaultValue="test" + /> +
+
+ ); + expect(screen.getByDisplayValue('test formatted')).not.toBeNull(); + }); }); - it('should format null values to an empty string to avoid console warnings about controlled/uncontrolled components', () => { - let inputProps; - render( - -
- - {props => { - inputProps = props; - return
; - }} - - - - ); - expect(inputProps.field.value).toEqual(''); - }); - it('should format null default values to an empty string to avoid console warnings about controlled/uncontrolled components', () => { - let inputProps; - render( - -
- - {props => { - inputProps = props; - return
; - }} - - - - ); - expect(inputProps.field.value).toEqual(''); + describe('parse', () => { + test('should apply the provided parse function before applying the value from the real input', () => { + render( + +
+ (value + 1).toString()} + source="test" + children={({ id, field }) => { + useEffect(() => { + field.onChange(999); + }, [field]); + + return ; + }} + /> +
+
+ ); + expect(screen.getByDisplayValue('1000')).not.toBeNull(); + }); }); }); diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index 58a76eaca91..e461cd83bd6 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -19,7 +19,7 @@ import { useFormGroups } from './useFormGroups'; import { useApplyInputDefaultValues } from './useApplyInputDefaultValues'; // replace null values by empty string to avoid controlled/ uncontrolled input warning -const defaultFormat = (value: any) => (value === null ? '' : value); +const defaultFormat = (value: any) => (value == null ? '' : value); export const useInput = (props: InputProps): UseInputValue => { const { From 43df168a353b006cc50ec8b009bbb05e4093166d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Thu, 13 Oct 2022 22:41:14 +0200 Subject: [PATCH 05/32] No need for format as it is already in useInput --- packages/ra-ui-materialui/src/input/TextInput.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/TextInput.tsx b/packages/ra-ui-materialui/src/input/TextInput.tsx index 4a7d9e23608..1d5d5b5bcc7 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.tsx @@ -48,7 +48,7 @@ export const TextInput = (props: TextInputProps) => { isRequired, } = useInput({ defaultValue, - format: format ?? defaultFormat, + format, parse: parse ?? defaultParse, resource, source, @@ -103,7 +103,6 @@ TextInput.defaultProps = { options: {}, }; -const defaultFormat = (value: any) => (value == null ? '' : value); const defaultParse = (value: string) => (value === '' ? null : value); export type TextInputProps = CommonInputProps & From 83a5b7612f86232ff2940208582bd335a35179e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Thu, 13 Oct 2022 22:41:46 +0200 Subject: [PATCH 06/32] Add more tests to SelectIInput --- .../src/input/SelectInput.stories.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx index f29e1df4fd2..2956aa5bfc3 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx @@ -4,6 +4,7 @@ import { Admin, AdminContext } from 'react-admin'; import { Resource, required } from 'ra-core'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; +import { useWatch } from 'react-hook-form'; import { Create, Edit } from '../detail'; import { SimpleForm } from '../form'; @@ -58,6 +59,7 @@ export const InitialValue = () => ( { id: 'F', name: 'Female' }, ]} /> + @@ -159,6 +161,18 @@ export const Sort = () => ( const i18nProvider = polyglotI18nProvider(() => englishMessages); +const FormInspector = ({ name = 'gender' }) => { + const value = useWatch({ name }); + return ( +
+ {name} value in form:  + + {JSON.stringify(value)} ({typeof value}) + +
+ ); +}; + const Wrapper = ({ children }) => ( ( } > {children} + @@ -266,6 +281,7 @@ export const InsideReferenceInput = () => ( + )} @@ -312,6 +328,7 @@ export const InsideReferenceInputDefaultValue = ({ + )} From aafc64e57c695f0ebcc731b4c46323c0c88015af Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 10:46:35 +0200 Subject: [PATCH 07/32] AutocompleteInput --- .../src/input/AutocompleteInput.spec.tsx | 16 +++++++++ .../src/input/AutocompleteInput.stories.tsx | 33 +++++++++++++++++++ .../src/input/AutocompleteInput.tsx | 8 ++--- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index b9850e87719..69793b681e5 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -15,6 +15,7 @@ import { useCreateSuggestionContext } from './useSupportCreateSuggestion'; import { InsideReferenceInput, InsideReferenceInputDefaultValue, + Nullable, VeryLargeOptionsNumber, } from './AutocompleteInput.stories'; import { act } from '@testing-library/react-hooks'; @@ -1187,6 +1188,21 @@ describe('', () => { expect(screen.queryByText('New Kid On The Block')).not.toBeNull(); }); + it('should return null when no choice is selected', async () => { + const onSuccess = jest.fn(); + render(); + const clearBtn = await screen.findByLabelText('Clear value'); + fireEvent.click(clearBtn); + screen.getByText('Save').click(); + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ author: null }), + expect.anything(), + expect.anything() + ); + }); + }); + describe('Inside ', () => { it('should work inside a ReferenceInput field', async () => { render(); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx index 7036aef8774..865f1201e1e 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx @@ -91,6 +91,39 @@ export const Basic = () => ( ); +export const Nullable = ({ onSuccess = console.log }) => { + const choices = [ + { id: 1, name: 'Leo Tolstoy' }, + { id: 2, name: 'Victor Hugo' }, + { id: 3, name: 'William Shakespeare' }, + { id: 4, name: 'Charles Baudelaire' }, + { id: 5, name: 'Marcel Proust' }, + ]; + return ( + + ( + + + + + + )} + /> + + ); +}; + const BookEditCustomText = () => { const choices = [ { id: 1, fullName: 'Leo Tolstoy' }, diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index 2c1968d7bc0..c4074a5e1dc 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -235,10 +235,8 @@ export const AutocompleteInput = < formState: formStateOverride, onBlur, onChange, - parse: - parse ?? (isFromReference ? convertEmptyStringToNull : undefined), - format: - format ?? (isFromReference ? convertNullToEmptyString : undefined), + parse: parse ?? convertEmptyStringToNull, + format: format ?? convertNullToEmptyString, resource, source, validate, @@ -745,4 +743,4 @@ const getSelectedItems = ( const DefaultFilterToQuery = searchText => ({ q: searchText }); const convertEmptyStringToNull = value => (value === '' ? null : value); -const convertNullToEmptyString = value => (value === null ? '' : value); +const convertNullToEmptyString = value => (value == null ? '' : value); From c9e3879dd1a895acfa33d905ee1478c771fa329f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 11:12:16 +0200 Subject: [PATCH 08/32] extract FormInspector --- .../src/input/NumberInput.stories.tsx | 17 +++-------------- .../src/input/SelectInput.stories.tsx | 18 +++--------------- .../src/input/TextInput.stories.tsx | 15 ++------------- .../src/input/common.stories.tsx | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 42 deletions(-) create mode 100644 packages/ra-ui-materialui/src/input/common.stories.tsx diff --git a/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx b/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx index 29509b8723e..b2b217c1dd6 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx @@ -1,26 +1,15 @@ import * as React from 'react'; import { required } from 'ra-core'; -import { useFormState, useWatch, useFormContext } from 'react-hook-form'; +import { useFormState, useFormContext } from 'react-hook-form'; import { NumberInput } from './NumberInput'; import { AdminContext } from '../AdminContext'; import { Create } from '../detail'; import { SimpleForm } from '../form'; +import { FormInspector } from './common.stories'; export default { title: 'ra-ui-materialui/input/NumberInput' }; -const FormInspector = ({ name = 'views' }) => { - const value = useWatch({ name }); - return ( -
- {name} value in form:  - - {JSON.stringify(value)} ({typeof value}) - -
- ); -}; - export const Basic = () => ( ( > - + diff --git a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx index 2956aa5bfc3..27af481bb9e 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx @@ -4,7 +4,6 @@ import { Admin, AdminContext } from 'react-admin'; import { Resource, required } from 'ra-core'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; -import { useWatch } from 'react-hook-form'; import { Create, Edit } from '../detail'; import { SimpleForm } from '../form'; @@ -13,6 +12,7 @@ import { TextInput } from './TextInput'; import { ReferenceInput } from './ReferenceInput'; import { SaveButton } from '../button//SaveButton'; import { Toolbar } from '../form/Toolbar'; +import { FormInspector } from './common.stories'; export default { title: 'ra-ui-materialui/input/SelectInput' }; @@ -59,7 +59,7 @@ export const InitialValue = () => ( { id: 'F', name: 'Female' }, ]} /> - + @@ -161,18 +161,6 @@ export const Sort = () => ( const i18nProvider = polyglotI18nProvider(() => englishMessages); -const FormInspector = ({ name = 'gender' }) => { - const value = useWatch({ name }); - return ( -
- {name} value in form:  - - {JSON.stringify(value)} ({typeof value}) - -
- ); -}; - const Wrapper = ({ children }) => ( ( } > {children} - + diff --git a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx index ed68ea14024..a81db15553a 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { required } from 'ra-core'; -import { useFormState, useWatch, useFormContext } from 'react-hook-form'; +import { useFormState, useFormContext } from 'react-hook-form'; import { TextInput } from './TextInput'; import { AdminContext } from '../AdminContext'; @@ -8,21 +8,10 @@ import { Create } from '../detail'; import { Edit } from '../detail'; import { SimpleForm, Toolbar } from '../form'; import { SaveButton } from '../button'; +import { FormInspector } from './common.stories'; export default { title: 'ra-ui-materialui/input/TextInput' }; -const FormInspector = ({ name = 'title' }) => { - const value = useWatch({ name }); - return ( -
- {name} value in form:  - - {JSON.stringify(value)} ({typeof value}) - -
- ); -}; - export const Basic = () => ( { + const value = useWatch({ name }); + return ( +
+ {name} value in form:  + + {JSON.stringify(value)} ({typeof value}) + +
+ ); +}; From 41bd57fa96d67ebd698fb2a8dc168798b11dd270 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 11:14:44 +0200 Subject: [PATCH 09/32] DateInput --- .../src/input/DateInput.spec.tsx | 30 +++++++++++++++++++ .../src/input/DateInput.stories.tsx | 6 +++- .../ra-ui-materialui/src/input/DateInput.tsx | 4 ++- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/DateInput.spec.tsx b/packages/ra-ui-materialui/src/input/DateInput.spec.tsx index 6d9f631796e..2053d158598 100644 --- a/packages/ra-ui-materialui/src/input/DateInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/DateInput.spec.tsx @@ -201,6 +201,36 @@ describe('', () => { expect(screen.queryByText('Dirty: false')).not.toBeNull(); }); + it('should return null when date is empty', async () => { + const onSubmit = jest.fn(); + render( + + + + + + ); + const input = screen.getByLabelText( + 'resources.posts.fields.publishedAt' + ) as HTMLInputElement; + expect(input.value).toBe('2021-09-11'); + fireEvent.change(input, { + target: { value: '' }, + }); + fireEvent.click(screen.getByLabelText('ra.action.save')); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + publishedAt: null, + }, + expect.anything() + ); + }); + }); + describe('error message', () => { it('should not be displayed if field is pristine', () => { render( diff --git a/packages/ra-ui-materialui/src/input/DateInput.stories.tsx b/packages/ra-ui-materialui/src/input/DateInput.stories.tsx index b0123b11672..552160fdf89 100644 --- a/packages/ra-ui-materialui/src/input/DateInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/DateInput.stories.tsx @@ -6,6 +6,7 @@ import { AdminContext } from '../AdminContext'; import { Create } from '../detail'; import { SimpleForm } from '../form'; import { DateInput } from './DateInput'; +import { FormInspector } from './common.stories'; export default { title: 'ra-ui-materialui/input/DateInput' }; @@ -32,7 +33,10 @@ const i18nProvider = polyglotI18nProvider(() => englishMessages); const Wrapper = ({ children }) => ( - {children} + + {children} + + ); diff --git a/packages/ra-ui-materialui/src/input/DateInput.tsx b/packages/ra-ui-materialui/src/input/DateInput.tsx index 8d2dd82c8c1..6010c6c54ad 100644 --- a/packages/ra-ui-materialui/src/input/DateInput.tsx +++ b/packages/ra-ui-materialui/src/input/DateInput.tsx @@ -51,7 +51,7 @@ export const DateInput = ({ defaultValue, name, format, - parse, + parse: parse ?? defaultParse, onBlur, onChange, resource, @@ -143,3 +143,5 @@ const getStringFromDate = (value: string | Date) => { return convertDateToString(new Date(value)); }; + +const defaultParse = (value: string) => (value === '' ? null : value); From 7b58efce758b17211b1d8f6c287c10e27bf07530 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 11:23:52 +0200 Subject: [PATCH 10/32] DateTimeInput --- .../src/input/DateTimeInput.spec.tsx | 32 +++++++++++++++++++ ....stories.tsx => DateTimeInput.stories.tsx} | 6 +++- .../src/input/DateTimeInput.tsx | 3 +- 3 files changed, 39 insertions(+), 2 deletions(-) rename packages/ra-ui-materialui/src/input/{DateTimInput.stories.tsx => DateTimeInput.stories.tsx} (84%) diff --git a/packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx b/packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx index 756bf64ba17..35ec583a190 100644 --- a/packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx @@ -150,6 +150,38 @@ describe('', () => { }); }); + it('should return null when datetime is empty', async () => { + const onSubmit = jest.fn(); + render( + + + + + + ); + const input = screen.getByLabelText( + 'resources.posts.fields.publishedAt' + ) as HTMLInputElement; + expect(input.value).toBe( + format(new Date('2021-09-11'), "yyyy-MM-dd'T'HH:mm") + ); + fireEvent.change(input, { + target: { value: '' }, + }); + fireEvent.click(screen.getByLabelText('ra.action.save')); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + publishedAt: null, + }, + expect.anything() + ); + }); + }); + describe('error message', () => { it('should not be displayed if field is pristine', () => { render( diff --git a/packages/ra-ui-materialui/src/input/DateTimInput.stories.tsx b/packages/ra-ui-materialui/src/input/DateTimeInput.stories.tsx similarity index 84% rename from packages/ra-ui-materialui/src/input/DateTimInput.stories.tsx rename to packages/ra-ui-materialui/src/input/DateTimeInput.stories.tsx index b090035a63c..bfbf2574532 100644 --- a/packages/ra-ui-materialui/src/input/DateTimInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/DateTimeInput.stories.tsx @@ -6,6 +6,7 @@ import { AdminContext } from '../AdminContext'; import { Create } from '../detail'; import { SimpleForm } from '../form'; import { DateTimeInput } from './DateTimeInput'; +import { FormInspector } from './common.stories'; export default { title: 'ra-ui-materialui/input/DateTimeInput' }; @@ -32,7 +33,10 @@ const i18nProvider = polyglotI18nProvider(() => englishMessages); const Wrapper = ({ children }) => ( - {children} + + {children} + + ); diff --git a/packages/ra-ui-materialui/src/input/DateTimeInput.tsx b/packages/ra-ui-materialui/src/input/DateTimeInput.tsx index 8345c61572e..d576bb88ef0 100644 --- a/packages/ra-ui-materialui/src/input/DateTimeInput.tsx +++ b/packages/ra-ui-materialui/src/input/DateTimeInput.tsx @@ -15,7 +15,8 @@ import { InputHelperText } from './InputHelperText'; * @param {string} value Date string, formatted as yyyy-MM-ddThh:mm * @return {Date} */ -const parseDateTime = (value: string) => (value ? new Date(value) : value); +const parseDateTime = (value: string) => + value ? new Date(value) : value === '' ? null : value; /** * Input component for entering a date and a time with timezone, using the browser locale From b8b5c9ac39d50eb12d44966f1da61d134792473c Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 11:32:40 +0200 Subject: [PATCH 11/32] TimeInput --- .../src/input/TimeInput.spec.tsx | 37 +++++++++++++++++++ .../src/input/TimeInput.stories.tsx | 6 ++- .../ra-ui-materialui/src/input/TimeInput.tsx | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/TimeInput.spec.tsx b/packages/ra-ui-materialui/src/input/TimeInput.spec.tsx index 148264704fa..f999bfb0313 100644 --- a/packages/ra-ui-materialui/src/input/TimeInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/TimeInput.spec.tsx @@ -141,6 +141,43 @@ describe('', () => { }); }); + it('should submit null when empty', async () => { + const publishedAt = new Date('Wed Oct 05 2011 16:48:00 GMT+0200'); + const onSubmit = jest.fn(); + render( + + + + + } + > + + + + ); + expect( + screen.queryByDisplayValue(format(publishedAt, 'HH:mm')) + ).not.toBeNull(); + const input = screen.getByLabelText( + 'resources.posts.fields.publishedAt' + ); + fireEvent.change(input, { + target: { value: '' }, + }); + fireEvent.click(screen.getByLabelText('ra.action.save')); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + publishedAt: null, + }, + expect.anything() + ); + }); + }); + describe('error message', () => { it('should not be displayed if field is pristine', () => { render( diff --git a/packages/ra-ui-materialui/src/input/TimeInput.stories.tsx b/packages/ra-ui-materialui/src/input/TimeInput.stories.tsx index d273d05642b..2fd898c4e3d 100644 --- a/packages/ra-ui-materialui/src/input/TimeInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/TimeInput.stories.tsx @@ -6,6 +6,7 @@ import { AdminContext } from '../AdminContext'; import { Create } from '../detail'; import { SimpleForm } from '../form'; import { TimeInput } from './TimeInput'; +import { FormInspector } from './common.stories'; export default { title: 'ra-ui-materialui/input/TimeInput' }; @@ -32,7 +33,10 @@ const i18nProvider = polyglotI18nProvider(() => englishMessages); const Wrapper = ({ children }) => ( - {children} + + {children} + + ); diff --git a/packages/ra-ui-materialui/src/input/TimeInput.tsx b/packages/ra-ui-materialui/src/input/TimeInput.tsx index bed4322fa45..58e4622c03f 100644 --- a/packages/ra-ui-materialui/src/input/TimeInput.tsx +++ b/packages/ra-ui-materialui/src/input/TimeInput.tsx @@ -16,7 +16,7 @@ import { InputHelperText } from './InputHelperText'; * @return {Date} */ const parseTime = (value: string) => { - if (!value) return value; + if (!value) return null; const timeTokens = value.split(':').map(v => parseInt(v)); const today = new Date(); today.setHours(timeTokens[0] ?? 0); From 0928ac31684bb642ded413ec35d9654d31f16b80 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 11:36:29 +0200 Subject: [PATCH 12/32] FileInput --- packages/ra-ui-materialui/src/input/FileInput.stories.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ra-ui-materialui/src/input/FileInput.stories.tsx b/packages/ra-ui-materialui/src/input/FileInput.stories.tsx index 7cdb20d7ab8..f8877e6482f 100644 --- a/packages/ra-ui-materialui/src/input/FileInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/FileInput.stories.tsx @@ -8,6 +8,7 @@ import { SimpleForm } from '../form'; import { FileInput } from './FileInput'; import { FileField } from '../field'; import { required } from 'ra-core'; +import { FormInspector } from './common.stories'; export default { title: 'ra-ui-materialui/input/FileInput' }; @@ -16,6 +17,7 @@ export const Basic = () => ( + ); @@ -24,6 +26,7 @@ export const LimitByFileType = () => ( + ); @@ -32,6 +35,7 @@ export const Required = () => ( + ); @@ -48,6 +52,7 @@ export const CustomPreview = () => ( title="title" /> + ); @@ -56,6 +61,7 @@ export const Multiple = () => ( + ); @@ -64,6 +70,7 @@ export const FullWidth = () => ( + ); @@ -72,6 +79,7 @@ export const Disabled = () => ( + ); From 901478668e3f2b28402465e1a08f7453d8839b68 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 11:38:45 +0200 Subject: [PATCH 13/32] ImageInput --- .../ra-ui-materialui/src/input/ImageInput.stories.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/ra-ui-materialui/src/input/ImageInput.stories.tsx b/packages/ra-ui-materialui/src/input/ImageInput.stories.tsx index 1f459b8da37..59836aa8a23 100644 --- a/packages/ra-ui-materialui/src/input/ImageInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ImageInput.stories.tsx @@ -8,6 +8,7 @@ import { SimpleForm } from '../form'; import { ImageInput } from './ImageInput'; import { ImageField } from '../field'; import { required } from 'ra-core'; +import { FormInspector } from './common.stories'; export default { title: 'ra-ui-materialui/input/ImageInput' }; @@ -16,6 +17,7 @@ export const Basic = () => ( + ); @@ -24,6 +26,7 @@ export const LimitByFileType = () => ( + ); @@ -40,6 +43,7 @@ export const CustomPreview = () => ( title="title" /> + ); @@ -48,6 +52,7 @@ export const Multiple = () => ( + ); @@ -56,6 +61,7 @@ export const FullWidth = () => ( + ); @@ -64,6 +70,7 @@ export const Disabled = () => ( + ); @@ -72,6 +79,7 @@ export const Required = () => ( + ); From ce222d46a89aebf313edb5bf9f8fbb88694a43d0 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 11:53:14 +0200 Subject: [PATCH 14/32] SelectInput --- .../src/input/SelectInput.spec.tsx | 18 ++++++++++++++++++ .../src/input/SelectInput.stories.tsx | 18 ++++++++++-------- .../ra-ui-materialui/src/input/SelectInput.tsx | 8 +++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx index 04e02ec320e..080030df1e0 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx @@ -12,6 +12,7 @@ import { SimpleForm } from '../form'; import { SelectInput } from './SelectInput'; import { useCreateSuggestionContext } from './useSupportCreateSuggestion'; import { + EmptyText, InsideReferenceInput, InsideReferenceInputDefaultValue, Sort, @@ -760,4 +761,21 @@ describe('', () => { }); }); }); + + it('should return null when empty', async () => { + const onSuccess = jest.fn(); + render(); + const input = await screen.findByLabelText('Gender'); + fireEvent.mouseDown(input); + fireEvent.click(screen.getByText('Male')); + fireEvent.click(screen.getByText('None')); + screen.getByText('Save').click(); + await waitFor(() => { + expect(onSuccess).toHaveBeenCalledWith( + expect.objectContaining({ gender: null }), + expect.anything(), + undefined + ); + }); + }); }); diff --git a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx index 27af481bb9e..aa035123753 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.stories.tsx @@ -104,8 +104,8 @@ export const Required = () => ( ); -export const EmptyText = () => ( - +export const EmptyText = ({ onSuccess = console.log }) => ( + ( const i18nProvider = polyglotI18nProvider(() => englishMessages); -const Wrapper = ({ children }) => ( +const Wrapper = ({ children, onSuccess = console.log }) => ( - Promise.resolve({ data: { id: 1, ...params.data } }), - }} + dataProvider={ + { + create: (resource, params) => + Promise.resolve({ data: { id: 1, ...params.data } }), + } as any + } > - + diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index 3f9b8165565..f051533fd33 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -178,10 +178,8 @@ export const SelectInput = (props: SelectInputProps) => { formState: { isSubmitted }, } = useInput({ defaultValue: defaultValue ?? (isFromReference ? null : ''), - parse: - parse ?? (isFromReference ? convertEmptyStringToNull : undefined), - format: - format ?? (isFromReference ? convertNullToEmptyString : undefined), + parse: parse ?? convertEmptyStringToNull, + format: format ?? convertNullToEmptyString, onBlur, onChange, resource, @@ -428,4 +426,4 @@ export type SelectInputProps = Omit & }; const convertEmptyStringToNull = value => (value === '' ? null : value); -const convertNullToEmptyString = value => (value === null ? '' : value); +const convertNullToEmptyString = value => (value == null ? '' : value); From 2da082e3e248f52f7f2bb1eb371b6af4b2b627cc Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 11:56:12 +0200 Subject: [PATCH 15/32] NullableBooleanInput --- .../input/NullableBooleanInput.stories.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/ra-ui-materialui/src/input/NullableBooleanInput.stories.tsx diff --git a/packages/ra-ui-materialui/src/input/NullableBooleanInput.stories.tsx b/packages/ra-ui-materialui/src/input/NullableBooleanInput.stories.tsx new file mode 100644 index 00000000000..f5672be58d7 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/NullableBooleanInput.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; + +import { AdminContext } from '../AdminContext'; +import { Create } from '../detail'; +import { SimpleForm } from '../form'; +import { NullableBooleanInput } from './NullableBooleanInput'; +import { FormInspector } from './common.stories'; + +export default { title: 'ra-ui-materialui/input/NullableBooleanInput' }; + +export const Basic = () => ( + + + +); + +export const Disabled = () => ( + + + +); + +const i18nProvider = polyglotI18nProvider(() => englishMessages); + +const Wrapper = ({ children }) => ( + + + + {children} + + + + +); From 0f3fba75f901a5e46646a819a18bea6162a4a152 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 12:04:54 +0200 Subject: [PATCH 16/32] RadioButtonGroupInput --- .../input/RadioButtonGroupInput.stories.tsx | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/ra-ui-materialui/src/input/RadioButtonGroupInput.stories.tsx diff --git a/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.stories.tsx b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.stories.tsx new file mode 100644 index 00000000000..4d7b008af2c --- /dev/null +++ b/packages/ra-ui-materialui/src/input/RadioButtonGroupInput.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import polyglotI18nProvider from 'ra-i18n-polyglot'; +import englishMessages from 'ra-language-english'; + +import { AdminContext } from '../AdminContext'; +import { Create } from '../detail'; +import { SimpleForm } from '../form'; +import { RadioButtonGroupInput } from './RadioButtonGroupInput'; +import { FormInspector } from './common.stories'; + +export default { title: 'ra-ui-materialui/input/RadioButtonGroupInput' }; + +const choices = [ + { id: 'M', name: 'Male' }, + { id: 'F', name: 'Female' }, +]; + +export const Basic = () => ( + + + +); + +const i18nProvider = polyglotI18nProvider(() => englishMessages); + +const Wrapper = ({ children }) => ( + + + + {children} + + + + +); From ebe15755da38ca47c48d39805f71934911600661 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 12:30:34 +0200 Subject: [PATCH 17/32] RichTextInput --- .../src/RichTextInput.stories.tsx | 19 +++++++++++++++++++ .../ra-input-rich-text/src/RichTextInput.tsx | 10 +++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx index b145caba7dd..fac0d9dd082 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.stories.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.stories.tsx @@ -3,9 +3,22 @@ import { I18nProvider, required } from 'ra-core'; import { AdminContext, SimpleForm, SimpleFormProps } from 'ra-ui-materialui'; import { RichTextInput } from './RichTextInput'; import { RichTextInputToolbar } from './RichTextInputToolbar'; +import { useWatch } from 'react-hook-form'; export default { title: 'ra-input-rich-text' }; +const FormInspector = ({ name = 'body' }) => { + const value = useWatch({ name }); + return ( +
+ {name} value in form:  + + {JSON.stringify(value)} ({typeof value}) + +
+ ); +}; + const i18nProvider: I18nProvider = { translate: (key: string, options: any) => options?._ ?? key, changeLocale: () => Promise.resolve(), @@ -20,6 +33,7 @@ export const Basic = (props: Partial) => ( {...props} > +
); @@ -32,6 +46,7 @@ export const Disabled = (props: Partial) => ( {...props} > +
); @@ -48,6 +63,7 @@ export const Small = (props: Partial) => ( label="Body" source="body" /> + ); @@ -64,6 +80,7 @@ export const Large = (props: Partial) => ( label="Body" source="body" /> + ); @@ -81,6 +98,7 @@ export const FullWidth = (props: Partial) => ( source="body" fullWidth /> + ); @@ -89,6 +107,7 @@ export const Validation = (props: Partial) => ( {}} {...props}> + ); diff --git a/packages/ra-input-rich-text/src/RichTextInput.tsx b/packages/ra-input-rich-text/src/RichTextInput.tsx index 2bb0ad92234..9f3476a15f7 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.tsx @@ -84,6 +84,7 @@ export const RichTextInput = (props: RichTextInputProps) => { readOnly = false, source, toolbar, + parse, } = props; const resource = useResourceContext(props); @@ -93,7 +94,12 @@ export const RichTextInput = (props: RichTextInputProps) => { isRequired, fieldState, formState: { isSubmitted }, - } = useInput({ ...props, source, defaultValue }); + } = useInput({ + ...props, + source, + defaultValue, + parse: parse ?? defaultParse, + }); const editor = useEditor({ ...editorOptions, @@ -211,6 +217,8 @@ const RichTextInputContent = ({ ); +const defaultParse = (value: string) => (value === '' ? null : value); + export const DefaultEditorOptions = { extensions: [ StarterKit, From 626cf59e832eb5525c019a5c2a02600efb7ed560 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 15:49:17 +0200 Subject: [PATCH 18/32] move default parse to useInput --- packages/ra-core/src/form/useInput.ts | 6 ++++-- packages/ra-input-rich-text/src/RichTextInput.tsx | 4 ---- packages/ra-ui-materialui/src/input/AutocompleteInput.tsx | 7 ++----- packages/ra-ui-materialui/src/input/DateInput.tsx | 4 +--- packages/ra-ui-materialui/src/input/SelectInput.tsx | 7 ++----- 5 files changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index e461cd83bd6..663669b0c06 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -18,8 +18,10 @@ import { useGetValidationErrorMessage } from './useGetValidationErrorMessage'; import { useFormGroups } from './useFormGroups'; import { useApplyInputDefaultValues } from './useApplyInputDefaultValues'; -// replace null values by empty string to avoid controlled/ uncontrolled input warning +// replace null or undefined values by empty string to avoid controlled/uncontrolled input warning const defaultFormat = (value: any) => (value == null ? '' : value); +// parse empty string into null as it's more suitable for a majority of backends +const defaultParse = (value: string) => (value === '' ? null : value); export const useInput = (props: InputProps): UseInputValue => { const { @@ -30,7 +32,7 @@ export const useInput = (props: InputProps): UseInputValue => { name, onBlur, onChange, - parse, + parse = defaultParse, source, validate, ...options diff --git a/packages/ra-input-rich-text/src/RichTextInput.tsx b/packages/ra-input-rich-text/src/RichTextInput.tsx index 9f3476a15f7..9be0c60fbc7 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.tsx @@ -84,7 +84,6 @@ export const RichTextInput = (props: RichTextInputProps) => { readOnly = false, source, toolbar, - parse, } = props; const resource = useResourceContext(props); @@ -98,7 +97,6 @@ export const RichTextInput = (props: RichTextInputProps) => { ...props, source, defaultValue, - parse: parse ?? defaultParse, }); const editor = useEditor({ @@ -217,8 +215,6 @@ const RichTextInputContent = ({ ); -const defaultParse = (value: string) => (value === '' ? null : value); - export const DefaultEditorOptions = { extensions: [ StarterKit, diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index c4074a5e1dc..cd401600464 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -235,8 +235,8 @@ export const AutocompleteInput = < formState: formStateOverride, onBlur, onChange, - parse: parse ?? convertEmptyStringToNull, - format: format ?? convertNullToEmptyString, + parse, + format, resource, source, validate, @@ -741,6 +741,3 @@ const getSelectedItems = ( }; const DefaultFilterToQuery = searchText => ({ q: searchText }); - -const convertEmptyStringToNull = value => (value === '' ? null : value); -const convertNullToEmptyString = value => (value == null ? '' : value); diff --git a/packages/ra-ui-materialui/src/input/DateInput.tsx b/packages/ra-ui-materialui/src/input/DateInput.tsx index 6010c6c54ad..8d2dd82c8c1 100644 --- a/packages/ra-ui-materialui/src/input/DateInput.tsx +++ b/packages/ra-ui-materialui/src/input/DateInput.tsx @@ -51,7 +51,7 @@ export const DateInput = ({ defaultValue, name, format, - parse: parse ?? defaultParse, + parse, onBlur, onChange, resource, @@ -143,5 +143,3 @@ const getStringFromDate = (value: string | Date) => { return convertDateToString(new Date(value)); }; - -const defaultParse = (value: string) => (value === '' ? null : value); diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index f051533fd33..fb9d62247df 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -178,8 +178,8 @@ export const SelectInput = (props: SelectInputProps) => { formState: { isSubmitted }, } = useInput({ defaultValue: defaultValue ?? (isFromReference ? null : ''), - parse: parse ?? convertEmptyStringToNull, - format: format ?? convertNullToEmptyString, + parse, + format, onBlur, onChange, resource, @@ -424,6 +424,3 @@ export type SelectInputProps = Omit & source?: string; onChange?: (event: ChangeEvent | RaRecord) => void; }; - -const convertEmptyStringToNull = value => (value === '' ? null : value); -const convertNullToEmptyString = value => (value == null ? '' : value); From a66f05c1ddd63832b6a405117593be6af6def619 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 16:08:25 +0200 Subject: [PATCH 19/32] update sanitizeEmptyValues to handle null --- packages/ra-core/src/form/sanitizeEmptyValues.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-core/src/form/sanitizeEmptyValues.ts b/packages/ra-core/src/form/sanitizeEmptyValues.ts index aaa3a5b0753..5ac62ec1068 100644 --- a/packages/ra-core/src/form/sanitizeEmptyValues.ts +++ b/packages/ra-core/src/form/sanitizeEmptyValues.ts @@ -7,7 +7,7 @@ export const sanitizeEmptyValues = (values: any, record: any = {}): any => { const sanitizedValues = {}; Object.keys(values).forEach(key => { - if (values[key] === '') { + if (values[key] == null || values[key] === '') { if (record.hasOwnProperty(key)) { // user has emptied a field, make the value null sanitizedValues[key] = null; From ace29f34dfc6eace25e1aaa4cbd8984074f70bdd Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 16:52:23 +0200 Subject: [PATCH 20/32] [no ci] update doc --- docs/Inputs.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/Inputs.md b/docs/Inputs.md index ea63d478fa7..af6ec7c78bb 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -44,11 +44,11 @@ All input components accept the following props: | `className` | Optional | `string` | - | Class name (usually generated by JSS) to customize the look and feel of the field element itself | | `defaultValue` | Optional | `any` | - | Default value of the input. | | `disabled` | Optional | `boolean` | - | If true, the input is disabled. | -| `format` | Optional | `Function` | - | Callback taking the value from the form state, and returning the input value. | +| `format` | Optional | `Function` | `value => value == null ? '' : value` | Callback taking the value from the form state, and returning the input value. | | `fullWidth` | Optional | `boolean` | `false` | If `true`, the input will expand to fill the form width | | `helperText` | Optional | `string` | - | Text to be displayed under the input | | `label` | Optional | `string` | - | Input label. In i18n apps, the label is passed to the `translate` function. Defaults to the humanized `source` when omitted. Set `label={false}` to hide the label. | -| `parse` | Optional | `Function` | - | Callback taking the input value, and returning the value you want stored in the form state. | +| `parse` | Optional | `Function` | `value => value === '' ? null : value` | Callback taking the input value, and returning the value you want stored in the form state. | | `sx` | Optional | `SxProps` | - | MUI shortcut for defining custom styles | | `validate` | Optional | `Function` | `array` | - | Validation rules for the current property. See the [Validation Documentation](./Validation.md#per-input-validation-built-in-field-validators) for details. | @@ -142,6 +142,12 @@ form state value --> format --> form input value (string) `format` often comes in pair with [`parse`](#parse) to transform the input value before storing it in the form state. See the [Transforming Input Value](#transforming-input-value-tofrom-record) section for more details. +**Tip:** By default, React-admin inputs have to following `format` function, which allows to turn any `null` or `undefined` value into an empty string, to avoid getting warnings about controlled/uncontrolled components: + +```js +const defaultFormat = (value: any) => (value == null ? '' : value); +``` + ## `fullWidth` If `true`, the input will expand to fill the form width. @@ -215,6 +221,12 @@ form input value (string) ---> parse ---> form state value `parse` often comes in pair with [`format`](#format) to transform the form value before passing it to the input. See the [Transforming Input Value](#transforming-input-value-tofrom-record) section for more details. +**Tip:** By default, React-admin inputs have to following `parse` function, which transforms any empty string into `null`, which is a more suitable empty value for most backends: + +```js +const defaultParse = (value: string) => (value === '' ? null : value); +``` + ## `source` Specifies the field of the record that the input should edit. @@ -331,30 +343,13 @@ Mnemonic for the two functions: - `parse()`: input -> record - `format()`: record -> input -A common usage for this feature is to strip empty strings from the record before saving it to the API. As a reminder, HTML form inputs always return strings, even for numbers and booleans. So the empty value for a text input is the empty string, not `null` or `undefined`. Leveraging `parse` allows you to transform the empty string to `null` before saving the record. - -```jsx -import { TextInput } from 'react-admin'; - -const TextInputWithNullEmptyValue = props => ( - v === '' ? null : v} - /> -); - -export default TextInputWithNullEmptyValue; -``` - -**Tip**: If you need to do that for every input, use [the `sanitizeEmptyValues` prop of the `
` component](./Form.md#sanitizeemptyvalues) instead. - -Let's look at another usage example. Say the user would like to input values of 0-100 to a percentage field but your API (hence record) expects 0-1.0. You can use simple `parse()` and `format()` functions to archive the transform: +Let's look at a simple example. Say the user would like to input values of 0-100 to a percentage field but your API (hence record) expects 0-1.0. You can use simple `parse()` and `format()` functions to archive the transform: ```jsx v * 100} parse={v => parseFloat(v) / 100} label="Formatted number" /> ``` -`` stores and returns a string. If you would like to store a JavaScript Date object in your record instead: +Another classical use-case is with handling dates. `` stores and returns a string. If you would like to store a JavaScript Date object in your record instead, you can do something like this: ```jsx const dateFormatRegex = /^\d{4}-\d{2}-\d{2}$/; @@ -393,6 +388,11 @@ const dateParser = value => { ``` +**Tip:** A common usage for this feature was to strip empty strings from the record before saving it to the API. Indeed HTML form inputs always return strings, even for numbers and booleans, however most backends expect a value like `null`. This is why, by default, all React-admin inputs will be parsed to `null` when the HTML input value is `''`. + +**Tip**: If you need React-admin to completely remove all empty values (that did not change) from the record upon submission, have a look at [the `sanitizeEmptyValues` prop of the `` component](./Form.md#sanitizeemptyvalues). + + ## Linking Two Inputs Edition forms often contain linked inputs, e.g. country and city (the choices of the latter depending on the value of the former). From 6aa5c03e43479f46b7f0e4d441b9a6ebadd273fa Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 17:16:39 +0200 Subject: [PATCH 21/32] add test about default parse in useInput --- packages/ra-core/src/form/useInput.spec.tsx | 38 +++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/form/useInput.spec.tsx b/packages/ra-core/src/form/useInput.spec.tsx index d99ad5e9996..794a01c9fee 100644 --- a/packages/ra-core/src/form/useInput.spec.tsx +++ b/packages/ra-core/src/form/useInput.spec.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { FunctionComponent, ReactElement, useEffect } from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { useFormContext } from 'react-hook-form'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useFormContext, useWatch } from 'react-hook-form'; import { CoreAdminContext } from '../core'; import { testDataProvider } from '../dataProvider'; import { Form } from './Form'; @@ -356,5 +356,39 @@ describe('useInput', () => { ); expect(screen.getByDisplayValue('1000')).not.toBeNull(); }); + + test('should parse empty strings to null by default', async () => { + const onSubmit = jest.fn(); + render( + + + { + useEffect(() => { + field.onChange(''); + }, [field]); + const value = useWatch({ name: 'test' }); + + return ( + <> + +
+ 'test' value in form:  + + {JSON.stringify(value)} ( + {typeof value}) + +
+ + ); + }} + /> + +
+ ); + await screen.findByText('null (object)'); + }); }); }); From 4af3f8fdd352c18c09fe0d24ece451b895935852 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 17:18:24 +0200 Subject: [PATCH 22/32] rollback useless change --- packages/ra-input-rich-text/src/RichTextInput.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/ra-input-rich-text/src/RichTextInput.tsx b/packages/ra-input-rich-text/src/RichTextInput.tsx index 9be0c60fbc7..2bb0ad92234 100644 --- a/packages/ra-input-rich-text/src/RichTextInput.tsx +++ b/packages/ra-input-rich-text/src/RichTextInput.tsx @@ -93,11 +93,7 @@ export const RichTextInput = (props: RichTextInputProps) => { isRequired, fieldState, formState: { isSubmitted }, - } = useInput({ - ...props, - source, - defaultValue, - }); + } = useInput({ ...props, source, defaultValue }); const editor = useEditor({ ...editorOptions, From a1b89bc32b9637d223ee1bccbf639aecc9c5a18f Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Fri, 14 Oct 2022 17:22:02 +0200 Subject: [PATCH 23/32] remove unused parse in textinput --- packages/ra-ui-materialui/src/input/TextInput.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/TextInput.tsx b/packages/ra-ui-materialui/src/input/TextInput.tsx index 1d5d5b5bcc7..1fd28467c64 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.tsx @@ -49,7 +49,7 @@ export const TextInput = (props: TextInputProps) => { } = useInput({ defaultValue, format, - parse: parse ?? defaultParse, + parse, resource, source, type: 'text', @@ -103,7 +103,5 @@ TextInput.defaultProps = { options: {}, }; -const defaultParse = (value: string) => (value === '' ? null : value); - export type TextInputProps = CommonInputProps & Omit; From a418c556b926621ae4f794d296397a238e7fcc4a Mon Sep 17 00:00:00 2001 From: Francois Zaninotto Date: Fri, 14 Oct 2022 22:34:35 +0200 Subject: [PATCH 24/32] Update Parse and format doc --- docs/Inputs.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/Inputs.md b/docs/Inputs.md index af6ec7c78bb..ddc982f0289 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -142,10 +142,10 @@ form state value --> format --> form input value (string) `format` often comes in pair with [`parse`](#parse) to transform the input value before storing it in the form state. See the [Transforming Input Value](#transforming-input-value-tofrom-record) section for more details. -**Tip:** By default, React-admin inputs have to following `format` function, which allows to turn any `null` or `undefined` value into an empty string, to avoid getting warnings about controlled/uncontrolled components: +**Tip:** By default, react-admin inputs have to following `format` function, which turns any `null` or `undefined` value into an empty string. This is to avoid warnings about controlled/uncontrolled input components: ```js -const defaultFormat = (value: any) => (value == null ? '' : value); +const defaultFormat = (value: any) => value == null ? '' : value; ``` ## `fullWidth` @@ -221,10 +221,10 @@ form input value (string) ---> parse ---> form state value `parse` often comes in pair with [`format`](#format) to transform the form value before passing it to the input. See the [Transforming Input Value](#transforming-input-value-tofrom-record) section for more details. -**Tip:** By default, React-admin inputs have to following `parse` function, which transforms any empty string into `null`, which is a more suitable empty value for most backends: +**Tip:** By default, react-admin inputs have to following `parse` function, which transforms any empty string into `null`: ```js -const defaultParse = (value: string) => (value === '' ? null : value); +const defaultParse = (value: string) => value === '' ? null : value; ``` ## `source` From 748cd238524da8a1fedca16a2e404880eda69872 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Oct 2022 22:53:02 +0200 Subject: [PATCH 25/32] Fix input doc tranform tutorial --- docs/Inputs.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/Inputs.md b/docs/Inputs.md index ddc982f0289..235e20d21c9 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -388,11 +388,10 @@ const dateParser = value => { ``` -**Tip:** A common usage for this feature was to strip empty strings from the record before saving it to the API. Indeed HTML form inputs always return strings, even for numbers and booleans, however most backends expect a value like `null`. This is why, by default, all React-admin inputs will be parsed to `null` when the HTML input value is `''`. +**Tip:** A common usage for this feature is to deal with empty values. Indeed HTML form inputs always return strings, even for numbers and booleans, however most backends expect a value like `null`. This is why, by default, all react-admin inputs will store the value `null` when the HTML input value is `''`. **Tip**: If you need React-admin to completely remove all empty values (that did not change) from the record upon submission, have a look at [the `sanitizeEmptyValues` prop of the `
` component](./Form.md#sanitizeemptyvalues). - ## Linking Two Inputs Edition forms often contain linked inputs, e.g. country and city (the choices of the latter depending on the value of the former). From 9d163d342f9c220309768918847b54d8b0499755 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Oct 2022 22:58:50 +0200 Subject: [PATCH 26/32] rewrite sanitizeEmptyValues doc --- docs/Form.md | 4 +++- docs/SimpleForm.md | 6 ++++-- docs/TabbedForm.md | 6 ++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/Form.md b/docs/Form.md index 1b5fd9577e0..15d4efed07d 100644 --- a/docs/Form.md +++ b/docs/Form.md @@ -136,7 +136,9 @@ export const PostCreate = () => { ## `sanitizeEmptyValues` -In HTML, the value of empty form inputs is the empty string (`''`) by default. React-hook-form doesn't sanitize these values. This leads to unexpected `create` and `update` payloads like: +In HTML, the value of empty form inputs is the empty string (`''`). React-admin inputs (like ``, ``, etc.) automatically transform these empty values into `null`. + +But for your own input components based on react-hook-form, this is not the default. React-hook-form doesn't transform empty values by default. This leads to unexpected `create` and `update` payloads like: ```jsx { diff --git a/docs/SimpleForm.md b/docs/SimpleForm.md index bd70c7e92ea..0ce66ea9c99 100644 --- a/docs/SimpleForm.md +++ b/docs/SimpleForm.md @@ -144,7 +144,9 @@ export const PostCreate = () => { ## `sanitizeEmptyValues` -As a reminder, HTML form inputs always return strings, even for numbers and booleans. So the empty value for a text input is the empty string, not `null` or `undefined`. This means that the data sent to the form handler will contain empty strings: +In HTML, the value of empty form inputs is the empty string (`''`). React-admin inputs (like ``, ``, etc.) automatically transform these empty values into `null`. + +But for your own input components based on react-hook-form, this is not the default. React-hook-form doesn't transform empty values by default. This leads to unexpected `create` and `update` payloads like: ```jsx { @@ -156,7 +158,7 @@ As a reminder, HTML form inputs always return strings, even for numbers and bool } ``` -React-hook-form doesn't sanitize these values. If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition. +If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition. ```jsx const PostCreate = () => ( diff --git a/docs/TabbedForm.md b/docs/TabbedForm.md index c38dfd8922f..3443569563f 100644 --- a/docs/TabbedForm.md +++ b/docs/TabbedForm.md @@ -192,7 +192,9 @@ export const PostCreate = () => ( ## `sanitizeEmptyValues` -As a reminder, HTML form inputs always return strings, even for numbers and booleans. So the empty value for a text input is the empty string, not `null` or `undefined`. This means that the data sent to the form handler will contain empty strings: +In HTML, the value of empty form inputs is the empty string (`''`). React-admin inputs (like ``, ``, etc.) automatically transform these empty values into `null`. + +But for your own input components based on react-hook-form, this is not the default. React-hook-form doesn't transform empty values by default. This leads to unexpected `create` and `update` payloads like: ```jsx { @@ -204,7 +206,7 @@ As a reminder, HTML form inputs always return strings, even for numbers and bool } ``` -React-hook-form doesn't sanitize these values. If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition. +If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition. ```jsx const PostCreate = () => ( From d1ff5f3a8d6b30f4d196fb195862a3480446bb3c Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 14 Oct 2022 23:04:55 +0200 Subject: [PATCH 27/32] Remove useless defaultValue in SelectInput and AutocompleteInput --- packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx | 2 +- packages/ra-ui-materialui/src/input/AutocompleteInput.tsx | 2 +- packages/ra-ui-materialui/src/input/SelectInput.spec.tsx | 2 +- packages/ra-ui-materialui/src/input/SelectInput.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index 69793b681e5..e2a978ab1e8 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -1258,7 +1258,7 @@ describe('', () => { screen.getByText('Save').click(); await waitFor(() => { expect(onSuccess).toHaveBeenCalledWith( - expect.objectContaining({ author: null }), + expect.objectContaining({ author: undefined }), expect.anything(), expect.anything() ); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index cd401600464..44204138283 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -228,7 +228,7 @@ export const AutocompleteInput = < fieldState: { error, invalid, isTouched }, formState: { isSubmitted }, } = useInput({ - defaultValue: defaultValue ?? (isFromReference ? null : ''), + defaultValue, id: idOverride, field: fieldOverride, fieldState: fieldStateOverride, diff --git a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx index 080030df1e0..0f027b5d4de 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.spec.tsx @@ -754,7 +754,7 @@ describe('', () => { screen.getByText('Save').click(); await waitFor(() => { expect(onSuccess).toHaveBeenCalledWith( - expect.objectContaining({ author: null }), + expect.objectContaining({ author: undefined }), expect.anything(), expect.anything() ); diff --git a/packages/ra-ui-materialui/src/input/SelectInput.tsx b/packages/ra-ui-materialui/src/input/SelectInput.tsx index fb9d62247df..96d313b2544 100644 --- a/packages/ra-ui-materialui/src/input/SelectInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectInput.tsx @@ -177,7 +177,7 @@ export const SelectInput = (props: SelectInputProps) => { isRequired, formState: { isSubmitted }, } = useInput({ - defaultValue: defaultValue ?? (isFromReference ? null : ''), + defaultValue, parse, format, onBlur, From c1010cfe16abf70e996fbf4ae1bc21c4793f4285 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Mon, 17 Oct 2022 11:02:17 +0200 Subject: [PATCH 28/32] fix Inputs.md regarding sanitizeEmptyValues --- docs/Inputs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Inputs.md b/docs/Inputs.md index 235e20d21c9..34b2d41be29 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -390,7 +390,7 @@ const dateParser = value => { **Tip:** A common usage for this feature is to deal with empty values. Indeed HTML form inputs always return strings, even for numbers and booleans, however most backends expect a value like `null`. This is why, by default, all react-admin inputs will store the value `null` when the HTML input value is `''`. -**Tip**: If you need React-admin to completely remove all empty values (that did not change) from the record upon submission, have a look at [the `sanitizeEmptyValues` prop of the `` component](./Form.md#sanitizeemptyvalues). +**Tip**: If you need to do this globally, including for custom input components that do not use [the `useInput` hook](#the-useinput-hook), have a look at [the `sanitizeEmptyValues` prop of the `` component](./Form.md#sanitizeemptyvalues). ## Linking Two Inputs From e3b5d68e40dc3fdcec986037a0416a4ce9cf1655 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Mon, 17 Oct 2022 11:02:30 +0200 Subject: [PATCH 29/32] update sanitizeEmptyValues story and test --- packages/ra-core/src/form/Form.spec.tsx | 14 +++++- packages/ra-core/src/form/Form.stories.tsx | 54 +++++++++++++++++++++- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/packages/ra-core/src/form/Form.spec.tsx b/packages/ra-core/src/form/Form.spec.tsx index 268868aa49b..9c63b55dafc 100644 --- a/packages/ra-core/src/form/Form.spec.tsx +++ b/packages/ra-core/src/form/Form.spec.tsx @@ -573,10 +573,22 @@ describe('Form', () => { fireEvent.change(screen.getByLabelText('field4'), { target: { value: '' }, }); + fireEvent.change(screen.getByLabelText('field11'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('field12'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('field14'), { + target: { value: 'hello' }, + }); + fireEvent.change(screen.getByLabelText('field14'), { + target: { value: '' }, + }); fireEvent.click(screen.getByText('Submit')); await waitFor(() => expect(screen.getByTestId('result')?.textContent).toEqual( - '{\n "id": 1,\n "field1": null,\n "field6": null\n}' + '{\n "id": 1,\n "field1": null,\n "field6": null,\n "field11": null,\n "field16": null\n}' ) ); }); diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index b4f72d1d78e..e2da4a3915d 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -1,5 +1,9 @@ import * as React from 'react'; -import { useFormState } from 'react-hook-form'; +import { + useController, + UseControllerProps, + useFormState, +} from 'react-hook-form'; import { CoreAdminContext } from '../core'; import { Form } from './Form'; @@ -64,13 +68,54 @@ export const Basic = () => { ); }; +const CustomInput = (props: UseControllerProps) => { + const { field, fieldState } = useController(props); + return ( +
+ + +

{fieldState.error?.message}

+
+ ); +}; + export const SanitizeEmptyValues = () => { const [submittedData, setSubmittedData] = React.useState(); + const field11 = { name: 'field11' }; + const field12 = { + name: 'field12', + defaultValue: 'bar', + }; + const field13 = { + name: 'field13', + defaultValue: '', + }; + const field14 = { name: 'field14' }; + const field16 = { name: 'field16' }; return ( setSubmittedData(data)} - record={{ id: 1, field1: 'bar', field6: null }} + record={{ + id: 1, + field1: 'bar', + field6: null, + field11: 'bar', + field16: null, + }} sanitizeEmptyValues > @@ -79,6 +124,11 @@ export const SanitizeEmptyValues = () => { v || undefined} /> + + + + + From 8c82a02453ff1d04faa01513477c1362eff6c014 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Mon, 17 Oct 2022 11:19:10 +0200 Subject: [PATCH 30/32] update sanitizeEmptyValues docs --- docs/Form.md | 23 ++++++++++++++++++----- docs/SimpleForm.md | 2 ++ docs/TabbedForm.md | 2 ++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/Form.md b/docs/Form.md index 15d4efed07d..e5d6231be9a 100644 --- a/docs/Form.md +++ b/docs/Form.md @@ -142,13 +142,15 @@ But for your own input components based on react-hook-form, this is not the defa ```jsx { - id: 123, - title: '', - author: '', -} + id: 1234, + title: 'Lorem Ipsum', + is_published: '', + body: '', + // etc. +} ``` -To avoid that, set the `sanitizeEmptyValues` prop to `true`. This will remove empty strings from the form state on submit, unless the record actually had a value for that field. +If you prefer to omit the keys for empty values, set the `sanitizeEmptyValues` prop to `true`. This will sanitize the form data before passing it to the `dataProvider`, i.e. remove empty strings from the form state, unless the record actually had a value for that field before edition. ```jsx const PostCreate = () => ( @@ -160,6 +162,17 @@ const PostCreate = () => ( ); ``` +For the previous example, the data sent to the `dataProvider` will be: + +```jsx +{ + id: 1234, + title: 'Lorem Ipsum', +} +``` + +**Note:** Setting the `sanitizeEmptyValues` prop to `true` will also have a (minor) impact on react-admin inputs (like ``, ``, etc.): empty values (i.e. values equal to `null`) will be removed from the form state on submit, unless the record actually had a value for that field. + If you need a more fine-grained control over the sanitization, you can use [the `transform` prop](./Edit.md#transform) of `` or `` components, or [the `parse` prop](./Inputs.md#parse) of individual inputs. ## `validate` diff --git a/docs/SimpleForm.md b/docs/SimpleForm.md index 0ce66ea9c99..1f4d4cd2cb1 100644 --- a/docs/SimpleForm.md +++ b/docs/SimpleForm.md @@ -179,6 +179,8 @@ For the previous example, the data sent to the `dataProvider` will be: } ``` +**Note:** Setting the `sanitizeEmptyValues` prop to `true` will also have a (minor) impact on react-admin inputs (like ``, ``, etc.): empty values (i.e. values equal to `null`) will be removed from the form state on submit, unless the record actually had a value for that field. + If you need a more fine-grained control over the sanitization, you can use [the `transform` prop](./Edit.md#transform) of `` or `` components, or [the `parse` prop](./Inputs.md#parse) of individual inputs. ## `sx`: CSS API diff --git a/docs/TabbedForm.md b/docs/TabbedForm.md index 3443569563f..4fa57dd97b6 100644 --- a/docs/TabbedForm.md +++ b/docs/TabbedForm.md @@ -227,6 +227,8 @@ For the previous example, the data sent to the `dataProvider` will be: } ``` +**Note:** Setting the `sanitizeEmptyValues` prop to `true` will also have a (minor) impact on react-admin inputs (like ``, ``, etc.): empty values (i.e. values equal to `null`) will be removed from the form state on submit, unless the record actually had a value for that field. + If you need a more fine-grained control over the sanitization, you can use [the `transform` prop](./Edit.md#transform) of `` or `` components, or [the `parse` prop](./Inputs.md#parse) of individual inputs. ## `syncWithLocation` From 413cd6b7ffe6a971cc7ac4bea9fc7b7c3ac41152 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Mon, 17 Oct 2022 11:31:24 +0200 Subject: [PATCH 31/32] document uncontrolled warning in custom inputs --- docs/Inputs.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/Inputs.md b/docs/Inputs.md index 34b2d41be29..a06e3701081 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -602,8 +602,8 @@ For instance, let's write a component to edit the latitude and longitude of the import { useController } from 'react-hook-form'; const LatLngInput = () => { - const input1 = useController({ name: 'lat' }); - const input2 = useController({ name: 'lng' }); + const input1 = useController({ name: 'lat', defaultValue: '' }); + const input2 = useController({ name: 'lng', defaultValue: '' }); return ( @@ -634,14 +634,16 @@ const ItemEdit = () => ( ``` +**Tip**: Notice that we have added `defaultValue: ''` as one of the `useController` params. This is a good practice to avoid getting console warnings about controlled/uncontrolled components, that may arise if the value of `record.lat` or `record.lng` is `undefined` or `null`. + **Tip**: React-hook-form's `useController` component supports dot notation in the `name` prop, to allow binding to nested values: ```jsx import { useController } from 'react-hook-form'; const LatLngInput = () => { - const input1 = useController({ name: 'position.lat' }); - const input2 = useController({ name: 'position.lng' }); + const input1 = useController({ name: 'position.lat', defaultValue: '' }); + const input2 = useController({ name: 'position.lng', defaultValue: '' }); return ( @@ -664,8 +666,8 @@ import { useController } from 'react-hook-form'; import { Labeled } from 'react-admin'; const LatLngInput = () => { - const input1 = useController({ name: 'lat' }); - const input2 = useController({ name: 'lng' }); + const input1 = useController({ name: 'lat', defaultValue: '' }); + const input2 = useController({ name: 'lng', defaultValue: '' }); return ( @@ -704,7 +706,7 @@ const BoundedTextField = ({ name, label }) => { field, fieldState: { isTouched, invalid, error }, formState: { isSubmitted } - } = useController(name); + } = useController(name, defaultValue: ''); return ( ( **Tip**: MUI's `` component already includes a label, so you don't need to use `` in this case. +**Tip**: Notice that we have added `defaultValue: ''` as one of the `useController` params. This is a good practice to avoid getting console warnings about controlled/uncontrolled components, that may arise if the value of `record.lat` or `record.lng` is `undefined` or `null`. + `useController()` returns three values: `field`, `fieldState`, and `formState`. To learn more about these props, please refer to the [useController](https://react-hook-form.com/api/usecontroller) hook documentation. Instead of HTML `input` elements or MUI components, you can use react-admin input components, like `` for instance. React-admin components already use `useController()`, and already include a label, so you don't need either `useController()` or `` when using them: From ea943fea69eb39afe754a8ab98ed0aa2e6d4458d Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Kaiser Date: Mon, 17 Oct 2022 11:32:55 +0200 Subject: [PATCH 32/32] fix linter warning --- packages/ra-core/src/form/useInput.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-core/src/form/useInput.spec.tsx b/packages/ra-core/src/form/useInput.spec.tsx index 794a01c9fee..a989b5bb5e9 100644 --- a/packages/ra-core/src/form/useInput.spec.tsx +++ b/packages/ra-core/src/form/useInput.spec.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { FunctionComponent, ReactElement, useEffect } from 'react'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { useFormContext, useWatch } from 'react-hook-form'; import { CoreAdminContext } from '../core'; import { testDataProvider } from '../dataProvider';