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 };