@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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/index.d.ts +634 -0
- package/dist/index.js +1577 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
- package/src/canvas-shell/canvas-shell.stories.tsx +77 -0
- package/src/canvas-shell/canvas-shell.test.tsx +106 -0
- package/src/canvas-shell/canvas-shell.tsx +128 -0
- package/src/canvas-shell/index.ts +1 -0
- package/src/flow-button-edge/flow-button-edge.stories.tsx +151 -0
- package/src/flow-button-edge/flow-button-edge.test.tsx +99 -0
- package/src/flow-button-edge/flow-button-edge.tsx +80 -0
- package/src/flow-button-edge/index.ts +5 -0
- package/src/flow-edge/flow-edge.stories.tsx +79 -0
- package/src/flow-edge/flow-edge.test.tsx +76 -0
- package/src/flow-edge/flow-edge.tsx +34 -0
- package/src/flow-edge/index.ts +1 -0
- package/src/flow-floating-edge/floating-edge-geometry.test.ts +100 -0
- package/src/flow-floating-edge/floating-edge-geometry.ts +128 -0
- package/src/flow-floating-edge/flow-floating-edge.stories.tsx +81 -0
- package/src/flow-floating-edge/flow-floating-edge.tsx +82 -0
- package/src/flow-floating-edge/index.ts +6 -0
- package/src/flow-group-node/flow-group-node.stories.tsx +283 -0
- package/src/flow-group-node/flow-group-node.test.tsx +100 -0
- package/src/flow-group-node/flow-group-node.tsx +134 -0
- package/src/flow-group-node/index.ts +6 -0
- package/src/flow-layout/flow-layout.stories.tsx +215 -0
- package/src/flow-layout/flow-layout.test.tsx +133 -0
- package/src/flow-layout/flow-layout.ts +104 -0
- package/src/flow-layout/index.ts +14 -0
- package/src/flow-layout/layout-graph.test.ts +257 -0
- package/src/flow-layout/layout-graph.ts +302 -0
- package/src/flow-layout/use-auto-layout.ts +26 -0
- package/src/flow-layout/use-flow-layout.ts +60 -0
- package/src/flow-mini-map/flow-mini-map.stories.tsx +74 -0
- package/src/flow-mini-map/flow-mini-map.test.tsx +56 -0
- package/src/flow-mini-map/flow-mini-map.tsx +29 -0
- package/src/flow-mini-map/index.ts +1 -0
- package/src/flow-node/flow-node.stories.tsx +143 -0
- package/src/flow-node/flow-node.test.tsx +156 -0
- package/src/flow-node/flow-node.tsx +190 -0
- package/src/flow-node/index.ts +8 -0
- package/src/flow-placeholder-node/flow-placeholder-node.stories.tsx +260 -0
- package/src/flow-placeholder-node/flow-placeholder-node.test.tsx +101 -0
- package/src/flow-placeholder-node/flow-placeholder-node.tsx +49 -0
- package/src/flow-placeholder-node/index.ts +5 -0
- package/src/flow-smart-edge/flow-smart-edge.stories.tsx +75 -0
- package/src/flow-smart-edge/flow-smart-edge.tsx +75 -0
- package/src/flow-smart-edge/index.ts +9 -0
- package/src/flow-smart-edge/smart-edge-geometry.test.ts +120 -0
- package/src/flow-smart-edge/smart-edge-geometry.ts +116 -0
- package/src/helper-lines/get-helper-lines.ts +98 -0
- package/src/helper-lines/helper-lines.stories.tsx +107 -0
- package/src/helper-lines/helper-lines.test.tsx +78 -0
- package/src/helper-lines/helper-lines.tsx +67 -0
- package/src/helper-lines/index.ts +7 -0
- package/src/helper-lines/use-helper-lines.ts +109 -0
- package/src/index.ts +37 -0
- package/src/inspector-panel/index.ts +1 -0
- package/src/inspector-panel/inspector-panel.stories.tsx +161 -0
- package/src/inspector-panel/inspector-panel.test.tsx +82 -0
- package/src/inspector-panel/inspector-panel.tsx +129 -0
- package/src/legend/index.ts +1 -0
- package/src/legend/legend.stories.tsx +56 -0
- package/src/legend/legend.test.tsx +46 -0
- package/src/legend/legend.tsx +39 -0
- package/src/templates-flow-workspace.stories.tsx +169 -0
- package/src/use-flow-groups/group-operations.test.ts +318 -0
- package/src/use-flow-groups/group-operations.ts +349 -0
- package/src/use-flow-groups/index.ts +13 -0
- package/src/use-flow-groups/use-flow-groups.ts +113 -0
- package/src/zoom-controls/index.ts +1 -0
- package/src/zoom-controls/zoom-controls.stories.tsx +68 -0
- package/src/zoom-controls/zoom-controls.test.tsx +80 -0
- package/src/zoom-controls/zoom-controls.tsx +78 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const setNodes = vi.fn();
|
|
5
|
+
const setEdges = vi.fn();
|
|
6
|
+
const getNodes = vi.fn(() => [
|
|
7
|
+
{ id: "test-group", type: "group", position: { x: 0, y: 0 }, data: { title: "Group" } },
|
|
8
|
+
]);
|
|
9
|
+
const getEdges = vi.fn(() => [] as unknown[]);
|
|
10
|
+
|
|
11
|
+
// @xyflow/react needs real layout/measurement — mock the engine and assert the
|
|
12
|
+
// brand component's own output. Real rendering + a11y are covered by Storybook
|
|
13
|
+
// interaction tests. The pure grouping logic is covered by group-operations.test.ts.
|
|
14
|
+
vi.mock("@xyflow/react", () => {
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- a vi.mock factory is hoisted above imports; a lazy require avoids the TDZ a top-level import would hit
|
|
16
|
+
const React = require("react");
|
|
17
|
+
return {
|
|
18
|
+
Handle: ({ type }: { type: string }) =>
|
|
19
|
+
React.createElement("div", { "data-testid": `handle-${type}` }),
|
|
20
|
+
NodeResizer: () => React.createElement("div", { "data-testid": "node-resizer" }),
|
|
21
|
+
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
|
22
|
+
useNodes: () => [] as unknown[],
|
|
23
|
+
useReactFlow: () => ({ getNodes, getEdges, setNodes, setEdges }),
|
|
24
|
+
getNodesBounds: () => ({ x: 0, y: 0, width: 0, height: 0 }),
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
import { FlowGroupNode, type BrandFlowGroupNode } from "./flow-group-node";
|
|
29
|
+
import type { NodeProps } from "@xyflow/react";
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup();
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function makeProps(
|
|
37
|
+
data: BrandFlowGroupNode["data"],
|
|
38
|
+
overrides: Partial<NodeProps<BrandFlowGroupNode>> = {},
|
|
39
|
+
): NodeProps<BrandFlowGroupNode> {
|
|
40
|
+
return {
|
|
41
|
+
id: "test-group",
|
|
42
|
+
data,
|
|
43
|
+
selected: false,
|
|
44
|
+
dragging: false,
|
|
45
|
+
zIndex: 0,
|
|
46
|
+
isConnectable: true,
|
|
47
|
+
draggable: true,
|
|
48
|
+
deletable: true,
|
|
49
|
+
selectable: true,
|
|
50
|
+
positionAbsoluteX: 0,
|
|
51
|
+
positionAbsoluteY: 0,
|
|
52
|
+
width: 320,
|
|
53
|
+
height: 200,
|
|
54
|
+
type: "group",
|
|
55
|
+
...overrides,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("FlowGroupNode", () => {
|
|
60
|
+
it("renders the title and the child-count badge", () => {
|
|
61
|
+
render(<FlowGroupNode {...makeProps({ title: "Transforms", childCount: 3 })} />);
|
|
62
|
+
expect(screen.getByText("Transforms")).toBeInTheDocument();
|
|
63
|
+
expect(screen.getByText("3")).toBeInTheDocument();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("exposes an accessible collapse toggle (aria-expanded true when expanded)", () => {
|
|
67
|
+
render(<FlowGroupNode {...makeProps({ title: "G" })} />);
|
|
68
|
+
const button = screen.getByRole("button", { name: /collapse group g/i });
|
|
69
|
+
expect(button).toHaveAttribute("aria-expanded", "true");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("shows an expand affordance (aria-expanded false) when collapsed", () => {
|
|
73
|
+
render(<FlowGroupNode {...makeProps({ title: "G", collapsed: true })} />);
|
|
74
|
+
const button = screen.getByRole("button", { name: /expand group g/i });
|
|
75
|
+
expect(button).toHaveAttribute("aria-expanded", "false");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("toggling calls into the store (setNodes/setEdges)", () => {
|
|
79
|
+
render(<FlowGroupNode {...makeProps({ title: "Group" })} />);
|
|
80
|
+
fireEvent.click(screen.getByRole("button", { name: /collapse group group/i }));
|
|
81
|
+
expect(setNodes).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(setEdges).toHaveBeenCalledTimes(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("renders a NodeResizer only when selected and expanded", () => {
|
|
86
|
+
const { rerender } = render(
|
|
87
|
+
<FlowGroupNode {...makeProps({ title: "G" }, { selected: true })} />,
|
|
88
|
+
);
|
|
89
|
+
expect(screen.getByTestId("node-resizer")).toBeInTheDocument();
|
|
90
|
+
|
|
91
|
+
rerender(<FlowGroupNode {...makeProps({ title: "G", collapsed: true }, { selected: true })} />);
|
|
92
|
+
expect(screen.queryByTestId("node-resizer")).not.toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("renders group handles so proxy edges can attach", () => {
|
|
96
|
+
render(<FlowGroupNode {...makeProps({ title: "G" })} />);
|
|
97
|
+
expect(screen.getByTestId("handle-target")).toBeInTheDocument();
|
|
98
|
+
expect(screen.getByTestId("handle-source")).toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { Handle, NodeResizer, Position, useNodes, type Node, type NodeProps } from "@xyflow/react";
|
|
3
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
4
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
5
|
+
import { useFlowGroups } from "../use-flow-groups";
|
|
6
|
+
|
|
7
|
+
/** Visual accent for a group container. */
|
|
8
|
+
export type FlowGroupTone = "default" | "accent" | "success" | "warning" | "destructive";
|
|
9
|
+
|
|
10
|
+
export interface FlowGroupNodeData extends Record<string, unknown> {
|
|
11
|
+
/** Header label for the group. */
|
|
12
|
+
title: string;
|
|
13
|
+
/** Optional leading glyph (a Lucide icon element, etc.). */
|
|
14
|
+
icon?: ReactNode;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the group is collapsed to an overview chip. Managed by
|
|
17
|
+
* `useFlowGroups().collapseGroup` / `expandGroup`; the header toggle flips it.
|
|
18
|
+
*/
|
|
19
|
+
collapsed?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Fallback count shown next to the title. When omitted, the node counts its
|
|
22
|
+
* live direct children from the store, so the badge stays accurate as nodes
|
|
23
|
+
* are added or removed.
|
|
24
|
+
*/
|
|
25
|
+
childCount?: number;
|
|
26
|
+
/** Accent tone for the header rule + count badge. */
|
|
27
|
+
tone?: FlowGroupTone;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type BrandFlowGroupNode = Node<FlowGroupNodeData, "group">;
|
|
31
|
+
|
|
32
|
+
const toneAccent: Record<FlowGroupTone, string> = {
|
|
33
|
+
default: "text-muted-foreground",
|
|
34
|
+
// #399 — the group LABEL is text on the canvas, so it takes the on-surface
|
|
35
|
+
// `-text` rung. (The status rows below stay on their fill rungs; those are a
|
|
36
|
+
// separate, still-open question, not part of #399's enumerated call sites.)
|
|
37
|
+
accent: "text-primary-text",
|
|
38
|
+
success: "text-success",
|
|
39
|
+
warning: "text-warning",
|
|
40
|
+
destructive: "text-destructive",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleClassName = "!size-2 !border-2 !border-flow-group-border !bg-flow-group";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Branded group container node. Register it as `nodeTypes={{ group: FlowGroupNode }}`
|
|
47
|
+
* and create nodes with `type: "group"` + typed `data: FlowGroupNodeData`. Children
|
|
48
|
+
* are re-parented onto it (`parentId`, `extent: "parent"`) — see `useFlowGroups`.
|
|
49
|
+
*
|
|
50
|
+
* The header carries the title, a live child-count badge and a collapse/expand
|
|
51
|
+
* toggle (a real `<button>` with `aria-expanded`) that drives
|
|
52
|
+
* `useFlowGroups().collapseGroup` / `expandGroup`. When selected (and expanded)
|
|
53
|
+
* a `<NodeResizer>` lets the group be resized.
|
|
54
|
+
*
|
|
55
|
+
* The surface uses the FL-01 tokens `--flow-group` (fill) + `--flow-group-border`
|
|
56
|
+
* (border). Under a theme whose `--flow-group` is near-transparent, the
|
|
57
|
+
* drawn border does the work.
|
|
58
|
+
*/
|
|
59
|
+
export function FlowGroupNode({ id, data, selected }: NodeProps<BrandFlowGroupNode>) {
|
|
60
|
+
const { toggleCollapse } = useFlowGroups();
|
|
61
|
+
const nodes = useNodes();
|
|
62
|
+
const collapsed = data.collapsed ?? false;
|
|
63
|
+
const tone = data.tone ?? "default";
|
|
64
|
+
|
|
65
|
+
// Live count of direct children; fall back to the stored count before the
|
|
66
|
+
// store settles (or in a non-interactive preview).
|
|
67
|
+
const liveChildCount = nodes.filter((n) => n.parentId === id).length;
|
|
68
|
+
const childCount = liveChildCount || data.childCount || 0;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div
|
|
72
|
+
className={cn(
|
|
73
|
+
"flex h-full w-full flex-col rounded-lg border bg-flow-group/60 text-foreground",
|
|
74
|
+
"border-flow-group-border transition-[box-shadow,border-color] duration-fast ease-standard motion-reduce:transition-none",
|
|
75
|
+
collapsed ? "shadow-sm" : "shadow-none",
|
|
76
|
+
selected && "ring-2 ring-ring",
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{!collapsed && selected ? (
|
|
80
|
+
<NodeResizer
|
|
81
|
+
minWidth={160}
|
|
82
|
+
minHeight={96}
|
|
83
|
+
lineClassName="!border-flow-group-border"
|
|
84
|
+
handleClassName={handleClassName}
|
|
85
|
+
/>
|
|
86
|
+
) : null}
|
|
87
|
+
|
|
88
|
+
{/* Handles so proxy edges can attach to the group when collapsed. */}
|
|
89
|
+
<Handle type="target" position={Position.Top} className={handleClassName} />
|
|
90
|
+
<Handle type="source" position={Position.Bottom} className={handleClassName} />
|
|
91
|
+
|
|
92
|
+
<div
|
|
93
|
+
className={cn(
|
|
94
|
+
"flex items-center gap-2 px-3 py-2",
|
|
95
|
+
!collapsed && "border-b border-flow-group-border",
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
onClick={() => toggleCollapse(id)}
|
|
101
|
+
aria-label={collapsed ? `Expand group ${data.title}` : `Collapse group ${data.title}`}
|
|
102
|
+
aria-expanded={!collapsed}
|
|
103
|
+
className={cn(
|
|
104
|
+
"-ml-1 grid size-5 shrink-0 place-items-center rounded",
|
|
105
|
+
"text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
106
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{collapsed ? (
|
|
110
|
+
<ChevronRight aria-hidden="true" className="size-4" />
|
|
111
|
+
) : (
|
|
112
|
+
<ChevronDown aria-hidden="true" className="size-4" />
|
|
113
|
+
)}
|
|
114
|
+
</button>
|
|
115
|
+
|
|
116
|
+
{data.icon ? (
|
|
117
|
+
<span className={cn("[&_svg]:size-4", toneAccent[tone])}>{data.icon}</span>
|
|
118
|
+
) : null}
|
|
119
|
+
|
|
120
|
+
<span className="min-w-0 flex-1 truncate text-body font-medium">{data.title}</span>
|
|
121
|
+
|
|
122
|
+
<span
|
|
123
|
+
className={cn(
|
|
124
|
+
"shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-meta font-medium tabular-nums",
|
|
125
|
+
toneAccent[tone],
|
|
126
|
+
)}
|
|
127
|
+
aria-label={`${childCount} ${childCount === 1 ? "node" : "nodes"}`}
|
|
128
|
+
>
|
|
129
|
+
{childCount}
|
|
130
|
+
</span>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import "@xyflow/react/dist/style.css";
|
|
4
|
+
import { Panel, useEdgesState, useNodesState, useReactFlow, type Edge } from "@xyflow/react";
|
|
5
|
+
import { CanvasShell } from "../canvas-shell";
|
|
6
|
+
import { FlowSmartEdge } from "../flow-smart-edge";
|
|
7
|
+
import { FlowNode, FLOW_ALL_SIDE_HANDLES, type BrandFlowNode } from "../flow-node";
|
|
8
|
+
import { ZoomControls } from "../zoom-controls";
|
|
9
|
+
import { useFlowLayout } from "./use-flow-layout";
|
|
10
|
+
import type { FlowLayoutDirection } from "./flow-layout";
|
|
11
|
+
import { layoutGraph, type LayoutAlgorithm } from "./layout-graph";
|
|
12
|
+
|
|
13
|
+
const nodeTypes = { brand: FlowNode };
|
|
14
|
+
// Smart edges + all-side anchors: each edge connects on the side facing the other
|
|
15
|
+
// node and RE-picks that side as the layout moves nodes — so anchors flip
|
|
16
|
+
// top/bottom ↔ left/right automatically for any layout (TB, LR, concentric, …).
|
|
17
|
+
const edgeTypes = { smart: FlowSmartEdge };
|
|
18
|
+
|
|
19
|
+
/** A deliberately messy diamond graph — overlapping, unordered positions. */
|
|
20
|
+
const messyNodes: BrandFlowNode[] = [
|
|
21
|
+
{
|
|
22
|
+
id: "ingest",
|
|
23
|
+
type: "brand",
|
|
24
|
+
position: { x: 40, y: 260 },
|
|
25
|
+
data: { kind: "Source", title: "Ingest", subtitle: "Raw data in", tone: "accent" },
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "transform",
|
|
29
|
+
type: "brand",
|
|
30
|
+
position: { x: 380, y: 20 },
|
|
31
|
+
data: { kind: "Process", title: "Transform", subtitle: "Normalize & enrich" },
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: "validate",
|
|
35
|
+
type: "brand",
|
|
36
|
+
position: { x: 60, y: 40 },
|
|
37
|
+
data: { kind: "Process", title: "Validate", subtitle: "Quality checks" },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "enrich",
|
|
41
|
+
type: "brand",
|
|
42
|
+
position: { x: 420, y: 340 },
|
|
43
|
+
data: { kind: "Process", title: "Enrich", subtitle: "Join reference data" },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "publish",
|
|
47
|
+
type: "brand",
|
|
48
|
+
position: { x: 150, y: 420 },
|
|
49
|
+
data: { kind: "Output", title: "Publish", subtitle: "Downstream sink", tone: "success" },
|
|
50
|
+
},
|
|
51
|
+
].map((n) => ({ ...n, data: { ...n.data, handles: FLOW_ALL_SIDE_HANDLES } }));
|
|
52
|
+
|
|
53
|
+
const messyEdges: Edge[] = [
|
|
54
|
+
{ id: "e-ingest-transform", source: "ingest", target: "transform", type: "smart" },
|
|
55
|
+
{ id: "e-ingest-validate", source: "ingest", target: "validate", type: "smart" },
|
|
56
|
+
{ id: "e-transform-enrich", source: "transform", target: "enrich", type: "smart" },
|
|
57
|
+
{ id: "e-validate-enrich", source: "validate", target: "enrich", type: "smart" },
|
|
58
|
+
{ id: "e-enrich-publish", source: "enrich", target: "publish", type: "smart" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function AutoLayoutButton({ direction }: { direction: FlowLayoutDirection }) {
|
|
62
|
+
const { layout, layouting } = useFlowLayout();
|
|
63
|
+
return (
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
disabled={layouting}
|
|
67
|
+
onClick={() => layout(direction)}
|
|
68
|
+
className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
69
|
+
>
|
|
70
|
+
Auto layout ({direction === "TB" ? "top → bottom" : "left → right"})
|
|
71
|
+
</button>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function FlowLayoutDemo({ direction }: { direction: FlowLayoutDirection }) {
|
|
76
|
+
const [nodes, , onNodesChange] = useNodesState(messyNodes);
|
|
77
|
+
const [edges, , onEdgesChange] = useEdgesState(messyEdges);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="h-[500px]">
|
|
81
|
+
<CanvasShell
|
|
82
|
+
nodes={nodes}
|
|
83
|
+
edges={edges}
|
|
84
|
+
nodeTypes={nodeTypes}
|
|
85
|
+
edgeTypes={edgeTypes}
|
|
86
|
+
onNodesChange={onNodesChange}
|
|
87
|
+
onEdgesChange={onEdgesChange}
|
|
88
|
+
>
|
|
89
|
+
<Panel position="top-left">
|
|
90
|
+
<AutoLayoutButton direction={direction} />
|
|
91
|
+
</Panel>
|
|
92
|
+
<ZoomControls />
|
|
93
|
+
</CanvasShell>
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const ALGORITHM_LABEL: Record<LayoutAlgorithm, string> = {
|
|
99
|
+
concentric: "concentric",
|
|
100
|
+
force: "force",
|
|
101
|
+
"layered-lr": "layered (left → right)",
|
|
102
|
+
grid: "grid",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
function AutoLayoutGraphButton({ algorithm }: { algorithm: LayoutAlgorithm }) {
|
|
106
|
+
const { getNodes, getEdges, setNodes, fitView } = useReactFlow();
|
|
107
|
+
const [layouting, setLayouting] = useState(false);
|
|
108
|
+
|
|
109
|
+
const handleClick = () => {
|
|
110
|
+
setLayouting(true);
|
|
111
|
+
const laidOut = layoutGraph(getNodes(), getEdges(), {
|
|
112
|
+
algorithm,
|
|
113
|
+
centerId: "ingest",
|
|
114
|
+
ringRadius: 160,
|
|
115
|
+
iterations: 250,
|
|
116
|
+
spacing: { x: 220, y: 140 },
|
|
117
|
+
});
|
|
118
|
+
setNodes(laidOut);
|
|
119
|
+
requestAnimationFrame(() => {
|
|
120
|
+
fitView();
|
|
121
|
+
setLayouting(false);
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
disabled={layouting}
|
|
129
|
+
onClick={handleClick}
|
|
130
|
+
className="rounded-md border border-input bg-surface-elevated px-3 py-1.5 text-body shadow-sm hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
131
|
+
>
|
|
132
|
+
Auto layout ({ALGORITHM_LABEL[algorithm]})
|
|
133
|
+
</button>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Same messy dataset as `FlowLayoutDemo`, laid out via the pure `layoutGraph` helper. */
|
|
138
|
+
function LayoutGraphDemo({ algorithm }: { algorithm: LayoutAlgorithm }) {
|
|
139
|
+
const [nodes, , onNodesChange] = useNodesState(messyNodes);
|
|
140
|
+
const [edges, , onEdgesChange] = useEdgesState(messyEdges);
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<div className="h-[500px]">
|
|
144
|
+
<CanvasShell
|
|
145
|
+
nodes={nodes}
|
|
146
|
+
edges={edges}
|
|
147
|
+
nodeTypes={nodeTypes}
|
|
148
|
+
edgeTypes={edgeTypes}
|
|
149
|
+
onNodesChange={onNodesChange}
|
|
150
|
+
onEdgesChange={onEdgesChange}
|
|
151
|
+
>
|
|
152
|
+
<Panel position="top-left">
|
|
153
|
+
<AutoLayoutGraphButton algorithm={algorithm} />
|
|
154
|
+
</Panel>
|
|
155
|
+
<ZoomControls />
|
|
156
|
+
</CanvasShell>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const meta = {
|
|
162
|
+
title: "Flow/FlowLayout",
|
|
163
|
+
tags: ["autodocs"],
|
|
164
|
+
parameters: { layout: "fullscreen" },
|
|
165
|
+
} satisfies Meta;
|
|
166
|
+
export default meta;
|
|
167
|
+
type Story = StoryObj<typeof meta>;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A messy graph; click "Auto layout" to run `useFlowLayout` (dagre, top → bottom).
|
|
171
|
+
* Nodes carry all-side anchors and `FlowSmartEdge`, so once stacked vertically the
|
|
172
|
+
* edges connect **bottom → top** (the facing sides).
|
|
173
|
+
*/
|
|
174
|
+
export const Default: Story = {
|
|
175
|
+
render: () => <FlowLayoutDemo direction="TB" />,
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Same graph, laid out left → right. Because the edges are smart + the nodes have
|
|
180
|
+
* anchors on every side, the connections **re-pick the facing side automatically**
|
|
181
|
+
* — now the right (source) / left (target) sides — instead of routing through
|
|
182
|
+
* top/bottom.
|
|
183
|
+
*/
|
|
184
|
+
export const LeftToRight: Story = {
|
|
185
|
+
render: () => <FlowLayoutDemo direction="LR" />,
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `layoutGraph({ algorithm: "concentric" })` — the "ingest" node is the focal
|
|
190
|
+
* center; the rest of the graph radiates outward in BFS shells.
|
|
191
|
+
*/
|
|
192
|
+
export const Concentric: Story = {
|
|
193
|
+
render: () => <LayoutGraphDemo algorithm="concentric" />,
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* `layoutGraph({ algorithm: "force" })` — a deterministic, seeded `d3-force`
|
|
198
|
+
* simulation spreads the nodes apart.
|
|
199
|
+
*/
|
|
200
|
+
export const Force: Story = {
|
|
201
|
+
render: () => <LayoutGraphDemo algorithm="force" />,
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `layoutGraph({ algorithm: "layered-lr" })` — delegates to the same dagre
|
|
206
|
+
* engine as `useFlowLayout`, exposed as a pure function.
|
|
207
|
+
*/
|
|
208
|
+
export const LayeredLR: Story = {
|
|
209
|
+
render: () => <LayoutGraphDemo algorithm="layered-lr" />,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** `layoutGraph({ algorithm: "grid" })` — a simple row-major grid. */
|
|
213
|
+
export const Grid: Story = {
|
|
214
|
+
render: () => <LayoutGraphDemo algorithm="grid" />,
|
|
215
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { Edge, Node } from "@xyflow/react";
|
|
3
|
+
import { layoutFlow } from "./flow-layout";
|
|
4
|
+
|
|
5
|
+
function chainNodes(): Node[] {
|
|
6
|
+
return [
|
|
7
|
+
{ id: "a", type: "brand", position: { x: 0, y: 0 }, data: { title: "A" } },
|
|
8
|
+
{ id: "b", type: "brand", position: { x: 0, y: 0 }, data: { title: "B" } },
|
|
9
|
+
{ id: "c", type: "brand", position: { x: 0, y: 0 }, data: { title: "C" } },
|
|
10
|
+
];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function chainEdges(): Edge[] {
|
|
14
|
+
return [
|
|
15
|
+
{ id: "e-ab", source: "a", target: "b" },
|
|
16
|
+
{ id: "e-bc", source: "b", target: "c" },
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("layoutFlow", () => {
|
|
21
|
+
it("orders a 3-node chain top-to-bottom for direction=TB (default)", () => {
|
|
22
|
+
const { nodes } = layoutFlow(chainNodes(), chainEdges());
|
|
23
|
+
const [a, b, c] = nodes as [Node, Node, Node];
|
|
24
|
+
|
|
25
|
+
// Ranks increase down the y axis; x stays roughly aligned per rank.
|
|
26
|
+
expect(a.position.y).toBeLessThan(b.position.y);
|
|
27
|
+
expect(b.position.y).toBeLessThan(c.position.y);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("orders a 3-node chain left-to-right for direction=LR", () => {
|
|
31
|
+
const { nodes } = layoutFlow(chainNodes(), chainEdges(), { direction: "LR" });
|
|
32
|
+
const [a, b, c] = nodes as [Node, Node, Node];
|
|
33
|
+
|
|
34
|
+
expect(a.position.x).toBeLessThan(b.position.x);
|
|
35
|
+
expect(b.position.x).toBeLessThan(c.position.x);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("moves the handle sides with the direction (TB → bottom/top, LR → right/left)", () => {
|
|
39
|
+
const tb = layoutFlow(chainNodes(), chainEdges(), { direction: "TB" }).nodes[0]!;
|
|
40
|
+
expect(tb.sourcePosition).toBe("bottom");
|
|
41
|
+
expect(tb.targetPosition).toBe("top");
|
|
42
|
+
|
|
43
|
+
const lr = layoutFlow(chainNodes(), chainEdges(), { direction: "LR" }).nodes[0]!;
|
|
44
|
+
expect(lr.sourcePosition).toBe("right");
|
|
45
|
+
expect(lr.targetPosition).toBe("left");
|
|
46
|
+
|
|
47
|
+
const rl = layoutFlow(chainNodes(), chainEdges(), { direction: "RL" }).nodes[0]!;
|
|
48
|
+
expect(rl.sourcePosition).toBe("left");
|
|
49
|
+
expect(rl.targetPosition).toBe("right");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reverses rank order for direction=BT relative to TB", () => {
|
|
53
|
+
const { nodes: tb } = layoutFlow(chainNodes(), chainEdges(), { direction: "TB" });
|
|
54
|
+
const { nodes: bt } = layoutFlow(chainNodes(), chainEdges(), { direction: "BT" });
|
|
55
|
+
const [tbA, tbB] = tb as [Node, Node];
|
|
56
|
+
const [btA, btB] = bt as [Node, Node];
|
|
57
|
+
|
|
58
|
+
// TB: a above b above c. BT: a below b below c.
|
|
59
|
+
expect(tbA.position.y).toBeLessThan(tbB.position.y);
|
|
60
|
+
expect(btA.position.y).toBeGreaterThan(btB.position.y);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("preserves node identity and data — only position changes", () => {
|
|
64
|
+
const original = chainNodes();
|
|
65
|
+
const { nodes } = layoutFlow(original, chainEdges());
|
|
66
|
+
|
|
67
|
+
nodes.forEach((node, i) => {
|
|
68
|
+
expect(node.id).toBe(original[i]!.id);
|
|
69
|
+
expect(node.data).toBe(original[i]!.data);
|
|
70
|
+
expect(node.type).toBe(original[i]!.type);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("preserves edges unchanged (same reference, same array)", () => {
|
|
75
|
+
const edges = chainEdges();
|
|
76
|
+
const { edges: outEdges } = layoutFlow(chainNodes(), edges);
|
|
77
|
+
expect(outEdges).toBe(edges);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("honors measured size over a stale width/height", () => {
|
|
81
|
+
const nodes: Node[] = [
|
|
82
|
+
{
|
|
83
|
+
id: "a",
|
|
84
|
+
position: { x: 0, y: 0 },
|
|
85
|
+
data: {},
|
|
86
|
+
width: 50,
|
|
87
|
+
height: 20,
|
|
88
|
+
measured: { width: 400, height: 200 },
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: "b",
|
|
92
|
+
position: { x: 0, y: 0 },
|
|
93
|
+
data: {},
|
|
94
|
+
width: 50,
|
|
95
|
+
height: 20,
|
|
96
|
+
measured: { width: 400, height: 200 },
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
const edges: Edge[] = [{ id: "e-ab", source: "a", target: "b" }];
|
|
100
|
+
|
|
101
|
+
const { nodes: layouted } = layoutFlow(nodes, edges, { direction: "LR" });
|
|
102
|
+
const [a, b] = layouted;
|
|
103
|
+
|
|
104
|
+
// With a 400-wide measured size and default 72px ranksep, b must start at
|
|
105
|
+
// least a.x + measured.width + ranksep away — a value only reachable if
|
|
106
|
+
// the (much larger) measured size, not the stale width, drove the layout.
|
|
107
|
+
expect(b!.position.x - a!.position.x).toBeGreaterThanOrEqual(400 + 72 - 1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("falls back to a sensible default size when neither measured nor width/height are set", () => {
|
|
111
|
+
const nodes: Node[] = [
|
|
112
|
+
{ id: "a", position: { x: 0, y: 0 }, data: {} },
|
|
113
|
+
{ id: "b", position: { x: 0, y: 0 }, data: {} },
|
|
114
|
+
];
|
|
115
|
+
const edges: Edge[] = [{ id: "e-ab", source: "a", target: "b" }];
|
|
116
|
+
|
|
117
|
+
expect(() => layoutFlow(nodes, edges)).not.toThrow();
|
|
118
|
+
const { nodes: layouted } = layoutFlow(nodes, edges);
|
|
119
|
+
expect(Number.isFinite(layouted[0]!.position.x)).toBe(true);
|
|
120
|
+
expect(Number.isFinite(layouted[0]!.position.y)).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("respects custom nodeSpacing/rankSpacing", () => {
|
|
124
|
+
const nodes = chainNodes();
|
|
125
|
+
const edges = chainEdges();
|
|
126
|
+
const tight = layoutFlow(nodes, edges, { rankSpacing: 10 });
|
|
127
|
+
const wide = layoutFlow(nodes, edges, { rankSpacing: 500 });
|
|
128
|
+
|
|
129
|
+
const tightGap = tight.nodes[1]!.position.y - tight.nodes[0]!.position.y;
|
|
130
|
+
const wideGap = wide.nodes[1]!.position.y - wide.nodes[0]!.position.y;
|
|
131
|
+
expect(wideGap).toBeGreaterThan(tightGap);
|
|
132
|
+
});
|
|
133
|
+
});
|