mirror of
https://github.com/apache/superset.git
synced 2026-04-24 02:25:13 +00:00
chore: Extract common select component code (#21094)
This commit is contained in:
@@ -19,7 +19,6 @@
|
||||
import React, {
|
||||
forwardRef,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
UIEvent,
|
||||
useEffect,
|
||||
@@ -30,176 +29,37 @@ import React, {
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
||||
import AntdSelect, {
|
||||
SelectProps as AntdSelectProps,
|
||||
SelectValue as AntdSelectValue,
|
||||
LabeledValue as AntdLabeledValue,
|
||||
} from 'antd/lib/select';
|
||||
import { DownOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import { Spin } from 'antd';
|
||||
import { LabeledValue as AntdLabeledValue } from 'antd/lib/select';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { isEqual } from 'lodash';
|
||||
import Icons from 'src/components/Icons';
|
||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||
import { SLOW_DEBOUNCE } from 'src/constants';
|
||||
import { rankedSearchCompare } from 'src/utils/rankedSearchCompare';
|
||||
import { getValue, hasOption, isLabeledValue } from './utils';
|
||||
|
||||
const { Option } = AntdSelect;
|
||||
|
||||
type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>;
|
||||
|
||||
type PickedSelectProps = Pick<
|
||||
AntdSelectAllProps,
|
||||
| 'allowClear'
|
||||
| 'autoFocus'
|
||||
| 'disabled'
|
||||
| 'filterOption'
|
||||
| 'loading'
|
||||
| 'notFoundContent'
|
||||
| 'onChange'
|
||||
| 'onClear'
|
||||
| 'onFocus'
|
||||
| 'onBlur'
|
||||
| 'onDropdownVisibleChange'
|
||||
| 'placeholder'
|
||||
| 'showSearch'
|
||||
| 'tokenSeparators'
|
||||
| 'value'
|
||||
| 'getPopupContainer'
|
||||
>;
|
||||
|
||||
export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
|
||||
|
||||
export type OptionsTypePage = {
|
||||
data: OptionsType;
|
||||
totalCount: number;
|
||||
};
|
||||
|
||||
export type OptionsPagePromise = (
|
||||
search: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
) => Promise<OptionsTypePage>;
|
||||
|
||||
export type AsyncSelectRef = HTMLInputElement & { clearCache: () => void };
|
||||
|
||||
export interface AsyncSelectProps extends PickedSelectProps {
|
||||
/**
|
||||
* It enables the user to create new options.
|
||||
* Can be used with standard or async select types.
|
||||
* Can be used with any mode, single or multiple.
|
||||
* False by default.
|
||||
* */
|
||||
allowNewOptions?: boolean;
|
||||
/**
|
||||
* It adds the aria-label tag for accessibility standards.
|
||||
* Must be plain English and localized.
|
||||
*/
|
||||
ariaLabel: string;
|
||||
/**
|
||||
* It adds a header on top of the Select.
|
||||
* Can be any ReactNode.
|
||||
*/
|
||||
header?: ReactNode;
|
||||
/**
|
||||
* It adds a helper text on top of the Select options
|
||||
* with additional context to help with the interaction.
|
||||
*/
|
||||
helperText?: string;
|
||||
/**
|
||||
* It fires a request against the server after
|
||||
* the first interaction and not on render.
|
||||
* Works in async mode only (See the options property).
|
||||
* True by default.
|
||||
*/
|
||||
lazyLoading?: boolean;
|
||||
/**
|
||||
* It defines whether the Select should allow for the
|
||||
* selection of multiple options or single.
|
||||
* Single by default.
|
||||
*/
|
||||
mode?: 'single' | 'multiple';
|
||||
/**
|
||||
* Deprecated.
|
||||
* Prefer ariaLabel instead.
|
||||
*/
|
||||
name?: string; // discourage usage
|
||||
/**
|
||||
* It allows to define which properties of the option object
|
||||
* should be looked for when searching.
|
||||
* By default label and value.
|
||||
*/
|
||||
optionFilterProps?: string[];
|
||||
/**
|
||||
* It defines the options of the Select.
|
||||
* The options are async, a promise that returns
|
||||
* an array of options.
|
||||
*/
|
||||
options: OptionsPagePromise;
|
||||
/**
|
||||
* It defines how many results should be included
|
||||
* in the query response.
|
||||
* Works in async mode only (See the options property).
|
||||
*/
|
||||
pageSize?: number;
|
||||
/**
|
||||
* It shows a stop-outlined icon at the far right of a selected
|
||||
* option instead of the default checkmark.
|
||||
* Useful to better indicate to the user that by clicking on a selected
|
||||
* option it will be de-selected.
|
||||
* False by default.
|
||||
*/
|
||||
invertSelection?: boolean;
|
||||
/**
|
||||
* It fires a request against the server only after
|
||||
* searching.
|
||||
* Works in async mode only (See the options property).
|
||||
* Undefined by default.
|
||||
*/
|
||||
fetchOnlyOnSearch?: boolean;
|
||||
/**
|
||||
* It provides a callback function when an error
|
||||
* is generated after a request is fired.
|
||||
* Works in async mode only (See the options property).
|
||||
*/
|
||||
onError?: (error: string) => void;
|
||||
/**
|
||||
* Customize how filtered options are sorted while users search.
|
||||
* Will not apply to predefined `options` array when users are not searching.
|
||||
*/
|
||||
sortComparator?: typeof DEFAULT_SORT_COMPARATOR;
|
||||
}
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledSelect = styled(AntdSelect)`
|
||||
${({ theme }) => `
|
||||
&& .ant-select-selector {
|
||||
border-radius: ${theme.gridUnit}px;
|
||||
}
|
||||
// Open the dropdown when clicking on the suffix
|
||||
// This is fixed in version 4.16
|
||||
.ant-select-arrow .anticon:not(.ant-select-suffix) {
|
||||
pointer-events: none;
|
||||
}
|
||||
.ant-select-dropdown {
|
||||
padding: 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledStopOutlined = styled(Icons.StopOutlined)`
|
||||
vertical-align: 0;
|
||||
`;
|
||||
|
||||
const StyledCheckOutlined = styled(Icons.CheckOutlined)`
|
||||
vertical-align: 0;
|
||||
`;
|
||||
import {
|
||||
getValue,
|
||||
hasOption,
|
||||
isLabeledValue,
|
||||
DEFAULT_SORT_COMPARATOR,
|
||||
EMPTY_OPTIONS,
|
||||
MAX_TAG_COUNT,
|
||||
SelectOptionsPagePromise,
|
||||
SelectOptionsType,
|
||||
SelectOptionsTypePage,
|
||||
StyledCheckOutlined,
|
||||
StyledStopOutlined,
|
||||
TOKEN_SEPARATORS,
|
||||
renderSelectOptions,
|
||||
StyledContainer,
|
||||
StyledSelect,
|
||||
hasCustomLabels,
|
||||
BaseSelectProps,
|
||||
sortSelectedFirstHelper,
|
||||
sortComparatorWithSearchHelper,
|
||||
sortComparatorForNoSearchHelper,
|
||||
getSuffixIcon,
|
||||
dropDownRenderHelper,
|
||||
handleFilterOptionHelper,
|
||||
} from './utils';
|
||||
|
||||
const StyledError = styled.div`
|
||||
${({ theme }) => `
|
||||
@@ -220,32 +80,44 @@ const StyledErrorMessage = styled.div`
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const StyledSpin = styled(Spin)`
|
||||
margin-top: ${({ theme }) => -theme.gridUnit}px;
|
||||
`;
|
||||
|
||||
const StyledLoadingText = styled.div`
|
||||
${({ theme }) => `
|
||||
margin-left: ${theme.gridUnit * 3}px;
|
||||
line-height: ${theme.gridUnit * 8}px;
|
||||
color: ${theme.colors.grayscale.light1};
|
||||
`}
|
||||
`;
|
||||
|
||||
const StyledHelperText = styled.div`
|
||||
${({ theme }) => `
|
||||
padding: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px;
|
||||
color: ${theme.colors.grayscale.base};
|
||||
font-size: ${theme.typography.sizes.s}px;
|
||||
cursor: default;
|
||||
border-bottom: 1px solid ${theme.colors.grayscale.light2};
|
||||
`}
|
||||
`;
|
||||
|
||||
const MAX_TAG_COUNT = 4;
|
||||
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
|
||||
const DEFAULT_PAGE_SIZE = 100;
|
||||
const EMPTY_OPTIONS: OptionsType = [];
|
||||
|
||||
export type AsyncSelectRef = HTMLInputElement & { clearCache: () => void };
|
||||
|
||||
export interface AsyncSelectProps extends BaseSelectProps {
|
||||
/**
|
||||
* 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 the options of the Select.
|
||||
* The options are async, a promise that returns
|
||||
* an array of options.
|
||||
*/
|
||||
options: SelectOptionsPagePromise;
|
||||
/**
|
||||
* It defines how many results should be included
|
||||
* in the query response.
|
||||
* Works in async mode only (See the options property).
|
||||
*/
|
||||
pageSize?: number;
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const Error = ({ error }: { error: string }) => (
|
||||
<StyledError>
|
||||
@@ -253,42 +125,6 @@ const Error = ({ error }: { error: string }) => (
|
||||
</StyledError>
|
||||
);
|
||||
|
||||
export const DEFAULT_SORT_COMPARATOR = (
|
||||
a: AntdLabeledValue,
|
||||
b: AntdLabeledValue,
|
||||
search?: string,
|
||||
) => {
|
||||
let aText: string | undefined;
|
||||
let bText: string | undefined;
|
||||
if (typeof a.label === 'string' && typeof b.label === 'string') {
|
||||
aText = a.label;
|
||||
bText = b.label;
|
||||
} else if (typeof a.value === 'string' && typeof b.value === 'string') {
|
||||
aText = a.value;
|
||||
bText = b.value;
|
||||
}
|
||||
// sort selected options first
|
||||
if (typeof aText === 'string' && typeof bText === 'string') {
|
||||
if (search) {
|
||||
return rankedSearchCompare(aText, bText, search);
|
||||
}
|
||||
return aText.localeCompare(bText);
|
||||
}
|
||||
return (a.value as number) - (b.value as number);
|
||||
};
|
||||
|
||||
/**
|
||||
* It creates a comparator to check for a specific property.
|
||||
* Can be used with string and number property values.
|
||||
* */
|
||||
export const propertyComparator =
|
||||
(property: string) => (a: AntdLabeledValue, b: AntdLabeledValue) => {
|
||||
if (typeof a[property] === 'string' && typeof b[property] === 'string') {
|
||||
return a[property].localeCompare(b[property]);
|
||||
}
|
||||
return (a[property] as number) - (b[property] as number);
|
||||
};
|
||||
|
||||
const getQueryCacheKey = (value: string, page: number, pageSize: number) =>
|
||||
`${value};${page};${pageSize}`;
|
||||
|
||||
@@ -359,23 +195,30 @@ const AsyncSelect = forwardRef(
|
||||
|
||||
const sortSelectedFirst = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
selectValue && a.value !== undefined && b.value !== undefined
|
||||
? Number(hasOption(b.value, selectValue)) -
|
||||
Number(hasOption(a.value, selectValue))
|
||||
: 0,
|
||||
sortSelectedFirstHelper(a, b, selectValue),
|
||||
[selectValue],
|
||||
);
|
||||
|
||||
const sortComparatorWithSearch = useCallback(
|
||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||
sortSelectedFirst(a, b) || sortComparator(a, b, inputValue),
|
||||
sortComparatorWithSearchHelper(
|
||||
a,
|
||||
b,
|
||||
inputValue,
|
||||
sortSelectedFirst,
|
||||
sortComparator,
|
||||
),
|
||||
[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.
|
||||
sortComparator(a, b, ''),
|
||||
sortComparatorForNoSearchHelper(
|
||||
a,
|
||||
b,
|
||||
sortSelectedFirst,
|
||||
sortComparator,
|
||||
),
|
||||
[sortComparator, sortSelectedFirst],
|
||||
);
|
||||
|
||||
@@ -390,11 +233,11 @@ const AsyncSelect = forwardRef(
|
||||
);
|
||||
|
||||
const [selectOptions, setSelectOptions] =
|
||||
useState<OptionsType>(initialOptionsSorted);
|
||||
useState<SelectOptionsType>(initialOptionsSorted);
|
||||
|
||||
// add selected values to options list if they are not in it
|
||||
const fullSelectOptions = useMemo(() => {
|
||||
const missingValues: OptionsType = ensureIsArray(selectValue)
|
||||
const missingValues: SelectOptionsType = ensureIsArray(selectValue)
|
||||
.filter(opt => !hasOption(getValue(opt), selectOptions))
|
||||
.map(opt =>
|
||||
isLabeledValue(opt) ? opt : { value: opt, label: String(opt) },
|
||||
@@ -404,8 +247,6 @@ const AsyncSelect = forwardRef(
|
||||
: selectOptions;
|
||||
}, [selectOptions, selectValue]);
|
||||
|
||||
const hasCustomLabels = fullSelectOptions.some(opt => !!opt?.customLabel);
|
||||
|
||||
const handleOnSelect = (
|
||||
selectedItem: string | number | AntdLabeledValue | undefined,
|
||||
) => {
|
||||
@@ -459,8 +300,8 @@ const AsyncSelect = forwardRef(
|
||||
);
|
||||
|
||||
const mergeData = useCallback(
|
||||
(data: OptionsType) => {
|
||||
let mergedData: OptionsType = [];
|
||||
(data: SelectOptionsType) => {
|
||||
let mergedData: SelectOptionsType = [];
|
||||
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));
|
||||
@@ -493,9 +334,9 @@ const AsyncSelect = forwardRef(
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
const fetchOptions = options as OptionsPagePromise;
|
||||
const fetchOptions = options as SelectOptionsPagePromise;
|
||||
fetchOptions(search, page, pageSize)
|
||||
.then(({ data, totalCount }: OptionsTypePage) => {
|
||||
.then(({ data, totalCount }: SelectOptionsTypePage) => {
|
||||
const mergedData = mergeData(data);
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
setTotalCount(totalCount);
|
||||
@@ -569,25 +410,8 @@ const AsyncSelect = forwardRef(
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterOption = (search: string, option: AntdLabeledValue) => {
|
||||
if (typeof filterOption === 'function') {
|
||||
return filterOption(search, option);
|
||||
}
|
||||
|
||||
if (filterOption) {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
if (optionFilterProps && optionFilterProps.length) {
|
||||
return optionFilterProps.some(prop => {
|
||||
const optionProp = option?.[prop]
|
||||
? String(option[prop]).trim().toLowerCase()
|
||||
: '';
|
||||
return optionProp.includes(searchValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
const handleFilterOption = (search: string, option: AntdLabeledValue) =>
|
||||
handleFilterOptionHelper(search, option, optionFilterProps, filterOption);
|
||||
|
||||
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
||||
setIsDropdownVisible(isDropdownVisible);
|
||||
@@ -624,36 +448,15 @@ const AsyncSelect = forwardRef(
|
||||
|
||||
const dropdownRender = (
|
||||
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
||||
) => {
|
||||
if (!isDropdownVisible) {
|
||||
originNode.ref?.current?.scrollTo({ top: 0 });
|
||||
}
|
||||
if (isLoading && fullSelectOptions.length === 0) {
|
||||
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
||||
}
|
||||
return error ? (
|
||||
<Error error={error} />
|
||||
) : (
|
||||
<>
|
||||
{helperText && (
|
||||
<StyledHelperText role="note">{helperText}</StyledHelperText>
|
||||
)}
|
||||
{originNode}
|
||||
</>
|
||||
) =>
|
||||
dropDownRenderHelper(
|
||||
originNode,
|
||||
isDropdownVisible,
|
||||
isLoading,
|
||||
fullSelectOptions.length,
|
||||
helperText,
|
||||
error ? <Error error={error} /> : undefined,
|
||||
);
|
||||
};
|
||||
|
||||
// use a function instead of component since every rerender of the
|
||||
// Select component will create a new component
|
||||
const getSuffixIcon = () => {
|
||||
if (isLoading) {
|
||||
return <StyledSpin size="small" />;
|
||||
}
|
||||
if (showSearch && isDropdownVisible) {
|
||||
return <SearchOutlined />;
|
||||
}
|
||||
return <DownOutlined />;
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectValue(undefined);
|
||||
@@ -709,6 +512,10 @@ const AsyncSelect = forwardRef(
|
||||
[ref],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectValue(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{header}
|
||||
@@ -732,13 +539,15 @@ const AsyncSelect = forwardRef(
|
||||
onSelect={handleOnSelect}
|
||||
onClear={handleClear}
|
||||
onChange={onChange}
|
||||
options={hasCustomLabels ? undefined : fullSelectOptions}
|
||||
options={
|
||||
hasCustomLabels(fullSelectOptions) ? undefined : fullSelectOptions
|
||||
}
|
||||
placeholder={placeholder}
|
||||
showSearch={showSearch}
|
||||
showArrow
|
||||
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
|
||||
value={selectValue}
|
||||
suffixIcon={getSuffixIcon()}
|
||||
suffixIcon={getSuffixIcon(isLoading, showSearch, isDropdownVisible)}
|
||||
menuItemSelectedIcon={
|
||||
invertSelection ? (
|
||||
<StyledStopOutlined iconSize="m" />
|
||||
@@ -746,21 +555,11 @@ const AsyncSelect = forwardRef(
|
||||
<StyledCheckOutlined iconSize="m" />
|
||||
)
|
||||
}
|
||||
ref={ref}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{hasCustomLabels &&
|
||||
fullSelectOptions.map(opt => {
|
||||
const isOptObject = typeof opt === 'object';
|
||||
const label = isOptObject ? opt?.label || opt.value : opt;
|
||||
const value = isOptObject ? opt.value : opt;
|
||||
const { customLabel, ...optProps } = opt;
|
||||
return (
|
||||
<Option {...optProps} key={value} label={label} value={value}>
|
||||
{isOptObject && customLabel ? customLabel : label}
|
||||
</Option>
|
||||
);
|
||||
})}
|
||||
{hasCustomLabels(fullSelectOptions) &&
|
||||
renderSelectOptions(fullSelectOptions)}
|
||||
</StyledSelect>
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user