Skip to content

Commit 8dcd8b7

Browse files
committed
doc and tests for auto layout hook
1 parent 83511f8 commit 8dcd8b7

2 files changed

Lines changed: 135 additions & 5 deletions

File tree

src/core/hooks/useAutoLayout.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,35 +13,51 @@ interface UseAutoLayoutProps {
1313
const NODE_WIDTH = 220;
1414
const NODE_HEIGHT = 150;
1515

16+
/**
17+
* Automatically organizes the visual graph using the Dagre directed acyclic graph layout algorithm.
18+
* Translates Dagre's center-based coordinates into React Flow's top-left based coordinates.
19+
*
20+
* @param props.nodes - The current array of nodes to be measured.
21+
* @param props.edges - The current array of edges to determine layout hierarchy.
22+
* @param props.setNodes - State setter to apply the newly calculated coordinates.
23+
* @param props.takeSnapshot - History callback invoked before mutation to allow user undo.
24+
*/
1625
export function useAutoLayout({ nodes, edges, setNodes, takeSnapshot }: UseAutoLayoutProps) {
1726

27+
/**
28+
* Executes the layout algorithm.
29+
* @param direction - The flow direction of the graph. 'LR' (Left-to-Right) or 'TB' (Top-to-Bottom).
30+
*/
1831
const autoLayout = useCallback((direction = 'LR') => {
19-
32+
33+
/* Captures state for the undo stack before overwriting coordinates */
2034
takeSnapshot();
2135

22-
2336
const dagreGraph = new dagre.graphlib.Graph();
2437
dagreGraph.setDefaultEdgeLabel(() => ({}));
2538

2639
dagreGraph.setGraph({ rankdir: direction, ranksep: 120, nodesep: 60 });
2740

28-
41+
/* Feeds the nodes and expected dimensions into the engine */
2942
nodes.forEach((node) => {
3043
dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
3144
});
45+
46+
/* Feeds the hierarchy/connections */
3247
edges.forEach((edge) => {
3348
dagreGraph.setEdge(edge.source, edge.target);
3449
});
3550

36-
51+
/* Executes the mathematical layout calculation */
3752
dagre.layout(dagreGraph);
3853

39-
54+
/* Translates the coordinates back to the React Flow format */
4055
const layoutedNodes = nodes.map((node) => {
4156
const nodeWithPosition = dagreGraph.node(node.id);
4257
return {
4358
...node,
4459
position: {
60+
/* Dagre returns the exact center. React Flow requires the top-left corner. */
4561
x: nodeWithPosition.x - NODE_WIDTH / 2,
4662
y: nodeWithPosition.y - NODE_HEIGHT / 2,
4763
},

tests/unit/useAutoLayout.spec.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// @vitest-environment jsdom
2+
// tests/unit/useAutoLayout.spec.ts
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { renderHook, act } from '@testing-library/react';
5+
import { useAutoLayout } from '../../src/core/hooks/useAutoLayout';
6+
import type { Node, Edge } from 'reactflow';
7+
8+
/* Isolate the mathematical dependency.
9+
* We mock Dagre using an actual ES6 class so the `new` keyword works perfectly.
10+
*/
11+
const mockSetGraph = vi.fn();
12+
13+
vi.mock('dagre', () => {
14+
class MockGraph {
15+
setDefaultEdgeLabel = vi.fn();
16+
setGraph = mockSetGraph;
17+
setNode = vi.fn();
18+
setEdge = vi.fn();
19+
node = vi.fn(() => ({ x: 500, y: 500 })); // Force predictable output
20+
}
21+
22+
return {
23+
default: {
24+
graphlib: { Graph: MockGraph },
25+
layout: vi.fn()
26+
},
27+
// Flat exports catch edge cases where the test runner drops the 'default' wrapper
28+
graphlib: { Graph: MockGraph },
29+
layout: vi.fn()
30+
};
31+
});
32+
33+
describe('useAutoLayout Hook', () => {
34+
35+
const mockSetNodes = vi.fn();
36+
const mockTakeSnapshot = vi.fn();
37+
38+
const mockNodes: Node[] = [
39+
{ id: 'n1', position: { x: 0, y: 0 }, data: {} },
40+
{ id: 'n2', position: { x: -100, y: -200 }, data: {} }
41+
];
42+
43+
const mockEdges: Edge[] = [
44+
{ id: 'e1', source: 'n1', target: 'n2' }
45+
];
46+
47+
beforeEach(() => {
48+
vi.clearAllMocks();
49+
});
50+
51+
it('triggers a history snapshot prior to modifying node coordinates', () => {
52+
const { result } = renderHook(() => useAutoLayout({
53+
nodes: mockNodes,
54+
edges: mockEdges,
55+
setNodes: mockSetNodes,
56+
takeSnapshot: mockTakeSnapshot
57+
}));
58+
59+
act(() => {
60+
result.current.autoLayout();
61+
});
62+
63+
/* The history snapshot MUST fire before the nodes are updated */
64+
expect(mockTakeSnapshot).toHaveBeenCalledTimes(1);
65+
expect(mockSetNodes).toHaveBeenCalledTimes(1);
66+
67+
/* Validates execution order: Snapshot -> Update */
68+
const snapshotOrder = mockTakeSnapshot.mock.invocationCallOrder[0];
69+
const updateOrder = mockSetNodes.mock.invocationCallOrder[0];
70+
expect(snapshotOrder).toBeLessThan(updateOrder);
71+
});
72+
73+
it('translates Dagre center coordinates to React Flow top-left coordinates', () => {
74+
const { result } = renderHook(() => useAutoLayout({
75+
nodes: mockNodes,
76+
edges: mockEdges,
77+
setNodes: mockSetNodes,
78+
takeSnapshot: mockTakeSnapshot
79+
}));
80+
81+
act(() => {
82+
result.current.autoLayout();
83+
});
84+
85+
const appliedNodes = mockSetNodes.mock.calls[0][0];
86+
87+
/* Expected offset mathematics:
88+
* Dagre Center: x: 500, y: 500
89+
* Width (220) / 2 = 110. 500 - 110 = 390
90+
* Height (150) / 2 = 75. 500 - 75 = 425
91+
*/
92+
expect(appliedNodes[0].position.x).toBe(390);
93+
expect(appliedNodes[0].position.y).toBe(425);
94+
expect(appliedNodes[1].position.x).toBe(390);
95+
expect(appliedNodes[1].position.y).toBe(425);
96+
});
97+
98+
it('respects layout direction overrides', () => {
99+
const { result } = renderHook(() => useAutoLayout({
100+
nodes: mockNodes,
101+
edges: mockEdges,
102+
setNodes: mockSetNodes,
103+
takeSnapshot: mockTakeSnapshot
104+
}));
105+
106+
act(() => {
107+
result.current.autoLayout('TB'); // Top-to-Bottom
108+
});
109+
110+
expect(mockSetGraph).toHaveBeenCalledWith(
111+
expect.objectContaining({ rankdir: 'TB' })
112+
);
113+
});
114+
});

0 commit comments

Comments
 (0)