From 6beab18283ade670c9c74176b0b7c30c23436eb7 Mon Sep 17 00:00:00 2001 From: Saumya Date: Sat, 26 Jul 2025 01:05:07 +0530 Subject: [PATCH] dsds --- .../conditional-workflow-builder.tsx | 1 + .../src/components/workflows/ui/index.css | 600 +++++++++--------- .../ui/node-types/condition-node.tsx | 41 +- .../workflows/ui/node-types/step-node.tsx | 54 +- .../workflows/ui/utils/conversion.ts | 6 +- .../workflows/ui/utils/validation.ts | 328 ++++++++++ .../workflows/ui/workflow-builder.tsx | 10 +- 7 files changed, 708 insertions(+), 332 deletions(-) create mode 100644 frontend/src/components/workflows/ui/utils/validation.ts diff --git a/frontend/src/components/agents/workflows/conditional-workflow-builder.tsx b/frontend/src/components/agents/workflows/conditional-workflow-builder.tsx index 3e1c7745..2576aa2d 100644 --- a/frontend/src/components/agents/workflows/conditional-workflow-builder.tsx +++ b/frontend/src/components/agents/workflows/conditional-workflow-builder.tsx @@ -31,6 +31,7 @@ export interface ConditionalStep { order: number; enabled?: boolean; hasIssues?: boolean; + position?: { x: number; y: number }; // Added for storing node positions } interface ConditionalWorkflowBuilderProps { diff --git a/frontend/src/components/workflows/ui/index.css b/frontend/src/components/workflows/ui/index.css index f14d7677..797804fa 100644 --- a/frontend/src/components/workflows/ui/index.css +++ b/frontend/src/components/workflows/ui/index.css @@ -1,357 +1,156 @@ -/* React Flow Workflow Builder Styles */ +/* React Flow Workflow Builder - Minimalist Vercel-style Design */ +/* Container and Background */ .react-flow-container { background: hsl(var(--background)); + position: relative; } -.react-flow__handle { - visibility: hidden; -} - +/* Base Node Styles */ .react-flow__node { - width: 240px; + width: 280px; padding: 0; - border-radius: 12px; - border: 1px solid #e5e7eb; - background: #ffffff !important; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); - transition: all 0.2s ease; + border-radius: 8px; + border: 1px solid hsl(var(--border)); + background: hsl(var(--card)) !important; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: grab; +} + +.react-flow__node:active { + cursor: grabbing; } .react-flow__node:hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + transform: translateY(-1px); } +.react-flow__node.selected { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1); +} + +/* Dark mode adjustments */ .dark .react-flow__node { - border: 1px solid #374151; - background: #1f2937 !important; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + border: 1px solid hsl(var(--border)); + background: hsl(var(--card)) !important; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px 0 rgba(0, 0, 0, 0.2); } .dark .react-flow__node:hover { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2); } +/* Step Node Specific */ .react-flow__node-step { - border: 1px solid #e5e7eb; - background: #ffffff !important; + border: 1px solid hsl(var(--border)); + background: hsl(var(--card)) !important; } -.dark .react-flow__node-step { - border: 1px solid #374151; - background: #1f2937 !important; -} - -.react-flow__node-step.selected { - border-color: #3b82f6; - box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); +.react-flow__node-step.has-issues { + border-color: hsl(var(--destructive)); } +/* Condition Node Specific */ .react-flow__node-condition { - width: 140px; - height: 80px; - border: 1px solid #f59e0b; - background: rgba(245, 158, 11, 0.1) !important; - border-radius: 24px; - transition: all 0.2s ease; -} - -.react-flow__node-condition:hover { - background: rgba(245, 158, 11, 0.15) !important; + width: 220px; + border-radius: 12px; + border: 1px dashed hsl(var(--border)); + background: hsl(var(--muted) / 0.3) !important; } .react-flow__node-condition.selected { - border-color: #f59e0b; - box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); + border-style: solid; + border-color: hsl(var(--primary)); + background: hsl(var(--muted) / 0.5) !important; } -.dark .react-flow__node-condition { - border-color: #f59e0b; - background: rgba(245, 158, 11, 0.15) !important; -} - -.dark .react-flow__node-condition:hover { - background: rgba(245, 158, 11, 0.2) !important; -} - -.react-flow__edge-workflow { - stroke: hsl(var(--muted-foreground)); - stroke-width: 2; - transition: stroke 0.2s ease; -} - -.react-flow__edge-workflow:hover { - stroke: hsl(var(--foreground)); - stroke-width: 3; -} - -.react-flow__edge-workflow.selected { - stroke: hsl(var(--primary)); - stroke-width: 3; -} - -/* Arrow marker styling */ -.react-flow__arrowhead { - fill: hsl(var(--muted-foreground)); - transition: fill 0.2s ease; -} - -.react-flow__edge-workflow:hover .react-flow__arrowhead { - fill: hsl(var(--foreground)); -} - -.react-flow__edge-workflow.selected .react-flow__arrowhead { - fill: hsl(var(--primary)); -} - -.edge-button { - pointer-events: all; - cursor: pointer; - position: absolute; - background: #ffffff; - border: 1px solid #e5e7eb; - width: 24px; - height: 24px; - border-radius: 50%; - color: #6b7280; - font-size: 14px; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s ease; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.edge-button:hover { - background: #f3f4f6; - border-color: #3b82f6; - color: #3b82f6; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); - transform: scale(1.1); -} - -.dark .edge-button { - background: #1f2937; - border-color: #374151; - color: #9ca3af; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); -} - -.dark .edge-button:hover { - background: #374151; - border-color: #3b82f6; - color: #3b82f6; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); -} - -/* Node content styles */ -.workflow-node-content { +/* Node Content */ +.node-content { padding: 16px; - width: 100%; - height: 100%; - display: flex; - flex-direction: column; - gap: 8px; } -.workflow-node-header { - display: flex; - align-items: flex-start; - gap: 8px; - margin-bottom: 4px; -} - -.workflow-node-icon { - width: 20px; - height: 20px; - border-radius: 6px; +.node-header { display: flex; align-items: center; - justify-content: center; - background: rgba(59, 130, 246, 0.1); - color: #3b82f6; - flex-shrink: 0; - margin-top: 2px; + justify-content: space-between; + margin-bottom: 8px; } -.workflow-node-title { +.node-title { font-size: 14px; font-weight: 600; - color: #111827; - line-height: 1.2; - flex: 1; - margin: 0; + color: hsl(var(--foreground)); + line-height: 1.4; } -.dark .workflow-node-title { - color: #f9fafb; +.node-description { + font-size: 13px; + color: hsl(var(--muted-foreground)); + line-height: 1.5; + margin-bottom: 12px; } -.workflow-node-description { - font-size: 12px; - color: #6b7280; - line-height: 1.3; - margin: 0; -} - -.dark .workflow-node-description { - color: #9ca3af; -} - -.workflow-node-tool { - display: flex; +.node-tool-badge { + display: inline-flex; align-items: center; gap: 6px; - padding: 4px 8px; - background: #f1f5f9; + padding: 4px 10px; border-radius: 6px; - font-size: 11px; - color: #475569; + background: hsl(var(--muted)); + font-size: 12px; font-weight: 500; - margin-top: 4px; + color: hsl(var(--muted-foreground)); + border: 1px solid hsl(var(--border)); } -.dark .workflow-node-tool { - background: #374151; - color: #d1d5db; +/* Handles */ +.react-flow__handle { + width: 8px; + height: 8px; + background: hsl(var(--primary)); + border: 2px solid hsl(var(--background)); + transition: all 0.2s ease; } -.workflow-node-tool-icon { +.react-flow__handle:hover { width: 12px; height: 12px; - opacity: 0.8; } -.workflow-node-actions { - display: flex; - align-items: center; - gap: 4px; - opacity: 0; - transition: opacity 0.2s ease; +.react-flow__node:hover .react-flow__handle { + visibility: visible; } -.react-flow__node:hover .workflow-node-actions { - opacity: 1; +/* Edges */ +.react-flow__edge-path { + stroke: hsl(var(--border)); + stroke-width: 2; } -.workflow-node-status { - display: flex; - align-items: center; - gap: 4px; - font-size: 11px; - color: #6b7280; - margin-top: 4px; +.react-flow__edge.selected .react-flow__edge-path { + stroke: hsl(var(--primary)); } -.dark .workflow-node-status { - color: #9ca3af; -} - -.workflow-node-status.has-issues { - color: #dc2626; -} - -.workflow-node-status.completed { - color: #16a34a; -} - -.condition-node-content { - padding: 12px; - width: 100%; - height: 100%; +.react-flow__edge-label { font-size: 12px; - font-weight: 600; - color: #d97706; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - gap: 4px; -} - -.condition-node-icon { - width: 16px; - height: 16px; - margin-bottom: 2px; -} - -.condition-node-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.condition-node-expression { - font-size: 9px; - font-weight: 400; - opacity: 0.8; - max-width: 100px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.dark .condition-node-content { - color: #f59e0b; -} - -/* Panel styling */ -.react-flow__panel { - background: hsl(var(--card)); - border: 1px solid hsl(var(--border)); - border-radius: 12px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); - padding: 12px; -} - -.dark .react-flow__panel { - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); -} - -.workflow-panel-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.workflow-panel-selection { - margin-top: 8px; - padding: 8px; - background: hsl(var(--primary) / 0.1); - border-radius: 8px; - font-size: 12px; - color: hsl(var(--primary)); font-weight: 500; } -/* Background styling */ -.react-flow__background { - background: hsl(var(--muted) / 0.3); -} - -.dark .react-flow__background { - background: hsl(var(--muted) / 0.2); -} - -/* Controls styling */ +/* Controls */ .react-flow__controls { - background: hsl(var(--card)); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); border: 1px solid hsl(var(--border)); border-radius: 8px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); -} - -.dark .react-flow__controls { - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + background: hsl(var(--card)); } .react-flow__controls-button { background: hsl(var(--card)); - border: none; - color: hsl(var(--foreground)); - transition: all 0.2s ease; + border-bottom: 1px solid hsl(var(--border)); + color: hsl(var(--muted-foreground)); } .react-flow__controls-button:hover { @@ -359,28 +158,225 @@ color: hsl(var(--accent-foreground)); } +.react-flow__controls-button:last-child { + border-bottom: none; +} + +/* Panel */ +.react-flow__panel { + margin: 16px; +} + +.workflow-panel-actions { + display: flex; + gap: 8px; + background: hsl(var(--background) / 0.8); + backdrop-filter: blur(10px); + padding: 12px; + border-radius: 8px; + border: 1px solid hsl(var(--border)); + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.workflow-panel-selection { + margin-top: 8px; + padding: 8px 12px; + background: hsl(var(--primary) / 0.1); + border: 1px solid hsl(var(--primary) / 0.2); + border-radius: 6px; + font-size: 13px; + color: hsl(var(--primary)); + font-weight: 500; +} + +/* Background */ +.react-flow__background-pattern { + stroke: hsl(var(--border) / 0.3); +} + /* Animations */ -@keyframes pulse-primary { - 0%, 100% { box-shadow: 0 0 0 0 hsl(var(--primary) / 0.4); } - 50% { box-shadow: 0 0 0 8px hsl(var(--primary) / 0); } +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } } -.workflow-node-pulse { - animation: pulse-primary 2s infinite; +.react-flow__node { + animation: fadeIn 0.3s ease-out; } -/* Responsive adjustments */ -@media (max-width: 768px) { - .react-flow__node { - width: 200px; - } - - .react-flow__node-condition { - width: 120px; - height: 70px; - } - - .workflow-node-content { - padding: 12px; - } +/* Node Actions */ +.node-actions { + position: absolute; + top: 8px; + right: 8px; + opacity: 0; + transition: opacity 0.2s ease; +} + +.react-flow__node:hover .node-actions { + opacity: 1; +} + +/* Drag Preview */ +.react-flow__node.dragging { + opacity: 0.5; + cursor: grabbing; +} + +/* Selection Box */ +.react-flow__selection { + background: hsl(var(--primary) / 0.08); + border: 1px solid hsl(var(--primary) / 0.3); +} + +/* Minimap */ +.react-flow__minimap { + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +.react-flow__minimap-mask { + fill: hsl(var(--muted)); +} + +.react-flow__minimap-node { + fill: hsl(var(--muted-foreground)); +} + +/* Condition Node Styles */ +.condition-node-content { + padding: 12px; + text-align: center; +} + +.condition-type-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 8px; +} + +.condition-type-if { + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); +} + +.condition-type-elseif { + background: hsl(var(--warning) / 0.1); + color: hsl(var(--warning)); +} + +.condition-type-else { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + +.condition-expression { + font-size: 12px; + color: hsl(var(--foreground)); + font-style: italic; + word-break: break-word; +} + +/* Empty State */ +.workflow-empty-state { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: hsl(var(--muted-foreground)); +} + +/* Loading State */ +.workflow-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +/* Error State */ +.node-error { + position: absolute; + bottom: -24px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + color: hsl(var(--destructive)); + white-space: nowrap; +} + +/* Tooltips */ +.workflow-tooltip { + position: absolute; + background: hsl(var(--popover)); + border: 1px solid hsl(var(--border)); + border-radius: 6px; + padding: 8px 12px; + font-size: 12px; + color: hsl(var(--popover-foreground)); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + z-index: 1000; + pointer-events: none; +} + +/* Edge Button */ +.edge-button { + pointer-events: all; + cursor: pointer; + position: absolute; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + width: 20px; + height: 20px; + border-radius: 50%; + color: hsl(var(--muted-foreground)); + font-size: 12px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); +} + +.edge-button:hover { + background: hsl(var(--accent)); + border-color: hsl(var(--primary)); + color: hsl(var(--primary)); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transform: scale(1.1); +} + +.edge-button:active { + transform: scale(0.95); +} + +/* Edge styles */ +.react-flow__edge-workflow .react-flow__edge-path { + stroke: hsl(var(--border)); + stroke-width: 2; + transition: stroke 0.2s ease; +} + +.react-flow__edge-workflow:hover .react-flow__edge-path { + stroke: hsl(var(--muted-foreground)); +} + +.react-flow__edge-workflow.selected .react-flow__edge-path { + stroke: hsl(var(--primary)); + stroke-width: 2.5; } \ No newline at end of file diff --git a/frontend/src/components/workflows/ui/node-types/condition-node.tsx b/frontend/src/components/workflows/ui/node-types/condition-node.tsx index e23d6c22..5223d0b3 100644 --- a/frontend/src/components/workflows/ui/node-types/condition-node.tsx +++ b/frontend/src/components/workflows/ui/node-types/condition-node.tsx @@ -17,6 +17,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Trash2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; import { getConditionLabel } from '../utils'; import { useReactFlow } from '@xyflow/react'; @@ -50,20 +51,36 @@ const ConditionNode = ({ id, data, selected }: any) => { return (
- -
-
- - {getConditionLabel(data.conditionType)} + +
+
+ {getConditionLabel(data.conditionType)}
{data.expression && data.conditionType !== 'else' && ( -
- {data.expression} +
+ "{data.expression}"
)} -
+ {!data.expression && data.conditionType !== 'else' && ( +
+ No condition set +
+ )} +
- +
); }; diff --git a/frontend/src/components/workflows/ui/node-types/step-node.tsx b/frontend/src/components/workflows/ui/node-types/step-node.tsx index 0573e7bd..62224e61 100644 --- a/frontend/src/components/workflows/ui/node-types/step-node.tsx +++ b/frontend/src/components/workflows/ui/node-types/step-node.tsx @@ -230,28 +230,30 @@ const StepNode = ({ id, data, selected }: any) => { return (
- -
-
+ +
+
{data.hasIssues && ( - + )} -

- {data.name} {data.stepNumber ? data.stepNumber : ''} +

+ {data.name || 'Unnamed Step'}

-

{data.description}

- {data.tool && ( -
- 🔧 {getDisplayToolName(data.tool)} -
- )}
-
+
+ + {/* Node Description */} + {data.description && ( +

{data.description}

+ )} + + {/* Tool Badge */} + {data.tool && ( +
+ 🔧 + {getDisplayToolName(data.tool)} +
+ )} + + {/* Error indicator */} + {data.hasIssues && ( +
Please configure this step
+ )}
- + {/* Pipedream Registry Dialog */} diff --git a/frontend/src/components/workflows/ui/utils/conversion.ts b/frontend/src/components/workflows/ui/utils/conversion.ts index e60ff45a..b82756a4 100644 --- a/frontend/src/components/workflows/ui/utils/conversion.ts +++ b/frontend/src/components/workflows/ui/utils/conversion.ts @@ -59,7 +59,7 @@ export function convertWorkflowToReactFlow(steps: ConditionalStep[]): Conversion const node: Node = { id: nodeId, type: 'step', - position: { x: xOffset, y: currentY }, + position: step.position || { x: xOffset, y: currentY }, data: { name: step.name, description: step.description, @@ -155,7 +155,7 @@ export function convertWorkflowToReactFlow(steps: ConditionalStep[]): Conversion const conditionNode: Node = { id: conditionNodeId, type: 'condition', - position: { x: branchXPos, y: yOffset + 100 }, + position: condition.position || { x: branchXPos, y: yOffset + 100 }, data: { conditionType: condition.conditions?.type || 'if', expression: condition.conditions?.expression || '', @@ -259,6 +259,7 @@ export function convertReactFlowToWorkflow(nodes: Node[], edges: Edge[]): Condit order: 0, enabled: true, hasIssues: nodeData.hasIssues || false, + position: node.position, children: [] }; @@ -288,6 +289,7 @@ export function convertReactFlowToWorkflow(nodes: Node[], edges: Edge[]): Condit order: 0, enabled: true, hasIssues: false, + position: node.position, children: [] }; diff --git a/frontend/src/components/workflows/ui/utils/validation.ts b/frontend/src/components/workflows/ui/utils/validation.ts new file mode 100644 index 00000000..584162a3 --- /dev/null +++ b/frontend/src/components/workflows/ui/utils/validation.ts @@ -0,0 +1,328 @@ +import { Node, Edge } from '@xyflow/react'; +import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder'; + +// Validation types +export interface ValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} + +// Node validation +export function validateNode(node: Node): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check if node has required data + if (!node.data) { + errors.push('Node is missing data'); + } + + // Step node validation + if (node.type === 'step') { + if (!node.data.name || node.data.name === 'New Step' || node.data.name === 'Unnamed Step') { + errors.push('Step must have a meaningful name'); + } + + if (!node.data.description) { + warnings.push('Step should have a description'); + } + } + + // Condition node validation + if (node.type === 'condition') { + const conditionType = node.data.conditionType; + + if (!conditionType) { + errors.push('Condition must have a type'); + } + + if ((conditionType === 'if' || conditionType === 'elseif') && !node.data.expression) { + errors.push('Condition must have an expression'); + } + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; +} + +// Workflow validation +export function validateWorkflow(nodes: Node[], edges: Edge[]): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check if workflow is empty + if (nodes.length === 0) { + warnings.push('Workflow is empty'); + return { isValid: true, errors, warnings }; + } + + // Check for disconnected nodes + const connectedNodeIds = new Set(); + edges.forEach(edge => { + connectedNodeIds.add(edge.source); + connectedNodeIds.add(edge.target); + }); + + const disconnectedNodes = nodes.filter(node => + !connectedNodeIds.has(node.id) && nodes.length > 1 + ); + + if (disconnectedNodes.length > 0) { + warnings.push(`${disconnectedNodes.length} node(s) are not connected`); + } + + // Check for circular dependencies + if (hasCircularDependency(nodes, edges)) { + errors.push('Workflow contains circular dependencies'); + } + + // Validate individual nodes + nodes.forEach(node => { + const nodeValidation = validateNode(node); + errors.push(...nodeValidation.errors); + warnings.push(...nodeValidation.warnings); + }); + + // Check condition groups + const conditionGroups = findConditionGroups(nodes, edges); + conditionGroups.forEach(group => { + const validation = validateConditionGroup(group); + errors.push(...validation.errors); + warnings.push(...validation.warnings); + }); + + return { + isValid: errors.length === 0, + errors, + warnings + }; +} + +// Helper function to detect circular dependencies +function hasCircularDependency(nodes: Node[], edges: Edge[]): boolean { + const adjacencyList = new Map(); + + // Build adjacency list + nodes.forEach(node => adjacencyList.set(node.id, [])); + edges.forEach(edge => { + const neighbors = adjacencyList.get(edge.source) || []; + neighbors.push(edge.target); + adjacencyList.set(edge.source, neighbors); + }); + + // DFS to detect cycles + const visited = new Set(); + const recursionStack = new Set(); + + function hasCycle(nodeId: string): boolean { + visited.add(nodeId); + recursionStack.add(nodeId); + + const neighbors = adjacencyList.get(nodeId) || []; + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + if (hasCycle(neighbor)) return true; + } else if (recursionStack.has(neighbor)) { + return true; + } + } + + recursionStack.delete(nodeId); + return false; + } + + for (const node of nodes) { + if (!visited.has(node.id)) { + if (hasCycle(node.id)) return true; + } + } + + return false; +} + +// Find condition groups (if/elseif/else chains) +interface ConditionGroup { + nodes: Node[]; + parentId: string | null; +} + +function findConditionGroups(nodes: Node[], edges: Edge[]): ConditionGroup[] { + const groups: ConditionGroup[] = []; + const processedNodes = new Set(); + + nodes.forEach(node => { + if (node.type === 'condition' && !processedNodes.has(node.id)) { + const group: ConditionGroup = { + nodes: [], + parentId: null + }; + + // Find parent node + const parentEdge = edges.find(edge => edge.target === node.id); + if (parentEdge) { + group.parentId = parentEdge.source; + + // Find all conditions with same parent + const siblingConditions = nodes.filter(n => { + if (n.type !== 'condition') return false; + const edge = edges.find(e => e.target === n.id); + return edge && edge.source === parentEdge.source; + }); + + group.nodes = siblingConditions; + siblingConditions.forEach(n => processedNodes.add(n.id)); + } + + if (group.nodes.length > 0) { + groups.push(group); + } + } + }); + + return groups; +} + +// Validate condition group +function validateConditionGroup(group: ConditionGroup): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Check for valid condition types + const types = group.nodes.map(n => n.data.conditionType); + const ifCount = types.filter(t => t === 'if').length; + const elseCount = types.filter(t => t === 'else').length; + + if (ifCount === 0) { + errors.push('Condition group must start with an "if" condition'); + } + + if (ifCount > 1) { + errors.push('Condition group can only have one "if" condition'); + } + + if (elseCount > 1) { + errors.push('Condition group can only have one "else" condition'); + } + + // Check order: if -> elseif* -> else? + let foundElse = false; + for (let i = 0; i < types.length; i++) { + const type = types[i]; + + if (i === 0 && type !== 'if') { + errors.push('Condition group must start with "if"'); + } + + if (type === 'else') { + foundElse = true; + } else if (foundElse) { + errors.push('"else" must be the last condition in a group'); + } + } + + return { isValid: errors.length === 0, errors, warnings }; +} + +// Check if a node can be safely deleted +export function canDeleteNode(nodeId: string, nodes: Node[], edges: Edge[]): { + canDelete: boolean; + reason?: string; +} { + const node = nodes.find(n => n.id === nodeId); + if (!node) { + return { canDelete: false, reason: 'Node not found' }; + } + + // Don't delete if it's the only node + if (nodes.length === 1) { + return { canDelete: false, reason: 'Cannot delete the last node' }; + } + + // Check if deleting this node would create orphaned nodes + const outgoingEdges = edges.filter(e => e.source === nodeId); + const incomingEdges = edges.filter(e => e.target === nodeId); + + // If node has children that would become disconnected + if (outgoingEdges.length > 0 && incomingEdges.length === 0) { + const childNodes = outgoingEdges.map(e => e.target); + const wouldOrphan = childNodes.some(childId => { + const otherIncoming = edges.filter(e => + e.target === childId && e.source !== nodeId + ); + return otherIncoming.length === 0; + }); + + if (wouldOrphan) { + return { + canDelete: false, + reason: 'Deleting this node would create disconnected nodes' + }; + } + } + + return { canDelete: true }; +} + +// Position validation +export function validateNodePosition(position: { x: number; y: number }): boolean { + // Ensure positions are within reasonable bounds + const MAX_POSITION = 10000; + const MIN_POSITION = -10000; + + return ( + position.x >= MIN_POSITION && + position.x <= MAX_POSITION && + position.y >= MIN_POSITION && + position.y <= MAX_POSITION && + !isNaN(position.x) && + !isNaN(position.y) + ); +} + +// Auto-fix common issues +export function autoFixWorkflow(nodes: Node[], edges: Edge[]): { + nodes: Node[]; + edges: Edge[]; + fixes: string[]; +} { + const fixes: string[] = []; + let updatedNodes = [...nodes]; + let updatedEdges = [...edges]; + + // Fix invalid positions + updatedNodes = updatedNodes.map(node => { + if (!validateNodePosition(node.position)) { + fixes.push(`Fixed invalid position for node "${node.data.name || node.id}"`); + return { + ...node, + position: { x: 0, y: 0 } + }; + } + return node; + }); + + // Remove duplicate edges + const edgeSet = new Set(); + const uniqueEdges: Edge[] = []; + + updatedEdges.forEach(edge => { + const edgeKey = `${edge.source}-${edge.target}`; + if (!edgeSet.has(edgeKey)) { + edgeSet.add(edgeKey); + uniqueEdges.push(edge); + } else { + fixes.push('Removed duplicate edge'); + } + }); + updatedEdges = uniqueEdges; + + return { + nodes: updatedNodes, + edges: updatedEdges, + fixes + }; +} \ No newline at end of file diff --git a/frontend/src/components/workflows/ui/workflow-builder.tsx b/frontend/src/components/workflows/ui/workflow-builder.tsx index 864dd5e7..d886c949 100644 --- a/frontend/src/components/workflows/ui/workflow-builder.tsx +++ b/frontend/src/components/workflows/ui/workflow-builder.tsx @@ -26,14 +26,16 @@ import edgeTypes from './edge-types'; import { ConditionalStep } from '@/components/agents/workflows/conditional-workflow-builder'; import { uuid } from './utils'; import { convertWorkflowToReactFlow, convertReactFlowToWorkflow } from './utils/conversion'; +import { validateWorkflow, canDeleteNode, autoFixWorkflow } from './utils/validation'; import { Button } from '@/components/ui/button'; -import { Plus, GitBranch } from 'lucide-react'; +import { Plus, GitBranch, AlertCircle } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Alert, AlertDescription } from '@/components/ui/alert'; import { toast } from 'sonner'; const proOptions: ProOptions = { account: 'paid-pro', hideAttribution: true }; @@ -313,10 +315,12 @@ function WorkflowBuilderInner({ fitViewOptions={{ padding: 0.2 }} minZoom={0.5} maxZoom={1.5} - nodesDraggable={false} - nodesConnectable={false} + nodesDraggable={true} + nodesConnectable={true} zoomOnDoubleClick={false} deleteKeyCode={null} + snapToGrid={true} + snapGrid={[15, 15]} >