Implement node type validation and position collision detection in WorkflowStore

- Added validation for node types to ensure only predefined types can be added to workflows, enhancing data integrity.
- Implemented position collision detection to automatically adjust node positions when overlaps occur, improving user experience.
- Introduced logging for invalid node type attempts and position adjustments, providing better feedback during workflow creation.
- Enhanced tests to cover new validation and collision detection features, ensuring robust functionality.
This commit is contained in:
Nikhil-Doye
2025-10-27 19:24:34 -04:00
parent 6cd8d7c5b6
commit 4a03977968
2 changed files with 272 additions and 2 deletions
+147
View File
@@ -26,6 +26,153 @@ describe("WorkflowStore", () => {
});
});
describe("Node Type Validation", () => {
it("should add node with valid type", () => {
const { result } = renderHook(() => useWorkflowStore());
act(() => {
result.current.createWorkflow("Test Workflow");
result.current.addNode("llmTask", { x: 100, y: 100 });
});
expect(result.current.currentWorkflow?.nodes).toHaveLength(1);
expect(result.current.currentWorkflow?.nodes[0].type).toBe("llmTask");
expect(result.current.currentWorkflow?.nodes[0].data.label).toBe(
"LLM Task"
);
});
it("should reject invalid node type", () => {
const { result } = renderHook(() => useWorkflowStore());
const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
act(() => {
result.current.createWorkflow("Test Workflow");
result.current.addNode("invalidType", { x: 100, y: 100 });
});
expect(result.current.currentWorkflow?.nodes).toHaveLength(0);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Invalid node type: "invalidType"'),
expect.any(String)
);
consoleErrorSpy.mockRestore();
});
it("should accept all valid node types", () => {
const { result } = renderHook(() => useWorkflowStore());
const validTypes = [
"webScraping",
"structuredOutput",
"embeddingGenerator",
"similaritySearch",
"llmTask",
"dataInput",
"dataOutput",
"database",
"slack",
"discord",
"gmail",
];
act(() => {
result.current.createWorkflow("Test Workflow");
validTypes.forEach((type, index) => {
result.current.addNode(type, { x: index * 150, y: 100 });
});
});
expect(result.current.currentWorkflow?.nodes).toHaveLength(
validTypes.length
);
});
});
describe("Position Collision Detection", () => {
it("should place node at requested position if no collision", () => {
const { result } = renderHook(() => useWorkflowStore());
act(() => {
result.current.createWorkflow("Test Workflow");
result.current.addNode("llmTask", { x: 100, y: 100 });
});
expect(result.current.currentWorkflow?.nodes[0].position).toEqual({
x: 100,
y: 100,
});
});
it("should resolve position collision automatically", () => {
const { result } = renderHook(() => useWorkflowStore());
const consoleLogSpy = jest.spyOn(console, "log").mockImplementation();
act(() => {
result.current.createWorkflow("Test Workflow");
// Add first node
result.current.addNode("llmTask", { x: 100, y: 100 });
// Try to add second node at same position
result.current.addNode("dataInput", { x: 100, y: 100 });
});
expect(result.current.currentWorkflow?.nodes).toHaveLength(2);
const node1Pos = result.current.currentWorkflow?.nodes[0].position;
const node2Pos = result.current.currentWorkflow?.nodes[1].position;
// Positions should be different
expect(node1Pos).not.toEqual(node2Pos);
// Should have logged collision detection
expect(consoleLogSpy).toHaveBeenCalledWith(
expect.stringContaining("Position collision detected")
);
consoleLogSpy.mockRestore();
});
it("should handle multiple collisions", () => {
const { result } = renderHook(() => useWorkflowStore());
act(() => {
result.current.createWorkflow("Test Workflow");
// Add multiple nodes at similar positions
result.current.addNode("llmTask", { x: 100, y: 100 });
result.current.addNode("dataInput", { x: 105, y: 105 });
result.current.addNode("dataOutput", { x: 110, y: 110 });
});
expect(result.current.currentWorkflow?.nodes).toHaveLength(3);
// All nodes should have unique positions
const positions = result.current.currentWorkflow?.nodes.map(
(n) => `${n.position.x},${n.position.y}`
);
const uniquePositions = new Set(positions);
expect(uniquePositions.size).toBe(3);
});
it("should allow nodes far apart without collision", () => {
const { result } = renderHook(() => useWorkflowStore());
const consoleLogSpy = jest.spyOn(console, "log").mockImplementation();
act(() => {
result.current.createWorkflow("Test Workflow");
result.current.addNode("llmTask", { x: 100, y: 100 });
result.current.addNode("dataInput", { x: 300, y: 100 }); // 200px apart
});
// No collision should be logged for nodes far apart
expect(consoleLogSpy).not.toHaveBeenCalledWith(
expect.stringContaining("Position collision detected")
);
consoleLogSpy.mockRestore();
});
});
describe("Workflow Management", () => {
it("should create a new workflow", () => {
const { result } = renderHook(() => useWorkflowStore());
+125 -2
View File
@@ -26,6 +26,104 @@ const WORKFLOWS_STORAGE_KEY = "agent-workflow-builder-workflows";
const generateNodeId = (): string => uuidv4();
const generateEdgeId = (): string => uuidv4();
// Valid node types registry
const VALID_NODE_TYPES = new Set<string>([
"webScraping",
"structuredOutput",
"embeddingGenerator",
"similaritySearch",
"llmTask",
"dataInput",
"dataOutput",
"database",
"slack",
"discord",
"gmail",
]);
// Validate node type
const isValidNodeType = (type: string): boolean => {
return VALID_NODE_TYPES.has(type);
};
// Position collision detection
const COLLISION_THRESHOLD = 80; // pixels - if nodes are within this distance, consider collision
const POSITION_OFFSET = 30; // pixels - offset for collision resolution
const hasPositionCollision = (
position: { x: number; y: number },
existingNodes: WorkflowNode[]
): boolean => {
return existingNodes.some((node) => {
const dx = Math.abs(node.position.x - position.x);
const dy = Math.abs(node.position.y - position.y);
return dx < COLLISION_THRESHOLD && dy < COLLISION_THRESHOLD;
});
};
const resolvePositionCollision = (
position: { x: number; y: number },
existingNodes: WorkflowNode[]
): { x: number; y: number } => {
let resolvedPosition = { ...position };
let attempts = 0;
const maxAttempts = 10;
// Try to find a non-colliding position
while (
hasPositionCollision(resolvedPosition, existingNodes) &&
attempts < maxAttempts
) {
// Try positions in a spiral pattern: right, down, left, up
const offset = POSITION_OFFSET * (attempts + 1);
switch (attempts % 4) {
case 0: // Right
resolvedPosition = { x: position.x + offset, y: position.y };
break;
case 1: // Down
resolvedPosition = { x: position.x, y: position.y + offset };
break;
case 2: // Left
resolvedPosition = { x: position.x - offset, y: position.y };
break;
case 3: // Up
resolvedPosition = { x: position.x, y: position.y - offset };
break;
}
attempts++;
}
// If still colliding after max attempts, use a random offset
if (hasPositionCollision(resolvedPosition, existingNodes)) {
resolvedPosition = {
x: position.x + Math.random() * 100 - 50,
y: position.y + Math.random() * 100 - 50,
};
}
return resolvedPosition;
};
// Get default label for node type
const getDefaultNodeLabel = (type: string): string => {
const labels: Record<string, string> = {
webScraping: "Web Scraping",
structuredOutput: "Structured Output",
embeddingGenerator: "Embedding Generator",
similaritySearch: "Similarity Search",
llmTask: "LLM Task",
dataInput: "Data Input",
dataOutput: "Data Output",
database: "Database",
slack: "Slack",
discord: "Discord",
gmail: "Gmail",
};
return labels[type] || `${type} Node`;
};
// Create a mapping from deterministic IDs to UUIDs (used for new generation flows)
const createIdMapping = (nodeCount: number): Map<string, string> => {
const mapping = new Map<string, string>();
@@ -505,15 +603,30 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
const { currentWorkflow } = get();
if (!currentWorkflow) return;
// Validate node type
if (!isValidNodeType(type)) {
console.error(
`[WorkflowStore] Invalid node type: "${type}". Valid types:`,
Array.from(VALID_NODE_TYPES).join(", ")
);
return;
}
// Resolve position collision
const resolvedPosition = resolvePositionCollision(
position,
currentWorkflow.nodes
);
const nodeId = generateNodeId();
const newNode: WorkflowNode = {
id: nodeId,
type,
position,
position: resolvedPosition,
data: {
id: nodeId,
type: type as any,
label: `${type} Node`,
label: getDefaultNodeLabel(type),
status: "idle",
config: {},
inputs: [],
@@ -521,6 +634,16 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
},
};
// Log if position was adjusted
if (
resolvedPosition.x !== position.x ||
resolvedPosition.y !== position.y
) {
console.log(
`[WorkflowStore] Position collision detected. Adjusted from (${position.x}, ${position.y}) to (${resolvedPosition.x}, ${resolvedPosition.y})`
);
}
set((state) => ({
currentWorkflow: state.currentWorkflow
? {