chore: Split Select component into Async and Sync components (#20466)
* Created AsyncSelect Component Changed files to reference AsyncSelect if needed * modified import of AsyncSelect, removed async tests and prefixes from select tests * fixed various import and lint warnings * fixing lint errors * fixed frontend test errors * fixed alertreportmodel tests * removed accidental import * fixed lint errors * updated async select
This commit is contained in:
parent
6132d78efb
commit
1109fe5fb7
|
|
@ -19,7 +19,7 @@
|
|||
import React from 'react';
|
||||
import { ReactWrapper } from 'enzyme';
|
||||
import Button from 'src/components/Button';
|
||||
import { Select } from 'src/components';
|
||||
import { AsyncSelect } from 'src/components';
|
||||
import AddSliceContainer, {
|
||||
AddSliceContainerProps,
|
||||
AddSliceContainerState,
|
||||
|
|
@ -72,7 +72,7 @@ async function getWrapper(user = mockUser) {
|
|||
|
||||
test('renders a select and a VizTypeControl', async () => {
|
||||
const wrapper = await getWrapper();
|
||||
expect(wrapper.find(Select)).toExist();
|
||||
expect(wrapper.find(AsyncSelect)).toExist();
|
||||
expect(wrapper.find(VizTypeGallery)).toExist();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
|||
import { URL_PARAMS } from 'src/constants';
|
||||
import { isNullish } from 'src/utils/common';
|
||||
import Button from 'src/components/Button';
|
||||
import { Select, Steps } from 'src/components';
|
||||
import { AsyncSelect, Steps } from 'src/components';
|
||||
import { Tooltip } from 'src/components/Tooltip';
|
||||
|
||||
import VizTypeGallery, {
|
||||
|
|
@ -349,7 +349,7 @@ export default class AddSliceContainer extends React.PureComponent<
|
|||
status={this.state.datasource?.value ? 'finish' : 'process'}
|
||||
description={
|
||||
<StyledStepDescription className="dataset">
|
||||
<Select
|
||||
<AsyncSelect
|
||||
autoFocus
|
||||
ariaLabel={t('Dataset')}
|
||||
name="select-datasource"
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
import React, { ReactNode, useState, useMemo, useEffect } from 'react';
|
||||
import { styled, SupersetClient, t } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { Select } from 'src/components';
|
||||
import { AsyncSelect, Select } from 'src/components';
|
||||
import Label from 'src/components/Label';
|
||||
import { FormLabel } from 'src/components/Form';
|
||||
import RefreshLabel from 'src/components/RefreshLabel';
|
||||
|
|
@ -272,7 +272,7 @@ export default function DatabaseSelector({
|
|||
|
||||
function renderDatabaseSelect() {
|
||||
return renderSelectRow(
|
||||
<Select
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Select database or type database name')}
|
||||
optionFilterProps={['database_name', 'value']}
|
||||
data-test="select-database"
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import Alert from 'src/components/Alert';
|
|||
import Badge from 'src/components/Badge';
|
||||
import shortid from 'shortid';
|
||||
import { styled, SupersetClient, t, withTheme } from '@superset-ui/core';
|
||||
import { Select, Row, Col } from 'src/components';
|
||||
import { Select, AsyncSelect, Row, Col } from 'src/components';
|
||||
import { FormLabel } from 'src/components/Form';
|
||||
import Button from 'src/components/Button';
|
||||
import Tabs from 'src/components/Tabs';
|
||||
|
|
@ -548,7 +548,7 @@ function OwnersSelector({ datasource, onChange }) {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Select
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Select owners')}
|
||||
mode="multiple"
|
||||
name="owners"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,705 @@
|
|||
/**
|
||||
* 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 React from 'react';
|
||||
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { AsyncSelect } from 'src/components';
|
||||
|
||||
const ARIA_LABEL = 'Test';
|
||||
const NEW_OPTION = 'Kyle';
|
||||
const NO_DATA = 'No Data';
|
||||
const LOADING = 'Loading...';
|
||||
const OPTIONS = [
|
||||
{ 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 loadOptions = async (search: string, page: number, pageSize: number) => {
|
||||
const totalCount = OPTIONS.length;
|
||||
const start = page * pageSize;
|
||||
const deleteCount =
|
||||
start + pageSize < totalCount ? pageSize : totalCount - start;
|
||||
const data = OPTIONS.filter(option => option.label.match(search)).splice(
|
||||
start,
|
||||
deleteCount,
|
||||
);
|
||||
return {
|
||||
data,
|
||||
totalCount: OPTIONS.length,
|
||||
};
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
allowClear: true,
|
||||
ariaLabel: ARIA_LABEL,
|
||||
labelInValue: true,
|
||||
options: OPTIONS,
|
||||
pageSize: 10,
|
||||
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: ARIA_LABEL });
|
||||
|
||||
const findSelectOption = (text: string) =>
|
||||
waitFor(() =>
|
||||
within(getElementByClassName('.rc-virtual-list')).getByText(text),
|
||||
);
|
||||
|
||||
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 = (text: string) => {
|
||||
const select = getSelect();
|
||||
userEvent.clear(select);
|
||||
return userEvent.type(select, text, { delay: 10 });
|
||||
};
|
||||
|
||||
const open = () => waitFor(() => userEvent.click(getSelect()));
|
||||
|
||||
test('displays a header', async () => {
|
||||
const headerText = 'Header';
|
||||
render(<AsyncSelect {...defaultProps} header={headerText} />);
|
||||
expect(screen.getByText(headerText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adds a new option if the value is not in the options', async () => {
|
||||
const { rerender } = render(
|
||||
<AsyncSelect {...defaultProps} options={[]} value={OPTIONS[0]} />,
|
||||
);
|
||||
await open();
|
||||
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<AsyncSelect {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]} />,
|
||||
);
|
||||
await open();
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(2);
|
||||
options.forEach((option, i) =>
|
||||
expect(option).toHaveTextContent(OPTIONS[i].label),
|
||||
);
|
||||
});
|
||||
|
||||
test('inverts the selection', async () => {
|
||||
render(<AsyncSelect {...defaultProps} invertSelection />);
|
||||
await open();
|
||||
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(<AsyncSelect {...defaultProps} options={unsortedOptions} />);
|
||||
await open();
|
||||
const options = await findAllSelectOptions();
|
||||
options.forEach((option, key) =>
|
||||
expect(option).toHaveTextContent(OPTIONS[key].label),
|
||||
);
|
||||
});
|
||||
|
||||
test('sort the options using a custom sort comparator', async () => {
|
||||
const sortComparator = (
|
||||
option1: typeof OPTIONS[0],
|
||||
option2: typeof OPTIONS[0],
|
||||
) => option1.gender.localeCompare(option2.gender);
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
sortComparator={sortComparator}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
const options = await findAllSelectOptions();
|
||||
const optionsPage = OPTIONS.slice(0, defaultProps.pageSize);
|
||||
const sortedOptions = optionsPage.sort(sortComparator);
|
||||
options.forEach((option, key) => {
|
||||
expect(option).toHaveTextContent(sortedOptions[key].label);
|
||||
});
|
||||
});
|
||||
|
||||
test('should sort selected to top when in single mode', async () => {
|
||||
render(<AsyncSelect {...defaultProps} mode="single" />);
|
||||
const originalLabels = OPTIONS.map(option => option.label);
|
||||
await open();
|
||||
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 type('{esc}');
|
||||
await open();
|
||||
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
|
||||
userEvent.click(await findSelectOption(originalLabels[5]));
|
||||
await matchOrder(labels);
|
||||
await type('{esc}');
|
||||
await open();
|
||||
labels = originalLabels.slice();
|
||||
labels = labels.splice(5, 1).concat(labels);
|
||||
expect(await matchOrder(labels)).toBe(true);
|
||||
|
||||
// should revert to original order
|
||||
clearAll();
|
||||
await type('{esc}');
|
||||
await open();
|
||||
expect(await matchOrder(originalLabels)).toBe(true);
|
||||
});
|
||||
|
||||
test('should sort selected to the top when in multi mode', async () => {
|
||||
render(<AsyncSelect {...defaultProps} mode="multiple" />);
|
||||
const originalLabels = OPTIONS.map(option => option.label);
|
||||
let labels = originalLabels.slice();
|
||||
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(labels[1]));
|
||||
expect(await matchOrder(labels)).toBe(true);
|
||||
|
||||
await type('{esc}');
|
||||
await open();
|
||||
labels = labels.splice(1, 1).concat(labels);
|
||||
expect(await matchOrder(labels)).toBe(true);
|
||||
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(labels[5]));
|
||||
await type('{esc}');
|
||||
await open();
|
||||
labels = [labels.splice(0, 1)[0], labels.splice(4, 1)[0]].concat(labels);
|
||||
expect(await matchOrder(labels)).toBe(true);
|
||||
|
||||
// should revert to original order
|
||||
clearAll();
|
||||
await type('{esc}');
|
||||
await open();
|
||||
expect(await matchOrder(originalLabels)).toBe(true);
|
||||
});
|
||||
|
||||
test('searches for label or value', async () => {
|
||||
const option = OPTIONS[11];
|
||||
render(<AsyncSelect {...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(<AsyncSelect {...defaultProps} />);
|
||||
await type('Her');
|
||||
const options = await findAllSelectOptions();
|
||||
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(<AsyncSelect {...defaultProps} />);
|
||||
await type('george');
|
||||
expect(await findSelectOption('George')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('same case should be ranked to the top', async () => {
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={[
|
||||
{ value: 'Cac' },
|
||||
{ value: 'abac' },
|
||||
{ value: 'acbc' },
|
||||
{ value: 'CAc' },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await type('Ac');
|
||||
const options = await findAllSelectOptions();
|
||||
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(<AsyncSelect {...defaultProps} />);
|
||||
await type('{shift}');
|
||||
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('searches for custom fields', async () => {
|
||||
render(
|
||||
<AsyncSelect {...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)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('removes duplicated values', async () => {
|
||||
render(<AsyncSelect {...defaultProps} mode="multiple" allowNewOptions />);
|
||||
await type('a,b,b,b,c,d,d');
|
||||
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 = [
|
||||
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
|
||||
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
|
||||
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
|
||||
];
|
||||
render(<AsyncSelect {...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 = [
|
||||
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
|
||||
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
|
||||
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
|
||||
];
|
||||
render(<AsyncSelect {...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(<AsyncSelect {...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(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
mode="multiple"
|
||||
value={[OPTIONS[0], OPTIONS[1]]}
|
||||
onClear={onClear}
|
||||
/>,
|
||||
);
|
||||
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(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('adds the null option when selected in single mode', async () => {
|
||||
render(<AsyncSelect {...defaultProps} options={[OPTIONS[0], NULL_OPTION]} />);
|
||||
await open();
|
||||
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(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={[OPTIONS[0], NULL_OPTION, OPTIONS[2]]}
|
||||
mode="multiple"
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(OPTIONS[0].label));
|
||||
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(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
expect(getSelect()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('opens the select without any data', async () => {
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={async () => ({ data: [], totalCount: 0 })}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
expect(await screen.findByText(/no data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays the loading indicator when opening', async () => {
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await waitFor(() => {
|
||||
userEvent.click(getSelect());
|
||||
expect(screen.getByText(LOADING)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('makes a selection in single mode', async () => {
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
const optionText = 'Emma';
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(optionText));
|
||||
expect(await findSelectValue()).toHaveTextContent(optionText);
|
||||
});
|
||||
|
||||
test('multiple selections in multiple mode', async () => {
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} mode="multiple" />,
|
||||
);
|
||||
await open();
|
||||
const [firstOption, secondOption] = OPTIONS;
|
||||
userEvent.click(await findSelectOption(firstOption.label));
|
||||
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(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} onChange={onChange} />,
|
||||
);
|
||||
await open();
|
||||
const [firstOption, secondOption] = OPTIONS;
|
||||
userEvent.click(await findSelectOption(firstOption.label));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: firstOption.label,
|
||||
value: firstOption.value,
|
||||
}),
|
||||
firstOption,
|
||||
);
|
||||
expect(await findSelectValue()).toHaveTextContent(firstOption.label);
|
||||
userEvent.click(await findSelectOption(secondOption.label));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: secondOption.label,
|
||||
value: secondOption.value,
|
||||
}),
|
||||
secondOption,
|
||||
);
|
||||
expect(await findSelectValue()).toHaveTextContent(secondOption.label);
|
||||
});
|
||||
|
||||
test('deselects an item in multiple mode', async () => {
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} mode="multiple" />,
|
||||
);
|
||||
await open();
|
||||
const option3 = OPTIONS[2];
|
||||
const option8 = OPTIONS[7];
|
||||
userEvent.click(await findSelectOption(option8.label));
|
||||
userEvent.click(await findSelectOption(option3.label));
|
||||
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(Math.min(defaultProps.pageSize, OPTIONS.length));
|
||||
expect(options[0]).toHaveTextContent(OPTIONS[0].label);
|
||||
expect(options[1]).toHaveTextContent(OPTIONS[1].label);
|
||||
|
||||
await type('{esc}');
|
||||
await open();
|
||||
|
||||
// should rank selected options to the top after menu closes
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(Math.min(defaultProps.pageSize, OPTIONS.length));
|
||||
expect(options[0]).toHaveTextContent(option3.label);
|
||||
expect(options[1]).toHaveTextContent(option8.label);
|
||||
|
||||
let values = await findAllSelectValues();
|
||||
expect(values).toHaveLength(2);
|
||||
// should keep the order by which the options were selected
|
||||
expect(values[0]).toHaveTextContent(option8.label);
|
||||
expect(values[1]).toHaveTextContent(option3.label);
|
||||
|
||||
userEvent.click(await findSelectOption(option3.label));
|
||||
values = await findAllSelectValues();
|
||||
expect(values.length).toBe(1);
|
||||
expect(values[0]).toHaveTextContent(option8.label);
|
||||
});
|
||||
|
||||
test('adds a new option if none is available and allowNewOptions is true', async () => {
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
||||
);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not add a new option if the option already exists', async () => {
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
||||
);
|
||||
const option = OPTIONS[0].label;
|
||||
await open();
|
||||
await type(option);
|
||||
await waitFor(() => {
|
||||
const array = within(
|
||||
getElementByClassName('.rc-virtual-list'),
|
||||
).getAllByText(option);
|
||||
expect(array.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('shows "No data" when allowNewOptions is false and a new option is entered', async () => {
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
allowNewOptions={false}
|
||||
showSearch
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not show "No data" when allowNewOptions is true and a new option is entered', async () => {
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} allowNewOptions />,
|
||||
);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
expect(screen.queryByText(NO_DATA)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('sets a initial value in single mode', async () => {
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} value={OPTIONS[0]} />,
|
||||
);
|
||||
expect(await findSelectValue()).toHaveTextContent(OPTIONS[0].label);
|
||||
});
|
||||
|
||||
test('sets a initial value in multiple mode', async () => {
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
mode="multiple"
|
||||
options={loadOptions}
|
||||
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 matches in both loaded and unloaded pages', async () => {
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await type('and');
|
||||
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options.length).toBe(1);
|
||||
expect(options[0]).toHaveTextContent('Alehandro');
|
||||
|
||||
await screen.findByText('Sandro');
|
||||
options = await findAllSelectOptions();
|
||||
expect(options.length).toBe(2);
|
||||
expect(options[0]).toHaveTextContent('Alehandro');
|
||||
expect(options[1]).toHaveTextContent('Sandro');
|
||||
});
|
||||
|
||||
test('searches for an item in a page not loaded', async () => {
|
||||
const mock = jest.fn(loadOptions);
|
||||
render(<AsyncSelect {...defaultProps} options={mock} />);
|
||||
const search = 'Sandro';
|
||||
await open();
|
||||
await type(search);
|
||||
await waitFor(() => expect(mock).toHaveBeenCalledTimes(2));
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options.length).toBe(1);
|
||||
expect(options[0]).toHaveTextContent(search);
|
||||
});
|
||||
|
||||
test('does not fetches data when rendering', async () => {
|
||||
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
expect(loadOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fetches data when opening', async () => {
|
||||
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
expect(loadOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('fetches data only after a search input is entered if fetchOnlyOnSearch is true', async () => {
|
||||
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} fetchOnlyOnSearch />,
|
||||
);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).not.toHaveBeenCalled());
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('displays an error message when an exception is thrown while fetching', async () => {
|
||||
const error = 'Fetch error';
|
||||
const loadOptions = async () => {
|
||||
throw new Error(error);
|
||||
};
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not fire a new request for the same search input', async () => {
|
||||
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||
render(
|
||||
<AsyncSelect {...defaultProps} options={loadOptions} fetchOnlyOnSearch />,
|
||||
);
|
||||
await type('search');
|
||||
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||
expect(loadOptions).toHaveBeenCalledTimes(1);
|
||||
clearAll();
|
||||
await type('search');
|
||||
expect(await screen.findByText(LOADING)).toBeInTheDocument();
|
||||
expect(loadOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not fire a new request if all values have been fetched', async () => {
|
||||
const mock = jest.fn(loadOptions);
|
||||
const search = 'George';
|
||||
const pageSize = OPTIONS.length;
|
||||
render(<AsyncSelect {...defaultProps} options={mock} pageSize={pageSize} />);
|
||||
await open();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
await type(search);
|
||||
expect(await findSelectOption(search)).toBeInTheDocument();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('fires a new request if all values have not been fetched', async () => {
|
||||
const mock = jest.fn(loadOptions);
|
||||
const pageSize = OPTIONS.length / 2;
|
||||
render(<AsyncSelect {...defaultProps} options={mock} pageSize={pageSize} />);
|
||||
await open();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
await type('or');
|
||||
|
||||
// `George` is on the first page so when it appears the API has not been called again
|
||||
expect(await findSelectOption('George')).toBeInTheDocument();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// `Igor` is on the second paged API request
|
||||
expect(await findSelectOption('Igor')).toBeInTheDocument();
|
||||
expect(mock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
/*
|
||||
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
|
||||
*/
|
||||
|
|
@ -0,0 +1,754 @@
|
|||
/**
|
||||
* 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 React, {
|
||||
forwardRef,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
UIEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
||||
import AntdSelect, {
|
||||
SelectProps as AntdSelectProps,
|
||||
SelectValue as AntdSelectValue,
|
||||
LabeledValue as AntdLabeledValue,
|
||||
} from 'antd/lib/select';
|
||||
import { DownOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { Spin } from 'antd';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { isEqual } from 'lodash';
|
||||
import Icons from 'src/components/Icons';
|
||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import { SLOW_DEBOUNCE } from 'src/constants';
|
||||
import { rankedSearchCompare } from 'src/utils/rankedSearchCompare';
|
||||
import { getValue, hasOption, isLabeledValue } from './utils';
|
||||
|
||||
const { Option } = AntdSelect;
|
||||
|
||||
type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>;
|
||||
|
||||
type PickedSelectProps = Pick<
|
||||
AntdSelectAllProps,
|
||||
| 'allowClear'
|
||||
| 'autoFocus'
|
||||
| 'disabled'
|
||||
| 'filterOption'
|
||||
| 'labelInValue'
|
||||
| 'loading'
|
||||
| 'notFoundContent'
|
||||
| 'onChange'
|
||||
| 'onClear'
|
||||
| 'onFocus'
|
||||
| 'onBlur'
|
||||
| 'onDropdownVisibleChange'
|
||||
| 'placeholder'
|
||||
| 'showSearch'
|
||||
| 'tokenSeparators'
|
||||
| 'value'
|
||||
| 'getPopupContainer'
|
||||
>;
|
||||
|
||||
export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
|
||||
|
||||
export type OptionsTypePage = {
|
||||
data: OptionsType;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
export type OptionsPagePromise = (
|
||||
search: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
) => Promise<OptionsTypePage>;
|
||||
|
||||
export type AsyncSelectRef = HTMLInputElement & { clearCache: () => void };
|
||||
|
||||
export interface AsyncSelectProps extends PickedSelectProps {
|
||||
/**
|
||||
* It enables the user to create new options.
|
||||
* Can be used with standard or async select types.
|
||||
* Can be used with any mode, single or multiple.
|
||||
* False by default.
|
||||
* */
|
||||
allowNewOptions?: boolean;
|
||||
/**
|
||||
* It adds the aria-label tag for accessibility standards.
|
||||
* Must be plain English and localized.
|
||||
*/
|
||||
ariaLabel: string;
|
||||
/**
|
||||
* It adds a header on top of the Select.
|
||||
* Can be any ReactNode.
|
||||
*/
|
||||
header?: ReactNode;
|
||||
/**
|
||||
* It fires a request against the server after
|
||||
* the first interaction and not on render.
|
||||
* Works in async mode only (See the options property).
|
||||
* True by default.
|
||||
*/
|
||||
lazyLoading?: boolean;
|
||||
/**
|
||||
* It defines whether the Select should allow for the
|
||||
* selection of multiple options or single.
|
||||
* Single by default.
|
||||
*/
|
||||
mode?: 'single' | 'multiple';
|
||||
/**
|
||||
* Deprecated.
|
||||
* Prefer ariaLabel instead.
|
||||
*/
|
||||
name?: string; // discourage usage
|
||||
/**
|
||||
* It allows to define which properties of the option object
|
||||
* should be looked for when searching.
|
||||
* By default label and value.
|
||||
*/
|
||||
optionFilterProps?: string[];
|
||||
/**
|
||||
* It defines the options of the Select.
|
||||
* The options can be static, an array of options.
|
||||
* The options can also be async, a promise that returns
|
||||
* an array of options.
|
||||
*/
|
||||
options: OptionsType | OptionsPagePromise;
|
||||
/**
|
||||
* It defines how many results should be included
|
||||
* in the query response.
|
||||
* Works in async mode only (See the options property).
|
||||
*/
|
||||
pageSize?: number;
|
||||
/**
|
||||
* It shows a stop-outlined icon at the far right of a selected
|
||||
* option instead of the default checkmark.
|
||||
* Useful to better indicate to the user that by clicking on a selected
|
||||
* option it will be de-selected.
|
||||
* False by default.
|
||||
*/
|
||||
invertSelection?: boolean;
|
||||
/**
|
||||
* It fires a request against the server only after
|
||||
* searching.
|
||||
* Works in async mode only (See the options property).
|
||||
* Undefined by default.
|
||||
*/
|
||||
fetchOnlyOnSearch?: boolean;
|
||||
/**
|
||||
* It provides a callback function when an error
|
||||
* is generated after a request is fired.
|
||||
* Works in async mode only (See the options property).
|
||||
*/
|
||||
onError?: (error: string) => void;
|
||||
/**
|
||||
* Customize how filtered options are sorted while users search.
|
||||
* Will not apply to predefined `options` array when users are not searching.
|
||||
*/
|
||||
sortComparator?: typeof DEFAULT_SORT_COMPARATOR;
|
||||
}
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSelect = styled(AntdSelect)`
|
||||
${({ theme }) => `
|
||||
&& .ant-select-selector {
|
||||
border-radius: ${theme.gridUnit}px;
|
||||
}
|
||||
// Open the dropdown when clicking on the suffix
|
||||
// This is fixed in version 4.16
|
||||
.ant-select-arrow .anticon:not(.ant-select-suffix) {
|
||||
pointer-events: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledStopOutlined = styled(Icons.StopOutlined)`
|
||||
vertical-align: 0;
|
||||
`;
|
||||
|
||||
const StyledCheckOutlined = styled(Icons.CheckOutlined)`
|
||||
vertical-align: 0;
|
||||
`;
|
||||
|
||||
const StyledError = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
padding: ${theme.gridUnit * 2}px;
|
||||
color: ${theme.colors.error.base};
|
||||
& svg {
|
||||
margin-right: ${theme.gridUnit * 2}px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledErrorMessage = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const StyledSpin = styled(Spin)`
|
||||
margin-top: ${({ theme }) => -theme.gridUnit}px;
|
||||
`;
|
||||
|
||||
const StyledLoadingText = styled.div`
|
||||
${({ theme }) => `
|
||||
margin-left: ${theme.gridUnit * 3}px;
|
||||
line-height: ${theme.gridUnit * 8}px;
|
||||
color: ${theme.colors.grayscale.light1};
|
||||
`}
|
||||
`;
|
||||
|
||||
const MAX_TAG_COUNT = 4;
|
||||
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
|
||||
const DEFAULT_PAGE_SIZE = 100;
|
||||
const EMPTY_OPTIONS: OptionsType = [];
|
||||
|
||||
const Error = ({ error }: { error: string }) => (
|
||||
<StyledError>
|
||||
<Icons.ErrorSolid /> <StyledErrorMessage>{error}</StyledErrorMessage>
|
||||
</StyledError>
|
||||
);
|
||||
|
||||
export const DEFAULT_SORT_COMPARATOR = (
|
||||
a: AntdLabeledValue,
|
||||
b: AntdLabeledValue,
|
||||
search?: string,
|
||||
) => {
|
||||
let aText: string | undefined;
|
||||
let bText: string | undefined;
|
||||
if (typeof a.label === 'string' && typeof b.label === 'string') {
|
||||
aText = a.label;
|
||||
bText = b.label;
|
||||
} else if (typeof a.value === 'string' && typeof b.value === 'string') {
|
||||
aText = a.value;
|
||||
bText = b.value;
|
||||
}
|
||||
// sort selected options first
|
||||
if (typeof aText === 'string' && typeof bText === 'string') {
|
||||
if (search) {
|
||||
return rankedSearchCompare(aText, bText, search);
|
||||
}
|
||||
return aText.localeCompare(bText);
|
||||
}
|
||||
return (a.value as number) - (b.value as number);
|
||||
};
|
||||
|
||||
/**
|
||||
* It creates a comparator to check for a specific property.
|
||||
* Can be used with string and number property values.
|
||||
* */
|
||||
export const propertyComparator =
|
||||
(property: string) => (a: AntdLabeledValue, b: AntdLabeledValue) => {
|
||||
if (typeof a[property] === 'string' && typeof b[property] === 'string') {
|
||||
return a[property].localeCompare(b[property]);
|
||||
}
|
||||
return (a[property] as number) - (b[property] as number);
|
||||
};
|
||||
|
||||
const getQueryCacheKey = (value: string, page: number, pageSize: number) =>
|
||||
`${value};${page};${pageSize}`;
|
||||
|
||||
/**
|
||||
* This component is a customized version of the Antdesign 4.X Select component
|
||||
* https://ant.design/components/select/.
|
||||
* The aim of the component was to combine all the instances of select components throughout the
|
||||
* project under one and to remove the react-select component entirely.
|
||||
* This Select component provides an API that is tested against all the different use cases of Superset.
|
||||
* It limits and overrides the existing Antdesign API in order to keep their usage to the minimum
|
||||
* and to enforce simplification and standardization.
|
||||
* It is divided into two macro categories, Static and Async.
|
||||
* The Static type accepts a static array of options.
|
||||
* The Async type accepts a promise that will return the options.
|
||||
* Each of the categories come with different abilities. For a comprehensive guide please refer to
|
||||
* the storybook in src/components/Select/Select.stories.tsx.
|
||||
*/
|
||||
const AsyncSelect = (
|
||||
{
|
||||
allowClear,
|
||||
allowNewOptions = false,
|
||||
ariaLabel,
|
||||
fetchOnlyOnSearch,
|
||||
filterOption = true,
|
||||
header = null,
|
||||
invertSelection = false,
|
||||
labelInValue = false,
|
||||
lazyLoading = true,
|
||||
loading,
|
||||
mode = 'single',
|
||||
name,
|
||||
notFoundContent,
|
||||
onError,
|
||||
onChange,
|
||||
onClear,
|
||||
onDropdownVisibleChange,
|
||||
optionFilterProps = ['label', 'value'],
|
||||
options,
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
placeholder = t('Select ...'),
|
||||
showSearch = true,
|
||||
sortComparator = DEFAULT_SORT_COMPARATOR,
|
||||
tokenSeparators,
|
||||
value,
|
||||
getPopupContainer,
|
||||
...props
|
||||
}: AsyncSelectProps,
|
||||
ref: RefObject<AsyncSelectRef>,
|
||||
) => {
|
||||
const isAsync = typeof options === 'function';
|
||||
const isSingleMode = mode === 'single';
|
||||
const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch;
|
||||
const [selectValue, setSelectValue] = useState(value);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(loading);
|
||||
const [error, setError] = useState('');
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [loadingEnabled, setLoadingEnabled] = useState(!lazyLoading);
|
||||
const [allValuesLoaded, setAllValuesLoaded] = useState(false);
|
||||
const fetchedQueries = useRef(new Map<string, number>());
|
||||
const mappedMode = isSingleMode
|
||||
? undefined
|
||||
: allowNewOptions
|
||||
? 'tags'
|
||||
: 'multiple';
|
||||
const allowFetch = !fetchOnlyOnSearch || inputValue;
|
||||
|
||||
const sortSelectedFirst = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
selectValue && a.value !== undefined && b.value !== undefined
|
||||
? Number(hasOption(b.value, selectValue)) -
|
||||
Number(hasOption(a.value, selectValue))
|
||||
: 0,
|
||||
[selectValue],
|
||||
);
|
||||
const sortComparatorWithSearch = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
sortSelectedFirst(a, b) || sortComparator(a, b, inputValue),
|
||||
[inputValue, sortComparator, sortSelectedFirst],
|
||||
);
|
||||
const sortComparatorForNoSearch = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
sortSelectedFirst(a, b) ||
|
||||
// Only apply the custom sorter in async mode because we should
|
||||
// preserve the options order as much as possible.
|
||||
(isAsync ? sortComparator(a, b, '') : 0),
|
||||
[isAsync, sortComparator, sortSelectedFirst],
|
||||
);
|
||||
|
||||
const initialOptions = useMemo(
|
||||
() => (options && Array.isArray(options) ? options.slice() : EMPTY_OPTIONS),
|
||||
[options],
|
||||
);
|
||||
const initialOptionsSorted = useMemo(
|
||||
() => initialOptions.slice().sort(sortComparatorForNoSearch),
|
||||
[initialOptions, sortComparatorForNoSearch],
|
||||
);
|
||||
|
||||
const [selectOptions, setSelectOptions] =
|
||||
useState<OptionsType>(initialOptionsSorted);
|
||||
|
||||
// add selected values to options list if they are not in it
|
||||
const fullSelectOptions = useMemo(() => {
|
||||
const missingValues: OptionsType = ensureIsArray(selectValue)
|
||||
.filter(opt => !hasOption(getValue(opt), selectOptions))
|
||||
.map(opt =>
|
||||
isLabeledValue(opt) ? opt : { value: opt, label: String(opt) },
|
||||
);
|
||||
return missingValues.length > 0
|
||||
? missingValues.concat(selectOptions)
|
||||
: selectOptions;
|
||||
}, [selectOptions, selectValue]);
|
||||
|
||||
const hasCustomLabels = fullSelectOptions.some(opt => !!opt?.customLabel);
|
||||
|
||||
const handleOnSelect = (
|
||||
selectedItem: string | number | AntdLabeledValue | undefined,
|
||||
) => {
|
||||
if (isSingleMode) {
|
||||
setSelectValue(selectedItem);
|
||||
} else {
|
||||
setSelectValue(previousState => {
|
||||
const array = ensureIsArray(previousState);
|
||||
const value = getValue(selectedItem);
|
||||
// Tokenized values can contain duplicated values
|
||||
if (!hasOption(value, array)) {
|
||||
const result = [...array, selectedItem];
|
||||
return isLabeledValue(selectedItem)
|
||||
? (result as AntdLabeledValue[])
|
||||
: (result as (string | number)[]);
|
||||
}
|
||||
return previousState;
|
||||
});
|
||||
}
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const handleOnDeselect = (
|
||||
value: string | number | AntdLabeledValue | undefined,
|
||||
) => {
|
||||
if (Array.isArray(selectValue)) {
|
||||
if (isLabeledValue(value)) {
|
||||
const array = selectValue as AntdLabeledValue[];
|
||||
setSelectValue(array.filter(element => element.value !== value.value));
|
||||
} else {
|
||||
const array = selectValue as (string | number)[];
|
||||
setSelectValue(array.filter(element => element !== value));
|
||||
}
|
||||
}
|
||||
setInputValue('');
|
||||
};
|
||||
|
||||
const internalOnError = useCallback(
|
||||
(response: Response) =>
|
||||
getClientErrorObject(response).then(e => {
|
||||
const { error } = e;
|
||||
setError(error);
|
||||
|
||||
if (onError) {
|
||||
onError(error);
|
||||
}
|
||||
}),
|
||||
[onError],
|
||||
);
|
||||
|
||||
const mergeData = useCallback(
|
||||
(data: OptionsType) => {
|
||||
let mergedData: OptionsType = [];
|
||||
if (data && Array.isArray(data) && data.length) {
|
||||
// unique option values should always be case sensitive so don't lowercase
|
||||
const dataValues = new Set(data.map(opt => opt.value));
|
||||
// merges with existing and creates unique options
|
||||
setSelectOptions(prevOptions => {
|
||||
mergedData = prevOptions
|
||||
.filter(previousOption => !dataValues.has(previousOption.value))
|
||||
.concat(data)
|
||||
.sort(sortComparatorForNoSearch);
|
||||
return mergedData;
|
||||
});
|
||||
}
|
||||
return mergedData;
|
||||
},
|
||||
[sortComparatorForNoSearch],
|
||||
);
|
||||
|
||||
const fetchPage = useMemo(
|
||||
() => (search: string, page: number) => {
|
||||
setPage(page);
|
||||
if (allValuesLoaded) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
const key = getQueryCacheKey(search, page, pageSize);
|
||||
const cachedCount = fetchedQueries.current.get(key);
|
||||
if (cachedCount !== undefined) {
|
||||
setTotalCount(cachedCount);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const fetchOptions = options as OptionsPagePromise;
|
||||
fetchOptions(search, page, pageSize)
|
||||
.then(({ data, totalCount }: OptionsTypePage) => {
|
||||
const mergedData = mergeData(data);
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
setTotalCount(totalCount);
|
||||
if (
|
||||
!fetchOnlyOnSearch &&
|
||||
value === '' &&
|
||||
mergedData.length >= totalCount
|
||||
) {
|
||||
setAllValuesLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch(internalOnError)
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
},
|
||||
[
|
||||
allValuesLoaded,
|
||||
fetchOnlyOnSearch,
|
||||
mergeData,
|
||||
internalOnError,
|
||||
options,
|
||||
pageSize,
|
||||
value,
|
||||
],
|
||||
);
|
||||
|
||||
const debouncedFetchPage = useMemo(
|
||||
() => debounce(fetchPage, SLOW_DEBOUNCE),
|
||||
[fetchPage],
|
||||
);
|
||||
|
||||
const handleOnSearch = (search: string) => {
|
||||
const searchValue = search.trim();
|
||||
if (allowNewOptions && isSingleMode) {
|
||||
const newOption = searchValue &&
|
||||
!hasOption(searchValue, fullSelectOptions, true) && {
|
||||
label: searchValue,
|
||||
value: searchValue,
|
||||
isNewOption: true,
|
||||
};
|
||||
const cleanSelectOptions = fullSelectOptions.filter(
|
||||
opt => !opt.isNewOption || hasOption(opt.value, selectValue),
|
||||
);
|
||||
const newOptions = newOption
|
||||
? [newOption, ...cleanSelectOptions]
|
||||
: cleanSelectOptions;
|
||||
setSelectOptions(newOptions);
|
||||
}
|
||||
if (
|
||||
isAsync &&
|
||||
!allValuesLoaded &&
|
||||
loadingEnabled &&
|
||||
!fetchedQueries.current.has(getQueryCacheKey(searchValue, 0, pageSize))
|
||||
) {
|
||||
// if fetch only on search but search value is empty, then should not be
|
||||
// in loading state
|
||||
setIsLoading(!(fetchOnlyOnSearch && !searchValue));
|
||||
}
|
||||
setInputValue(search);
|
||||
};
|
||||
|
||||
const handlePagination = (e: UIEvent<HTMLElement>) => {
|
||||
const vScroll = e.currentTarget;
|
||||
const thresholdReached =
|
||||
vScroll.scrollTop > (vScroll.scrollHeight - vScroll.offsetHeight) * 0.7;
|
||||
const hasMoreData = page * pageSize + pageSize < totalCount;
|
||||
|
||||
if (!isLoading && isAsync && hasMoreData && thresholdReached) {
|
||||
const newPage = page + 1;
|
||||
fetchPage(inputValue, newPage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterOption = (search: string, option: AntdLabeledValue) => {
|
||||
if (typeof filterOption === 'function') {
|
||||
return filterOption(search, option);
|
||||
}
|
||||
|
||||
if (filterOption) {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
if (optionFilterProps && optionFilterProps.length) {
|
||||
return optionFilterProps.some(prop => {
|
||||
const optionProp = option?.[prop]
|
||||
? String(option[prop]).trim().toLowerCase()
|
||||
: '';
|
||||
return optionProp.includes(searchValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
||||
setIsDropdownVisible(isDropdownVisible);
|
||||
|
||||
if (isAsync) {
|
||||
// loading is enabled when dropdown is open,
|
||||
// disabled when dropdown is closed
|
||||
if (loadingEnabled !== isDropdownVisible) {
|
||||
setLoadingEnabled(isDropdownVisible);
|
||||
}
|
||||
// when closing dropdown, always reset loading state
|
||||
if (!isDropdownVisible && isLoading) {
|
||||
// delay is for the animation of closing the dropdown
|
||||
// so the dropdown doesn't flash between "Loading..." and "No data"
|
||||
// before closing.
|
||||
setTimeout(() => {
|
||||
setIsLoading(false);
|
||||
}, 250);
|
||||
}
|
||||
}
|
||||
// if no search input value, force sort options because it won't be sorted by
|
||||
// `filterSort`.
|
||||
if (isDropdownVisible && !inputValue && selectOptions.length > 1) {
|
||||
const sortedOptions = isAsync
|
||||
? selectOptions.slice().sort(sortComparatorForNoSearch)
|
||||
: // if not in async mode, revert to the original select options
|
||||
// (with selected options still sorted to the top)
|
||||
initialOptionsSorted;
|
||||
if (!isEqual(sortedOptions, selectOptions)) {
|
||||
setSelectOptions(sortedOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if (onDropdownVisibleChange) {
|
||||
onDropdownVisibleChange(isDropdownVisible);
|
||||
}
|
||||
};
|
||||
|
||||
const dropdownRender = (
|
||||
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
||||
) => {
|
||||
if (!isDropdownVisible) {
|
||||
originNode.ref?.current?.scrollTo({ top: 0 });
|
||||
}
|
||||
if (isLoading && fullSelectOptions.length === 0) {
|
||||
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
||||
}
|
||||
return error ? <Error error={error} /> : originNode;
|
||||
};
|
||||
|
||||
// use a function instead of component since every rerender of the
|
||||
// Select component will create a new component
|
||||
const getSuffixIcon = () => {
|
||||
if (isLoading) {
|
||||
return <StyledSpin size="small" />;
|
||||
}
|
||||
if (shouldShowSearch && isDropdownVisible) {
|
||||
return <SearchOutlined />;
|
||||
}
|
||||
return <DownOutlined />;
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectValue(undefined);
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// when `options` list is updated from component prop, reset states
|
||||
fetchedQueries.current.clear();
|
||||
setAllValuesLoaded(false);
|
||||
setSelectOptions(initialOptions);
|
||||
}, [initialOptions]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectValue(value);
|
||||
}, [value]);
|
||||
|
||||
// Stop the invocation of the debounced function after unmounting
|
||||
useEffect(
|
||||
() => () => {
|
||||
debouncedFetchPage.cancel();
|
||||
},
|
||||
[debouncedFetchPage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAsync && loadingEnabled && allowFetch) {
|
||||
// trigger fetch every time inputValue changes
|
||||
if (inputValue) {
|
||||
debouncedFetchPage(inputValue, 0);
|
||||
} else {
|
||||
fetchPage('', 0);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isAsync,
|
||||
loadingEnabled,
|
||||
fetchPage,
|
||||
allowFetch,
|
||||
inputValue,
|
||||
debouncedFetchPage,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading !== undefined && loading !== isLoading) {
|
||||
setIsLoading(loading);
|
||||
}
|
||||
}, [isLoading, loading]);
|
||||
|
||||
const clearCache = () => fetchedQueries.current.clear();
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
...(ref.current as HTMLInputElement),
|
||||
clearCache,
|
||||
}),
|
||||
[ref],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{header}
|
||||
<StyledSelect
|
||||
allowClear={!isLoading && allowClear}
|
||||
aria-label={ariaLabel || name}
|
||||
dropdownRender={dropdownRender}
|
||||
filterOption={handleFilterOption}
|
||||
filterSort={sortComparatorWithSearch}
|
||||
getPopupContainer={
|
||||
getPopupContainer || (triggerNode => triggerNode.parentNode)
|
||||
}
|
||||
labelInValue={isAsync || labelInValue}
|
||||
maxTagCount={MAX_TAG_COUNT}
|
||||
mode={mappedMode}
|
||||
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
|
||||
onDeselect={handleOnDeselect}
|
||||
onDropdownVisibleChange={handleOnDropdownVisibleChange}
|
||||
onPopupScroll={isAsync ? handlePagination : undefined}
|
||||
onSearch={shouldShowSearch ? handleOnSearch : undefined}
|
||||
onSelect={handleOnSelect}
|
||||
onClear={handleClear}
|
||||
onChange={onChange}
|
||||
options={hasCustomLabels ? undefined : fullSelectOptions}
|
||||
placeholder={placeholder}
|
||||
showSearch={shouldShowSearch}
|
||||
showArrow
|
||||
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
|
||||
value={selectValue}
|
||||
suffixIcon={getSuffixIcon()}
|
||||
menuItemSelectedIcon={
|
||||
invertSelection ? (
|
||||
<StyledStopOutlined iconSize="m" />
|
||||
) : (
|
||||
<StyledCheckOutlined iconSize="m" />
|
||||
)
|
||||
}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{hasCustomLabels &&
|
||||
fullSelectOptions.map(opt => {
|
||||
const isOptObject = typeof opt === 'object';
|
||||
const label = isOptObject ? opt?.label || opt.value : opt;
|
||||
const value = isOptObject ? opt.value : opt;
|
||||
const { customLabel, ...optProps } = opt;
|
||||
return (
|
||||
<Option {...optProps} key={value} label={label} value={value}>
|
||||
{isOptObject && customLabel ? customLabel : label}
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
</StyledSelect>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default forwardRef(AsyncSelect);
|
||||
|
|
@ -19,12 +19,8 @@
|
|||
import React, { ReactNode, useState, useCallback, useRef } from 'react';
|
||||
import Button from 'src/components/Button';
|
||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||
import Select, {
|
||||
SelectProps,
|
||||
OptionsTypePage,
|
||||
OptionsType,
|
||||
SelectRef,
|
||||
} from './Select';
|
||||
import AsyncSelect, { AsyncSelectProps, AsyncSelectRef } from './AsyncSelect';
|
||||
import Select, { SelectProps, OptionsTypePage, OptionsType } from './Select';
|
||||
|
||||
export default {
|
||||
title: 'Select',
|
||||
|
|
@ -381,19 +377,19 @@ const USERS = [
|
|||
'Ilenia',
|
||||
].sort();
|
||||
|
||||
export const AsyncSelect = ({
|
||||
export const AsynchronousSelect = ({
|
||||
fetchOnlyOnSearch,
|
||||
withError,
|
||||
withInitialValue,
|
||||
responseTime,
|
||||
...rest
|
||||
}: SelectProps & {
|
||||
}: AsyncSelectProps & {
|
||||
withError: boolean;
|
||||
withInitialValue: boolean;
|
||||
responseTime: number;
|
||||
}) => {
|
||||
const [requests, setRequests] = useState<ReactNode[]>([]);
|
||||
const ref = useRef<SelectRef>(null);
|
||||
const ref = useRef<AsyncSelectRef>(null);
|
||||
|
||||
const getResults = (username?: string) => {
|
||||
let results: { label: string; value: string }[] = [];
|
||||
|
|
@ -463,12 +459,12 @@ export const AsyncSelect = ({
|
|||
width: DEFAULT_WIDTH,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
<AsyncSelect
|
||||
{...rest}
|
||||
ref={ref}
|
||||
fetchOnlyOnSearch={fetchOnlyOnSearch}
|
||||
options={withError ? fetchUserListError : fetchUserListPage}
|
||||
placeholder={fetchOnlyOnSearch ? 'Type anything' : 'Select...'}
|
||||
placeholder={fetchOnlyOnSearch ? 'Type anything' : 'AsyncSelect...'}
|
||||
value={
|
||||
withInitialValue
|
||||
? { label: 'Valentina', value: 'Valentina' }
|
||||
|
|
@ -509,7 +505,7 @@ export const AsyncSelect = ({
|
|||
);
|
||||
};
|
||||
|
||||
AsyncSelect.args = {
|
||||
AsynchronousSelect.args = {
|
||||
allowClear: false,
|
||||
allowNewOptions: false,
|
||||
fetchOnlyOnSearch: false,
|
||||
|
|
@ -519,7 +515,7 @@ AsyncSelect.args = {
|
|||
tokenSeparators: ['\n', '\t', ';'],
|
||||
};
|
||||
|
||||
AsyncSelect.argTypes = {
|
||||
AsynchronousSelect.argTypes = {
|
||||
...ARG_TYPES,
|
||||
header: {
|
||||
table: {
|
||||
|
|
@ -552,7 +548,7 @@ AsyncSelect.argTypes = {
|
|||
},
|
||||
};
|
||||
|
||||
AsyncSelect.story = {
|
||||
AsynchronousSelect.story = {
|
||||
parameters: {
|
||||
knobs: {
|
||||
disable: true,
|
||||
|
|
|
|||
|
|
@ -16,11 +16,10 @@
|
|||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React, { RefObject } from 'react';
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, within } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Select } from 'src/components';
|
||||
import { SelectRef } from './Select';
|
||||
|
||||
const ARIA_LABEL = 'Test';
|
||||
const NEW_OPTION = 'Kyle';
|
||||
|
|
@ -56,21 +55,6 @@ const NULL_OPTION = { label: '<NULL>', value: null } as unknown as {
|
|||
value: number;
|
||||
};
|
||||
|
||||
const loadOptions = async (search: string, page: number, pageSize: number) => {
|
||||
const totalCount = OPTIONS.length;
|
||||
const start = page * pageSize;
|
||||
const deleteCount =
|
||||
start + pageSize < totalCount ? pageSize : totalCount - start;
|
||||
const data = OPTIONS.filter(option => option.label.match(search)).splice(
|
||||
start,
|
||||
deleteCount,
|
||||
);
|
||||
return {
|
||||
data,
|
||||
totalCount: OPTIONS.length,
|
||||
};
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
allowClear: true,
|
||||
ariaLabel: ARIA_LABEL,
|
||||
|
|
@ -165,27 +149,6 @@ test('sort the options by label if no sort comparator is provided', async () =>
|
|||
);
|
||||
});
|
||||
|
||||
test('sort the options using a custom sort comparator', async () => {
|
||||
const sortComparator = (
|
||||
option1: typeof OPTIONS[0],
|
||||
option2: typeof OPTIONS[0],
|
||||
) => option1.gender.localeCompare(option2.gender);
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
sortComparator={sortComparator}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
const options = await findAllSelectOptions();
|
||||
const optionsPage = OPTIONS.slice(0, defaultProps.pageSize);
|
||||
const sortedOptions = optionsPage.sort(sortComparator);
|
||||
options.forEach((option, key) => {
|
||||
expect(option).toHaveTextContent(sortedOptions[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);
|
||||
|
|
@ -383,7 +346,7 @@ test('clear all the values', async () => {
|
|||
});
|
||||
|
||||
test('does not add a new option if allowNewOptions is false', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} />);
|
||||
render(<Select {...defaultProps} options={OPTIONS} />);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||
|
|
@ -413,18 +376,18 @@ test('adds the null option when selected in multiple mode', async () => {
|
|||
expect(values[1]).toHaveTextContent(NULL_OPTION.label);
|
||||
});
|
||||
|
||||
test('static - renders the select with default props', () => {
|
||||
test('renders the select with default props', () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
expect(getSelect()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('static - opens the select without any data', async () => {
|
||||
test('opens the select without any data', async () => {
|
||||
render(<Select {...defaultProps} options={[]} />);
|
||||
await open();
|
||||
expect(screen.getByText(NO_DATA)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('static - makes a selection in single mode', async () => {
|
||||
test('makes a selection in single mode', async () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
const optionText = 'Emma';
|
||||
await open();
|
||||
|
|
@ -432,7 +395,7 @@ test('static - makes a selection in single mode', async () => {
|
|||
expect(await findSelectValue()).toHaveTextContent(optionText);
|
||||
});
|
||||
|
||||
test('static - multiple selections in multiple mode', async () => {
|
||||
test('multiple selections in multiple mode', async () => {
|
||||
render(<Select {...defaultProps} mode="multiple" />);
|
||||
await open();
|
||||
const [firstOption, secondOption] = OPTIONS;
|
||||
|
|
@ -443,7 +406,7 @@ test('static - multiple selections in multiple mode', async () => {
|
|||
expect(values[1]).toHaveTextContent(secondOption.label);
|
||||
});
|
||||
|
||||
test('static - changes the selected item in single mode', async () => {
|
||||
test('changes the selected item in single mode', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<Select {...defaultProps} onChange={onChange} />);
|
||||
await open();
|
||||
|
|
@ -468,7 +431,7 @@ test('static - changes the selected item in single mode', async () => {
|
|||
expect(await findSelectValue()).toHaveTextContent(secondOption.label);
|
||||
});
|
||||
|
||||
test('static - deselects an item in multiple mode', async () => {
|
||||
test('deselects an item in multiple mode', async () => {
|
||||
render(<Select {...defaultProps} mode="multiple" />);
|
||||
await open();
|
||||
const [firstOption, secondOption] = OPTIONS;
|
||||
|
|
@ -484,35 +447,35 @@ test('static - deselects an item in multiple mode', async () => {
|
|||
expect(values[0]).toHaveTextContent(secondOption.label);
|
||||
});
|
||||
|
||||
test('static - adds a new option if none is available and allowNewOptions is true', async () => {
|
||||
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('static - shows "No data" when allowNewOptions is false and a new option is entered', async () => {
|
||||
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)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('static - does not show "No data" when allowNewOptions is true and a new option is entered', async () => {
|
||||
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);
|
||||
expect(screen.queryByText(NO_DATA)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('static - does not show "Loading..." when allowNewOptions is false and a new option is entered', async () => {
|
||||
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('static - does not add a new option if the option already exists', async () => {
|
||||
test('does not add a new option if the option already exists', async () => {
|
||||
render(<Select {...defaultProps} allowNewOptions />);
|
||||
const option = OPTIONS[0].label;
|
||||
await open();
|
||||
|
|
@ -520,12 +483,12 @@ test('static - does not add a new option if the option already exists', async ()
|
|||
expect(await findSelectOption(option)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('static - sets a initial value in single mode', async () => {
|
||||
test('sets a initial value in single mode', async () => {
|
||||
render(<Select {...defaultProps} value={OPTIONS[0]} />);
|
||||
expect(await findSelectValue()).toHaveTextContent(OPTIONS[0].label);
|
||||
});
|
||||
|
||||
test('static - sets a initial value in multiple mode', async () => {
|
||||
test('sets a initial value in multiple mode', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
|
|
@ -538,7 +501,7 @@ test('static - sets a initial value in multiple mode', async () => {
|
|||
expect(values[1]).toHaveTextContent(OPTIONS[1].label);
|
||||
});
|
||||
|
||||
test('static - searches for an item', async () => {
|
||||
test('searches for an item', async () => {
|
||||
render(<Select {...defaultProps} />);
|
||||
const search = 'Oli';
|
||||
await type(search);
|
||||
|
|
@ -548,303 +511,7 @@ test('static - searches for an item', async () => {
|
|||
expect(options[1]).toHaveTextContent('Olivia');
|
||||
});
|
||||
|
||||
test('async - renders the select with default props', () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} />);
|
||||
expect(getSelect()).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('async - opens the select without any data', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={async () => ({ data: [], totalCount: 0 })}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
expect(await screen.findByText(/no data/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('async - displays the loading indicator when opening', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} />);
|
||||
await waitFor(() => {
|
||||
userEvent.click(getSelect());
|
||||
expect(screen.getByText(LOADING)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('async - makes a selection in single mode', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} />);
|
||||
const optionText = 'Emma';
|
||||
await open();
|
||||
userEvent.click(await findSelectOption(optionText));
|
||||
expect(await findSelectValue()).toHaveTextContent(optionText);
|
||||
});
|
||||
|
||||
test('async - multiple selections in multiple mode', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} mode="multiple" />);
|
||||
await open();
|
||||
const [firstOption, secondOption] = OPTIONS;
|
||||
userEvent.click(await findSelectOption(firstOption.label));
|
||||
userEvent.click(await findSelectOption(secondOption.label));
|
||||
const values = await findAllSelectValues();
|
||||
expect(values[0]).toHaveTextContent(firstOption.label);
|
||||
expect(values[1]).toHaveTextContent(secondOption.label);
|
||||
});
|
||||
|
||||
test('async - changes the selected item in single mode', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<Select {...defaultProps} options={loadOptions} onChange={onChange} />,
|
||||
);
|
||||
await open();
|
||||
const [firstOption, secondOption] = OPTIONS;
|
||||
userEvent.click(await findSelectOption(firstOption.label));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: firstOption.label,
|
||||
value: firstOption.value,
|
||||
}),
|
||||
firstOption,
|
||||
);
|
||||
expect(await findSelectValue()).toHaveTextContent(firstOption.label);
|
||||
userEvent.click(await findSelectOption(secondOption.label));
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
label: secondOption.label,
|
||||
value: secondOption.value,
|
||||
}),
|
||||
secondOption,
|
||||
);
|
||||
expect(await findSelectValue()).toHaveTextContent(secondOption.label);
|
||||
});
|
||||
|
||||
test('async - deselects an item in multiple mode', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} mode="multiple" />);
|
||||
await open();
|
||||
const option3 = OPTIONS[2];
|
||||
const option8 = OPTIONS[7];
|
||||
userEvent.click(await findSelectOption(option8.label));
|
||||
userEvent.click(await findSelectOption(option3.label));
|
||||
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(Math.min(defaultProps.pageSize, OPTIONS.length));
|
||||
expect(options[0]).toHaveTextContent(OPTIONS[0].label);
|
||||
expect(options[1]).toHaveTextContent(OPTIONS[1].label);
|
||||
|
||||
await type('{esc}');
|
||||
await open();
|
||||
|
||||
// should rank selected options to the top after menu closes
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(Math.min(defaultProps.pageSize, OPTIONS.length));
|
||||
expect(options[0]).toHaveTextContent(option3.label);
|
||||
expect(options[1]).toHaveTextContent(option8.label);
|
||||
|
||||
let values = await findAllSelectValues();
|
||||
expect(values).toHaveLength(2);
|
||||
// should keep the order by which the options were selected
|
||||
expect(values[0]).toHaveTextContent(option8.label);
|
||||
expect(values[1]).toHaveTextContent(option3.label);
|
||||
|
||||
userEvent.click(await findSelectOption(option3.label));
|
||||
values = await findAllSelectValues();
|
||||
expect(values.length).toBe(1);
|
||||
expect(values[0]).toHaveTextContent(option8.label);
|
||||
});
|
||||
|
||||
test('async - adds a new option if none is available and allowNewOptions is true', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} allowNewOptions />);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('async - does not add a new option if the option already exists', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} allowNewOptions />);
|
||||
const option = OPTIONS[0].label;
|
||||
await open();
|
||||
await type(option);
|
||||
await waitFor(() => {
|
||||
const array = within(
|
||||
getElementByClassName('.rc-virtual-list'),
|
||||
).getAllByText(option);
|
||||
expect(array.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('async - shows "No data" when allowNewOptions is false and a new option is entered', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
allowNewOptions={false}
|
||||
showSearch
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('async - does not show "No data" when allowNewOptions is true and a new option is entered', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} allowNewOptions />);
|
||||
await open();
|
||||
await type(NEW_OPTION);
|
||||
expect(screen.queryByText(NO_DATA)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('async - sets a initial value in single mode', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} value={OPTIONS[0]} />);
|
||||
expect(await findSelectValue()).toHaveTextContent(OPTIONS[0].label);
|
||||
});
|
||||
|
||||
test('async - sets a initial value in multiple mode', async () => {
|
||||
render(
|
||||
<Select
|
||||
{...defaultProps}
|
||||
mode="multiple"
|
||||
options={loadOptions}
|
||||
value={[OPTIONS[0], OPTIONS[1]]}
|
||||
/>,
|
||||
);
|
||||
const values = await findAllSelectValues();
|
||||
expect(values[0]).toHaveTextContent(OPTIONS[0].label);
|
||||
expect(values[1]).toHaveTextContent(OPTIONS[1].label);
|
||||
});
|
||||
|
||||
test('async - searches for matches in both loaded and unloaded pages', async () => {
|
||||
render(<Select {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await type('and');
|
||||
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options.length).toBe(1);
|
||||
expect(options[0]).toHaveTextContent('Alehandro');
|
||||
|
||||
await screen.findByText('Sandro');
|
||||
options = await findAllSelectOptions();
|
||||
expect(options.length).toBe(2);
|
||||
expect(options[0]).toHaveTextContent('Alehandro');
|
||||
expect(options[1]).toHaveTextContent('Sandro');
|
||||
});
|
||||
|
||||
test('async - searches for an item in a page not loaded', async () => {
|
||||
const mock = jest.fn(loadOptions);
|
||||
render(<Select {...defaultProps} options={mock} />);
|
||||
const search = 'Sandro';
|
||||
await open();
|
||||
await type(search);
|
||||
await waitFor(() => expect(mock).toHaveBeenCalledTimes(2));
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options.length).toBe(1);
|
||||
expect(options[0]).toHaveTextContent(search);
|
||||
});
|
||||
|
||||
test('async - does not fetches data when rendering', async () => {
|
||||
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||
render(<Select {...defaultProps} options={loadOptions} />);
|
||||
expect(loadOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('async - fetches data when opening', async () => {
|
||||
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||
render(<Select {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
expect(loadOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('async - fetches data only after a search input is entered if fetchOnlyOnSearch is true', async () => {
|
||||
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||
render(<Select {...defaultProps} options={loadOptions} fetchOnlyOnSearch />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).not.toHaveBeenCalled());
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test('async - displays an error message when an exception is thrown while fetching', async () => {
|
||||
const error = 'Fetch error';
|
||||
const loadOptions = async () => {
|
||||
throw new Error(error);
|
||||
};
|
||||
render(<Select {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
expect(screen.getByText(error)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('async - does not fire a new request for the same search input', async () => {
|
||||
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||
render(<Select {...defaultProps} options={loadOptions} fetchOnlyOnSearch />);
|
||||
await type('search');
|
||||
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||
expect(loadOptions).toHaveBeenCalledTimes(1);
|
||||
clearAll();
|
||||
await type('search');
|
||||
expect(await screen.findByText(LOADING)).toBeInTheDocument();
|
||||
expect(loadOptions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('async - does not fire a new request if all values have been fetched', async () => {
|
||||
const mock = jest.fn(loadOptions);
|
||||
const search = 'George';
|
||||
const pageSize = OPTIONS.length;
|
||||
render(<Select {...defaultProps} options={mock} pageSize={pageSize} />);
|
||||
await open();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
await type(search);
|
||||
expect(await findSelectOption(search)).toBeInTheDocument();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('async - fires a new request if all values have not been fetched', async () => {
|
||||
const mock = jest.fn(loadOptions);
|
||||
const pageSize = OPTIONS.length / 2;
|
||||
render(<Select {...defaultProps} options={mock} pageSize={pageSize} />);
|
||||
await open();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
await type('or');
|
||||
|
||||
// `George` is on the first page so when it appears the API has not been called again
|
||||
expect(await findSelectOption('George')).toBeInTheDocument();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// `Igor` is on the second paged API request
|
||||
expect(await findSelectOption('Igor')).toBeInTheDocument();
|
||||
expect(mock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('async - requests the options again after clearing the cache', async () => {
|
||||
const ref: RefObject<SelectRef> = { current: null };
|
||||
const mock = jest.fn(loadOptions);
|
||||
const pageSize = OPTIONS.length;
|
||||
render(
|
||||
<Select {...defaultProps} options={mock} pageSize={pageSize} ref={ref} />,
|
||||
);
|
||||
await open();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
ref.current?.clearCache();
|
||||
await type('{esc}');
|
||||
await open();
|
||||
expect(mock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('async - triggers getPopupContainer if passed', async () => {
|
||||
const getPopupContainer = jest.fn();
|
||||
render(
|
||||
<div>
|
||||
<Select
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
getPopupContainer={getPopupContainer}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
await open();
|
||||
expect(getPopupContainer).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('static - triggers getPopupContainer if passed', async () => {
|
||||
test('triggers getPopupContainer if passed', async () => {
|
||||
const getPopupContainer = jest.fn();
|
||||
render(<Select {...defaultProps} getPopupContainer={getPopupContainer} />);
|
||||
await open();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
* E.g. import { Select } from 'src/components'
|
||||
*/
|
||||
export { default as Select } from './Select/Select';
|
||||
export { default as AsyncSelect } from './Select/AsyncSelect';
|
||||
|
||||
/*
|
||||
* Components that don't conflict with the ones in src/components.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { Input } from 'src/components/Input';
|
|||
import { FormItem } from 'src/components/Form';
|
||||
import jsonStringify from 'json-stringify-pretty-compact';
|
||||
import Button from 'src/components/Button';
|
||||
import { Select, Row, Col, AntdForm } from 'src/components';
|
||||
import { AsyncSelect, Row, Col, AntdForm } from 'src/components';
|
||||
import rison from 'rison';
|
||||
import {
|
||||
styled,
|
||||
|
|
@ -370,7 +370,7 @@ const PropertiesModal = ({
|
|||
<Col xs={24} md={12}>
|
||||
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
|
||||
<StyledFormItem label={t('Owners')}>
|
||||
<Select
|
||||
<AsyncSelect
|
||||
allowClear
|
||||
ariaLabel={t('Owners')}
|
||||
disabled={isLoading}
|
||||
|
|
@ -417,7 +417,7 @@ const PropertiesModal = ({
|
|||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledFormItem label={t('Owners')}>
|
||||
<Select
|
||||
<AsyncSelect
|
||||
allowClear
|
||||
ariaLabel={t('Owners')}
|
||||
disabled={isLoading}
|
||||
|
|
@ -437,7 +437,7 @@ const PropertiesModal = ({
|
|||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<StyledFormItem label={t('Roles')}>
|
||||
<Select
|
||||
<AsyncSelect
|
||||
allowClear
|
||||
ariaLabel={t('Roles')}
|
||||
disabled={isLoading}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import rison from 'rison';
|
||||
import { t, SupersetClient } from '@superset-ui/core';
|
||||
import { Select } from 'src/components';
|
||||
import { AsyncSelect } from 'src/components';
|
||||
import { cacheWrapper } from 'src/utils/cacheWrapper';
|
||||
import {
|
||||
ClientErrorObject,
|
||||
|
|
@ -85,7 +85,7 @@ const DatasetSelect = ({ onChange, value }: DatasetSelectProps) => {
|
|||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Dataset')}
|
||||
value={value}
|
||||
options={loadDatasetOptions}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import React, { useMemo, useState, useCallback, useEffect } from 'react';
|
|||
import Modal from 'src/components/Modal';
|
||||
import { Input, TextArea } from 'src/components/Input';
|
||||
import Button from 'src/components/Button';
|
||||
import { Select, Row, Col, AntdForm } from 'src/components';
|
||||
import { AsyncSelect, Row, Col, AntdForm } from 'src/components';
|
||||
import { SelectValue } from 'antd/lib/select';
|
||||
import rison from 'rison';
|
||||
import { t, SupersetClient, styled } from '@superset-ui/core';
|
||||
|
|
@ -298,7 +298,7 @@ function PropertiesModal({
|
|||
</FormItem>
|
||||
<h3 style={{ marginTop: '1em' }}>{t('Access')}</h3>
|
||||
<FormItem label={ownersLabel}>
|
||||
<Select
|
||||
<AsyncSelect
|
||||
ariaLabel={ownersLabel}
|
||||
mode="multiple"
|
||||
name="owners"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import fetchMock from 'fetch-mock';
|
|||
import { act } from 'react-dom/test-utils';
|
||||
import AlertReportModal from 'src/views/CRUD/alert/AlertReportModal';
|
||||
import Modal from 'src/components/Modal';
|
||||
import { Select } from 'src/components';
|
||||
import { Select, AsyncSelect } from 'src/components';
|
||||
import { Switch } from 'src/components/Switch';
|
||||
import { Radio } from 'src/components/Radio';
|
||||
import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
|
||||
|
|
@ -182,7 +182,9 @@ describe('AlertReportModal', () => {
|
|||
|
||||
it('renders five select elements when in report mode', () => {
|
||||
expect(wrapper.find(Select)).toExist();
|
||||
expect(wrapper.find(Select)).toHaveLength(5);
|
||||
expect(wrapper.find(AsyncSelect)).toExist();
|
||||
expect(wrapper.find(Select)).toHaveLength(2);
|
||||
expect(wrapper.find(AsyncSelect)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders Switch element', () => {
|
||||
|
|
@ -220,7 +222,9 @@ describe('AlertReportModal', () => {
|
|||
|
||||
it('renders five select element when in report mode', () => {
|
||||
expect(wrapper.find(Select)).toExist();
|
||||
expect(wrapper.find(Select)).toHaveLength(5);
|
||||
expect(wrapper.find(AsyncSelect)).toExist();
|
||||
expect(wrapper.find(Select)).toHaveLength(2);
|
||||
expect(wrapper.find(AsyncSelect)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('renders seven select elements when in alert mode', async () => {
|
||||
|
|
@ -232,7 +236,9 @@ describe('AlertReportModal', () => {
|
|||
const addWrapper = await mountAndWait(props);
|
||||
|
||||
expect(addWrapper.find(Select)).toExist();
|
||||
expect(addWrapper.find(Select)).toHaveLength(7);
|
||||
expect(addWrapper.find(AsyncSelect)).toExist();
|
||||
expect(addWrapper.find(Select)).toHaveLength(3);
|
||||
expect(addWrapper.find(AsyncSelect)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('renders value input element when in alert mode', async () => {
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@ import { Switch } from 'src/components/Switch';
|
|||
import Modal from 'src/components/Modal';
|
||||
import TimezoneSelector from 'src/components/TimezoneSelector';
|
||||
import { Radio } from 'src/components/Radio';
|
||||
import Select, { propertyComparator } from 'src/components/Select/Select';
|
||||
import { propertyComparator } from 'src/components/Select/Select';
|
||||
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||
import Owner from 'src/types/Owner';
|
||||
import { AntdCheckbox } from 'src/components';
|
||||
import { AntdCheckbox, AsyncSelect, Select } from 'src/components';
|
||||
import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
|
||||
import { useCommonConf } from 'src/views/CRUD/data/database/state';
|
||||
import {
|
||||
|
|
@ -1098,7 +1098,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||
<span className="required">*</span>
|
||||
</div>
|
||||
<div data-test="owners-select" className="input-container">
|
||||
<Select
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Owners')}
|
||||
allowClear
|
||||
name="owners"
|
||||
|
|
@ -1146,7 +1146,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||
<span className="required">*</span>
|
||||
</div>
|
||||
<div className="input-container">
|
||||
<Select
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Database')}
|
||||
name="source"
|
||||
value={
|
||||
|
|
@ -1319,7 +1319,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||
<StyledRadio value="dashboard">{t('Dashboard')}</StyledRadio>
|
||||
<StyledRadio value="chart">{t('Chart')}</StyledRadio>
|
||||
</Radio.Group>
|
||||
<Select
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Chart')}
|
||||
css={{
|
||||
display: contentType === 'chart' ? 'inline' : 'none',
|
||||
|
|
@ -1336,7 +1336,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
|||
options={loadChartOptions}
|
||||
onChange={onChartChange}
|
||||
/>
|
||||
<Select
|
||||
<AsyncSelect
|
||||
ariaLabel={t('Dashboard')}
|
||||
css={{
|
||||
display: contentType === 'dashboard' ? 'inline' : 'none',
|
||||
|
|
|
|||
Loading…
Reference in New Issue