Skip to content

Commit d2bf053

Browse files
sunnysinghsdirix
andauthored
Improve React Material date/time input handling
Whenever the user entered an invalid date/time string, the renderers stored `undefined` in the data, i.e. the attribute was deleted. This is now changed to pass through the invalid string which is a much more flexible behavior. However dayjs is actually very lenient when parsing strings and tries its best to parse *something* out of any given string. This can have the effect that what the user entered is not necessarily what is actually stored in the data. To improve UX the date/time inputs now always reflect the actual stored data once they lose focus. Co-authored-by: Stefan Dirix <[email protected]>
1 parent 23a3a9e commit d2bf053

File tree

8 files changed

+214
-47
lines changed

8 files changed

+214
-47
lines changed

packages/material/src/controls/MaterialDateControl.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ import {
3232
rankWith,
3333
} from '@jsonforms/core';
3434
import { withJsonFormsControlProps } from '@jsonforms/react';
35-
import { FormHelperText, Hidden, TextField } from '@mui/material';
35+
import { FormHelperText, Hidden } from '@mui/material';
3636
import {
3737
DatePicker,
3838
LocalizationProvider
3939
} from '@mui/lab';
4040
import AdapterDayjs from '@mui/lab/AdapterDayjs';
41-
import { createOnChangeHandler, getData, useFocus } from '../util';
41+
import {
42+
createOnChangeHandler,
43+
getData,
44+
ResettableTextField,
45+
useFocus,
46+
} from '../util';
4247

4348
export const MaterialDateControl = (props: ControlProps)=> {
4449
const [focused, onFocus, onBlur] = useFocus();
@@ -82,12 +87,15 @@ export const MaterialDateControl = (props: ControlProps)=> {
8287
saveFormat
8388
),[path, handleChange, saveFormat]);
8489

90+
const value = getData(data, saveFormat);
91+
const valueInInputFormat = value ? value.format(format) : '';
92+
8593
return (
8694
<Hidden xsUp={!visible}>
8795
<LocalizationProvider dateAdapter={AdapterDayjs}>
8896
<DatePicker
8997
label={label}
90-
value={getData(data, saveFormat)}
98+
value={value}
9199
clearable
92100
onChange={onChange}
93101
inputFormat={format}
@@ -98,14 +106,21 @@ export const MaterialDateControl = (props: ControlProps)=> {
98106
clearText={appliedUiSchemaOptions.clearLabel}
99107
okText={appliedUiSchemaOptions.okLabel}
100108
renderInput={params => (
101-
<TextField
109+
<ResettableTextField
102110
{...params}
111+
rawValue={data}
112+
dayjsValueIsValid={value !== null}
113+
valueInInputFormat={valueInInputFormat}
114+
focused={focused}
103115
id={id + '-input'}
104116
required={required && !appliedUiSchemaOptions.hideRequiredAsterisk}
105117
autoFocus={appliedUiSchemaOptions.focus}
106118
error={!isValid}
107119
fullWidth={!appliedUiSchemaOptions.trim}
108-
inputProps={{ ...params.inputProps, type: 'text' }}
120+
inputProps={{
121+
...params.inputProps,
122+
type: 'text',
123+
}}
109124
InputLabelProps={data ? { shrink: true } : undefined}
110125
onFocus={onFocus}
111126
onBlur={onBlur}

packages/material/src/controls/MaterialDateTimeControl.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ import {
3232
rankWith
3333
} from '@jsonforms/core';
3434
import { withJsonFormsControlProps } from '@jsonforms/react';
35-
import { FormHelperText, Hidden, TextField } from '@mui/material';
35+
import { FormHelperText, Hidden } from '@mui/material';
3636
import {
3737
DateTimePicker,
3838
LocalizationProvider
3939
} from '@mui/lab';
4040
import AdapterDayjs from '@mui/lab/AdapterDayjs';
41-
import { createOnChangeHandler, getData, useFocus } from '../util';
41+
import {
42+
createOnChangeHandler,
43+
getData,
44+
ResettableTextField,
45+
useFocus
46+
} from '../util';
4247

4348
export const MaterialDateTimeControl = (props: ControlProps) => {
4449
const [focused, onFocus, onBlur] = useFocus();
@@ -84,12 +89,15 @@ export const MaterialDateTimeControl = (props: ControlProps) => {
8489
saveFormat
8590
),[path, handleChange, saveFormat]);
8691

92+
const value = getData(data, saveFormat);
93+
const valueInInputFormat = value ? value.format(format) : '';
94+
8795
return (
8896
<Hidden xsUp={!visible}>
8997
<LocalizationProvider dateAdapter={AdapterDayjs}>
9098
<DateTimePicker
9199
label={label}
92-
value={getData(data, saveFormat)}
100+
value={value}
93101
clearable
94102
onChange={onChange}
95103
inputFormat={format}
@@ -101,14 +109,21 @@ export const MaterialDateTimeControl = (props: ControlProps) => {
101109
clearText={appliedUiSchemaOptions.clearLabel}
102110
okText={appliedUiSchemaOptions.okLabel}
103111
renderInput={params => (
104-
<TextField
112+
<ResettableTextField
105113
{...params}
114+
rawValue={data}
115+
dayjsValueIsValid={value !== null}
116+
valueInInputFormat={valueInInputFormat}
117+
focused={focused}
106118
id={id + '-input'}
107119
required={required && !appliedUiSchemaOptions.hideRequiredAsterisk}
108120
autoFocus={appliedUiSchemaOptions.focus}
109121
error={!isValid}
110122
fullWidth={!appliedUiSchemaOptions.trim}
111-
inputProps={{ ...params.inputProps, type: 'text' }}
123+
inputProps={{
124+
...params.inputProps,
125+
type: 'text',
126+
}}
112127
InputLabelProps={data ? { shrink: true } : undefined}
113128
onFocus={onFocus}
114129
onBlur={onBlur}

packages/material/src/controls/MaterialTimeControl.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,18 @@ import {
3232
rankWith
3333
} from '@jsonforms/core';
3434
import { withJsonFormsControlProps } from '@jsonforms/react';
35-
import { FormHelperText, Hidden, TextField } from '@mui/material';
35+
import { FormHelperText, Hidden } from '@mui/material';
3636
import {
3737
TimePicker,
3838
LocalizationProvider
3939
} from '@mui/lab';
4040
import AdapterDayjs from '@mui/lab/AdapterDayjs';
41-
import { createOnChangeHandler, getData, useFocus } from '../util';
41+
import {
42+
createOnChangeHandler,
43+
getData,
44+
ResettableTextField,
45+
useFocus
46+
} from '../util';
4247

4348
export const MaterialTimeControl = (props: ControlProps) => {
4449
const [focused, onFocus, onBlur] = useFocus();
@@ -84,12 +89,15 @@ export const MaterialTimeControl = (props: ControlProps) => {
8489
saveFormat
8590
),[path, handleChange, saveFormat]);
8691

92+
const value = getData(data, saveFormat);
93+
const valueInInputFormat = value ? value.format(format) : '';
94+
8795
return (
8896
<Hidden xsUp={!visible}>
8997
<LocalizationProvider dateAdapter={AdapterDayjs}>
9098
<TimePicker
9199
label={label}
92-
value={getData(data, saveFormat)}
100+
value={value}
93101
clearable
94102
onChange={onChange}
95103
inputFormat={format}
@@ -101,14 +109,21 @@ export const MaterialTimeControl = (props: ControlProps) => {
101109
clearText={appliedUiSchemaOptions.clearLabel}
102110
okText={appliedUiSchemaOptions.okLabel}
103111
renderInput={params => (
104-
<TextField
112+
<ResettableTextField
105113
{...params}
114+
rawValue={data}
115+
dayjsValueIsValid={value !== null}
116+
valueInInputFormat={valueInInputFormat}
117+
focused={focused}
106118
id={id + '-input'}
107119
required={required && !appliedUiSchemaOptions.hideRequiredAsterisk}
108120
autoFocus={appliedUiSchemaOptions.focus}
109121
error={!isValid}
110122
fullWidth={!appliedUiSchemaOptions.trim}
111-
inputProps={{ ...params.inputProps, type: 'text' }}
123+
inputProps={{
124+
...params.inputProps,
125+
type: 'text'
126+
}}
112127
InputLabelProps={data ? { shrink: true } : undefined}
113128
onFocus={onFocus}
114129
onBlur={onBlur}

packages/material/src/util/datejs.ts

Lines changed: 0 additions & 32 deletions
This file was deleted.

packages/material/src/util/datejs.tsx

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { TextField, TextFieldProps } from '@mui/material';
2+
import dayjs from 'dayjs';
3+
import customParsing from 'dayjs/plugin/customParseFormat';
4+
import React, { useRef} from 'react';
5+
6+
// required for the custom save formats in the date, time and date-time pickers
7+
dayjs.extend(customParsing);
8+
9+
export const createOnChangeHandler = (
10+
path: string,
11+
handleChange: (path: string, value: any) => void,
12+
saveFormat: string | undefined
13+
) => (time: dayjs.Dayjs, textInputValue: string) => {
14+
if (!time) {
15+
handleChange(path, undefined);
16+
return;
17+
}
18+
const result = dayjs(time).format(saveFormat);
19+
handleChange(path, result === 'Invalid Date' ? textInputValue : result);
20+
};
21+
22+
export const getData = (
23+
data: any,
24+
saveFormat: string | undefined
25+
): dayjs.Dayjs | null => {
26+
if (!data) {
27+
return null;
28+
}
29+
const dayjsData = dayjs(data, saveFormat);
30+
if (dayjsData.toString() === 'Invalid Date') {
31+
return null;
32+
}
33+
return dayjsData;
34+
};
35+
36+
37+
interface InputRef {
38+
lastInput: string;
39+
toShow: string;
40+
}
41+
42+
type ResettableTextFieldProps = TextFieldProps & {
43+
rawValue: any;
44+
dayjsValueIsValid: boolean;
45+
valueInInputFormat: string;
46+
focused: boolean;
47+
}
48+
49+
/**
50+
* The dayjs formatter/parser is very lenient and for example ignores additional digits and/or characters.
51+
* In these cases the input text can look vastly different than the actual value stored in the data.
52+
* The 'ResettableTextField' component adjusts the text field to reflect the actual value stored in the data
53+
* once it's no longer 'focused', i.e. when the user stops editing.
54+
*/
55+
export const ResettableTextField: React.FC<ResettableTextFieldProps> = ({ rawValue, dayjsValueIsValid, valueInInputFormat, focused, inputProps, ...props }) => {
56+
const value = useRef<InputRef>({ lastInput: inputProps?.value, toShow: inputProps?.value });
57+
if (!focused) {
58+
// The input text is not focused, therefore let's show the value actually stored in the data
59+
if (!dayjsValueIsValid) {
60+
// pass through the "raw" value in case it can't be formatted by dayjs
61+
value.current.toShow = typeof rawValue === 'string' || rawValue === null || rawValue === undefined ? rawValue : JSON.stringify(rawValue)
62+
} else {
63+
// otherwise use the specified format
64+
value.current.toShow = valueInInputFormat;
65+
}
66+
}
67+
if (focused && inputProps?.value !== value.current.lastInput) {
68+
// Show the current text the user is typing into the text input
69+
value.current.lastInput = inputProps?.value;
70+
value.current.toShow = inputProps?.value;
71+
}
72+
return <TextField {...props} inputProps={{ ...inputProps, value: value.current.toShow || '' }} />
73+
}

packages/material/test/renderers/MaterialDateControl.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,4 +381,31 @@ describe('Material date control', () => {
381381
input.simulate('change', input);
382382
expect(onChangeData.data.foo).toBe('04---1961');
383383
});
384+
385+
it('should call onChange with original input value for invalid date strings', () => {
386+
const core = initCore(schema, uischema);
387+
const onChangeData: any = {
388+
data: undefined
389+
};
390+
wrapper = mount(
391+
<JsonFormsStateProvider initState={{ renderers: materialRenderers, core }}>
392+
<TestEmitter
393+
onChange={({ data }) => {
394+
onChangeData.data = data;
395+
}}
396+
/>
397+
<MaterialDateControl
398+
schema={schema}
399+
uischema={{...uischema}}
400+
/>
401+
</JsonFormsStateProvider>
402+
);
403+
404+
const input = wrapper.find('input').first();
405+
expect(input.props().value).toBe('');
406+
407+
(input.getDOMNode() as HTMLInputElement).value = 'invalid date string';
408+
input.simulate('change', input);
409+
expect(onChangeData.data.foo).toBe('invalid date string');
410+
});
384411
});

packages/material/test/renderers/MaterialDateTimeControl.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,4 +387,31 @@ describe('Material date time control', () => {
387387
input.simulate('change', input);
388388
expect(onChangeData.data.foo).toBe('2005/12/10 11:22 am');
389389
});
390+
391+
it('should call onChange with original input value for invalid date strings', () => {
392+
const core = initCore(schema, uischema);
393+
const onChangeData: any = {
394+
data: undefined
395+
};
396+
wrapper = mount(
397+
<JsonFormsStateProvider initState={{ renderers: materialRenderers, core }}>
398+
<TestEmitter
399+
onChange={({ data }) => {
400+
onChangeData.data = data;
401+
}}
402+
/>
403+
<MaterialDateTimeControl
404+
schema={schema}
405+
uischema={{...uischema}}
406+
/>
407+
</JsonFormsStateProvider>
408+
);
409+
410+
const input = wrapper.find('input').first();
411+
expect(input.props().value).toBe('');
412+
413+
(input.getDOMNode() as HTMLInputElement).value = 'invalid date string';
414+
input.simulate('change', input);
415+
expect(onChangeData.data.foo).toBe('invalid date string');
416+
});
390417
});

packages/material/test/renderers/MaterialTimeControl.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,4 +381,31 @@ describe('Material time control', () => {
381381
input.simulate('change', input);
382382
expect(onChangeData.data.foo).toBe('1//12 am');
383383
});
384+
385+
it('should call onChange with original input value for invalid date strings', () => {
386+
const core = initCore(schema, uischema);
387+
const onChangeData: any = {
388+
data: undefined
389+
};
390+
wrapper = mount(
391+
<JsonFormsStateProvider initState={{ renderers: materialRenderers, core }}>
392+
<TestEmitter
393+
onChange={({ data }) => {
394+
onChangeData.data = data;
395+
}}
396+
/>
397+
<MaterialTimeControl
398+
schema={schema}
399+
uischema={{...uischema}}
400+
/>
401+
</JsonFormsStateProvider>
402+
);
403+
404+
const input = wrapper.find('input').first();
405+
expect(input.props().value).toBe('');
406+
407+
(input.getDOMNode() as HTMLInputElement).value = 'invalid date string';
408+
input.simulate('change', input);
409+
expect(onChangeData.data.foo).toBe('invalid date string');
410+
});
384411
});

0 commit comments

Comments
 (0)