diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts b/airflow-core/src/airflow/ui/src/constants/searchParams.ts index 195a8eccc1c09..40414c07ed5b1 100644 --- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts +++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts @@ -17,6 +17,7 @@ * under the License. */ export enum SearchParamsKeys { + ADVANCED_SEARCH = "advanced_search", AFTER = "after", ASSET_EVENT_DATE_RANGE = "asset_event_date_range", BEFORE = "before", diff --git a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx new file mode 100644 index 0000000000000..88ca4786e5b00 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx @@ -0,0 +1,148 @@ +/*! + * 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 type { PropsWithChildren } from "react"; + +import { act, renderHook } from "@testing-library/react"; +import { MemoryRouter, useSearchParams } from "react-router-dom"; +import { afterEach, describe, expect, it } from "vitest"; + +import { advancedSearchKey } from "src/constants/localStorage"; +import { SearchParamsKeys } from "src/constants/searchParams"; +import { BaseWrapper } from "src/utils/Wrapper"; + +import { useAdvancedSearch } from "./useAdvancedSearch"; + +const createWrapper = + (initialEntries: Array = ["/dags"]) => + ({ children }: PropsWithChildren) => ( + + {children} + + ); + +const renderAdvancedSearch = (key: string, initialEntries?: Array) => + renderHook( + () => { + const [searchParams] = useSearchParams(); + + return { advanced: useAdvancedSearch(key), searchParams }; + }, + { wrapper: createWrapper(initialEntries) }, + ); + +afterEach(() => { + localStorage.clear(); +}); + +describe("useAdvancedSearch reads", () => { + it("is disabled when neither the URL nor localStorage has a value", () => { + const { result } = renderAdvancedSearch("dags"); + + expect(result.current.advanced.enabled).toBe(false); + }); + + it("is enabled when the key is listed in advanced_search", () => { + const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=dags"]); + + expect(result.current.advanced.enabled).toBe(true); + }); + + it("keeps searchbars independent via per-key values", () => { + const entries = ["/events?advanced_search=dag_id&advanced_search=run_id"]; + + expect(renderAdvancedSearch("dag_id", entries).result.current.advanced.enabled).toBe(true); + expect(renderAdvancedSearch("run_id", entries).result.current.advanced.enabled).toBe(true); + expect(renderAdvancedSearch("task_id", entries).result.current.advanced.enabled).toBe(false); + }); + + it("falls back to the stored preference when the param is absent", () => { + localStorage.setItem(advancedSearchKey("dags"), JSON.stringify(true)); + + const { result } = renderAdvancedSearch("dags"); + + expect(result.current.advanced.enabled).toBe(true); + }); + + it("honors an explicit off marker over a stored-on preference", () => { + localStorage.setItem(advancedSearchKey("dags"), JSON.stringify(true)); + + const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=-dags"]); + + expect(result.current.advanced.enabled).toBe(false); + }); +}); + +describe("useAdvancedSearch toggle", () => { + it("adds the key to advanced_search when enabled", () => { + const { result } = renderAdvancedSearch("dags"); + + act(() => result.current.advanced.onToggle(true)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["dags"]); + expect(result.current.advanced.enabled).toBe(true); + }); + + it("records an explicit off marker and keeps the other searchbars when disabled", () => { + const { result } = renderAdvancedSearch("dag_id", [ + "/events?advanced_search=dag_id&advanced_search=run_id", + ]); + + act(() => result.current.advanced.onToggle(false)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual([ + "run_id", + "-dag_id", + ]); + expect(result.current.advanced.enabled).toBe(false); + }); + + it("writes an explicit off marker when disabled from no prior entry", () => { + const { result } = renderAdvancedSearch("dags"); + + act(() => result.current.advanced.onToggle(false)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["-dags"]); + expect(result.current.advanced.enabled).toBe(false); + }); + + it("flips from off to on without leaving the off marker behind", () => { + const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=-dags"]); + + act(() => result.current.advanced.onToggle(true)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["dags"]); + expect(result.current.advanced.enabled).toBe(true); + }); + + it("does not duplicate the key when enabled while already present", () => { + const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=dags"]); + + act(() => result.current.advanced.onToggle(true)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["dags"]); + }); + + it("persists the toggle to localStorage as well", () => { + const { result } = renderAdvancedSearch("dags"); + + act(() => result.current.advanced.onToggle(true)); + + expect(localStorage.getItem(advancedSearchKey("dags"))).toBe(JSON.stringify(true)); + }); +}); diff --git a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts index 2f17b7f6fd6be..551c2424b01b0 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts +++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts @@ -16,17 +16,43 @@ * specific language governing permissions and limitations * under the License. */ +import { useSearchParams } from "react-router-dom"; import { useLocalStorage } from "usehooks-ts"; import { advancedSearchKey } from "src/constants/localStorage"; +import { SearchParamsKeys } from "src/constants/searchParams"; -// Toggle is intentionally NOT mirrored in the URL: shared links default to the -// fast prefix-search behavior, and recipients can opt back into substring search -// per searchbar if they want it. +// The "match anywhere" (substring) toggle is mirrored in the URL so a filtered search can be shared +// and reproduced in both directions. ``advanced_search`` is a repeated param carrying each searchbar's +// explicit choice by key: ``key`` for on, ``-key`` for off +// (`?advanced_search=dag_id&advanced_search=-run_id`), keeping each searchbar independent. An explicit URL +// entry wins — a shared link reproduces the sender's on/off choices whatever the recipient's own +// preferences — and a key with no entry (e.g. landing through the nav) falls back to the per-searchbar +// localStorage preference. Toggling writes the explicit on/off entry and localStorage. export const useAdvancedSearch = (key: string) => { - const [enabled, setEnabled] = useLocalStorage(advancedSearchKey(key), false); + const [searchParams, setSearchParams] = useSearchParams(); + const [storedEnabled, setStoredEnabled] = useLocalStorage(advancedSearchKey(key), false); - return { enabled, onToggle: setEnabled }; + const urlValues = searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH); + const enabled = urlValues.includes(key) ? true : urlValues.includes(`-${key}`) ? false : storedEnabled; + + const onToggle = (nextEnabled: boolean) => { + setSearchParams((previous) => { + const next = new URLSearchParams(previous); + const retained = next + .getAll(SearchParamsKeys.ADVANCED_SEARCH) + .filter((value) => value !== key && value !== `-${key}`); + + next.delete(SearchParamsKeys.ADVANCED_SEARCH); + retained.forEach((value) => next.append(SearchParamsKeys.ADVANCED_SEARCH, value)); + next.append(SearchParamsKeys.ADVANCED_SEARCH, nextEnabled ? key : `-${key}`); + + return next; + }); + setStoredEnabled(nextEnabled); + }; + + return { enabled, onToggle }; }; type AdvancedSearchArgOptions = {