diff --git a/superset-frontend/src/components/Select/AntdSelect.stories.tsx b/superset-frontend/src/components/Select/AntdSelect.stories.tsx deleted file mode 100644 index e26588ad99..0000000000 --- a/superset-frontend/src/components/Select/AntdSelect.stories.tsx +++ /dev/null @@ -1,244 +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 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 => ( -
- -); - -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) => +
+ ))} +

+ The objective of this panel is to show how the Select behaves when in + touch with the viewport extremities. In particular, how the drop-down is + displayed and if the tooltips of truncated items are correctly positioned. +

+ +); + +AtEveryCorner.story = { + parameters: { + actions: { + disable: true, + }, + controls: { + disable: true, + }, + knobs: { + disable: true, + }, + }, +}; + +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', +]; + +export const AsyncSelect = ( + args: SelectProps & { withError: boolean; responseTime: number }, +) => { + const [requests, setRequests] = useState([]); + + const fetchUserList = useCallback( + (search: string, page = 0): Promise => { + const username = search.trim().toLowerCase(); + return new Promise(resolve => { + 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 pageSize = 10; + const offset = !page ? 0 : page * pageSize; + const resultsNum = !page ? pageSize : (page + 1) * pageSize; + results = results.length ? results.splice(offset, resultsNum) : []; + + const request = ( + <> + Emulating network request for page {page} and search{' '} + {username || 'empty'} ... {resultsNum} results + + ); + + setRequests(requests => [request, ...requests]); + + const totalPages = + USERS.length / pageSize + (USERS.length % pageSize > 0 ? 1 : 0); + + const result: OptionsPromiseResult = { + data: results, + hasMoreData: page + 1 < totalPages, + }; + + setTimeout(() => { + resolve(result); + }, args.responseTime * 1000); + }); + }, + [args.responseTime], + ); + + async function fetchUserListError(): Promise { + return new Promise((_, reject) => { + // eslint-disable-next-line prefer-promise-reject-errors + reject('This is an error'); + }); + } + + return ( + <> +
+ +
+); + +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/Select.tsx similarity index 89% rename from superset-frontend/src/components/Select/AntdSelect.tsx rename to superset-frontend/src/components/Select/Select.tsx index da8fe1aecf..f7df21e42a 100644 --- a/superset-frontend/src/components/Select/AntdSelect.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -39,6 +39,7 @@ import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { hasOption } from './utils'; type AntdSelectAllProps = AntdSelectProps; + type PickedSelectProps = Pick< AntdSelectAllProps, | 'allowClear' @@ -55,16 +56,25 @@ type PickedSelectProps = Pick< | 'showSearch' | 'value' >; + export type OptionsType = Exclude; + +export type OptionsPromiseResult = { + data: OptionsType; + hasMoreData: boolean; +}; + export type OptionsPromise = ( search: string, page?: number, -) => Promise; +) => Promise; + export enum ESelectTypes { MULTIPLE = 'multiple', TAGS = 'tags', SINGLE = '', } + export interface SelectProps extends PickedSelectProps { allowNewOptions?: boolean; ariaLabel: string; @@ -75,10 +85,15 @@ export interface SelectProps extends PickedSelectProps { paginatedFetch?: boolean; } +const StyledContainer = styled.div` + display: flex; + flex-direction: column; +`; + // unexposed default behaviors const MAX_TAG_COUNT = 4; const TOKEN_SEPARATORS = [',', '\n', '\t', ';']; -const DEBOUNCE_TIMEOUT = 800; +const DEBOUNCE_TIMEOUT = 500; const Error = ({ error }: { error: string }) => { const StyledError = styled.div` @@ -108,7 +123,7 @@ const DropdownContent = ({ return content; }; -const SelectComponent = ({ +const Select = ({ allowNewOptions = false, ariaLabel, filterOption, @@ -135,6 +150,7 @@ const SelectComponent = ({ const [isLoading, setLoading] = useState(loading); const [error, setError] = useState(''); const [isDropdownVisible, setIsDropdownVisible] = useState(false); + const [hasMoreData, setHasMoreData] = useState(false); const fetchRef = useRef(0); const handleSelectMode = () => { @@ -191,9 +207,8 @@ const SelectComponent = ({ } }; - const handleFetch = useMemo(() => { - const fetchOptions = options as OptionsPromise; - const loadOptions = (value: string, paginate?: 'paginate') => { + const handleFetch = useMemo( + () => (value: string, paginate?: 'paginate') => { if (paginate) { fetchRef.current += 1; } else { @@ -201,15 +216,17 @@ const SelectComponent = ({ } const fetchId = fetchRef.current; const page = paginatedFetch ? fetchId : undefined; - + const fetchOptions = options as OptionsPromise; fetchOptions(value, page) - .then((newOptions: OptionsType) => { + .then((result: OptionsPromiseResult) => { + const { data, hasMoreData } = result; + setHasMoreData(hasMoreData); if (fetchId !== fetchRef.current) return; - if (newOptions && Array.isArray(newOptions) && newOptions.length) { + if (data && Array.isArray(data) && data.length) { // merges with existing and creates unique options setOptions(prevOptions => [ ...prevOptions, - ...newOptions.filter( + ...data.filter( newOpt => !prevOptions.find(prevOpt => prevOpt.value === newOpt.value), ), @@ -223,11 +240,11 @@ const SelectComponent = ({ }), ) .finally(() => setLoading(false)); - }; - return debounce(loadOptions, DEBOUNCE_TIMEOUT); - }, [options, paginatedFetch]); + }, + [options, paginatedFetch], + ); - const handleOnSearch = (search: string) => { + const handleOnSearch = debounce((search: string) => { const searchValue = search.trim(); // enables option creation if (allowNewOptions && isSingleMode) { @@ -252,11 +269,12 @@ const SelectComponent = ({ } } setSearchedValue(searchValue); - }; + }, DEBOUNCE_TIMEOUT); const handlePagination = (e: UIEvent) => { const vScroll = e.currentTarget; if ( + hasMoreData && isAsync && paginatedFetch && vScroll.scrollTop === vScroll.scrollHeight - vScroll.offsetHeight @@ -310,19 +328,21 @@ const SelectComponent = ({ } }, [allowNewOptions, isAsync, handleFetch, searchedValue]); + const dropdownRender = ( + originNode: ReactElement & { ref?: RefObject }, + ) => { + if (!isDropdownVisible) { + originNode.ref?.current?.scrollTo({ top: 0 }); + } + return ; + }; + return ( - <> + {header} }, - ) => { - if (!isDropdownVisible) { - originNode.ref?.current?.scrollTo({ top: 0 }); - } - return ; - }} + dropdownRender={dropdownRender} filterOption={handleFilterOption as any} getPopupContainer={triggerNode => triggerNode.parentNode} loading={isLoading} @@ -339,17 +359,11 @@ const SelectComponent = ({ showSearch={shouldShowSearch} tokenSeparators={TOKEN_SEPARATORS} value={selectValue} + style={{ width: '100%' }} {...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/utils.ts b/superset-frontend/src/components/Select/utils.ts index 1195cd6471..18f1e16c05 100644 --- a/superset-frontend/src/components/Select/utils.ts +++ b/superset-frontend/src/components/Select/utils.ts @@ -23,7 +23,7 @@ import { GroupedOptionsType, } from 'react-select'; -import { OptionsType as AntdOptionsType } from './AntdSelect'; +import { OptionsType as AntdOptionsType } from './Select'; /** * Find Option value that matches a possibly string value. diff --git a/superset-frontend/src/components/index.ts b/superset-frontend/src/components/index.ts index 02a919d5ae..596fbb435b 100644 --- a/superset-frontend/src/components/index.ts +++ b/superset-frontend/src/components/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { default as Select } from './Select/AntdSelect'; +export { default as Select } from './Select/Select';