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 e9066d8c1809b..86ce290f716e3 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 616a924de40af..7bb933d8f8ee3 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, @@ -185,8 +186,6 @@ const { OFFSET, OWNERS, PAUSED, - TAGS, - TAGS_MATCH_MODE, }: SearchParamsKeysType = SearchParamsKeys; const cardDef: CardDef = { @@ -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 0000000000000..95228a1d3b9c0 --- /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 = ["/"]) => + ({ children }: PropsWithChildren) => ( + + {children} + + ); + +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 0000000000000..ea93928817c46 --- /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>(TAGS, []); + const [savedTagMatchMode, setSavedTagMatchMode] = useLocalStorage(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) => { + 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 }; +};