This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch changes-3.4.0rc3
in repository https://gitbox.apache.org/repos/asf/airflow.git
commit aba0c089f2d54127b484f21a8381d430111ee606
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Wed Nov 4 22:41:37 2026 +0530
[v3-4-test] UI: Fix task groups not expanding when localStorage is full
(#76302) (#76452)
* UI: Fix task groups not expanding when localStorage is full
* Remove saved task group state when it matches the default
(cherry picked from commit 37d4d9395f774d8d264dd18de8e6fba6a243d247)
Co-authored-by: Lazizbek Ergashev <[email protected]>
---
.../ui/src/context/groups/GroupsProvider.test.tsx | 34 +++++++++++++++-
.../ui/src/context/groups/GroupsProvider.tsx | 45 ++++++++++++++++++----
2 files changed, 71 insertions(+), 8 deletions(-)
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
index 0b231864832..5a0301bc356 100644
--- a/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.test.tsx
+++ b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.test.tsx
@@ -23,7 +23,7 @@ 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 { allGroupsKey, DEFAULT_TASK_GROUPS_EXPANDED_KEY, openGroupsKey } from
"src/constants/localStorage";
import { GroupsProvider } from "./GroupsProvider";
import { useGroups } from "./useGroups";
@@ -52,6 +52,7 @@ beforeEach(() => {
structure.nodes = nodes;
});
afterEach(() => {
+ vi.restoreAllMocks();
localStorage.clear();
});
@@ -109,4 +110,35 @@ describe("task group defaults", () => {
expect(next.result.current.openGroupIds).toEqual([]);
});
+ it.each([
+ { expanded: "false", selection: [] },
+ { expanded: "true", selection: ["group.nested", "group"] },
+ ])(
+ "removes the saved selection when it matches the default (expanded:
$expanded)",
+ ({ expanded, selection }) => {
+ localStorage.setItem(DEFAULT_TASK_GROUPS_EXPANDED_KEY, expanded);
+ localStorage.setItem(openGroupsKey("example"),
JSON.stringify(["group.nested"]));
+ const { result } = renderHook(useGroups, { wrapper });
+
+ act(() => result.current.setOpenGroupIds(selection));
+ expect(result.current.openGroupIds).toEqual(selection);
+ expect(localStorage.getItem(openGroupsKey("example"))).toBeNull();
+ },
+ );
+ it("removes the saved group list when the Dag has no groups", () => {
+ localStorage.setItem(allGroupsKey("example"), JSON.stringify(["group"]));
+ structure.nodes = [];
+ renderHook(useGroups, { wrapper });
+
+ expect(localStorage.getItem(allGroupsKey("example"))).toBeNull();
+ });
+ it("toggles a group when localStorage is full", async () => {
+ vi.spyOn(localStorage, "setItem").mockImplementation(() => {
+ throw new DOMException("Quota exceeded", "QuotaExceededError");
+ });
+ const { result } = renderHook(useGroups, { wrapper });
+
+ act(() => result.current.toggleGroupId("group"));
+ await waitFor(() =>
expect(result.current.openGroupIds).toEqual(["group"]));
+ });
});
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 b74926b3afa..eb19e7c4a13 100644
--- a/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
+++ b/airflow-core/src/airflow/ui/src/context/groups/GroupsProvider.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useEffect, useRef, type PropsWithChildren } from "react";
+import { useCallback, useEffect, useRef, useState, type PropsWithChildren }
from "react";
import { useDebouncedCallback } from "use-debounce";
import { useLocalStorage } from "usehooks-ts";
@@ -36,12 +36,17 @@ type Props = {
} & PropsWithChildren;
export const GroupsProvider = ({ children, dagId }: Props) => {
- const [storedOpenGroupIds, setOpenGroupIds] = useLocalStorage<Array<string>
| null>(
- openGroupsKey(dagId),
- null,
- );
+ const [storedOpenGroupIds, setStoredOpenGroupIds, removeStoredOpenGroupIds] =
+ useLocalStorage<Array<string> | null>(openGroupsKey(dagId), null);
+ const [sessionOpenGroupIds, setSessionOpenGroupIds] = useState<{
+ dagId: string;
+ groupIds: Array<string>;
+ }>();
const [defaultTaskGroupsExpanded] = useDefaultTaskGroupsExpanded();
- const [allGroupIds, setAllGroupIds] =
useLocalStorage<Array<string>>(allGroupsKey(dagId), []);
+ const [allGroupIds, setStoredAllGroupIds, removeStoredAllGroupIds] =
useLocalStorage<Array<string>>(
+ allGroupsKey(dagId),
+ [],
+ );
const allGroupIdsRef = useRef(allGroupIds);
@@ -61,7 +66,33 @@ export const GroupsProvider = ({ children, dagId }: Props)
=> {
);
const { allGroupIds: observedGroupIds, allOperators } =
flattenGraphNodes(structure.nodes);
- const openGroupIds = storedOpenGroupIds ?? (defaultTaskGroupsExpanded ?
observedGroupIds : []);
+ const savedOpenGroupIds =
+ sessionOpenGroupIds?.dagId === dagId ? sessionOpenGroupIds.groupIds :
storedOpenGroupIds;
+ const defaultOpenGroupIds = defaultTaskGroupsExpanded ? observedGroupIds :
[];
+ const openGroupIds = savedOpenGroupIds ?? defaultOpenGroupIds;
+
+ const setOpenGroupIds = (groupIds: Array<string>) => {
+ setSessionOpenGroupIds({ dagId, groupIds });
+ if (
+ groupIds.length === defaultOpenGroupIds.length &&
+ groupIds.every((id) => defaultOpenGroupIds.includes(id))
+ ) {
+ removeStoredOpenGroupIds();
+ } else {
+ setStoredOpenGroupIds(groupIds);
+ }
+ };
+
+ const setAllGroupIds = useCallback(
+ (groupIds: Array<string>) => {
+ if (groupIds.length === 0) {
+ removeStoredAllGroupIds();
+ } else {
+ setStoredAllGroupIds(groupIds);
+ }
+ },
+ [removeStoredAllGroupIds, setStoredAllGroupIds],
+ );
useEffect(() => {
if (JSON.stringify(observedGroupIds) !==
JSON.stringify(allGroupIdsRef.current)) {