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

shahar1 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 0f3a356e768 Add a user setting for the default search match mode 
(#74021)
0f3a356e768 is described below

commit 0f3a356e768eb54c4272d90dbf715376c01399a7
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Sat Oct 3 09:50:08 2026 +0200

    Add a user setting for the default search match mode (#74021)
    
    Turning on substring ("match anywhere") search has to be redone on every 
searchbar today, since the toggle only remembers itself per searchbar. This 
adds a single default in the Settings page so a user can opt into substring 
search everywhere at once, while an explicit per-searchbar toggle or a shared 
URL still takes precedence. The helper text calls out that substring search is 
slower because it cannot use the prefix index.
---
 .../airflow/ui/public/i18n/locales/en/common.json  |  7 ++++++
 .../src/airflow/ui/src/constants/localStorage.ts   |  1 +
 .../ui/src/hooks/useAdvancedSearch.test.tsx        | 29 +++++++++++++++++++++-
 .../src/airflow/ui/src/hooks/useAdvancedSearch.ts  | 13 ++++++----
 .../airflow/ui/src/hooks/useUserSettings.test.tsx  | 21 ++++++++++++++++
 .../src/airflow/ui/src/hooks/useUserSettings.ts    |  7 ++++++
 .../ui/src/pages/Settings/Settings.test.tsx        | 18 ++++++++++++++
 .../src/airflow/ui/src/pages/Settings/Settings.tsx | 16 ++++++++++++
 8 files changed, 106 insertions(+), 6 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json 
b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
index 8caa9019a45..1eddb1b424a 100644
--- a/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
+++ b/airflow-core/src/airflow/ui/public/i18n/locales/en/common.json
@@ -320,6 +320,13 @@
       },
       "title": "Marking"
     },
+    "search": {
+      "matchAnywhere": {
+        "helper": "$t(search.advanced.description) Per-searchbar choices and 
shared links still take precedence.",
+        "label": "Match anywhere by default"
+      },
+      "title": "Search"
+    },
     "taskInstance": {
       "defaultTab": {
         "helper": "Tab shown first when you open a task instance. Links to a 
specific tab still open that tab.",
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts 
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 0b0ec8f4e92..db0afb7eab8 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -40,6 +40,7 @@ export const CLEAR_KEEP_TASK_STATE_KEY = 
"clear_keep_task_state";
 export const MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY = 
"mark_task_instance_default_options";
 export const DEFAULT_TASK_INSTANCE_TAB_KEY = "default_task_instance_tab";
 export const DEFAULT_LANDING_PAGE_KEY = "default_landing_page";
+export const DEFAULT_MATCH_ANYWHERE_KEY = "default_match_anywhere";
 
 // Dag-scoped keys
 export const dagRunsLimitKey = (dagId: string) => `dag_runs_limit-${dagId}`;
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 88ca4786e5b..47de25c4b24 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx
+++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.test.tsx
@@ -22,7 +22,7 @@ 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 { advancedSearchKey, DEFAULT_MATCH_ANYWHERE_KEY } from 
"src/constants/localStorage";
 import { SearchParamsKeys } from "src/constants/searchParams";
 import { BaseWrapper } from "src/utils/Wrapper";
 
@@ -146,3 +146,30 @@ describe("useAdvancedSearch toggle", () => {
     
expect(localStorage.getItem(advancedSearchKey("dags"))).toBe(JSON.stringify(true));
   });
 });
+
+describe("useAdvancedSearch global default", () => {
+  it("follows the match-anywhere-by-default setting when URL and per-key 
storage are absent", () => {
+    localStorage.setItem(DEFAULT_MATCH_ANYWHERE_KEY, JSON.stringify(true));
+
+    const { result } = renderAdvancedSearch("dags");
+
+    expect(result.current.advanced.enabled).toBe(true);
+  });
+
+  it("lets an explicit per-searchbar choice override the global default", () 
=> {
+    localStorage.setItem(DEFAULT_MATCH_ANYWHERE_KEY, JSON.stringify(true));
+    localStorage.setItem(advancedSearchKey("dags"), JSON.stringify(false));
+
+    const { result } = renderAdvancedSearch("dags");
+
+    expect(result.current.advanced.enabled).toBe(false);
+  });
+
+  it("lets the URL override the global default", () => {
+    localStorage.setItem(DEFAULT_MATCH_ANYWHERE_KEY, JSON.stringify(false));
+
+    const { result } = renderAdvancedSearch("dags", 
["/dags?advanced_search=dags"]);
+
+    expect(result.current.advanced.enabled).toBe(true);
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts 
b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts
index 551c2424b01..f3d4a629334 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/useAdvancedSearch.ts
@@ -21,17 +21,20 @@ import { useLocalStorage } from "usehooks-ts";
 
 import { advancedSearchKey } from "src/constants/localStorage";
 import { SearchParamsKeys } from "src/constants/searchParams";
+import { useDefaultMatchAnywhere } from "src/hooks/useUserSettings";
 
 // 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.
+// (`?advanced_search=dag_id&advanced_search=-run_id`), keeping each searchbar 
independent. Resolution
+// order: an explicit URL entry wins, so a shared link reproduces the sender's 
on/off choices whatever
+// the recipient's own preferences; otherwise the user's per-searchbar 
localStorage choice; otherwise
+// the global "match anywhere by default" setting. Toggling writes the 
explicit on/off entry and
+// localStorage.
 export const useAdvancedSearch = (key: string) => {
   const [searchParams, setSearchParams] = useSearchParams();
-  const [storedEnabled, setStoredEnabled] = 
useLocalStorage<boolean>(advancedSearchKey(key), false);
+  const [defaultEnabled] = useDefaultMatchAnywhere();
+  const [storedEnabled, setStoredEnabled] = 
useLocalStorage<boolean>(advancedSearchKey(key), defaultEnabled);
 
   const urlValues = searchParams.getAll(SearchParamsKeys.ADVANCED_SEARCH);
   const enabled = urlValues.includes(key) ? true : 
urlValues.includes(`-${key}`) ? false : storedEnabled;
diff --git a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx 
b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
index 9987b1ac52d..af7003ec7d8 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
+++ b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
@@ -26,6 +26,7 @@ import {
   CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
   DEFAULT_GRAPH_DIRECTION_KEY,
   DEFAULT_LANDING_PAGE_KEY,
+  DEFAULT_MATCH_ANYWHERE_KEY,
   DEFAULT_TASK_INSTANCE_TAB_KEY,
   MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
 } from "src/constants/localStorage";
@@ -37,6 +38,7 @@ import {
   useClearTaskInstanceDefaultOptions,
   useDefaultGraphDirection,
   useDefaultLandingPage,
+  useDefaultMatchAnywhere,
   useDefaultTaskInstanceTab,
   useMarkTaskInstanceDefaultOptions,
 } from "./useUserSettings";
@@ -152,6 +154,25 @@ describe("useClearKeepTaskStateDefault", () => {
   });
 });
 
+describe("useDefaultMatchAnywhere", () => {
+  it("defaults to false", () => {
+    const { result } = renderHook(() => useDefaultMatchAnywhere());
+
+    expect(result.current[0]).toBe(false);
+  });
+
+  it("persists a new value", () => {
+    const { result } = renderHook(() => useDefaultMatchAnywhere());
+
+    act(() => {
+      result.current[1](true);
+    });
+
+    expect(result.current[0]).toBe(true);
+    expect(JSON.parse(localStorage.getItem(DEFAULT_MATCH_ANYWHERE_KEY) ?? 
"false")).toBe(true);
+  });
+});
+
 describe("useDefaultTaskInstanceTab", () => {
   it("defaults to logs when nothing is stored", () => {
     const { result } = renderHook(() => useDefaultTaskInstanceTab());
diff --git a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts 
b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
index e37ab971b33..97f7f2e7a96 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
@@ -26,6 +26,7 @@ import {
   CLEAR_RUN_DEFAULT_OPTIONS_KEY,
   CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
   DEFAULT_GRAPH_DIRECTION_KEY,
+  DEFAULT_MATCH_ANYWHERE_KEY,
   DEFAULT_TASK_GROUPS_EXPANDED_KEY,
   DEFAULT_LANDING_PAGE_KEY,
   DEFAULT_TASK_INSTANCE_TAB_KEY,
@@ -49,6 +50,12 @@ export const useDefaultGraphDirection = () =>
 export const useDefaultTaskGroupsExpanded = () =>
   useLocalStorage<boolean>(DEFAULT_TASK_GROUPS_EXPANDED_KEY, false);
 
+/**
+ * Fallback for the per-searchbar "match anywhere" (substring) toggle: the 
value a searchbar uses
+ * when neither the URL nor its own localStorage entry says otherwise. 
Defaults to off (prefix).
+ */
+export const useDefaultMatchAnywhere = () => 
useLocalStorage<boolean>(DEFAULT_MATCH_ANYWHERE_KEY, false);
+
 /** Default selection for the Dag-run Clear dialog toggle (existing / 
only-failed / queue-new). */
 export const useClearRunDefaultOptions = () =>
   useLocalStorage<Array<string>>(CLEAR_RUN_DEFAULT_OPTIONS_KEY, 
["existingTasks"]);
diff --git a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.test.tsx 
b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.test.tsx
index 8520bc31622..10cd4fc9aae 100644
--- a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.test.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.test.tsx
@@ -26,6 +26,7 @@ import {
   CLEAR_KEEP_TASK_STATE_KEY,
   CLEAR_PREVENT_RUNNING_TASK_KEY,
   DEFAULT_GRAPH_DIRECTION_KEY,
+  DEFAULT_MATCH_ANYWHERE_KEY,
   DEFAULT_TASK_GROUPS_EXPANDED_KEY,
   DEFAULT_LANDING_PAGE_KEY,
   DEFAULT_TASK_INSTANCE_TAB_KEY,
@@ -70,6 +71,10 @@ beforeAll(async () => {
               taskSelection: { helper: "helper", label: "Default mark 
selection" },
               title: "Marking",
             },
+            search: {
+              matchAnywhere: { helper: "helper", label: "Match anywhere by 
default" },
+              title: "Search",
+            },
             taskInstance: {
               defaultTab: { helper: "helper", label: "Default task instance 
tab" },
               title: "Task Instance",
@@ -178,3 +183,16 @@ describe("task group setting", () => {
     
expect(screen.getByTestId("default-task-groups-expanded")).toHaveAttribute("data-state",
 "checked");
   });
 });
+
+describe("match anywhere setting", () => {
+  it("defaults to off and persists the preference", async () => {
+    const { unmount } = render(<Settings />, { wrapper: BaseWrapper });
+
+    
expect(screen.getByTestId("default-match-anywhere")).toHaveAttribute("data-state",
 "unchecked");
+    fireEvent.click(screen.getByRole("checkbox", { name: "Match anywhere by 
default" }));
+    await waitFor(() => 
expect(localStorage.getItem(DEFAULT_MATCH_ANYWHERE_KEY)).toBe("true"));
+    unmount();
+    render(<Settings />, { wrapper: BaseWrapper });
+    
expect(screen.getByTestId("default-match-anywhere")).toHaveAttribute("data-state",
 "checked");
+  });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx 
b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
index 17a7623eff1..11d085dc631 100644
--- a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
@@ -33,6 +33,7 @@ import {
   useClearTaskInstanceDefaultOptions,
   useDefaultGraphDirection,
   useDefaultLandingPage,
+  useDefaultMatchAnywhere,
   useDefaultTaskInstanceTab,
   useDefaultTaskGroupsExpanded,
   useMarkTaskInstanceDefaultOptions,
@@ -180,6 +181,7 @@ export const Settings = () => {
   const [markTaskOptions, setMarkTaskOptions] = 
useMarkTaskInstanceDefaultOptions();
   const [defaultTaskInstanceTab, setDefaultTaskInstanceTab] = 
useDefaultTaskInstanceTab();
   const [defaultLandingPage, setDefaultLandingPage] = useDefaultLandingPage();
+  const [matchAnywhere, setMatchAnywhere] = useDefaultMatchAnywhere();
 
   const taskInstanceTabOptions: Array<SelectOption<DefaultTaskInstanceTab>> = [
     { label: translate("dag:tabs.logs"), value: "logs" },
@@ -235,6 +237,20 @@ export const Settings = () => {
             value={defaultLandingPage}
           />
         </Section>
+        <Section title={translate("settings.search.title")}>
+          <SettingRow
+            control={
+              <Switch
+                aria-label={translate("settings.search.matchAnywhere.label")}
+                checked={matchAnywhere}
+                data-testid="default-match-anywhere"
+                onCheckedChange={(event) => setMatchAnywhere(event.checked)}
+              />
+            }
+            helper={translate("settings.search.matchAnywhere.helper")}
+            label={translate("settings.search.matchAnywhere.label")}
+          />
+        </Section>
         <Section title={translate("settings.graph.title")}>
           <SettingRow
             control={

Reply via email to