feat: Select component (Iteration 1) (#15121)

* Implement initial structure

* Add aria-label

* Rename files

* Refactor single mode new options

* Clean up

* Add select at every corner in storybook

* Clean up

* Add pagination

* Move selected options at the top

* Clean up

* Add license

* Refactor

* Improve pagination

* Fetch when allowNewOptions

* Clean up
This commit is contained in:
Geido 2021-06-16 20:05:58 +03:00 committed by GitHub
parent dafaaaeb28
commit d578ae9897
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 635 additions and 4 deletions

View File

@ -0,0 +1,244 @@
/**
* 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 from 'react';
import Select, { OptionsType, SelectProps } from './AntdSelect';
export default {
title: 'Select',
component: Select,
};
const options = [
{
label: 'Such an incredibly awesome long long label',
value: 'Such an incredibly awesome long long label',
},
{
label: 'Another incredibly awesome long long label',
value: 'Another incredibly awesome long long label',
},
{ label: 'Just a label', value: 'Just a label' },
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' },
{ label: 'D', value: 'D' },
{ label: 'E', value: 'E' },
{ label: 'F', value: 'F' },
{ label: 'G', value: 'G' },
{ label: 'H', value: 'H' },
{ label: 'I', value: 'I' },
];
const selectPositions = [
{
id: 'topLeft',
style: { top: '0', left: '0' },
},
{
id: 'topRight',
style: { top: '0', right: '0' },
},
{
id: 'bottomLeft',
style: { bottom: '0', left: '0' },
},
{
id: 'bottomRight',
style: { bottom: '0', right: '0' },
},
];
export const AtEveryCorner = () => (
<>
{selectPositions.map(position => (
<div
key={position.id}
style={{
...position.style,
width: '120px',
position: 'absolute',
}}
>
<Select ariaLabel={`gallery-${position.id}`} options={options} />
</div>
))}
</>
);
AtEveryCorner.story = {
parameters: {
actions: {
disable: true,
},
controls: {
disable: true,
},
knobs: {
disable: true,
},
},
};
async function fetchUserList(search: string, page = 0): Promise<OptionsType> {
const username = search.trim().toLowerCase();
return new Promise(resolve => {
const users = [
'John',
'Liam',
'Olivia',
'Emma',
'Noah',
'Ava',
'Oliver',
'Elijah',
'Charlotte',
'Diego',
'Evan',
'Michael',
'Giovanni',
'Luca',
'Paolo',
'Francesca',
'Chiara',
'Sara',
'Valentina',
'Jessica',
'Angelica',
'Mario',
'Marco',
'Andrea',
'Luigi',
'Quarto',
'Quinto',
'Sesto',
'Franco',
'Sandro',
'Alehandro',
'Johnny',
'Nikole',
'Igor',
'Sipatha',
'Thami',
'Munei',
'Guilherme',
'Umair',
'Ashfaq',
'Amna',
'Irfan',
'George',
'Naseer',
'Mohammad',
'Rick',
'Saliya',
'Claire',
'Benedetta',
'Ilenia',
];
let results: { label: string; value: string }[] = [];
if (!username) {
results = users.map(u => ({
label: u,
value: u,
}));
} else {
const foundUsers = users.find(u => u.toLowerCase().includes(username));
if (foundUsers && Array.isArray(foundUsers)) {
results = foundUsers.map(u => ({ label: u, value: u }));
}
if (foundUsers && typeof foundUsers === 'string') {
const u = foundUsers;
results = [{ label: u, value: u }];
}
}
const offset = !page ? 0 : page * 10;
const resultsNum = !page ? 10 : (page + 1) * 10;
results = results.length ? results.splice(offset, resultsNum) : [];
// eslint-disable-next-line no-console
console.warn(
`Emulating network request for search string: ${
username || '"empty"'
} and page: ${page} with results: [${results
.map(u => u.value)
.join(', ')}]`,
);
setTimeout(() => {
resolve(results);
}, 500);
});
}
async function fetchUserListError(): Promise<OptionsType> {
return new Promise((_, reject) => {
// eslint-disable-next-line prefer-promise-reject-errors
reject('This is an error');
});
}
export const AsyncSelect = (args: SelectProps & { withError: boolean }) => (
<Select
{...args}
options={args.withError ? fetchUserListError : fetchUserList}
/>
);
AsyncSelect.args = {
withError: false,
allowNewOptions: false,
paginatedFetch: false,
};
AsyncSelect.argTypes = {
mode: {
control: { type: 'select', options: ['single', 'multiple', 'tags'] },
},
};
AsyncSelect.story = {
parameters: {
knobs: {
disable: true,
},
},
};
export const InteractiveSelect = (args: SelectProps) => <Select {...args} />;
InteractiveSelect.args = {
allowNewOptions: false,
options,
showSearch: false,
};
InteractiveSelect.argTypes = {
mode: {
control: { type: 'select', options: ['single', 'multiple', 'tags'] },
},
};
InteractiveSelect.story = {
parameters: {
knobs: {
disable: true,
},
},
};

View File

@ -0,0 +1,355 @@
/**
* 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,
} 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 { hasOption } from './utils';
type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>;
type PickedSelectProps = Pick<
AntdSelectAllProps,
| 'allowClear'
| 'autoFocus'
| 'value'
| 'defaultValue'
| 'disabled'
| 'filterOption'
| 'loading'
| 'mode'
| 'notFoundContent'
| 'onChange'
| 'placeholder'
| 'showSearch'
| 'value'
>;
export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
export type OptionsPromise = (
search: string,
page?: number,
) => Promise<OptionsType>;
export enum ESelectTypes {
MULTIPLE = 'multiple',
TAGS = 'tags',
SINGLE = '',
}
export interface SelectProps extends PickedSelectProps {
allowNewOptions?: boolean;
ariaLabel: string;
header?: ReactNode;
name?: string; // discourage usage
notFoundContent?: ReactNode;
options: OptionsType | OptionsPromise;
paginatedFetch?: boolean;
}
// unexposed default behaviors
const MAX_TAG_COUNT = 4;
const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
const DEBOUNCE_TIMEOUT = 800;
const Error = ({ error }: { error: string }) => {
const StyledError = styled.div`
display: flex;
justify-content: center;
width: 100%;
color: ${({ theme }) => theme.colors.error};
`;
return (
<StyledError>
<Icons.Error /> {error}
</StyledError>
);
};
const DropdownContent = ({
content,
error,
}: {
content: ReactElement;
error?: string;
loading?: boolean;
}) => {
if (error) {
return <Error error={error} />;
}
return content;
};
const SelectComponent = ({
allowNewOptions = false,
ariaLabel,
filterOption,
header = null,
loading,
mode,
name,
notFoundContent,
paginatedFetch = false,
placeholder = t('Select ...'),
options,
showSearch,
value,
...props
}: SelectProps) => {
const isAsync = typeof options === 'function';
const isSingleMode =
mode !== ESelectTypes.TAGS && mode !== ESelectTypes.MULTIPLE;
const shouldShowSearch = isAsync || allowNewOptions ? true : showSearch;
const initialOptions = options && Array.isArray(options) ? options : [];
const [selectOptions, setOptions] = useState<OptionsType>(initialOptions);
const [selectValue, setSelectValue] = useState(value);
const [searchedValue, setSearchedValue] = useState('');
const [isLoading, setLoading] = useState(loading);
const [error, setError] = useState('');
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const fetchRef = useRef(0);
const handleSelectMode = () => {
if (allowNewOptions && mode === ESelectTypes.MULTIPLE) {
return ESelectTypes.TAGS;
}
if (!allowNewOptions && mode === ESelectTypes.TAGS) {
return ESelectTypes.MULTIPLE;
}
return mode;
};
const handleTopOptions = (selectedValue: any) => {
// bringing selected options to the top of the list
if (selectedValue) {
const currentValue = selectedValue as string[] | string;
const topOptions = selectOptions.filter(opt =>
currentValue?.includes(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 });
}
}
}
setOptions([...topOptions, ...otherOptions]);
}
};
const handleOnSelect = (selectedValue: any) => {
if (!isSingleMode) {
const currentSelected = Array.isArray(selectValue) ? selectValue : [];
setSelectValue([...currentSelected, selectedValue]);
} else {
setSelectValue(selectedValue);
// in single mode the sorting must happen on selection
handleTopOptions(selectedValue);
}
};
const handleOnDeselect = (value: any) => {
if (Array.isArray(selectValue)) {
const selectedValues = [
...(selectValue as []).filter(opt => opt !== value),
];
setSelectValue(selectedValues);
}
};
const handleFetch = useMemo(() => {
const fetchOptions = options as OptionsPromise;
const loadOptions = (value: string, paginate?: 'paginate') => {
if (paginate) {
fetchRef.current += 1;
} else {
fetchRef.current = 0;
}
const fetchId = fetchRef.current;
const page = paginatedFetch ? fetchId : undefined;
fetchOptions(value, page)
.then((newOptions: OptionsType) => {
if (fetchId !== fetchRef.current) return;
if (newOptions && Array.isArray(newOptions) && newOptions.length) {
// merges with existing and creates unique options
setOptions(prevOptions => [
...prevOptions,
...newOptions.filter(
newOpt =>
!prevOptions.find(prevOpt => prevOpt.value === newOpt.value),
),
]);
}
})
.catch(response =>
getClientErrorObject(response).then(e => {
const { error } = e;
setError(error);
}),
)
.finally(() => setLoading(false));
};
return debounce(loadOptions, DEBOUNCE_TIMEOUT);
}, [options, paginatedFetch]);
const handleOnSearch = (search: string) => {
const searchValue = search.trim();
// enables option creation
if (allowNewOptions && isSingleMode) {
const lastOption = selectOptions[selectOptions.length - 1].value;
// replaces the last search value entered with the new one
// only when the value wasn't part of the original options
if (
lastOption === searchedValue &&
!initialOptions.find(o => o.value === searchedValue)
) {
selectOptions.pop();
setOptions(selectOptions);
}
if (searchValue && !hasOption(searchValue, selectOptions)) {
const newOption = {
label: searchValue,
value: searchValue,
};
// adds a custom option
const newOptions = [...selectOptions, newOption];
setOptions(newOptions);
}
}
setSearchedValue(searchValue);
};
const handlePagination = (e: UIEvent<HTMLElement>) => {
const vScroll = e.currentTarget;
if (
isAsync &&
paginatedFetch &&
vScroll.scrollTop === vScroll.scrollHeight - vScroll.offsetHeight
) {
handleFetch(searchedValue, 'paginate');
}
};
const handleFilterOption = (search: string, option: AntdLabeledValue) => {
const searchValue = search.trim().toLowerCase();
if (filterOption && typeof filterOption === 'boolean') return filterOption;
if (filterOption && typeof filterOption === 'function') {
return filterOption(search, option);
}
const { value, label } = option;
if (
value &&
label &&
typeof value === 'string' &&
typeof label === 'string'
) {
return (
value.toLowerCase().includes(searchValue) ||
label.toLowerCase().includes(searchValue)
);
}
return true;
};
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 && !allowNewOptions) {
setLoading(true);
handleFetch(searchedValue);
}
}, [allowNewOptions, isAsync, handleFetch, searchedValue, selectOptions]);
useEffect(() => {
if (isAsync && allowNewOptions) {
setLoading(true);
handleFetch(searchedValue);
}
}, [allowNewOptions, isAsync, handleFetch, searchedValue]);
return (
<>
{header}
<AntdSelect
aria-label={ariaLabel || name}
dropdownRender={(
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
) => {
if (!isDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
}
return <DropdownContent content={originNode} error={error} />;
}}
filterOption={handleFilterOption as any}
getPopupContainer={triggerNode => triggerNode.parentNode}
loading={isLoading}
maxTagCount={MAX_TAG_COUNT}
mode={handleSelectMode()}
notFoundContent={isLoading ? null : notFoundContent}
onDeselect={handleOnDeselect}
onDropdownVisibleChange={handleOnDropdownVisibleChange}
onPopupScroll={handlePagination}
onSearch={handleOnSearch}
onSelect={handleOnSelect}
options={selectOptions}
placeholder={shouldShowSearch ? t('Search ...') : placeholder}
showSearch={shouldShowSearch}
tokenSeparators={TOKEN_SEPARATORS}
value={selectValue}
{...props}
/>
</>
);
};
const Select = styled((
// eslint-disable-next-line @typescript-eslint/no-unused-vars
{ ...props }: SelectProps,
) => <SelectComponent {...props} />)`
width: 100%;
`;
export default Select;

View File

@ -28,7 +28,7 @@ const OPTIONS = [
];
export default {
title: 'Select',
title: 'DeprecatedSelect',
argTypes: {
options: {
type: 'select',

View File

@ -16,8 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
export * from './Select';
export * from './DeprecatedSelect';
export * from './styles';
export { default } from './Select';
export { default } from './DeprecatedSelect';
export { default as OnPasteSelect } from './OnPasteSelect';
export { NativeSelect, NativeGraySelect } from './NativeSelect';

View File

@ -30,7 +30,7 @@ import { Props as SelectProps } from 'react-select/src/Select';
import { colors as reactSelectColors } from 'react-select/src/theme';
import { DeepNonNullable } from 'react-select/src/components';
import { OptionType } from 'antd/lib/select';
import { SupersetStyledSelectProps } from './Select';
import { SupersetStyledSelectProps } from './DeprecatedSelect';
export const DEFAULT_CLASS_NAME = 'Select';
export const DEFAULT_CLASS_NAME_PREFIX = 'Select';

View File

@ -23,6 +23,8 @@ import {
GroupedOptionsType,
} from 'react-select';
import { OptionsType as AntdOptionsType } from './AntdSelect';
/**
* Find Option value that matches a possibly string value.
*
@ -57,3 +59,13 @@ export function findValue<OptionType extends OptionTypeBase>(
// 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 hasOption(search: string, options: AntdOptionsType) {
const searchOption = search.trim().toLowerCase();
return options.find(
opt =>
opt.value.toLowerCase().includes(searchOption) ||
(typeof opt.label === 'string' &&
opt.label.toLowerCase().includes(searchOption)),
);
}

View File

@ -0,0 +1,20 @@
/**
* 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.
*/
export { default as Select } from './Select/AntdSelect';