From 4fcc1d952f69ba35fed538a24d88f06317621ca6 Mon Sep 17 00:00:00 2001 From: cccs-RyanK <102618419+cccs-RyanK@users.noreply.github.com> Date: Thu, 15 Sep 2022 08:57:37 -0400 Subject: [PATCH] chore: Extract common select component code (#21094) --- .../src/components/Select/AsyncSelect.tsx | 385 ++++----------- .../src/components/Select/Select.stories.tsx | 17 +- .../src/components/Select/Select.tsx | 318 +++---------- .../src/components/Select/utils.ts | 99 ---- .../src/components/Select/utils.tsx | 443 ++++++++++++++++++ .../components/RefreshIntervalModal.tsx | 3 +- .../controls/SelectAsyncControl/index.tsx | 7 +- .../components/Select/SelectFilterPlugin.tsx | 2 +- .../src/views/CRUD/alert/AlertReportModal.tsx | 2 +- 9 files changed, 608 insertions(+), 668 deletions(-) delete mode 100644 superset-frontend/src/components/Select/utils.ts create mode 100644 superset-frontend/src/components/Select/utils.tsx diff --git a/superset-frontend/src/components/Select/AsyncSelect.tsx b/superset-frontend/src/components/Select/AsyncSelect.tsx index beeb7b262f62..e3118f228498 100644 --- a/superset-frontend/src/components/Select/AsyncSelect.tsx +++ b/superset-frontend/src/components/Select/AsyncSelect.tsx @@ -19,7 +19,6 @@ import React, { forwardRef, ReactElement, - ReactNode, RefObject, UIEvent, useEffect, @@ -30,83 +29,62 @@ 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; - -type PickedSelectProps = Pick< - AntdSelectAllProps, - | 'allowClear' - | 'autoFocus' - | 'disabled' - | 'filterOption' - | 'loading' - | 'notFoundContent' - | 'onChange' - | 'onClear' - | 'onFocus' - | 'onBlur' - | 'onDropdownVisibleChange' - | 'placeholder' - | 'showSearch' - | 'tokenSeparators' - | 'value' - | 'getPopupContainer' ->; - -export type OptionsType = Exclude; - -export type OptionsTypePage = { - data: OptionsType; - totalCount: number; -}; - -export type OptionsPagePromise = ( - search: string, - page: number, - pageSize: number, -) => Promise; +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 }) => ` + display: flex; + justify-content: center; + align-items: flex-start; + width: 100%; + padding: ${theme.gridUnit * 2}px; + color: ${theme.colors.error.base}; + & svg { + margin-right: ${theme.gridUnit * 2}px; + } + `} +`; + +const StyledErrorMessage = styled.div` + overflow: hidden; + text-overflow: ellipsis; +`; + +const DEFAULT_PAGE_SIZE = 100; 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; +export interface AsyncSelectProps extends BaseSelectProps { /** * It fires a request against the server after * the first interaction and not on render. @@ -114,43 +92,18 @@ export interface AsyncSelectProps extends PickedSelectProps { * 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; + 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 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. @@ -164,131 +117,14 @@ export interface AsyncSelectProps extends PickedSelectProps { * 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` - ${({ theme }) => ` - display: flex; - justify-content: center; - align-items: flex-start; - width: 100%; - padding: ${theme.gridUnit * 2}px; - color: ${theme.colors.error.base}; - & svg { - margin-right: ${theme.gridUnit * 2}px; - } - `} -`; - -const StyledErrorMessage = styled.div` - overflow: hidden; - text-overflow: ellipsis; -`; - -const StyledSpin = styled(Spin)` - margin-top: ${({ theme }) => -theme.gridUnit}px; -`; - -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 = []; - const Error = ({ error }: { error: string }) => ( {error} ); -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(initialOptionsSorted); + useState(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 }, - ) => { - if (!isDropdownVisible) { - originNode.ref?.current?.scrollTo({ top: 0 }); - } - if (isLoading && fullSelectOptions.length === 0) { - return {t('Loading...')}; - } - return error ? ( - - ) : ( - <> - {helperText && ( - {helperText} - )} - {originNode} - + ) => + dropDownRenderHelper( + originNode, + isDropdownVisible, + isLoading, + fullSelectOptions.length, + helperText, + 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 ; - } - if (showSearch && isDropdownVisible) { - return ; - } - return ; - }; const handleClear = () => { setSelectValue(undefined); @@ -709,6 +512,10 @@ const AsyncSelect = forwardRef( [ref], ); + useEffect(() => { + setSelectValue(value); + }, [value]); + return ( {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 ? ( @@ -746,21 +555,11 @@ const AsyncSelect = forwardRef( ) } - 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 ( - - ); - })} + {hasCustomLabels(fullSelectOptions) && + renderSelectOptions(fullSelectOptions)} ); diff --git a/superset-frontend/src/components/Select/Select.stories.tsx b/superset-frontend/src/components/Select/Select.stories.tsx index b75e1ff28bd0..e9a03fe5634b 100644 --- a/superset-frontend/src/components/Select/Select.stories.tsx +++ b/superset-frontend/src/components/Select/Select.stories.tsx @@ -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 => { + ): Promise => { 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 => + const fetchUserListError = async (): Promise => new Promise((_, reject) => { reject(new Error('Error while fetching the names from the server')); }); diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/Select.tsx index e668682b5dde..6b6713852d54 100644 --- a/superset-frontend/src/components/Select/Select.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -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'; - -const { Option } = AntdSelect; - -type AntdSelectAllProps = AntdSelectProps; - -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; - -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[]; +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'; + +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(initialOptionsSorted); + useState(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 }, - ) => { - if (!isDropdownVisible) { - originNode.ref?.current?.scrollTo({ top: 0 }); - } - if (isLoading && fullSelectOptions.length === 0) { - return {t('Loading...')}; - } - return ( - <> - {helperText && ( - {helperText} - )} - {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 ; - } - if (shouldShowSearch && isDropdownVisible) { - return ; - } - return ; - }; 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 ( {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 ? ( @@ -501,21 +310,10 @@ const Select = forwardRef( ) } - 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 ( - - ); - })} + {hasCustomLabels(options) && renderSelectOptions(fullSelectOptions)} ); diff --git a/superset-frontend/src/components/Select/utils.ts b/superset-frontend/src/components/Select/utils.ts deleted file mode 100644 index 9836b9ddd2ea..000000000000 --- a/superset-frontend/src/components/Select/utils.ts +++ /dev/null @@ -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 { - 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( - value: ValueType | string, - options: GroupedOptionsType | OptionsType = [], - valueKey = 'value', -): OptionType[] { - if (value === null || value === undefined || value === '') { - return []; - } - const isGroup = Array.isArray((options[0] || {}).options); - const flatOptions = isGroup - ? (options as GroupedOptionsType).flatMap(x => x.options || []) - : (options as OptionsType); - - 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 = { label?: ReactNode; value?: V }; - -export function hasOption( - value: V, - options?: V | LabeledValue | (V | LabeledValue)[], - 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 - ); -} diff --git a/superset-frontend/src/components/Select/utils.tsx b/superset-frontend/src/components/Select/utils.tsx new file mode 100644 index 000000000000..9916ef07e269 --- /dev/null +++ b/superset-frontend/src/components/Select/utils.tsx @@ -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 { + 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( + value: ValueType | string, + options: GroupedOptionsType | OptionsType = [], + valueKey = 'value', +): OptionType[] { + if (value === null || value === undefined || value === '') { + return []; + } + const isGroup = Array.isArray((options[0] || {}).options); + const flatOptions = isGroup + ? (options as GroupedOptionsType).flatMap(x => x.options || []) + : (options as OptionsType); + + 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 = { label?: ReactNode; value?: V }; + +export function hasOption( + value: V, + options?: V | LabeledValue | (V | LabeledValue)[], + 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; + +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; + +export type SelectOptionsTypePage = { + data: SelectOptionsType; + totalCount: number; +}; + +export type SelectOptionsPagePromise = ( + search: string, + page: number, + pageSize: number, +) => Promise; + +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 ; + } + if (showSearch && isDropdownVisible) { + return ; + } + return ; +}; + +export const dropDownRenderHelper = ( + originNode: ReactElement & { ref?: RefObject }, + 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 {t('Loading...')}; + } + if (errorComponent) { + return errorComponent; + } + return ( + <> + {helperText && ( + {helperText} + )} + {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>, + ) => ReactElement>; + /** + * 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; +} + +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 ( + + ); + }); diff --git a/superset-frontend/src/dashboard/components/RefreshIntervalModal.tsx b/superset-frontend/src/dashboard/components/RefreshIntervalModal.tsx index 54d11bba1458..98b763ed859b 100644 --- a/superset-frontend/src/dashboard/components/RefreshIntervalModal.tsx +++ b/superset-frontend/src/dashboard/components/RefreshIntervalModal.tsx @@ -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")], diff --git a/superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx b/superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx index 66d9fb154eb8..74364fcf981a 100644 --- a/superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx @@ -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) => OptionsType; + mutator?: (response: Record) => SelectOptionsType; multi?: boolean; onChange: (val: SelectValue) => void; // ControlHeader related props @@ -57,7 +58,7 @@ const SelectAsyncControl = ({ value, ...props }: SelectAsyncControlProps) => { - const [options, setOptions] = useState([]); + const [options, setOptions] = useState([]); const handleOnChange = (val: SelectValue) => { let onChangeVal = val; diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index 936c6d548a67..d1bb3df747a8 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -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'; diff --git a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx index 820a83b8c337..fe34b22e8032 100644 --- a/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx +++ b/superset-frontend/src/views/CRUD/alert/AlertReportModal.tsx @@ -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';