Skip to content

Commit

Permalink
Improve React Material date/time input handling
Browse files Browse the repository at this point in the history
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 <sdirix@eclipsesource.com>
  • Loading branch information
sunnysingh and sdirix committed Jun 26, 2022
1 parent 23a3a9e commit d2bf053
Show file tree
Hide file tree
Showing 8 changed files with 214 additions and 47 deletions.
25 changes: 20 additions & 5 deletions packages/material/src/controls/MaterialDateControl.tsx
Expand Up @@ -32,13 +32,18 @@ import {
rankWith,
} from '@jsonforms/core';
import { withJsonFormsControlProps } from '@jsonforms/react';
import { FormHelperText, Hidden, TextField } from '@mui/material';
import { FormHelperText, Hidden } from '@mui/material';
import {
DatePicker,
LocalizationProvider
} from '@mui/lab';
import AdapterDayjs from '@mui/lab/AdapterDayjs';
import { createOnChangeHandler, getData, useFocus } from '../util';
import {
createOnChangeHandler,
getData,
ResettableTextField,
useFocus,
} from '../util';

export const MaterialDateControl = (props: ControlProps)=> {
const [focused, onFocus, onBlur] = useFocus();
Expand Down Expand Up @@ -82,12 +87,15 @@ export const MaterialDateControl = (props: ControlProps)=> {
saveFormat
),[path, handleChange, saveFormat]);

const value = getData(data, saveFormat);
const valueInInputFormat = value ? value.format(format) : '';

return (
<Hidden xsUp={!visible}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
label={label}
value={getData(data, saveFormat)}
value={value}
clearable
onChange={onChange}
inputFormat={format}
Expand All @@ -98,14 +106,21 @@ export const MaterialDateControl = (props: ControlProps)=> {
clearText={appliedUiSchemaOptions.clearLabel}
okText={appliedUiSchemaOptions.okLabel}
renderInput={params => (
<TextField
<ResettableTextField
{...params}
rawValue={data}
dayjsValueIsValid={value !== null}
valueInInputFormat={valueInInputFormat}
focused={focused}
id={id + '-input'}
required={required && !appliedUiSchemaOptions.hideRequiredAsterisk}
autoFocus={appliedUiSchemaOptions.focus}
error={!isValid}
fullWidth={!appliedUiSchemaOptions.trim}
inputProps={{ ...params.inputProps, type: 'text' }}
inputProps={{
...params.inputProps,
type: 'text',
}}
InputLabelProps={data ? { shrink: true } : undefined}
onFocus={onFocus}
onBlur={onBlur}
Expand Down
25 changes: 20 additions & 5 deletions packages/material/src/controls/MaterialDateTimeControl.tsx
Expand Up @@ -32,13 +32,18 @@ import {
rankWith
} from '@jsonforms/core';
import { withJsonFormsControlProps } from '@jsonforms/react';
import { FormHelperText, Hidden, TextField } from '@mui/material';
import { FormHelperText, Hidden } from '@mui/material';
import {
DateTimePicker,
LocalizationProvider
} from '@mui/lab';
import AdapterDayjs from '@mui/lab/AdapterDayjs';
import { createOnChangeHandler, getData, useFocus } from '../util';
import {
createOnChangeHandler,
getData,
ResettableTextField,
useFocus
} from '../util';

export const MaterialDateTimeControl = (props: ControlProps) => {
const [focused, onFocus, onBlur] = useFocus();
Expand Down Expand Up @@ -84,12 +89,15 @@ export const MaterialDateTimeControl = (props: ControlProps) => {
saveFormat
),[path, handleChange, saveFormat]);

const value = getData(data, saveFormat);
const valueInInputFormat = value ? value.format(format) : '';

return (
<Hidden xsUp={!visible}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DateTimePicker
label={label}
value={getData(data, saveFormat)}
value={value}
clearable
onChange={onChange}
inputFormat={format}
Expand All @@ -101,14 +109,21 @@ export const MaterialDateTimeControl = (props: ControlProps) => {
clearText={appliedUiSchemaOptions.clearLabel}
okText={appliedUiSchemaOptions.okLabel}
renderInput={params => (
<TextField
<ResettableTextField
{...params}
rawValue={data}
dayjsValueIsValid={value !== null}
valueInInputFormat={valueInInputFormat}
focused={focused}
id={id + '-input'}
required={required && !appliedUiSchemaOptions.hideRequiredAsterisk}
autoFocus={appliedUiSchemaOptions.focus}
error={!isValid}
fullWidth={!appliedUiSchemaOptions.trim}
inputProps={{ ...params.inputProps, type: 'text' }}
inputProps={{
...params.inputProps,
type: 'text',
}}
InputLabelProps={data ? { shrink: true } : undefined}
onFocus={onFocus}
onBlur={onBlur}
Expand Down
25 changes: 20 additions & 5 deletions packages/material/src/controls/MaterialTimeControl.tsx
Expand Up @@ -32,13 +32,18 @@ import {
rankWith
} from '@jsonforms/core';
import { withJsonFormsControlProps } from '@jsonforms/react';
import { FormHelperText, Hidden, TextField } from '@mui/material';
import { FormHelperText, Hidden } from '@mui/material';
import {
TimePicker,
LocalizationProvider
} from '@mui/lab';
import AdapterDayjs from '@mui/lab/AdapterDayjs';
import { createOnChangeHandler, getData, useFocus } from '../util';
import {
createOnChangeHandler,
getData,
ResettableTextField,
useFocus
} from '../util';

export const MaterialTimeControl = (props: ControlProps) => {
const [focused, onFocus, onBlur] = useFocus();
Expand Down Expand Up @@ -84,12 +89,15 @@ export const MaterialTimeControl = (props: ControlProps) => {
saveFormat
),[path, handleChange, saveFormat]);

const value = getData(data, saveFormat);
const valueInInputFormat = value ? value.format(format) : '';

return (
<Hidden xsUp={!visible}>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<TimePicker
label={label}
value={getData(data, saveFormat)}
value={value}
clearable
onChange={onChange}
inputFormat={format}
Expand All @@ -101,14 +109,21 @@ export const MaterialTimeControl = (props: ControlProps) => {
clearText={appliedUiSchemaOptions.clearLabel}
okText={appliedUiSchemaOptions.okLabel}
renderInput={params => (
<TextField
<ResettableTextField
{...params}
rawValue={data}
dayjsValueIsValid={value !== null}
valueInInputFormat={valueInInputFormat}
focused={focused}
id={id + '-input'}
required={required && !appliedUiSchemaOptions.hideRequiredAsterisk}
autoFocus={appliedUiSchemaOptions.focus}
error={!isValid}
fullWidth={!appliedUiSchemaOptions.trim}
inputProps={{ ...params.inputProps, type: 'text' }}
inputProps={{
...params.inputProps,
type: 'text'
}}
InputLabelProps={data ? { shrink: true } : undefined}
onFocus={onFocus}
onBlur={onBlur}
Expand Down
32 changes: 0 additions & 32 deletions packages/material/src/util/datejs.ts

This file was deleted.

73 changes: 73 additions & 0 deletions packages/material/src/util/datejs.tsx
@@ -0,0 +1,73 @@
import { TextField, TextFieldProps } from '@mui/material';
import dayjs from 'dayjs';
import customParsing from 'dayjs/plugin/customParseFormat';
import React, { useRef} from 'react';

// required for the custom save formats in the date, time and date-time pickers
dayjs.extend(customParsing);

export const createOnChangeHandler = (
path: string,
handleChange: (path: string, value: any) => void,
saveFormat: string | undefined
) => (time: dayjs.Dayjs, textInputValue: string) => {
if (!time) {
handleChange(path, undefined);
return;
}
const result = dayjs(time).format(saveFormat);
handleChange(path, result === 'Invalid Date' ? textInputValue : result);
};

export const getData = (
data: any,
saveFormat: string | undefined
): dayjs.Dayjs | null => {
if (!data) {
return null;
}
const dayjsData = dayjs(data, saveFormat);
if (dayjsData.toString() === 'Invalid Date') {
return null;
}
return dayjsData;
};


interface InputRef {
lastInput: string;
toShow: string;
}

type ResettableTextFieldProps = TextFieldProps & {
rawValue: any;
dayjsValueIsValid: boolean;
valueInInputFormat: string;
focused: boolean;
}

/**
* The dayjs formatter/parser is very lenient and for example ignores additional digits and/or characters.
* In these cases the input text can look vastly different than the actual value stored in the data.
* The 'ResettableTextField' component adjusts the text field to reflect the actual value stored in the data
* once it's no longer 'focused', i.e. when the user stops editing.
*/
export const ResettableTextField: React.FC<ResettableTextFieldProps> = ({ rawValue, dayjsValueIsValid, valueInInputFormat, focused, inputProps, ...props }) => {
const value = useRef<InputRef>({ lastInput: inputProps?.value, toShow: inputProps?.value });
if (!focused) {
// The input text is not focused, therefore let's show the value actually stored in the data
if (!dayjsValueIsValid) {
// pass through the "raw" value in case it can't be formatted by dayjs
value.current.toShow = typeof rawValue === 'string' || rawValue === null || rawValue === undefined ? rawValue : JSON.stringify(rawValue)
} else {
// otherwise use the specified format
value.current.toShow = valueInInputFormat;
}
}
if (focused && inputProps?.value !== value.current.lastInput) {
// Show the current text the user is typing into the text input
value.current.lastInput = inputProps?.value;
value.current.toShow = inputProps?.value;
}
return <TextField {...props} inputProps={{ ...inputProps, value: value.current.toShow || '' }} />
}
27 changes: 27 additions & 0 deletions packages/material/test/renderers/MaterialDateControl.test.tsx
Expand Up @@ -381,4 +381,31 @@ describe('Material date control', () => {
input.simulate('change', input);
expect(onChangeData.data.foo).toBe('04---1961');
});

it('should call onChange with original input value for invalid date strings', () => {
const core = initCore(schema, uischema);
const onChangeData: any = {
data: undefined
};
wrapper = mount(
<JsonFormsStateProvider initState={{ renderers: materialRenderers, core }}>
<TestEmitter
onChange={({ data }) => {
onChangeData.data = data;
}}
/>
<MaterialDateControl
schema={schema}
uischema={{...uischema}}
/>
</JsonFormsStateProvider>
);

const input = wrapper.find('input').first();
expect(input.props().value).toBe('');

(input.getDOMNode() as HTMLInputElement).value = 'invalid date string';
input.simulate('change', input);
expect(onChangeData.data.foo).toBe('invalid date string');
});
});
27 changes: 27 additions & 0 deletions packages/material/test/renderers/MaterialDateTimeControl.test.tsx
Expand Up @@ -387,4 +387,31 @@ describe('Material date time control', () => {
input.simulate('change', input);
expect(onChangeData.data.foo).toBe('2005/12/10 11:22 am');
});

it('should call onChange with original input value for invalid date strings', () => {
const core = initCore(schema, uischema);
const onChangeData: any = {
data: undefined
};
wrapper = mount(
<JsonFormsStateProvider initState={{ renderers: materialRenderers, core }}>
<TestEmitter
onChange={({ data }) => {
onChangeData.data = data;
}}
/>
<MaterialDateTimeControl
schema={schema}
uischema={{...uischema}}
/>
</JsonFormsStateProvider>
);

const input = wrapper.find('input').first();
expect(input.props().value).toBe('');

(input.getDOMNode() as HTMLInputElement).value = 'invalid date string';
input.simulate('change', input);
expect(onChangeData.data.foo).toBe('invalid date string');
});
});
27 changes: 27 additions & 0 deletions packages/material/test/renderers/MaterialTimeControl.test.tsx
Expand Up @@ -381,4 +381,31 @@ describe('Material time control', () => {
input.simulate('change', input);
expect(onChangeData.data.foo).toBe('1//12 am');
});

it('should call onChange with original input value for invalid date strings', () => {
const core = initCore(schema, uischema);
const onChangeData: any = {
data: undefined
};
wrapper = mount(
<JsonFormsStateProvider initState={{ renderers: materialRenderers, core }}>
<TestEmitter
onChange={({ data }) => {
onChangeData.data = data;
}}
/>
<MaterialTimeControl
schema={schema}
uischema={{...uischema}}
/>
</JsonFormsStateProvider>
);

const input = wrapper.find('input').first();
expect(input.props().value).toBe('');

(input.getDOMNode() as HTMLInputElement).value = 'invalid date string';
input.simulate('change', input);
expect(onChangeData.data.foo).toBe('invalid date string');
});
});

0 comments on commit d2bf053

Please sign in to comment.