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")}

Reply via email to