blob: e13c98c5473ff28592debb51575b109b70703b0f [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
createEvent,
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from '@superset-ui/core/spec';
import { Select } from '.';
type Option = {
label: string;
value: number;
gender: string;
disabled?: boolean;
};
const ARIA_LABEL = 'Test';
const NEW_OPTION = 'Kyle';
const NO_DATA = 'No data';
const LOADING = 'Loading...';
const OPTIONS: Option[] = [
{ label: 'John', value: 1, gender: 'Male' },
{ label: 'Liam', value: 2, gender: 'Male' },
{ label: 'Olivia', value: 3, gender: 'Female' },
{ label: 'Emma', value: 4, gender: 'Female' },
{ label: 'Noah', value: 5, gender: 'Male' },
{ label: 'Ava', value: 6, gender: 'Female' },
{ label: 'Oliver', value: 7, gender: 'Male' },
{ label: 'ElijahH', value: 8, gender: 'Male' },
{ label: 'Charlotte', value: 9, gender: 'Female' },
{ label: 'Giovanni', value: 10, gender: 'Male' },
{ label: 'Franco', value: 11, gender: 'Male' },
{ label: 'Sandro', value: 12, gender: 'Male' },
{ label: 'Alehandro', value: 13, gender: 'Male' },
{ label: 'Johnny', value: 14, gender: 'Male' },
{ label: 'Nikole', value: 15, gender: 'Female' },
{ label: 'Igor', value: 16, gender: 'Male' },
{ label: 'Guilherme', value: 17, gender: 'Male' },
{ label: 'Irfan', value: 18, gender: 'Male' },
{ label: 'George', value: 19, gender: 'Male' },
{ label: 'Ashfaq', value: 20, gender: 'Male' },
{ label: 'Herme', value: 21, gender: 'Male' },
{ label: 'Cher', value: 22, gender: 'Female' },
{ label: 'Her', value: 23, gender: 'Male' },
].sort((option1, option2) => option1.label.localeCompare(option2.label));
const NULL_OPTION = { label: '<NULL>', value: null } as unknown as {
label: string;
value: number;
};
const defaultProps = {
allowClear: true,
ariaLabel: ARIA_LABEL,
labelInValue: true,
options: OPTIONS,
showSearch: true,
};
const getElementByClassName = (className: string) =>
document.querySelector(className)! as HTMLElement;
const getElementsByClassName = (className: string) =>
document.querySelectorAll(className)! as NodeListOf<HTMLElement>;
const getSelect = () =>
screen.getByRole('combobox', { name: new RegExp(ARIA_LABEL, 'i') });
const selectAllButtonText = (length: number) => `Select all (${length})`;
const deselectAllButtonText = (length: number) => `Deselect all (${length})`;
const findSelectOption = (text: string) =>
waitFor(() =>
within(getElementByClassName('.rc-virtual-list')).getByText(text),
);
const querySelectOption = (text: string) =>
waitFor(() =>
within(getElementByClassName('.rc-virtual-list')).queryByText(text),
);
const getAllSelectOptions = () =>
getElementsByClassName('.ant-select-item-option-content');
const findAllSelectOptions = () =>
waitFor(() => getElementsByClassName('.ant-select-item-option-content'));
const findSelectValue = () =>
waitFor(() => getElementByClassName('.ant-select-selection-item'));
const findAllSelectValues = () =>
waitFor(() => [...getElementsByClassName('.ant-select-selection-item')]);
const clearAll = () => userEvent.click(screen.getByLabelText('close-circle'));
const matchOrder = async (expectedLabels: string[]) => {
const actualLabels: string[] = [];
(await findAllSelectOptions()).forEach(option => {
actualLabels.push(option.textContent || '');
});
// menu is a virtual list, which means it may not render all options
expect(actualLabels.slice(0, expectedLabels.length)).toEqual(
expectedLabels.slice(0, actualLabels.length),
);
return true;
};
const type = async (text: string, delay?: number, clear = true) => {
const select = getSelect();
if (clear) {
await userEvent.clear(select);
}
return userEvent.type(select, text, { delay: delay ?? 10 });
};
const clearTypedText = async () => {
const select = getSelect();
await userEvent.clear(select);
};
const open = () => waitFor(() => userEvent.click(getSelect()));
const reopen = async () => {
await type('{esc}');
await open();
};
test('displays a header', async () => {
const headerText = 'Header';
render(<Select {...defaultProps} header={headerText} />);
expect(screen.getByText(headerText)).toBeInTheDocument();
});
test('adds a new option if the value is not in the options, when options are empty', async () => {
render(<Select {...defaultProps} options={[]} value={OPTIONS[0]} />);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
options.forEach((option, i) =>
expect(option).toHaveTextContent(OPTIONS[i].label),
);
});
test('adds a new option if the value is not in the options, when options have values', async () => {
render(
<Select {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]} />,
);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
expect(await findSelectOption(OPTIONS[1].label)).toBeInTheDocument();
const options = await findAllSelectOptions();
expect(options).toHaveLength(2);
options.forEach((option, i) =>
expect(option).toHaveTextContent(OPTIONS[i].label),
);
});
test('does not add a new option if the value is already in the options', async () => {
render(
<Select {...defaultProps} options={[OPTIONS[0]]} value={OPTIONS[0]} />,
);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
});
test('does not add new options when the value is in a nested/grouped option', async () => {
const options = [
{
label: 'Group',
options: [OPTIONS[0]],
},
];
render(<Select {...defaultProps} options={options} value={OPTIONS[0]} />);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
const selectOptions = await findAllSelectOptions();
expect(selectOptions).toHaveLength(1);
});
test('inverts the selection', async () => {
render(<Select {...defaultProps} invertSelection />);
await open();
await userEvent.click(await findSelectOption(OPTIONS[0].label));
expect(await screen.findByLabelText('stop')).toBeInTheDocument();
});
test('sort the options by label if no sort comparator is provided', async () => {
const unsortedOptions = [...OPTIONS].sort(() => Math.random());
render(<Select {...defaultProps} options={unsortedOptions} />);
await open();
const options = await findAllSelectOptions();
options.forEach((option, key) =>
expect(option).toHaveTextContent(OPTIONS[key].label),
);
});
test('should sort selected to top when in single mode', async () => {
render(<Select {...defaultProps} mode="single" />);
const originalLabels = OPTIONS.map(option => option.label);
await open();
await userEvent.click(await findSelectOption(originalLabels[1]));
// after selection, keep the original order
expect(await matchOrder(originalLabels)).toBe(true);
// order selected to top when reopen
await reopen();
let labels = originalLabels.slice();
labels = labels.splice(1, 1).concat(labels);
expect(await matchOrder(labels)).toBe(true);
// keep clicking other items, the updated order should still based on
// original order
await userEvent.click(await findSelectOption(originalLabels[5]));
await matchOrder(labels);
await reopen();
labels = originalLabels.slice();
labels = labels.splice(5, 1).concat(labels);
expect(await matchOrder(labels)).toBe(true);
// should revert to original order
await clearAll();
await reopen();
expect(await matchOrder(originalLabels)).toBe(true);
});
test('should sort selected to the top when in multi mode', async () => {
render(<Select {...defaultProps} mode="multiple" />);
const originalLabels = OPTIONS.map(option => option.label);
let labels = originalLabels.slice();
await open();
await userEvent.click(await findSelectOption(labels[2]));
expect(await matchOrder(labels)).toBe(true);
await reopen();
labels = labels.splice(2, 1).concat(labels);
expect(await matchOrder(labels)).toBe(true);
await open();
await userEvent.click(await findSelectOption(labels[5]));
await reopen();
labels = [labels.splice(0, 1)[0], labels.splice(4, 1)[0]].concat(labels);
expect(await matchOrder(labels)).toBe(true);
// should revert to original order
await clearAll();
await reopen();
expect(await matchOrder(originalLabels)).toBe(true);
});
test('order of selected values is preserved until dropdown is closed', async () => {
render(<Select {...defaultProps} mode="multiple" allowSelectAll={false} />);
const originalLabels = OPTIONS.map(option => option.label);
await open();
await userEvent.click(await findSelectOption(originalLabels[1]));
await userEvent.click(await findSelectOption(originalLabels[5]));
expect(await matchOrder(originalLabels)).toBe(true);
});
test('searches for label or value', async () => {
const option = OPTIONS[11];
render(<Select {...defaultProps} />);
const search = option.value;
await type(search.toString());
const options = await findAllSelectOptions();
expect(options.length).toBe(1);
expect(options[0]).toHaveTextContent(option.label);
});
test('search order exact and startWith match first', async () => {
render(<Select {...defaultProps} />);
await type('Her');
await waitFor(() => {
const options = getAllSelectOptions();
expect(options.length).toBe(4);
expect(options[0]?.textContent).toEqual('Her');
expect(options[1]?.textContent).toEqual('Herme');
expect(options[2]?.textContent).toEqual('Cher');
expect(options[3]?.textContent).toEqual('Guilherme');
});
});
test('ignores case when searching', async () => {
render(<Select {...defaultProps} />);
await type('george');
expect(await findSelectOption('George')).toBeInTheDocument();
});
test('same case should be ranked to the top', async () => {
render(
<Select
{...defaultProps}
options={[
{ value: 'Cac' },
{ value: 'abac' },
{ value: 'acbc' },
{ value: 'CAc' },
]}
/>,
);
await type('Ac');
await waitFor(() => {
const options = getAllSelectOptions();
expect(options.length).toBe(4);
expect(options[0]?.textContent).toEqual('acbc');
expect(options[1]?.textContent).toEqual('CAc');
expect(options[2]?.textContent).toEqual('abac');
expect(options[3]?.textContent).toEqual('Cac');
});
});
test('ignores special keys when searching', async () => {
render(<Select {...defaultProps} />);
await type('{shift}');
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
});
test('searches for custom fields', async () => {
render(<Select {...defaultProps} optionFilterProps={['label', 'gender']} />);
await type('Liam');
let options = await findAllSelectOptions();
expect(options.length).toBe(1);
expect(options[0]).toHaveTextContent('Liam');
await type('Female');
options = await findAllSelectOptions();
expect(options.length).toBe(6);
expect(options[0]).toHaveTextContent('Ava');
expect(options[1]).toHaveTextContent('Charlotte');
expect(options[2]).toHaveTextContent('Cher');
expect(options[3]).toHaveTextContent('Emma');
expect(options[4]).toHaveTextContent('Nikole');
expect(options[5]).toHaveTextContent('Olivia');
await type('1');
expect(
screen.getByText(NO_DATA, { selector: '.ant-empty-description' }),
).toBeInTheDocument();
});
test('removes duplicated values', async () => {
render(<Select {...defaultProps} mode="multiple" allowNewOptions />);
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'a,b,b,b,c,d,d',
},
});
fireEvent(input, paste);
const values = await findAllSelectValues();
expect(values.length).toBe(4);
expect(values[0]).toHaveTextContent('a');
expect(values[1]).toHaveTextContent('b');
expect(values[2]).toHaveTextContent('c');
expect(values[3]).toHaveTextContent('d');
});
test('renders a custom label', async () => {
const options = [
{ value: 'John', label: <h1>John</h1> },
{ value: 'Liam', label: <h1>Liam</h1> },
{ value: 'Olivia', label: <h1>Olivia</h1> },
];
render(<Select {...defaultProps} options={options} />);
await open();
expect(screen.getByRole('heading', { name: 'John' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Liam' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Olivia' })).toBeInTheDocument();
});
test('searches for a word with a custom label', async () => {
const options = [
{ value: 'John', label: <h1>John</h1> },
{ value: 'Liam', label: <h1>Liam</h1> },
{ value: 'Olivia', label: <h1>Olivia</h1> },
];
render(<Select {...defaultProps} options={options} />);
await type('Liam');
const selectOptions = await findAllSelectOptions();
expect(selectOptions.length).toBe(1);
expect(selectOptions[0]).toHaveTextContent('Liam');
});
test('removes a new option if the user does not select it', async () => {
render(<Select {...defaultProps} allowNewOptions />);
await type(NEW_OPTION);
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
await type('k');
await waitFor(() =>
expect(screen.queryByText(NEW_OPTION)).not.toBeInTheDocument(),
);
});
test('clear all the values', async () => {
const onClear = jest.fn();
render(
<Select
{...defaultProps}
mode="multiple"
value={[OPTIONS[0], OPTIONS[1]]}
onClear={onClear}
/>,
);
await clearAll();
expect(onClear).toHaveBeenCalled();
const values = await findAllSelectValues();
expect(values.length).toBe(0);
});
test('does not add a new option if allowNewOptions is false', async () => {
render(<Select {...defaultProps} options={OPTIONS} />);
await open();
await type(NEW_OPTION);
expect(
await screen.findByText(NO_DATA, { selector: '.ant-empty-description' }),
).toBeInTheDocument();
});
test('adds the null option when selected in single mode', async () => {
render(<Select {...defaultProps} options={[OPTIONS[0], NULL_OPTION]} />);
await open();
await userEvent.click(await findSelectOption(NULL_OPTION.label));
const values = await findAllSelectValues();
expect(values[0]).toHaveTextContent(NULL_OPTION.label);
});
test('adds the null option when selected in multiple mode', async () => {
render(
<Select
{...defaultProps}
options={[OPTIONS[0], NULL_OPTION, OPTIONS[2]]}
mode="multiple"
/>,
);
await open();
await userEvent.click(await findSelectOption(OPTIONS[0].label));
await userEvent.click(await findSelectOption(NULL_OPTION.label));
const values = await findAllSelectValues();
expect(values[0]).toHaveTextContent(OPTIONS[0].label);
expect(values[1]).toHaveTextContent(NULL_OPTION.label);
});
test('renders the select with default props', () => {
render(<Select {...defaultProps} />);
expect(getSelect()).toBeInTheDocument();
});
test('opens the select without any data', async () => {
render(<Select {...defaultProps} options={[]} />);
await open();
expect(
screen.getByText(NO_DATA, { selector: '.ant-empty-description' }),
).toBeInTheDocument();
});
test('makes a selection in single mode', async () => {
render(<Select {...defaultProps} />);
const optionText = 'Emma';
await open();
await userEvent.click(await findSelectOption(optionText));
expect(await findSelectValue()).toHaveTextContent(optionText);
});
test('multiple selections in multiple mode', async () => {
render(<Select {...defaultProps} mode="multiple" />);
await open();
const [firstOption, secondOption] = OPTIONS;
await userEvent.click(await findSelectOption(firstOption.label));
await userEvent.click(await findSelectOption(secondOption.label));
const values = await findAllSelectValues();
expect(values[0]).toHaveTextContent(firstOption.label);
expect(values[1]).toHaveTextContent(secondOption.label);
});
test('changes the selected item in single mode', async () => {
const onChange = jest.fn();
render(<Select {...defaultProps} onChange={onChange} />);
await open();
const [firstOption, secondOption] = OPTIONS;
await userEvent.click(await findSelectOption(firstOption.label));
expect(await findSelectValue()).toHaveTextContent(firstOption.label);
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
label: firstOption.label,
value: firstOption.value,
}),
expect.objectContaining(firstOption),
);
await userEvent.click(await findSelectOption(secondOption.label));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({
label: secondOption.label,
value: secondOption.value,
}),
expect.objectContaining(secondOption),
);
expect(await findSelectValue()).toHaveTextContent(secondOption.label);
});
test('deselects an item in multiple mode', async () => {
render(<Select {...defaultProps} mode="multiple" />);
await open();
const [firstOption, secondOption] = OPTIONS;
await userEvent.click(await findSelectOption(firstOption.label));
await userEvent.click(await findSelectOption(secondOption.label));
let values = await findAllSelectValues();
expect(values.length).toBe(2);
expect(values[0]).toHaveTextContent(firstOption.label);
expect(values[1]).toHaveTextContent(secondOption.label);
await userEvent.click(await findSelectOption(firstOption.label));
values = await findAllSelectValues();
expect(values.length).toBe(1);
expect(values[0]).toHaveTextContent(secondOption.label);
});
test('adds a new option if none is available and allowNewOptions is true', async () => {
render(<Select {...defaultProps} allowNewOptions />);
await open();
await type(NEW_OPTION);
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
});
test('shows "No data" when allowNewOptions is false and a new option is entered', async () => {
render(<Select {...defaultProps} allowNewOptions={false} />);
await open();
await type(NEW_OPTION);
expect(
await screen.findByText(NO_DATA, { selector: '.ant-empty-description' }),
).toBeInTheDocument();
});
test('does not show "No data" when allowNewOptions is true and a new option is entered', async () => {
render(<Select {...defaultProps} allowNewOptions />);
await open();
await type(NEW_OPTION);
await waitFor(() =>
expect(screen.queryByText(NO_DATA)).not.toBeInTheDocument(),
);
});
test('does not show "Loading..." when allowNewOptions is false and a new option is entered', async () => {
render(<Select {...defaultProps} allowNewOptions={false} />);
await open();
await type(NEW_OPTION);
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
});
test('does not add a new option if the option already exists', async () => {
render(<Select {...defaultProps} allowNewOptions />);
const option = OPTIONS[0].label;
await open();
await type(option);
expect(await findSelectOption(option)).toBeInTheDocument();
});
test('sets a initial value in single mode', async () => {
render(<Select {...defaultProps} value={OPTIONS[0]} />);
expect(await findSelectValue()).toHaveTextContent(OPTIONS[0].label);
});
test('sets a initial value in multiple mode', async () => {
render(
<Select
{...defaultProps}
mode="multiple"
value={[OPTIONS[0], OPTIONS[1]]}
/>,
);
const values = await findAllSelectValues();
expect(values[0]).toHaveTextContent(OPTIONS[0].label);
expect(values[1]).toHaveTextContent(OPTIONS[1].label);
});
test('searches for an item', async () => {
render(<Select {...defaultProps} />);
const search = 'Oli';
await type(search);
const options = await findAllSelectOptions();
expect(options.length).toBe(2);
expect(options[0]).toHaveTextContent('Oliver');
expect(options[1]).toHaveTextContent('Olivia');
});
test('triggers getPopupContainer if passed', async () => {
const getPopupContainer = jest.fn();
render(<Select {...defaultProps} getPopupContainer={getPopupContainer} />);
await open();
expect(getPopupContainer).toHaveBeenCalled();
});
test('does not render a helper text by default', async () => {
render(<Select {...defaultProps} />);
await open();
expect(screen.queryByRole('note')).not.toBeInTheDocument();
});
test('renders a helper text when one is provided', async () => {
const helperText = 'Helper text';
render(<Select {...defaultProps} helperText={helperText} />);
await open();
expect(screen.getByRole('note')).toBeInTheDocument();
expect(screen.queryByText(helperText)).toBeInTheDocument();
});
test('finds an element with a numeric value and does not duplicate the options', async () => {
const options = [
{ label: 'a', value: 11 },
{ label: 'b', value: 12 },
];
render(<Select {...defaultProps} options={options} allowNewOptions />);
await open();
await type('11');
expect(await findSelectOption('a')).toBeInTheDocument();
expect(await querySelectOption('11')).not.toBeInTheDocument();
});
test('render "Select all" for multi select', async () => {
render(<Select {...defaultProps} mode="multiple" options={OPTIONS} />);
await open();
expect(
await screen.findByText(selectAllButtonText(OPTIONS.length)),
).toBeInTheDocument();
});
test('does not render "Select all" for single select', async () => {
render(<Select {...defaultProps} options={OPTIONS} mode="single" />);
await open();
expect(
screen.queryByText(selectAllButtonText(OPTIONS.length)),
).not.toBeInTheDocument();
expect(
screen.queryByText(selectAllButtonText(OPTIONS.length)),
).not.toBeInTheDocument();
});
test('does not render "Select all" for an empty multiple select', async () => {
render(<Select {...defaultProps} options={[]} mode="multiple" />);
await open();
expect(
screen.queryByText(selectAllButtonText(OPTIONS.length)),
).not.toBeInTheDocument();
});
test('Renders "Select all" when searching', async () => {
render(<Select {...defaultProps} options={OPTIONS} mode="multiple" />);
await open();
await type('Select');
await waitFor(() =>
expect(
screen.queryByText(selectAllButtonText(OPTIONS.length)),
).not.toBeInTheDocument(),
);
});
test('selects all values', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS}
mode="multiple"
maxTagCount={0}
/>,
);
await open();
await userEvent.click(
await screen.findByText(selectAllButtonText(OPTIONS.length)),
);
const values = await findAllSelectValues();
expect(values.length).toBe(1);
expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
});
test('unselects all values', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS}
mode="multiple"
maxTagCount={0}
/>,
);
await open();
await userEvent.click(
await screen.findByText(selectAllButtonText(OPTIONS.length)),
);
let values = await findAllSelectValues();
expect(values.length).toBe(1);
expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
await userEvent.click(
await screen.findByText(deselectAllButtonText(OPTIONS.length)),
);
values = await findAllSelectValues();
expect(values.length).toBe(0);
});
test('deselecting a new value also removes it from the options', async () => {
render(
<Select
{...defaultProps}
options={OPTIONS.slice(0, 10)}
mode="multiple"
allowNewOptions
/>,
);
await open();
await type(NEW_OPTION);
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
await type('{enter}');
clearTypedText();
await userEvent.click(await findSelectOption(NEW_OPTION));
expect(await querySelectOption(NEW_OPTION)).not.toBeInTheDocument();
});
test('Renders only 1 tag and an overflow tag in oneLine mode', () => {
render(
<Select
{...defaultProps}
value={[OPTIONS[0], OPTIONS[1], OPTIONS[2]]}
mode="multiple"
oneLine
/>,
);
expect(screen.getByText(OPTIONS[0].label)).toBeVisible();
expect(screen.queryByText(OPTIONS[1].label)).not.toBeInTheDocument();
expect(screen.queryByText(OPTIONS[2].label)).not.toBeInTheDocument();
expect(screen.getByText('+ 2 ...')).toBeVisible();
});
test('Renders only an overflow tag if dropdown is open in oneLine mode', async () => {
render(
<Select
{...defaultProps}
value={[OPTIONS[0], OPTIONS[1], OPTIONS[2]]}
mode="multiple"
oneLine
/>,
);
await open();
const withinSelector = within(getElementByClassName('.ant-select-selector'));
await waitFor(() => {
expect(
withinSelector.queryByText(OPTIONS[0].label),
).not.toBeInTheDocument();
expect(
withinSelector.queryByText(OPTIONS[1].label),
).not.toBeInTheDocument();
expect(
withinSelector.queryByText(OPTIONS[2].label),
).not.toBeInTheDocument();
expect(withinSelector.getByText('+ 3 ...')).toBeVisible();
});
await type('{esc}');
expect(await withinSelector.findByText(OPTIONS[0].label)).toBeVisible();
expect(withinSelector.queryByText(OPTIONS[1].label)).not.toBeInTheDocument();
expect(withinSelector.queryByText(OPTIONS[2].label)).not.toBeInTheDocument();
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
});
// Test for checking the issue described in: https://github.com/apache/superset/issues/35132
test('Maintains stable maxTagCount to prevent click target disappearing in oneLine mode', async () => {
render(
<Select
{...defaultProps}
value={[OPTIONS[0], OPTIONS[1], OPTIONS[2]]}
mode="multiple"
oneLine
/>,
);
const withinSelector = within(getElementByClassName('.ant-select-selector'));
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
await userEvent.click(getSelect());
expect(withinSelector.getByText(OPTIONS[0].label)).toBeVisible();
await waitFor(() => {
expect(
withinSelector.queryByText(OPTIONS[0].label),
).not.toBeInTheDocument();
expect(withinSelector.getByText('+ 3 ...')).toBeVisible();
});
// Close dropdown
await type('{esc}');
expect(await withinSelector.findByText(OPTIONS[0].label)).toBeVisible();
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
});
test('does not render "Select all" when there are 0 or 1 options', async () => {
const { rerender } = render(
<Select {...defaultProps} options={[]} mode="multiple" allowNewOptions />,
);
await open();
expect(screen.queryByText(selectAllButtonText(0))).not.toBeInTheDocument();
rerender(
<Select
{...defaultProps}
options={OPTIONS.slice(0, 1)}
mode="multiple"
allowNewOptions
/>,
);
await open();
expect(screen.queryByText(selectAllButtonText(1))).not.toBeInTheDocument();
rerender(
<Select
{...defaultProps}
options={OPTIONS.slice(0, 2)}
mode="multiple"
allowNewOptions
/>,
);
await open();
expect(screen.getByText(selectAllButtonText(2))).toBeInTheDocument();
});
test('do not count unselected disabled options in "Select all"', async () => {
const options = [...OPTIONS];
options[0].disabled = true;
options[1].disabled = true;
render(
<Select
{...defaultProps}
options={options}
mode="multiple"
value={options[0]}
/>,
);
await open();
// We have 2 options disabled but one is selected initially
// Select all should count one and ignore the other
expect(
screen.getByText(selectAllButtonText(OPTIONS.length - 1)),
).toBeInTheDocument();
});
test('"Deselect all" counts all selected options', async () => {
render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
await open();
await userEvent.click(await findSelectOption('Ava'));
expect(await screen.findByText(deselectAllButtonText(1))).toBeInTheDocument();
});
test('"Deselect all" counts new selected options', async () => {
render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
await open();
await type(NEW_OPTION);
await userEvent.click(await findSelectOption(NEW_OPTION));
clearTypedText();
await open();
await userEvent.click(await findSelectOption('Ava'));
expect(await screen.findByText(deselectAllButtonText(2))).toBeInTheDocument();
});
test('"Select all" does not count unselected new options', async () => {
render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
await open();
await type('er');
// We have 5 options matching the search
expect(await screen.findByText(selectAllButtonText(5))).toBeInTheDocument();
});
test('"Select all" does not affect disabled options', async () => {
const options = [...OPTIONS];
options[0].disabled = true;
options[1].disabled = true;
render(
<Select
{...defaultProps}
options={options}
mode="multiple"
value={options[0]}
/>,
);
await open();
// We have 2 options disabled but one is selected initially
expect(await findSelectValue()).toHaveTextContent(options[0].label);
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
// Checking Select all shouldn't affect the disabled options
const selectAll = selectAllButtonText(OPTIONS.length - 1);
await userEvent.click(await screen.findByText(selectAll));
expect(await findSelectValue()).toHaveTextContent(options[0].label);
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
// Unchecking Select all shouldn't affect the disabled options
await userEvent.click(await screen.findByText(selectAll));
expect(await findSelectValue()).toHaveTextContent(options[0].label);
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
});
test('does not fire onChange when searching but no selection', async () => {
const onChange = jest.fn();
render(
<div role="main">
<Select
{...defaultProps}
onChange={onChange}
mode="multiple"
allowNewOptions
/>
</div>,
);
await open();
await type('Joh');
await userEvent.click(await findSelectOption('John'));
await userEvent.click(screen.getByRole('main'));
expect(onChange).toHaveBeenCalledTimes(1);
});
test('fires onChange when clearing the selection in single mode', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
onChange={onChange}
mode="single"
value={OPTIONS[0]}
/>,
);
await clearAll();
expect(onChange).toHaveBeenCalledTimes(1);
});
test('fires onChange when clearing the selection in multiple mode', async () => {
const onChange = jest.fn();
render(
<Select
{...defaultProps}
onChange={onChange}
mode="multiple"
value={OPTIONS[0]}
/>,
);
await clearAll();
expect(onChange).toHaveBeenCalledTimes(1);
});
test('fires onChange when pasting a selection', async () => {
const onChange = jest.fn();
render(<Select {...defaultProps} onChange={onChange} />);
await open();
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => OPTIONS[0].label,
},
});
fireEvent(input, paste);
expect(onChange).toHaveBeenCalledTimes(1);
});
test('does not duplicate options when using numeric values', async () => {
render(
<Select
{...defaultProps}
mode="multiple"
options={[
{ label: '1', value: 1 },
{ label: '2', value: 2 },
]}
/>,
);
await type('1');
await waitFor(() => expect(getAllSelectOptions().length).toBe(1));
});
test('pasting an existing option does not duplicate it', async () => {
render(<Select {...defaultProps} options={[OPTIONS[0]]} />);
await open();
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => OPTIONS[0].label,
},
});
fireEvent(input, paste);
expect(await findAllSelectOptions()).toHaveLength(1);
});
test('pasting an existing option does not duplicate it in multiple mode', async () => {
const options = [
{ label: 'John', value: 1 },
{ label: 'Liam', value: 2 },
{ label: 'Olivia', value: 3 },
];
render(
<Select
{...defaultProps}
options={options}
mode="multiple"
allowSelectAll={false}
allowNewOptions
/>,
);
await open();
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'John,Liam,Peter',
},
});
fireEvent(input, paste);
// Only Peter should be added
expect(await findAllSelectOptions()).toHaveLength(4);
});
test('pasting an non-existent option should not add it if allowNewOptions is false', async () => {
render(<Select {...defaultProps} options={[]} allowNewOptions={false} />);
await open();
const input = getElementByClassName('.ant-select-selection-search-input');
const paste = createEvent.paste(input, {
clipboardData: {
getData: () => 'John',
},
});
fireEvent(input, paste);
expect(await findAllSelectOptions()).toHaveLength(0);
});
test('does not fire onChange if the same value is selected in single mode', async () => {
const onChange = jest.fn();
render(<Select {...defaultProps} onChange={onChange} />);
const optionText = 'Emma';
await open();
expect(onChange).toHaveBeenCalledTimes(0);
await userEvent.click(await findSelectOption(optionText));
expect(onChange).toHaveBeenCalledTimes(1);
await userEvent.click(await findSelectOption(optionText));
expect(onChange).toHaveBeenCalledTimes(1);
});
// Reference for the bug this tests: https://github.com/apache/superset/pull/33043#issuecomment-2809419640
test('typing and deleting the last character for a new option displays correctly', async () => {
jest.useFakeTimers();
render(<Select {...defaultProps} allowNewOptions />);
await open();
await type('aaa', 0, false);
jest.runAllTimers();
await type('{backspace}', 0, false);
await type('a', 0, false);
jest.runAllTimers();
expect(
screen.queryByText(NO_DATA, { selector: '.ant-empty-description' }),
).not.toBeInTheDocument();
expect(await findSelectOption('aaa')).toBeInTheDocument();
jest.useRealTimers();
});
describe('grouped options search', () => {
const GROUPED_OPTIONS = [
{
label: 'Male',
options: OPTIONS.filter(option => option.gender === 'Male'),
},
{
label: 'Female',
options: OPTIONS.filter(option => option.gender === 'Female'),
},
];
it('searches within grouped options and shows matching groups', async () => {
render(<Select {...defaultProps} options={GROUPED_OPTIONS} />);
await open();
await type('John');
expect(await findSelectOption('John')).toBeInTheDocument();
expect(await findSelectOption('Johnny')).toBeInTheDocument();
expect(screen.queryByText('Female')).not.toBeInTheDocument();
expect(screen.queryByText('Olivia')).not.toBeInTheDocument();
expect(screen.getByText('Male')).toBeInTheDocument();
expect(screen.queryByText('Female')).not.toBeInTheDocument();
});
it('shows multiple groups when search matches both', async () => {
render(<Select {...defaultProps} options={GROUPED_OPTIONS} />);
await open();
await type('er');
expect(screen.getByText('Male')).toBeInTheDocument();
expect(screen.getByText('Female')).toBeInTheDocument();
expect(await findSelectOption('Oliver')).toBeInTheDocument();
expect(await findSelectOption('Cher')).toBeInTheDocument();
expect(await findSelectOption('Her')).toBeInTheDocument();
});
it('handles case-insensitive search in grouped options', async () => {
render(<Select {...defaultProps} options={GROUPED_OPTIONS} />);
await open();
await type('EMMA');
expect(await findSelectOption('Emma')).toBeInTheDocument();
expect(screen.getByText('Female')).toBeInTheDocument();
expect(screen.queryByText('Male')).not.toBeInTheDocument();
});
it('shows no options when search matches nothing in any group', async () => {
render(<Select {...defaultProps} options={GROUPED_OPTIONS} />);
await open();
await type('xyz123');
expect(screen.queryByText('Male')).not.toBeInTheDocument();
expect(screen.queryByText('Female')).not.toBeInTheDocument();
expect(
screen.getByText(NO_DATA, { selector: '.ant-empty-description' }),
).toBeInTheDocument();
});
it('works in multiple selection mode with grouped options', async () => {
render(
<Select {...defaultProps} options={GROUPED_OPTIONS} mode="multiple" />,
);
await open();
await type('John');
await userEvent.click(await findSelectOption('John'));
// Clear search and search for female name
await clearTypedText();
await type('Emma');
await userEvent.click(await findSelectOption('Emma'));
// Both should be selected
const values = await findAllSelectValues();
expect(values).toHaveLength(2);
expect(values[0]).toHaveTextContent('John');
expect(values[1]).toHaveTextContent('Emma');
});
it('preserves group structure when not searching', async () => {
render(<Select {...defaultProps} options={GROUPED_OPTIONS} />);
await open();
expect(screen.getByText('Male')).toBeInTheDocument();
expect(screen.getByText('Female')).toBeInTheDocument();
expect(await findSelectOption('John')).toBeInTheDocument();
expect(await findSelectOption('Emma')).toBeInTheDocument();
});
it('handles empty groups gracefully', async () => {
const optionsWithEmptyGroup = [
...GROUPED_OPTIONS,
{
label: 'Empty Group',
options: [],
},
];
render(<Select {...defaultProps} options={optionsWithEmptyGroup} />);
await open();
await type('John');
expect(await findSelectOption('John')).toBeInTheDocument();
expect(screen.queryByText('Empty Group')).not.toBeInTheDocument();
});
});
/*
TODO: Add tests that require scroll interaction. Needs further investigation.
- Fetches more data when scrolling and more data is available
- Doesn't fetch more data when no more data is available
- Requests the correct page and page size
- Sets the page to zero when a new search is made
*/