diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts index bd6cdef1586..4a97ec14988 100644 --- a/apps/sim/app/api/workflows/sync/route.ts +++ b/apps/sim/app/api/workflows/sync/route.ts @@ -21,7 +21,7 @@ const MarketplaceDataSchema = z const WorkflowStateSchema = z.object({ blocks: z.record(z.any()), edges: z.array(z.any()), - loops: z.record(z.any()), + loops: z.record(z.any()).default({}), lastSaved: z.number().optional(), isDeployed: z.boolean().optional(), deployedAt: z diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index 15289fc8864..4388158b5a3 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -8,7 +8,11 @@ } .workflow-container .react-flow__node { - z-index: 20 !important; + z-index: 21 !important; +} + +.workflow-container .react-flow__node-loopNode { + z-index: -1 !important; } .workflow-container .react-flow__handle { @@ -335,4 +339,4 @@ input[type='search']::-ms-clear { .main-content-overlay { z-index: 40; /* Higher z-index to appear above content */ -} +} \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx index 340445940b9..7460500a3d7 100644 --- a/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx +++ b/apps/sim/app/w/[id]/components/control-bar/components/deployment-controls/components/deployed-workflow-modal.tsx @@ -52,9 +52,10 @@ export function DeployedWorkflowModal({ })) const handleRevert = () => { - revertToDeployedState(deployedWorkflowState) - setShowRevertDialog(false) - onClose() + // Revert to the deployed state + revertToDeployedState(deployedWorkflowState); + setShowRevertDialog(false); + onClose(); } return ( diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx new file mode 100644 index 00000000000..6fad3f4655d --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx @@ -0,0 +1,236 @@ +import { useCallback, useEffect, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import Editor from 'react-simple-code-editor' +import { highlight, languages } from 'prismjs' +import 'prismjs/components/prism-javascript' +import 'prismjs/themes/prism.css' + + +interface LoopNodeData { + width?: number; + height?: number; + parentId?: string; + state?: string; + type?: string; + extent?: 'parent'; + loopType?: 'for' | 'forEach'; + count?: number; + collection?: string | any[] | Record; + executionState?: { + currentIteration: number; + isExecuting: boolean; + startTime: number | null; + endTime: number | null; + }; +} + +interface LoopBadgesProps { + nodeId: string + data: LoopNodeData +} + +export function LoopBadges({ nodeId, data }: LoopBadgesProps) { + // State + const [loopType, setLoopType] = useState(data?.loopType || 'for') + const [iterations, setIterations] = useState(data?.count || 5) + const [inputValue, setInputValue] = useState((data?.count || 5).toString()) + const [editorValue, setEditorValue] = useState('') + const [typePopoverOpen, setTypePopoverOpen] = useState(false) + const [configPopoverOpen, setConfigPopoverOpen] = useState(false) + + // Get store methods + const updateNodeData = useCallback((updates: Partial) => { + useWorkflowStore.setState(state => ({ + blocks: { + ...state.blocks, + [nodeId]: { + ...state.blocks[nodeId], + data: { + ...state.blocks[nodeId].data, + ...updates + } + } + } + })) + }, [nodeId]) + + // Initialize editor value from data when it changes + useEffect(() => { + if (data?.loopType && data.loopType !== loopType) { + setLoopType(data.loopType) + } + if (data?.count && data.count !== iterations) { + setIterations(data.count) + setInputValue(data.count.toString()) + } + + if (loopType === 'forEach' && data?.collection) { + if (typeof data.collection === 'string') { + setEditorValue(data.collection) + } else if (Array.isArray(data.collection) || typeof data.collection === 'object') { + setEditorValue(JSON.stringify(data.collection)) + } + } + }, [data?.loopType, data?.count, data?.collection, loopType, iterations]) + + // Handle loop type change + const handleLoopTypeChange = useCallback((newType: 'for' | 'forEach') => { + setLoopType(newType) + updateNodeData({ loopType: newType }) + setTypePopoverOpen(false) + }, [updateNodeData]) + + // Handle iterations input change + const handleIterationsChange = useCallback((e: React.ChangeEvent) => { + const sanitizedValue = e.target.value.replace(/[^0-9]/g, '') + const numValue = parseInt(sanitizedValue) + + if (!isNaN(numValue)) { + setInputValue(Math.min(100, numValue).toString()) + } else { + setInputValue(sanitizedValue) + } + }, []) + + // Handle iterations save + const handleIterationsSave = useCallback(() => { + const value = parseInt(inputValue) + + if (!isNaN(value)) { + const newValue = Math.min(100, Math.max(1, value)) + setIterations(newValue) + updateNodeData({ count: newValue }) + setInputValue(newValue.toString()) + } else { + setInputValue(iterations.toString()) + } + setConfigPopoverOpen(false) + }, [inputValue, iterations, updateNodeData]) + + // Handle editor change + const handleEditorChange = useCallback((value: string) => { + setEditorValue(value) + updateNodeData({ collection: value }) + }, [updateNodeData]) + + return ( +
+ {/* Loop Type Badge */} + + e.stopPropagation()}> + + {loopType === 'for' ? 'For Loop' : 'For Each'} + + + + e.stopPropagation()}> +
+
Loop Type
+
+
handleLoopTypeChange('for')} + > + For Loop +
+
handleLoopTypeChange('forEach')} + > + For Each +
+
+
+
+
+ + {/* Iterations/Collection Badge */} + + e.stopPropagation()}> + + {loopType === 'for' ? `Iterations: ${iterations}` : 'Items'} + + + + e.stopPropagation()} + > +
+
+ {loopType === 'for' ? 'Loop Iterations' : 'Collection Items'} +
+ + {loopType === 'for' ? ( + // Number input for 'for' loops +
+ e.key === 'Enter' && handleIterationsSave()} + className="h-8 text-sm" + autoFocus + /> +
+ ) : ( + // Code editor for 'forEach' loops +
+ {editorValue === '' && ( +
+ ['item1', 'item2', 'item3'] +
+ )} + highlight(code, languages.javascript, 'javascript')} + padding={0} + style={{ + fontFamily: 'monospace', + lineHeight: '21px', + }} + className="focus:outline-none w-full" + textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap" + /> +
+ )} + +
+ {loopType === 'for' + ? 'Enter a number between 1 and 100' + : 'Array or object to iterate over'} +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-config.ts b/apps/sim/app/w/[id]/components/loop-node/loop-config.ts new file mode 100644 index 00000000000..90dbb8460af --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/loop-config.ts @@ -0,0 +1,31 @@ +import { RepeatIcon } from 'lucide-react' + +export const LoopTool = { + id: 'loop', + type: 'loop', + name: 'Loop', + description: 'Create a Loop', + icon: RepeatIcon, + bgColor: '#2FB3FF', + data: { + label: 'Loop', + loopType: 'for', + count: 5, + collection: '', + width: 500, + height: 300, + extent: 'parent', + executionState: { + currentIteration: 0, + isExecuting: false, + startTime: null, + endTime: null, + } + }, + style: { + width: 500, + height: 300, + }, + // Specify that this should be rendered as a ReactFlow group node + isResizable: true, +} \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx new file mode 100644 index 00000000000..e827c58106e --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -0,0 +1,244 @@ +import { memo, useMemo, useRef } from 'react' +import { Handle, NodeProps, Position, useReactFlow } from 'reactflow' +import { Trash2 } from 'lucide-react' +import { StartIcon } from '@/components/icons' +import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { LoopBadges } from './components/loop-badges' +import React from 'react' + +// Add these styles to your existing global CSS file or create a separate CSS module +const LoopNodeStyles: React.FC = () => { + return ( + + ) +} + +export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { + const { getNodes } = useReactFlow(); + const blockRef = useRef(null); + + // Determine nesting level by counting parents + const nestingLevel = useMemo(() => { + let level = 0; + let currentParentId = data?.parentId; + + while (currentParentId) { + level++; + const parentNode = getNodes().find(n => n.id === currentParentId); + if (!parentNode) break; + currentParentId = parentNode.data?.parentId; + } + + return level; + }, [id, data?.parentId, getNodes]); + + // Generate different background styles based on nesting level + const getNestedStyles = () => { + // Base styles + const styles: Record = { + backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent', + }; + + // Apply nested styles + if (nestingLevel > 0) { + // Each nesting level gets a different color + const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']; + const colorIndex = (nestingLevel - 1) % colors.length; + + styles.backgroundColor = `${colors[colorIndex]}30`; // Slightly more visible background + } + + return styles; + }; + + const nestedStyles = getNestedStyles(); + + return ( + <> + +
+ 0 && `border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}` + )} + style={{ + width: data.width || 500, + height: data.height || 300, + position: 'relative', + overflow: 'visible', + ...nestedStyles, + pointerEvents: 'all', + }} + data-node-id={id} + data-type="loopNode" + data-nesting-level={nestingLevel} + > + {/* Critical drag handle that controls only the loop node movement */} +
+ + {/* Custom visible resize handle */} +
+
+ + {/* Child nodes container - Set pointerEvents: none to allow events to reach edges */} +
+ {/* Delete button - styled like in action-bar.tsx */} + + + {/* Loop Start Block */} +
+ + + +
+
+ + {/* Input handle on left middle */} + + + {/* Output handle on right middle */} + + + {/* Loop Configuration Badges */} + + +
+ + ) +}) + +LoopNodeComponent.displayName = 'LoopNodeComponent' \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx index 4ed3c030d0d..77545155fc6 100644 --- a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-block/toolbar-block.tsx @@ -12,7 +12,7 @@ export function ToolbarBlock({ config }: ToolbarBlockProps) { } // Handle click to add block - const handleClick = useCallback(() => { + const handleClick = useCallback((e: React.MouseEvent) => { if (config.type === 'connectionBlock') return // Dispatch a custom event to be caught by the workflow component diff --git a/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx new file mode 100644 index 00000000000..c572a17fe9e --- /dev/null +++ b/apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx @@ -0,0 +1,47 @@ +import { LoopTool } from "../../../loop-node/loop-config" +import { useCallback } from 'react' + +// Custom component for the Loop Tool +export default function LoopToolbarItem () { + const handleDragStart = (e: React.DragEvent) => { + // Only send the essential data for the loop node + const simplifiedData = { + type: 'loop' + } + e.dataTransfer.setData('application/json', JSON.stringify(simplifiedData)) + e.dataTransfer.effectAllowed = 'move' + } + + // Handle click to add loop block + const handleClick = useCallback((e: React.MouseEvent) => { + // Dispatch a custom event to be caught by the workflow component + const event = new CustomEvent('add-block-from-toolbar', { + detail: { + type: 'loop', + clientX: e.clientX, + clientY: e.clientY + }, + }) + window.dispatchEvent(event) + }, []) + + return ( +
+
+ +
+
+

{LoopTool.name}

+

{LoopTool.description}

+
+
+ ) + } \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx index 703675353e9..b69fc529a1c 100644 --- a/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx +++ b/apps/sim/app/w/[id]/components/toolbar/toolbar.tsx @@ -1,7 +1,7 @@ 'use client' import { useMemo, useState } from 'react' -import { PanelLeftClose, PanelRight, PanelRightClose, Search } from 'lucide-react' +import { PanelLeftClose, PanelRight, Search } from 'lucide-react' import { Input } from '@/components/ui/input' import { ScrollArea } from '@/components/ui/scroll-area' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -10,6 +10,7 @@ import { getAllBlocks, getBlocksByCategory } from '@/blocks' import { BlockCategory } from '@/blocks/types' import { ToolbarBlock } from './components/toolbar-block/toolbar-block' import { ToolbarTabs } from './components/toolbar-tabs/toolbar-tabs' +import LoopToolbarItem from './components/toolbar-loop-block/toolbar-loop-block' export function Toolbar() { const [activeTab, setActiveTab] = useState('blocks') @@ -87,6 +88,7 @@ export function Toolbar() { {blocks.map((block) => ( ))} + {activeTab === 'blocks' && !searchQuery && }
diff --git a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx b/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx index 5c6ce446ca4..5d9527dba8c 100644 --- a/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx +++ b/apps/sim/app/w/[id]/components/workflow-edge/workflow-edge.tsx @@ -1,5 +1,8 @@ import { X } from 'lucide-react' import { BaseEdge, EdgeLabelRenderer, EdgeProps, getSmoothStepPath } from 'reactflow' +import { createLogger } from '@/lib/logs/console-logger' + +const logger = createLogger('WorkflowEdge') export const WorkflowEdge = ({ id, @@ -10,6 +13,7 @@ export const WorkflowEdge = ({ sourcePosition, targetPosition, data, + style, }: EdgeProps) => { const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left' @@ -24,20 +28,31 @@ export const WorkflowEdge = ({ offset: isHorizontal ? 30 : 20, }) - const isSelected = id === data?.selectedEdgeId + // Use the directly provided isSelected flag instead of computing it + const isSelected = data?.isSelected ?? false; + const isInsideLoop = data?.isInsideLoop ?? false; + const parentLoopId = data?.parentLoopId; + + + // Merge any style props passed from parent + const edgeStyle = { + strokeWidth: isSelected ? 2.5 : 2, + stroke: isSelected ? '#475569' : '#94a3b8', + strokeDasharray: '5,5', + ...style + }; return ( <>
{ - e.preventDefault() - e.stopPropagation() + e.preventDefault(); + e.stopPropagation(); + if (data?.onDelete) { - data.onDelete(id) + // Pass this specific edge's ID to the delete function + data.onDelete(id); } }} > @@ -70,4 +87,4 @@ export const WorkflowEdge = ({ )} ) -} +} \ No newline at end of file diff --git a/apps/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx b/apps/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx deleted file mode 100644 index e7d09595990..00000000000 --- a/apps/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { useEffect, useRef, useState } from 'react' -import { ChevronDown } from 'lucide-react' -import { highlight, languages } from 'prismjs' -import 'prismjs/components/prism-javascript' -import 'prismjs/themes/prism.css' -import Editor from 'react-simple-code-editor' -import { NodeProps } from 'reactflow' -import { Badge } from '@/components/ui/badge' -import { Input } from '@/components/ui/input' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -export function LoopInput({ id }: NodeProps) { - // Extract the loop ID from the node ID - const loopId = id.replace('loop-input-', '') - - // Get the loop data from the store - const loop = useWorkflowStore((state) => state.loops[loopId]) - const iterations = loop?.iterations ?? 5 - const loopType = loop?.loopType || 'for' - const updateLoopIterations = useWorkflowStore((state) => state.updateLoopIterations) - const updateLoopForEachItems = useWorkflowStore((state) => state.updateLoopForEachItems) - - // Local state for input values - const [inputValue, setInputValue] = useState(iterations.toString()) - const [editorValue, setEditorValue] = useState('') - const [open, setOpen] = useState(false) - const editorRef = useRef(null) - - // Initialize editor value from the store - useEffect(() => { - if (loopType === 'forEach' && loop?.forEachItems) { - // Handle different types of forEachItems - if (typeof loop.forEachItems === 'string') { - // Preserve the string exactly as stored - setEditorValue(loop.forEachItems) - } else if (Array.isArray(loop.forEachItems) || typeof loop.forEachItems === 'object') { - // For new objects/arrays from the store, use default formatting - // This only happens for data loaded from DB that wasn't originally user-formatted - setEditorValue(JSON.stringify(loop.forEachItems)) - } - } else if (loopType === 'forEach') { - setEditorValue('') - } - }, [loopType, loop?.forEachItems]) - - const handleChange = (e: React.ChangeEvent) => { - const sanitizedValue = e.target.value.replace(/[^0-9]/g, '') - const numValue = parseInt(sanitizedValue) - - // Only update if it's a valid number and <= 50 - if (!isNaN(numValue)) { - setInputValue(Math.min(50, numValue).toString()) - } else { - setInputValue(sanitizedValue) - } - } - - const handleSave = () => { - const value = parseInt(inputValue) - - if (!isNaN(value)) { - const newValue = Math.min(50, Math.max(1, value)) - updateLoopIterations(loopId, newValue) - // Sync input with store value - setInputValue(newValue.toString()) - } else { - // Reset to current store value if invalid - setInputValue(iterations.toString()) - } - } - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault() - handleSave() - setOpen(false) - } - } - - const handleEditorChange = (value: string) => { - // Always set the editor value to exactly what the user typed - setEditorValue(value) - - // Save the items to the store for forEach loops - if (loopType === 'forEach') { - // Pass the exact string to preserve formatting - updateLoopForEachItems(loopId, value) - } - } - - // Determine label based on loop type - const getLabel = () => { - switch (loopType) { - case 'for': - return `Iterations: ${iterations}` - case 'forEach': - return 'Items' - default: - return `Iterations: ${iterations}` - } - } - - const getPlaceholder = () => { - switch (loopType) { - case 'forEach': - return "['item1', 'item2', 'item3']" - default: - return '' - } - } - - return ( - - e.stopPropagation()}> - - {getLabel()} - - - - e.stopPropagation()} - > -
-
-
- {loopType === 'for' ? 'Loop Iterations' : 'Collection Items'} -
-
- - {loopType === 'for' ? ( - // Number input for 'for' loops -
- -
- ) : ( - // Code editor for 'forEach' loops -
- {editorValue === '' && ( -
- {getPlaceholder()} -
- )} - highlight(code, languages.javascript, 'javascript')} - padding={0} - style={{ - fontFamily: 'monospace', - lineHeight: '21px', - }} - className="focus:outline-none w-full" - textareaClassName="focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap" - /> -
- )} - -
- {loopType === 'for' - ? 'Enter a number between 1 and 50' - : 'Array or object to iterate over'} -
-
-
-
- ) -} diff --git a/apps/sim/app/w/[id]/components/workflow-loop/components/loop-label/loop-label.tsx b/apps/sim/app/w/[id]/components/workflow-loop/components/loop-label/loop-label.tsx deleted file mode 100644 index c6a1b76bcb4..00000000000 --- a/apps/sim/app/w/[id]/components/workflow-loop/components/loop-label/loop-label.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useState } from 'react' -import { ChevronDown } from 'lucide-react' -import { NodeProps } from 'reactflow' -import { Badge } from '@/components/ui/badge' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { cn } from '@/lib/utils' -import { useWorkflowStore } from '@/stores/workflows/workflow/store' - -export function LoopLabel({ id, data }: NodeProps) { - // Extract the loop ID from the node ID - const loopId = id.replace('loop-label-', '') - - // Get the loop type from the store - const loop = useWorkflowStore((state) => state.loops[loopId]) - const updateLoopType = useWorkflowStore((state) => state.updateLoopType) - - // Local state for popover - const [open, setOpen] = useState(false) - - // Default to 'for' if not set - const loopType = loop?.loopType || 'for' - - // Get label based on loop type - const getLoopLabel = () => { - switch (loopType) { - case 'for': - return 'For loop' - case 'forEach': - return 'For each' - default: - return 'Loop' - } - } - - const handleLoopTypeChange = (type: 'for' | 'forEach') => { - updateLoopType(loopId, type) - setOpen(false) - } - - return ( - - e.stopPropagation()}> - - {getLoopLabel()} - - - - e.stopPropagation()}> -
-
handleLoopTypeChange('for')} - > - For loop -
-
handleLoopTypeChange('forEach')} - > - For each -
-
-
-
- ) -} diff --git a/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx b/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx deleted file mode 100644 index 9949f37f784..00000000000 --- a/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { Loop } from '@/stores/workflows/workflow/types' - -interface WorkflowLoopProps { - loopId: string - loop: Loop - blocks: Record -} - -// Helper function to create loop label node -function createLoopLabelNode(loopId: string, bounds: { x: number; y: number }) { - return { - id: `loop-label-${loopId}`, - type: 'loopLabel', - position: { x: 0, y: -32 }, - parentNode: `loop-${loopId}`, - draggable: false, - data: { - loopId, - label: 'Loop', - }, - } -} - -// Helper function to create loop input node -function createLoopInputNode(loopId: string, bounds: { x: number; width: number }) { - const loop = useWorkflowStore.getState().loops[loopId] - const loopType = loop?.loopType || 'for' - - // Dynamic width based on loop type - let BADGE_WIDTH = 116 // Default for 'for' loop - - if (loopType === 'forEach') { - BADGE_WIDTH = 72 // Adjusted for 'Items' text - } - - return { - id: `loop-input-${loopId}`, - type: 'loopInput', - position: { x: bounds.width - BADGE_WIDTH, y: -32 }, // Position from right edge - parentNode: `loop-${loopId}`, - draggable: false, - data: { - loopId, - }, - } -} - -function calculateLoopBounds(loop: Loop, blocks: Record) { - // Get all blocks in this loop and filter out any undefined blocks - const loopBlocks = loop.nodes - .map((id) => blocks[id]) - .filter( - (block): block is NonNullable => - block !== undefined && block.position !== undefined - ) - - if (!loopBlocks.length) return null - - // Calculate bounds of all blocks in loop - const bound = loopBlocks.reduce( - (acc, block) => { - // Calculate block dimensions - const blockWidth = block.isWide ? 480 : 320 - const blockHeight = block.height || 200 // Fallback height if not set - - // Update bounds - acc.minX = Math.min(acc.minX, block.position.x) - acc.minY = Math.min(acc.minY, block.position.y) - acc.maxX = Math.max(acc.maxX, block.position.x + blockWidth) - acc.maxY = Math.max(acc.maxY, block.position.y + blockHeight) - return acc - }, - { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity } - ) - - // Add padding around the group with extra bottom padding - const PADDING = { - TOP: 50, - RIGHT: 50, - BOTTOM: 110, - LEFT: 50, - } - - return { - x: bound.minX - PADDING.LEFT, - y: bound.minY - PADDING.TOP, - width: bound.maxX - bound.minX + PADDING.LEFT + PADDING.RIGHT, - height: bound.maxY - bound.minY + PADDING.TOP + PADDING.BOTTOM, - } -} - -// Update the createLoopNode function -export function createLoopNode({ loopId, loop, blocks }: WorkflowLoopProps) { - const loopBounds = calculateLoopBounds(loop, blocks) - if (!loopBounds) return null - - const loopNode = { - id: `loop-${loopId}`, - type: 'group', - position: { x: loopBounds.x, y: loopBounds.y }, - className: 'bg-[rgb(247,247,248)] dark:bg-[rgb(36,37,45)] dark:bg-opacity-50', - style: { - border: '1px solid rgb(203, 213, 225)', - borderRadius: '12px', - width: loopBounds.width, - height: loopBounds.height, - pointerEvents: 'none', - zIndex: -1, - isolation: 'isolate', - }, - darkModeStyle: { - borderColor: 'rgb(63, 63, 70)', - }, - data: { - label: 'Loop', - }, - } - - // Create both label and input nodes - const labelNode = createLoopLabelNode(loopId, loopBounds) - const inputNode = createLoopInputNode(loopId, loopBounds) - - // Return all three nodes - return [loopNode, labelNode, inputNode] -} - -// Helper function to calculate relative position for child blocks -export function getRelativeLoopPosition( - blockPosition: { x: number; y: number }, - loopBounds: { x: number; y: number } -) { - return { - x: blockPosition.x - loopBounds.x, - y: blockPosition.y - loopBounds.y, - } -} diff --git a/apps/sim/app/w/[id]/utils.ts b/apps/sim/app/w/[id]/utils.ts new file mode 100644 index 00000000000..a0e83363a3d --- /dev/null +++ b/apps/sim/app/w/[id]/utils.ts @@ -0,0 +1,337 @@ +import { createLogger } from '@/lib/logs/console-logger'; + +const logger = createLogger('WorkflowUtils'); + +/** + * Utility functions for handling node hierarchies and loop operations in the workflow + */ + +/** + * Calculates the depth of a node in the hierarchy tree + * @param nodeId ID of the node to check + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Depth level (0 for root nodes, increasing for nested nodes) + */ +export const getNodeDepth = ( + nodeId: string, + getNodes: () => any[] +): number => { + const node = getNodes().find(n => n.id === nodeId); + if (!node || !node.parentId) return 0; + return 1 + getNodeDepth(node.parentId, getNodes); +}; + +/** + * Gets the full hierarchy path of a node (its parent chain) + * @param nodeId ID of the node to check + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Array of node IDs representing the hierarchy path + */ +export const getNodeHierarchy = ( + nodeId: string, + getNodes: () => any[] +): string[] => { + const node = getNodes().find(n => n.id === nodeId); + if (!node || !node.parentId) return [nodeId]; + return [...getNodeHierarchy(node.parentId, getNodes), nodeId]; +}; + +/** + * Gets the absolute position of a node (accounting for nested parents) + * @param nodeId ID of the node to check + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Absolute position coordinates {x, y} + */ +export const getNodeAbsolutePosition = ( + nodeId: string, + getNodes: () => any[] +): { x: number, y: number } => { + const node = getNodes().find(n => n.id === nodeId); + if (!node) { + // Handle case where node doesn't exist anymore by returning origin position + // This helps prevent errors during cleanup operations + logger.warn('Attempted to get position of non-existent node', { nodeId }); + return { x: 0, y: 0 }; + } + + if (!node.parentId) { + return node.position; + } + + // Check if parent exists + const parentNode = getNodes().find(n => n.id === node.parentId); + if (!parentNode) { + // Parent reference is invalid, return node's current position + logger.warn('Node references non-existent parent', { + nodeId, + invalidParentId: node.parentId + }); + return node.position; + } + + // Check for circular reference to prevent infinite recursion + const visited = new Set(); + let current: any = node; + while (current && current.parentId) { + if (visited.has(current.parentId)) { + // Circular reference detected + logger.error('Circular parent reference detected', { + nodeId, + parentChain: Array.from(visited) + }); + return node.position; + } + visited.add(current.id); + current = getNodes().find(n => n.id === current.parentId); + } + + // Get parent's absolute position + const parentPos = getNodeAbsolutePosition(node.parentId, getNodes); + + // Calculate this node's absolute position + return { + x: parentPos.x + node.position.x, + y: parentPos.y + node.position.y + }; +}; + +/** + * Calculates the relative position of a node to a new parent + * @param nodeId ID of the node being repositioned + * @param newParentId ID of the new parent + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Relative position coordinates {x, y} + */ +export const calculateRelativePosition = ( + nodeId: string, + newParentId: string, + getNodes: () => any[] +): { x: number, y: number } => { + // Get absolute position of the node + const nodeAbsPos = getNodeAbsolutePosition(nodeId, getNodes); + + // Get absolute position of the new parent + const parentAbsPos = getNodeAbsolutePosition(newParentId, getNodes); + + // Calculate relative position + return { + x: nodeAbsPos.x - parentAbsPos.x, + y: nodeAbsPos.y - parentAbsPos.y + }; +}; + +/** + * Updates a node's parent with proper position calculation + * @param nodeId ID of the node being reparented + * @param newParentId ID of the new parent (or null to remove parent) + * @param getNodes Function to retrieve all nodes from ReactFlow + * @param updateBlockPosition Function to update the position of a block + * @param updateParentId Function to update the parent ID of a block + * @param resizeLoopNodes Function to resize loop nodes after parent update + */ +export const updateNodeParent = ( + nodeId: string, + newParentId: string | null, + getNodes: () => any[], + updateBlockPosition: (id: string, position: { x: number, y: number }) => void, + updateParentId: (id: string, parentId: string, extent: "parent") => void, + resizeLoopNodes: () => void +) => { + // Skip if no change + const node = getNodes().find(n => n.id === nodeId); + if (!node) return; + + const currentParentId = node.parentId || null; + if (newParentId === currentParentId) return; + + if (newParentId) { + // Moving to a new parent - calculate relative position + const relativePosition = calculateRelativePosition(nodeId, newParentId, getNodes); + + // Update both position and parent + updateBlockPosition(nodeId, relativePosition); + updateParentId(nodeId, newParentId, 'parent'); + + logger.info('Updated node parent', { + nodeId, + newParentId, + relativePosition + }); + } + + // Resize affected loops + resizeLoopNodes(); +}; + +/** + * Checks if a point is inside a loop node + * @param position Position coordinates to check + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns The smallest loop containing the point, or null if none + */ +export const isPointInLoopNode = ( + position: { x: number, y: number }, + getNodes: () => any[] +): { + loopId: string, + loopPosition: { x: number, y: number }, + dimensions: { width: number, height: number } +} | null => { + // Find loops that contain this position point + const containingLoops = getNodes() + .filter(n => n.type === 'loopNode') + .filter(n => { + const loopRect = { + left: n.position.x, + right: n.position.x + (n.data?.width || 500), + top: n.position.y, + bottom: n.position.y + (n.data?.height || 300) + }; + + return ( + position.x >= loopRect.left && + position.x <= loopRect.right && + position.y >= loopRect.top && + position.y <= loopRect.bottom + ); + }) + .map(n => ({ + loopId: n.id, + loopPosition: n.position, + dimensions: { + width: n.data?.width || 500, + height: n.data?.height || 300 + } + })); + + // Sort by area (smallest first) in case of nested loops + if (containingLoops.length > 0) { + return containingLoops.sort((a, b) => { + const aArea = a.dimensions.width * a.dimensions.height; + const bArea = b.dimensions.width * b.dimensions.height; + return aArea - bArea; + })[0]; + } + + return null; +}; + +/** + * Calculates appropriate dimensions for a loop node based on its children + * @param loopId ID of the loop node + * @param getNodes Function to retrieve all nodes from ReactFlow + * @returns Calculated width and height for the loop + */ +export const calculateLoopDimensions = ( + loopId: string, + getNodes: () => any[] +): { width: number, height: number } => { + // Default minimum dimensions + const minWidth = 500; + const minHeight = 300; + + // Get all child nodes of this loop + const childNodes = getNodes().filter(node => node.parentId === loopId); + + if (childNodes.length === 0) { + return { width: minWidth, height: minHeight }; + } + + // Calculate the bounding box that contains all children + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + childNodes.forEach(node => { + // Get accurate node dimensions based on node type + let nodeWidth; + let nodeHeight; + + if (node.type === 'loopNode') { + // For nested loops, don't add excessive padding to the parent + // Use actual dimensions without additional padding to prevent cascading expansion + nodeWidth = node.data?.width || 500; + nodeHeight = node.data?.height || 300; + } else if (node.type === 'workflowBlock') { + // Handle all workflowBlock types appropriately + const blockType = node.data?.type; + + switch (blockType) { + case 'agent': + case 'api': + // Tall blocks + nodeWidth = 350; + nodeHeight = 650; + break; + case 'condition': + case 'function': + nodeWidth = 250; + nodeHeight = 200; + break; + case 'router': + nodeWidth = 250; + nodeHeight = 350; + break; + default: + // Default dimensions for other block types + nodeWidth = 200; + nodeHeight = 200; + } + } else { + // Default dimensions for any other node types + nodeWidth = 200; + nodeHeight = 200; + } + + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x + nodeWidth); + maxY = Math.max(maxY, node.position.y + nodeHeight); + }); + + // Add buffer padding to all sides (20px buffer before edges) + // Add extra padding for nested loops to prevent tight boundaries + const hasNestedLoops = childNodes.some(node => node.type === 'loopNode'); + + // More reasonable padding values, especially for nested loops + // Reduce the excessive padding that was causing parent loops to be too large + const sidePadding = hasNestedLoops ? 150 : 120; // Reduced padding for loops containing other loops + + // Ensure the width and height are never less than the minimums + // Apply padding to all sides (left/right and top/bottom) + const width = Math.max(minWidth, maxX + sidePadding); + const height = Math.max(minHeight, maxY + sidePadding); + + return { width, height }; +}; + +/** + * Resizes all loop nodes based on their children + * @param getNodes Function to retrieve all nodes from ReactFlow + * @param updateNodeDimensions Function to update the dimensions of a node + */ +export const resizeLoopNodes = ( + getNodes: () => any[], + updateNodeDimensions: (id: string, dimensions: { width: number, height: number }) => void +) => { + // Find all loop nodes and sort by hierarchy depth (parents first) + const loopNodes = getNodes() + .filter(node => node.type === 'loopNode') + .map(node => ({ + ...node, + depth: getNodeDepth(node.id, getNodes) + })) + .sort((a, b) => a.depth - b.depth); + + // Resize each loop node based on its children + loopNodes.forEach(loopNode => { + const dimensions = calculateLoopDimensions(loopNode.id, getNodes); + + // Only update if dimensions have changed (to avoid unnecessary updates) + if (dimensions.width !== loopNode.data?.width || + dimensions.height !== loopNode.data?.height) { + updateNodeDimensions(loopNode.id, dimensions); + } + }); +}; \ No newline at end of file diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index a345d747b16..2f9134185b1 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -24,43 +24,58 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { NotificationList } from '@/app/w/[id]/components/notifications/notifications' import { getBlock } from '@/blocks' +import { + calculateLoopDimensions, + calculateRelativePosition, + getNodeAbsolutePosition, + getNodeDepth, + getNodeHierarchy, + isPointInLoopNode, + resizeLoopNodes, + updateNodeParent as updateNodeParentUtil +} from './utils' import { ControlBar } from './components/control-bar/control-bar' import { ErrorBoundary } from './components/error/index' import { Panel } from './components/panel/panel' import { Toolbar } from './components/toolbar/toolbar' import { WorkflowBlock } from './components/workflow-block/workflow-block' import { WorkflowEdge } from './components/workflow-edge/workflow-edge' -import { LoopInput } from './components/workflow-loop/components/loop-input/loop-input' -import { LoopLabel } from './components/workflow-loop/components/loop-label/loop-label' -import { createLoopNode, getRelativeLoopPosition } from './components/workflow-loop/workflow-loop' +import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node' const logger = createLogger('Workflow') // Define custom node and edge types const nodeTypes: NodeTypes = { workflowBlock: WorkflowBlock, - loopLabel: LoopLabel, - loopInput: LoopInput, + loopNode: LoopNodeComponent, } const edgeTypes: EdgeTypes = { workflowEdge: WorkflowEdge } function WorkflowContent() { // State - const [selectedEdgeId, setSelectedEdgeId] = useState(null) const [isInitialized, setIsInitialized] = useState(false) const { mode, isExpanded } = useSidebarStore() // In hover mode, act as if sidebar is always collapsed for layout purposes const isSidebarCollapsed = mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover' - + // State for tracking node dragging + const [draggedNodeId, setDraggedNodeId] = useState(null) + const [potentialParentId, setPotentialParentId] = useState(null) + // Enhanced edge selection with parent context and unique identifier + const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{ + id: string; + parentLoopId?: string; + contextId?: string; // Unique identifier combining edge ID and context + } | null>(null) // Hooks const params = useParams() const router = useRouter() - const { project } = useReactFlow() + const { project, getNodes } = useReactFlow() // Store access const { workflows, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() - const { blocks, edges, loops, addBlock, updateBlockPosition, addEdge, removeEdge } = + //Removed loops from the store + const { blocks, edges, addBlock, updateNodeDimensions, updateBlockPosition, addEdge, removeEdge, updateParentId, removeBlock } = useWorkflowStore() const { setValue: setSubBlockValue } = useSubBlockStore() const { markAllAsRead } = useNotificationStore() @@ -69,6 +84,52 @@ function WorkflowContent() { // Execution and debug mode state const { activeBlockIds, pendingBlocks } = useExecutionStore() const { isDebugModeEnabled } = useGeneralStore() + const [dragStartParentId, setDragStartParentId] = useState(null) + + // Wrapper functions that use the utilities but provide the getNodes function + const getNodeDepthWrapper = useCallback((nodeId: string): number => { + return getNodeDepth(nodeId, getNodes); + }, [getNodes]); + + const getNodeHierarchyWrapper = useCallback((nodeId: string): string[] => { + return getNodeHierarchy(nodeId, getNodes); + }, [getNodes]); + + const getNodeAbsolutePositionWrapper = useCallback((nodeId: string): { x: number, y: number } => { + return getNodeAbsolutePosition(nodeId, getNodes); + }, [getNodes]); + + const calculateRelativePositionWrapper = useCallback((nodeId: string, newParentId: string): { x: number, y: number } => { + return calculateRelativePosition(nodeId, newParentId, getNodes); + }, [getNodes]); + + // Helper function to update a node's parent with proper position calculation + const updateNodeParent = useCallback((nodeId: string, newParentId: string | null) => { + return updateNodeParentUtil( + nodeId, + newParentId, + getNodes, + updateBlockPosition, + updateParentId, + resizeLoopNodesWrapper + ); + }, [getNodes, updateBlockPosition, updateParentId]); + + const isPointInLoopNodeWrapper = useCallback((position: { x: number, y: number }) => { + return isPointInLoopNode(position, getNodes); + }, [getNodes]); + + const calculateLoopDimensionsWrapper = useCallback((loopId: string): { width: number, height: number } => { + return calculateLoopDimensions(loopId, getNodes); + }, [getNodes]); + + // Function to resize all loop nodes with improved hierarchy handling + const resizeLoopNodesWrapper = useCallback(() => { + return resizeLoopNodes(getNodes, updateNodeDimensions); + }, [getNodes, updateNodeDimensions]); + + // Use direct resizing function instead of debounced version for immediate updates + const debouncedResizeLoopNodes = resizeLoopNodesWrapper; // Initialize workflow useEffect(() => { @@ -141,6 +202,47 @@ function WorkflowContent() { if (!type) return if (type === 'connectionBlock') return + // Special handling for loop nodes + if (type === 'loop') { + // Create a unique ID and name for the loop + const id = crypto.randomUUID() + const name = 'Loop' + + // Calculate the center position of the viewport + const centerPosition = project({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }) + + // Add the loop node directly to canvas with default dimensions + addBlock(id, type, name, centerPosition, { + width: 500, + height: 300, + type: 'loopNode' + }) + + // Auto-connect logic for loop nodes + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled + if (isAutoConnectEnabled) { + const closestBlock = findClosestOutput(centerPosition) + if (closestBlock) { + // Get appropriate source handle + const sourceHandle = determineSourceHandle(closestBlock) + + addEdge({ + id: crypto.randomUUID(), + source: closestBlock.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }) + } + } + + return + } + const blockConfig = getBlock(type) if (!blockConfig) { logger.error('Invalid block type:', { type }) @@ -176,7 +278,7 @@ function WorkflowContent() { target: id, sourceHandle, targetHandle: 'target', - type: 'custom', + type: 'workflowEdge', }) } } @@ -207,44 +309,263 @@ function WorkflowContent() { y: event.clientY - reactFlowBounds.top, }) + // Check if dropping inside a loop node + const loopInfo = isPointInLoopNodeWrapper(position); + + // Clear any drag-over styling + document.querySelectorAll('.loop-node-drag-over').forEach(el => { + el.classList.remove('loop-node-drag-over'); + }); + document.body.style.cursor = ''; + + // Special handling for loop nodes + if (data.type === 'loop') { + // Create a unique ID and name for the loop + const id = crypto.randomUUID() + const name = 'Loop' + + // Check if we're dropping inside another loop + if (loopInfo) { + // Calculate position relative to the parent loop + const relativePosition = { + x: position.x - loopInfo.loopPosition.x, + y: position.y - loopInfo.loopPosition.y + }; + + // Add the loop as a child of the parent loop + addBlock(id, data.type, name, relativePosition, { + width: 500, + height: 300, + type: 'loopNode', + parentId: loopInfo.loopId, + extent: 'parent' + }); + + logger.info('Added nested loop inside parent loop', { + loopId: id, + parentLoopId: loopInfo.loopId, + relativePosition + }); + + // Auto-connect the nested loop to nodes inside the parent loop + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled; + if (isAutoConnectEnabled) { + // Try to find other nodes in the parent loop to connect to + const loopNodes = getNodes().filter(n => n.parentId === loopInfo.loopId); + + if (loopNodes.length > 0) { + // Connect to the closest node in the loop + const closestNode = loopNodes + .map(n => ({ + id: n.id, + distance: Math.sqrt( + Math.pow(n.position.x - relativePosition.x, 2) + + Math.pow(n.position.y - relativePosition.y, 2) + ) + })) + .sort((a, b) => a.distance - b.distance)[0]; + + if (closestNode) { + // Get appropriate source handle + const sourceNode = getNodes().find(n => n.id === closestNode.id); + const sourceType = sourceNode?.data?.type; + + // Default source handle + let sourceHandle = 'source'; + + // For condition blocks, use the condition-true handle + if (sourceType === 'condition') { + sourceHandle = 'condition-true'; + } + + addEdge({ + id: crypto.randomUUID(), + source: closestNode.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }); + } + } + } + + // Resize the parent loop to fit the new child loop + debouncedResizeLoopNodes(); + } else { + // Add the loop node directly to canvas with default dimensions + addBlock(id, data.type, name, position, { + width: 500, + height: 300, + type: 'loopNode' + }); + + // Auto-connect the loop to the closest node on the canvas + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled; + if (isAutoConnectEnabled) { + const closestBlock = findClosestOutput(position); + if (closestBlock) { + const sourceHandle = determineSourceHandle(closestBlock); + + addEdge({ + id: crypto.randomUUID(), + source: closestBlock.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }); + } + } + } + + return + } + const blockConfig = getBlock(data.type) - if (!blockConfig) { + if (!blockConfig && data.type !== 'loop') { logger.error('Invalid block type:', { data }) return } - + + // Generate id and name here so they're available in all code paths const id = crypto.randomUUID() - const name = `${blockConfig.name} ${ - Object.values(blocks).filter((b) => b.type === data.type).length + 1 - }` - - addBlock(id, data.type, name, position) - - // Auto-connect logic - const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled - if (isAutoConnectEnabled && data.type !== 'starter') { - const closestBlock = findClosestOutput(position) - if (closestBlock) { - // Get appropriate source handle - const sourceHandle = determineSourceHandle(closestBlock) - - addEdge({ - id: crypto.randomUUID(), - source: closestBlock.id, - target: id, - sourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - }) + const name = data.type === 'loop' + ? 'Loop' + : `${blockConfig!.name} ${Object.values(blocks).filter((b) => b.type === data.type).length + 1}` + + if (loopInfo) { + // Calculate position relative to the loop node + const relativePosition = { + x: position.x - loopInfo.loopPosition.x, + y: position.y - loopInfo.loopPosition.y + }; + + // Add block with parent info + addBlock(id, data.type, name, relativePosition, { + parentId: loopInfo.loopId, + extent: 'parent' + }); + + logger.info('Added block inside loop', { + blockId: id, + blockType: data.type, + loopId: loopInfo.loopId, + relativePosition + }); + + // Resize the loop node to fit the new block + // Immediate resize without delay + debouncedResizeLoopNodes(); + + // Auto-connect logic for blocks inside loops + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled; + if (isAutoConnectEnabled && data.type !== 'starter') { + // Try to find other nodes in the loop to connect to + const loopNodes = getNodes().filter(n => n.parentId === loopInfo.loopId); + + if (loopNodes.length > 0) { + // Connect to the closest node in the loop + const closestNode = loopNodes + .map(n => ({ + id: n.id, + distance: Math.sqrt( + Math.pow(n.position.x - relativePosition.x, 2) + + Math.pow(n.position.y - relativePosition.y, 2) + ) + })) + .sort((a, b) => a.distance - b.distance)[0]; + + if (closestNode) { + // Get appropriate source handle + const sourceNode = getNodes().find(n => n.id === closestNode.id); + const sourceType = sourceNode?.data?.type; + + // Default source handle + let sourceHandle = 'source'; + + // For condition blocks, use the condition-true handle + if (sourceType === 'condition') { + sourceHandle = 'condition-true'; + } + + addEdge({ + id: crypto.randomUUID(), + source: closestNode.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }); + } + } + } + } else { + // Regular canvas drop + addBlock(id, data.type, name, position); + + // Regular auto-connect logic + const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled; + if (isAutoConnectEnabled && data.type !== 'starter') { + const closestBlock = findClosestOutput(position); + if (closestBlock) { + const sourceHandle = determineSourceHandle(closestBlock); + + addEdge({ + id: crypto.randomUUID(), + source: closestBlock.id, + target: id, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + }); + } } } } catch (err) { logger.error('Error dropping block:', { err }) } }, - [project, blocks, addBlock, addEdge, findClosestOutput, determineSourceHandle] + [project, blocks, addBlock, addEdge, findClosestOutput, determineSourceHandle, isPointInLoopNodeWrapper, getNodes] ) + // Handle drag over for ReactFlow canvas + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + + // Only handle toolbar items + if (!event.dataTransfer?.types.includes('application/json')) return; + + try { + const reactFlowBounds = event.currentTarget.getBoundingClientRect(); + const position = project({ + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }); + + // Check if hovering over a loop node + const loopInfo = isPointInLoopNodeWrapper(position); + + // Clear any previous highlighting + document.querySelectorAll('.loop-node-drag-over').forEach(el => { + el.classList.remove('loop-node-drag-over'); + }); + + // If hovering over a loop node, highlight it + if (loopInfo) { + const loopElement = document.querySelector(`[data-id="${loopInfo.loopId}"]`); + if (loopElement) { + loopElement.classList.add('loop-node-drag-over'); + document.body.style.cursor = 'copy'; + } + } else { + document.body.style.cursor = ''; + } + } catch (err) { + logger.error('Error in onDragOver', { err }); + } + }, [project, isPointInLoopNodeWrapper]); + // Init workflow useEffect(() => { if (!isInitialized) return @@ -304,15 +625,6 @@ function WorkflowContent() { const nodes = useMemo(() => { const nodeArray: any[] = [] - // Add loop group nodes and their labels - Object.entries(loops).forEach(([loopId, loop]) => { - const loopNodes = createLoopNode({ loopId, loop, blocks }) - if (loopNodes) { - // Add both the loop node and its label node - nodeArray.push(...loopNodes) - } - }) - // Add block nodes Object.entries(blocks).forEach(([blockId, block]) => { if (!block.type || !block.name) { @@ -320,6 +632,24 @@ function WorkflowContent() { return } + // Handle loop nodes differently + if (block.type === 'loop') { + nodeArray.push({ + id: block.id, + type: 'loopNode', + position: block.position, + parentId: block.data?.parentId, + extent: block.data?.extent || undefined, + dragHandle: '.workflow-drag-handle', + data: { + ...block.data, + width: block.data?.width || 500, + height: block.data?.height || 300, + }, + }) + return + } + const blockConfig = getBlock(block.type) if (!blockConfig) { logger.error(`No configuration found for block type: ${block.type}`, { @@ -328,17 +658,8 @@ function WorkflowContent() { return } - const parentLoop = Object.entries(loops).find(([_, loop]) => loop.nodes.includes(block.id)) let position = block.position - if (parentLoop) { - const [loopId] = parentLoop - const loopNode = nodeArray.find((node) => node.id === `loop-${loopId}`) - if (loopNode) { - position = getRelativeLoopPosition(block.position, loopNode.position) - } - } - const isActive = activeBlockIds.has(block.id) const isPending = isDebugModeEnabled && pendingBlocks.includes(block.id) @@ -346,8 +667,8 @@ function WorkflowContent() { id: block.id, type: 'workflowBlock', position, - parentId: parentLoop ? `loop-${parentLoop[0]}` : undefined, - dragHandle: '.workflow-drag-handle', + parentId: block.data?.parentId, + extent: block.data?.extent || undefined, data: { type: block.type, config: blockConfig, @@ -359,7 +680,7 @@ function WorkflowContent() { }) return nodeArray - }, [blocks, loops, activeBlockIds, pendingBlocks, isDebugModeEnabled]) + }, [blocks, activeBlockIds, pendingBlocks, isDebugModeEnabled]) // Update nodes const onNodesChange = useCallback( @@ -368,86 +689,452 @@ function WorkflowContent() { if (change.type === 'position' && change.position) { const node = nodes.find((n) => n.id === change.id) if (!node) return - - if (node.parentId) { - const loopNode = nodes.find((n) => n.id === node.parentId) - if (loopNode) { - const absolutePosition = { - x: change.position.x + loopNode.position.x, - y: change.position.y + loopNode.position.y, - } - updateBlockPosition(change.id, absolutePosition) - } - } else { - updateBlockPosition(change.id, change.position) - } + updateBlockPosition(change.id, change.position) } }) }, [nodes, updateBlockPosition] ) + // Effect to resize loops when nodes change (add/remove/position change) + useEffect(() => { + // Skip during initial render when nodes aren't loaded yet + if (nodes.length === 0) return; + + // Resize all loops to fit their children + debouncedResizeLoopNodes(); + + // No need for cleanup with direct function + return () => {}; + }, [nodes, debouncedResizeLoopNodes]); + + // Special effect to handle cleanup after node deletion + useEffect(() => { + // Create a mapping of node IDs to check for missing parent references + const nodeIds = new Set(Object.keys(blocks)); + + // Check for nodes with invalid parent references + Object.entries(blocks).forEach(([id, block]) => { + const parentId = block.data?.parentId; + + // If block has a parent reference but parent no longer exists + if (parentId && !nodeIds.has(parentId)) { + logger.warn('Found orphaned node with invalid parent reference', { + nodeId: id, + missingParentId: parentId + }); + + // Fix the node by removing its parent reference and calculating absolute position + const absolutePosition = getNodeAbsolutePositionWrapper(id); + + // Update the node to remove parent reference and use absolute position + updateBlockPosition(id, absolutePosition); + updateParentId(id, '', 'parent'); + } + }); + }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePositionWrapper]); + // Update edges const onEdgesChange = useCallback( (changes: any) => { changes.forEach((change: any) => { if (change.type === 'remove') { - removeEdge(change.id) + logger.info('Edge removal requested via ReactFlow:', { edgeId: change.id }); + removeEdge(change.id); } - }) + }); }, [removeEdge] ) - // Handle connections + // Handle connections with improved parent tracking const onConnect = useCallback( (connection: any) => { if (connection.source && connection.target) { + // Check if connecting nodes across loop boundaries + const sourceNode = getNodes().find(n => n.id === connection.source); + const targetNode = getNodes().find(n => n.id === connection.target); + + if (!sourceNode || !targetNode) return; + + // Get parent information (handle loop start node case) + const sourceParentId = sourceNode.parentId || + (connection.sourceHandle === 'loop-start-source' ? + connection.source : undefined); + const targetParentId = targetNode.parentId; + + // Generate a unique edge ID + const edgeId = crypto.randomUUID(); + + // Special case for loop-start-source: Always allow connections to nodes within the same loop + if (connection.sourceHandle === 'loop-start-source' && targetNode.parentId === sourceNode.id) { + // This is a connection from loop start to a node inside the loop - always allow + logger.info('Creating loop start connection:', { + edgeId, + sourceId: connection.source, + targetId: connection.target, + parentLoopId: sourceNode.id + }); + + addEdge({ + ...connection, + id: edgeId, + type: 'workflowEdge', + // Add metadata about the loop context + data: { + parentLoopId: sourceNode.id, + isInsideLoop: true + } + }); + return; + } + + // Prevent connections across loop boundaries + if ((sourceParentId && !targetParentId) || (!sourceParentId && targetParentId) || + (sourceParentId && targetParentId && sourceParentId !== targetParentId)) { + logger.info('Rejected cross-boundary connection:', { + sourceId: connection.source, + targetId: connection.target, + sourceParentId, + targetParentId + }); + return; + } + + // Track if this connection is inside a loop + const isInsideLoop = Boolean(sourceParentId) || Boolean(targetParentId); + const parentLoopId = sourceParentId || targetParentId; + + logger.info('Creating connection:', { + edgeId, + sourceId: connection.source, + targetId: connection.target, + isInsideLoop, + parentLoopId + }); + + // Add appropriate metadata for loop context addEdge({ ...connection, - id: crypto.randomUUID(), + id: edgeId, type: 'workflowEdge', + data: isInsideLoop ? { + parentLoopId, + isInsideLoop + } : undefined + }); + } + }, + [addEdge, getNodes] + ); + + // Handle node drag to detect intersections with loop nodes + const onNodeDrag = useCallback( + (event: React.MouseEvent, node: any) => { + // Store currently dragged node ID + setDraggedNodeId(node.id); + + // Get the current parent ID of the node being dragged + const currentParentId = blocks[node.id]?.data?.parentId || null; + + // Check if this is a starter block - starter blocks should never be in loops + const isStarterBlock = node.data?.type === 'starter'; + if (isStarterBlock) { + // If it's a starter block, remove any highlighting and don't allow it to be dragged into loops + if (potentialParentId) { + const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`); + if (prevElement) { + prevElement.classList.remove('loop-node-drag-over'); + } + setPotentialParentId(null); + document.body.style.cursor = ''; + } + return; // Exit early - don't process any loop intersections for starter blocks + } + + // Get the node's absolute position to properly calculate intersections + const nodeAbsolutePos = getNodeAbsolutePositionWrapper(node.id); + + // Find intersections with loop nodes using absolute coordinates + const intersectingNodes = getNodes() + .filter(n => { + // Only consider loop nodes that aren't the dragged node + if (n.type !== 'loopNode' || n.id === node.id) return false; + + // Skip if this loop is already the parent of the node being dragged + if (n.id === currentParentId) return false; + + // Skip self-nesting: prevent a loop from becoming its own descendant + if (node.type === 'loopNode') { + // Get the full hierarchy of the potential parent + const hierarchy = getNodeHierarchyWrapper(n.id); + + // If the dragged node is in the hierarchy, this would create a circular reference + if (hierarchy.includes(node.id)) { + return false; // Avoid circular nesting + } + } + + // Get the loop's absolute position + const loopAbsolutePos = getNodeAbsolutePositionWrapper(n.id); + + // Get dimensions based on node type + const nodeWidth = node.type === 'loopNode' + ? (node.data?.width || 500) + : (node.type === 'condition' ? 250 : 350); + + const nodeHeight = node.type === 'loopNode' + ? (node.data?.height || 300) + : (node.type === 'condition' ? 150 : 100); + + // Check intersection using absolute coordinates + const nodeRect = { + left: nodeAbsolutePos.x, + right: nodeAbsolutePos.x + nodeWidth, + top: nodeAbsolutePos.y, + bottom: nodeAbsolutePos.y + nodeHeight + }; + + const loopRect = { + left: loopAbsolutePos.x, + right: loopAbsolutePos.x + (n.data?.width || 500), + top: loopAbsolutePos.y, + bottom: loopAbsolutePos.y + (n.data?.height || 300) + }; + + // Check intersection with absolute coordinates for accurate detection + return ( + nodeRect.left < loopRect.right && + nodeRect.right > loopRect.left && + nodeRect.top < loopRect.bottom && + nodeRect.bottom > loopRect.top + ); }) + // Add more information for sorting + .map(n => ({ + loop: n, + depth: getNodeDepthWrapper(n.id), + // Calculate size for secondary sorting + size: (n.data?.width || 500) * (n.data?.height || 300) + })); + + // Update potential parent if there's at least one intersecting loop node + if (intersectingNodes.length > 0) { + // Sort by depth first (deepest/most nested loops first), then by size if same depth + const sortedLoops = intersectingNodes.sort((a, b) => { + // First try to compare by hierarchy depth + if (a.depth !== b.depth) { + return b.depth - a.depth; // Higher depth (more nested) comes first + } + // If same depth, use size as secondary criterion + return a.size - b.size; // Smaller container takes precedence + }); + + // Use the most appropriate loop (deepest or smallest at same depth) + const bestLoopMatch = sortedLoops[0]; + + // Add a check to see if the bestLoopMatch is apart of the heirarchy of the node being dragged + const hierarchy = getNodeHierarchyWrapper(node.id); + if (hierarchy.includes(bestLoopMatch.loop.id)) { + setPotentialParentId(null); + return; + } + + setPotentialParentId(bestLoopMatch.loop.id); + + // Add highlight class and change cursor + const loopElement = document.querySelector(`[data-id="${bestLoopMatch.loop.id}"]`); + if (loopElement) { + loopElement.classList.add('loop-node-drag-over'); + document.body.style.cursor = 'copy'; + } + } else { + // Remove highlighting if no longer over a loop + if (potentialParentId) { + const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`); + if (prevElement) { + prevElement.classList.remove('loop-node-drag-over'); + } + setPotentialParentId(null); + document.body.style.cursor = ''; + } } }, - [addEdge] - ) + [getNodes, potentialParentId, blocks, getNodeHierarchyWrapper, getNodeAbsolutePositionWrapper, getNodeDepthWrapper] + ); + + // Add in a nodeDrag start event to set the dragStartParentId + const onNodeDragStart = useCallback((event: React.MouseEvent, node: any) => { + // Store the original parent ID when starting to drag + const currentParentId = node.parentId || blocks[node.id]?.data?.parentId || null; + setDragStartParentId(currentParentId); + + logger.info('Node drag started', { + nodeId: node.id, + startParentId: currentParentId, + nodeType: node.type + }); + }, [blocks]); + + // Handle node drag stop to establish parent-child relationships + const onNodeDragStop = useCallback( + (event: React.MouseEvent, node: any) => { + // Clear UI effects + document.querySelectorAll('.loop-node-drag-over').forEach(el => { + el.classList.remove('loop-node-drag-over'); + }); + document.body.style.cursor = ''; + + // Don't process if the node hasn't actually changed parent or is being moved within same parent + if (potentialParentId === dragStartParentId) return; + + logger.info('Node drag stopped', { + nodeId: node.id, + dragStartParentId, + potentialParentId, + nodeType: node.type + }); + + // Check if this is a starter block - starter blocks should never be in loops + const isStarterBlock = node.data?.type === 'starter'; + if (isStarterBlock) { + logger.warn('Prevented starter block from being placed inside a loop', { + blockId: node.id, + attemptedParentId: potentialParentId + }); + // Reset state without updating parent + setDraggedNodeId(null); + setPotentialParentId(null); + return; // Exit early - don't allow starter blocks to have parents + } + + // If we're dragging a loop node, do additional checks to prevent circular references + if (node.type === 'loopNode' && potentialParentId) { + // Get the hierarchy of the potential parent loop + const parentHierarchy = getNodeHierarchyWrapper(potentialParentId); + + // If the dragged node is in the parent's hierarchy, it would create a circular reference + if (parentHierarchy.includes(node.id)) { + logger.warn('Prevented circular loop nesting', { + draggedLoopId: node.id, + potentialParentId, + parentHierarchy + }); + return; + } + } + + // Update the node's parent relationship + if (potentialParentId) { + // Moving to a new parent loop + updateNodeParent(node.id, potentialParentId); + } + + // Reset state + setDraggedNodeId(null); + setPotentialParentId(null); + }, + [getNodes, dragStartParentId, potentialParentId, updateNodeParent, getNodeHierarchyWrapper] + ); // Update onPaneClick to only handle edge selection const onPaneClick = useCallback(() => { - setSelectedEdgeId(null) + setSelectedEdgeInfo(null) }, []) // Edge selection const onEdgeClick = useCallback((event: React.MouseEvent, edge: any) => { - setSelectedEdgeId(edge.id) - }, []) - - // Transform edges to include selection state - const edgesWithSelection = edges.map((edge) => ({ - ...edge, - type: edge.type || 'workflowEdge', - data: { - selectedEdgeId, - onDelete: (edgeId: string) => { - removeEdge(edgeId) - setSelectedEdgeId(null) + event.stopPropagation(); // Prevent bubbling + + // Determine if edge is inside a loop by checking its source/target nodes + const sourceNode = getNodes().find(n => n.id === edge.source); + const targetNode = getNodes().find(n => n.id === edge.target); + + // An edge is inside a loop if either source or target has a parent + // If source and target have different parents, prioritize source's parent + const parentLoopId = sourceNode?.parentId || targetNode?.parentId; + + // Create a unique identifier that combines edge ID and parent context + const contextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}`; + + logger.info('Edge selected:', { + edgeId: edge.id, + sourceId: edge.source, + targetId: edge.target, + sourceNodeParent: sourceNode?.parentId, + targetNodeParent: targetNode?.parentId, + parentLoopId, + contextId + }); + + setSelectedEdgeInfo({ + id: edge.id, + parentLoopId, + contextId + }); + }, [getNodes]); + + // Transform edges to include improved selection state + const edgesWithSelection = edges.map((edge) => { + // Check if this edge connects nodes inside a loop + const sourceNode = getNodes().find(n => n.id === edge.source); + const targetNode = getNodes().find(n => n.id === edge.target); + const parentLoopId = sourceNode?.parentId || targetNode?.parentId; + const isInsideLoop = Boolean(parentLoopId); + + // Create a unique context ID for this edge + const edgeContextId = `${edge.id}${parentLoopId ? `-${parentLoopId}` : ''}`; + + // Determine if this edge is selected using context-aware matching + const isSelected = selectedEdgeInfo?.contextId === edgeContextId; + + return { + ...edge, + type: edge.type || 'workflowEdge', + data: { + // Send only necessary data to the edge component + isSelected, + isInsideLoop, + parentLoopId, + onDelete: (edgeId: string) => { + // Log deletion for debugging + logger.info('Deleting edge:', { + edgeId, + fromSelection: selectedEdgeInfo?.id === edgeId, + contextId: edgeContextId + }); + + // Only delete this specific edge + removeEdge(edgeId); + + // Only clear selection if this was the selected edge + if (selectedEdgeInfo?.id === edgeId) { + setSelectedEdgeInfo(null); + } + }, }, - }, - })) + } + }); - // Handle keyboard shortcuts + // Handle keyboard shortcuts with better edge tracking useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeId) { - removeEdge(selectedEdgeId) - setSelectedEdgeId(null) + if ((event.key === 'Delete' || event.key === 'Backspace') && selectedEdgeInfo) { + logger.info('Keyboard shortcut edge deletion:', { + edgeId: selectedEdgeInfo.id, + parentLoopId: selectedEdgeInfo.parentLoopId, + contextId: selectedEdgeInfo.contextId + }); + + // Only delete the specific selected edge + removeEdge(selectedEdgeInfo.id); + setSelectedEdgeInfo(null); } } - window.addEventListener('keydown', handleKeyDown) - return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEdgeId, removeEdge]) + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedEdgeInfo, removeEdge]); // Handle sub-block value updates from custom events useEffect(() => { @@ -496,7 +1183,7 @@ function WorkflowContent() { nodeTypes={nodeTypes} edgeTypes={edgeTypes} onDrop={onDrop} - onDragOver={(e) => e.preventDefault()} + onDragOver={onDragOver} fitView minZoom={0.1} maxZoom={1.3} @@ -509,9 +1196,9 @@ function WorkflowContent() { strokeDasharray: '5,5', }} connectionLineType={ConnectionLineType.SmoothStep} - onNodeClick={(e) => { - e.stopPropagation() - e.preventDefault() + onNodeClick={(e, node) => { + // Allow selecting nodes, but stop propagation to prevent triggering other events + e.stopPropagation(); }} onPaneClick={onPaneClick} onEdgeClick={onEdgeClick} @@ -524,6 +1211,15 @@ function WorkflowContent() { edgesFocusable={true} edgesUpdatable={true} className="workflow-container h-full" + onNodeDrag={onNodeDrag} + onNodeDragStop={onNodeDragStop} + onNodeDragStart={onNodeDragStart} + snapToGrid={false} + snapGrid={[20, 20]} + elevateEdgesOnSelect={true} + elevateNodesOnSelect={true} + autoPanOnConnect={true} + autoPanOnNodeDrag={true} > diff --git a/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx b/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx index 28b903ba660..3783e58b3ea 100644 --- a/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx +++ b/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx @@ -18,11 +18,12 @@ import { Card } from '@/components/ui/card' import { Label } from '@/components/ui/label' import { cn } from '@/lib/utils' import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edge' -import { LoopInput } from '@/app/w/[id]/components/workflow-loop/components/loop-input/loop-input' -import { LoopLabel } from '@/app/w/[id]/components/workflow-loop/components/loop-label/loop-label' -import { createLoopNode } from '@/app/w/[id]/components/workflow-loop/workflow-loop' +// import { LoopInput } from '@/app/w/[id]/components/workflow-loop/components/loop-input/loop-input' +// import { LoopLabel } from '@/app/w/[id]/components/workflow-loop/components/loop-label/loop-label' +// import { createLoopNode } from '@/app/w/[id]/components/workflow-loop/workflow-loop' import { getBlock } from '@/blocks' import { SubBlockConfig } from '@/blocks/types' +import { LoopTool } from '@/app/w/[id]/components/loop-node/loop-config' interface WorkflowPreviewProps { // The workflow state to render @@ -56,8 +57,8 @@ interface ExtendedSubBlockConfig extends SubBlockConfig { // Define node types const nodeTypes: NodeTypes = { workflowBlock: PreviewWorkflowBlock, - loopLabel: LoopLabel, - loopInput: LoopInput, + // loopLabel: LoopLabel, + // loopInput: LoopInput, } // Define edge types @@ -406,13 +407,21 @@ function PreviewSubBlock({ config }: { config: ExtendedSubBlockConfig }) { } function PreviewWorkflowBlock({ id, data }: NodeProps) { - const { type, config, name, blockState, showSubBlocks = true } = data + const { type, config, name, blockState, showSubBlocks = true, isLoopBlock } = data + // Get block configuration - use LoopTool for loop blocks if config is missing + const blockConfig = useMemo(() => { + if (type === 'loop' && !config) { + return LoopTool; + } + return config; + }, [type, config]); + // Only prepare subblocks if they should be shown const preparedSubBlocks = useMemo(() => { if (!showSubBlocks) return [] - return prepareSubBlocks(blockState?.subBlocks, config) - }, [blockState?.subBlocks, config, showSubBlocks]) + return prepareSubBlocks(blockState?.subBlocks, blockConfig) + }, [blockState?.subBlocks, blockConfig, showSubBlocks]) // Group subblocks for layout const subBlockRows = useMemo(() => { @@ -433,14 +442,24 @@ function PreviewWorkflowBlock({ id, data }: NodeProps) {
- + {blockConfig?.icon ? ( + + ) : ( + + )}
{name}
+ {type === 'loop' && ( +
+ {blockState?.data?.loopType === 'forEach' ? 'For Each' : 'For'} + {blockState?.data?.count && ` (${blockState.data.count}x)`} +
+ )}
{/* Block Content */} @@ -460,7 +479,11 @@ function PreviewWorkflowBlock({ id, data }: NodeProps) { )) ) : ( -
No configured items
+
+ {type === 'loop' + ? 'Loop configuration' + : 'No configured items'} +
)} )} @@ -512,45 +535,74 @@ function WorkflowPreviewContent({ const nodes: Node[] = useMemo(() => { const nodeArray: Node[] = [] - // Add loop nodes - Object.entries(workflowState.loops || {}).forEach(([loopId, loop]) => { - const loopNodes = createLoopNode({ - loopId, - loop: loop as any, - blocks: workflowState.blocks, - }) - - if (loopNodes) { - if (Array.isArray(loopNodes)) { - nodeArray.push(...(loopNodes as Node[])) - } else { - nodeArray.push(loopNodes) - } + // First, get all blocks with parent-child relationships + const blocksWithParents: Record = {} + const topLevelBlocks: Record = {} + + // Categorize blocks as top-level or child blocks + Object.entries(workflowState.blocks).forEach(([blockId, block]) => { + if (block.data?.parentId) { + // This is a child block + blocksWithParents[blockId] = block + } else { + // This is a top-level block + topLevelBlocks[blockId] = block } }) - // Add block nodes - Object.entries(workflowState.blocks).forEach(([blockId, block]) => { + // Process top-level blocks first + Object.entries(topLevelBlocks).forEach(([blockId, block]) => { const blockConfig = getBlock(block.type) - if (!blockConfig) return - + nodeArray.push({ id: blockId, type: 'workflowBlock', position: block.position, data: { type: block.type, - config: blockConfig, + config: blockConfig || (block.type === 'loop' ? LoopTool : null), name: block.name, blockState: block, - showSubBlocks, + showSubBlocks }, draggable: false, }) + + // Add children of this block if it's a loop + if (block.type === 'loop') { + // Find all children of this loop + const childBlocks = Object.entries(blocksWithParents) + .filter(([_, childBlock]) => childBlock.data?.parentId === blockId) + + // Add all child blocks to the node array + childBlocks.forEach(([childId, childBlock]) => { + const childConfig = getBlock(childBlock.type) + + nodeArray.push({ + id: childId, + type: 'workflowBlock', + // Position child blocks relative to the parent + position: { + x: block.position.x + 50, // Offset children to the right + y: block.position.y + (childBlock.position?.y || 100) // Preserve vertical positioning + }, + data: { + type: childBlock.type, + config: childConfig, + name: childBlock.name, + blockState: childBlock, + showSubBlocks, + isChild: true, + parentId: blockId + }, + draggable: false, + }) + }) + } }) return nodeArray - }, [workflowState.blocks, workflowState.loops, showSubBlocks]) + }, [workflowState.blocks, showSubBlocks]) // Transform edges const edges: Edge[] = useMemo(() => { diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index 5404b7d63b8..715adf33498 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -60,7 +60,7 @@ export function getWorkflowWithValues(workflowId: string) { state: { blocks: mergedBlocks, edges: workflowState.edges, - loops: workflowState.loops, + loops: workflowState.loops || {}, lastSaved: workflowState.lastSaved, isDeployed: workflowState.isDeployed, deployedAt: workflowState.deployedAt, @@ -140,7 +140,7 @@ export function getAllWorkflowsWithValues() { state: { blocks: mergedBlocks, edges: workflowState.edges, - loops: workflowState.loops, + loops: workflowState.loops || {}, lastSaved: workflowState.lastSaved, isDeployed: workflowState.isDeployed, deployedAt: workflowState.deployedAt, diff --git a/apps/sim/stores/workflows/persistence.ts b/apps/sim/stores/workflows/persistence.ts index 6c8a1ae3866..9a79405afb2 100644 --- a/apps/sim/stores/workflows/persistence.ts +++ b/apps/sim/stores/workflows/persistence.ts @@ -157,12 +157,15 @@ export function setupUnloadPersistence(): void { if (currentId) { // Save workflow state const currentState = useWorkflowStore.getState() + + // Generate loops from the current blocks for consistency + const generatedLoops = currentState.generateLoopBlocks ? currentState.generateLoopBlocks() : {} // Save the complete state including history which is added by middleware saveWorkflowState(currentId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + loops: generatedLoops, // Use generated loops isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, lastSaved: Date.now(), diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 608c3595c62..1c4d4485276 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -375,7 +375,7 @@ export const useWorkflowRegistry = create()( state: { blocks, edges, - loops: {}, + loops, isDeployed: isDeployed !== undefined ? isDeployed : false, deployedAt: deployedAt, }, diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index d4ece4ef70b..195f91a62d8 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -10,7 +10,7 @@ import { useSubBlockStore } from '../subblock/store' import { markWorkflowsDirty, workflowSync } from '../sync' import { mergeSubblockState } from '../utils' import { Loop, Position, SubBlockState, SyncControl, WorkflowState } from './types' -import { detectCycle } from './utils' +import { detectCycle, generateLoopBlocks } from './utils' const initialState = { blocks: {}, @@ -81,9 +81,52 @@ export const useWorkflowStore = create()( set({ needsRedeployment }) }, - addBlock: (id: string, type: string, name: string, position: Position) => { + addBlock: (id: string, type: string, name: string, position: Position, data?: Record, parentId?: string, extent?: 'parent') => { const blockConfig = getBlock(type) + // For custom nodes like loop that don't use BlockConfig + if (!blockConfig && type === 'loop') { + // Merge parentId and extent into data if provided + const nodeData = { + ...data, + ...(parentId && { parentId, extent: extent || 'parent' }) + } + + const newState = { + blocks: { + ...get().blocks, + [id]: { + id, + type, + name, + position, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + isWide: false, + height: 0, + data: nodeData + }, + }, + edges: [...get().edges], + loops: get().generateLoopBlocks(), + } + + set(newState) + pushHistory(set, get, newState, `Add ${type} node`) + get().updateLastSaved() + workflowSync.sync() + return + } + if (!blockConfig) return + + // Merge parentId and extent into data for regular blocks + const nodeData = { + ...data, + ...(parentId && { parentId, extent: extent || 'parent' }) + } + const subBlocks: Record = {} blockConfig.subBlocks.forEach((subBlock) => { @@ -111,10 +154,11 @@ export const useWorkflowStore = create()( horizontalHandles: true, isWide: false, height: 0, + data: nodeData, }, }, edges: [...get().edges], - loops: { ...get().loops }, + loops: get().generateLoopBlocks(), } set(newState) @@ -140,6 +184,90 @@ export const useWorkflowStore = create()( // No sync here as this is a frequent operation during dragging }, + updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { + set((state) => ({ + blocks: { + ...state.blocks, + [id]: { + ...state.blocks[id], + data: { + ...state.blocks[id].data, + width: dimensions.width, + height: dimensions.height, + }, + }, + }, + edges: [...state.edges], + })) + get().updateLastSaved() + workflowSync.sync() + }, + + updateParentId: (id: string, parentId: string, extent: 'parent') => { + const block = get().blocks[id]; + if (!block) { + console.warn(`Cannot set parent: Block ${id} not found`); + return; + } + + console.log('UpdateParentId called:', { + blockId: id, + blockName: block.name, + blockType: block.type, + newParentId: parentId, + extent, + currentParentId: block.data?.parentId + }); + + // Skip if the parent ID hasn't changed + if (block.data?.parentId === parentId) { + console.log('Parent ID unchanged, skipping update'); + return; + } + + // Store current absolute position + const absolutePosition = { ...block.position }; + + // Handle empty or null parentId (removing from parent) + const newData = !parentId + ? { ...block.data } // Remove parentId and extent if empty + : { + ...block.data, + parentId, + extent + }; + + // Remove parentId and extent properties for empty parent ID + if (!parentId && newData.parentId) { + delete newData.parentId; + delete newData.extent; + } + + const newState = { + blocks: { + ...get().blocks, + [id]: { + ...block, + position: absolutePosition, + data: newData + }, + }, + edges: [...get().edges], + loops: { ...get().loops }, + }; + + console.log('[WorkflowStore/updateParentId] Updated parentId relationship:', { + blockId: id, + newParentId: parentId || 'None (removed parent)', + keepingPosition: absolutePosition + }); + + set(newState); + pushHistory(set, get, newState, parentId ? `Set parent for ${block.name}` : `Remove parent for ${block.name}`); + get().updateLastSaved(); + workflowSync.sync(); + }, + removeBlock: (id: string) => { // First, clean up any subblock values for this block const subBlockStore = useSubBlockStore.getState() @@ -151,12 +279,39 @@ export const useWorkflowStore = create()( loops: { ...get().loops }, } + // Find and remove all child blocks if this is a parent node + const blocksToRemove = new Set([id]) + + // Recursively find all descendant blocks (children, grandchildren, etc.) + const findAllDescendants = (parentId: string) => { + Object.entries(newState.blocks).forEach(([blockId, block]) => { + if (block.data?.parentId === parentId) { + blocksToRemove.add(blockId) + // Recursively find this block's children + findAllDescendants(blockId) + } + }) + } + + // Start recursive search from the target block + findAllDescendants(id) + + console.log('[WorkflowStore/removeBlock] Found blocks to remove:', { + targetId: id, + totalBlocksToRemove: Array.from(blocksToRemove), + includesHierarchy: blocksToRemove.size > 1 + }) + // Clean up subblock values before removing the block if (activeWorkflowId) { const updatedWorkflowValues = { ...(subBlockStore.workflowValues[activeWorkflowId] || {}), } - delete updatedWorkflowValues[id] + + // Remove values for all blocks being deleted + blocksToRemove.forEach(blockId => { + delete updatedWorkflowValues[blockId] + }) // Update subblock store useSubBlockStore.setState((state) => ({ @@ -167,26 +322,18 @@ export const useWorkflowStore = create()( })) } - // Clean up loops - Object.entries(newState.loops).forEach(([loopId, loop]) => { - if (loop.nodes.includes(id)) { - // If removing this node would leave the loop empty, delete the loop - if (loop.nodes.length <= 1) { - delete newState.loops[loopId] - } else { - newState.loops[loopId] = { - ...loop, - nodes: loop.nodes.filter((nodeId) => nodeId !== id), - } - } - } - }) + // Remove all edges connected to any of the blocks being removed + newState.edges = newState.edges.filter(edge => + !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target) + ) - // Delete the block last - delete newState.blocks[id] + // Delete all blocks marked for removal + blocksToRemove.forEach(blockId => { + delete newState.blocks[blockId] + }) set(newState) - pushHistory(set, get, newState, 'Remove block') + pushHistory(set, get, newState, 'Remove block and children') get().updateLastSaved() get().sync.markDirty() get().sync.forceSync() @@ -262,10 +409,16 @@ export const useWorkflowStore = create()( }) }) + // Generate loops from custom loop blocks + const generatedLoops = generateLoopBlocks(get().blocks); + + // Merge with detected cycles loops + const mergedLoops = { ...newLoops, ...generatedLoops }; + const newState = { blocks: { ...get().blocks }, edges: newEdges, - loops: newLoops, + loops: mergedLoops, } set(newState) @@ -276,9 +429,24 @@ export const useWorkflowStore = create()( }, removeEdge: (edgeId: string) => { - const newEdges = get().edges.filter((edge) => edge.id !== edgeId) - - // Recalculate all loops after edge removal + // Validate the edge exists + const edgeToRemove = get().edges.find(edge => edge.id === edgeId); + if (!edgeToRemove) { + console.warn(`Attempted to remove non-existent edge: ${edgeId}`); + return; + } + + console.log('Removing edge in store:', { + id: edgeId, + source: edgeToRemove.source, + target: edgeToRemove.target + }); + + const newEdges = get().edges.filter((edge) => edge.id !== edgeId); + + // Recalculate all loops after edge removal + + //TODO: comment this loop logic out. const newLoops: Record = {} const processedPaths = new Set() const existingLoops = get().loops @@ -323,10 +491,13 @@ export const useWorkflowStore = create()( }) }) + + + // Only remove the specific edge by ID and maintain existing loops const newState = { blocks: { ...get().blocks }, edges: newEdges, - loops: newLoops, + loops: { }, } set(newState) @@ -377,10 +548,11 @@ export const useWorkflowStore = create()( const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (activeWorkflowId) { const currentState = get() + const generatedLoops = currentState.generateLoopBlocks() saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + loops: generatedLoops, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -404,6 +576,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], + loops: { ...get().loops }, } set(newState) @@ -454,7 +627,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loops: { ...get().loops }, + loops: get().generateLoopBlocks(), } // Update the subblock store with the duplicated values @@ -490,6 +663,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], + loops: { ...get().loops }, } set(newState) @@ -594,7 +768,7 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], - loops: { ...get().loops }, + loops: { ...state.loops }, })) get().updateLastSaved() get().sync.markDirty() @@ -611,69 +785,78 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], + loops: { ...state.loops }, })) get().updateLastSaved() // No sync needed for height changes, just visual }, - updateLoopIterations: (loopId: string, iterations: number) => { - const newState = { - blocks: { ...get().blocks }, - edges: [...get().edges], - loops: { - ...get().loops, - [loopId]: { - ...get().loops[loopId], - iterations: Math.max(1, Math.min(50, iterations)), // Clamp between 1-50 + updateLoopCount: (loopId: string, count: number) => + set(state => { + const block = state.blocks[loopId]; + if (!block || block.type !== 'loop') return state; + + return { + blocks: { + ...state.blocks, + [loopId]: { + ...block, + data: { + ...block.data, + count: Math.max(1, Math.min(50, count)) // Clamp between 1-50 + } + } }, - }, - } - - set(newState) - pushHistory(set, get, newState, 'Update loop iterations') - get().updateLastSaved() - get().sync.markDirty() - get().sync.forceSync() - }, - - updateLoopType: (loopId: string, loopType: Loop['loopType']) => { - const newState = { - blocks: { ...get().blocks }, - edges: [...get().edges], - loops: { - ...get().loops, - [loopId]: { - ...get().loops[loopId], - loopType, + edges: [...state.edges], + loops: { ...state.loops }, + }; + }), + + updateLoopType: (loopId: string, loopType: 'for' | 'forEach') => + set(state => { + const block = state.blocks[loopId]; + if (!block || block.type !== 'loop') return state; + + return { + blocks: { + ...state.blocks, + [loopId]: { + ...block, + data: { + ...block.data, + loopType + } + } }, - }, - } - - set(newState) - pushHistory(set, get, newState, 'Update loop type') - get().updateLastSaved() - get().sync.markDirty() - get().sync.forceSync() - }, - - updateLoopForEachItems: (loopId: string, items: string) => { - const newState = { - blocks: { ...get().blocks }, - edges: [...get().edges], - loops: { - ...get().loops, - [loopId]: { - ...get().loops[loopId], - forEachItems: items, + edges: [...state.edges], + loops: { ...state.loops }, + }; + }), + + updateLoopCollection: (loopId: string, collection: string) => + set(state => { + const block = state.blocks[loopId]; + if (!block || block.type !== 'loop') return state; + + return { + blocks: { + ...state.blocks, + [loopId]: { + ...block, + data: { + ...block.data, + collection + } + } }, - }, - } - - set(newState) - pushHistory(set, get, newState, 'Update forEach items') - get().updateLastSaved() - get().sync.markDirty() - get().sync.forceSync() + edges: [...state.edges], + loops: { ...state.loops }, + }; + }), + + // Function to convert UI loop blocks to execution format + generateLoopBlocks: () => { + return generateLoopBlocks(get().blocks); }, triggerUpdate: () => { @@ -724,7 +907,7 @@ export const useWorkflowStore = create()( const newState = { blocks: deployedState.blocks, edges: deployedState.edges, - loops: deployedState.loops, + loops: deployedState.loops || {}, // Ensure loops property is set isDeployed: true, needsRedeployment: false, hasActiveWebhook: false, // Reset webhook status diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index e409bbabc40..c320c7131e3 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -17,6 +17,7 @@ export interface BlockState { horizontalHandles?: boolean isWide?: boolean height?: number + data?: Record } export interface SubBlockState { @@ -25,6 +26,21 @@ export interface SubBlockState { value: string | number | string[][] | null } +export interface LoopBlock { + id: string; + loopType: 'for' | 'forEach'; + count: number; // UI representation of iterations + collection: string; // UI representation of forEachItems + width: number; + height: number; + executionState: { + currentIteration: number; + isExecuting: boolean; + startTime: null | number; + endTime: null | number; + } +} + export interface Loop { id: string nodes: string[] @@ -57,8 +73,10 @@ export interface SyncControl { } export interface WorkflowActions { - addBlock: (id: string, type: string, name: string, position: Position) => void + addBlock: (id: string, type: string, name: string, position: Position, data?: Record, parentId?: string, extent?: 'parent') => void updateBlockPosition: (id: string, position: Position) => void + updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void + updateParentId: (id: string, parentId: string, extent: 'parent') => void removeBlock: (id: string) => void addEdge: (edge: Edge) => void removeEdge: (edgeId: string) => void @@ -71,9 +89,10 @@ export interface WorkflowActions { toggleBlockWide: (id: string) => void updateBlockHeight: (id: string, height: number) => void triggerUpdate: () => void - updateLoopIterations: (loopId: string, iterations: number) => void - updateLoopType: (loopId: string, loopType: Loop['loopType']) => void - updateLoopForEachItems: (loopId: string, items: string) => void + updateLoopCount: (loopId: string, count: number) => void + updateLoopType: (loopId: string, loopType: 'for' | 'forEach') => void + updateLoopCollection: (loopId: string, collection: string) => void + generateLoopBlocks: () => Record setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void setScheduleStatus: (hasActiveSchedule: boolean) => void diff --git a/apps/sim/stores/workflows/workflow/utils.ts b/apps/sim/stores/workflows/workflow/utils.ts index 5a76c383011..625639698e9 100644 --- a/apps/sim/stores/workflows/workflow/utils.ts +++ b/apps/sim/stores/workflows/workflow/utils.ts @@ -1,4 +1,6 @@ import { Edge } from 'reactflow' +import { Loop } from './types'; +import { BlockState } from './types'; /** * Performs a depth-first search to detect all cycles in the graph @@ -56,3 +58,85 @@ export function detectCycle( paths: allCycles, } } + +/** + * Convert UI loop block to executor Loop format + * + * @param loopBlockId - ID of the loop block to convert + * @param blocks - Record of all blocks in the workflow + * @returns Loop object for execution engine or undefined if not a valid loop + */ +export function convertLoopBlockToLoop( + loopBlockId: string, + blocks: Record +): Loop | undefined { + const loopBlock = blocks[loopBlockId]; + if (!loopBlock || loopBlock.type !== 'loop') return undefined; + + return { + id: loopBlockId, + nodes: findChildNodes(loopBlockId, blocks), + iterations: loopBlock.data?.count || 5, + loopType: loopBlock.data?.loopType || 'for', + forEachItems: loopBlock.data?.collection || '', + }; +} + +/** + * Find all nodes that are children of this loop + * + * @param loopId - ID of the loop to find children for + * @param blocks - Record of all blocks in the workflow + * @returns Array of node IDs that are direct children of this loop + */ +export function findChildNodes(loopId: string, blocks: Record): string[] { + return Object.values(blocks) + .filter(block => block.data?.parentId === loopId) + .map(block => block.id); +} + +/** + * Find all descendant nodes, including children, grandchildren, etc. + * + * @param loopId - ID of the loop to find descendants for + * @param blocks - Record of all blocks in the workflow + * @returns Array of node IDs that are descendants of this loop + */ +export function findAllDescendantNodes(loopId: string, blocks: Record): string[] { + const descendants: string[] = []; + const findDescendants = (parentId: string) => { + const children = Object.values(blocks) + .filter(block => block.data?.parentId === parentId) + .map(block => block.id); + + children.forEach(childId => { + descendants.push(childId); + findDescendants(childId); + }); + }; + + findDescendants(loopId); + return descendants; +} + +/** + * Builds a complete collection of loops from the UI blocks + * + * @param blocks - Record of all blocks in the workflow + * @returns Record of Loop objects for execution engine + */ +export function generateLoopBlocks(blocks: Record): Record { + const loops: Record = {}; + + // Find all loop nodes + Object.entries(blocks) + .filter(([_, block]) => block.type === 'loop') + .forEach(([id, block]) => { + const loop = convertLoopBlockToLoop(id, blocks); + if (loop) { + loops[id] = loop; + } + }); + + return loops; +} diff --git a/apps/sim/tsconfig.json b/apps/sim/tsconfig.json index 58e1fd44cbc..47c4525adc1 100644 --- a/apps/sim/tsconfig.json +++ b/apps/sim/tsconfig.json @@ -50,4 +50,4 @@ "telemetry.config.js" ], "exclude": ["node_modules"] -} +} \ No newline at end of file