This is an automated email from the ASF dual-hosted git repository.
michaelsmolina pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 1109fe5fb7 chore: Split Select component into Async and Sync
components (#20466)
1109fe5fb7 is described below
commit 1109fe5fb7a777e3fbb4f3d8d60283b676d1acc0
Author: cccs-RyanK <[email protected]>
AuthorDate: Thu Jul 7 14:51:37 2022 -0400
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
---
.../src/addSlice/AddSliceContainer.test.tsx | 4 +-
.../src/addSlice/AddSliceContainer.tsx | 4 +-
.../src/components/DatabaseSelector/index.tsx | 4 +-
.../src/components/Datasource/DatasourceEditor.jsx | 4 +-
.../{Select.test.tsx => AsyncSelect.test.tsx} | 327 +++------
.../src/components/Select/AsyncSelect.tsx | 754 +++++++++++++++++++++
.../src/components/Select/Select.stories.tsx | 24 +-
.../src/components/Select/Select.test.tsx | 367 +---------
superset-frontend/src/components/index.ts | 1 +
.../dashboard/components/PropertiesModal/index.tsx | 8 +-
.../FiltersConfigForm/DatasetSelect.tsx | 4 +-
.../explore/components/PropertiesModal/index.tsx | 4 +-
.../src/views/CRUD/alert/AlertReportModal.test.jsx | 14 +-
.../src/views/CRUD/alert/AlertReportModal.tsx | 12 +-
14 files changed, 900 insertions(+), 631 deletions(-)
diff --git a/superset-frontend/src/addSlice/AddSliceContainer.test.tsx
b/superset-frontend/src/addSlice/AddSliceContainer.test.tsx
index 91e6b28dd1..a656efb818 100644
--- a/superset-frontend/src/addSlice/AddSliceContainer.test.tsx
+++ b/superset-frontend/src/addSlice/AddSliceContainer.test.tsx
@@ -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();
});
diff --git a/superset-frontend/src/addSlice/AddSliceContainer.tsx
b/superset-frontend/src/addSlice/AddSliceContainer.tsx
index d4c6bfc78a..84c37b61c7 100644
--- a/superset-frontend/src/addSlice/AddSliceContainer.tsx
+++ b/superset-frontend/src/addSlice/AddSliceContainer.tsx
@@ -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"
diff --git a/superset-frontend/src/components/DatabaseSelector/index.tsx
b/superset-frontend/src/components/DatabaseSelector/index.tsx
index 4a5e94b2bc..e972e95b00 100644
--- a/superset-frontend/src/components/DatabaseSelector/index.tsx
+++ b/superset-frontend/src/components/DatabaseSelector/index.tsx
@@ -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"
diff --git a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
index 94dd1241b0..7985165f73 100644
--- a/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
+++ b/superset-frontend/src/components/Datasource/DatasourceEditor.jsx
@@ -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"
diff --git a/superset-frontend/src/components/Select/Select.test.tsx
b/superset-frontend/src/components/Select/AsyncSelect.test.tsx
similarity index 65%
copy from superset-frontend/src/components/Select/Select.test.tsx
copy to superset-frontend/src/components/Select/AsyncSelect.test.tsx
index 1515a7c548..dc6eff35d9 100644
--- a/superset-frontend/src/components/Select/Select.test.tsx
+++ b/superset-frontend/src/components/Select/AsyncSelect.test.tsx
@@ -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';
+import { AsyncSelect } from 'src/components';
const ARIA_LABEL = 'Test';
const NEW_OPTION = 'Kyle';
@@ -126,19 +125,19 @@ const open = () => waitFor(() =>
userEvent.click(getSelect()));
test('displays a header', async () => {
const headerText = 'Header';
- render(<Select {...defaultProps} header={headerText} />);
+ 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(
- <Select {...defaultProps} options={[]} value={OPTIONS[0]} />,
+ <AsyncSelect {...defaultProps} options={[]} value={OPTIONS[0]} />,
);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
rerender(
- <Select {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]} />,
+ <AsyncSelect {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]}
/>,
);
await open();
const options = await findAllSelectOptions();
@@ -149,7 +148,7 @@ test('adds a new option if the value is not in the
options', async () => {
});
test('inverts the selection', async () => {
- render(<Select {...defaultProps} invertSelection />);
+ render(<AsyncSelect {...defaultProps} invertSelection />);
await open();
userEvent.click(await findSelectOption(OPTIONS[0].label));
expect(await screen.findByLabelText('stop')).toBeInTheDocument();
@@ -157,7 +156,7 @@ test('inverts the selection', async () => {
test('sort the options by label if no sort comparator is provided', async ()
=> {
const unsortedOptions = [...OPTIONS].sort(() => Math.random());
- render(<Select {...defaultProps} options={unsortedOptions} />);
+ render(<AsyncSelect {...defaultProps} options={unsortedOptions} />);
await open();
const options = await findAllSelectOptions();
options.forEach((option, key) =>
@@ -171,7 +170,7 @@ test('sort the options using a custom sort comparator',
async () => {
option2: typeof OPTIONS[0],
) => option1.gender.localeCompare(option2.gender);
render(
- <Select
+ <AsyncSelect
{...defaultProps}
options={loadOptions}
sortComparator={sortComparator}
@@ -187,7 +186,7 @@ test('sort the options using a custom sort comparator',
async () => {
});
test('should sort selected to top when in single mode', async () => {
- render(<Select {...defaultProps} mode="single" />);
+ render(<AsyncSelect {...defaultProps} mode="single" />);
const originalLabels = OPTIONS.map(option => option.label);
await open();
userEvent.click(await findSelectOption(originalLabels[1]));
@@ -219,7 +218,7 @@ test('should sort selected to top when in single mode',
async () => {
});
test('should sort selected to the top when in multi mode', async () => {
- render(<Select {...defaultProps} mode="multiple" />);
+ render(<AsyncSelect {...defaultProps} mode="multiple" />);
const originalLabels = OPTIONS.map(option => option.label);
let labels = originalLabels.slice();
@@ -248,7 +247,7 @@ test('should sort selected to the top when in multi mode',
async () => {
test('searches for label or value', async () => {
const option = OPTIONS[11];
- render(<Select {...defaultProps} />);
+ render(<AsyncSelect {...defaultProps} />);
const search = option.value;
await type(search.toString());
const options = await findAllSelectOptions();
@@ -257,7 +256,7 @@ test('searches for label or value', async () => {
});
test('search order exact and startWith match first', async () => {
- render(<Select {...defaultProps} />);
+ render(<AsyncSelect {...defaultProps} />);
await type('Her');
const options = await findAllSelectOptions();
expect(options.length).toBe(4);
@@ -268,14 +267,14 @@ test('search order exact and startWith match first',
async () => {
});
test('ignores case when searching', async () => {
- render(<Select {...defaultProps} />);
+ render(<AsyncSelect {...defaultProps} />);
await type('george');
expect(await findSelectOption('George')).toBeInTheDocument();
});
test('same case should be ranked to the top', async () => {
render(
- <Select
+ <AsyncSelect
{...defaultProps}
options={[
{ value: 'Cac' },
@@ -295,13 +294,15 @@ test('same case should be ranked to the top', async () =>
{
});
test('ignores special keys when searching', async () => {
- render(<Select {...defaultProps} />);
+ render(<AsyncSelect {...defaultProps} />);
await type('{shift}');
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
});
test('searches for custom fields', async () => {
- render(<Select {...defaultProps} optionFilterProps={['label', 'gender']} />);
+ render(
+ <AsyncSelect {...defaultProps} optionFilterProps={['label', 'gender']} />,
+ );
await type('Liam');
let options = await findAllSelectOptions();
expect(options.length).toBe(1);
@@ -320,7 +321,7 @@ test('searches for custom fields', async () => {
});
test('removes duplicated values', async () => {
- render(<Select {...defaultProps} mode="multiple" allowNewOptions />);
+ render(<AsyncSelect {...defaultProps} mode="multiple" allowNewOptions />);
await type('a,b,b,b,c,d,d');
const values = await findAllSelectValues();
expect(values.length).toBe(4);
@@ -336,7 +337,7 @@ test('renders a custom label', async () => {
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
];
- render(<Select {...defaultProps} options={options} />);
+ render(<AsyncSelect {...defaultProps} options={options} />);
await open();
expect(screen.getByRole('heading', { name: 'John' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Liam' })).toBeInTheDocument();
@@ -349,7 +350,7 @@ test('searches for a word with a custom label', async () =>
{
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
];
- render(<Select {...defaultProps} options={options} />);
+ render(<AsyncSelect {...defaultProps} options={options} />);
await type('Liam');
const selectOptions = await findAllSelectOptions();
expect(selectOptions.length).toBe(1);
@@ -357,7 +358,7 @@ test('searches for a word with a custom label', async () =>
{
});
test('removes a new option if the user does not select it', async () => {
- render(<Select {...defaultProps} allowNewOptions />);
+ render(<AsyncSelect {...defaultProps} allowNewOptions />);
await type(NEW_OPTION);
expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
await type('k');
@@ -369,7 +370,7 @@ test('removes a new option if the user does not select it',
async () => {
test('clear all the values', async () => {
const onClear = jest.fn();
render(
- <Select
+ <AsyncSelect
{...defaultProps}
mode="multiple"
value={[OPTIONS[0], OPTIONS[1]]}
@@ -383,14 +384,14 @@ test('clear all the values', async () => {
});
test('does not add a new option if allowNewOptions is false', async () => {
- render(<Select {...defaultProps} options={loadOptions} />);
+ 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(<Select {...defaultProps} options={[OPTIONS[0], NULL_OPTION]} />);
+ render(<AsyncSelect {...defaultProps} options={[OPTIONS[0], NULL_OPTION]}
/>);
await open();
userEvent.click(await findSelectOption(NULL_OPTION.label));
const values = await findAllSelectValues();
@@ -399,7 +400,7 @@ test('adds the null option when selected in single mode',
async () => {
test('adds the null option when selected in multiple mode', async () => {
render(
- <Select
+ <AsyncSelect
{...defaultProps}
options={[OPTIONS[0], NULL_OPTION, OPTIONS[2]]}
mode="multiple"
@@ -413,149 +414,14 @@ 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', () => {
- render(<Select {...defaultProps} />);
- expect(getSelect()).toBeInTheDocument();
-});
-
-test('static - 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 () => {
- render(<Select {...defaultProps} />);
- const optionText = 'Emma';
- await open();
- userEvent.click(await findSelectOption(optionText));
- expect(await findSelectValue()).toHaveTextContent(optionText);
-});
-
-test('static - multiple selections in multiple mode', async () => {
- render(<Select {...defaultProps} 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('static - changes the selected item in single mode', async () => {
- const onChange = jest.fn();
- render(<Select {...defaultProps} onChange={onChange} />);
- await open();
- const [firstOption, secondOption] = OPTIONS;
- userEvent.click(await findSelectOption(firstOption.label));
- expect(await findSelectValue()).toHaveTextContent(firstOption.label);
- expect(onChange).toHaveBeenCalledWith(
- expect.objectContaining({
- label: firstOption.label,
- value: firstOption.value,
- }),
- firstOption,
- );
- 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('static - deselects an item in multiple mode', async () => {
- render(<Select {...defaultProps} mode="multiple" />);
- await open();
- const [firstOption, secondOption] = OPTIONS;
- userEvent.click(await findSelectOption(firstOption.label));
- 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);
- userEvent.click(await findSelectOption(firstOption.label));
- values = await findAllSelectValues();
- expect(values.length).toBe(1);
- expect(values[0]).toHaveTextContent(secondOption.label);
-});
-
-test('static - 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 () => {
- 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 () => {
- 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 () => {
- 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
() => {
- render(<Select {...defaultProps} allowNewOptions />);
- const option = OPTIONS[0].label;
- await open();
- await type(option);
- expect(await findSelectOption(option)).toBeInTheDocument();
-});
-
-test('static - 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 () => {
- 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('static - 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('async - renders the select with default props', () => {
- render(<Select {...defaultProps} options={loadOptions} />);
+test('renders the select with default props', () => {
+ render(<AsyncSelect {...defaultProps} options={loadOptions} />);
expect(getSelect()).toBeInTheDocument();
});
-test('async - opens the select without any data', async () => {
+test('opens the select without any data', async () => {
render(
- <Select
+ <AsyncSelect
{...defaultProps}
options={async () => ({ data: [], totalCount: 0 })}
/>,
@@ -564,8 +430,8 @@ test('async - opens the select without any data', async ()
=> {
expect(await screen.findByText(/no data/i)).toBeInTheDocument();
});
-test('async - displays the loading indicator when opening', async () => {
- render(<Select {...defaultProps} options={loadOptions} />);
+test('displays the loading indicator when opening', async () => {
+ render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await waitFor(() => {
userEvent.click(getSelect());
expect(screen.getByText(LOADING)).toBeInTheDocument();
@@ -573,16 +439,18 @@ test('async - displays the loading indicator when
opening', async () => {
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
});
-test('async - makes a selection in single mode', async () => {
- render(<Select {...defaultProps} options={loadOptions} />);
+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('async - multiple selections in multiple mode', async () => {
- render(<Select {...defaultProps} options={loadOptions} mode="multiple" />);
+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));
@@ -592,10 +460,10 @@ test('async - multiple selections in multiple mode',
async () => {
expect(values[1]).toHaveTextContent(secondOption.label);
});
-test('async - changes the selected item in single mode', async () => {
+test('changes the selected item in single mode', async () => {
const onChange = jest.fn();
render(
- <Select {...defaultProps} options={loadOptions} onChange={onChange} />,
+ <AsyncSelect {...defaultProps} options={loadOptions} onChange={onChange}
/>,
);
await open();
const [firstOption, secondOption] = OPTIONS;
@@ -619,8 +487,10 @@ test('async - changes the selected item in single mode',
async () => {
expect(await findSelectValue()).toHaveTextContent(secondOption.label);
});
-test('async - deselects an item in multiple mode', async () => {
- render(<Select {...defaultProps} options={loadOptions} mode="multiple" />);
+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];
@@ -653,15 +523,19 @@ test('async - deselects an item in multiple mode', async
() => {
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 />);
+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('async - does not add a new option if the option already exists', async
() => {
- render(<Select {...defaultProps} options={loadOptions} allowNewOptions />);
+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);
@@ -673,9 +547,9 @@ test('async - does not add a new option if the option
already exists', async ()
});
});
-test('async - 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
+ <AsyncSelect
{...defaultProps}
options={loadOptions}
allowNewOptions={false}
@@ -687,21 +561,25 @@ test('async - shows "No data" when allowNewOptions is
false and a new option is
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 />);
+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('async - sets a initial value in single mode', async () => {
- render(<Select {...defaultProps} options={loadOptions} value={OPTIONS[0]}
/>);
+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('async - sets a initial value in multiple mode', async () => {
+test('sets a initial value in multiple mode', async () => {
render(
- <Select
+ <AsyncSelect
{...defaultProps}
mode="multiple"
options={loadOptions}
@@ -713,8 +591,8 @@ test('async - sets a initial value in multiple mode', async
() => {
expect(values[1]).toHaveTextContent(OPTIONS[1].label);
});
-test('async - searches for matches in both loaded and unloaded pages', async
() => {
- render(<Select {...defaultProps} options={loadOptions} />);
+test('searches for matches in both loaded and unloaded pages', async () => {
+ render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await type('and');
@@ -729,9 +607,9 @@ test('async - searches for matches in both loaded and
unloaded pages', async ()
expect(options[1]).toHaveTextContent('Sandro');
});
-test('async - searches for an item in a page not loaded', async () => {
+test('searches for an item in a page not loaded', async () => {
const mock = jest.fn(loadOptions);
- render(<Select {...defaultProps} options={mock} />);
+ render(<AsyncSelect {...defaultProps} options={mock} />);
const search = 'Sandro';
await open();
await type(search);
@@ -741,41 +619,45 @@ test('async - searches for an item in a page not loaded',
async () => {
expect(options[0]).toHaveTextContent(search);
});
-test('async - does not fetches data when rendering', async () => {
+test('does not fetches data when rendering', async () => {
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
- render(<Select {...defaultProps} options={loadOptions} />);
+ render(<AsyncSelect {...defaultProps} options={loadOptions} />);
expect(loadOptions).not.toHaveBeenCalled();
});
-test('async - fetches data when opening', async () => {
+test('fetches data when opening', async () => {
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
- render(<Select {...defaultProps} options={loadOptions} />);
+ render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
expect(loadOptions).toHaveBeenCalled();
});
-test('async - fetches data only after a search input is entered if
fetchOnlyOnSearch is true', async () => {
+test('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 />);
+ render(
+ <AsyncSelect {...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 () => {
+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(<Select {...defaultProps} options={loadOptions} />);
+ render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
expect(screen.getByText(error)).toBeInTheDocument();
});
-test('async - does not fire a new request for the same search input', async ()
=> {
+test('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 />);
+ render(
+ <AsyncSelect {...defaultProps} options={loadOptions} fetchOnlyOnSearch />,
+ );
await type('search');
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
expect(loadOptions).toHaveBeenCalledTimes(1);
@@ -785,11 +667,11 @@ test('async - does not fire a new request for the same
search input', async () =
expect(loadOptions).toHaveBeenCalledTimes(1);
});
-test('async - does not fire a new request if all values have been fetched',
async () => {
+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(<Select {...defaultProps} options={mock} pageSize={pageSize} />);
+ render(<AsyncSelect {...defaultProps} options={mock} pageSize={pageSize} />);
await open();
expect(mock).toHaveBeenCalledTimes(1);
await type(search);
@@ -797,10 +679,10 @@ test('async - does not fire a new request if all values
have been fetched', asyn
expect(mock).toHaveBeenCalledTimes(1);
});
-test('async - fires a new request if all values have not been fetched', async
() => {
+test('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} />);
+ render(<AsyncSelect {...defaultProps} options={mock} pageSize={pageSize} />);
await open();
expect(mock).toHaveBeenCalledTimes(1);
await type('or');
@@ -814,43 +696,6 @@ test('async - fires a new request if all values have not
been fetched', async ()
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 () => {
- const getPopupContainer = jest.fn();
- render(<Select {...defaultProps} getPopupContainer={getPopupContainer} />);
- await open();
- expect(getPopupContainer).toHaveBeenCalled();
-});
-
/*
TODO: Add tests that require scroll interaction. Needs further investigation.
- Fetches more data when scrolling and more data is available
diff --git a/superset-frontend/src/components/Select/AsyncSelect.tsx
b/superset-frontend/src/components/Select/AsyncSelect.tsx
new file mode 100644
index 0000000000..98f146f15f
--- /dev/null
+++ b/superset-frontend/src/components/Select/AsyncSelect.tsx
@@ -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);
diff --git a/superset-frontend/src/components/Select/Select.stories.tsx
b/superset-frontend/src/components/Select/Select.stories.tsx
index 9b38586467..efcd91c0c3 100644
--- a/superset-frontend/src/components/Select/Select.stories.tsx
+++ b/superset-frontend/src/components/Select/Select.stories.tsx
@@ -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,
diff --git a/superset-frontend/src/components/Select/Select.test.tsx
b/superset-frontend/src/components/Select/Select.test.tsx
index 1515a7c548..0b353bde38 100644
--- a/superset-frontend/src/components/Select/Select.test.tsx
+++ b/superset-frontend/src/components/Select/Select.test.tsx
@@ -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();
diff --git a/superset-frontend/src/components/index.ts
b/superset-frontend/src/components/index.ts
index a370836fcf..bfa341a9dd 100644
--- a/superset-frontend/src/components/index.ts
+++ b/superset-frontend/src/components/index.ts
@@ -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.
diff --git
a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
index effd18b3d0..749411f2e9 100644
--- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
+++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx
@@ -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}
diff --git
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx
index 8ecb75777f..ed69c6734f 100644
---
a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx
+++
b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/DatasetSelect.tsx
@@ -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}
diff --git a/superset-frontend/src/explore/components/PropertiesModal/index.tsx
b/superset-frontend/src/explore/components/PropertiesModal/index.tsx
index de1639894b..3aa0fa60d2 100644
--- a/superset-frontend/src/explore/components/PropertiesModal/index.tsx
+++ b/superset-frontend/src/explore/components/PropertiesModal/index.tsx
@@ -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"
diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.test.jsx
b/superset-frontend/src/views/CRUD/alert/AlertReportModal.test.jsx
index 1598e5a926..8b3ff1d016 100644
--- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.test.jsx
+++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.test.jsx
@@ -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 () => {
diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx
b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx
index 04398a3582..820a83b8c3 100644
--- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx
+++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx
@@ -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',