Skip to content

Commit 8622de6

Browse files
committed
Add Node Manager
1 parent c754609 commit 8622de6

File tree

2 files changed

+247
-0
lines changed

2 files changed

+247
-0
lines changed

src/nodeManager.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { beforeEach, describe, expect, it } from "vitest";
2+
3+
import { NodeManager } from "./nodeManager";
4+
5+
describe("NodeManager", () => {
6+
let nodeManager: NodeManager;
7+
8+
beforeEach(() => {
9+
nodeManager = new NodeManager();
10+
});
11+
12+
describe("stable node ID generation", () => {
13+
it("should generate consistent node IDs for the same task", () => {
14+
const nodeId1 = nodeManager.getNodeId("test-task", "task");
15+
const nodeId2 = nodeManager.getNodeId("test-task", "task");
16+
17+
expect(nodeId1).toBe(nodeId2);
18+
expect(nodeId1).toMatch(/^task_/);
19+
});
20+
21+
it("should generate different IDs for different tasks", () => {
22+
const nodeId1 = nodeManager.getNodeId("task-1", "task");
23+
const nodeId2 = nodeManager.getNodeId("task-2", "task");
24+
25+
expect(nodeId1).not.toBe(nodeId2);
26+
});
27+
});
28+
29+
describe("ref ID updates", () => {
30+
it("should update ref ID while preserving node ID", () => {
31+
const originalNodeId = nodeManager.getNodeId("old-task", "task");
32+
nodeManager.updateRefId("old-task", "new-task");
33+
34+
const newNodeId = nodeManager.getNodeId("new-task", "task");
35+
expect(newNodeId).toBe(originalNodeId);
36+
37+
const taskId = nodeManager.getRefId(originalNodeId);
38+
expect(taskId).toBe("new-task");
39+
});
40+
});
41+
});

src/nodeManager.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { nanoid } from "nanoid";
2+
3+
import {
4+
type ComponentSpec,
5+
isGraphImplementation,
6+
} from "./utils/componentSpec";
7+
8+
export type NodeType =
9+
| "task"
10+
| "input"
11+
| "output"
12+
| "inputHandle"
13+
| "outputHandle";
14+
15+
export interface HandleInfo {
16+
parentRefId: string;
17+
handleName: string;
18+
handleType: NodeType;
19+
}
20+
21+
interface NodeMapping {
22+
refId: string;
23+
nodeType: NodeType;
24+
// For InputHandle & OutputHandle:
25+
parentRefId?: string;
26+
handleName?: string;
27+
}
28+
29+
/*
30+
Manages stable ReactFlow Node IDs for Tasks, Inputs and Outputs on the Canvas.
31+
- Each object gets a stable Node ID based on its Reference ID and type.
32+
- Each input/output handle also gets a stable Node ID based on Reference ID and handle name.
33+
- A utility is provided to update Reference IDs to maintain consistency of Node IDs.
34+
- If an object is deleted, its Node ID and all associated handles are removed.
35+
- The NodeManager is automatically kept in sync with all changes in the Component Spec.
36+
*/
37+
38+
export class NodeManager {
39+
private mappings = new Map<string, NodeMapping>();
40+
41+
getNodeId(refId: string, nodeType: NodeType): string {
42+
const existingNodeId = this.findNodeByRefId(refId, nodeType);
43+
if (existingNodeId) return existingNodeId;
44+
45+
const nodeId = `${nodeType}_${nanoid()}`;
46+
this.mappings.set(nodeId, { refId, nodeType });
47+
return nodeId;
48+
}
49+
50+
getHandleNodeId(
51+
parentRefId: string,
52+
handleName: string,
53+
handleType: "inputHandle" | "outputHandle",
54+
): string {
55+
const handleRefId = `${handleType}:${handleName}`;
56+
57+
const existingNodeId = this.findHandleByParentAndId(
58+
parentRefId,
59+
handleRefId,
60+
);
61+
if (existingNodeId) return existingNodeId;
62+
63+
const nodeId = `${handleType}_${nanoid()}`;
64+
this.mappings.set(nodeId, {
65+
refId: handleRefId,
66+
nodeType: handleType,
67+
parentRefId,
68+
handleName,
69+
});
70+
return nodeId;
71+
}
72+
73+
getHandleInfo(nodeId: string): HandleInfo | undefined {
74+
const mapping = this.mappings.get(nodeId);
75+
if (!mapping || !mapping.parentRefId || !mapping.handleName) {
76+
return undefined;
77+
}
78+
return {
79+
parentRefId: mapping.parentRefId,
80+
handleName: mapping.handleName,
81+
handleType: mapping.nodeType,
82+
};
83+
}
84+
85+
getNodeType(nodeId: string): NodeType | undefined {
86+
return this.mappings.get(nodeId)?.nodeType;
87+
}
88+
89+
getRefId(nodeId: string): string | undefined {
90+
return this.mappings.get(nodeId)?.refId;
91+
}
92+
93+
updateRefId(oldRefId: string, newRefId: string, nodeType?: NodeType): void {
94+
for (const [_, mapping] of this.mappings) {
95+
if (
96+
mapping.refId === oldRefId &&
97+
(!nodeType || mapping.nodeType === nodeType) &&
98+
!mapping.parentRefId
99+
) {
100+
mapping.refId = newRefId;
101+
}
102+
103+
if (mapping.parentRefId === oldRefId) {
104+
mapping.parentRefId = newRefId;
105+
}
106+
}
107+
}
108+
109+
// Helpers
110+
private findNodeByRefId(
111+
refId: string,
112+
nodeType: NodeType,
113+
): string | undefined {
114+
for (const [nodeId, mapping] of this.mappings) {
115+
if (
116+
mapping.refId === refId &&
117+
mapping.nodeType === nodeType &&
118+
!mapping.parentRefId
119+
) {
120+
return nodeId;
121+
}
122+
}
123+
return undefined;
124+
}
125+
126+
private findHandleByParentAndId(
127+
parentRefId: string,
128+
handleRefId: string,
129+
): string | undefined {
130+
for (const [nodeId, mapping] of this.mappings) {
131+
if (
132+
mapping.parentRefId === parentRefId &&
133+
mapping.refId === handleRefId
134+
) {
135+
return nodeId;
136+
}
137+
}
138+
return undefined;
139+
}
140+
141+
// Sync with component spec
142+
syncWithComponentSpec(componentSpec: ComponentSpec): void {
143+
const validNodeIds = new Set<string>();
144+
145+
if (isGraphImplementation(componentSpec.implementation)) {
146+
const graphSpec = componentSpec.implementation.graph;
147+
148+
// Tasks
149+
Object.entries(graphSpec.tasks).forEach(([taskId, taskSpec]) => {
150+
const taskNodeId = this.getNodeId(taskId, "task");
151+
validNodeIds.add(taskNodeId);
152+
153+
const inputs = taskSpec.componentRef.spec?.inputs || [];
154+
const outputs = taskSpec.componentRef.spec?.outputs || [];
155+
156+
inputs.forEach((input) => {
157+
const handleNodeId = this.getHandleNodeId(
158+
taskId,
159+
input.name,
160+
"inputHandle",
161+
);
162+
validNodeIds.add(handleNodeId);
163+
});
164+
165+
outputs.forEach((output) => {
166+
const handleNodeId = this.getHandleNodeId(
167+
taskId,
168+
output.name,
169+
"outputHandle",
170+
);
171+
validNodeIds.add(handleNodeId);
172+
});
173+
});
174+
}
175+
176+
// IO nodes
177+
componentSpec.inputs?.forEach((input) => {
178+
const inputNodeId = this.getNodeId(input.name, "input");
179+
const handleNodeId = this.getHandleNodeId(
180+
input.name,
181+
input.name,
182+
"outputHandle",
183+
);
184+
validNodeIds.add(inputNodeId);
185+
validNodeIds.add(handleNodeId);
186+
});
187+
188+
componentSpec.outputs?.forEach((output) => {
189+
const outputNodeId = this.getNodeId(output.name, "output");
190+
const handleNodeId = this.getHandleNodeId(
191+
output.name,
192+
output.name,
193+
"inputHandle",
194+
);
195+
validNodeIds.add(outputNodeId);
196+
validNodeIds.add(handleNodeId);
197+
});
198+
199+
// Remove deleted objects
200+
for (const nodeId of this.mappings.keys()) {
201+
if (!validNodeIds.has(nodeId)) {
202+
this.mappings.delete(nodeId);
203+
}
204+
}
205+
}
206+
}

0 commit comments

Comments
 (0)