Validate date format and min/max in date range picker component (#18006)
* add validation behaviour * add specs * refactor into a hook
This commit is contained in:
parent
3c00226428
commit
d806393642
3 changed files with 252 additions and 4 deletions
|
|
@ -318,6 +318,12 @@ textarea.crayons-textfield.crayons-textfield--ghost {
|
|||
}
|
||||
}
|
||||
|
||||
&--error {
|
||||
.DateRangePickerInput {
|
||||
border-color: var(--accent-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.DateRangePicker:focus-within {
|
||||
.DateRangePickerInput {
|
||||
border-color: var(--focus);
|
||||
|
|
@ -430,4 +436,8 @@ textarea.crayons-textfield.crayons-textfield--ghost {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__errors {
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { h } from 'preact';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useState } from 'preact/hooks';
|
||||
import { useState, useLayoutEffect, useEffect } from 'preact/hooks';
|
||||
import 'react-dates/initialize';
|
||||
import moment from 'moment';
|
||||
import { DateRangePicker as ReactDateRangePicker } from 'react-dates';
|
||||
|
|
@ -24,6 +24,12 @@ const PICKER_PHRASES = {
|
|||
chooseAvailableStartDate: ({ date }) => `Choose ${date} as start date`,
|
||||
chooseAvailableEndDate: ({ date }) => `Choose ${date} as end date`,
|
||||
focusStartDate: 'Interact with the calendar and add your start date',
|
||||
invalidDateFormat: (format, errorPrefix) =>
|
||||
`${errorPrefix} must be in the format ${format}`,
|
||||
dateTooLate: (latestDate, errorPrefix) =>
|
||||
`${errorPrefix} must be on or before ${latestDate}`,
|
||||
dateTooEarly: (earliestDate, errorPrefix) =>
|
||||
`${errorPrefix} must be on or after ${earliestDate}`,
|
||||
};
|
||||
|
||||
const MONTH_NAMES = [...Array(12).keys()].map((key) =>
|
||||
|
|
@ -152,6 +158,87 @@ const PresetDateRangeOptions = ({
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that will attach listeners to date range inputs and return the correct error message for each.
|
||||
* @param {Object} props
|
||||
* @param {Object} props.earliesMoment Moment object reflecting the earliest date that may be selected
|
||||
* @param {Object} props.latestMoment Moment object reflecting the latest date that may be selected
|
||||
* @param {string} props.dateFormat The expected date format, e.g. "MM/DD/YYYY"
|
||||
*/
|
||||
const useDateRangeValidation = ({
|
||||
earliestMoment,
|
||||
latestMoment,
|
||||
dateFormat,
|
||||
}) => {
|
||||
const [startDateInput, setStartDateInput] = useState(null);
|
||||
const [endDateInput, setEndDateInput] = useState(null);
|
||||
const [startDateError, setStartDateError] = useState('');
|
||||
const [endDateError, setEndDateError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const setError = (error, inputType) =>
|
||||
inputType === 'start'
|
||||
? setStartDateError(error ?? '')
|
||||
: setEndDateError(error ?? '');
|
||||
|
||||
const handleBlur = ({ target: { value } }, inputType) => {
|
||||
if (value === '') {
|
||||
setError('', inputType);
|
||||
return;
|
||||
}
|
||||
|
||||
const errorPrefix = `${inputType === 'start' ? 'Start' : 'End'} date`;
|
||||
|
||||
const valueMoment = moment(value, dateFormat);
|
||||
if (!valueMoment.isValid()) {
|
||||
setError(
|
||||
PICKER_PHRASES.invalidDateFormat(dateFormat, errorPrefix),
|
||||
inputType,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (valueMoment.isBefore(earliestMoment)) {
|
||||
setError(
|
||||
PICKER_PHRASES.dateTooEarly(
|
||||
earliestMoment.format(dateFormat),
|
||||
errorPrefix,
|
||||
),
|
||||
inputType,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (valueMoment.isAfter(latestMoment)) {
|
||||
setError(
|
||||
PICKER_PHRASES.dateTooLate(
|
||||
latestMoment.format(dateFormat),
|
||||
errorPrefix,
|
||||
),
|
||||
inputType,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Date is valid
|
||||
setError('', inputType);
|
||||
};
|
||||
|
||||
const handleStartBlur = (e) => handleBlur(e, 'start');
|
||||
const handleEndBlur = (e) => handleBlur(e, 'end');
|
||||
|
||||
startDateInput?.addEventListener('blur', handleStartBlur);
|
||||
endDateInput?.addEventListener('blur', handleEndBlur);
|
||||
|
||||
return () => {
|
||||
startDateInput?.removeEventListener('blur', handleStartBlur);
|
||||
endDateInput?.removeEventListener('blur', handleEndBlur);
|
||||
};
|
||||
}, [startDateInput, endDateInput, dateFormat, earliestMoment, latestMoment]);
|
||||
|
||||
return { setStartDateInput, setEndDateInput, startDateError, endDateError };
|
||||
};
|
||||
|
||||
/**
|
||||
* Used to facilitate picking a date range. This component is a wrapper around the one provided from react-dates.
|
||||
*
|
||||
|
|
@ -186,6 +273,9 @@ export const DateRangePicker = ({
|
|||
defaultEndDate ? moment(defaultEndDate) : null,
|
||||
);
|
||||
|
||||
const dateFormat =
|
||||
getCurrentLocale().toLowerCase() === 'en-us' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
|
||||
|
||||
const useCompactLayout = useMediaQuery(
|
||||
`(max-width: ${BREAKPOINTS.Medium - 1}px)`,
|
||||
);
|
||||
|
|
@ -199,12 +289,26 @@ export const DateRangePicker = ({
|
|||
|
||||
const today = moment(todaysDate);
|
||||
|
||||
const dateFormat =
|
||||
getCurrentLocale().toLowerCase() === 'en-us' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
|
||||
const { setStartDateInput, setEndDateInput, startDateError, endDateError } =
|
||||
useDateRangeValidation({ earliestMoment, latestMoment, dateFormat });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const startDateInput = document.getElementById(startDateId);
|
||||
startDateInput.setAttribute('aria-describedby', 'start-date-error');
|
||||
setStartDateInput(startDateInput);
|
||||
|
||||
const endDateInput = document.getElementById(endDateId);
|
||||
endDateInput.setAttribute('aria-describedby', 'end-date-error');
|
||||
setEndDateInput(endDateInput);
|
||||
}, [startDateId, endDateId, setStartDateInput, setEndDateInput]);
|
||||
|
||||
return (
|
||||
// We wrap in a span to assist with scoping CSS selectors & overriding styles from react-dates
|
||||
<span className="c-date-picker">
|
||||
<span
|
||||
className={`c-date-picker${
|
||||
startDateError || endDateError ? ' c-date-picker--error' : ''
|
||||
}`}
|
||||
>
|
||||
<ReactDateRangePicker
|
||||
startDateId={startDateId}
|
||||
startDate={startMoment}
|
||||
|
|
@ -277,6 +381,13 @@ export const DateRangePicker = ({
|
|||
/>
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className="c-date-picker__errors crayons-field__description"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div id="start-date-error">{startDateError}</div>
|
||||
<div id="end-date-error">{endDateError}</div>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -148,6 +148,133 @@ describe('<DateRangePicker />', () => {
|
|||
expect(endDate.getFullYear()).toEqual(2022);
|
||||
});
|
||||
|
||||
it('displays errors on blur if an invalid date is typed', async () => {
|
||||
const { getByRole } = render(
|
||||
<DateRangePicker
|
||||
startDateId="start-date"
|
||||
endDateId="end-date"
|
||||
todaysDate={todayMock}
|
||||
minStartDate={new Date('2022-01-01')}
|
||||
maxEndDate={new Date('2022-01-31')}
|
||||
/>,
|
||||
);
|
||||
|
||||
const startDateInput = getByRole('textbox', {
|
||||
name: 'Start date (MM/DD/YYYY)',
|
||||
});
|
||||
|
||||
const endDateInput = getByRole('textbox', {
|
||||
name: 'End date (MM/DD/YYYY)',
|
||||
});
|
||||
|
||||
userEvent.type(startDateInput, 'something');
|
||||
await waitFor(() => expect(startDateInput).toHaveDisplayValue('something'));
|
||||
// Move away from the input to trigger the blur event
|
||||
userEvent.tab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(startDateInput).toHaveAccessibleDescription(
|
||||
'Start date must be in the format MM/DD/YYYY',
|
||||
),
|
||||
);
|
||||
|
||||
userEvent.type(endDateInput, '12345');
|
||||
await waitFor(() => expect(endDateInput).toHaveDisplayValue('12345'));
|
||||
userEvent.tab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(endDateInput).toHaveAccessibleDescription(
|
||||
'End date must be in the format MM/DD/YYYY',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('displays errors on blur if date is before minimum date', async () => {
|
||||
const { getByRole } = render(
|
||||
<DateRangePicker
|
||||
startDateId="start-date"
|
||||
endDateId="end-date"
|
||||
todaysDate={todayMock}
|
||||
minStartDate={new Date('2022-01-01')}
|
||||
maxEndDate={new Date('2022-01-31')}
|
||||
/>,
|
||||
);
|
||||
|
||||
const startDateInput = getByRole('textbox', {
|
||||
name: 'Start date (MM/DD/YYYY)',
|
||||
});
|
||||
|
||||
const endDateInput = getByRole('textbox', {
|
||||
name: 'End date (MM/DD/YYYY)',
|
||||
});
|
||||
|
||||
userEvent.type(startDateInput, '01/22/2020');
|
||||
await waitFor(() =>
|
||||
expect(startDateInput).toHaveDisplayValue('01/22/2020'),
|
||||
);
|
||||
// Move away from the input to trigger the blur event
|
||||
userEvent.tab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(startDateInput).toHaveAccessibleDescription(
|
||||
'Start date must be on or after 01/01/2022',
|
||||
),
|
||||
);
|
||||
|
||||
userEvent.type(endDateInput, '01/22/2020');
|
||||
await waitFor(() => expect(endDateInput).toHaveDisplayValue('01/22/2020'));
|
||||
userEvent.tab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(endDateInput).toHaveAccessibleDescription(
|
||||
'End date must be on or after 01/01/2022',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('displays errors on blur if date is after maximum date', async () => {
|
||||
const { getByRole } = render(
|
||||
<DateRangePicker
|
||||
startDateId="start-date"
|
||||
endDateId="end-date"
|
||||
todaysDate={todayMock}
|
||||
minStartDate={new Date('2022-01-01')}
|
||||
maxEndDate={new Date('2022-01-31')}
|
||||
/>,
|
||||
);
|
||||
|
||||
const startDateInput = getByRole('textbox', {
|
||||
name: 'Start date (MM/DD/YYYY)',
|
||||
});
|
||||
|
||||
const endDateInput = getByRole('textbox', {
|
||||
name: 'End date (MM/DD/YYYY)',
|
||||
});
|
||||
|
||||
userEvent.type(startDateInput, '05/22/2022');
|
||||
await waitFor(() =>
|
||||
expect(startDateInput).toHaveDisplayValue('05/22/2022'),
|
||||
);
|
||||
// Move away from the input to trigger the blur event
|
||||
userEvent.tab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(startDateInput).toHaveAccessibleDescription(
|
||||
'Start date must be on or before 01/31/2022',
|
||||
),
|
||||
);
|
||||
|
||||
userEvent.type(endDateInput, '05/22/2022');
|
||||
await waitFor(() => expect(endDateInput).toHaveDisplayValue('05/22/2022'));
|
||||
userEvent.tab();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(endDateInput).toHaveAccessibleDescription(
|
||||
'End date must be on or before 01/31/2022',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips to a selected year', async () => {
|
||||
const { getByRole, getAllByRole } = render(
|
||||
<DateRangePicker
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue