import { act, render, screen } from '@testing-library/react'; import userEvent, { UserEvent } from '@testing-library/user-event'; import React from 'react'; import { MultiCombobox, MultiComboboxProps } from './MultiCombobox'; import { ComboboxOption } from './types'; describe('MultiCombobox', () => { beforeAll(() => { const mockGetBoundingClientRect = jest.fn(() => ({ width: 120, height: 120, top: 0, left: 0, bottom: 0, right: 0, })); Object.defineProperty(Element.prototype, 'getBoundingClientRect', { value: mockGetBoundingClientRect, }); }); let user: UserEvent; beforeEach(() => { user = userEvent.setup(); }); it('should render with options', async () => { const options = [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]; render(); const input = screen.getByRole('combobox'); user.click(input); expect(await screen.findByText('A')).toBeInTheDocument(); expect(screen.getByText('B')).toBeInTheDocument(); expect(screen.getByText('C')).toBeInTheDocument(); }); it('should render with value', () => { const options = [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]; render(); expect(screen.getByText('A')).toBeInTheDocument(); }); it('should render with placeholder', () => { const options = [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]; render(); expect(screen.getByPlaceholderText('Select')).toBeInTheDocument(); }); it.each([ ['a', 'b', 'c'], [1, 2, 3], ])('should call onChange with the correct values', async (first, second, third) => { const options = [ { label: 'A', value: first }, { label: 'B', value: second }, { label: 'C', value: third }, ]; const onChange = jest.fn(); const ControlledMultiCombobox = (props: MultiComboboxProps) => { const [value, setValue] = React.useState([]); return ( { //@ts-expect-error Don't do this for real life use cases setValue(val ?? []); onChange(val); }} /> ); }; render(); const input = screen.getByRole('combobox'); await user.click(input); await user.click(await screen.findByRole('option', { name: 'A' })); // Second option await user.click(screen.getByRole('option', { name: 'C' })); // Deselect await user.click(screen.getByRole('option', { name: 'A' })); expect(onChange).toHaveBeenNthCalledWith(1, [{ label: 'A', value: first }]); expect(onChange).toHaveBeenNthCalledWith(2, [ { label: 'A', value: first }, { label: 'C', value: third }, ]); expect(onChange).toHaveBeenNthCalledWith(3, [{ label: 'C', value: third }]); }); it('should be able to render a value that is not in the options', async () => { const options = [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]; render(); await user.click(screen.getByRole('combobox')); expect(await screen.findByText('d')).toBeInTheDocument(); }); describe('all option', () => { it('should render all option', async () => { const options = [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]; render(); const input = screen.getByRole('combobox'); await user.click(input); expect(await screen.findByRole('option', { name: 'All' })).toBeInTheDocument(); }); it('should select all option', async () => { const options = [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]; const onChange = jest.fn(); render(); const input = screen.getByRole('combobox'); await user.click(input); await user.click(await screen.findByText('All')); expect(onChange).toHaveBeenCalledWith([ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]); }); it('should deselect all option', async () => { const options = [ { label: 'A', value: 'a' }, { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ]; const onChange = jest.fn(); render( ); const input = screen.getByRole('combobox'); await user.click(input); await user.click(await screen.findByRole('option', { name: 'All' })); expect(onChange).toHaveBeenCalledWith([]); }); }); describe('async', () => { const onChangeHandler = jest.fn(); let user: ReturnType; beforeAll(() => { user = userEvent.setup({ delay: null }); jest.useFakeTimers(); }); afterAll(() => { jest.useRealTimers(); }); afterEach(() => { onChangeHandler.mockReset(); }); // Assume that most apis only return with the value const simpleAsyncOptions = [{ value: 'Option 1' }, { value: 'Option 2' }, { value: 'Option 3' }]; it('should allow async options', async () => { const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions)); render(); const input = screen.getByRole('combobox'); await user.click(input); // Debounce await act(async () => jest.advanceTimersByTime(200)); expect(asyncOptions).toHaveBeenCalled(); }); it('should allow async options and select value', async () => { const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions)); render(); const input = screen.getByRole('combobox'); await user.click(input); const item = await screen.findByRole('option', { name: 'Option 3' }); await user.click(item); expect(onChangeHandler).toHaveBeenCalledWith([simpleAsyncOptions[2]]); }); it('should retain values not returned by the async function', async () => { const asyncOptions = jest.fn(() => Promise.resolve(simpleAsyncOptions)); render(); const input = screen.getByRole('combobox'); await user.click(input); const item = await screen.findByRole('option', { name: 'Option 3' }); await user.click(item); expect(onChangeHandler).toHaveBeenCalledWith([{ value: 'Option 69' }, { value: 'Option 3' }]); }); it('should ignore late responses', async () => { const asyncOptions = jest.fn(async (searchTerm: string) => { if (searchTerm === 'a') { return promiseResolvesWith([{ value: 'first' }], 1500); } else if (searchTerm === 'ab') { return promiseResolvesWith([{ value: 'second' }], 500); } else if (searchTerm === 'abc') { return promiseResolvesWith([{ value: 'third' }], 800); } return Promise.resolve([]); }); render(); const input = screen.getByRole('combobox'); await user.click(input); await user.keyboard('a'); act(() => jest.advanceTimersByTime(200)); // Skip debounce await user.keyboard('b'); act(() => jest.advanceTimersByTime(200)); // Skip debounce await user.keyboard('c'); act(() => jest.advanceTimersByTime(500)); // Resolve the second request, should be ignored expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'third' })).not.toBeInTheDocument(); jest.advanceTimersByTime(800); // Resolve the third request, should be shown expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument(); expect(await screen.findByRole('option', { name: 'third' })).toBeInTheDocument(); jest.advanceTimersByTime(1500); // Resolve the first request, should be ignored expect(screen.queryByRole('option', { name: 'first' })).not.toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'second' })).not.toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'third' })).toBeInTheDocument(); jest.clearAllTimers(); }); it('should debounce requests', async () => { const asyncOptions = jest.fn(async () => { return promiseResolvesWith([{ value: 'Option 3' }], 1); }); render(); const input = screen.getByRole('combobox'); await user.click(input); await user.keyboard('a'); act(() => jest.advanceTimersByTime(10)); await user.keyboard('b'); act(() => jest.advanceTimersByTime(10)); await user.keyboard('c'); act(() => jest.advanceTimersByTime(200)); const item = await screen.findByRole('option', { name: 'Option 3' }); expect(item).toBeInTheDocument(); expect(asyncOptions).toHaveBeenCalledTimes(1); expect(asyncOptions).toHaveBeenCalledWith('abc'); }); }); }); function promiseResolvesWith(value: ComboboxOption[], timeout = 0) { return new Promise((resolve) => setTimeout(() => resolve(value), timeout)); }