From 709ecb58bc4df2c06bf7e64f890c341a59d2ea58 Mon Sep 17 00:00:00 2001
From: Julian Roeland <julian@maykinmedia.nl>
Date: Thu, 23 Jan 2025 15:32:33 +0100
Subject: [PATCH] :sparkles: #635 - feat: method to clear filters

---
 .../e2e/issues/test_635_filters_reset.py      | 40 +++++++++++++
 .../openarchiefbeheer/utils/tests/gherkin.py  | 12 ++++
 frontend/src/hooks/useFields.ts               | 59 +++++++++++++++++--
 .../destructionlist/abstract/BaseListView.tsx | 31 +++++++---
 4 files changed, 130 insertions(+), 12 deletions(-)
 create mode 100644 backend/src/openarchiefbeheer/destruction/tests/e2e/issues/test_635_filters_reset.py

diff --git a/backend/src/openarchiefbeheer/destruction/tests/e2e/issues/test_635_filters_reset.py b/backend/src/openarchiefbeheer/destruction/tests/e2e/issues/test_635_filters_reset.py
new file mode 100644
index 000000000..d5e94f1f5
--- /dev/null
+++ b/backend/src/openarchiefbeheer/destruction/tests/e2e/issues/test_635_filters_reset.py
@@ -0,0 +1,40 @@
+# fmt: off
+from django.test import tag
+
+from openarchiefbeheer.destruction.constants import ListStatus
+from openarchiefbeheer.utils.tests.e2e import browser_page
+from openarchiefbeheer.utils.tests.gherkin import GherkinLikeTestCase
+
+
+@tag("e2e")
+@tag("gh-635")
+class Issue635FiltersReset(GherkinLikeTestCase):
+    # Tests if:
+    # - Reset button resets query parameters
+    # - Reset button resets input fields
+    # - Reset button resets page number to 1
+    # - Reset button is not shown when no filters are applied
+    async def test_scenario_reset_button_works(self):
+        async with browser_page() as page:
+            zaken = await self.given.zaken_are_indexed(amount=500)
+            record_manager = await self.given.record_manager_exists()
+
+            await self.given.list_exists(
+                name="Destruction list to reset filters for",
+                status=ListStatus.ready_to_review,
+                zaken=zaken,
+            )
+
+            await self.when.user_logs_in(page, record_manager)
+            await self.then.path_should_be(page, "/destruction-lists")
+            await self.when.user_clicks_button(page, "Destruction list to reset filters for")
+            await self.then.url_should_contain_text(page, "destruction-lists/")
+            initial_url_with_page = page.url + "?page=1"
+            await self.when.user_clicks_button(page, "Volgende")
+            await self.then.url_should_contain_text(page, "page=2")
+            await self.then.page_should_not_contain_text(page, "Filters wissen")
+            await self.when.user_filters_zaken(page, "omschrijving", "some text")
+            await self.then.url_should_contain_text(page, "omschrijving__icontains=")
+            await self.when.user_clicks_button(page, "Filters wissen")
+            await self.then.input_field_should_be_empty(page, "Omschrijving")
+            await self.then.url_should_be(page, initial_url_with_page)
diff --git a/backend/src/openarchiefbeheer/utils/tests/gherkin.py b/backend/src/openarchiefbeheer/utils/tests/gherkin.py
index 9707bab52..efc6bb75d 100644
--- a/backend/src/openarchiefbeheer/utils/tests/gherkin.py
+++ b/backend/src/openarchiefbeheer/utils/tests/gherkin.py
@@ -723,6 +723,14 @@ async def page_should_contain_text(self, page, text, timeout=None):
             element = page.locator(f"text={text}")
             await expect(element.nth(0)).to_be_visible(timeout=timeout)
 
+        async def page_should_not_contain_text(self, page, text, timeout=None):
+            if timeout is None:
+                timeout = 500 if self.is_inverted else 10000
+
+            # Check if the text is not present within the timeout
+            element = page.locator(f"text={text}")
+            await expect(element).to_have_count(0, timeout=timeout)
+
         async def page_should_contain_element_with_title(
             self, page, title, timeout=5000
         ):
@@ -771,3 +779,7 @@ async def this_number_of_zaken_should_be_visible(self, page, number):
             rows = await locator.locator("tbody").locator("tr").all()
 
             self.testcase.assertEqual(len(rows), number)
+
+        async def input_field_should_be_empty(self, page, placeholder):
+            locator = page.get_by_placeholder(placeholder)
+            await expect(locator).to_have_value("")
diff --git a/frontend/src/hooks/useFields.ts b/frontend/src/hooks/useFields.ts
index f8b154de9..f5803b21d 100644
--- a/frontend/src/hooks/useFields.ts
+++ b/frontend/src/hooks/useFields.ts
@@ -45,6 +45,8 @@ export function useFields<T extends Zaak = Zaak>(
   (
     filterData: Partial<TypedSerializedFormData<keyof T & string>>,
   ) => FilterTransformReturnType<T>,
+  Record<string, string>,
+  () => void,
 ] {
   const [fieldSelectionState, setFieldSelectionState] =
     useState<FieldSelection>();
@@ -53,7 +55,7 @@ export function useFields<T extends Zaak = Zaak>(
       setFieldSelectionState(fieldSelection),
     );
   }, []);
-  const [searchParams] = useSearchParams();
+  const [searchParams, setSearchParams] = useSearchParams();
   const selectielijstKlasseChoices = useSelectielijstKlasseChoices();
   const zaaktypeChoices = useZaaktypeChoices(
     destructionList,
@@ -62,8 +64,9 @@ export function useFields<T extends Zaak = Zaak>(
   );
 
   // The raw, unfiltered configuration of the available base fields.
+  // Both filterLookup AND filterLookups will be used for clearing filters.
   // NOTE: This get filtered by `getActiveFields()`.
-  const fields: TypedField<T>[] = [
+  const fields: (TypedField<T> & { filterLookups?: string[] })[] = [
     {
       name: "identificatie",
       filterLookup: "identificatie__icontains",
@@ -96,6 +99,7 @@ export function useFields<T extends Zaak = Zaak>(
     {
       name: "startdatum",
       type: "daterange",
+      filterLookups: ["startdatum__gte", "startdatum__lte"],
       filterValue:
         searchParams.get("startdatum__gte") &&
         searchParams.get("startdatum__lte")
@@ -108,6 +112,7 @@ export function useFields<T extends Zaak = Zaak>(
     {
       name: "einddatum",
       type: "daterange",
+      filterLookups: ["einddatum__gte", "einddatum__lte"],
       filterValue:
         searchParams.get("einddatum__gte") && searchParams.get("einddatum__lte")
           ? `${searchParams.get("einddatum__gte")}/${searchParams.get("einddatum__lte")}`
@@ -162,6 +167,7 @@ export function useFields<T extends Zaak = Zaak>(
       name: "archiefactiedatum",
       type: "daterange",
       width: "130px",
+      filterLookups: ["archiefactiedatum__gte", "archiefactiedatum__lte"],
       filterValue:
         searchParams.get("archiefactiedatum__gte") &&
         searchParams.get("archiefactiedatum__lte")
@@ -206,6 +212,17 @@ export function useFields<T extends Zaak = Zaak>(
     ...(extraFields || []),
   ];
 
+  const filterLookupValues = [
+    ...new Set(
+      fields
+        .flatMap((field) => [
+          field.filterLookup,
+          ...(field.filterLookups || []),
+        ])
+        .filter(Boolean),
+    ),
+  ];
+
   const getActiveFields = useCallback(() => {
     return fields.map((field) => {
       const isActiveFromStorage =
@@ -214,10 +231,38 @@ export function useFields<T extends Zaak = Zaak>(
         typeof isActiveFromStorage === "undefined"
           ? field.active !== false
           : isActiveFromStorage;
-      return { ...field, active: isActive } as TypedField;
+      return { ...field, active: isActive };
     });
   }, [fields, fieldSelectionState]);
 
+  /**
+   * Function to reset all the filters
+   * It will concat all the `filterLookup` and `filterLookups` values from the `fields` array and remove them from the searchParams
+   */
+  const resetFilters = () => {
+    const newSearchParams = new URLSearchParams(searchParams);
+    filterLookupValues.forEach((filterLookup) => {
+      if (!filterLookup) return;
+      newSearchParams.delete(filterLookup);
+    });
+    setSearchParams(newSearchParams);
+  };
+
+  /**
+   * A function to return the current active filters
+   */
+  const getActiveFilters = () => {
+    const activeFilters: Record<string, string> = {};
+    filterLookupValues.forEach((filterLookup) => {
+      if (!filterLookup) return;
+      const value = searchParams.get(filterLookup);
+      if (value) {
+        activeFilters[filterLookup] = value;
+      }
+    });
+    return activeFilters;
+  };
+
   /**
    * Gets called when the fields selection is changed.
    * Pass this to `filterTransform` of a DataGrid component.
@@ -267,5 +312,11 @@ export function useFields<T extends Zaak = Zaak>(
     };
   };
 
-  return [getActiveFields(), setFields, filterTransform];
+  return [
+    getActiveFields(),
+    setFields,
+    filterTransform,
+    getActiveFilters(),
+    resetFilters,
+  ];
 }
diff --git a/frontend/src/pages/destructionlist/abstract/BaseListView.tsx b/frontend/src/pages/destructionlist/abstract/BaseListView.tsx
index cd7a3e9f3..122d755a8 100644
--- a/frontend/src/pages/destructionlist/abstract/BaseListView.tsx
+++ b/frontend/src/pages/destructionlist/abstract/BaseListView.tsx
@@ -100,11 +100,8 @@ export function BaseListView<T extends Zaak = Zaak>({
   })) as unknown as T[];
 
   // Fields.
-  const [fields, setFields, filterTransform] = useFields<T>(
-    destructionList,
-    review,
-    extraFields,
-  );
+  const [fields, setFields, filterTransform, activeFilters, resetFilters] =
+    useFields<T>(destructionList, review, extraFields);
   type FilterTransformData = ReturnType<typeof filterTransform>;
 
   // Filter.
@@ -162,7 +159,7 @@ export function BaseListView<T extends Zaak = Zaak>({
         : { ...props, disabled: selectable && !hasSelection },
     );
     const fixedItems = disabled
-      ? ([
+      ? [
           {
             children: (
               <>
@@ -174,9 +171,27 @@ export function BaseListView<T extends Zaak = Zaak>({
             wrap: false,
             onClick: handleClearZaakSelection,
           },
-        ] as ButtonProps[])
+        ]
       : [];
-    return [...dynamicItems, ...fixedItems];
+    if (!Object.keys(activeFilters).length) {
+      return [...dynamicItems, ...fixedItems];
+    }
+
+    return [
+      ...dynamicItems,
+      ...fixedItems,
+      {
+        children: (
+          <>
+            <Solid.XCircleIcon />
+            Filters wissen
+          </>
+        ),
+        variant: "warning",
+        wrap: false,
+        onClick: resetFilters,
+      },
+    ];
   }, [selectable, hasSelection, selectedZakenOnPage, selectionActions]);
 
   return (