diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx index 9801a4f57e..a37a331165 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/condition-input/condition-input.tsx @@ -156,7 +156,7 @@ export function ConditionInput({ [key: string]: number[] }>({}) const updateNodeInternals = useUpdateNodeInternals() - const removeEdge = useWorkflowStore((state) => state.removeEdge) + const batchRemoveEdges = useWorkflowStore((state) => state.batchRemoveEdges) const edges = useWorkflowStore((state) => state.edges) const prevStoreValueRef = useRef(null) @@ -657,11 +657,12 @@ export function ConditionInput({ if (isPreview || disabled || conditionalBlocks.length <= 2) return // Remove any associated edges before removing the block - edges.forEach((edge) => { - if (edge.sourceHandle?.startsWith(`condition-${id}`)) { - removeEdge(edge.id) - } - }) + const edgeIdsToRemove = edges + .filter((edge) => edge.sourceHandle?.startsWith(`condition-${id}`)) + .map((edge) => edge.id) + if (edgeIdsToRemove.length > 0) { + batchRemoveEdges(edgeIdsToRemove) + } if (conditionalBlocks.length === 1) return shouldPersistRef.current = true diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts index ffa148d881..d90317ea7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-node-utilities.ts @@ -373,16 +373,20 @@ export function useNodeUtilities(blocks: Record) { * Updates a node's parent with proper position calculation * @param nodeId ID of the node being reparented * @param newParentId ID of the new parent (or null to remove parent) - * @param updateBlockPosition Function to update the position of a block - * @param updateParentId Function to update the parent ID of a block + * @param batchUpdatePositions Function to batch update positions of blocks + * @param batchUpdateBlocksWithParent Function to batch update blocks with parent info * @param resizeCallback Function to resize loop nodes after parent update */ const updateNodeParent = useCallback( ( nodeId: string, newParentId: string | null, - updateBlockPosition: (id: string, position: { x: number; y: number }) => void, - updateParentId: (id: string, parentId: string, extent: 'parent') => void, + batchUpdatePositions: ( + updates: Array<{ id: string; position: { x: number; y: number } }> + ) => void, + batchUpdateBlocksWithParent: ( + updates: Array<{ id: string; position: { x: number; y: number }; parentId?: string }> + ) => void, resizeCallback: () => void ) => { const node = getNodes().find((n) => n.id === nodeId) @@ -394,15 +398,15 @@ export function useNodeUtilities(blocks: Record) { if (newParentId) { const relativePosition = calculateRelativePosition(nodeId, newParentId) - updateBlockPosition(nodeId, relativePosition) - updateParentId(nodeId, newParentId, 'parent') + batchUpdatePositions([{ id: nodeId, position: relativePosition }]) + batchUpdateBlocksWithParent([ + { id: nodeId, position: relativePosition, parentId: newParentId }, + ]) } else if (currentParentId) { const absolutePosition = getNodeAbsolutePosition(nodeId) - // First set the absolute position so the node visually stays in place - updateBlockPosition(nodeId, absolutePosition) - // Then clear the parent relationship in the store (empty string removes parentId/extent) - updateParentId(nodeId, '', 'parent') + batchUpdatePositions([{ id: nodeId, position: absolutePosition }]) + batchUpdateBlocksWithParent([{ id: nodeId, position: absolutePosition, parentId: '' }]) } resizeCallback() diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx index 876ee009a4..c179bd3526 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx @@ -443,11 +443,9 @@ const WorkflowContent = React.memo(() => { }, [userPermissions, currentWorkflow.isSnapshotView]) const { - collaborativeAddEdge: addEdge, - collaborativeRemoveEdge: removeEdge, + collaborativeBatchAddEdges, collaborativeBatchRemoveEdges, collaborativeBatchUpdatePositions, - collaborativeUpdateParentId: updateParentId, collaborativeBatchUpdateParent, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, @@ -464,6 +462,34 @@ const WorkflowContent = React.memo(() => { [collaborativeBatchUpdatePositions] ) + const addEdge = useCallback( + (edge: Edge) => { + collaborativeBatchAddEdges([edge]) + }, + [collaborativeBatchAddEdges] + ) + + const removeEdge = useCallback( + (edgeId: string) => { + collaborativeBatchRemoveEdges([edgeId]) + }, + [collaborativeBatchRemoveEdges] + ) + + const batchUpdateBlocksWithParent = useCallback( + (updates: Array<{ id: string; position: { x: number; y: number }; parentId?: string }>) => { + collaborativeBatchUpdateParent( + updates.map((u) => ({ + blockId: u.id, + newParentId: u.parentId || null, + newPosition: u.position, + affectedEdges: [], + })) + ) + }, + [collaborativeBatchUpdateParent] + ) + const addBlock = useCallback( ( id: string, @@ -570,8 +596,8 @@ const WorkflowContent = React.memo(() => { const result = updateNodeParentUtil( nodeId, newParentId, - updateBlockPosition, - updateParentId, + collaborativeBatchUpdatePositions, + batchUpdateBlocksWithParent, () => resizeLoopNodesWrapper() ) @@ -594,8 +620,8 @@ const WorkflowContent = React.memo(() => { }, [ getNodes, - updateBlockPosition, - updateParentId, + collaborativeBatchUpdatePositions, + batchUpdateBlocksWithParent, blocks, edgesForDisplay, getNodeAbsolutePosition, @@ -903,22 +929,15 @@ const WorkflowContent = React.memo(() => { (blockId: string, edgesToRemove: Edge[]): void => { if (edgesToRemove.length === 0) return - window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } })) - - try { - edgesToRemove.forEach((edge) => { - removeEdge(edge.id) - }) + const edgeIds = edgesToRemove.map((edge) => edge.id) + collaborativeBatchRemoveEdges(edgeIds, { skipUndoRedo: true }) - logger.debug('Removed edges for node', { - blockId, - edgeCount: edgesToRemove.length, - }) - } finally { - window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } })) - } + logger.debug('Removed edges for node', { + blockId, + edgeCount: edgesToRemove.length, + }) }, - [removeEdge] + [collaborativeBatchRemoveEdges] ) /** Finds the closest block to a position for auto-connect. */ @@ -1942,27 +1961,37 @@ const WorkflowContent = React.memo(() => { const movingNodeIds = new Set(validBlockIds) + // Find boundary edges (edges that cross the subflow boundary) const boundaryEdges = edgesForDisplay.filter((e) => { const sourceInSelection = movingNodeIds.has(e.source) const targetInSelection = movingNodeIds.has(e.target) return sourceInSelection !== targetInSelection }) - // Collect absolute positions BEFORE updating parents + // Collect absolute positions BEFORE any mutations const absolutePositions = new Map() for (const blockId of validBlockIds) { absolutePositions.set(blockId, getNodeAbsolutePosition(blockId)) } - for (const blockId of validBlockIds) { + // Build batch update with all blocks and their affected edges + const updates = validBlockIds.map((blockId) => { + const absolutePosition = absolutePositions.get(blockId)! const edgesForThisNode = boundaryEdges.filter( (e) => e.source === blockId || e.target === blockId ) - removeEdgesForNode(blockId, edgesForThisNode) - updateNodeParent(blockId, null, edgesForThisNode) - } + return { + blockId, + newParentId: null, + newPosition: absolutePosition, + affectedEdges: edgesForThisNode, + } + }) - // Immediately update displayNodes to prevent React Flow from using stale parent data + // Single atomic batch update (handles edge removal + parent update + undo/redo) + collaborativeBatchUpdateParent(updates) + + // Update displayNodes once to prevent React Flow from using stale parent data setDisplayNodes((nodes) => nodes.map((n) => { const absPos = absolutePositions.get(n.id) @@ -1977,6 +2006,8 @@ const WorkflowContent = React.memo(() => { return n }) ) + + // Note: Container resize happens automatically via the derivedNodes effect } catch (err) { logger.error('Failed to remove from subflow', { err }) } @@ -1985,7 +2016,7 @@ const WorkflowContent = React.memo(() => { window.addEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) return () => window.removeEventListener('remove-from-subflow', handleRemoveFromSubflow as EventListener) - }, [blocks, edgesForDisplay, removeEdgesForNode, updateNodeParent, getNodeAbsolutePosition]) + }, [blocks, edgesForDisplay, getNodeAbsolutePosition, collaborativeBatchUpdateParent]) /** Handles node position changes - updates local state for smooth drag, syncs to store only on drag end. */ const onNodesChange = useCallback((changes: NodeChange[]) => { @@ -2072,7 +2103,12 @@ const WorkflowContent = React.memo(() => { // Create a mapping of node IDs to check for missing parent references const nodeIds = new Set(Object.keys(blocks)) - // Check for nodes with invalid parent references + // Check for nodes with invalid parent references and collect updates + const orphanedUpdates: Array<{ + id: string + position: { x: number; y: number } + parentId: string + }> = [] Object.entries(blocks).forEach(([id, block]) => { const parentId = block.data?.parentId @@ -2084,22 +2120,28 @@ const WorkflowContent = React.memo(() => { }) const absolutePosition = getNodeAbsolutePosition(id) - updateBlockPosition(id, absolutePosition) - updateParentId(id, '', 'parent') + orphanedUpdates.push({ id, position: absolutePosition, parentId: '' }) } }) - }, [blocks, updateBlockPosition, updateParentId, getNodeAbsolutePosition, isWorkflowReady]) + + // Batch update all orphaned nodes at once + if (orphanedUpdates.length > 0) { + batchUpdateBlocksWithParent(orphanedUpdates) + } + }, [blocks, batchUpdateBlocksWithParent, getNodeAbsolutePosition, isWorkflowReady]) /** Handles edge removal changes. */ const onEdgesChange = useCallback( (changes: any) => { - changes.forEach((change: any) => { - if (change.type === 'remove') { - removeEdge(change.id) - } - }) + const edgeIdsToRemove = changes + .filter((change: any) => change.type === 'remove') + .map((change: any) => change.id) + + if (edgeIdsToRemove.length > 0) { + collaborativeBatchRemoveEdges(edgeIdsToRemove) + } }, - [removeEdge] + [collaborativeBatchRemoveEdges] ) /** @@ -2683,9 +2725,6 @@ const WorkflowContent = React.memo(() => { const edgesToAdd: Edge[] = autoConnectEdge ? [autoConnectEdge] : [] - // Skip recording these edges separately since they're part of the parent update - window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: true } })) - // Moving to a new parent container - pass both removed and added edges for undo/redo const affectedEdges = [...edgesToRemove, ...edgesToAdd] updateNodeParent(node.id, potentialParentId, affectedEdges) @@ -2704,10 +2743,10 @@ const WorkflowContent = React.memo(() => { }) ) - // Now add the edges after parent update - edgesToAdd.forEach((edge) => addEdge(edge)) - - window.dispatchEvent(new CustomEvent('skip-edge-recording', { detail: { skip: false } })) + // Add edges after parent update (skip undo recording - it's part of parent update) + if (edgesToAdd.length > 0) { + collaborativeBatchAddEdges(edgesToAdd, { skipUndoRedo: true }) + } } else if (!potentialParentId && dragStartParentId) { // Moving OUT of a subflow to canvas // Get absolute position BEFORE removing from parent @@ -2761,7 +2800,7 @@ const WorkflowContent = React.memo(() => { potentialParentId, updateNodeParent, updateBlockPosition, - addEdge, + collaborativeBatchAddEdges, tryCreateAutoConnectEdge, blocks, edgesForDisplay, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx index 86d65cd038..63c17c3af5 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/access-control/access-control.tsx @@ -25,8 +25,8 @@ import { Input as BaseInput, Skeleton } from '@/components/ui' import { useSession } from '@/lib/auth/auth-client' import { getSubscriptionStatus } from '@/lib/billing/client' import type { PermissionGroupConfig } from '@/lib/permission-groups/types' +import { getUserColor } from '@/lib/workspaces/colors' import { getUserRole } from '@/lib/workspaces/organization' -import { getUserColor } from '@/app/workspace/[workspaceId]/w/utils/get-user-color' import { getAllBlocks } from '@/blocks' import { useOrganization, useOrganizations } from '@/hooks/queries/organization' import { diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index 5b46caf03f..f06d09d8dc 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -2,14 +2,12 @@ import { useCallback, useEffect, useRef } from 'react' import { createLogger } from '@sim/logger' import type { Edge } from 'reactflow' import { useSession } from '@/lib/auth/auth-client' -import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' import { useUndoRedo } from '@/hooks/use-undo-redo' import { BLOCK_OPERATIONS, BLOCKS_OPERATIONS, - EDGE_OPERATIONS, EDGES_OPERATIONS, OPERATION_TARGETS, SUBBLOCK_OPERATIONS, @@ -34,7 +32,6 @@ const logger = createLogger('CollaborativeWorkflow') export function useCollaborativeWorkflow() { const undoRedo = useUndoRedo() const isUndoRedoInProgress = useRef(false) - const skipEdgeRecording = useRef(false) const lastDiffOperationId = useRef(null) useEffect(() => { @@ -60,11 +57,6 @@ export function useCollaborativeWorkflow() { ) } - const skipEdgeHandler = (e: any) => { - const { skip } = e.detail || {} - skipEdgeRecording.current = skip - } - const diffOperationHandler = (e: any) => { const { type, @@ -110,12 +102,10 @@ export function useCollaborativeWorkflow() { window.addEventListener('workflow-record-move', moveHandler) window.addEventListener('workflow-record-parent-update', parentUpdateHandler) - window.addEventListener('skip-edge-recording', skipEdgeHandler) window.addEventListener('record-diff-operation', diffOperationHandler) return () => { window.removeEventListener('workflow-record-move', moveHandler) window.removeEventListener('workflow-record-parent-update', parentUpdateHandler) - window.removeEventListener('skip-edge-recording', skipEdgeHandler) window.removeEventListener('record-diff-operation', diffOperationHandler) } }, [undoRedo]) @@ -208,105 +198,29 @@ export function useCollaborativeWorkflow() { try { if (target === OPERATION_TARGETS.BLOCK) { switch (operation) { - case BLOCK_OPERATIONS.UPDATE_POSITION: { - const blockId = payload.id - - if (!data.timestamp) { - logger.warn('Position update missing timestamp, applying without ordering check', { - blockId, - }) - workflowStore.updateBlockPosition(payload.id, payload.position) - break - } - - const updateTimestamp = data.timestamp - const lastTimestamp = lastPositionTimestamps.current.get(blockId) || 0 - - if (updateTimestamp >= lastTimestamp) { - workflowStore.updateBlockPosition(payload.id, payload.position) - lastPositionTimestamps.current.set(blockId, updateTimestamp) - } else { - // Skip out-of-order position update to prevent jagged movement - logger.debug('Skipping out-of-order position update', { - blockId, - updateTimestamp, - lastTimestamp, - position: payload.position, - }) - } - break - } case BLOCK_OPERATIONS.UPDATE_NAME: workflowStore.updateBlockName(payload.id, payload.name) break - case BLOCK_OPERATIONS.TOGGLE_ENABLED: - workflowStore.toggleBlockEnabled(payload.id) - break - case BLOCK_OPERATIONS.UPDATE_PARENT: - workflowStore.updateParentId(payload.id, payload.parentId, payload.extent) - break case BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE: workflowStore.setBlockAdvancedMode(payload.id, payload.advancedMode) break - case BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE: - workflowStore.setBlockTriggerMode(payload.id, payload.triggerMode) - break - case BLOCK_OPERATIONS.TOGGLE_HANDLES: { - const currentBlock = workflowStore.blocks[payload.id] - if (currentBlock && currentBlock.horizontalHandles !== payload.horizontalHandles) { - workflowStore.toggleBlockHandles(payload.id) - } - break - } } } else if (target === OPERATION_TARGETS.BLOCKS) { switch (operation) { case BLOCKS_OPERATIONS.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) - } - }) + workflowStore.batchUpdatePositions(updates) } break } } - } else if (target === OPERATION_TARGETS.EDGE) { - switch (operation) { - case EDGE_OPERATIONS.ADD: - workflowStore.addEdge(payload as Edge) - break - case EDGE_OPERATIONS.REMOVE: { - workflowStore.removeEdge(payload.id) - - 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 - } - } } else if (target === OPERATION_TARGETS.EDGES) { switch (operation) { case EDGES_OPERATIONS.BATCH_REMOVE_EDGES: { const { ids } = payload - if (Array.isArray(ids)) { - ids.forEach((id: string) => { - workflowStore.removeEdge(id) - }) + if (Array.isArray(ids) && ids.length > 0) { + workflowStore.batchRemoveEdges(ids) const updatedBlocks = useWorkflowStore.getState().blocks const updatedEdges = useWorkflowStore.getState().edges @@ -328,8 +242,8 @@ export function useCollaborativeWorkflow() { } case EDGES_OPERATIONS.BATCH_ADD_EDGES: { const { edges } = payload - if (Array.isArray(edges)) { - edges.forEach((edge: Edge) => workflowStore.addEdge(edge)) + if (Array.isArray(edges) && edges.length > 0) { + workflowStore.batchAddEdges(edges) } break } @@ -433,85 +347,82 @@ export function useCollaborativeWorkflow() { if (target === OPERATION_TARGETS.BLOCKS) { switch (operation) { case BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS: { - const { - blocks, - edges, - loops, - parallels, - subBlockValues: addedSubBlockValues, - } = payload + const { blocks, edges, 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, - } - ) - }) + if (blocks && blocks.length > 0) { + workflowStore.batchAddBlocks(blocks, edges || [], addedSubBlockValues || {}) + } - ;(edges || []).forEach((edge: Edge) => { - workflowStore.addEdge(edge) + logger.info('Successfully applied batch-add-blocks from remote user') + break + } + case BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS: { + const { ids } = payload + logger.info('Received batch-remove-blocks from remote user', { + userId, + count: (ids || []).length, }) - if (loops) { - Object.entries(loops as Record).forEach(([loopId, loopConfig]) => { - useWorkflowStore.setState((state) => ({ - loops: { ...state.loops, [loopId]: loopConfig }, - })) - }) + if (ids && ids.length > 0) { + workflowStore.batchRemoveBlocks(ids) } - if (parallels) { - Object.entries(parallels as Record).forEach( - ([parallelId, parallelConfig]) => { - useWorkflowStore.setState((state) => ({ - parallels: { ...state.parallels, [parallelId]: parallelConfig }, - })) - } - ) - } + logger.info('Successfully applied batch-remove-blocks from remote user') + break + } + case BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED: { + const { blockIds } = payload + logger.info('Received batch-toggle-enabled from remote user', { + userId, + count: (blockIds || []).length, + }) - if (addedSubBlockValues && activeWorkflowId) { - Object.entries( - addedSubBlockValues as Record> - ).forEach(([blockId, subBlocks]) => { - Object.entries(subBlocks).forEach(([subBlockId, value]) => { - subBlockStore.setValue(blockId, subBlockId, value) - }) - }) + if (blockIds && blockIds.length > 0) { + workflowStore.batchToggleEnabled(blockIds) } - logger.info('Successfully applied batch-add-blocks from remote user') + logger.info('Successfully applied batch-toggle-enabled from remote user') break } - case BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS: { - const { ids } = payload - logger.info('Received batch-remove-blocks from remote user', { + case BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES: { + const { blockIds } = payload + logger.info('Received batch-toggle-handles from remote user', { userId, - count: (ids || []).length, + count: (blockIds || []).length, }) - ;(ids || []).forEach((id: string) => { - workflowStore.removeBlock(id) + if (blockIds && blockIds.length > 0) { + workflowStore.batchToggleHandles(blockIds) + } + + logger.info('Successfully applied batch-toggle-handles from remote user') + break + } + case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: { + const { updates } = payload + logger.info('Received batch-update-parent from remote user', { + userId, + count: (updates || []).length, }) - logger.info('Successfully applied batch-remove-blocks from remote user') + if (updates && updates.length > 0) { + workflowStore.batchUpdateBlocksWithParent( + updates.map( + (u: { id: string; parentId: string; position: { x: number; y: number } }) => ({ + id: u.id, + position: u.position, + parentId: u.parentId || undefined, + }) + ) + ) + } + + logger.info('Successfully applied batch-update-parent from remote user') break } } @@ -790,9 +701,7 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) - updates.forEach(({ id, position }) => { - workflowStore.updateBlockPosition(id, position) - }) + workflowStore.batchUpdatePositions(updates) if (options?.previousPositions && options.previousPositions.size > 0) { const moves = updates @@ -927,27 +836,13 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) - for (const id of validIds) { - workflowStore.toggleBlockEnabled(id) - } + workflowStore.batchToggleEnabled(validIds) undoRedo.recordBatchToggleEnabled(validIds, previousStates) }, [addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo] ) - const collaborativeUpdateParentId = useCallback( - (id: string, parentId: string, extent: 'parent') => { - executeQueuedOperation( - BLOCK_OPERATIONS.UPDATE_PARENT, - OPERATION_TARGETS.BLOCK, - { id, parentId, extent }, - () => workflowStore.updateParentId(id, parentId, extent) - ) - }, - [executeQueuedOperation, workflowStore] - ) - const collaborativeBatchUpdateParent = useCallback( ( updates: Array<{ @@ -979,16 +874,21 @@ export function useCollaborativeWorkflow() { } }) - for (const update of updates) { - if (update.affectedEdges.length > 0) { - update.affectedEdges.forEach((e) => workflowStore.removeEdge(e.id)) - } - workflowStore.updateBlockPosition(update.blockId, update.newPosition) - if (update.newParentId) { - workflowStore.updateParentId(update.blockId, update.newParentId, 'parent') - } + // Collect all edge IDs to remove + const edgeIdsToRemove = updates.flatMap((u) => u.affectedEdges.map((e) => e.id)) + if (edgeIdsToRemove.length > 0) { + workflowStore.batchRemoveEdges(edgeIdsToRemove) } + // Batch update positions and parents + workflowStore.batchUpdateBlocksWithParent( + updates.map((u) => ({ + id: u.blockId, + position: u.newPosition, + parentId: u.newParentId || undefined, + })) + ) + undoRedo.recordBatchUpdateParent(batchUpdates) const operationId = crypto.randomUUID() @@ -1031,37 +931,6 @@ export function useCollaborativeWorkflow() { [executeQueuedOperation, workflowStore] ) - const collaborativeToggleBlockTriggerMode = useCallback( - (id: string) => { - const currentBlock = workflowStore.blocks[id] - if (!currentBlock) return - - const newTriggerMode = !currentBlock.triggerMode - - // When enabling trigger mode, check if block is inside a subflow - if (newTriggerMode && TriggerUtils.isBlockInSubflow(id, workflowStore.blocks)) { - // Dispatch custom event to show warning modal - window.dispatchEvent( - new CustomEvent('show-trigger-warning', { - detail: { - type: 'trigger_in_subflow', - triggerName: 'trigger', - }, - }) - ) - return - } - - executeQueuedOperation( - BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE, - OPERATION_TARGETS.BLOCK, - { id, triggerMode: newTriggerMode }, - () => workflowStore.toggleBlockTriggerMode(id) - ) - }, - [executeQueuedOperation, workflowStore] - ) - const collaborativeBatchToggleBlockHandles = useCallback( (ids: string[]) => { if (ids.length === 0) return @@ -1092,57 +961,44 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) - for (const id of validIds) { - workflowStore.toggleBlockHandles(id) - } + workflowStore.batchToggleHandles(validIds) undoRedo.recordBatchToggleHandles(validIds, previousStates) }, [addToQueue, activeWorkflowId, session?.user?.id, workflowStore, undoRedo] ) - const collaborativeAddEdge = useCallback( - (edge: Edge) => { - executeQueuedOperation(EDGE_OPERATIONS.ADD, OPERATION_TARGETS.EDGE, edge, () => - workflowStore.addEdge(edge) - ) - if (!skipEdgeRecording.current) { - undoRedo.recordAddEdge(edge.id) + const collaborativeBatchAddEdges = useCallback( + (edges: Edge[], options?: { skipUndoRedo?: boolean }) => { + if (!isInActiveRoom()) { + logger.debug('Skipping batch add edges - not in active workflow') + return false } - }, - [executeQueuedOperation, workflowStore, undoRedo] - ) - const collaborativeRemoveEdge = useCallback( - (edgeId: string) => { - const edge = workflowStore.edges.find((e) => e.id === edgeId) + if (edges.length === 0) return false - if (!edge) { - logger.debug('Edge already removed, skipping operation', { edgeId }) - return - } + const operationId = crypto.randomUUID() - const sourceExists = workflowStore.blocks[edge.source] - const targetExists = workflowStore.blocks[edge.target] + addToQueue({ + id: operationId, + operation: { + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges }, + }, + workflowId: activeWorkflowId || '', + userId: session?.user?.id || 'unknown', + }) - if (!sourceExists || !targetExists) { - logger.debug('Edge source or target block no longer exists, skipping operation', { - edgeId, - sourceExists: !!sourceExists, - targetExists: !!targetExists, - }) - return - } + workflowStore.batchAddEdges(edges) - if (!skipEdgeRecording.current) { - undoRedo.recordBatchRemoveEdges([edge]) + if (!options?.skipUndoRedo) { + edges.forEach((edge) => undoRedo.recordAddEdge(edge.id)) } - executeQueuedOperation(EDGE_OPERATIONS.REMOVE, OPERATION_TARGETS.EDGE, { id: edgeId }, () => - workflowStore.removeEdge(edgeId) - ) + return true }, - [executeQueuedOperation, workflowStore, undoRedo] + [addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, workflowStore, undoRedo] ) const collaborativeBatchRemoveEdges = useCallback( @@ -1187,7 +1043,7 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) - validEdgeIds.forEach((id) => workflowStore.removeEdge(id)) + workflowStore.batchRemoveEdges(validEdgeIds) if (!options?.skipUndoRedo && edgeSnapshots.length > 0) { undoRedo.recordBatchRemoveEdges(edgeSnapshots) @@ -1619,48 +1475,7 @@ export function useCollaborativeWorkflow() { 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) - }) - }) - } + workflowStore.batchAddBlocks(blocks, edges, subBlockValues) if (!options?.skipUndoRedo) { undoRedo.recordBatchAddBlocks(blocks, edges, subBlockValues) @@ -1751,9 +1566,7 @@ export function useCollaborativeWorkflow() { userId: session?.user?.id || 'unknown', }) - blockIds.forEach((id) => { - workflowStore.removeBlock(id) - }) + workflowStore.batchRemoveBlocks(blockIds) if (!options?.skipUndoRedo && blockSnapshots.length > 0) { undoRedo.recordBatchRemoveBlocks(blockSnapshots, edgeSnapshots, subBlockValues) @@ -1787,15 +1600,12 @@ export function useCollaborativeWorkflow() { collaborativeBatchUpdatePositions, collaborativeUpdateBlockName, collaborativeBatchToggleBlockEnabled, - collaborativeUpdateParentId, collaborativeBatchUpdateParent, collaborativeToggleBlockAdvancedMode, - collaborativeToggleBlockTriggerMode, collaborativeBatchToggleBlockHandles, collaborativeBatchAddBlocks, collaborativeBatchRemoveBlocks, - collaborativeAddEdge, - collaborativeRemoveEdge, + collaborativeBatchAddEdges, collaborativeBatchRemoveEdges, collaborativeSetSubblockValue, collaborativeSetTagSelection, diff --git a/apps/sim/hooks/use-undo-redo.ts b/apps/sim/hooks/use-undo-redo.ts index 740b50293b..1bb6bf590c 100644 --- a/apps/sim/hooks/use-undo-redo.ts +++ b/apps/sim/hooks/use-undo-redo.ts @@ -481,7 +481,7 @@ export function useUndoRedo() { userId, }) - existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) + workflowStore.batchRemoveBlocks(existingBlockIds) break } case UNDO_REDO_OPERATIONS.BATCH_ADD_BLOCKS: { @@ -544,11 +544,12 @@ export function useUndoRedo() { } if (edgeSnapshots && edgeSnapshots.length > 0) { - edgeSnapshots.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - } - }) + const edgesToAdd = edgeSnapshots.filter( + (edge) => !workflowStore.edges.find((e) => e.id === edge.id) + ) + if (edgesToAdd.length > 0) { + workflowStore.batchAddEdges(edgesToAdd) + } } break } @@ -572,7 +573,7 @@ export function useUndoRedo() { workflowId: activeWorkflowId, userId, }) - edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) + workflowStore.batchRemoveEdges(edgesToRemove) } logger.debug('Undid batch-add-edges', { edgeCount: edgesToRemove.length }) break @@ -597,7 +598,7 @@ export function useUndoRedo() { workflowId: activeWorkflowId, userId, }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + workflowStore.batchAddEdges(edgesToAdd) } logger.debug('Undid batch-remove-edges', { edgeCount: edgesToAdd.length }) break @@ -613,14 +614,11 @@ export function useUndoRedo() { id: move.blockId, position: { x: move.after.x, y: move.after.y }, }) - workflowStore.updateBlockPosition(move.blockId, { - x: move.after.x, - y: move.after.y, - }) } } if (positionUpdates.length > 0) { + workflowStore.batchUpdatePositions(positionUpdates) addToQueue({ id: opId, operation: { @@ -654,7 +652,7 @@ export function useUndoRedo() { workflowId: activeWorkflowId, userId, }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + workflowStore.batchAddEdges(edgesToAdd) } } @@ -675,9 +673,6 @@ export function useUndoRedo() { userId, }) - // Update position locally - workflowStore.updateBlockPosition(blockId, newPosition) - // Send parent update to server addToQueue({ id: opId, @@ -696,26 +691,35 @@ export function useUndoRedo() { userId, }) - // Update parent locally - workflowStore.updateParentId(blockId, newParentId || '', 'parent') + // Update position and parent locally using batch method + workflowStore.batchUpdateBlocksWithParent([ + { + id: blockId, + position: newPosition, + parentId: newParentId, + }, + ]) // If we're removing FROM a subflow (undo of add to subflow), remove edges after if (!newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.removeEdge(edge.id) + const edgeIdsToRemove = affectedEdges + .filter((edge) => workflowStore.edges.find((e) => e.id === edge.id)) + .map((edge) => edge.id) + if (edgeIdsToRemove.length > 0) { + workflowStore.batchRemoveEdges(edgeIdsToRemove) + edgeIdsToRemove.forEach((edgeId) => { addToQueue({ id: crypto.randomUUID(), operation: { operation: EDGE_OPERATIONS.REMOVE, target: OPERATION_TARGETS.EDGE, - payload: { id: edge.id, isUndo: true }, + payload: { id: edgeId, isUndo: true }, }, workflowId: activeWorkflowId, userId, }) - } - }) + }) + } } } else { logger.debug('Undo update-parent skipped; block missing', { blockId }) @@ -732,54 +736,67 @@ export function useUndoRedo() { break } - // Process each update + // Collect all edge operations first + const allEdgesToAdd: Edge[] = [] + const allEdgeIdsToRemove: string[] = [] + for (const update of validUpdates) { - const { blockId, newParentId, newPosition, affectedEdges } = update + const { newParentId, affectedEdges } = update // Moving OUT of subflow (undoing insert) → restore edges first if (!newParentId && affectedEdges && affectedEdges.length > 0) { const edgesToAdd = affectedEdges.filter( (e) => !workflowStore.edges.find((edge) => edge.id === e.id) ) - if (edgesToAdd.length > 0) { - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, - target: OPERATION_TARGETS.EDGES, - payload: { edges: edgesToAdd }, - }, - workflowId: activeWorkflowId, - userId, - }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) - } + allEdgesToAdd.push(...edgesToAdd) } // Moving INTO subflow (undoing removal) → remove edges first if (newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.removeEdge(edge.id) - } - }) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, - target: OPERATION_TARGETS.EDGES, - payload: { edgeIds: affectedEdges.map((e) => e.id) }, - }, - workflowId: activeWorkflowId, - userId, - }) + const edgeIds = affectedEdges + .filter((edge) => workflowStore.edges.find((e) => e.id === edge.id)) + .map((edge) => edge.id) + allEdgeIdsToRemove.push(...edgeIds) } + } + + // Apply edge operations in batch + if (allEdgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges: allEdgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + workflowStore.batchAddEdges(allEdgesToAdd) + } - // Update position and parent locally - workflowStore.updateBlockPosition(blockId, newPosition) - workflowStore.updateParentId(blockId, newParentId || '', 'parent') + if (allEdgeIdsToRemove.length > 0) { + workflowStore.batchRemoveEdges(allEdgeIdsToRemove) + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edgeIds: allEdgeIdsToRemove }, + }, + workflowId: activeWorkflowId, + userId, + }) } + // Update positions and parents locally in batch + const blockUpdates = validUpdates.map((update) => ({ + id: update.blockId, + position: update.newPosition, + parentId: update.newParentId, + })) + workflowStore.batchUpdateBlocksWithParent(blockUpdates) + // Send batch update to server addToQueue({ id: opId, @@ -1104,11 +1121,12 @@ export function useUndoRedo() { } if (edgeSnapshots && edgeSnapshots.length > 0) { - edgeSnapshots.forEach((edge) => { - if (!workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.addEdge(edge) - } - }) + const edgesToAdd = edgeSnapshots.filter( + (edge) => !workflowStore.edges.find((e) => e.id === edge.id) + ) + if (edgesToAdd.length > 0) { + workflowStore.batchAddEdges(edgesToAdd) + } } break } @@ -1134,7 +1152,7 @@ export function useUndoRedo() { userId, }) - existingBlockIds.forEach((id) => workflowStore.removeBlock(id)) + workflowStore.batchRemoveBlocks(existingBlockIds) break } case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: { @@ -1157,7 +1175,7 @@ export function useUndoRedo() { workflowId: activeWorkflowId, userId, }) - edgesToRemove.forEach((id) => workflowStore.removeEdge(id)) + workflowStore.batchRemoveEdges(edgesToRemove) } logger.debug('Redid batch-remove-edges', { edgeCount: edgesToRemove.length }) @@ -1183,7 +1201,7 @@ export function useUndoRedo() { workflowId: activeWorkflowId, userId, }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + workflowStore.batchAddEdges(edgesToAdd) } logger.debug('Redid batch-add-edges', { edgeCount: edgesToAdd.length }) @@ -1200,14 +1218,11 @@ export function useUndoRedo() { id: move.blockId, position: { x: move.after.x, y: move.after.y }, }) - workflowStore.updateBlockPosition(move.blockId, { - x: move.after.x, - y: move.after.y, - }) } } if (positionUpdates.length > 0) { + workflowStore.batchUpdatePositions(positionUpdates) addToQueue({ id: opId, operation: { @@ -1229,21 +1244,24 @@ export function useUndoRedo() { if (workflowStore.blocks[blockId]) { // If we're removing FROM a subflow, remove edges first if (!newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.removeEdge(edge.id) + const edgeIdsToRemove = affectedEdges + .filter((edge) => workflowStore.edges.find((e) => e.id === edge.id)) + .map((edge) => edge.id) + if (edgeIdsToRemove.length > 0) { + workflowStore.batchRemoveEdges(edgeIdsToRemove) + edgeIdsToRemove.forEach((edgeId) => { addToQueue({ id: crypto.randomUUID(), operation: { operation: EDGE_OPERATIONS.REMOVE, target: OPERATION_TARGETS.EDGE, - payload: { id: edge.id, isRedo: true }, + payload: { id: edgeId, isRedo: true }, }, workflowId: activeWorkflowId, userId, }) - } - }) + }) + } } // Send position update to server @@ -1264,9 +1282,6 @@ export function useUndoRedo() { userId, }) - // Update position locally - workflowStore.updateBlockPosition(blockId, newPosition) - // Send parent update to server addToQueue({ id: opId, @@ -1285,8 +1300,14 @@ export function useUndoRedo() { userId, }) - // Update parent locally - workflowStore.updateParentId(blockId, newParentId || '', 'parent') + // Update position and parent locally using batch method + workflowStore.batchUpdateBlocksWithParent([ + { + id: blockId, + position: newPosition, + parentId: newParentId, + }, + ]) // If we're adding TO a subflow, restore edges after if (newParentId && affectedEdges && affectedEdges.length > 0) { @@ -1304,7 +1325,7 @@ export function useUndoRedo() { workflowId: activeWorkflowId, userId, }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) + workflowStore.batchAddEdges(edgesToAdd) } } } else { @@ -1322,54 +1343,68 @@ export function useUndoRedo() { break } - // Process each update + // Collect all edge operations first + const allEdgesToAdd: Edge[] = [] + const allEdgeIdsToRemove: string[] = [] + for (const update of validUpdates) { - const { blockId, newParentId, newPosition, affectedEdges } = update + const { newParentId, affectedEdges } = update // Moving INTO subflow (redoing insert) → remove edges first if (newParentId && affectedEdges && affectedEdges.length > 0) { - affectedEdges.forEach((edge) => { - if (workflowStore.edges.find((e) => e.id === edge.id)) { - workflowStore.removeEdge(edge.id) - } - }) - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, - target: OPERATION_TARGETS.EDGES, - payload: { edgeIds: affectedEdges.map((e) => e.id) }, - }, - workflowId: activeWorkflowId, - userId, - }) + const edgeIds = affectedEdges + .filter((edge) => workflowStore.edges.find((e) => e.id === edge.id)) + .map((edge) => edge.id) + allEdgeIdsToRemove.push(...edgeIds) } - // Update position and parent locally - workflowStore.updateBlockPosition(blockId, newPosition) - workflowStore.updateParentId(blockId, newParentId || '', 'parent') - // Moving OUT of subflow (redoing removal) → restore edges after if (!newParentId && affectedEdges && affectedEdges.length > 0) { const edgesToAdd = affectedEdges.filter( (e) => !workflowStore.edges.find((edge) => edge.id === e.id) ) - if (edgesToAdd.length > 0) { - addToQueue({ - id: crypto.randomUUID(), - operation: { - operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, - target: OPERATION_TARGETS.EDGES, - payload: { edges: edgesToAdd }, - }, - workflowId: activeWorkflowId, - userId, - }) - edgesToAdd.forEach((edge) => workflowStore.addEdge(edge)) - } + allEdgesToAdd.push(...edgesToAdd) } } + // Apply edge removals in batch first + if (allEdgeIdsToRemove.length > 0) { + workflowStore.batchRemoveEdges(allEdgeIdsToRemove) + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edgeIds: allEdgeIdsToRemove }, + }, + workflowId: activeWorkflowId, + userId, + }) + } + + // Update positions and parents locally in batch + const blockUpdates = validUpdates.map((update) => ({ + id: update.blockId, + position: update.newPosition, + parentId: update.newParentId, + })) + workflowStore.batchUpdateBlocksWithParent(blockUpdates) + + // Apply edge additions in batch after + if (allEdgesToAdd.length > 0) { + addToQueue({ + id: crypto.randomUUID(), + operation: { + operation: EDGES_OPERATIONS.BATCH_ADD_EDGES, + target: OPERATION_TARGETS.EDGES, + payload: { edges: allEdgesToAdd }, + }, + workflowId: activeWorkflowId, + userId, + }) + workflowStore.batchAddEdges(allEdgesToAdd) + } + // Send batch update to server addToQueue({ id: opId, diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index d8c08430fb..db6dcfd329 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -34,7 +34,9 @@ import { updateOllamaProviderModels, } from '@/providers/utils' -const isHostedSpy = vi.spyOn(environmentModule, 'isHosted', 'get') +const isHostedSpy = vi.spyOn(environmentModule, 'isHosted', 'get') as unknown as { + mockReturnValue: (value: boolean) => void +} const mockGetRotatingApiKey = vi.fn().mockReturnValue('rotating-server-key') const originalRequire = module.require diff --git a/apps/sim/socket/constants.ts b/apps/sim/socket/constants.ts index 89aa7020e6..98f49d846e 100644 --- a/apps/sim/socket/constants.ts +++ b/apps/sim/socket/constants.ts @@ -3,9 +3,7 @@ export const BLOCK_OPERATIONS = { UPDATE_NAME: 'update-name', TOGGLE_ENABLED: 'toggle-enabled', UPDATE_PARENT: 'update-parent', - UPDATE_WIDE: 'update-wide', UPDATE_ADVANCED_MODE: 'update-advanced-mode', - UPDATE_TRIGGER_MODE: 'update-trigger-mode', TOGGLE_HANDLES: 'toggle-handles', } as const @@ -37,8 +35,6 @@ export const EDGES_OPERATIONS = { export type EdgesOperation = (typeof EDGES_OPERATIONS)[keyof typeof EDGES_OPERATIONS] export const SUBFLOW_OPERATIONS = { - ADD: 'add', - REMOVE: 'remove', UPDATE: 'update', } as const @@ -94,3 +90,20 @@ export const UNDO_REDO_OPERATIONS = { } as const export type UndoRedoOperation = (typeof UNDO_REDO_OPERATIONS)[keyof typeof UNDO_REDO_OPERATIONS] + +/** + * All socket operations that require permission checks. + * This is the single source of truth for valid operations. + */ +export const ALL_SOCKET_OPERATIONS = [ + ...Object.values(BLOCK_OPERATIONS), + ...Object.values(BLOCKS_OPERATIONS), + ...Object.values(EDGE_OPERATIONS), + ...Object.values(EDGES_OPERATIONS), + ...Object.values(WORKFLOW_OPERATIONS), + ...Object.values(SUBBLOCK_OPERATIONS), + ...Object.values(VARIABLE_OPERATIONS), + ...Object.values(SUBFLOW_OPERATIONS), +] as const + +export type SocketOperation = (typeof ALL_SOCKET_OPERATIONS)[number] diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts index 59e73ef605..1f52d46ef9 100644 --- a/apps/sim/socket/database/operations.ts +++ b/apps/sim/socket/database/operations.ts @@ -396,28 +396,6 @@ async function handleBlockOperationTx( break } - case BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE: { - if (!payload.id || payload.triggerMode === undefined) { - throw new Error('Missing required fields for update trigger mode operation') - } - - const updateResult = await tx - .update(workflowBlocks) - .set({ - triggerMode: payload.triggerMode, - updatedAt: new Date(), - }) - .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) - .returning({ id: workflowBlocks.id }) - - if (updateResult.length === 0) { - throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) - } - - logger.debug(`Updated block trigger mode: ${payload.id} -> ${payload.triggerMode}`) - break - } - case BLOCK_OPERATIONS.TOGGLE_HANDLES: { if (!payload.id || payload.horizontalHandles === undefined) { throw new Error('Missing required fields for toggle handles operation') diff --git a/apps/sim/socket/middleware/permissions.test.ts b/apps/sim/socket/middleware/permissions.test.ts index 1c2192eacc..2bb202fd90 100644 --- a/apps/sim/socket/middleware/permissions.test.ts +++ b/apps/sim/socket/middleware/permissions.test.ts @@ -207,19 +207,12 @@ describe('checkRolePermission', () => { { operation: 'update-name', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'toggle-enabled', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'update-parent', adminAllowed: true, writeAllowed: true, readAllowed: false }, - { operation: 'update-wide', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'update-advanced-mode', adminAllowed: true, writeAllowed: true, readAllowed: false, }, - { - operation: 'update-trigger-mode', - adminAllowed: true, - writeAllowed: true, - readAllowed: false, - }, { operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false }, { operation: 'batch-update-positions', diff --git a/apps/sim/socket/middleware/permissions.ts b/apps/sim/socket/middleware/permissions.ts index 5772142e20..1ff6b09e8b 100644 --- a/apps/sim/socket/middleware/permissions.ts +++ b/apps/sim/socket/middleware/permissions.ts @@ -3,56 +3,56 @@ import { workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + BLOCK_OPERATIONS, + BLOCKS_OPERATIONS, + EDGE_OPERATIONS, + EDGES_OPERATIONS, + SUBFLOW_OPERATIONS, + WORKFLOW_OPERATIONS, +} from '@/socket/constants' const logger = createLogger('SocketPermissions') +// All write operations (admin and write roles have same permissions) +const WRITE_OPERATIONS: string[] = [ + // Block operations + BLOCK_OPERATIONS.UPDATE_POSITION, + BLOCK_OPERATIONS.UPDATE_NAME, + BLOCK_OPERATIONS.TOGGLE_ENABLED, + BLOCK_OPERATIONS.UPDATE_PARENT, + BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, + BLOCK_OPERATIONS.TOGGLE_HANDLES, + // Batch block operations + BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, + BLOCKS_OPERATIONS.BATCH_ADD_BLOCKS, + BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS, + BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED, + BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES, + BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT, + // Edge operations + EDGE_OPERATIONS.ADD, + EDGE_OPERATIONS.REMOVE, + // Batch edge operations + EDGES_OPERATIONS.BATCH_ADD_EDGES, + EDGES_OPERATIONS.BATCH_REMOVE_EDGES, + // Subflow operations + SUBFLOW_OPERATIONS.UPDATE, + // Workflow operations + WORKFLOW_OPERATIONS.REPLACE_STATE, +] + +// Read role can only update positions (for cursor sync, etc.) +const READ_OPERATIONS: string[] = [ + BLOCK_OPERATIONS.UPDATE_POSITION, + BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS, +] + // Define operation permissions based on role const ROLE_PERMISSIONS: Record = { - admin: [ - 'add', - 'remove', - 'update', - 'update-position', - 'batch-update-positions', - 'batch-add-blocks', - 'batch-remove-blocks', - 'batch-add-edges', - 'batch-remove-edges', - 'batch-toggle-enabled', - 'batch-toggle-handles', - 'batch-update-parent', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'update-wide', - 'update-advanced-mode', - 'update-trigger-mode', - 'toggle-handles', - 'replace-state', - ], - write: [ - 'add', - 'remove', - 'update', - 'update-position', - 'batch-update-positions', - 'batch-add-blocks', - 'batch-remove-blocks', - 'batch-add-edges', - 'batch-remove-edges', - 'batch-toggle-enabled', - 'batch-toggle-handles', - 'batch-update-parent', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'update-wide', - 'update-advanced-mode', - 'update-trigger-mode', - 'toggle-handles', - 'replace-state', - ], - read: ['update-position', 'batch-update-positions'], + admin: WRITE_OPERATIONS, + write: WRITE_OPERATIONS, + read: READ_OPERATIONS, } // Check if a role allows a specific operation (no DB query, pure logic) diff --git a/apps/sim/socket/validation/schemas.ts b/apps/sim/socket/validation/schemas.ts index 85499b0c5b..395b321028 100644 --- a/apps/sim/socket/validation/schemas.ts +++ b/apps/sim/socket/validation/schemas.ts @@ -30,9 +30,7 @@ export const BlockOperationSchema = z.object({ BLOCK_OPERATIONS.UPDATE_NAME, BLOCK_OPERATIONS.TOGGLE_ENABLED, BLOCK_OPERATIONS.UPDATE_PARENT, - BLOCK_OPERATIONS.UPDATE_WIDE, BLOCK_OPERATIONS.UPDATE_ADVANCED_MODE, - BLOCK_OPERATIONS.UPDATE_TRIGGER_MODE, BLOCK_OPERATIONS.TOGGLE_HANDLES, ]), target: z.literal(OPERATION_TARGETS.BLOCK), @@ -87,7 +85,7 @@ export const EdgeOperationSchema = z.object({ }) export const SubflowOperationSchema = z.object({ - operation: z.enum([SUBFLOW_OPERATIONS.ADD, SUBFLOW_OPERATIONS.REMOVE, SUBFLOW_OPERATIONS.UPDATE]), + operation: z.literal(SUBFLOW_OPERATIONS.UPDATE), target: z.literal(OPERATION_TARGETS.SUBFLOW), payload: z.object({ id: z.string(), diff --git a/apps/sim/stores/workflows/workflow/store.test.ts b/apps/sim/stores/workflows/workflow/store.test.ts index 68d9b66cb1..f1fef5bef7 100644 --- a/apps/sim/stores/workflows/workflow/store.test.ts +++ b/apps/sim/stores/workflows/workflow/store.test.ts @@ -247,28 +247,30 @@ describe('workflow store', () => { }) }) - describe('removeBlock', () => { + describe('batchRemoveBlocks', () => { it('should remove a block', () => { - const { addBlock, removeBlock } = useWorkflowStore.getState() + const { addBlock, batchRemoveBlocks } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) - removeBlock('block-1') + batchRemoveBlocks(['block-1']) const { blocks } = useWorkflowStore.getState() expectBlockNotExists(blocks, 'block-1') }) it('should remove connected edges when block is removed', () => { - const { addBlock, addEdge, removeBlock } = useWorkflowStore.getState() + const { addBlock, batchAddEdges, batchRemoveBlocks } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'Middle', { x: 200, y: 0 }) addBlock('block-3', 'function', 'End', { x: 400, y: 0 }) - addEdge({ id: 'e1', source: 'block-1', target: 'block-2' }) - addEdge({ id: 'e2', source: 'block-2', target: 'block-3' }) + batchAddEdges([ + { id: 'e1', source: 'block-1', target: 'block-2' }, + { id: 'e2', source: 'block-2', target: 'block-3' }, + ]) - removeBlock('block-2') + batchRemoveBlocks(['block-2']) const state = useWorkflowStore.getState() expectBlockNotExists(state.blocks, 'block-2') @@ -276,59 +278,59 @@ describe('workflow store', () => { }) it('should not throw when removing non-existent block', () => { - const { removeBlock } = useWorkflowStore.getState() + const { batchRemoveBlocks } = useWorkflowStore.getState() - expect(() => removeBlock('non-existent')).not.toThrow() + expect(() => batchRemoveBlocks(['non-existent'])).not.toThrow() }) }) - describe('addEdge', () => { + describe('batchAddEdges', () => { it('should add an edge between two blocks', () => { - const { addBlock, addEdge } = useWorkflowStore.getState() + const { addBlock, batchAddEdges } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) - addEdge({ id: 'e1', source: 'block-1', target: 'block-2' }) + batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-2' }]) const { edges } = useWorkflowStore.getState() expectEdgeConnects(edges, 'block-1', 'block-2') }) it('should not add duplicate edges', () => { - const { addBlock, addEdge } = useWorkflowStore.getState() + const { addBlock, batchAddEdges } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) - addEdge({ id: 'e1', source: 'block-1', target: 'block-2' }) - addEdge({ id: 'e2', source: 'block-1', target: 'block-2' }) + batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-2' }]) + batchAddEdges([{ id: 'e2', source: 'block-1', target: 'block-2' }]) const state = useWorkflowStore.getState() expectEdgeCount(state, 1) }) it('should prevent self-referencing edges', () => { - const { addBlock, addEdge } = useWorkflowStore.getState() + const { addBlock, batchAddEdges } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Self', { x: 0, y: 0 }) - addEdge({ id: 'e1', source: 'block-1', target: 'block-1' }) + batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-1' }]) const state = useWorkflowStore.getState() expectEdgeCount(state, 0) }) }) - describe('removeEdge', () => { + describe('batchRemoveEdges', () => { it('should remove an edge by id', () => { - const { addBlock, addEdge, removeEdge } = useWorkflowStore.getState() + const { addBlock, batchAddEdges, batchRemoveEdges } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) - addEdge({ id: 'e1', source: 'block-1', target: 'block-2' }) + batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-2' }]) - removeEdge('e1') + batchRemoveEdges(['e1']) const state = useWorkflowStore.getState() expectEdgeCount(state, 0) @@ -336,19 +338,19 @@ describe('workflow store', () => { }) it('should not throw when removing non-existent edge', () => { - const { removeEdge } = useWorkflowStore.getState() + const { batchRemoveEdges } = useWorkflowStore.getState() - expect(() => removeEdge('non-existent')).not.toThrow() + expect(() => batchRemoveEdges(['non-existent'])).not.toThrow() }) }) describe('clear', () => { it('should clear all blocks and edges', () => { - const { addBlock, addEdge, clear } = useWorkflowStore.getState() + const { addBlock, batchAddEdges, clear } = useWorkflowStore.getState() addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 }) addBlock('block-2', 'function', 'End', { x: 200, y: 0 }) - addEdge({ id: 'e1', source: 'block-1', target: 'block-2' }) + batchAddEdges([{ id: 'e1', source: 'block-1', target: 'block-2' }]) clear() @@ -358,18 +360,18 @@ describe('workflow store', () => { }) }) - describe('toggleBlockEnabled', () => { + describe('batchToggleEnabled', () => { it('should toggle block enabled state', () => { - const { addBlock, toggleBlockEnabled } = useWorkflowStore.getState() + const { addBlock, batchToggleEnabled } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) expect(useWorkflowStore.getState().blocks['block-1'].enabled).toBe(true) - toggleBlockEnabled('block-1') + batchToggleEnabled(['block-1']) expect(useWorkflowStore.getState().blocks['block-1'].enabled).toBe(false) - toggleBlockEnabled('block-1') + batchToggleEnabled(['block-1']) expect(useWorkflowStore.getState().blocks['block-1'].enabled).toBe(true) }) }) @@ -398,13 +400,13 @@ describe('workflow store', () => { }) }) - describe('updateBlockPosition', () => { + describe('batchUpdatePositions', () => { it('should update block position', () => { - const { addBlock, updateBlockPosition } = useWorkflowStore.getState() + const { addBlock, batchUpdatePositions } = useWorkflowStore.getState() addBlock('block-1', 'function', 'Test', { x: 0, y: 0 }) - updateBlockPosition('block-1', { x: 100, y: 200 }) + batchUpdatePositions([{ id: 'block-1', position: { x: 100, y: 200 } }]) const { blocks } = useWorkflowStore.getState() expect(blocks['block-1'].position).toEqual({ x: 100, y: 200 }) diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts index d59c4cfc9b..398c662812 100644 --- a/apps/sim/stores/workflows/workflow/store.ts +++ b/apps/sim/stores/workflows/workflow/store.ts @@ -7,7 +7,6 @@ import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { TriggerUtils } from '@/lib/workflows/triggers/triggers' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' -import { isAnnotationOnlyBlock } from '@/executor/constants' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { getUniqueBlockName, mergeSubblockState, normalizeName } from '@/stores/workflows/utils' @@ -239,25 +238,12 @@ export const useWorkflowStore = create()( get().updateLastSaved() }, - updateBlockPosition: (id: string, position: Position) => { - set((state) => ({ - blocks: { - ...state.blocks, - [id]: { - ...state.blocks[id], - position, - }, - }, - })) - }, - updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => { set((state) => { - // Check if the block exists before trying to update it const block = state.blocks[id] if (!block) { logger.warn(`Cannot update dimensions: Block ${id} not found in workflow store`) - return state // Return unchanged state + return state } return { @@ -281,196 +267,290 @@ export const useWorkflowStore = create()( } }) get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically }, - updateParentId: (id: string, parentId: string, extent: 'parent') => { - const block = get().blocks[id] - if (!block) { - logger.warn(`Cannot set parent: Block ${id} not found`) - return + batchUpdateBlocksWithParent: ( + updates: Array<{ + id: string + position: { x: number; y: number } + parentId?: string + }> + ) => { + const currentBlocks = get().blocks + const newBlocks = { ...currentBlocks } + + for (const update of updates) { + const block = newBlocks[update.id] + if (!block) continue + + // Compute new data based on whether we're adding or removing a parent + let newData = block.data + if (update.parentId) { + // Adding/changing parent - set parentId and extent + newData = { ...block.data, parentId: update.parentId, extent: 'parent' as const } + } else if (block.data?.parentId) { + // Removing parent - clear parentId and extent + const { parentId: _removed, extent: _removedExtent, ...restData } = block.data + newData = restData + } + + newBlocks[update.id] = { + ...block, + position: update.position, + data: newData, + } } - if (parentId === id) { - logger.error('Blocked attempt to set block as its own parent', { blockId: id }) - return + set({ + blocks: newBlocks, + edges: [...get().edges], + loops: generateLoopBlocks(newBlocks), + parallels: generateParallelBlocks(newBlocks), + }) + }, + + batchUpdatePositions: (updates: Array<{ id: string; position: Position }>) => { + const newBlocks = { ...get().blocks } + for (const { id, position } of updates) { + if (newBlocks[id]) { + newBlocks[id] = { ...newBlocks[id], position } + } } + set({ blocks: newBlocks }) + }, - if (block.data?.parentId === parentId) { - return + batchAddBlocks: ( + blocks: Array<{ + id: string + type: string + name: string + position: Position + subBlocks: Record + outputs: Record + enabled: boolean + horizontalHandles?: boolean + advancedMode?: boolean + triggerMode?: boolean + height?: number + data?: Record + }>, + edges?: Edge[], + subBlockValues?: Record> + ) => { + const currentBlocks = get().blocks + const currentEdges = get().edges + const newBlocks = { ...currentBlocks } + const newEdges = [...currentEdges] + + for (const block of blocks) { + newBlocks[block.id] = { + id: block.id, + type: block.type, + name: block.name, + position: block.position, + subBlocks: block.subBlocks, + outputs: block.outputs, + enabled: block.enabled ?? true, + horizontalHandles: block.horizontalHandles ?? true, + advancedMode: block.advancedMode ?? false, + triggerMode: block.triggerMode ?? false, + height: block.height ?? 0, + data: block.data, + } } - const absolutePosition = { ...block.position } - const newData = !parentId - ? {} - : { - ...block.data, - parentId, - extent, + if (edges && edges.length > 0) { + const existingEdgeIds = new Set(currentEdges.map((e) => e.id)) + for (const edge of edges) { + if (!existingEdgeIds.has(edge.id)) { + newEdges.push({ + id: edge.id || crypto.randomUUID(), + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: edge.type || 'default', + data: edge.data || {}, + }) } + } + } - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...block, - position: absolutePosition, - data: newData, - }, - }, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: { ...get().parallels }, + set({ + blocks: newBlocks, + edges: newEdges, + loops: generateLoopBlocks(newBlocks), + parallels: generateParallelBlocks(newBlocks), + }) + + if (subBlockValues && Object.keys(subBlockValues).length > 0) { + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (activeWorkflowId) { + const subBlockStore = useSubBlockStore.getState() + const updatedWorkflowValues = { + ...(subBlockStore.workflowValues[activeWorkflowId] || {}), + } + + for (const [blockId, values] of Object.entries(subBlockValues)) { + updatedWorkflowValues[blockId] = { + ...(updatedWorkflowValues[blockId] || {}), + ...values, + } + } + + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: updatedWorkflowValues, + }, + })) + } } - set(newState) get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically }, - removeBlock: (id: string) => { - // First, clean up any subblock values for this block - const subBlockStore = useSubBlockStore.getState() - const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId - - const newState = { - blocks: { ...get().blocks }, - edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id), - loops: { ...get().loops }, - parallels: { ...get().parallels }, - } + batchRemoveBlocks: (ids: string[]) => { + const currentBlocks = get().blocks + const currentEdges = get().edges + const newBlocks = { ...currentBlocks } - // Find and remove all child blocks if this is a parent node - const blocksToRemove = new Set([id]) + const blocksToRemove = new Set(ids) - // Recursively find all descendant blocks (children, grandchildren, etc.) const findAllDescendants = (parentId: string) => { - Object.entries(newState.blocks).forEach(([blockId, block]) => { + Object.entries(newBlocks).forEach(([blockId, block]) => { if (block.data?.parentId === parentId) { blocksToRemove.add(blockId) - // Recursively find this block's children findAllDescendants(blockId) } }) } - // Start recursive search from the target block - findAllDescendants(id) + for (const id of ids) { + findAllDescendants(id) + } - logger.info('Found blocks to remove:', { - targetId: id, - totalBlocksToRemove: Array.from(blocksToRemove), - includesHierarchy: blocksToRemove.size > 1, + const newEdges = currentEdges.filter( + (edge) => !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target) + ) + + blocksToRemove.forEach((blockId) => { + delete newBlocks[blockId] }) - // Clean up subblock values before removing the block - if (activeWorkflowId && subBlockStore.workflowValues) { - const updatedWorkflowValues = { - ...(subBlockStore.workflowValues[activeWorkflowId] || {}), - } + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (activeWorkflowId) { + const subBlockStore = useSubBlockStore.getState() + if (subBlockStore.workflowValues[activeWorkflowId]) { + const updatedWorkflowValues = { + ...subBlockStore.workflowValues[activeWorkflowId], + } - // Remove values for all blocks being deleted - blocksToRemove.forEach((blockId) => { - delete updatedWorkflowValues[blockId] - }) + blocksToRemove.forEach((blockId) => { + delete updatedWorkflowValues[blockId] + }) - // Update subblock store - useSubBlockStore.setState((state) => ({ - workflowValues: { - ...state.workflowValues, - [activeWorkflowId]: updatedWorkflowValues, - }, - })) + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: updatedWorkflowValues, + }, + })) + } } - // Remove all edges connected to any of the blocks being removed - newState.edges = newState.edges.filter( - (edge) => !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target) - ) - - // Delete all blocks marked for removal - blocksToRemove.forEach((blockId) => { - delete newState.blocks[blockId] + set({ + blocks: newBlocks, + edges: newEdges, + loops: generateLoopBlocks(newBlocks), + parallels: generateParallelBlocks(newBlocks), }) - set(newState) get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically }, - addEdge: (edge: Edge) => { - // Prevent connections to/from annotation-only blocks (non-executable) - const sourceBlock = get().blocks[edge.source] - const targetBlock = get().blocks[edge.target] - - if (isAnnotationOnlyBlock(sourceBlock?.type) || isAnnotationOnlyBlock(targetBlock?.type)) { - return + batchToggleEnabled: (ids: string[]) => { + const newBlocks = { ...get().blocks } + for (const id of ids) { + if (newBlocks[id]) { + newBlocks[id] = { ...newBlocks[id], enabled: !newBlocks[id].enabled } + } } + set({ blocks: newBlocks, edges: [...get().edges] }) + get().updateLastSaved() + }, - // Prevent self-connections and cycles - if (wouldCreateCycle(get().edges, edge.source, edge.target)) { - logger.warn('Prevented edge that would create a cycle', { - source: edge.source, - target: edge.target, - }) - return + batchToggleHandles: (ids: string[]) => { + const newBlocks = { ...get().blocks } + for (const id of ids) { + if (newBlocks[id]) { + newBlocks[id] = { + ...newBlocks[id], + horizontalHandles: !newBlocks[id].horizontalHandles, + } + } } + set({ blocks: newBlocks, edges: [...get().edges] }) + get().updateLastSaved() + }, - // Check for duplicate connections - const isDuplicate = get().edges.some( - (existingEdge) => - existingEdge.source === edge.source && - existingEdge.target === edge.target && - existingEdge.sourceHandle === edge.sourceHandle && - existingEdge.targetHandle === edge.targetHandle - ) + batchAddEdges: (edges: Edge[]) => { + const currentEdges = get().edges + const newEdges = [...currentEdges] + const existingEdgeIds = new Set(currentEdges.map((e) => e.id)) + // Track existing connections to prevent duplicates (same source->target) + const existingConnections = new Set(currentEdges.map((e) => `${e.source}->${e.target}`)) - // If it's a duplicate connection, return early without adding the edge - if (isDuplicate) { - return - } + for (const edge of edges) { + // Skip if edge ID already exists + if (existingEdgeIds.has(edge.id)) continue - const newEdge: Edge = { - id: edge.id || crypto.randomUUID(), - source: edge.source, - target: edge.target, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, - type: edge.type || 'default', - data: edge.data || {}, - } + // Skip self-referencing edges + if (edge.source === edge.target) continue - const newEdges = [...get().edges, newEdge] + // Skip if connection already exists (same source and target) + const connectionKey = `${edge.source}->${edge.target}` + if (existingConnections.has(connectionKey)) continue - const newState = { - blocks: { ...get().blocks }, - edges: newEdges, - loops: generateLoopBlocks(get().blocks), - parallels: get().generateParallelBlocks(), + // Skip if would create a cycle + if (wouldCreateCycle([...newEdges], edge.source, edge.target)) continue + + newEdges.push({ + id: edge.id || crypto.randomUUID(), + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + type: edge.type || 'default', + data: edge.data || {}, + }) + existingEdgeIds.add(edge.id) + existingConnections.add(connectionKey) } - set(newState) + const blocks = get().blocks + set({ + blocks: { ...blocks }, + edges: newEdges, + loops: generateLoopBlocks(blocks), + parallels: generateParallelBlocks(blocks), + }) + get().updateLastSaved() }, - removeEdge: (edgeId: string) => { - // Validate the edge exists - const edgeToRemove = get().edges.find((edge) => edge.id === edgeId) - if (!edgeToRemove) { - logger.warn(`Attempted to remove non-existent edge: ${edgeId}`) - return - } - - const newEdges = get().edges.filter((edge) => edge.id !== edgeId) + batchRemoveEdges: (ids: string[]) => { + const idsSet = new Set(ids) + const newEdges = get().edges.filter((e) => !idsSet.has(e.id)) + const blocks = get().blocks - const newState = { - blocks: { ...get().blocks }, + set({ + blocks: { ...blocks }, edges: newEdges, - loops: generateLoopBlocks(get().blocks), - parallels: get().generateParallelBlocks(), - } + loops: generateLoopBlocks(blocks), + parallels: generateParallelBlocks(blocks), + }) - set(newState) get().updateLastSaved() }, @@ -483,16 +563,13 @@ export const useWorkflowStore = create()( lastSaved: Date.now(), } set(newState) - // Note: Socket.IO handles real-time sync automatically return newState }, updateLastSaved: () => { set({ lastSaved: Date.now() }) - // Note: Socket.IO handles real-time sync automatically }, - // Add method to get current workflow state (eliminates duplication in diff store) getWorkflowState: (): WorkflowState => { const state = get() return { @@ -540,25 +617,6 @@ export const useWorkflowStore = create()( }) }, - toggleBlockEnabled: (id: string) => { - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...get().blocks[id], - enabled: !get().blocks[id].enabled, - }, - }, - edges: [...get().edges], - loops: { ...get().loops }, - parallels: { ...get().parallels }, - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - setBlockEnabled: (id: string, enabled: boolean) => { const block = get().blocks[id] if (!block || block.enabled === enabled) return @@ -592,10 +650,8 @@ export const useWorkflowStore = create()( const newName = getUniqueBlockName(block.name, get().blocks) - // Get merged state to capture current subblock values const mergedBlock = mergeSubblockState(get().blocks, id)[id] - // Create new subblocks with merged values const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce( (acc, [subId, subBlock]) => ({ ...acc, @@ -623,7 +679,6 @@ export const useWorkflowStore = create()( parallels: get().generateParallelBlocks(), } - // Update the subblock store with the duplicated values const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId if (activeWorkflowId) { const subBlockValues = @@ -641,25 +696,6 @@ export const useWorkflowStore = create()( set(newState) get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically - }, - - toggleBlockHandles: (id: string) => { - const newState = { - blocks: { - ...get().blocks, - [id]: { - ...get().blocks[id], - horizontalHandles: !get().blocks[id].horizontalHandles, - }, - }, - edges: [...get().edges], - loops: { ...get().loops }, - } - - set(newState) - get().updateLastSaved() - // Note: Socket.IO handles real-time sync automatically }, setBlockHandles: (id: string, horizontalHandles: boolean) => { @@ -705,7 +741,6 @@ export const useWorkflowStore = create()( return { success: false, changedSubblocks: [] } } - // Create a new state with the updated block name const newState = { blocks: { ...get().blocks, diff --git a/apps/sim/stores/workflows/workflow/types.ts b/apps/sim/stores/workflows/workflow/types.ts index 2488c5e43c..43afa31a66 100644 --- a/apps/sim/stores/workflows/workflow/types.ts +++ b/apps/sim/stores/workflows/workflow/types.ts @@ -186,18 +186,29 @@ export interface WorkflowActions { height?: number } ) => void - updateBlockPosition: (id: string, position: Position) => void updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void - updateParentId: (id: string, parentId: string, extent: 'parent') => void - removeBlock: (id: string) => void - addEdge: (edge: Edge) => void - removeEdge: (edgeId: string) => void + batchUpdateBlocksWithParent: ( + updates: Array<{ + id: string + position: { x: number; y: number } + parentId?: string + }> + ) => void + batchUpdatePositions: (updates: Array<{ id: string; position: Position }>) => void + batchAddBlocks: ( + blocks: BlockState[], + edges?: Edge[], + subBlockValues?: Record> + ) => void + batchRemoveBlocks: (ids: string[]) => void + batchToggleEnabled: (ids: string[]) => void + batchToggleHandles: (ids: string[]) => void + batchAddEdges: (edges: Edge[]) => void + batchRemoveEdges: (ids: string[]) => void clear: () => Partial updateLastSaved: () => void - toggleBlockEnabled: (id: string) => void setBlockEnabled: (id: string, enabled: boolean) => void duplicateBlock: (id: string) => void - toggleBlockHandles: (id: string) => void setBlockHandles: (id: string, horizontalHandles: boolean) => void updateBlockName: ( id: string, diff --git a/packages/testing/src/factories/permission.factory.ts b/packages/testing/src/factories/permission.factory.ts index 2e8f840b9a..5680cdaaa5 100644 --- a/packages/testing/src/factories/permission.factory.ts +++ b/packages/testing/src/factories/permission.factory.ts @@ -252,24 +252,54 @@ export function createWorkflowAccessContext(options: { } /** - * All socket operations that can be performed. + * Socket operations + */ +const BLOCK_OPERATIONS = { + UPDATE_POSITION: 'update-position', + UPDATE_NAME: 'update-name', + TOGGLE_ENABLED: 'toggle-enabled', + UPDATE_PARENT: 'update-parent', + UPDATE_ADVANCED_MODE: 'update-advanced-mode', + TOGGLE_HANDLES: 'toggle-handles', +} as const + +const BLOCKS_OPERATIONS = { + BATCH_UPDATE_POSITIONS: 'batch-update-positions', + BATCH_ADD_BLOCKS: 'batch-add-blocks', + BATCH_REMOVE_BLOCKS: 'batch-remove-blocks', + BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled', + BATCH_TOGGLE_HANDLES: 'batch-toggle-handles', + BATCH_UPDATE_PARENT: 'batch-update-parent', +} as const + +const EDGE_OPERATIONS = { + ADD: 'add', + REMOVE: 'remove', +} as const + +const EDGES_OPERATIONS = { + BATCH_ADD_EDGES: 'batch-add-edges', + BATCH_REMOVE_EDGES: 'batch-remove-edges', +} as const + +const SUBFLOW_OPERATIONS = { + UPDATE: 'update', +} as const + +const WORKFLOW_OPERATIONS = { + REPLACE_STATE: 'replace-state', +} as const + +/** + * All socket operations that require permission checks. */ export const SOCKET_OPERATIONS = [ - 'add', - 'remove', - 'batch-add-blocks', - 'batch-remove-blocks', - 'update', - 'update-position', - 'update-name', - 'toggle-enabled', - 'update-parent', - 'update-wide', - 'update-advanced-mode', - 'update-trigger-mode', - 'toggle-handles', - 'batch-update-positions', - 'replace-state', + ...Object.values(BLOCK_OPERATIONS), + ...Object.values(BLOCKS_OPERATIONS), + ...Object.values(EDGE_OPERATIONS), + ...Object.values(EDGES_OPERATIONS), + ...Object.values(SUBFLOW_OPERATIONS), + ...Object.values(WORKFLOW_OPERATIONS), ] as const export type SocketOperation = (typeof SOCKET_OPERATIONS)[number] @@ -277,10 +307,10 @@ export type SocketOperation = (typeof SOCKET_OPERATIONS)[number] /** * Operations allowed for each role. */ -export const ROLE_ALLOWED_OPERATIONS: Record = { - admin: [...SOCKET_OPERATIONS], - write: [...SOCKET_OPERATIONS], - read: ['update-position', 'batch-update-positions'], +export const ROLE_ALLOWED_OPERATIONS: Record = { + admin: SOCKET_OPERATIONS, + write: SOCKET_OPERATIONS, + read: [BLOCK_OPERATIONS.UPDATE_POSITION, BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS], } /**