Files
superset2/superset-frontend/src/components/Select/Select.tsx
2021-07-06 13:07:33 -03:00

425 lines
12 KiB
TypeScript

/**
* 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 { Select as AntdSelect } from 'antd';
import Icons from 'src/components/Icons';
import {
SelectProps as AntdSelectProps,
SelectValue as AntdSelectValue,
LabeledValue as AntdLabeledValue,
} from 'antd/lib/select';
import debounce from 'lodash/debounce';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { isEqual } from 'lodash';
import { hasOption } from './utils';
type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>;
type PickedSelectProps = Pick<
AntdSelectAllProps,
| 'allowClear'
| 'autoFocus'
| 'value'
| 'defaultValue'
| '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,
offset: number,
limit: 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;
}
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;
}
`}
`;
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 MAX_TAG_COUNT = 4;
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
const DEBOUNCE_TIMEOUT = 500;
const DEFAULT_PAGE_SIZE = 50;
const EMPTY_OPTIONS: OptionsType = [];
const Error = ({ error }: { error: string }) => (
<StyledError>
<Icons.ErrorSolid /> {error}
</StyledError>
);
const Select = ({
allowNewOptions = false,
ariaLabel,
filterOption = true,
header = null,
mode = 'single',
name,
pageSize = DEFAULT_PAGE_SIZE,
placeholder = t('Select ...'),
options,
showSearch,
invertSelection = false,
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 [offset, setOffset] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const fetchedQueries = useRef(new Set<string>());
const mappedMode = isSingleMode
? undefined
: allowNewOptions
? 'tags'
: 'multiple';
useEffect(() => {
setSelectOptions(
options && Array.isArray(options) ? options : EMPTY_OPTIONS,
);
}, [options]);
useEffect(() => {
setSelectValue(value);
}, [value]);
const handleTopOptions = useCallback(
(selectedValue: AntdSelectValue | undefined) => {
// bringing selected options to the top of the list
if (selectedValue) {
const currentValue = selectedValue as string[] | string;
const topOptions = selectOptions.filter(opt =>
Array.isArray(currentValue)
? currentValue.includes(opt.value)
: currentValue === opt.value,
);
const otherOptions = selectOptions.filter(
opt => !topOptions.find(tOpt => tOpt.value === opt.value),
);
// fallback for custom options in tags mode as they
// do not appear in the selectOptions state
if (!isSingleMode && Array.isArray(currentValue)) {
// eslint-disable-next-line no-restricted-syntax
for (const val of currentValue) {
if (!topOptions.find(tOpt => tOpt.value === val)) {
topOptions.push({ label: val, value: val });
}
}
}
const sortedOptions = [...topOptions, ...otherOptions];
if (!isEqual(sortedOptions, selectOptions)) {
setSelectOptions(sortedOptions);
}
}
},
[isSingleMode, selectOptions],
);
const handleOnSelect = (
selectedValue: string | number | AntdLabeledValue,
) => {
if (isSingleMode) {
setSelectValue(selectedValue);
} else {
const currentSelected = Array.isArray(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, offset: number, limit: number) => {
const key = `${value};${offset};${limit}`;
if (fetchedQueries.current.has(key)) {
return;
}
setLoading(true);
const fetchOptions = options as OptionsPagePromise;
fetchOptions(value, offset, limit)
.then(({ data, totalCount }: OptionsTypePage) => {
handleData(data);
fetchedQueries.current.add(key);
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 = offset + pageSize < totalCount;
if (!isLoading && isAsync && hasMoreData && thresholdReached) {
const newOffset = offset + pageSize;
const limit =
newOffset + pageSize > totalCount ? totalCount - newOffset : pageSize;
handlePaginatedFetch(searchedValue, newOffset, limit);
setOffset(newOffset);
}
};
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);
// 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 foundOption = hasOption(searchedValue, selectOptions);
if (isAsync && !foundOption) {
const offset = 0;
handlePaginatedFetch(searchedValue, offset, pageSize);
setOffset(offset);
}
}, [isAsync, searchedValue, selectOptions, pageSize, handlePaginatedFetch]);
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;
};
return (
<StyledContainer>
{header}
<StyledSelect
hasHeader={!!header}
aria-label={ariaLabel || name}
dropdownRender={dropdownRender}
filterOption={handleFilterOption}
getPopupContainer={triggerNode => triggerNode.parentNode}
loading={isLoading}
maxTagCount={MAX_TAG_COUNT}
mode={mappedMode}
onDeselect={handleOnDeselect}
onDropdownVisibleChange={handleOnDropdownVisibleChange}
onPopupScroll={isAsync ? handlePagination : undefined}
onSearch={handleOnSearch}
onSelect={handleOnSelect}
onClear={() => setSelectValue(undefined)}
options={selectOptions}
placeholder={placeholder}
showSearch={shouldShowSearch}
showArrow
tokenSeparators={TOKEN_SEPARATORS}
value={selectValue}
menuItemSelectedIcon={
invertSelection ? (
<StyledStopOutlined iconSize="m" />
) : (
<StyledCheckOutlined iconSize="m" />
)
}
{...props}
/>
</StyledContainer>
);
};
export default Select;