diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts index 365a7e4ecbaa4..ff6939e9afd85 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/nativeFilters.test.ts @@ -404,9 +404,9 @@ describe('Horizontal FilterBar', () => { saveNativeFilterSettings([SAMPLE_CHART]); cy.getBySel('filter-bar').within(() => { cy.get(nativeFilters.filterItem).contains('Albania').should('be.visible'); - cy.get(nativeFilters.filterItem).contains('+1').should('be.visible'); + cy.get(nativeFilters.filterItem).contains('+ 1 ...').should('be.visible'); cy.get('.ant-select-selection-search-input').click(); - cy.get(nativeFilters.filterItem).contains('+2').should('be.visible'); + cy.get(nativeFilters.filterItem).contains('+ 2 ...').should('be.visible'); }); }); }); diff --git a/superset-frontend/src/components/Select/AsyncSelect.tsx b/superset-frontend/src/components/Select/AsyncSelect.tsx index 4863095467e3d..1e1307a5880e0 100644 --- a/superset-frontend/src/components/Select/AsyncSelect.tsx +++ b/superset-frontend/src/components/Select/AsyncSelect.tsx @@ -71,7 +71,7 @@ import { TOKEN_SEPARATORS, DEFAULT_SORT_COMPARATOR, } from './constants'; -import { oneLineTagRender } from './CustomTag'; +import { customTagRender } from './CustomTag'; const Error = ({ error }: { error: string }) => ( @@ -517,7 +517,7 @@ const AsyncSelect = forwardRef( ) } oneLine={oneLine} - tagRender={oneLine ? oneLineTagRender : undefined} + tagRender={customTagRender} {...props} ref={ref} > diff --git a/superset-frontend/src/components/Select/CustomTag.tsx b/superset-frontend/src/components/Select/CustomTag.tsx index 57aa37c81b68c..a7ffe10f6d54f 100644 --- a/superset-frontend/src/components/Select/CustomTag.tsx +++ b/superset-frontend/src/components/Select/CustomTag.tsx @@ -22,6 +22,8 @@ import { styled } from '@superset-ui/core'; import { useCSSTextTruncation } from 'src/hooks/useTruncation'; import { Tooltip } from '../Tooltip'; import { CustomTagProps } from './types'; +import { SELECT_ALL_VALUE } from './utils'; +import { NoElement } from './styles'; const StyledTag = styled(AntdTag)` & .ant-tag-close-icon { @@ -51,10 +53,10 @@ const Tag = (props: any) => { }; /** - * Custom tag renderer dedicated for oneLine mode + * Custom tag renderer */ -export const oneLineTagRender = (props: CustomTagProps) => { - const { label } = props; +export const customTagRender = (props: CustomTagProps) => { + const { label, value } = props; const onPreventMouseDown = (event: React.MouseEvent) => { // if close icon is clicked, stop propagation to avoid opening the dropdown @@ -69,9 +71,12 @@ export const oneLineTagRender = (props: CustomTagProps) => { } }; - return ( - - {label} - - ); + if (value !== SELECT_ALL_VALUE) { + return ( + + {label} + + ); + } + return ; }; diff --git a/superset-frontend/src/components/Select/Select.stories.tsx b/superset-frontend/src/components/Select/Select.stories.tsx index 4a5d3551f02fc..6c774fe169d01 100644 --- a/superset-frontend/src/components/Select/Select.stories.tsx +++ b/superset-frontend/src/components/Select/Select.stories.tsx @@ -141,6 +141,13 @@ const ARG_TYPES = { Requires '"mode=multiple"'. `, }, + maxTagCount: { + defaultValue: 4, + description: `Sets maxTagCount attribute. The overflow tag is displayed in + place of the remaining items. + Requires '"mode=multiple"'. + `, + }, }; const mountHeader = (type: String) => { @@ -207,6 +214,7 @@ InteractiveSelect.args = { placeholder: 'Select ...', optionFilterProps: ['value', 'label', 'custom'], oneLine: false, + maxTagCount: 4, }; InteractiveSelect.argTypes = { diff --git a/superset-frontend/src/components/Select/Select.test.tsx b/superset-frontend/src/components/Select/Select.test.tsx index cb4548633eb29..52f834d7cda0d 100644 --- a/superset-frontend/src/components/Select/Select.test.tsx +++ b/superset-frontend/src/components/Select/Select.test.tsx @@ -19,7 +19,8 @@ import React from 'react'; import { render, screen, waitFor, within } from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; -import { Select } from 'src/components'; +import Select from 'src/components/Select/Select'; +import { SELECT_ALL_VALUE } from './utils'; const ARIA_LABEL = 'Test'; const NEW_OPTION = 'Kyle'; @@ -64,6 +65,9 @@ const defaultProps = { showSearch: true, }; +const selectAllOptionLabel = (numOptions: number) => + `${String(SELECT_ALL_VALUE)} (${numOptions})`; + const getElementByClassName = (className: string) => document.querySelector(className)! as HTMLElement; @@ -89,7 +93,12 @@ const findSelectValue = () => waitFor(() => getElementByClassName('.ant-select-selection-item')); const findAllSelectValues = () => - waitFor(() => getElementsByClassName('.ant-select-selection-item')); + waitFor(() => [...getElementsByClassName('.ant-select-selection-item')]); + +const findAllCheckedValues = () => + waitFor(() => [ + ...getElementsByClassName('.ant-select-item-option-selected'), + ]); const clearAll = () => userEvent.click(screen.getByLabelText('close-circle')); @@ -209,26 +218,37 @@ test('should sort selected to the top when in multi mode', async () => { let labels = originalLabels.slice(); await open(); - userEvent.click(await findSelectOption(labels[1])); - expect(await matchOrder(labels)).toBe(true); + userEvent.click(await findSelectOption(labels[2])); + expect( + await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]), + ).toBe(true); await type('{esc}'); await open(); - labels = labels.splice(1, 1).concat(labels); - expect(await matchOrder(labels)).toBe(true); + labels = labels.splice(2, 1).concat(labels); + expect( + await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]), + ).toBe(true); await open(); userEvent.click(await findSelectOption(labels[5])); await type('{esc}'); await open(); labels = [labels.splice(0, 1)[0], labels.splice(4, 1)[0]].concat(labels); - expect(await matchOrder(labels)).toBe(true); + expect( + await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]), + ).toBe(true); // should revert to original order clearAll(); await type('{esc}'); await open(); - expect(await matchOrder(originalLabels)).toBe(true); + expect( + await matchOrder([ + selectAllOptionLabel(originalLabels.length), + ...originalLabels, + ]), + ).toBe(true); }); test('searches for label or value', async () => { @@ -440,7 +460,7 @@ test('changes the selected item in single mode', async () => { label: firstOption.label, value: firstOption.value, }), - firstOption, + expect.objectContaining(firstOption), ); userEvent.click(await findSelectOption(secondOption.label)); expect(onChange).toHaveBeenCalledWith( @@ -448,7 +468,7 @@ test('changes the selected item in single mode', async () => { label: secondOption.label, value: secondOption.value, }), - secondOption, + expect.objectContaining(secondOption), ); expect(await findSelectValue()).toHaveTextContent(secondOption.label); }); @@ -566,6 +586,136 @@ test('finds an element with a numeric value and does not duplicate the options', expect(await querySelectOption('11')).not.toBeInTheDocument(); }); +test('render "Select all" for multi select', async () => { + render(); + await open(); + expect( + screen.queryByText(selectAllOptionLabel(OPTIONS.length)), + ).not.toBeInTheDocument(); +}); + +test('does not render "Select all" for an empty multiple select', async () => { + render(); + await open(); + await type('Select'); + expect( + screen.queryByText(selectAllOptionLabel(OPTIONS.length)), + ).not.toBeInTheDocument(); +}); + +test('does not render "Select all" as one of the tags after selection', async () => { + render(, + ); + await open(); + const options = await findAllSelectOptions(); + expect(options[0]).toHaveTextContent(selectAllOptionLabel(10)); + expect(options[1]).toHaveTextContent(selected.label); +}); + +test('selects all values', async () => { + render( + , + ); + await open(); + userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length))); + let values = await findAllSelectValues(); + expect(values.length).toBe(1); + expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`); + userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length))); + values = await findAllSelectValues(); + expect(values.length).toBe(0); +}); + +test('deselecting a value also deselects "Select all"', async () => { + render( + , + ); + await open(); + const options = await findAllSelectOptions(); + options.forEach((option, index) => { + // skip select all + if (index > 0) { + userEvent.click(option); + } + }); + const values = await findAllSelectValues(); + expect(values[0]).toHaveTextContent(`+ 10 ...`); +}); + test('Renders only 1 tag and an overflow tag in oneLine mode', () => { render( , + ); + await open(); + userEvent.click(await findSelectOption(selectAllOptionLabel(10))); + const values = await findAllSelectValues(); + // maxTagCount is 0 so the +N tag should be + 10 ... + expect(values[0]).toHaveTextContent('+ 10 ...'); +}); + +test('"Select All" is checked when unchecking a newly added option and all the other options are still selected', async () => { + render( + , + ); + await open(); + expect(screen.queryByText(selectAllOptionLabel(0))).not.toBeInTheDocument(); + await type(`${NEW_OPTION}{enter}`); + expect(screen.queryByText(selectAllOptionLabel(1))).not.toBeInTheDocument(); + await type(`Kyle2{enter}`); + expect(screen.queryByText(selectAllOptionLabel(2))).toBeInTheDocument(); +}); + /* TODO: Add tests that require scroll interaction. Needs further investigation. - Fetches more data when scrolling and more data is available diff --git a/superset-frontend/src/components/Select/Select.tsx b/superset-frontend/src/components/Select/Select.tsx index 5550463492231..70ca4e6903342 100644 --- a/superset-frontend/src/components/Select/Select.tsx +++ b/superset-frontend/src/components/Select/Select.tsx @@ -25,20 +25,26 @@ import React, { useState, useCallback, } from 'react'; -import { ensureIsArray, t } from '@superset-ui/core'; -import { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; +import { + ensureIsArray, + formatNumber, + NumberFormats, + t, +} from '@superset-ui/core'; +import AntdSelect, { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; import { isEqual } from 'lodash'; import { getValue, hasOption, isLabeledValue, renderSelectOptions, - hasCustomLabels, sortSelectedFirstHelper, sortComparatorWithSearchHelper, handleFilterOptionHelper, dropDownRenderHelper, getSuffixIcon, + SELECT_ALL_VALUE, + selectAllOption, } from './utils'; import { SelectOptionsType, SelectProps } from './types'; import { @@ -54,7 +60,7 @@ import { TOKEN_SEPARATORS, DEFAULT_SORT_COMPARATOR, } from './constants'; -import { oneLineTagRender } from './CustomTag'; +import { customTagRender } from './CustomTag'; /** * This component is a customized version of the Antdesign 4.X Select component @@ -125,6 +131,8 @@ const Select = forwardRef( ? 'tags' : 'multiple'; + const { Option } = AntdSelect; + const sortSelectedFirst = useCallback( (a: AntdLabeledValue, b: AntdLabeledValue) => sortSelectedFirstHelper(a, b, selectValue), @@ -162,11 +170,23 @@ const Select = forwardRef( .map(opt => isLabeledValue(opt) ? opt : { value: opt, label: String(opt) }, ); - return missingValues.length > 0 - ? missingValues.concat(selectOptions) - : selectOptions; + const result = + missingValues.length > 0 + ? missingValues.concat(selectOptions) + : selectOptions; + return result.filter(opt => opt.value !== SELECT_ALL_VALUE); }, [selectOptions, selectValue]); + const selectAllEnabled = useMemo( + () => !isSingleMode && fullSelectOptions.length > 1 && !inputValue, + [fullSelectOptions, isSingleMode, inputValue], + ); + + const selectAllMode = useMemo( + () => ensureIsArray(selectValue).length === fullSelectOptions.length + 1, + [selectValue, fullSelectOptions], + ); + const handleOnSelect = ( selectedItem: string | number | AntdLabeledValue | undefined, ) => { @@ -177,11 +197,29 @@ const Select = forwardRef( const array = ensureIsArray(previousState); const value = getValue(selectedItem); // Tokenized values can contain duplicated values + if (value === getValue(SELECT_ALL_VALUE)) { + if (isLabeledValue(selectedItem)) { + return [ + ...fullSelectOptions, + selectAllOption, + ] as AntdLabeledValue[]; + } + return [ + SELECT_ALL_VALUE, + ...fullSelectOptions.map(opt => opt.value), + ] as AntdLabeledValue[]; + } if (!hasOption(value, array)) { const result = [...array, selectedItem]; - return isLabeledValue(selectedItem) - ? (result as AntdLabeledValue[]) - : (result as (string | number)[]); + if ( + result.length === fullSelectOptions.length && + selectAllEnabled + ) { + return isLabeledValue(selectedItem) + ? ([...result, selectAllOption] as AntdLabeledValue[]) + : ([...result, SELECT_ALL_VALUE] as (string | number)[]); + } + return result as AntdLabeledValue[]; } return previousState; }); @@ -193,14 +231,23 @@ const Select = forwardRef( value: string | number | AntdLabeledValue | undefined, ) => { if (Array.isArray(selectValue)) { - if (isLabeledValue(value)) { - const array = selectValue as AntdLabeledValue[]; - setSelectValue( - array.filter(element => element.value !== value.value), - ); + if (getValue(value) === getValue(SELECT_ALL_VALUE)) { + setSelectValue(undefined); } else { - const array = selectValue as (string | number)[]; - setSelectValue(array.filter(element => element !== value)); + let array = selectValue as AntdLabeledValue[]; + array = array.filter( + element => getValue(element) !== getValue(value), + ); + // if this was not a new item, deselect select all option + if ( + selectAllMode && + selectOptions.some(opt => opt.value === getValue(value)) + ) { + array = array.filter( + element => getValue(element) !== SELECT_ALL_VALUE, + ); + } + setSelectValue(array); } } setInputValue(''); @@ -215,7 +262,7 @@ const Select = forwardRef( value: searchValue, isNewOption: true, }; - const cleanSelectOptions = fullSelectOptions.filter( + const cleanSelectOptions = ensureIsArray(fullSelectOptions).filter( opt => !opt.isNewOption || hasOption(opt.value, selectValue), ); const newOptions = newOption @@ -277,6 +324,97 @@ const Select = forwardRef( setSelectValue(value); }, [value]); + useEffect(() => { + // if all values are selected, add select all to value + if ( + !isSingleMode && + ensureIsArray(value).length === fullSelectOptions.length && + fullSelectOptions.length > 0 + ) { + setSelectValue( + labelInValue + ? ([...ensureIsArray(value), selectAllOption] as AntdLabeledValue[]) + : ([ + ...ensureIsArray(value), + SELECT_ALL_VALUE, + ] as AntdLabeledValue[]), + ); + } + }, [value, isSingleMode, labelInValue, fullSelectOptions.length]); + + useEffect(() => { + const checkSelectAll = ensureIsArray(selectValue).some( + v => getValue(v) === SELECT_ALL_VALUE, + ); + if (checkSelectAll && !selectAllMode) { + setSelectValue( + labelInValue + ? ([...fullSelectOptions, selectAllOption] as AntdLabeledValue[]) + : ([...fullSelectOptions, SELECT_ALL_VALUE] as AntdLabeledValue[]), + ); + } + }, [selectValue, selectAllMode, labelInValue, fullSelectOptions]); + + const selectAllLabel = useMemo( + () => () => + `${SELECT_ALL_VALUE} (${formatNumber( + NumberFormats.INTEGER, + fullSelectOptions.length, + )})`, + [fullSelectOptions.length], + ); + + const handleOnChange = (values: any, options: any) => { + // intercept onChange call to handle the select all case + // if the "select all" option is selected, we want to send all options to the onChange, + // otherwise we want to remove + let newValues = values; + let newOptions = options; + if (!isSingleMode) { + if ( + ensureIsArray(newValues).some( + val => getValue(val) === SELECT_ALL_VALUE, + ) + ) { + // send all options to onchange if all are not currently there + if (!selectAllMode) { + newValues = labelInValue + ? fullSelectOptions.map(opt => ({ + key: opt.value, + value: opt.value, + label: opt.label, + })) + : fullSelectOptions.map(opt => opt.value); + newOptions = fullSelectOptions.map(opt => ({ + children: opt.label, + key: opt.value, + value: opt.value, + label: opt.label, + })); + } else { + newValues = ensureIsArray(values).filter( + (val: any) => getValue(val) !== SELECT_ALL_VALUE, + ); + } + } else if ( + ensureIsArray(values).length === fullSelectOptions.length && + selectAllMode + ) { + newValues = []; + newValues = []; + } + } + onChange?.(newValues, newOptions); + }; + + const customMaxTagPlaceholder = () => { + const num_selected = ensureIsArray(selectValue).length; + const num_shown = maxTagCount as number; + return selectAllMode + ? `+ ${num_selected - num_shown - 1} ...` + : `+ ${num_selected - num_shown} ...`; + }; + return ( {header && ( @@ -294,6 +432,7 @@ const Select = forwardRef( headerPosition={headerPosition} labelInValue={labelInValue} maxTagCount={maxTagCount} + maxTagPlaceholder={customMaxTagPlaceholder} mode={mappedMode} notFoundContent={isLoading ? t('Loading...') : notFoundContent} onDeselect={handleOnDeselect} @@ -302,8 +441,7 @@ const Select = forwardRef( onSearch={shouldShowSearch ? handleOnSearch : undefined} onSelect={handleOnSelect} onClear={handleClear} - onChange={onChange} - options={hasCustomLabels(options) ? undefined : fullSelectOptions} + onChange={handleOnChange} placeholder={placeholder} showSearch={shouldShowSearch} showArrow @@ -322,11 +460,20 @@ const Select = forwardRef( ) } oneLine={oneLine} - tagRender={oneLine ? oneLineTagRender : undefined} + tagRender={customTagRender} {...props} ref={ref} > - {hasCustomLabels(options) && renderSelectOptions(fullSelectOptions)} + {selectAllEnabled && ( + + )} + {renderSelectOptions(fullSelectOptions)} ); diff --git a/superset-frontend/src/components/Select/styles.tsx b/superset-frontend/src/components/Select/styles.tsx index 7064ae55a3720..b954a04fc7a8b 100644 --- a/superset-frontend/src/components/Select/styles.tsx +++ b/superset-frontend/src/components/Select/styles.tsx @@ -18,7 +18,7 @@ */ import { styled } from '@superset-ui/core'; import Icons from 'src/components/Icons'; -import { Spin } from 'antd'; +import { Spin, Tag } from 'antd'; import AntdSelect from 'antd/lib/select'; export const StyledHeader = styled.span<{ headerPosition: string }>` @@ -74,6 +74,18 @@ export const StyledSelect = styled(AntdSelect, { `} `; +export const NoElement = styled.span` + display: none; +`; + +export const StyledTag = styled(Tag)` + ${({ theme }) => ` + background: ${theme.colors.grayscale.light3}; + font-size: ${theme.typography.sizes.m}px; + border: none; + `} +`; + export const StyledStopOutlined = styled(Icons.StopOutlined)` vertical-align: 0; `; diff --git a/superset-frontend/src/components/Select/types.ts b/superset-frontend/src/components/Select/types.ts index 76f7acd0a288b..6e4c7f072db80 100644 --- a/superset-frontend/src/components/Select/types.ts +++ b/superset-frontend/src/components/Select/types.ts @@ -158,8 +158,6 @@ 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; } @@ -215,4 +213,5 @@ export interface AsyncSelectProps extends BaseSelectProps { export type CustomTagProps = HTMLSpanElement & TagProps & { label: ReactNode; + value: string; }; diff --git a/superset-frontend/src/components/Select/utils.tsx b/superset-frontend/src/components/Select/utils.tsx index 0d499b4f1da4a..e8199358222b5 100644 --- a/superset-frontend/src/components/Select/utils.tsx +++ b/superset-frontend/src/components/Select/utils.tsx @@ -25,6 +25,12 @@ import { LabeledValue, RawValue, SelectOptionsType, V } from './types'; const { Option } = AntdSelect; +export const SELECT_ALL_VALUE: RawValue = 'Select All'; +export const selectAllOption = { + value: SELECT_ALL_VALUE, + label: String(SELECT_ALL_VALUE), +}; + export function isObject(value: unknown): value is Record { return ( value !== null && diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx index c19754b518b29..fb8e20093c921 100644 --- a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx +++ b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx @@ -333,9 +333,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) { // @ts-ignore options={options} sortComparator={sortComparator} - maxTagPlaceholder={(val: AntdLabeledValue[]) => ( - +{val.length} - )} onDropdownVisibleChange={setFilterActive} />