From bf5d0a55730658ba716c70d41134184e694eb9f7 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 31 Dec 2025 02:47:06 -0800 Subject: [PATCH] feat(copy-paste): allow cross workflow selection, paste, move for blocks (#2649) * feat(copy-paste): allow cross workflow selection, paste, move for blocks * fix drag options * add keyboard and mouse controls into docs * refactor sockets and undo/redo for batch additions and removals * fix tests * cleanup more code * fix perms issue * fix subflow copy/paste * remove log file * fit paste in viewport bounds * fix deselection --- .../docs/en/keyboard-shortcuts/index.mdx | 64 ++ apps/docs/content/docs/en/meta.json | 3 +- .../components/subflows/subflow-node.tsx | 4 +- .../components/action-bar/action-bar.tsx | 41 +- .../[workspaceId]/w/[workflowId]/workflow.tsx | 321 ++++++- apps/sim/hooks/use-collaborative-workflow.ts | 835 +++++++----------- apps/sim/hooks/use-undo-redo.ts | 782 +++++----------- apps/sim/socket/database/operations.ts | 663 ++++++-------- apps/sim/socket/handlers/operations.ts | 100 ++- apps/sim/socket/index.test.ts | 63 +- .../sim/socket/middleware/permissions.test.ts | 66 +- apps/sim/socket/middleware/permissions.ts | 10 +- apps/sim/socket/tests/socket-server.test.ts | 55 +- apps/sim/socket/validation/schemas.ts | 59 +- apps/sim/stores/operation-queue/store.ts | 63 +- apps/sim/stores/panel/variables/store.ts | 33 - apps/sim/stores/panel/variables/types.ts | 6 - apps/sim/stores/undo-redo/store.test.ts | 60 +- apps/sim/stores/undo-redo/store.ts | 19 +- apps/sim/stores/undo-redo/types.ts | 39 +- apps/sim/stores/undo-redo/utils.ts | 86 +- apps/sim/stores/variables/store.ts | 31 - apps/sim/stores/variables/types.ts | 1 - apps/sim/stores/workflows/json/importer.ts | 117 +-- apps/sim/stores/workflows/registry/store.ts | 99 ++- apps/sim/stores/workflows/registry/types.ts | 24 + apps/sim/stores/workflows/utils.ts | 397 ++++++++- packages/testing/src/factories/index.ts | 6 +- .../src/factories/permission.factory.ts | 6 +- .../src/factories/undo-redo.factory.ts | 130 ++- 30 files changed, 2125 insertions(+), 2058 deletions(-) create mode 100644 apps/docs/content/docs/en/keyboard-shortcuts/index.mdx diff --git a/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx new file mode 100644 index 000000000..604883c42 --- /dev/null +++ b/apps/docs/content/docs/en/keyboard-shortcuts/index.mdx @@ -0,0 +1,64 @@ +--- +title: Keyboard Shortcuts +description: Master the workflow canvas with keyboard shortcuts and mouse controls +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +Speed up your workflow building with these keyboard shortcuts and mouse controls. All shortcuts work when the canvas is focused (not when typing in an input field). + + + **Mod** refers to `Cmd` on macOS and `Ctrl` on Windows/Linux. + + +## Canvas Controls + +### Mouse Controls + +| Action | Control | +|--------|---------| +| Pan/move canvas | Left-drag on empty space | +| Pan/move canvas | Scroll or trackpad | +| Select multiple blocks | Right-drag to draw selection box | +| Drag block | Left-drag on block header | +| Add to selection | `Mod` + click on blocks | + +### Workflow Actions + +| Shortcut | Action | +|----------|--------| +| `Mod` + `Enter` | Run workflow (or cancel if running) | +| `Mod` + `Z` | Undo | +| `Mod` + `Shift` + `Z` | Redo | +| `Mod` + `C` | Copy selected blocks | +| `Mod` + `V` | Paste blocks | +| `Delete` or `Backspace` | Delete selected blocks or edges | +| `Shift` + `L` | Auto-layout canvas | + +## Panel Navigation + +These shortcuts switch between panel tabs on the right side of the canvas. + +| Shortcut | Action | +|----------|--------| +| `C` | Focus Copilot tab | +| `T` | Focus Toolbar tab | +| `E` | Focus Editor tab | +| `Mod` + `F` | Focus Toolbar search | + +## Global Navigation + +| Shortcut | Action | +|----------|--------| +| `Mod` + `K` | Open search | +| `Mod` + `Shift` + `A` | Add new agent workflow | +| `Mod` + `Y` | Go to templates | +| `Mod` + `L` | Go to logs | + +## Utility + +| Shortcut | Action | +|----------|--------| +| `Mod` + `D` | Clear terminal console | +| `Mod` + `E` | Clear notifications | + diff --git a/apps/docs/content/docs/en/meta.json b/apps/docs/content/docs/en/meta.json index 235c7c506..ddef012c7 100644 --- a/apps/docs/content/docs/en/meta.json +++ b/apps/docs/content/docs/en/meta.json @@ -14,7 +14,8 @@ "execution", "permissions", "sdks", - "self-hosting" + "self-hosting", + "./keyboard-shortcuts/index" ], "defaultOpen": false } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx index 98375ee56..e37f4dd88 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx @@ -61,7 +61,7 @@ export interface SubflowNodeData { */ export const SubflowNodeComponent = memo(({ data, id }: NodeProps) => { const { getNodes } = useReactFlow() - const { collaborativeRemoveBlock } = useCollaborativeWorkflow() + const { collaborativeBatchRemoveBlocks } = useCollaborativeWorkflow() const blockRef = useRef(null) const currentWorkflow = useCurrentWorkflow() @@ -184,7 +184,7 @@ export const SubflowNodeComponent = memo(({ data, id }: NodeProps { e.stopPropagation() - collaborativeRemoveBlock(id) + collaborativeBatchRemoveBlocks([id]) }} className='h-[14px] w-[14px] p-0 opacity-0 transition-opacity duration-100 group-hover:opacity-100' > diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index 1e33517d6..3faa5498a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -4,8 +4,13 @@ import { Button, Copy, Tooltip, Trash2 } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow' +import { useWorkflowRegistry } from '@/stores/workflows/registry/store' +import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { getUniqueBlockName, prepareDuplicateBlockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +const DEFAULT_DUPLICATE_OFFSET = { x: 50, y: 50 } + /** * Props for the ActionBar component */ @@ -27,11 +32,39 @@ interface ActionBarProps { export const ActionBar = memo( function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) { const { - collaborativeRemoveBlock, + collaborativeBatchAddBlocks, + collaborativeBatchRemoveBlocks, collaborativeToggleBlockEnabled, - collaborativeDuplicateBlock, collaborativeToggleBlockHandles, } = useCollaborativeWorkflow() + const { activeWorkflowId } = useWorkflowRegistry() + const blocks = useWorkflowStore((state) => state.blocks) + const subBlockStore = useSubBlockStore() + + const handleDuplicateBlock = useCallback(() => { + const sourceBlock = blocks[blockId] + if (!sourceBlock) return + + const newId = crypto.randomUUID() + const newName = getUniqueBlockName(sourceBlock.name, blocks) + const subBlockValues = subBlockStore.workflowValues[activeWorkflowId || '']?.[blockId] || {} + + const { block, subBlockValues: filteredValues } = prepareDuplicateBlockState({ + sourceBlock, + newId, + newName, + positionOffset: DEFAULT_DUPLICATE_OFFSET, + subBlockValues, + }) + + collaborativeBatchAddBlocks([block], [], {}, {}, { [newId]: filteredValues }) + }, [ + blockId, + blocks, + activeWorkflowId, + subBlockStore.workflowValues, + collaborativeBatchAddBlocks, + ]) /** * Optimized single store subscription for all block data @@ -115,7 +148,7 @@ export const ActionBar = memo( onClick={(e) => { e.stopPropagation() if (!disabled) { - collaborativeDuplicateBlock(blockId) + handleDuplicateBlock() } }} className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]' @@ -185,7 +218,7 @@ export const ActionBar = memo( onClick={(e) => { e.stopPropagation() if (!disabled) { - collaborativeRemoveBlock(blockId) + collaborativeBatchRemoveBlocks([blockId]) } }} className='hover:!text-[var(--text-inverse)] h-[23px] w-[23px] rounded-[8px] bg-[var(--surface-7)] p-0 text-[var(--text-secondary)] hover:bg-[var(--brand-secondary)]' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 405651eb8..50710ef29 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -60,7 +60,7 @@ import { usePanelEditorStore } from '@/stores/panel/editor/store' import { useGeneralStore } from '@/stores/settings/general/store' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' -import { getUniqueBlockName } from '@/stores/workflows/utils' +import { getUniqueBlockName, prepareBlockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' /** Lazy-loaded components for non-critical UI that can load after initial render */ @@ -77,6 +77,60 @@ const LazyOAuthRequiredModal = lazy(() => const logger = createLogger('Workflow') +const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 } + +/** + * Calculates the offset to paste blocks at viewport center + */ +function calculatePasteOffset( + clipboard: { + blocks: Record + } | null, + screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number } +): { x: number; y: number } { + if (!clipboard) return DEFAULT_PASTE_OFFSET + + const clipboardBlocks = Object.values(clipboard.blocks) + if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET + + // Calculate bounding box using proper dimensions + const minX = Math.min(...clipboardBlocks.map((b) => b.position.x)) + const maxX = Math.max( + ...clipboardBlocks.map((b) => { + const width = + b.type === 'loop' || b.type === 'parallel' + ? CONTAINER_DIMENSIONS.DEFAULT_WIDTH + : BLOCK_DIMENSIONS.FIXED_WIDTH + return b.position.x + width + }) + ) + const minY = Math.min(...clipboardBlocks.map((b) => b.position.y)) + const maxY = Math.max( + ...clipboardBlocks.map((b) => { + const height = + b.type === 'loop' || b.type === 'parallel' + ? CONTAINER_DIMENSIONS.DEFAULT_HEIGHT + : Math.max(b.height || BLOCK_DIMENSIONS.MIN_HEIGHT, BLOCK_DIMENSIONS.MIN_HEIGHT) + return b.position.y + height + }) + ) + const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 } + + const flowContainer = document.querySelector('.react-flow') + if (!flowContainer) return DEFAULT_PASTE_OFFSET + + const rect = flowContainer.getBoundingClientRect() + const viewportCenter = screenToFlowPosition({ + x: rect.width / 2, + y: rect.height / 2, + }) + + return { + x: viewportCenter.x - clipboardCenter.x, + y: viewportCenter.y - clipboardCenter.y, + } +} + /** Custom node types for ReactFlow. */ const nodeTypes: NodeTypes = { workflowBlock: WorkflowBlock, @@ -93,17 +147,13 @@ const edgeTypes: EdgeTypes = { /** ReactFlow configuration constants. */ const defaultEdgeOptions = { type: 'custom' } -/** Tailwind classes for ReactFlow internal element styling */ const reactFlowStyles = [ - // Z-index layering '[&_.react-flow__edges]:!z-0', '[&_.react-flow__node]:!z-[21]', '[&_.react-flow__handle]:!z-[30]', '[&_.react-flow__edge-labels]:!z-[60]', - // Light mode: transparent pane to show dots '[&_.react-flow__pane]:!bg-transparent', '[&_.react-flow__renderer]:!bg-transparent', - // Dark mode: solid background, hide dots 'dark:[&_.react-flow__pane]:!bg-[var(--bg)]', 'dark:[&_.react-flow__renderer]:!bg-[var(--bg)]', 'dark:[&_.react-flow__background]:hidden', @@ -132,6 +182,8 @@ const WorkflowContent = React.memo(() => { const [isCanvasReady, setIsCanvasReady] = useState(false) const [potentialParentId, setPotentialParentId] = useState(null) const [selectedEdgeInfo, setSelectedEdgeInfo] = useState(null) + const [isShiftPressed, setIsShiftPressed] = useState(false) + const [isSelectionDragActive, setIsSelectionDragActive] = useState(false) const [isErrorConnectionDrag, setIsErrorConnectionDrag] = useState(false) const [oauthModal, setOauthModal] = useState<{ provider: OAuthProvider @@ -151,12 +203,25 @@ const WorkflowContent = React.memo(() => { const addNotification = useNotificationStore((state) => state.addNotification) - const { workflows, activeWorkflowId, hydration, setActiveWorkflow } = useWorkflowRegistry( + const { + workflows, + activeWorkflowId, + hydration, + setActiveWorkflow, + copyBlocks, + preparePasteData, + hasClipboard, + clipboard, + } = useWorkflowRegistry( useShallow((state) => ({ workflows: state.workflows, activeWorkflowId: state.activeWorkflowId, hydration: state.hydration, setActiveWorkflow: state.setActiveWorkflow, + copyBlocks: state.copyBlocks, + preparePasteData: state.preparePasteData, + hasClipboard: state.hasClipboard, + clipboard: state.clipboard, })) ) @@ -336,16 +401,56 @@ const WorkflowContent = React.memo(() => { }, [userPermissions, currentWorkflow.isSnapshotView]) const { - collaborativeAddBlock: addBlock, collaborativeAddEdge: addEdge, - collaborativeRemoveBlock: removeBlock, collaborativeRemoveEdge: removeEdge, - collaborativeUpdateBlockPosition, + collaborativeBatchUpdatePositions, collaborativeUpdateParentId: updateParentId, + collaborativeBatchAddBlocks, + collaborativeBatchRemoveBlocks, undo, redo, } = useCollaborativeWorkflow() + const updateBlockPosition = useCallback( + (id: string, position: { x: number; y: number }) => { + collaborativeBatchUpdatePositions([{ id, position }]) + }, + [collaborativeBatchUpdatePositions] + ) + + const addBlock = useCallback( + ( + id: string, + type: string, + name: string, + position: { x: number; y: number }, + data?: Record, + parentId?: string, + extent?: 'parent', + autoConnectEdge?: Edge, + triggerMode?: boolean + ) => { + const blockData: Record = { ...(data || {}) } + if (parentId) blockData.parentId = parentId + if (extent) blockData.extent = extent + + const block = prepareBlockState({ + id, + type, + name, + position, + data: blockData, + parentId, + extent, + triggerMode, + }) + + collaborativeBatchAddBlocks([block], autoConnectEdge ? [autoConnectEdge] : [], {}, {}, {}) + usePanelEditorStore.getState().setCurrentBlockId(id) + }, + [collaborativeBatchAddBlocks] + ) + const { activeBlockIds, pendingBlocks, isDebugging } = useExecutionStore( useShallow((state) => ({ activeBlockIds: state.activeBlockIds, @@ -419,7 +524,7 @@ const WorkflowContent = React.memo(() => { const result = updateNodeParentUtil( nodeId, newParentId, - collaborativeUpdateBlockPosition, + updateBlockPosition, updateParentId, () => resizeLoopNodesWrapper() ) @@ -443,7 +548,7 @@ const WorkflowContent = React.memo(() => { }, [ getNodes, - collaborativeUpdateBlockPosition, + updateBlockPosition, updateParentId, blocks, edgesForDisplay, @@ -495,6 +600,54 @@ const WorkflowContent = React.memo(() => { ) { event.preventDefault() redo() + } else if ((event.ctrlKey || event.metaKey) && event.key === 'c') { + const selectedNodes = getNodes().filter((node) => node.selected) + if (selectedNodes.length > 0) { + event.preventDefault() + copyBlocks(selectedNodes.map((node) => node.id)) + } else { + const currentBlockId = usePanelEditorStore.getState().currentBlockId + if (currentBlockId && blocks[currentBlockId]) { + event.preventDefault() + copyBlocks([currentBlockId]) + } + } + } else if ((event.ctrlKey || event.metaKey) && event.key === 'v') { + if (effectivePermissions.canEdit && hasClipboard()) { + event.preventDefault() + + // Calculate offset to paste blocks at viewport center + const pasteOffset = calculatePasteOffset(clipboard, screenToFlowPosition) + + const pasteData = preparePasteData(pasteOffset) + if (pasteData) { + const pastedBlocks = Object.values(pasteData.blocks) + const hasTriggerInPaste = pastedBlocks.some((block) => + TriggerUtils.isAnyTriggerType(block.type) + ) + if (hasTriggerInPaste) { + const existingTrigger = Object.values(blocks).find((block) => + TriggerUtils.isAnyTriggerType(block.type) + ) + if (existingTrigger) { + addNotification({ + level: 'error', + message: + 'A workflow can only have one trigger block. Please remove the existing one before pasting.', + workflowId: activeWorkflowId || undefined, + }) + return + } + } + collaborativeBatchAddBlocks( + pastedBlocks, + pasteData.edges, + pasteData.loops, + pasteData.parallels, + pasteData.subBlockValues + ) + } + } } } @@ -504,7 +657,22 @@ const WorkflowContent = React.memo(() => { window.removeEventListener('keydown', handleKeyDown) if (cleanup) cleanup() } - }, [debouncedAutoLayout, undo, redo]) + }, [ + debouncedAutoLayout, + undo, + redo, + getNodes, + copyBlocks, + preparePasteData, + collaborativeBatchAddBlocks, + hasClipboard, + effectivePermissions.canEdit, + blocks, + addNotification, + activeWorkflowId, + clipboard, + screenToFlowPosition, + ]) /** * Removes all edges connected to a block, skipping individual edge recording for undo/redo. @@ -617,14 +785,17 @@ const WorkflowContent = React.memo(() => { /** Creates a standardized edge object for workflow connections. */ const createEdgeObject = useCallback( - (sourceId: string, targetId: string, sourceHandle: string): Edge => ({ - id: crypto.randomUUID(), - source: sourceId, - target: targetId, - sourceHandle, - targetHandle: 'target', - type: 'workflowEdge', - }), + (sourceId: string, targetId: string, sourceHandle: string): Edge => { + const edge = { + id: crypto.randomUUID(), + source: sourceId, + target: targetId, + sourceHandle, + targetHandle: 'target', + type: 'workflowEdge', + } + return edge + }, [] ) @@ -1568,6 +1739,21 @@ const WorkflowContent = React.memo(() => { // Local state for nodes - allows smooth drag without store updates on every frame const [displayNodes, setDisplayNodes] = useState([]) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') setIsShiftPressed(true) + } + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') setIsShiftPressed(false) + } + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + } + }, []) + // Sync derived nodes to display nodes when structure changes useEffect(() => { setDisplayNodes(derivedNodes) @@ -1673,21 +1859,12 @@ const WorkflowContent = React.memo(() => { 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 - collaborativeUpdateBlockPosition(id, absolutePosition) + updateBlockPosition(id, absolutePosition) updateParentId(id, '', 'parent') } }) - }, [ - blocks, - collaborativeUpdateBlockPosition, - updateParentId, - getNodeAbsolutePosition, - isWorkflowReady, - ]) + }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePosition, isWorkflowReady]) /** Handles edge removal changes. */ const onEdgesChange = useCallback( @@ -2095,9 +2272,7 @@ const WorkflowContent = React.memo(() => { } } - // Emit collaborative position update for the final position - // This ensures other users see the smooth final position - collaborativeUpdateBlockPosition(node.id, finalPosition, true) + updateBlockPosition(node.id, finalPosition) // Record single move entry on drag end to avoid micro-moves const start = getDragStartPosition() @@ -2218,7 +2393,7 @@ const WorkflowContent = React.memo(() => { dragStartParentId, potentialParentId, updateNodeParent, - collaborativeUpdateBlockPosition, + updateBlockPosition, addEdge, tryCreateAutoConnectEdge, blocks, @@ -2232,7 +2407,57 @@ const WorkflowContent = React.memo(() => { ] ) - /** Clears edge selection and panel state when clicking empty canvas. */ + // Lock selection mode when selection drag starts (captures Shift state at drag start) + const onSelectionStart = useCallback(() => { + if (isShiftPressed) { + setIsSelectionDragActive(true) + } + }, [isShiftPressed]) + + const onSelectionEnd = useCallback(() => { + requestAnimationFrame(() => setIsSelectionDragActive(false)) + }, []) + + const onSelectionDragStop = useCallback( + (_event: React.MouseEvent, nodes: any[]) => { + requestAnimationFrame(() => setIsSelectionDragActive(false)) + if (nodes.length === 0) return + + const positionUpdates = nodes.map((node) => { + const currentBlock = blocks[node.id] + const currentParentId = currentBlock?.data?.parentId + let finalPosition = node.position + + if (currentParentId) { + const parentNode = getNodes().find((n) => n.id === currentParentId) + if (parentNode) { + const containerDimensions = { + width: parentNode.data?.width || CONTAINER_DIMENSIONS.DEFAULT_WIDTH, + height: parentNode.data?.height || CONTAINER_DIMENSIONS.DEFAULT_HEIGHT, + } + const blockDimensions = { + width: BLOCK_DIMENSIONS.FIXED_WIDTH, + height: Math.max( + currentBlock?.height || BLOCK_DIMENSIONS.MIN_HEIGHT, + BLOCK_DIMENSIONS.MIN_HEIGHT + ), + } + finalPosition = clampPositionToContainer( + node.position, + containerDimensions, + blockDimensions + ) + } + } + + return { id: node.id, position: finalPosition } + }) + + collaborativeBatchUpdatePositions(positionUpdates) + }, + [blocks, getNodes, collaborativeBatchUpdatePositions] + ) + const onPaneClick = useCallback(() => { setSelectedEdgeInfo(null) usePanelEditorStore.getState().clearCurrentBlock() @@ -2333,13 +2558,19 @@ const WorkflowContent = React.memo(() => { } event.preventDefault() - const primaryNode = selectedNodes[0] - removeBlock(primaryNode.id) + const selectedIds = selectedNodes.map((node) => node.id) + collaborativeBatchRemoveBlocks(selectedIds) } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [selectedEdgeInfo, removeEdge, getNodes, removeBlock, effectivePermissions.canEdit]) + }, [ + selectedEdgeInfo, + removeEdge, + getNodes, + collaborativeBatchRemoveBlocks, + effectivePermissions.canEdit, + ]) return (
@@ -2390,15 +2621,18 @@ const WorkflowContent = React.memo(() => { proOptions={reactFlowProOptions} connectionLineStyle={connectionLineStyle} connectionLineType={ConnectionLineType.SmoothStep} - onNodeClick={(e, _node) => { - e.stopPropagation() - }} onPaneClick={onPaneClick} onEdgeClick={onEdgeClick} + onPaneContextMenu={(e) => e.preventDefault()} + onNodeContextMenu={(e) => e.preventDefault()} onPointerMove={handleCanvasPointerMove} onPointerLeave={handleCanvasPointerLeave} elementsSelectable={true} - selectNodesOnDrag={false} + selectionOnDrag={isShiftPressed || isSelectionDragActive} + panOnDrag={isShiftPressed || isSelectionDragActive ? false : [0, 1]} + onSelectionStart={onSelectionStart} + onSelectionEnd={onSelectionEnd} + multiSelectionKeyCode={['Meta', 'Control']} nodesConnectable={effectivePermissions.canEdit} nodesDraggable={effectivePermissions.canEdit} draggable={false} @@ -2408,6 +2642,7 @@ const WorkflowContent = React.memo(() => { className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'}`} onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined} onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined} + onSelectionDragStop={effectivePermissions.canEdit ? onSelectionDragStop : undefined} onNodeDragStart={effectivePermissions.canEdit ? onNodeDragStart : undefined} snapToGrid={snapToGrid} snapGrid={snapGrid} diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 37e599a27..4df0e00f4 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -2,8 +2,6 @@ import { useCallback, useEffect, useRef } from 'react' import { createLogger } from '@sim/logger' import type { Edge } from 'reactflow' import { useSession } from '@/lib/auth/auth-client' -import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' -import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' @@ -16,9 +14,9 @@ import { useUndoRedoStore } from '@/stores/undo-redo' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils' +import { mergeSubblockState, normalizeName } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' -import type { BlockState, Position } from '@/stores/workflows/workflow/types' +import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types' const logger = createLogger('CollaborativeWorkflow') @@ -201,39 +199,6 @@ export function useCollaborativeWorkflow() { try { if (target === 'block') { switch (operation) { - case 'add': - workflowStore.addBlock( - payload.id, - payload.type, - payload.name, - payload.position, - payload.data, - payload.parentId, - payload.extent, - { - enabled: payload.enabled, - horizontalHandles: payload.horizontalHandles, - advancedMode: payload.advancedMode, - triggerMode: payload.triggerMode ?? false, - height: payload.height, - } - ) - if (payload.autoConnectEdge) { - workflowStore.addEdge(payload.autoConnectEdge) - } - // Apply subblock values if present in payload - if (payload.subBlocks && typeof payload.subBlocks === 'object') { - Object.entries(payload.subBlocks).forEach(([subblockId, subblock]) => { - if (WEBHOOK_SUBBLOCK_FIELDS.includes(subblockId)) { - return - } - const value = (subblock as any)?.value - if (value !== undefined && value !== null) { - subBlockStore.setValue(payload.id, subblockId, value) - } - }) - } - break case 'update-position': { const blockId = payload.id @@ -265,40 +230,6 @@ export function useCollaborativeWorkflow() { case 'update-name': workflowStore.updateBlockName(payload.id, payload.name) break - case 'remove': { - const blockId = payload.id - const blocksToRemove = new Set([blockId]) - - const findAllDescendants = (parentId: string) => { - Object.entries(workflowStore.blocks).forEach(([id, block]) => { - if (block.data?.parentId === parentId) { - blocksToRemove.add(id) - findAllDescendants(id) - } - }) - } - findAllDescendants(blockId) - - workflowStore.removeBlock(blockId) - lastPositionTimestamps.current.delete(blockId) - - const updatedBlocks = useWorkflowStore.getState().blocks - const updatedEdges = useWorkflowStore.getState().edges - const graph = { - blocksById: updatedBlocks, - edgesById: Object.fromEntries(updatedEdges.map((e) => [e.id, e])), - } - - const undoRedoStore = useUndoRedoStore.getState() - const stackKeys = Object.keys(undoRedoStore.stacks) - stackKeys.forEach((key) => { - const [workflowId, userId] = key.split(':') - if (workflowId === activeWorkflowId) { - undoRedoStore.pruneInvalidEntries(workflowId, userId, graph) - } - }) - break - } case 'toggle-enabled': workflowStore.toggleBlockEnabled(payload.id) break @@ -318,40 +249,20 @@ export function useCollaborativeWorkflow() { } break } - case 'duplicate': - workflowStore.addBlock( - payload.id, - payload.type, - payload.name, - payload.position, - payload.data, - payload.parentId, - payload.extent, - { - enabled: payload.enabled, - horizontalHandles: payload.horizontalHandles, - advancedMode: payload.advancedMode, - triggerMode: payload.triggerMode ?? false, - height: payload.height, - } - ) - // Handle auto-connect edge if present - if (payload.autoConnectEdge) { - workflowStore.addEdge(payload.autoConnectEdge) - } - // Apply subblock values from duplicate payload so collaborators see content immediately - if (payload.subBlocks && typeof payload.subBlocks === 'object') { - Object.entries(payload.subBlocks).forEach(([subblockId, subblock]) => { - if (WEBHOOK_SUBBLOCK_FIELDS.includes(subblockId)) { - return - } - const value = (subblock as any)?.value - if (value !== undefined) { - subBlockStore.setValue(payload.id, subblockId, value) + } + } else if (target === 'blocks') { + switch (operation) { + case 'batch-update-positions': { + const { updates } = payload + if (Array.isArray(updates)) { + updates.forEach(({ id, position }: { id: string; position: Position }) => { + if (id && position) { + workflowStore.updateBlockPosition(id, position) } }) } break + } } } else if (target === 'edge') { switch (operation) { @@ -439,9 +350,6 @@ export function useCollaborativeWorkflow() { case 'remove': variablesStore.deleteVariable(payload.variableId) break - case 'duplicate': - variablesStore.duplicateVariable(payload.sourceVariableId, payload.id) - break } } else if (target === 'workflow') { switch (operation) { @@ -477,6 +385,93 @@ export function useCollaborativeWorkflow() { break } } + + if (target === 'blocks') { + switch (operation) { + case 'batch-add-blocks': { + const { + blocks, + edges, + loops, + parallels, + subBlockValues: addedSubBlockValues, + } = payload + logger.info('Received batch-add-blocks from remote user', { + userId, + blockCount: (blocks || []).length, + edgeCount: (edges || []).length, + }) + + ;(blocks || []).forEach((block: BlockState) => { + workflowStore.addBlock( + block.id, + block.type, + block.name, + block.position, + block.data, + block.data?.parentId, + block.data?.extent, + { + enabled: block.enabled, + horizontalHandles: block.horizontalHandles, + advancedMode: block.advancedMode, + triggerMode: block.triggerMode ?? false, + height: block.height, + } + ) + }) + + ;(edges || []).forEach((edge: Edge) => { + workflowStore.addEdge(edge) + }) + + if (loops) { + Object.entries(loops as Record).forEach(([loopId, loopConfig]) => { + useWorkflowStore.setState((state) => ({ + loops: { ...state.loops, [loopId]: loopConfig }, + })) + }) + } + + if (parallels) { + Object.entries(parallels as Record).forEach( + ([parallelId, parallelConfig]) => { + useWorkflowStore.setState((state) => ({ + parallels: { ...state.parallels, [parallelId]: parallelConfig }, + })) + } + ) + } + + if (addedSubBlockValues && activeWorkflowId) { + Object.entries( + addedSubBlockValues as Record> + ).forEach(([blockId, subBlocks]) => { + Object.entries(subBlocks).forEach(([subBlockId, value]) => { + subBlockStore.setValue(blockId, subBlockId, value) + }) + }) + } + + logger.info('Successfully applied batch-add-blocks from remote user') + break + } + case 'batch-remove-blocks': { + const { ids } = payload + logger.info('Received batch-remove-blocks from remote user', { + userId, + count: (ids || []).length, + }) + + ;(ids || []).forEach((id: string) => { + workflowStore.removeBlock(id) + }) + + logger.info('Successfully applied batch-remove-blocks from remote user') + break + } + } + } } catch (error) { logger.error('Error applying remote operation:', error) } finally { @@ -726,294 +721,33 @@ export function useCollaborativeWorkflow() { ] ) - const executeQueuedDebouncedOperation = useCallback( - (operation: string, target: string, payload: any, localAction: () => void) => { - if (isApplyingRemoteChange.current) return - - if (isBaselineDiffView) { - logger.debug('Skipping debounced socket operation while viewing baseline diff:', operation) - return - } - + const collaborativeBatchUpdatePositions = useCallback( + (updates: Array<{ id: string; position: Position }>) => { if (!isInActiveRoom()) { - logger.debug('Skipping debounced operation - not in active workflow', { - currentWorkflowId, - activeWorkflowId, - operation, - target, - }) + logger.debug('Skipping batch position update - not in active workflow') return } - localAction() + if (updates.length === 0) return - emitWorkflowOperation(operation, target, payload) - }, - [emitWorkflowOperation, isBaselineDiffView, isInActiveRoom, currentWorkflowId, activeWorkflowId] - ) - - const collaborativeAddBlock = useCallback( - ( - id: string, - type: string, - name: string, - position: Position, - data?: Record, - parentId?: string, - extent?: 'parent', - autoConnectEdge?: Edge, - triggerMode?: boolean - ) => { - // Skip socket operations when viewing baseline diff - if (isBaselineDiffView) { - logger.debug('Skipping collaborative add block while viewing baseline diff') - return - } - - if (!isInActiveRoom()) { - logger.debug('Skipping collaborative add block - not in active workflow', { - currentWorkflowId, - activeWorkflowId, - }) - return - } - - const blockConfig = getBlock(type) - - // Handle loop/parallel blocks that don't use BlockConfig - if (!blockConfig && (type === 'loop' || type === 'parallel')) { - // For loop/parallel blocks, use empty subBlocks and outputs - const completeBlockData = { - id, - type, - name, - position, - data: data || {}, - subBlocks: {}, - outputs: {}, - enabled: true, - horizontalHandles: true, - advancedMode: false, - triggerMode: triggerMode || false, - height: 0, - parentId, - extent, - autoConnectEdge, // Include edge data for atomic operation - } - - // Skip if applying remote changes (don't auto-select blocks added by other users) - if (isApplyingRemoteChange.current) { - workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: triggerMode || false, - }) - if (autoConnectEdge) { - workflowStore.addEdge(autoConnectEdge) - } - return - } - - // Generate operation ID for queue tracking - const operationId = crypto.randomUUID() - - // Add to queue for retry mechanism - addToQueue({ - id: operationId, - operation: { - operation: 'add', - target: 'block', - payload: completeBlockData, - }, - workflowId: activeWorkflowId || '', - userId: session?.user?.id || 'unknown', - }) - - // Apply locally first (immediate UI feedback) - workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: triggerMode || false, - }) - if (autoConnectEdge) { - workflowStore.addEdge(autoConnectEdge) - } - - // Record for undo AFTER adding (pass the autoConnectEdge explicitly) - undoRedo.recordAddBlock(id, autoConnectEdge) - - // Automatically select the newly added block (opens editor tab) - usePanelEditorStore.getState().setCurrentBlockId(id) - - return - } - - if (!blockConfig) { - logger.error(`Block type ${type} not found`) - return - } - - // Generate subBlocks and outputs from the block configuration - const subBlocks: Record = {} - - if (blockConfig.subBlocks) { - blockConfig.subBlocks.forEach((subBlock) => { - let initialValue: unknown = null - - if (typeof subBlock.value === 'function') { - try { - initialValue = subBlock.value({}) - } catch (error) { - logger.warn('Failed to resolve dynamic sub-block default value', { - subBlockId: subBlock.id, - error: error instanceof Error ? error.message : String(error), - }) - } - } else if (subBlock.defaultValue !== undefined) { - initialValue = subBlock.defaultValue - } else if (subBlock.type === 'input-format') { - initialValue = [ - { - id: crypto.randomUUID(), - name: '', - type: 'string', - value: '', - collapsed: false, - }, - ] - } else if (subBlock.type === 'table') { - initialValue = [] - } - - subBlocks[subBlock.id] = { - id: subBlock.id, - type: subBlock.type, - value: initialValue, - } - }) - } - - // Get outputs based on trigger mode - const isTriggerMode = triggerMode || false - const outputs = getBlockOutputs(type, subBlocks, isTriggerMode) - - const completeBlockData = { - id, - type, - name, - position, - data: data || {}, - subBlocks, - outputs, - enabled: true, - horizontalHandles: true, - advancedMode: false, - triggerMode: isTriggerMode, - height: 0, // Default height, will be set by the UI - parentId, - extent, - autoConnectEdge, // Include edge data for atomic operation - } - - // Skip if applying remote changes (don't auto-select blocks added by other users) - if (isApplyingRemoteChange.current) return - - // Generate operation ID const operationId = crypto.randomUUID() - // Add to queue addToQueue({ id: operationId, operation: { - operation: 'add', - target: 'block', - payload: completeBlockData, + operation: 'batch-update-positions', + target: 'blocks', + payload: { updates }, }, workflowId: activeWorkflowId || '', userId: session?.user?.id || 'unknown', }) - // Apply locally - workflowStore.addBlock(id, type, name, position, data, parentId, extent, { - triggerMode: triggerMode || false, - }) - if (autoConnectEdge) { - workflowStore.addEdge(autoConnectEdge) - } - - // Record for undo AFTER adding (pass the autoConnectEdge explicitly) - undoRedo.recordAddBlock(id, autoConnectEdge) - - // Automatically select the newly added block (opens editor tab) - usePanelEditorStore.getState().setCurrentBlockId(id) - }, - [ - workflowStore, - activeWorkflowId, - addToQueue, - session?.user?.id, - isBaselineDiffView, - isInActiveRoom, - currentWorkflowId, - undoRedo, - ] - ) - - const collaborativeRemoveBlock = useCallback( - (id: string) => { - cancelOperationsForBlock(id) - - // Get all blocks that will be removed (including nested blocks in subflows) - const blocksToRemove = new Set([id]) - const findAllDescendants = (parentId: string) => { - Object.entries(workflowStore.blocks).forEach(([blockId, block]) => { - if (block.data?.parentId === parentId) { - blocksToRemove.add(blockId) - findAllDescendants(blockId) - } - }) - } - findAllDescendants(id) - - // If the currently edited block is among the blocks being removed, clear selection to reset the panel - const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId - if (currentEditedBlockId && blocksToRemove.has(currentEditedBlockId)) { - usePanelEditorStore.getState().clearCurrentBlock() - } - - // Capture state before removal, including all nested blocks with subblock values - const allBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined) - const capturedBlocks: Record = {} - blocksToRemove.forEach((blockId) => { - if (allBlocks[blockId]) { - capturedBlocks[blockId] = allBlocks[blockId] - } - }) - - // Capture all edges connected to any of the blocks being removed - const edges = workflowStore.edges.filter( - (edge) => blocksToRemove.has(edge.source) || blocksToRemove.has(edge.target) - ) - - if (Object.keys(capturedBlocks).length > 0) { - undoRedo.recordRemoveBlock(id, capturedBlocks[id], edges, capturedBlocks) - } - - executeQueuedOperation('remove', 'block', { id }, () => workflowStore.removeBlock(id)) - }, - [executeQueuedOperation, workflowStore, cancelOperationsForBlock, undoRedo, activeWorkflowId] - ) - - const collaborativeUpdateBlockPosition = useCallback( - (id: string, position: Position, commit = true) => { - if (commit) { - executeQueuedOperation('update-position', 'block', { id, position, commit }, () => { - workflowStore.updateBlockPosition(id, position) - }) - return - } - - executeQueuedDebouncedOperation('update-position', 'block', { id, position }, () => { + updates.forEach(({ id, position }) => { workflowStore.updateBlockPosition(id, position) }) }, - [executeQueuedDebouncedOperation, executeQueuedOperation, workflowStore] + [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore] ) const collaborativeUpdateBlockName = useCallback( @@ -1333,148 +1067,6 @@ export function useCollaborativeWorkflow() { ] ) - const collaborativeDuplicateBlock = useCallback( - (sourceId: string) => { - if (!isInActiveRoom()) { - logger.debug('Skipping duplicate block - not in active workflow', { - currentWorkflowId, - activeWorkflowId, - sourceId, - }) - return - } - - const sourceBlock = workflowStore.blocks[sourceId] - if (!sourceBlock) return - - // Prevent duplication of start blocks (both legacy starter and unified start_trigger) - if (sourceBlock.type === 'starter' || sourceBlock.type === 'start_trigger') { - logger.warn('Cannot duplicate start block - only one start block allowed per workflow', { - blockId: sourceId, - blockType: sourceBlock.type, - }) - return - } - - // Generate new ID and calculate position - const newId = crypto.randomUUID() - const offsetPosition = { - x: sourceBlock.position.x + DEFAULT_DUPLICATE_OFFSET.x, - y: sourceBlock.position.y + DEFAULT_DUPLICATE_OFFSET.y, - } - - const newName = getUniqueBlockName(sourceBlock.name, workflowStore.blocks) - - // Get subblock values from the store, excluding webhook-specific fields - const allSubBlockValues = - subBlockStore.workflowValues[activeWorkflowId || '']?.[sourceId] || {} - const subBlockValues = Object.fromEntries( - Object.entries(allSubBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key)) - ) - - // Merge subblock structure with actual values - const mergedSubBlocks = sourceBlock.subBlocks - ? JSON.parse(JSON.stringify(sourceBlock.subBlocks)) - : {} - - WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => { - if (field in mergedSubBlocks) { - delete mergedSubBlocks[field] - } - }) - Object.entries(subBlockValues).forEach(([subblockId, value]) => { - if (mergedSubBlocks[subblockId]) { - mergedSubBlocks[subblockId].value = value - } else { - // Create subblock if it doesn't exist in structure - mergedSubBlocks[subblockId] = { - id: subblockId, - type: 'unknown', - value: value, - } - } - }) - - // Create the complete block data for the socket operation - const duplicatedBlockData = { - sourceId, - id: newId, - type: sourceBlock.type, - name: newName, - position: offsetPosition, - data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {}, - subBlocks: mergedSubBlocks, - outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {}, - parentId: sourceBlock.data?.parentId || null, - extent: sourceBlock.data?.extent || null, - enabled: sourceBlock.enabled ?? true, - horizontalHandles: sourceBlock.horizontalHandles ?? true, - advancedMode: sourceBlock.advancedMode ?? false, - triggerMode: sourceBlock.triggerMode ?? false, - height: sourceBlock.height || 0, - } - - workflowStore.addBlock( - newId, - sourceBlock.type, - newName, - offsetPosition, - sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {}, - sourceBlock.data?.parentId, - sourceBlock.data?.extent, - { - enabled: sourceBlock.enabled, - horizontalHandles: sourceBlock.horizontalHandles, - advancedMode: sourceBlock.advancedMode, - triggerMode: sourceBlock.triggerMode ?? false, - height: sourceBlock.height, - } - ) - - // Focus the newly duplicated block in the editor - usePanelEditorStore.getState().setCurrentBlockId(newId) - - executeQueuedOperation('duplicate', 'block', duplicatedBlockData, () => { - workflowStore.addBlock( - newId, - sourceBlock.type, - newName, - offsetPosition, - sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {}, - sourceBlock.data?.parentId, - sourceBlock.data?.extent, - { - enabled: sourceBlock.enabled, - horizontalHandles: sourceBlock.horizontalHandles, - advancedMode: sourceBlock.advancedMode, - triggerMode: sourceBlock.triggerMode ?? false, - height: sourceBlock.height, - } - ) - - // Apply subblock values locally for immediate UI feedback - // The server will persist these values as part of the block creation - if (activeWorkflowId && Object.keys(subBlockValues).length > 0) { - Object.entries(subBlockValues).forEach(([subblockId, value]) => { - subBlockStore.setValue(newId, subblockId, value) - }) - } - - // Record for undo after the block is added - undoRedo.recordDuplicateBlock(sourceId, newId, duplicatedBlockData, undefined) - }) - }, - [ - executeQueuedOperation, - workflowStore, - subBlockStore, - activeWorkflowId, - isInActiveRoom, - currentWorkflowId, - undoRedo, - ] - ) - const collaborativeUpdateLoopType = useCallback( (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => { const currentBlock = workflowStore.blocks[loopId] @@ -1714,23 +1306,196 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, variablesStore, cancelOperationsForVariable] ) - const collaborativeDuplicateVariable = useCallback( - (variableId: string) => { - const newId = crypto.randomUUID() - const sourceVariable = useVariablesStore.getState().variables[variableId] - if (!sourceVariable) return null + const collaborativeBatchAddBlocks = useCallback( + ( + blocks: BlockState[], + edges: Edge[] = [], + loops: Record = {}, + parallels: Record = {}, + subBlockValues: Record> = {}, + options?: { skipUndoRedo?: boolean } + ) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch add blocks - not in active workflow') + return false + } - executeQueuedOperation( - 'duplicate', - 'variable', - { sourceVariableId: variableId, id: newId }, - () => { - variablesStore.duplicateVariable(variableId, newId) - } - ) - return newId + if (isBaselineDiffView) { + logger.debug('Skipping batch add blocks while viewing baseline diff') + return false + } + + if (blocks.length === 0) return false + + logger.info('Batch adding blocks collaboratively', { + blockCount: blocks.length, + edgeCount: edges.length, + }) + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: 'batch-add-blocks', + target: 'blocks', + payload: { blocks, edges, loops, parallels, subBlockValues }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + blocks.forEach((block) => { + workflowStore.addBlock( + block.id, + block.type, + block.name, + block.position, + block.data, + block.data?.parentId, + block.data?.extent, + { + enabled: block.enabled, + horizontalHandles: block.horizontalHandles, + advancedMode: block.advancedMode, + triggerMode: block.triggerMode ?? false, + height: block.height, + } + ) + }) + + edges.forEach((edge) => { + workflowStore.addEdge(edge) + }) + + if (Object.keys(loops).length > 0) { + useWorkflowStore.setState((state) => ({ + loops: { ...state.loops, ...loops }, + })) + } + + if (Object.keys(parallels).length > 0) { + useWorkflowStore.setState((state) => ({ + parallels: { ...state.parallels, ...parallels }, + })) + } + + if (activeWorkflowId) { + Object.entries(subBlockValues).forEach(([blockId, subBlocks]) => { + Object.entries(subBlocks).forEach(([subBlockId, value]) => { + subBlockStore.setValue(blockId, subBlockId, value) + }) + }) + } + + if (!options?.skipUndoRedo) { + undoRedo.recordBatchAddBlocks(blocks, edges, subBlockValues) + } + + return true }, - [executeQueuedOperation, variablesStore] + [ + addToQueue, + activeWorkflowId, + session?.user?.id, + isBaselineDiffView, + isInActiveRoom, + workflowStore, + subBlockStore, + undoRedo, + ] + ) + + const collaborativeBatchRemoveBlocks = useCallback( + (blockIds: string[], options?: { skipUndoRedo?: boolean }) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch remove blocks - not in active workflow') + return false + } + + if (blockIds.length === 0) return false + + blockIds.forEach((id) => cancelOperationsForBlock(id)) + + const allBlocksToRemove = new Set(blockIds) + const findAllDescendants = (parentId: string) => { + Object.entries(workflowStore.blocks).forEach(([blockId, block]) => { + if (block.data?.parentId === parentId) { + allBlocksToRemove.add(blockId) + findAllDescendants(blockId) + } + }) + } + blockIds.forEach((id) => findAllDescendants(id)) + + const currentEditedBlockId = usePanelEditorStore.getState().currentBlockId + if (currentEditedBlockId && allBlocksToRemove.has(currentEditedBlockId)) { + usePanelEditorStore.getState().clearCurrentBlock() + } + + const mergedBlocks = mergeSubblockState(workflowStore.blocks, activeWorkflowId || undefined) + const blockSnapshots: BlockState[] = [] + const subBlockValues: Record> = {} + + allBlocksToRemove.forEach((blockId) => { + const block = mergedBlocks[blockId] + if (block) { + blockSnapshots.push(block) + if (block.subBlocks) { + const values: Record = {} + Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { + if (subBlock.value !== null && subBlock.value !== undefined) { + values[subBlockId] = subBlock.value + } + }) + if (Object.keys(values).length > 0) { + subBlockValues[blockId] = values + } + } + } + }) + + const edgeSnapshots = workflowStore.edges.filter( + (e) => allBlocksToRemove.has(e.source) || allBlocksToRemove.has(e.target) + ) + + logger.info('Batch removing blocks collaboratively', { + requestedCount: blockIds.length, + totalCount: allBlocksToRemove.size, + }) + + const operationId = crypto.randomUUID() + + addToQueue({ + id: operationId, + operation: { + operation: 'batch-remove-blocks', + target: 'blocks', + payload: { ids: Array.from(allBlocksToRemove) }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) + + blockIds.forEach((id) => { + workflowStore.removeBlock(id) + }) + + if (!options?.skipUndoRedo && blockSnapshots.length > 0) { + undoRedo.recordBatchRemoveBlocks(blockSnapshots, edgeSnapshots, subBlockValues) + } + + return true + }, + [ + addToQueue, + activeWorkflowId, + session?.user?.id, + isInActiveRoom, + workflowStore, + cancelOperationsForBlock, + undoRedo, + ] ) return { @@ -1745,16 +1510,15 @@ export function useCollaborativeWorkflow() { leaveWorkflow, // Collaborative operations - collaborativeAddBlock, - collaborativeUpdateBlockPosition, + collaborativeBatchUpdatePositions, collaborativeUpdateBlockName, - collaborativeRemoveBlock, collaborativeToggleBlockEnabled, collaborativeUpdateParentId, collaborativeToggleBlockAdvancedMode, collaborativeToggleBlockTriggerMode, collaborativeToggleBlockHandles, - collaborativeDuplicateBlock, + collaborativeBatchAddBlocks, + collaborativeBatchRemoveBlocks, collaborativeAddEdge, collaborativeRemoveEdge, collaborativeSetSubblockValue, @@ -1764,7 +1528,6 @@ export function useCollaborativeWorkflow() { collaborativeUpdateVariable, collaborativeAddVariable, collaborativeDeleteVariable, - collaborativeDuplicateVariable, // Collaborative loop/parallel operations collaborativeUpdateLoopType, diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 01e9def71..33457cf39 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -5,11 +5,11 @@ import { useSession } from '@/lib/auth/auth-client' import { enqueueReplaceWorkflowState } from '@/lib/workflows/operations/socket-operations' import { useOperationQueue } from '@/stores/operation-queue/store' import { + type BatchAddBlocksOperation, + type BatchRemoveBlocksOperation, createOperationEntry, - type DuplicateBlockOperation, type MoveBlockOperation, type Operation, - type RemoveBlockOperation, type RemoveEdgeOperation, runWithUndoRedoRecordingSuspended, type UpdateParentOperation, @@ -17,7 +17,7 @@ import { } from '@/stores/undo-redo' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import { getUniqueBlockName, mergeSubblockState } from '@/stores/workflows/utils' +import { mergeSubblockState } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -32,89 +32,94 @@ export function useUndoRedo() { const userId = session?.user?.id || 'unknown' - const recordAddBlock = useCallback( - (blockId: string, autoConnectEdge?: Edge) => { - if (!activeWorkflowId) return + const recordBatchAddBlocks = useCallback( + ( + blockSnapshots: BlockState[], + edgeSnapshots: Edge[] = [], + subBlockValues: Record> = {} + ) => { + if (!activeWorkflowId || blockSnapshots.length === 0) return - const operation: Operation = { + const operation: BatchAddBlocksOperation = { id: crypto.randomUUID(), - type: 'add-block', - timestamp: Date.now(), - workflowId: activeWorkflowId, - userId, - data: { blockId }, - } - - // Get fresh state from store - const currentBlocks = useWorkflowStore.getState().blocks - const merged = mergeSubblockState(currentBlocks, activeWorkflowId, blockId) - const blockSnapshot = merged[blockId] || currentBlocks[blockId] - - const edgesToRemove = autoConnectEdge ? [autoConnectEdge] : [] - - const inverse: RemoveBlockOperation = { - id: crypto.randomUUID(), - type: 'remove-block', + type: 'batch-add-blocks', timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - blockId, - blockSnapshot, - edgeSnapshots: edgesToRemove, + blockSnapshots, + edgeSnapshots, + subBlockValues, + }, + } + + const inverse: BatchRemoveBlocksOperation = { + id: crypto.randomUUID(), + type: 'batch-remove-blocks', + timestamp: Date.now(), + workflowId: activeWorkflowId, + userId, + data: { + blockSnapshots, + edgeSnapshots, + subBlockValues, }, } const entry = createOperationEntry(operation, inverse) undoRedoStore.push(activeWorkflowId, userId, entry) - logger.debug('Recorded add block', { - blockId, - hasAutoConnect: !!autoConnectEdge, - edgeCount: edgesToRemove.length, + logger.debug('Recorded batch add blocks', { + blockCount: blockSnapshots.length, + edgeCount: edgeSnapshots.length, workflowId: activeWorkflowId, - hasSnapshot: !!blockSnapshot, }) }, [activeWorkflowId, userId, undoRedoStore] ) - const recordRemoveBlock = useCallback( + const recordBatchRemoveBlocks = useCallback( ( - blockId: string, - blockSnapshot: BlockState, - edgeSnapshots: Edge[], - allBlockSnapshots?: Record + blockSnapshots: BlockState[], + edgeSnapshots: Edge[] = [], + subBlockValues: Record> = {} ) => { - if (!activeWorkflowId) return + if (!activeWorkflowId || blockSnapshots.length === 0) return - const operation: RemoveBlockOperation = { + const operation: BatchRemoveBlocksOperation = { id: crypto.randomUUID(), - type: 'remove-block', + type: 'batch-remove-blocks', timestamp: Date.now(), workflowId: activeWorkflowId, userId, data: { - blockId, - blockSnapshot, + blockSnapshots, edgeSnapshots, - allBlockSnapshots, + subBlockValues, }, } - const inverse: Operation = { + const inverse: BatchAddBlocksOperation = { id: crypto.randomUUID(), - type: 'add-block', + type: 'batch-add-blocks', timestamp: Date.now(), workflowId: activeWorkflowId, userId, - data: { blockId }, + data: { + blockSnapshots, + edgeSnapshots, + subBlockValues, + }, } const entry = createOperationEntry(operation, inverse) undoRedoStore.push(activeWorkflowId, userId, entry) - logger.debug('Recorded remove block', { blockId, workflowId: activeWorkflowId }) + logger.debug('Recorded batch remove blocks', { + blockCount: blockSnapshots.length, + edgeCount: edgeSnapshots.length, + workflowId: activeWorkflowId, + }) }, [activeWorkflowId, userId, undoRedoStore] ) @@ -227,51 +232,6 @@ export function useUndoRedo() { [activeWorkflowId, userId, undoRedoStore] ) - const recordDuplicateBlock = useCallback( - ( - sourceBlockId: string, - duplicatedBlockId: string, - duplicatedBlockSnapshot: BlockState, - autoConnectEdge?: Edge - ) => { - if (!activeWorkflowId) return - - const operation: DuplicateBlockOperation = { - id: crypto.randomUUID(), - type: 'duplicate-block', - timestamp: Date.now(), - workflowId: activeWorkflowId, - userId, - data: { - sourceBlockId, - duplicatedBlockId, - duplicatedBlockSnapshot, - autoConnectEdge, - }, - } - - // Inverse is to remove the duplicated block - const inverse: RemoveBlockOperation = { - id: crypto.randomUUID(), - type: 'remove-block', - timestamp: Date.now(), - workflowId: activeWorkflowId, - userId, - data: { - blockId: duplicatedBlockId, - blockSnapshot: duplicatedBlockSnapshot, - edgeSnapshots: autoConnectEdge ? [autoConnectEdge] : [], - }, - } - - const entry = createOperationEntry(operation, inverse) - undoRedoStore.push(activeWorkflowId, userId, entry) - - logger.debug('Recorded duplicate block', { sourceBlockId, duplicatedBlockId }) - }, - [activeWorkflowId, userId, undoRedoStore] - ) - const recordUpdateParent = useCallback( ( blockId: string, @@ -347,204 +307,117 @@ export function useUndoRedo() { const opId = crypto.randomUUID() switch (entry.inverse.type) { - case 'remove-block': { - const removeInverse = entry.inverse as RemoveBlockOperation - const blockId = removeInverse.data.blockId + case 'batch-remove-blocks': { + const batchRemoveOp = entry.inverse as BatchRemoveBlocksOperation + const { blockSnapshots } = batchRemoveOp.data + const blockIds = blockSnapshots.map((b) => b.id) - if (workflowStore.blocks[blockId]) { - // Refresh inverse snapshot to capture the latest subblock values and edges at undo time - const mergedNow = mergeSubblockState(workflowStore.blocks, activeWorkflowId, blockId) - const latestBlockSnapshot = mergedNow[blockId] || workflowStore.blocks[blockId] - const latestEdgeSnapshots = workflowStore.edges.filter( - (e) => e.source === blockId || e.target === blockId - ) - removeInverse.data.blockSnapshot = latestBlockSnapshot - removeInverse.data.edgeSnapshots = latestEdgeSnapshots - // First remove the edges that were added with the block (autoConnect edge) - const edgesToRemove = removeInverse.data.edgeSnapshots || [] - edgesToRemove.forEach((edge) => { - if (workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.removeEdge(edge.id) - // Send edge removal to server - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'remove', - target: 'edge', - payload: { id: edge.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - } - }) - - // Then remove the block - addToQueue({ - id: opId, - operation: { - operation: 'remove', - target: 'block', - payload: { id: blockId, isUndo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.removeBlock(blockId) - } else { - logger.debug('Undo remove-block skipped; block missing', { - blockId, - }) - } - break - } - case 'add-block': { - const originalOp = entry.operation as RemoveBlockOperation - const { blockSnapshot, edgeSnapshots, allBlockSnapshots } = originalOp.data - if (!blockSnapshot || workflowStore.blocks[blockSnapshot.id]) { - logger.debug('Undo add-block skipped', { - hasSnapshot: Boolean(blockSnapshot), - exists: Boolean(blockSnapshot && workflowStore.blocks[blockSnapshot.id]), - }) + const existingBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (existingBlockIds.length === 0) { + logger.debug('Undo batch-remove-blocks skipped; no blocks exist') break } - // Preserve the original name from the snapshot on undo - const restoredName = blockSnapshot.name + const latestEdges = workflowStore.edges.filter( + (e) => existingBlockIds.includes(e.source) || existingBlockIds.includes(e.target) + ) + batchRemoveOp.data.edgeSnapshots = latestEdges + + const latestSubBlockValues: Record> = {} + existingBlockIds.forEach((blockId) => { + const merged = mergeSubblockState(workflowStore.blocks, activeWorkflowId, blockId) + const block = merged[blockId] + if (block?.subBlocks) { + const values: Record = {} + Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { + if (subBlock.value !== null && subBlock.value !== undefined) { + values[subBlockId] = subBlock.value + } + }) + if (Object.keys(values).length > 0) { + latestSubBlockValues[blockId] = values + } + } + }) + batchRemoveOp.data.subBlockValues = latestSubBlockValues - // FIRST: Add the main block (parent subflow) with subBlocks in payload addToQueue({ id: opId, operation: { - operation: 'add', - target: 'block', + operation: 'batch-remove-blocks', + target: 'blocks', + payload: { ids: existingBlockIds }, + }, + workflowId: activeWorkflowId, + userId, + }) + + existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) + break + } + case 'batch-add-blocks': { + const batchAddOp = entry.operation as BatchAddBlocksOperation + const { blockSnapshots, edgeSnapshots, subBlockValues } = batchAddOp.data + + const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id]) + if (blocksToAdd.length === 0) { + logger.debug('Undo batch-add-blocks skipped; all blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-add-blocks', + target: 'blocks', payload: { - ...blockSnapshot, - name: restoredName, - subBlocks: blockSnapshot.subBlocks || {}, - autoConnectEdge: undefined, - isUndo: true, - originalOpId: entry.id, + blocks: blocksToAdd, + edges: edgeSnapshots || [], + loops: {}, + parallels: {}, + subBlockValues: subBlockValues || {}, }, }, workflowId: activeWorkflowId, userId, }) - workflowStore.addBlock( - blockSnapshot.id, - blockSnapshot.type, - restoredName, - blockSnapshot.position, - blockSnapshot.data, - blockSnapshot.data?.parentId, - blockSnapshot.data?.extent, - { - enabled: blockSnapshot.enabled, - horizontalHandles: blockSnapshot.horizontalHandles, - advancedMode: blockSnapshot.advancedMode, - triggerMode: blockSnapshot.triggerMode, - height: blockSnapshot.height, - } - ) - - // Set subblock values for the main block locally - if (blockSnapshot.subBlocks && activeWorkflowId) { - const subblockValues: Record = {} - Object.entries(blockSnapshot.subBlocks).forEach( - ([subBlockId, subBlock]: [string, any]) => { - if (subBlock.value !== null && subBlock.value !== undefined) { - subblockValues[subBlockId] = subBlock.value - } + blocksToAdd.forEach((block) => { + workflowStore.addBlock( + block.id, + block.type, + block.name, + block.position, + block.data, + block.data?.parentId, + block.data?.extent, + { + enabled: block.enabled, + horizontalHandles: block.horizontalHandles, + advancedMode: block.advancedMode, + triggerMode: block.triggerMode, + height: block.height, } ) + }) - if (Object.keys(subblockValues).length > 0) { - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: { - ...state.workflowValues[activeWorkflowId], - [blockSnapshot.id]: subblockValues, - }, + if (subBlockValues && Object.keys(subBlockValues).length > 0) { + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: { + ...state.workflowValues[activeWorkflowId], + ...subBlockValues, }, - })) - } + }, + })) } - // SECOND: If this is a subflow with nested blocks, restore them AFTER the parent exists - if (allBlockSnapshots) { - Object.entries(allBlockSnapshots).forEach(([id, snap]: [string, any]) => { - if (id !== blockSnapshot.id && !workflowStore.blocks[id]) { - // Preserve original nested block name from snapshot on undo - const restoredNestedName = snap.name - - // Add nested block locally - workflowStore.addBlock( - snap.id, - snap.type, - restoredNestedName, - snap.position, - snap.data, - snap.data?.parentId, - snap.data?.extent, - { - enabled: snap.enabled, - horizontalHandles: snap.horizontalHandles, - advancedMode: snap.advancedMode, - triggerMode: snap.triggerMode, - height: snap.height, - } - ) - - // Send to server with subBlocks included in payload - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'block', - payload: { - ...snap, - name: restoredNestedName, - subBlocks: snap.subBlocks || {}, - autoConnectEdge: undefined, - isUndo: true, - originalOpId: entry.id, - }, - }, - workflowId: activeWorkflowId, - userId, - }) - - // Restore subblock values for nested blocks locally - if (snap.subBlocks && activeWorkflowId) { - const subBlockStore = useSubBlockStore.getState() - Object.entries(snap.subBlocks).forEach( - ([subBlockId, subBlock]: [string, any]) => { - if (subBlock.value !== null && subBlock.value !== undefined) { - subBlockStore.setValue(snap.id, subBlockId, subBlock.value) - } - } - ) - } - } - }) - } - - // THIRD: Finally restore edges after all blocks exist if (edgeSnapshots && edgeSnapshots.length > 0) { edgeSnapshots.forEach((edge) => { - workflowStore.addEdge(edge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: edge, - }, - workflowId: activeWorkflowId, - userId, - }) + if (!workflowStore.edges.find((e) => e.id === edge.id)) { + workflowStore.addEdge(edge) + } }) } break @@ -639,49 +512,6 @@ export function useUndoRedo() { } break } - case 'duplicate-block': { - // Undo duplicate means removing the duplicated block - const dupOp = entry.operation as DuplicateBlockOperation - const duplicatedId = dupOp.data.duplicatedBlockId - - if (workflowStore.blocks[duplicatedId]) { - // Remove any edges connected to the duplicated block - const edges = workflowStore.edges.filter( - (edge) => edge.source === duplicatedId || edge.target === duplicatedId - ) - edges.forEach((edge) => { - workflowStore.removeEdge(edge.id) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'remove', - target: 'edge', - payload: { id: edge.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - }) - - // Remove the duplicated block - addToQueue({ - id: opId, - operation: { - operation: 'remove', - target: 'block', - payload: { id: duplicatedId, isUndo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.removeBlock(duplicatedId) - } else { - logger.debug('Undo duplicate-block skipped; duplicated block missing', { - duplicatedId, - }) - } - break - } case 'update-parent': { // Undo parent update means reverting to the old parent and position const updateOp = entry.inverse as UpdateParentOperation @@ -963,189 +793,96 @@ export function useUndoRedo() { const opId = crypto.randomUUID() switch (entry.operation.type) { - case 'add-block': { - // Redo should re-apply the original add: add the block first, then edges - const inv = entry.inverse as RemoveBlockOperation - const snap = inv.data.blockSnapshot - const edgeSnapshots = inv.data.edgeSnapshots || [] - const allBlockSnapshots = inv.data.allBlockSnapshots + case 'batch-add-blocks': { + const batchOp = entry.operation as BatchAddBlocksOperation + const { blockSnapshots, edgeSnapshots, subBlockValues } = batchOp.data - if (!snap || workflowStore.blocks[snap.id]) { + const blocksToAdd = blockSnapshots.filter((b) => !workflowStore.blocks[b.id]) + if (blocksToAdd.length === 0) { + logger.debug('Redo batch-add-blocks skipped; all blocks exist') break } - // Preserve the original name from the snapshot on redo - const restoredName = snap.name - - // FIRST: Add the main block (parent subflow) with subBlocks included addToQueue({ id: opId, operation: { - operation: 'add', - target: 'block', + operation: 'batch-add-blocks', + target: 'blocks', payload: { - ...snap, - name: restoredName, - subBlocks: snap.subBlocks || {}, - isRedo: true, - originalOpId: entry.id, + blocks: blocksToAdd, + edges: edgeSnapshots || [], + loops: {}, + parallels: {}, + subBlockValues: subBlockValues || {}, }, }, workflowId: activeWorkflowId, userId, }) - workflowStore.addBlock( - snap.id, - snap.type, - restoredName, - snap.position, - snap.data, - snap.data?.parentId, - snap.data?.extent, - { - enabled: snap.enabled, - horizontalHandles: snap.horizontalHandles, - advancedMode: snap.advancedMode, - triggerMode: snap.triggerMode, - height: snap.height, - } - ) - - // Set subblock values for the main block locally - if (snap.subBlocks && activeWorkflowId) { - const subblockValues: Record = {} - Object.entries(snap.subBlocks).forEach(([subBlockId, subBlock]: [string, any]) => { - if (subBlock.value !== null && subBlock.value !== undefined) { - subblockValues[subBlockId] = subBlock.value + blocksToAdd.forEach((block) => { + workflowStore.addBlock( + block.id, + block.type, + block.name, + block.position, + block.data, + block.data?.parentId, + block.data?.extent, + { + enabled: block.enabled, + horizontalHandles: block.horizontalHandles, + advancedMode: block.advancedMode, + triggerMode: block.triggerMode, + height: block.height, } - }) - - if (Object.keys(subblockValues).length > 0) { - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: { - ...state.workflowValues[activeWorkflowId], - [snap.id]: subblockValues, - }, - }, - })) - } - } - - // SECOND: If this is a subflow with nested blocks, restore them AFTER the parent exists - if (allBlockSnapshots) { - Object.entries(allBlockSnapshots).forEach(([id, snapNested]: [string, any]) => { - if (id !== snap.id && !workflowStore.blocks[id]) { - // Preserve original nested block name from snapshot on redo - const restoredNestedName = snapNested.name - - // Add nested block locally - workflowStore.addBlock( - snapNested.id, - snapNested.type, - restoredNestedName, - snapNested.position, - snapNested.data, - snapNested.data?.parentId, - snapNested.data?.extent, - { - enabled: snapNested.enabled, - horizontalHandles: snapNested.horizontalHandles, - advancedMode: snapNested.advancedMode, - triggerMode: snapNested.triggerMode, - height: snapNested.height, - } - ) - - // Send to server with subBlocks included - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'block', - payload: { - ...snapNested, - name: restoredNestedName, - subBlocks: snapNested.subBlocks || {}, - autoConnectEdge: undefined, - isRedo: true, - originalOpId: entry.id, - }, - }, - workflowId: activeWorkflowId, - userId, - }) - - // Restore subblock values for nested blocks locally - if (snapNested.subBlocks && activeWorkflowId) { - const subBlockStore = useSubBlockStore.getState() - Object.entries(snapNested.subBlocks).forEach( - ([subBlockId, subBlock]: [string, any]) => { - if (subBlock.value !== null && subBlock.value !== undefined) { - subBlockStore.setValue(snapNested.id, subBlockId, subBlock.value) - } - } - ) - } - } - }) - } - - // THIRD: Finally restore edges after all blocks exist - edgeSnapshots.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: { ...edge, isRedo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - } + ) }) + + if (subBlockValues && Object.keys(subBlockValues).length > 0) { + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: { + ...state.workflowValues[activeWorkflowId], + ...subBlockValues, + }, + }, + })) + } + + if (edgeSnapshots && edgeSnapshots.length > 0) { + edgeSnapshots.forEach((edge) => { + if (!workflowStore.edges.find((e) => e.id === edge.id)) { + workflowStore.addEdge(edge) + } + }) + } break } - case 'remove-block': { - // Redo should re-apply the original remove: remove edges first, then block - const blockId = entry.operation.data.blockId - const edgesToRemove = (entry.operation as RemoveBlockOperation).data.edgeSnapshots || [] - edgesToRemove.forEach((edge) => { - if (workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.removeEdge(edge.id) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'remove', - target: 'edge', - payload: { id: edge.id, isRedo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - } + case 'batch-remove-blocks': { + const batchOp = entry.operation as BatchRemoveBlocksOperation + const { blockSnapshots } = batchOp.data + const blockIds = blockSnapshots.map((b) => b.id) + + const existingBlockIds = blockIds.filter((id) => workflowStore.blocks[id]) + if (existingBlockIds.length === 0) { + logger.debug('Redo batch-remove-blocks skipped; no blocks exist') + break + } + + addToQueue({ + id: opId, + operation: { + operation: 'batch-remove-blocks', + target: 'blocks', + payload: { ids: existingBlockIds }, + }, + workflowId: activeWorkflowId, + userId, }) - if (workflowStore.blocks[blockId]) { - addToQueue({ - id: opId, - operation: { - operation: 'remove', - target: 'block', - payload: { id: blockId, isRedo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - workflowStore.removeBlock(blockId) - } else { - logger.debug('Redo remove-block skipped; block missing', { blockId }) - } + existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) break } case 'add-edge': { @@ -1230,100 +967,6 @@ export function useUndoRedo() { } break } - case 'duplicate-block': { - // Redo duplicate means re-adding the duplicated block - const dupOp = entry.operation as DuplicateBlockOperation - const { duplicatedBlockSnapshot, autoConnectEdge } = dupOp.data - - if (!duplicatedBlockSnapshot || workflowStore.blocks[duplicatedBlockSnapshot.id]) { - logger.debug('Redo duplicate-block skipped', { - hasSnapshot: Boolean(duplicatedBlockSnapshot), - exists: Boolean( - duplicatedBlockSnapshot && workflowStore.blocks[duplicatedBlockSnapshot.id] - ), - }) - break - } - - const currentBlocks = useWorkflowStore.getState().blocks - const uniqueName = getUniqueBlockName(duplicatedBlockSnapshot.name, currentBlocks) - - // Add the duplicated block - addToQueue({ - id: opId, - operation: { - operation: 'duplicate', - target: 'block', - payload: { - ...duplicatedBlockSnapshot, - name: uniqueName, - subBlocks: duplicatedBlockSnapshot.subBlocks || {}, - autoConnectEdge, - isRedo: true, - originalOpId: entry.id, - }, - }, - workflowId: activeWorkflowId, - userId, - }) - - workflowStore.addBlock( - duplicatedBlockSnapshot.id, - duplicatedBlockSnapshot.type, - uniqueName, - duplicatedBlockSnapshot.position, - duplicatedBlockSnapshot.data, - duplicatedBlockSnapshot.data?.parentId, - duplicatedBlockSnapshot.data?.extent, - { - enabled: duplicatedBlockSnapshot.enabled, - horizontalHandles: duplicatedBlockSnapshot.horizontalHandles, - advancedMode: duplicatedBlockSnapshot.advancedMode, - triggerMode: duplicatedBlockSnapshot.triggerMode, - height: duplicatedBlockSnapshot.height, - } - ) - - // Restore subblock values - if (duplicatedBlockSnapshot.subBlocks && activeWorkflowId) { - const subblockValues: Record = {} - Object.entries(duplicatedBlockSnapshot.subBlocks).forEach( - ([subBlockId, subBlock]: [string, any]) => { - if (subBlock.value !== null && subBlock.value !== undefined) { - subblockValues[subBlockId] = subBlock.value - } - } - ) - - if (Object.keys(subblockValues).length > 0) { - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: { - ...state.workflowValues[activeWorkflowId], - [duplicatedBlockSnapshot.id]: subblockValues, - }, - }, - })) - } - } - - // Add auto-connect edge if present - if (autoConnectEdge && !workflowStore.edges.find((e) => e.id === autoConnectEdge.id)) { - workflowStore.addEdge(autoConnectEdge) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: 'add', - target: 'edge', - payload: { ...autoConnectEdge, isRedo: true, originalOpId: entry.id }, - }, - workflowId: activeWorkflowId, - userId, - }) - } - break - } case 'update-parent': { // Redo parent update means applying the new parent and position const updateOp = entry.operation as UpdateParentOperation @@ -1726,12 +1369,11 @@ export function useUndoRedo() { ) return { - recordAddBlock, - recordRemoveBlock, + recordBatchAddBlocks, + recordBatchRemoveBlocks, recordAddEdge, recordRemoveEdge, recordMove, - recordDuplicateBlock, recordUpdateParent, recordApplyDiff, recordAcceptDiff, diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index faacd94fd..014cf08a6 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -173,6 +173,9 @@ export async function persistWorkflowOperation(workflowId: string, operation: an case 'block': await handleBlockOperationTx(tx, workflowId, op, payload) break + case 'blocks': + await handleBlocksOperationTx(tx, workflowId, op, payload) + break case 'edge': await handleEdgeOperationTx(tx, workflowId, op, payload) break @@ -216,107 +219,6 @@ async function handleBlockOperationTx( payload: any ) { switch (operation) { - case 'add': { - // Validate required fields for add operation - if (!payload.id || !payload.type || !payload.name || !payload.position) { - throw new Error('Missing required fields for add block operation') - } - - logger.debug(`Adding block: ${payload.type} (${payload.id})`, { - isSubflowType: isSubflowBlockType(payload.type), - }) - - // Extract parentId and extent from payload.data if they exist there, otherwise from payload directly - const parentId = payload.parentId || payload.data?.parentId || null - const extent = payload.extent || payload.data?.extent || null - - logger.debug(`Block parent info:`, { - blockId: payload.id, - hasParent: !!parentId, - parentId, - extent, - payloadParentId: payload.parentId, - dataParentId: payload.data?.parentId, - }) - - try { - const insertData = { - id: payload.id, - workflowId, - type: payload.type, - name: payload.name, - positionX: payload.position.x, - positionY: payload.position.y, - data: { - ...(payload.data || {}), - ...(parentId ? { parentId } : {}), - ...(extent ? { extent } : {}), - }, - subBlocks: payload.subBlocks || {}, - outputs: payload.outputs || {}, - enabled: payload.enabled ?? true, - horizontalHandles: payload.horizontalHandles ?? true, - advancedMode: payload.advancedMode ?? false, - triggerMode: payload.triggerMode ?? false, - height: payload.height || 0, - } - - await tx.insert(workflowBlocks).values(insertData) - - await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger) - } catch (insertError) { - logger.error(`❌ Failed to insert block ${payload.id}:`, insertError) - throw insertError - } - - // Auto-create subflow entry for loop/parallel blocks - if (isSubflowBlockType(payload.type)) { - try { - const subflowConfig = - payload.type === SubflowType.LOOP - ? { - id: payload.id, - nodes: [], // Empty initially, will be populated when child blocks are added - iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS, - loopType: payload.data?.loopType || 'for', - // Set the appropriate field based on loop type - ...(payload.data?.loopType === 'while' - ? { whileCondition: payload.data?.whileCondition || '' } - : payload.data?.loopType === 'doWhile' - ? { doWhileCondition: payload.data?.doWhileCondition || '' } - : { forEachItems: payload.data?.collection || '' }), - } - : { - id: payload.id, - nodes: [], // Empty initially, will be populated when child blocks are added - distribution: payload.data?.collection || '', - count: payload.data?.count || DEFAULT_PARALLEL_COUNT, - parallelType: payload.data?.parallelType || 'count', - } - - logger.debug(`Auto-creating ${payload.type} subflow ${payload.id}:`, subflowConfig) - - await tx.insert(workflowSubflows).values({ - id: payload.id, - workflowId, - type: payload.type, - config: subflowConfig, - }) - } catch (subflowError) { - logger.error(`❌ Failed to create ${payload.type} subflow ${payload.id}:`, subflowError) - throw subflowError - } - } - - // If this block has a parent, update the parent's subflow node list - if (parentId) { - await updateSubflowNodeList(tx, workflowId, parentId) - } - - logger.debug(`Added block ${payload.id} (${payload.type}) to workflow ${workflowId}`) - break - } - case 'update-position': { if (!payload.id || !payload.position) { throw new Error('Missing required fields for update position operation') @@ -342,156 +244,6 @@ async function handleBlockOperationTx( break } - case 'remove': { - if (!payload.id) { - throw new Error('Missing block ID for remove operation') - } - - // Collect all block IDs that will be deleted (including child blocks) - const blocksToDelete = new Set([payload.id]) - - // Check if this is a subflow block that needs cascade deletion - const blockToRemove = await tx - .select({ - type: workflowBlocks.type, - parentId: sql`${workflowBlocks.data}->>'parentId'`, - }) - .from(workflowBlocks) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - .limit(1) - - if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) { - // Cascade delete: Remove all child blocks first - const childBlocks = await tx - .select({ id: workflowBlocks.id, type: workflowBlocks.type }) - .from(workflowBlocks) - .where( - and( - eq(workflowBlocks.workflowId, workflowId), - sql`${workflowBlocks.data}->>'parentId' = ${payload.id}` - ) - ) - - logger.debug( - `Starting cascade deletion for subflow block ${payload.id} (type: ${blockToRemove[0].type})` - ) - logger.debug( - `Found ${childBlocks.length} child blocks to delete: [${childBlocks.map((b: any) => `${b.id} (${b.type})`).join(', ')}]` - ) - - // Add child blocks to deletion set - childBlocks.forEach((child: { id: string; type: string }) => blocksToDelete.add(child.id)) - - // Remove edges connected to child blocks - for (const childBlock of childBlocks) { - await tx - .delete(workflowEdges) - .where( - and( - eq(workflowEdges.workflowId, workflowId), - or( - eq(workflowEdges.sourceBlockId, childBlock.id), - eq(workflowEdges.targetBlockId, childBlock.id) - ) - ) - ) - } - - // Remove child blocks from database - await tx - .delete(workflowBlocks) - .where( - and( - eq(workflowBlocks.workflowId, workflowId), - sql`${workflowBlocks.data}->>'parentId' = ${payload.id}` - ) - ) - - // Remove the subflow entry - await tx - .delete(workflowSubflows) - .where( - and(eq(workflowSubflows.id, payload.id), eq(workflowSubflows.workflowId, workflowId)) - ) - } - - // Clean up external webhooks before deleting blocks - try { - const blockIdsArray = Array.from(blocksToDelete) - const webhooksToCleanup = await tx - .select({ - webhook: webhook, - workflow: { - id: workflow.id, - userId: workflow.userId, - workspaceId: workflow.workspaceId, - }, - }) - .from(webhook) - .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) - .where(and(eq(webhook.workflowId, workflowId), inArray(webhook.blockId, blockIdsArray))) - - if (webhooksToCleanup.length > 0) { - logger.debug( - `Found ${webhooksToCleanup.length} webhook(s) to cleanup for blocks: ${blockIdsArray.join(', ')}` - ) - - const requestId = `socket-${workflowId}-${Date.now()}-${Math.random().toString(36).substring(7)}` - - // Clean up each webhook (don't fail if cleanup fails) - for (const webhookData of webhooksToCleanup) { - try { - await cleanupExternalWebhook(webhookData.webhook, webhookData.workflow, requestId) - } catch (cleanupError) { - logger.error(`Failed to cleanup external webhook during block deletion`, { - webhookId: webhookData.webhook.id, - workflowId: webhookData.workflow.id, - userId: webhookData.workflow.userId, - workspaceId: webhookData.workflow.workspaceId, - provider: webhookData.webhook.provider, - blockId: webhookData.webhook.blockId, - error: cleanupError, - }) - // Continue with deletion even if cleanup fails - } - } - } - } catch (webhookCleanupError) { - logger.error(`Error during webhook cleanup for block deletion (continuing with deletion)`, { - workflowId, - blockIds: Array.from(blocksToDelete), - error: webhookCleanupError, - }) - // Continue with block deletion even if webhook cleanup fails - } - - // Remove any edges connected to this block - await tx - .delete(workflowEdges) - .where( - and( - eq(workflowEdges.workflowId, workflowId), - or( - eq(workflowEdges.sourceBlockId, payload.id), - eq(workflowEdges.targetBlockId, payload.id) - ) - ) - ) - - // Finally remove the block itself - await tx - .delete(workflowBlocks) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - - // If this block had a parent, update the parent's subflow node list - if (blockToRemove.length > 0 && blockToRemove[0].parentId) { - await updateSubflowNodeList(tx, workflowId, blockToRemove[0].parentId) - } - - logger.debug(`Removed block ${payload.id} and its connections from workflow ${workflowId}`) - break - } - case 'update-name': { if (!payload.id || !payload.name) { throw new Error('Missing required fields for update name operation') @@ -677,114 +429,272 @@ async function handleBlockOperationTx( break } - case 'duplicate': { - // Validate required fields for duplicate operation - if (!payload.sourceId || !payload.id || !payload.type || !payload.name || !payload.position) { - throw new Error('Missing required fields for duplicate block operation') - } - - logger.debug(`Duplicating block: ${payload.type} (${payload.sourceId} -> ${payload.id})`, { - isSubflowType: isSubflowBlockType(payload.type), - payload, - }) - - // Extract parentId and extent from payload - const parentId = payload.parentId || null - const extent = payload.extent || null - - try { - const insertData = { - id: payload.id, - workflowId, - type: payload.type, - name: payload.name, - positionX: payload.position.x, - positionY: payload.position.y, - data: { - ...(payload.data || {}), - ...(parentId ? { parentId } : {}), - ...(extent ? { extent } : {}), - }, - subBlocks: payload.subBlocks || {}, - outputs: payload.outputs || {}, - enabled: payload.enabled ?? true, - horizontalHandles: payload.horizontalHandles ?? true, - advancedMode: payload.advancedMode ?? false, - triggerMode: payload.triggerMode ?? false, - height: payload.height || 0, - } - - await tx.insert(workflowBlocks).values(insertData) - - // Handle auto-connect edge if present - await insertAutoConnectEdge(tx, workflowId, payload.autoConnectEdge, logger) - } catch (insertError) { - logger.error(`❌ Failed to insert duplicated block ${payload.id}:`, insertError) - throw insertError - } - - // Auto-create subflow entry for loop/parallel blocks - if (isSubflowBlockType(payload.type)) { - try { - const subflowConfig = - payload.type === SubflowType.LOOP - ? { - id: payload.id, - nodes: [], // Empty initially, will be populated when child blocks are added - iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS, - loopType: payload.data?.loopType || 'for', - // Set the appropriate field based on loop type - ...(payload.data?.loopType === 'while' - ? { whileCondition: payload.data?.whileCondition || '' } - : payload.data?.loopType === 'doWhile' - ? { doWhileCondition: payload.data?.doWhileCondition || '' } - : { forEachItems: payload.data?.collection || '' }), - } - : { - id: payload.id, - nodes: [], // Empty initially, will be populated when child blocks are added - distribution: payload.data?.collection || '', - } - - logger.debug( - `Auto-creating ${payload.type} subflow for duplicated block ${payload.id}:`, - subflowConfig - ) - - await tx.insert(workflowSubflows).values({ - id: payload.id, - workflowId, - type: payload.type, - config: subflowConfig, - }) - } catch (subflowError) { - logger.error( - `❌ Failed to create ${payload.type} subflow for duplicated block ${payload.id}:`, - subflowError - ) - throw subflowError - } - } - - // If this block has a parent, update the parent's subflow node list - if (parentId) { - await updateSubflowNodeList(tx, workflowId, parentId) - } - - logger.debug( - `Duplicated block ${payload.sourceId} -> ${payload.id} (${payload.type}) in workflow ${workflowId}` - ) - break - } - - // Add other block operations as needed default: logger.warn(`Unknown block operation: ${operation}`) throw new Error(`Unsupported block operation: ${operation}`) } } -// Edge operations +async function handleBlocksOperationTx( + tx: any, + workflowId: string, + operation: string, + payload: any +) { + switch (operation) { + case 'batch-update-positions': { + const { updates } = payload + if (!Array.isArray(updates) || updates.length === 0) { + return + } + + for (const update of updates) { + const { id, position } = update + if (!id || !position) continue + + await tx + .update(workflowBlocks) + .set({ + positionX: position.x, + positionY: position.y, + }) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + } + break + } + + case 'batch-add-blocks': { + const { blocks, edges, loops, parallels } = payload + + logger.info(`Batch adding blocks to workflow ${workflowId}`, { + blockCount: blocks?.length || 0, + edgeCount: edges?.length || 0, + loopCount: Object.keys(loops || {}).length, + parallelCount: Object.keys(parallels || {}).length, + }) + + if (blocks && blocks.length > 0) { + const blockValues = blocks.map((block: Record) => ({ + id: block.id as string, + workflowId, + type: block.type as string, + name: block.name as string, + positionX: (block.position as { x: number; y: number }).x, + positionY: (block.position as { x: number; y: number }).y, + data: (block.data as Record) || {}, + subBlocks: (block.subBlocks as Record) || {}, + outputs: (block.outputs as Record) || {}, + enabled: (block.enabled as boolean) ?? true, + horizontalHandles: (block.horizontalHandles as boolean) ?? true, + advancedMode: (block.advancedMode as boolean) ?? false, + triggerMode: (block.triggerMode as boolean) ?? false, + height: (block.height as number) || 0, + })) + + await tx.insert(workflowBlocks).values(blockValues) + + // Create subflow entries for loop/parallel blocks (skip if already in payload) + const loopIds = new Set(loops ? Object.keys(loops) : []) + const parallelIds = new Set(parallels ? Object.keys(parallels) : []) + for (const block of blocks) { + const blockId = block.id as string + if (block.type === 'loop' && !loopIds.has(blockId)) { + await tx.insert(workflowSubflows).values({ + id: blockId, + workflowId, + type: 'loop', + config: { + loopType: 'for', + iterations: DEFAULT_LOOP_ITERATIONS, + nodes: [], + }, + }) + } else if (block.type === 'parallel' && !parallelIds.has(blockId)) { + await tx.insert(workflowSubflows).values({ + id: blockId, + workflowId, + type: 'parallel', + config: { + parallelType: 'fixed', + count: DEFAULT_PARALLEL_COUNT, + nodes: [], + }, + }) + } + } + + // Update parent subflow node lists + const parentIds = new Set() + for (const block of blocks) { + const parentId = (block.data as Record)?.parentId as string | undefined + if (parentId) { + parentIds.add(parentId) + } + } + for (const parentId of parentIds) { + await updateSubflowNodeList(tx, workflowId, parentId) + } + } + + if (edges && edges.length > 0) { + const edgeValues = edges.map((edge: Record) => ({ + id: edge.id as string, + workflowId, + sourceBlockId: edge.source as string, + targetBlockId: edge.target as string, + sourceHandle: (edge.sourceHandle as string | null) || null, + targetHandle: (edge.targetHandle as string | null) || null, + })) + + await tx.insert(workflowEdges).values(edgeValues) + } + + if (loops && Object.keys(loops).length > 0) { + const loopValues = Object.entries(loops).map(([id, loop]) => ({ + id, + workflowId, + type: 'loop', + config: loop as Record, + })) + + await tx.insert(workflowSubflows).values(loopValues) + } + + if (parallels && Object.keys(parallels).length > 0) { + const parallelValues = Object.entries(parallels).map(([id, parallel]) => ({ + id, + workflowId, + type: 'parallel', + config: parallel as Record, + })) + + await tx.insert(workflowSubflows).values(parallelValues) + } + + logger.info(`Successfully batch added blocks to workflow ${workflowId}`) + break + } + + case 'batch-remove-blocks': { + const { ids } = payload + if (!Array.isArray(ids) || ids.length === 0) { + return + } + + logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`) + + // Collect all block IDs including children of subflows + const allBlocksToDelete = new Set(ids) + + for (const id of ids) { + const blockToRemove = await tx + .select({ type: workflowBlocks.type }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) { + const childBlocks = await tx + .select({ id: workflowBlocks.id }) + .from(workflowBlocks) + .where( + and( + eq(workflowBlocks.workflowId, workflowId), + sql`${workflowBlocks.data}->>'parentId' = ${id}` + ) + ) + + childBlocks.forEach((child: { id: string }) => allBlocksToDelete.add(child.id)) + } + } + + const blockIdsArray = Array.from(allBlocksToDelete) + + // Collect parent IDs BEFORE deleting blocks + const parentIds = new Set() + for (const id of ids) { + const parentInfo = await tx + .select({ parentId: sql`${workflowBlocks.data}->>'parentId'` }) + .from(workflowBlocks) + .where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId))) + .limit(1) + + if (parentInfo.length > 0 && parentInfo[0].parentId) { + parentIds.add(parentInfo[0].parentId) + } + } + + // Clean up external webhooks + const webhooksToCleanup = await tx + .select({ + webhook: webhook, + workflow: { + id: workflow.id, + userId: workflow.userId, + workspaceId: workflow.workspaceId, + }, + }) + .from(webhook) + .innerJoin(workflow, eq(webhook.workflowId, workflow.id)) + .where(and(eq(webhook.workflowId, workflowId), inArray(webhook.blockId, blockIdsArray))) + + if (webhooksToCleanup.length > 0) { + const requestId = `socket-batch-${workflowId}-${Date.now()}` + for (const { webhook: wh, workflow: wf } of webhooksToCleanup) { + try { + await cleanupExternalWebhook(wh, wf, requestId) + } catch (error) { + logger.error(`Failed to cleanup webhook ${wh.id}:`, error) + } + } + } + + // Delete edges connected to any of the blocks + await tx + .delete(workflowEdges) + .where( + and( + eq(workflowEdges.workflowId, workflowId), + or( + inArray(workflowEdges.sourceBlockId, blockIdsArray), + inArray(workflowEdges.targetBlockId, blockIdsArray) + ) + ) + ) + + // Delete subflow entries + await tx + .delete(workflowSubflows) + .where( + and( + eq(workflowSubflows.workflowId, workflowId), + inArray(workflowSubflows.id, blockIdsArray) + ) + ) + + // Delete all blocks + await tx + .delete(workflowBlocks) + .where( + and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIdsArray)) + ) + + // Update parent subflow node lists using pre-collected parent IDs + for (const parentId of parentIds) { + await updateSubflowNodeList(tx, workflowId, parentId) + } + + logger.info( + `Successfully batch removed ${blockIdsArray.length} blocks from workflow ${workflowId}` + ) + break + } + + default: + throw new Error(`Unsupported blocks operation: ${operation}`) + } +} + async function handleEdgeOperationTx(tx: any, workflowId: string, operation: string, payload: any) { switch (operation) { case 'add': { @@ -1013,53 +923,6 @@ async function handleVariableOperationTx( break } - case 'duplicate': { - if (!payload.sourceVariableId || !payload.id) { - throw new Error('Missing required fields for duplicate variable operation') - } - - const sourceVariable = currentVariables[payload.sourceVariableId] - if (!sourceVariable) { - throw new Error(`Source variable ${payload.sourceVariableId} not found`) - } - - // Create duplicated variable with unique name - const baseName = `${sourceVariable.name} (copy)` - let uniqueName = baseName - let nameIndex = 1 - - // Ensure name uniqueness - const existingNames = Object.values(currentVariables).map((v: any) => v.name) - while (existingNames.includes(uniqueName)) { - uniqueName = `${baseName} (${nameIndex})` - nameIndex++ - } - - const duplicatedVariable = { - ...sourceVariable, - id: payload.id, - name: uniqueName, - } - - const updatedVariables = { - ...currentVariables, - [payload.id]: duplicatedVariable, - } - - await tx - .update(workflow) - .set({ - variables: updatedVariables, - updatedAt: new Date(), - }) - .where(eq(workflow.id, workflowId)) - - logger.debug( - `Duplicated variable ${payload.sourceVariableId} -> ${payload.id} (${uniqueName}) in workflow ${workflowId}` - ) - break - } - default: logger.warn(`Unknown variable operation: ${operation}`) throw new Error(`Unsupported variable operation: ${operation}`) diff --git a/apps/sim/socket/handlers/operations.ts b/apps/sim/socket/handlers/operations.ts index 0ae0c5859..7fd64995c 100644 --- a/apps/sim/socket/handlers/operations.ts +++ b/apps/sim/socket/handlers/operations.ts @@ -145,7 +145,46 @@ export function setupOperationsHandlers( return } - if (target === 'variable' && ['add', 'remove', 'duplicate'].includes(operation)) { + if (target === 'blocks' && operation === 'batch-update-positions') { + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID(), isBatchPositionUpdate: true }, + }) + + try { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + room.lastModified = Date.now() + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + } catch (error) { + logger.error('Failed to persist batch position update:', error) + if (operationId) { + socket.emit('operation-failed', { + operationId, + error: error instanceof Error ? error.message : 'Database persistence failed', + retryable: true, + }) + } + } + + return + } + + if (target === 'variable' && ['add', 'remove'].includes(operation)) { // Persist first, then broadcast await persistWorkflowOperation(workflowId, { operation, @@ -184,7 +223,6 @@ export function setupOperationsHandlers( } if (target === 'workflow' && operation === 'replace-state') { - // Persist the workflow state replacement to database first await persistWorkflowOperation(workflowId, { operation, target, @@ -221,6 +259,64 @@ export function setupOperationsHandlers( return } + if (target === 'blocks' && operation === 'batch-add-blocks') { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + + if (target === 'blocks' && operation === 'batch-remove-blocks') { + await persistWorkflowOperation(workflowId, { + operation, + target, + payload, + timestamp: operationTimestamp, + userId: session.userId, + }) + + room.lastModified = Date.now() + + socket.to(workflowId).emit('workflow-operation', { + operation, + target, + payload, + timestamp: operationTimestamp, + senderId: socket.id, + userId: session.userId, + userName: session.userName, + metadata: { workflowId, operationId: crypto.randomUUID() }, + }) + + if (operationId) { + socket.emit('operation-confirmed', { operationId, serverTimestamp: Date.now() }) + } + + return + } + // For non-position operations, persist first then broadcast await persistWorkflowOperation(workflowId, { operation, diff --git a/apps/sim/socket/index.test.ts b/apps/sim/socket/index.test.ts index 690530efe..377edd41b 100644 --- a/apps/sim/socket/index.test.ts +++ b/apps/sim/socket/index.test.ts @@ -294,13 +294,21 @@ describe('Socket Server Index Integration', () => { const { WorkflowOperationSchema } = await import('@/socket/validation/schemas') const validOperation = { - operation: 'add', - target: 'block', + operation: 'batch-add-blocks', + target: 'blocks', payload: { - id: 'test-block', - type: 'action', - name: 'Test Block', - position: { x: 100, y: 200 }, + blocks: [ + { + id: 'test-block', + type: 'action', + name: 'Test Block', + position: { x: 100, y: 200 }, + }, + ], + edges: [], + loops: {}, + parallels: {}, + subBlockValues: {}, }, timestamp: Date.now(), } @@ -308,30 +316,39 @@ describe('Socket Server Index Integration', () => { expect(() => WorkflowOperationSchema.parse(validOperation)).not.toThrow() }) - it.concurrent('should validate block operations with autoConnectEdge', async () => { + it.concurrent('should validate batch-add-blocks with edges', async () => { const { WorkflowOperationSchema } = await import('@/socket/validation/schemas') - const validOperationWithAutoEdge = { - operation: 'add', - target: 'block', + const validOperationWithEdge = { + operation: 'batch-add-blocks', + target: 'blocks', payload: { - id: 'test-block', - type: 'action', - name: 'Test Block', - position: { x: 100, y: 200 }, - autoConnectEdge: { - id: 'auto-edge-123', - source: 'source-block', - target: 'test-block', - sourceHandle: 'output', - targetHandle: 'target', - type: 'workflowEdge', - }, + blocks: [ + { + id: 'test-block', + type: 'action', + name: 'Test Block', + position: { x: 100, y: 200 }, + }, + ], + edges: [ + { + id: 'auto-edge-123', + source: 'source-block', + target: 'test-block', + sourceHandle: 'output', + targetHandle: 'target', + type: 'workflowEdge', + }, + ], + loops: {}, + parallels: {}, + subBlockValues: {}, }, timestamp: Date.now(), } - expect(() => WorkflowOperationSchema.parse(validOperationWithAutoEdge)).not.toThrow() + expect(() => WorkflowOperationSchema.parse(validOperationWithEdge)).not.toThrow() }) it.concurrent('should validate edge operations', async () => { diff --git a/apps/sim/socket/middleware/permissions.test.ts b/apps/sim/socket/middleware/permissions.test.ts index 86e7b0423..1c2192eac 100644 --- a/apps/sim/socket/middleware/permissions.test.ts +++ b/apps/sim/socket/middleware/permissions.test.ts @@ -27,13 +27,13 @@ describe('checkRolePermission', () => { } }) - it('should allow add operation', () => { - const result = checkRolePermission('admin', 'add') + it('should allow batch-add-blocks operation', () => { + const result = checkRolePermission('admin', 'batch-add-blocks') expectPermissionAllowed(result) }) - it('should allow remove operation', () => { - const result = checkRolePermission('admin', 'remove') + it('should allow batch-remove-blocks operation', () => { + const result = checkRolePermission('admin', 'batch-remove-blocks') expectPermissionAllowed(result) }) @@ -42,8 +42,8 @@ describe('checkRolePermission', () => { expectPermissionAllowed(result) }) - it('should allow duplicate operation', () => { - const result = checkRolePermission('admin', 'duplicate') + it('should allow batch-update-positions operation', () => { + const result = checkRolePermission('admin', 'batch-update-positions') expectPermissionAllowed(result) }) @@ -63,13 +63,13 @@ describe('checkRolePermission', () => { } }) - it('should allow add operation', () => { - const result = checkRolePermission('write', 'add') + it('should allow batch-add-blocks operation', () => { + const result = checkRolePermission('write', 'batch-add-blocks') expectPermissionAllowed(result) }) - it('should allow remove operation', () => { - const result = checkRolePermission('write', 'remove') + it('should allow batch-remove-blocks operation', () => { + const result = checkRolePermission('write', 'batch-remove-blocks') expectPermissionAllowed(result) }) @@ -85,14 +85,14 @@ describe('checkRolePermission', () => { expectPermissionAllowed(result) }) - it('should deny add operation for read role', () => { - const result = checkRolePermission('read', 'add') + it('should deny batch-add-blocks operation for read role', () => { + const result = checkRolePermission('read', 'batch-add-blocks') expectPermissionDenied(result, 'read') - expectPermissionDenied(result, 'add') + expectPermissionDenied(result, 'batch-add-blocks') }) - it('should deny remove operation for read role', () => { - const result = checkRolePermission('read', 'remove') + it('should deny batch-remove-blocks operation for read role', () => { + const result = checkRolePermission('read', 'batch-remove-blocks') expectPermissionDenied(result, 'read') }) @@ -101,9 +101,9 @@ describe('checkRolePermission', () => { expectPermissionDenied(result, 'read') }) - it('should deny duplicate operation for read role', () => { - const result = checkRolePermission('read', 'duplicate') - expectPermissionDenied(result, 'read') + it('should allow batch-update-positions operation for read role', () => { + const result = checkRolePermission('read', 'batch-update-positions') + expectPermissionAllowed(result) }) it('should deny replace-state operation for read role', () => { @@ -117,7 +117,8 @@ describe('checkRolePermission', () => { }) it('should deny all write operations for read role', () => { - const writeOperations = SOCKET_OPERATIONS.filter((op) => op !== 'update-position') + const readAllowedOps = ['update-position', 'batch-update-positions'] + const writeOperations = SOCKET_OPERATIONS.filter((op) => !readAllowedOps.includes(op)) for (const operation of writeOperations) { const result = checkRolePermission('read', operation) @@ -138,7 +139,7 @@ describe('checkRolePermission', () => { }) it('should deny operations for empty role', () => { - const result = checkRolePermission('', 'add') + const result = checkRolePermission('', 'batch-add-blocks') expectPermissionDenied(result) }) }) @@ -186,15 +187,21 @@ describe('checkRolePermission', () => { it('should verify read has minimal permissions', () => { const readOps = ROLE_ALLOWED_OPERATIONS.read - expect(readOps).toHaveLength(1) + expect(readOps).toHaveLength(2) expect(readOps).toContain('update-position') + expect(readOps).toContain('batch-update-positions') }) }) describe('specific operations', () => { const testCases = [ - { operation: 'add', adminAllowed: true, writeAllowed: true, readAllowed: false }, - { operation: 'remove', adminAllowed: true, writeAllowed: true, readAllowed: false }, + { operation: 'batch-add-blocks', adminAllowed: true, writeAllowed: true, readAllowed: false }, + { + operation: 'batch-remove-blocks', + adminAllowed: true, + writeAllowed: true, + readAllowed: false, + }, { operation: 'update', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'update-position', adminAllowed: true, writeAllowed: true, readAllowed: true }, { operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false }, @@ -214,7 +221,12 @@ describe('checkRolePermission', () => { readAllowed: false, }, { operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false }, - { operation: 'duplicate', adminAllowed: true, writeAllowed: true, readAllowed: false }, + { + operation: 'batch-update-positions', + adminAllowed: true, + writeAllowed: true, + readAllowed: true, + }, { operation: 'replace-state', adminAllowed: true, writeAllowed: true, readAllowed: false }, ] @@ -238,13 +250,13 @@ describe('checkRolePermission', () => { describe('reason messages', () => { it('should include role in denial reason', () => { - const result = checkRolePermission('read', 'add') + const result = checkRolePermission('read', 'batch-add-blocks') expect(result.reason).toContain("'read'") }) it('should include operation in denial reason', () => { - const result = checkRolePermission('read', 'add') - expect(result.reason).toContain("'add'") + const result = checkRolePermission('read', 'batch-add-blocks') + expect(result.reason).toContain("'batch-add-blocks'") }) it('should have descriptive denial message format', () => { diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 65ffe6478..02aadfdde 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -13,6 +13,9 @@ const ROLE_PERMISSIONS: Record = { 'remove', 'update', 'update-position', + 'batch-update-positions', + 'batch-add-blocks', + 'batch-remove-blocks', 'update-name', 'toggle-enabled', 'update-parent', @@ -20,7 +23,6 @@ const ROLE_PERMISSIONS: Record = { 'update-advanced-mode', 'update-trigger-mode', 'toggle-handles', - 'duplicate', 'replace-state', ], write: [ @@ -28,6 +30,9 @@ const ROLE_PERMISSIONS: Record = { 'remove', 'update', 'update-position', + 'batch-update-positions', + 'batch-add-blocks', + 'batch-remove-blocks', 'update-name', 'toggle-enabled', 'update-parent', @@ -35,10 +40,9 @@ const ROLE_PERMISSIONS: Record = { 'update-advanced-mode', 'update-trigger-mode', 'toggle-handles', - 'duplicate', 'replace-state', ], - read: ['update-position'], + read: ['update-position', 'batch-update-positions'], } // Check if a role allows a specific operation (no DB query, pure logic) diff --git a/apps/sim/socket/tests/socket-server.test.ts b/apps/sim/socket/tests/socket-server.test.ts index 706024de1..c4940cfaf 100644 --- a/apps/sim/socket/tests/socket-server.test.ts +++ b/apps/sim/socket/tests/socket-server.test.ts @@ -103,18 +103,26 @@ describe('Socket Server Integration Tests', () => { const operationPromise = new Promise((resolve) => { client2.once('workflow-operation', (data) => { - expect(data.operation).toBe('add') - expect(data.target).toBe('block') - expect(data.payload.id).toBe('block-123') + expect(data.operation).toBe('batch-add-blocks') + expect(data.target).toBe('blocks') + expect(data.payload.blocks[0].id).toBe('block-123') resolve() }) }) clientSocket.emit('workflow-operation', { workflowId, - operation: 'add', - target: 'block', - payload: { id: 'block-123', type: 'action', name: 'Test Block' }, + operation: 'batch-add-blocks', + target: 'blocks', + payload: { + blocks: [ + { id: 'block-123', type: 'action', name: 'Test Block', position: { x: 0, y: 0 } }, + ], + edges: [], + loops: {}, + parallels: {}, + subBlockValues: {}, + }, timestamp: Date.now(), }) @@ -170,9 +178,17 @@ describe('Socket Server Integration Tests', () => { clients[0].emit('workflow-operation', { workflowId, - operation: 'add', - target: 'block', - payload: { id: 'stress-block', type: 'action' }, + operation: 'batch-add-blocks', + target: 'blocks', + payload: { + blocks: [ + { id: 'stress-block', type: 'action', name: 'Stress Block', position: { x: 0, y: 0 } }, + ], + edges: [], + loops: {}, + parallels: {}, + subBlockValues: {}, + }, timestamp: Date.now(), }) @@ -211,7 +227,7 @@ describe('Socket Server Integration Tests', () => { const operationsPromise = new Promise((resolve) => { client2.on('workflow-operation', (data) => { receivedCount++ - receivedOperations.add(data.payload.id) + receivedOperations.add(data.payload.blocks[0].id) if (receivedCount === numOperations) { resolve() @@ -222,9 +238,22 @@ describe('Socket Server Integration Tests', () => { for (let i = 0; i < numOperations; i++) { clientSocket.emit('workflow-operation', { workflowId, - operation: 'add', - target: 'block', - payload: { id: `rapid-block-${i}`, type: 'action' }, + operation: 'batch-add-blocks', + target: 'blocks', + payload: { + blocks: [ + { + id: `rapid-block-${i}`, + type: 'action', + name: `Rapid Block ${i}`, + position: { x: 0, y: 0 }, + }, + ], + edges: [], + loops: {}, + parallels: {}, + subBlockValues: {}, + }, timestamp: Date.now(), }) } diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index 4f1aa97fa..71be52977 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -17,8 +17,6 @@ const AutoConnectEdgeSchema = z.object({ export const BlockOperationSchema = z.object({ operation: z.enum([ - 'add', - 'remove', 'update-position', 'update-name', 'toggle-enabled', @@ -27,12 +25,10 @@ export const BlockOperationSchema = z.object({ 'update-advanced-mode', 'update-trigger-mode', 'toggle-handles', - 'duplicate', ]), target: z.literal('block'), payload: z.object({ id: z.string(), - sourceId: z.string().optional(), // For duplicate operations type: z.string().optional(), name: z.string().optional(), position: PositionSchema.optional(), @@ -47,7 +43,21 @@ export const BlockOperationSchema = z.object({ advancedMode: z.boolean().optional(), triggerMode: z.boolean().optional(), height: z.number().optional(), - autoConnectEdge: AutoConnectEdgeSchema.optional(), // Add support for auto-connect edges + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchPositionUpdateSchema = z.object({ + operation: z.literal('batch-update-positions'), + target: z.literal('blocks'), + payload: z.object({ + updates: z.array( + z.object({ + id: z.string(), + position: PositionSchema, + }) + ), }), timestamp: z.number(), operationId: z.string().optional(), @@ -102,23 +112,37 @@ export const VariableOperationSchema = z.union([ timestamp: z.number(), operationId: z.string().optional(), }), - z.object({ - operation: z.literal('duplicate'), - target: z.literal('variable'), - payload: z.object({ - sourceVariableId: z.string(), - id: z.string(), - }), - timestamp: z.number(), - operationId: z.string().optional(), - }), ]) export const WorkflowStateOperationSchema = z.object({ operation: z.literal('replace-state'), target: z.literal('workflow'), payload: z.object({ - state: z.any(), // Full workflow state + state: z.any(), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchAddBlocksSchema = z.object({ + operation: z.literal('batch-add-blocks'), + target: z.literal('blocks'), + payload: z.object({ + blocks: z.array(z.record(z.any())), + edges: z.array(AutoConnectEdgeSchema).optional(), + loops: z.record(z.any()).optional(), + parallels: z.record(z.any()).optional(), + subBlockValues: z.record(z.record(z.any())).optional(), + }), + timestamp: z.number(), + operationId: z.string().optional(), +}) + +export const BatchRemoveBlocksSchema = z.object({ + operation: z.literal('batch-remove-blocks'), + target: z.literal('blocks'), + payload: z.object({ + ids: z.array(z.string()), }), timestamp: z.number(), operationId: z.string().optional(), @@ -126,6 +150,9 @@ export const WorkflowStateOperationSchema = z.object({ export const WorkflowOperationSchema = z.union([ BlockOperationSchema, + BatchPositionUpdateSchema, + BatchAddBlocksSchema, + BatchRemoveBlocksSchema, EdgeOperationSchema, SubflowOperationSchema, VariableOperationSchema, diff --git a/apps/sim/stores/operation-queue/store.ts b/apps/sim/stores/operation-queue/store.ts index 25e2ad09d..5d39b56f9 100644 --- a/apps/sim/stores/operation-queue/store.ts +++ b/apps/sim/stores/operation-queue/store.ts @@ -375,15 +375,31 @@ export const useOperationQueueStore = create((set, get) => cancelOperationsForBlock: (blockId: string) => { logger.debug('Canceling all operations for block', { blockId }) - // No debounced timeouts to cancel (moved to server-side) - - // Find and cancel operation timeouts for operations related to this block const state = get() - const operationsToCancel = state.operations.filter( - (op) => - (op.operation.target === 'block' && op.operation.payload?.id === blockId) || - (op.operation.target === 'subblock' && op.operation.payload?.blockId === blockId) - ) + const operationsToCancel = state.operations.filter((op) => { + const { target, payload, operation } = op.operation + + // Single block property updates (update-position, toggle-enabled, update-name, etc.) + if (target === 'block' && payload?.id === blockId) return true + + // Subblock updates for this block + if (target === 'subblock' && payload?.blockId === blockId) return true + + // Batch block operations + if (target === 'blocks') { + if (operation === 'batch-add-blocks' && Array.isArray(payload?.blocks)) { + return payload.blocks.some((b: { id: string }) => b.id === blockId) + } + if (operation === 'batch-remove-blocks' && Array.isArray(payload?.ids)) { + return payload.ids.includes(blockId) + } + if (operation === 'batch-update-positions' && Array.isArray(payload?.updates)) { + return payload.updates.some((u: { id: string }) => u.id === blockId) + } + } + + return false + }) // Cancel timeouts for these operations operationsToCancel.forEach((op) => { @@ -401,13 +417,30 @@ export const useOperationQueueStore = create((set, get) => }) // Remove all operations for this block (both pending and processing) - const newOperations = state.operations.filter( - (op) => - !( - (op.operation.target === 'block' && op.operation.payload?.id === blockId) || - (op.operation.target === 'subblock' && op.operation.payload?.blockId === blockId) - ) - ) + const newOperations = state.operations.filter((op) => { + const { target, payload, operation } = op.operation + + // Single block property updates (update-position, toggle-enabled, update-name, etc.) + if (target === 'block' && payload?.id === blockId) return false + + // Subblock updates for this block + if (target === 'subblock' && payload?.blockId === blockId) return false + + // Batch block operations + if (target === 'blocks') { + if (operation === 'batch-add-blocks' && Array.isArray(payload?.blocks)) { + if (payload.blocks.some((b: { id: string }) => b.id === blockId)) return false + } + if (operation === 'batch-remove-blocks' && Array.isArray(payload?.ids)) { + if (payload.ids.includes(blockId)) return false + } + if (operation === 'batch-update-positions' && Array.isArray(payload?.updates)) { + if (payload.updates.some((u: { id: string }) => u.id === blockId)) return false + } + } + + return true + }) set({ operations: newOperations, diff --git a/apps/sim/stores/panel/variables/store.ts b/apps/sim/stores/panel/variables/store.ts index 14878753d..7a4b8975e 100644 --- a/apps/sim/stores/panel/variables/store.ts +++ b/apps/sim/stores/panel/variables/store.ts @@ -283,39 +283,6 @@ export const useVariablesStore = create()( }) }, - duplicateVariable: (id, providedId?: string) => { - const state = get() - if (!state.variables[id]) return '' - - const variable = state.variables[id] - const newId = providedId || crypto.randomUUID() - - const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId) - const baseName = `${variable.name} (copy)` - let uniqueName = baseName - let nameIndex = 1 - - while (workflowVariables.some((v) => v.name === uniqueName)) { - uniqueName = `${baseName} (${nameIndex})` - nameIndex++ - } - - set((state) => ({ - variables: { - ...state.variables, - [newId]: { - id: newId, - workflowId: variable.workflowId, - name: uniqueName, - type: variable.type, - value: variable.value, - }, - }, - })) - - return newId - }, - getVariablesByWorkflowId: (workflowId) => { return Object.values(get().variables).filter((variable) => variable.workflowId === workflowId) }, diff --git a/apps/sim/stores/panel/variables/types.ts b/apps/sim/stores/panel/variables/types.ts index 2aacd13b7..5910168e1 100644 --- a/apps/sim/stores/panel/variables/types.ts +++ b/apps/sim/stores/panel/variables/types.ts @@ -43,12 +43,6 @@ export interface VariablesStore { deleteVariable: (id: string) => void - /** - * Duplicates a variable with a "(copy)" suffix, ensuring name uniqueness - * Optionally accepts a predetermined ID for collaborative operations - */ - duplicateVariable: (id: string, providedId?: string) => string - /** * Returns all variables for a specific workflow */ diff --git a/apps/sim/stores/undo-redo/store.test.ts b/apps/sim/stores/undo-redo/store.test.ts index f668e8b4a..99f6f3827 100644 --- a/apps/sim/stores/undo-redo/store.test.ts +++ b/apps/sim/stores/undo-redo/store.test.ts @@ -14,7 +14,6 @@ import { createAddBlockEntry, createAddEdgeEntry, createBlock, - createDuplicateBlockEntry, createMockStorage, createMoveBlockEntry, createRemoveBlockEntry, @@ -23,7 +22,7 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' import { runWithUndoRedoRecordingSuspended, useUndoRedoStore } from '@/stores/undo-redo/store' -import type { DuplicateBlockOperation, UpdateParentOperation } from '@/stores/undo-redo/types' +import type { UpdateParentOperation } from '@/stores/undo-redo/types' describe('useUndoRedoStore', () => { const workflowId = 'wf-test' @@ -617,63 +616,6 @@ describe('useUndoRedoStore', () => { }) }) - describe('duplicate-block operations', () => { - it('should handle duplicate-block operations', () => { - const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState() - - const sourceBlock = createBlock({ id: 'source-block' }) - const duplicatedBlock = createBlock({ id: 'duplicated-block' }) - - push( - workflowId, - userId, - createDuplicateBlockEntry('source-block', 'duplicated-block', duplicatedBlock, { - workflowId, - userId, - }) - ) - - expect(getStackSizes(workflowId, userId).undoSize).toBe(1) - - const entry = undo(workflowId, userId) - expect(entry?.operation.type).toBe('duplicate-block') - expect(entry?.inverse.type).toBe('remove-block') - expect(getStackSizes(workflowId, userId).redoSize).toBe(1) - - redo(workflowId, userId) - expect(getStackSizes(workflowId, userId).undoSize).toBe(1) - }) - - it('should store the duplicated block snapshot correctly', () => { - const { push, undo } = useUndoRedoStore.getState() - - const duplicatedBlock = createBlock({ - id: 'duplicated-block', - name: 'Duplicated Agent', - type: 'agent', - position: { x: 200, y: 200 }, - }) - - push( - workflowId, - userId, - createDuplicateBlockEntry('source-block', 'duplicated-block', duplicatedBlock, { - workflowId, - userId, - }) - ) - - const entry = undo(workflowId, userId) - const operation = entry?.operation as DuplicateBlockOperation - expect(operation.data.duplicatedBlockSnapshot).toMatchObject({ - id: 'duplicated-block', - name: 'Duplicated Agent', - type: 'agent', - position: { x: 200, y: 200 }, - }) - }) - }) - describe('update-parent operations', () => { it('should handle update-parent operations', () => { const { push, undo, redo, getStackSizes } = useUndoRedoStore.getState() diff --git a/apps/sim/stores/undo-redo/store.ts b/apps/sim/stores/undo-redo/store.ts index 2d5b9dafa..07c67a0f5 100644 --- a/apps/sim/stores/undo-redo/store.ts +++ b/apps/sim/stores/undo-redo/store.ts @@ -3,10 +3,11 @@ import type { Edge } from 'reactflow' import { create } from 'zustand' import { createJSONStorage, persist } from 'zustand/middleware' import type { + BatchAddBlocksOperation, + BatchRemoveBlocksOperation, MoveBlockOperation, Operation, OperationEntry, - RemoveBlockOperation, RemoveEdgeOperation, UndoRedoState, } from '@/stores/undo-redo/types' @@ -83,13 +84,13 @@ function isOperationApplicable( graph: { blocksById: Record; edgesById: Record } ): boolean { switch (operation.type) { - case 'remove-block': { - const op = operation as RemoveBlockOperation - return Boolean(graph.blocksById[op.data.blockId]) + case 'batch-remove-blocks': { + const op = operation as BatchRemoveBlocksOperation + return op.data.blockSnapshots.every((block) => Boolean(graph.blocksById[block.id])) } - case 'add-block': { - const blockId = operation.data.blockId - return !graph.blocksById[blockId] + case 'batch-add-blocks': { + const op = operation as BatchAddBlocksOperation + return op.data.blockSnapshots.every((block) => !graph.blocksById[block.id]) } case 'move-block': { const op = operation as MoveBlockOperation @@ -99,10 +100,6 @@ function isOperationApplicable( const blockId = operation.data.blockId return Boolean(graph.blocksById[blockId]) } - case 'duplicate-block': { - const duplicatedId = operation.data.duplicatedBlockId - return Boolean(graph.blocksById[duplicatedId]) - } case 'remove-edge': { const op = operation as RemoveEdgeOperation return Boolean(graph.edgesById[op.data.edgeId]) diff --git a/apps/sim/stores/undo-redo/types.ts b/apps/sim/stores/undo-redo/types.ts index 7edc47fd9..d69688e83 100644 --- a/apps/sim/stores/undo-redo/types.ts +++ b/apps/sim/stores/undo-redo/types.ts @@ -2,15 +2,14 @@ import type { Edge } from 'reactflow' import type { BlockState } from '@/stores/workflows/workflow/types' export type OperationType = - | 'add-block' - | 'remove-block' + | 'batch-add-blocks' + | 'batch-remove-blocks' | 'add-edge' | 'remove-edge' | 'add-subflow' | 'remove-subflow' | 'move-block' | 'move-subflow' - | 'duplicate-block' | 'update-parent' | 'apply-diff' | 'accept-diff' @@ -24,20 +23,21 @@ export interface BaseOperation { userId: string } -export interface AddBlockOperation extends BaseOperation { - type: 'add-block' +export interface BatchAddBlocksOperation extends BaseOperation { + type: 'batch-add-blocks' data: { - blockId: string + blockSnapshots: BlockState[] + edgeSnapshots: Edge[] + subBlockValues: Record> } } -export interface RemoveBlockOperation extends BaseOperation { - type: 'remove-block' +export interface BatchRemoveBlocksOperation extends BaseOperation { + type: 'batch-remove-blocks' data: { - blockId: string - blockSnapshot: BlockState | null - edgeSnapshots?: Edge[] - allBlockSnapshots?: Record + blockSnapshots: BlockState[] + edgeSnapshots: Edge[] + subBlockValues: Record> } } @@ -103,16 +103,6 @@ export interface MoveSubflowOperation extends BaseOperation { } } -export interface DuplicateBlockOperation extends BaseOperation { - type: 'duplicate-block' - data: { - sourceBlockId: string - duplicatedBlockId: string - duplicatedBlockSnapshot: BlockState - autoConnectEdge?: Edge - } -} - export interface UpdateParentOperation extends BaseOperation { type: 'update-parent' data: { @@ -155,15 +145,14 @@ export interface RejectDiffOperation extends BaseOperation { } export type Operation = - | AddBlockOperation - | RemoveBlockOperation + | BatchAddBlocksOperation + | BatchRemoveBlocksOperation | AddEdgeOperation | RemoveEdgeOperation | AddSubflowOperation | RemoveSubflowOperation | MoveBlockOperation | MoveSubflowOperation - | DuplicateBlockOperation | UpdateParentOperation | ApplyDiffOperation | AcceptDiffOperation diff --git a/apps/sim/stores/undo-redo/utils.ts b/apps/sim/stores/undo-redo/utils.ts index c8e588f95..e00209c20 100644 --- a/apps/sim/stores/undo-redo/utils.ts +++ b/apps/sim/stores/undo-redo/utils.ts @@ -1,4 +1,9 @@ -import type { Operation, OperationEntry } from '@/stores/undo-redo/types' +import type { + BatchAddBlocksOperation, + BatchRemoveBlocksOperation, + Operation, + OperationEntry, +} from '@/stores/undo-redo/types' export function createOperationEntry(operation: Operation, inverse: Operation): OperationEntry { return { @@ -11,25 +16,31 @@ export function createOperationEntry(operation: Operation, inverse: Operation): export function createInverseOperation(operation: Operation): Operation { switch (operation.type) { - case 'add-block': + case 'batch-add-blocks': { + const op = operation as BatchAddBlocksOperation return { ...operation, - type: 'remove-block', + type: 'batch-remove-blocks', data: { - blockId: operation.data.blockId, - blockSnapshot: null, - edgeSnapshots: [], + blockSnapshots: op.data.blockSnapshots, + edgeSnapshots: op.data.edgeSnapshots, + subBlockValues: op.data.subBlockValues, }, - } + } as BatchRemoveBlocksOperation + } - case 'remove-block': + case 'batch-remove-blocks': { + const op = operation as BatchRemoveBlocksOperation return { ...operation, - type: 'add-block', + type: 'batch-add-blocks', data: { - blockId: operation.data.blockId, + blockSnapshots: op.data.blockSnapshots, + edgeSnapshots: op.data.edgeSnapshots, + subBlockValues: op.data.subBlockValues, }, - } + } as BatchAddBlocksOperation + } case 'add-edge': return { @@ -89,17 +100,6 @@ export function createInverseOperation(operation: Operation): Operation { }, } - case 'duplicate-block': - return { - ...operation, - type: 'remove-block', - data: { - blockId: operation.data.duplicatedBlockId, - blockSnapshot: operation.data.duplicatedBlockSnapshot, - edgeSnapshots: [], - }, - } - case 'update-parent': return { ...operation, @@ -147,7 +147,7 @@ export function createInverseOperation(operation: Operation): Operation { default: { const exhaustiveCheck: never = operation - throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`) + throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) } } } @@ -155,22 +155,32 @@ export function createInverseOperation(operation: Operation): Operation { export function operationToCollaborativePayload(operation: Operation): { operation: string target: string - payload: any + payload: Record } { switch (operation.type) { - case 'add-block': + case 'batch-add-blocks': { + const op = operation as BatchAddBlocksOperation return { - operation: 'add', - target: 'block', - payload: { id: operation.data.blockId }, + operation: 'batch-add-blocks', + target: 'blocks', + payload: { + blocks: op.data.blockSnapshots, + edges: op.data.edgeSnapshots, + loops: {}, + parallels: {}, + subBlockValues: op.data.subBlockValues, + }, } + } - case 'remove-block': + case 'batch-remove-blocks': { + const op = operation as BatchRemoveBlocksOperation return { - operation: 'remove', - target: 'block', - payload: { id: operation.data.blockId }, + operation: 'batch-remove-blocks', + target: 'blocks', + payload: { ids: op.data.blockSnapshots.map((b) => b.id) }, } + } case 'add-edge': return { @@ -223,16 +233,6 @@ export function operationToCollaborativePayload(operation: Operation): { }, } - case 'duplicate-block': - return { - operation: 'duplicate', - target: 'block', - payload: { - sourceId: operation.data.sourceBlockId, - duplicatedId: operation.data.duplicatedBlockId, - }, - } - case 'update-parent': return { operation: 'update-parent', @@ -274,7 +274,7 @@ export function operationToCollaborativePayload(operation: Operation): { default: { const exhaustiveCheck: never = operation - throw new Error(`Unhandled operation type: ${(exhaustiveCheck as any).type}`) + throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`) } } } diff --git a/apps/sim/stores/variables/store.ts b/apps/sim/stores/variables/store.ts index df348e12b..bf26e6fb0 100644 --- a/apps/sim/stores/variables/store.ts +++ b/apps/sim/stores/variables/store.ts @@ -381,37 +381,6 @@ export const useVariablesStore = create()( }) }, - duplicateVariable: (id, providedId) => { - const state = get() - const existing = state.variables[id] - if (!existing) return '' - const newId = providedId || uuidv4() - - const workflowVariables = state.getVariablesByWorkflowId(existing.workflowId) - const baseName = `${existing.name} (copy)` - let uniqueName = baseName - let nameIndex = 1 - while (workflowVariables.some((v) => v.name === uniqueName)) { - uniqueName = `${baseName} (${nameIndex})` - nameIndex++ - } - - set((state) => ({ - variables: { - ...state.variables, - [newId]: { - id: newId, - workflowId: existing.workflowId, - name: uniqueName, - type: existing.type, - value: existing.value, - }, - }, - })) - - return newId - }, - getVariablesByWorkflowId: (workflowId) => { return Object.values(get().variables).filter((v) => v.workflowId === workflowId) }, diff --git a/apps/sim/stores/variables/types.ts b/apps/sim/stores/variables/types.ts index e4674717a..610192f49 100644 --- a/apps/sim/stores/variables/types.ts +++ b/apps/sim/stores/variables/types.ts @@ -57,6 +57,5 @@ export interface VariablesStore { addVariable: (variable: Omit, providedId?: string) => string updateVariable: (id: string, update: Partial>) => void deleteVariable: (id: string) => void - duplicateVariable: (id: string, providedId?: string) => string getVariablesByWorkflowId: (workflowId: string) => Variable[] } diff --git a/apps/sim/stores/workflows/json/importer.ts b/apps/sim/stores/workflows/json/importer.ts index 6ab3764de..bb119e989 100644 --- a/apps/sim/stores/workflows/json/importer.ts +++ b/apps/sim/stores/workflows/json/importer.ts @@ -1,119 +1,9 @@ import { createLogger } from '@sim/logger' -import { v4 as uuidv4 } from 'uuid' -import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' +import { regenerateWorkflowIds } from '@/stores/workflows/utils' import type { WorkflowState } from '../workflow/types' const logger = createLogger('WorkflowJsonImporter') -/** - * Generate new IDs for all blocks and edges to avoid conflicts - */ -function regenerateIds(workflowState: WorkflowState): WorkflowState { - const { metadata, variables } = workflowState - const blockIdMap = new Map() - const newBlocks: WorkflowState['blocks'] = {} - - // First pass: create new IDs for all blocks - Object.entries(workflowState.blocks).forEach(([oldId, block]) => { - const newId = uuidv4() - blockIdMap.set(oldId, newId) - newBlocks[newId] = { - ...block, - id: newId, - } - }) - - // Second pass: update edges with new block IDs - const newEdges = workflowState.edges.map((edge) => ({ - ...edge, - id: uuidv4(), // Generate new edge ID - source: blockIdMap.get(edge.source) || edge.source, - target: blockIdMap.get(edge.target) || edge.target, - })) - - // Third pass: update loops with new block IDs - // CRITICAL: Loop IDs must match their block IDs (loops are keyed by their block ID) - const newLoops: WorkflowState['loops'] = {} - if (workflowState.loops) { - Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => { - // Map the loop ID using the block ID mapping (loop ID = block ID) - const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId - newLoops[newLoopId] = { - ...loop, - id: newLoopId, - nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId), - } - }) - } - - // Fourth pass: update parallels with new block IDs - // CRITICAL: Parallel IDs must match their block IDs (parallels are keyed by their block ID) - const newParallels: WorkflowState['parallels'] = {} - if (workflowState.parallels) { - Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => { - // Map the parallel ID using the block ID mapping (parallel ID = block ID) - const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId - newParallels[newParallelId] = { - ...parallel, - id: newParallelId, - nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId), - } - }) - } - - // Fifth pass: update any block references in subblock values and clear runtime trigger values - Object.entries(newBlocks).forEach(([blockId, block]) => { - if (block.subBlocks) { - Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { - if (TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) { - block.subBlocks[subBlockId] = { - ...subBlock, - value: null, - } - return - } - - if (subBlock.value && typeof subBlock.value === 'string') { - // Replace any block references in the value - let updatedValue = subBlock.value - blockIdMap.forEach((newId, oldId) => { - // Replace references like with new IDs - const regex = new RegExp(`<${oldId}\\.`, 'g') - updatedValue = updatedValue.replace(regex, `<${newId}.`) - }) - block.subBlocks[subBlockId] = { - ...subBlock, - value: updatedValue, - } - } - }) - } - - // Update parentId references in block.data - if (block.data?.parentId) { - const newParentId = blockIdMap.get(block.data.parentId) - if (newParentId) { - block.data.parentId = newParentId - } else { - // Parent ID not in mapping - this shouldn't happen but log it - logger.warn(`Block ${blockId} references unmapped parent ${block.data.parentId}`) - // Remove invalid parent reference - block.data.parentId = undefined - block.data.extent = undefined - } - } - }) - - return { - blocks: newBlocks, - edges: newEdges, - loops: newLoops, - parallels: newParallels, - metadata, - variables, - } -} - /** * Normalize subblock values by converting empty strings to null. * This provides backwards compatibility for workflows exported before the null sanitization fix, @@ -260,9 +150,10 @@ export function parseWorkflowJson( variables: Array.isArray(workflowData.variables) ? workflowData.variables : undefined, } - // Regenerate IDs if requested (default: true) if (regenerateIdsFlag) { - const regeneratedState = regenerateIds(workflowState) + const { idMap: _, ...regeneratedState } = regenerateWorkflowIds(workflowState, { + clearTriggerRuntimeValues: true, + }) workflowState = { ...regeneratedState, metadata: workflowState.metadata, diff --git a/apps/sim/stores/workflows/registry/store.ts b/apps/sim/stores/workflows/registry/store.ts index a531207cf..3c1c03987 100644 --- a/apps/sim/stores/workflows/registry/store.ts +++ b/apps/sim/stores/workflows/registry/store.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { create } from 'zustand' import { devtools } from 'zustand/middleware' import { withOptimisticUpdate } from '@/lib/core/utils/optimistic-update' +import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' import { useVariablesStore } from '@/stores/panel/variables/store' import type { @@ -12,7 +13,9 @@ import type { } from '@/stores/workflows/registry/types' import { getNextWorkflowColor } from '@/stores/workflows/registry/utils' import { useSubBlockStore } from '@/stores/workflows/subblock/store' +import { getUniqueBlockName, regenerateBlockIds } from '@/stores/workflows/utils' import { useWorkflowStore } from '@/stores/workflows/workflow/store' +import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowRegistry') const initialHydration: HydrationState = { @@ -70,12 +73,12 @@ function setWorkspaceTransitioning(isTransitioning: boolean): void { export const useWorkflowRegistry = create()( devtools( (set, get) => ({ - // Store state workflows: {}, activeWorkflowId: null, error: null, deploymentStatuses: {}, hydration: initialHydration, + clipboard: null, beginMetadataLoad: (workspaceId: string) => { set((state) => ({ @@ -772,10 +775,104 @@ export const useWorkflowRegistry = create()( deploymentStatuses: {}, error: null, hydration: initialHydration, + clipboard: null, }) logger.info('Logout complete - all workflow data cleared') }, + + copyBlocks: (blockIds: string[]) => { + if (blockIds.length === 0) return + + const workflowStore = useWorkflowStore.getState() + const activeWorkflowId = get().activeWorkflowId + const subBlockStore = useSubBlockStore.getState() + + const copiedBlocks: Record = {} + const copiedSubBlockValues: Record> = {} + const blockIdSet = new Set(blockIds) + + // Auto-include nested nodes from selected subflows + blockIds.forEach((blockId) => { + const loop = workflowStore.loops[blockId] + if (loop?.nodes) loop.nodes.forEach((n) => blockIdSet.add(n)) + const parallel = workflowStore.parallels[blockId] + if (parallel?.nodes) parallel.nodes.forEach((n) => blockIdSet.add(n)) + }) + + blockIdSet.forEach((blockId) => { + const block = workflowStore.blocks[blockId] + if (block) { + copiedBlocks[blockId] = JSON.parse(JSON.stringify(block)) + if (activeWorkflowId) { + const blockValues = subBlockStore.workflowValues[activeWorkflowId]?.[blockId] + if (blockValues) { + copiedSubBlockValues[blockId] = JSON.parse(JSON.stringify(blockValues)) + } + } + } + }) + + const copiedEdges = workflowStore.edges.filter( + (edge) => blockIdSet.has(edge.source) && blockIdSet.has(edge.target) + ) + + const copiedLoops: Record = {} + Object.entries(workflowStore.loops).forEach(([loopId, loop]) => { + if (blockIdSet.has(loopId)) { + copiedLoops[loopId] = JSON.parse(JSON.stringify(loop)) + } + }) + + const copiedParallels: Record = {} + Object.entries(workflowStore.parallels).forEach(([parallelId, parallel]) => { + if (blockIdSet.has(parallelId)) { + copiedParallels[parallelId] = JSON.parse(JSON.stringify(parallel)) + } + }) + + set({ + clipboard: { + blocks: copiedBlocks, + edges: copiedEdges, + subBlockValues: copiedSubBlockValues, + loops: copiedLoops, + parallels: copiedParallels, + timestamp: Date.now(), + }, + }) + + logger.info('Copied blocks to clipboard', { count: Object.keys(copiedBlocks).length }) + }, + + preparePasteData: (positionOffset = DEFAULT_DUPLICATE_OFFSET) => { + const { clipboard, activeWorkflowId } = get() + if (!clipboard || Object.keys(clipboard.blocks).length === 0) return null + if (!activeWorkflowId) return null + + const workflowStore = useWorkflowStore.getState() + const { blocks, edges, loops, parallels, subBlockValues } = regenerateBlockIds( + clipboard.blocks, + clipboard.edges, + clipboard.loops, + clipboard.parallels, + clipboard.subBlockValues, + positionOffset, + workflowStore.blocks, + getUniqueBlockName + ) + + return { blocks, edges, loops, parallels, subBlockValues } + }, + + hasClipboard: () => { + const { clipboard } = get() + return clipboard !== null && Object.keys(clipboard.blocks).length > 0 + }, + + clearClipboard: () => { + set({ clipboard: null }) + }, }), { name: 'workflow-registry' } ) diff --git a/apps/sim/stores/workflows/registry/types.ts b/apps/sim/stores/workflows/registry/types.ts index 5db4999bc..80180a451 100644 --- a/apps/sim/stores/workflows/registry/types.ts +++ b/apps/sim/stores/workflows/registry/types.ts @@ -1,3 +1,6 @@ +import type { Edge } from 'reactflow' +import type { BlockState, Loop, Parallel } from '@/stores/workflows/workflow/types' + export interface DeploymentStatus { isDeployed: boolean deployedAt?: Date @@ -5,6 +8,15 @@ export interface DeploymentStatus { needsRedeployment?: boolean } +export interface ClipboardData { + blocks: Record + edges: Edge[] + subBlockValues: Record> + loops: Record + parallels: Record + timestamp: number +} + export interface WorkflowMetadata { id: string name: string @@ -38,6 +50,7 @@ export interface WorkflowRegistryState { error: string | null deploymentStatuses: Record hydration: HydrationState + clipboard: ClipboardData | null } export interface WorkflowRegistryActions { @@ -58,6 +71,17 @@ export interface WorkflowRegistryActions { apiKey?: string ) => void setWorkflowNeedsRedeployment: (workflowId: string | null, needsRedeployment: boolean) => void + copyBlocks: (blockIds: string[]) => void + preparePasteData: (positionOffset?: { x: number; y: number }) => { + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + subBlockValues: Record> + } | null + hasClipboard: () => boolean + clearClipboard: () => void + logout: () => void } export type WorkflowRegistry = WorkflowRegistryState & WorkflowRegistryActions diff --git a/apps/sim/stores/workflows/utils.ts b/apps/sim/stores/workflows/utils.ts index a0da2e3e7..2eb2c4618 100644 --- a/apps/sim/stores/workflows/utils.ts +++ b/apps/sim/stores/workflows/utils.ts @@ -1,9 +1,31 @@ +import type { Edge } from 'reactflow' +import { v4 as uuidv4 } from 'uuid' +import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' +import { getBlock } from '@/blocks' import { normalizeName } from '@/executor/constants' import { useSubBlockStore } from '@/stores/workflows/subblock/store' -import type { BlockState, SubBlockState } from '@/stores/workflows/workflow/types' +import type { + BlockState, + Loop, + Parallel, + Position, + SubBlockState, + WorkflowState, +} from '@/stores/workflows/workflow/types' +import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants' + +const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath'] export { normalizeName } +export interface RegeneratedState { + blocks: Record + edges: Edge[] + loops: Record + parallels: Record + idMap: Map +} + /** * Generates a unique block name by finding the highest number suffix among existing blocks * with the same base name and incrementing it @@ -44,6 +66,166 @@ export function getUniqueBlockName(baseName: string, existingBlocks: Record + parentId?: string + extent?: 'parent' + triggerMode?: boolean +} + +/** + * Prepares a BlockState object from block type and configuration. + * Generates subBlocks and outputs from the block registry. + */ +export function prepareBlockState(options: PrepareBlockStateOptions): BlockState { + const { id, type, name, position, data, parentId, extent, triggerMode = false } = options + + const blockConfig = getBlock(type) + + const blockData: Record = { ...(data || {}) } + if (parentId) blockData.parentId = parentId + if (extent) blockData.extent = extent + + if (!blockConfig) { + return { + id, + type, + name, + position, + data: blockData, + subBlocks: {}, + outputs: {}, + enabled: true, + horizontalHandles: true, + advancedMode: false, + triggerMode, + height: 0, + } + } + + const subBlocks: Record = {} + + if (blockConfig.subBlocks) { + blockConfig.subBlocks.forEach((subBlock) => { + let initialValue: unknown = null + + if (typeof subBlock.value === 'function') { + try { + initialValue = subBlock.value({}) + } catch { + initialValue = null + } + } else if (subBlock.defaultValue !== undefined) { + initialValue = subBlock.defaultValue + } else if (subBlock.type === 'input-format') { + initialValue = [ + { + id: crypto.randomUUID(), + name: '', + type: 'string', + value: '', + collapsed: false, + }, + ] + } else if (subBlock.type === 'table') { + initialValue = [] + } + + subBlocks[subBlock.id] = { + id: subBlock.id, + type: subBlock.type, + value: initialValue as SubBlockState['value'], + } + }) + } + + const outputs = getBlockOutputs(type, subBlocks, triggerMode) + + return { + id, + type, + name, + position, + data: blockData, + subBlocks, + outputs, + enabled: true, + horizontalHandles: true, + advancedMode: false, + triggerMode, + height: 0, + } +} + +export interface PrepareDuplicateBlockStateOptions { + sourceBlock: BlockState + newId: string + newName: string + positionOffset: { x: number; y: number } + subBlockValues: Record +} + +/** + * Prepares a BlockState for duplicating an existing block. + * Copies block structure and subblock values, excluding webhook fields. + */ +export function prepareDuplicateBlockState(options: PrepareDuplicateBlockStateOptions): { + block: BlockState + subBlockValues: Record +} { + const { sourceBlock, newId, newName, positionOffset, subBlockValues } = options + + const filteredSubBlockValues = Object.fromEntries( + Object.entries(subBlockValues).filter(([key]) => !WEBHOOK_SUBBLOCK_FIELDS.includes(key)) + ) + + const mergedSubBlocks: Record = sourceBlock.subBlocks + ? JSON.parse(JSON.stringify(sourceBlock.subBlocks)) + : {} + + WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => { + if (field in mergedSubBlocks) { + delete mergedSubBlocks[field] + } + }) + + Object.entries(filteredSubBlockValues).forEach(([subblockId, value]) => { + if (mergedSubBlocks[subblockId]) { + mergedSubBlocks[subblockId].value = value as SubBlockState['value'] + } else { + mergedSubBlocks[subblockId] = { + id: subblockId, + type: 'short-input', + value: value as SubBlockState['value'], + } + } + }) + + const block: BlockState = { + id: newId, + type: sourceBlock.type, + name: newName, + position: { + x: sourceBlock.position.x + positionOffset.x, + y: sourceBlock.position.y + positionOffset.y, + }, + data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {}, + subBlocks: mergedSubBlocks, + outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {}, + enabled: sourceBlock.enabled ?? true, + horizontalHandles: sourceBlock.horizontalHandles ?? true, + advancedMode: sourceBlock.advancedMode ?? false, + triggerMode: sourceBlock.triggerMode ?? false, + height: sourceBlock.height || 0, + } + + return { block, subBlockValues: filteredSubBlockValues } +} + /** * Merges workflow block states with subblock values while maintaining block structure * @param blocks - Block configurations from workflow store @@ -211,6 +393,217 @@ export async function mergeSubblockStateAsync( }) ) - // Convert entries back to an object return Object.fromEntries(processedBlockEntries) as Record } + +function updateValueReferences(value: unknown, nameMap: Map): unknown { + if (typeof value === 'string') { + let updatedValue = value + nameMap.forEach((newName, oldName) => { + const regex = new RegExp(`<${oldName}\\.`, 'g') + updatedValue = updatedValue.replace(regex, `<${newName}.`) + }) + return updatedValue + } + if (Array.isArray(value)) { + return value.map((item) => updateValueReferences(item, nameMap)) + } + if (value && typeof value === 'object') { + const result: Record = {} + for (const [key, val] of Object.entries(value)) { + result[key] = updateValueReferences(val, nameMap) + } + return result + } + return value +} + +function updateBlockReferences( + blocks: Record, + idMap: Map, + nameMap: Map, + clearTriggerRuntimeValues = false +): void { + Object.entries(blocks).forEach(([_, block]) => { + if (block.data?.parentId) { + const newParentId = idMap.get(block.data.parentId) + if (newParentId) { + block.data = { ...block.data, parentId: newParentId } + } else { + block.data = { ...block.data, parentId: undefined, extent: undefined } + } + } + + if (block.subBlocks) { + Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => { + if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) { + block.subBlocks[subBlockId] = { ...subBlock, value: null } + return + } + + if (subBlock.value !== undefined && subBlock.value !== null) { + const updatedValue = updateValueReferences( + subBlock.value, + nameMap + ) as SubBlockState['value'] + block.subBlocks[subBlockId] = { ...subBlock, value: updatedValue } + } + }) + } + }) +} + +export function regenerateWorkflowIds( + workflowState: WorkflowState, + options: { clearTriggerRuntimeValues?: boolean } = {} +): WorkflowState & { idMap: Map } { + const { clearTriggerRuntimeValues = true } = options + const blockIdMap = new Map() + const nameMap = new Map() + const newBlocks: Record = {} + + Object.entries(workflowState.blocks).forEach(([oldId, block]) => { + const newId = uuidv4() + blockIdMap.set(oldId, newId) + const oldNormalizedName = normalizeName(block.name) + nameMap.set(oldNormalizedName, oldNormalizedName) + newBlocks[newId] = { ...block, id: newId } + }) + + const newEdges = workflowState.edges.map((edge) => ({ + ...edge, + id: uuidv4(), + source: blockIdMap.get(edge.source) || edge.source, + target: blockIdMap.get(edge.target) || edge.target, + })) + + const newLoops: Record = {} + if (workflowState.loops) { + Object.entries(workflowState.loops).forEach(([oldLoopId, loop]) => { + const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId + newLoops[newLoopId] = { + ...loop, + id: newLoopId, + nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId), + } + }) + } + + const newParallels: Record = {} + if (workflowState.parallels) { + Object.entries(workflowState.parallels).forEach(([oldParallelId, parallel]) => { + const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId + newParallels[newParallelId] = { + ...parallel, + id: newParallelId, + nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId), + } + }) + } + + updateBlockReferences(newBlocks, blockIdMap, nameMap, clearTriggerRuntimeValues) + + return { + blocks: newBlocks, + edges: newEdges, + loops: newLoops, + parallels: newParallels, + metadata: workflowState.metadata, + variables: workflowState.variables, + idMap: blockIdMap, + } +} + +export function regenerateBlockIds( + blocks: Record, + edges: Edge[], + loops: Record, + parallels: Record, + subBlockValues: Record>, + positionOffset: { x: number; y: number }, + existingBlockNames: Record, + uniqueNameFn: (name: string, blocks: Record) => string +): RegeneratedState & { subBlockValues: Record> } { + const blockIdMap = new Map() + const nameMap = new Map() + const newBlocks: Record = {} + const newSubBlockValues: Record> = {} + + // Track all blocks for name uniqueness (existing + newly processed) + const allBlocksForNaming = { ...existingBlockNames } + + Object.entries(blocks).forEach(([oldId, block]) => { + const newId = uuidv4() + blockIdMap.set(oldId, newId) + + const oldNormalizedName = normalizeName(block.name) + const newName = uniqueNameFn(block.name, allBlocksForNaming) + const newNormalizedName = normalizeName(newName) + nameMap.set(oldNormalizedName, newNormalizedName) + + const isNested = !!block.data?.parentId + const newBlock: BlockState = { + ...block, + id: newId, + name: newName, + position: isNested + ? block.position + : { + x: block.position.x + positionOffset.x, + y: block.position.y + positionOffset.y, + }, + } + + newBlocks[newId] = newBlock + // Add to tracking so next block gets unique name + allBlocksForNaming[newId] = newBlock + + if (subBlockValues[oldId]) { + newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId])) + } + }) + + const newEdges = edges.map((edge) => ({ + ...edge, + id: uuidv4(), + source: blockIdMap.get(edge.source) || edge.source, + target: blockIdMap.get(edge.target) || edge.target, + })) + + const newLoops: Record = {} + Object.entries(loops).forEach(([oldLoopId, loop]) => { + const newLoopId = blockIdMap.get(oldLoopId) || oldLoopId + newLoops[newLoopId] = { + ...loop, + id: newLoopId, + nodes: loop.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId), + } + }) + + const newParallels: Record = {} + Object.entries(parallels).forEach(([oldParallelId, parallel]) => { + const newParallelId = blockIdMap.get(oldParallelId) || oldParallelId + newParallels[newParallelId] = { + ...parallel, + id: newParallelId, + nodes: parallel.nodes.map((nodeId) => blockIdMap.get(nodeId) || nodeId), + } + }) + + updateBlockReferences(newBlocks, blockIdMap, nameMap, false) + + Object.entries(newSubBlockValues).forEach(([_, blockValues]) => { + Object.keys(blockValues).forEach((subBlockId) => { + blockValues[subBlockId] = updateValueReferences(blockValues[subBlockId], nameMap) + }) + }) + + return { + blocks: newBlocks, + edges: newEdges, + loops: newLoops, + parallels: newParallels, + subBlockValues: newSubBlockValues, + idMap: blockIdMap, + } +} diff --git a/packages/testing/src/factories/index.ts b/packages/testing/src/factories/index.ts index 4df0ac4c8..4a7f456b1 100644 --- a/packages/testing/src/factories/index.ts +++ b/packages/testing/src/factories/index.ts @@ -120,22 +120,20 @@ export { } from './serialized-block.factory' // Undo/redo operation factories export { - type AddBlockOperation, type AddEdgeOperation, type BaseOperation, + type BatchAddBlocksOperation, + type BatchRemoveBlocksOperation, createAddBlockEntry, createAddEdgeEntry, - createDuplicateBlockEntry, createMoveBlockEntry, createRemoveBlockEntry, createRemoveEdgeEntry, createUpdateParentEntry, - type DuplicateBlockOperation, type MoveBlockOperation, type Operation, type OperationEntry, type OperationType, - type RemoveBlockOperation, type RemoveEdgeOperation, type UpdateParentOperation, } from './undo-redo.factory' diff --git a/packages/testing/src/factories/permission.factory.ts b/packages/testing/src/factories/permission.factory.ts index 1a5ee59f8..2e8f840b9 100644 --- a/packages/testing/src/factories/permission.factory.ts +++ b/packages/testing/src/factories/permission.factory.ts @@ -257,6 +257,8 @@ export function createWorkflowAccessContext(options: { export const SOCKET_OPERATIONS = [ 'add', 'remove', + 'batch-add-blocks', + 'batch-remove-blocks', 'update', 'update-position', 'update-name', @@ -266,7 +268,7 @@ export const SOCKET_OPERATIONS = [ 'update-advanced-mode', 'update-trigger-mode', 'toggle-handles', - 'duplicate', + 'batch-update-positions', 'replace-state', ] as const @@ -278,7 +280,7 @@ export type SocketOperation = (typeof SOCKET_OPERATIONS)[number] export const ROLE_ALLOWED_OPERATIONS: Record = { admin: [...SOCKET_OPERATIONS], write: [...SOCKET_OPERATIONS], - read: ['update-position'], + read: ['update-position', 'batch-update-positions'], } /** diff --git a/packages/testing/src/factories/undo-redo.factory.ts b/packages/testing/src/factories/undo-redo.factory.ts index 76e903c34..d03c8cefe 100644 --- a/packages/testing/src/factories/undo-redo.factory.ts +++ b/packages/testing/src/factories/undo-redo.factory.ts @@ -6,12 +6,11 @@ import { nanoid } from 'nanoid' * Operation types supported by the undo/redo store. */ export type OperationType = - | 'add-block' - | 'remove-block' + | 'batch-add-blocks' + | 'batch-remove-blocks' | 'add-edge' | 'remove-edge' | 'move-block' - | 'duplicate-block' | 'update-parent' /** @@ -38,22 +37,26 @@ export interface MoveBlockOperation extends BaseOperation { } /** - * Add block operation data. + * Batch add blocks operation data. */ -export interface AddBlockOperation extends BaseOperation { - type: 'add-block' - data: { blockId: string } +export interface BatchAddBlocksOperation extends BaseOperation { + type: 'batch-add-blocks' + data: { + blockSnapshots: any[] + edgeSnapshots: any[] + subBlockValues: Record> + } } /** - * Remove block operation data. + * Batch remove blocks operation data. */ -export interface RemoveBlockOperation extends BaseOperation { - type: 'remove-block' +export interface BatchRemoveBlocksOperation extends BaseOperation { + type: 'batch-remove-blocks' data: { - blockId: string - blockSnapshot: any - edgeSnapshots?: any[] + blockSnapshots: any[] + edgeSnapshots: any[] + subBlockValues: Record> } } @@ -73,18 +76,6 @@ export interface RemoveEdgeOperation extends BaseOperation { data: { edgeId: string; edgeSnapshot: any } } -/** - * Duplicate block operation data. - */ -export interface DuplicateBlockOperation extends BaseOperation { - type: 'duplicate-block' - data: { - sourceBlockId: string - duplicatedBlockId: string - duplicatedBlockSnapshot: any - } -} - /** * Update parent operation data. */ @@ -100,12 +91,11 @@ export interface UpdateParentOperation extends BaseOperation { } export type Operation = - | AddBlockOperation - | RemoveBlockOperation + | BatchAddBlocksOperation + | BatchRemoveBlocksOperation | AddEdgeOperation | RemoveEdgeOperation | MoveBlockOperation - | DuplicateBlockOperation | UpdateParentOperation /** @@ -126,36 +116,51 @@ interface OperationEntryOptions { } /** - * Creates a mock add-block operation entry. + * Creates a mock batch-add-blocks operation entry. */ export function createAddBlockEntry(blockId: string, options: OperationEntryOptions = {}): any { const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options const timestamp = Date.now() + const mockBlockSnapshot = { + id: blockId, + type: 'action', + name: `Block ${blockId}`, + position: { x: 0, y: 0 }, + } + return { id, createdAt, operation: { id: nanoid(8), - type: 'add-block', + type: 'batch-add-blocks', timestamp, workflowId, userId, - data: { blockId }, + data: { + blockSnapshots: [mockBlockSnapshot], + edgeSnapshots: [], + subBlockValues: {}, + }, }, inverse: { id: nanoid(8), - type: 'remove-block', + type: 'batch-remove-blocks', timestamp, workflowId, userId, - data: { blockId, blockSnapshot: null }, + data: { + blockSnapshots: [mockBlockSnapshot], + edgeSnapshots: [], + subBlockValues: {}, + }, }, } } /** - * Creates a mock remove-block operation entry. + * Creates a mock batch-remove-blocks operation entry. */ export function createRemoveBlockEntry( blockId: string, @@ -165,24 +170,39 @@ export function createRemoveBlockEntry( const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options const timestamp = Date.now() + const snapshotToUse = blockSnapshot || { + id: blockId, + type: 'action', + name: `Block ${blockId}`, + position: { x: 0, y: 0 }, + } + return { id, createdAt, operation: { id: nanoid(8), - type: 'remove-block', + type: 'batch-remove-blocks', timestamp, workflowId, userId, - data: { blockId, blockSnapshot }, + data: { + blockSnapshots: [snapshotToUse], + edgeSnapshots: [], + subBlockValues: {}, + }, }, inverse: { id: nanoid(8), - type: 'add-block', + type: 'batch-add-blocks', timestamp, workflowId, userId, - data: { blockId }, + data: { + blockSnapshots: [snapshotToUse], + edgeSnapshots: [], + subBlockValues: {}, + }, }, } } @@ -290,40 +310,6 @@ export function createMoveBlockEntry(blockId: string, options: MoveBlockOptions } } -/** - * Creates a mock duplicate-block operation entry. - */ -export function createDuplicateBlockEntry( - sourceBlockId: string, - duplicatedBlockId: string, - duplicatedBlockSnapshot: any, - options: OperationEntryOptions = {} -): any { - const { id = nanoid(8), workflowId = 'wf-1', userId = 'user-1', createdAt = Date.now() } = options - const timestamp = Date.now() - - return { - id, - createdAt, - operation: { - id: nanoid(8), - type: 'duplicate-block', - timestamp, - workflowId, - userId, - data: { sourceBlockId, duplicatedBlockId, duplicatedBlockSnapshot }, - }, - inverse: { - id: nanoid(8), - type: 'remove-block', - timestamp, - workflowId, - userId, - data: { blockId: duplicatedBlockId, blockSnapshot: duplicatedBlockSnapshot }, - }, - } -} - /** * Creates a mock update-parent operation entry. */