Subject Under Test
A date picker component integrating mui's date picker with React Hook Form's form context. It uses the Controller
component from React Hook Form(RHF) and configures mui's DatePicker
to handle validations and more. I use this component instead of mui
's DatePicker
in all my forms.
Behaviours
- It inherits all the behaviours from
DatePicker
ofmui
and accepts allDatePicker
props as-is. - It takes
name
,formContext
anddefaultValue
required props and registers theDatePicker
to the form context of RHF - It has two modes: edit mode and read-only mode. In read-only mode, it is disabled, has no date picker icon button and is rendered as a standard(underline)
TextField
. In edit mode, it is rendered as outlinedTextField
. - It builds in the
required
validation rule and takes arequired
prop. - It builds in a validation rule for invalid date input
- It accepts validation rules and enforces them.
- It takes an optional
onChange
prop. It will update the value and trigger the givenonChange
method on change. - It has a default mask and date format and can be changed with props.
- It defaults to size small, full width and shrink label.
- It set time to end of the day.
- It takes a
style
prop for styling the underlyingTextField
.
Code
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { FormForTesting } from '@epic/testing/react'; | |
import { render, waitFor } from '@testing-library/react'; | |
import { useForm } from 'react-hook-form'; | |
import { EpicDatePicker } from './EpicDatePicker'; | |
import { EpicDatePickerProps } from './EpicDatePickerProps'; | |
import AdapterDateFns from '@mui/lab/AdapterDateFns'; | |
import { LocalizationProvider } from '@mui/lab'; | |
import userEvent from '@testing-library/user-event'; | |
import { endOfDay } from 'date-fns'; | |
type Model = { | |
date?: Date; | |
}; | |
beforeAll(() => { | |
// add window.matchMedia | |
// this is necessary for the date picker to be rendered in desktop mode. | |
// if this is not provided, the mobile mode is rendered, which might lead to unexpected behavior | |
Object.defineProperty(window, 'matchMedia', { | |
writable: true, | |
value: (query: any) => ({ | |
media: query, | |
// this is the media query that @material-ui/pickers uses to determine if a device is a desktop device | |
matches: query === '(pointer: fine)', | |
onchange: () => {}, | |
addEventListener: () => {}, | |
removeEventListener: () => {}, | |
addListener: () => {}, | |
removeListener: () => {}, | |
dispatchEvent: () => false, | |
}), | |
}); | |
}); | |
afterAll(() => { | |
// @ts-ignore | |
delete window.matchMedia; | |
}); | |
const onSubmit = jest.fn(); | |
function TestComponent({ | |
initialValue, | |
...props | |
}: Omit<EpicDatePickerProps, 'formContext' | 'name' | 'label'> & { initialValue?: Model }) { | |
const formContext = useForm<Model>({ | |
defaultValues: initialValue, | |
}); | |
const { | |
formState: { errors }, | |
} = formContext; | |
const submit = (d: Model) => { | |
onSubmit(d); | |
}; | |
return ( | |
<LocalizationProvider dateAdapter={AdapterDateFns}> | |
<FormForTesting submit={submit} formContext={formContext}> | |
<EpicDatePicker | |
formContext={formContext} | |
name={'date'} | |
label={'Date'} | |
{...props} | |
error={!!errors} | |
helperText={errors.date?.message} | |
/> | |
<button type="submit">Submit</button> | |
</FormForTesting> | |
</LocalizationProvider> | |
); | |
} | |
describe('Appearance', function () { | |
it('should set size to small by default and can be set to medium', function () { | |
const { getByLabelText, rerender } = render(<TestComponent canEdit />); | |
const input = getByLabelText('Date'); | |
expect(input.className).toMatch(/inputSizeSmall/i); | |
rerender(<TestComponent canEdit={false} />); | |
const input1 = getByLabelText('Date'); | |
expect(input1.className).toMatch(/inputSizeSmall/i); | |
rerender(<TestComponent size={'medium'} />); | |
const input2 = getByLabelText('Date'); | |
expect(input2.className).not.toMatch(/inputSizeSmall/i); | |
}); | |
it('should always shrink label by default', function () { | |
const { getByText } = render(<TestComponent />); | |
const label = getByText('Date', { selector: 'label' }); | |
expect(label).toHaveAttribute('data-shrink', 'true'); | |
}); | |
it('should be full width by default', function () { | |
const { getByLabelText } = render(<TestComponent />); | |
const input = getByLabelText('Date'); | |
expect(input.closest('div')?.className).toMatch(/fullwidth/i); | |
}); | |
it('should render outlined style in edit mode', function () { | |
const { getByLabelText } = render(<TestComponent />); | |
const input = getByLabelText('Date'); | |
expect(input.className).toMatch(/outlined/i); | |
}); | |
it('should apple standard style(underline) in read mode', function () { | |
const { getByLabelText } = render(<TestComponent canEdit={false} />); | |
const input = getByLabelText('Date'); | |
expect(input.closest('div')?.className).toMatch(/underline/i); | |
}); | |
it('should not show * on the label on required field in read mode', function () { | |
const { getByLabelText, queryByLabelText, rerender } = render( | |
<TestComponent required canEdit={false} /> | |
); | |
expect(getByLabelText(/Date/i)).toBeInTheDocument(); | |
expect(queryByLabelText(/\*/i)).toBeNull(); | |
// should show * with required and edit mode | |
rerender(<TestComponent required canEdit />); | |
expect(getByLabelText(/Date/i, { selector: 'input' })).toBeInTheDocument(); | |
expect(queryByLabelText(/\*/i)).toBeInTheDocument(); | |
}); | |
}); | |
describe('Behaviours', () => { | |
it('should be disabled and not show choose date icon button in non edit mode', async function () { | |
const { getByLabelText, queryByLabelText } = render(<TestComponent canEdit={false} />); | |
const input = getByLabelText('Date'); | |
expect(input).toBeDisabled(); | |
expect(queryByLabelText(/Choose Date/i)).toBeNull(); | |
}); | |
it('should not be disabled and show choose date icon button in edit mode', function () { | |
const { getByLabelText } = render(<TestComponent canEdit={true} />); | |
const input = getByLabelText('Date'); | |
expect(input).not.toBeDisabled(); | |
expect(getByLabelText(/Choose Date/i)).toBeInTheDocument(); | |
}); | |
it('should set default value', function () { | |
const { getByLabelText } = render(<TestComponent defaultValue={new Date(2022, 0, 1)} />); | |
const input = getByLabelText('Date'); | |
expect(input).toHaveValue('2022-01-01'); | |
}); | |
it('should apply styles', function () { | |
const { getByLabelText } = render(<TestComponent style={{ fontSize: '10px' }} />); | |
const input = getByLabelText('Date'); | |
expect(input.closest('div[style="font-size: 10px;"]')).toBeInTheDocument(); | |
}); | |
it('should have default mask of yyyy-MM-dd', () => { | |
const { getByLabelText } = render(<TestComponent />); | |
const input = getByLabelText('Date'); | |
userEvent.type(input, '2020-01-01'); | |
expect(input).toHaveValue('2020-01-01'); | |
}); | |
it('should format date to default format of yyyy-MM-dd', () => { | |
const { getByLabelText } = render( | |
<TestComponent initialValue={{ date: new Date(2022, 1, 1) }} /> | |
); | |
const input = getByLabelText('Date'); | |
expect(input).toHaveValue('2022-02-01'); | |
}); | |
it('should format date to given format', () => { | |
const { getByLabelText } = render( | |
<TestComponent | |
initialValue={{ date: new Date(2022, 1, 1) }} | |
mask={'__/__/____'} | |
inputFormat={'dd/MM/yyyy'} | |
/> | |
); | |
const input = getByLabelText('Date'); | |
expect(input).toHaveValue('01/02/2022'); | |
}); | |
}); | |
describe('Validations', function () { | |
it('should validate date', async function () { | |
const { getByLabelText, getByText, findByText } = render(<TestComponent />); | |
const input = getByLabelText('Date'); | |
userEvent.type(input, '010101'); | |
userEvent.click(getByText('Submit')); | |
expect(await findByText('Date is not valid')).toBeInTheDocument(); | |
}); | |
it('should validate required', async function () { | |
const { getByLabelText, getByText, findByText } = render(<TestComponent required />); | |
getByLabelText(/\*/); | |
userEvent.click(getByText('Submit')); | |
expect(await findByText('Required')).toBeInTheDocument(); | |
}); | |
it('should emit end of day date', async function () { | |
const { getByLabelText, getByText } = render(<TestComponent />); | |
const input = getByLabelText('Date'); | |
userEvent.type(input, '2020-01-01'); | |
userEvent.click(getByText('Submit')); | |
await waitFor(() => | |
expect(onSubmit).toHaveBeenCalledWith({ date: new Date(2020, 0, 1, 23, 59, 59, 999) }) | |
); | |
}); | |
it('should enforce rules', async function () { | |
const afterDate = { | |
validate: (v: Date) => { | |
return ( | |
endOfDay(v).getTime() > new Date(2020, 0, 1, 23, 59, 59, 999).getTime() || | |
'Date must be after 2020-01-01' | |
); | |
}, | |
}; | |
const { getByLabelText, getByText, findByText } = render(<TestComponent rules={afterDate} />); | |
const input = getByLabelText('Date'); | |
userEvent.type(input, '2020-01-01'); | |
userEvent.click(getByText('Submit')); | |
expect(await findByText('Date must be after 2020-01-01')).toBeInTheDocument(); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import styled from '@emotion/styled'; | |
import DatePicker from '@mui/lab/DatePicker'; | |
import React from 'react'; | |
import { Controller } from 'react-hook-form'; | |
import { callAll, ensureDate } from '../../utils'; | |
import { EpicDatePickerProps } from './EpicDatePickerProps'; | |
import { endOfDay, isValid } from 'date-fns'; | |
import { EpicTextField } from '../TextField'; | |
const StyledDatePicker = styled(DatePicker)` | |
padding-bottom: 15px; | |
`; | |
export function EpicDatePicker({ | |
defaultValue, | |
name, | |
formContext, | |
canEdit = true, | |
size = 'small', | |
required, | |
onChange, | |
style, | |
error, | |
helperText, | |
rules, | |
mask = '____-__-__', | |
inputFormat = 'yyyy-MM-dd', | |
...props | |
}: EpicDatePickerProps) { | |
const { control } = formContext; | |
return ( | |
<Controller | |
control={control} | |
name={name} | |
rules={{ | |
required: { value: required ?? false, message: 'Required' }, | |
validate: (v) => { | |
return isValid(ensureDate(v)) || v === null || 'Date is not valid'; | |
}, | |
...rules, | |
}} | |
defaultValue={defaultValue ?? null} | |
render={({ field: { value, ref, onChange: _onChange, ...rest } }) => ( | |
<StyledDatePicker | |
value={value ?? null} | |
onChange={(e) => { | |
if (isValid(ensureDate(e as string))) { | |
callAll(_onChange, onChange)(endOfDay(e as Date)); | |
return; | |
} | |
callAll(_onChange, onChange)(e); | |
}} | |
renderInput={(params) => ( | |
<EpicTextField | |
size={size} | |
variant={canEdit ? 'outlined' : 'standard'} | |
fullWidth | |
required={required && canEdit} | |
InputLabelProps={{ shrink: true }} | |
{...params} | |
{...rest} | |
style={{ ...style }} | |
inputProps={{ | |
...params.inputProps, | |
'data-testid': name, | |
}} | |
InputProps={{ | |
endAdornment: canEdit ? params?.InputProps?.endAdornment : undefined, | |
}} | |
helperText={helperText} | |
error={error} | |
/> | |
)} | |
disabled={!canEdit} | |
mask={mask} | |
inputFormat={inputFormat} | |
{...rest} | |
{...props} | |
/> | |
)} | |
/> | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { DatePickerProps } from '@mui/lab'; | |
import { CSSProperties } from 'react'; | |
import { RegisterOptions, UseFormReturn } from 'react-hook-form'; | |
export interface EpicDatePickerProps extends Partial<DatePickerProps> { | |
formContext: UseFormReturn<any>; | |
name: string; | |
canEdit?: boolean; | |
defaultValue?: Date | null; | |
size?: 'small' | 'medium'; | |
error?: boolean; | |
required?: boolean; | |
helperText?: string; | |
style?: CSSProperties; | |
rules?: Omit<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>; | |
} |
Notes
-
matchMedia
is mocked so that the date picker can be rendered in desktop mode with the date picker icon button -
TestComponent
sets up a React Hook Form environment and shows how the SUT can be used. -
FormForTesting
is a testing utility component for testing React Hook Form form components. - The tests are grouped into three categories: appearance, behaviours and validations.
Top comments (0)