You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2023/01/18 12:42:11 UTC

[superset] branch master updated: feat: Select all for synchronous select (#22084)

This is an automated email from the ASF dual-hosted git repository.

michaelsmolina 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 02c9242d68 feat: Select all for synchronous select (#22084)
02c9242d68 is described below

commit 02c9242d680a67dca18ae05b2ca585c0cf385ad0
Author: cccs-RyanK <10...@users.noreply.github.com>
AuthorDate: Wed Jan 18 07:41:58 2023 -0500

    feat: Select all for synchronous select (#22084)
    
    Co-authored-by: GITHUB_USERNAME <EMAIL>
---
 .../integration/dashboard/nativeFilters.test.ts    |   4 +-
 .../src/components/Select/AsyncSelect.tsx          |   4 +-
 .../src/components/Select/CustomTag.tsx            |  21 +-
 .../src/components/Select/Select.stories.tsx       |   8 +
 .../src/components/Select/Select.test.tsx          | 225 ++++++++++++++++++++-
 superset-frontend/src/components/Select/Select.tsx | 191 +++++++++++++++--
 superset-frontend/src/components/Select/styles.tsx |  14 +-
 superset-frontend/src/components/Select/types.ts   |   3 +-
 superset-frontend/src/components/Select/utils.tsx  |   6 +
 .../components/Select/SelectFilterPlugin.tsx       |   3 -
 10 files changed, 429 insertions(+), 50 deletions(-)

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 365a7e4ecb..ff6939e9af 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 4863095467..1e1307a588 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 }) => (
   <StyledError>
@@ -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 57aa37c81b..a7ffe10f6d 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<HTMLElement>) => {
     // if close icon is clicked, stop propagation to avoid opening the dropdown
@@ -69,9 +71,12 @@ export const oneLineTagRender = (props: CustomTagProps) => {
     }
   };
 
-  return (
-    <Tag onMouseDown={onPreventMouseDown} {...props}>
-      {label}
-    </Tag>
-  );
+  if (value !== SELECT_ALL_VALUE) {
+    return (
+      <Tag onMouseDown={onPreventMouseDown} {...(props as object)}>
+        {label}
+      </Tag>
+    );
+  }
+  return <NoElement />;
 };
diff --git a/superset-frontend/src/components/Select/Select.stories.tsx b/superset-frontend/src/components/Select/Select.stories.tsx
index 4a5d3551f0..6c774fe169 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 cb4548633e..52f834d7cd 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(<Select {...defaultProps} mode="multiple" options={OPTIONS} />);
+  await open();
+  const options = await findAllSelectOptions();
+  expect(options[0]).toHaveTextContent(selectAllOptionLabel(OPTIONS.length));
+});
+
+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)),
+  ).not.toBeInTheDocument();
+});
+
+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)),
+  ).not.toBeInTheDocument();
+});
+
+test('does not render "Select all" when searching', async () => {
+  render(<Select {...defaultProps} options={OPTIONS} mode="multiple" />);
+  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(<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
+      {...defaultProps}
+      options={OPTIONS}
+      mode="multiple"
+      maxTagCount={0}
+    />,
+  );
+  await open();
+  userEvent.click(await findSelectOption(selectAllOptionLabel(OPTIONS.length)));
+  const values = await findAllSelectValues();
+  expect(values.length).toBe(1);
+  expect(values[0]).toHaveTextContent(`+ ${OPTIONS.length} ...`);
+});
+
+test('unselects all values', async () => {
+  render(
+    <Select
+      {...defaultProps}
+      options={OPTIONS}
+      mode="multiple"
+      maxTagCount={0}
+    />,
+  );
+  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(
+    <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('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
@@ -614,6 +764,61 @@ 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}{enter}`);
+  expect(await findSelectOption(selectAllOptionLabel(11))).toBeInTheDocument();
+  expect(await findSelectOption(NEW_OPTION)).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 () => {
+  render(
+    <Select {...defaultProps} options={[]} mode="multiple" allowNewOptions />,
+  );
+  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 5550463492..70ca4e6903 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 (
       <StyledContainer headerPosition={headerPosition}>
         {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 && (
+            <Option
+              id="select-all"
+              key={SELECT_ALL_VALUE}
+              value={SELECT_ALL_VALUE}
+            >
+              {selectAllLabel()}
+            </Option>
+          )}
+          {renderSelectOptions(fullSelectOptions)}
         </StyledSelect>
       </StyledContainer>
     );
diff --git a/superset-frontend/src/components/Select/styles.tsx b/superset-frontend/src/components/Select/styles.tsx
index 7064ae55a3..b954a04fc7 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 76f7acd0a2..6e4c7f072d 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 0d499b4f1d..e819935822 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<string, unknown> {
   return (
     value !== null &&
diff --git a/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx b/superset-frontend/src/filters/components/Select/SelectFilterPlugin.tsx
index c19754b518..fb8e20093c 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[]) => (
-            <span>+{val.length}</span>
-          )}
           onDropdownVisibleChange={setFilterActive}
         />
       </StyledFormItem>