| /** |
| * 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 React, { |
| ReactElement, |
| ReactNode, |
| RefObject, |
| UIEvent, |
| useEffect, |
| useMemo, |
| useState, |
| useRef, |
| useCallback, |
| } from 'react'; |
| import { 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 debounce from 'lodash/debounce'; |
| import { isEqual } from 'lodash'; |
| import { Spin } from 'antd'; |
| import Icons from 'src/components/Icons'; |
| import { getClientErrorObject } from 'src/utils/getClientErrorObject'; |
| import { hasOption } from './utils'; |
| |
| type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>; |
| |
| type PickedSelectProps = Pick< |
| AntdSelectAllProps, |
| | 'allowClear' |
| | 'autoFocus' |
| | 'value' |
| | 'disabled' |
| | 'filterOption' |
| | 'notFoundContent' |
| | 'onChange' |
| | 'placeholder' |
| | 'showSearch' |
| | 'value' |
| >; |
| |
| 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 interface SelectProps extends PickedSelectProps { |
| allowNewOptions?: boolean; |
| ariaLabel: string; |
| header?: ReactNode; |
| mode?: 'single' | 'multiple'; |
| name?: string; // discourage usage |
| options: OptionsType | OptionsPagePromise; |
| pageSize?: number; |
| invertSelection?: boolean; |
| fetchOnlyOnSearch?: boolean; |
| } |
| |
| const StyledContainer = styled.div` |
| display: flex; |
| flex-direction: column; |
| `; |
| |
| const StyledSelect = styled(AntdSelect, { |
| shouldForwardProp: prop => prop !== 'hasHeader', |
| })<{ hasHeader: boolean }>` |
| ${({ theme, hasHeader }) => ` |
| width: 100%; |
| margin-top: ${hasHeader ? theme.gridUnit : 0}px; |
| |
| && .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; |
| } |
| `} |
| `; |
| |
| 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 StyledSpin = styled(Spin)` |
| margin-top: ${({ theme }) => -theme.gridUnit}px; |
| `; |
| |
| const MAX_TAG_COUNT = 4; |
| const TOKEN_SEPARATORS = [',', '\n', '\t', ';']; |
| const DEBOUNCE_TIMEOUT = 500; |
| const DEFAULT_PAGE_SIZE = 100; |
| const EMPTY_OPTIONS: OptionsType = []; |
| |
| const Error = ({ error }: { error: string }) => ( |
| <StyledError> |
| <Icons.ErrorSolid /> {error} |
| </StyledError> |
| ); |
| |
| const Select = ({ |
| allowNewOptions = false, |
| ariaLabel, |
| fetchOnlyOnSearch, |
| filterOption = true, |
| header = null, |
| invertSelection = false, |
| mode = 'single', |
| name, |
| options, |
| pageSize = DEFAULT_PAGE_SIZE, |
| placeholder = t('Select ...'), |
| showSearch, |
| value, |
| ...props |
| }: SelectProps) => { |
| const isAsync = typeof options === 'function'; |
| const isSingleMode = mode === 'single'; |
| const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch; |
| const initialOptions = |
| options && Array.isArray(options) ? options : EMPTY_OPTIONS; |
| const [selectOptions, setSelectOptions] = useState<OptionsType>( |
| initialOptions, |
| ); |
| const [selectValue, setSelectValue] = useState(value); |
| const [searchedValue, setSearchedValue] = useState(''); |
| const [isLoading, setLoading] = useState(false); |
| const [error, setError] = useState(''); |
| const [isDropdownVisible, setIsDropdownVisible] = useState(false); |
| const [page, setPage] = useState(0); |
| const [totalCount, setTotalCount] = useState(0); |
| const [loadingEnabled, setLoadingEnabled] = useState(false); |
| const fetchedQueries = useRef(new Map<string, number>()); |
| const mappedMode = isSingleMode |
| ? undefined |
| : allowNewOptions |
| ? 'tags' |
| : 'multiple'; |
| |
| useEffect(() => { |
| setSelectOptions( |
| options && Array.isArray(options) ? options : EMPTY_OPTIONS, |
| ); |
| }, [options]); |
| |
| useEffect(() => { |
| if (isAsync && value) { |
| const array: AntdLabeledValue[] = Array.isArray(value) |
| ? (value as AntdLabeledValue[]) |
| : [value as AntdLabeledValue]; |
| const options: AntdLabeledValue[] = []; |
| array.forEach(element => { |
| const found = selectOptions.find( |
| option => option.value === element.value, |
| ); |
| if (!found) { |
| options.push(element); |
| } |
| }); |
| if (options.length > 0) { |
| setSelectOptions([...selectOptions, ...options]); |
| } |
| } |
| }, [isAsync, selectOptions, value]); |
| |
| useEffect(() => { |
| setSelectValue(value); |
| }, [value]); |
| |
| const handleTopOptions = useCallback( |
| (selectedValue: AntdSelectValue | undefined) => { |
| // bringing selected options to the top of the list |
| if (selectedValue) { |
| const topOptions: OptionsType = []; |
| const otherOptions: OptionsType = []; |
| |
| selectOptions.forEach(opt => { |
| let found = false; |
| if (Array.isArray(selectedValue)) { |
| if (isAsync) { |
| found = |
| (selectedValue as AntdLabeledValue[]).find( |
| element => element.value === opt.value, |
| ) !== undefined; |
| } else { |
| found = selectedValue.includes(opt.value); |
| } |
| } else { |
| found = isAsync |
| ? (selectedValue as AntdLabeledValue).value === opt.value |
| : selectedValue === opt.value; |
| } |
| |
| if (found) { |
| topOptions.push(opt); |
| } else { |
| otherOptions.push(opt); |
| } |
| }); |
| |
| // fallback for custom options in tags mode as they |
| // do not appear in the selectOptions state |
| if (!isSingleMode && Array.isArray(selectedValue)) { |
| selectedValue.forEach((val: string | number | AntdLabeledValue) => { |
| if ( |
| !topOptions.find( |
| tOpt => |
| tOpt.value === |
| (isAsync ? (val as AntdLabeledValue)?.value : val), |
| ) |
| ) { |
| if (isAsync) { |
| const labelValue = val as AntdLabeledValue; |
| topOptions.push({ |
| label: labelValue.label, |
| value: labelValue.value, |
| }); |
| } else { |
| const value = val as string | number; |
| topOptions.push({ label: String(value), value }); |
| } |
| } |
| }); |
| } |
| |
| const sortedOptions = [...topOptions, ...otherOptions]; |
| if (!isEqual(sortedOptions, selectOptions)) { |
| setSelectOptions(sortedOptions); |
| } |
| } |
| }, |
| [isAsync, isSingleMode, selectOptions], |
| ); |
| |
| const handleOnSelect = ( |
| selectedValue: string | number | AntdLabeledValue, |
| ) => { |
| if (isSingleMode) { |
| setSelectValue(selectedValue); |
| } else { |
| const currentSelected = selectValue |
| ? Array.isArray(selectValue) |
| ? selectValue |
| : [selectValue] |
| : []; |
| if ( |
| typeof selectedValue === 'number' || |
| typeof selectedValue === 'string' |
| ) { |
| setSelectValue([ |
| ...(currentSelected as (string | number)[]), |
| selectedValue as string | number, |
| ]); |
| } else { |
| setSelectValue([ |
| ...(currentSelected as AntdLabeledValue[]), |
| selectedValue as AntdLabeledValue, |
| ]); |
| } |
| } |
| setSearchedValue(''); |
| }; |
| |
| const handleOnDeselect = (value: string | number | AntdLabeledValue) => { |
| if (Array.isArray(selectValue)) { |
| const selectedValues = [ |
| ...(selectValue as []).filter(opt => opt !== value), |
| ]; |
| setSelectValue(selectedValues); |
| } |
| setSearchedValue(''); |
| }; |
| |
| const onError = (response: Response) => |
| getClientErrorObject(response).then(e => { |
| const { error } = e; |
| setError(error); |
| }); |
| |
| const handleData = (data: OptionsType) => { |
| if (data && Array.isArray(data) && data.length) { |
| // merges with existing and creates unique options |
| setSelectOptions(prevOptions => [ |
| ...prevOptions, |
| ...data.filter( |
| newOpt => |
| !prevOptions.find(prevOpt => prevOpt.value === newOpt.value), |
| ), |
| ]); |
| } |
| }; |
| |
| const handlePaginatedFetch = useMemo( |
| () => (value: string, page: number, pageSize: number) => { |
| const key = `${value};${page};${pageSize}`; |
| const cachedCount = fetchedQueries.current.get(key); |
| if (cachedCount) { |
| setTotalCount(cachedCount); |
| return; |
| } |
| setLoading(true); |
| const fetchOptions = options as OptionsPagePromise; |
| fetchOptions(value, page, pageSize) |
| .then(({ data, totalCount }: OptionsTypePage) => { |
| handleData(data); |
| fetchedQueries.current.set(key, totalCount); |
| setTotalCount(totalCount); |
| }) |
| .catch(onError) |
| .finally(() => setLoading(false)); |
| }, |
| [options], |
| ); |
| |
| const handleOnSearch = debounce((search: string) => { |
| const searchValue = search.trim(); |
| // enables option creation |
| if (allowNewOptions && isSingleMode) { |
| const firstOption = selectOptions.length > 0 && selectOptions[0].value; |
| // replaces the last search value entered with the new one |
| // only when the value wasn't part of the original options |
| if ( |
| searchValue && |
| firstOption === searchedValue && |
| !initialOptions.find(o => o.value === searchedValue) |
| ) { |
| selectOptions.shift(); |
| setSelectOptions(selectOptions); |
| } |
| if (searchValue && !hasOption(searchValue, selectOptions)) { |
| const newOption = { |
| label: searchValue, |
| value: searchValue, |
| }; |
| // adds a custom option |
| const newOptions = [...selectOptions, newOption]; |
| setSelectOptions(newOptions); |
| setSelectValue(searchValue); |
| } |
| } |
| setSearchedValue(searchValue); |
| }, DEBOUNCE_TIMEOUT); |
| |
| const handlePagination = (e: UIEvent<HTMLElement>) => { |
| const vScroll = e.currentTarget; |
| const thresholdReached = |
| vScroll.scrollTop > (vScroll.scrollHeight - vScroll.offsetHeight) * 0.7; |
| const hasMoreData = page * pageSize + pageSize < totalCount; |
| |
| if (!isLoading && isAsync && hasMoreData && thresholdReached) { |
| const newPage = page + 1; |
| handlePaginatedFetch(searchedValue, newPage, pageSize); |
| setPage(newPage); |
| } |
| }; |
| |
| const handleFilterOption = (search: string, option: AntdLabeledValue) => { |
| if (typeof filterOption === 'function') { |
| return filterOption(search, option); |
| } |
| |
| if (filterOption) { |
| const searchValue = search.trim().toLowerCase(); |
| const { value, label } = option; |
| const valueText = String(value); |
| const labelText = String(label); |
| return ( |
| valueText.toLowerCase().includes(searchValue) || |
| labelText.toLowerCase().includes(searchValue) |
| ); |
| } |
| |
| return false; |
| }; |
| |
| const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => { |
| setIsDropdownVisible(isDropdownVisible); |
| |
| if (isAsync && !loadingEnabled) { |
| setLoadingEnabled(true); |
| } |
| |
| // multiple or tags mode keep the dropdown visible while selecting options |
| // this waits for the dropdown to be closed before sorting the top options |
| if (!isSingleMode && !isDropdownVisible) { |
| handleTopOptions(selectValue); |
| } |
| }; |
| |
| useEffect(() => { |
| const allowFetch = !fetchOnlyOnSearch || searchedValue; |
| if (isAsync && loadingEnabled && allowFetch) { |
| const page = 0; |
| handlePaginatedFetch(searchedValue, page, pageSize); |
| setPage(page); |
| } |
| }, [ |
| isAsync, |
| searchedValue, |
| pageSize, |
| handlePaginatedFetch, |
| loadingEnabled, |
| fetchOnlyOnSearch, |
| ]); |
| |
| useEffect(() => { |
| if (isSingleMode) { |
| handleTopOptions(selectValue); |
| } |
| }, [handleTopOptions, isSingleMode, selectValue]); |
| |
| const dropdownRender = ( |
| originNode: ReactElement & { ref?: RefObject<HTMLElement> }, |
| ) => { |
| if (!isDropdownVisible) { |
| originNode.ref?.current?.scrollTo({ top: 0 }); |
| } |
| return error ? <Error error={error} /> : originNode; |
| }; |
| |
| const SuffixIcon = () => { |
| if (isLoading) { |
| return <StyledSpin size="small" />; |
| } |
| if (shouldShowSearch && isDropdownVisible) { |
| return <SearchOutlined />; |
| } |
| return <DownOutlined />; |
| }; |
| |
| return ( |
| <StyledContainer> |
| {header} |
| <StyledSelect |
| hasHeader={!!header} |
| aria-label={ariaLabel || name} |
| dropdownRender={dropdownRender} |
| filterOption={handleFilterOption} |
| getPopupContainer={triggerNode => triggerNode.parentNode} |
| labelInValue={isAsync} |
| maxTagCount={MAX_TAG_COUNT} |
| mode={mappedMode} |
| onDeselect={handleOnDeselect} |
| onDropdownVisibleChange={handleOnDropdownVisibleChange} |
| onPopupScroll={isAsync ? handlePagination : undefined} |
| onSearch={shouldShowSearch ? handleOnSearch : undefined} |
| onSelect={handleOnSelect} |
| onClear={() => setSelectValue(undefined)} |
| options={selectOptions} |
| placeholder={placeholder} |
| showSearch={shouldShowSearch} |
| showArrow |
| tokenSeparators={TOKEN_SEPARATORS} |
| value={selectValue} |
| suffixIcon={<SuffixIcon />} |
| menuItemSelectedIcon={ |
| invertSelection ? ( |
| <StyledStopOutlined iconSize="m" /> |
| ) : ( |
| <StyledCheckOutlined iconSize="m" /> |
| ) |
| } |
| {...props} |
| /> |
| </StyledContainer> |
| ); |
| }; |
| |
| export default Select; |