Skip to content

Commit 4f5f865

Browse files
authored
Merge pull request #10335 from marmelab/fix-date-inputs-external-changes
Fix `<DateInput>` and `<DateTimeInput>` do not react to form changes
2 parents dac5101 + a70b0df commit 4f5f865

File tree

6 files changed

+326
-81
lines changed

6 files changed

+326
-81
lines changed

packages/ra-ui-materialui/src/input/DateInput.spec.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import { useFormState } from 'react-hook-form';
88
import { AdminContext } from '../AdminContext';
99
import { SimpleForm } from '../form';
1010
import { DateInput } from './DateInput';
11-
import { Basic, Parse } from './DateInput.stories';
11+
import {
12+
Basic,
13+
ExternalChanges,
14+
ExternalChangesWithParse,
15+
Parse,
16+
} from './DateInput.stories';
1217

1318
describe('<DateInput />', () => {
1419
const defaultProps = {
@@ -246,6 +251,51 @@ describe('<DateInput />', () => {
246251
});
247252
});
248253

254+
it('should change its value when the form value has changed', async () => {
255+
render(<ExternalChanges />);
256+
await screen.findByText('"2021-09-11" (string)');
257+
const input = screen.getByLabelText('Published at') as HTMLInputElement;
258+
fireEvent.change(input, {
259+
target: { value: '2021-10-30' },
260+
});
261+
fireEvent.blur(input);
262+
await screen.findByText('"2021-10-30" (string)');
263+
fireEvent.click(screen.getByText('Change value'));
264+
await screen.findByText('"2021-10-20" (string)');
265+
});
266+
267+
it('should change its value when the form value has changed with a custom parse', async () => {
268+
render(<ExternalChangesWithParse />);
269+
await screen.findByText(
270+
'Sat Sep 11 2021 02:00:00 GMT+0200 (Central European Summer Time)'
271+
);
272+
const input = screen.getByLabelText('Published at') as HTMLInputElement;
273+
fireEvent.change(input, {
274+
target: { value: '2021-10-30' },
275+
});
276+
fireEvent.blur(input);
277+
await screen.findByText(
278+
'Sat Oct 30 2021 02:00:00 GMT+0200 (Central European Summer Time)'
279+
);
280+
fireEvent.click(screen.getByText('Change value'));
281+
await screen.findByText(
282+
'Wed Oct 20 2021 02:00:00 GMT+0200 (Central European Summer Time)'
283+
);
284+
});
285+
286+
it('should change its value when the form value is reset', async () => {
287+
render(<ExternalChanges />);
288+
await screen.findByText('"2021-09-11" (string)');
289+
const input = screen.getByLabelText('Published at') as HTMLInputElement;
290+
fireEvent.change(input, {
291+
target: { value: '2021-10-30' },
292+
});
293+
fireEvent.blur(input);
294+
await screen.findByText('"2021-10-30" (string)');
295+
fireEvent.click(screen.getByText('Reset'));
296+
await screen.findByText('"2021-09-11" (string)');
297+
});
298+
249299
describe('error message', () => {
250300
it('should not be displayed if field is pristine', () => {
251301
render(<Basic dateInputProps={{ validate: required() }} />);

packages/ra-ui-materialui/src/input/DateInput.stories.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import * as React from 'react';
22
import polyglotI18nProvider from 'ra-i18n-polyglot';
33
import englishMessages from 'ra-language-english';
4-
import { minValue } from 'ra-core';
4+
import { minValue, useRecordContext } from 'ra-core';
5+
import { useFormContext, useWatch } from 'react-hook-form';
6+
import { Box, Button, Typography } from '@mui/material';
7+
import get from 'lodash/get';
58

69
import { AdminContext } from '../AdminContext';
710
import { Create } from '../detail';
@@ -100,6 +103,38 @@ export const Parse = ({ simpleFormProps }) => (
100103
</Wrapper>
101104
);
102105

106+
export const ExternalChanges = ({
107+
dateInputProps = {},
108+
simpleFormProps = {
109+
defaultValues: { publishedAt: '2021-09-11' },
110+
},
111+
}: {
112+
dateInputProps?: Partial<DateInputProps>;
113+
simpleFormProps?: Omit<SimpleFormProps, 'children'>;
114+
}) => (
115+
<Wrapper simpleFormProps={simpleFormProps}>
116+
<DateInput source="publishedAt" {...dateInputProps} />
117+
<DateHelper source="publishedAt" value="2021-10-20" />
118+
</Wrapper>
119+
);
120+
121+
export const ExternalChangesWithParse = ({
122+
dateInputProps = {
123+
parse: value => new Date(value),
124+
},
125+
simpleFormProps = {
126+
defaultValues: { publishedAt: new Date('2021-09-11') },
127+
},
128+
}: {
129+
dateInputProps?: Partial<DateInputProps>;
130+
simpleFormProps?: Omit<SimpleFormProps, 'children'>;
131+
}) => (
132+
<Wrapper simpleFormProps={simpleFormProps}>
133+
<DateInput source="publishedAt" {...dateInputProps} />
134+
<DateHelper source="publishedAt" value={new Date('2021-10-20')} />
135+
</Wrapper>
136+
);
137+
103138
const i18nProvider = polyglotI18nProvider(() => englishMessages);
104139

105140
const Wrapper = ({
@@ -118,3 +153,43 @@ const Wrapper = ({
118153
</Create>
119154
</AdminContext>
120155
);
156+
157+
const DateHelper = ({
158+
source,
159+
value,
160+
}: {
161+
source: string;
162+
value: string | Date;
163+
}) => {
164+
const record = useRecordContext();
165+
const { resetField, setValue } = useFormContext();
166+
const currentValue = useWatch({ name: source });
167+
168+
return (
169+
<Box>
170+
<Typography>
171+
Record value: {get(record, source)?.toString() ?? '-'}
172+
</Typography>
173+
<Typography>
174+
Current value: <span>{currentValue?.toString() ?? '-'}</span>
175+
</Typography>
176+
<Button
177+
onClick={() => {
178+
setValue(source, value, { shouldDirty: true });
179+
}}
180+
type="button"
181+
>
182+
Change value
183+
</Button>
184+
<Button
185+
color="error"
186+
onClick={() => {
187+
resetField(source);
188+
}}
189+
type="button"
190+
>
191+
Reset
192+
</Button>
193+
</Box>
194+
);
195+
};

packages/ra-ui-materialui/src/input/DateInput.tsx

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -67,28 +67,32 @@ export const DateInput = ({
6767
format,
6868
...rest,
6969
});
70-
const [renderCount, setRenderCount] = React.useState(1);
71-
const valueChangedFromInput = React.useRef(false);
7270
const localInputRef = React.useRef<HTMLInputElement>();
71+
// DateInput is not a really controlled input to ensure users can start entering a date, go to another input and come back to complete it.
72+
// This ref stores the value that is passed to the input defaultValue prop to solve this issue.
7373
const initialDefaultValueRef = React.useRef(field.value);
74+
// As the defaultValue prop won't trigger a remount of the HTML input, we will force it by changing the key.
75+
const [inputKey, setInputKey] = React.useState(1);
76+
// This ref let us track that the last change of the form state value was made by the input itself
77+
const wasLastChangedByInput = React.useRef(false);
7478

75-
// update the react-hook-form value if the field value changes
79+
// This effect ensures we stays in sync with the react-hook-form state when the value changes from outside the input
80+
// for instance by using react-hook-form reset or setValue methods.
7681
React.useEffect(() => {
77-
const initialDateValue =
78-
new Date(initialDefaultValueRef.current).getTime() || null;
79-
80-
const fieldDateValue = new Date(field.value).getTime() || null;
81-
82-
if (
83-
initialDateValue !== fieldDateValue &&
84-
!valueChangedFromInput.current
85-
) {
86-
setRenderCount(r => r + 1);
87-
field.onChange(field.value);
88-
initialDefaultValueRef.current = field.value;
89-
valueChangedFromInput.current = false;
82+
// Ignore react-hook-form state changes if it came from the input itself
83+
if (wasLastChangedByInput.current) {
84+
// Resets the flag to ensure futures changes are handled
85+
wasLastChangedByInput.current = false;
86+
return;
9087
}
91-
}, [setRenderCount, field]);
88+
89+
// The value has changed from outside the input, we update the input value
90+
initialDefaultValueRef.current = field.value;
91+
// Trigger a remount of the HTML input
92+
setInputKey(r => r + 1);
93+
// Resets the flag to ensure futures changes are handled
94+
wasLastChangedByInput.current = false;
95+
}, [setInputKey, field.value]);
9296

9397
const { onBlur: onBlurFromField } = field;
9498
const hasFocus = React.useRef(false);
@@ -118,7 +122,8 @@ export const DateInput = ({
118122
// The input reset is handled in the onBlur event handler
119123
if (newValue !== '' && newValue != null && isNewValueValid) {
120124
field.onChange(newValue);
121-
valueChangedFromInput.current = true;
125+
// Track the fact that the next react-hook-form state change was triggered by the input itself
126+
wasLastChangedByInput.current = true;
122127
}
123128
}
124129
);
@@ -167,7 +172,7 @@ export const DateInput = ({
167172
name={name}
168173
inputRef={inputRef}
169174
defaultValue={format(initialDefaultValueRef.current)}
170-
key={renderCount}
175+
key={inputKey}
171176
type="date"
172177
onChange={handleChange}
173178
onFocus={handleFocus}

packages/ra-ui-materialui/src/input/DateTimeInput.spec.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { SimpleForm, Toolbar } from '../form';
1010
import { DateTimeInput } from './DateTimeInput';
1111
import { ArrayInput, SimpleFormIterator } from './ArrayInput';
1212
import { SaveButton } from '../button';
13+
import {
14+
ExternalChanges,
15+
ExternalChangesWithParse,
16+
} from './DateTimeInput.stories';
1317

1418
describe('<DateTimeInput />', () => {
1519
const defaultProps = {
@@ -197,6 +201,53 @@ describe('<DateTimeInput />', () => {
197201
});
198202
});
199203

204+
it('should change its value when the form value has changed', async () => {
205+
render(<ExternalChanges />);
206+
await screen.findByText('"2021-09-11 20:00:00" (string)');
207+
const input = screen.getByLabelText('Published') as HTMLInputElement;
208+
fireEvent.change(input, {
209+
target: { value: '2021-10-30 09:00:00' },
210+
});
211+
fireEvent.blur(input);
212+
await screen.findByText('"2021-10-30T09:00" (string)');
213+
fireEvent.click(screen.getByText('Change value'));
214+
await screen.findByText('"2021-10-20 10:00:00" (string)');
215+
});
216+
217+
it('should change its value when the form value has changed with custom parse', async () => {
218+
render(<ExternalChangesWithParse />);
219+
await screen.findByText(
220+
// Because of the parse that uses the Date object, we check the value displayed and not the form value
221+
// to avoid timezone issues
222+
'Sat Sep 11 2021 20:00:00 GMT+0200 (Central European Summer Time)'
223+
);
224+
const input = screen.getByLabelText('Published') as HTMLInputElement;
225+
fireEvent.change(input, {
226+
target: { value: '2021-10-30 09:00:00' },
227+
});
228+
fireEvent.blur(input);
229+
await screen.findByText(
230+
'Sat Oct 30 2021 09:00:00 GMT+0200 (Central European Summer Time)'
231+
);
232+
fireEvent.click(screen.getByText('Change value'));
233+
await screen.findByText(
234+
'Wed Oct 20 2021 10:00:00 GMT+0200 (Central European Summer Time)'
235+
);
236+
});
237+
238+
it('should change its value when the form value is reset', async () => {
239+
render(<ExternalChanges />);
240+
await screen.findByText('"2021-09-11 20:00:00" (string)');
241+
const input = screen.getByLabelText('Published') as HTMLInputElement;
242+
fireEvent.change(input, {
243+
target: { value: '2021-10-30 09:00:00' },
244+
});
245+
fireEvent.blur(input);
246+
await screen.findByText('"2021-10-30T09:00" (string)');
247+
fireEvent.click(screen.getByText('Reset'));
248+
await screen.findByText('"2021-09-11 20:00:00" (string)');
249+
});
250+
200251
describe('error message', () => {
201252
it('should not be displayed if field is pristine', () => {
202253
render(

0 commit comments

Comments
 (0)