DEV Community

Rex
Rex

Posted on

Testing a mui Auto Complete Adaptor Component integrated with React Hook Form

Subject Under Test

A mui auto-complete adaptor component integrated with React Hook Form's form context. It uses the Controller component from React Hook Form(RHF) and configures mui's Autocomplete to handle validations and more. I use this component instead of mui's Autocomplete in all my forms.

Behaviours

  1. It registers the Autocomplete component with RHF's form context
  2. It inherits all the behaviours from mui's' Autocomplete and accepts all Autocomplete props.
  3. It has three modes: edit, locked, and read-only. In edit mode, it renders a functional Autocomplete component. In locked mode and read-only mode, it renders a TextField component.
  4. It supports single and multiple selection modes
  5. It can limit the number of options it renders with optionLimit prop.
  6. It has a build-in required validation rule
  7. It accepts validation rules from rules prop.
  8. It can be locked(non-editable in edit mode), and it would show a lock icon with a tooltip explaining why it is locked.

Variants - a design decision

The component has three variants:

  1. EpicAutocomplete, a regular auto-complete. It has the same API surface as mui's Autocomplete component plus binding props for RHF.
  2. EpicAutocompleteWithManger, an auto-complete with an additional option for managing options, extends EpicAutocomplete and has extra required props: manager and managerLabel.
  3. EpicAutocompleteWithUrl, an auto-complete with a link icon button for users to navigate to the detail page of the selected entity. It extends EpicAutocomplete and has an extra prop: url.

Before writing the tests for this component, I did not have the above variants, and all the functions were crammed into a single component, which was a leaky implementation.

  1. Many instances don't need the manager or URL but carry the baggage with them.
  2. URL require react-router, and it adds unnecessary dependencies to users that don't use it
  3. The component internals are too complicated.

For the new structure, I moved the implementations into a base component called EpicAutocompleteBase.

  1. It is private and not exposed to external libraries. It cannot be used in forms directly.
  2. All three variants are composed of it.

With the above design, the API for the variants are more precise and lean, and they don't carry unnecessary dependencies with them.

I sincerely invite suggestions from my dear readers for a better approach.

Notes

  1. Each variant has its separate tests. Their tests are focused on their public APIs (props)
  2. No direct tests for EpicAutocompleteBase because three variants are testing it.

Code

import { FormForTesting } from '@epic/testing/react';
import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useForm } from 'react-hook-form';
import { EpicAutocomplete } from './EpicAutocomplete';
import { EpicAutocompleteProps } from './EpicAutocompleteProps';
const onSubmit = jest.fn();
jest.mock('../../locale', () => {
return {
useTranslation: jest.fn(() => ({
t: (key: string) => key,
})),
};
});
afterEach(() => {
jest.clearAllMocks();
});
type Model = { test: string };
function TestComponent(
props: Omit<
EpicAutocompleteProps,
'name' | 'label' | 'options' | 'formContext' | 'getOptionLabel'
>
) {
const formContext = useForm<Model>();
const {
formState: { errors },
} = formContext;
const submit = (data: Model) => {
onSubmit(data);
};
return (
<FormForTesting formContext={formContext} submit={submit}>
<EpicAutocomplete
name="test"
label="Test"
placeholder="Test"
options={['1', '2', '3']}
formContext={formContext}
getOptionLabel={(option: string) => option}
error={!!errors}
helperText={errors?.test?.message}
{...props}
/>
<button type={'submit'}>Submit</button>
</FormForTesting>
);
}
describe('Behaviours', function () {
it('should bind to form context', async function () {
const { getByLabelText, findByText, getByText } = render(<TestComponent />);
getByLabelText('Test');
const openButton = getByLabelText('Open');
userEvent.click(openButton);
const option1 = await findByText('1');
userEvent.click(option1);
expect(getByLabelText('Test')).toHaveValue('1');
const submit = getByText('Submit');
userEvent.click(submit);
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ test: '1' }));
});
it('should render disabled input without buttons in read only mode', async function () {
const { getByLabelText, queryByLabelText } = render(<TestComponent canEdit={false} />);
const input = getByLabelText('Test');
const openButton = queryByLabelText('Open');
expect(input).toBeDisabled();
expect(input).not.toHaveAttribute('aria-autocomplete');
expect(openButton).toBeNull();
});
it('should render readonly input without buttons in readonly edit mode', async function () {
const lockedReason = 'For Testing';
const { getByLabelText, queryByLabelText } = render(
<TestComponent canEdit={true} readOnly readOnlyReason={lockedReason} />
);
const input = getByLabelText('Test');
const openButton = queryByLabelText('Open');
expect(getByLabelText('Locked. For Testing')).toBeInTheDocument();
expect(input).toHaveAttribute('readonly');
expect(input).not.toHaveAttribute('aria-autocomplete');
expect(openButton).toBeNull();
});
it('should set default value', async function () {
const { getByLabelText, getByText } = render(<TestComponent defaultValue={'1'} />);
getByLabelText('Test');
const submit = getByText('Submit');
userEvent.click(submit);
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ test: '1' }));
});
it('should be full width by default', function () {
const { getByLabelText } = render(<TestComponent />);
const input = getByLabelText('Test');
expect(input.closest('div')?.className).toMatch(/fullwidth/i);
});
it('should support multiple selections', async function () {
const { getByLabelText, findByText, getByText, debug } = render(<TestComponent multiple />);
getByLabelText('Test', { selector: 'input' });
const openButton = getByLabelText('Open');
userEvent.click(openButton);
const option1 = await findByText('1');
const option2 = await findByText('2');
userEvent.click(option1);
userEvent.click(option2);
expect(await findByText('1', { selector: 'span.MuiChip-label' })).toBeInTheDocument();
expect(await findByText('2', { selector: 'span.MuiChip-label' })).toBeInTheDocument();
const submit = getByText('Submit');
userEvent.click(submit);
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ test: ['1', '2'] }));
});
it('should enforce option limit', async function () {
const { getByLabelText, findByText, queryByText, rerender } = render(<TestComponent />);
userEvent.click(getByLabelText('Open'));
expect(await findByText('3')).toBeInTheDocument();
rerender(<TestComponent optionLimit={2} />);
await findByText('1');
expect(queryByText('3')).toBeNull();
userEvent.type(getByLabelText('Test', { selector: 'input' }), '3');
expect(await findByText('3')).toBeInTheDocument();
});
it('should trigger given onChange event on change', async function () {
const onChange = jest.fn();
const { getByLabelText, findByText } = render(<TestComponent onChange={onChange} />);
getByLabelText('Test');
const openButton = getByLabelText('Open');
userEvent.click(openButton);
const option1 = await findByText('1');
userEvent.click(option1);
await waitFor(() => expect(onChange).toHaveBeenCalledWith('1'));
});
it('should apply text field configurations', function () {
const { getByLabelText } = render(
<TestComponent textFieldProps={{ style: { color: 'black' } }} />
);
const input = getByLabelText('Test', { selector: 'input' });
expect(input.closest('div[style="color: black;"]')).toBeInTheDocument();
});
});
describe('Validations', function () {
it('should validate required rule', async function () {
const { getByLabelText, findByText, getByText, debug } = render(<TestComponent required />);
getByLabelText(/Test/);
getByLabelText(/\*/);
userEvent.click(getByLabelText('Open'));
userEvent.click(getByText('Submit'));
expect(await findByText('Required')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it('should enforce given rules', async function () {
const minSelection = (minSelection = 2) => ({
validate: (value: string[]) => {
if (!value?.length || value.length < minSelection) {
return `Must select at least ${minSelection} options`;
}
},
});
const { getByLabelText, findByText, getByText, queryByText } = render(
<TestComponent multiple rules={minSelection()} />
);
const openButton = getByLabelText('Open');
userEvent.click(openButton);
const option1 = await findByText('1');
userEvent.click(option1);
const submit = getByText('Submit');
userEvent.click(submit);
expect(await findByText('Must select at least 2 options')).toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
userEvent.click(openButton);
const option2 = await findByText('2');
userEvent.click(option2);
userEvent.click(submit);
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ test: ['1', '2'] }));
expect(queryByText('Must select at least 2 options')).toBeNull();
});
});
import React from 'react';
import { EpicAutocompleteProps } from './EpicAutocompleteProps';
import { EpicAutoCompleteBase } from './EpicAutoCompleteBase';
export function EpicAutocomplete(props: EpicAutocompleteProps) {
return <EpicAutoCompleteBase {...props} />;
}
import { Checkbox, IconButton, Tooltip } from '@mui/material';
import { Lock } from '@mui/icons-material';
import Autocomplete, { createFilterOptions } from '@mui/material/Autocomplete';
import React, { FC, ReactNode, useCallback } from 'react';
import { Controller, ControllerRenderProps } from 'react-hook-form';
import { RiCheckboxBlankCircleLine, RiCheckboxCircleLine } from 'react-icons/ri';
import { useTranslation } from '../../locale';
import { callAll, useGeneratedId } from '../../utils';
import { EpicTextField } from '../TextField/EpicTextField';
import { EpicAutocompleteBaseProps } from './EpicAutocompleteBaseProps';
type RHFRenderProps = {
field: ControllerRenderProps;
};
export const EpicAutoCompleteBase: FC<EpicAutocompleteBaseProps> = ({
canEdit = true,
formContext,
name,
label,
options,
multiple,
getOptionLabel,
defaultValue,
textFieldProps,
onChange,
renderInput,
size = 'small',
optionLimit,
error,
helperText,
rules,
required,
onChangeFactory,
readOnly,
renderOption,
readOnlyReason,
disableCloseOnSelect,
InputPropsFactory,
...autoCompleteProps
}) => {
const { t } = useTranslation();
const filterOptions = createFilterOptions<any>({
matchFrom: 'any',
limit: optionLimit,
});
const { control } = formContext;
const renderText = useCallback(
(value: string | string[] | number | null | undefined): ReactNode => {
if (!value) {
return '';
}
if (typeof value === 'string' || typeof value === 'number') {
return getOptionLabel(`${value}`) || `${value}`;
}
return (value as string[]).map((x) => getOptionLabel(x)).join(',');
},
[getOptionLabel]
);
const generatedId = useGeneratedId();
const renderAutocomplete = ({
field: { onChange: _onChange, value, ...controllerProps },
}: RHFRenderProps) => (
<Autocomplete
id={generatedId}
value={value ?? (multiple ? [] : null)}
multiple={multiple || false}
disableCloseOnSelect={disableCloseOnSelect ?? (multiple || false)}
options={options}
size={size}
disableClearable={required}
filterOptions={optionLimit ? filterOptions : undefined}
onChange={
(onChangeFactory && onChangeFactory(_onChange)) ??
((e, data) => {
callAll(_onChange, onChange)(data);
})
}
renderOption={
multiple
? (props, option, state) => {
const { selected } = state;
return (
<li {...props}>
<Checkbox
icon={<RiCheckboxBlankCircleLine />}
checkedIcon={<RiCheckboxCircleLine />}
style={{ marginRight: 8 }}
checked={selected}
/>
{(renderOption && renderOption(props, option, state)) ?? getOptionLabel(option)}
</li>
);
}
: (props, option, state) => (
<li {...props}>
{(renderOption && renderOption(props, option, state)) ?? getOptionLabel(option)}
</li>
)
}
getOptionLabel={getOptionLabel}
renderInput={
renderInput ||
((params) => (
<EpicTextField
autoComplete={'off'}
variant={'outlined'}
label={label}
ref={params.InputProps.ref}
error={error}
helperText={helperText}
{...textFieldProps}
required={textFieldProps?.required ?? required}
{...params}
inputProps={{
...params.inputProps,
autoComplete: 'off',
'data-testid': name,
}}
InputLabelProps={{
shrink: true,
}}
/>
))
}
{...controllerProps}
{...autoCompleteProps}
/>
);
const renderReadOnlyLockedTextField = ({
field: { onChange: _onChange, value, ...controllerProps },
}: RHFRenderProps) => (
<EpicTextField
value={renderText(value) || ''}
label={label}
size={size}
fullWidth
variant={canEdit ? 'outlined' : 'standard'}
{...textFieldProps}
{...controllerProps}
required={false}
InputLabelProps={{
shrink: true,
}}
inputProps={{
'data-testid': name,
}}
InputProps={{
...(textFieldProps?.InputProps ?? {}),
readOnly: true,
endAdornment: canEdit ? (
<Tooltip title={`${t('Locked')}. ${readOnlyReason ?? ''}`}>
<IconButton size={'small'}>
<Lock fontSize={'inherit'} />
</IconButton>
</Tooltip>
) : undefined,
}}
/>
);
const renderDisabledTextField = ({
field: { onChange: _onChange, value, ...controllerProps },
}: RHFRenderProps) => (
<EpicTextField
value={renderText(value) || ''}
label={label}
id={generatedId}
size={size}
fullWidth
variant={'standard'}
{...textFieldProps}
{...controllerProps}
required={false}
InputLabelProps={{
shrink: true,
}}
inputProps={{
'data-testid': name,
}}
InputProps={{
...(textFieldProps?.InputProps ?? {}),
disabled: true,
...((InputPropsFactory && InputPropsFactory(value)) ?? {}),
}}
/>
);
return (
<Controller
control={control}
name={name}
rules={{
required: {
value: (textFieldProps?.required || required) ?? false,
message: `Required`,
},
...rules,
}}
defaultValue={defaultValue}
render={(renderPops) =>
canEdit
? readOnly
? renderReadOnlyLockedTextField(renderPops)
: renderAutocomplete(renderPops)
: renderDisabledTextField(renderPops)
}
/>
);
};
import { InputProps } from '@mui/material';
import { AutocompleteChangeDetails, AutocompleteChangeReason } from '@mui/material/Autocomplete';
import { EpicAutocompleteProps } from './EpicAutocompleteProps';
export interface EpicAutocompleteBaseProps extends EpicAutocompleteProps {
onChangeFactory?: (
onChange: (...event: any[]) => void
) => (
event: React.SyntheticEvent,
value: string | string[] | null,
reason: AutocompleteChangeReason,
details?: AutocompleteChangeDetails<unknown>
) => void;
InputPropsFactory?: (value: string) => Partial<InputProps>;
}
import {
FilledTextFieldProps,
OutlinedTextFieldProps,
StandardTextFieldProps,
} from '@mui/material';
import { AutocompleteProps } from '@mui/material/Autocomplete';
import { RegisterOptions, UseFormReturn } from 'react-hook-form';
export interface EpicAutocompleteProps
extends Partial<Omit<AutocompleteProps<string, boolean, boolean, boolean>, 'getOptionLabel'>> {
canEdit?: boolean;
formContext: UseFormReturn<any>;
name: string;
label: string;
options: string[];
multiple?: boolean;
defaultValue?: string | string[];
getOptionLabel: (option: string) => string;
textFieldProps?: StandardTextFieldProps | OutlinedTextFieldProps | FilledTextFieldProps;
optionLimit?: number;
onChange?: (value: any) => void;
error?: boolean;
helperText?: string;
rules?: Omit<RegisterOptions, 'valueAsNumber' | 'valueAsDate' | 'setValueAs'>;
required?: boolean;
readOnly?: boolean;
readOnlyReason?: string;
}
import { FormForTesting } from '@epic/testing/react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useForm } from 'react-hook-form';
import {
EpicAutocompleteWithManager,
EpicAutocompleteWithManagerProps,
} from './EpicAutocompleteWithManager';
const onSubmit = jest.fn();
const manager = jest.fn();
jest.mock('../../locale', () => {
return {
useTranslation: jest.fn(() => ({
t: (key: string) => key,
})),
};
});
afterEach(() => {
jest.clearAllMocks();
});
type Model = { test: string };
function TestComponent(
props: Omit<
EpicAutocompleteWithManagerProps,
'name' | 'label' | 'options' | 'formContext' | 'getOptionLabel' | 'manager'
>
) {
const formContext = useForm<Model>();
const {
formState: { errors },
} = formContext;
const submit = (data: Model) => {
onSubmit(data);
};
return (
<FormForTesting formContext={formContext} submit={submit}>
<EpicAutocompleteWithManager
name="test"
label="Test"
placeholder="Test"
options={['1', '2', '3']}
formContext={formContext}
getOptionLabel={(option: string) => option}
error={!!errors}
manager={manager}
helperText={errors?.test?.message}
{...props}
/>
<button type={'submit'}>Submit</button>
</FormForTesting>
);
}
describe('Autocomplete with manager', function () {
it('should render extra option at the end and clicking it launches the given function', async function () {
const { getByLabelText, findByText } = render(<TestComponent />);
const button = getByLabelText('Open');
userEvent.click(button);
const masterDetail = await findByText('Manage...');
masterDetail.click();
expect(manager).toHaveBeenCalled();
});
it('should change to given label', async function () {
const { getByLabelText, findByText } = render(<TestComponent managerLabel={'Add/Edit'} />);
const button = getByLabelText('Open');
userEvent.click(button);
const masterDetail = await findByText('Add/Edit...');
masterDetail.click();
expect(manager).toHaveBeenCalled();
});
});
import React, { FC, ReactNode, useCallback, useMemo } from 'react';
import { useTranslation } from '../../locale';
import { callAll } from '../../utils';
import { EpicAutoCompleteBase } from './EpicAutoCompleteBase';
import { EpicAutocompleteProps } from './EpicAutocompleteProps';
export interface EpicAutocompleteWithManagerProps extends EpicAutocompleteProps {
manager: () => void;
managerLabel?: string;
}
export const EpicAutocompleteWithManager: FC<EpicAutocompleteWithManagerProps> = ({
options,
getOptionLabel,
manager,
managerLabel,
multiple,
onChange,
...props
}) => {
const { t } = useTranslation();
const manageKey = 'manage';
const _options = useMemo(() => [...options, manageKey], [options]);
const getLabel = useCallback(
(option: string) => {
if (option === manageKey) {
return `${managerLabel ?? t('Manage')}...`;
}
return getOptionLabel(option) ?? option;
},
[getOptionLabel, managerLabel, t]
);
const renderOption = useCallback(
(props: React.HTMLAttributes<HTMLLIElement>, option: string, _): ReactNode => {
if (option === manageKey) {
return `${managerLabel ?? t('Manage')}...`;
}
return getOptionLabel(option) ?? option;
},
[getOptionLabel, managerLabel, t]
);
return (
<EpicAutoCompleteBase
getOptionLabel={getLabel}
options={_options}
renderOption={renderOption}
onChangeFactory={(_onChange) => (_, data) => {
if (data === manageKey) {
_onChange(multiple ? [] : null);
manager && manager();
return;
}
callAll(_onChange, onChange)(data);
}}
{...props}
/>
);
};
import { FormForTesting } from '@epic/testing/react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useForm } from 'react-hook-form';
import { BrowserRouter } from 'react-router-dom';
import { EpicAutocompleteWithUrl, EpicAutocompleteWithUrlProps } from './EpicAutocompleteWithUrl';
const onSubmit = jest.fn();
jest.mock('../../locale', () => {
return {
useTranslation: jest.fn(() => ({
t: (key: string) => key,
})),
};
});
afterEach(() => {
jest.clearAllMocks();
});
type Model = { test: string };
function TestComponent(
props: Omit<
EpicAutocompleteWithUrlProps,
'name' | 'label' | 'options' | 'formContext' | 'getOptionLabel' | 'url'
>
) {
const formContext = useForm<Model>();
const {
formState: { errors },
} = formContext;
const submit = (data: Model) => {
onSubmit(data);
};
return (
<BrowserRouter>
<FormForTesting formContext={formContext} submit={submit}>
<EpicAutocompleteWithUrl
name="test"
label="Test"
placeholder="Test"
options={['1', '2', '3']}
formContext={formContext}
getOptionLabel={(option: string) => option}
error={!!errors}
url={'/sales/customers'}
helperText={errors?.test?.message}
{...props}
/>
<button type={'submit'}>Submit</button>
</FormForTesting>
</BrowserRouter>
);
}
describe('Autocomplete with url', function () {
it('should navigate to url on clicking the link icon button in read only mode', async function () {
const { findByLabelText, getByTestId } = render(
<TestComponent defaultValue={'1'} canEdit={false} />
);
await findByLabelText('Test');
userEvent.click(getByTestId('goToUrl'));
expect(window.location.href).toBe('http://localhost/sales/customers/1');
});
it('should not show go to url button if there is no value', async function () {
const { findByLabelText, queryByTestId } = render(<TestComponent canEdit={false} />);
await findByLabelText('Test');
expect(queryByTestId('goToUrl')).toBeNull();
});
it('should not show go to url button in edit mode', async function () {
const { findByLabelText, queryByTestId } = render(<TestComponent defaultValue={'1'} canEdit />);
await findByLabelText('Test');
expect(queryByTestId('goToUrl')).toBeNull();
});
it('should not show go to url button in edit mode and readonly', async function () {
const { findByLabelText, queryByTestId } = render(
<TestComponent defaultValue={'1'} canEdit readOnly />
);
await findByLabelText('Test');
expect(queryByTestId('goToUrl')).toBeNull();
});
});
import { OpenInBrowser } from '@mui/icons-material';
import { IconButton, Tooltip } from '@mui/material';
import { useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from '../../locale';
import { EpicAutocompleteProps } from './EpicAutocompleteProps';
import { EpicAutoCompleteBase } from './EpicAutoCompleteBase';
export interface EpicAutocompleteWithUrlProps extends EpicAutocompleteProps {
url: string;
}
export function EpicAutocompleteWithUrl(props: EpicAutocompleteWithUrlProps) {
const { url, ...rest } = props;
const { t } = useTranslation();
const navigate = useNavigate();
const navigateToItem = useCallback(
(id: string) => {
if (!url || !id) {
return;
}
navigate(`${url}/${id}`);
},
[navigate, url]
);
return (
<EpicAutoCompleteBase
{...rest}
InputPropsFactory={(value) => ({
startAdornment:
url && value ? (
<Tooltip title={t('Open...')}>
<IconButton
onClick={() => navigateToItem(value)}
size={'small'}
data-testid={'goToUrl'}
>
<OpenInBrowser fontSize={'inherit'} />
</IconButton>
</Tooltip>
) : undefined,
})}
/>
);
}

Image of Datadog

Create and maintain end-to-end frontend tests

Learn best practices on creating frontend tests, testing on-premise apps, integrating tests into your CI/CD pipeline, and using Datadog’s testing tunnel.

Download The Guide

Top comments (0)

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs