From d578ae989797d73249e51b901d75a083dbd3e105 Mon Sep 17 00:00:00 2001
From: Geido <60598000+geido@users.noreply.github.com>
Date: Wed, 16 Jun 2021 20:05:58 +0300
Subject: [PATCH] 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
---
.../components/Select/AntdSelect.stories.tsx | 244 ++++++++++++
.../src/components/Select/AntdSelect.tsx | 355 ++++++++++++++++++
...ories.tsx => DeprecatedSelect.stories.tsx} | 2 +-
.../{Select.tsx => DeprecatedSelect.tsx} | 0
.../src/components/Select/index.ts | 4 +-
.../src/components/Select/styles.tsx | 2 +-
.../src/components/Select/utils.ts | 12 +
superset-frontend/src/components/index.ts | 20 +
8 files changed, 635 insertions(+), 4 deletions(-)
create mode 100644 superset-frontend/src/components/Select/AntdSelect.stories.tsx
create mode 100644 superset-frontend/src/components/Select/AntdSelect.tsx
rename superset-frontend/src/components/Select/{Select.stories.tsx => DeprecatedSelect.stories.tsx} (99%)
rename superset-frontend/src/components/Select/{Select.tsx => DeprecatedSelect.tsx} (100%)
create mode 100644 superset-frontend/src/components/index.ts
diff --git a/superset-frontend/src/components/Select/AntdSelect.stories.tsx b/superset-frontend/src/components/Select/AntdSelect.stories.tsx
new file mode 100644
index 0000000000..e26588ad99
--- /dev/null
+++ b/superset-frontend/src/components/Select/AntdSelect.stories.tsx
@@ -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 => (
+
+
+
+ ))}
+ >
+);
+
+AtEveryCorner.story = {
+ parameters: {
+ actions: {
+ disable: true,
+ },
+ controls: {
+ disable: true,
+ },
+ knobs: {
+ disable: true,
+ },
+ },
+};
+
+async function fetchUserList(search: string, page = 0): Promise {
+ 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 {
+ return new Promise((_, reject) => {
+ // eslint-disable-next-line prefer-promise-reject-errors
+ reject('This is an error');
+ });
+}
+
+export const AsyncSelect = (args: SelectProps & { withError: boolean }) => (
+
+);
+
+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) => ;
+
+InteractiveSelect.args = {
+ allowNewOptions: false,
+ options,
+ showSearch: false,
+};
+
+InteractiveSelect.argTypes = {
+ mode: {
+ control: { type: 'select', options: ['single', 'multiple', 'tags'] },
+ },
+};
+
+InteractiveSelect.story = {
+ parameters: {
+ knobs: {
+ disable: true,
+ },
+ },
+};
diff --git a/superset-frontend/src/components/Select/AntdSelect.tsx b/superset-frontend/src/components/Select/AntdSelect.tsx
new file mode 100644
index 0000000000..da8fe1aecf
--- /dev/null
+++ b/superset-frontend/src/components/Select/AntdSelect.tsx
@@ -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;
+type PickedSelectProps = Pick<
+ AntdSelectAllProps,
+ | 'allowClear'
+ | 'autoFocus'
+ | 'value'
+ | 'defaultValue'
+ | 'disabled'
+ | 'filterOption'
+ | 'loading'
+ | 'mode'
+ | 'notFoundContent'
+ | 'onChange'
+ | 'placeholder'
+ | 'showSearch'
+ | 'value'
+>;
+export type OptionsType = Exclude;
+export type OptionsPromise = (
+ search: string,
+ page?: number,
+) => Promise;
+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 (
+
+ {error}
+
+ );
+};
+
+const DropdownContent = ({
+ content,
+ error,
+}: {
+ content: ReactElement;
+ error?: string;
+ loading?: boolean;
+}) => {
+ if (error) {
+ return ;
+ }
+ 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(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) => {
+ 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}
+ },
+ ) => {
+ if (!isDropdownVisible) {
+ originNode.ref?.current?.scrollTo({ top: 0 });
+ }
+ return ;
+ }}
+ 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,
+) => )`
+ width: 100%;
+`;
+
+export default Select;
diff --git a/superset-frontend/src/components/Select/Select.stories.tsx b/superset-frontend/src/components/Select/DeprecatedSelect.stories.tsx
similarity index 99%
rename from superset-frontend/src/components/Select/Select.stories.tsx
rename to superset-frontend/src/components/Select/DeprecatedSelect.stories.tsx
index 125f667786..191ee81e00 100644
--- a/superset-frontend/src/components/Select/Select.stories.tsx
+++ b/superset-frontend/src/components/Select/DeprecatedSelect.stories.tsx
@@ -28,7 +28,7 @@ const OPTIONS = [
];
export default {
- title: 'Select',
+ title: 'DeprecatedSelect',
argTypes: {
options: {
type: 'select',
diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/DeprecatedSelect.tsx
similarity index 100%
rename from superset-frontend/src/components/Select/Select.tsx
rename to superset-frontend/src/components/Select/DeprecatedSelect.tsx
diff --git a/superset-frontend/src/components/Select/index.ts b/superset-frontend/src/components/Select/index.ts
index 009e8edf22..e8f30eb7e4 100644
--- a/superset-frontend/src/components/Select/index.ts
+++ b/superset-frontend/src/components/Select/index.ts
@@ -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';
diff --git a/superset-frontend/src/components/Select/styles.tsx b/superset-frontend/src/components/Select/styles.tsx
index b1603e6473..015c9bbc47 100644
--- a/superset-frontend/src/components/Select/styles.tsx
+++ b/superset-frontend/src/components/Select/styles.tsx
@@ -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';
diff --git a/superset-frontend/src/components/Select/utils.ts b/superset-frontend/src/components/Select/utils.ts
index 497791b62d..1195cd6471 100644
--- a/superset-frontend/src/components/Select/utils.ts
+++ b/superset-frontend/src/components/Select/utils.ts
@@ -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(
// 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)),
+ );
+}
diff --git a/superset-frontend/src/components/index.ts b/superset-frontend/src/components/index.ts
new file mode 100644
index 0000000000..02a919d5ae
--- /dev/null
+++ b/superset-frontend/src/components/index.ts
@@ -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';