From 8b9f7b76a686c763b6878f2127e6c3b0031a1b25 Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Thu, 1 Oct 2026 13:24:19 +0200 Subject: [PATCH 1/3] Persist the match-anywhere search toggle in the URL The substring (match-anywhere) search toggle only lived in each browser's localStorage, so a filtered search could not be reproduced from a shared link: a colleague opening the URL saw prefix results unless they happened to have the same toggle enabled locally. Mirroring the toggle in a shareable URL param fixes that. The param lists the enabled searchbars by key so pages with several text filters stay unambiguous, and the per-searchbar localStorage preference remains the fallback when the URL does not carry it. closes: #73772 --- .../airflow/ui/src/constants/searchParams.ts | 1 + .../ui/src/hooks/useAdvancedSearch.test.tsx | 118 ++++++++++++++++++ .../airflow/ui/src/hooks/useAdvancedSearch.ts | 34 ++++- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts b/airflow-core/src/airflow/ui/src/constants/searchParams.ts index 195a8eccc1c09..95aec885f797c 100644 --- a/airflow-core/src/airflow/ui/src/constants/searchParams.ts +++ b/airflow-core/src/airflow/ui/src/constants/searchParams.ts @@ -79,6 +79,7 @@ export enum SearchParamsKeys { LOGICAL_DATE_RANGE = "logical_date_range", MAP_INDEX = "map_index", MAPPED = "mapped", + MATCH_ANYWHERE = "match_anywhere", MAX_ACTIVE_RUNS_GTE = "max_active_runs_gte", MAX_ACTIVE_RUNS_LTE = "max_active_runs_lte", MISSED = "missed", 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..fa4b0e5ccd159 --- /dev/null +++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx @@ -0,0 +1,118 @@ +/*! + * 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 match_anywhere", () => { + const { result } = renderAdvancedSearch("dags", ["/dags?match_anywhere=dags"]); + + expect(result.current.advanced.enabled).toBe(true); + }); + + it("keeps searchbars independent via per-key values", () => { + const entries = ["/events?match_anywhere=dag_id&match_anywhere=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); + }); +}); + +describe("useAdvancedSearch toggle", () => { + it("adds the key to match_anywhere when enabled", () => { + const { result } = renderAdvancedSearch("dags"); + + act(() => result.current.advanced.onToggle(true)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).toEqual(["dags"]); + expect(result.current.advanced.enabled).toBe(true); + }); + + it("removes only its own key and keeps the other searchbars", () => { + const { result } = renderAdvancedSearch("dag_id", [ + "/events?match_anywhere=dag_id&match_anywhere=run_id", + ]); + + act(() => result.current.advanced.onToggle(false)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).toEqual(["run_id"]); + }); + + it("does not duplicate the key when enabled while already present", () => { + const { result } = renderAdvancedSearch("dags", ["/dags?match_anywhere=dags"]); + + act(() => result.current.advanced.onToggle(true)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).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..443031450d6c1 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts +++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts @@ -16,17 +16,41 @@ * 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. ``match_anywhere`` is a repeated param listing the search keys whose toggle is on +// (`?match_anywhere=dags`, or `?match_anywhere=dag_id&match_anywhere=run_id` on a page with several +// text filters), keeping each searchbar independent. A key present in the URL wins so a shared link +// reproduces the sender's results; otherwise the per-searchbar localStorage preference applies, and +// clicking the toggle writes both. 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 enabled = searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE).includes(key) || storedEnabled; + + const onToggle = (nextEnabled: boolean) => { + setSearchParams((previous) => { + const next = new URLSearchParams(previous); + const retained = next.getAll(SearchParamsKeys.MATCH_ANYWHERE).filter((value) => value !== key); + + next.delete(SearchParamsKeys.MATCH_ANYWHERE); + retained.forEach((value) => next.append(SearchParamsKeys.MATCH_ANYWHERE, value)); + if (nextEnabled) { + next.append(SearchParamsKeys.MATCH_ANYWHERE, key); + } + + return next; + }); + setStoredEnabled(nextEnabled); + }; + + return { enabled, onToggle }; }; type AdvancedSearchArgOptions = { From b1cc918aa085db8634f385daf19796e01aa1ec45 Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Fri, 2 Oct 2026 10:58:18 +0200 Subject: [PATCH 2/3] Persist the match-anywhere off state in the URL too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle only recorded the positive, so turning match-anywhere off just dropped the param — a shared link then could not reproduce an off result for a recipient whose stored preference (or, with the stacked setting, default) was on. The param now carries an explicit `-key` entry for off, so both directions are shareable. The URL still wins over localStorage, and a key with no entry falls back to it. --- .../ui/src/hooks/useAdvancedSearch.test.tsx | 34 +++++++++++++++++-- .../airflow/ui/src/hooks/useAdvancedSearch.ts | 22 ++++++------ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx index fa4b0e5ccd159..7bb2a09d45514 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx +++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx @@ -78,6 +78,14 @@ describe("useAdvancedSearch reads", () => { 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?match_anywhere=-dags"]); + + expect(result.current.advanced.enabled).toBe(false); + }); }); describe("useAdvancedSearch toggle", () => { @@ -90,14 +98,36 @@ describe("useAdvancedSearch toggle", () => { expect(result.current.advanced.enabled).toBe(true); }); - it("removes only its own key and keeps the other searchbars", () => { + it("records an explicit off marker and keeps the other searchbars when disabled", () => { const { result } = renderAdvancedSearch("dag_id", [ "/events?match_anywhere=dag_id&match_anywhere=run_id", ]); act(() => result.current.advanced.onToggle(false)); - expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).toEqual(["run_id"]); + expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).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.MATCH_ANYWHERE)).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?match_anywhere=-dags"]); + + act(() => result.current.advanced.onToggle(true)); + + expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).toEqual(["dags"]); + expect(result.current.advanced.enabled).toBe(true); }); it("does not duplicate the key when enabled while already present", () => { diff --git a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts index 443031450d6c1..5f44d878a5c30 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts +++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts @@ -23,27 +23,29 @@ import { advancedSearchKey } from "src/constants/localStorage"; import { SearchParamsKeys } from "src/constants/searchParams"; // The "match anywhere" (substring) toggle is mirrored in the URL so a filtered search can be shared -// and reproduced. ``match_anywhere`` is a repeated param listing the search keys whose toggle is on -// (`?match_anywhere=dags`, or `?match_anywhere=dag_id&match_anywhere=run_id` on a page with several -// text filters), keeping each searchbar independent. A key present in the URL wins so a shared link -// reproduces the sender's results; otherwise the per-searchbar localStorage preference applies, and -// clicking the toggle writes both. +// and reproduced in both directions. ``match_anywhere`` is a repeated param carrying each searchbar's +// explicit choice by key: ``key`` for on, ``-key`` for off +// (`?match_anywhere=dag_id&match_anywhere=-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 [searchParams, setSearchParams] = useSearchParams(); const [storedEnabled, setStoredEnabled] = useLocalStorage(advancedSearchKey(key), false); - const enabled = searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE).includes(key) || storedEnabled; + const urlValues = searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE); + 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.MATCH_ANYWHERE).filter((value) => value !== key); + const retained = next + .getAll(SearchParamsKeys.MATCH_ANYWHERE) + .filter((value) => value !== key && value !== `-${key}`); next.delete(SearchParamsKeys.MATCH_ANYWHERE); retained.forEach((value) => next.append(SearchParamsKeys.MATCH_ANYWHERE, value)); - if (nextEnabled) { - next.append(SearchParamsKeys.MATCH_ANYWHERE, key); - } + next.append(SearchParamsKeys.MATCH_ANYWHERE, nextEnabled ? key : `-${key}`); return next; }); From eb27a4ddd39790798e6f1fc6bcb47beb713c56a4 Mon Sep 17 00:00:00 2001 From: pierrejeambrun Date: Fri, 2 Oct 2026 11:18:13 +0200 Subject: [PATCH 3/3] Rename the search toggle URL param to advanced_search The toggle already persists per-searchbar under the `advanced_search-` localStorage key, so naming the URL param `advanced_search` too (rather than `match_anywhere`) makes it obvious the two are the same feature. --- .../airflow/ui/src/constants/searchParams.ts | 2 +- .../ui/src/hooks/useAdvancedSearch.test.tsx | 26 +++++++++---------- .../airflow/ui/src/hooks/useAdvancedSearch.ts | 14 +++++----- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts b/airflow-core/src/airflow/ui/src/constants/searchParams.ts index 95aec885f797c..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", @@ -79,7 +80,6 @@ export enum SearchParamsKeys { LOGICAL_DATE_RANGE = "logical_date_range", MAP_INDEX = "map_index", MAPPED = "mapped", - MATCH_ANYWHERE = "match_anywhere", MAX_ACTIVE_RUNS_GTE = "max_active_runs_gte", MAX_ACTIVE_RUNS_LTE = "max_active_runs_lte", MISSED = "missed", diff --git a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx index 7bb2a09d45514..88ca4786e5b00 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx +++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx @@ -57,14 +57,14 @@ describe("useAdvancedSearch reads", () => { expect(result.current.advanced.enabled).toBe(false); }); - it("is enabled when the key is listed in match_anywhere", () => { - const { result } = renderAdvancedSearch("dags", ["/dags?match_anywhere=dags"]); + 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?match_anywhere=dag_id&match_anywhere=run_id"]; + 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); @@ -82,30 +82,30 @@ describe("useAdvancedSearch reads", () => { it("honors an explicit off marker over a stored-on preference", () => { localStorage.setItem(advancedSearchKey("dags"), JSON.stringify(true)); - const { result } = renderAdvancedSearch("dags", ["/dags?match_anywhere=-dags"]); + const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=-dags"]); expect(result.current.advanced.enabled).toBe(false); }); }); describe("useAdvancedSearch toggle", () => { - it("adds the key to match_anywhere when enabled", () => { + 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.MATCH_ANYWHERE)).toEqual(["dags"]); + 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?match_anywhere=dag_id&match_anywhere=run_id", + "/events?advanced_search=dag_id&advanced_search=run_id", ]); act(() => result.current.advanced.onToggle(false)); - expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).toEqual([ + expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual([ "run_id", "-dag_id", ]); @@ -117,25 +117,25 @@ describe("useAdvancedSearch toggle", () => { act(() => result.current.advanced.onToggle(false)); - expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).toEqual(["-dags"]); + 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?match_anywhere=-dags"]); + const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=-dags"]); act(() => result.current.advanced.onToggle(true)); - expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).toEqual(["dags"]); + 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?match_anywhere=dags"]); + const { result } = renderAdvancedSearch("dags", ["/dags?advanced_search=dags"]); act(() => result.current.advanced.onToggle(true)); - expect(result.current.searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE)).toEqual(["dags"]); + expect(result.current.searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH)).toEqual(["dags"]); }); it("persists the toggle to localStorage as well", () => { diff --git a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts index 5f44d878a5c30..551c2424b01b0 100644 --- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts +++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts @@ -23,9 +23,9 @@ import { advancedSearchKey } from "src/constants/localStorage"; import { SearchParamsKeys } from "src/constants/searchParams"; // The "match anywhere" (substring) toggle is mirrored in the URL so a filtered search can be shared -// and reproduced in both directions. ``match_anywhere`` is a repeated param carrying each searchbar's +// 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 -// (`?match_anywhere=dag_id&match_anywhere=-run_id`), keeping each searchbar independent. An explicit URL +// (`?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. @@ -33,19 +33,19 @@ export const useAdvancedSearch = (key: string) => { const [searchParams, setSearchParams] = useSearchParams(); const [storedEnabled, setStoredEnabled] = useLocalStorage(advancedSearchKey(key), false); - const urlValues = searchParams.getAll(SearchParamsKeys.MATCH_ANYWHERE); + 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.MATCH_ANYWHERE) + .getAll(SearchParamsKeys.ADVANCED_SEARCH) .filter((value) => value !== key && value !== `-${key}`); - next.delete(SearchParamsKeys.MATCH_ANYWHERE); - retained.forEach((value) => next.append(SearchParamsKeys.MATCH_ANYWHERE, value)); - next.append(SearchParamsKeys.MATCH_ANYWHERE, nextEnabled ? key : `-${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; });