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>
= {