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
- It registers the
Autocomplete
component with RHF's form context - It inherits all the behaviours from
mui
's'Autocomplete
and accepts allAutocomplete
props. - 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 aTextField
component. - It supports single and multiple selection modes
- It can limit the number of options it renders with
optionLimit
prop. - It has a build-in
required
validation rule - It accepts validation rules from
rules
prop. - 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:
-
EpicAutocomplete
, a regular auto-complete. It has the same API surface asmui
'sAutocomplete
component plus binding props for RHF. -
EpicAutocompleteWithManger
, an auto-complete with an additional option for managing options, extendsEpicAutocomplete
and has extra required props:manager
andmanagerLabel
. -
EpicAutocompleteWithUrl
, an auto-complete with a link icon button for users to navigate to the detail page of the selected entity. It extendsEpicAutocomplete
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.
- Many instances don't need the manager or URL but carry the baggage with them.
- URL require
react-router
, and it adds unnecessary dependencies to users that don't use it - The component internals are too complicated.
For the new structure, I moved the implementations into a base component called EpicAutocompleteBase
.
- It is private and not exposed to external libraries. It cannot be used in forms directly.
- 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
- Each variant has its separate tests. Their tests are focused on their public APIs (props)
- 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, | |
})} | |
/> | |
); | |
} |
Top comments (0)