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:
cccs-RyanK 2022-07-28 09:09:37 -04:00 committed by GitHub
parent 718bc3062e
commit fe91974163
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 214 additions and 397 deletions

View File

@ -302,7 +302,6 @@ export default function DatabaseSelector({
disabled={!currentDb || readOnly}
header={<FormLabel>{t('Schema')}</FormLabel>}
labelInValue
lazyLoading={false}
loading={loadingSchemas}
name="select-schema"
placeholder={t('Select schema or type schema name')}

View File

@ -26,6 +26,7 @@ import { t } from '@superset-ui/core';
import { Select } from 'src/components';
import { Filter, SelectOption } from 'src/components/ListView/types';
import { FormLabel } from 'src/components/Form';
import AsyncSelect from 'src/components/Select/AsyncSelect';
import { FilterContainer, BaseFilter, FilterHandler } from './Base';
interface SelectFilterProps extends BaseFilter {
@ -86,19 +87,34 @@ function SelectFilter(
return (
<FilterContainer>
<Select
allowClear
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
labelInValue
data-test="filters-select"
header={<FormLabel>{Header}</FormLabel>}
onChange={onChange}
onClear={onClear}
options={fetchSelects ? fetchAndFormatSelects : selects}
placeholder={t('Select or type a value')}
showSearch
value={selectedOption}
/>
{fetchSelects ? (
<AsyncSelect
allowClear
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
data-test="filters-select"
header={<FormLabel>{Header}</FormLabel>}
onChange={onChange}
onClear={onClear}
options={fetchAndFormatSelects}
placeholder={t('Select or type a value')}
showSearch
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>
);
}

View File

@ -60,10 +60,16 @@ const loadOptions = async (search: string, page: number, pageSize: number) => {
const start = page * pageSize;
const deleteCount =
start + pageSize < totalCount ? pageSize : totalCount - start;
const data = OPTIONS.filter(option => option.label.match(search)).splice(
start,
deleteCount,
);
const searchValue = search.trim().toLowerCase();
const optionFilterProps = ['label', 'value', 'gender'];
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 {
data,
totalCount: OPTIONS.length,
@ -74,7 +80,7 @@ const defaultProps = {
allowClear: true,
ariaLabel: ARIA_LABEL,
labelInValue: true,
options: OPTIONS,
options: loadOptions,
pageSize: 10,
showSearch: true,
};
@ -129,17 +135,31 @@ test('displays a header', async () => {
expect(screen.getByText(headerText)).toBeInTheDocument();
});
test('adds a new option if the value is not in the options', async () => {
const { rerender } = render(
<AsyncSelect {...defaultProps} options={[]} value={OPTIONS[0]} />,
test('adds a new option if the value is not in the options, when options are empty', async () => {
const loadOptions = jest.fn(async () => ({ data: [], totalCount: 0 }));
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);
options.forEach((option, i) =>
expect(option).toHaveTextContent(OPTIONS[i].label),
);
});
rerender(
<AsyncSelect {...defaultProps} options={[OPTIONS[1]]} value={OPTIONS[0]} />,
test('adds a new option if the value is not in the options, when options have values', async () => {
const loadOptions = jest.fn(async () => ({
data: [OPTIONS[1]],
totalCount: 1,
}));
render(
<AsyncSelect {...defaultProps} options={loadOptions} value={OPTIONS[0]} />,
);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
expect(await findSelectOption(OPTIONS[1].label)).toBeInTheDocument();
const options = await findAllSelectOptions();
expect(options).toHaveLength(2);
options.forEach((option, i) =>
@ -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 () => {
render(<AsyncSelect {...defaultProps} invertSelection />);
await open();
@ -155,8 +189,11 @@ 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(<AsyncSelect {...defaultProps} options={unsortedOptions} />);
const loadUnsortedOptions = jest.fn(async () => ({
data: [...OPTIONS].sort(() => Math.random()),
totalCount: 2,
}));
render(<AsyncSelect {...defaultProps} options={loadUnsortedOptions} />);
await open();
const options = await findAllSelectOptions();
options.forEach((option, key) =>
@ -250,20 +287,23 @@ test('searches for label or value', async () => {
render(<AsyncSelect {...defaultProps} />);
const search = option.value;
await type(search.toString());
expect(await findSelectOption(option.label)).toBeInTheDocument();
const options = await findAllSelectOptions();
expect(options.length).toBe(1);
expect(options[0]).toHaveTextContent(option.label);
});
test('search order exact and startWith match first', async () => {
render(<AsyncSelect {...defaultProps} />);
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
await type('Her');
expect(await findSelectOption('Guilherme')).toBeInTheDocument();
const options = await findAllSelectOptions();
expect(options.length).toBe(4);
expect(options[0]?.textContent).toEqual('Her');
expect(options[1]?.textContent).toEqual('Herme');
expect(options[2]?.textContent).toEqual('Cher');
expect(options[3]?.textContent).toEqual('Guilherme');
expect(options[0]).toHaveTextContent('Her');
expect(options[1]).toHaveTextContent('Herme');
expect(options[2]).toHaveTextContent('Cher');
expect(options[3]).toHaveTextContent('Guilherme');
});
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 () => {
render(
<AsyncSelect
{...defaultProps}
options={[
{ value: 'Cac' },
{ value: 'abac' },
{ value: 'acbc' },
{ value: 'CAc' },
]}
/>,
);
const loadOptions = jest.fn(async () => ({
data: [
{ value: 'Cac' },
{ value: 'abac' },
{ value: 'acbc' },
{ value: 'CAc' },
],
totalCount: 4,
}));
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await type('Ac');
const options = await findAllSelectOptions();
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 () => {
render(<AsyncSelect {...defaultProps} />);
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await type('{shift}');
expect(screen.queryByText(LOADING)).not.toBeInTheDocument();
});
@ -303,11 +342,16 @@ test('searches for custom fields', async () => {
render(
<AsyncSelect {...defaultProps} optionFilterProps={['label', 'gender']} />,
);
await open();
await type('Liam');
// Liam is on the second page. need to wait to fetch options
expect(await findSelectOption('Liam')).toBeInTheDocument();
let options = await findAllSelectOptions();
expect(options.length).toBe(1);
expect(options[0]).toHaveTextContent('Liam');
await type('Female');
// Olivia is on the second page. need to wait to fetch options
expect(await findSelectOption('Olivia')).toBeInTheDocument();
options = await findAllSelectOptions();
expect(options.length).toBe(6);
expect(options[0]).toHaveTextContent('Ava');
@ -317,7 +361,7 @@ test('searches for custom fields', async () => {
expect(options[4]).toHaveTextContent('Nikole');
expect(options[5]).toHaveTextContent('Olivia');
await type('1');
expect(screen.getByText(NO_DATA)).toBeInTheDocument();
expect(await screen.findByText(NO_DATA)).toBeInTheDocument();
});
test('removes duplicated values', async () => {
@ -332,12 +376,15 @@ test('removes duplicated values', async () => {
});
test('renders a custom label', async () => {
const options = [
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
];
render(<AsyncSelect {...defaultProps} options={options} />);
const loadOptions = jest.fn(async () => ({
data: [
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
],
totalCount: 3,
}));
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await open();
expect(screen.getByRole('heading', { name: 'John' })).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 () => {
const options = [
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
];
render(<AsyncSelect {...defaultProps} options={options} />);
const loadOptions = jest.fn(async () => ({
data: [
{ label: 'John', value: 1, customLabel: <h1>John</h1> },
{ label: 'Liam', value: 2, customLabel: <h1>Liam</h1> },
{ label: 'Olivia', value: 3, customLabel: <h1>Olivia</h1> },
],
totalCount: 3,
}));
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
await type('Liam');
const selectOptions = await findAllSelectOptions();
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 () => {
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();
userEvent.click(await findSelectOption(NULL_OPTION.label));
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 () => {
const loadOptions = jest.fn(async () => ({
data: [OPTIONS[0], NULL_OPTION],
totalCount: 2,
}));
render(
<AsyncSelect
{...defaultProps}
options={[OPTIONS[0], NULL_OPTION, OPTIONS[2]]}
mode="multiple"
/>,
<AsyncSelect {...defaultProps} options={loadOptions} mode="multiple" />,
);
await open();
userEvent.click(await findSelectOption(OPTIONS[0].label));

View File

@ -55,7 +55,6 @@ type PickedSelectProps = Pick<
| 'autoFocus'
| 'disabled'
| 'filterOption'
| 'labelInValue'
| 'loading'
| 'notFoundContent'
| 'onChange'
@ -129,11 +128,10 @@ export interface AsyncSelectProps extends PickedSelectProps {
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
* The options are async, a promise that returns
* an array of options.
*/
options: OptionsType | OptionsPagePromise;
options: OptionsPagePromise;
/**
* It defines how many results should be included
* in the query response.
@ -299,7 +297,6 @@ const AsyncSelect = (
filterOption = true,
header = null,
invertSelection = false,
labelInValue = false,
lazyLoading = true,
loading,
mode = 'single',
@ -322,9 +319,7 @@ const AsyncSelect = (
}: 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);
@ -360,8 +355,8 @@ const AsyncSelect = (
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],
sortComparator(a, b, ''),
[sortComparator, sortSelectedFirst],
);
const initialOptions = useMemo(
@ -528,7 +523,6 @@ const AsyncSelect = (
setSelectOptions(newOptions);
}
if (
isAsync &&
!allValuesLoaded &&
loadingEnabled &&
!fetchedQueries.current.has(getQueryCacheKey(searchValue, 0, pageSize))
@ -546,7 +540,7 @@ const AsyncSelect = (
vScroll.scrollTop > (vScroll.scrollHeight - vScroll.offsetHeight) * 0.7;
const hasMoreData = page * pageSize + pageSize < totalCount;
if (!isLoading && isAsync && hasMoreData && thresholdReached) {
if (!isLoading && hasMoreData && thresholdReached) {
const newPage = page + 1;
fetchPage(inputValue, newPage);
}
@ -575,30 +569,26 @@ const AsyncSelect = (
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);
}
// 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;
const sortedOptions = selectOptions
.slice()
.sort(sortComparatorForNoSearch);
if (!isEqual(sortedOptions, selectOptions)) {
setSelectOptions(sortedOptions);
}
@ -627,7 +617,7 @@ const AsyncSelect = (
if (isLoading) {
return <StyledSpin size="small" />;
}
if (shouldShowSearch && isDropdownVisible) {
if (showSearch && isDropdownVisible) {
return <SearchOutlined />;
}
return <DownOutlined />;
@ -660,7 +650,7 @@ const AsyncSelect = (
);
useEffect(() => {
if (isAsync && loadingEnabled && allowFetch) {
if (loadingEnabled && allowFetch) {
// trigger fetch every time inputValue changes
if (inputValue) {
debouncedFetchPage(inputValue, 0);
@ -668,14 +658,7 @@ const AsyncSelect = (
fetchPage('', 0);
}
}
}, [
isAsync,
loadingEnabled,
fetchPage,
allowFetch,
inputValue,
debouncedFetchPage,
]);
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
useEffect(() => {
if (loading !== undefined && loading !== isLoading) {
@ -706,20 +689,20 @@ const AsyncSelect = (
getPopupContainer={
getPopupContainer || (triggerNode => triggerNode.parentNode)
}
labelInValue={isAsync || labelInValue}
labelInValue
maxTagCount={MAX_TAG_COUNT}
mode={mappedMode}
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
onDeselect={handleOnDeselect}
onDropdownVisibleChange={handleOnDropdownVisibleChange}
onPopupScroll={isAsync ? handlePagination : undefined}
onSearch={shouldShowSearch ? handleOnSearch : undefined}
onPopupScroll={handlePagination}
onSearch={showSearch ? handleOnSearch : undefined}
onSelect={handleOnSelect}
onClear={handleClear}
onChange={onChange}
options={hasCustomLabels ? undefined : fullSelectOptions}
placeholder={placeholder}
showSearch={shouldShowSearch}
showSearch={showSearch}
showArrow
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
value={selectValue}

View File

@ -16,11 +16,22 @@
* specific language governing permissions and limitations
* 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 ControlHeader from 'src/explore/components/ControlHeader';
import AsyncSelect, { AsyncSelectProps, AsyncSelectRef } from './AsyncSelect';
import Select, { SelectProps, OptionsTypePage, OptionsType } from './Select';
import AsyncSelect, {
AsyncSelectProps,
AsyncSelectRef,
OptionsTypePage,
} from './AsyncSelect';
import Select, { SelectProps, OptionsType } from './Select';
export default {
title: 'Select',
@ -452,6 +463,11 @@ export const AsynchronousSelect = ({
reject(new Error('Error while fetching the names from the server'));
});
const initialValue = useMemo(
() => ({ label: 'Valentina', value: 'Valentina' }),
[],
);
return (
<>
<div
@ -465,11 +481,7 @@ export const AsynchronousSelect = ({
fetchOnlyOnSearch={fetchOnlyOnSearch}
options={withError ? fetchUserListError : fetchUserListPage}
placeholder={fetchOnlyOnSearch ? 'Type anything' : 'AsyncSelect...'}
value={
withInitialValue
? { label: 'Valentina', value: 'Valentina' }
: undefined
}
value={withInitialValue ? initialValue : undefined}
/>
</div>
<div

View File

@ -114,17 +114,24 @@ test('displays a header', async () => {
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]} />,
);
test('adds a new option if the value is not in the options, when options are empty', async () => {
render(<Select {...defaultProps} options={[]} value={OPTIONS[0]} />);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
const options = await findAllSelectOptions();
expect(options).toHaveLength(1);
options.forEach((option, i) =>
expect(option).toHaveTextContent(OPTIONS[i].label),
);
});
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]} />,
);
await open();
expect(await findSelectOption(OPTIONS[0].label)).toBeInTheDocument();
expect(await findSelectOption(OPTIONS[1].label)).toBeInTheDocument();
const options = await findAllSelectOptions();
expect(options).toHaveLength(2);
options.forEach((option, i) =>
@ -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 () => {
render(<Select {...defaultProps} invertSelection />);
await open();

View File

@ -21,13 +21,10 @@ import React, {
ReactElement,
ReactNode,
RefObject,
UIEvent,
useEffect,
useMemo,
useState,
useRef,
useCallback,
useImperativeHandle,
} from 'react';
import { ensureIsArray, styled, t } from '@superset-ui/core';
import AntdSelect, {
@ -37,11 +34,8 @@ import AntdSelect, {
} 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';
@ -72,19 +66,6 @@ type PickedSelectProps = Pick<
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 {
/**
* It enables the user to create new options.
@ -103,13 +84,6 @@ export interface SelectProps extends PickedSelectProps {
* 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.
@ -133,13 +107,7 @@ export interface SelectProps extends PickedSelectProps {
* 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;
options: OptionsType;
/**
* It shows a stop-outlined icon at the far right of a selected
* option instead of the default checkmark.
@ -148,19 +116,6 @@ export interface SelectProps extends PickedSelectProps {
* 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.
@ -195,25 +150,6 @@ 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;
`;
@ -228,15 +164,8 @@ const StyledLoadingText = styled.div`
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,
@ -273,9 +202,6 @@ export const propertyComparator =
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/.
@ -295,23 +221,19 @@ const Select = (
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,
@ -320,27 +242,19 @@ const Select = (
getPopupContainer,
...props
}: SelectProps,
ref: RefObject<SelectRef>,
ref: RefObject<HTMLInputElement>,
) => {
const isAsync = typeof options === 'function';
const isSingleMode = mode === 'single';
const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch;
const shouldShowSearch = 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) =>
@ -355,22 +269,14 @@ const Select = (
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],
() => initialOptions.slice().sort(sortSelectedFirst),
[initialOptions, sortSelectedFirst],
);
const [selectOptions, setSelectOptions] =
@ -427,89 +333,6 @@ const Select = (
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) {
@ -527,31 +350,9 @@ const Select = (
: 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);
@ -575,35 +376,13 @@ const Select = (
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 (!isEqual(initialOptionsSorted, selectOptions)) {
setSelectOptions(initialOptionsSorted);
}
}
if (onDropdownVisibleChange) {
onDropdownVisibleChange(isDropdownVisible);
}
@ -618,7 +397,7 @@ const Select = (
if (isLoading && fullSelectOptions.length === 0) {
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
}
return error ? <Error error={error} /> : originNode;
return originNode;
};
// use a function instead of component since every rerender of the
@ -642,8 +421,6 @@ const Select = (
useEffect(() => {
// when `options` list is updated from component prop, reset states
fetchedQueries.current.clear();
setAllValuesLoaded(false);
setSelectOptions(initialOptions);
}, [initialOptions]);
@ -651,49 +428,12 @@ const Select = (
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}
@ -706,13 +446,13 @@ const Select = (
getPopupContainer={
getPopupContainer || (triggerNode => triggerNode.parentNode)
}
labelInValue={isAsync || labelInValue}
labelInValue={labelInValue}
maxTagCount={MAX_TAG_COUNT}
mode={mappedMode}
notFoundContent={isLoading ? t('Loading...') : notFoundContent}
onDeselect={handleOnDeselect}
onDropdownVisibleChange={handleOnDropdownVisibleChange}
onPopupScroll={isAsync ? handlePagination : undefined}
onPopupScroll={undefined}
onSearch={shouldShowSearch ? handleOnSearch : undefined}
onSelect={handleOnSelect}
onClear={handleClear}

View File

@ -336,7 +336,6 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
filterOption={handleFilterOption}
header={header}
labelInValue
lazyLoading={false}
loading={loadingTables}
name="select-table"
onChange={(options: TableOption | TableOption[]) =>

View File

@ -23,7 +23,6 @@ import {
QueryFormData,
} from '@superset-ui/core';
import { RefObject } from 'react';
import { SelectRef } from 'src/components/Select/Select';
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
interface PluginFilterGroupByCustomizeProps {
@ -41,7 +40,7 @@ export type PluginFilterGroupByProps = PluginFilterStylesProps & {
data: DataRecord[];
filterState: FilterState;
formData: PluginFilterGroupByQueryFormData;
inputRef: RefObject<SelectRef>;
inputRef: RefObject<HTMLInputElement>;
} & PluginFilterHooks;
export const DEFAULT_FORM_DATA: PluginFilterGroupByCustomizeProps = {

View File

@ -23,7 +23,6 @@ import {
QueryFormData,
} from '@superset-ui/core';
import { RefObject } from 'react';
import { SelectRef } from 'src/components/Select/Select';
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
interface PluginFilterTimeColumnCustomizeProps {
@ -40,7 +39,7 @@ export type PluginFilterTimeColumnProps = PluginFilterStylesProps & {
data: DataRecord[];
filterState: FilterState;
formData: PluginFilterTimeColumnQueryFormData;
inputRef: RefObject<SelectRef>;
inputRef: RefObject<HTMLInputElement>;
} & PluginFilterHooks;
export const DEFAULT_FORM_DATA: PluginFilterTimeColumnCustomizeProps = {

View File

@ -18,7 +18,6 @@
*/
import { FilterState, QueryFormData, DataRecord } from '@superset-ui/core';
import { RefObject } from 'react';
import { SelectRef } from 'src/components/Select/Select';
import { PluginFilterHooks, PluginFilterStylesProps } from '../types';
interface PluginFilterTimeGrainCustomizeProps {
@ -34,7 +33,7 @@ export type PluginFilterTimeGrainProps = PluginFilterStylesProps & {
data: DataRecord[];
filterState: FilterState;
formData: PluginFilterTimeGrainQueryFormData;
inputRef: RefObject<SelectRef>;
inputRef: RefObject<HTMLInputElement>;
} & PluginFilterHooks;
export const DEFAULT_FORM_DATA: PluginFilterTimeGrainCustomizeProps = {