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 326c119e44d Add a user settings page for cross-page UI defaults
(#70687)
326c119e44d is described below
commit 326c119e44d888df2c54de6d3bacb726432f8ec3
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Aug 11 18:42:59 2026 +0200
Add a user settings page for cross-page UI defaults (#70687)
Several cross-page UI behaviors have no single place to set a personal
default. Graph layout direction and dependency scope are remembered per-Dag,
and the clear / mark-as dialog selections reset to a fixed choice every time,
so users re-apply the same options over and over. Centralizing these as
browser-local preferences lets a user set their default once and have the
individual features fall back to it.
---
.../airflow/ui/public/i18n/locales/en/common.json | 35 ++-
.../ui/src/components/Clear/Run/ClearRunDialog.tsx | 6 +-
.../TaskInstance/ClearGroupTaskInstanceDialog.tsx | 6 +-
.../Clear/TaskInstance/ClearTaskInstanceDialog.tsx | 12 +-
.../components/Graph/DirectionDropdown.test.tsx | 74 ++++++
.../ui/src/components/Graph/DirectionDropdown.tsx | 4 +-
.../TaskInstance/MarkTaskInstanceAsDialog.tsx | 5 +-
.../src/airflow/ui/src/constants/localStorage.ts | 5 +
.../airflow/ui/src/hooks/useUserSettings.test.tsx | 149 ++++++++++++
.../src/airflow/ui/src/hooks/useUserSettings.ts | 54 +++++
.../airflow/ui/src/layouts/Details/Graph/Graph.tsx | 4 +-
.../ui/src/layouts/Nav/UserSettingsButton.test.tsx | 37 +++
.../ui/src/layouts/Nav/UserSettingsButton.tsx | 23 +-
.../src/airflow/ui/src/pages/Asset/AssetGraph.tsx | 4 +-
.../ui/src/pages/Settings/Settings.test.tsx | 118 ++++++++++
.../src/airflow/ui/src/pages/Settings/Settings.tsx | 256 +++++++++++++++++++++
.../src/airflow/ui/src/pages/Settings/index.tsx | 19 ++
airflow-core/src/airflow/ui/src/router.tsx | 5 +
18 files changed, 788 insertions(+), 28 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 7db7ed5306c..1d37e59d795 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
@@ -88,8 +88,6 @@
"dagRunId": "Dag Run ID",
"dagRunState": "Dag Run State",
"dagWarnings": "Dag warnings/errors",
- "defaultToGraphView": "Default to graph view",
- "defaultToGridView": "Default to grid view",
"delete": "Delete",
"diff": "Diff",
"diffCompareWith": "Compare with",
@@ -246,6 +244,39 @@
},
"selectLanguage": "Select Language",
"selected": "Selected",
+ "settings": {
+ "clearing": {
+ "preventRunningTask": {
+ "helper": "Skip tasks that are currently running when clearing task
instances.",
+ "label": "Prevent clearing running tasks"
+ },
+ "runSelection": {
+ "helper": "Options selected by default when clearing a Dag run.",
+ "label": "Default run clear selection"
+ },
+ "taskSelection": {
+ "helper": "Options selected by default when clearing task instances.",
+ "label": "Default task clear selection"
+ },
+ "title": "Clearing"
+ },
+ "description": "These preferences are saved in this browser only and apply
across the whole app.",
+ "graph": {
+ "defaultDirection": {
+ "helper": "Layout direction for Dag and asset graphs you haven't set
individually.",
+ "label": "Default graph direction"
+ },
+ "title": "Graph"
+ },
+ "marking": {
+ "taskSelection": {
+ "helper": "Options selected by default when marking a task instance's
state.",
+ "label": "Default mark selection"
+ },
+ "title": "Marking"
+ },
+ "title": "Settings"
+ },
"shortcuts": {
"categories": {
"code": "Code",
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunDialog.tsx
b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunDialog.tsx
index c864f0b047c..c4124cc8878 100644
--- a/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunDialog.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Clear/Run/ClearRunDialog.tsx
@@ -28,6 +28,7 @@ import { getRunOnLatestVersionState } from
"src/components/Clear/TaskInstance/ru
import { useRerunWithLatestVersion } from
"src/components/Clear/useRerunWithLatestVersion";
import { Checkbox, Dialog } from "src/components/ui";
import SegmentedControl from "src/components/ui/SegmentedControl";
+import { useClearRunDefaultOptions } from "src/hooks/useUserSettings";
import { useClearDagRunDryRun } from "src/queries/useClearDagRunDryRun";
import { useClearDagRun } from "src/queries/useClearRun";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -55,7 +56,8 @@ const ClearRunDialog = ({ dagRun, onClose, open }: Props) => {
setNote(dagRun.note);
onClose();
};
- const [selectedOptions, setSelectedOptions] =
useState<Array<string>>(["existingTasks"]);
+ const [clearRunDefaultOptions] = useClearRunDefaultOptions();
+ const [selectedOptions, setSelectedOptions] =
useState<Array<string>>(clearRunDefaultOptions);
const onlyFailed = selectedOptions.includes("onlyFailed");
const onlyNew = selectedOptions.includes("newTasks");
@@ -132,7 +134,7 @@ const ClearRunDialog = ({ dagRun, onClose, open }: Props)
=> {
<Dialog.Body width="full">
<Flex justifyContent="center">
<SegmentedControl
- defaultValues={["existingTasks"]}
+ defaultValues={clearRunDefaultOptions}
onChange={setSelectedOptions}
options={[
{
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog.tsx
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog.tsx
index ab61c46e7ae..6a7e33354ae 100644
---
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog.tsx
+++
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearGroupTaskInstanceDialog.tsx
@@ -28,6 +28,7 @@ import { ActionAccordion } from
"src/components/ActionAccordion";
import { useRerunWithLatestVersion } from
"src/components/Clear/useRerunWithLatestVersion";
import { Checkbox, Dialog } from "src/components/ui";
import SegmentedControl from "src/components/ui/SegmentedControl";
+import { useClearTaskInstanceDefaultOptions } from "src/hooks/useUserSettings";
import { useClearTaskInstances } from "src/queries/useClearTaskInstances";
import { useClearTaskInstancesDryRun } from
"src/queries/useClearTaskInstancesDryRun";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -51,7 +52,8 @@ export const ClearGroupTaskInstanceDialog = ({ onClose, open,
taskInstance }: Pr
onSuccessConfirm: onClose,
});
- const [selectedOptions, setSelectedOptions] =
useState<Array<string>>(["downstream"]);
+ const [clearTaskInstanceDefaultOptions] =
useClearTaskInstanceDefaultOptions();
+ const [selectedOptions, setSelectedOptions] =
useState<Array<string>>(clearTaskInstanceDefaultOptions);
const onlyFailed = selectedOptions.includes("onlyFailed");
const past = selectedOptions.includes("past");
@@ -144,7 +146,7 @@ export const ClearGroupTaskInstanceDialog = ({ onClose,
open, taskInstance }: Pr
<Dialog.Body width="full">
<Flex justifyContent="center">
<SegmentedControl
- defaultValues={["downstream"]}
+ defaultValues={clearTaskInstanceDefaultOptions}
multiple
onChange={setSelectedOptions}
options={[
diff --git
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
index 0026da07395..53525e320b6 100644
---
a/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
+++
b/airflow-core/src/airflow/ui/src/components/Clear/TaskInstance/ClearTaskInstanceDialog.tsx
@@ -29,6 +29,10 @@ import { useRerunWithLatestVersion } from
"src/components/Clear/useRerunWithLate
import Time from "src/components/Time";
import { Checkbox, Dialog } from "src/components/ui";
import SegmentedControl from "src/components/ui/SegmentedControl";
+import {
+ useClearPreventRunningTaskDefault,
+ useClearTaskInstanceDefaultOptions,
+} from "src/hooks/useUserSettings";
import { useClearTaskInstances } from "src/queries/useClearTaskInstances";
import { useClearTaskInstancesDryRun } from
"src/queries/useClearTaskInstancesDryRun";
import { isStatePending, useAutoRefresh } from "src/utils";
@@ -77,14 +81,16 @@ const ClearTaskInstanceDialog = (props: Props) => {
const { t: translate } = useTranslation();
const { onClose, onOpen, open } = useDisclosure();
- const [selectedOptions, setSelectedOptions] =
useState<Array<string>>(["downstream"]);
+ const [clearTaskInstanceDefaultOptions] =
useClearTaskInstanceDefaultOptions();
+ const [preventRunningTaskDefault] = useClearPreventRunningTaskDefault();
+ const [selectedOptions, setSelectedOptions] =
useState<Array<string>>(clearTaskInstanceDefaultOptions);
const onlyFailed = selectedOptions.includes("onlyFailed");
const past = selectedOptions.includes("past");
const future = selectedOptions.includes("future");
const upstream = selectedOptions.includes("upstream");
const downstream = selectedOptions.includes("downstream");
- const [preventRunningTask, setPreventRunningTask] = useState(true);
+ const [preventRunningTask, setPreventRunningTask] =
useState(preventRunningTaskDefault);
const [note, setNote] = useState<string | null>(taskInstance?.note ?? null);
@@ -215,7 +221,7 @@ const ClearTaskInstanceDialog = (props: Props) => {
<Dialog.Body width="full">
<Flex justifyContent="center">
<SegmentedControl
- defaultValues={["downstream"]}
+ defaultValues={clearTaskInstanceDefaultOptions}
multiple
onChange={setSelectedOptions}
options={[
diff --git
a/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.test.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.test.tsx
new file mode 100644
index 00000000000..a78b1506811
--- /dev/null
+++
b/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.test.tsx
@@ -0,0 +1,74 @@
+/*!
+ * 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 "@testing-library/jest-dom/vitest";
+import { render, screen } from "@testing-library/react";
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import { afterEach, beforeAll, describe, expect, it } from "vitest";
+
+import { DEFAULT_GRAPH_DIRECTION_KEY, directionKey } from
"src/constants/localStorage";
+import { ChakraWrapper } from "src/utils/ChakraWrapper";
+
+import { DirectionDropdown } from "./DirectionDropdown";
+
+beforeAll(async () => {
+ await i18n.use(initReactI18next).init({
+ defaultNS: "components",
+ fallbackLng: "en",
+ interpolation: { escapeValue: false },
+ lng: "en",
+ ns: ["components", "dag"],
+ resources: {
+ en: {
+ components: {
+ graph: {
+ directionDown: "DOWN-LABEL",
+ directionLeft: "LEFT-LABEL",
+ directionRight: "RIGHT-LABEL",
+ directionUp: "UP-LABEL",
+ },
+ },
+ dag: { panel: { graphDirection: { label: "Direction" } } },
+ },
+ },
+ });
+});
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("DirectionDropdown", () => {
+ it("initializes from the global default when no per-graph direction is
stored", () => {
+ localStorage.setItem(DEFAULT_GRAPH_DIRECTION_KEY, JSON.stringify("DOWN"));
+
+ render(<DirectionDropdown graphId="test-dag" />, { wrapper: ChakraWrapper
});
+
+ expect(screen.getByRole("combobox")).toHaveTextContent("DOWN-LABEL");
+ });
+
+ it("uses the stored per-graph direction over the global default", () => {
+ localStorage.setItem(DEFAULT_GRAPH_DIRECTION_KEY, JSON.stringify("DOWN"));
+ localStorage.setItem(directionKey("test-dag"), JSON.stringify("LEFT"));
+
+ render(<DirectionDropdown graphId="test-dag" />, { wrapper: ChakraWrapper
});
+
+ expect(screen.getByRole("combobox")).toHaveTextContent("LEFT-LABEL");
+ });
+});
diff --git
a/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.tsx
b/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.tsx
index 41ad483ac90..82228d5b408 100644
--- a/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.tsx
+++ b/airflow-core/src/airflow/ui/src/components/Graph/DirectionDropdown.tsx
@@ -21,13 +21,15 @@ import { useTranslation } from "react-i18next";
import { useLocalStorage } from "usehooks-ts";
import { directionKey } from "src/constants/localStorage";
+import { useDefaultGraphDirection } from "src/hooks/useUserSettings";
export type Direction = "DOWN" | "LEFT" | "RIGHT" | "UP";
export const DirectionDropdown = ({ graphId }: { readonly graphId: string })
=> {
const { t: translate } = useTranslation(["components", "dag"]);
- const [direction, setDirection] =
useLocalStorage<Direction>(directionKey(graphId), "RIGHT");
+ const [defaultDirection] = useDefaultGraphDirection();
+ const [direction, setDirection] =
useLocalStorage<Direction>(directionKey(graphId), defaultDirection);
const directionOptions = () =>
createListCollection({
diff --git
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
index a89e6189f5f..dc1919a54a5 100644
---
a/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
+++
b/airflow-core/src/airflow/ui/src/components/MarkAs/TaskInstance/MarkTaskInstanceAsDialog.tsx
@@ -26,6 +26,7 @@ import { StateBadge } from "src/components/StateBadge";
import Time from "src/components/Time";
import { Dialog } from "src/components/ui";
import SegmentedControl from "src/components/ui/SegmentedControl";
+import { useMarkTaskInstanceDefaultOptions } from "src/hooks/useUserSettings";
import { usePatchTaskInstance } from "src/queries/usePatchTaskInstance";
import { usePatchTaskInstanceDryRun } from
"src/queries/usePatchTaskInstanceDryRun";
@@ -43,7 +44,8 @@ const MarkTaskInstanceAsDialog = ({ onClose, open, state,
taskInstance }: Props)
const mapIndex = taskInstance.map_index;
const { t: translate } = useTranslation();
- const [selectedOptions, setSelectedOptions] = useState<Array<string>>([]);
+ const [markTaskInstanceDefaultOptions] = useMarkTaskInstanceDefaultOptions();
+ const [selectedOptions, setSelectedOptions] =
useState<Array<string>>(markTaskInstanceDefaultOptions);
const past = selectedOptions.includes("past");
const future = selectedOptions.includes("future");
@@ -126,6 +128,7 @@ const MarkTaskInstanceAsDialog = ({ onClose, open, state,
taskInstance }: Props)
<Dialog.Body width="full">
<Flex justifyContent="center">
<SegmentedControl
+ defaultValues={markTaskInstanceDefaultOptions}
multiple
onChange={setSelectedOptions}
options={[
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 9e366796fd6..85e1d101350 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -29,6 +29,11 @@ export const LOG_SHOW_SOURCE_KEY = "log_show_source";
export const VERSION_INDICATOR_DISPLAY_MODE_KEY =
"version_indicator_display_mode";
export const COLLAPSED_UI_ALERTS_KEY = "collapsed_ui_alerts";
export const SHOW_ALL_DEPENDENCIES_KEY = "show_all_dependencies";
+export const DEFAULT_GRAPH_DIRECTION_KEY = "default_graph_direction";
+export const CLEAR_RUN_DEFAULT_OPTIONS_KEY = "clear_run_default_options";
+export const CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY =
"clear_task_instance_default_options";
+export const CLEAR_PREVENT_RUNNING_TASK_KEY = "clear_prevent_running_task";
+export const MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY =
"mark_task_instance_default_options";
// Dag-scoped keys
export const dagRunsLimitKey = (dagId: string) => `dag_runs_limit-${dagId}`;
diff --git a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
new file mode 100644
index 00000000000..4117595b0de
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.test.tsx
@@ -0,0 +1,149 @@
+/*!
+ * 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 { act, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import {
+ CLEAR_PREVENT_RUNNING_TASK_KEY,
+ CLEAR_RUN_DEFAULT_OPTIONS_KEY,
+ CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
+ DEFAULT_GRAPH_DIRECTION_KEY,
+ MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
+} from "src/constants/localStorage";
+
+import {
+ useClearPreventRunningTaskDefault,
+ useClearRunDefaultOptions,
+ useClearTaskInstanceDefaultOptions,
+ useDefaultGraphDirection,
+ useMarkTaskInstanceDefaultOptions,
+} from "./useUserSettings";
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("useDefaultGraphDirection", () => {
+ it("defaults to RIGHT when nothing is stored", () => {
+ const { result } = renderHook(() => useDefaultGraphDirection());
+
+ expect(result.current[0]).toBe("RIGHT");
+ });
+
+ it("reads an existing stored direction", () => {
+ localStorage.setItem(DEFAULT_GRAPH_DIRECTION_KEY, JSON.stringify("LEFT"));
+
+ const { result } = renderHook(() => useDefaultGraphDirection());
+
+ expect(result.current[0]).toBe("LEFT");
+ });
+
+ it("persists a new direction to localStorage", () => {
+ const { result } = renderHook(() => useDefaultGraphDirection());
+
+ act(() => {
+ result.current[1]("DOWN");
+ });
+
+ expect(result.current[0]).toBe("DOWN");
+ expect(JSON.parse(localStorage.getItem(DEFAULT_GRAPH_DIRECTION_KEY) ??
'""')).toBe("DOWN");
+ });
+});
+
+describe("useClearRunDefaultOptions", () => {
+ it("defaults to ['existingTasks']", () => {
+ const { result } = renderHook(() => useClearRunDefaultOptions());
+
+ expect(result.current[0]).toEqual(["existingTasks"]);
+ });
+
+ it("persists a new selection", () => {
+ const { result } = renderHook(() => useClearRunDefaultOptions());
+
+ act(() => {
+ result.current[1](["onlyFailed"]);
+ });
+
+ expect(result.current[0]).toEqual(["onlyFailed"]);
+ expect(JSON.parse(localStorage.getItem(CLEAR_RUN_DEFAULT_OPTIONS_KEY) ??
"[]")).toEqual(["onlyFailed"]);
+ });
+});
+
+describe("useClearTaskInstanceDefaultOptions", () => {
+ it("defaults to ['downstream']", () => {
+ const { result } = renderHook(() => useClearTaskInstanceDefaultOptions());
+
+ expect(result.current[0]).toEqual(["downstream"]);
+ });
+
+ it("persists a new selection", () => {
+ const { result } = renderHook(() => useClearTaskInstanceDefaultOptions());
+
+ act(() => {
+ result.current[1](["past", "future", "downstream"]);
+ });
+
+ expect(result.current[0]).toEqual(["past", "future", "downstream"]);
+
expect(JSON.parse(localStorage.getItem(CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY)
?? "[]")).toEqual([
+ "past",
+ "future",
+ "downstream",
+ ]);
+ });
+});
+
+describe("useClearPreventRunningTaskDefault", () => {
+ it("defaults to true", () => {
+ const { result } = renderHook(() => useClearPreventRunningTaskDefault());
+
+ expect(result.current[0]).toBe(true);
+ });
+
+ it("persists a new value", () => {
+ const { result } = renderHook(() => useClearPreventRunningTaskDefault());
+
+ act(() => {
+ result.current[1](false);
+ });
+
+ expect(result.current[0]).toBe(false);
+ expect(JSON.parse(localStorage.getItem(CLEAR_PREVENT_RUNNING_TASK_KEY) ??
"true")).toBe(false);
+ });
+});
+
+describe("useMarkTaskInstanceDefaultOptions", () => {
+ it("defaults to an empty selection", () => {
+ const { result } = renderHook(() => useMarkTaskInstanceDefaultOptions());
+
+ expect(result.current[0]).toEqual([]);
+ });
+
+ it("persists a new selection", () => {
+ const { result } = renderHook(() => useMarkTaskInstanceDefaultOptions());
+
+ act(() => {
+ result.current[1](["downstream"]);
+ });
+
+ expect(result.current[0]).toEqual(["downstream"]);
+
expect(JSON.parse(localStorage.getItem(MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY)
?? "[]")).toEqual([
+ "downstream",
+ ]);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
new file mode 100644
index 00000000000..bec8c853b5b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
@@ -0,0 +1,54 @@
+/*!
+ * 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 { useLocalStorage } from "usehooks-ts";
+
+import type { Direction } from "src/components/Graph/DirectionDropdown";
+import {
+ CLEAR_PREVENT_RUNNING_TASK_KEY,
+ CLEAR_RUN_DEFAULT_OPTIONS_KEY,
+ CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
+ DEFAULT_GRAPH_DIRECTION_KEY,
+ MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
+} from "src/constants/localStorage";
+
+/**
+ * User-configurable defaults surfaced in the Settings page and consumed as
+ * fallbacks by the individual features. Everything is persisted in this
+ * browser's localStorage only — there is no server-side user profile.
+ */
+
+/** Fallback graph layout direction used when a graph has no per-graph choice.
*/
+export const useDefaultGraphDirection = () =>
+ useLocalStorage<Direction>(DEFAULT_GRAPH_DIRECTION_KEY, "RIGHT");
+
+/** 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"]);
+
+/** Default selection for the task-instance Clear dialog toggle (past / future
/ … / only-failed). */
+export const useClearTaskInstanceDefaultOptions = () =>
+ useLocalStorage<Array<string>>(CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
["downstream"]);
+
+/** Default state of the "prevent running tasks" checkbox when clearing task
instances. */
+export const useClearPreventRunningTaskDefault = () =>
+ useLocalStorage<boolean>(CLEAR_PREVENT_RUNNING_TASK_KEY, true);
+
+/** Default selection for the "Mark as" task-instance dialog toggle (past /
future / … ). */
+export const useMarkTaskInstanceDefaultOptions = () =>
+ useLocalStorage<Array<string>>(MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY, []);
diff --git a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
index 73d92d89bfc..3803a4079d5 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Graph/Graph.tsx
@@ -33,6 +33,7 @@ import { SHOW_ALL_DEPENDENCIES_KEY, directionKey } from
"src/constants/localStor
import { useColorMode } from "src/context/colorMode";
import { useGroups } from "src/context/groups";
import useSelectedVersion from "src/hooks/useSelectedVersion";
+import { useDefaultGraphDirection } from "src/hooks/useUserSettings";
import { flattenGraphNodes } from "src/layouts/Details/Grid/utils.ts";
import { useDependencyGraph } from "src/queries/useDependencyGraph";
import { useGridTiSummariesStream } from "src/queries/useGridTISummaries.ts";
@@ -71,7 +72,8 @@ export const Graph = () => {
const { allGroupIds, openGroupIds, setAllGroupIds } = useGroups();
const [showAllDependencies] =
useLocalStorage<boolean>(SHOW_ALL_DEPENDENCIES_KEY, false);
- const [direction] = useLocalStorage<Direction>(directionKey(dagId), "RIGHT");
+ const [defaultDirection] = useDefaultGraphDirection();
+ const [direction] = useLocalStorage<Direction>(directionKey(dagId),
defaultDirection);
const selectedColor = colorMode === "dark" ? selectedDarkColor :
selectedLightColor;
const { data: graphData = { edges: [], nodes: [] } } =
useStructureServiceStructureData(
diff --git
a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
new file mode 100644
index 00000000000..c6688151b16
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.test.tsx
@@ -0,0 +1,37 @@
+/*!
+ * 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 "@testing-library/jest-dom";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { Wrapper } from "src/utils/Wrapper";
+
+import { UserSettingsButton } from "./UserSettingsButton";
+
+describe("UserSettingsButton", () => {
+ it("links to the settings page from the user menu", async () => {
+ render(<UserSettingsButton externalViews={[]} />, { wrapper: Wrapper });
+
+ fireEvent.click(screen.getByRole("button", { name: /user/iu }));
+
+ const settingsLink = await screen.findByRole("menuitem", { name:
/settings.title/iu });
+
+ expect(settingsLink).toHaveAttribute("href", "/settings");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
index 115737892ee..df41e3912fd 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Nav/UserSettingsButton.tsx
@@ -19,10 +19,10 @@
import { Box, Icon, useDisclosure } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import {
- FiGrid,
FiKey,
FiLogOut,
FiMoon,
+ FiSettings,
FiSun,
FiUser,
FiGlobe,
@@ -31,12 +31,10 @@ import {
FiChevronLeft,
FiMonitor,
} from "react-icons/fi";
-import { MdOutlineAccountTree } from "react-icons/md";
-import { useLocalStorage } from "usehooks-ts";
import { useAuthLinksServiceGetCurrentUserInfo } from "openapi/queries";
import { Menu } from "src/components/ui";
-import { DEFAULT_DAG_VIEW_KEY } from "src/constants/localStorage";
+import { RouterLink } from "src/components/ui/RouterLink";
import { useColorMode } from "src/context/colorMode/useColorMode";
import type { NavItemResponse } from "src/utils/types";
@@ -79,8 +77,6 @@ export const UserSettingsButton = ({ externalViews }: {
readonly externalViews:
const { onClose: onCloseLanguage, onOpen: onOpenLanguage, open:
isOpenLanguage } = useDisclosure();
const { onClose: onCloseToken, onOpen: onOpenToken, open: isOpenToken } =
useDisclosure();
- const [dagView, setDagView] = useLocalStorage<"graph" |
"grid">(DEFAULT_DAG_VIEW_KEY, "grid");
-
const theme = selectedTheme ?? COLOR_MODES.SYSTEM;
const isRTL = i18n.dir() === "rtl";
@@ -105,6 +101,12 @@ export const UserSettingsButton = ({ externalViews }: {
readonly externalViews:
<Menu.Separator />
</>
) : undefined}
+ <Menu.Item asChild value="settings">
+ <RouterLink color="inherit" to="/settings">
+ <Icon as={FiSettings} boxSize={4} />
+ <Box flex="1">{translate("settings.title")}</Box>
+ </RouterLink>
+ </Menu.Item>
<Menu.Item onClick={onOpenLanguage} value="language">
<Icon as={FiGlobe} boxSize={4} />
<Box flex="1">{translate("selectLanguage")}</Box>
@@ -127,15 +129,6 @@ export const UserSettingsButton = ({ externalViews }: {
readonly externalViews:
</Menu.RadioItemGroup>
</Menu.Content>
</Menu.Root>
- <Menu.Item
- onClick={() => (dagView === "grid" ? setDagView("graph") :
setDagView("grid"))}
- value={dagView}
- >
- <Icon as={dagView === "grid" ? MdOutlineAccountTree : FiGrid}
boxSize={4} />
- <Box flex="1">
- {dagView === "grid" ? translate("defaultToGraphView") :
translate("defaultToGridView")}
- </Box>
- </Menu.Item>
<Menu.Item onClick={onOpenToken} value="generateToken">
<Icon as={FiKey} boxSize={4} />
<Box flex="1">{translate("generateToken")}</Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
b/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
index e49c4a5cea2..ed0e2eab41c 100644
--- a/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Asset/AssetGraph.tsx
@@ -30,6 +30,7 @@ import { getGatePathEdgeIdsForSelection, type CustomNodeProps
} from "src/compon
import { useGraphLayout } from "src/components/Graph/useGraphLayout";
import { directionKey } from "src/constants/localStorage";
import { useColorMode } from "src/context/colorMode";
+import { useDefaultGraphDirection } from "src/hooks/useUserSettings";
import { useDependencyGraph } from "src/queries/useDependencyGraph";
import { getReactFlowThemeStyle } from "src/theme";
@@ -48,7 +49,8 @@ export const AssetGraph = ({
dependencyType,
});
- const [direction] = useLocalStorage<Direction>(directionKey(assetId ?? ""),
"RIGHT");
+ const [defaultDirection] = useDefaultGraphDirection();
+ const [direction] = useLocalStorage<Direction>(directionKey(assetId ?? ""),
defaultDirection);
const { data: layoutData } = useGraphLayout({
...graphData,
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
new file mode 100644
index 00000000000..51ef9cd8b8d
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.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 "@testing-library/jest-dom/vitest";
+import { render, screen } from "@testing-library/react";
+import i18n from "i18next";
+import { initReactI18next } from "react-i18next";
+import { afterEach, beforeAll, describe, expect, it } from "vitest";
+
+import { CLEAR_PREVENT_RUNNING_TASK_KEY, DEFAULT_GRAPH_DIRECTION_KEY } from
"src/constants/localStorage";
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { Settings } from "./Settings";
+
+beforeAll(async () => {
+ await i18n.use(initReactI18next).init({
+ defaultNS: "common",
+ fallbackLng: "en",
+ interpolation: { escapeValue: false },
+ lng: "en",
+ ns: ["common", "components", "dag", "dags"],
+ resources: {
+ en: {
+ common: {
+ settings: {
+ clearing: {
+ preventRunningTask: { helper: "helper", label: "Prevent clearing
running tasks" },
+ runSelection: { helper: "helper", label: "Default run clear
selection" },
+ taskSelection: { helper: "helper", label: "Default task clear
selection" },
+ title: "Clearing",
+ },
+ description: "browser only",
+ graph: {
+ defaultDirection: { helper: "helper", label: "Default graph
direction" },
+ title: "Graph",
+ },
+ marking: {
+ taskSelection: { helper: "helper", label: "Default mark
selection" },
+ title: "Marking",
+ },
+ title: "Settings",
+ },
+ },
+ components: {
+ graph: {
+ directionDown: "DOWN-LABEL",
+ directionLeft: "LEFT-LABEL",
+ directionRight: "RIGHT-LABEL",
+ directionUp: "UP-LABEL",
+ },
+ },
+ dags: {
+ runAndTaskActions: {
+ options: {
+ downstream: "DOWNSTREAM-OPT",
+ existingTasks: "EXISTING-OPT",
+ future: "FUTURE-OPT",
+ onlyFailed: "ONLY-FAILED-OPT",
+ past: "PAST-OPT",
+ queueNew: "QUEUE-NEW-OPT",
+ upstream: "UPSTREAM-OPT",
+ },
+ },
+ },
+ },
+ },
+ });
+});
+
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("Settings page", () => {
+ it("renders the graph, clearing and marking settings", () => {
+ render(<Settings />, { wrapper: BaseWrapper });
+
+ expect(screen.getByText("Settings")).toBeInTheDocument();
+
+ // Selects and the switch expose test ids.
+ for (const testId of ["default-graph-direction",
"clear-prevent-running-task"]) {
+ expect(screen.getByTestId(testId)).toBeInTheDocument();
+ }
+
+ // Toggle settings are identified by their labels.
+ expect(screen.getByText("Default run clear
selection")).toBeInTheDocument();
+ expect(screen.getByText("Default task clear
selection")).toBeInTheDocument();
+ expect(screen.getByText("Default mark selection")).toBeInTheDocument();
+
+ // The prevent-running switch defaults to on.
+
expect(screen.getByTestId("clear-prevent-running-task")).toHaveAttribute("data-state",
"checked");
+ });
+
+ it("reflects stored values in the controls", () => {
+ localStorage.setItem(DEFAULT_GRAPH_DIRECTION_KEY, JSON.stringify("DOWN"));
+ localStorage.setItem(CLEAR_PREVENT_RUNNING_TASK_KEY,
JSON.stringify(false));
+
+ render(<Settings />, { wrapper: BaseWrapper });
+
+
expect(screen.getByTestId("default-graph-direction")).toHaveTextContent("DOWN-LABEL");
+
expect(screen.getByTestId("clear-prevent-running-task")).toHaveAttribute("data-state",
"unchecked");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
new file mode 100644
index 00000000000..0400dde881b
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Settings/Settings.tsx
@@ -0,0 +1,256 @@
+/*!
+ * 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 { Box, createListCollection, Flex, Heading, Stack, Text } from
"@chakra-ui/react";
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+
+import type { Direction } from "src/components/Graph/DirectionDropdown";
+import { Select } from "src/components/ui";
+import SegmentedControl from "src/components/ui/SegmentedControl";
+import { Switch } from "src/components/ui/Switch";
+import {
+ useClearPreventRunningTaskDefault,
+ useClearRunDefaultOptions,
+ useClearTaskInstanceDefaultOptions,
+ useDefaultGraphDirection,
+ useMarkTaskInstanceDefaultOptions,
+} from "src/hooks/useUserSettings";
+import { useDocumentTitle } from "src/utils";
+import type { Option } from "src/utils/option";
+
+type SelectOption<T extends string> = { label: string; value: T };
+
+const SettingRow = ({
+ control,
+ helper,
+ label,
+}: {
+ readonly control: ReactNode;
+ readonly helper?: string;
+ readonly label: string;
+}) => (
+ <Flex align="center" gap={6} justifyContent="space-between" py={3}>
+ <Box>
+ <Text fontWeight="medium">{label}</Text>
+ {helper === undefined ? undefined : (
+ <Text color="fg.muted" fontSize="sm">
+ {helper}
+ </Text>
+ )}
+ </Box>
+ {control}
+ </Flex>
+);
+
+const SelectSetting = <T extends string>({
+ helper,
+ label,
+ onChange,
+ options,
+ testId,
+ value,
+}: {
+ readonly helper?: string;
+ readonly label: string;
+ readonly onChange: (value: T) => void;
+ readonly options: Array<SelectOption<T>>;
+ readonly testId: string;
+ readonly value: T;
+}) => {
+ const collection = createListCollection({ items: options });
+
+ return (
+ <SettingRow
+ control={
+ <Select.Root
+ collection={collection}
+ onValueChange={(event) => {
+ const [next] = event.value;
+
+ if (next !== undefined) {
+ onChange(next as T);
+ }
+ }}
+ value={[value]}
+ width="220px"
+ >
+ <Select.Trigger dataTestId={testId}>
+ <Select.ValueText />
+ </Select.Trigger>
+ <Select.Content>
+ {options.map((option) => (
+ <Select.Item item={option} key={option.value}>
+ {option.label}
+ </Select.Item>
+ ))}
+ </Select.Content>
+ </Select.Root>
+ }
+ helper={helper}
+ label={label}
+ />
+ );
+};
+
+const ToggleSetting = ({
+ defaultValues,
+ helper,
+ label,
+ multiple = false,
+ onChange,
+ options,
+}: {
+ readonly defaultValues: Array<string>;
+ readonly helper?: string;
+ readonly label: string;
+ readonly multiple?: boolean;
+ readonly onChange: (values: Array<string>) => void;
+ readonly options: Array<Option>;
+}) => (
+ <Box py={3}>
+ <Text fontWeight="medium">{label}</Text>
+ {helper === undefined ? undefined : (
+ <Text color="fg.muted" fontSize="sm" mb={2}>
+ {helper}
+ </Text>
+ )}
+ <SegmentedControl
+ defaultValues={defaultValues}
+ multiple={multiple}
+ onChange={onChange}
+ options={options}
+ />
+ </Box>
+);
+
+const Section = ({ children, title }: { readonly children: ReactNode; readonly
title: string }) => (
+ <Box>
+ <Heading
+ borderBottomWidth="1px"
+ color="fg.muted"
+ fontSize="sm"
+ fontWeight="bold"
+ letterSpacing="wider"
+ mb={1}
+ pb={2}
+ textTransform="uppercase"
+ >
+ {title}
+ </Heading>
+ <Stack divideY="1px" gap={0}>
+ {children}
+ </Stack>
+ </Box>
+);
+
+export const Settings = () => {
+ const { t: translate } = useTranslation(["common", "components", "dags"]);
+
+ useDocumentTitle(translate("settings.title"));
+
+ const [graphDirection, setGraphDirection] = useDefaultGraphDirection();
+ const [clearRunOptions, setClearRunOptions] = useClearRunDefaultOptions();
+ const [clearTaskOptions, setClearTaskOptions] =
useClearTaskInstanceDefaultOptions();
+ const [preventRunningTask, setPreventRunningTask] =
useClearPreventRunningTaskDefault();
+ const [markTaskOptions, setMarkTaskOptions] =
useMarkTaskInstanceDefaultOptions();
+
+ const directionOptions: Array<SelectOption<Direction>> = [
+ { label: translate("components:graph.directionRight"), value: "RIGHT" },
+ { label: translate("components:graph.directionLeft"), value: "LEFT" },
+ { label: translate("components:graph.directionUp"), value: "UP" },
+ { label: translate("components:graph.directionDown"), value: "DOWN" },
+ ];
+
+ const clearRunToggleOptions: Array<Option> = [
+ { label: translate("dags:runAndTaskActions.options.existingTasks"), value:
"existingTasks" },
+ { label: translate("dags:runAndTaskActions.options.onlyFailed"), value:
"onlyFailed" },
+ { label: translate("dags:runAndTaskActions.options.queueNew"), value:
"newTasks" },
+ ];
+
+ const directionalToggleOptions: Array<Option> = [
+ { label: translate("dags:runAndTaskActions.options.past"), value: "past" },
+ { label: translate("dags:runAndTaskActions.options.future"), value:
"future" },
+ { label: translate("dags:runAndTaskActions.options.upstream"), value:
"upstream" },
+ { label: translate("dags:runAndTaskActions.options.downstream"), value:
"downstream" },
+ ];
+
+ return (
+ <Box maxW="720px">
+ <Heading mb={1} size="lg">
+ {translate("settings.title")}
+ </Heading>
+ <Text color="fg.muted" mb={6}>
+ {translate("settings.description")}
+ </Text>
+ <Stack gap={8}>
+ <Section title={translate("settings.graph.title")}>
+ <SelectSetting
+ helper={translate("settings.graph.defaultDirection.helper")}
+ label={translate("settings.graph.defaultDirection.label")}
+ onChange={setGraphDirection}
+ options={directionOptions}
+ testId="default-graph-direction"
+ value={graphDirection}
+ />
+ </Section>
+ <Section title={translate("settings.clearing.title")}>
+ <ToggleSetting
+ defaultValues={clearRunOptions}
+ helper={translate("settings.clearing.runSelection.helper")}
+ label={translate("settings.clearing.runSelection.label")}
+ onChange={setClearRunOptions}
+ options={clearRunToggleOptions}
+ />
+ <ToggleSetting
+ defaultValues={clearTaskOptions}
+ helper={translate("settings.clearing.taskSelection.helper")}
+ label={translate("settings.clearing.taskSelection.label")}
+ multiple
+ onChange={setClearTaskOptions}
+ options={[
+ ...directionalToggleOptions,
+ { label: translate("dags:runAndTaskActions.options.onlyFailed"),
value: "onlyFailed" },
+ ]}
+ />
+ <SettingRow
+ control={
+ <Switch
+ checked={preventRunningTask}
+ data-testid="clear-prevent-running-task"
+ onCheckedChange={(event) =>
setPreventRunningTask(event.checked)}
+ />
+ }
+ helper={translate("settings.clearing.preventRunningTask.helper")}
+ label={translate("settings.clearing.preventRunningTask.label")}
+ />
+ </Section>
+ <Section title={translate("settings.marking.title")}>
+ <ToggleSetting
+ defaultValues={markTaskOptions}
+ helper={translate("settings.marking.taskSelection.helper")}
+ label={translate("settings.marking.taskSelection.label")}
+ multiple
+ onChange={setMarkTaskOptions}
+ options={directionalToggleOptions}
+ />
+ </Section>
+ </Stack>
+ </Box>
+ );
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/Settings/index.tsx
b/airflow-core/src/airflow/ui/src/pages/Settings/index.tsx
new file mode 100644
index 00000000000..96d4f418c2a
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Settings/index.tsx
@@ -0,0 +1,19 @@
+/*!
+ * 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.
+ */
+export { Settings } from "./Settings";
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index 38a160e935a..fd3ea5ef30d 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -55,6 +55,7 @@ import { Run } from "src/pages/Run";
import { AssetEvents as DagRunAssetEvents } from "src/pages/Run/AssetEvents";
import { Details as DagRunDetails } from "src/pages/Run/Details";
import { Security } from "src/pages/Security";
+import { Settings } from "src/pages/Settings";
import { Task } from "src/pages/Task";
import { Overview as TaskOverview } from "src/pages/Task/Overview";
import { TaskInstance, Logs } from "src/pages/TaskInstance";
@@ -133,6 +134,10 @@ export const routerConfig = [
element: <Configs />,
path: "configs",
},
+ {
+ element: <Settings />,
+ path: "settings",
+ },
{
children: [
{ element: <AssetEvents />, index: true },