This is an automated email from the ASF dual-hosted git repository.

bbovenzi 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 c31888641b1 Persist the match-anywhere search toggle in the URL 
(#74019)
c31888641b1 is described below

commit c31888641b1c2c9f3d90b44be1241d7be0b46472
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Oct 2 16:28:36 2026 +0200

    Persist the match-anywhere search toggle in the URL (#74019)
    
    * 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
    
    * Persist the match-anywhere off state in the URL too
    
    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.
    
    * Rename the search toggle URL param to advanced_search
    
    The toggle already persists per-searchbar under the `advanced_search-<id>` 
localStorage key, so naming the URL param `advanced_search` too (rather than 
`match_anywhere`) makes it obvious the two are the same feature.
---
 .../src/airflow/ui/src/constants/searchParams.ts   |   1 +
 .../ui/src/hooks/useAdvancedSearch.test.tsx        | 148 +++++++++++++++++++++
 .../src/airflow/ui/src/hooks/useAdvancedSearch.ts  |  36 ++++-
 3 files changed, 180 insertions(+), 5 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/constants/searchParams.ts 
b/airflow-core/src/airflow/ui/src/constants/searchParams.ts
index 74453e5a44e..d063d27766c 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 00000000000..88ca4786e5b
--- /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<string> = ["/dags"]) =>
+  ({ children }: PropsWithChildren) => (
+    <BaseWrapper>
+      <MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>
+    </BaseWrapper>
+  );
+
+const renderAdvancedSearch = (key: string, initialEntries?: Array<string>) =>
+  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 2f17b7f6fd6..551c2424b01 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<boolean>(advancedSearchKey(key), false);
+  const [searchParams, setSearchParams] = useSearchParams();
+  const [storedEnabled, setStoredEnabled] = 
useLocalStorage<boolean>(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<TPrefix extends string, TPattern extends string> 
= {

Reply via email to