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