From 344faf7fcf51235e81f479a06f4c2c9dffe81086 Mon Sep 17 00:00:00 2001
From: Alexander Polyankin <alexander.polyankin@metabase.com>
Date: Thu, 12 Sep 2024 14:37:18 -0400
Subject: [PATCH] Add Select all in ListValuePicker (#47865)

* Unify FilterPickerForm

* Unify FilterPickerForm

* Add Select all in ListValuePicker

* Layout changes

* Layout changes

* Add tests

* Add tests

* Revert "Unify FilterPickerForm"

This reverts commit a7dfe8848d9b62d9e8371ddb47451c8d45002cbc.

* Revert "Unify FilterPickerForm"

This reverts commit 8f002b87212936996b7dc1cd04f16e06a7b0c967.
---
 .../FilterValuePicker.unit.spec.tsx           |  48 ++++---
 .../ListValuePicker/ListValuePicker.tsx       |  56 +++++---
 .../ListValuePicker.unit.spec.tsx             | 129 ++++++++++++++++++
 3 files changed, 193 insertions(+), 40 deletions(-)
 create mode 100644 frontend/src/metabase/querying/filters/components/FilterValuePicker/ListValuePicker/ListValuePicker.unit.spec.tsx

diff --git a/frontend/src/metabase/querying/filters/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx b/frontend/src/metabase/querying/filters/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx
index df14c65d0e7..293a9fe2ce2 100644
--- a/frontend/src/metabase/querying/filters/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx
+++ b/frontend/src/metabase/querying/filters/components/FilterValuePicker/FilterValuePicker.unit.spec.tsx
@@ -369,12 +369,13 @@ describe("StringFilterValuePicker", () => {
       });
 
       const checkboxes = screen.getAllByRole("checkbox");
-      expect(checkboxes[0]).toHaveAccessibleName("In-progress");
-      expect(checkboxes[0]).toBeChecked();
-      expect(checkboxes[1]).toHaveAccessibleName("To-do");
-      expect(checkboxes[1]).not.toBeChecked();
-      expect(checkboxes[2]).toHaveAccessibleName("Completed");
+      expect(checkboxes[0]).toHaveAccessibleName("Select all");
+      expect(checkboxes[1]).toHaveAccessibleName("In-progress");
+      expect(checkboxes[1]).toBeChecked();
+      expect(checkboxes[2]).toHaveAccessibleName("To-do");
       expect(checkboxes[2]).not.toBeChecked();
+      expect(checkboxes[3]).toHaveAccessibleName("Completed");
+      expect(checkboxes[3]).not.toBeChecked();
     });
 
     it("should not elevate selected field values after checking an item", async () => {
@@ -404,12 +405,13 @@ describe("StringFilterValuePicker", () => {
         />,
       );
       const checkboxes = screen.getAllByRole("checkbox");
-      expect(checkboxes[0]).toHaveAccessibleName("In-progress");
-      expect(checkboxes[0]).toBeChecked();
-      expect(checkboxes[1]).toHaveAccessibleName("To-do");
-      expect(checkboxes[1]).not.toBeChecked();
-      expect(checkboxes[2]).toHaveAccessibleName("Completed");
-      expect(checkboxes[2]).toBeChecked();
+      expect(checkboxes[0]).toHaveAccessibleName("Select all");
+      expect(checkboxes[1]).toHaveAccessibleName("In-progress");
+      expect(checkboxes[1]).toBeChecked();
+      expect(checkboxes[2]).toHaveAccessibleName("To-do");
+      expect(checkboxes[2]).not.toBeChecked();
+      expect(checkboxes[3]).toHaveAccessibleName("Completed");
+      expect(checkboxes[3]).toBeChecked();
     });
 
     it("should not elevate selected field values after unchecking an item", async () => {
@@ -439,12 +441,13 @@ describe("StringFilterValuePicker", () => {
         />,
       );
       const checkboxes = screen.getAllByRole("checkbox");
-      expect(checkboxes[0]).toHaveAccessibleName("In-progress");
-      expect(checkboxes[0]).not.toBeChecked();
-      expect(checkboxes[1]).toHaveAccessibleName("Completed");
-      expect(checkboxes[1]).toBeChecked();
-      expect(checkboxes[2]).toHaveAccessibleName("To-do");
-      expect(checkboxes[2]).not.toBeChecked();
+      expect(checkboxes[0]).toHaveAccessibleName("Select all");
+      expect(checkboxes[1]).toHaveAccessibleName("In-progress");
+      expect(checkboxes[1]).not.toBeChecked();
+      expect(checkboxes[2]).toHaveAccessibleName("Completed");
+      expect(checkboxes[2]).toBeChecked();
+      expect(checkboxes[3]).toHaveAccessibleName("To-do");
+      expect(checkboxes[3]).not.toBeChecked();
     });
 
     it("should handle empty field values", async () => {
@@ -1024,12 +1027,13 @@ describe("NumberFilterValuePicker", () => {
       });
 
       const checkboxes = screen.getAllByRole("checkbox");
-      expect(checkboxes[0]).toHaveAccessibleName("In-progress");
-      expect(checkboxes[0]).toBeChecked();
-      expect(checkboxes[1]).toHaveAccessibleName("To-do");
-      expect(checkboxes[1]).not.toBeChecked();
-      expect(checkboxes[2]).toHaveAccessibleName("Completed");
+      expect(checkboxes[0]).toHaveAccessibleName("Select all");
+      expect(checkboxes[1]).toHaveAccessibleName("In-progress");
+      expect(checkboxes[1]).toBeChecked();
+      expect(checkboxes[2]).toHaveAccessibleName("To-do");
       expect(checkboxes[2]).not.toBeChecked();
+      expect(checkboxes[3]).toHaveAccessibleName("Completed");
+      expect(checkboxes[3]).not.toBeChecked();
     });
   });
 
diff --git a/frontend/src/metabase/querying/filters/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx b/frontend/src/metabase/querying/filters/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx
index 169d53a99f7..2ab69dfede9 100644
--- a/frontend/src/metabase/querying/filters/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx
+++ b/frontend/src/metabase/querying/filters/components/FilterValuePicker/ListValuePicker/ListValuePicker.tsx
@@ -57,11 +57,21 @@ function CheckboxListPicker({
     elevatedValues,
   );
   const visibleOptions = searchOptions(options, searchValue);
+  const isAll = options.length === selectedValues.length;
+  const isNone = selectedValues.length === 0;
 
   const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
     setSearchValue(event.currentTarget.value);
   };
 
+  const handleToggleAll = () => {
+    if (isAll) {
+      onChange([]);
+    } else {
+      onChange(options.map(option => option.value));
+    }
+  };
+
   return (
     <Stack>
       <TextInput
@@ -70,24 +80,34 @@ function CheckboxListPicker({
         autoFocus={autoFocus}
         onChange={handleInputChange}
       />
-      <Checkbox.Group value={selectedValues} onChange={onChange}>
-        {visibleOptions.length > 0 ? (
-          <Stack>
-            {visibleOptions.map(option => (
-              <Checkbox
-                key={option.value}
-                value={option.value}
-                label={option.label}
-              />
-            ))}
-          </Stack>
-        ) : (
-          <Stack c="text-light" justify="center" align="center">
-            <Icon name="search" size={40} />
-            <Text c="text-medium" fw="bold">{t`Didn't find anything`}</Text>
-          </Stack>
-        )}
-      </Checkbox.Group>
+      {visibleOptions.length > 0 ? (
+        <Stack>
+          <Checkbox
+            variant="stacked"
+            label={isAll ? `Select none` : t`Select all`}
+            checked={isAll}
+            indeterminate={!isAll && !isNone}
+            fw="bold"
+            onChange={handleToggleAll}
+          />
+          <Checkbox.Group value={selectedValues} onChange={onChange}>
+            <Stack>
+              {visibleOptions.map(option => (
+                <Checkbox
+                  key={option.value}
+                  value={option.value}
+                  label={option.label}
+                />
+              ))}
+            </Stack>
+          </Checkbox.Group>
+        </Stack>
+      ) : (
+        <Stack c="text-light" justify="center" align="center">
+          <Icon name="search" size={40} />
+          <Text c="text-medium" fw="bold">{t`Didn't find anything`}</Text>
+        </Stack>
+      )}
     </Stack>
   );
 }
diff --git a/frontend/src/metabase/querying/filters/components/FilterValuePicker/ListValuePicker/ListValuePicker.unit.spec.tsx b/frontend/src/metabase/querying/filters/components/FilterValuePicker/ListValuePicker/ListValuePicker.unit.spec.tsx
new file mode 100644
index 00000000000..06835d917b3
--- /dev/null
+++ b/frontend/src/metabase/querying/filters/components/FilterValuePicker/ListValuePicker/ListValuePicker.unit.spec.tsx
@@ -0,0 +1,129 @@
+import userEvent from "@testing-library/user-event";
+
+import { renderWithProviders, screen } from "__support__/ui";
+import type { FieldValue } from "metabase-types/api";
+import { PRODUCT_CATEGORY_VALUES } from "metabase-types/api/mocks/presets";
+
+import { ListValuePicker } from "./ListValuePicker";
+
+type SetupOpts = {
+  fieldValues?: FieldValue[];
+  selectedValues?: string[];
+  placeholder?: string;
+  shouldCreate?: (query: string, values: string[]) => boolean;
+  autoFocus?: boolean;
+  compact?: boolean;
+};
+
+function setup({
+  fieldValues = [],
+  selectedValues = [],
+  placeholder = "Search the list",
+  shouldCreate,
+  autoFocus,
+  compact,
+}: SetupOpts) {
+  const onChange = jest.fn();
+  const onFocus = jest.fn();
+  const onBlur = jest.fn();
+
+  renderWithProviders(
+    <ListValuePicker
+      fieldValues={fieldValues}
+      selectedValues={selectedValues}
+      placeholder={placeholder}
+      shouldCreate={shouldCreate}
+      autoFocus={autoFocus}
+      compact={compact}
+      onChange={onChange}
+      onFocus={onFocus}
+      onBlur={onBlur}
+    />,
+  );
+
+  return { onChange, onFocus, onBlur };
+}
+
+describe("ListValuePicker", () => {
+  describe("checkbox list mode", () => {
+    const allOptions = PRODUCT_CATEGORY_VALUES.values;
+    const allValues = allOptions.map(([value]) => String(value));
+
+    it("should allow to select all options", async () => {
+      const { onChange } = setup({
+        fieldValues: allOptions,
+        selectedValues: [],
+      });
+
+      const checkbox = screen.getByLabelText("Select all");
+      expect(checkbox).not.toBeChecked();
+      await userEvent.click(checkbox);
+
+      expect(onChange).toHaveBeenCalledWith(allValues);
+    });
+
+    it("should allow to select all options when some are selected", async () => {
+      const { onChange } = setup({
+        fieldValues: allOptions,
+        selectedValues: [allValues[0]],
+      });
+
+      const checkbox = screen.getByLabelText("Select all");
+      expect(checkbox).not.toBeChecked();
+      await userEvent.click(checkbox);
+
+      expect(onChange).toHaveBeenCalledWith(allValues);
+    });
+
+    it("should allow to select all options after search", async () => {
+      const { onChange } = setup({
+        fieldValues: allOptions,
+        selectedValues: [],
+      });
+
+      await userEvent.type(
+        screen.getByPlaceholderText("Search the list"),
+        allValues[0],
+      );
+      expect(screen.getByLabelText(allValues[0])).toBeInTheDocument();
+      expect(screen.queryByLabelText(allValues[1])).not.toBeInTheDocument();
+
+      const checkbox = screen.getByLabelText("Select all");
+      expect(checkbox).not.toBeChecked();
+      await userEvent.click(checkbox);
+      expect(onChange).toHaveBeenCalledWith(allValues);
+    });
+
+    it("should allow to deselect all options", async () => {
+      const { onChange } = setup({
+        fieldValues: allOptions,
+        selectedValues: allValues,
+      });
+
+      const checkbox = screen.getByLabelText("Select none");
+      expect(checkbox).toBeChecked();
+      await userEvent.click(checkbox);
+
+      expect(onChange).toHaveBeenCalledWith([]);
+    });
+
+    it("should allow to deselect all options after search", async () => {
+      const { onChange } = setup({
+        fieldValues: allOptions,
+        selectedValues: allValues,
+      });
+
+      await userEvent.type(
+        screen.getByPlaceholderText("Search the list"),
+        allValues[0],
+      );
+      expect(screen.getByLabelText(allValues[0])).toBeInTheDocument();
+      expect(screen.queryByLabelText(allValues[1])).not.toBeInTheDocument();
+
+      const checkbox = screen.getByLabelText("Select none");
+      expect(checkbox).toBeChecked();
+      await userEvent.click(checkbox);
+      expect(onChange).toHaveBeenCalledWith([]);
+    });
+  });
+});
-- 
GitLab