@elabs-ai/components-flow 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +79 -0
  3. package/dist/index.d.ts +634 -0
  4. package/dist/index.js +1577 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +68 -0
  7. package/src/canvas-shell/canvas-shell.stories.tsx +77 -0
  8. package/src/canvas-shell/canvas-shell.test.tsx +106 -0
  9. package/src/canvas-shell/canvas-shell.tsx +128 -0
  10. package/src/canvas-shell/index.ts +1 -0
  11. package/src/flow-button-edge/flow-button-edge.stories.tsx +151 -0
  12. package/src/flow-button-edge/flow-button-edge.test.tsx +99 -0
  13. package/src/flow-button-edge/flow-button-edge.tsx +80 -0
  14. package/src/flow-button-edge/index.ts +5 -0
  15. package/src/flow-edge/flow-edge.stories.tsx +79 -0
  16. package/src/flow-edge/flow-edge.test.tsx +76 -0
  17. package/src/flow-edge/flow-edge.tsx +34 -0
  18. package/src/flow-edge/index.ts +1 -0
  19. package/src/flow-floating-edge/floating-edge-geometry.test.ts +100 -0
  20. package/src/flow-floating-edge/floating-edge-geometry.ts +128 -0
  21. package/src/flow-floating-edge/flow-floating-edge.stories.tsx +81 -0
  22. package/src/flow-floating-edge/flow-floating-edge.tsx +82 -0
  23. package/src/flow-floating-edge/index.ts +6 -0
  24. package/src/flow-group-node/flow-group-node.stories.tsx +283 -0
  25. package/src/flow-group-node/flow-group-node.test.tsx +100 -0
  26. package/src/flow-group-node/flow-group-node.tsx +134 -0
  27. package/src/flow-group-node/index.ts +6 -0
  28. package/src/flow-layout/flow-layout.stories.tsx +215 -0
  29. package/src/flow-layout/flow-layout.test.tsx +133 -0
  30. package/src/flow-layout/flow-layout.ts +104 -0
  31. package/src/flow-layout/index.ts +14 -0
  32. package/src/flow-layout/layout-graph.test.ts +257 -0
  33. package/src/flow-layout/layout-graph.ts +302 -0
  34. package/src/flow-layout/use-auto-layout.ts +26 -0
  35. package/src/flow-layout/use-flow-layout.ts +60 -0
  36. package/src/flow-mini-map/flow-mini-map.stories.tsx +74 -0
  37. package/src/flow-mini-map/flow-mini-map.test.tsx +56 -0
  38. package/src/flow-mini-map/flow-mini-map.tsx +29 -0
  39. package/src/flow-mini-map/index.ts +1 -0
  40. package/src/flow-node/flow-node.stories.tsx +143 -0
  41. package/src/flow-node/flow-node.test.tsx +156 -0
  42. package/src/flow-node/flow-node.tsx +190 -0
  43. package/src/flow-node/index.ts +8 -0
  44. package/src/flow-placeholder-node/flow-placeholder-node.stories.tsx +260 -0
  45. package/src/flow-placeholder-node/flow-placeholder-node.test.tsx +101 -0
  46. package/src/flow-placeholder-node/flow-placeholder-node.tsx +49 -0
  47. package/src/flow-placeholder-node/index.ts +5 -0
  48. package/src/flow-smart-edge/flow-smart-edge.stories.tsx +75 -0
  49. package/src/flow-smart-edge/flow-smart-edge.tsx +75 -0
  50. package/src/flow-smart-edge/index.ts +9 -0
  51. package/src/flow-smart-edge/smart-edge-geometry.test.ts +120 -0
  52. package/src/flow-smart-edge/smart-edge-geometry.ts +116 -0
  53. package/src/helper-lines/get-helper-lines.ts +98 -0
  54. package/src/helper-lines/helper-lines.stories.tsx +107 -0
  55. package/src/helper-lines/helper-lines.test.tsx +78 -0
  56. package/src/helper-lines/helper-lines.tsx +67 -0
  57. package/src/helper-lines/index.ts +7 -0
  58. package/src/helper-lines/use-helper-lines.ts +109 -0
  59. package/src/index.ts +37 -0
  60. package/src/inspector-panel/index.ts +1 -0
  61. package/src/inspector-panel/inspector-panel.stories.tsx +161 -0
  62. package/src/inspector-panel/inspector-panel.test.tsx +82 -0
  63. package/src/inspector-panel/inspector-panel.tsx +129 -0
  64. package/src/legend/index.ts +1 -0
  65. package/src/legend/legend.stories.tsx +56 -0
  66. package/src/legend/legend.test.tsx +46 -0
  67. package/src/legend/legend.tsx +39 -0
  68. package/src/templates-flow-workspace.stories.tsx +169 -0
  69. package/src/use-flow-groups/group-operations.test.ts +318 -0
  70. package/src/use-flow-groups/group-operations.ts +349 -0
  71. package/src/use-flow-groups/index.ts +13 -0
  72. package/src/use-flow-groups/use-flow-groups.ts +113 -0
  73. package/src/zoom-controls/index.ts +1 -0
  74. package/src/zoom-controls/zoom-controls.stories.tsx +68 -0
  75. package/src/zoom-controls/zoom-controls.test.tsx +80 -0
  76. package/src/zoom-controls/zoom-controls.tsx +78 -0
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Flow workspace template — the canonical full-screen flow-workspace
3
+ * composition (app-shell + branded React Flow canvas). This story is the
4
+ * single source of truth: `pnpm gen:templates` derives the consumer template
5
+ * source (`docs/playbooks/templates/flow-workspace.tsx`) from it.
6
+ * Remember `import "@xyflow/react/dist/style.css"` is wired in Storybook preview.
7
+ * Verify across all three themes with globals=theme:<slug>.
8
+ */
9
+ import type { Meta, StoryObj } from "@storybook/react-vite";
10
+ import { useState } from "react";
11
+ import {
12
+ Button,
13
+ Sidebar,
14
+ SidebarContent,
15
+ SidebarGroup,
16
+ SidebarGroupContent,
17
+ SidebarHeader,
18
+ SidebarInset,
19
+ SidebarMenu,
20
+ SidebarMenuButton,
21
+ SidebarMenuItem,
22
+ SidebarProvider,
23
+ SidebarTrigger,
24
+ } from "@elabs-ai/components-ui";
25
+ import {
26
+ CanvasShell,
27
+ FlowEdge,
28
+ FlowMiniMap,
29
+ FlowNode,
30
+ Panel,
31
+ ZoomControls,
32
+ useEdgesState,
33
+ useFlowLayout,
34
+ useNodesState,
35
+ type BrandFlowNode,
36
+ type Edge,
37
+ } from "./index";
38
+ import { AppIcon } from "@elabs-ai/components-icons";
39
+
40
+ const nodeTypes = { brand: FlowNode };
41
+ const edgeTypes = { brand: FlowEdge };
42
+
43
+ /**
44
+ * Auto-layout control. Rendered as a child of `CanvasShell` so `useFlowLayout`
45
+ * resolves the flow context; it tidies the graph with dagre and fits the view.
46
+ */
47
+ function LayoutControls() {
48
+ const { layout } = useFlowLayout();
49
+ return (
50
+ <Panel position="top-right">
51
+ <Button size="sm" variant="secondary" onClick={() => layout("LR")}>
52
+ Auto layout
53
+ </Button>
54
+ </Panel>
55
+ );
56
+ }
57
+
58
+ const initialNodes: BrandFlowNode[] = [
59
+ {
60
+ id: "ingest",
61
+ type: "brand",
62
+ position: { x: 60, y: 160 },
63
+ data: { kind: "Source", title: "Ingest", subtitle: "Raw data in", tone: "accent" },
64
+ },
65
+ {
66
+ id: "transform",
67
+ type: "brand",
68
+ position: { x: 320, y: 80 },
69
+ data: { kind: "Process", title: "Transform", subtitle: "Normalize & enrich" },
70
+ },
71
+ {
72
+ id: "validate",
73
+ type: "brand",
74
+ position: { x: 320, y: 240 },
75
+ data: { kind: "Process", title: "Validate", subtitle: "Quality checks" },
76
+ },
77
+ {
78
+ id: "publish",
79
+ type: "brand",
80
+ position: { x: 580, y: 160 },
81
+ data: { kind: "Output", title: "Publish", subtitle: "Downstream sink", tone: "success" },
82
+ },
83
+ ];
84
+
85
+ const initialEdges: Edge[] = [
86
+ { id: "e-ingest-transform", source: "ingest", target: "transform", type: "brand" },
87
+ { id: "e-ingest-validate", source: "ingest", target: "validate", type: "brand" },
88
+ { id: "e-transform-publish", source: "transform", target: "publish", type: "brand" },
89
+ { id: "e-validate-publish", source: "validate", target: "publish", type: "brand" },
90
+ ];
91
+
92
+ const nav = [
93
+ { id: "canvas", label: "Canvas" },
94
+ { id: "layers", label: "Layers" },
95
+ { id: "home", label: "Home" },
96
+ { id: "settings", label: "Settings" },
97
+ ];
98
+
99
+ function FlowWorkspaceTemplate() {
100
+ const [active, setActive] = useState("canvas");
101
+ const [nodes, , onNodesChange] = useNodesState(initialNodes);
102
+ const [edges, , onEdgesChange] = useEdgesState(initialEdges);
103
+
104
+ return (
105
+ <SidebarProvider>
106
+ <Sidebar collapsible="offcanvas">
107
+ <SidebarHeader className="px-3 py-2">
108
+ <div className="flex items-center gap-2">
109
+ <AppIcon height={20} aria-hidden />
110
+ <span className="truncate font-semibold group-data-[collapsible=icon]:hidden">
111
+ Flow
112
+ </span>
113
+ </div>
114
+ </SidebarHeader>
115
+ <SidebarContent>
116
+ <SidebarGroup>
117
+ <SidebarGroupContent>
118
+ <SidebarMenu>
119
+ {nav.map((n) => (
120
+ <SidebarMenuItem key={n.id}>
121
+ <SidebarMenuButton
122
+ isActive={active === n.id}
123
+ tooltip={n.label}
124
+ onClick={() => setActive(n.id)}
125
+ >
126
+ <span>{n.label}</span>
127
+ </SidebarMenuButton>
128
+ </SidebarMenuItem>
129
+ ))}
130
+ </SidebarMenu>
131
+ </SidebarGroupContent>
132
+ </SidebarGroup>
133
+ </SidebarContent>
134
+ </Sidebar>
135
+ <SidebarInset className="flex flex-col overflow-hidden">
136
+ <header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
137
+ <SidebarTrigger />
138
+ <h1 className="text-body font-medium capitalize">{active}</h1>
139
+ </header>
140
+ <div className="h-[calc(100vh-3.5rem)]">
141
+ <CanvasShell
142
+ className="h-full w-full"
143
+ helperLines
144
+ nodes={nodes}
145
+ edges={edges}
146
+ nodeTypes={nodeTypes}
147
+ edgeTypes={edgeTypes}
148
+ onNodesChange={onNodesChange}
149
+ onEdgesChange={onEdgesChange}
150
+ >
151
+ <LayoutControls />
152
+ <ZoomControls />
153
+ <FlowMiniMap />
154
+ </CanvasShell>
155
+ </div>
156
+ </SidebarInset>
157
+ </SidebarProvider>
158
+ );
159
+ }
160
+
161
+ const meta = {
162
+ title: "Patterns/Templates/Flow Workspace",
163
+ parameters: { layout: "fullscreen" },
164
+ tags: ["autodocs"],
165
+ } satisfies Meta;
166
+ export default meta;
167
+ type Story = StoryObj<typeof meta>;
168
+
169
+ export const Default: Story = { render: () => <FlowWorkspaceTemplate /> };
@@ -0,0 +1,318 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Edge, Node } from "@xyflow/react";
3
+ import {
4
+ collapseGroup,
5
+ expandGroup,
6
+ groupNodes,
7
+ isFlowGroupProxyEdge,
8
+ toggleGroupCollapsed,
9
+ ungroup,
10
+ } from "./group-operations";
11
+
12
+ /** Three free-standing nodes, sized so `getNodesBounds` has real dimensions. */
13
+ function baseNodes(): Node[] {
14
+ return [
15
+ { id: "a", type: "brand", position: { x: 0, y: 0 }, width: 100, height: 40, data: {} },
16
+ { id: "b", type: "brand", position: { x: 200, y: 0 }, width: 100, height: 40, data: {} },
17
+ { id: "c", type: "brand", position: { x: 400, y: 200 }, width: 100, height: 40, data: {} },
18
+ ];
19
+ }
20
+
21
+ describe("groupNodes", () => {
22
+ it("inserts the group parent BEFORE its children in the array", () => {
23
+ const { nodes } = groupNodes(baseNodes(), [], ["a", "b"], { groupId: "g1" });
24
+ const groupIdx = nodes.findIndex((n) => n.id === "g1");
25
+ const aIdx = nodes.findIndex((n) => n.id === "a");
26
+ const bIdx = nodes.findIndex((n) => n.id === "b");
27
+
28
+ expect(groupIdx).toBeGreaterThanOrEqual(0);
29
+ expect(groupIdx).toBeLessThan(aIdx);
30
+ expect(groupIdx).toBeLessThan(bIdx);
31
+ });
32
+
33
+ it("re-parents children and makes their positions relative to the group origin", () => {
34
+ const { nodes } = groupNodes(baseNodes(), [], ["a", "b"], { groupId: "g1" });
35
+ const group = nodes.find((n) => n.id === "g1")!;
36
+ const a = nodes.find((n) => n.id === "a")!;
37
+
38
+ expect(a.parentId).toBe("g1");
39
+ expect(a.extent).toBe("parent");
40
+ // Original absolute position minus the group origin equals the new relative position.
41
+ expect(a.position.x).toBeCloseTo(0 - group.position.x);
42
+ expect(a.position.y).toBeCloseTo(0 - group.position.y);
43
+ // Relative positions must be non-negative (children sit inside the group box).
44
+ expect(a.position.x).toBeGreaterThanOrEqual(0);
45
+ expect(a.position.y).toBeGreaterThanOrEqual(0);
46
+ });
47
+
48
+ it("sets a childCount and leaves non-selected nodes untouched", () => {
49
+ const input = baseNodes();
50
+ const { nodes } = groupNodes(input, [], ["a", "b"], { groupId: "g1", title: "My group" });
51
+ const group = nodes.find((n) => n.id === "g1")!;
52
+ const c = nodes.find((n) => n.id === "c")!;
53
+
54
+ expect((group.data as { title: string; childCount: number }).title).toBe("My group");
55
+ expect((group.data as { childCount: number }).childCount).toBe(2);
56
+ // `c` was not grouped — same reference, unchanged.
57
+ expect(c).toBe(input[2]);
58
+ });
59
+
60
+ it("no-ops when no matching ids are given", () => {
61
+ const input = baseNodes();
62
+ const { nodes } = groupNodes(input, [], ["nope"], { groupId: "g1" });
63
+ expect(nodes).toBe(input);
64
+ });
65
+
66
+ it("does not mutate the input arrays", () => {
67
+ const input = baseNodes();
68
+ const snapshot = JSON.parse(JSON.stringify(input));
69
+ groupNodes(input, [], ["a", "b"], { groupId: "g1" });
70
+ expect(input).toEqual(snapshot);
71
+ });
72
+ });
73
+
74
+ describe("ungroup", () => {
75
+ it("restores children to absolute positions and removes the group", () => {
76
+ const original = baseNodes();
77
+ const grouped = groupNodes(original, [], ["a", "b"], { groupId: "g1" });
78
+ const { nodes } = ungroup(grouped.nodes, grouped.edges, "g1");
79
+
80
+ expect(nodes.find((n) => n.id === "g1")).toBeUndefined();
81
+ const a = nodes.find((n) => n.id === "a")!;
82
+ expect(a.parentId).toBeUndefined();
83
+ expect(a.extent).toBeUndefined();
84
+ // Back to the original absolute coordinates.
85
+ expect(a.position.x).toBeCloseTo(0);
86
+ expect(a.position.y).toBeCloseTo(0);
87
+ });
88
+ });
89
+
90
+ /**
91
+ * A group `g` containing children `x`, `y`; with an outside node `out`.
92
+ * Edges: out→x (boundary), x→y (fully inside), y→out (boundary).
93
+ */
94
+ function groupedFixture(): { nodes: Node[]; edges: Edge[] } {
95
+ const nodes: Node[] = [
96
+ {
97
+ id: "g",
98
+ type: "group",
99
+ position: { x: 0, y: 0 },
100
+ width: 300,
101
+ height: 200,
102
+ data: { title: "G", childCount: 2 },
103
+ },
104
+ {
105
+ id: "x",
106
+ type: "brand",
107
+ parentId: "g",
108
+ extent: "parent",
109
+ position: { x: 20, y: 60 },
110
+ data: {},
111
+ },
112
+ {
113
+ id: "y",
114
+ type: "brand",
115
+ parentId: "g",
116
+ extent: "parent",
117
+ position: { x: 160, y: 60 },
118
+ data: {},
119
+ },
120
+ { id: "out", type: "brand", position: { x: 600, y: 60 }, data: {} },
121
+ ];
122
+ const edges: Edge[] = [
123
+ { id: "e-out-x", source: "out", target: "x" },
124
+ { id: "e-x-y", source: "x", target: "y" },
125
+ { id: "e-y-out", source: "y", target: "out" },
126
+ ];
127
+ return { nodes, edges };
128
+ }
129
+
130
+ describe("collapseGroup", () => {
131
+ it("hides the whole subtree but not the group or outside nodes", () => {
132
+ const { nodes, edges } = groupedFixture();
133
+ const out = collapseGroup(nodes, edges, "g");
134
+
135
+ expect(out.nodes.find((n) => n.id === "x")!.hidden).toBe(true);
136
+ expect(out.nodes.find((n) => n.id === "y")!.hidden).toBe(true);
137
+ expect(out.nodes.find((n) => n.id === "g")!.hidden).toBeUndefined();
138
+ expect(out.nodes.find((n) => n.id === "out")!.hidden).toBeUndefined();
139
+ expect((out.nodes.find((n) => n.id === "g")!.data as { collapsed: boolean }).collapsed).toBe(
140
+ true,
141
+ );
142
+ });
143
+
144
+ it("creates proxy edges ONLY for boundary-crossing edges (not fully-inside)", () => {
145
+ const { nodes, edges } = groupedFixture();
146
+ const out = collapseGroup(nodes, edges, "g");
147
+
148
+ const proxies = out.edges.filter(isFlowGroupProxyEdge);
149
+ // Two boundary edges (out→x, y→out); the inside edge (x→y) gets no proxy.
150
+ expect(proxies).toHaveLength(2);
151
+
152
+ const inTargeting = proxies.find((e) => e.source === "out");
153
+ expect(inTargeting!.target).toBe("g"); // x replaced by the group
154
+ const outSourcing = proxies.find((e) => e.target === "out");
155
+ expect(outSourcing!.source).toBe("g"); // y replaced by the group
156
+
157
+ // The fully-inside edge is never proxied and is not hidden.
158
+ const inside = out.edges.find((e) => e.id === "e-x-y")!;
159
+ expect(isFlowGroupProxyEdge(inside)).toBe(false);
160
+ expect(inside.hidden).toBeUndefined();
161
+
162
+ // Original boundary edges are hidden while collapsed.
163
+ expect(out.edges.find((e) => e.id === "e-out-x")!.hidden).toBe(true);
164
+ expect(out.edges.find((e) => e.id === "e-y-out")!.hidden).toBe(true);
165
+ });
166
+
167
+ it("shrinks the group to a fixed overview size", () => {
168
+ const { nodes, edges } = groupedFixture();
169
+ const out = collapseGroup(nodes, edges, "g");
170
+ const g = out.nodes.find((n) => n.id === "g")!;
171
+ expect(g.width).toBeLessThan(300);
172
+ expect(g.height).toBeLessThan(200);
173
+ });
174
+
175
+ it("is a no-op when the group is already collapsed", () => {
176
+ const { nodes, edges } = groupedFixture();
177
+ const once = collapseGroup(nodes, edges, "g");
178
+ const twice = collapseGroup(once.nodes, once.edges, "g");
179
+ expect(twice.nodes).toBe(once.nodes);
180
+ expect(twice.edges).toBe(once.edges);
181
+ });
182
+
183
+ it("does not mutate the input arrays", () => {
184
+ const { nodes, edges } = groupedFixture();
185
+ const nodeSnap = JSON.parse(JSON.stringify(nodes));
186
+ const edgeSnap = JSON.parse(JSON.stringify(edges));
187
+ collapseGroup(nodes, edges, "g");
188
+ expect(nodes).toEqual(nodeSnap);
189
+ expect(edges).toEqual(edgeSnap);
190
+ });
191
+ });
192
+
193
+ describe("expandGroup — exact inverse of collapseGroup", () => {
194
+ it("round-trips nodes and edges back to the exact original", () => {
195
+ const { nodes, edges } = groupedFixture();
196
+ const collapsed = collapseGroup(nodes, edges, "g");
197
+ const expanded = expandGroup(collapsed.nodes, collapsed.edges, "g");
198
+
199
+ expect(expanded.nodes).toEqual(nodes);
200
+ expect(expanded.edges).toEqual(edges);
201
+ });
202
+
203
+ it("removes proxy edges and restores the original boundary edges", () => {
204
+ const { nodes, edges } = groupedFixture();
205
+ const collapsed = collapseGroup(nodes, edges, "g");
206
+ const expanded = expandGroup(collapsed.nodes, collapsed.edges, "g");
207
+
208
+ expect(expanded.edges.some(isFlowGroupProxyEdge)).toBe(false);
209
+ expect(expanded.edges.find((e) => e.id === "e-out-x")!.hidden).toBeUndefined();
210
+ expect(expanded.edges.map((e) => e.id)).toEqual(["e-out-x", "e-x-y", "e-y-out"]);
211
+ });
212
+
213
+ it("toggle collapses then expands back to the original", () => {
214
+ const { nodes, edges } = groupedFixture();
215
+ const collapsed = toggleGroupCollapsed(nodes, edges, "g");
216
+ expect(
217
+ (collapsed.nodes.find((n) => n.id === "g")!.data as { collapsed: boolean }).collapsed,
218
+ ).toBe(true);
219
+ const expanded = toggleGroupCollapsed(collapsed.nodes, collapsed.edges, "g");
220
+ expect(expanded.nodes).toEqual(nodes);
221
+ expect(expanded.edges).toEqual(edges);
222
+ });
223
+
224
+ it("no-ops when the group is not collapsed", () => {
225
+ const { nodes, edges } = groupedFixture();
226
+ const out = expandGroup(nodes, edges, "g");
227
+ expect(out.nodes).toBe(nodes);
228
+ expect(out.edges).toBe(edges);
229
+ });
230
+ });
231
+
232
+ /**
233
+ * Nested fixture: outer group `G` contains child `p` and subgroup `H`; `H`
234
+ * contains `q`. An outside node `out`. Edges cross several boundaries.
235
+ */
236
+ function nestedFixture(): { nodes: Node[]; edges: Edge[] } {
237
+ const nodes: Node[] = [
238
+ {
239
+ id: "G",
240
+ type: "group",
241
+ position: { x: 0, y: 0 },
242
+ width: 500,
243
+ height: 300,
244
+ data: { title: "G", childCount: 2 },
245
+ },
246
+ {
247
+ id: "p",
248
+ type: "brand",
249
+ parentId: "G",
250
+ extent: "parent",
251
+ position: { x: 20, y: 60 },
252
+ data: {},
253
+ },
254
+ {
255
+ id: "H",
256
+ type: "group",
257
+ parentId: "G",
258
+ extent: "parent",
259
+ position: { x: 200, y: 60 },
260
+ width: 240,
261
+ height: 180,
262
+ data: { title: "H", childCount: 1 },
263
+ },
264
+ {
265
+ id: "q",
266
+ type: "brand",
267
+ parentId: "H",
268
+ extent: "parent",
269
+ position: { x: 20, y: 60 },
270
+ data: {},
271
+ },
272
+ { id: "out", type: "brand", position: { x: 900, y: 60 }, data: {} },
273
+ ];
274
+ const edges: Edge[] = [
275
+ { id: "e-out-q", source: "out", target: "q" }, // out → deep inside
276
+ { id: "e-p-q", source: "p", target: "q" }, // inside G, crosses H boundary
277
+ { id: "e-q-out", source: "q", target: "out" }, // deep inside → out
278
+ ];
279
+ return { nodes, edges };
280
+ }
281
+
282
+ describe("nested groups", () => {
283
+ it("collapsing the inner group then the outer keeps the inner collapsed on outer-expand (no double-unhide)", () => {
284
+ const { nodes, edges } = nestedFixture();
285
+
286
+ // Collapse inner H first.
287
+ const hCollapsed = collapseGroup(nodes, edges, "H");
288
+ expect(hCollapsed.nodes.find((n) => n.id === "q")!.hidden).toBe(true);
289
+
290
+ // Then collapse outer G.
291
+ const gCollapsed = collapseGroup(hCollapsed.nodes, hCollapsed.edges, "G");
292
+ expect(gCollapsed.nodes.find((n) => n.id === "p")!.hidden).toBe(true);
293
+ expect(gCollapsed.nodes.find((n) => n.id === "H")!.hidden).toBe(true);
294
+ expect(gCollapsed.nodes.find((n) => n.id === "q")!.hidden).toBe(true);
295
+
296
+ // Expand only G — must restore to the post-H-collapse state exactly:
297
+ // H visible + still collapsed, q still hidden (H owns it).
298
+ const gExpanded = expandGroup(gCollapsed.nodes, gCollapsed.edges, "G");
299
+ expect(gExpanded.nodes).toEqual(hCollapsed.nodes);
300
+ expect(gExpanded.edges).toEqual(hCollapsed.edges);
301
+ expect(gExpanded.nodes.find((n) => n.id === "H")!.hidden).toBeUndefined();
302
+ expect(
303
+ (gExpanded.nodes.find((n) => n.id === "H")!.data as { collapsed: boolean }).collapsed,
304
+ ).toBe(true);
305
+ expect(gExpanded.nodes.find((n) => n.id === "q")!.hidden).toBe(true);
306
+ });
307
+
308
+ it("full nested round-trip: collapse H, collapse G, expand G, expand H returns the original", () => {
309
+ const { nodes, edges } = nestedFixture();
310
+ const hCollapsed = collapseGroup(nodes, edges, "H");
311
+ const gCollapsed = collapseGroup(hCollapsed.nodes, hCollapsed.edges, "G");
312
+ const gExpanded = expandGroup(gCollapsed.nodes, gCollapsed.edges, "G");
313
+ const hExpanded = expandGroup(gExpanded.nodes, gExpanded.edges, "H");
314
+
315
+ expect(hExpanded.nodes).toEqual(nodes);
316
+ expect(hExpanded.edges).toEqual(edges);
317
+ });
318
+ });