chore: Extract common select component code (#21094)

This commit is contained in:
cccs-RyanK 2022-09-15 08:57:37 -04:00 committed by GitHub
parent 2c7323a87d
commit 4fcc1d952f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 621 additions and 681 deletions

View File

@ -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>
);

View File

@ -25,13 +25,10 @@ import React, {
} from 'react';
import Button from 'src/components/Button';
import ControlHeader from 'src/explore/components/ControlHeader';
import AsyncSelect, {
AsyncSelectProps,
AsyncSelectRef,
OptionsTypePage,
} from './AsyncSelect';
import AsyncSelect, { AsyncSelectProps, AsyncSelectRef } from './AsyncSelect';
import { SelectOptionsType, SelectOptionsTypePage } from './utils';
import Select, { SelectProps, OptionsType } from './Select';
import Select, { SelectProps } from './Select';
export default {
title: 'Select',
@ -40,7 +37,7 @@ export default {
const DEFAULT_WIDTH = 200;
const options: OptionsType = [
const options: SelectOptionsType = [
{
label: 'Such an incredibly awesome long long label',
value: 'Such an incredibly awesome long long label',
@ -160,7 +157,7 @@ const mountHeader = (type: String) => {
return header;
};
const generateOptions = (opts: OptionsType, count: number) => {
const generateOptions = (opts: SelectOptionsType, count: number) => {
let generated = opts.slice();
let iteration = 0;
while (generated.length < count) {
@ -440,7 +437,7 @@ export const AsynchronousSelect = ({
search: string,
page: number,
pageSize: number,
): Promise<OptionsTypePage> => {
): Promise<SelectOptionsTypePage> => {
const username = search.trim().toLowerCase();
return new Promise(resolve => {
let results = getResults(username);
@ -458,7 +455,7 @@ export const AsynchronousSelect = ({
[responseTime],
);
const fetchUserListError = async (): Promise<OptionsTypePage> =>
const fetchUserListError = async (): Promise<SelectOptionsTypePage> =>
new Promise((_, reject) => {
reject(new Error('Error while fetching the names from the server'));
});

View File

@ -19,207 +19,48 @@
import React, {
forwardRef,
ReactElement,
ReactNode,
RefObject,
useEffect,
useMemo,
useState,
useCallback,
} 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 { ensureIsArray, t } from '@superset-ui/core';
import { LabeledValue as AntdLabeledValue } from 'antd/lib/select';
import { isEqual } from 'lodash';
import Icons from 'src/components/Icons';
import { rankedSearchCompare } from 'src/utils/rankedSearchCompare';
import { getValue, hasOption, isLabeledValue } from './utils';
import {
getValue,
hasOption,
isLabeledValue,
DEFAULT_SORT_COMPARATOR,
EMPTY_OPTIONS,
MAX_TAG_COUNT,
SelectOptionsType,
StyledCheckOutlined,
StyledStopOutlined,
TOKEN_SEPARATORS,
renderSelectOptions,
StyledSelect,
StyledContainer,
hasCustomLabels,
BaseSelectProps,
sortSelectedFirstHelper,
sortComparatorWithSearchHelper,
handleFilterOptionHelper,
dropDownRenderHelper,
getSuffixIcon,
} from './utils';
const { Option } = AntdSelect;
type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>;
type PickedSelectProps = Pick<
AntdSelectAllProps,
| 'allowClear'
| 'autoFocus'
| 'disabled'
| 'filterOption'
| 'labelInValue'
| 'loading'
| 'notFoundContent'
| 'onChange'
| 'onClear'
| 'onFocus'
| 'onBlur'
| 'onDropdownVisibleChange'
| 'placeholder'
| 'showSearch'
| 'tokenSeparators'
| 'value'
| 'getPopupContainer'
>;
export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
export interface SelectProps 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 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[];
export interface SelectProps extends BaseSelectProps {
/**
* It defines the options of the Select.
* The options can be static, an array of options.
* The options can also be async, a promise that returns
* an array of options.
*/
options: OptionsType;
/**
* 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;
/**
* 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;
options: SelectOptionsType;
}
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;
`;
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 EMPTY_OPTIONS: OptionsType = [];
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);
};
/**
* This component is a customized version of the Antdesign 4.X Select component
* https://ant.design/components/select/.
@ -278,15 +119,18 @@ const Select = 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],
);
@ -301,11 +145,11 @@ const Select = 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) },
@ -315,8 +159,6 @@ const Select = forwardRef(
: selectOptions;
}, [selectOptions, selectValue]);
const hasCustomLabels = fullSelectOptions.some(opt => !!opt?.customLabel);
const handleOnSelect = (
selectedItem: string | number | AntdLabeledValue | undefined,
) => {
@ -376,25 +218,8 @@ const Select = forwardRef(
setInputValue(search);
};
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);
@ -413,34 +238,14 @@ const Select = 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 (
<>
{helperText && (
<StyledHelperText role="note">{helperText}</StyledHelperText>
)}
{originNode}
</>
) =>
dropDownRenderHelper(
originNode,
isDropdownVisible,
isLoading,
fullSelectOptions.length,
helperText,
);
};
// use a function instead of component since every rerender of the
// Select component will create a new component
const getSuffixIcon = () => {
if (isLoading) {
return <StyledSpin size="small" />;
}
if (shouldShowSearch && isDropdownVisible) {
return <SearchOutlined />;
}
return <DownOutlined />;
};
const handleClear = () => {
setSelectValue(undefined);
@ -454,16 +259,16 @@ const Select = forwardRef(
setSelectOptions(initialOptions);
}, [initialOptions]);
useEffect(() => {
setSelectValue(value);
}, [value]);
useEffect(() => {
if (loading !== undefined && loading !== isLoading) {
setIsLoading(loading);
}
}, [isLoading, loading]);
useEffect(() => {
setSelectValue(value);
}, [value]);
return (
<StyledContainer>
{header}
@ -487,13 +292,17 @@ const Select = forwardRef(
onSelect={handleOnSelect}
onClear={handleClear}
onChange={onChange}
options={hasCustomLabels ? undefined : fullSelectOptions}
options={hasCustomLabels(options) ? undefined : fullSelectOptions}
placeholder={placeholder}
showSearch={shouldShowSearch}
showArrow
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
value={selectValue}
suffixIcon={getSuffixIcon()}
suffixIcon={getSuffixIcon(
isLoading,
shouldShowSearch,
isDropdownVisible,
)}
menuItemSelectedIcon={
invertSelection ? (
<StyledStopOutlined iconSize="m" />
@ -501,21 +310,10 @@ const Select = 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(options) && renderSelectOptions(fullSelectOptions)}
</StyledSelect>
</StyledContainer>
);

View File

@ -1,99 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ReactNode } from 'react';
import { ensureIsArray } from '@superset-ui/core';
import {
OptionTypeBase,
ValueType,
OptionsType,
GroupedOptionsType,
} from 'react-select';
import { LabeledValue as AntdLabeledValue } from 'antd/lib/select';
export function isObject(value: unknown): value is Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
Array.isArray(value) === false
);
}
/**
* Find Option value that matches a possibly string value.
*
* Translate possible string values to `OptionType` objects, fallback to value
* itself if cannot be found in the options list.
*
* Always returns an array.
*/
export function findValue<OptionType extends OptionTypeBase>(
value: ValueType<OptionType> | string,
options: GroupedOptionsType<OptionType> | OptionsType<OptionType> = [],
valueKey = 'value',
): OptionType[] {
if (value === null || value === undefined || value === '') {
return [];
}
const isGroup = Array.isArray((options[0] || {}).options);
const flatOptions = isGroup
? (options as GroupedOptionsType<OptionType>).flatMap(x => x.options || [])
: (options as OptionsType<OptionType>);
const find = (val: OptionType) => {
const realVal = (value || {}).hasOwnProperty(valueKey)
? val[valueKey]
: val;
return (
flatOptions.find(x => x === realVal || x[valueKey] === realVal) || val
);
};
// If value is a single string, must return an Array so `cleanValue` won't be
// empty: https://github.com/JedWatson/react-select/blob/32ad5c040bdd96cd1ca71010c2558842d684629c/packages/react-select/src/utils.js#L64
return (Array.isArray(value) ? value : [value]).map(find);
}
export function isLabeledValue(value: unknown): value is AntdLabeledValue {
return isObject(value) && 'value' in value && 'label' in value;
}
export function getValue(
option: string | number | AntdLabeledValue | null | undefined,
) {
return isLabeledValue(option) ? option.value : option;
}
type LabeledValue<V> = { label?: ReactNode; value?: V };
export function hasOption<V>(
value: V,
options?: V | LabeledValue<V> | (V | LabeledValue<V>)[],
checkLabel = false,
): boolean {
const optionsArray = ensureIsArray(options);
return (
optionsArray.find(
x =>
x === value ||
(isObject(x) &&
(('value' in x && x.value === value) ||
(checkLabel && 'label' in x && x.label === value))),
) !== undefined
);
}

View File

@ -0,0 +1,443 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ensureIsArray, styled, t } from '@superset-ui/core';
import { Spin } from 'antd';
import Icons from 'src/components/Icons';
import AntdSelect, {
SelectProps as AntdSelectProps,
SelectValue as AntdSelectValue,
LabeledValue as AntdLabeledValue,
} from 'antd/lib/select';
import { rankedSearchCompare } from 'src/utils/rankedSearchCompare';
import {
OptionTypeBase,
ValueType,
OptionsType,
GroupedOptionsType,
} from 'react-select';
import React, {
ReactElement,
ReactNode,
RefObject,
JSXElementConstructor,
} from 'react';
import { DownOutlined, SearchOutlined } from '@ant-design/icons';
declare type RawValue = string | number;
const { Option } = AntdSelect;
export function isObject(value: unknown): value is Record<string, unknown> {
return (
value !== null &&
typeof value === 'object' &&
Array.isArray(value) === false
);
}
/**
* Find Option value that matches a possibly string value.
*
* Translate possible string values to `OptionType` objects, fallback to value
* itself if cannot be found in the options list.
*
* Always returns an array.
*/
export function findValue<OptionType extends OptionTypeBase>(
value: ValueType<OptionType> | string,
options: GroupedOptionsType<OptionType> | OptionsType<OptionType> = [],
valueKey = 'value',
): OptionType[] {
if (value === null || value === undefined || value === '') {
return [];
}
const isGroup = Array.isArray((options[0] || {}).options);
const flatOptions = isGroup
? (options as GroupedOptionsType<OptionType>).flatMap(x => x.options || [])
: (options as OptionsType<OptionType>);
const find = (val: OptionType) => {
const realVal = (value || {}).hasOwnProperty(valueKey)
? val[valueKey]
: val;
return (
flatOptions.find(x => x === realVal || x[valueKey] === realVal) || val
);
};
// If value is a single string, must return an Array so `cleanValue` won't be
// empty: https://github.com/JedWatson/react-select/blob/32ad5c040bdd96cd1ca71010c2558842d684629c/packages/react-select/src/utils.js#L64
return (Array.isArray(value) ? value : [value]).map(find);
}
export function isLabeledValue(value: unknown): value is AntdLabeledValue {
return isObject(value) && 'value' in value && 'label' in value;
}
export function getValue(
option: string | number | AntdLabeledValue | null | undefined,
) {
return isLabeledValue(option) ? option.value : option;
}
type LabeledValue<V> = { label?: ReactNode; value?: V };
export function hasOption<V>(
value: V,
options?: V | LabeledValue<V> | (V | LabeledValue<V>)[],
checkLabel = false,
): boolean {
const optionsArray = ensureIsArray(options);
return (
optionsArray.find(
x =>
x === value ||
(isObject(x) &&
(('value' in x && x.value === value) ||
(checkLabel && 'label' in x && x.label === value))),
) !== undefined
);
}
export type AntdProps = AntdSelectProps<AntdSelectValue>;
export type AntdExposedProps = Pick<
AntdProps,
| 'allowClear'
| 'autoFocus'
| 'disabled'
| 'filterOption'
| 'filterSort'
| 'loading'
| 'labelInValue'
| 'maxTagCount'
| 'notFoundContent'
| 'onChange'
| 'onClear'
| 'onDeselect'
| 'onSelect'
| 'onFocus'
| 'onBlur'
| 'onPopupScroll'
| 'onSearch'
| 'onDropdownVisibleChange'
| 'placeholder'
| 'showArrow'
| 'showSearch'
| 'tokenSeparators'
| 'value'
| 'getPopupContainer'
| 'menuItemSelectedIcon'
>;
export type SelectOptionsType = Exclude<AntdProps['options'], undefined>;
export type SelectOptionsTypePage = {
data: SelectOptionsType;
totalCount: number;
};
export type SelectOptionsPagePromise = (
search: string,
page: number,
pageSize: number,
) => Promise<SelectOptionsTypePage>;
export const StyledContainer = styled.div`
display: flex;
flex-direction: column;
width: 100%;
`;
export 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;
}
`}
`;
export const StyledStopOutlined = styled(Icons.StopOutlined)`
vertical-align: 0;
`;
export const StyledCheckOutlined = styled(Icons.CheckOutlined)`
vertical-align: 0;
`;
export const StyledSpin = styled(Spin)`
margin-top: ${({ theme }) => -theme.gridUnit}px;
`;
export 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};
`}
`;
export const MAX_TAG_COUNT = 4;
export const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
export const EMPTY_OPTIONS: SelectOptionsType = [];
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);
};
export const sortSelectedFirstHelper = (
a: AntdLabeledValue,
b: AntdLabeledValue,
selectValue:
| string
| number
| RawValue[]
| AntdLabeledValue
| AntdLabeledValue[]
| undefined,
) =>
selectValue && a.value !== undefined && b.value !== undefined
? Number(hasOption(b.value, selectValue)) -
Number(hasOption(a.value, selectValue))
: 0;
export const sortComparatorWithSearchHelper = (
a: AntdLabeledValue,
b: AntdLabeledValue,
inputValue: string,
sortCallback: (a: AntdLabeledValue, b: AntdLabeledValue) => number,
sortComparator: (
a: AntdLabeledValue,
b: AntdLabeledValue,
search?: string | undefined,
) => number,
) => sortCallback(a, b) || sortComparator(a, b, inputValue);
export const sortComparatorForNoSearchHelper = (
a: AntdLabeledValue,
b: AntdLabeledValue,
sortCallback: (a: AntdLabeledValue, b: AntdLabeledValue) => number,
sortComparator: (
a: AntdLabeledValue,
b: AntdLabeledValue,
search?: string | undefined,
) => number,
) => sortCallback(a, b) || sortComparator(a, b, '');
// use a function instead of component since every rerender of the
// Select component will create a new component
export const getSuffixIcon = (
isLoading: boolean | undefined,
showSearch: boolean,
isDropdownVisible: boolean,
) => {
if (isLoading) {
return <StyledSpin size="small" />;
}
if (showSearch && isDropdownVisible) {
return <SearchOutlined />;
}
return <DownOutlined />;
};
export const dropDownRenderHelper = (
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
isDropdownVisible: boolean,
isLoading: boolean | undefined,
optionsLength: number,
helperText: string | undefined,
errorComponent?: JSX.Element,
) => {
if (!isDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
if (isLoading && optionsLength === 0) {
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
}
if (errorComponent) {
return errorComponent;
}
return (
<>
{helperText && (
<StyledHelperText role="note">{helperText}</StyledHelperText>
)}
{originNode}
</>
);
};
export const handleFilterOptionHelper = (
search: string,
option: AntdLabeledValue,
optionFilterProps: string[],
filterOption: boolean | Function,
) => {
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;
};
export const hasCustomLabels = (options: SelectOptionsType) =>
options?.some(opt => !!opt?.customLabel);
export interface BaseSelectProps extends AntdExposedProps {
/**
* 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;
/**
* Renders the dropdown
*/
dropdownRender?: (
menu: ReactElement<any, string | JSXElementConstructor<any>>,
) => ReactElement<any, string | JSXElementConstructor<any>>;
/**
* 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 allows to define which properties of the option object
* should be looked for when searching.
* By default label and value.
*/
mappedMode?: 'multiple' | 'tags';
/**
* 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 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;
/**
* 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;
suffixIcon?: ReactNode;
ref: RefObject<HTMLInputElement>;
}
export const renderSelectOptions = (options: SelectOptionsType) =>
options.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>
);
});

View File

@ -17,13 +17,14 @@
* under the License.
*/
import React from 'react';
import Select, { propertyComparator } from 'src/components/Select/Select';
import Select from 'src/components/Select/Select';
import { t, styled } from '@superset-ui/core';
import Alert from 'src/components/Alert';
import Button from 'src/components/Button';
import ModalTrigger, { ModalTriggerRef } from 'src/components/ModalTrigger';
import { FormLabel } from 'src/components/Form';
import { propertyComparator } from 'src/components/Select/utils';
export const options = [
[0, t("Don't refresh")],

View File

@ -20,7 +20,8 @@ import React, { useEffect, useState } from 'react';
import { t, SupersetClient } from '@superset-ui/core';
import ControlHeader from 'src/explore/components/ControlHeader';
import { Select } from 'src/components';
import { SelectProps, OptionsType } from 'src/components/Select/Select';
import { SelectProps } from 'src/components/Select/Select';
import { SelectOptionsType } from 'src/components/Select/utils';
import { SelectValue, LabeledValue } from 'antd/lib/select';
import withToasts from 'src/components/MessageToasts/withToasts';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
@ -32,7 +33,7 @@ interface SelectAsyncControlProps extends SelectAsyncProps {
ariaLabel?: string;
dataEndpoint: string;
default?: SelectValue;
mutator?: (response: Record<string, any>) => OptionsType;
mutator?: (response: Record<string, any>) => SelectOptionsType;
multi?: boolean;
onChange: (val: SelectValue) => void;
// ControlHeader related props
@ -57,7 +58,7 @@ const SelectAsyncControl = ({
value,
...props
}: SelectAsyncControlProps) => {
const [options, setOptions] = useState<OptionsType>([]);
const [options, setOptions] = useState<SelectOptionsType>([]);
const handleOnChange = (val: SelectValue) => {
let onChangeVal = val;

View File

@ -36,7 +36,7 @@ import { Select } from 'src/components';
import debounce from 'lodash/debounce';
import { SLOW_DEBOUNCE } from 'src/constants';
import { useImmerReducer } from 'use-immer';
import { propertyComparator } from 'src/components/Select/Select';
import { propertyComparator } from 'src/components/Select/utils';
import { PluginFilterSelectProps, SelectValue } from './types';
import { StyledFormItem, FilterPluginStyle, StatusMessage } from '../common';
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';

View File

@ -38,7 +38,7 @@ import { Switch } from 'src/components/Switch';
import Modal from 'src/components/Modal';
import TimezoneSelector from 'src/components/TimezoneSelector';
import { Radio } from 'src/components/Radio';
import { propertyComparator } from 'src/components/Select/Select';
import { propertyComparator } from 'src/components/Select/utils';
import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
import withToasts from 'src/components/MessageToasts/withToasts';
import Owner from 'src/types/Owner';