From 5d86d5cc8e369ec3811e1208b4925aeafed01d10 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Tue, 13 May 2025 17:01:23 -0700 Subject: [PATCH 01/13] (feature) adding the loop block --- apps/sim/app/globals.css | 152 +++++ .../components/loop-config-badges.tsx | 227 +++++++ .../[id]/components/loop-node/loop-config.ts | 32 + .../w/[id]/components/loop-node/loop-node.tsx | 147 +++++ .../toolbar-block/toolbar-block.tsx | 4 +- .../toolbar-loop-block/toolbar-loop-block.tsx | 47 ++ .../app/w/[id]/components/toolbar/toolbar.tsx | 4 +- .../workflow-loop/workflow-loop.tsx | 4 +- apps/sim/app/w/[id]/workflow.tsx | 592 ++++++++++++++++-- apps/sim/components/icons.tsx | 28 + apps/sim/stores/workflows/workflow/store.ts | 194 +++++- apps/sim/stores/workflows/workflow/types.ts | 5 +- 12 files changed, 1354 insertions(+), 82 deletions(-) create mode 100644 apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx create mode 100644 apps/sim/app/w/[id]/components/loop-node/loop-config.ts create mode 100644 apps/sim/app/w/[id]/components/loop-node/loop-node.tsx create mode 100644 apps/sim/app/w/[id]/components/toolbar/components/toolbar-loop-block/toolbar-loop-block.tsx diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index 15289fc8864..ff002e7826e 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -336,3 +336,155 @@ input[type='search']::-ms-clear { z-index: 40; /* Higher z-index to appear above content */ } + +/* Drag and drop styles for loop node */ +.drag-target { + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.drag-target.drag-over { + border-color: hsl(var(--primary)); + background-color: hsl(var(--primary) / 0.05); +} + +/* Improve dragging performance */ +.smooth-drag-container { + contain: layout style paint; + transform: translateZ(0); + backface-visibility: hidden; + perspective: 1000px; + will-change: transform; + transition: transform 0.01s linear; + -webkit-transform-style: preserve-3d; + -webkit-backface-visibility: hidden; +} + +.smooth-drag-container * { + transform: translateZ(0); + backface-visibility: hidden; + -webkit-backface-visibility: hidden; +} + +/* Optimize node rendering for smooth dragging performance */ +.react-flow__node-workflowBlock { + contain: layout style; + will-change: transform; + user-select: none; + touch-action: none; +} + +/* React Flow position transitions within loops */ +.react-flow__node[data-parent-node-id] { + transition: transform 0.05s ease; + pointer-events: all; +} + +/* Prevent jumpy drag behavior */ +.loop-drop-container .react-flow__node { + transform-origin: center; + position: absolute; +} + +/* Remove default border from React Flow group nodes */ +.react-flow__node-group { + border: none; + background-color: transparent; + outline: none; + box-shadow: none; +} + +/* Ensure child nodes stay within parent bounds */ +.react-flow__node[data-parent-node-id] .react-flow__handle { + z-index: 30; +} + +/* Enhanced drag detection */ +.react-flow__node-group.dragging-over { + border: 2px solid #40E0D0; + background-color: rgba(34,197,94,0.05); + transition: all 0.2s ease-in-out; +} + +/* Loop node animation for drag operations */ +@keyframes loop-node-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(64, 224, 208, 0.3); + } + 70% { + box-shadow: 0 0 0 6px rgba(64, 224, 208, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(64, 224, 208, 0); + } +} + +.loop-node-drag-over { + animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + border-color: #40E0D0 !important; + border-width: 2px !important; + border-style: solid !important; + background-color: rgba(64, 224, 208, 0.08) !important; + transition: all 0.2s ease-in-out; + box-shadow: 0 0 0 8px rgba(64, 224, 208, 0.1); +} +/* Make resizer handles more visible */ +.react-flow__resize-control { + z-index: 10; + pointer-events: all !important; +} + +.react-flow__resize-control.bottom-right { + width: 12px !important; + height: 12px !important; + background-color: hsl(var(--primary)) !important; + border: 2px solid white !important; + transition: transform 0.2s ease-in-out; + opacity: 1 !important; + visibility: visible !important; + pointer-events: all !important; + position: absolute !important; + right: 0 !important; + bottom: 0 !important; +} + +.react-flow__resize-control.bottom-right:hover { + transform: scale(1.3); + background-color: #40E0D0 !important; +} + +/* Ensure NodeResizer is always above the custom resize handle */ +.react-flow__noderesize { + z-index: 11 !important; + pointer-events: all !important; +} + +/* Ensure parent borders are visible when hovering over resize controls */ +.react-flow__node-group:hover, +.hover-highlight { + border-color: #1e293b !important; +} + +/* Ensure hover effects work well */ +.group-node-container:hover .react-flow__resize-control.bottom-right { + opacity: 1 !important; + visibility: visible !important; +} + +/* Child node styling */ +.react-flow__node[data-parent] { + border: 1px dashed #4e9eff !important; + transition: all 0.2s ease; +} + +/* Visual feedback for nodes that are children of loops */ +.react-flow__node[data-parent]::after { + content: ''; + position: absolute; + top: -8px; + right: -8px; + width: 12px; + height: 12px; + border-radius: 50%; + background-color: #4e9eff; + z-index: 5; +} diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx b/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx new file mode 100644 index 00000000000..be42f87ff40 --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useState } from 'react' +import { IterationCw, ListOrdered, 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 { createLogger } from '@/lib/logs/console-logger' +import Editor from 'react-simple-code-editor' +import { highlight, languages } from 'prismjs' +import 'prismjs/components/prism-javascript' +import 'prismjs/themes/prism.css' + +const logger = createLogger('LoopConfigBadges') + +interface LoopConfigBadgesProps { + nodeId: string + data: any +} + +export function LoopConfigBadges({ nodeId, data }: LoopConfigBadgesProps) { + // 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: any) => { + 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' ? ( + + ) : ( + + )} + {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..a3827091b9d --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/loop-config.ts @@ -0,0 +1,32 @@ +import { RepeatIcon } from 'lucide-react' + +export const LoopTool = { + id: 'loop', + type: 'loop', + name: 'Loop', + description: 'Create a Loop', + icon: RepeatIcon, + bgColor: '#40E0D0', + data: { + label: 'Loop', + loopType: 'for', + count: 5, + collection: '', + width: 800, + height: 1000, + extent: 'parent', + // Store loop execution state + executionState: { + currentIteration: 0, + isExecuting: false, + startTime: null, + endTime: null, + } + }, + style: { + width: 800, + height: 1000, + }, + // 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..de3fd3b6130 --- /dev/null +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -0,0 +1,147 @@ +import { memo, useCallback, useState, useEffect } from 'react' +import { Handle, NodeProps, Position, NodeResizer, useReactFlow } from 'reactflow' +import { X, PlayCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import { createLogger } from '@/lib/logs/console-logger' +import { getBlock } from '@/blocks' +import { useGeneralStore } from '@/stores/settings/general/store' +import { LoopConfigBadges } from './components/loop-config-badges' + +const logger = createLogger('LoopNode') + +export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { + // const { getNodes, setNodes, screenToFlowPosition } = useReactFlow() + const { updateNodeDimensions } = useWorkflowStore() + + // // Handle resize with boundaries + const handleResize = useCallback((evt: any, params: { width: number; height: number }) => { + // Always ensure minimum dimensions + const minWidth = 800 + const minHeight = 1000 + + const finalWidth = Math.max(params.width, minWidth) + const finalHeight = Math.max(params.height, minHeight) + + // Update node dimensions + updateNodeDimensions(id, { width: finalWidth, height: finalHeight }) + }, [id, updateNodeDimensions]) + + return ( +
{ + e.preventDefault(); + try { + // Check for toolbar items + if (e.dataTransfer?.types.includes('application/json')) { + const rawData = e.dataTransfer.getData('application/json'); + if (rawData) { + const data = JSON.parse(rawData); + const type = data.type || (data.data && data.data.type); + } + } + + // If we get here, no valid drag is happening + } catch (err) { + logger.error('Error checking dataTransfer:', err); + } + }} + data-node-id={id} + data-type="loopNode" + > + {/* Critical drag handle that controls only the loop node movement */} +
+ + {/* Custom visible resize handle */} +
+
+ + {/* Child nodes container */} +
+ {/* Delete button - now always visible */} +
{ + e.stopPropagation(); + useWorkflowStore.getState().removeBlock(id); + }} + > + +
+ + {/* Loop Start Block - positioned at left middle */} +
+
+
+ +
+ +
+ +
+
+
+
+ + {/* 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..87130b24dc3 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,13 +12,15 @@ 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 const event = new CustomEvent('add-block-from-toolbar', { detail: { type: config.type, + clientX: e.clientX, + clientY: e.clientY }, }) window.dispatchEvent(event) 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-loop/workflow-loop.tsx b/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx index 9949f37f784..5f52d3b4cc6 100644 --- a/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx +++ b/apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx @@ -13,7 +13,7 @@ function createLoopLabelNode(loopId: string, bounds: { x: number; y: number }) { id: `loop-label-${loopId}`, type: 'loopLabel', position: { x: 0, y: -32 }, - parentNode: `loop-${loopId}`, + parentId: `loop-${loopId}`, draggable: false, data: { loopId, @@ -38,7 +38,7 @@ function createLoopInputNode(loopId: string, bounds: { x: number; width: number id: `loop-input-${loopId}`, type: 'loopInput', position: { x: bounds.width - BADGE_WIDTH, y: -32 }, // Position from right edge - parentNode: `loop-${loopId}`, + parentId: `loop-${loopId}`, draggable: false, data: { loopId, diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index a345d747b16..77c33aea9fa 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -30,17 +30,15 @@ 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' +import { debounce } from 'lodash' 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 } @@ -52,15 +50,17 @@ function WorkflowContent() { // 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) // 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 } = + const { blocks, edges, loops, addBlock, updateBlockPosition, addEdge, removeEdge, updateParentId } = useWorkflowStore() const { setValue: setSubBlockValue } = useSubBlockStore() const { markAllAsRead } = useNotificationStore() @@ -70,6 +70,123 @@ function WorkflowContent() { const { activeBlockIds, pendingBlocks } = useExecutionStore() const { isDebugModeEnabled } = useGeneralStore() + // Helper function to check if a point is inside a loop node + const isPointInLoopNode = useCallback((position: { x: number, y: number }): { + 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 || 800), + top: n.position.y, + bottom: n.position.y + (n.data?.height || 1000) + }; + + 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 || 800, + height: n.data?.height || 1000 + } + })); + + // 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; + }, [getNodes]); + + // Helper function to calculate proper dimensions for a loop node based on its children + const calculateLoopDimensions = useCallback((loopId: string): { width: number, height: number } => { + // Default minimum dimensions + const minWidth = 800; + const minHeight = 1000; + + // 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 + const nodeWidth = node.type === 'condition' ? 250 : 200; + const nodeHeight = node.type === 'condition' ? 350 : 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 padding around the bounding box (give extra space on the right/bottom) + const padding = 200; + + // Ensure the width and height are never less than the minimums + const width = Math.max(minWidth, maxX + padding); + const height = Math.max(minHeight, maxY + padding); + + return { width, height }; + }, [getNodes]); + + // Function to resize all loop nodes + const resizeLoopNodes = useCallback(() => { + // Find all loop nodes + const loopNodes = getNodes().filter(node => node.type === 'loopNode'); + + // Resize each loop node based on its children + loopNodes.forEach(loopNode => { + const dimensions = calculateLoopDimensions(loopNode.id); + + // Only update if dimensions have changed (to avoid unnecessary updates) + if (dimensions.width !== loopNode.data?.width || + dimensions.height !== loopNode.data?.height) { + logger.info('Resizing loop node', { + loopId: loopNode.id, + newDimensions: dimensions, + oldDimensions: { + width: loopNode.data?.width, + height: loopNode.data?.height + } + }); + // Use the updateNodeDimensions from the workflow store + useWorkflowStore.getState().updateNodeDimensions(loopNode.id, dimensions); + } + }); + }, [getNodes, calculateLoopDimensions]); + + // Create a debounced version of resizeLoopNodes to avoid too many updates + const debouncedResizeLoopNodes = useMemo( + () => debounce(resizeLoopNodes, 100), + [resizeLoopNodes] + ); + // Initialize workflow useEffect(() => { if (typeof window !== 'undefined') { @@ -207,44 +324,174 @@ function WorkflowContent() { y: event.clientY - reactFlowBounds.top, }) + // Check if dropping inside a loop node + const loopInfo = isPointInLoopNode(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') { + // Don't allow loops to be created inside other loops + const id = crypto.randomUUID() + const name = 'Loop' + + // Add the loop node with default dimensions + addBlock(id, data.type, name, position, { + width: 800, + height: 1000, + type: 'loopNode' + }) + + return + } + const blockConfig = getBlock(data.type) if (!blockConfig) { 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', - }) + 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 + setTimeout(() => debouncedResizeLoopNodes(), 50); + + // 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, isPointInLoopNode, 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 = isPointInLoopNode(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, isPointInLoopNode]); + // Init workflow useEffect(() => { if (!isInitialized) return @@ -304,15 +551,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 +558,22 @@ function WorkflowContent() { return } + // Handle loop nodes differently + if (block.type === 'loop') { + nodeArray.push({ + id: block.id, + type: 'loopNode', + position: block.position, + dragHandle: '.workflow-drag-handle', + data: { + ...block.data, + width: block.data?.width || 800, + height: block.data?.height || 1000, + }, + }) + return + } + const blockConfig = getBlock(block.type) if (!blockConfig) { logger.error(`No configuration found for block type: ${block.type}`, { @@ -328,17 +582,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 +591,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 +604,7 @@ function WorkflowContent() { }) return nodeArray - }, [blocks, loops, activeBlockIds, pendingBlocks, isDebugModeEnabled]) + }, [blocks, activeBlockIds, pendingBlocks, isDebugModeEnabled]) // Update nodes const onNodesChange = useCallback( @@ -368,25 +613,28 @@ 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(); + + // Clean up on unmount + return () => { + debouncedResizeLoopNodes.cancel(); + }; + }, [nodes, debouncedResizeLoopNodes]); + + // Update edges const onEdgesChange = useCallback( (changes: any) => { @@ -413,6 +661,216 @@ function WorkflowContent() { [addEdge] ) + // Handle node drag to detect intersections with loop nodes + const onNodeDrag = useCallback( + (event: React.MouseEvent, node: any) => { + // Skip if dragging a loop node (loops can't be children) + if (node.type === 'loopNode') return; + + // 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; + + // Find intersections with loop nodes + 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 + + // Get more accurate node dimensions - can be improved with dynamic size detection + const nodeWidth = node.type === 'condition' ? 250 : 200; + const nodeHeight = node.type === 'condition' ? 150 : 100; + + // Check if node is within the bounds of the loop node + const nodeRect = { + left: node.position.x, + right: node.position.x + nodeWidth, + top: node.position.y, + bottom: node.position.y + nodeHeight + } + + const loopRect = { + left: n.position.x, + right: n.position.x + (n.data?.width || 800), + top: n.position.y, + bottom: n.position.y + (n.data?.height || 1000) + } + + return ( + nodeRect.left < loopRect.right && + nodeRect.right > loopRect.left && + nodeRect.top < loopRect.bottom && + nodeRect.bottom > loopRect.top + ) + }) + + // Update potential parent if there's at least one intersecting loop node + if (intersectingNodes.length > 0) { + // Find smallest loop (for handling nested loops) + const smallestLoop = intersectingNodes.sort((a, b) => { + const aSize = (a.data?.width || 800) * (a.data?.height || 1000) + const bSize = (b.data?.width || 800) * (b.data?.height || 1000) + return aSize - bSize + })[0] + + // Set potential parent and add visual indicator + setPotentialParentId(smallestLoop.id) + + // Add highlight class and change cursor + const loopElement = document.querySelector(`[data-id="${smallestLoop.id}"]`) + if (loopElement) { + loopElement.classList.add('loop-node-drag-over') + + // Change cursor to indicate item can be dropped + 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) + + // Reset cursor + document.body.style.cursor = '' + } + } + }, + [getNodes, potentialParentId, blocks] + ) + + // Handle node drag stop to establish parent-child relationships + const onNodeDragStop = useCallback( + (event: React.MouseEvent, node: any) => { + // Skip if dragging a loop node (loops can't be children) + if (node.type === 'loopNode') { + setDraggedNodeId(null) + return + } + + // If the node has a parent, don't update the parent relationship + if (node.parentId) { + return + } + + logger.info('Node drag stopped', { + nodeId: node.id, + potentialParentId, + nodeType: node.type + }) + + // Get the current parent ID of the node being dragged + const currentParentId = blocks[node.id]?.data?.parentId || null; + + // Find intersections with loop nodes + 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 + + // Get more accurate node dimensions - can be improved with dynamic size detection + const nodeWidth = node.type === 'condition' ? 250 : 200; + const nodeHeight = node.type === 'condition' ? 150 : 100; + + // Check if node is within the bounds of the loop node + const nodeRect = { + left: node.position.x, + right: node.position.x + nodeWidth, + top: node.position.y, + bottom: node.position.y + nodeHeight + } + + const loopRect = { + left: n.position.x, + right: n.position.x + (n.data?.width || 800), + top: n.position.y, + bottom: n.position.y + (n.data?.height || 1000) + } + + return ( + nodeRect.left < loopRect.right && + nodeRect.right > loopRect.left && + nodeRect.top < loopRect.bottom && + nodeRect.bottom > loopRect.top + ) + }) + + // Remove all highlight classes + document.querySelectorAll('.loop-node-drag-over').forEach(el => { + el.classList.remove('loop-node-drag-over') + }) + + // Reset cursor + document.body.style.cursor = '' + + // If intersecting with loops, establish parent-child relationship + if (intersectingNodes.length > 0) { + // Find smallest loop (for handling nested loops) + const smallestLoop = intersectingNodes.sort((a, b) => { + const aSize = (a.data?.width || 800) * (a.data?.height || 1000) + const bSize = (b.data?.width || 800) * (b.data?.height || 1000) + return aSize - bSize + })[0] + + // Calculate position relative to parent + const relativePosition = { + x: node.position.x - smallestLoop.position.x, + y: node.position.y - smallestLoop.position.y + } + + // Check if the node is already a child of this loop + if (currentParentId === smallestLoop.id) { + // Node is already a child of this loop, just updating position + logger.info('Node already a child of this loop, just updating position', { + blockId: node.id, + parentId: smallestLoop.id, + relativePosition + }); + + // Only update the position, not the parent relationship + updateBlockPosition(node.id, relativePosition); + + // Resize the loop after moving the child + setTimeout(() => debouncedResizeLoopNodes(), 50); + } else { + // Node is not a child of this loop yet, establish the relationship + logger.info('Setting new parent-child relationship', { + blockId: node.id, + parentId: smallestLoop.id, + relativePosition + }); + + // Update both position and parent relationship + updateBlockPosition(node.id, relativePosition); + updateParentId(node.id, smallestLoop.id, 'parent'); + + // Resize the loop to accommodate the new child + setTimeout(() => debouncedResizeLoopNodes(), 50); + } + } else if (blocks[node.id]?.data?.parentId) { + // If node was in a loop but is now outside, handle removal + logger.info('Node dragged out of loop - parent relationship should be removed', { + blockId: node.id, + currentParentId: blocks[node.id]?.data?.parentId + }) + + // For now, we keep the node where it is + // You may want to handle this case based on your store implementation + } + + // Reset drag state + setDraggedNodeId(null) + setPotentialParentId(null) + }, + [getNodes, potentialParentId, blocks, updateParentId, updateBlockPosition] + ) + + // Update onPaneClick to only handle edge selection const onPaneClick = useCallback(() => { setSelectedEdgeId(null) @@ -496,7 +954,7 @@ function WorkflowContent() { nodeTypes={nodeTypes} edgeTypes={edgeTypes} onDrop={onDrop} - onDragOver={(e) => e.preventDefault()} + onDragOver={onDragOver} fitView minZoom={0.1} maxZoom={1.3} @@ -524,6 +982,14 @@ function WorkflowContent() { edgesFocusable={true} edgesUpdatable={true} className="workflow-container h-full" + onNodeDrag={onNodeDrag} + onNodeDragStop={onNodeDragStop} + snapToGrid={false} + snapGrid={[20, 20]} + elevateEdgesOnSelect={true} + elevateNodesOnSelect={true} + autoPanOnConnect={true} + autoPanOnNodeDrag={true} > diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 942107e3507..9c732050439 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2463,3 +2463,31 @@ export function ClayIcon(props: SVGProps) { ) } + +export function LoopIcon() { + return ( + + ) +} diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index d4ece4ef70b..e3120ef7ebf 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -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().loops }, + } + + 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,6 +154,7 @@ export const useWorkflowStore = create()( horizontalHandles: true, isWide: false, height: 0, + data: nodeData, }, }, edges: [...get().edges], @@ -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,24 @@ export const useWorkflowStore = create()( loops: { ...get().loops }, } + // Find and remove all child blocks if this is a parent node + const blocksToRemove = new Set([id]) + Object.entries(newState.blocks).forEach(([blockId, block]) => { + if (block.data?.parentId === id) { + blocksToRemove.add(blockId) + } + }) + // 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) => ({ @@ -169,24 +309,35 @@ 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), + if (loop && loop.nodes) { + const hasRemovedNodes = loop.nodes.some(nodeId => blocksToRemove.has(nodeId)) + if (hasRemovedNodes) { + // If removing these nodes would leave the loop empty, delete the loop + const remainingNodes = loop.nodes.filter(nodeId => !blocksToRemove.has(nodeId)) + if (remainingNodes.length === 0) { + delete newState.loops[loopId] + } else { + newState.loops[loopId] = { + ...loop, + nodes: remainingNodes, + } } } } }) - // Delete the block last - delete newState.blocks[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 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() @@ -276,9 +427,24 @@ export const useWorkflowStore = create()( }, removeEdge: (edgeId: string) => { - const newEdges = get().edges.filter((edge) => edge.id !== edgeId) + // 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 diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index e409bbabc40..00659d48eb1 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 { @@ -57,8 +58,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 From 214a1e0d633b34f61c786f62dad24838ce7112ef Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Tue, 13 May 2025 20:26:34 -0700 Subject: [PATCH 02/13] (fix) cleaned up edge detection --- .../w/[id]/components/loop-node/loop-node.tsx | 49 ++------ .../workflow-edge/workflow-edge.tsx | 23 ++-- apps/sim/app/w/[id]/workflow.tsx | 111 ++++++++++++++---- 3 files changed, 111 insertions(+), 72 deletions(-) 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 index de3fd3b6130..33786c9ce9e 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -1,32 +1,12 @@ -import { memo, useCallback, useState, useEffect } from 'react' -import { Handle, NodeProps, Position, NodeResizer, useReactFlow } from 'reactflow' +import { memo } from 'react' +import { Handle, NodeProps, Position } from 'reactflow' import { X, PlayCircle } from 'lucide-react' import { cn } from '@/lib/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import { createLogger } from '@/lib/logs/console-logger' -import { getBlock } from '@/blocks' -import { useGeneralStore } from '@/stores/settings/general/store' import { LoopConfigBadges } from './components/loop-config-badges' -const logger = createLogger('LoopNode') export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { - // const { getNodes, setNodes, screenToFlowPosition } = useReactFlow() - const { updateNodeDimensions } = useWorkflowStore() - - // // Handle resize with boundaries - const handleResize = useCallback((evt: any, params: { width: number; height: number }) => { - // Always ensure minimum dimensions - const minWidth = 800 - const minHeight = 1000 - - const finalWidth = Math.max(params.width, minWidth) - const finalHeight = Math.max(params.height, minHeight) - - // Update node dimensions - updateNodeDimensions(id, { width: finalWidth, height: finalHeight }) - }, [id, updateNodeDimensions]) - return (
{ backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent', transition: 'width 0.2s ease-out, height 0.2s ease-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out', }} - onDragOver={(e) => { - e.preventDefault(); - try { - // Check for toolbar items - if (e.dataTransfer?.types.includes('application/json')) { - const rawData = e.dataTransfer.getData('application/json'); - if (rawData) { - const data = JSON.parse(rawData); - const type = data.type || (data.data && data.data.type); - } - } - - // If we get here, no valid drag is happening - } catch (err) { - logger.error('Error checking dataTransfer:', err); - } - }} data-node-id={id} data-type="loopNode" > @@ -96,7 +59,12 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { {/* Loop Start Block - positioned at left middle */}
-
+
@@ -107,6 +75,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { position={Position.Right} id="loop-start-source" className="!bg-[#40E0D0] !w-3 !h-3 z-40" + data-parent-id={id} />
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..9ac2062b525 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 @@ -10,6 +10,7 @@ export const WorkflowEdge = ({ sourcePosition, targetPosition, data, + style, }: EdgeProps) => { const isHorizontal = sourcePosition === 'right' || sourcePosition === 'left' @@ -24,19 +25,25 @@ export const WorkflowEdge = ({ offset: isHorizontal ? 30 : 20, }) - const isSelected = id === data?.selectedEdgeId + // Check if this edge is selected using the enhanced selection state + const isSelected = data?.selectedEdgeInfo?.id === id; + const isInsideLoop = data?.isInsideLoop; + + // Merge any style props passed from parent + const edgeStyle = { + strokeWidth: isSelected ? 2.5 : 2, + stroke: isSelected ? '#475569' : '#94a3b8', + strokeDasharray: '5,5', + zIndex: isInsideLoop ? 100 : -10, + ...style + }; return ( <>
(null) const [isInitialized, setIsInitialized] = useState(false) const { mode, isExpanded } = useSidebarStore() // In hover mode, act as if sidebar is always collapsed for layout purposes @@ -53,6 +52,11 @@ function WorkflowContent() { // State for tracking node dragging const [draggedNodeId, setDraggedNodeId] = useState(null) const [potentialParentId, setPotentialParentId] = useState(null) + // Enhanced edge selection with parent context + const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{ + id: string; + parentLoopId?: string; + } | null>(null) // Hooks const params = useParams() const router = useRouter() @@ -145,12 +149,18 @@ function WorkflowContent() { maxY = Math.max(maxY, node.position.y + nodeHeight); }); - // Add padding around the bounding box (give extra space on the right/bottom) - const padding = 200; + + // Add buffer padding to all sides (20px buffer before edges) + const sidePadding = 220; // 200px original padding + 20px buffer // Ensure the width and height are never less than the minimums - const width = Math.max(minWidth, maxX + padding); - const height = Math.max(minHeight, maxY + padding); + // Apply padding to all sides (left/right and top/bottom) + const width = Math.max(minWidth, maxX + sidePadding); + const height = Math.max(minHeight, maxY + sidePadding + 100); + console.log('minHeight', minHeight) + console.log('maxY', maxY) + console.log('sidePadding', sidePadding) + console.log('height', height) return { width, height }; }, [getNodes]); @@ -183,7 +193,7 @@ function WorkflowContent() { // Create a debounced version of resizeLoopNodes to avoid too many updates const debouncedResizeLoopNodes = useMemo( - () => debounce(resizeLoopNodes, 100), + () => debounce(resizeLoopNodes, 50), // Reduced from 100ms to 50ms for better responsiveness [resizeLoopNodes] ); @@ -651,6 +661,35 @@ function WorkflowContent() { 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; + + // 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 + addEdge({ + ...connection, + id: crypto.randomUUID(), + type: 'workflowEdge', + }); + return; + } + + // Prevent connections across loop boundaries + if ((sourceParentId && !targetParentId) || (!sourceParentId && targetParentId) || + (sourceParentId && targetParentId && sourceParentId !== targetParentId)) { + return; + } + addEdge({ ...connection, id: crypto.randomUUID(), @@ -658,7 +697,7 @@ function WorkflowContent() { }) } }, - [addEdge] + [addEdge, getNodes] ) // Handle node drag to detect intersections with loop nodes @@ -873,39 +912,63 @@ function WorkflowContent() { // 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) - }, []) + 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; + + setSelectedEdgeInfo({ + id: edge.id, + parentLoopId + }); + }, [getNodes]); // 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) + 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 isInsideLoop = Boolean(sourceNode?.parentId) || Boolean(targetNode?.parentId); + + // Determine if this edge is selected + const isSelected = selectedEdgeInfo?.id === edge.id; + + return { + ...edge, + type: edge.type || 'workflowEdge', + data: { + selectedEdgeInfo, + isInsideLoop, + onDelete: (edgeId: string) => { + removeEdge(edgeId) + setSelectedEdgeInfo(null) + }, }, - }, - })) + } + }) // Handle keyboard shortcuts 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) { + removeEdge(selectedEdgeInfo.id) + setSelectedEdgeInfo(null) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEdgeId, removeEdge]) + }, [selectedEdgeInfo, removeEdge]) // Handle sub-block value updates from custom events useEffect(() => { From fdc79890b12eeb3ef0e035772833479a8356cfae Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Wed, 14 May 2025 02:00:42 -0700 Subject: [PATCH 03/13] (fix) styling and edge selection --- apps/sim/app/globals.css | 6 +- .../components/loop-config-badges.tsx | 13 +- .../w/[id]/components/loop-node/loop-node.tsx | 60 ++-- .../workflow-edge/workflow-edge.tsx | 30 +- apps/sim/app/w/[id]/workflow.tsx | 158 +++++++--- apps/sim/stores/workflows/registry/store.ts | 18 +- apps/sim/stores/workflows/workflow/store.ts | 278 +++++++++--------- apps/sim/stores/workflows/workflow/types.ts | 8 +- 8 files changed, 330 insertions(+), 241 deletions(-) diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index ff002e7826e..f8d7ca1e91e 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 { diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx b/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx index be42f87ff40..d1a6638d775 100644 --- a/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx @@ -1,17 +1,15 @@ import { useCallback, useEffect, useState } from 'react' -import { IterationCw, ListOrdered, ChevronDown } from 'lucide-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 { createLogger } from '@/lib/logs/console-logger' import Editor from 'react-simple-code-editor' import { highlight, languages } from 'prismjs' import 'prismjs/components/prism-javascript' import 'prismjs/themes/prism.css' -const logger = createLogger('LoopConfigBadges') interface LoopConfigBadgesProps { nodeId: string @@ -103,7 +101,7 @@ export function LoopConfigBadges({ nodeId, data }: LoopConfigBadgesProps) { }, [updateNodeData]) return ( -
+
{/* Loop Type Badge */} e.stopPropagation()}> @@ -115,11 +113,6 @@ export function LoopConfigBadges({ nodeId, data }: LoopConfigBadgesProps) { 'flex items-center gap-1' )} > - {loopType === 'for' ? ( - - ) : ( - - )} {loopType === 'for' ? 'For Loop' : 'For Each'} @@ -135,7 +128,6 @@ export function LoopConfigBadges({ nodeId, data }: LoopConfigBadgesProps) { )} onClick={() => handleLoopTypeChange('for')} > - For Loop
handleLoopTypeChange('forEach')} > - For Each
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 index 33786c9ce9e..fce041a7e53 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -1,6 +1,7 @@ import { memo } from 'react' import { Handle, NodeProps, Position } from 'reactflow' -import { X, PlayCircle } from 'lucide-react' +import { Trash2 } from 'lucide-react' +import { StartIcon } from '@/components/icons' import { cn } from '@/lib/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { LoopConfigBadges } from './components/loop-config-badges' @@ -10,7 +11,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { return (
{ borderRadius: '8px', position: 'relative', overflow: 'visible', - border: data?.state === 'valid' ? '2px solid #40E0D0' : '2px dashed #94a3b8', + border: '2px solid #94a3b8', backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent', transition: 'width 0.2s ease-out, height 0.2s ease-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out', }} @@ -29,55 +30,64 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { {/* Critical drag handle that controls only the loop node movement */}
{/* Custom visible resize handle */}
- {/* Child nodes container */} + {/* Child nodes container - Set pointerEvents: none to allow events to reach edges */}
{/* Delete button - now always visible */}
{ e.stopPropagation(); useWorkflowStore.getState().removeBlock(id); }} + style={{ pointerEvents: 'auto' }} // Re-enable pointer events for this button > - +
{/* Loop Start Block - positioned at left middle */} -
+
-
- -
+ -
- -
+
@@ -86,11 +96,12 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { @@ -98,11 +109,12 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { 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 9ac2062b525..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, @@ -25,16 +28,17 @@ export const WorkflowEdge = ({ offset: isHorizontal ? 30 : 20, }) - // Check if this edge is selected using the enhanced selection state - const isSelected = data?.selectedEdgeInfo?.id === id; - const isInsideLoop = data?.isInsideLoop; + // 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', - zIndex: isInsideLoop ? 100 : -10, ...style }; @@ -44,7 +48,11 @@ export const WorkflowEdge = ({ path={edgePath} data-testid="workflow-edge" style={edgeStyle} - interactionWidth={20} + interactionWidth={30} + data-edge-id={id} + data-parent-loop-id={parentLoopId} + data-is-selected={isSelected ? 'true' : 'false'} + data-is-inside-loop={isInsideLoop ? 'true' : 'false'} /> { - 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); } }} > @@ -77,4 +87,4 @@ export const WorkflowEdge = ({ )} ) -} +} \ 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 83b00438e29..0b79822d0e8 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -52,10 +52,11 @@ function WorkflowContent() { // State for tracking node dragging const [draggedNodeId, setDraggedNodeId] = useState(null) const [potentialParentId, setPotentialParentId] = useState(null) - // Enhanced edge selection with parent context + // 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() @@ -64,7 +65,8 @@ function WorkflowContent() { // Store access const { workflows, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() - const { blocks, edges, loops, addBlock, updateBlockPosition, addEdge, removeEdge, updateParentId } = + //Removed loops from the store + const { blocks, edges, addBlock, updateBlockPosition, addEdge, removeEdge, updateParentId } = useWorkflowStore() const { setValue: setSubBlockValue } = useSubBlockStore() const { markAllAsRead } = useNotificationStore() @@ -157,10 +159,6 @@ function WorkflowContent() { // Apply padding to all sides (left/right and top/bottom) const width = Math.max(minWidth, maxX + sidePadding); const height = Math.max(minHeight, maxY + sidePadding + 100); - console.log('minHeight', minHeight) - console.log('maxY', maxY) - console.log('sidePadding', sidePadding) - console.log('height', height) return { width, height }; }, [getNodes]); @@ -191,11 +189,8 @@ function WorkflowContent() { }); }, [getNodes, calculateLoopDimensions]); - // Create a debounced version of resizeLoopNodes to avoid too many updates - const debouncedResizeLoopNodes = useMemo( - () => debounce(resizeLoopNodes, 50), // Reduced from 100ms to 50ms for better responsiveness - [resizeLoopNodes] - ); + // Use direct resizing function instead of debounced version for immediate updates + const debouncedResizeLoopNodes = resizeLoopNodes; // Initialize workflow useEffect(() => { @@ -392,7 +387,8 @@ function WorkflowContent() { }); // Resize the loop node to fit the new block - setTimeout(() => debouncedResizeLoopNodes(), 50); + // Immediate resize without delay + debouncedResizeLoopNodes(); // Auto-connect logic for blocks inside loops const isAutoConnectEnabled = useGeneralStore.getState().isAutoConnectEnabled; @@ -638,10 +634,8 @@ function WorkflowContent() { // Resize all loops to fit their children debouncedResizeLoopNodes(); - // Clean up on unmount - return () => { - debouncedResizeLoopNodes.cancel(); - }; + // No need for cleanup with direct function + return () => {}; }, [nodes, debouncedResizeLoopNodes]); @@ -650,14 +644,15 @@ function WorkflowContent() { (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) { @@ -673,13 +668,28 @@ function WorkflowContent() { 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: crypto.randomUUID(), + id: edgeId, type: 'workflowEdge', + // Add metadata about the loop context + data: { + parentLoopId: sourceNode.id, + isInsideLoop: true + } }); return; } @@ -687,18 +697,41 @@ function WorkflowContent() { // 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( @@ -874,8 +907,8 @@ function WorkflowContent() { // Only update the position, not the parent relationship updateBlockPosition(node.id, relativePosition); - // Resize the loop after moving the child - setTimeout(() => debouncedResizeLoopNodes(), 50); + // Immediate resize without delay + debouncedResizeLoopNodes(); } else { // Node is not a child of this loop yet, establish the relationship logger.info('Setting new parent-child relationship', { @@ -888,8 +921,8 @@ function WorkflowContent() { updateBlockPosition(node.id, relativePosition); updateParentId(node.id, smallestLoop.id, 'parent'); - // Resize the loop to accommodate the new child - setTimeout(() => debouncedResizeLoopNodes(), 50); + // Immediate resize without delay + debouncedResizeLoopNodes(); } } else if (blocks[node.id]?.data?.parentId) { // If node was in a loop but is now outside, handle removal @@ -927,48 +960,87 @@ function WorkflowContent() { // 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 + parentLoopId, + contextId }); }, [getNodes]); - // Transform edges to include selection state + // 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 isInsideLoop = Boolean(sourceNode?.parentId) || Boolean(targetNode?.parentId); + 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; - // Determine if this edge is selected - const isSelected = selectedEdgeInfo?.id === edge.id; - return { ...edge, type: edge.type || 'workflowEdge', data: { - selectedEdgeInfo, + // Send only necessary data to the edge component + isSelected, isInsideLoop, + parentLoopId, onDelete: (edgeId: string) => { - removeEdge(edgeId) - setSelectedEdgeInfo(null) + // 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') && selectedEdgeInfo) { - removeEdge(selectedEdgeInfo.id) - setSelectedEdgeInfo(null) + 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) - }, [selectedEdgeInfo, removeEdge]) + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [selectedEdgeInfo, removeEdge]); // Handle sub-block value updates from custom events useEffect(() => { diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index 608c3595c62..7d420f9a198 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -129,7 +129,7 @@ function resetWorkflowStores() { useWorkflowStore.setState({ blocks: {}, edges: [], - loops: {}, + // loops: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -139,7 +139,7 @@ function resetWorkflowStores() { state: { blocks: {}, edges: [], - loops: {}, + // loops: {}, isDeployed: false, deployedAt: undefined, }, @@ -339,7 +339,7 @@ export const useWorkflowRegistry = create()( saveWorkflowState(currentId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + // loops: currentState.loops, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -365,7 +365,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks, edges, - loops, + // loops, isDeployed: isDeployed !== undefined ? isDeployed : false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, hasActiveSchedule: false, @@ -392,7 +392,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks: {}, edges: [], - loops: {}, + // loops: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -402,7 +402,7 @@ export const useWorkflowRegistry = create()( state: { blocks: {}, edges: [], - loops: {}, + // loops: {}, isDeployed: false, deployedAt: undefined, }, @@ -881,7 +881,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks, edges, - loops, + // loops, isDeployed: isDeployed || false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, hasActiveSchedule: false, @@ -906,7 +906,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks: {}, edges: [], - loops: {}, + // loops: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -916,7 +916,7 @@ export const useWorkflowRegistry = create()( state: { blocks: {}, edges: [], - loops: {}, + // loops: {}, isDeployed: false, deployedAt: undefined, }, diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index e3120ef7ebf..ab4df5c834f 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -109,7 +109,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loops: { ...get().loops }, + // loops: { ...get().loops }, } set(newState) @@ -158,7 +158,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loops: { ...get().loops }, + // loops: { ...get().loops }, } set(newState) @@ -253,7 +253,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loops: { ...get().loops }, + // loops: { ...get().loops }, }; console.log('[WorkflowStore/updateParentId] Updated parentId relationship:', { @@ -276,7 +276,7 @@ export const useWorkflowStore = create()( const newState = { blocks: { ...get().blocks }, edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id), - loops: { ...get().loops }, + // loops: { ...get().loops }, } // Find and remove all child blocks if this is a parent node @@ -307,24 +307,24 @@ export const useWorkflowStore = create()( })) } - // Clean up loops - Object.entries(newState.loops).forEach(([loopId, loop]) => { - if (loop && loop.nodes) { - const hasRemovedNodes = loop.nodes.some(nodeId => blocksToRemove.has(nodeId)) - if (hasRemovedNodes) { - // If removing these nodes would leave the loop empty, delete the loop - const remainingNodes = loop.nodes.filter(nodeId => !blocksToRemove.has(nodeId)) - if (remainingNodes.length === 0) { - delete newState.loops[loopId] - } else { - newState.loops[loopId] = { - ...loop, - nodes: remainingNodes, - } - } - } - } - }) + // // Clean up loops + // Object.entries(newState.loops).forEach(([loopId, loop]) => { + // if (loop && loop.nodes) { + // const hasRemovedNodes = loop.nodes.some(nodeId => blocksToRemove.has(nodeId)) + // if (hasRemovedNodes) { + // // If removing these nodes would leave the loop empty, delete the loop + // const remainingNodes = loop.nodes.filter(nodeId => !blocksToRemove.has(nodeId)) + // if (remainingNodes.length === 0) { + // delete newState.loops[loopId] + // } else { + // newState.loops[loopId] = { + // ...loop, + // nodes: remainingNodes, + // } + // } + // } + // } + // }) // Remove all edges connected to any of the blocks being removed newState.edges = newState.edges.filter(edge => @@ -371,7 +371,7 @@ export const useWorkflowStore = create()( // Recalculate all loops after adding the edge const newLoops: Record = {} const processedPaths = new Set() - const existingLoops = get().loops + // const existingLoops = get().loops // Check for cycles from each node const nodes = new Set(newEdges.map((e) => e.source)) @@ -385,12 +385,12 @@ export const useWorkflowStore = create()( // Check if this path matches an existing loop let existingLoop: Loop | undefined - Object.values(existingLoops).forEach((loop) => { - const loopCanonicalPath = [...loop.nodes].sort().join(',') - if (loopCanonicalPath === canonicalPath) { - existingLoop = loop - } - }) + // Object.values(existingLoops).forEach((loop) => { + // const loopCanonicalPath = [...loop.nodes].sort().join(',') + // if (loopCanonicalPath === canonicalPath) { + // existingLoop = loop + // } + // }) if (existingLoop) { // Preserve the existing loop's properties @@ -442,57 +442,60 @@ export const useWorkflowStore = create()( const newEdges = get().edges.filter((edge) => edge.id !== edgeId); - // Recalculate all loops after edge removal + // Recalculate all loops after edge removal //TODO: comment this loop logic out. - const newLoops: Record = {} - const processedPaths = new Set() - const existingLoops = get().loops - - // Check for cycles from each node - const nodes = new Set(newEdges.map((e) => e.source)) - nodes.forEach((node) => { - const { paths } = detectCycle(newEdges, node) - paths.forEach((path) => { - // Create a canonical path representation for deduplication - const canonicalPath = [...path].sort().join(',') - if (!processedPaths.has(canonicalPath)) { - processedPaths.add(canonicalPath) - - // Check if this path matches an existing loop - let existingLoop: Loop | undefined - Object.values(existingLoops).forEach((loop) => { - const loopCanonicalPath = [...loop.nodes].sort().join(',') - if (loopCanonicalPath === canonicalPath) { - existingLoop = loop - } - }) - - if (existingLoop) { - // Preserve the existing loop's properties - newLoops[existingLoop.id] = { - ...existingLoop, - nodes: path, // Update nodes in case order changed - } - } else { - // Create a new loop with default settings - const loopId = crypto.randomUUID() - newLoops[loopId] = { - id: loopId, - nodes: path, - iterations: 5, - loopType: 'for', - forEachItems: '', - } - } - } - }) - }) - + // const newLoops: Record = {} + // const processedPaths = new Set() + // const existingLoops = get().loops + + // // Check for cycles from each node + // const nodes = new Set(newEdges.map((e) => e.source)) + // nodes.forEach((node) => { + // const { paths } = detectCycle(newEdges, node) + // paths.forEach((path) => { + // // Create a canonical path representation for deduplication + // const canonicalPath = [...path].sort().join(',') + // if (!processedPaths.has(canonicalPath)) { + // processedPaths.add(canonicalPath) + + // // Check if this path matches an existing loop + // let existingLoop: Loop | undefined + // Object.values(existingLoops).forEach((loop) => { + // const loopCanonicalPath = [...loop.nodes].sort().join(',') + // if (loopCanonicalPath === canonicalPath) { + // existingLoop = loop + // } + // }) + + // if (existingLoop) { + // // Preserve the existing loop's properties + // newLoops[existingLoop.id] = { + // ...existingLoop, + // nodes: path, // Update nodes in case order changed + // } + // } else { + // // Create a new loop with default settings + // const loopId = crypto.randomUUID() + // newLoops[loopId] = { + // id: loopId, + // nodes: path, + // iterations: 5, + // loopType: 'for', + // forEachItems: '', + // } + // } + // } + // }) + // }) + + + + // Only remove the specific edge by ID and maintain existing loops const newState = { blocks: { ...get().blocks }, edges: newEdges, - loops: newLoops, + loops: { }, // TODO: potentially remove this or edit, before removing: ...get().loops } set(newState) @@ -546,7 +549,7 @@ export const useWorkflowStore = create()( saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + // loops: currentState.loops, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -620,7 +623,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loops: { ...get().loops }, + // loops: { ...get().loops }, } // Update the subblock store with the duplicated values @@ -678,7 +681,7 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loops: { ...get().loops }, + // loops: { ...get().loops }, } // Update references in subblock store @@ -760,7 +763,7 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], - loops: { ...get().loops }, + // loops: { ...get().loops }, })) get().updateLastSaved() get().sync.markDirty() @@ -782,65 +785,62 @@ export const useWorkflowStore = create()( // 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 - }, - }, - } - - 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, - }, - }, - } - - 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, - }, - }, - } - - set(newState) - pushHistory(set, get, newState, 'Update forEach items') - get().updateLastSaved() - get().sync.markDirty() - get().sync.forceSync() - }, + // 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 + // }, + // }, + // } + + // set(newState) + // pushHistory(set, get, newState, 'Update loop iterations') + // get().updateLastSaved() + // workflowSync.sync() + // }, + + // updateLoopType: (loopId: string, loopType: Loop['loopType']) => { + // const newState = { + // blocks: { ...get().blocks }, + // edges: [...get().edges], + // loops: { + // ...get().loops, + // [loopId]: { + // ...get().loops[loopId], + // loopType, + // }, + // }, + // } + + // set(newState) + // pushHistory(set, get, newState, 'Update loop type') + // get().updateLastSaved() + // workflowSync.sync() + // }, + + // updateLoopForEachItems: (loopId: string, items: string) => { + // const newState = { + // blocks: { ...get().blocks }, + // edges: [...get().edges], + // loops: { + // ...get().loops, + // [loopId]: { + // ...get().loops[loopId], + // forEachItems: items, + // }, + // }, + // } + + // set(newState) + // pushHistory(set, get, newState, 'Update forEach items') + // get().updateLastSaved() + // workflowSync.sync() + // }, triggerUpdate: () => { set((state) => ({ @@ -890,7 +890,7 @@ export const useWorkflowStore = create()( const newState = { blocks: deployedState.blocks, edges: deployedState.edges, - loops: deployedState.loops, + // loops: deployedState.loops, 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 00659d48eb1..fa9a1ea11ea 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -38,7 +38,7 @@ export interface WorkflowState { blocks: Record edges: Edge[] lastSaved?: number - loops: Record + // loops: Record lastUpdate?: number isDeployed?: boolean deployedAt?: Date @@ -74,9 +74,9 @@ 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 + // updateLoopIterations: (loopId: string, iterations: number) => void + // updateLoopType: (loopId: string, loopType: Loop['loopType']) => void + // updateLoopForEachItems: (loopId: string, items: string) => void setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void setDeploymentStatus: (isDeployed: boolean, deployedAt?: Date) => void setScheduleStatus: (hasActiveSchedule: boolean) => void From 555d4fa6fcb486857cfd88cf5f35ac6e76f6a518 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Wed, 14 May 2025 12:16:33 -0700 Subject: [PATCH 04/13] (improvement) nested loop first implementation --- .../w/[id]/components/loop-node/loop-node.tsx | 58 +++- apps/sim/app/w/[id]/workflow.tsx | 306 +++++++++++------- 2 files changed, 251 insertions(+), 113 deletions(-) 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 index fce041a7e53..2bb2e1194b1 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -1,5 +1,5 @@ -import { memo } from 'react' -import { Handle, NodeProps, Position } from 'reactflow' +import { memo, useMemo } from 'react' +import { Handle, NodeProps, Position, useReactFlow } from 'reactflow' import { Trash2 } from 'lucide-react' import { StartIcon } from '@/components/icons' import { cn } from '@/lib/utils' @@ -8,6 +8,46 @@ import { LoopConfigBadges } from './components/loop-config-badges' export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { + const { getNodes } = useReactFlow(); + + // 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 border styles based on nesting level + const getBorderStyle = () => { + // Base styles + const styles = { + border: '2px solid #94a3b8', + 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.border = `2px solid ${colors[colorIndex]}`; + styles.backgroundColor = `${colors[colorIndex]}30`; // Slightly more visible background + } + + return styles; + }; + + const borderStyle = getBorderStyle(); + return (
{ borderRadius: '8px', position: 'relative', overflow: 'visible', - border: '2px solid #94a3b8', - backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent', + ...borderStyle, transition: 'width 0.2s ease-out, height 0.2s ease-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out', }} data-node-id={id} data-type="loopNode" + data-nesting-level={nestingLevel} > {/* Critical drag handle that controls only the loop node movement */}
{ style={{ pointerEvents: 'auto' }} /> + {/* Nesting level indicator */} + {nestingLevel > 0 && ( +
+ Nested: L{nestingLevel} +
+ )} + {/* Custom visible resize handle */}
(null) // Helper function to check if a point is inside a loop node const isPointInLoopNode = useCallback((position: { x: number, y: number }): { @@ -141,9 +141,22 @@ function WorkflowContent() { let maxY = -Infinity; childNodes.forEach(node => { - // Get accurate node dimensions - const nodeWidth = node.type === 'condition' ? 250 : 200; - const nodeHeight = node.type === 'condition' ? 350 : 200; + // Get accurate node dimensions based on node type + let nodeWidth; + let nodeHeight; + + if (node.type === 'loopNode') { + // For nested loops, use their actual dimensions plus extra padding + nodeWidth = node.data?.width || 800; + nodeHeight = node.data?.height || 1000; + } else if (node.type === 'workflowBlock' && node.data?.type === 'condition') { + nodeWidth = 250; + nodeHeight = 350; + } else { + // Default dimensions for regular nodes + nodeWidth = 200; + nodeHeight = 200; + } minX = Math.min(minX, node.position.x); minY = Math.min(minY, node.position.y); @@ -151,14 +164,16 @@ function WorkflowContent() { maxY = Math.max(maxY, node.position.y + nodeHeight); }); - // Add buffer padding to all sides (20px buffer before edges) - const sidePadding = 220; // 200px original padding + 20px buffer + // Add extra padding for nested loops to prevent tight boundaries + const hasNestedLoops = childNodes.some(node => node.type === 'loopNode'); + const sidePadding = hasNestedLoops ? 300 : 220; // Extra padding for loops containing other loops + const bottomPadding = hasNestedLoops ? 200 : 120; // More bottom padding for 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 + 100); + const height = Math.max(minHeight, maxY + sidePadding + bottomPadding); return { width, height }; }, [getNodes]); @@ -175,14 +190,6 @@ function WorkflowContent() { // Only update if dimensions have changed (to avoid unnecessary updates) if (dimensions.width !== loopNode.data?.width || dimensions.height !== loopNode.data?.height) { - logger.info('Resizing loop node', { - loopId: loopNode.id, - newDimensions: dimensions, - oldDimensions: { - width: loopNode.data?.width, - height: loopNode.data?.height - } - }); // Use the updateNodeDimensions from the workflow store useWorkflowStore.getState().updateNodeDimensions(loopNode.id, dimensions); } @@ -340,16 +347,43 @@ function WorkflowContent() { // Special handling for loop nodes if (data.type === 'loop') { - // Don't allow loops to be created inside other loops + // Create a unique ID and name for the loop const id = crypto.randomUUID() const name = 'Loop' - // Add the loop node with default dimensions - addBlock(id, data.type, name, position, { - width: 800, - height: 1000, - type: 'loopNode' - }) + // 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: 800, + height: 1000, + type: 'loopNode', + parentId: loopInfo.loopId, + extent: 'parent' + }); + + logger.info('Added nested loop inside parent loop', { + loopId: id, + parentLoopId: loopInfo.loopId, + relativePosition + }); + + // 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: 800, + height: 1000, + type: 'loopNode' + }); + } return } @@ -570,6 +604,8 @@ function WorkflowContent() { id: block.id, type: 'loopNode', position: block.position, + parentId: block.data?.parentId, + extent: block.data?.extent || undefined, dragHandle: '.workflow-drag-handle', data: { ...block.data, @@ -736,12 +772,12 @@ function WorkflowContent() { // Handle node drag to detect intersections with loop nodes const onNodeDrag = useCallback( (event: React.MouseEvent, node: any) => { - // Skip if dragging a loop node (loops can't be children) - if (node.type === 'loopNode') return; - // Store currently dragged node ID setDraggedNodeId(node.id) + // Skip if dragging a loop node that already has a parent to avoid nested loop issues + if (node.type === 'loopNode' && node.parentId) return; + // Get the current parent ID of the node being dragged const currentParentId = blocks[node.id]?.data?.parentId || null; @@ -753,9 +789,29 @@ function WorkflowContent() { // Skip if this loop is already the parent of the node being dragged if (n.id === currentParentId) return false - // Get more accurate node dimensions - can be improved with dynamic size detection - const nodeWidth = node.type === 'condition' ? 250 : 200; - const nodeHeight = node.type === 'condition' ? 150 : 100; + // Skip self-nesting: prevent a loop from becoming its own descendant + if (node.type === 'loopNode') { + // Check if the target loop is already a descendant of the dragged loop + // This prevents circular parent-child relationships + let currentNode = n; + while (currentNode && currentNode.parentId) { + if (currentNode.parentId === node.id) { + return false; // Avoid circular nesting + } + const parentNode = getNodes().find(pn => pn.id === currentNode.parentId); + if (!parentNode) break; + currentNode = parentNode; + } + } + + // Get dimensions based on node type + const nodeWidth = node.type === 'loopNode' + ? (node.data?.width || 800) + : (node.type === 'condition' ? 250 : 200); + + const nodeHeight = node.type === 'loopNode' + ? (node.data?.height || 1000) + : (node.type === 'condition' ? 150 : 100); // Check if node is within the bounds of the loop node const nodeRect = { @@ -817,129 +873,160 @@ function WorkflowContent() { [getNodes, potentialParentId, blocks] ) + // Add in a nodeDrag start event to set the dragStartParentId + const onNodeDragStart = useCallback((event: React.MouseEvent, node: any) => { + setDragStartParentId(node.parentId || blocks[node.id]?.data?.parentId || null) + }, [blocks]) + // Handle node drag stop to establish parent-child relationships const onNodeDragStop = useCallback( (event: React.MouseEvent, node: any) => { - // Skip if dragging a loop node (loops can't be children) - if (node.type === 'loopNode') { - setDraggedNodeId(null) - return - } - // If the node has a parent, don't update the parent relationship - if (node.parentId) { - return - } + //if the currnt parent ID is the same as the dragStartParentId, then we don't need to do anything + + console.log('potentialParentId', potentialParentId) + console.log('dragStartParentId', dragStartParentId) + + // This is the case where the node is being moved inside of it's original parent loop + if (potentialParentId === dragStartParentId || (dragStartParentId && !potentialParentId)) return; + + //Else, we want to perform the logic below logger.info('Node drag stopped', { nodeId: node.id, + dragStartParentId, potentialParentId, nodeType: node.type - }) + }); - // Get the current parent ID of the node being dragged - const currentParentId = blocks[node.id]?.data?.parentId || null; + // Clear UI effects + document.querySelectorAll('.loop-node-drag-over').forEach(el => { + el.classList.remove('loop-node-drag-over'); + }); + document.body.style.cursor = ''; - // Find intersections with loop nodes + // Find potential new parent loop based on intersections 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 + if (n.type !== 'loopNode' || n.id === node.id) return false; + + // Skip self-nesting and circular parent checks (keep existing logic) + if (node.type === 'loopNode') { + let currentNode = n; + while (currentNode && currentNode.parentId) { + if (currentNode.parentId === node.id) return false; + const parentNode = getNodes().find(pn => pn.id === currentNode.parentId); + if (!parentNode) break; + currentNode = parentNode; + } + } - // Get more accurate node dimensions - can be improved with dynamic size detection - const nodeWidth = node.type === 'condition' ? 250 : 200; - const nodeHeight = node.type === 'condition' ? 150 : 100; + // Calculate node dimensions and check intersection (keep existing logic) + const nodeWidth = node.type === 'loopNode' + ? (node.data?.width || 800) + : (node.type === 'condition' ? 250 : 200); + + const nodeHeight = node.type === 'loopNode' + ? (node.data?.height || 1000) + : (node.type === 'condition' ? 150 : 100); - // Check if node is within the bounds of the loop node + // Check intersection with loop nodes const nodeRect = { left: node.position.x, right: node.position.x + nodeWidth, top: node.position.y, bottom: node.position.y + nodeHeight - } + }; const loopRect = { left: n.position.x, right: n.position.x + (n.data?.width || 800), top: n.position.y, bottom: n.position.y + (n.data?.height || 1000) - } + }; return ( nodeRect.left < loopRect.right && nodeRect.right > loopRect.left && nodeRect.top < loopRect.bottom && nodeRect.bottom > loopRect.top - ) - }) - - // Remove all highlight classes - document.querySelectorAll('.loop-node-drag-over').forEach(el => { - el.classList.remove('loop-node-drag-over') - }) - - // Reset cursor - document.body.style.cursor = '' - - // If intersecting with loops, establish parent-child relationship - if (intersectingNodes.length > 0) { - // Find smallest loop (for handling nested loops) - const smallestLoop = intersectingNodes.sort((a, b) => { - const aSize = (a.data?.width || 800) * (a.data?.height || 1000) - const bSize = (b.data?.width || 800) * (b.data?.height || 1000) - return aSize - bSize - })[0] - - // Calculate position relative to parent - const relativePosition = { - x: node.position.x - smallestLoop.position.x, - y: node.position.y - smallestLoop.position.y - } + ); + }); - // Check if the node is already a child of this loop - if (currentParentId === smallestLoop.id) { - // Node is already a child of this loop, just updating position - logger.info('Node already a child of this loop, just updating position', { - blockId: node.id, - parentId: smallestLoop.id, - relativePosition + // Get the new potential parent loop (smallest intersecting loop) + const newParentId = intersectingNodes.length > 0 + ? intersectingNodes.sort((a, b) => { + const aSize = (a.data?.width || 800) * (a.data?.height || 1000); + const bSize = (b.data?.width || 800) * (b.data?.height || 1000); + return aSize - bSize; + })[0].id + : null; + + // KEY LOGIC: Only update parent relationship if it's different from starting parent + if (newParentId !== dragStartParentId) { + if (newParentId) { + // Node is being moved to a new parent loop + const newParentNode = getNodes().find(n => n.id === newParentId); + if (newParentNode) { + // Calculate position relative to new parent + const relativePosition = { + x: node.position.x - newParentNode.position.x, + y: node.position.y - newParentNode.position.y + }; + + logger.info('Moving node to new parent loop', { + nodeId: node.id, + oldParentId: dragStartParentId, + newParentId, + relativePosition + }); + + // Update both position and parent ID + updateBlockPosition(node.id, relativePosition); + updateParentId(node.id, newParentId, 'parent'); + + // Resize loop to fit new content + debouncedResizeLoopNodes(); + } + } else if (dragStartParentId) { + // Node is being moved out of a loop entirely + logger.info('Removing node from parent loop', { + nodeId: node.id, + oldParentId: dragStartParentId }); - - // Only update the position, not the parent relationship - updateBlockPosition(node.id, relativePosition); - - // Immediate resize without delay + + // Keep current absolute position but remove parent relationship + updateParentId(node.id, '', 'parent'); + + // After removing from parent, resize the old parent loop debouncedResizeLoopNodes(); - } else { - // Node is not a child of this loop yet, establish the relationship - logger.info('Setting new parent-child relationship', { - blockId: node.id, - parentId: smallestLoop.id, + } + } else if (newParentId && dragStartParentId === newParentId) { + // Node remains in the same parent, just update its position + const parentNode = getNodes().find(n => n.id === newParentId); + if (parentNode) { + // Calculate position relative to same parent + const relativePosition = { + x: node.position.x - parentNode.position.x, + y: node.position.y - parentNode.position.y + }; + + logger.info('Repositioning node within same parent loop', { + nodeId: node.id, + parentId: newParentId, relativePosition }); - - // Update both position and parent relationship + + // Only update position, not parent relationship updateBlockPosition(node.id, relativePosition); - updateParentId(node.id, smallestLoop.id, 'parent'); - - // Immediate resize without delay - debouncedResizeLoopNodes(); } - } else if (blocks[node.id]?.data?.parentId) { - // If node was in a loop but is now outside, handle removal - logger.info('Node dragged out of loop - parent relationship should be removed', { - blockId: node.id, - currentParentId: blocks[node.id]?.data?.parentId - }) - - // For now, we keep the node where it is - // You may want to handle this case based on your store implementation } - // Reset drag state - setDraggedNodeId(null) - setPotentialParentId(null) + // Reset state + setDraggedNodeId(null); + setPotentialParentId(null); }, - [getNodes, potentialParentId, blocks, updateParentId, updateBlockPosition] + [getNodes, dragStartParentId, potentialParentId, updateBlockPosition, updateParentId, debouncedResizeLoopNodes] ) @@ -1119,6 +1206,7 @@ function WorkflowContent() { className="workflow-container h-full" onNodeDrag={onNodeDrag} onNodeDragStop={onNodeDragStop} + onNodeDragStart={onNodeDragStart} snapToGrid={false} snapGrid={[20, 20]} elevateEdgesOnSelect={true} From e8cd888a0147be39693d432b88bfd90bf92a57b2 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Wed, 14 May 2025 16:16:22 -0700 Subject: [PATCH 05/13] (improvement) loop block nesting logic and visual --- .../w/[id]/components/loop-node/loop-node.tsx | 9 +- apps/sim/app/w/[id]/workflow.tsx | 541 +++++++++++------- apps/sim/stores/workflows/workflow/store.ts | 42 +- 3 files changed, 349 insertions(+), 243 deletions(-) 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 index 2bb2e1194b1..943a183edeb 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -61,6 +61,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { position: 'relative', overflow: 'visible', ...borderStyle, + pointerEvents: 'all', transition: 'width 0.2s ease-out, height 0.2s ease-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out', }} data-node-id={id} @@ -114,22 +115,22 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { {/* Loop Start Block - positioned at left middle */}
- + (null) + // Helper function to calculate node depth in hierarchy + const getNodeDepth = useCallback((nodeId: string): number => { + const node = getNodes().find(n => n.id === nodeId); + if (!node || !node.parentId) return 0; + return 1 + getNodeDepth(node.parentId); + }, [getNodes]); + + // Helper function to get the full hierarchy path of a node + const getNodeHierarchy = useCallback((nodeId: string): string[] => { + const node = getNodes().find(n => n.id === nodeId); + if (!node || !node.parentId) return [nodeId]; + return [...getNodeHierarchy(node.parentId), nodeId]; + }, [getNodes]); + + // Helper function to get absolute position of a node (accounting for nested parents) + const getNodeAbsolutePosition = useCallback((nodeId: string): { 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); + + // Calculate this node's absolute position + return { + x: parentPos.x + node.position.x, + y: parentPos.y + node.position.y + }; + }, [getNodes]); + + // Helper function to calculate relative position to a new parent + const calculateRelativePosition = useCallback((nodeId: string, newParentId: string): { x: number, y: number } => { + // Get absolute position of the node + const nodeAbsPos = getNodeAbsolutePosition(nodeId); + + // Get absolute position of the new parent + const parentAbsPos = getNodeAbsolutePosition(newParentId); + + // Calculate relative position + return { + x: nodeAbsPos.x - parentAbsPos.x, + y: nodeAbsPos.y - parentAbsPos.y + }; + }, [getNodeAbsolutePosition]); + + // Helper function to update a node's parent with proper position calculation + const updateNodeParent = useCallback((nodeId: string, newParentId: string | null) => { + // 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); + + // Update both position and parent + updateBlockPosition(nodeId, relativePosition); + updateParentId(nodeId, newParentId, 'parent'); + + logger.info('Updated node parent', { + nodeId, + newParentId, + relativePosition + }); + } + + // Resize affected loops + debouncedResizeLoopNodes(); + }, [getNodes, calculateRelativePosition, getNodeAbsolutePosition, updateBlockPosition, updateParentId]); + // Helper function to check if a point is inside a loop node const isPointInLoopNode = useCallback((position: { x: number, y: number }): { loopId: string, @@ -178,10 +286,16 @@ function WorkflowContent() { return { width, height }; }, [getNodes]); - // Function to resize all loop nodes + // Function to resize all loop nodes with improved hierarchy handling const resizeLoopNodes = useCallback(() => { - // Find all loop nodes - const loopNodes = getNodes().filter(node => node.type === 'loopNode'); + // 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) + })) + .sort((a, b) => a.depth - b.depth); // Resize each loop node based on its children loopNodes.forEach(loopNode => { @@ -194,7 +308,7 @@ function WorkflowContent() { useWorkflowStore.getState().updateNodeDimensions(loopNode.id, dimensions); } }); - }, [getNodes, calculateLoopDimensions]); + }, [getNodes, calculateLoopDimensions, getNodeDepth]); // Use direct resizing function instead of debounced version for immediate updates const debouncedResizeLoopNodes = resizeLoopNodes; @@ -270,6 +384,28 @@ 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: 800, + height: 1000, + type: 'loopNode' + }) + + return + } + const blockConfig = getBlock(type) if (!blockConfig) { logger.error('Invalid block type:', { type }) @@ -389,16 +525,16 @@ function WorkflowContent() { } 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 - }` + 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 @@ -674,6 +810,31 @@ function WorkflowContent() { 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 = getNodeAbsolutePosition(id); + + // Update the node to remove parent reference and use absolute position + updateBlockPosition(id, absolutePosition); + updateParentId(id, '', 'parent'); + } + }); + }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePosition]); // Update edges const onEdgesChange = useCallback( @@ -773,124 +934,161 @@ function WorkflowContent() { const onNodeDrag = useCallback( (event: React.MouseEvent, node: any) => { // Store currently dragged node ID - setDraggedNodeId(node.id) - - // Skip if dragging a loop node that already has a parent to avoid nested loop issues - if (node.type === 'loopNode' && node.parentId) return; + setDraggedNodeId(node.id); // Get the current parent ID of the node being dragged const currentParentId = blocks[node.id]?.data?.parentId || null; - - // Find intersections with loop nodes - 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') { - // Check if the target loop is already a descendant of the dragged loop - // This prevents circular parent-child relationships - let currentNode = n; - while (currentNode && currentNode.parentId) { - if (currentNode.parentId === node.id) { + + // 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 = getNodeAbsolutePosition(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 = getNodeHierarchy(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 } - const parentNode = getNodes().find(pn => pn.id === currentNode.parentId); - if (!parentNode) break; - currentNode = parentNode; } - } + + // Get the loop's absolute position + const loopAbsolutePos = getNodeAbsolutePosition(n.id); - // Get dimensions based on node type - const nodeWidth = node.type === 'loopNode' - ? (node.data?.width || 800) - : (node.type === 'condition' ? 250 : 200); + // Get dimensions based on node type + const nodeWidth = node.type === 'loopNode' + ? (node.data?.width || 800) + : (node.type === 'condition' ? 250 : 200); - const nodeHeight = node.type === 'loopNode' - ? (node.data?.height || 1000) - : (node.type === 'condition' ? 150 : 100); - - // Check if node is within the bounds of the loop node - const nodeRect = { - left: node.position.x, - right: node.position.x + nodeWidth, - top: node.position.y, - bottom: node.position.y + nodeHeight - } + const nodeHeight = node.type === 'loopNode' + ? (node.data?.height || 1000) + : (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: n.position.x, - right: n.position.x + (n.data?.width || 800), - top: n.position.y, - bottom: n.position.y + (n.data?.height || 1000) - } + const loopRect = { + left: loopAbsolutePos.x, + right: loopAbsolutePos.x + (n.data?.width || 800), + top: loopAbsolutePos.y, + bottom: loopAbsolutePos.y + (n.data?.height || 1000) + }; - return ( - nodeRect.left < loopRect.right && - nodeRect.right > loopRect.left && - nodeRect.top < loopRect.bottom && - nodeRect.bottom > loopRect.top - ) - }) + // 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: getNodeDepth(n.id), + // Calculate size for secondary sorting + size: (n.data?.width || 800) * (n.data?.height || 1000) + })); // Update potential parent if there's at least one intersecting loop node if (intersectingNodes.length > 0) { - // Find smallest loop (for handling nested loops) - const smallestLoop = intersectingNodes.sort((a, b) => { - const aSize = (a.data?.width || 800) * (a.data?.height || 1000) - const bSize = (b.data?.width || 800) * (b.data?.height || 1000) - return aSize - bSize - })[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]; - // Set potential parent and add visual indicator - setPotentialParentId(smallestLoop.id) + // Add a check to see if the bestLoopMatch is apart of the heirarchy of the node being dragged + const hierarchy = getNodeHierarchy(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="${smallestLoop.id}"]`) + const loopElement = document.querySelector(`[data-id="${bestLoopMatch.loop.id}"]`); if (loopElement) { - loopElement.classList.add('loop-node-drag-over') - - // Change cursor to indicate item can be dropped - document.body.style.cursor = 'copy' + 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}"]`) + const prevElement = document.querySelector(`[data-id="${potentialParentId}"]`); if (prevElement) { - prevElement.classList.remove('loop-node-drag-over') + prevElement.classList.remove('loop-node-drag-over'); } - setPotentialParentId(null) - - // Reset cursor - document.body.style.cursor = '' + setPotentialParentId(null); + document.body.style.cursor = ''; } } }, - [getNodes, potentialParentId, blocks] - ) + [getNodes, potentialParentId, blocks, getNodeHierarchy, getNodeAbsolutePosition, getNodeDepth] + ); // Add in a nodeDrag start event to set the dragStartParentId const onNodeDragStart = useCallback((event: React.MouseEvent, node: any) => { - setDragStartParentId(node.parentId || blocks[node.id]?.data?.parentId || null) - }, [blocks]) + // 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 = ''; - //if the currnt parent ID is the same as the dragStartParentId, then we don't need to do anything - - console.log('potentialParentId', potentialParentId) - console.log('dragStartParentId', dragStartParentId) - - // This is the case where the node is being moved inside of it's original parent loop - if (potentialParentId === dragStartParentId || (dragStartParentId && !potentialParentId)) return; - - //Else, we want to perform the logic below + // 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, @@ -899,136 +1097,47 @@ function WorkflowContent() { nodeType: node.type }); - // Clear UI effects - document.querySelectorAll('.loop-node-drag-over').forEach(el => { - el.classList.remove('loop-node-drag-over'); - }); - document.body.style.cursor = ''; + // 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 + } - // Find potential new parent loop based on intersections - 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; + // 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 = getNodeHierarchy(potentialParentId); - // Skip self-nesting and circular parent checks (keep existing logic) - if (node.type === 'loopNode') { - let currentNode = n; - while (currentNode && currentNode.parentId) { - if (currentNode.parentId === node.id) return false; - const parentNode = getNodes().find(pn => pn.id === currentNode.parentId); - if (!parentNode) break; - currentNode = parentNode; - } - } - - // Calculate node dimensions and check intersection (keep existing logic) - const nodeWidth = node.type === 'loopNode' - ? (node.data?.width || 800) - : (node.type === 'condition' ? 250 : 200); - - const nodeHeight = node.type === 'loopNode' - ? (node.data?.height || 1000) - : (node.type === 'condition' ? 150 : 100); - - // Check intersection with loop nodes - const nodeRect = { - left: node.position.x, - right: node.position.x + nodeWidth, - top: node.position.y, - bottom: node.position.y + nodeHeight - }; - - const loopRect = { - left: n.position.x, - right: n.position.x + (n.data?.width || 800), - top: n.position.y, - bottom: n.position.y + (n.data?.height || 1000) - }; - - return ( - nodeRect.left < loopRect.right && - nodeRect.right > loopRect.left && - nodeRect.top < loopRect.bottom && - nodeRect.bottom > loopRect.top - ); - }); - - // Get the new potential parent loop (smallest intersecting loop) - const newParentId = intersectingNodes.length > 0 - ? intersectingNodes.sort((a, b) => { - const aSize = (a.data?.width || 800) * (a.data?.height || 1000); - const bSize = (b.data?.width || 800) * (b.data?.height || 1000); - return aSize - bSize; - })[0].id - : null; - - // KEY LOGIC: Only update parent relationship if it's different from starting parent - if (newParentId !== dragStartParentId) { - if (newParentId) { - // Node is being moved to a new parent loop - const newParentNode = getNodes().find(n => n.id === newParentId); - if (newParentNode) { - // Calculate position relative to new parent - const relativePosition = { - x: node.position.x - newParentNode.position.x, - y: node.position.y - newParentNode.position.y - }; - - logger.info('Moving node to new parent loop', { - nodeId: node.id, - oldParentId: dragStartParentId, - newParentId, - relativePosition - }); - - // Update both position and parent ID - updateBlockPosition(node.id, relativePosition); - updateParentId(node.id, newParentId, 'parent'); - - // Resize loop to fit new content - debouncedResizeLoopNodes(); - } - } else if (dragStartParentId) { - // Node is being moved out of a loop entirely - logger.info('Removing node from parent loop', { - nodeId: node.id, - oldParentId: dragStartParentId - }); - - // Keep current absolute position but remove parent relationship - updateParentId(node.id, '', 'parent'); - - // After removing from parent, resize the old parent loop - debouncedResizeLoopNodes(); - } - } else if (newParentId && dragStartParentId === newParentId) { - // Node remains in the same parent, just update its position - const parentNode = getNodes().find(n => n.id === newParentId); - if (parentNode) { - // Calculate position relative to same parent - const relativePosition = { - x: node.position.x - parentNode.position.x, - y: node.position.y - parentNode.position.y - }; - - logger.info('Repositioning node within same parent loop', { - nodeId: node.id, - parentId: newParentId, - relativePosition + // 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 }); - - // Only update position, not parent relationship - updateBlockPosition(node.id, relativePosition); + 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, updateBlockPosition, updateParentId, debouncedResizeLoopNodes] - ) - + [getNodes, dragStartParentId, potentialParentId, updateNodeParent, getNodeHierarchy] + ); // Update onPaneClick to only handle edge selection const onPaneClick = useCallback(() => { @@ -1189,9 +1298,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} diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index ab4df5c834f..e465f8eb210 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -281,10 +281,25 @@ export const useWorkflowStore = create()( // Find and remove all child blocks if this is a parent node const blocksToRemove = new Set([id]) - Object.entries(newState.blocks).forEach(([blockId, block]) => { - if (block.data?.parentId === id) { - blocksToRemove.add(blockId) - } + + // 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 @@ -307,25 +322,6 @@ export const useWorkflowStore = create()( })) } - // // Clean up loops - // Object.entries(newState.loops).forEach(([loopId, loop]) => { - // if (loop && loop.nodes) { - // const hasRemovedNodes = loop.nodes.some(nodeId => blocksToRemove.has(nodeId)) - // if (hasRemovedNodes) { - // // If removing these nodes would leave the loop empty, delete the loop - // const remainingNodes = loop.nodes.filter(nodeId => !blocksToRemove.has(nodeId)) - // if (remainingNodes.length === 0) { - // delete newState.loops[loopId] - // } else { - // newState.loops[loopId] = { - // ...loop, - // nodes: remainingNodes, - // } - // } - // } - // } - // }) - // 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) From 720ba2f7ac9565a48d8ccc05b8f7434a9a55d489 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Wed, 14 May 2025 16:39:34 -0700 Subject: [PATCH 06/13] (fix) styling --- apps/sim/app/globals.css | 28 ++----------------- .../[id]/components/loop-node/loop-config.ts | 2 +- .../w/[id]/components/loop-node/loop-node.tsx | 16 +++++++---- apps/sim/components/icons.tsx | 2 +- 4 files changed, 15 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/globals.css b/apps/sim/app/globals.css index f8d7ca1e91e..f7ce6df0cd2 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -351,24 +351,6 @@ input[type='search']::-ms-clear { background-color: hsl(var(--primary) / 0.05); } -/* Improve dragging performance */ -.smooth-drag-container { - contain: layout style paint; - transform: translateZ(0); - backface-visibility: hidden; - perspective: 1000px; - will-change: transform; - transition: transform 0.01s linear; - -webkit-transform-style: preserve-3d; - -webkit-backface-visibility: hidden; -} - -.smooth-drag-container * { - transform: translateZ(0); - backface-visibility: hidden; - -webkit-backface-visibility: hidden; -} - /* Optimize node rendering for smooth dragging performance */ .react-flow__node-workflowBlock { contain: layout style; @@ -404,7 +386,6 @@ input[type='search']::-ms-clear { /* Enhanced drag detection */ .react-flow__node-group.dragging-over { - border: 2px solid #40E0D0; background-color: rgba(34,197,94,0.05); transition: all 0.2s ease-in-out; } @@ -424,12 +405,9 @@ input[type='search']::-ms-clear { .loop-node-drag-over { animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite; - border-color: #40E0D0 !important; - border-width: 2px !important; border-style: solid !important; - background-color: rgba(64, 224, 208, 0.08) !important; - transition: all 0.2s ease-in-out; - box-shadow: 0 0 0 8px rgba(64, 224, 208, 0.1); + background-color: rgba(47, 179, 255, 0.08) !important; + box-shadow: 0 0 0 8px rgba(47, 179, 255, 0.1); } /* Make resizer handles more visible */ .react-flow__resize-control { @@ -453,7 +431,7 @@ input[type='search']::-ms-clear { .react-flow__resize-control.bottom-right:hover { transform: scale(1.3); - background-color: #40E0D0 !important; + background-color: #2FB3FF !important; } /* Ensure NodeResizer is always above the custom resize handle */ 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 index a3827091b9d..603b7e0f7ef 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-config.ts +++ b/apps/sim/app/w/[id]/components/loop-node/loop-config.ts @@ -6,7 +6,7 @@ export const LoopTool = { name: 'Loop', description: 'Create a Loop', icon: RepeatIcon, - bgColor: '#40E0D0', + bgColor: '#2FB3FF', data: { label: 'Loop', loopType: 'for', 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 index 943a183edeb..fbf79bc4382 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -29,7 +29,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { const getBorderStyle = () => { // Base styles const styles = { - border: '2px solid #94a3b8', + border: '1px solid rgba(148, 163, 184, 0.6)', backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent', }; @@ -52,7 +52,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
{ {/* Loop Start Block - positioned at left middle */}
- +
+ +
From d1e756c798f240846d1ed1c9b4958ac452ad7644 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Wed, 14 May 2025 19:13:33 -0700 Subject: [PATCH 07/13] improvement: more styling changes --- .../[id]/components/loop-node/loop-config.ts | 8 +- .../w/[id]/components/loop-node/loop-node.tsx | 195 +++++++++--------- apps/sim/app/w/[id]/workflow.tsx | 171 ++++++++++++--- 3 files changed, 234 insertions(+), 140 deletions(-) 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 index 603b7e0f7ef..33beaea18c9 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-config.ts +++ b/apps/sim/app/w/[id]/components/loop-node/loop-config.ts @@ -12,8 +12,8 @@ export const LoopTool = { loopType: 'for', count: 5, collection: '', - width: 800, - height: 1000, + width: 500, + height: 300, extent: 'parent', // Store loop execution state executionState: { @@ -24,8 +24,8 @@ export const LoopTool = { } }, style: { - width: 800, - height: 1000, + width: 500, + height: 300, }, // Specify that this should be rendered as a ReactFlow group node isResizable: true, 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 index fbf79bc4382..cfb5d0e88f3 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -1,7 +1,9 @@ -import { memo, useMemo } from 'react' +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 { LoopConfigBadges } from './components/loop-config-badges' @@ -9,6 +11,7 @@ import { LoopConfigBadges } from './components/loop-config-badges' export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { const { getNodes } = useReactFlow(); + const blockRef = useRef(null); // Determine nesting level by counting parents const nestingLevel = useMemo(() => { @@ -25,11 +28,10 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { return level; }, [id, data?.parentId, getNodes]); - // Generate different border styles based on nesting level - const getBorderStyle = () => { + // Generate different background styles based on nesting level + const getNestedStyles = () => { // Base styles - const styles = { - border: '1px solid rgba(148, 163, 184, 0.6)', + const styles: Record = { backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent', }; @@ -39,102 +41,89 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { const colors = ['#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b', '#475569']; const colorIndex = (nestingLevel - 1) % colors.length; - styles.border = `2px solid ${colors[colorIndex]}`; styles.backgroundColor = `${colors[colorIndex]}30`; // Slightly more visible background } return styles; }; - const borderStyle = getBorderStyle(); + const nestedStyles = getNestedStyles(); return ( -
- {/* Critical drag handle that controls only the loop node movement */} -
- - {/* Nesting level indicator */} - {nestingLevel > 0 && ( -
- Nested: L{nestingLevel} -
- )} - - {/* Custom visible resize handle */} -
-
- - {/* Child nodes container - Set pointerEvents: none to allow events to reach edges */} -
+ 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', - minHeight: '100%', - pointerEvents: 'none', + overflow: 'visible', + ...nestedStyles, + pointerEvents: 'all', }} + data-node-id={id} + data-type="loopNode" + data-nesting-level={nestingLevel} > - {/* Delete button - now always visible */} + {/* Critical drag handle that controls only the loop node movement */}
{ - e.stopPropagation(); - useWorkflowStore.getState().removeBlock(id); - }} - style={{ pointerEvents: 'auto' }} // Re-enable pointer events for this button + className="absolute top-0 left-0 right-0 h-10 workflow-drag-handle cursor-move z-10" + style={{ pointerEvents: 'auto' }} + /> + + {/* Custom visible resize handle */} +
-
- {/* Loop Start Block - positioned at left middle */} + {/* 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 */} - + {/* Input handle on left middle */} + - {/* Output handle on right middle */} - + {/* Output handle on right middle */} + - {/* Loop Configuration Badges */} - + {/* Loop Configuration Badges */} + +
) }) diff --git a/apps/sim/app/w/[id]/workflow.tsx b/apps/sim/app/w/[id]/workflow.tsx index 0421eacfaef..17bbdfb3f68 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -196,9 +196,9 @@ function WorkflowContent() { .filter(n => { const loopRect = { left: n.position.x, - right: n.position.x + (n.data?.width || 800), + right: n.position.x + (n.data?.width || 500), top: n.position.y, - bottom: n.position.y + (n.data?.height || 1000) + bottom: n.position.y + (n.data?.height || 300) }; return ( @@ -212,8 +212,8 @@ function WorkflowContent() { loopId: n.id, loopPosition: n.position, dimensions: { - width: n.data?.width || 800, - height: n.data?.height || 1000 + width: n.data?.width || 500, + height: n.data?.height || 300 } })); @@ -232,8 +232,8 @@ function WorkflowContent() { // Helper function to calculate proper dimensions for a loop node based on its children const calculateLoopDimensions = useCallback((loopId: string): { width: number, height: number } => { // Default minimum dimensions - const minWidth = 800; - const minHeight = 1000; + const minWidth = 500; + const minHeight = 300; // Get all child nodes of this loop const childNodes = getNodes().filter(node => node.parentId === loopId); @@ -254,14 +254,37 @@ function WorkflowContent() { let nodeHeight; if (node.type === 'loopNode') { - // For nested loops, use their actual dimensions plus extra padding - nodeWidth = node.data?.width || 800; - nodeHeight = node.data?.height || 1000; - } else if (node.type === 'workflowBlock' && node.data?.type === 'condition') { - nodeWidth = 250; - nodeHeight = 350; + // 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 regular nodes + // Default dimensions for any other node types nodeWidth = 200; nodeHeight = 200; } @@ -275,14 +298,16 @@ function WorkflowContent() { // 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'); - const sidePadding = hasNestedLoops ? 300 : 220; // Extra padding for loops containing other loops - const bottomPadding = hasNestedLoops ? 200 : 120; // More bottom padding for loops - + + // 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 + bottomPadding); - + const height = Math.max(minHeight, maxY + sidePadding); + return { width, height }; }, [getNodes]); @@ -398,11 +423,30 @@ function WorkflowContent() { // Add the loop node directly to canvas with default dimensions addBlock(id, type, name, centerPosition, { - width: 800, - height: 1000, + 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 } @@ -441,7 +485,7 @@ function WorkflowContent() { target: id, sourceHandle, targetHandle: 'target', - type: 'custom', + type: 'workflowEdge', }) } } @@ -497,8 +541,8 @@ function WorkflowContent() { // Add the loop as a child of the parent loop addBlock(id, data.type, name, relativePosition, { - width: 800, - height: 1000, + width: 500, + height: 300, type: 'loopNode', parentId: loopInfo.loopId, extent: 'parent' @@ -510,15 +554,76 @@ function WorkflowContent() { 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: 800, - height: 1000, + 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 @@ -745,8 +850,8 @@ function WorkflowContent() { dragHandle: '.workflow-drag-handle', data: { ...block.data, - width: block.data?.width || 800, - height: block.data?.height || 1000, + width: block.data?.width || 500, + height: block.data?.height || 300, }, }) return @@ -982,11 +1087,11 @@ function WorkflowContent() { // Get dimensions based on node type const nodeWidth = node.type === 'loopNode' - ? (node.data?.width || 800) - : (node.type === 'condition' ? 250 : 200); + ? (node.data?.width || 500) + : (node.type === 'condition' ? 250 : 350); const nodeHeight = node.type === 'loopNode' - ? (node.data?.height || 1000) + ? (node.data?.height || 300) : (node.type === 'condition' ? 150 : 100); // Check intersection using absolute coordinates @@ -999,9 +1104,9 @@ function WorkflowContent() { const loopRect = { left: loopAbsolutePos.x, - right: loopAbsolutePos.x + (n.data?.width || 800), + right: loopAbsolutePos.x + (n.data?.width || 500), top: loopAbsolutePos.y, - bottom: loopAbsolutePos.y + (n.data?.height || 1000) + bottom: loopAbsolutePos.y + (n.data?.height || 300) }; // Check intersection with absolute coordinates for accurate detection @@ -1017,7 +1122,7 @@ function WorkflowContent() { loop: n, depth: getNodeDepth(n.id), // Calculate size for secondary sorting - size: (n.data?.width || 800) * (n.data?.height || 1000) + size: (n.data?.width || 500) * (n.data?.height || 300) })); // Update potential parent if there's at least one intersecting loop node From a97e546b1e347d3df51963a29dd55492fa2a7834 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Thu, 15 May 2025 10:50:07 -0700 Subject: [PATCH 08/13] fix: updated the old logic --- .../[id]/components/loop-node/loop-config.ts | 1 - .../components/loop-input/loop-input.tsx | 188 ------------------ .../components/loop-label/loop-label.tsx | 79 -------- .../workflow-loop/workflow-loop.tsx | 137 ------------- .../generic-workflow-preview.tsx | 40 ++-- 5 files changed, 20 insertions(+), 425 deletions(-) delete mode 100644 apps/sim/app/w/[id]/components/workflow-loop/components/loop-input/loop-input.tsx delete mode 100644 apps/sim/app/w/[id]/components/workflow-loop/components/loop-label/loop-label.tsx delete mode 100644 apps/sim/app/w/[id]/components/workflow-loop/workflow-loop.tsx 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 index 33beaea18c9..90dbb8460af 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-config.ts +++ b/apps/sim/app/w/[id]/components/loop-node/loop-config.ts @@ -15,7 +15,6 @@ export const LoopTool = { width: 500, height: 300, extent: 'parent', - // Store loop execution state executionState: { currentIteration: 0, isExecuting: false, 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 5f52d3b4cc6..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 }, - parentId: `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 - parentId: `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/components/workflow-preview/generic-workflow-preview.tsx b/apps/sim/app/w/components/workflow-preview/generic-workflow-preview.tsx index 28b903ba660..14ff88cbb7b 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,9 +18,9 @@ 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' @@ -56,8 +56,8 @@ interface ExtendedSubBlockConfig extends SubBlockConfig { // Define node types const nodeTypes: NodeTypes = { workflowBlock: PreviewWorkflowBlock, - loopLabel: LoopLabel, - loopInput: LoopInput, + // loopLabel: LoopLabel, + // loopInput: LoopInput, } // Define edge types @@ -513,21 +513,21 @@ function WorkflowPreviewContent({ 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) - } - } - }) + // 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) + // } + // } + // }) // Add block nodes Object.entries(workflowState.blocks).forEach(([blockId, block]) => { From 69a1dd79179a38d2738d65020ab158d3f4015633 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Thu, 15 May 2025 13:43:06 -0700 Subject: [PATCH 09/13] feature: first pass on loop logic --- apps/sim/app/api/workflows/sync/route.ts | 3 +- .../generic-workflow-preview.tsx | 110 +++++-- apps/sim/components/icons.tsx | 28 -- apps/sim/package.json | 2 + apps/sim/stores/workflows/index.ts | 6 +- apps/sim/stores/workflows/middleware.ts | 12 +- apps/sim/stores/workflows/persistence.ts | 6 +- apps/sim/stores/workflows/registry/store.ts | 47 +-- apps/sim/stores/workflows/sync.ts | 2 +- apps/sim/stores/workflows/workflow/store.ts | 281 ++++++++++-------- apps/sim/stores/workflows/workflow/types.ts | 25 +- apps/sim/stores/workflows/workflow/utils.ts | 84 ++++++ apps/sim/tsconfig.json | 2 +- 13 files changed, 394 insertions(+), 214 deletions(-) diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts index bd6cdef1586..3058bc87322 100644 --- a/apps/sim/app/api/workflows/sync/route.ts +++ b/apps/sim/app/api/workflows/sync/route.ts @@ -21,7 +21,8 @@ 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({}), + loopBlocks: z.record(z.any()).optional(), lastSaved: z.number().optional(), isDeployed: z.boolean().optional(), deployedAt: z 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 14ff88cbb7b..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 @@ -23,6 +23,7 @@ import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edg // 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 @@ -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) - // } - // } - // }) - - // Add block nodes + // 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]) => { - const blockConfig = getBlock(block.type) - if (!blockConfig) return + if (block.data?.parentId) { + // This is a child block + blocksWithParents[blockId] = block + } else { + // This is a top-level block + topLevelBlocks[blockId] = block + } + }) + // Process top-level blocks first + Object.entries(topLevelBlocks).forEach(([blockId, block]) => { + const blockConfig = getBlock(block.type) + 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/components/icons.tsx b/apps/sim/components/icons.tsx index 8f43e5304b0..942107e3507 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2463,31 +2463,3 @@ export function ClayIcon(props: SVGProps) { ) } - -export function LoopIcon() { - return ( - - ) -} diff --git a/apps/sim/package.json b/apps/sim/package.json index 6710ee3b1cd..e537e90cb7a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -131,6 +131,8 @@ "critters": "^0.0.23", "dotenv": "^16.4.7", "drizzle-kit": "^0.31.1", + "eslint": "9.26.0", + "eslint-config-next": "15.3.2", "husky": "^9.1.7", "jsdom": "^26.0.0", "lint-staged": "^15.4.3", diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index 5404b7d63b8..af88c6c4f1d 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -33,6 +33,7 @@ export function getWorkflowWithValues(workflowId: string) { workflowState = { blocks: currentState.blocks, edges: currentState.edges, + loopBlocks: currentState.loopBlocks, loops: currentState.loops, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -60,7 +61,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, @@ -111,6 +112,7 @@ export function getAllWorkflowsWithValues() { workflowState = { blocks: currentState.blocks, edges: currentState.edges, + loopBlocks: currentState.loopBlocks, loops: currentState.loops, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -140,7 +142,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/middleware.ts b/apps/sim/stores/workflows/middleware.ts index 3975f63bfe5..292f7770f5f 100644 --- a/apps/sim/stores/workflows/middleware.ts +++ b/apps/sim/stores/workflows/middleware.ts @@ -46,6 +46,7 @@ export const withHistory = ( state: { blocks: initialState.blocks, edges: initialState.edges, + loopBlocks: initialState.loopBlocks, loops: initialState.loops, }, timestamp: Date.now(), @@ -110,7 +111,7 @@ export const withHistory = ( saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + loopBlocks: currentState.loopBlocks, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -161,7 +162,7 @@ export const withHistory = ( saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + loopBlocks: currentState.loopBlocks, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -174,11 +175,11 @@ export const withHistory = ( const newState = { blocks: {}, edges: [], - loops: {}, + loopBlocks: {}, history: { past: [], present: { - state: { blocks: {}, edges: [], loops: {} }, + state: { blocks: {}, edges: [], loopBlocks: {}, loops: {} }, timestamp: Date.now(), action: 'Clear workflow', subblockValues: {}, @@ -236,7 +237,7 @@ export const withHistory = ( saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loops: currentState.loops, + loopBlocks: currentState.loopBlocks, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -256,6 +257,7 @@ export const createHistoryEntry = (state: WorkflowState, action: string): Histor const stateCopy = { blocks: { ...state.blocks }, edges: [...state.edges], + loopBlocks: { ...state.loopBlocks }, loops: { ...state.loops }, } diff --git a/apps/sim/stores/workflows/persistence.ts b/apps/sim/stores/workflows/persistence.ts index 6c8a1ae3866..36588750ef8 100644 --- a/apps/sim/stores/workflows/persistence.ts +++ b/apps/sim/stores/workflows/persistence.ts @@ -157,12 +157,16 @@ 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, + loopBlocks: currentState.loopBlocks, + loops: generatedLoops, // Add loops for compatibility with API 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 7d420f9a198..7aaf2721459 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -129,7 +129,7 @@ function resetWorkflowStores() { useWorkflowStore.setState({ blocks: {}, edges: [], - // loops: {}, + loopBlocks: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -139,7 +139,8 @@ function resetWorkflowStores() { state: { blocks: {}, edges: [], - // loops: {}, + loopBlocks: {}, + loops: {}, isDeployed: false, deployedAt: undefined, }, @@ -339,7 +340,7 @@ export const useWorkflowRegistry = create()( saveWorkflowState(currentId, { blocks: currentState.blocks, edges: currentState.edges, - // loops: currentState.loops, + loopBlocks: currentState.loopBlocks, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -356,7 +357,7 @@ export const useWorkflowRegistry = create()( // Load workflow state for the new active workflow const parsedState = loadWorkflowState(id) if (parsedState) { - const { blocks, edges, history, loops, isDeployed, deployedAt } = parsedState + const { blocks, edges, history, loopBlocks, isDeployed, deployedAt } = parsedState // Initialize subblock store with workflow values useSubBlockStore.getState().initializeFromWorkflow(id, blocks) @@ -365,7 +366,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks, edges, - // loops, + loopBlocks, isDeployed: isDeployed !== undefined ? isDeployed : false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, hasActiveSchedule: false, @@ -375,7 +376,7 @@ export const useWorkflowRegistry = create()( state: { blocks, edges, - loops: {}, + loopBlocks, isDeployed: isDeployed !== undefined ? isDeployed : false, deployedAt: deployedAt, }, @@ -392,7 +393,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks: {}, edges: [], - // loops: {}, + loopBlocks: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -402,7 +403,8 @@ export const useWorkflowRegistry = create()( state: { blocks: {}, edges: [], - // loops: {}, + loopBlocks: {}, + loops: {}, isDeployed: false, deployedAt: undefined, }, @@ -456,7 +458,7 @@ export const useWorkflowRegistry = create()( initialState = { blocks: options.marketplaceState.blocks || {}, edges: options.marketplaceState.edges || [], - loops: options.marketplaceState.loops || {}, + loopBlocks: options.marketplaceState.loopBlocks || {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in the state object @@ -466,7 +468,7 @@ export const useWorkflowRegistry = create()( state: { blocks: options.marketplaceState.blocks || {}, edges: options.marketplaceState.edges || [], - loops: options.marketplaceState.loops || {}, + loopBlocks: options.marketplaceState.loopBlocks || {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in history @@ -579,7 +581,7 @@ export const useWorkflowRegistry = create()( [starterId]: starterBlock, }, edges: [], - loops: {}, + loopBlocks: {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in the state object @@ -591,7 +593,7 @@ export const useWorkflowRegistry = create()( [starterId]: starterBlock, }, edges: [], - loops: {}, + loopBlocks: {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in history @@ -651,7 +653,7 @@ export const useWorkflowRegistry = create()( /** * Creates a new workflow from a marketplace workflow * @param marketplaceId - The ID of the marketplace workflow to import - * @param state - The state of the marketplace workflow (blocks, edges, loops) + * @param state - The state of the marketplace workflow (blocks, edges, loopBlocks) * @param metadata - Additional metadata like name, description from marketplace * @returns The ID of the newly created workflow */ @@ -677,7 +679,7 @@ export const useWorkflowRegistry = create()( const initialState = { blocks: state.blocks || {}, edges: state.edges || [], - loops: state.loops || {}, + loopBlocks: state.loopBlocks || {}, isDeployed: false, deployedAt: undefined, history: { @@ -686,7 +688,7 @@ export const useWorkflowRegistry = create()( state: { blocks: state.blocks || {}, edges: state.edges || [], - loops: state.loops || {}, + loopBlocks: state.loopBlocks || {}, isDeployed: false, deployedAt: undefined, }, @@ -772,7 +774,7 @@ export const useWorkflowRegistry = create()( const newState = { blocks: sourceState.blocks || {}, edges: sourceState.edges || [], - loops: sourceState.loops || {}, + loopBlocks: sourceState.loopBlocks || {}, isDeployed: false, // Reset deployment status deployedAt: undefined, // Reset deployment timestamp workspaceId, // Include workspaceId in state @@ -782,7 +784,7 @@ export const useWorkflowRegistry = create()( state: { blocks: sourceState.blocks || {}, edges: sourceState.edges || [], - loops: sourceState.loops || {}, + loopBlocks: sourceState.loopBlocks || {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspaceId in history state @@ -877,11 +879,11 @@ export const useWorkflowRegistry = create()( newActiveWorkflowId = remainingIds[0] const savedState = loadWorkflowState(newActiveWorkflowId) if (savedState) { - const { blocks, edges, history, loops, isDeployed, deployedAt } = savedState + const { blocks, edges, history, loopBlocks, isDeployed, deployedAt } = savedState useWorkflowStore.setState({ blocks, edges, - // loops, + loopBlocks, isDeployed: isDeployed || false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, hasActiveSchedule: false, @@ -891,7 +893,7 @@ export const useWorkflowRegistry = create()( state: { blocks, edges, - loops, + loopBlocks, isDeployed: isDeployed || false, deployedAt, }, @@ -906,7 +908,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks: {}, edges: [], - // loops: {}, + loopBlocks: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -916,7 +918,8 @@ export const useWorkflowRegistry = create()( state: { blocks: {}, edges: [], - // loops: {}, + loopBlocks: {}, + loops: {}, isDeployed: false, deployedAt: undefined, }, diff --git a/apps/sim/stores/workflows/sync.ts b/apps/sim/stores/workflows/sync.ts index 376566458b1..6609a2c01ef 100644 --- a/apps/sim/stores/workflows/sync.ts +++ b/apps/sim/stores/workflows/sync.ts @@ -263,7 +263,7 @@ export async function fetchWorkflowsFromDB(): Promise { const workflowState = { blocks: state.blocks || {}, edges: state.edges || [], - loops: state.loops || {}, + loopBlocks: state.loopBlocks || {}, isDeployed: isDeployed || false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, apiKey, diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index e465f8eb210..fd9574373e6 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -9,12 +9,13 @@ import { useWorkflowRegistry } from '../registry/store' 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 { Loop, Position, SubBlockState, WorkflowState } from './types' +import { detectCycle, generateLoopBlocks } from './utils' const initialState = { blocks: {}, edges: [], + loopBlocks: {}, loops: {}, lastSaved: undefined, isDeployed: false, @@ -25,7 +26,7 @@ const initialState = { history: { past: [], present: { - state: { blocks: {}, edges: [], loops: {}, isDeployed: false, isPublished: false }, + state: { blocks: {}, edges: [], loopBlocks: {}, loops: {}, isDeployed: false, isPublished: false }, timestamp: Date.now(), action: 'Initial state', subblockValues: {}, @@ -109,7 +110,8 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - // loops: { ...get().loops }, + loopBlocks: { ...get().loopBlocks }, + loops: get().generateLoopBlocks(), } set(newState) @@ -158,7 +160,8 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - // loops: { ...get().loops }, + loopBlocks: { ...get().loopBlocks }, + loops: get().generateLoopBlocks(), } set(newState) @@ -253,7 +256,8 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - // loops: { ...get().loops }, + loopBlocks: { ...get().loopBlocks }, + loops: { ...get().loops }, }; console.log('[WorkflowStore/updateParentId] Updated parentId relationship:', { @@ -276,7 +280,8 @@ export const useWorkflowStore = create()( const newState = { blocks: { ...get().blocks }, edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id), - // loops: { ...get().loops }, + loopBlocks: { ...get().loopBlocks }, + loops: { ...get().loops }, } // Find and remove all child blocks if this is a parent node @@ -367,7 +372,7 @@ export const useWorkflowStore = create()( // Recalculate all loops after adding the edge const newLoops: Record = {} const processedPaths = new Set() - // const existingLoops = get().loops + const existingLoops = get().loopBlocks // Check for cycles from each node const nodes = new Set(newEdges.map((e) => e.source)) @@ -381,12 +386,12 @@ export const useWorkflowStore = create()( // Check if this path matches an existing loop let existingLoop: Loop | undefined - // Object.values(existingLoops).forEach((loop) => { - // const loopCanonicalPath = [...loop.nodes].sort().join(',') - // if (loopCanonicalPath === canonicalPath) { - // existingLoop = loop - // } - // }) + Object.values(existingLoops).forEach((loop) => { + const loopCanonicalPath = [...loop.nodes].sort().join(',') + if (loopCanonicalPath === canonicalPath) { + existingLoop = loop + } + }) if (existingLoop) { // Preserve the existing loop's properties @@ -409,10 +414,17 @@ export const useWorkflowStore = create()( }) }) + // Generate loopBlocks 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, + loopBlocks: mergedLoops, + loops: mergedLoops, } set(newState) @@ -441,49 +453,49 @@ export const useWorkflowStore = create()( // Recalculate all loops after edge removal //TODO: comment this loop logic out. - // const newLoops: Record = {} - // const processedPaths = new Set() - // const existingLoops = get().loops - - // // Check for cycles from each node - // const nodes = new Set(newEdges.map((e) => e.source)) - // nodes.forEach((node) => { - // const { paths } = detectCycle(newEdges, node) - // paths.forEach((path) => { - // // Create a canonical path representation for deduplication - // const canonicalPath = [...path].sort().join(',') - // if (!processedPaths.has(canonicalPath)) { - // processedPaths.add(canonicalPath) - - // // Check if this path matches an existing loop - // let existingLoop: Loop | undefined - // Object.values(existingLoops).forEach((loop) => { - // const loopCanonicalPath = [...loop.nodes].sort().join(',') - // if (loopCanonicalPath === canonicalPath) { - // existingLoop = loop - // } - // }) - - // if (existingLoop) { - // // Preserve the existing loop's properties - // newLoops[existingLoop.id] = { - // ...existingLoop, - // nodes: path, // Update nodes in case order changed - // } - // } else { - // // Create a new loop with default settings - // const loopId = crypto.randomUUID() - // newLoops[loopId] = { - // id: loopId, - // nodes: path, - // iterations: 5, - // loopType: 'for', - // forEachItems: '', - // } - // } - // } - // }) - // }) + const newLoops: Record = {} + const processedPaths = new Set() + const existingLoops = get().loopBlocks + + // Check for cycles from each node + const nodes = new Set(newEdges.map((e) => e.source)) + nodes.forEach((node) => { + const { paths } = detectCycle(newEdges, node) + paths.forEach((path) => { + // Create a canonical path representation for deduplication + const canonicalPath = [...path].sort().join(',') + if (!processedPaths.has(canonicalPath)) { + processedPaths.add(canonicalPath) + + // Check if this path matches an existing loop + let existingLoop: Loop | undefined + Object.values(existingLoops).forEach((loop) => { + const loopCanonicalPath = [...loop.nodes].sort().join(',') + if (loopCanonicalPath === canonicalPath) { + existingLoop = loop + } + }) + + if (existingLoop) { + // Preserve the existing loop's properties + newLoops[existingLoop.id] = { + ...existingLoop, + nodes: path, // Update nodes in case order changed + } + } else { + // Create a new loop with default settings + const loopId = crypto.randomUUID() + newLoops[loopId] = { + id: loopId, + nodes: path, + iterations: 5, + loopType: 'for', + forEachItems: '', + } + } + } + }) + }) @@ -491,7 +503,8 @@ export const useWorkflowStore = create()( const newState = { blocks: { ...get().blocks }, edges: newEdges, - loops: { }, // TODO: potentially remove this or edit, before removing: ...get().loops + loopBlocks: { }, + loops: { }, } set(newState) @@ -505,6 +518,7 @@ export const useWorkflowStore = create()( const newState = { blocks: {}, edges: [], + loopBlocks: {}, loops: {}, history: { past: [], @@ -512,6 +526,7 @@ export const useWorkflowStore = create()( state: { blocks: {}, edges: [], + loopBlocks: {}, loops: {}, isDeployed: false, isPublished: false, @@ -542,10 +557,12 @@ export const useWorkflowStore = create()( const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (activeWorkflowId) { const currentState = get() + const loopBlocks = currentState.generateLoopBlocks() saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - // loops: currentState.loops, + loopBlocks: loopBlocks, + loops: loopBlocks, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -569,6 +586,8 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], + loopBlocks: { ...get().loopBlocks }, + loops: { ...get().loops }, } set(newState) @@ -619,7 +638,8 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - // loops: { ...get().loops }, + loopBlocks: { ...get().loopBlocks }, + loops: get().generateLoopBlocks(), } // Update the subblock store with the duplicated values @@ -655,6 +675,8 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], + loopBlocks: { ...get().loopBlocks }, + loops: { ...get().loops }, } set(newState) @@ -677,7 +699,8 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - // loops: { ...get().loops }, + loopBlocks: { ...get().loopBlocks }, + loops: { ...get().loops }, } // Update references in subblock store @@ -759,7 +782,8 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], - // loops: { ...get().loops }, + loopBlocks: { ...state.loopBlocks }, + loops: { ...state.loops }, })) get().updateLastSaved() get().sync.markDirty() @@ -776,67 +800,83 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], + loopBlocks: { ...state.loopBlocks }, + 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 - // }, - // }, - // } - - // set(newState) - // pushHistory(set, get, newState, 'Update loop iterations') - // get().updateLastSaved() - // workflowSync.sync() - // }, - - // updateLoopType: (loopId: string, loopType: Loop['loopType']) => { - // const newState = { - // blocks: { ...get().blocks }, - // edges: [...get().edges], - // loops: { - // ...get().loops, - // [loopId]: { - // ...get().loops[loopId], - // loopType, - // }, - // }, - // } - - // set(newState) - // pushHistory(set, get, newState, 'Update loop type') - // get().updateLastSaved() - // workflowSync.sync() - // }, - - // updateLoopForEachItems: (loopId: string, items: string) => { - // const newState = { - // blocks: { ...get().blocks }, - // edges: [...get().edges], - // loops: { - // ...get().loops, - // [loopId]: { - // ...get().loops[loopId], - // forEachItems: items, - // }, - // }, - // } - - // set(newState) - // pushHistory(set, get, newState, 'Update forEach items') - // get().updateLastSaved() - // workflowSync.sync() - // }, + 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 + } + } + }, + edges: [...state.edges], + loopBlocks: { ...state.loopBlocks }, + 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 + } + } + }, + edges: [...state.edges], + loopBlocks: { ...state.loopBlocks }, + 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 + } + } + }, + edges: [...state.edges], + loopBlocks: { ...state.loopBlocks }, + loops: { ...state.loops }, + }; + }), + + // Function to convert UI loop blocks to execution format + generateLoopBlocks: () => { + return generateLoopBlocks(get().blocks); + }, triggerUpdate: () => { set((state) => ({ @@ -886,7 +926,8 @@ export const useWorkflowStore = create()( const newState = { blocks: deployedState.blocks, edges: deployedState.edges, - // loops: deployedState.loops, + loopBlocks: deployedState.loopBlocks, + loops: deployedState.loopBlocks || {}, // 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 fa9a1ea11ea..88e3ebe7ec0 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -26,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[] @@ -38,7 +53,8 @@ export interface WorkflowState { blocks: Record edges: Edge[] lastSaved?: number - // loops: Record + loopBlocks: Record + loops: Record lastUpdate?: number isDeployed?: boolean deployedAt?: Date @@ -74,9 +90,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..558dadb3c5e 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 loopBlocks collection 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 loopBlocks: Record = {}; + + // Find all loop nodes + Object.entries(blocks) + .filter(([_, block]) => block.type === 'loop') + .forEach(([id, block]) => { + const loop = convertLoopBlockToLoop(id, blocks); + if (loop) { + loopBlocks[id] = loop; + } + }); + + return loopBlocks; +} 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 From 5a09db771393182fe97bd7f045a20a1a32409cce Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Fri, 16 May 2025 16:44:24 -0400 Subject: [PATCH 10/13] add: package-lock.json --- apps/sim/stores/workflows/workflow/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index fd9574373e6..eeeaeb2d53e 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -9,7 +9,7 @@ import { useWorkflowRegistry } from '../registry/store' import { useSubBlockStore } from '../subblock/store' import { markWorkflowsDirty, workflowSync } from '../sync' import { mergeSubblockState } from '../utils' -import { Loop, Position, SubBlockState, WorkflowState } from './types' +import { Loop, Position, SubBlockState, SyncControl, WorkflowState } from './types' import { detectCycle, generateLoopBlocks } from './utils' const initialState = { From a1b8380c9870c2b8ee8018142510ae9a202ecd5a Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Fri, 16 May 2025 16:50:42 -0400 Subject: [PATCH 11/13] fix: deployedWorkflowState in modal --- .../components/deployed-workflow-modal.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) 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..2699a5cdd9b 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,15 @@ export function DeployedWorkflowModal({ })) const handleRevert = () => { - revertToDeployedState(deployedWorkflowState) - setShowRevertDialog(false) - onClose() + // Add the missing loopBlocks property + const completeState = { + ...deployedWorkflowState, + loopBlocks: {} // Add empty loopBlocks if not present + }; + + revertToDeployedState(completeState); + setShowRevertDialog(false); + onClose(); } return ( From 37fa5279013f47e386904aecf8321206850a2ce8 Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Sun, 18 May 2025 18:36:10 -0700 Subject: [PATCH 12/13] fix: resolved comments --- apps/sim/app/api/workflows/sync/route.ts | 1 - apps/sim/app/globals.css | 132 +------ .../components/deployed-workflow-modal.tsx | 9 +- ...loop-config-badges.tsx => loop-badges.tsx} | 26 +- .../w/[id]/components/loop-node/loop-node.tsx | 278 +++++++++------ .../toolbar-block/toolbar-block.tsx | 2 - apps/sim/app/w/[id]/utils.ts | 337 ++++++++++++++++++ apps/sim/app/w/[id]/workflow.tsx | 313 +++------------- apps/sim/stores/workflows/index.ts | 2 - apps/sim/stores/workflows/middleware.ts | 12 +- apps/sim/stores/workflows/persistence.ts | 3 +- apps/sim/stores/workflows/registry/store.ts | 41 +-- apps/sim/stores/workflows/sync.ts | 2 +- apps/sim/stores/workflows/workflow/store.ts | 34 +- apps/sim/stores/workflows/workflow/types.ts | 1 - apps/sim/stores/workflows/workflow/utils.ts | 8 +- 16 files changed, 627 insertions(+), 574 deletions(-) rename apps/sim/app/w/[id]/components/loop-node/components/{loop-config-badges.tsx => loop-badges.tsx} (93%) create mode 100644 apps/sim/app/w/[id]/utils.ts diff --git a/apps/sim/app/api/workflows/sync/route.ts b/apps/sim/app/api/workflows/sync/route.ts index 3058bc87322..4a97ec14988 100644 --- a/apps/sim/app/api/workflows/sync/route.ts +++ b/apps/sim/app/api/workflows/sync/route.ts @@ -22,7 +22,6 @@ const WorkflowStateSchema = z.object({ blocks: z.record(z.any()), edges: z.array(z.any()), loops: z.record(z.any()).default({}), - loopBlocks: z.record(z.any()).optional(), 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 f7ce6df0cd2..4388158b5a3 100644 --- a/apps/sim/app/globals.css +++ b/apps/sim/app/globals.css @@ -339,134 +339,4 @@ input[type='search']::-ms-clear { .main-content-overlay { z-index: 40; /* Higher z-index to appear above content */ -} - -/* Drag and drop styles for loop node */ -.drag-target { - transition: border-color 0.2s ease, background-color 0.2s ease; -} - -.drag-target.drag-over { - border-color: hsl(var(--primary)); - background-color: hsl(var(--primary) / 0.05); -} - -/* Optimize node rendering for smooth dragging performance */ -.react-flow__node-workflowBlock { - contain: layout style; - will-change: transform; - user-select: none; - touch-action: none; -} - -/* React Flow position transitions within loops */ -.react-flow__node[data-parent-node-id] { - transition: transform 0.05s ease; - pointer-events: all; -} - -/* Prevent jumpy drag behavior */ -.loop-drop-container .react-flow__node { - transform-origin: center; - position: absolute; -} - -/* Remove default border from React Flow group nodes */ -.react-flow__node-group { - border: none; - background-color: transparent; - outline: none; - box-shadow: none; -} - -/* Ensure child nodes stay within parent bounds */ -.react-flow__node[data-parent-node-id] .react-flow__handle { - z-index: 30; -} - -/* Enhanced drag detection */ -.react-flow__node-group.dragging-over { - background-color: rgba(34,197,94,0.05); - transition: all 0.2s ease-in-out; -} - -/* Loop node animation for drag operations */ -@keyframes loop-node-pulse { - 0% { - box-shadow: 0 0 0 0 rgba(64, 224, 208, 0.3); - } - 70% { - box-shadow: 0 0 0 6px rgba(64, 224, 208, 0); - } - 100% { - box-shadow: 0 0 0 0 rgba(64, 224, 208, 0); - } -} - -.loop-node-drag-over { - animation: loop-node-pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite; - border-style: solid !important; - background-color: rgba(47, 179, 255, 0.08) !important; - box-shadow: 0 0 0 8px rgba(47, 179, 255, 0.1); -} -/* Make resizer handles more visible */ -.react-flow__resize-control { - z-index: 10; - pointer-events: all !important; -} - -.react-flow__resize-control.bottom-right { - width: 12px !important; - height: 12px !important; - background-color: hsl(var(--primary)) !important; - border: 2px solid white !important; - transition: transform 0.2s ease-in-out; - opacity: 1 !important; - visibility: visible !important; - pointer-events: all !important; - position: absolute !important; - right: 0 !important; - bottom: 0 !important; -} - -.react-flow__resize-control.bottom-right:hover { - transform: scale(1.3); - background-color: #2FB3FF !important; -} - -/* Ensure NodeResizer is always above the custom resize handle */ -.react-flow__noderesize { - z-index: 11 !important; - pointer-events: all !important; -} - -/* Ensure parent borders are visible when hovering over resize controls */ -.react-flow__node-group:hover, -.hover-highlight { - border-color: #1e293b !important; -} - -/* Ensure hover effects work well */ -.group-node-container:hover .react-flow__resize-control.bottom-right { - opacity: 1 !important; - visibility: visible !important; -} - -/* Child node styling */ -.react-flow__node[data-parent] { - border: 1px dashed #4e9eff !important; - transition: all 0.2s ease; -} - -/* Visual feedback for nodes that are children of loops */ -.react-flow__node[data-parent]::after { - content: ''; - position: absolute; - top: -8px; - right: -8px; - width: 12px; - height: 12px; - border-radius: 50%; - background-color: #4e9eff; - z-index: 5; -} +} \ 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 2699a5cdd9b..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,13 +52,8 @@ export function DeployedWorkflowModal({ })) const handleRevert = () => { - // Add the missing loopBlocks property - const completeState = { - ...deployedWorkflowState, - loopBlocks: {} // Add empty loopBlocks if not present - }; - - revertToDeployedState(completeState); + // Revert to the deployed state + revertToDeployedState(deployedWorkflowState); setShowRevertDialog(false); onClose(); } diff --git a/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx similarity index 93% rename from apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx rename to apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx index d1a6638d775..6fad3f4655d 100644 --- a/apps/sim/app/w/[id]/components/loop-node/components/loop-config-badges.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/components/loop-badges.tsx @@ -11,12 +11,30 @@ import 'prismjs/components/prism-javascript' import 'prismjs/themes/prism.css' -interface LoopConfigBadgesProps { +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: any + data: LoopNodeData } -export function LoopConfigBadges({ nodeId, data }: LoopConfigBadgesProps) { +export function LoopBadges({ nodeId, data }: LoopBadgesProps) { // State const [loopType, setLoopType] = useState(data?.loopType || 'for') const [iterations, setIterations] = useState(data?.count || 5) @@ -26,7 +44,7 @@ export function LoopConfigBadges({ nodeId, data }: LoopConfigBadgesProps) { const [configPopoverOpen, setConfigPopoverOpen] = useState(false) // Get store methods - const updateNodeData = useCallback((updates: any) => { + const updateNodeData = useCallback((updates: Partial) => { useWorkflowStore.setState(state => ({ blocks: { ...state.blocks, 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 index cfb5d0e88f3..e827c58106e 100644 --- a/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx +++ b/apps/sim/app/w/[id]/components/loop-node/loop-node.tsx @@ -6,8 +6,77 @@ 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 { LoopConfigBadges } from './components/loop-config-badges' +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(); @@ -50,122 +119,125 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => { 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 */} -
+ +
+ 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', - minHeight: '100%', - pointerEvents: 'none', + overflow: 'visible', + ...nestedStyles, + pointerEvents: 'all', }} + data-node-id={id} + data-type="loopNode" + data-nesting-level={nestingLevel} > - {/* Delete button - styled like in action-bar.tsx */} - + /> - {/* Loop Start Block */} + {/* 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 */} - + {/* Input handle on left middle */} + - {/* Output handle on right middle */} - + {/* Output handle on right middle */} + - {/* Loop Configuration Badges */} - -
-
+ {/* Loop Configuration Badges */} + + +
+ ) }) 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 87130b24dc3..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 @@ -19,8 +19,6 @@ export function ToolbarBlock({ config }: ToolbarBlockProps) { const event = new CustomEvent('add-block-from-toolbar', { detail: { type: config.type, - clientX: e.clientX, - clientY: e.clientY }, }) window.dispatchEvent(event) 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 17bbdfb3f68..2f9134185b1 100644 --- a/apps/sim/app/w/[id]/workflow.tsx +++ b/apps/sim/app/w/[id]/workflow.tsx @@ -24,6 +24,16 @@ 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' @@ -65,7 +75,7 @@ function WorkflowContent() { // Store access const { workflows, setActiveWorkflow, createWorkflow } = useWorkflowRegistry() //Removed loops from the store - const { blocks, edges, addBlock, updateBlockPosition, addEdge, removeEdge, updateParentId, removeBlock } = + const { blocks, edges, addBlock, updateNodeDimensions, updateBlockPosition, addEdge, removeEdge, updateParentId, removeBlock } = useWorkflowStore() const { setValue: setSubBlockValue } = useSubBlockStore() const { markAllAsRead } = useNotificationStore() @@ -76,267 +86,50 @@ function WorkflowContent() { const { isDebugModeEnabled } = useGeneralStore() const [dragStartParentId, setDragStartParentId] = useState(null) - // Helper function to calculate node depth in hierarchy - const getNodeDepth = useCallback((nodeId: string): number => { - const node = getNodes().find(n => n.id === nodeId); - if (!node || !node.parentId) return 0; - return 1 + getNodeDepth(node.parentId); + // Wrapper functions that use the utilities but provide the getNodes function + const getNodeDepthWrapper = useCallback((nodeId: string): number => { + return getNodeDepth(nodeId, getNodes); }, [getNodes]); - // Helper function to get the full hierarchy path of a node - const getNodeHierarchy = useCallback((nodeId: string): string[] => { - const node = getNodes().find(n => n.id === nodeId); - if (!node || !node.parentId) return [nodeId]; - return [...getNodeHierarchy(node.parentId), nodeId]; + const getNodeHierarchyWrapper = useCallback((nodeId: string): string[] => { + return getNodeHierarchy(nodeId, getNodes); }, [getNodes]); - // Helper function to get absolute position of a node (accounting for nested parents) - const getNodeAbsolutePosition = useCallback((nodeId: string): { 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); - - // Calculate this node's absolute position - return { - x: parentPos.x + node.position.x, - y: parentPos.y + node.position.y - }; + const getNodeAbsolutePositionWrapper = useCallback((nodeId: string): { x: number, y: number } => { + return getNodeAbsolutePosition(nodeId, getNodes); }, [getNodes]); - // Helper function to calculate relative position to a new parent - const calculateRelativePosition = useCallback((nodeId: string, newParentId: string): { x: number, y: number } => { - // Get absolute position of the node - const nodeAbsPos = getNodeAbsolutePosition(nodeId); - - // Get absolute position of the new parent - const parentAbsPos = getNodeAbsolutePosition(newParentId); - - // Calculate relative position - return { - x: nodeAbsPos.x - parentAbsPos.x, - y: nodeAbsPos.y - parentAbsPos.y - }; - }, [getNodeAbsolutePosition]); + 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) => { - // 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); - - // Update both position and parent - updateBlockPosition(nodeId, relativePosition); - updateParentId(nodeId, newParentId, 'parent'); - - logger.info('Updated node parent', { - nodeId, - newParentId, - relativePosition - }); - } - - // Resize affected loops - debouncedResizeLoopNodes(); - }, [getNodes, calculateRelativePosition, getNodeAbsolutePosition, updateBlockPosition, updateParentId]); - - // Helper function to check if a point is inside a loop node - const isPointInLoopNode = useCallback((position: { x: number, y: number }): { - 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; + return updateNodeParentUtil( + nodeId, + newParentId, + getNodes, + updateBlockPosition, + updateParentId, + resizeLoopNodesWrapper + ); + }, [getNodes, updateBlockPosition, updateParentId]); + + const isPointInLoopNodeWrapper = useCallback((position: { x: number, y: number }) => { + return isPointInLoopNode(position, getNodes); }, [getNodes]); - // Helper function to calculate proper dimensions for a loop node based on its children - const calculateLoopDimensions = useCallback((loopId: string): { 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 }; + 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 resizeLoopNodes = useCallback(() => { - // 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) - })) - .sort((a, b) => a.depth - b.depth); - - // Resize each loop node based on its children - loopNodes.forEach(loopNode => { - const dimensions = calculateLoopDimensions(loopNode.id); - - // Only update if dimensions have changed (to avoid unnecessary updates) - if (dimensions.width !== loopNode.data?.width || - dimensions.height !== loopNode.data?.height) { - // Use the updateNodeDimensions from the workflow store - useWorkflowStore.getState().updateNodeDimensions(loopNode.id, dimensions); - } - }); - }, [getNodes, calculateLoopDimensions, getNodeDepth]); + const resizeLoopNodesWrapper = useCallback(() => { + return resizeLoopNodes(getNodes, updateNodeDimensions); + }, [getNodes, updateNodeDimensions]); // Use direct resizing function instead of debounced version for immediate updates - const debouncedResizeLoopNodes = resizeLoopNodes; + const debouncedResizeLoopNodes = resizeLoopNodesWrapper; // Initialize workflow useEffect(() => { @@ -517,7 +310,7 @@ function WorkflowContent() { }) // Check if dropping inside a loop node - const loopInfo = isPointInLoopNode(position); + const loopInfo = isPointInLoopNodeWrapper(position); // Clear any drag-over styling document.querySelectorAll('.loop-node-drag-over').forEach(el => { @@ -733,7 +526,7 @@ function WorkflowContent() { logger.error('Error dropping block:', { err }) } }, - [project, blocks, addBlock, addEdge, findClosestOutput, determineSourceHandle, isPointInLoopNode, getNodes] + [project, blocks, addBlock, addEdge, findClosestOutput, determineSourceHandle, isPointInLoopNodeWrapper, getNodes] ) // Handle drag over for ReactFlow canvas @@ -751,7 +544,7 @@ function WorkflowContent() { }); // Check if hovering over a loop node - const loopInfo = isPointInLoopNode(position); + const loopInfo = isPointInLoopNodeWrapper(position); // Clear any previous highlighting document.querySelectorAll('.loop-node-drag-over').forEach(el => { @@ -771,7 +564,7 @@ function WorkflowContent() { } catch (err) { logger.error('Error in onDragOver', { err }); } - }, [project, isPointInLoopNode]); + }, [project, isPointInLoopNodeWrapper]); // Init workflow useEffect(() => { @@ -932,14 +725,14 @@ function WorkflowContent() { }); // Fix the node by removing its parent reference and calculating absolute position - const absolutePosition = getNodeAbsolutePosition(id); + const absolutePosition = getNodeAbsolutePositionWrapper(id); // Update the node to remove parent reference and use absolute position updateBlockPosition(id, absolutePosition); updateParentId(id, '', 'parent'); } }); - }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePosition]); + }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePositionWrapper]); // Update edges const onEdgesChange = useCallback( @@ -1060,7 +853,7 @@ function WorkflowContent() { } // Get the node's absolute position to properly calculate intersections - const nodeAbsolutePos = getNodeAbsolutePosition(node.id); + const nodeAbsolutePos = getNodeAbsolutePositionWrapper(node.id); // Find intersections with loop nodes using absolute coordinates const intersectingNodes = getNodes() @@ -1074,7 +867,7 @@ function WorkflowContent() { // 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 = getNodeHierarchy(n.id); + const hierarchy = getNodeHierarchyWrapper(n.id); // If the dragged node is in the hierarchy, this would create a circular reference if (hierarchy.includes(node.id)) { @@ -1083,7 +876,7 @@ function WorkflowContent() { } // Get the loop's absolute position - const loopAbsolutePos = getNodeAbsolutePosition(n.id); + const loopAbsolutePos = getNodeAbsolutePositionWrapper(n.id); // Get dimensions based on node type const nodeWidth = node.type === 'loopNode' @@ -1120,7 +913,7 @@ function WorkflowContent() { // Add more information for sorting .map(n => ({ loop: n, - depth: getNodeDepth(n.id), + depth: getNodeDepthWrapper(n.id), // Calculate size for secondary sorting size: (n.data?.width || 500) * (n.data?.height || 300) })); @@ -1141,7 +934,7 @@ function WorkflowContent() { const bestLoopMatch = sortedLoops[0]; // Add a check to see if the bestLoopMatch is apart of the heirarchy of the node being dragged - const hierarchy = getNodeHierarchy(node.id); + const hierarchy = getNodeHierarchyWrapper(node.id); if (hierarchy.includes(bestLoopMatch.loop.id)) { setPotentialParentId(null); return; @@ -1167,7 +960,7 @@ function WorkflowContent() { } } }, - [getNodes, potentialParentId, blocks, getNodeHierarchy, getNodeAbsolutePosition, getNodeDepth] + [getNodes, potentialParentId, blocks, getNodeHierarchyWrapper, getNodeAbsolutePositionWrapper, getNodeDepthWrapper] ); // Add in a nodeDrag start event to set the dragStartParentId @@ -1218,7 +1011,7 @@ function WorkflowContent() { // 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 = getNodeHierarchy(potentialParentId); + 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)) { @@ -1241,7 +1034,7 @@ function WorkflowContent() { setDraggedNodeId(null); setPotentialParentId(null); }, - [getNodes, dragStartParentId, potentialParentId, updateNodeParent, getNodeHierarchy] + [getNodes, dragStartParentId, potentialParentId, updateNodeParent, getNodeHierarchyWrapper] ); // Update onPaneClick to only handle edge selection diff --git a/apps/sim/stores/workflows/index.ts b/apps/sim/stores/workflows/index.ts index af88c6c4f1d..715adf33498 100644 --- a/apps/sim/stores/workflows/index.ts +++ b/apps/sim/stores/workflows/index.ts @@ -33,7 +33,6 @@ export function getWorkflowWithValues(workflowId: string) { workflowState = { blocks: currentState.blocks, edges: currentState.edges, - loopBlocks: currentState.loopBlocks, loops: currentState.loops, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -112,7 +111,6 @@ export function getAllWorkflowsWithValues() { workflowState = { blocks: currentState.blocks, edges: currentState.edges, - loopBlocks: currentState.loopBlocks, loops: currentState.loops, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, diff --git a/apps/sim/stores/workflows/middleware.ts b/apps/sim/stores/workflows/middleware.ts index 292f7770f5f..3975f63bfe5 100644 --- a/apps/sim/stores/workflows/middleware.ts +++ b/apps/sim/stores/workflows/middleware.ts @@ -46,7 +46,6 @@ export const withHistory = ( state: { blocks: initialState.blocks, edges: initialState.edges, - loopBlocks: initialState.loopBlocks, loops: initialState.loops, }, timestamp: Date.now(), @@ -111,7 +110,7 @@ export const withHistory = ( saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loopBlocks: currentState.loopBlocks, + loops: currentState.loops, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -162,7 +161,7 @@ export const withHistory = ( saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loopBlocks: currentState.loopBlocks, + loops: currentState.loops, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -175,11 +174,11 @@ export const withHistory = ( const newState = { blocks: {}, edges: [], - loopBlocks: {}, + loops: {}, history: { past: [], present: { - state: { blocks: {}, edges: [], loopBlocks: {}, loops: {} }, + state: { blocks: {}, edges: [], loops: {} }, timestamp: Date.now(), action: 'Clear workflow', subblockValues: {}, @@ -237,7 +236,7 @@ export const withHistory = ( saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loopBlocks: currentState.loopBlocks, + loops: currentState.loops, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -257,7 +256,6 @@ export const createHistoryEntry = (state: WorkflowState, action: string): Histor const stateCopy = { blocks: { ...state.blocks }, edges: [...state.edges], - loopBlocks: { ...state.loopBlocks }, loops: { ...state.loops }, } diff --git a/apps/sim/stores/workflows/persistence.ts b/apps/sim/stores/workflows/persistence.ts index 36588750ef8..9a79405afb2 100644 --- a/apps/sim/stores/workflows/persistence.ts +++ b/apps/sim/stores/workflows/persistence.ts @@ -165,8 +165,7 @@ export function setupUnloadPersistence(): void { saveWorkflowState(currentId, { blocks: currentState.blocks, edges: currentState.edges, - loopBlocks: currentState.loopBlocks, - loops: generatedLoops, // Add loops for compatibility with API + 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 7aaf2721459..1c4d4485276 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -129,7 +129,7 @@ function resetWorkflowStores() { useWorkflowStore.setState({ blocks: {}, edges: [], - loopBlocks: {}, + loops: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -139,7 +139,6 @@ function resetWorkflowStores() { state: { blocks: {}, edges: [], - loopBlocks: {}, loops: {}, isDeployed: false, deployedAt: undefined, @@ -340,7 +339,7 @@ export const useWorkflowRegistry = create()( saveWorkflowState(currentId, { blocks: currentState.blocks, edges: currentState.edges, - loopBlocks: currentState.loopBlocks, + loops: currentState.loops, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -357,7 +356,7 @@ export const useWorkflowRegistry = create()( // Load workflow state for the new active workflow const parsedState = loadWorkflowState(id) if (parsedState) { - const { blocks, edges, history, loopBlocks, isDeployed, deployedAt } = parsedState + const { blocks, edges, history, loops, isDeployed, deployedAt } = parsedState // Initialize subblock store with workflow values useSubBlockStore.getState().initializeFromWorkflow(id, blocks) @@ -366,7 +365,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks, edges, - loopBlocks, + loops, isDeployed: isDeployed !== undefined ? isDeployed : false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, hasActiveSchedule: false, @@ -376,7 +375,7 @@ export const useWorkflowRegistry = create()( state: { blocks, edges, - loopBlocks, + loops, isDeployed: isDeployed !== undefined ? isDeployed : false, deployedAt: deployedAt, }, @@ -393,7 +392,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks: {}, edges: [], - loopBlocks: {}, + loops: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -403,7 +402,6 @@ export const useWorkflowRegistry = create()( state: { blocks: {}, edges: [], - loopBlocks: {}, loops: {}, isDeployed: false, deployedAt: undefined, @@ -458,7 +456,7 @@ export const useWorkflowRegistry = create()( initialState = { blocks: options.marketplaceState.blocks || {}, edges: options.marketplaceState.edges || [], - loopBlocks: options.marketplaceState.loopBlocks || {}, + loops: options.marketplaceState.loops || {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in the state object @@ -468,7 +466,7 @@ export const useWorkflowRegistry = create()( state: { blocks: options.marketplaceState.blocks || {}, edges: options.marketplaceState.edges || [], - loopBlocks: options.marketplaceState.loopBlocks || {}, + loops: options.marketplaceState.loops || {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in history @@ -581,7 +579,7 @@ export const useWorkflowRegistry = create()( [starterId]: starterBlock, }, edges: [], - loopBlocks: {}, + loops: {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in the state object @@ -593,7 +591,7 @@ export const useWorkflowRegistry = create()( [starterId]: starterBlock, }, edges: [], - loopBlocks: {}, + loops: {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspace ID in history @@ -653,7 +651,7 @@ export const useWorkflowRegistry = create()( /** * Creates a new workflow from a marketplace workflow * @param marketplaceId - The ID of the marketplace workflow to import - * @param state - The state of the marketplace workflow (blocks, edges, loopBlocks) + * @param state - The state of the marketplace workflow (blocks, edges, loops) * @param metadata - Additional metadata like name, description from marketplace * @returns The ID of the newly created workflow */ @@ -679,7 +677,7 @@ export const useWorkflowRegistry = create()( const initialState = { blocks: state.blocks || {}, edges: state.edges || [], - loopBlocks: state.loopBlocks || {}, + loops: state.loops || {}, isDeployed: false, deployedAt: undefined, history: { @@ -688,7 +686,7 @@ export const useWorkflowRegistry = create()( state: { blocks: state.blocks || {}, edges: state.edges || [], - loopBlocks: state.loopBlocks || {}, + loops: state.loops || {}, isDeployed: false, deployedAt: undefined, }, @@ -774,7 +772,7 @@ export const useWorkflowRegistry = create()( const newState = { blocks: sourceState.blocks || {}, edges: sourceState.edges || [], - loopBlocks: sourceState.loopBlocks || {}, + loops: sourceState.loops || {}, isDeployed: false, // Reset deployment status deployedAt: undefined, // Reset deployment timestamp workspaceId, // Include workspaceId in state @@ -784,7 +782,7 @@ export const useWorkflowRegistry = create()( state: { blocks: sourceState.blocks || {}, edges: sourceState.edges || [], - loopBlocks: sourceState.loopBlocks || {}, + loops: sourceState.loops || {}, isDeployed: false, deployedAt: undefined, workspaceId, // Include workspaceId in history state @@ -879,11 +877,11 @@ export const useWorkflowRegistry = create()( newActiveWorkflowId = remainingIds[0] const savedState = loadWorkflowState(newActiveWorkflowId) if (savedState) { - const { blocks, edges, history, loopBlocks, isDeployed, deployedAt } = savedState + const { blocks, edges, history, loops, isDeployed, deployedAt } = savedState useWorkflowStore.setState({ blocks, edges, - loopBlocks, + loops, isDeployed: isDeployed || false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, hasActiveSchedule: false, @@ -893,7 +891,7 @@ export const useWorkflowRegistry = create()( state: { blocks, edges, - loopBlocks, + loops, isDeployed: isDeployed || false, deployedAt, }, @@ -908,7 +906,7 @@ export const useWorkflowRegistry = create()( useWorkflowStore.setState({ blocks: {}, edges: [], - loopBlocks: {}, + loops: {}, isDeployed: false, deployedAt: undefined, hasActiveSchedule: false, @@ -918,7 +916,6 @@ export const useWorkflowRegistry = create()( state: { blocks: {}, edges: [], - loopBlocks: {}, loops: {}, isDeployed: false, deployedAt: undefined, diff --git a/apps/sim/stores/workflows/sync.ts b/apps/sim/stores/workflows/sync.ts index 6609a2c01ef..376566458b1 100644 --- a/apps/sim/stores/workflows/sync.ts +++ b/apps/sim/stores/workflows/sync.ts @@ -263,7 +263,7 @@ export async function fetchWorkflowsFromDB(): Promise { const workflowState = { blocks: state.blocks || {}, edges: state.edges || [], - loopBlocks: state.loopBlocks || {}, + loops: state.loops || {}, isDeployed: isDeployed || false, deployedAt: deployedAt ? new Date(deployedAt) : undefined, apiKey, diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index eeeaeb2d53e..195f91a62d8 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -15,7 +15,6 @@ import { detectCycle, generateLoopBlocks } from './utils' const initialState = { blocks: {}, edges: [], - loopBlocks: {}, loops: {}, lastSaved: undefined, isDeployed: false, @@ -26,7 +25,7 @@ const initialState = { history: { past: [], present: { - state: { blocks: {}, edges: [], loopBlocks: {}, loops: {}, isDeployed: false, isPublished: false }, + state: { blocks: {}, edges: [], loops: {}, isDeployed: false, isPublished: false }, timestamp: Date.now(), action: 'Initial state', subblockValues: {}, @@ -110,7 +109,6 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loopBlocks: { ...get().loopBlocks }, loops: get().generateLoopBlocks(), } @@ -160,7 +158,6 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loopBlocks: { ...get().loopBlocks }, loops: get().generateLoopBlocks(), } @@ -256,7 +253,6 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loopBlocks: { ...get().loopBlocks }, loops: { ...get().loops }, }; @@ -280,7 +276,6 @@ export const useWorkflowStore = create()( const newState = { blocks: { ...get().blocks }, edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id), - loopBlocks: { ...get().loopBlocks }, loops: { ...get().loops }, } @@ -372,7 +367,7 @@ export const useWorkflowStore = create()( // Recalculate all loops after adding the edge const newLoops: Record = {} const processedPaths = new Set() - const existingLoops = get().loopBlocks + const existingLoops = get().loops // Check for cycles from each node const nodes = new Set(newEdges.map((e) => e.source)) @@ -414,7 +409,7 @@ export const useWorkflowStore = create()( }) }) - // Generate loopBlocks from custom loop blocks + // Generate loops from custom loop blocks const generatedLoops = generateLoopBlocks(get().blocks); // Merge with detected cycles loops @@ -423,7 +418,6 @@ export const useWorkflowStore = create()( const newState = { blocks: { ...get().blocks }, edges: newEdges, - loopBlocks: mergedLoops, loops: mergedLoops, } @@ -455,7 +449,7 @@ export const useWorkflowStore = create()( //TODO: comment this loop logic out. const newLoops: Record = {} const processedPaths = new Set() - const existingLoops = get().loopBlocks + const existingLoops = get().loops // Check for cycles from each node const nodes = new Set(newEdges.map((e) => e.source)) @@ -503,7 +497,6 @@ export const useWorkflowStore = create()( const newState = { blocks: { ...get().blocks }, edges: newEdges, - loopBlocks: { }, loops: { }, } @@ -518,7 +511,6 @@ export const useWorkflowStore = create()( const newState = { blocks: {}, edges: [], - loopBlocks: {}, loops: {}, history: { past: [], @@ -526,7 +518,6 @@ export const useWorkflowStore = create()( state: { blocks: {}, edges: [], - loopBlocks: {}, loops: {}, isDeployed: false, isPublished: false, @@ -557,12 +548,11 @@ export const useWorkflowStore = create()( const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (activeWorkflowId) { const currentState = get() - const loopBlocks = currentState.generateLoopBlocks() + const generatedLoops = currentState.generateLoopBlocks() saveWorkflowState(activeWorkflowId, { blocks: currentState.blocks, edges: currentState.edges, - loopBlocks: loopBlocks, - loops: loopBlocks, + loops: generatedLoops, history: currentState.history, isDeployed: currentState.isDeployed, deployedAt: currentState.deployedAt, @@ -586,7 +576,6 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loopBlocks: { ...get().loopBlocks }, loops: { ...get().loops }, } @@ -638,7 +627,6 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loopBlocks: { ...get().loopBlocks }, loops: get().generateLoopBlocks(), } @@ -675,7 +663,6 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loopBlocks: { ...get().loopBlocks }, loops: { ...get().loops }, } @@ -699,7 +686,6 @@ export const useWorkflowStore = create()( }, }, edges: [...get().edges], - loopBlocks: { ...get().loopBlocks }, loops: { ...get().loops }, } @@ -782,7 +768,6 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], - loopBlocks: { ...state.loopBlocks }, loops: { ...state.loops }, })) get().updateLastSaved() @@ -800,7 +785,6 @@ export const useWorkflowStore = create()( }, }, edges: [...state.edges], - loopBlocks: { ...state.loopBlocks }, loops: { ...state.loops }, })) get().updateLastSaved() @@ -824,7 +808,6 @@ export const useWorkflowStore = create()( } }, edges: [...state.edges], - loopBlocks: { ...state.loopBlocks }, loops: { ...state.loops }, }; }), @@ -846,7 +829,6 @@ export const useWorkflowStore = create()( } }, edges: [...state.edges], - loopBlocks: { ...state.loopBlocks }, loops: { ...state.loops }, }; }), @@ -868,7 +850,6 @@ export const useWorkflowStore = create()( } }, edges: [...state.edges], - loopBlocks: { ...state.loopBlocks }, loops: { ...state.loops }, }; }), @@ -926,8 +907,7 @@ export const useWorkflowStore = create()( const newState = { blocks: deployedState.blocks, edges: deployedState.edges, - loopBlocks: deployedState.loopBlocks, - loops: deployedState.loopBlocks || {}, // Ensure loops property is set + 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 88e3ebe7ec0..c320c7131e3 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -53,7 +53,6 @@ export interface WorkflowState { blocks: Record edges: Edge[] lastSaved?: number - loopBlocks: Record loops: Record lastUpdate?: number isDeployed?: boolean diff --git a/apps/sim/stores/workflows/workflow/utils.ts b/apps/sim/stores/workflows/workflow/utils.ts index 558dadb3c5e..625639698e9 100644 --- a/apps/sim/stores/workflows/workflow/utils.ts +++ b/apps/sim/stores/workflows/workflow/utils.ts @@ -120,13 +120,13 @@ export function findAllDescendantNodes(loopId: string, blocks: Record): Record { - const loopBlocks: Record = {}; + const loops: Record = {}; // Find all loop nodes Object.entries(blocks) @@ -134,9 +134,9 @@ export function generateLoopBlocks(blocks: Record): Record { const loop = convertLoopBlockToLoop(id, blocks); if (loop) { - loopBlocks[id] = loop; + loops[id] = loop; } }); - return loopBlocks; + return loops; } From 2ead645a907b2c6bcb3609a21ceb8fb6dbf1000a Mon Sep 17 00:00:00 2001 From: Adam Gough Date: Sun, 18 May 2025 18:40:38 -0700 Subject: [PATCH 13/13] fix: edited package.json --- apps/sim/package.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/sim/package.json b/apps/sim/package.json index e537e90cb7a..6710ee3b1cd 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -131,8 +131,6 @@ "critters": "^0.0.23", "dotenv": "^16.4.7", "drizzle-kit": "^0.31.1", - "eslint": "9.26.0", - "eslint-config-next": "15.3.2", "husky": "^9.1.7", "jsdom": "^26.0.0", "lint-staged": "^15.4.3",