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 a83394c6280 UI: Add a default preference for expanded task groups
(#73429)
a83394c6280 is described below
commit a83394c62801a0918656b7f8c0a4cabbcbf2e718
Author: ucaeon <[email protected]>
AuthorDate: Wed Sep 23 22:50:17 2026 +0900
UI: Add a default preference for expanded task groups (#73429)
* UI: Add a default preference for expanded task groups
* Correct the task group preference release note PR number
* Remove unnecessary task group preference release note
---
.../airflow/ui/public/i18n/locales/en/common.json | 4 +
.../src/airflow/ui/src/constants/localStorage.ts | 1 +
.../ui/src/context/groups/GroupsProvider.test.tsx | 112 +++++++++++++++++++++
.../ui/src/context/groups/GroupsProvider.tsx | 8 +-
.../src/airflow/ui/src/hooks/useUserSettings.ts | 4 +
.../ui/src/pages/Settings/Settings.test.tsx | 18 +++-
.../src/airflow/ui/src/pages/Settings/Settings.tsx | 14 +++
7 files changed, 159 insertions(+), 2 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 8bc87e77e4a..0f5d63160f1 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
@@ -297,6 +297,10 @@
"helper": "Layout direction for Dag and asset graphs you haven't set
individually.",
"label": "Default graph direction"
},
+ "taskGroupsExpanded": {
+ "helper": "Expand task groups in Dags where you haven't saved a group
selection. Existing selections are kept.",
+ "label": "Expand task groups by default"
+ },
"title": "Graph"
},
"marking": {
diff --git a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
index 5dde959e3fd..16926fe8c66 100644
--- a/airflow-core/src/airflow/ui/src/constants/localStorage.ts
+++ b/airflow-core/src/airflow/ui/src/constants/localStorage.ts
@@ -30,6 +30,7 @@ export const VERSION_INDICATOR_DISPLAY_MODE_KEY =
"version_indicator_display_mod
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 DEFAULT_TASK_GROUPS_EXPANDED_KEY = "default_task_groups_expanded";
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";
diff --git
a/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.test.tsx
b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.test.tsx
new file mode 100644
index 00000000000..0b231864832
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.test.tsx
@@ -0,0 +1,112 @@
+/*!
+ * 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, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { NodeResponse } from "openapi/requests/types.gen";
+
+import { DEFAULT_TASK_GROUPS_EXPANDED_KEY, openGroupsKey } from
"src/constants/localStorage";
+
+import { GroupsProvider } from "./GroupsProvider";
+import { useGroups } from "./useGroups";
+
+const structure = vi.hoisted(() => ({ nodes: undefined as Array<NodeResponse>
| undefined }));
+
+vi.mock("openapi/queries", () => ({
+ useStructureServiceStructureData: () => ({
+ data: structure.nodes === undefined ? undefined : { edges: [], nodes:
structure.nodes },
+ }),
+}));
+vi.mock("src/hooks/useSelectedVersion", () => ({ default: () => 1 }));
+const nodes: Array<NodeResponse> = [
+ {
+ children: [{ children: [], id: "group.nested", label: "Nested", type:
"task" }],
+ id: "group",
+ label: "Group",
+ type: "task",
+ },
+];
+const wrapper = ({ children }: PropsWithChildren) => (
+ <GroupsProvider dagId="example">{children}</GroupsProvider>
+);
+
+beforeEach(() => {
+ structure.nodes = nodes;
+});
+afterEach(() => {
+ localStorage.clear();
+});
+
+describe("task group defaults", () => {
+ it("expands nested groups when the preference is enabled", () => {
+ localStorage.setItem(DEFAULT_TASK_GROUPS_EXPANDED_KEY, "true");
+ const { result } = renderHook(useGroups, { wrapper });
+
+ expect(result.current.openGroupIds).toEqual(["group", "group.nested"]);
+ });
+ it.each([{ saved: [] }, { saved: ["group"] }])(
+ "preserves a saved selection $saved over the default",
+ ({ saved }) => {
+ localStorage.setItem(DEFAULT_TASK_GROUPS_EXPANDED_KEY, "true");
+ const initial = renderHook(useGroups, { wrapper });
+
+ expect(initial.result.current.openGroupIds).toEqual(["group",
"group.nested"]);
+ initial.unmount();
+ localStorage.setItem(openGroupsKey("example"), JSON.stringify(saved));
+ const { result } = renderHook(useGroups, { wrapper });
+
+ expect(result.current.openGroupIds).toEqual(saved);
+ },
+ );
+ it("applies the default after structure loads without saving an empty
selection", () => {
+ localStorage.setItem(DEFAULT_TASK_GROUPS_EXPANDED_KEY, "true");
+ structure.nodes = undefined;
+ const { rerender, result } = renderHook(useGroups, { wrapper });
+
+ expect(result.current.openGroupIds).toEqual([]);
+ structure.nodes = nodes;
+ rerender();
+ expect(result.current.openGroupIds).toEqual(["group", "group.nested"]);
+ expect(localStorage.getItem(openGroupsKey("example"))).toBeNull();
+ });
+ it("preserves a manual collapse after remounting", async () => {
+ localStorage.setItem(DEFAULT_TASK_GROUPS_EXPANDED_KEY, "true");
+ const { result, unmount } = renderHook(useGroups, { wrapper });
+
+ act(() => result.current.toggleGroupId("group"));
+ await waitFor(() =>
expect(result.current.openGroupIds).toEqual(["group.nested"]));
+ unmount();
+ const next = renderHook(useGroups, { wrapper });
+
+ expect(next.result.current.openGroupIds).toEqual(["group.nested"]);
+ });
+ it("keeps collapse-all saved even with the expanded default", () => {
+ localStorage.setItem(DEFAULT_TASK_GROUPS_EXPANDED_KEY, "true");
+ const { result, unmount } = renderHook(useGroups, { wrapper });
+
+ expect(result.current.openGroupIds).toEqual(["group", "group.nested"]);
+ act(() => result.current.setOpenGroupIds([]));
+ unmount();
+ const next = renderHook(useGroups, { wrapper });
+
+ expect(next.result.current.openGroupIds).toEqual([]);
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
index 8265f9cb53f..b74926b3afa 100644
--- a/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
+++ b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
@@ -27,6 +27,7 @@ import { flattenGraphNodes } from
"src/layouts/Details/Grid/utils";
import { allGroupsKey, openGroupsKey } from "src/constants/localStorage";
import useSelectedVersion from "src/hooks/useSelectedVersion";
+import { useDefaultTaskGroupsExpanded } from "src/hooks/useUserSettings";
import { GroupsContext, type GroupsContextType } from "./Context";
@@ -35,7 +36,11 @@ type Props = {
} & PropsWithChildren;
export const GroupsProvider = ({ children, dagId }: Props) => {
- const [openGroupIds, setOpenGroupIds] =
useLocalStorage<Array<string>>(openGroupsKey(dagId), []);
+ const [storedOpenGroupIds, setOpenGroupIds] = useLocalStorage<Array<string>
| null>(
+ openGroupsKey(dagId),
+ null,
+ );
+ const [defaultTaskGroupsExpanded] = useDefaultTaskGroupsExpanded();
const [allGroupIds, setAllGroupIds] =
useLocalStorage<Array<string>>(allGroupsKey(dagId), []);
const allGroupIdsRef = useRef(allGroupIds);
@@ -56,6 +61,7 @@ export const GroupsProvider = ({ children, dagId }: Props) =>
{
);
const { allGroupIds: observedGroupIds, allOperators } =
flattenGraphNodes(structure.nodes);
+ const openGroupIds = storedOpenGroupIds ?? (defaultTaskGroupsExpanded ?
observedGroupIds : []);
useEffect(() => {
if (JSON.stringify(observedGroupIds) !==
JSON.stringify(allGroupIdsRef.current)) {
diff --git a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
index 670b448309d..cfa8877ab95 100644
--- a/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
+++ b/airflow-core/src/airflow/ui/src/hooks/useUserSettings.ts
@@ -25,6 +25,7 @@ import {
CLEAR_RUN_DEFAULT_OPTIONS_KEY,
CLEAR_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
DEFAULT_GRAPH_DIRECTION_KEY,
+ DEFAULT_TASK_GROUPS_EXPANDED_KEY,
DEFAULT_LANDING_PAGE_KEY,
DEFAULT_TASK_INSTANCE_TAB_KEY,
MARK_TASK_INSTANCE_DEFAULT_OPTIONS_KEY,
@@ -44,6 +45,9 @@ export type LandingPageOption = "dags" | "dashboard";
export const useDefaultGraphDirection = () =>
useLocalStorage<Direction>(DEFAULT_GRAPH_DIRECTION_KEY, "RIGHT");
+export const useDefaultTaskGroupsExpanded = () =>
+ useLocalStorage<boolean>(DEFAULT_TASK_GROUPS_EXPANDED_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 f1030f5e585..670aefb81ae 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
@@ -17,7 +17,7 @@
* under the License.
*/
import "@testing-library/jest-dom/vitest";
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
@@ -25,6 +25,7 @@ import { afterEach, beforeAll, describe, expect, it } from
"vitest";
import {
CLEAR_PREVENT_RUNNING_TASK_KEY,
DEFAULT_GRAPH_DIRECTION_KEY,
+ DEFAULT_TASK_GROUPS_EXPANDED_KEY,
DEFAULT_LANDING_PAGE_KEY,
DEFAULT_TASK_INSTANCE_TAB_KEY,
} from "src/constants/localStorage";
@@ -60,6 +61,7 @@ beforeAll(async () => {
},
graph: {
defaultDirection: { helper: "helper", label: "Default graph
direction" },
+ taskGroupsExpanded: { helper: "helper", label: "Expand task
groups by default" },
title: "Graph",
},
marking: {
@@ -154,3 +156,17 @@ describe("Settings page", () => {
expect(screen.getByTestId("clear-prevent-running-task")).toHaveAttribute("data-state",
"unchecked");
});
});
+
+describe("task group setting", () => {
+ it("defaults to collapsed and persists the expanded preference", async () =>
{
+ const { unmount } = render(<Settings />, { wrapper: BaseWrapper });
+ const toggle = screen.getByTestId("default-task-groups-expanded");
+
+ expect(toggle).toHaveAttribute("data-state", "unchecked");
+ fireEvent.click(screen.getByRole("checkbox", { name: "Expand task groups
by default" }));
+ await waitFor(() =>
expect(localStorage.getItem(DEFAULT_TASK_GROUPS_EXPANDED_KEY)).toBe("true"));
+ unmount();
+ render(<Settings />, { wrapper: BaseWrapper });
+
expect(screen.getByTestId("default-task-groups-expanded")).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 88a72af6fc4..fcce8e29872 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 {
useDefaultGraphDirection,
useDefaultLandingPage,
useDefaultTaskInstanceTab,
+ useDefaultTaskGroupsExpanded,
useMarkTaskInstanceDefaultOptions,
type LandingPageOption,
} from "src/hooks/useUserSettings";
@@ -170,6 +171,7 @@ export const Settings = () => {
useDocumentTitle(translate("settings.title"));
const [graphDirection, setGraphDirection] = useDefaultGraphDirection();
+ const [taskGroupsExpanded, setTaskGroupsExpanded] =
useDefaultTaskGroupsExpanded();
const [clearRunOptions, setClearRunOptions] = useClearRunDefaultOptions();
const [clearTaskOptions, setClearTaskOptions] =
useClearTaskInstanceDefaultOptions();
const [preventRunningTask, setPreventRunningTask] =
useClearPreventRunningTaskDefault();
@@ -232,6 +234,18 @@ export const Settings = () => {
/>
</Section>
<Section title={translate("settings.graph.title")}>
+ <SettingRow
+ control={
+ <Switch
+
aria-label={translate("settings.graph.taskGroupsExpanded.label")}
+ checked={taskGroupsExpanded}
+ data-testid="default-task-groups-expanded"
+ onCheckedChange={(event) =>
setTaskGroupsExpanded(event.checked)}
+ />
+ }
+ helper={translate("settings.graph.taskGroupsExpanded.helper")}
+ label={translate("settings.graph.taskGroupsExpanded.label")}
+ />
<SelectSetting
helper={translate("settings.graph.defaultDirection.helper")}
label={translate("settings.graph.defaultDirection.label")}