mirror of
https://github.com/apache/superset.git
synced 2024-09-16 02:29:39 -04:00
chore: Extract common select component code (#21094)
This commit is contained in:
parent
2c7323a87d
commit
4fcc1d952f
@ -19,7 +19,6 @@
|
|||||||
import React, {
|
import React, {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
ReactNode,
|
|
||||||
RefObject,
|
RefObject,
|
||||||
UIEvent,
|
UIEvent,
|
||||||
useEffect,
|
useEffect,
|
||||||
@ -30,176 +29,37 @@ import React, {
|
|||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
||||||
import AntdSelect, {
|
import { LabeledValue as AntdLabeledValue } from 'antd/lib/select';
|
||||||
SelectProps as AntdSelectProps,
|
|
||||||
SelectValue as AntdSelectValue,
|
|
||||||
LabeledValue as AntdLabeledValue,
|
|
||||||
} from 'antd/lib/select';
|
|
||||||
import { DownOutlined, SearchOutlined } from '@ant-design/icons';
|
|
||||||
import { Spin } from 'antd';
|
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import Icons from 'src/components/Icons';
|
import Icons from 'src/components/Icons';
|
||||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||||
import { SLOW_DEBOUNCE } from 'src/constants';
|
import { SLOW_DEBOUNCE } from 'src/constants';
|
||||||
import { rankedSearchCompare } from 'src/utils/rankedSearchCompare';
|
import {
|
||||||
import { getValue, hasOption, isLabeledValue } from './utils';
|
getValue,
|
||||||
|
hasOption,
|
||||||
const { Option } = AntdSelect;
|
isLabeledValue,
|
||||||
|
DEFAULT_SORT_COMPARATOR,
|
||||||
type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>;
|
EMPTY_OPTIONS,
|
||||||
|
MAX_TAG_COUNT,
|
||||||
type PickedSelectProps = Pick<
|
SelectOptionsPagePromise,
|
||||||
AntdSelectAllProps,
|
SelectOptionsType,
|
||||||
| 'allowClear'
|
SelectOptionsTypePage,
|
||||||
| 'autoFocus'
|
StyledCheckOutlined,
|
||||||
| 'disabled'
|
StyledStopOutlined,
|
||||||
| 'filterOption'
|
TOKEN_SEPARATORS,
|
||||||
| 'loading'
|
renderSelectOptions,
|
||||||
| 'notFoundContent'
|
StyledContainer,
|
||||||
| 'onChange'
|
StyledSelect,
|
||||||
| 'onClear'
|
hasCustomLabels,
|
||||||
| 'onFocus'
|
BaseSelectProps,
|
||||||
| 'onBlur'
|
sortSelectedFirstHelper,
|
||||||
| 'onDropdownVisibleChange'
|
sortComparatorWithSearchHelper,
|
||||||
| 'placeholder'
|
sortComparatorForNoSearchHelper,
|
||||||
| 'showSearch'
|
getSuffixIcon,
|
||||||
| 'tokenSeparators'
|
dropDownRenderHelper,
|
||||||
| 'value'
|
handleFilterOptionHelper,
|
||||||
| 'getPopupContainer'
|
} from './utils';
|
||||||
>;
|
|
||||||
|
|
||||||
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;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const StyledError = styled.div`
|
const StyledError = styled.div`
|
||||||
${({ theme }) => `
|
${({ theme }) => `
|
||||||
@ -220,32 +80,44 @@ const StyledErrorMessage = styled.div`
|
|||||||
text-overflow: ellipsis;
|
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 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 }) => (
|
const Error = ({ error }: { error: string }) => (
|
||||||
<StyledError>
|
<StyledError>
|
||||||
@ -253,42 +125,6 @@ const Error = ({ error }: { error: string }) => (
|
|||||||
</StyledError>
|
</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) =>
|
const getQueryCacheKey = (value: string, page: number, pageSize: number) =>
|
||||||
`${value};${page};${pageSize}`;
|
`${value};${page};${pageSize}`;
|
||||||
|
|
||||||
@ -359,23 +195,30 @@ const AsyncSelect = forwardRef(
|
|||||||
|
|
||||||
const sortSelectedFirst = useCallback(
|
const sortSelectedFirst = useCallback(
|
||||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||||
selectValue && a.value !== undefined && b.value !== undefined
|
sortSelectedFirstHelper(a, b, selectValue),
|
||||||
? Number(hasOption(b.value, selectValue)) -
|
|
||||||
Number(hasOption(a.value, selectValue))
|
|
||||||
: 0,
|
|
||||||
[selectValue],
|
[selectValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortComparatorWithSearch = useCallback(
|
const sortComparatorWithSearch = useCallback(
|
||||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||||
sortSelectedFirst(a, b) || sortComparator(a, b, inputValue),
|
sortComparatorWithSearchHelper(
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
inputValue,
|
||||||
|
sortSelectedFirst,
|
||||||
|
sortComparator,
|
||||||
|
),
|
||||||
[inputValue, sortComparator, sortSelectedFirst],
|
[inputValue, sortComparator, sortSelectedFirst],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortComparatorForNoSearch = useCallback(
|
const sortComparatorForNoSearch = useCallback(
|
||||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||||
sortSelectedFirst(a, b) ||
|
sortComparatorForNoSearchHelper(
|
||||||
// Only apply the custom sorter in async mode because we should
|
a,
|
||||||
// preserve the options order as much as possible.
|
b,
|
||||||
sortComparator(a, b, ''),
|
sortSelectedFirst,
|
||||||
|
sortComparator,
|
||||||
|
),
|
||||||
[sortComparator, sortSelectedFirst],
|
[sortComparator, sortSelectedFirst],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -390,11 +233,11 @@ const AsyncSelect = forwardRef(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [selectOptions, setSelectOptions] =
|
const [selectOptions, setSelectOptions] =
|
||||||
useState<OptionsType>(initialOptionsSorted);
|
useState<SelectOptionsType>(initialOptionsSorted);
|
||||||
|
|
||||||
// add selected values to options list if they are not in it
|
// add selected values to options list if they are not in it
|
||||||
const fullSelectOptions = useMemo(() => {
|
const fullSelectOptions = useMemo(() => {
|
||||||
const missingValues: OptionsType = ensureIsArray(selectValue)
|
const missingValues: SelectOptionsType = ensureIsArray(selectValue)
|
||||||
.filter(opt => !hasOption(getValue(opt), selectOptions))
|
.filter(opt => !hasOption(getValue(opt), selectOptions))
|
||||||
.map(opt =>
|
.map(opt =>
|
||||||
isLabeledValue(opt) ? opt : { value: opt, label: String(opt) },
|
isLabeledValue(opt) ? opt : { value: opt, label: String(opt) },
|
||||||
@ -404,8 +247,6 @@ const AsyncSelect = forwardRef(
|
|||||||
: selectOptions;
|
: selectOptions;
|
||||||
}, [selectOptions, selectValue]);
|
}, [selectOptions, selectValue]);
|
||||||
|
|
||||||
const hasCustomLabels = fullSelectOptions.some(opt => !!opt?.customLabel);
|
|
||||||
|
|
||||||
const handleOnSelect = (
|
const handleOnSelect = (
|
||||||
selectedItem: string | number | AntdLabeledValue | undefined,
|
selectedItem: string | number | AntdLabeledValue | undefined,
|
||||||
) => {
|
) => {
|
||||||
@ -459,8 +300,8 @@ const AsyncSelect = forwardRef(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const mergeData = useCallback(
|
const mergeData = useCallback(
|
||||||
(data: OptionsType) => {
|
(data: SelectOptionsType) => {
|
||||||
let mergedData: OptionsType = [];
|
let mergedData: SelectOptionsType = [];
|
||||||
if (data && Array.isArray(data) && data.length) {
|
if (data && Array.isArray(data) && data.length) {
|
||||||
// unique option values should always be case sensitive so don't lowercase
|
// unique option values should always be case sensitive so don't lowercase
|
||||||
const dataValues = new Set(data.map(opt => opt.value));
|
const dataValues = new Set(data.map(opt => opt.value));
|
||||||
@ -493,9 +334,9 @@ const AsyncSelect = forwardRef(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const fetchOptions = options as OptionsPagePromise;
|
const fetchOptions = options as SelectOptionsPagePromise;
|
||||||
fetchOptions(search, page, pageSize)
|
fetchOptions(search, page, pageSize)
|
||||||
.then(({ data, totalCount }: OptionsTypePage) => {
|
.then(({ data, totalCount }: SelectOptionsTypePage) => {
|
||||||
const mergedData = mergeData(data);
|
const mergedData = mergeData(data);
|
||||||
fetchedQueries.current.set(key, totalCount);
|
fetchedQueries.current.set(key, totalCount);
|
||||||
setTotalCount(totalCount);
|
setTotalCount(totalCount);
|
||||||
@ -569,25 +410,8 @@ const AsyncSelect = forwardRef(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterOption = (search: string, option: AntdLabeledValue) => {
|
const handleFilterOption = (search: string, option: AntdLabeledValue) =>
|
||||||
if (typeof filterOption === 'function') {
|
handleFilterOptionHelper(search, option, optionFilterProps, filterOption);
|
||||||
return filterOption(search, option);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterOption) {
|
|
||||||
const searchValue = search.trim().toLowerCase();
|
|
||||||
if (optionFilterProps && optionFilterProps.length) {
|
|
||||||
return optionFilterProps.some(prop => {
|
|
||||||
const optionProp = option?.[prop]
|
|
||||||
? String(option[prop]).trim().toLowerCase()
|
|
||||||
: '';
|
|
||||||
return optionProp.includes(searchValue);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
||||||
setIsDropdownVisible(isDropdownVisible);
|
setIsDropdownVisible(isDropdownVisible);
|
||||||
@ -624,36 +448,15 @@ const AsyncSelect = forwardRef(
|
|||||||
|
|
||||||
const dropdownRender = (
|
const dropdownRender = (
|
||||||
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
||||||
) => {
|
) =>
|
||||||
if (!isDropdownVisible) {
|
dropDownRenderHelper(
|
||||||
originNode.ref?.current?.scrollTo({ top: 0 });
|
originNode,
|
||||||
}
|
isDropdownVisible,
|
||||||
if (isLoading && fullSelectOptions.length === 0) {
|
isLoading,
|
||||||
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
fullSelectOptions.length,
|
||||||
}
|
helperText,
|
||||||
return error ? (
|
error ? <Error error={error} /> : undefined,
|
||||||
<Error error={error} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{helperText && (
|
|
||||||
<StyledHelperText role="note">{helperText}</StyledHelperText>
|
|
||||||
)}
|
|
||||||
{originNode}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
// use a function instead of component since every rerender of the
|
|
||||||
// Select component will create a new component
|
|
||||||
const getSuffixIcon = () => {
|
|
||||||
if (isLoading) {
|
|
||||||
return <StyledSpin size="small" />;
|
|
||||||
}
|
|
||||||
if (showSearch && isDropdownVisible) {
|
|
||||||
return <SearchOutlined />;
|
|
||||||
}
|
|
||||||
return <DownOutlined />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setSelectValue(undefined);
|
setSelectValue(undefined);
|
||||||
@ -709,6 +512,10 @@ const AsyncSelect = forwardRef(
|
|||||||
[ref],
|
[ref],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
{header}
|
{header}
|
||||||
@ -732,13 +539,15 @@ const AsyncSelect = forwardRef(
|
|||||||
onSelect={handleOnSelect}
|
onSelect={handleOnSelect}
|
||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={hasCustomLabels ? undefined : fullSelectOptions}
|
options={
|
||||||
|
hasCustomLabels(fullSelectOptions) ? undefined : fullSelectOptions
|
||||||
|
}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
showSearch={showSearch}
|
showSearch={showSearch}
|
||||||
showArrow
|
showArrow
|
||||||
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
|
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
|
||||||
value={selectValue}
|
value={selectValue}
|
||||||
suffixIcon={getSuffixIcon()}
|
suffixIcon={getSuffixIcon(isLoading, showSearch, isDropdownVisible)}
|
||||||
menuItemSelectedIcon={
|
menuItemSelectedIcon={
|
||||||
invertSelection ? (
|
invertSelection ? (
|
||||||
<StyledStopOutlined iconSize="m" />
|
<StyledStopOutlined iconSize="m" />
|
||||||
@ -746,21 +555,11 @@ const AsyncSelect = forwardRef(
|
|||||||
<StyledCheckOutlined iconSize="m" />
|
<StyledCheckOutlined iconSize="m" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ref={ref}
|
|
||||||
{...props}
|
{...props}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{hasCustomLabels &&
|
{hasCustomLabels(fullSelectOptions) &&
|
||||||
fullSelectOptions.map(opt => {
|
renderSelectOptions(fullSelectOptions)}
|
||||||
const isOptObject = typeof opt === 'object';
|
|
||||||
const label = isOptObject ? opt?.label || opt.value : opt;
|
|
||||||
const value = isOptObject ? opt.value : opt;
|
|
||||||
const { customLabel, ...optProps } = opt;
|
|
||||||
return (
|
|
||||||
<Option {...optProps} key={value} label={label} value={value}>
|
|
||||||
{isOptObject && customLabel ? customLabel : label}
|
|
||||||
</Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</StyledSelect>
|
</StyledSelect>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
|
@ -25,13 +25,10 @@ import React, {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import Button from 'src/components/Button';
|
import Button from 'src/components/Button';
|
||||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||||
import AsyncSelect, {
|
import AsyncSelect, { AsyncSelectProps, AsyncSelectRef } from './AsyncSelect';
|
||||||
AsyncSelectProps,
|
import { SelectOptionsType, SelectOptionsTypePage } from './utils';
|
||||||
AsyncSelectRef,
|
|
||||||
OptionsTypePage,
|
|
||||||
} from './AsyncSelect';
|
|
||||||
|
|
||||||
import Select, { SelectProps, OptionsType } from './Select';
|
import Select, { SelectProps } from './Select';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Select',
|
title: 'Select',
|
||||||
@ -40,7 +37,7 @@ export default {
|
|||||||
|
|
||||||
const DEFAULT_WIDTH = 200;
|
const DEFAULT_WIDTH = 200;
|
||||||
|
|
||||||
const options: OptionsType = [
|
const options: SelectOptionsType = [
|
||||||
{
|
{
|
||||||
label: 'Such an incredibly awesome long long label',
|
label: 'Such an incredibly awesome long long label',
|
||||||
value: '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;
|
return header;
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateOptions = (opts: OptionsType, count: number) => {
|
const generateOptions = (opts: SelectOptionsType, count: number) => {
|
||||||
let generated = opts.slice();
|
let generated = opts.slice();
|
||||||
let iteration = 0;
|
let iteration = 0;
|
||||||
while (generated.length < count) {
|
while (generated.length < count) {
|
||||||
@ -440,7 +437,7 @@ export const AsynchronousSelect = ({
|
|||||||
search: string,
|
search: string,
|
||||||
page: number,
|
page: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
): Promise<OptionsTypePage> => {
|
): Promise<SelectOptionsTypePage> => {
|
||||||
const username = search.trim().toLowerCase();
|
const username = search.trim().toLowerCase();
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let results = getResults(username);
|
let results = getResults(username);
|
||||||
@ -458,7 +455,7 @@ export const AsynchronousSelect = ({
|
|||||||
[responseTime],
|
[responseTime],
|
||||||
);
|
);
|
||||||
|
|
||||||
const fetchUserListError = async (): Promise<OptionsTypePage> =>
|
const fetchUserListError = async (): Promise<SelectOptionsTypePage> =>
|
||||||
new Promise((_, reject) => {
|
new Promise((_, reject) => {
|
||||||
reject(new Error('Error while fetching the names from the server'));
|
reject(new Error('Error while fetching the names from the server'));
|
||||||
});
|
});
|
||||||
|
@ -19,207 +19,48 @@
|
|||||||
import React, {
|
import React, {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
ReactNode,
|
|
||||||
RefObject,
|
RefObject,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
import { ensureIsArray, t } from '@superset-ui/core';
|
||||||
import AntdSelect, {
|
import { LabeledValue as AntdLabeledValue } from 'antd/lib/select';
|
||||||
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 { isEqual } from 'lodash';
|
import { isEqual } from 'lodash';
|
||||||
import Icons from 'src/components/Icons';
|
import {
|
||||||
import { rankedSearchCompare } from 'src/utils/rankedSearchCompare';
|
getValue,
|
||||||
import { getValue, hasOption, isLabeledValue } from './utils';
|
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;
|
export interface SelectProps extends BaseSelectProps {
|
||||||
|
|
||||||
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[];
|
|
||||||
/**
|
/**
|
||||||
* It defines the options of the Select.
|
* It defines the options of the Select.
|
||||||
* The options can be static, an array of options.
|
* The options can be static, an array of options.
|
||||||
* The options can also be async, a promise that returns
|
* The options can also be async, a promise that returns
|
||||||
* an array of options.
|
* an array of options.
|
||||||
*/
|
*/
|
||||||
options: OptionsType;
|
options: SelectOptionsType;
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
* This component is a customized version of the Antdesign 4.X Select component
|
||||||
* https://ant.design/components/select/.
|
* https://ant.design/components/select/.
|
||||||
@ -278,15 +119,18 @@ const Select = forwardRef(
|
|||||||
|
|
||||||
const sortSelectedFirst = useCallback(
|
const sortSelectedFirst = useCallback(
|
||||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||||
selectValue && a.value !== undefined && b.value !== undefined
|
sortSelectedFirstHelper(a, b, selectValue),
|
||||||
? Number(hasOption(b.value, selectValue)) -
|
|
||||||
Number(hasOption(a.value, selectValue))
|
|
||||||
: 0,
|
|
||||||
[selectValue],
|
[selectValue],
|
||||||
);
|
);
|
||||||
const sortComparatorWithSearch = useCallback(
|
const sortComparatorWithSearch = useCallback(
|
||||||
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
(a: AntdLabeledValue, b: AntdLabeledValue) =>
|
||||||
sortSelectedFirst(a, b) || sortComparator(a, b, inputValue),
|
sortComparatorWithSearchHelper(
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
inputValue,
|
||||||
|
sortSelectedFirst,
|
||||||
|
sortComparator,
|
||||||
|
),
|
||||||
[inputValue, sortComparator, sortSelectedFirst],
|
[inputValue, sortComparator, sortSelectedFirst],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -301,11 +145,11 @@ const Select = forwardRef(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [selectOptions, setSelectOptions] =
|
const [selectOptions, setSelectOptions] =
|
||||||
useState<OptionsType>(initialOptionsSorted);
|
useState<SelectOptionsType>(initialOptionsSorted);
|
||||||
|
|
||||||
// add selected values to options list if they are not in it
|
// add selected values to options list if they are not in it
|
||||||
const fullSelectOptions = useMemo(() => {
|
const fullSelectOptions = useMemo(() => {
|
||||||
const missingValues: OptionsType = ensureIsArray(selectValue)
|
const missingValues: SelectOptionsType = ensureIsArray(selectValue)
|
||||||
.filter(opt => !hasOption(getValue(opt), selectOptions))
|
.filter(opt => !hasOption(getValue(opt), selectOptions))
|
||||||
.map(opt =>
|
.map(opt =>
|
||||||
isLabeledValue(opt) ? opt : { value: opt, label: String(opt) },
|
isLabeledValue(opt) ? opt : { value: opt, label: String(opt) },
|
||||||
@ -315,8 +159,6 @@ const Select = forwardRef(
|
|||||||
: selectOptions;
|
: selectOptions;
|
||||||
}, [selectOptions, selectValue]);
|
}, [selectOptions, selectValue]);
|
||||||
|
|
||||||
const hasCustomLabels = fullSelectOptions.some(opt => !!opt?.customLabel);
|
|
||||||
|
|
||||||
const handleOnSelect = (
|
const handleOnSelect = (
|
||||||
selectedItem: string | number | AntdLabeledValue | undefined,
|
selectedItem: string | number | AntdLabeledValue | undefined,
|
||||||
) => {
|
) => {
|
||||||
@ -376,25 +218,8 @@ const Select = forwardRef(
|
|||||||
setInputValue(search);
|
setInputValue(search);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilterOption = (search: string, option: AntdLabeledValue) => {
|
const handleFilterOption = (search: string, option: AntdLabeledValue) =>
|
||||||
if (typeof filterOption === 'function') {
|
handleFilterOptionHelper(search, option, optionFilterProps, filterOption);
|
||||||
return filterOption(search, option);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterOption) {
|
|
||||||
const searchValue = search.trim().toLowerCase();
|
|
||||||
if (optionFilterProps && optionFilterProps.length) {
|
|
||||||
return optionFilterProps.some(prop => {
|
|
||||||
const optionProp = option?.[prop]
|
|
||||||
? String(option[prop]).trim().toLowerCase()
|
|
||||||
: '';
|
|
||||||
return optionProp.includes(searchValue);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
|
||||||
setIsDropdownVisible(isDropdownVisible);
|
setIsDropdownVisible(isDropdownVisible);
|
||||||
@ -413,34 +238,14 @@ const Select = forwardRef(
|
|||||||
|
|
||||||
const dropdownRender = (
|
const dropdownRender = (
|
||||||
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
||||||
) => {
|
) =>
|
||||||
if (!isDropdownVisible) {
|
dropDownRenderHelper(
|
||||||
originNode.ref?.current?.scrollTo({ top: 0 });
|
originNode,
|
||||||
}
|
isDropdownVisible,
|
||||||
if (isLoading && fullSelectOptions.length === 0) {
|
isLoading,
|
||||||
return <StyledLoadingText>{t('Loading...')}</StyledLoadingText>;
|
fullSelectOptions.length,
|
||||||
}
|
helperText,
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{helperText && (
|
|
||||||
<StyledHelperText role="note">{helperText}</StyledHelperText>
|
|
||||||
)}
|
|
||||||
{originNode}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
// use a function instead of component since every rerender of the
|
|
||||||
// Select component will create a new component
|
|
||||||
const getSuffixIcon = () => {
|
|
||||||
if (isLoading) {
|
|
||||||
return <StyledSpin size="small" />;
|
|
||||||
}
|
|
||||||
if (shouldShowSearch && isDropdownVisible) {
|
|
||||||
return <SearchOutlined />;
|
|
||||||
}
|
|
||||||
return <DownOutlined />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setSelectValue(undefined);
|
setSelectValue(undefined);
|
||||||
@ -454,16 +259,16 @@ const Select = forwardRef(
|
|||||||
setSelectOptions(initialOptions);
|
setSelectOptions(initialOptions);
|
||||||
}, [initialOptions]);
|
}, [initialOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectValue(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading !== undefined && loading !== isLoading) {
|
if (loading !== undefined && loading !== isLoading) {
|
||||||
setIsLoading(loading);
|
setIsLoading(loading);
|
||||||
}
|
}
|
||||||
}, [isLoading, loading]);
|
}, [isLoading, loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer>
|
<StyledContainer>
|
||||||
{header}
|
{header}
|
||||||
@ -487,13 +292,17 @@ const Select = forwardRef(
|
|||||||
onSelect={handleOnSelect}
|
onSelect={handleOnSelect}
|
||||||
onClear={handleClear}
|
onClear={handleClear}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
options={hasCustomLabels ? undefined : fullSelectOptions}
|
options={hasCustomLabels(options) ? undefined : fullSelectOptions}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
showSearch={shouldShowSearch}
|
showSearch={shouldShowSearch}
|
||||||
showArrow
|
showArrow
|
||||||
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
|
tokenSeparators={tokenSeparators || TOKEN_SEPARATORS}
|
||||||
value={selectValue}
|
value={selectValue}
|
||||||
suffixIcon={getSuffixIcon()}
|
suffixIcon={getSuffixIcon(
|
||||||
|
isLoading,
|
||||||
|
shouldShowSearch,
|
||||||
|
isDropdownVisible,
|
||||||
|
)}
|
||||||
menuItemSelectedIcon={
|
menuItemSelectedIcon={
|
||||||
invertSelection ? (
|
invertSelection ? (
|
||||||
<StyledStopOutlined iconSize="m" />
|
<StyledStopOutlined iconSize="m" />
|
||||||
@ -501,21 +310,10 @@ const Select = forwardRef(
|
|||||||
<StyledCheckOutlined iconSize="m" />
|
<StyledCheckOutlined iconSize="m" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ref={ref}
|
|
||||||
{...props}
|
{...props}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{hasCustomLabels &&
|
{hasCustomLabels(options) && renderSelectOptions(fullSelectOptions)}
|
||||||
fullSelectOptions.map(opt => {
|
|
||||||
const isOptObject = typeof opt === 'object';
|
|
||||||
const label = isOptObject ? opt?.label || opt.value : opt;
|
|
||||||
const value = isOptObject ? opt.value : opt;
|
|
||||||
const { customLabel, ...optProps } = opt;
|
|
||||||
return (
|
|
||||||
<Option {...optProps} key={value} label={label} value={value}>
|
|
||||||
{isOptObject && customLabel ? customLabel : label}
|
|
||||||
</Option>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</StyledSelect>
|
</StyledSelect>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
);
|
);
|
||||||
|
@ -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
|
|
||||||
);
|
|
||||||
}
|
|
443
superset-frontend/src/components/Select/utils.tsx
Normal file
443
superset-frontend/src/components/Select/utils.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
@ -17,13 +17,14 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React from 'react';
|
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 { t, styled } from '@superset-ui/core';
|
||||||
import Alert from 'src/components/Alert';
|
import Alert from 'src/components/Alert';
|
||||||
import Button from 'src/components/Button';
|
import Button from 'src/components/Button';
|
||||||
|
|
||||||
import ModalTrigger, { ModalTriggerRef } from 'src/components/ModalTrigger';
|
import ModalTrigger, { ModalTriggerRef } from 'src/components/ModalTrigger';
|
||||||
import { FormLabel } from 'src/components/Form';
|
import { FormLabel } from 'src/components/Form';
|
||||||
|
import { propertyComparator } from 'src/components/Select/utils';
|
||||||
|
|
||||||
export const options = [
|
export const options = [
|
||||||
[0, t("Don't refresh")],
|
[0, t("Don't refresh")],
|
||||||
|
@ -20,7 +20,8 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { t, SupersetClient } from '@superset-ui/core';
|
import { t, SupersetClient } from '@superset-ui/core';
|
||||||
import ControlHeader from 'src/explore/components/ControlHeader';
|
import ControlHeader from 'src/explore/components/ControlHeader';
|
||||||
import { Select } from 'src/components';
|
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 { SelectValue, LabeledValue } from 'antd/lib/select';
|
||||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||||
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
|
||||||
@ -32,7 +33,7 @@ interface SelectAsyncControlProps extends SelectAsyncProps {
|
|||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
dataEndpoint: string;
|
dataEndpoint: string;
|
||||||
default?: SelectValue;
|
default?: SelectValue;
|
||||||
mutator?: (response: Record<string, any>) => OptionsType;
|
mutator?: (response: Record<string, any>) => SelectOptionsType;
|
||||||
multi?: boolean;
|
multi?: boolean;
|
||||||
onChange: (val: SelectValue) => void;
|
onChange: (val: SelectValue) => void;
|
||||||
// ControlHeader related props
|
// ControlHeader related props
|
||||||
@ -57,7 +58,7 @@ const SelectAsyncControl = ({
|
|||||||
value,
|
value,
|
||||||
...props
|
...props
|
||||||
}: SelectAsyncControlProps) => {
|
}: SelectAsyncControlProps) => {
|
||||||
const [options, setOptions] = useState<OptionsType>([]);
|
const [options, setOptions] = useState<SelectOptionsType>([]);
|
||||||
|
|
||||||
const handleOnChange = (val: SelectValue) => {
|
const handleOnChange = (val: SelectValue) => {
|
||||||
let onChangeVal = val;
|
let onChangeVal = val;
|
||||||
|
@ -36,7 +36,7 @@ import { Select } from 'src/components';
|
|||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { SLOW_DEBOUNCE } from 'src/constants';
|
import { SLOW_DEBOUNCE } from 'src/constants';
|
||||||
import { useImmerReducer } from 'use-immer';
|
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 { PluginFilterSelectProps, SelectValue } from './types';
|
||||||
import { StyledFormItem, FilterPluginStyle, StatusMessage } from '../common';
|
import { StyledFormItem, FilterPluginStyle, StatusMessage } from '../common';
|
||||||
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
|
import { getDataRecordFormatter, getSelectExtraFormData } from '../../utils';
|
||||||
|
@ -38,7 +38,7 @@ import { Switch } from 'src/components/Switch';
|
|||||||
import Modal from 'src/components/Modal';
|
import Modal from 'src/components/Modal';
|
||||||
import TimezoneSelector from 'src/components/TimezoneSelector';
|
import TimezoneSelector from 'src/components/TimezoneSelector';
|
||||||
import { Radio } from 'src/components/Radio';
|
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 { FeatureFlag, isFeatureEnabled } from 'src/featureFlags';
|
||||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||||
import Owner from 'src/types/Owner';
|
import Owner from 'src/types/Owner';
|
||||||
|
Loading…
Reference in New Issue
Block a user