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

pierrejeambrun pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 3651eea45bc Persisting filter by tag (#63273)
3651eea45bc is described below

commit 3651eea45bcd0c081c74ab2ed9f073d5a6abaabd
Author: Zach Liu <[email protected]>
AuthorDate: Mon Mar 23 13:33:34 2026 -0400

    Persisting filter by tag (#63273)
    
    * define constants for dags tag filter
    
    * make tags persistent
    
    * add deps list
    
    * optimizing, need no extra constants
    
    * Add useTagFilter hook for DagsList tag filtering
    
    * Add TagMatchMode type to useTagFilter hook
    
    * Extract tag filter logic into reusable useTagFilter hook
    
    * Add tests for useTagFilter hook
    
    * Reset offset when tag filter mode changes
    
    * Fix tag filter mode persistence for single tag selections
    
    * Stop resetting tag match mode when tag count drops below 2
---
 .../src/pages/DagsList/DagsFilters/DagsFilters.tsx |  21 +-
 .../src/airflow/ui/src/pages/DagsList/DagsList.tsx |   6 +-
 .../ui/src/pages/DagsList/useTagFilter.test.tsx    | 275 +++++++++++++++++++++
 .../airflow/ui/src/pages/DagsList/useTagFilter.ts  |  63 +++++
 4 files changed, 344 insertions(+), 21 deletions(-)

diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
index e9066d8c180..86ce290f716 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.tsx
@@ -26,6 +26,7 @@ import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searc
 import { useConfig } from "src/queries/useConfig";
 import { useDagTagsInfinite } from "src/queries/useDagTagsInfinite";
 
+import { useTagFilter } from "../useTagFilter";
 import { FavoriteFilter } from "./FavoriteFilter";
 import { PausedFilter } from "./PausedFilter";
 import { RequiredActionFilter } from "./RequiredActionFilter";
@@ -38,8 +39,6 @@ const {
   NEEDS_REVIEW: NEEDS_REVIEW_PARAM,
   OFFSET: OFFSET_PARAM,
   PAUSED: PAUSED_PARAM,
-  TAGS: TAGS_PARAM,
-  TAGS_MATCH_MODE: TAGS_MATCH_MODE_PARAM,
 }: SearchParamsKeysType = SearchParamsKeys;
 
 type StateValue = "all" | "failed" | "queued" | "running" | "success";
@@ -59,13 +58,12 @@ const toBooleanFilterValue = (
 
 export const DagsFilters = () => {
   const [searchParams, setSearchParams] = useSearchParams();
+  const { selectedTags, setSelectedTags, setTagFilterMode, tagFilterMode } = 
useTagFilter();
 
   const showPaused = searchParams.get(PAUSED_PARAM);
   const showFavorites = searchParams.get(FAVORITE_PARAM);
   const needsReview = searchParams.get(NEEDS_REVIEW_PARAM);
   const state = searchParams.get(LAST_DAG_RUN_STATE_PARAM);
-  const selectedTags = searchParams.getAll(TAGS_PARAM);
-  const tagFilterMode = searchParams.get(TAGS_MATCH_MODE_PARAM) ?? "any";
 
   const [pattern, setPattern] = useState("");
 
@@ -135,22 +133,11 @@ export const DagsFilters = () => {
       value: string;
     }>,
   ) => {
-    searchParams.delete(TAGS_PARAM);
-    tags.forEach(({ value }) => {
-      searchParams.append(TAGS_PARAM, value);
-    });
-    if (tags.length < 2) {
-      searchParams.delete(TAGS_MATCH_MODE_PARAM);
-    }
-    searchParams.delete(OFFSET_PARAM);
-    setSearchParams(searchParams);
+    setSelectedTags(tags.map(({ value }) => value));
   };
 
   const handleTagModeChange = ({ checked }: { checked: boolean }) => {
-    const mode = checked ? "all" : "any";
-
-    searchParams.set(TAGS_MATCH_MODE_PARAM, mode);
-    setSearchParams(searchParams);
+    setTagFilterMode(checked ? "all" : "any");
   };
 
   const stateValue = toStateValue(state);
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
index 616a924de40..7bb933d8f8e 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
@@ -56,6 +56,7 @@ import { DagTags } from "./DagTags";
 import { DagsFilters } from "./DagsFilters";
 import { Schedule } from "./Schedule";
 import { SortSelect } from "./SortSelect";
+import { useTagFilter } from "./useTagFilter";
 
 const createColumns = (
   translate: (key: string, options?: Record<string, unknown>) => string,
@@ -185,8 +186,6 @@ const {
   OFFSET,
   OWNERS,
   PAUSED,
-  TAGS,
-  TAGS_MATCH_MODE,
 }: SearchParamsKeysType = SearchParamsKeys;
 
 const cardDef: CardDef<DAGWithLatestDagRunsResponse> = {
@@ -209,8 +208,7 @@ export const DagsList = () => {
   const showFavorites = searchParams.get(FAVORITE);
 
   const lastDagRunState = searchParams.get(LAST_DAG_RUN_STATE) as DagRunState;
-  const selectedTags = searchParams.getAll(TAGS);
-  const selectedMatchMode = searchParams.get(TAGS_MATCH_MODE) as "all" | "any";
+  const { selectedTags, tagFilterMode: selectedMatchMode } = useTagFilter();
   const pendingReviews = searchParams.get(NEEDS_REVIEW);
   const owners = searchParams.getAll(OWNERS);
 
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.test.tsx
new file mode 100644
index 00000000000..95228a1d3b9
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.test.tsx
@@ -0,0 +1,275 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { act, renderHook } from "@testing-library/react";
+import type { PropsWithChildren } from "react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { useTagFilter } from "./useTagFilter";
+
+const createWrapper =
+  (initialEntries: Array<string> = ["/"]) =>
+  ({ children }: PropsWithChildren) => (
+    <BaseWrapper>
+      <MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
+    </BaseWrapper>
+  );
+
+afterEach(() => {
+  localStorage.clear();
+});
+
+describe("useTagFilter — initial state", () => {
+  it("returns empty tags and 'any' mode by default", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(),
+    });
+
+    expect(result.current.selectedTags).toEqual([]);
+    expect(result.current.tagFilterMode).toBe("any");
+  });
+
+  it("reads tags from URL params", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(["/?tags=production&tags=ml"]),
+    });
+
+    expect(result.current.selectedTags).toEqual(["production", "ml"]);
+  });
+
+  it("reads match mode from URL params", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: 
createWrapper(["/?tags=production&tags=ml&tags_match_mode=all"]),
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+  });
+
+  it("falls back to localStorage when URL has no tags", () => {
+    localStorage.setItem("tags", JSON.stringify(["saved-tag-1", 
"saved-tag-2"]));
+
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(),
+    });
+
+    expect(result.current.selectedTags).toEqual(["saved-tag-1", 
"saved-tag-2"]);
+  });
+
+  it("restores match mode from localStorage when using saved tags with 2+ 
tags", () => {
+    localStorage.setItem("tags", JSON.stringify(["tag-a", "tag-b"]));
+    localStorage.setItem("tags_match_mode", JSON.stringify("all"));
+
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(),
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+  });
+
+  it("restores match mode from localStorage even with fewer than 2 tags", () 
=> {
+    localStorage.setItem("tags", JSON.stringify(["only-one"]));
+    localStorage.setItem("tags_match_mode", JSON.stringify("all"));
+
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(),
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+  });
+
+  it("URL tags take precedence over localStorage", () => {
+    localStorage.setItem("tags", JSON.stringify(["saved-tag"]));
+
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(["/?tags=url-tag"]),
+    });
+
+    expect(result.current.selectedTags).toEqual(["url-tag"]);
+  });
+});
+
+describe("useTagFilter — setSelectedTags", () => {
+  it("sets tags", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(),
+    });
+
+    act(() => {
+      result.current.setSelectedTags(["new-tag-1", "new-tag-2"]);
+    });
+
+    expect(result.current.selectedTags).toEqual(["new-tag-1", "new-tag-2"]);
+  });
+
+  it("saves tags to localStorage", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(),
+    });
+
+    act(() => {
+      result.current.setSelectedTags(["persisted-tag"]);
+    });
+
+    expect(JSON.parse(localStorage.getItem("tags") ?? 
"[]")).toEqual(["persisted-tag"]);
+  });
+
+  it("clears tags when given empty array", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(["/?tags=old-tag"]),
+    });
+
+    act(() => {
+      result.current.setSelectedTags([]);
+    });
+
+    expect(result.current.selectedTags).toEqual([]);
+  });
+
+  it("does not reset match mode when tags drop below 2", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(["/?tags=a&tags=b&tags_match_mode=all"]),
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+
+    act(() => {
+      result.current.setSelectedTags(["only-one"]);
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+  });
+
+  it("resets offset when tags change", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(["/?tags=a&offset=20"]),
+    });
+
+    act(() => {
+      result.current.setSelectedTags(["b"]);
+    });
+
+    expect(result.current.selectedTags).toEqual(["b"]);
+  });
+});
+
+describe("useTagFilter — setTagFilterMode", () => {
+  it("toggles mode from 'any' to 'all'", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(["/?tags=a&tags=b"]),
+    });
+
+    expect(result.current.tagFilterMode).toBe("any");
+
+    act(() => {
+      result.current.setTagFilterMode("all");
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+  });
+
+  it("toggles mode from 'all' to 'any'", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(["/?tags=a&tags=b&tags_match_mode=all"]),
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+
+    act(() => {
+      result.current.setTagFilterMode("any");
+    });
+
+    expect(result.current.tagFilterMode).toBe("any");
+  });
+
+  it("resets offset when mode changes", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: 
createWrapper(["/?tags=a&tags=b&tags_match_mode=any&offset=20"]),
+    });
+
+    act(() => {
+      result.current.setTagFilterMode("all");
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+  });
+
+  it("persists mode to localStorage", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(["/?tags=a&tags=b"]),
+    });
+
+    act(() => {
+      result.current.setTagFilterMode("all");
+    });
+
+    expect(JSON.parse(localStorage.getItem("tags_match_mode") ?? 
'"any"')).toBe("all");
+  });
+});
+
+describe("useTagFilter — tag count transitions preserve match mode", () => {
+  it("match mode is preserved when replacing tags", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(),
+    });
+
+    act(() => {
+      result.current.setSelectedTags(["first", "second"]);
+    });
+
+    act(() => {
+      result.current.setTagFilterMode("all");
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+
+    act(() => {
+      result.current.setSelectedTags(["only-one"]);
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+
+    act(() => {
+      result.current.setSelectedTags(["only-one", "new-second"]);
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+  });
+
+  it("match mode is preserved when clearing all tags", () => {
+    const { result } = renderHook(() => useTagFilter(), {
+      wrapper: createWrapper(),
+    });
+
+    act(() => {
+      result.current.setSelectedTags(["a", "b"]);
+    });
+
+    act(() => {
+      result.current.setTagFilterMode("all");
+    });
+
+    act(() => {
+      result.current.setSelectedTags([]);
+    });
+
+    expect(result.current.tagFilterMode).toBe("all");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.ts 
b/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.ts
new file mode 100644
index 00000000000..ea93928817c
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.ts
@@ -0,0 +1,63 @@
+/*!
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useSearchParams } from "react-router-dom";
+import { useLocalStorage } from "usehooks-ts";
+
+import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
+
+const { OFFSET, TAGS, TAGS_MATCH_MODE }: SearchParamsKeysType = 
SearchParamsKeys;
+
+type TagMatchMode = "all" | "any";
+
+export const useTagFilter = () => {
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [savedTags, setSavedTags] = useLocalStorage<Array<string>>(TAGS, []);
+  const [savedTagMatchMode, setSavedTagMatchMode] = 
useLocalStorage<TagMatchMode>(TAGS_MATCH_MODE, "any");
+
+  const urlTags = searchParams.getAll(TAGS);
+  const urlMatchMode = searchParams.get(TAGS_MATCH_MODE);
+
+  // URL params take precedence; fall back to localStorage when URL has no 
tags.
+  const selectedTags = urlTags.length > 0 ? urlTags : savedTags;
+  const tagFilterMode: TagMatchMode =
+    urlMatchMode === null
+      ? urlTags.length === 0
+        ? savedTagMatchMode
+        : "any"
+      : (urlMatchMode as TagMatchMode);
+
+  const setSelectedTags = (tags: Array<string>) => {
+    searchParams.delete(TAGS);
+    tags.forEach((tag) => {
+      searchParams.append(TAGS, tag);
+    });
+    searchParams.delete(OFFSET);
+    setSearchParams(searchParams);
+    setSavedTags(tags);
+  };
+
+  const setTagFilterMode = (mode: TagMatchMode) => {
+    searchParams.set(TAGS_MATCH_MODE, mode);
+    searchParams.delete(OFFSET);
+    setSearchParams(searchParams);
+    setSavedTagMatchMode(mode);
+  };
+
+  return { selectedTags, setSelectedTags, setTagFilterMode, tagFilterMode };
+};

Reply via email to