mirror of
https://github.com/apache/superset.git
synced 2024-09-12 00:29:39 -04:00
chore: Remove unecessary code from async and sync select components (#20690)
* 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 * removed code from select component * fixed select test * fixed async label value and select initial values * cleaned up async test * fixed lint errors * minor fixes to sync select component * removed unecessary variables and fixed linting * fixed npm test errors * fixed linting issues * fixed showSearch and storybook * fixed linting
This commit is contained in:
parent
718bc3062e
commit
fe91974163
@ -302,7 +302,6 @@ export default function DatabaseSelector({
|
|||||||
disabled={!currentDb || readOnly}
|
disabled={!currentDb || readOnly}
|
||||||
header={<FormLabel>{t('Schema')}</FormLabel>}
|
header={<FormLabel>{t('Schema')}</FormLabel>}
|
||||||
labelInValue
|
labelInValue
|
||||||
lazyLoading={false}
|
|
||||||
loading={loadingSchemas}
|
loading={loadingSchemas}
|
||||||
name="select-schema"
|
name="select-schema"
|
||||||
placeholder={t('Select schema or type schema name')}
|
placeholder={t('Select schema or type schema name')}
|
||||||
|
@ -26,6 +26,7 @@ import { t } from '@superset-ui/core';
|
|||||||
import { Select } from 'src/components';
|
import { Select } from 'src/components';
|
||||||
import { Filter, SelectOption } from 'src/components/ListView/types';
|
import { Filter, SelectOption } from 'src/components/ListView/types';
|
||||||
import { FormLabel } from 'src/components/Form';
|
import { FormLabel } from 'src/components/Form';
|
||||||
|
import AsyncSelect from 'src/components/Select/AsyncSelect';
|
||||||
import { FilterContainer, BaseFilter, FilterHandler } from './Base';
|
import { FilterContainer, BaseFilter, FilterHandler } from './Base';
|
||||||
|
|
||||||
interface SelectFilterProps extends BaseFilter {
|
interface SelectFilterProps extends BaseFilter {
|
||||||
@ -86,19 +87,34 @@ function SelectFilter(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterContainer>
|
<FilterContainer>
|
||||||
<Select
|
{fetchSelects ? (
|
||||||
allowClear
|
<AsyncSelect
|
||||||
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
|
allowClear
|
||||||
labelInValue
|
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
|
||||||
data-test="filters-select"
|
data-test="filters-select"
|
||||||
header={<FormLabel>{Header}</FormLabel>}
|
header={<FormLabel>{Header}</FormLabel>}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onClear={onClear}
|
onClear={onClear}
|
||||||
options={fetchSelects ? fetchAndFormatSelects : selects}
|
options={fetchAndFormatSelects}
|
||||||
placeholder={t('Select or type a value')}
|
placeholder={t('Select or type a value')}
|
||||||
showSearch
|
showSearch
|
||||||
value={selectedOption}
|
value={selectedOption}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
|
||||||
|
data-test="filters-select"
|
||||||
|
header={<FormLabel>{Header}</FormLabel>}
|
||||||
|
labelInValue
|
||||||
|
onChange={onChange}
|
||||||
|
onClear={onClear}
|
||||||
|
options={selects}
|
||||||
|
placeholder={t('Select or type a value')}
|
||||||
|
showSearch
|
||||||
|
value={selectedOption}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</FilterContainer>
|
</FilterContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -60,10 +60,16 @@ const loadOptions = async (search: string, page: number, pageSize: number) => {
|
|||||||
const start = page * pageSize;
|
const start = page * pageSize;
|
||||||
const deleteCount =
|
const deleteCount =
|
||||||
start + pageSize < totalCount ? pageSize : totalCount - start;
|
start + pageSize < totalCount ? pageSize : totalCount - start;
|
||||||
const data = OPTIONS.filter(option => option.label.match(search)).splice(
|
const searchValue = search.trim().toLowerCase();
|
||||||
start,
|
const optionFilterProps = ['label', 'value', 'gender'];
|
||||||
deleteCount,
|
const data = OPTIONS.filter(option =>
|
||||||
);
|
optionFilterProps.some(prop => {
|
||||||
|
const optionProp = option?.[prop]
|
||||||
|
? String(option[prop]).trim().toLowerCase()
|
||||||
|
: '';
|
||||||
|
return optionProp.includes(searchValue);
|
||||||
|
}),
|
||||||
|
).splice(start, deleteCount);
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
totalCount: OPTIONS.length,
|
totalCount: OPTIONS.length,
|
||||||
@ -74,7 +80,7 @@ const defaultProps = {
|
|||||||
allowClear: true,
|
allowClear: true,
|
||||||
ariaLabel: ARIA_LABEL,
|
ariaLabel: ARIA_LABEL,
|
||||||
labelInValue: true,
|
labelInValue: true,
|
||||||
options: OPTIONS,
|
options: loadOptions,
|
||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
};
|
};
|
||||||
@ -129,17 +135,31 @@ test('displays a header', async () => {
|
|||||||
expect(screen.getByText(headerText)).toBeInTheDocument();
|
expect(screen.getByText(headerText)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('adds a new option if the value is not in the options', async () => {
|
test('adds a new option if the value is not in the options, when options are empty', async () => {
|
||||||
const { rerender } = render(
|
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
|
||||||
<AsyncSelect {...defaultProps} options={[]} value={OPTIONS[0]} />,
|
render(
|
||||||
|
<AsyncSelect {...defaultProps} options={loadOptions} value={OPTIONS[0]} />,
|
||||||
);
|
);
|
||||||
await open();
|
await open();
|
||||||
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
||||||
|
const options = await findAllSelectOptions();
|
||||||
|
expect(options).toHaveLength(1);
|
||||||
|
options.forEach((option, i) =>
|
||||||
|
expect(option).toHaveTextContent(OPTIONS[i].label),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
rerender(
|
test('adds a new option if the value is not in the options, when options have values', async () => {
|
||||||
<AsyncSelect {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]} />,
|
const loadOptions = jest.fn(async () => ({
|
||||||
|
data: [OPTIONS[1]],
|
||||||
|
totalCount: 1,
|
||||||
|
}));
|
||||||
|
render(
|
||||||
|
<AsyncSelect {...defaultProps} options={loadOptions} value={OPTIONS[0]} />,
|
||||||
);
|
);
|
||||||
await open();
|
await open();
|
||||||
|
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
||||||
|
expect(await findSelectOption(OPTIONS[1].label)).toBeInTheDocument();
|
||||||
const options = await findAllSelectOptions();
|
const options = await findAllSelectOptions();
|
||||||
expect(options).toHaveLength(2);
|
expect(options).toHaveLength(2);
|
||||||
options.forEach((option, i) =>
|
options.forEach((option, i) =>
|
||||||
@ -147,6 +167,20 @@ test('adds a new option if the value is not in the options', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not add a new option if the value is already in the options', async () => {
|
||||||
|
const loadOptions = jest.fn(async () => ({
|
||||||
|
data: [OPTIONS[0]],
|
||||||
|
totalCount: 1,
|
||||||
|
}));
|
||||||
|
render(
|
||||||
|
<AsyncSelect {...defaultProps} options={loadOptions} value={OPTIONS[0]} />,
|
||||||
|
);
|
||||||
|
await open();
|
||||||
|
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
||||||
|
const options = await findAllSelectOptions();
|
||||||
|
expect(options).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('inverts the selection', async () => {
|
test('inverts the selection', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} invertSelection />);
|
render(<AsyncSelect {...defaultProps} invertSelection />);
|
||||||
await open();
|
await open();
|
||||||
@ -155,8 +189,11 @@ test('inverts the selection', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sort the options by label if no sort comparator is provided', async () => {
|
test('sort the options by label if no sort comparator is provided', async () => {
|
||||||
const unsortedOptions = [...OPTIONS].sort(() => Math.random());
|
const loadUnsortedOptions = jest.fn(async () => ({
|
||||||
render(<AsyncSelect {...defaultProps} options={unsortedOptions} />);
|
data: [...OPTIONS].sort(() => Math.random()),
|
||||||
|
totalCount: 2,
|
||||||
|
}));
|
||||||
|
render(<AsyncSelect {...defaultProps} options={loadUnsortedOptions} />);
|
||||||
await open();
|
await open();
|
||||||
const options = await findAllSelectOptions();
|
const options = await findAllSelectOptions();
|
||||||
options.forEach((option, key) =>
|
options.forEach((option, key) =>
|
||||||
@ -250,20 +287,23 @@ test('searches for label or value', async () => {
|
|||||||
render(<AsyncSelect {...defaultProps} />);
|
render(<AsyncSelect {...defaultProps} />);
|
||||||
const search = option.value;
|
const search = option.value;
|
||||||
await type(search.toString());
|
await type(search.toString());
|
||||||
|
expect(await findSelectOption(option.label)).toBeInTheDocument();
|
||||||
const options = await findAllSelectOptions();
|
const options = await findAllSelectOptions();
|
||||||
expect(options.length).toBe(1);
|
expect(options.length).toBe(1);
|
||||||
expect(options[0]).toHaveTextContent(option.label);
|
expect(options[0]).toHaveTextContent(option.label);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search order exact and startWith match first', async () => {
|
test('search order exact and startWith match first', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} />);
|
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||||
|
await open();
|
||||||
await type('Her');
|
await type('Her');
|
||||||
|
expect(await findSelectOption('Guilherme')).toBeInTheDocument();
|
||||||
const options = await findAllSelectOptions();
|
const options = await findAllSelectOptions();
|
||||||
expect(options.length).toBe(4);
|
expect(options.length).toBe(4);
|
||||||
expect(options[0]?.textContent).toEqual('Her');
|
expect(options[0]).toHaveTextContent('Her');
|
||||||
expect(options[1]?.textContent).toEqual('Herme');
|
expect(options[1]).toHaveTextContent('Herme');
|
||||||
expect(options[2]?.textContent).toEqual('Cher');
|
expect(options[2]).toHaveTextContent('Cher');
|
||||||
expect(options[3]?.textContent).toEqual('Guilherme');
|
expect(options[3]).toHaveTextContent('Guilherme');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ignores case when searching', async () => {
|
test('ignores case when searching', async () => {
|
||||||
@ -273,17 +313,16 @@ test('ignores case when searching', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('same case should be ranked to the top', async () => {
|
test('same case should be ranked to the top', async () => {
|
||||||
render(
|
const loadOptions = jest.fn(async () => ({
|
||||||
<AsyncSelect
|
data: [
|
||||||
{...defaultProps}
|
{ value: 'Cac' },
|
||||||
options={[
|
{ value: 'abac' },
|
||||||
{ value: 'Cac' },
|
{ value: 'acbc' },
|
||||||
{ value: 'abac' },
|
{ value: 'CAc' },
|
||||||
{ value: 'acbc' },
|
],
|
||||||
{ value: 'CAc' },
|
totalCount: 4,
|
||||||
]}
|
}));
|
||||||
/>,
|
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||||
);
|
|
||||||
await type('Ac');
|
await type('Ac');
|
||||||
const options = await findAllSelectOptions();
|
const options = await findAllSelectOptions();
|
||||||
expect(options.length).toBe(4);
|
expect(options.length).toBe(4);
|
||||||
@ -294,7 +333,7 @@ test('same case should be ranked to the top', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('ignores special keys when searching', async () => {
|
test('ignores special keys when searching', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} />);
|
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||||
await type('{shift}');
|
await type('{shift}');
|
||||||
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
|
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -303,11 +342,16 @@ test('searches for custom fields', async () => {
|
|||||||
render(
|
render(
|
||||||
<AsyncSelect {...defaultProps} optionFilterProps={['label', 'gender']} />,
|
<AsyncSelect {...defaultProps} optionFilterProps={['label', 'gender']} />,
|
||||||
);
|
);
|
||||||
|
await open();
|
||||||
await type('Liam');
|
await type('Liam');
|
||||||
|
// Liam is on the second page. need to wait to fetch options
|
||||||
|
expect(await findSelectOption('Liam')).toBeInTheDocument();
|
||||||
let options = await findAllSelectOptions();
|
let options = await findAllSelectOptions();
|
||||||
expect(options.length).toBe(1);
|
expect(options.length).toBe(1);
|
||||||
expect(options[0]).toHaveTextContent('Liam');
|
expect(options[0]).toHaveTextContent('Liam');
|
||||||
await type('Female');
|
await type('Female');
|
||||||
|
// Olivia is on the second page. need to wait to fetch options
|
||||||
|
expect(await findSelectOption('Olivia')).toBeInTheDocument();
|
||||||
options = await findAllSelectOptions();
|
options = await findAllSelectOptions();
|
||||||
expect(options.length).toBe(6);
|
expect(options.length).toBe(6);
|
||||||
expect(options[0]).toHaveTextContent('Ava');
|
expect(options[0]).toHaveTextContent('Ava');
|
||||||
@ -317,7 +361,7 @@ test('searches for custom fields', async () => {
|
|||||||
expect(options[4]).toHaveTextContent('Nikole');
|
expect(options[4]).toHaveTextContent('Nikole');
|
||||||
expect(options[5]).toHaveTextContent('Olivia');
|
expect(options[5]).toHaveTextContent('Olivia');
|
||||||
await type('1');
|
await type('1');
|
||||||
expect(screen.getByText(NO_DATA)).toBeInTheDocument();
|
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removes duplicated values', async () => {
|
test('removes duplicated values', async () => {
|
||||||
@ -332,12 +376,15 @@ test('removes duplicated values', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('renders a custom label', async () => {
|
test('renders a custom label', async () => {
|
||||||
const options = [
|
const loadOptions = jest.fn(async () => ({
|
||||||
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
|
data: [
|
||||||
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
|
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
|
||||||
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
|
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
|
||||||
];
|
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
|
||||||
render(<AsyncSelect {...defaultProps} options={options} />);
|
],
|
||||||
|
totalCount: 3,
|
||||||
|
}));
|
||||||
|
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||||
await open();
|
await open();
|
||||||
expect(screen.getByRole('heading', { name: 'John' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'John' })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('heading', { name: 'Liam' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Liam' })).toBeInTheDocument();
|
||||||
@ -345,12 +392,15 @@ test('renders a custom label', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('searches for a word with a custom label', async () => {
|
test('searches for a word with a custom label', async () => {
|
||||||
const options = [
|
const loadOptions = jest.fn(async () => ({
|
||||||
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
|
data: [
|
||||||
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
|
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
|
||||||
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
|
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
|
||||||
];
|
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
|
||||||
render(<AsyncSelect {...defaultProps} options={options} />);
|
],
|
||||||
|
totalCount: 3,
|
||||||
|
}));
|
||||||
|
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||||
await type('Liam');
|
await type('Liam');
|
||||||
const selectOptions = await findAllSelectOptions();
|
const selectOptions = await findAllSelectOptions();
|
||||||
expect(selectOptions.length).toBe(1);
|
expect(selectOptions.length).toBe(1);
|
||||||
@ -391,7 +441,11 @@ test('does not add a new option if allowNewOptions is false', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adds the null option when selected in single mode', async () => {
|
test('adds the null option when selected in single mode', async () => {
|
||||||
render(<AsyncSelect {...defaultProps} options={[OPTIONS[0], NULL_OPTION]} />);
|
const loadOptions = jest.fn(async () => ({
|
||||||
|
data: [OPTIONS[0], NULL_OPTION],
|
||||||
|
totalCount: 2,
|
||||||
|
}));
|
||||||
|
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||||
await open();
|
await open();
|
||||||
userEvent.click(await findSelectOption(NULL_OPTION.label));
|
userEvent.click(await findSelectOption(NULL_OPTION.label));
|
||||||
const values = await findAllSelectValues();
|
const values = await findAllSelectValues();
|
||||||
@ -399,12 +453,12 @@ test('adds the null option when selected in single mode', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('adds the null option when selected in multiple mode', async () => {
|
test('adds the null option when selected in multiple mode', async () => {
|
||||||
|
const loadOptions = jest.fn(async () => ({
|
||||||
|
data: [OPTIONS[0], NULL_OPTION],
|
||||||
|
totalCount: 2,
|
||||||
|
}));
|
||||||
render(
|
render(
|
||||||
<AsyncSelect
|
<AsyncSelect {...defaultProps} options={loadOptions} mode="multiple" />,
|
||||||
{...defaultProps}
|
|
||||||
options={[OPTIONS[0], NULL_OPTION, OPTIONS[2]]}
|
|
||||||
mode="multiple"
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
await open();
|
await open();
|
||||||
userEvent.click(await findSelectOption(OPTIONS[0].label));
|
userEvent.click(await findSelectOption(OPTIONS[0].label));
|
||||||
|
@ -55,7 +55,6 @@ type PickedSelectProps = Pick<
|
|||||||
| 'autoFocus'
|
| 'autoFocus'
|
||||||
| 'disabled'
|
| 'disabled'
|
||||||
| 'filterOption'
|
| 'filterOption'
|
||||||
| 'labelInValue'
|
|
||||||
| 'loading'
|
| 'loading'
|
||||||
| 'notFoundContent'
|
| 'notFoundContent'
|
||||||
| 'onChange'
|
| 'onChange'
|
||||||
@ -129,11 +128,10 @@ export interface AsyncSelectProps extends PickedSelectProps {
|
|||||||
optionFilterProps?: string[];
|
optionFilterProps?: string[];
|
||||||
/**
|
/**
|
||||||
* It defines the options of the Select.
|
* It defines the options of the Select.
|
||||||
* The options can be static, an array of options.
|
* The options are async, a promise that returns
|
||||||
* The options can also be async, a promise that returns
|
|
||||||
* an array of options.
|
* an array of options.
|
||||||
*/
|
*/
|
||||||
options: OptionsType | OptionsPagePromise;
|
options: OptionsPagePromise;
|
||||||
/**
|
/**
|
||||||
* It defines how many results should be included
|
* It defines how many results should be included
|
||||||
* in the query response.
|
* in the query response.
|
||||||
@ -299,7 +297,6 @@ const AsyncSelect = (
|
|||||||
filterOption = true,
|
filterOption = true,
|
||||||
header = null,
|
header = null,
|
||||||
invertSelection = false,
|
invertSelection = false,
|
||||||
labelInValue = false,
|
|
||||||
lazyLoading = true,
|
lazyLoading = true,
|
||||||
loading,
|
loading,
|
||||||
mode = 'single',
|
mode = 'single',
|
||||||
@ -322,9 +319,7 @@ const AsyncSelect = (
|
|||||||
}: AsyncSelectProps,
|
}: AsyncSelectProps,
|
||||||
ref: RefObject<AsyncSelectRef>,
|
ref: RefObject<AsyncSelectRef>,
|
||||||
) => {
|
) => {
|
||||||
const isAsync = typeof options === 'function';
|
|
||||||
const isSingleMode = mode === 'single';
|
const isSingleMode = mode === 'single';
|
||||||
const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch;
|
|
||||||
const [selectValue, setSelectValue] = useState(value);
|
const [selectValue, setSelectValue] = useState(value);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(loading);
|
const [isLoading, setIsLoading] = useState(loading);
|
||||||
@ -360,8 +355,8 @@ const AsyncSelect = (
|
|||||||
sortSelectedFirst(a, b) ||
|
sortSelectedFirst(a, b) ||
|
||||||
// Only apply the custom sorter in async mode because we should
|
// Only apply the custom sorter in async mode because we should
|
||||||
// preserve the options order as much as possible.
|
// preserve the options order as much as possible.
|
||||||
(isAsync ? sortComparator(a, b, '') : 0),
|
sortComparator(a, b, ''),
|
||||||
[isAsync, sortComparator, sortSelectedFirst],
|
[sortComparator, sortSelectedFirst],
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialOptions = useMemo(
|
const initialOptions = useMemo(
|
||||||
@ -528,7 +523,6 @@ const AsyncSelect = (
|
|||||||
setSelectOptions(newOptions);
|
setSelectOptions(newOptions);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
isAsync &&
|
|
||||||
!allValuesLoaded &&
|
!allValuesLoaded &&
|
||||||
loadingEnabled &&
|
loadingEnabled &&
|
||||||
!fetchedQueries.current.has(getQueryCacheKey(searchValue, 0, pageSize))
|
!fetchedQueries.current.has(getQueryCacheKey(searchValue, 0, pageSize))
|
||||||
@ -546,7 +540,7 @@ const AsyncSelect = (
|
|||||||
vScroll.scrollTop > (vScroll.scrollHeight - vScroll.offsetHeight) * 0.7;
|
vScroll.scrollTop > (vScroll.scrollHeight - vScroll.offsetHeight) * 0.7;
|
||||||
const hasMoreData = page * pageSize + pageSize < totalCount;
|
const hasMoreData = page * pageSize + pageSize < totalCount;
|
||||||
|
|
||||||
if (!isLoading && isAsync && hasMoreData && thresholdReached) {
|
if (!isLoading && hasMoreData && thresholdReached) {
|
||||||
const newPage = page + 1;
|
const newPage = page + 1;
|
||||||
fetchPage(inputValue, newPage);
|
fetchPage(inputValue, newPage);
|
||||||
}
|
}
|
||||||
@ -575,30 +569,26 @@ const AsyncSelect = (
|
|||||||
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
||||||
setIsDropdownVisible(isDropdownVisible);
|
setIsDropdownVisible(isDropdownVisible);
|
||||||
|
|
||||||
if (isAsync) {
|
// loading is enabled when dropdown is open,
|
||||||
// loading is enabled when dropdown is open,
|
// disabled when dropdown is closed
|
||||||
// disabled when dropdown is closed
|
if (loadingEnabled !== isDropdownVisible) {
|
||||||
if (loadingEnabled !== isDropdownVisible) {
|
setLoadingEnabled(isDropdownVisible);
|
||||||
setLoadingEnabled(isDropdownVisible);
|
}
|
||||||
}
|
// when closing dropdown, always reset loading state
|
||||||
// when closing dropdown, always reset loading state
|
if (!isDropdownVisible && isLoading) {
|
||||||
if (!isDropdownVisible && isLoading) {
|
// delay is for the animation of closing the dropdown
|
||||||
// delay is for the animation of closing the dropdown
|
// so the dropdown doesn't flash between "Loading..." and "No data"
|
||||||
// so the dropdown doesn't flash between "Loading..." and "No data"
|
// before closing.
|
||||||
// before closing.
|
setTimeout(() => {
|
||||||
setTimeout(() => {
|
setIsLoading(false);
|
||||||
setIsLoading(false);
|
}, 250);
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// if no search input value, force sort options because it won't be sorted by
|
// if no search input value, force sort options because it won't be sorted by
|
||||||
// `filterSort`.
|
// `filterSort`.
|
||||||
if (isDropdownVisible && !inputValue && selectOptions.length > 1) {
|
if (isDropdownVisible && !inputValue && selectOptions.length > 1) {
|
||||||
const sortedOptions = isAsync
|
const sortedOptions = selectOptions
|
||||||
? selectOptions.slice().sort(sortComparatorForNoSearch)
|
.slice()
|
||||||
: // if not in async mode, revert to the original select options
|
.sort(sortComparatorForNoSearch);
|
||||||
// (with selected options still sorted to the top)
|
|
||||||
initialOptionsSorted;
|
|
||||||
if (!isEqual(sortedOptions, selectOptions)) {
|
if (!isEqual(sortedOptions, selectOptions)) {
|
||||||
setSelectOptions(sortedOptions);
|
setSelectOptions(sortedOptions);
|
||||||
}
|
}
|
||||||
@ -627,7 +617,7 @@ const AsyncSelect = (
|
|||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <StyledSpin size="small" />;
|
return <StyledSpin size="small" />;
|
||||||
}
|
}
|
||||||
if (shouldShowSearch && isDropdownVisible) {
|
if (showSearch && isDropdownVisible) {
|
||||||
return <SearchOutlined />;
|
return <SearchOutlined />;
|
||||||
}
|
}
|
||||||
return <DownOutlined />;
|
return <DownOutlined />;
|
||||||
@ -660,7 +650,7 @@ const AsyncSelect = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAsync && loadingEnabled && allowFetch) {
|
if (loadingEnabled && allowFetch) {
|
||||||
// trigger fetch every time inputValue changes
|
// trigger fetch every time inputValue changes
|
||||||
if (inputValue) {
|
if (inputValue) {
|
||||||
debouncedFetchPage(inputValue, 0);
|
debouncedFetchPage(inputValue, 0);
|
||||||
@ -668,14 +658,7 @@ const AsyncSelect = (
|
|||||||
fetchPage('', 0);
|
fetchPage('', 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
|
||||||
isAsync,
|
|
||||||
loadingEnabled,
|
|
||||||
fetchPage,
|
|
||||||
allowFetch,
|
|
||||||
inputValue,
|
|
||||||
debouncedFetchPage,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading !== undefined && loading !== isLoading) {
|
if (loading !== undefined && loading !== isLoading) {
|
||||||
@ -706,20 +689,20 @@ const AsyncSelect = (
|
|||||||
getPopupContainer={
|
getPopupContainer={
|
||||||
getPopupContainer || (triggerNode => triggerNode.parentNode)
|
getPopupContainer || (triggerNode => triggerNode.parentNode)
|
||||||
}
|
}
|
||||||
labelInValue={isAsync || labelInValue}
|
labelInValue
|
||||||
maxTagCount={MAX_TAG_COUNT}
|
maxTagCount={MAX_TAG_COUNT}
|
||||||
mode={mappedMode}
|
mode={mappedMode}
|
||||||
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
|
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
|
||||||
onDeselect={handleOnDeselect}
|
onDeselect={handleOnDeselect}
|
||||||
onDropdownVisibleChange={handleOnDropdownVisibleChange}
|
onDropdownVisibleChange={handleOnDropdownVisibleChange}
|
||||||
onPopupScroll={isAsync ? handlePagination : undefined}
|
onPopupScroll={handlePagination}
|
||||||
onSearch={shouldShowSearch ? handleOnSearch : undefined}
|
onSearch={showSearch ? handleOnSearch : undefined}
|
||||||
onSelect={handleOnSelect}
|
onSelect={handleOnSelect}
|
||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={hasCustomLabels ? undefined : fullSelectOptions}
|
options={hasCustomLabels ? undefined : fullSelectOptions}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
showSearch={shouldShowSearch}
|
showSearch={showSearch}
|
||||||
showArrow
|
showArrow
|
||||||
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
|
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
|
||||||
value={selectValue}
|
value={selectValue}
|
||||||
|
@ -16,11 +16,22 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { ReactNode, useState, useCallback, useRef } from 'react';
|
import React, {
|
||||||
|
ReactNode,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
import Button from 'src/components/Button';
|
import Button from 'src/components/Button';
|
||||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||||
import AsyncSelect, { AsyncSelectProps, AsyncSelectRef } from './AsyncSelect';
|
import AsyncSelect, {
|
||||||
import Select, { SelectProps, OptionsTypePage, OptionsType } from './Select';
|
AsyncSelectProps,
|
||||||
|
AsyncSelectRef,
|
||||||
|
OptionsTypePage,
|
||||||
|
} from './AsyncSelect';
|
||||||
|
|
||||||
|
import Select, { SelectProps, OptionsType } from './Select';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Select',
|
title: 'Select',
|
||||||
@ -452,6 +463,11 @@ export const AsynchronousSelect = ({
|
|||||||
reject(new Error('Error while fetching the names from the server'));
|
reject(new Error('Error while fetching the names from the server'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const initialValue = useMemo(
|
||||||
|
() => ({ label: 'Valentina', value: 'Valentina' }),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@ -465,11 +481,7 @@ export const AsynchronousSelect = ({
|
|||||||
fetchOnlyOnSearch={fetchOnlyOnSearch}
|
fetchOnlyOnSearch={fetchOnlyOnSearch}
|
||||||
options={withError ? fetchUserListError : fetchUserListPage}
|
options={withError ? fetchUserListError : fetchUserListPage}
|
||||||
placeholder={fetchOnlyOnSearch ? 'Type anything' : 'AsyncSelect...'}
|
placeholder={fetchOnlyOnSearch ? 'Type anything' : 'AsyncSelect...'}
|
||||||
value={
|
value={withInitialValue ? initialValue : undefined}
|
||||||
withInitialValue
|
|
||||||
? { label: 'Valentina', value: 'Valentina' }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
@ -114,17 +114,24 @@ test('displays a header', async () => {
|
|||||||
expect(screen.getByText(headerText)).toBeInTheDocument();
|
expect(screen.getByText(headerText)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('adds a new option if the value is not in the options', async () => {
|
test('adds a new option if the value is not in the options, when options are empty', async () => {
|
||||||
const { rerender } = render(
|
render(<Select {...defaultProps} options={[]} value={OPTIONS[0]} />);
|
||||||
<Select {...defaultProps} options={[]} value={OPTIONS[0]} />,
|
|
||||||
);
|
|
||||||
await open();
|
await open();
|
||||||
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
||||||
|
const options = await findAllSelectOptions();
|
||||||
|
expect(options).toHaveLength(1);
|
||||||
|
options.forEach((option, i) =>
|
||||||
|
expect(option).toHaveTextContent(OPTIONS[i].label),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
rerender(
|
test('adds a new option if the value is not in the options, when options have values', async () => {
|
||||||
|
render(
|
||||||
<Select {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]} />,
|
<Select {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]} />,
|
||||||
);
|
);
|
||||||
await open();
|
await open();
|
||||||
|
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
||||||
|
expect(await findSelectOption(OPTIONS[1].label)).toBeInTheDocument();
|
||||||
const options = await findAllSelectOptions();
|
const options = await findAllSelectOptions();
|
||||||
expect(options).toHaveLength(2);
|
expect(options).toHaveLength(2);
|
||||||
options.forEach((option, i) =>
|
options.forEach((option, i) =>
|
||||||
@ -132,6 +139,16 @@ test('adds a new option if the value is not in the options', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('does not add a new option if the value is already in the options', async () => {
|
||||||
|
render(
|
||||||
|
<Select {...defaultProps} options={[OPTIONS[0]]} value={OPTIONS[0]} />,
|
||||||
|
);
|
||||||
|
await open();
|
||||||
|
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
|
||||||
|
const options = await findAllSelectOptions();
|
||||||
|
expect(options).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('inverts the selection', async () => {
|
test('inverts the selection', async () => {
|
||||||
render(<Select {...defaultProps} invertSelection />);
|
render(<Select {...defaultProps} invertSelection />);
|
||||||
await open();
|
await open();
|
||||||
|
@ -21,13 +21,10 @@ import React, {
|
|||||||
ReactElement,
|
ReactElement,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
UIEvent,
|
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
useRef,
|
|
||||||
useCallback,
|
useCallback,
|
||||||
useImperativeHandle,
|
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
||||||
import AntdSelect, {
|
import AntdSelect, {
|
||||||
@ -37,11 +34,8 @@ import AntdSelect, {
|
|||||||
} from 'antd/lib/select';
|
} from 'antd/lib/select';
|
||||||
import { DownOutlined, SearchOutlined } from '@ant-design/icons';
|
import { DownOutlined, SearchOutlined } from '@ant-design/icons';
|
||||||
import { Spin } from 'antd';
|
import { Spin } from 'antd';
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import Icons from 'src/components/Icons';
|
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 { rankedSearchCompare } from 'src/utils/rankedSearchCompare';
|
||||||
import { getValue, hasOption, isLabeledValue } from './utils';
|
import { getValue, hasOption, isLabeledValue } from './utils';
|
||||||
|
|
||||||
@ -72,19 +66,6 @@ type PickedSelectProps = Pick<
|
|||||||
|
|
||||||
export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
|
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 SelectRef = HTMLInputElement & { clearCache: () => void };
|
|
||||||
|
|
||||||
export interface SelectProps extends PickedSelectProps {
|
export interface SelectProps extends PickedSelectProps {
|
||||||
/**
|
/**
|
||||||
* It enables the user to create new options.
|
* It enables the user to create new options.
|
||||||
@ -103,13 +84,6 @@ export interface SelectProps extends PickedSelectProps {
|
|||||||
* Can be any ReactNode.
|
* Can be any ReactNode.
|
||||||
*/
|
*/
|
||||||
header?: 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
|
* It defines whether the Select should allow for the
|
||||||
* selection of multiple options or single.
|
* selection of multiple options or single.
|
||||||
@ -133,13 +107,7 @@ export interface SelectProps extends PickedSelectProps {
|
|||||||
* The options can also be async, a promise that returns
|
* The options can also be async, a promise that returns
|
||||||
* an array of options.
|
* an array of options.
|
||||||
*/
|
*/
|
||||||
options: OptionsType | OptionsPagePromise;
|
options: OptionsType;
|
||||||
/**
|
|
||||||
* 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
|
* It shows a stop-outlined icon at the far right of a selected
|
||||||
* option instead of the default checkmark.
|
* option instead of the default checkmark.
|
||||||
@ -148,19 +116,6 @@ export interface SelectProps extends PickedSelectProps {
|
|||||||
* False by default.
|
* False by default.
|
||||||
*/
|
*/
|
||||||
invertSelection?: boolean;
|
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.
|
* Customize how filtered options are sorted while users search.
|
||||||
* Will not apply to predefined `options` array when users are not searching.
|
* Will not apply to predefined `options` array when users are not searching.
|
||||||
@ -195,25 +150,6 @@ const StyledCheckOutlined = styled(Icons.CheckOutlined)`
|
|||||||
vertical-align: 0;
|
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)`
|
const StyledSpin = styled(Spin)`
|
||||||
margin-top: ${({ theme }) => -theme.gridUnit}px;
|
margin-top: ${({ theme }) => -theme.gridUnit}px;
|
||||||
`;
|
`;
|
||||||
@ -228,15 +164,8 @@ const StyledLoadingText = styled.div`
|
|||||||
|
|
||||||
const MAX_TAG_COUNT = 4;
|
const MAX_TAG_COUNT = 4;
|
||||||
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
|
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
|
||||||
const DEFAULT_PAGE_SIZE = 100;
|
|
||||||
const EMPTY_OPTIONS: OptionsType = [];
|
const EMPTY_OPTIONS: OptionsType = [];
|
||||||
|
|
||||||
const Error = ({ error }: { error: string }) => (
|
|
||||||
<StyledError>
|
|
||||||
<Icons.ErrorSolid /> <StyledErrorMessage>{error}</StyledErrorMessage>
|
|
||||||
</StyledError>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const DEFAULT_SORT_COMPARATOR = (
|
export const DEFAULT_SORT_COMPARATOR = (
|
||||||
a: AntdLabeledValue,
|
a: AntdLabeledValue,
|
||||||
b: AntdLabeledValue,
|
b: AntdLabeledValue,
|
||||||
@ -273,9 +202,6 @@ export const propertyComparator =
|
|||||||
return (a[property] as number) - (b[property] as number);
|
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
|
* This component is a customized version of the Antdesign 4.X Select component
|
||||||
* https://ant.design/components/select/.
|
* https://ant.design/components/select/.
|
||||||
@ -295,23 +221,19 @@ const Select = (
|
|||||||
allowClear,
|
allowClear,
|
||||||
allowNewOptions = false,
|
allowNewOptions = false,
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
fetchOnlyOnSearch,
|
|
||||||
filterOption = true,
|
filterOption = true,
|
||||||
header = null,
|
header = null,
|
||||||
invertSelection = false,
|
invertSelection = false,
|
||||||
labelInValue = false,
|
labelInValue = false,
|
||||||
lazyLoading = true,
|
|
||||||
loading,
|
loading,
|
||||||
mode = 'single',
|
mode = 'single',
|
||||||
name,
|
name,
|
||||||
notFoundContent,
|
notFoundContent,
|
||||||
onError,
|
|
||||||
onChange,
|
onChange,
|
||||||
onClear,
|
onClear,
|
||||||
onDropdownVisibleChange,
|
onDropdownVisibleChange,
|
||||||
optionFilterProps = ['label', 'value'],
|
optionFilterProps = ['label', 'value'],
|
||||||
options,
|
options,
|
||||||
pageSize = DEFAULT_PAGE_SIZE,
|
|
||||||
placeholder = t('Select ...'),
|
placeholder = t('Select ...'),
|
||||||
showSearch = true,
|
showSearch = true,
|
||||||
sortComparator = DEFAULT_SORT_COMPARATOR,
|
sortComparator = DEFAULT_SORT_COMPARATOR,
|
||||||
@ -320,27 +242,19 @@ const Select = (
|
|||||||
getPopupContainer,
|
getPopupContainer,
|
||||||
...props
|
...props
|
||||||
}: SelectProps,
|
}: SelectProps,
|
||||||
ref: RefObject<SelectRef>,
|
ref: RefObject<HTMLInputElement>,
|
||||||
) => {
|
) => {
|
||||||
const isAsync = typeof options === 'function';
|
|
||||||
const isSingleMode = mode === 'single';
|
const isSingleMode = mode === 'single';
|
||||||
const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch;
|
const shouldShowSearch = allowNewOptions ? true : showSearch;
|
||||||
const [selectValue, setSelectValue] = useState(value);
|
const [selectValue, setSelectValue] = useState(value);
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(loading);
|
const [isLoading, setIsLoading] = useState(loading);
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
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
|
const mappedMode = isSingleMode
|
||||||
? undefined
|
? undefined
|
||||||
: allowNewOptions
|
: allowNewOptions
|
||||||
? 'tags'
|
? 'tags'
|
||||||
: 'multiple';
|
: 'multiple';
|
||||||
const allowFetch = !fetchOnlyOnSearch || inputValue;
|
|
||||||
|
|
||||||
const sortSelectedFirst = useCallback(
|
const sortSelectedFirst = useCallback(
|
||||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||||
@ -355,22 +269,14 @@ const Select = (
|
|||||||
sortSelectedFirst(a, b) || sortComparator(a, b, inputValue),
|
sortSelectedFirst(a, b) || sortComparator(a, b, inputValue),
|
||||||
[inputValue, sortComparator, sortSelectedFirst],
|
[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(
|
const initialOptions = useMemo(
|
||||||
() => (options && Array.isArray(options) ? options.slice() : EMPTY_OPTIONS),
|
() => (options && Array.isArray(options) ? options.slice() : EMPTY_OPTIONS),
|
||||||
[options],
|
[options],
|
||||||
);
|
);
|
||||||
const initialOptionsSorted = useMemo(
|
const initialOptionsSorted = useMemo(
|
||||||
() => initialOptions.slice().sort(sortComparatorForNoSearch),
|
() => initialOptions.slice().sort(sortSelectedFirst),
|
||||||
[initialOptions, sortComparatorForNoSearch],
|
[initialOptions, sortSelectedFirst],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [selectOptions, setSelectOptions] =
|
const [selectOptions, setSelectOptions] =
|
||||||
@ -427,89 +333,6 @@ const Select = (
|
|||||||
setInputValue('');
|
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 handleOnSearch = (search: string) => {
|
||||||
const searchValue = search.trim();
|
const searchValue = search.trim();
|
||||||
if (allowNewOptions && isSingleMode) {
|
if (allowNewOptions && isSingleMode) {
|
||||||
@ -527,31 +350,9 @@ const Select = (
|
|||||||
: cleanSelectOptions;
|
: cleanSelectOptions;
|
||||||
setSelectOptions(newOptions);
|
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);
|
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) => {
|
const handleFilterOption = (search: string, option: AntdLabeledValue) => {
|
||||||
if (typeof filterOption === 'function') {
|
if (typeof filterOption === 'function') {
|
||||||
return filterOption(search, option);
|
return filterOption(search, option);
|
||||||
@ -575,35 +376,13 @@ const Select = (
|
|||||||
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
||||||
setIsDropdownVisible(isDropdownVisible);
|
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
|
// if no search input value, force sort options because it won't be sorted by
|
||||||
// `filterSort`.
|
// `filterSort`.
|
||||||
if (isDropdownVisible && !inputValue && selectOptions.length > 1) {
|
if (isDropdownVisible && !inputValue && selectOptions.length > 1) {
|
||||||
const sortedOptions = isAsync
|
if (!isEqual(initialOptionsSorted, selectOptions)) {
|
||||||
? selectOptions.slice().sort(sortComparatorForNoSearch)
|
setSelectOptions(initialOptionsSorted);
|
||||||
: // 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) {
|
if (onDropdownVisibleChange) {
|
||||||
onDropdownVisibleChange(isDropdownVisible);
|
onDropdownVisibleChange(isDropdownVisible);
|
||||||
}
|
}
|
||||||
@ -618,7 +397,7 @@ const Select = (
|
|||||||
if (isLoading && fullSelectOptions.length === 0) {
|
if (isLoading && fullSelectOptions.length === 0) {
|
||||||
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
||||||
}
|
}
|
||||||
return error ? <Error error={error} /> : originNode;
|
return originNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
// use a function instead of component since every rerender of the
|
// use a function instead of component since every rerender of the
|
||||||
@ -642,8 +421,6 @@ const Select = (
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// when `options` list is updated from component prop, reset states
|
// when `options` list is updated from component prop, reset states
|
||||||
fetchedQueries.current.clear();
|
|
||||||
setAllValuesLoaded(false);
|
|
||||||
setSelectOptions(initialOptions);
|
setSelectOptions(initialOptions);
|
||||||
}, [initialOptions]);
|
}, [initialOptions]);
|
||||||
|
|
||||||
@ -651,49 +428,12 @@ const Select = (
|
|||||||
setSelectValue(value);
|
setSelectValue(value);
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
if (loading !== undefined && loading !== isLoading) {
|
if (loading !== undefined && loading !== isLoading) {
|
||||||
setIsLoading(loading);
|
setIsLoading(loading);
|
||||||
}
|
}
|
||||||
}, [isLoading, loading]);
|
}, [isLoading, loading]);
|
||||||
|
|
||||||
const clearCache = () => fetchedQueries.current.clear();
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
...(ref.current as HTMLInputElement),
|
|
||||||
clearCache,
|
|
||||||
}),
|
|
||||||
[ref],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
{header}
|
{header}
|
||||||
@ -706,13 +446,13 @@ const Select = (
|
|||||||
getPopupContainer={
|
getPopupContainer={
|
||||||
getPopupContainer || (triggerNode => triggerNode.parentNode)
|
getPopupContainer || (triggerNode => triggerNode.parentNode)
|
||||||
}
|
}
|
||||||
labelInValue={isAsync || labelInValue}
|
labelInValue={labelInValue}
|
||||||
maxTagCount={MAX_TAG_COUNT}
|
maxTagCount={MAX_TAG_COUNT}
|
||||||
mode={mappedMode}
|
mode={mappedMode}
|
||||||
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
|
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
|
||||||
onDeselect={handleOnDeselect}
|
onDeselect={handleOnDeselect}
|
||||||
onDropdownVisibleChange={handleOnDropdownVisibleChange}
|
onDropdownVisibleChange={handleOnDropdownVisibleChange}
|
||||||
onPopupScroll={isAsync ? handlePagination : undefined}
|
onPopupScroll={undefined}
|
||||||
onSearch={shouldShowSearch ? handleOnSearch : undefined}
|
onSearch={shouldShowSearch ? handleOnSearch : undefined}
|
||||||
onSelect={handleOnSelect}
|
onSelect={handleOnSelect}
|
||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
|
@ -336,7 +336,6 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
|||||||
filterOption={handleFilterOption}
|
filterOption={handleFilterOption}
|
||||||
header={header}
|
header={header}
|
||||||
labelInValue
|
labelInValue
|
||||||
lazyLoading={false}
|
|
||||||
loading={loadingTables}
|
loading={loadingTables}
|
||||||
name="select-table"
|
name="select-table"
|
||||||
onChange={(options: TableOption | TableOption[]) =>
|
onChange={(options: TableOption | TableOption[]) =>
|
||||||
|
@ -23,7 +23,6 @@ import {
|
|||||||
QueryFormData,
|
QueryFormData,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
import { SelectRef } from 'src/components/Select/Select';
|
|
||||||
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
||||||
|
|
||||||
interface PluginFilterGroupByCustomizeProps {
|
interface PluginFilterGroupByCustomizeProps {
|
||||||
@ -41,7 +40,7 @@ export type PluginFilterGroupByProps = PluginFilterStylesProps & {
|
|||||||
data: DataRecord[];
|
data: DataRecord[];
|
||||||
filterState: FilterState;
|
filterState: FilterState;
|
||||||
formData: PluginFilterGroupByQueryFormData;
|
formData: PluginFilterGroupByQueryFormData;
|
||||||
inputRef: RefObject<SelectRef>;
|
inputRef: RefObject<HTMLInputElement>;
|
||||||
} & PluginFilterHooks;
|
} & PluginFilterHooks;
|
||||||
|
|
||||||
export const DEFAULT_FORM_DATA: PluginFilterGroupByCustomizeProps = {
|
export const DEFAULT_FORM_DATA: PluginFilterGroupByCustomizeProps = {
|
||||||
|
@ -23,7 +23,6 @@ import {
|
|||||||
QueryFormData,
|
QueryFormData,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
import { SelectRef } from 'src/components/Select/Select';
|
|
||||||
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
||||||
|
|
||||||
interface PluginFilterTimeColumnCustomizeProps {
|
interface PluginFilterTimeColumnCustomizeProps {
|
||||||
@ -40,7 +39,7 @@ export type PluginFilterTimeColumnProps = PluginFilterStylesProps & {
|
|||||||
data: DataRecord[];
|
data: DataRecord[];
|
||||||
filterState: FilterState;
|
filterState: FilterState;
|
||||||
formData: PluginFilterTimeColumnQueryFormData;
|
formData: PluginFilterTimeColumnQueryFormData;
|
||||||
inputRef: RefObject<SelectRef>;
|
inputRef: RefObject<HTMLInputElement>;
|
||||||
} & PluginFilterHooks;
|
} & PluginFilterHooks;
|
||||||
|
|
||||||
export const DEFAULT_FORM_DATA: PluginFilterTimeColumnCustomizeProps = {
|
export const DEFAULT_FORM_DATA: PluginFilterTimeColumnCustomizeProps = {
|
||||||
|
@ -18,7 +18,6 @@
|
|||||||
*/
|
*/
|
||||||
import { FilterState, QueryFormData, DataRecord } from '@superset-ui/core';
|
import { FilterState, QueryFormData, DataRecord } from '@superset-ui/core';
|
||||||
import { RefObject } from 'react';
|
import { RefObject } from 'react';
|
||||||
import { SelectRef } from 'src/components/Select/Select';
|
|
||||||
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
|
||||||
|
|
||||||
interface PluginFilterTimeGrainCustomizeProps {
|
interface PluginFilterTimeGrainCustomizeProps {
|
||||||
@ -34,7 +33,7 @@ export type PluginFilterTimeGrainProps = PluginFilterStylesProps & {
|
|||||||
data: DataRecord[];
|
data: DataRecord[];
|
||||||
filterState: FilterState;
|
filterState: FilterState;
|
||||||
formData: PluginFilterTimeGrainQueryFormData;
|
formData: PluginFilterTimeGrainQueryFormData;
|
||||||
inputRef: RefObject<SelectRef>;
|
inputRef: RefObject<HTMLInputElement>;
|
||||||
} & PluginFilterHooks;
|
} & PluginFilterHooks;
|
||||||
|
|
||||||
export const DEFAULT_FORM_DATA: PluginFilterTimeGrainCustomizeProps = {
|
export const DEFAULT_FORM_DATA: PluginFilterTimeGrainCustomizeProps = {
|
||||||
|
Loading…
Reference in New Issue
Block a user