This is an automated email from the ASF dual-hosted git repository.
diegopucci pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new a53907a646 feat(Select): Select all and Deselect all that works on
visible items while searching (#33043)
a53907a646 is described below
commit a53907a646f7113180d2778ea3ff33df1f1a5718
Author: Mehmet Salih Yavuz <[email protected]>
AuthorDate: Thu Apr 17 18:04:08 2025 +0300
feat(Select): Select all and Deselect all that works on visible items while
searching (#33043)
---
.../DatabaseSelector/DatabaseSelector.test.tsx | 16 +-
.../src/components/Select/CustomTag.tsx | 17 +-
.../src/components/Select/Select.test.tsx | 214 ++++----------
superset-frontend/src/components/Select/Select.tsx | 323 ++++++++++++---------
superset-frontend/src/components/Select/styles.tsx | 20 +-
superset-frontend/src/components/Select/utils.tsx | 8 +-
.../components/controls/SelectControl.test.jsx | 6 +-
7 files changed, 285 insertions(+), 319 deletions(-)
diff --git
a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
index 62e0092b00..1adb20ad80 100644
---
a/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
+++
b/superset-frontend/src/components/DatabaseSelector/DatabaseSelector.test.tsx
@@ -328,12 +328,16 @@ test('Should schema select display options', async () => {
});
expect(select).toBeInTheDocument();
userEvent.click(select);
- expect(
- await screen.findByRole('option', { name: 'public' }),
- ).toBeInTheDocument();
- expect(
- await screen.findByRole('option', { name: 'information_schema' }),
- ).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
+ });
+ const publicOption = await screen.findByRole('option', { name: 'public' });
+ expect(publicOption).toBeInTheDocument();
+
+ const infoSchemaOption = await screen.findByRole('option', {
+ name: 'information_schema',
+ });
+ expect(infoSchemaOption).toBeInTheDocument();
});
test('Sends the correct db when changing the database', async () => {
diff --git a/superset-frontend/src/components/Select/CustomTag.tsx
b/superset-frontend/src/components/Select/CustomTag.tsx
index bb5a8fc7cd..ab5d135ec9 100644
--- a/superset-frontend/src/components/Select/CustomTag.tsx
+++ b/superset-frontend/src/components/Select/CustomTag.tsx
@@ -23,8 +23,6 @@ import { styled, useCSSTextTruncation } from
'@superset-ui/core';
import { Tooltip } from '../Tooltip';
import { CustomCloseIcon } from '../Tags/Tag';
import { CustomTagProps } from './types';
-import { SELECT_ALL_VALUE } from './utils';
-import { NoElement } from './styles';
const StyledTag = styled(AntdTag)`
& .ant-tag-close-icon {
@@ -61,7 +59,7 @@ const Tag = (props: any) => {
* Custom tag renderer
*/
export const customTagRender = (props: CustomTagProps) => {
- const { label, value } = props;
+ const { label } = props;
const onPreventMouseDown = (event: MouseEvent<HTMLElement>) => {
// if close icon is clicked, stop propagation to avoid opening the dropdown
@@ -76,12 +74,9 @@ export const customTagRender = (props: CustomTagProps) => {
}
};
- if (value !== SELECT_ALL_VALUE) {
- return (
- <Tag onMouseDown={onPreventMouseDown} {...(props as object)}>
- {label}
- </Tag>
- );
- }
- return <NoElement />;
+ return (
+ <Tag onMouseDown={onPreventMouseDown} {...(props as object)}>
+ {label}
+ </Tag>
+ );
};
diff --git a/superset-frontend/src/components/Select/Select.test.tsx
b/superset-frontend/src/components/Select/Select.test.tsx
index 9e561ffd7f..4ed6a384b3 100644
--- a/superset-frontend/src/components/Select/Select.test.tsx
+++ b/superset-frontend/src/components/Select/Select.test.tsx
@@ -26,7 +26,6 @@ import {
within,
} from 'spec/helpers/testing-library';
import Select from 'src/components/Select/Select';
-import { SELECT_ALL_VALUE } from './utils';
type Option = {
label: string;
@@ -77,9 +76,6 @@ const defaultProps = {
showSearch: true,
};
-const selectAllOptionLabel = (numOptions: number) =>
- `${String(SELECT_ALL_VALUE)} (${numOptions})`;
-
const getElementByClassName = (className: string) =>
document.querySelector(className)! as HTMLElement;
@@ -88,6 +84,9 @@ const getElementsByClassName = (className: string) =>
const getSelect = () => screen.getByRole('combobox', { name: ARIA_LABEL });
+const selectAllButtonText = (length: number) => `Select all (${length})`;
+const deselectAllButtonText = (length: number) => `Deselect all (${length})`;
+
const findSelectOption = (text: string) =>
waitFor(() =>
within(getElementByClassName('.rc-virtual-list')).getByText(text),
@@ -110,11 +109,6 @@ const findSelectValue = () =>
const findAllSelectValues = () =>
waitFor(() => [...getElementsByClassName('.ant-select-selection-item')]);
-const findAllCheckedValues = () =>
- waitFor(() => [
- ...getElementsByClassName('.ant-select-item-option-selected'),
- ]);
-
const clearAll = () => userEvent.click(screen.getByLabelText('close-circle'));
const matchOrder = async (expectedLabels: string[]) => {
@@ -255,33 +249,22 @@ test('should sort selected to the top when in multi
mode', async () => {
await open();
userEvent.click(await findSelectOption(labels[2]));
- expect(
- await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
- ).toBe(true);
+ expect(await matchOrder(labels)).toBe(true);
await reopen();
labels = labels.splice(2, 1).concat(labels);
- expect(
- await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
- ).toBe(true);
+ expect(await matchOrder(labels)).toBe(true);
await open();
userEvent.click(await findSelectOption(labels[5]));
await reopen();
labels = [labels.splice(0, 1)[0], labels.splice(4, 1)[0]].concat(labels);
- expect(
- await matchOrder([selectAllOptionLabel(originalLabels.length), ...labels]),
- ).toBe(true);
+ expect(await matchOrder(labels)).toBe(true);
// should revert to original order
clearAll();
await reopen();
- expect(
- await matchOrder([
- selectAllOptionLabel(originalLabels.length),
- ...originalLabels,
- ]),
- ).toBe(true);
+ expect(await matchOrder(originalLabels)).toBe(true);
});
test('searches for label or value', async () => {
@@ -634,15 +617,19 @@ test('finds an element with a numeric value and does not
duplicate the options',
test('render "Select all" for multi select', async () => {
render(<Select {...defaultProps} mode="multiple" options={OPTIONS} />);
await open();
- const options = await findAllSelectOptions();
- expect(options[0]).toHaveTextContent(selectAllOptionLabel(OPTIONS.length));
+ expect(
+ await screen.findByText(selectAllButtonText(OPTIONS.length)),
+ ).toBeInTheDocument();
});
test('does not render "Select all" for single select', async () => {
render(<Select {...defaultProps} options={OPTIONS} mode="single" />);
await open();
expect(
- screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
+ screen.queryByText(selectAllButtonText(OPTIONS.length)),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(selectAllButtonText(OPTIONS.length)),
).not.toBeInTheDocument();
});
@@ -650,45 +637,21 @@ test('does not render "Select all" for an empty multiple
select', async () => {
render(<Select {...defaultProps} options={[]} mode="multiple" />);
await open();
expect(
- screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
+ screen.queryByText(selectAllButtonText(OPTIONS.length)),
).not.toBeInTheDocument();
});
-test('does not render "Select all" when searching', async () => {
+test('Renders "Select all" when searching', async () => {
render(<Select {...defaultProps} options={OPTIONS} mode="multiple" />);
await open();
await type('Select');
await waitFor(() =>
expect(
- screen.queryByText(selectAllOptionLabel(OPTIONS.length)),
+ screen.queryByText(selectAllButtonText(OPTIONS.length)),
).not.toBeInTheDocument(),
);
});
-test('does not render "Select all" as one of the tags after selection', async
() => {
- render(<Select {...defaultProps} options={OPTIONS} mode="multiple" />);
- await open();
- userEvent.click(await
findSelectOption(selectAllOptionLabel(OPTIONS.length)));
- const values = await findAllSelectValues();
-
expect(values[0]).not.toHaveTextContent(selectAllOptionLabel(OPTIONS.length));
-});
-
-test('keeps "Select all" at the top after a selection', async () => {
- const selected = OPTIONS[2];
- render(
- <Select
- {...defaultProps}
- options={OPTIONS.slice(0, 10)}
- mode="multiple"
- value={[selected]}
- />,
- );
- await open();
- const options = await findAllSelectOptions();
- expect(options[0]).toHaveTextContent(selectAllOptionLabel(10));
- expect(options[1]).toHaveTextContent(selected.label);
-});
-
test('selects all values', async () => {
render(
<Select
@@ -699,7 +662,7 @@ test('selects all values', async () => {
/>,
);
await open();
- userEvent.click(await
findSelectOption(selectAllOptionLabel(OPTIONS.length)));
+ userEvent.click(await
screen.findByText(selectAllButtonText(OPTIONS.length)));
const values = await findAllSelectValues();
expect(values.length).toBe(1);
expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
@@ -715,33 +678,17 @@ test('unselects all values', async () => {
/>,
);
await open();
- userEvent.click(await
findSelectOption(selectAllOptionLabel(OPTIONS.length)));
+ userEvent.click(await
screen.findByText(selectAllButtonText(OPTIONS.length)));
let values = await findAllSelectValues();
expect(values.length).toBe(1);
expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
- userEvent.click(await
findSelectOption(selectAllOptionLabel(OPTIONS.length)));
+ userEvent.click(
+ await screen.findByText(deselectAllButtonText(OPTIONS.length)),
+ );
values = await findAllSelectValues();
expect(values.length).toBe(0);
});
-test('deselecting a value also deselects "Select all"', async () => {
- render(
- <Select
- {...defaultProps}
- options={OPTIONS.slice(0, 10)}
- mode="multiple"
- maxTagCount={0}
- />,
- );
- await open();
- userEvent.click(await findSelectOption(selectAllOptionLabel(10)));
- let values = await findAllCheckedValues();
- expect(values[0]).toHaveTextContent(selectAllOptionLabel(10));
- userEvent.click(await findSelectOption(OPTIONS[0].label));
- values = await findAllCheckedValues();
- expect(values[0]).not.toHaveTextContent(selectAllOptionLabel(10));
-});
-
test('deselecting a new value also removes it from the options', async () => {
render(
<Select
@@ -760,27 +707,6 @@ test('deselecting a new value also removes it from the
options', async () => {
expect(await querySelectOption(NEW_OPTION)).not.toBeInTheDocument();
});
-test('selecting all values also selects "Select all"', async () => {
- render(
- <Select
- {...defaultProps}
- options={OPTIONS.slice(0, 10)}
- mode="multiple"
- maxTagCount={0}
- />,
- );
- 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(
<Select
@@ -829,56 +755,12 @@ test('Renders only an overflow tag if dropdown is open in
oneLine mode', async (
expect(withinSelector.getByText('+ 2 ...')).toBeVisible();
});
-test('+N tag does not count the "Select All" option', async () => {
- render(
- <Select
- {...defaultProps}
- options={OPTIONS.slice(0, 10)}
- mode="multiple"
- maxTagCount={0}
- />,
- );
- 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(
- <Select
- {...defaultProps}
- options={OPTIONS.slice(0, 10)}
- mode="multiple"
- allowNewOptions
- />,
- );
- await open();
- userEvent.click(await findSelectOption(selectAllOptionLabel(10)));
- expect(await findSelectOption(selectAllOptionLabel(10))).toBeInTheDocument();
- // add a new option
- await type(NEW_OPTION);
- expect(await findSelectOption(NEW_OPTION)).toBeInTheDocument();
- clearTypedText();
- expect(await findSelectOption(selectAllOptionLabel(11))).toBeInTheDocument();
- // select all should be selected
- let values = await findAllCheckedValues();
- expect(values[0]).toHaveTextContent(selectAllOptionLabel(11));
- // remove new option
- userEvent.click(await findSelectOption(NEW_OPTION));
- // select all should still be selected
- values = await findAllCheckedValues();
- expect(values[0]).toHaveTextContent(selectAllOptionLabel(10));
- expect(await findSelectOption(selectAllOptionLabel(10))).toBeInTheDocument();
-});
-
-test('does not render "Select All" when there are 0 or 1 options', async () =>
{
+test('does not render "Select all" when there are 0 or 1 options', async () =>
{
const { rerender } = render(
<Select {...defaultProps} options={[]} mode="multiple" allowNewOptions />,
);
await open();
- expect(screen.queryByText(selectAllOptionLabel(0))).not.toBeInTheDocument();
+ expect(screen.queryByText(selectAllButtonText(0))).not.toBeInTheDocument();
rerender(
<Select
{...defaultProps}
@@ -888,7 +770,7 @@ test('does not render "Select All" when there are 0 or 1
options', async () => {
/>,
);
await open();
- expect(screen.queryByText(selectAllOptionLabel(1))).not.toBeInTheDocument();
+ expect(screen.queryByText(selectAllButtonText(1))).not.toBeInTheDocument();
rerender(
<Select
{...defaultProps}
@@ -898,10 +780,10 @@ test('does not render "Select All" when there are 0 or 1
options', async () => {
/>,
);
await open();
- expect(screen.getByText(selectAllOptionLabel(2))).toBeInTheDocument();
+ expect(screen.getByText(selectAllButtonText(2))).toBeInTheDocument();
});
-test('do not count unselected disabled options in "Select All"', async () => {
+test('do not count unselected disabled options in "Select all"', async () => {
const options = [...OPTIONS];
options[0].disabled = true;
options[1].disabled = true;
@@ -915,13 +797,39 @@ test('do not count unselected disabled options in "Select
All"', async () => {
);
await open();
// We have 2 options disabled but one is selected initially
- // Select All should count one and ignore the other
+ // Select all should count one and ignore the other
expect(
- screen.getByText(selectAllOptionLabel(OPTIONS.length - 1)),
+ screen.getByText(selectAllButtonText(OPTIONS.length - 1)),
).toBeInTheDocument();
});
-test('"Select All" does not affect disabled options', async () => {
+test('"Deselect all" counts all selected options', async () => {
+ render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
+ await open();
+ userEvent.click(await findSelectOption('Ava'));
+ expect(await
screen.findByText(deselectAllButtonText(1))).toBeInTheDocument();
+});
+
+test('"Deselect all" counts new selected options', async () => {
+ render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
+ await open();
+ await type(NEW_OPTION);
+ userEvent.click(await findSelectOption(NEW_OPTION));
+ clearTypedText();
+ await open();
+ userEvent.click(await findSelectOption('Ava'));
+ expect(await
screen.findByText(deselectAllButtonText(2))).toBeInTheDocument();
+});
+
+test('"Select all" does not count unselected new options', async () => {
+ render(<Select {...defaultProps} allowNewOptions mode="multiple" />);
+ await open();
+ await type('er');
+ // We have 5 options matching the search
+ expect(await screen.findByText(selectAllButtonText(5))).toBeInTheDocument();
+});
+
+test('"Select all" does not affect disabled options', async () => {
const options = [...OPTIONS];
options[0].disabled = true;
options[1].disabled = true;
@@ -939,14 +847,14 @@ test('"Select All" does not affect disabled options',
async () => {
expect(await findSelectValue()).toHaveTextContent(options[0].label);
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
- // Checking Select All shouldn't affect the disabled options
- const selectAll = selectAllOptionLabel(OPTIONS.length - 1);
- userEvent.click(await findSelectOption(selectAll));
+ // Checking Select all shouldn't affect the disabled options
+ const selectAll = selectAllButtonText(OPTIONS.length - 1);
+ userEvent.click(await screen.findByText(selectAll));
expect(await findSelectValue()).toHaveTextContent(options[0].label);
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
- // Unchecking Select All shouldn't affect the disabled options
- userEvent.click(await findSelectOption(selectAll));
+ // Unchecking Select all shouldn't affect the disabled options
+ userEvent.click(await screen.findByText(selectAll));
expect(await findSelectValue()).toHaveTextContent(options[0].label);
expect(await findSelectValue()).not.toHaveTextContent(options[1].label);
});
diff --git a/superset-frontend/src/components/Select/Select.tsx
b/superset-frontend/src/components/Select/Select.tsx
index 273eb2fa3a..efef785616 100644
--- a/superset-frontend/src/components/Select/Select.tsx
+++ b/superset-frontend/src/components/Select/Select.tsx
@@ -28,15 +28,9 @@ import {
ClipboardEvent,
} from 'react';
-import {
- ensureIsArray,
- formatNumber,
- NumberFormats,
- t,
- usePrevious,
-} from '@superset-ui/core';
+import { ensureIsArray, t, usePrevious } from '@superset-ui/core';
// eslint-disable-next-line no-restricted-imports
-import AntdSelect, { LabeledValue as AntdLabeledValue } from
'antd/lib/select'; // TODO: Remove antd
+import { LabeledValue as AntdLabeledValue } from 'antd/lib/select'; // TODO:
Remove antd
import { debounce, isEqual, uniq } from 'lodash';
import { FAST_DEBOUNCE } from 'src/constants';
import {
@@ -49,8 +43,6 @@ import {
handleFilterOptionHelper,
dropDownRenderHelper,
getSuffixIcon,
- SELECT_ALL_VALUE,
- selectAllOption,
mapValues,
mapOptions,
hasCustomLabels,
@@ -60,6 +52,7 @@ import {
} from './utils';
import { RawValue, SelectOptionsType, SelectProps } from './types';
import {
+ StyledBulkActionsContainer,
StyledCheckOutlined,
StyledContainer,
StyledHeader,
@@ -73,6 +66,7 @@ import {
DEFAULT_SORT_COMPARATOR,
} from './constants';
import { customTagRender } from './CustomTag';
+import Button from '../Button';
/**
* This component is a customized version of the Antdesign 4.X Select component
@@ -133,12 +127,13 @@ const Select = forwardRef(
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(loading);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
+ const [isSearching, setIsSearching] = useState(false);
+ const [visibleOptions, setVisibleOptions] =
useState<SelectOptionsType>([]);
const [maxTagCount, setMaxTagCount] = useState(
propsMaxTagCount ?? MAX_TAG_COUNT,
);
const [onChangeCount, setOnChangeCount] = useState(0);
const previousChangeCount = usePrevious(onChangeCount, 0);
-
const fireOnChange = useCallback(
() => setOnChangeCount(onChangeCount + 1),
[onChangeCount],
@@ -152,8 +147,6 @@ const Select = forwardRef(
const mappedMode = isSingleMode ? undefined : 'multiple';
- const { Option } = AntdSelect;
-
const sortSelectedFirst = useCallback(
(a: AntdLabeledValue, b: AntdLabeledValue) =>
sortSelectedFirstHelper(a, b, selectValue),
@@ -204,20 +197,22 @@ const Select = forwardRef(
missingValues.length > 0
? missingValues.concat(selectOptions)
: selectOptions;
- return result.filter(opt => opt.value !== SELECT_ALL_VALUE);
- }, [selectOptions, selectValue]);
+ return result.slice().sort(sortSelectedFirst);
+ }, [selectOptions, selectValue, sortSelectedFirst]);
const enabledOptions = useMemo(
- () => fullSelectOptions.filter(option => !option.disabled),
- [fullSelectOptions],
+ () => visibleOptions.filter(option => !option.disabled),
+ [visibleOptions],
);
const selectAllEligible = useMemo(
() =>
- fullSelectOptions.filter(
- option => hasOption(option.value, selectValue) || !option.disabled,
+ visibleOptions.filter(
+ option =>
+ (hasOption(option.value, selectValue) || !option.disabled) &&
+ !option.isNewOption,
),
- [fullSelectOptions, selectValue],
+ [visibleOptions, selectValue],
);
const selectAllEnabled = useMemo(
@@ -225,14 +220,12 @@ const Select = forwardRef(
!isSingleMode &&
allowSelectAll &&
selectOptions.length > 0 &&
- enabledOptions.length > 1 &&
- !inputValue,
+ enabledOptions.length > 1,
[
isSingleMode,
allowSelectAll,
selectOptions.length,
enabledOptions.length,
- inputValue,
],
);
@@ -241,6 +234,31 @@ const Select = forwardRef(
[selectValue, selectAllEligible],
);
+ const bulkSelectCounts = useMemo(() => {
+ const selectedValuesSet = new Set(
+ ensureIsArray(selectValue).map(getValue),
+ );
+ return visibleOptions.reduce(
+ (acc, option) => {
+ const isSelected = selectedValuesSet.has(option.value);
+ const isDisabled = option.disabled;
+ const isNew = option.isNewOption;
+
+ if (
+ (!isDisabled || isSelected) &&
+ ((isNew && isSelected) || !isNew)
+ ) {
+ acc.selectable += 1;
+ }
+ if (isSelected && !isDisabled) {
+ acc.deselectable += 1;
+ }
+ return acc;
+ },
+ { selectable: 0, deselectable: 0 },
+ );
+ }, [visibleOptions, selectValue]);
+
const handleOnSelect: SelectProps['onSelect'] = (selectedItem, option) => {
if (isSingleMode) {
// on select is fired in single value mode if the same value is
selected
@@ -257,19 +275,6 @@ const Select = forwardRef(
setSelectValue(previousState => {
const array = ensureIsArray(previousState);
const value = getValue(selectedItem);
- // Tokenized values can contain duplicated values
- if (value === getValue(SELECT_ALL_VALUE)) {
- if (isLabeledValue(selectedItem)) {
- return [
- ...selectAllEligible,
- selectAllOption,
- ] as AntdLabeledValue[];
- }
- return [
- SELECT_ALL_VALUE,
- ...selectAllEligible.map(opt => opt.value),
- ] as AntdLabeledValue[];
- }
if (!hasOption(value, array)) {
const result = [...array, selectedItem];
if (
@@ -277,8 +282,8 @@ const Select = forwardRef(
selectAllEnabled
) {
return isLabeledValue(selectedItem)
- ? ([...result, selectAllOption] as AntdLabeledValue[])
- : ([...result, SELECT_ALL_VALUE] as (string | number)[]);
+ ? ([...result] as AntdLabeledValue[])
+ : ([...result] as (string | number)[]);
}
return result as AntdLabeledValue[];
}
@@ -310,37 +315,33 @@ const Select = forwardRef(
const handleOnDeselect: SelectProps['onDeselect'] = (value, option) => {
if (Array.isArray(selectValue)) {
- if (getValue(value) === getValue(SELECT_ALL_VALUE)) {
- clear();
- } else {
- let array = selectValue as AntdLabeledValue[];
- array = array.filter(
- element => getValue(element) !== getValue(value),
+ const array = (selectValue as AntdLabeledValue[]).filter(
+ element => getValue(element) !== getValue(value),
+ );
+ setSelectValue(array);
+
+ // removes new option
+ if (option.isNewOption) {
+ const updatedOptions = fullSelectOptions.filter(
+ option => getValue(option.value) !== getValue(value),
);
- // if this was not a new item, deselect select all option
- if (selectAllMode && !option.isNewOption) {
- array = array.filter(
- element => getValue(element) !== SELECT_ALL_VALUE,
- );
- }
- setSelectValue(array);
-
- // removes new option
- if (option.isNewOption) {
- setSelectOptions(
- fullSelectOptions.filter(
- option => getValue(option.value) !== getValue(value),
- ),
- );
- }
+ setSelectOptions(updatedOptions);
+ setVisibleOptions(updatedOptions);
}
}
fireOnChange();
onDeselect?.(value, option);
};
+ const handleFilterOption = (search: string, option: AntdLabeledValue) =>
+ handleFilterOptionHelper(search, option, optionFilterProps,
filterOption);
+
const handleOnSearch = debounce((search: string) => {
const searchValue = search.trim();
+ setIsSearching(!!searchValue);
+
+ let updatedOptions = selectOptions;
+
if (allowNewOptions) {
const newOption = searchValue &&
!hasOption(searchValue, fullSelectOptions, true) && {
@@ -351,23 +352,27 @@ const Select = forwardRef(
const cleanSelectOptions = ensureIsArray(fullSelectOptions).filter(
opt => !opt.isNewOption || hasOption(opt.value, selectValue),
);
- const newOptions = newOption
+ updatedOptions = newOption
? [newOption, ...cleanSelectOptions]
: cleanSelectOptions;
- setSelectOptions(newOptions);
+ setSelectOptions(updatedOptions);
}
+
+ const filteredOptions = updatedOptions.filter(
+ (option: AntdLabeledValue) => handleFilterOption(search, option),
+ );
+
+ setVisibleOptions(filteredOptions);
setInputValue(searchValue);
onSearch?.(searchValue);
}, FAST_DEBOUNCE);
useEffect(() => () => handleOnSearch.cancel(), [handleOnSearch]);
- const handleFilterOption = (search: string, option: AntdLabeledValue) =>
- handleFilterOptionHelper(search, option, optionFilterProps,
filterOption);
-
const handleOnDropdownVisibleChange = (isDropdownVisible: boolean) => {
setIsDropdownVisible(isDropdownVisible);
+ setVisibleOptions(fullSelectOptions);
// if no search input value, force sort options because it won't be
sorted by
// `filterSort`.
if (isDropdownVisible && !inputValue && selectOptions.length > 1) {
@@ -375,11 +380,101 @@ const Select = forwardRef(
setSelectOptions(initialOptionsSorted);
}
}
+ if (!isDropdownVisible) {
+ setSelectOptions(initialOptionsSorted);
+ }
if (onDropdownVisibleChange) {
onDropdownVisibleChange(isDropdownVisible);
}
};
+ const handleSelectAll = useCallback(() => {
+ if (isSingleMode) return;
+
+ const optionsToSelect = isSearching
+ ? visibleOptions.filter(option => !option.isNewOption)
+ : enabledOptions;
+
+ const currentValues = ensureIsArray(selectValue);
+ const currentValuesSet = new Set(currentValues.map(getValue));
+
+ const newValues = [...currentValues] as AntdLabeledValue[];
+ optionsToSelect.forEach(option => {
+ if (!option.disabled && !currentValuesSet.has(option.value)) {
+ if (labelInValue) {
+ newValues.push({
+ label: option.label,
+ value: option.value,
+ });
+ } else {
+ newValues.push(option.value);
+ }
+ }
+ });
+
+ setSelectValue(newValues);
+ fireOnChange();
+ }, [
+ isSingleMode,
+ isSearching,
+ visibleOptions,
+ enabledOptions,
+ selectValue,
+ labelInValue,
+ fireOnChange,
+ ]);
+
+ const handleDeselectAll = useCallback(() => {
+ if (isSingleMode) return;
+
+ const deselectionValues = new Set(enabledOptions.map(opt => opt.value));
+
+ const newValues = ensureIsArray(selectValue).filter(item => {
+ const itemValue = getValue(item);
+ return !deselectionValues.has(itemValue);
+ }) as AntdLabeledValue[];
+
+ setSelectValue(newValues);
+ fireOnChange();
+ }, [isSingleMode, enabledOptions, selectValue, fireOnChange]);
+
+ const bulkSelectComponent = useMemo(
+ () => (
+ <StyledBulkActionsContainer size={0}>
+ <Button
+ type="link"
+ buttonSize="xsmall"
+ disabled={bulkSelectCounts.selectable === 0}
+ onMouseDown={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleSelectAll();
+ }}
+ >
+ {`${t('Select all')} (${bulkSelectCounts.selectable})`}
+ </Button>
+ <Button
+ type="link"
+ buttonSize="xsmall"
+ disabled={bulkSelectCounts.deselectable === 0}
+ onMouseDown={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ handleDeselectAll();
+ }}
+ >
+ {`${t('Deselect all')} (${bulkSelectCounts.deselectable})`}
+ </Button>
+ </StyledBulkActionsContainer>
+ ),
+ [
+ handleSelectAll,
+ handleDeselectAll,
+ bulkSelectCounts.selectable,
+ bulkSelectCounts.deselectable,
+ ],
+ );
+
const dropdownRender = (
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
) =>
@@ -389,6 +484,8 @@ const Select = forwardRef(
isLoading,
fullSelectOptions.length,
helperText,
+ undefined,
+ selectAllEnabled ? bulkSelectComponent : undefined,
);
const handleClear = () => {
@@ -401,6 +498,7 @@ const Select = forwardRef(
useEffect(() => {
// when `options` list is updated from component prop, reset states
setSelectOptions(initialOptions);
+ setVisibleOptions(initialOptions);
}, [initialOptions]);
useEffect(() => {
@@ -413,50 +511,6 @@ const Select = forwardRef(
setSelectValue(value);
}, [value]);
- useEffect(() => {
- // if all values are selected, add select all to value
- if (
- selectAllEnabled &&
- ensureIsArray(value).length === selectAllEligible.length
- ) {
- setSelectValue(
- labelInValue
- ? ([...ensureIsArray(value), selectAllOption] as
AntdLabeledValue[])
- : ([...ensureIsArray(value), SELECT_ALL_VALUE] as RawValue[]),
- );
- }
- }, [labelInValue, selectAllEligible.length, selectAllEnabled, value]);
-
- useEffect(() => {
- const checkSelectAll = ensureIsArray(selectValue).some(
- v => getValue(v) === SELECT_ALL_VALUE,
- );
- if (checkSelectAll && !selectAllMode) {
- const optionsToSelect = selectAllEligible.map(option =>
- labelInValue ? option : option.value,
- );
- optionsToSelect.push(labelInValue ? selectAllOption :
SELECT_ALL_VALUE);
- setSelectValue(optionsToSelect);
- fireOnChange();
- }
- }, [
- selectValue,
- selectAllMode,
- labelInValue,
- selectAllEligible,
- fireOnChange,
- ]);
-
- const selectAllLabel = useMemo(
- () => () =>
- // TODO: localize
- `${SELECT_ALL_VALUE} (${formatNumber(
- NumberFormats.INTEGER,
- selectAllEligible.length,
- )})`,
- [selectAllEligible],
- );
-
const handleOnBlur = (event: FocusEvent<HTMLElement>) => {
setInputValue('');
onBlur?.(event);
@@ -471,20 +525,6 @@ const Select = forwardRef(
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 = mapValues(selectAllEligible, labelInValue);
- newOptions = mapOptions(selectAllEligible);
- } else {
- newValues = ensureIsArray(values).filter(
- (val: any) => getValue(val) !== SELECT_ALL_VALUE,
- );
- }
- } else if (
ensureIsArray(values).length === selectAllEligible.length &&
selectAllMode
) {
@@ -587,9 +627,29 @@ const Select = forwardRef(
} else {
const token = tokenSeparators.find(token =>
pastedText.includes(token));
const array = token ? uniq(pastedText.split(token)) : [pastedText];
+
+ const newOptions: SelectOptionsType = [];
+
const values = array
- .map(item => getPastedTextValue(item))
+ .map(item => {
+ const option = getOption(item, fullSelectOptions, true);
+ if (!option && allowNewOptions) {
+ const newOption = {
+ label: item,
+ value: item,
+ isNewOption: true,
+ };
+ newOptions.push(newOption);
+ }
+ return getPastedTextValue(item);
+ })
.filter(item => item !== undefined);
+
+ if (newOptions.length > 0) {
+ const updatedOptions = [...fullSelectOptions, ...newOptions];
+ setSelectOptions(updatedOptions);
+ setVisibleOptions(updatedOptions);
+ }
if (labelInValue) {
setSelectValue(previous => [
...((previous || []) as AntdLabeledValue[]),
@@ -653,24 +713,13 @@ const Select = forwardRef(
<StyledCheckOutlined iconSize="m" aria-label="check" />
)
}
- options={shouldRenderChildrenOptions ? undefined : fullSelectOptions}
+ options={shouldRenderChildrenOptions ? undefined : visibleOptions}
oneLine={oneLine}
tagRender={customTagRender}
{...props}
ref={ref}
>
- {selectAllEnabled && (
- <Option
- id="select-all"
- className="select-all"
- key={SELECT_ALL_VALUE}
- value={SELECT_ALL_VALUE}
- >
- {selectAllLabel()}
- </Option>
- )}
- {shouldRenderChildrenOptions &&
- renderSelectOptions(fullSelectOptions)}
+ {shouldRenderChildrenOptions && renderSelectOptions(visibleOptions)}
</StyledSelect>
</StyledContainer>
);
diff --git a/superset-frontend/src/components/Select/styles.tsx
b/superset-frontend/src/components/Select/styles.tsx
index 5ccc4a5fa5..966d9ade42 100644
--- a/superset-frontend/src/components/Select/styles.tsx
+++ b/superset-frontend/src/components/Select/styles.tsx
@@ -22,6 +22,7 @@ import { Icons } from 'src/components/Icons';
import { Spin, Tag } from 'antd'; // TODO: Remove antd
// eslint-disable-next-line no-restricted-imports
import AntdSelect from 'antd/lib/select'; // TODO: Remove antd
+import { Space } from '../Space';
export const StyledHeader = styled.span<{ headerPosition: string }>`
${({ theme, headerPosition }) => `
@@ -54,9 +55,6 @@ export const StyledSelect = styled(AntdSelect, {
.ant-select-arrow .anticon:not(.ant-select-suffix) {
pointer-events: none;
}
- .select-all {
- border-bottom: 1px solid ${theme.colors.grayscale.light3};
- }
${
oneLine &&
`
@@ -138,3 +136,19 @@ export const StyledErrorMessage = styled.div`
overflow: hidden;
text-overflow: ellipsis;
`;
+
+export const StyledBulkActionsContainer = styled(Space)`
+ ${({ theme }) => `
+ padding: ${theme.gridUnit}px;
+ display: flex;
+ justify-content: center;
+ border-top: 1px solid ${theme.colors.grayscale.light3};
+ .superset-button {
+ color: ${theme.colors.primary.dark1};
+ font-weight: ${theme.typography.weights.normal};
+ }
+ .superset-button:disabled {
+ background-color: transparent;
+ }
+ `}
+`;
diff --git a/superset-frontend/src/components/Select/utils.tsx
b/superset-frontend/src/components/Select/utils.tsx
index 7025b7951a..528d9249df 100644
--- a/superset-frontend/src/components/Select/utils.tsx
+++ b/superset-frontend/src/components/Select/utils.tsx
@@ -26,12 +26,6 @@ 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<string, unknown> {
return (
value !== null &&
@@ -158,6 +152,7 @@ export const dropDownRenderHelper = (
optionsLength: number,
helperText: string | undefined,
errorComponent?: JSX.Element,
+ bulkSelectComponents?: JSX.Element,
) => {
if (!isDropdownVisible) {
originNode.ref?.current?.scrollTo({ top: 0 });
@@ -174,6 +169,7 @@ export const dropDownRenderHelper = (
<StyledHelperText role="note">{helperText}</StyledHelperText>
)}
{originNode}
+ {bulkSelectComponents}
</>
);
};
diff --git
a/superset-frontend/src/explore/components/controls/SelectControl.test.jsx
b/superset-frontend/src/explore/components/controls/SelectControl.test.jsx
index 086044d84b..4cf235a98e 100644
--- a/superset-frontend/src/explore/components/controls/SelectControl.test.jsx
+++ b/superset-frontend/src/explore/components/controls/SelectControl.test.jsx
@@ -97,7 +97,7 @@ describe('SelectControl', () => {
expect(selectorWrapper).toBeInTheDocument();
expect(selectorInput).toBeInTheDocument();
userEvent.click(selectorInput);
- expect(screen.getByText('Select All (3)')).toBeInTheDocument();
+ expect(screen.getByText('Select all (3)')).toBeInTheDocument();
});
it('renders with allowNewOptions when freeForm', () => {
@@ -167,8 +167,8 @@ describe('SelectControl', () => {
expect(yearOption).toBeInTheDocument();
expect(yearOption).toHaveAttribute('aria-selected', 'true');
const weekOption = screen.getByText(/1 week ago/, {
- selector: 'div',
- }).parentNode;
+ selector: 'div [role="option"]',
+ });
expect(weekOption?.getAttribute('aria-selected')).toEqual('true');
});