mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(ops): fix subflow resizing on exit (#2760)
* fix(sockets): broadcast handles and enabled/disabled state * made all ops batched, removed all individual ops * fix subflow resizing on exit * removed unused custom event * fix failing tests, update testing * fix test mock
This commit is contained in:
@@ -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<string | null>(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
|
||||
|
||||
@@ -373,16 +373,20 @@ export function useNodeUtilities(blocks: Record<string, any>) {
|
||||
* 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<string, any>) {
|
||||
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()
|
||||
|
||||
@@ -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 } }))
|
||||
const edgeIds = edgesToRemove.map((edge) => edge.id)
|
||||
collaborativeBatchRemoveEdges(edgeIds, { skipUndoRedo: true })
|
||||
|
||||
try {
|
||||
edgesToRemove.forEach((edge) => {
|
||||
removeEdge(edge.id)
|
||||
})
|
||||
|
||||
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<string, { x: number; y: number }>()
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string | null>(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,68 +347,15 @@ 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,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
;(edges || []).forEach((edge: Edge) => {
|
||||
workflowStore.addEdge(edge)
|
||||
})
|
||||
|
||||
if (loops) {
|
||||
Object.entries(loops as Record<string, Loop>).forEach(([loopId, loopConfig]) => {
|
||||
useWorkflowStore.setState((state) => ({
|
||||
loops: { ...state.loops, [loopId]: loopConfig },
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
if (parallels) {
|
||||
Object.entries(parallels as Record<string, Parallel>).forEach(
|
||||
([parallelId, parallelConfig]) => {
|
||||
useWorkflowStore.setState((state) => ({
|
||||
parallels: { ...state.parallels, [parallelId]: parallelConfig },
|
||||
}))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (addedSubBlockValues && activeWorkflowId) {
|
||||
Object.entries(
|
||||
addedSubBlockValues as Record<string, Record<string, unknown>>
|
||||
).forEach(([blockId, subBlocks]) => {
|
||||
Object.entries(subBlocks).forEach(([subBlockId, value]) => {
|
||||
subBlockStore.setValue(blockId, subBlockId, value)
|
||||
})
|
||||
})
|
||||
if (blocks && blocks.length > 0) {
|
||||
workflowStore.batchAddBlocks(blocks, edges || [], addedSubBlockValues || {})
|
||||
}
|
||||
|
||||
logger.info('Successfully applied batch-add-blocks from remote user')
|
||||
@@ -507,13 +368,63 @@ export function useCollaborativeWorkflow() {
|
||||
count: (ids || []).length,
|
||||
})
|
||||
|
||||
;(ids || []).forEach((id: string) => {
|
||||
workflowStore.removeBlock(id)
|
||||
})
|
||||
if (ids && ids.length > 0) {
|
||||
workflowStore.batchRemoveBlocks(ids)
|
||||
}
|
||||
|
||||
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 (blockIds && blockIds.length > 0) {
|
||||
workflowStore.batchToggleEnabled(blockIds)
|
||||
}
|
||||
|
||||
logger.info('Successfully applied batch-toggle-enabled from remote user')
|
||||
break
|
||||
}
|
||||
case BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES: {
|
||||
const { blockIds } = payload
|
||||
logger.info('Received batch-toggle-handles from remote user', {
|
||||
userId,
|
||||
count: (blockIds || []).length,
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
if (edges.length === 0) return false
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: EDGES_OPERATIONS.BATCH_ADD_EDGES,
|
||||
target: OPERATION_TARGETS.EDGES,
|
||||
payload: { edges },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
workflowStore.batchAddEdges(edges)
|
||||
|
||||
if (!options?.skipUndoRedo) {
|
||||
edges.forEach((edge) => undoRedo.recordAddEdge(edge.id))
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
[executeQueuedOperation, workflowStore, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeRemoveEdge = useCallback(
|
||||
(edgeId: string) => {
|
||||
const edge = workflowStore.edges.find((e) => e.id === edgeId)
|
||||
|
||||
if (!edge) {
|
||||
logger.debug('Edge already removed, skipping operation', { edgeId })
|
||||
return
|
||||
}
|
||||
|
||||
const sourceExists = workflowStore.blocks[edge.source]
|
||||
const targetExists = workflowStore.blocks[edge.target]
|
||||
|
||||
if (!sourceExists || !targetExists) {
|
||||
logger.debug('Edge source or target block no longer exists, skipping operation', {
|
||||
edgeId,
|
||||
sourceExists: !!sourceExists,
|
||||
targetExists: !!targetExists,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!skipEdgeRecording.current) {
|
||||
undoRedo.recordBatchRemoveEdges([edge])
|
||||
}
|
||||
|
||||
executeQueuedOperation(EDGE_OPERATIONS.REMOVE, OPERATION_TARGETS.EDGE, { id: edgeId }, () =>
|
||||
workflowStore.removeEdge(edgeId)
|
||||
)
|
||||
},
|
||||
[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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// Update position and parent locally
|
||||
workflowStore.updateBlockPosition(blockId, newPosition)
|
||||
workflowStore.updateParentId(blockId, newParentId || '', 'parent')
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<string, string[]> = {
|
||||
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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<WorkflowStore>()(
|
||||
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<WorkflowStore>()(
|
||||
}
|
||||
})
|
||||
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 })
|
||||
},
|
||||
|
||||
batchAddBlocks: (
|
||||
blocks: Array<{
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
position: Position
|
||||
subBlocks: Record<string, SubBlockState>
|
||||
outputs: Record<string, any>
|
||||
enabled: boolean
|
||||
horizontalHandles?: boolean
|
||||
advancedMode?: boolean
|
||||
triggerMode?: boolean
|
||||
height?: number
|
||||
data?: Record<string, any>
|
||||
}>,
|
||||
edges?: Edge[],
|
||||
subBlockValues?: Record<string, Record<string, unknown>>
|
||||
) => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
if (block.data?.parentId === parentId) {
|
||||
return
|
||||
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 absolutePosition = { ...block.position }
|
||||
const newData = !parentId
|
||||
? {}
|
||||
: {
|
||||
...block.data,
|
||||
parentId,
|
||||
extent,
|
||||
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] || {}),
|
||||
}
|
||||
|
||||
const newState = {
|
||||
blocks: {
|
||||
...get().blocks,
|
||||
[id]: {
|
||||
...block,
|
||||
position: absolutePosition,
|
||||
data: newData,
|
||||
},
|
||||
},
|
||||
edges: [...get().edges],
|
||||
loops: { ...get().loops },
|
||||
parallels: { ...get().parallels },
|
||||
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
|
||||
batchRemoveBlocks: (ids: string[]) => {
|
||||
const currentBlocks = get().blocks
|
||||
const currentEdges = get().edges
|
||||
const newBlocks = { ...currentBlocks }
|
||||
|
||||
const newState = {
|
||||
blocks: { ...get().blocks },
|
||||
edges: [...get().edges].filter((edge) => edge.source !== id && edge.target !== id),
|
||||
loops: { ...get().loops },
|
||||
parallels: { ...get().parallels },
|
||||
}
|
||||
const blocksToRemove = new Set<string>(ids)
|
||||
|
||||
// Find and remove all child blocks if this is a parent node
|
||||
const blocksToRemove = new Set([id])
|
||||
|
||||
// Recursively find all descendant blocks (children, grandchildren, etc.)
|
||||
const findAllDescendants = (parentId: string) => {
|
||||
Object.entries(newState.blocks).forEach(([blockId, block]) => {
|
||||
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)
|
||||
|
||||
logger.info('Found blocks to remove:', {
|
||||
targetId: id,
|
||||
totalBlocksToRemove: Array.from(blocksToRemove),
|
||||
includesHierarchy: blocksToRemove.size > 1,
|
||||
})
|
||||
|
||||
// Clean up subblock values before removing the block
|
||||
if (activeWorkflowId && subBlockStore.workflowValues) {
|
||||
const updatedWorkflowValues = {
|
||||
...(subBlockStore.workflowValues[activeWorkflowId] || {}),
|
||||
}
|
||||
|
||||
// Remove values for all blocks being deleted
|
||||
blocksToRemove.forEach((blockId) => {
|
||||
delete updatedWorkflowValues[blockId]
|
||||
})
|
||||
|
||||
// Update subblock store
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: updatedWorkflowValues,
|
||||
},
|
||||
}))
|
||||
for (const id of ids) {
|
||||
findAllDescendants(id)
|
||||
}
|
||||
|
||||
// Remove all edges connected to any of the blocks being removed
|
||||
newState.edges = newState.edges.filter(
|
||||
const newEdges = currentEdges.filter(
|
||||
(edge) => !blocksToRemove.has(edge.source) && !blocksToRemove.has(edge.target)
|
||||
)
|
||||
|
||||
// Delete all blocks marked for removal
|
||||
blocksToRemove.forEach((blockId) => {
|
||||
delete newState.blocks[blockId]
|
||||
delete newBlocks[blockId]
|
||||
})
|
||||
|
||||
set(newState)
|
||||
get().updateLastSaved()
|
||||
// Note: Socket.IO handles real-time sync automatically
|
||||
},
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
if (activeWorkflowId) {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
if (subBlockStore.workflowValues[activeWorkflowId]) {
|
||||
const updatedWorkflowValues = {
|
||||
...subBlockStore.workflowValues[activeWorkflowId],
|
||||
}
|
||||
|
||||
addEdge: (edge: Edge) => {
|
||||
// Prevent connections to/from annotation-only blocks (non-executable)
|
||||
const sourceBlock = get().blocks[edge.source]
|
||||
const targetBlock = get().blocks[edge.target]
|
||||
blocksToRemove.forEach((blockId) => {
|
||||
delete updatedWorkflowValues[blockId]
|
||||
})
|
||||
|
||||
if (isAnnotationOnlyBlock(sourceBlock?.type) || isAnnotationOnlyBlock(targetBlock?.type)) {
|
||||
return
|
||||
useSubBlockStore.setState((state) => ({
|
||||
workflowValues: {
|
||||
...state.workflowValues,
|
||||
[activeWorkflowId]: updatedWorkflowValues,
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent self-connections and cycles
|
||||
if (wouldCreateCycle(get().edges, edge.source, edge.target)) {
|
||||
logger.warn('Prevented edge that would create a cycle', {
|
||||
set({
|
||||
blocks: newBlocks,
|
||||
edges: newEdges,
|
||||
loops: generateLoopBlocks(newBlocks),
|
||||
parallels: generateParallelBlocks(newBlocks),
|
||||
})
|
||||
|
||||
get().updateLastSaved()
|
||||
},
|
||||
|
||||
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()
|
||||
},
|
||||
|
||||
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()
|
||||
},
|
||||
|
||||
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}`))
|
||||
|
||||
for (const edge of edges) {
|
||||
// Skip if edge ID already exists
|
||||
if (existingEdgeIds.has(edge.id)) continue
|
||||
|
||||
// Skip self-referencing edges
|
||||
if (edge.source === edge.target) continue
|
||||
|
||||
// Skip if connection already exists (same source and target)
|
||||
const connectionKey = `${edge.source}->${edge.target}`
|
||||
if (existingConnections.has(connectionKey)) continue
|
||||
|
||||
// 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 || {},
|
||||
})
|
||||
return
|
||||
existingEdgeIds.add(edge.id)
|
||||
existingConnections.add(connectionKey)
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// If it's a duplicate connection, return early without adding the edge
|
||||
if (isDuplicate) {
|
||||
return
|
||||
}
|
||||
|
||||
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 || {},
|
||||
}
|
||||
|
||||
const newEdges = [...get().edges, newEdge]
|
||||
|
||||
const newState = {
|
||||
blocks: { ...get().blocks },
|
||||
const 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()
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
batchRemoveEdges: (ids: string[]) => {
|
||||
const idsSet = new Set(ids)
|
||||
const newEdges = get().edges.filter((e) => !idsSet.has(e.id))
|
||||
const blocks = get().blocks
|
||||
|
||||
const newEdges = get().edges.filter((edge) => edge.id !== edgeId)
|
||||
|
||||
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<WorkflowStore>()(
|
||||
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<WorkflowStore>()(
|
||||
})
|
||||
},
|
||||
|
||||
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<WorkflowStore>()(
|
||||
|
||||
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<WorkflowStore>()(
|
||||
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<WorkflowStore>()(
|
||||
|
||||
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<WorkflowStore>()(
|
||||
return { success: false, changedSubblocks: [] }
|
||||
}
|
||||
|
||||
// Create a new state with the updated block name
|
||||
const newState = {
|
||||
blocks: {
|
||||
...get().blocks,
|
||||
|
||||
@@ -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<string, Record<string, unknown>>
|
||||
) => void
|
||||
batchRemoveBlocks: (ids: string[]) => void
|
||||
batchToggleEnabled: (ids: string[]) => void
|
||||
batchToggleHandles: (ids: string[]) => void
|
||||
batchAddEdges: (edges: Edge[]) => void
|
||||
batchRemoveEdges: (ids: string[]) => void
|
||||
clear: () => Partial<WorkflowState>
|
||||
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,
|
||||
|
||||
@@ -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<PermissionType, SocketOperation[]> = {
|
||||
admin: [...SOCKET_OPERATIONS],
|
||||
write: [...SOCKET_OPERATIONS],
|
||||
read: ['update-position', 'batch-update-positions'],
|
||||
export const ROLE_ALLOWED_OPERATIONS: Record<PermissionType, readonly SocketOperation[]> = {
|
||||
admin: SOCKET_OPERATIONS,
|
||||
write: SOCKET_OPERATIONS,
|
||||
read: [BLOCK_OPERATIONS.UPDATE_POSITION, BLOCKS_OPERATIONS.BATCH_UPDATE_POSITIONS],
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user