Subject Under Test
An input component integrating TextFeild
of mui
with React Hook Form. It uses the Controller
component from React Hook Form(RHF) and configures TextField
to handle validations and more. I use this component instead of TextField
from mui
in all my forms. There are three benefits to this approach(adaptor pattern):
- It provides RHF integration
- It is a customised version of
TextField
with some common functions that meet the business requirements - It is an adaptor bridging the RHF forms and
mui
so that the form components do not dependmui
, which made upgrading or replacingmui
very easy.
Target Users
The SUT's target users are developers, and it is to be used inside an RHF form only. Therefore the behaviours and tests are focused on the expectations of the developers.
Behaviours
- It inherits all the behaviours from
TextField
ofmui
and accepts allTextField
props as-is. - It takes
name
,formContext
anddefaultValue
required props and registers theTextField
to the form context of RHF - It has two modes: edit mode and read-only mode. In read-only mode, it is disabled and rendered as a standard(underline)
TextField
. In edit mode, it is rendered as outlinedTextField
. - It hides if
hidden
is true. - It builds in the
required
validation rule and takes arequired
prop. - It accepts validation rules and enforces them.
- It formats numbers with thousands separator commas by default and accept
numericProps
of typeNumberFormatProps
fromreact-number-format
for further customisation. - It defaults to size small.
- It takes an optional
onChange
prop. On change, it will trigger the givenonChange
method and update input value.
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 { useForm } from 'react-hook-form'; | |
import { EpicInput } from './EpicInput'; | |
import { render, waitFor } from '@testing-library/react'; | |
import userEvent from '@testing-library/user-event'; | |
import { EpicInputProps } from './EpicInputProps'; | |
import { emailRule } from './rules'; | |
type Model = { | |
inputField?: string | number; | |
}; | |
const onSubmit = jest.fn(); | |
function EpicInputWithFormContext({ | |
initialValue, | |
...props | |
}: Omit<EpicInputProps, 'formContext' | 'name'> & { initialValue?: Model }) { | |
const formContext = useForm({ | |
defaultValues: initialValue, | |
}); | |
const { | |
handleSubmit, | |
formState: { errors }, | |
} = formContext; | |
const submit = (data: Model) => { | |
onSubmit(data); | |
}; | |
return ( | |
<> | |
<div data-testid={'formErrors'}>{JSON.stringify(errors)}</div> | |
<form | |
onSubmit={(e) => { | |
e.stopPropagation(); | |
e.preventDefault(); | |
handleSubmit(submit)(e); | |
}} | |
> | |
<EpicInput | |
label="Input Label" | |
name={'inputField'} | |
formContext={formContext} | |
error={!!errors.inputField} | |
helperText={errors.inputField?.message} | |
{...props} | |
/> | |
<button type="submit">Submit</button> | |
</form> | |
</> | |
); | |
} | |
beforeEach(() => { | |
onSubmit.mockClear(); | |
}); | |
describe('Appearance', function () { | |
it('should set size to small by default and can be set to medium', function () { | |
const { getByLabelText, rerender } = render(<EpicInputWithFormContext canEdit />); | |
const input = getByLabelText(/input label/i); | |
expect(input.className).toMatch(/inputSizeSmall/i); | |
rerender(<EpicInputWithFormContext canEdit={false} />); | |
const input1 = getByLabelText(/input label/i); | |
expect(input1.className).toMatch(/inputSizeSmall/i); | |
rerender(<EpicInputWithFormContext size={'medium'} />); | |
const input2 = getByLabelText(/input label/i); | |
expect(input2.className).not.toMatch(/inputSizeSmall/i); | |
}); | |
it('should always shrink label by default', function () { | |
const { getByText } = render(<EpicInputWithFormContext />); | |
const label = getByText(/input label/i, { selector: 'label' }); | |
expect(label).toHaveAttribute('data-shrink', 'true'); | |
}); | |
it('should be full width by default', function () { | |
const { getByLabelText } = render(<EpicInputWithFormContext />); | |
const input = getByLabelText(/input label/i); | |
expect(input.closest('div')?.className).toMatch(/fullwidth/i); | |
}); | |
it('should render outlined style in edit mode', function () { | |
const { getByLabelText } = render(<EpicInputWithFormContext />); | |
const input = getByLabelText(/input label/i); | |
expect(input.className).toMatch(/outlined/i); | |
}); | |
it('should apple standard style(underline) in read mode', function () { | |
const { getByLabelText } = render(<EpicInputWithFormContext canEdit={false} />); | |
const input = getByLabelText(/input label/i); | |
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( | |
<EpicInputWithFormContext required canEdit={false} /> | |
); | |
expect(getByLabelText(/input label/i)).toBeInTheDocument(); | |
expect(queryByLabelText(/\*/i)).toBeNull(); | |
rerender(<EpicInputWithFormContext required canEdit />); | |
expect(getByLabelText(/input label/i)).toBeInTheDocument(); | |
expect(queryByLabelText(/\*/i)).toBeInTheDocument(); | |
}); | |
}); | |
describe('Default behaviours', function () { | |
it('should have name as test id', function () { | |
const { getByTestId } = render(<EpicInputWithFormContext />); | |
expect(getByTestId(/inputField/i)).toBeInTheDocument(); | |
}); | |
it('should be default to edit mode if canEdit not specified', async function () { | |
const { getByLabelText } = render(<EpicInputWithFormContext />); | |
const input = getByLabelText(/input label/i); | |
expect(input).not.toHaveAttribute('disabled'); | |
}); | |
it('should be disabled if canEdit is false', async function () { | |
const { getByLabelText } = render(<EpicInputWithFormContext canEdit={false} />); | |
const input = getByLabelText(/input label/i); | |
expect(input).toHaveAttribute('disabled'); | |
}); | |
it('should be readonly if specified', async function () { | |
const { getByLabelText } = render(<EpicInputWithFormContext canEdit readOnly />); | |
const input = getByLabelText(/input label/i); | |
expect(input).toHaveAttribute('readonly'); | |
}); | |
it('should initial value', function () { | |
const { getByLabelText } = render( | |
<EpicInputWithFormContext initialValue={{ inputField: 'initial value' }} /> | |
); | |
const input = getByLabelText(/input label/i); | |
expect(input).toHaveValue('initial value'); | |
}); | |
it('should hide if hidden is true', function () { | |
const { getByLabelText } = render(<EpicInputWithFormContext hidden />); | |
const input = getByLabelText(/input label/i); | |
expect(input.closest('div[style="display: none;"]')).toBeInTheDocument(); | |
}); | |
it('should submit value and call onChange event', async function () { | |
const onChange = jest.fn(); | |
const { getByLabelText, getByText } = render(<EpicInputWithFormContext onChange={onChange} />); | |
const input = getByLabelText(/input label/i); | |
userEvent.type(input, 'test'); | |
['t', 'te', 'tes', 'test'].forEach((char) => { | |
expect(onChange).toHaveBeenCalledWith(char); | |
}); | |
userEvent.click(getByText(/submit/i)); | |
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: 'test' })); | |
}); | |
}); | |
describe('Validations', function () { | |
it('should show and hide required validation messages as appropriate', async function () { | |
const { getByLabelText, getByText, queryByText, getByTestId, findByTestId } = render( | |
<EpicInputWithFormContext canEdit required /> | |
); | |
const input = getByLabelText(/\*/); | |
userEvent.click(getByText(/submit/i)); | |
expect((await findByTestId('formErrors')).innerHTML).toMatchInlineSnapshot( | |
`"{\\"inputField\\":{\\"type\\":\\"required\\",\\"message\\":\\"Required\\",\\"ref\\":{}}}"` | |
); | |
expect(input).toHaveAttribute('aria-invalid', 'true'); | |
expect(onSubmit).not.toHaveBeenCalled(); | |
expect(getByText('Required')).toBeInTheDocument(); | |
userEvent.type(input, 'I am valid'); | |
userEvent.click(getByText(/submit/i)); | |
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: 'I am valid' })); | |
expect(getByTestId('formErrors').innerHTML).toBe('{}'); | |
expect(input).toHaveAttribute('aria-invalid', 'false'); | |
expect(queryByText('Required')).toBeNull(); | |
}); | |
it('should enforce validation rules passed in as props', async function () { | |
const { getByLabelText, getByTestId, queryByText, findByTestId, getByText } = render( | |
<EpicInputWithFormContext canEdit rules={emailRule} /> | |
); | |
const input = getByLabelText(/input label/i); | |
userEvent.type(input, 'invalid email'); | |
userEvent.click(getByText('Submit')); | |
expect((await findByTestId('formErrors')).innerHTML).toMatchInlineSnapshot( | |
`"{\\"inputField\\":{\\"type\\":\\"pattern\\",\\"message\\":\\"Email is not valid\\",\\"ref\\":{}}}"` | |
); | |
const validationMessage = 'Email is not valid'; | |
expect(onSubmit).not.toHaveBeenCalled(); | |
expect(input).toHaveAttribute('aria-invalid', 'true'); | |
expect(getByText(validationMessage)).toBeInTheDocument(); | |
userEvent.clear(input); | |
userEvent.type(input, 'valid@gmail.com'); | |
userEvent.click(getByText('Submit')); | |
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: 'valid@gmail.com' })); | |
expect(getByTestId('formErrors').innerHTML).toBe('{}'); | |
expect(input).toHaveAttribute('aria-invalid', 'false'); | |
expect(queryByText(validationMessage)).toBeNull(); | |
}); | |
}); | |
describe('Number Formatting', function () { | |
it('should format number', async function () { | |
const { getByText, getByLabelText } = render( | |
<EpicInputWithFormContext canEdit type={'number'} /> | |
); | |
const input = getByLabelText(/input label/i); | |
userEvent.type(input, '1000563.50689034'); | |
userEvent.click(getByText('Submit')); | |
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: '1000563.50689034' })); | |
expect(input).toHaveValue('1,000,563.50689034'); | |
}); | |
it('should add prefix and limit decimal scale as specified', async function () { | |
const { getByText, getByLabelText } = render( | |
<EpicInputWithFormContext | |
canEdit | |
type={'number'} | |
numericProps={{ prefix: '£', decimalScale: 2 }} | |
/> | |
); | |
const input = getByLabelText(/input label/i); | |
userEvent.type(input, '1000563.50689034'); | |
userEvent.click(getByText('Submit')); | |
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ inputField: '1000563.50' })); | |
expect(input).toHaveValue('£1,000,563.50'); | |
}); | |
}); |
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 React, { ElementType, FC } from 'react'; | |
import { Controller } from 'react-hook-form'; | |
import { callAll, useGeneratedId } from '../../utils'; | |
import { EpicInputProps } from './EpicInputProps'; | |
import { EpicTextField } from './EpicTextField'; | |
import { EpicNumberFormat } from './EpicNumberFormat'; | |
import { InputBaseComponentProps } from '@mui/material'; | |
export const EpicInput: FC<EpicInputProps> = ({ | |
defaultValue, | |
formContext, | |
name, | |
hidden, | |
readOnly = false, | |
canEdit = true, | |
size = 'small', | |
required, | |
type, | |
canEditVariant, | |
onChange, | |
viewVariant, | |
rules, | |
label, | |
InputProps, | |
numericProps = {}, | |
...props | |
}) => { | |
const { control } = formContext; | |
const generatedId = useGeneratedId(); | |
return ( | |
<Controller | |
control={control} | |
name={name} | |
rules={{ | |
required: { value: required ?? false, message: 'Required' }, | |
...rules, | |
}} | |
defaultValue={defaultValue} | |
render={({ field: { ref, value, onChange: renderOnChange, ...renderProps } }) => ( | |
<EpicTextField | |
size={size} | |
key={generatedId} | |
style={{ | |
display: hidden ? 'none' : 'inherit', | |
}} | |
inputRef={ref} | |
fullWidth | |
variant={canEdit ? canEditVariant || 'outlined' : viewVariant || 'standard'} | |
required={required && canEdit} | |
InputLabelProps={{ | |
shrink: true, | |
}} | |
type={type === 'number' ? 'text' : type} | |
InputProps={{ | |
...(type === 'number' | |
? { | |
inputComponent: | |
EpicNumberFormat as unknown as ElementType<InputBaseComponentProps>, | |
inputProps: numericProps, | |
} | |
: {}), | |
...InputProps, | |
disabled: !canEdit, | |
readOnly: readOnly, | |
}} | |
inputProps={{ | |
'data-testid': name, | |
}} | |
label={label} | |
onChange={(e) => { | |
let value: string | null | number = e.target.value; | |
if (value === '') { | |
value = null; | |
} | |
callAll(renderOnChange, onChange)(value); | |
}} | |
value={value ?? ''} | |
{...props} | |
{...renderProps} | |
/> | |
)} | |
/> | |
); | |
}; |
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 { StandardTextFieldProps } from '@mui/material'; | |
import { RegisterOptions, UseFormReturn } from 'react-hook-form'; | |
import { NumberFormatProps } from 'react-number-format'; | |
export interface EpicInputProps extends StandardTextFieldProps { | |
formContext: UseFormReturn<any>; | |
name: string; | |
canEdit?: boolean; | |
readOnly?: boolean; | |
defaultValue?: string | number | Date | null; | |
hidden?: boolean; | |
canEditVariant?: 'filled' | 'outlined' | 'standard' | undefined; | |
viewVariant?: 'filled' | 'outlined' | 'standard' | undefined; | |
onChange?: (event: any) => void; | |
rules?: Omit<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>; | |
numericProps?: NumberFormatProps; | |
} |
Notes
-
TestComponent
shows the usage of the SUT. Its props are extended from the SUT's props so that the tests can configure the SUT on the fly. - For good orders, the tests are grouped into four categories: appearance, behaviours, validations and number formatting.
- Appearance tests depend on how
mui
renders itsTextField
and assert the class names rendered bymui
. - Validation tests depend on RHF's validations and the render helper text of
TextField
. - The tests use
userEvent
to mimic end-user browser interactions. -
onSubmit
is mocked and cleared before each test. -
EpicTextField
is a styledTextField
with@emotion/styled
Top comments (0)