Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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("");

Expand Down Expand Up @@ -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);
Expand Down
6 changes: 2 additions & 4 deletions airflow-core/src/airflow/ui/src/pages/DagsList/DagsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -185,8 +186,6 @@ const {
OFFSET,
OWNERS,
PAUSED,
TAGS,
TAGS_MATCH_MODE,
}: SearchParamsKeysType = SearchParamsKeys;

const cardDef: CardDef<DAGWithLatestDagRunsResponse> = {
Expand All @@ -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);

Expand Down
275 changes: 275 additions & 0 deletions airflow-core/src/airflow/ui/src/pages/DagsList/useTagFilter.test.tsx
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading