import { mockAnimationsApi } from 'jsdom-testing-mocks'; import { mockMatchMedia, mockResizeObserver, render, screen, userEvent, waitFor, within, } from '../test-utils'; import { MoneyInput, CurrencyItem, CurrencyOptionItem, Field } from '..'; import { MoneyInputPropsWithInputAttributes } from './MoneyInput'; import messages from './MoneyInput.messages'; mockMatchMedia(); mockResizeObserver(); mockAnimationsApi(); describe('Money Input', () => { const popularCurrencies: CurrencyOptionItem[] = [ { value: 'EUR', label: 'EUR', note: 'Euro', currency: 'eur', searchable: 'Spain, Germany, France, Austria, Estonia', }, { value: 'USD', label: 'USD', note: 'United States dollar', currency: 'usd', searchable: 'Hong Kong, Saudi Arabia', }, { value: 'GBP', label: 'GBP', note: 'British pound', currency: 'gbp', searchable: 'England, Scotland, Wales', }, ]; const otherCurrencies: CurrencyOptionItem[] = [ { value: 'CAD', label: 'CAD', note: 'Canadian dollar', currency: 'cad', }, { value: 'AUD', label: 'AUD', note: 'Australian dollar', currency: 'aud', }, ]; const currencies: CurrencyItem[] = [ { header: 'Popular currencies' }, ...popularCurrencies, { header: 'Some other currencies' }, ...otherCurrencies, ]; const initialProps = { currencies, selectedCurrency: popularCurrencies[1], amount: 1000, onAmountChange: jest.fn(), onCurrencyChange: jest.fn(), onSearchChange: jest.fn(), }; const customRender = ( overrides: Partial = {}, locale?: string, ) => render(, { locale }); beforeEach(jest.clearAllMocks); const getTrigger = () => screen.getByRole('combobox', { name: /Select currency/ }); const getInput = () => screen.getAllByRole('textbox')[0]; const openDropdown = async () => { await userEvent.click(getTrigger()); }; const getPopularGroup = () => screen.getByRole('group', { name: 'Popular currencies' }); const getPopularCurrencies = () => within(getPopularGroup()).getAllByRole('option'); const getOtherGroup = () => screen.getByRole('group', { name: 'Some other currencies' }); const getOtherCurrencies = () => within(getOtherGroup()).getAllByRole('option'); it('renders a select with all currencies grouped', async () => { customRender(); await openDropdown(); expect(getPopularGroup()).toBeInTheDocument(); getPopularCurrencies().forEach((option, index) => { expect(option).toHaveTextContent( new RegExp(`${popularCurrencies[index].label}\\s*?${popularCurrencies[index].note}`), ); }); expect(getOtherGroup()).toBeInTheDocument(); getOtherCurrencies().forEach((option, index) => { expect(option).toHaveTextContent( new RegExp(`${otherCurrencies[index].label}\\s*?${otherCurrencies[index].note}`), ); }); }); it('shows the currently active currency as active and hides its note', () => { customRender({ selectedCurrency: popularCurrencies[0] }); expect(getTrigger()).toHaveTextContent(popularCurrencies[0].label); expect(getTrigger()).not.toHaveTextContent(popularCurrencies[0].note || ''); }); it('calls onCurrencyChange when the user selects a different currency', async () => { customRender(); await openDropdown(); await userEvent.keyboard('eur'); await waitFor(() => { expect(screen.getAllByRole('option')).toHaveLength(1); }); await userEvent.keyboard('{Enter}'); await waitFor(() => { expect(initialProps.onCurrencyChange).toHaveBeenCalledTimes(1); }); expect(initialProps.onCurrencyChange).toHaveBeenCalledWith(popularCurrencies[0]); await waitFor(() => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); await openDropdown(); await userEvent.keyboard('gbp'); await waitFor(() => { expect(screen.getAllByRole('option')).toHaveLength(1); }); await userEvent.keyboard('{Enter}'); await waitFor(() => { expect(initialProps.onCurrencyChange).toHaveBeenCalledTimes(2); }); expect(initialProps.onCurrencyChange).toHaveBeenCalledWith(popularCurrencies[2]); }); describe('sizing', () => { ( [ { label: 'large as default', prop: undefined, suffix: 'lg' }, { label: 'large', prop: 'lg', suffix: 'lg' }, { label: 'medium', prop: 'md', suffix: 'md' }, { label: 'small', prop: 'sm', suffix: 'sm' }, ] as const ).forEach((props) => { it(`should respect ${props.label} size`, () => { const { container } = customRender({ size: props.prop }); expect(container.querySelector('.tw-money-input')).toHaveClass( `input-group-${props.suffix}`, ); expect(getTrigger()).toHaveClass(`np-form-control--size-${props.suffix}`); }); }); }); describe('when searching', () => { it('hides headers', async () => { customRender(); await openDropdown(); await userEvent.keyboard('dollar'); expect(screen.queryByRole('group', { name: 'Popular currencies' })).not.toBeInTheDocument(); expect( screen.queryByRole('group', { name: 'Some other currencies' }), ).not.toBeInTheDocument(); }); it('searches by label', async () => { customRender(); await openDropdown(); await userEvent.keyboard('gb'); const options = screen.getAllByRole('option'); expect(options).toHaveLength(1); expect(options[0]).toHaveAccessibleName(new RegExp(popularCurrencies[2].label)); }); it('searches by note', async () => { customRender(); await openDropdown(); await userEvent.keyboard('dollar'); const options = screen.getAllByRole('option'); expect(options).toHaveLength(3); expect(options[0]).toHaveAccessibleName(new RegExp(popularCurrencies[1].note!)); expect(options[1]).toHaveAccessibleName(new RegExp(otherCurrencies[0].note!)); expect(options[2]).toHaveAccessibleName(new RegExp(otherCurrencies[1].note!)); }); it('searches by searchable string', async () => { customRender(); await openDropdown(); await userEvent.keyboard('hon'); const options = screen.getAllByRole('option'); expect(options).toHaveLength(1); expect(options[0]).toHaveAccessibleName(new RegExp(popularCurrencies[1].label)); }); describe('custom action', () => { const actionProps = { onCustomAction: jest.fn(), customActionLabel: 'Custom action label', }; it('does not shows when onCustomAction is not set', async () => { customRender({ customActionLabel: actionProps.customActionLabel }); await openDropdown(); expect( screen.queryByRole('button', { name: actionProps.customActionLabel }), ).not.toBeInTheDocument(); }); it('shows when onCustomAction and customActionLabel are set', async () => { customRender(actionProps); await openDropdown(); expect( screen.getByRole('button', { name: actionProps.customActionLabel }), ).toBeInTheDocument(); }); it('triggers onCustomAction but not onCurrencyChange', async () => { customRender(actionProps); await openDropdown(); await userEvent.click(screen.getByRole('button', { name: actionProps.customActionLabel })); expect(actionProps.onCustomAction).toHaveBeenCalledTimes(1); expect(initialProps.onCurrencyChange).not.toHaveBeenCalled(); }); }); it('sorts by labels first', async () => { customRender(); await openDropdown(); await userEvent.keyboard('au'); const options = screen.getAllByRole('option'); expect(options).toHaveLength(3); expect(options[0]).toHaveAccessibleName(new RegExp(otherCurrencies[1].label)); expect(options[1]).toHaveAccessibleName(new RegExp(popularCurrencies[0].label)); expect(options[2]).toHaveAccessibleName(new RegExp(popularCurrencies[1].label)); }); }); describe('amount formatting', () => { it('formats the number you input after you blur it', async () => { customRender(); const input = getInput(); await userEvent.clear(input); await userEvent.type(input, '1234567.6543'); await userEvent.tab(); expect(input).toHaveValue('1,234,567.65'); }); it('uses custom decimals when specified', async () => { customRender({ decimals: 4 }); const input = getInput(); await userEvent.clear(input); await userEvent.type(input, '1234.56789'); await userEvent.tab(); expect(input).toHaveValue('1,234.5679'); }); it('uses custom decimals of 0', async () => { customRender({ decimals: 0 }); const input = getInput(); await userEvent.clear(input); await userEvent.type(input, '1234.56'); await userEvent.tab(); expect(input).toHaveValue('1,235'); }); it('ignores custom decimals override for zero-decimal currencies like JPY', async () => { const jpyCurrency: CurrencyOptionItem = { value: 'JPY', label: 'JPY', note: 'Japanese yen', currency: 'jpy', }; customRender({ decimals: 4, selectedCurrency: jpyCurrency, currencies: [jpyCurrency], amount: 1234, }); const input = getInput(); expect(input).toHaveValue('1,234'); await userEvent.clear(input); await userEvent.type(input, '5678.9999'); await userEvent.tab(); expect(input).toHaveValue('5,679'); }); }); it('calls onAmountChange with parsed and formatted value', async () => { customRender(); expect(initialProps.onAmountChange).not.toHaveBeenCalled(); await userEvent.type(getInput(), '.6543'); expect(initialProps.onAmountChange).toHaveBeenCalledTimes(5); expect(initialProps.onAmountChange).toHaveBeenCalledWith(1000.65); }); it('calls onAmountChange with value rounded to custom decimals', async () => { customRender({ decimals: 4, amount: null }); await userEvent.type(getInput(), '12.34567'); expect(initialProps.onAmountChange).toHaveBeenCalledWith(12.3457); }); it('calls onAmountChange when input value is empty', async () => { customRender(); await userEvent.clear(getInput()); expect(initialProps.onAmountChange).toHaveBeenCalledTimes(1); expect(initialProps.onAmountChange).toHaveBeenCalledWith(null); }); it('renders addon when element is passed through props', () => { const addonElement = ; customRender({ addon: addonElement }); expect(screen.getByTestId('test-addon')).toBeInTheDocument(); }); describe('fixed currency', () => { const EEK = { value: 'EEK', label: 'EEK', currency: 'eek' }; it('shows fixed currency view if currencies array is empty but select currency exists', () => { customRender({ currencies: [], selectedCurrency: EEK, }); expect(screen.queryByRole('combobox', { name: /Select currency/ })).not.toBeInTheDocument(); expect(screen.getByText(EEK.label)).toBeInTheDocument(); }); it('shows fixed currency view if only one currency available and selected', () => { customRender({ currencies: [EEK], selectedCurrency: EEK, }); expect(screen.queryByRole('combobox', { name: /Select currency/ })).not.toBeInTheDocument(); expect(screen.getByText(EEK.label)).toBeInTheDocument(); }); it('shows fixed currency view when no function is passed to onCurrencyChange prop', () => { customRender({ onCurrencyChange: undefined }); expect(screen.queryByRole('combobox', { name: /Select currency/ })).not.toBeInTheDocument(); expect(screen.getByText(popularCurrencies[1].label)).toBeInTheDocument(); }); describe('currency keyline and flag', () => { ( [ ['lg', true], ['md', true], ['sm', false], ] as const ).forEach(([size, isShown]) => { it(`${isShown ? 'shows' : `doesn't show`} for '${size}' input`, () => { const { container } = customRender({ currencies: [EEK], selectedCurrency: EEK, size, }); expect(container.querySelector('.wds-flag-eek'))[isShown ? 'toBeTruthy' : 'toBeFalsy'](); }); }); }); describe('disabled state', () => { it('should be enable by default', () => { customRender({ currencies: [EEK], selectedCurrency: EEK, }); expect(getInput()).toBeEnabled(); }); it('should be disabled when there is no onAmountChange prop', () => { customRender({ currencies: [EEK], selectedCurrency: EEK, onAmountChange: undefined, }); expect(getInput()).toBeDisabled(); }); }); }); describe('search placeholder', () => { it('uses default value', async () => { customRender(); await openDropdown(); expect( screen.getByRole('combobox', { name: messages.searchPlaceholder.defaultMessage }), ).toBeInTheDocument(); }); it('allows for custom values', async () => { const searchPlaceholder = 'custom placeholder'; customRender({ searchPlaceholder }); await openDropdown(); expect(screen.getByRole('combobox', { name: searchPlaceholder })).toBeInTheDocument(); }); }); it('calls onSearchChange', async () => { const searchQuery = 'aus'; customRender(); await openDropdown(); await userEvent.keyboard(searchQuery); expect(initialProps.onSearchChange).toHaveBeenCalledTimes(searchQuery.length); expect(initialProps.onSearchChange).toHaveBeenCalledWith({ filteredOptions: [popularCurrencies[0], otherCurrencies[1]], searchQuery, }); await userEvent.keyboard('{Backspace}{Backspace}{Backspace}'); expect(initialProps.onSearchChange).toHaveBeenCalledWith({ filteredOptions: currencies, searchQuery: '', }); }); it('ensures namespaced classNames can be provided and used', () => { const classNames = { 'tw-money-input': 'customClass' }; customRender({ classNames }); expect(screen.getAllByRole('group')[0]).toHaveClass(classNames['tw-money-input']); }); describe('placeholder', () => { it('shows a formatted placeholder when provided', () => { customRender({ placeholder: 12345767.6543 }); expect(getInput()).toHaveAttribute('placeholder', '12,345,767.65'); }); it('allows a placeholder of 0', () => { customRender({ placeholder: 0 }); expect(getInput()).toHaveAttribute('placeholder', '0'); }); }); it('should respect selectProps', async () => { customRender({ selectProps: { className: 'selectClassName', }, }); expect(screen.getAllByRole('group')[1]).toHaveClass('selectClassName'); }); describe('text input', () => { it.each([ ['asd', ''], ['1a2s3d', '123'], ['±!@#$^*_+?><', ''], ['1±!@#$^*,_+?><2', '1,2'], ['12,3', '12,3'], ['12.3', '12.3'], ] as const)( "ignores the letters when typed '%s' and shows '%s'", async (testValue: string, expectedValue: string) => { customRender({ amount: null }); await userEvent.type(getInput(), testValue); expect(getInput()).toHaveValue(expectedValue); }, ); }); it('supports custom `aria-labelledby` attribute', () => { render( <> {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} , ); expect(screen.getAllByLabelText('Prioritized label')[0]).toHaveClass('np-form-control'); }); it('supports `Field` for labeling', () => { render( , ); expect(screen.getAllByRole('group')[0]).toHaveAccessibleName(/^Recipient gets/); }); it('focuses money input when `Field` label is clicked', async () => { const label = 'Recipient gets'; render( , ); const input = screen.getByRole('textbox'); await userEvent.click(screen.getByLabelText(label)); expect(input).toHaveFocus(); }); describe('ids', () => { it('renders Select component with generated id when id is not provided', () => { customRender(); expect(getTrigger()).toHaveAttribute('id'); }); it('passes the id given to the input element', () => { customRender({ id: 'some-id' }); expect(getInput()).toHaveAttribute('id', 'some-id'); }); it('should guarantee id and connect the input with the selected currency via withId HoC', () => { customRender(); expect(getInput().getAttribute('id')).toBeTruthy(); expect(getInput()).toHaveAttribute('aria-describedby', getTrigger().getAttribute('id')); }); it('should have unique id for the select filter with predefined id', async () => { const fieldId = 'myFieldId'; const searchPlaceholder = 'Type a currency or country'; render( , ); await openDropdown(); expect(screen.getByLabelText(searchPlaceholder)).toHaveAttribute( 'id', `${fieldId}SelectedCurrencySearch`, ); }); it('should have unique id for the select filter without predefined id', async () => { const searchPlaceholder = 'Type a currency or country'; render( , ); await openDropdown(); expect(screen.getByLabelText(searchPlaceholder)).toHaveAttribute( 'id', expect.stringMatching(/^:.*?:SelectedCurrencySearch$/), ); }); }); it('should have AT label for the currency dropdown', () => { customRender(); expect(getTrigger()).toHaveAttribute('aria-label', messages.selectCurrencyLabel.defaultMessage); }); it('should have a listbox label', async () => { customRender(); const trigger = getTrigger(); await openDropdown(); const triggerLabel = trigger.getAttribute('aria-label'); expect(triggerLabel).toBeTruthy(); expect(screen.getByRole('listbox', { name: triggerLabel ?? '' })).toBeInTheDocument(); }); it('renders custom action button in dropdown footer and calls onCustomAction', async () => { const onCustomAction = jest.fn(); render( , ); const trigger = screen.getByRole('combobox', { name: /select currency/i }); await userEvent.click(trigger); const customActionBtn = await screen.findByRole('button', { name: /go to wishes/i }); expect(customActionBtn).toBeInTheDocument(); await userEvent.click(customActionBtn); expect(onCustomAction).toHaveBeenCalled(); }); });