Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve onError validation #1730

Merged
merged 27 commits into from
May 6, 2020
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
5563142
Introduce reason-based validation
dmtrKovalenko Apr 30, 2020
70b66d4
Remove dead validation code
dmtrKovalenko Apr 30, 2020
e9a0687
Implement new validation for TimePicker and DateTimePicker
dmtrKovalenko Apr 30, 2020
4f2207f
Remove redux form docs example
dmtrKovalenko Apr 30, 2020
868358a
Add Formik with validation schema example
dmtrKovalenko Apr 30, 2020
679ea8d
More tests
dmtrKovalenko Apr 30, 2020
a501984
TimePicker validation tests
dmtrKovalenko Apr 30, 2020
a1a6b42
Move parsing min/max date up to the root component
dmtrKovalenko May 1, 2020
5ca8ef2
Use touched state in the formik example
dmtrKovalenko May 1, 2020
3af9888
Remove console.logs
dmtrKovalenko May 1, 2020
71981e0
Merge conflicts
dmtrKovalenko May 1, 2020
cbe5fc4
Fix lint and build errors
dmtrKovalenko May 1, 2020
3eddd8b
Remove visual regression flakiness with time validation
dmtrKovalenko May 1, 2020
f21609c
Remove emptyInputText
dmtrKovalenko May 1, 2020
754a95b
Fix validation tests
dmtrKovalenko May 1, 2020
58f12c7
Commit .size-snapshot
dmtrKovalenko May 1, 2020
e349496
Implement validation for DateRangePicker.tsx
dmtrKovalenko May 1, 2020
5bce1e4
Add DateRange validation tests
dmtrKovalenko May 1, 2020
e4166a7
Fix linter
dmtrKovalenko May 1, 2020
860d6f5
Fix broken design of date rangepicker input parsing
dmtrKovalenko May 5, 2020
c73d4e7
Merge conflicts
dmtrKovalenko May 5, 2020
8dedc04
Remove <Code> from formik examples
dmtrKovalenko May 5, 2020
66eb022
Update yarn.lock
dmtrKovalenko May 5, 2020
af381ef
Update docs/pages/guides/FormikOurValidation.example.tsx
dmtrKovalenko May 5, 2020
1201eef
Update docs/pages/guides/FormikOurValidation.example.tsx
dmtrKovalenko May 5, 2020
79cea16
Update docs/pages/guides/FormikOurValidation.example.tsx
dmtrKovalenko May 5, 2020
806b63c
Update new forms example to be more consolidated with @materail-ui/core
dmtrKovalenko May 6, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Introduce reason-based validation
  • Loading branch information
dmtrKovalenko committed Apr 30, 2020
commit 5563142b34e981bf31b8aa3e2c9d722aeacb6ec2
1,033 changes: 205 additions & 828 deletions docs/prop-types.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions lib/src/DatePicker/DatePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
import { useUtils } from '../_shared/hooks/useUtils';
import { DatePickerToolbar } from './DatePickerToolbar';
import { getFormatByViews } from '../_helpers/date-utils';
import { WithViewsProps } from '../Picker/SharedPickerProps';
import { datePickerDefaultProps } from '../constants/prop-types';
import { ResponsiveWrapper } from '../wrappers/ResponsiveWrapper';
import { ExportedCalendarViewProps } from '../views/Calendar/CalendarView';
import { makePickerWithStateAndWrapper } from '../Picker/makePickerWithState';
import { datePickerDefaultProps, ParsableDate } from '../constants/prop-types';
import { ModalWrapper, InlineWrapper, StaticWrapper } from '../wrappers/Wrapper';
import { makeValidationHook, ValidationProps } from '../_shared/hooks/useValidation';
import { getFormatByViews, validateDate, DateValidationError } from '../_helpers/date-utils';

export type DatePickerView = 'year' | 'date' | 'month';

export interface DatePickerProps
extends WithViewsProps<'year' | 'date' | 'month'>,
ValidationProps<DateValidationError, ParsableDate>,
ExportedCalendarViewProps {}

const datePickerConfig = {
useValidation: makeValidationHook<DateValidationError, ParsableDate, DatePickerProps>(
validateDate
),
DefaultToolbarComponent: DatePickerToolbar,
useDefaultProps: ({ openTo = 'date', views = ['year', 'date'] }: DatePickerProps) => {
const utils = useUtils();
Expand Down
4 changes: 1 addition & 3 deletions lib/src/Picker/SharedPickerProps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { DateTimePickerView } from '../DateTimePicker';
import { ParsableDate } from '../constants/prop-types';
import { BasePickerProps } from '../typings/BasePicker';
import { MaterialUiPickersDate } from '../typings/date';
import { DateValidationProps } from '../_helpers/text-field-helper';
import { WithDateAdapterProps } from '../_shared/withDateAdapterProp';
import { ExportedDateInputProps, DateInputProps } from '../_shared/PureDateInput';

Expand All @@ -14,8 +13,7 @@ export type AllSharedPickerProps<
TDateValue = MaterialUiPickersDate
> = BasePickerProps<TInputValue, TDateValue> &
ExportedDateInputProps<TInputValue, TDateValue> &
WithDateAdapterProps &
DateValidationProps;
WithDateAdapterProps;

export interface SharedPickerProps<TInputValue, TDateValue> {
isMobileKeyboardViewOpen: boolean;
Expand Down
19 changes: 12 additions & 7 deletions lib/src/Picker/makePickerWithState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,32 @@ import * as React from 'react';
import { useUtils } from '../_shared/hooks/useUtils';
import { ParsableDate } from '../constants/prop-types';
import { MaterialUiPickersDate } from '../typings/date';
import { parsePickerInputValue } from '../_helpers/date-utils';
import { KeyboardDateInput } from '../_shared/KeyboardDateInput';
import { usePickerState } from '../_shared/hooks/usePickerState';
import { validateDateValue } from '../_helpers/text-field-helper';
import { ResponsiveWrapper } from '../wrappers/ResponsiveWrapper';
import { withDateAdapterProp } from '../_shared/withDateAdapterProp';
import { makeWrapperComponent } from '../wrappers/makeWrapperComponent';
import { PureDateInput, DateInputProps } from '../_shared/PureDateInput';
import { AnyPickerView, AllSharedPickerProps } from './SharedPickerProps';
import { SomeWrapper, ExtendWrapper, WrapperProps } from '../wrappers/Wrapper';
import { parsePickerInputValue, DateValidationError } from '../_helpers/date-utils';
import { Picker, ToolbarComponentProps, ExportedPickerProps, PickerProps } from './Picker';

type AllAvailableForOverrideProps = ExportedPickerProps<AnyPickerView>;

export interface MakePickerOptions<T extends unknown> {
useValidation: (value: any, props: T) => boolean;
useDefaultProps: (props: T & AllSharedPickerProps) => Partial<T> & { inputFormat: string };
DefaultToolbarComponent: React.ComponentType<ToolbarComponentProps>;
}

export function makePickerWithStateAndWrapper<
T extends AllAvailableForOverrideProps,
TWrapper extends SomeWrapper = typeof ResponsiveWrapper
>(Wrapper: TWrapper, { useDefaultProps, DefaultToolbarComponent }: MakePickerOptions<T>) {
>(
Wrapper: TWrapper,
{ useDefaultProps, useValidation, DefaultToolbarComponent }: MakePickerOptions<T>
) {
const PickerWrapper = makeWrapperComponent<DateInputProps, ParsableDate, MaterialUiPickersDate>(
Wrapper,
{
Expand All @@ -38,30 +41,32 @@ export function makePickerWithStateAndWrapper<
const defaultProps = useDefaultProps(__props);
const allProps = { ...defaultProps, ...__props };

const validationError = useValidation(allProps.value, allProps);
const { pickerProps, inputProps, wrapperProps } = usePickerState<
ParsableDate,
MaterialUiPickersDate
MaterialUiPickersDate,
DateValidationError
>(allProps, {
emptyValue: null,
parseInput: parsePickerInputValue,
validateInput: validateDateValue,
areValuesEqual: (a, b) => utils.isEqual(a, b),
});

// Note that we are passing down all the value without spread.
// It saves us >1kb gzip and make any prop available automatically on any level down.
const { value, onChange, ...other } = allProps;
const DateInputProps = { ...inputProps, ...other, validationError };

return (
<PickerWrapper
DateInputProps={({ ...inputProps, ...other } as unknown) as DateInputProps}
DateInputProps={DateInputProps}
wrapperProps={wrapperProps}
{...((other as unknown) as WrapperProps)}
>
<Picker
{...pickerProps}
toolbarTitle={allProps.label || allProps.toolbarTitle}
DateInputProps={{ ...inputProps, ...allProps }}
DateInputProps={DateInputProps}
ToolbarComponent={other.ToolbarComponent || DefaultToolbarComponent}
{...((other as unknown) as PickerProps<any>)}
/>
Expand Down
60 changes: 60 additions & 0 deletions lib/src/_helpers/date-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { arrayIncludes } from './utils';
import { IUtils } from '@date-io/core/IUtils';
import { ParsableDate } from '../constants/prop-types';
import { MaterialUiPickersDate } from '../typings/date';
import { BasePickerProps } from '../typings/BasePicker';
import { DatePickerView } from '../DatePicker/DatePicker';
Expand Down Expand Up @@ -138,3 +139,62 @@ export const isEndOfRange = (
) => {
return isRangeValid(utils, range) && utils.isSameDay(day, range[1]);
};

export interface DateValidationProps {
/**
* Min selectable date
* @default Date(1900-01-01)
*/
minDate?: MaterialUiPickersDate;
/**
* Max selectable date
* @default Date(2100-01-01)
*/
maxDate?: MaterialUiPickersDate;
/** Disable specific date @DateIOType */
shouldDisableDate?: (day: MaterialUiPickersDate) => boolean;
/**
* Disable past dates
* @default false
*/
disablePast?: boolean;
/**
* Disable future dates
* @default false
*/
disableFuture?: boolean;
}

export const validateDate = (
utils: MuiPickersAdapter,
value: MaterialUiPickersDate | ParsableDate,
{ minDate, maxDate, disableFuture, shouldDisableDate, disablePast }: DateValidationProps
) => {
const now = utils.date();
const date = utils.date(value);

switch (true) {
case !utils.isValid(value):
return 'invalidDate';

case Boolean(shouldDisableDate && shouldDisableDate(date)):
return 'shouldDisableDate';

case Boolean(disableFuture && utils.isAfterDay(date, now)):
return 'disableFuture';

case Boolean(disablePast && utils.isBeforeDay(date, now)):
return 'disablePast';

case Boolean(minDate && utils.isBeforeDay(date, minDate)):
return 'minDate';

case Boolean(maxDate && utils.isAfterDay(date, maxDate)):
return 'maxDate';

default:
return null;
}
};

export type DateValidationError = NonNullable<ReturnType<typeof validateDate>>;
26 changes: 0 additions & 26 deletions lib/src/_helpers/text-field-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,32 +24,6 @@ export const getDisplayDate = (
return utils.isValid(date) ? utils.formatByString(date, inputFormat) : '';
};

export interface BaseValidationProps {
/**
* Message, appearing when date cannot be parsed
* @default 'Invalid Date Format'
*/
invalidDateMessage?: React.ReactNode;
}

export interface DateValidationProps extends BaseValidationProps {
/**
* Error message, shown if date is less then minimal date
* @default 'Date should not be before minimal date'
*/
minDateMessage?: React.ReactNode;
/**
* Error message, shown if date is more then maximal date
* @default 'Date should not be after maximal date'
*/
maxDateMessage?: React.ReactNode;
/**
* Compare dates by the exact timestamp, instead of start/end of date
* @default false
*/
strictCompareDates?: boolean;
}

const getComparisonMaxDate = (
utils: MuiPickersAdapter,
strictCompareDates: boolean,
Expand Down
4 changes: 2 additions & 2 deletions lib/src/_shared/KeyboardDateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ export const KeyboardDateInput: React.FC<DateInputProps & DateInputRefs> = ({
inputRef: forwardedRef,
type: shouldUseMaskedInput ? 'tel' : 'text',
placeholder: formatHelperText,
error: Boolean(validationError),
helperText: formatHelperText || validationError,
error: validationError,
helperText: formatHelperText,
'data-mui-test': 'keyboard-date-input',
inputProps: { readOnly },
InputProps: {
Expand Down
5 changes: 2 additions & 3 deletions lib/src/_shared/PureDateInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface DateInputProps<TInputValue = ParsableDate, TDateValue = Materia
openPicker: () => void;
readOnly?: boolean;
disabled?: boolean;
validationError?: React.ReactNode;
validationError?: boolean;
label?: TextFieldProps['label'];
InputProps?: TextFieldProps['InputProps'];
TextFieldProps?: Partial<MuiTextFieldProps>;
Expand Down Expand Up @@ -125,8 +125,7 @@ export const PureDateInput: React.FC<DateInputProps & DateInputRefs> = ({
disabled,
ref: containerRef,
inputRef: forwardedRef,
error: Boolean(validationError),
helperText: validationError,
error: validationError,
'aria-label': getOpenDialogAriaText(rawValue, utils),
onClick: onOpen,
value: inputValue,
Expand Down
2 changes: 1 addition & 1 deletion lib/src/_shared/hooks/date-helpers-hooks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from 'react';
import { useUtils } from './useUtils';
import { MaterialUiPickersDate } from '../../typings/date';

export function useParsedDate(possiblyUnparsedValue: any) {
export function useParsedDate(possiblyUnparsedValue: any): MaterialUiPickersDate | undefined {
const utils = useUtils();
return React.useMemo(
() =>
Expand Down
31 changes: 4 additions & 27 deletions lib/src/_shared/hooks/usePickerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,19 @@ import { useCallback, useDebugValue, useEffect, useMemo, useState } from 'react'

export const FORCE_FINISH_PICKER = Symbol('Force closing picker, useful for accessibility');

export function usePickerState<TInput, TDateValue>(
props: BasePickerProps<TInput, TDateValue>,
export function usePickerState<TInput, TDateValue, TErrorReason extends string>(
props: BasePickerProps<TInput, TDateValue, TErrorReason>,
valueManager: {
parseInput: (
now: MaterialUiPickersDate,
utils: MuiPickersAdapter,
props: BasePickerProps<TInput, TDateValue>
) => TDateValue;
validateInput: (
value: TInput,
utils: MuiPickersAdapter,
props: BasePickerProps<TInput, TDateValue>
) => React.ReactNode | undefined;
emptyValue: TDateValue;
areValuesEqual: (valueLeft: TDateValue, valueRight: TDateValue) => boolean;
}
) {
const { autoOk, inputFormat, disabled, readOnly, onAccept, onChange, onError, value } = props;
const { autoOk, inputFormat, disabled, readOnly, onAccept, onChange, value } = props;

if (!inputFormat) {
throw new Error('inputFormat prop is required');
Expand Down Expand Up @@ -117,34 +112,16 @@ export function usePickerState<TInput, TDateValue>(
[acceptDate, autoOk, isMobileKeyboardViewOpen, pickerDate]
);

const validationError = valueManager.validateInput(value, utils, props);
useEffect(() => {
if (onError) {
onError(validationError, value);
}
}, [onError, validationError, value]);

const inputProps = useMemo(
() => ({
onChange,
inputFormat,
open: isOpen,
rawValue: value,
validationError,
parsedDateValue: pickerDate,
openPicker: () => !readOnly && !disabled && setIsOpen(true),
}),
[
onChange,
inputFormat,
isOpen,
value,
validationError,
pickerDate,
readOnly,
disabled,
setIsOpen,
]
[onChange, inputFormat, isOpen, value, pickerDate, readOnly, disabled, setIsOpen]
);

const pickerState = { pickerProps, inputProps, wrapperProps };
Expand Down
28 changes: 28 additions & 0 deletions lib/src/_shared/hooks/useValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react';
import { useUtils, MuiPickersAdapter } from './useUtils';

export interface ValidationProps<TError, TDateValue> {
/** Callback fired when new error should be displayed
* (!! This is a side effect. Be careful if you want to rerender the component) @DateIOType
*/
onError?: (reason: TError | null, value: TDateValue) => void;
}

export function makeValidationHook<
TError,
TDateValue,
TProps extends ValidationProps<TError, TDateValue>
>(validateFn: (utils: MuiPickersAdapter, value: TDateValue, props: TProps) => TError | null) {
return (value: TDateValue, props: TProps) => {
const utils = useUtils();
const validationError = validateFn(utils, value, props);

React.useEffect(() => {
if (props.onError) {
props.onError(validationError, value);
}
}, [props, validationError, value]);

return validationError !== null;
};
}
8 changes: 3 additions & 5 deletions lib/src/typings/BasePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { ToolbarComponentProps } from '../Picker/Picker';

export interface BasePickerProps<
TInputValue = ParsableDate,
TDateValue = MaterialUiPickersDate | null
TDateValue = MaterialUiPickersDate | null,
TErrorReason extends string = never
> {
/** Picker value */
value: TInputValue;
Expand All @@ -25,10 +26,7 @@ export interface BasePickerProps<
defaultHighlight?: ParsableDate;
/** Callback fired when date is accepted @DateIOType */
onAccept?: (date: TDateValue) => void;
/** Callback fired when new error should be displayed
* (!! This is a side effect. Be careful if you want to rerender the component) @DateIOType
*/
onError?: (error: React.ReactNode, value: TInputValue | TDateValue) => void;

/** On open callback */
onOpen?: () => void;
/** On close callback */
Expand Down
11 changes: 1 addition & 10 deletions lib/src/views/Calendar/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,7 @@ export interface ExportedCalendarProps
extends Pick<DayProps, 'disableHighlightToday' | 'showDaysOutsideCurrentMonth'> {
/** Calendar onChange */
onChange: PickerOnChangeFn;
/**
* Disable past dates
* @default false
*/
disablePast?: boolean;
/**
* Disable future dates
* @default false
*/
disableFuture?: boolean;

/** Custom renderer for day. Check [DayComponentProps api](https://material-ui-pickers.dev/api/Day) @DateIOType */
renderDay?: (
day: MaterialUiPickersDate,
Expand Down
Loading