From 6658db7543c5d47e8c24353a6e91b036010e5592 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 3 Aug 2022 19:27:07 +0200 Subject: [PATCH 1/5] Fix NumberInput state only changes on blur Closes #7919 --- .../src/input/NumberInput.spec.tsx | 27 +++++++--- .../src/input/NumberInput.stories.tsx | 54 ++++++++++++++++++- .../src/input/NumberInput.tsx | 38 +++---------- 3 files changed, 78 insertions(+), 41 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx index ba8ba87ed7a..8e6f9d34b15 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx @@ -134,7 +134,7 @@ describe('', () => { 'views:{"invalid":false,"isDirty":false,"isTouched":false}' ); }); - it('should return correct state when the field is touched', () => { + it('should return correct state when the field is dirty', () => { render( @@ -147,9 +147,26 @@ describe('', () => { 'resources.posts.fields.views' ) as HTMLInputElement; fireEvent.change(input, { target: { value: '3' } }); + screen.getByText( + 'views:{"invalid":false,"isDirty":true,"isTouched":false}' + ); + }); + it('should return correct state when the field is touched', () => { + render( + + + + + + + ); + const input = screen.getByLabelText( + 'resources.posts.fields.views' + ) as HTMLInputElement; + fireEvent.click(input); fireEvent.blur(input); screen.getByText( - 'views:{"invalid":false,"isDirty":true,"isTouched":true}' + 'views:{"invalid":false,"isDirty":false,"isTouched":true}' ); }); it('should return correct state when the field is invalid', async () => { @@ -229,7 +246,6 @@ describe('', () => { ); const input = screen.getByLabelText('resources.posts.fields.views'); fireEvent.change(input, { target: { value: '3' } }); - fireEvent.blur(input); fireEvent.click(screen.getByText('ra.action.save')); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ views: 3 }); @@ -252,7 +268,6 @@ describe('', () => { ); const input = screen.getByLabelText('resources.posts.fields.views'); fireEvent.change(input, { target: { value: '' } }); - fireEvent.blur(input); fireEvent.click(screen.getByText('ra.action.save')); await waitFor(() => { expect(onSubmit).toHaveBeenCalledWith({ views: null }); @@ -280,7 +295,6 @@ describe('', () => { ); const input = screen.getByLabelText('resources.posts.fields.views'); fireEvent.change(input, { target: { value: '3' } }); - fireEvent.blur(input); await waitFor(() => { expect(value).toEqual('3'); }); @@ -304,7 +318,6 @@ describe('', () => { ); const input = screen.getByLabelText('resources.posts.fields.views'); fireEvent.change(input, { target: { value: '3' } }); - fireEvent.blur(input); expect(value).toEqual('3'); fireEvent.click(screen.getByText('ra.action.save')); await waitFor(() => { @@ -391,7 +404,6 @@ describe('', () => { ); const input = screen.getByLabelText('resources.posts.fields.views'); fireEvent.change(input, { target: { value: '3' } }); - fireEvent.blur(input); fireEvent.click(screen.getByText('ra.action.save')); @@ -416,7 +428,6 @@ describe('', () => { ); const input = screen.getByLabelText('resources.posts.fields.views'); fireEvent.change(input, { target: { value: '3' } }); - fireEvent.blur(input); fireEvent.click(screen.getByText('ra.action.save')); diff --git a/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx b/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx index 274ce394acf..21060f84f3e 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { required } from 'ra-core'; -import { useWatch } from 'react-hook-form'; +import { useFormState, useWatch, useFormContext } from 'react-hook-form'; import { NumberInput } from './NumberInput'; import { AdminContext } from '../AdminContext'; @@ -270,3 +270,55 @@ export const Sx = () => ( ); + +const FormStateInspector = () => { + const { + touchedFields, + isDirty, + dirtyFields, + isValid, + errors, + } = useFormState(); + return ( +
+ form state:  + + {JSON.stringify({ + touchedFields, + isDirty, + dirtyFields, + isValid, + errors, + })} + +
+ ); +}; + +const FieldStateInspector = ({ name = 'views' }) => { + const formContext = useFormContext(); + return ( +
+ {name}: + + {JSON.stringify(formContext.getFieldState(name))} + +
+ ); +}; + +export const FieldState = () => ( + + + + + + + + + +); diff --git a/packages/ra-ui-materialui/src/input/NumberInput.tsx b/packages/ra-ui-materialui/src/input/NumberInput.tsx index 8a5a9bb02ad..3e6a24f980a 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.tsx @@ -11,9 +11,6 @@ import { sanitizeInputRestProps } from './sanitizeInputRestProps'; /** * An Input component for a number * - * Due to limitations in React controlled components and number formatting, - * this input only updates the form value on blur. - * * @example * * @@ -29,7 +26,6 @@ export const NumberInput = ({ helperText, label, margin, - onBlur, onChange, parse, resource, @@ -58,8 +54,9 @@ export const NumberInput = ({ const inputProps = { ...overrideInputProps, step, min, max }; - // This is a controlled input that doesn't transform the user input on change. - // The user input is only turned into a number on blur. + // This is a controlled input that renders directly the string typed by the user. + // This string is converted to a number on change, and stored in the form state, + // but that number is not not displayed. // This is to allow transitory values like '1.0' that will lead to '1.02' // text typed by the user and displayed in the input, unparsed @@ -82,22 +79,8 @@ export const NumberInput = ({ ) { return; } - setValue(event.target.value); - }; - - // set the numeric value on the form on blur - const handleBlur = (...event: any[]) => { - if (onBlur) { - onBlur(...event); - } - const eventParam = event[0] as React.FocusEvent; - if ( - typeof eventParam.target === 'undefined' || - typeof eventParam.target.value === 'undefined' - ) { - return; - } - const target = eventParam.target; + const target = event.target; + setValue(target.value); const newValue = target.valueAsNumber ? parse ? parse(target.valueAsNumber) @@ -106,24 +89,15 @@ export const NumberInput = ({ ? parse(target.value) : convertStringToNumber(target.value); field.onChange(newValue); - field.onBlur(); - }; - - const handleKeyUp = (event: React.KeyboardEvent) => { - if (event.key === 'Enter') { - handleBlur(event); - } }; return ( Date: Wed, 3 Aug 2022 20:05:04 +0200 Subject: [PATCH 2/5] Add TextInput stories --- .../src/input/TextInput.stories.tsx | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 packages/ra-ui-materialui/src/input/TextInput.stories.tsx diff --git a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx new file mode 100644 index 00000000000..3d120df9ad6 --- /dev/null +++ b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx @@ -0,0 +1,262 @@ +import * as React from 'react'; +import { required } from 'ra-core'; +import { useFormState, useWatch, useFormContext } from 'react-hook-form'; + +import { TextInput } from './TextInput'; +import { AdminContext } from '../AdminContext'; +import { Create } from '../detail'; +import { SimpleForm } from '../form'; + +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 = () => ( + + + + + + + + +); + +export const DefaultValue = () => ( + + + + + + + + + + + + + + +); + +export const HelperText = () => ( + + + + + + + + + +); + +export const Label = () => ( + + + + + + + + + +); + +export const FullWidth = () => ( + + + + + + + + +); + +export const Margin = () => ( + + + + + + + + + +); + +export const Variant = () => ( + + + + + + + + + +); + +export const Required = () => ( + + + + + + + + + + +); + +export const Error = () => ( + + + ({ + values: {}, + errors: { + title: { + type: 'custom', + message: 'Special error message', + }, + }, + })} + > + + + + +); + +export const Sx = () => ( + + + + + + + +); + +const FormStateInspector = () => { + const { + touchedFields, + isDirty, + dirtyFields, + isValid, + errors, + } = useFormState(); + return ( +
+ form state:  + + {JSON.stringify({ + touchedFields, + isDirty, + dirtyFields, + isValid, + errors, + })} + +
+ ); +}; + +const FieldStateInspector = ({ name = 'title' }) => { + const formContext = useFormContext(); + return ( +
+ {name}: + + {JSON.stringify(formContext.getFieldState(name))} + +
+ ); +}; + +export const FieldState = () => ( + + + + + + + + + +); From 0dc29620f07c8524e6d630bb4cf2ae9bd0a9fdec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Wed, 3 Aug 2022 22:29:47 +0200 Subject: [PATCH 3/5] Fix test --- .../src/input/NumberInput.spec.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx index 8e6f9d34b15..12aaf904e5d 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx @@ -1,11 +1,11 @@ import * as React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { useFormContext, useWatch, useFormState } from 'react-hook-form'; import { NumberInput } from './NumberInput'; import { AdminContext } from '../AdminContext'; import { SaveButton } from '../button'; import { SimpleForm, Toolbar } from '../form'; -import { useFormContext, useWatch } from 'react-hook-form'; describe('', () => { const defaultProps = { @@ -152,22 +152,26 @@ describe('', () => { ); }); it('should return correct state when the field is touched', () => { + // FIXME: cannot use FieldState for isTouched due to react-hook-form bug https://github.com/react-hook-form/react-hook-form/issues/8786 + const FieldStateForTouched = () => { + const { touchedFields } = useFormState(); + return touchedFields.views ? <>Touched : <>Untouched; + }; render( - + ); + screen.getByText('Untouched'); const input = screen.getByLabelText( 'resources.posts.fields.views' ) as HTMLInputElement; fireEvent.click(input); fireEvent.blur(input); - screen.getByText( - 'views:{"invalid":false,"isDirty":false,"isTouched":true}' - ); + screen.getByText('Touched'); }); it('should return correct state when the field is invalid', async () => { render( From af1a7fad578b42e78e6244be8bb1a4768c53aef6 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 4 Aug 2022 00:23:08 +0200 Subject: [PATCH 4/5] use getFieldState the correct way --- .../src/input/NumberInput.spec.tsx | 17 ++++++++--------- .../src/input/NumberInput.stories.tsx | 4 +++- .../src/input/TextInput.stories.tsx | 4 +++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx index 12aaf904e5d..fd2821c7b48 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx @@ -117,7 +117,10 @@ describe('', () => { const formContext = useFormContext(); return ( - {name}:{JSON.stringify(formContext.getFieldState(name))} + {name}: + {JSON.stringify( + formContext.getFieldState(name, formContext.formState) + )} ); }; @@ -152,26 +155,22 @@ describe('', () => { ); }); it('should return correct state when the field is touched', () => { - // FIXME: cannot use FieldState for isTouched due to react-hook-form bug https://github.com/react-hook-form/react-hook-form/issues/8786 - const FieldStateForTouched = () => { - const { touchedFields } = useFormState(); - return touchedFields.views ? <>Touched : <>Untouched; - }; render( - + ); - screen.getByText('Untouched'); const input = screen.getByLabelText( 'resources.posts.fields.views' ) as HTMLInputElement; fireEvent.click(input); fireEvent.blur(input); - screen.getByText('Touched'); + screen.getByText( + 'views:{"invalid":false,"isDirty":false,"isTouched":true}' + ); }); it('should return correct state when the field is invalid', async () => { render( diff --git a/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx b/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx index 21060f84f3e..29509b8723e 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.stories.tsx @@ -301,7 +301,9 @@ const FieldStateInspector = ({ name = 'views' }) => {
{name}: - {JSON.stringify(formContext.getFieldState(name))} + {JSON.stringify( + formContext.getFieldState(name, formContext.formState) + )}
); diff --git a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx index 3d120df9ad6..4a0041e8b5f 100644 --- a/packages/ra-ui-materialui/src/input/TextInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/TextInput.stories.tsx @@ -239,7 +239,9 @@ const FieldStateInspector = ({ name = 'title' }) => {
{name}: - {JSON.stringify(formContext.getFieldState(name))} + {JSON.stringify( + formContext.getFieldState(name, formContext.formState) + )}
); From a953277d7217246b8417d79dd97be17bd6218839 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 4 Aug 2022 09:22:59 +0200 Subject: [PATCH 5/5] Fix eslint warning --- packages/ra-ui-materialui/src/input/NumberInput.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx index fd2821c7b48..891f76550c8 100644 --- a/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/NumberInput.spec.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { useFormContext, useWatch, useFormState } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; import { NumberInput } from './NumberInput'; import { AdminContext } from '../AdminContext';