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