diff --git a/superset-frontend/src/components/Select/AsyncSelect.stories.tsx b/superset-frontend/src/components/Select/AsyncSelect.stories.tsx new file mode 100644 index 0000000000..547fc7fa99 --- /dev/null +++ b/superset-frontend/src/components/Select/AsyncSelect.stories.tsx @@ -0,0 +1,359 @@ +/** + * 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, { + ReactNode, + useState, + useCallback, + useRef, + useMemo, +} from 'react'; +import Button from 'src/components/Button'; +import AsyncSelect from './AsyncSelect'; +import { + SelectOptionsType, + AsyncSelectProps, + AsyncSelectRef, + SelectOptionsTypePage, +} from './types'; + +export default { + title: 'AsyncSelect', + component: AsyncSelect, +}; + +const DEFAULT_WIDTH = 200; + +const options: SelectOptionsType = [ + { + label: 'Such an incredibly awesome long long label', + value: 'Such an incredibly awesome long long label', + custom: 'Secret custom prop', + }, + { + label: 'Another incredibly awesome long long label', + value: 'Another incredibly awesome long long label', + }, + { + label: 'JSX Label', + customLabel:
JSX Label
, + value: 'JSX 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 ARG_TYPES = { + options: { + defaultValue: options, + description: `It defines the options of the Select. + The options can be static, an array of options. + The options can also be async, a promise that returns an array of options. + `, + }, + ariaLabel: { + description: `It adds the aria-label tag for accessibility standards. + Must be plain English and localized. + `, + }, + labelInValue: { + defaultValue: true, + table: { + disable: true, + }, + }, + name: { + table: { + disable: true, + }, + }, + notFoundContent: { + table: { + disable: true, + }, + }, + mode: { + description: `It defines whether the Select should allow for + the selection of multiple options or single. Single by default. + `, + defaultValue: 'single', + control: { + type: 'inline-radio', + options: ['single', 'multiple'], + }, + }, + allowNewOptions: { + description: `It enables the user to create new options. + Can be used with standard or async select types. + Can be used with any mode, single or multiple. False by default. + `, + }, + invertSelection: { + description: `It shows a stop-outlined icon at the far right of a selected + option instead of the default checkmark. + Useful to better indicate to the user that by clicking on a selected + option it will be de-selected. False by default. + `, + }, + optionFilterProps: { + description: `It allows to define which properties of the option object + should be looked for when searching. + By default label and value. + `, + }, +}; + +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', +].sort(); + +export const AsynchronousSelect = ({ + fetchOnlyOnSearch, + withError, + withInitialValue, + responseTime, + ...rest +}: AsyncSelectProps & { + withError: boolean; + withInitialValue: boolean; + responseTime: number; +}) => { + const [requests, setRequests] = useState([]); + const ref = useRef(null); + + const getResults = (username?: string) => { + let results: { label: string; value: string }[] = []; + + if (!username) { + results = USERS.map(u => ({ + label: u, + value: u, + })); + } else { + const foundUsers = USERS.filter(u => u.toLowerCase().includes(username)); + if (foundUsers) { + results = foundUsers.map(u => ({ label: u, value: u })); + } else { + results = []; + } + } + return results; + }; + + const setRequestLog = (results: number, total: number, username?: string) => { + const request = ( + <> + Emulating network request with search {username || 'empty'} ...{' '} + + {results}/{total} + {' '} + results + + ); + + setRequests(requests => [request, ...requests]); + }; + + const fetchUserListPage = useCallback( + ( + search: string, + page: number, + pageSize: number, + ): Promise => { + const username = search.trim().toLowerCase(); + return new Promise(resolve => { + let results = getResults(username); + const totalCount = results.length; + const start = page * pageSize; + const deleteCount = + start + pageSize < totalCount ? pageSize : totalCount - start; + results = results.splice(start, deleteCount); + setRequestLog(start + results.length, totalCount, username); + setTimeout(() => { + resolve({ data: results, totalCount }); + }, responseTime * 1000); + }); + }, + [responseTime], + ); + + const fetchUserListError = async (): Promise => + new Promise((_, reject) => { + reject(new Error('Error while fetching the names from the server')); + }); + + const initialValue = useMemo( + () => ({ label: 'Valentina', value: 'Valentina' }), + [], + ); + + return ( + <> +
+ +
+
+ {requests.map((request, index) => ( +

{request}

+ ))} +
+ + + ); +}; + +AsynchronousSelect.args = { + allowClear: false, + allowNewOptions: false, + fetchOnlyOnSearch: false, + pageSize: 10, + withError: false, + withInitialValue: false, + tokenSeparators: ['\n', '\t', ';'], +}; + +AsynchronousSelect.argTypes = { + ...ARG_TYPES, + header: { + table: { + disable: true, + }, + }, + invertSelection: { + table: { + disable: true, + }, + }, + pageSize: { + defaultValue: 10, + control: { + type: 'range', + min: 10, + max: 50, + step: 10, + }, + }, + responseTime: { + defaultValue: 0.5, + name: 'responseTime (seconds)', + control: { + type: 'range', + min: 0.5, + max: 5, + step: 0.5, + }, + }, +}; + +AsynchronousSelect.story = { + parameters: { + knobs: { + disable: true, + }, + }, +}; diff --git a/superset-frontend/src/components/Select/AsyncSelect.tsx b/superset-frontend/src/components/Select/AsyncSelect.tsx index 27c62023ce..524ca53e61 100644 --- a/superset-frontend/src/components/Select/AsyncSelect.tsx +++ b/superset-frontend/src/components/Select/AsyncSelect.tsx @@ -28,7 +28,7 @@ import React, { useCallback, useImperativeHandle, } from 'react'; -import { ensureIsArray, styled, t } from '@superset-ui/core'; +import { ensureIsArray, t } from '@superset-ui/core'; import { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; import debounce from 'lodash/debounce'; import { isEqual } from 'lodash'; @@ -39,20 +39,8 @@ import { getValue, hasOption, isLabeledValue, - DEFAULT_SORT_COMPARATOR, - EMPTY_OPTIONS, - MAX_TAG_COUNT, - SelectOptionsPagePromise, - SelectOptionsType, - SelectOptionsTypePage, - StyledCheckOutlined, - StyledStopOutlined, - TOKEN_SEPARATORS, renderSelectOptions, - StyledContainer, - StyledSelect, hasCustomLabels, - BaseSelectProps, sortSelectedFirstHelper, sortComparatorWithSearchHelper, sortComparatorForNoSearchHelper, @@ -60,64 +48,28 @@ import { dropDownRenderHelper, handleFilterOptionHelper, } from './utils'; - -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 StyledErrorMessage = styled.div` - overflow: hidden; - text-overflow: ellipsis; -`; - -const DEFAULT_PAGE_SIZE = 100; - -export type AsyncSelectRef = HTMLInputElement & { clearCache: () => void }; - -export interface AsyncSelectProps extends BaseSelectProps { - /** - * It fires a request against the server after - * the first interaction and not on render. - * Works in async mode only (See the options property). - * True by default. - */ - lazyLoading?: boolean; - /** - * It defines the options of the Select. - * The options are async, a promise that returns - * an array of options. - */ - options: SelectOptionsPagePromise; - /** - * It defines how many results should be included - * in the query response. - * Works in async mode only (See the options property). - */ - pageSize?: number; - /** - * It fires a request against the server only after - * searching. - * Works in async mode only (See the options property). - * Undefined by default. - */ - fetchOnlyOnSearch?: boolean; - /** - * It provides a callback function when an error - * is generated after a request is fired. - * Works in async mode only (See the options property). - */ - onError?: (error: string) => void; -} +import { + AsyncSelectProps, + AsyncSelectRef, + SelectOptionsPagePromise, + SelectOptionsType, + SelectOptionsTypePage, +} from './types'; +import { + StyledCheckOutlined, + StyledContainer, + StyledError, + StyledErrorMessage, + StyledSelect, + StyledStopOutlined, +} from './styles'; +import { + DEFAULT_PAGE_SIZE, + EMPTY_OPTIONS, + MAX_TAG_COUNT, + TOKEN_SEPARATORS, + DEFAULT_SORT_COMPARATOR, +} from './constants'; const Error = ({ error }: { error: string }) => ( diff --git a/superset-frontend/src/components/Select/Select.stories.tsx b/superset-frontend/src/components/Select/Select.stories.tsx index e9a03fe563..c4802a45e0 100644 --- a/superset-frontend/src/components/Select/Select.stories.tsx +++ b/superset-frontend/src/components/Select/Select.stories.tsx @@ -16,19 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import React, { - ReactNode, - useState, - useCallback, - useRef, - useMemo, -} from 'react'; -import Button from 'src/components/Button'; +import React from 'react'; import ControlHeader from 'src/explore/components/ControlHeader'; -import AsyncSelect, { AsyncSelectProps, AsyncSelectRef } from './AsyncSelect'; -import { SelectOptionsType, SelectOptionsTypePage } from './utils'; +import { SelectOptionsType, SelectProps } from './types'; -import Select, { SelectProps } from './Select'; +import Select from './Select'; export default { title: 'Select', @@ -331,236 +323,3 @@ PageScroll.story = { }, }, }; - -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', -].sort(); - -export const AsynchronousSelect = ({ - fetchOnlyOnSearch, - withError, - withInitialValue, - responseTime, - ...rest -}: AsyncSelectProps & { - withError: boolean; - withInitialValue: boolean; - responseTime: number; -}) => { - const [requests, setRequests] = useState([]); - const ref = useRef(null); - - const getResults = (username?: string) => { - let results: { label: string; value: string }[] = []; - - if (!username) { - results = USERS.map(u => ({ - label: u, - value: u, - })); - } else { - const foundUsers = USERS.filter(u => u.toLowerCase().includes(username)); - if (foundUsers) { - results = foundUsers.map(u => ({ label: u, value: u })); - } else { - results = []; - } - } - return results; - }; - - const setRequestLog = (results: number, total: number, username?: string) => { - const request = ( - <> - Emulating network request with search {username || 'empty'} ...{' '} - - {results}/{total} - {' '} - results - - ); - - setRequests(requests => [request, ...requests]); - }; - - const fetchUserListPage = useCallback( - ( - search: string, - page: number, - pageSize: number, - ): Promise => { - const username = search.trim().toLowerCase(); - return new Promise(resolve => { - let results = getResults(username); - const totalCount = results.length; - const start = page * pageSize; - const deleteCount = - start + pageSize < totalCount ? pageSize : totalCount - start; - results = results.splice(start, deleteCount); - setRequestLog(start + results.length, totalCount, username); - setTimeout(() => { - resolve({ data: results, totalCount }); - }, responseTime * 1000); - }); - }, - [responseTime], - ); - - const fetchUserListError = async (): Promise => - new Promise((_, reject) => { - reject(new Error('Error while fetching the names from the server')); - }); - - const initialValue = useMemo( - () => ({ label: 'Valentina', value: 'Valentina' }), - [], - ); - - return ( - <> -
- -
-
- {requests.map((request, index) => ( -

{request}

- ))} -
- - - ); -}; - -AsynchronousSelect.args = { - allowClear: false, - allowNewOptions: false, - fetchOnlyOnSearch: false, - pageSize: 10, - withError: false, - withInitialValue: false, - tokenSeparators: ['\n', '\t', ';'], -}; - -AsynchronousSelect.argTypes = { - ...ARG_TYPES, - header: { - table: { - disable: true, - }, - }, - invertSelection: { - table: { - disable: true, - }, - }, - pageSize: { - defaultValue: 10, - control: { - type: 'range', - min: 10, - max: 50, - step: 10, - }, - }, - responseTime: { - defaultValue: 0.5, - name: 'responseTime (seconds)', - control: { - type: 'range', - min: 0.5, - max: 5, - step: 0.5, - }, - }, -}; - -AsynchronousSelect.story = { - parameters: { - knobs: { - disable: true, - }, - }, -}; diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/Select.tsx index 6b6713852d..1ad1e6b0a2 100644 --- a/superset-frontend/src/components/Select/Select.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -32,34 +32,27 @@ import { getValue, hasOption, isLabeledValue, - DEFAULT_SORT_COMPARATOR, - EMPTY_OPTIONS, - MAX_TAG_COUNT, - SelectOptionsType, - StyledCheckOutlined, - StyledStopOutlined, - TOKEN_SEPARATORS, renderSelectOptions, - StyledSelect, - StyledContainer, hasCustomLabels, - BaseSelectProps, sortSelectedFirstHelper, sortComparatorWithSearchHelper, handleFilterOptionHelper, dropDownRenderHelper, getSuffixIcon, } from './utils'; - -export interface SelectProps extends BaseSelectProps { - /** - * It defines the options of the Select. - * The options can be static, an array of options. - * The options can also be async, a promise that returns - * an array of options. - */ - options: SelectOptionsType; -} +import { SelectOptionsType, SelectProps } from './types'; +import { + StyledCheckOutlined, + StyledContainer, + StyledSelect, + StyledStopOutlined, +} from './styles'; +import { + EMPTY_OPTIONS, + MAX_TAG_COUNT, + TOKEN_SEPARATORS, + DEFAULT_SORT_COMPARATOR, +} from './constants'; /** * This component is a customized version of the Antdesign 4.X Select component diff --git a/superset-frontend/src/components/Select/constants.ts b/superset-frontend/src/components/Select/constants.ts new file mode 100644 index 0000000000..b8c60e8523 --- /dev/null +++ b/superset-frontend/src/components/Select/constants.ts @@ -0,0 +1,52 @@ +/** + * 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 { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; +import { rankedSearchCompare } from 'src/utils/rankedSearchCompare'; + +export const MAX_TAG_COUNT = 4; + +export const TOKEN_SEPARATORS = [',', '\n', '\t', ';']; + +export const EMPTY_OPTIONS = []; + +export const DEFAULT_PAGE_SIZE = 100; + +export const DEFAULT_SORT_COMPARATOR = ( + a: AntdLabeledValue, + b: AntdLabeledValue, + search?: string, +) => { + let aText: string | undefined; + let bText: string | undefined; + if (typeof a.label === 'string' && typeof b.label === 'string') { + aText = a.label; + bText = b.label; + } else if (typeof a.value === 'string' && typeof b.value === 'string') { + aText = a.value; + bText = b.value; + } + // sort selected options first + if (typeof aText === 'string' && typeof bText === 'string') { + if (search) { + return rankedSearchCompare(aText, bText, search); + } + return aText.localeCompare(bText); + } + return (a.value as number) - (b.value as number); +}; diff --git a/superset-frontend/src/components/Select/styles.tsx b/superset-frontend/src/components/Select/styles.tsx new file mode 100644 index 0000000000..85dbefe88f --- /dev/null +++ b/superset-frontend/src/components/Select/styles.tsx @@ -0,0 +1,90 @@ +/** + * 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 { styled } from '@superset-ui/core'; +import Icons from 'src/components/Icons'; +import { Spin } from 'antd'; +import AntdSelect from 'antd/lib/select'; + +export const StyledContainer = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +export const StyledSelect = styled(AntdSelect)` + ${({ theme }) => ` + && .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; + } + `} +`; + +export const StyledStopOutlined = styled(Icons.StopOutlined)` + vertical-align: 0; +`; + +export const StyledCheckOutlined = styled(Icons.CheckOutlined)` + vertical-align: 0; +`; + +export const StyledSpin = styled(Spin)` + margin-top: ${({ theme }) => -theme.gridUnit}px; +`; + +export const StyledLoadingText = styled.div` + ${({ theme }) => ` + margin-left: ${theme.gridUnit * 3}px; + line-height: ${theme.gridUnit * 8}px; + color: ${theme.colors.grayscale.light1}; + `} +`; + +export const StyledHelperText = styled.div` + ${({ theme }) => ` + padding: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px; + color: ${theme.colors.grayscale.base}; + font-size: ${theme.typography.sizes.s}px; + cursor: default; + border-bottom: 1px solid ${theme.colors.grayscale.light2}; + `} +`; + +export 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; + } + `} +`; + +export const StyledErrorMessage = styled.div` + overflow: hidden; + text-overflow: ellipsis; +`; diff --git a/superset-frontend/src/components/Select/types.ts b/superset-frontend/src/components/Select/types.ts new file mode 100644 index 0000000000..e2a7d5d1f3 --- /dev/null +++ b/superset-frontend/src/components/Select/types.ts @@ -0,0 +1,201 @@ +/** + * 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 { + JSXElementConstructor, + ReactElement, + ReactNode, + RefObject, +} from 'react'; +import { + SelectProps as AntdSelectProps, + SelectValue as AntdSelectValue, + LabeledValue as AntdLabeledValue, +} from 'antd/lib/select'; + +export type RawValue = string | number; + +export type V = string | number | null | undefined; + +export type LabeledValue = { label?: ReactNode; value?: V }; + +export type AntdProps = AntdSelectProps; + +export type AntdExposedProps = Pick< + AntdProps, + | 'allowClear' + | 'autoFocus' + | 'disabled' + | 'filterOption' + | 'filterSort' + | 'loading' + | 'labelInValue' + | 'maxTagCount' + | 'notFoundContent' + | 'onChange' + | 'onClear' + | 'onDeselect' + | 'onSelect' + | 'onFocus' + | 'onBlur' + | 'onPopupScroll' + | 'onSearch' + | 'onDropdownVisibleChange' + | 'placeholder' + | 'showArrow' + | 'showSearch' + | 'tokenSeparators' + | 'value' + | 'getPopupContainer' + | 'menuItemSelectedIcon' +>; + +export type SelectOptionsType = Exclude; + +export interface BaseSelectProps extends AntdExposedProps { + /** + * It enables the user to create new options. + * Can be used with standard or async select types. + * Can be used with any mode, single or multiple. + * False by default. + * */ + allowNewOptions?: boolean; + /** + * It adds the aria-label tag for accessibility standards. + * Must be plain English and localized. + */ + ariaLabel?: string; + /** + * Renders the dropdown + */ + dropdownRender?: ( + menu: ReactElement>, + ) => ReactElement>; + /** + * It adds a header on top of the Select. + * Can be any ReactNode. + */ + header?: ReactNode; + /** + * It adds a helper text on top of the Select options + * with additional context to help with the interaction. + */ + helperText?: string; + /** + * It allows to define which properties of the option object + * should be looked for when searching. + * By default label and value. + */ + mappedMode?: 'multiple' | 'tags'; + /** + * It defines whether the Select should allow for the + * selection of multiple options or single. + * Single by default. + */ + mode?: 'single' | 'multiple'; + /** + * Deprecated. + * Prefer ariaLabel instead. + */ + name?: string; // discourage usage + /** + * It allows to define which properties of the option object + * should be looked for when searching. + * By default label and value. + */ + optionFilterProps?: string[]; + /** + * It shows a stop-outlined icon at the far right of a selected + * option instead of the default checkmark. + * Useful to better indicate to the user that by clicking on a selected + * option it will be de-selected. + * False by default. + */ + invertSelection?: boolean; + /** + * Customize how filtered options are sorted while users search. + * Will not apply to predefined `options` array when users are not searching. + */ + sortComparator?: ( + a: AntdLabeledValue, + b: AntdLabeledValue, + search?: string, + ) => number; + + suffixIcon?: ReactNode; + + ref: RefObject; +} + +export interface SelectProps extends BaseSelectProps { + /** + * It defines the options of the Select. + * The options can be static, an array of options. + * The options can also be async, a promise that returns + * an array of options. + */ + options: SelectOptionsType; +} + +export type AsyncSelectRef = HTMLInputElement & { clearCache: () => void }; + +export type SelectOptionsTypePage = { + data: SelectOptionsType; + totalCount: number; +}; + +export type SelectOptionsPagePromise = ( + search: string, + page: number, + pageSize: number, +) => Promise; + +export interface AsyncSelectProps extends BaseSelectProps { + /** + * It fires a request against the server after + * the first interaction and not on render. + * Works in async mode only (See the options property). + * True by default. + */ + lazyLoading?: boolean; + /** + * It defines the options of the Select. + * The options are async, a promise that returns + * an array of options. + */ + options: SelectOptionsPagePromise; + /** + * It defines how many results should be included + * in the query response. + * Works in async mode only (See the options property). + */ + pageSize?: number; + /** + * It fires a request against the server only after + * searching. + * Works in async mode only (See the options property). + * Undefined by default. + */ + fetchOnlyOnSearch?: boolean; + /** + * It provides a callback function when an error + * is generated after a request is fired. + * Works in async mode only (See the options property). + */ + onError?: (error: string) => void; +} diff --git a/superset-frontend/src/components/Select/utils.tsx b/superset-frontend/src/components/Select/utils.tsx index 27a97ac8bd..5ec7e33d10 100644 --- a/superset-frontend/src/components/Select/utils.tsx +++ b/superset-frontend/src/components/Select/utils.tsx @@ -16,30 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { ensureIsArray, styled, t } from '@superset-ui/core'; -import { Spin } from 'antd'; -import Icons from 'src/components/Icons'; -import AntdSelect, { - SelectProps as AntdSelectProps, - SelectValue as AntdSelectValue, - LabeledValue as AntdLabeledValue, -} from 'antd/lib/select'; -import { rankedSearchCompare } from 'src/utils/rankedSearchCompare'; -import { - OptionTypeBase, - ValueType, - OptionsType, - GroupedOptionsType, -} from 'react-select'; -import React, { - ReactElement, - ReactNode, - RefObject, - JSXElementConstructor, -} from 'react'; +import { ensureIsArray, t } from '@superset-ui/core'; +import AntdSelect, { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; +import React, { ReactElement, RefObject } from 'react'; import { DownOutlined, SearchOutlined } from '@ant-design/icons'; - -declare type RawValue = string | number; +import { StyledHelperText, StyledLoadingText, StyledSpin } from './styles'; +import { LabeledValue, RawValue, SelectOptionsType, V } from './types'; const { Option } = AntdSelect; @@ -51,41 +33,6 @@ export function isObject(value: unknown): value is Record { ); } -/** - * Find Option value that matches a possibly string value. - * - * Translate possible string values to `OptionType` objects, fallback to value - * itself if cannot be found in the options list. - * - * Always returns an array. - */ -export function findValue( - value: ValueType | string, - options: GroupedOptionsType | OptionsType = [], - valueKey = 'value', -): OptionType[] { - if (value === null || value === undefined || value === '') { - return []; - } - const isGroup = Array.isArray((options[0] || {}).options); - const flatOptions = isGroup - ? (options as GroupedOptionsType).flatMap(x => x.options || []) - : (options as OptionsType); - - const find = (val: OptionType) => { - const realVal = (value || {}).hasOwnProperty(valueKey) - ? val[valueKey] - : val; - return ( - flatOptions.find(x => x === realVal || x[valueKey] === realVal) || val - ); - }; - - // If value is a single string, must return an Array so `cleanValue` won't be - // 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 isLabeledValue(value: unknown): value is AntdLabeledValue { return isObject(value) && 'value' in value && 'label' in value; } @@ -96,10 +43,6 @@ export function getValue( return isLabeledValue(option) ? option.value : option; } -type V = string | number | null | undefined; - -type LabeledValue = { label?: ReactNode; value?: V }; - export function hasOption( value: V, options?: V | LabeledValue | (V | LabeledValue)[], @@ -121,127 +64,6 @@ export function hasOption( ); } -export type AntdProps = AntdSelectProps; - -export type AntdExposedProps = Pick< - AntdProps, - | 'allowClear' - | 'autoFocus' - | 'disabled' - | 'filterOption' - | 'filterSort' - | 'loading' - | 'labelInValue' - | 'maxTagCount' - | 'notFoundContent' - | 'onChange' - | 'onClear' - | 'onDeselect' - | 'onSelect' - | 'onFocus' - | 'onBlur' - | 'onPopupScroll' - | 'onSearch' - | 'onDropdownVisibleChange' - | 'placeholder' - | 'showArrow' - | 'showSearch' - | 'tokenSeparators' - | 'value' - | 'getPopupContainer' - | 'menuItemSelectedIcon' ->; - -export type SelectOptionsType = Exclude; - -export type SelectOptionsTypePage = { - data: SelectOptionsType; - totalCount: number; -}; - -export type SelectOptionsPagePromise = ( - search: string, - page: number, - pageSize: number, -) => Promise; - -export const StyledContainer = styled.div` - display: flex; - flex-direction: column; - width: 100%; -`; - -export const StyledSelect = styled(AntdSelect)` - ${({ theme }) => ` - && .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; - } - `} -`; - -export const StyledStopOutlined = styled(Icons.StopOutlined)` - vertical-align: 0; -`; - -export const StyledCheckOutlined = styled(Icons.CheckOutlined)` - vertical-align: 0; -`; - -export const StyledSpin = styled(Spin)` - margin-top: ${({ theme }) => -theme.gridUnit}px; -`; - -export const StyledLoadingText = styled.div` - ${({ theme }) => ` - margin-left: ${theme.gridUnit * 3}px; - line-height: ${theme.gridUnit * 8}px; - color: ${theme.colors.grayscale.light1}; - `} -`; - -const StyledHelperText = styled.div` - ${({ theme }) => ` - padding: ${theme.gridUnit * 2}px ${theme.gridUnit * 3}px; - color: ${theme.colors.grayscale.base}; - font-size: ${theme.typography.sizes.s}px; - cursor: default; - border-bottom: 1px solid ${theme.colors.grayscale.light2}; - `} -`; - -export const MAX_TAG_COUNT = 4; -export const TOKEN_SEPARATORS = [',', '\n', '\t', ';']; -export const EMPTY_OPTIONS: SelectOptionsType = []; - -export const DEFAULT_SORT_COMPARATOR = ( - a: AntdLabeledValue, - b: AntdLabeledValue, - search?: string, -) => { - let aText: string | undefined; - let bText: string | undefined; - if (typeof a.label === 'string' && typeof b.label === 'string') { - aText = a.label; - bText = b.label; - } else if (typeof a.value === 'string' && typeof b.value === 'string') { - aText = a.value; - bText = b.value; - } - // sort selected options first - if (typeof aText === 'string' && typeof bText === 'string') { - if (search) { - return rankedSearchCompare(aText, bText, search); - } - return aText.localeCompare(bText); - } - return (a.value as number) - (b.value as number); -}; - /** * It creates a comparator to check for a specific property. * Can be used with string and number property values. @@ -364,77 +186,6 @@ export const handleFilterOptionHelper = ( export const hasCustomLabels = (options: SelectOptionsType) => options?.some(opt => !!opt?.customLabel); -export interface BaseSelectProps extends AntdExposedProps { - /** - * It enables the user to create new options. - * Can be used with standard or async select types. - * Can be used with any mode, single or multiple. - * False by default. - * */ - allowNewOptions?: boolean; - /** - * It adds the aria-label tag for accessibility standards. - * Must be plain English and localized. - */ - ariaLabel?: string; - /** - * Renders the dropdown - */ - dropdownRender?: ( - menu: ReactElement>, - ) => ReactElement>; - /** - * It adds a header on top of the Select. - * Can be any ReactNode. - */ - header?: ReactNode; - /** - * It adds a helper text on top of the Select options - * with additional context to help with the interaction. - */ - helperText?: string; - /** - * It allows to define which properties of the option object - * should be looked for when searching. - * By default label and value. - */ - mappedMode?: 'multiple' | 'tags'; - /** - * It defines whether the Select should allow for the - * selection of multiple options or single. - * Single by default. - */ - mode?: 'single' | 'multiple'; - /** - * Deprecated. - * Prefer ariaLabel instead. - */ - name?: string; // discourage usage - /** - * It allows to define which properties of the option object - * should be looked for when searching. - * By default label and value. - */ - optionFilterProps?: string[]; - /** - * It shows a stop-outlined icon at the far right of a selected - * option instead of the default checkmark. - * Useful to better indicate to the user that by clicking on a selected - * option it will be de-selected. - * False by default. - */ - invertSelection?: boolean; - /** - * Customize how filtered options are sorted while users search. - * Will not apply to predefined `options` array when users are not searching. - */ - sortComparator?: typeof DEFAULT_SORT_COMPARATOR; - - suffixIcon?: ReactNode; - - ref: RefObject; -} - export const renderSelectOptions = (options: SelectOptionsType) => options.map(opt => { const isOptObject = typeof opt === 'object'; diff --git a/superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx b/superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx index 74364fcf98..ddc242a764 100644 --- a/superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/SelectAsyncControl/index.tsx @@ -20,8 +20,7 @@ import React, { useEffect, useState } from 'react'; import { t, SupersetClient } from '@superset-ui/core'; import ControlHeader from 'src/explore/components/ControlHeader'; import { Select } from 'src/components'; -import { SelectProps } from 'src/components/Select/Select'; -import { SelectOptionsType } from 'src/components/Select/utils'; +import { SelectOptionsType, SelectProps } from 'src/components/Select/types'; import { SelectValue, LabeledValue } from 'antd/lib/select'; import withToasts from 'src/components/MessageToasts/withToasts'; import { getClientErrorObject } from 'src/utils/getClientErrorObject';