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

bbovenzi 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 12e3f08d547 Add preset filters to the Dags list page (#71368)
12e3f08d547 is described below

commit 12e3f08d54785b22b5a99983b8097a04e5fb3c0e
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Aug 11 18:45:10 2026 +0200

    Add preset filters to the Dags list page (#71368)
    
    PR #68484 shipped save/restore/set-default views on every table that goes
    through the shared FilterBar, but the Dags list hand-rolls its own filter
    row (DagsFilters) and was left out. Users complained that their status,
    run-state, favorites, and needs-review filters reset on navigation.
    
    PresetFiltersMenu is already self-contained — it reads and writes
    useSearchParams and persists per pathname to localStorage — so it drops
    into DagsFilters unchanged. All Dags-page filters (paused, favorite,
    last/any run state, needs_review, tags, tags_match_mode, teams,
    timetable_type, name_pattern) are already URL params, so a saved preset
    restores them faithfully.
    
    For that to actually hold, the tag filter had to stop persisting its
    selection to localStorage (added in #63273): that fallback shadowed the
    URL, so applying a preset that omitted tags kept the previous selection
    instead of clearing it. Tags are now URL-only like every other filter,
    and the stale tags / tags_match_mode keys are pruned on load.
    
    closes: #66933
---
 .../airflow/ui/src/constants/localStorage.test.ts  | 32 ++++++++++++++-
 .../src/airflow/ui/src/constants/localStorage.ts   |  8 ++++
 airflow-core/src/airflow/ui/src/main.tsx           |  3 +-
 .../DagsList/DagsFilters/DagsFilters.test.tsx      |  6 +++
 .../src/pages/DagsList/DagsFilters/DagsFilters.tsx |  8 ++--
 .../ui/src/pages/DagsList/useTagFilter.test.tsx    | 46 ++++------------------
 .../airflow/ui/src/pages/DagsList/useTagFilter.ts  | 17 +-------
 7 files changed, 61 insertions(+), 59 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.test.ts 
b/airflow-core/src/airflow/ui/src/constants/localStorage.test.ts
index b485f9420c8..cae41719013 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.test.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.test.ts
@@ -18,7 +18,11 @@
  */
 import { afterEach, describe, expect, it } from "vitest";
 
-import { pruneLegacyDependencyKeys, SHOW_ALL_DEPENDENCIES_KEY } from 
"./localStorage";
+import {
+  pruneLegacyDependencyKeys,
+  pruneLegacyTagFilterKeys,
+  SHOW_ALL_DEPENDENCIES_KEY,
+} from "./localStorage";
 
 describe("pruneLegacyDependencyKeys", () => {
   afterEach(() => {
@@ -49,3 +53,29 @@ describe("pruneLegacyDependencyKeys", () => {
     expect(globalThis.localStorage.length).toBe(1);
   });
 });
+
+describe("pruneLegacyTagFilterKeys", () => {
+  afterEach(() => {
+    globalThis.localStorage.clear();
+  });
+
+  it("removes the legacy tag-filter keys, leaving everything else intact", () 
=> {
+    globalThis.localStorage.setItem("tags", JSON.stringify(["a", "b"]));
+    globalThis.localStorage.setItem("tags_match_mode", JSON.stringify("all"));
+    globalThis.localStorage.setItem(SHOW_ALL_DEPENDENCIES_KEY, "true");
+
+    pruneLegacyTagFilterKeys();
+
+    expect(globalThis.localStorage.getItem("tags")).toBeNull();
+    expect(globalThis.localStorage.getItem("tags_match_mode")).toBeNull();
+    
expect(globalThis.localStorage.getItem(SHOW_ALL_DEPENDENCIES_KEY)).toBe("true");
+  });
+
+  it("is a no-op when there are no legacy keys", () => {
+    globalThis.localStorage.setItem(SHOW_ALL_DEPENDENCIES_KEY, "false");
+
+    pruneLegacyTagFilterKeys();
+
+    expect(globalThis.localStorage.length).toBe(1);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts 
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 85e1d101350..29a60434f3d 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -67,3 +67,11 @@ export const pruneLegacyDependencyKeys = (storage: Storage = 
globalThis.localSto
 
   staleKeys.forEach((key) => storage.removeItem(key));
 };
+
+// One-time cleanup of the per-browser tag-filter persistence (`tags` / 
`tags_match_mode`) that
+// predates preset filters (#63273). Tags are URL-driven now like every other 
filter, and preset
+// filters own cross-navigation persistence, so these keys are stale.
+export const pruneLegacyTagFilterKeys = (storage: Storage = 
globalThis.localStorage): void => {
+  storage.removeItem("tags");
+  storage.removeItem("tags_match_mode");
+};
diff --git a/airflow-core/src/airflow/ui/src/main.tsx 
b/airflow-core/src/airflow/ui/src/main.tsx
index f3f8f2b0cdb..a88f22a0084 100644
--- a/airflow-core/src/airflow/ui/src/main.tsx
+++ b/airflow-core/src/airflow/ui/src/main.tsx
@@ -29,7 +29,7 @@ import * as ReactRouterDOM from "react-router-dom";
 import * as ReactJSXRuntime from "react/jsx-runtime";
 
 import type { HTTPExceptionResponse } from "openapi/requests/types.gen";
-import { pruneLegacyDependencyKeys } from "src/constants/localStorage";
+import { pruneLegacyDependencyKeys, pruneLegacyTagFilterKeys } from 
"src/constants/localStorage";
 import { ChakraCustomProvider } from "src/context/ChakraCustomProvider";
 import { ColorModeProvider } from "src/context/colorMode";
 import { ShortcutRegistryProvider } from "src/context/keyboardShortcuts";
@@ -97,6 +97,7 @@ axios.interceptors.response.use(
 );
 
 pruneLegacyDependencyKeys();
+pruneLegacyTagFilterKeys();
 
 createRoot(document.querySelector("#root") as HTMLDivElement).render(
   <StrictMode>
diff --git 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
index 14cdfa4c212..f34887382c2 100644
--- 
a/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
+++ 
b/airflow-core/src/airflow/ui/src/pages/DagsList/DagsFilters/DagsFilters.test.tsx
@@ -143,4 +143,10 @@ describe("Paused filter with hide_paused_dags_by_default 
enabled", () => {
 
     expect(await 
screen.findByLabelText("dagDetails.team")).toBeInTheDocument();
   });
+
+  it("renders the preset filters menu", async () => {
+    render(<AppWrapper initialEntries={["/dags"]} />);
+
+    expect(await 
screen.findByTestId("preset-filters-button")).toBeInTheDocument();
+  });
 });
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 7480a064692..a148d8fece7 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
@@ -16,12 +16,13 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Box, HStack } from "@chakra-ui/react";
+import { HStack } from "@chakra-ui/react";
 import { useState } from "react";
 import { useTranslation } from "react-i18next";
 import { useSearchParams } from "react-router-dom";
 
 import { useTableURLState } from "src/components/DataTable/useTableUrlState";
+import { PresetFiltersMenu } from "src/components/PresetFiltersMenu";
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 import { useConfig } from "src/queries/useConfig";
 import { useDagTagsInfinite } from "src/queries/useDagTagsInfinite";
@@ -233,9 +234,8 @@ export const DagsFilters = () => {
       {multiTeamEnabled ? (
         <TeamFilter onChange={handleTeamsChange} selectedTeams={selectedTeams} 
/>
       ) : undefined}
-      <Box marginInlineStart="auto">
-        <FavoriteFilter onChange={handleFavoriteChange} value={favoriteValue} 
/>
-      </Box>
+      <FavoriteFilter onChange={handleFavoriteChange} value={favoriteValue} />
+      <PresetFiltersMenu />
     </HStack>
   );
 };
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
index 95228a1d3b9..660db17a3b6 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.test.tsx
@@ -63,46 +63,16 @@ describe("useTagFilter — initial state", () => {
     expect(result.current.tagFilterMode).toBe("all");
   });
 
-  it("falls back to localStorage when URL has no tags", () => {
+  it("ignores legacy tag values left in localStorage (URL is the only 
source)", () => {
     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"]);
+    expect(result.current.selectedTags).toEqual([]);
+    expect(result.current.tagFilterMode).toBe("any");
   });
 });
 
@@ -119,16 +89,16 @@ describe("useTagFilter — setSelectedTags", () => {
     expect(result.current.selectedTags).toEqual(["new-tag-1", "new-tag-2"]);
   });
 
-  it("saves tags to localStorage", () => {
+  it("does not persist tags to localStorage", () => {
     const { result } = renderHook(() => useTagFilter(), {
       wrapper: createWrapper(),
     });
 
     act(() => {
-      result.current.setSelectedTags(["persisted-tag"]);
+      result.current.setSelectedTags(["some-tag"]);
     });
 
-    expect(JSON.parse(localStorage.getItem("tags") ?? 
"[]")).toEqual(["persisted-tag"]);
+    expect(localStorage.getItem("tags")).toBeNull();
   });
 
   it("clears tags when given empty array", () => {
@@ -211,7 +181,7 @@ describe("useTagFilter — setTagFilterMode", () => {
     expect(result.current.tagFilterMode).toBe("all");
   });
 
-  it("persists mode to localStorage", () => {
+  it("does not persist mode to localStorage", () => {
     const { result } = renderHook(() => useTagFilter(), {
       wrapper: createWrapper(["/?tags=a&tags=b"]),
     });
@@ -220,7 +190,7 @@ describe("useTagFilter — setTagFilterMode", () => {
       result.current.setTagFilterMode("all");
     });
 
-    expect(JSON.parse(localStorage.getItem("tags_match_mode") ?? 
'"any"')).toBe("all");
+    expect(localStorage.getItem("tags_match_mode")).toBeNull();
   });
 });
 
diff --git a/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.ts 
b/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.ts
index ea93928817c..b9da0059811 100644
--- a/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.ts
+++ b/airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.ts
@@ -17,7 +17,6 @@
  * under the License.
  */
 import { useSearchParams } from "react-router-dom";
-import { useLocalStorage } from "usehooks-ts";
 
 import { SearchParamsKeys, type SearchParamsKeysType } from 
"src/constants/searchParams";
 
@@ -27,20 +26,10 @@ 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 selectedTags = 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 tagFilterMode: TagMatchMode = urlMatchMode === null ? "any" : 
(urlMatchMode as TagMatchMode);
 
   const setSelectedTags = (tags: Array<string>) => {
     searchParams.delete(TAGS);
@@ -49,14 +38,12 @@ export const useTagFilter = () => {
     });
     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