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