diff --git a/frontend/src/components/DatePicker/Calendar/CalendarHeader.tsx b/frontend/src/components/DatePicker/Calendar/CalendarHeader.tsx index 018656918f..762e15b38c 100644 --- a/frontend/src/components/DatePicker/Calendar/CalendarHeader.tsx +++ b/frontend/src/components/DatePicker/Calendar/CalendarHeader.tsx @@ -30,6 +30,8 @@ const MonthYearSelect = ({ ( } + variant="inputAttached" + borderRadius={0} isActive={isOpen} - fontSize="1.25rem" - variant="outline" - color="secondary.500" - borderColor="neutral.400" - borderRadius="0" - // Avoid double border with input - ml="-1px" /> ( bg="white" > - {/* Having this extra guard here allows for tab rotation instead of closing the - calendar on certain tab key presses. - data-focus-guard is required to work with FocusLock - NFI why this is necessary, just that it works. Such is the life of a software engineer. */} - ( bg="white" > - {/* Having this extra guard here allows for tab rotation instead of closing the - calendar on certain tab key presses. - data-focus-guard is required to work with FocusLock - NFI why this is necessary, just that it works. Such is the life of a software engineer. */} - { switch (field.fieldType) { + case BasicField.Date: + return ( + + ) case BasicField.Section: return ( @@ -117,11 +120,5 @@ export const FormField = ({ field, colorTheme }: FormFieldProps) => { ) case BasicField.Table: return - default: - return ( - - {JSON.stringify(field)} - - ) } } diff --git a/frontend/src/templates/Field/Date/DateField.stories.tsx b/frontend/src/templates/Field/Date/DateField.stories.tsx new file mode 100644 index 0000000000..5d0a1d78f5 --- /dev/null +++ b/frontend/src/templates/Field/Date/DateField.stories.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { Text } from '@chakra-ui/react' +import { Meta, Story } from '@storybook/react' +import { addDays, lightFormat } from 'date-fns' + +import { BasicField, DateSelectedValidation } from '~shared/types/field' + +import { mockDateDecorator } from '~utils/storybook' +import Button from '~components/Button' + +import { + DateField as DateFieldComponent, + DateFieldProps, + DateFieldSchema, +} from './DateField' + +const MOCKED_TODAY_DATE = '2021-12-13' + +export default { + title: 'Templates/Field/DateField', + component: DateFieldComponent, + decorators: [mockDateDecorator], + parameters: { + // Exported for testing + test: { + MOCKED_TODAY_DATE, + }, + mockdate: new Date(MOCKED_TODAY_DATE), + docs: { + // Required in this story due to react-hook-form conflicting with + // Storybook somehow. + // See https://github.com/storybookjs/storybook/issues/12747. + source: { + type: 'code', + }, + }, + }, +} as Meta + +const baseSchema: DateFieldSchema = { + dateValidation: { + customMaxDate: null, + customMinDate: null, + selectedDateValidation: null, + }, + title: 'Date field snapshot', + description: '', + required: true, + disabled: false, + fieldType: BasicField.Date, + _id: '611b94dfbb9e300012f702a7', +} + +interface StoryDateFieldProps extends DateFieldProps { + defaultValue?: string +} + +const Template: Story = ({ defaultValue, ...args }) => { + const formMethods = useForm({ + defaultValues: { + [args.schema._id]: defaultValue, + }, + }) + + const [submitValues, setSubmitValues] = useState() + + const onSubmit = (values: Record) => { + setSubmitValues(values[args.schema._id] || 'Nothing was selected') + } + + useEffect(() => { + if (defaultValue !== undefined) { + formMethods.trigger() + } + }, []) + + return ( + +
+ + + {submitValues && You have submitted: {submitValues}} + +
+ ) +} + +export const ValidationRequired = Template.bind({}) +ValidationRequired.args = { + schema: baseSchema, + defaultValue: '', +} + +export const ValidationOptional = Template.bind({}) +ValidationOptional.args = { + schema: { + ...baseSchema, + required: false, + description: 'Date field is optional', + }, + defaultValue: '', +} + +export const ValidationNoFuture = Template.bind({}) +ValidationNoFuture.args = { + schema: { + ...baseSchema, + description: 'Future dates are disallowed', + dateValidation: { + customMaxDate: null, + customMinDate: null, + selectedDateValidation: DateSelectedValidation.NoFuture, + }, + }, + defaultValue: lightFormat( + addDays(new Date(MOCKED_TODAY_DATE), 10), + 'yyyy-MM-dd', + ), +} + +export const ValidationNoPast = Template.bind({}) +ValidationNoPast.args = { + schema: { + ...baseSchema, + description: 'Past dates are disallowed', + dateValidation: { + customMaxDate: null, + customMinDate: null, + selectedDateValidation: DateSelectedValidation.NoPast, + }, + }, + defaultValue: lightFormat( + addDays(new Date(MOCKED_TODAY_DATE), -10), + 'yyyy-MM-dd', + ), +} + +export const ValidationCustomRange = Template.bind({}) +ValidationCustomRange.args = { + schema: { + ...baseSchema, + description: 'Only 12 December to 25 December 2021 is allowed', + dateValidation: { + customMaxDate: new Date('2021-12-25'), + customMinDate: new Date('2021-12-12'), + selectedDateValidation: DateSelectedValidation.Custom, + }, + }, + defaultValue: '2021-12-26', +} diff --git a/frontend/src/templates/Field/Date/DateField.test.tsx b/frontend/src/templates/Field/Date/DateField.test.tsx new file mode 100644 index 0000000000..dbba994f35 --- /dev/null +++ b/frontend/src/templates/Field/Date/DateField.test.tsx @@ -0,0 +1,330 @@ +import { composeStories } from '@storybook/testing-react' +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { addDays, isBefore, lightFormat } from 'date-fns' + +import { REQUIRED_ERROR } from '~constants/validation' + +import * as stories from './DateField.stories' + +const { + ValidationOptional, + ValidationRequired, + ValidationNoFuture, + ValidationNoPast, + ValidationCustomRange, +} = composeStories(stories) + +const { MOCKED_TODAY_DATE } = stories.default.parameters?.test + +describe('required field', () => { + it('renders error when field is empty before submitting', async () => { + // Arrange + await act(async () => { + // `defaultValue=undefined` so trigger does not run in the story. + render() + }) + const submitButton = screen.getByRole('button', { name: /submit/i }) + + // Act + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show error message. + expect(screen.getByText(REQUIRED_ERROR)).toBeInTheDocument() + }) +}) + +describe('optional field', () => { + it('renders success even when field is empty before submitting', async () => { + // Arrange + await act(async () => { + render() + }) + const submitButton = screen.getByRole('button', { name: /submit/i }) + + // Act + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + expect(screen.getByText(/you have submitted/i)).toBeInTheDocument() + }) + + it('renders success when submitting with valid date input', async () => { + // Arrange + const schema = ValidationOptional.args?.schema + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + const validDate = '2011-11-11' + await act(async () => userEvent.type(input, validDate)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + const success = screen.getByText(`You have submitted: ${validDate}`) + expect(success).not.toBeNull() + const error = screen.queryByText('Please fill in required field') + expect(error).toBeNull() + }) +}) + +describe('validation', () => { + describe('ValidationNoFuture', () => { + it('renders invalid date error when future date is selected', async () => { + // Arrange + const schema = ValidationNoFuture.args?.schema + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + const invalidDate = '2031-11-11' + await act(async () => userEvent.type(input, invalidDate)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show error message. + const error = screen.queryByText('Only dates today or before are allowed') + expect(error).not.toBeNull() + }) + + it('renders success when "today" is selected', async () => { + // Arrange + const schema = ValidationNoFuture.args?.schema + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + await act(async () => userEvent.type(input, MOCKED_TODAY_DATE)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + const success = screen.getByText( + `You have submitted: ${MOCKED_TODAY_DATE}`, + ) + expect(success).not.toBeNull() + }) + + it('renders success when non-future date is selected', async () => { + // Arrange + const schema = ValidationNoFuture.args?.schema + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + const pastDate = lightFormat( + addDays(new Date(MOCKED_TODAY_DATE), -10), + 'yyyy-MM-dd', + ) + await act(async () => userEvent.type(input, pastDate)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + const success = screen.getByText(`You have submitted: ${pastDate}`) + expect(success).not.toBeNull() + }) + }) + + describe('ValidationNoPast', () => { + it('renders invalid date error when past date is selected', async () => { + // Arrange + const schema = ValidationNoPast.args?.schema + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + const pastDate = lightFormat( + addDays(new Date(MOCKED_TODAY_DATE), -10), + 'yyyy-MM-dd', + ) + await act(async () => userEvent.type(input, pastDate)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show error message. + const error = screen.queryByText('Only dates today or after are allowed') + expect(error).not.toBeNull() + }) + + it('renders success when "today" is selected', async () => { + // Arrange + const schema = ValidationNoPast.args?.schema + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + await act(async () => userEvent.type(input, MOCKED_TODAY_DATE)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + const success = screen.getByText( + `You have submitted: ${MOCKED_TODAY_DATE}`, + ) + expect(success).not.toBeNull() + }) + + it('renders success when future date is selected', async () => { + // Arrange + const schema = ValidationNoPast.args?.schema + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + const futureDate = lightFormat( + addDays(new Date(MOCKED_TODAY_DATE), 5), + 'yyyy-MM-dd', + ) + await act(async () => userEvent.type(input, futureDate)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + const success = screen.getByText(`You have submitted: ${futureDate}`) + expect(success).not.toBeNull() + }) + }) + + describe('ValidationCustomRange', () => { + it('renders invalid date error when date after max is selected', async () => { + // Arrange + const schema = ValidationCustomRange.args?.schema + const { customMaxDate } = schema.dateValidation + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + const afterMaxDate = lightFormat(addDays(customMaxDate, 10), 'yyyy-MM-dd') + await act(async () => userEvent.type(input, afterMaxDate)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show error message. + const error = screen.queryByText( + 'Selected date is not within the allowed date range', + ) + expect(error).not.toBeNull() + }) + + it('renders invalid date error when date before min is selected', async () => { + // Arrange + const schema = ValidationCustomRange.args?.schema + const { customMinDate } = schema.dateValidation + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + // Act + const beforeMinDate = lightFormat( + addDays(customMinDate, -10), + 'yyyy-MM-dd', + ) + await act(async () => userEvent.type(input, beforeMinDate)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show error message. + const error = screen.queryByText( + 'Selected date is not within the allowed date range', + ) + expect(error).not.toBeNull() + }) + + it('renders success when selected date is in range', async () => { + // Arrange + const schema = ValidationCustomRange.args?.schema + const { customMinDate, customMaxDate } = schema.dateValidation + await act(async () => { + render() + }) + const input = screen.getByLabelText( + new RegExp(schema!.title, 'i'), + ) as HTMLInputElement + const submitButton = screen.getByRole('button', { name: /submit/i }) + + expect(input.value).toBe('') + + const inRangeDate = addDays(customMinDate, 10) + const inRangeDateString = lightFormat(inRangeDate, 'yyyy-MM-dd') + // Should be in range. + expect(isBefore(inRangeDate, customMaxDate)).toEqual(true) + + // Act + await act(async () => userEvent.type(input, inRangeDateString)) + await act(async () => userEvent.click(submitButton)) + + // Assert + // Should show success message. + const success = screen.getByText( + `You have submitted: ${inRangeDateString}`, + ) + expect(success).not.toBeNull() + }) + }) +}) diff --git a/frontend/src/templates/Field/Date/DateField.tsx b/frontend/src/templates/Field/Date/DateField.tsx new file mode 100644 index 0000000000..67491a582e --- /dev/null +++ b/frontend/src/templates/Field/Date/DateField.tsx @@ -0,0 +1,70 @@ +import { useCallback, useMemo } from 'react' +import { Controller } from 'react-hook-form' + +import { + DateFieldBase, + DateSelectedValidation, + FormFieldWithId, +} from '~shared/types/field' + +import { + isDateAfterToday, + isDateBeforeToday, + isDateOutOfRange, +} from '~utils/date' +import { createDateValidationRules } from '~utils/fieldValidation' +import DateInput from '~components/DatePicker' + +import { BaseFieldProps, FieldContainer } from '../FieldContainer' + +export type DateFieldSchema = FormFieldWithId +export interface DateFieldProps extends BaseFieldProps { + schema: DateFieldSchema +} + +/** + * @precondition Must have a parent `react-hook-form#FormProvider` component. + */ +export const DateField = ({ + schema, + questionNumber, +}: DateFieldProps): JSX.Element => { + const validationRules = useMemo( + () => createDateValidationRules(schema), + [schema], + ) + + const isDateUnavailable = useCallback( + (date: Date) => { + const { selectedDateValidation } = schema.dateValidation + // All dates available. + if (!selectedDateValidation) return false + + switch (selectedDateValidation) { + case DateSelectedValidation.NoPast: + return isDateBeforeToday(date) + case DateSelectedValidation.NoFuture: + return isDateAfterToday(date) + case DateSelectedValidation.Custom: { + const { customMinDate, customMaxDate } = schema.dateValidation + return isDateOutOfRange(date, customMinDate, customMaxDate) + } + default: + return false + } + }, + [schema.dateValidation], + ) + + return ( + + ( + + )} + /> + + ) +} diff --git a/frontend/src/templates/Field/Date/index.ts b/frontend/src/templates/Field/Date/index.ts new file mode 100644 index 0000000000..84e0e697c0 --- /dev/null +++ b/frontend/src/templates/Field/Date/index.ts @@ -0,0 +1 @@ +export { DateField as default } from './DateField' diff --git a/frontend/src/templates/Field/index.ts b/frontend/src/templates/Field/index.ts index 345156e776..f9cc422bbe 100644 --- a/frontend/src/templates/Field/index.ts +++ b/frontend/src/templates/Field/index.ts @@ -1,5 +1,6 @@ import AttachmentField from './Attachment' import CheckboxField from './Checkbox' +import DateField from './Date' import DecimalField from './Decimal' import DropdownField from './Dropdown' import EmailField from './Email' @@ -21,6 +22,7 @@ import YesNoField from './YesNo' export { AttachmentField, CheckboxField, + DateField, DecimalField, DropdownField, EmailField, diff --git a/frontend/src/theme/components/Button.ts b/frontend/src/theme/components/Button.ts index 8349c656a2..b242a5cfc2 100644 --- a/frontend/src/theme/components/Button.ts +++ b/frontend/src/theme/components/Button.ts @@ -1,4 +1,4 @@ -import { SystemStyleFunction } from '@chakra-ui/theme-tools' +import { getColor, SystemStyleFunction } from '@chakra-ui/theme-tools' import merge from 'lodash/merge' import { textStyles } from '../textStyles' @@ -11,6 +11,7 @@ export type ThemeButtonVariant = | 'outline' | 'clear' | 'link' + | 'inputAttached' const variantSolid: SystemStyleFunction = (props) => { const { colorScheme: c } = props @@ -135,6 +136,43 @@ const variantLink: SystemStyleFunction = (props) => { }) } +const variantInputAttached: SystemStyleFunction = (props) => { + const { + focusBorderColor: fc = `${props.colorScheme}.500`, + errorBorderColor: ec = `danger.500`, + theme, + } = props + + return { + fontSize: '1.25rem', + color: 'secondary.500', + ml: '-1px', + borderColor: 'neutral.400', + borderRadius: 0, + _hover: { + bg: 'neutral.100', + }, + _active: { + borderColor: getColor(theme, fc), + bg: 'white', + zIndex: 1, + _hover: { + bg: 'neutral.100', + }, + }, + _invalid: { + // Remove extra 1px of outline. + borderColor: getColor(theme, ec), + boxShadow: 'none', + }, + _focus: { + borderColor: fc, + boxShadow: `0 0 0 1px ${getColor(theme, fc)}`, + zIndex: 1, + }, + } +} + export const Button = { baseStyle: { ...textStyles['subhead-1'], @@ -162,6 +200,7 @@ export const Button = { outline: variantOutlineReverse, clear: variantClear, link: variantLink, + inputAttached: variantInputAttached, }, defaultProps: { variant: 'solid', diff --git a/frontend/src/theme/components/DateInput.ts b/frontend/src/theme/components/DateInput.ts index b2bc661938..b3383d1620 100644 --- a/frontend/src/theme/components/DateInput.ts +++ b/frontend/src/theme/components/DateInput.ts @@ -1,4 +1,9 @@ -import { anatomy, getColor, SystemStyleFunction } from '@chakra-ui/theme-tools' +import { + anatomy, + getColor, + PartsStyleObject, + SystemStyleFunction, +} from '@chakra-ui/theme-tools' import { ComponentMultiStyleConfig } from '~theme/types' @@ -32,10 +37,6 @@ const baseDayOfMonthStyles: SystemStyleFunction = ({ : isOutsideCurrMonth ? 'secondary.300' : 'secondary.500', - p: { - base: 0, - md: 0.75, - }, outline: 'none', border: '1px solid', borderColor: isToday @@ -55,15 +56,48 @@ const baseDayOfMonthStyles: SystemStyleFunction = ({ bg: 'transparent', textDecor: 'line-through', }, - w: { - base: '2rem', - md: '3rem', + } +} + +const sizes: Record> = { + md: { + dayOfMonth: { + p: { + base: 0, + md: 0.75, + }, + w: { + base: '2rem', + md: '3rem', + }, + h: { + base: '2rem', + md: '3rem', + }, + }, + monthYearSelectorContainer: { + pt: '0.75rem', + h: '3.5rem', }, - h: { - base: '2rem', - md: '3rem', + calendarContainer: { + pb: '1rem', + px: '0.625rem', + mb: '-1px', }, - } + dayNamesContainer: { + w: { + base: '2.25rem', + md: '3.25rem', + }, + h: { + base: '2rem', + md: '3rem', + }, + }, + todayLinkContainer: { + py: '0.75rem', + }, + }, } export const DateInput: ComponentMultiStyleConfig = { @@ -76,7 +110,6 @@ export const DateInput: ComponentMultiStyleConfig = { monthYearSelectorContainer: { display: 'flex', justifyContent: 'space-between', - py: '0.375rem', }, monthYearDropdownContainer: { display: 'flex', @@ -87,9 +120,6 @@ export const DateInput: ComponentMultiStyleConfig = { justifyContent: 'flex-end', }, calendarContainer: { - pb: '1rem', - px: '0.625rem', - mb: '-1px', borderBottom: '1px solid', borderColor: 'neutral.300', }, @@ -103,23 +133,16 @@ export const DateInput: ComponentMultiStyleConfig = { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - w: { - base: '2rem', - md: '3.25rem', - }, - h: { - base: '2rem', - md: '3rem', - }, }, dayOfMonth: baseDayOfMonthStyles(props), todayLinkContainer: { textAlign: 'center', - py: '0.75rem', }, } }, + sizes, defaultProps: { colorScheme: 'primary', + size: 'md', }, } diff --git a/frontend/src/utils/date.ts b/frontend/src/utils/date.ts new file mode 100644 index 0000000000..1c9ebdcf5e --- /dev/null +++ b/frontend/src/utils/date.ts @@ -0,0 +1,64 @@ +import { addDays, endOfToday, isAfter, isBefore, startOfToday } from 'date-fns' + +/** + * Checks whether the current date is before today + * @param date The date to check + * @returns True if the date is before today, false otherwise + * @note this is not equivalent to `!isDateAfterToday(date)`, and will fail if the date is indeed today. + * @example + * ```ts + * // today is 2020-01-01 + * isDateAfterToday('2020-01-01') // false + * isDateBeforeToday('2020-01-01') // also false + * + * isDateAfterToday('2020-01-01') === !isDateBeforeToday('2020-01-01') // false + * ``` + */ +export const isDateBeforeToday = (date: number | Date) => { + return isBefore(date, startOfToday()) +} + +/** + * Checks whether the current date is after today + * @param date The date to check + * @returns True if the date is after today, false otherwise + * @note this is not equivalent to `!isDateBeforeToday(date)`, and will fail if the date is indeed today. + * @example + * ```ts + * // today is 2020-01-01 + * isDateAfterToday('2020-01-01') // false + * isDateBeforeToday('2020-01-01') // also false + * + * isDateAfterToday('2020-01-01') === !isDateBeforeToday('2020-01-01') // false + * ``` + */ +export const isDateAfterToday = (date: number | Date) => { + return isAfter(date, endOfToday()) +} + +/** + * Checks whether given date is out of (start, end] range, inclusive start only. + * If no start or end is given, it will be treated an unbounded range in that direction. + * @param date Date to check + * @param start Start of range + * @param end End of range + * @returns Whether date is out of range + */ +export const isDateOutOfRange = ( + date: number | Date, + start?: number | Date | null, + end?: number | Date | null, +) => { + const inclusiveStart = start ? addDays(start, -1) : null + if (inclusiveStart && end) { + return isBefore(date, inclusiveStart) || isAfter(date, end) + } + if (inclusiveStart) { + return isBefore(date, inclusiveStart) + } + if (end) { + return isAfter(date, end) + } + + return false +} diff --git a/frontend/src/utils/fieldValidation.ts b/frontend/src/utils/fieldValidation.ts index ac2378fe21..a9f64453bb 100644 --- a/frontend/src/utils/fieldValidation.ts +++ b/frontend/src/utils/fieldValidation.ts @@ -3,12 +3,15 @@ * to the field schema. */ import { RegisterOptions } from 'react-hook-form' +import { isDate, parseISO } from 'date-fns' import simplur from 'simplur' import validator from 'validator' import { AttachmentFieldBase, CheckboxFieldBase, + DateFieldBase, + DateSelectedValidation, DecimalFieldBase, DropdownFieldBase, EmailFieldBase, @@ -39,6 +42,7 @@ import { REQUIRED_ERROR, } from '~constants/validation' +import { isDateAfterToday, isDateBeforeToday, isDateOutOfRange } from './date' import { formatNumberToLocaleString } from './stringFormat' type OmitUnusedProps = Omit< @@ -304,6 +308,59 @@ export const createCheckboxValidationRules: ValidationRuleFn< } } +export const createDateValidationRules: ValidationRuleFn = ( + schema, +) => { + return { + ...createBaseValidationRules(schema), + validate: { + validDate: (val) => + !val || isDate(parseISO(val)) || 'Please enter a valid date', + noFuture: (val) => { + if ( + !val || + schema.dateValidation.selectedDateValidation !== + DateSelectedValidation.NoFuture + ) { + return true + } + return ( + !isDateAfterToday(parseISO(val)) || + 'Only dates today or before are allowed' + ) + }, + noPast: (val) => { + if ( + !val || + schema.dateValidation.selectedDateValidation !== + DateSelectedValidation.NoPast + ) { + return true + } + return ( + !isDateBeforeToday(parseISO(val)) || + 'Only dates today or after are allowed' + ) + }, + range: (val) => { + if ( + !val || + schema.dateValidation.selectedDateValidation !== + DateSelectedValidation.Custom + ) { + return true + } + + const { customMinDate, customMaxDate } = schema.dateValidation ?? {} + return ( + !isDateOutOfRange(parseISO(val), customMinDate, customMaxDate) || + 'Selected date is not within the allowed date range' + ) + }, + }, + } +} + export const createRadioValidationRules: ValidationRuleFn = ( schema, ) => {