Skip to content

Instantly share code, notes, and snippets.

@rexebin
Last active January 22, 2022 11:48
Autocomplete
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,
})}
/>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment