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');
     });
 


Reply via email to