Compare commits

...

6 Commits

Author SHA1 Message Date
waleed
276ce665e4 ack comments 2026-01-27 14:50:57 -08:00
waleed
08bad4da9f ack comments 2026-01-27 14:38:38 -08:00
waleed
37dbfe393a fix(workflow): preserve parent and position when duplicating/pasting nested blocks
Three related fixes for blocks inside containers (loop/parallel):

1. regenerateBlockIds now preserves parentId when the parent exists in
   the current workflow, not just when it's in the copy set. This keeps
   duplicated blocks inside their container.

2. calculatePasteOffset now uses simple offset for nested blocks instead
   of viewport-center calculation. Since nested blocks use relative
   positioning, the viewport-center offset would place them incorrectly.

3. Use CONTAINER_DIMENSIONS constants instead of hardcoded magic numbers
   in orphan cleanup position calculation.
2026-01-27 14:17:16 -08:00
waleed
503f676910 fix(store): clear extent property when orphaning blocks
When a block's parent is removed, properly clear both parentId and extent
properties from block.data, matching the pattern used in batchUpdateBlocksWithParent.
2026-01-27 14:10:55 -08:00
waleed
f2ca90ae6f refactor(store): remove unused workflow store functions
Remove redundant functions superseded by collaborative workflow patterns:
- duplicateBlock (superseded by collaborative paste flow)
- toggleBlockAdvancedMode (superseded by setBlockAdvancedMode)
- updateLoopCollection (redundant wrapper)
- setBlockTriggerMode (unused)
- generateLoopBlocks/generateParallelBlocks methods (called directly as utils)

Also removes ~160 lines of related tests and cleans up unused imports.
2026-01-27 14:10:55 -08:00
waleed
fe4fd47b9d improvement(workflow): remove useEffect anti-patterns 2026-01-27 14:10:55 -08:00
4 changed files with 131 additions and 417 deletions

View File

@@ -99,19 +99,33 @@ const logger = createLogger('Workflow')
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 } const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
/** /**
* Calculates the offset to paste blocks at viewport center * Calculates the offset to paste blocks at viewport center, or simple offset for nested blocks
*/ */
function calculatePasteOffset( function calculatePasteOffset(
clipboard: { clipboard: {
blocks: Record<string, { position: { x: number; y: number }; type: string; height?: number }> blocks: Record<
string,
{
position: { x: number; y: number }
type: string
height?: number
data?: { parentId?: string }
}
>
} | null, } | null,
viewportCenter: { x: number; y: number } viewportCenter: { x: number; y: number },
existingBlocks: Record<string, { id: string }> = {}
): { x: number; y: number } { ): { x: number; y: number } {
if (!clipboard) return DEFAULT_PASTE_OFFSET if (!clipboard) return DEFAULT_PASTE_OFFSET
const clipboardBlocks = Object.values(clipboard.blocks) const clipboardBlocks = Object.values(clipboard.blocks)
if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET if (clipboardBlocks.length === 0) return DEFAULT_PASTE_OFFSET
const allBlocksNested = clipboardBlocks.every(
(b) => b.data?.parentId && existingBlocks[b.data.parentId]
)
if (allBlocksNested) return DEFAULT_PASTE_OFFSET
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x)) const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
const maxX = Math.max( const maxX = Math.max(
...clipboardBlocks.map((b) => { ...clipboardBlocks.map((b) => {
@@ -307,9 +321,7 @@ const WorkflowContent = React.memo(() => {
const isAutoConnectEnabled = useAutoConnect() const isAutoConnectEnabled = useAutoConnect()
const autoConnectRef = useRef(isAutoConnectEnabled) const autoConnectRef = useRef(isAutoConnectEnabled)
useEffect(() => {
autoConnectRef.current = isAutoConnectEnabled autoConnectRef.current = isAutoConnectEnabled
}, [isAutoConnectEnabled])
// Panel open states for context menu // Panel open states for context menu
const isVariablesOpen = useVariablesStore((state) => state.isOpen) const isVariablesOpen = useVariablesStore((state) => state.isOpen)
@@ -448,11 +460,16 @@ const WorkflowContent = React.memo(() => {
) )
/** Re-applies diff markers when blocks change after socket rehydration. */ /** Re-applies diff markers when blocks change after socket rehydration. */
const blocksRef = useRef(blocks) const diffBlocksRef = useRef(blocks)
useEffect(() => { useEffect(() => {
if (!isWorkflowReady) return if (!isWorkflowReady) return
if (hasActiveDiff && isDiffReady && blocks !== blocksRef.current) {
blocksRef.current = blocks const blocksChanged = blocks !== diffBlocksRef.current
if (!blocksChanged) return
diffBlocksRef.current = blocks
if (hasActiveDiff && isDiffReady) {
setTimeout(() => reapplyDiffMarkers(), 0) setTimeout(() => reapplyDiffMarkers(), 0)
} }
}, [blocks, hasActiveDiff, isDiffReady, reapplyDiffMarkers, isWorkflowReady]) }, [blocks, hasActiveDiff, isDiffReady, reapplyDiffMarkers, isWorkflowReady])
@@ -515,8 +532,7 @@ const WorkflowContent = React.memo(() => {
}) })
}, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks]) }, [edges, isShowingDiff, isDiffReady, diffAnalysis, blocks])
const { userPermissions, workspacePermissions, permissionsError } = const { userPermissions } = useWorkspacePermissionsContext()
useWorkspacePermissionsContext()
/** Returns read-only permissions when viewing snapshot, otherwise user permissions. */ /** Returns read-only permissions when viewing snapshot, otherwise user permissions. */
const effectivePermissions = useMemo(() => { const effectivePermissions = useMemo(() => {
@@ -752,25 +768,6 @@ const WorkflowContent = React.memo(() => {
[isErrorConnectionDrag] [isErrorConnectionDrag]
) )
/** Logs permission loading results for debugging. */
useEffect(() => {
if (permissionsError) {
logger.error('Failed to load workspace permissions', {
workspaceId,
error: permissionsError,
})
} else if (workspacePermissions) {
logger.info('Workspace permissions loaded in workflow', {
workspaceId,
userCount: workspacePermissions.total,
permissions: workspacePermissions.users.map((u) => ({
email: u.email,
permissions: u.permissionType,
})),
})
}
}, [workspacePermissions, permissionsError, workspaceId])
const updateNodeParent = useCallback( const updateNodeParent = useCallback(
(nodeId: string, newParentId: string | null, affectedEdges: any[] = []) => { (nodeId: string, newParentId: string | null, affectedEdges: any[] = []) => {
const node = getNodes().find((n: any) => n.id === nodeId) const node = getNodes().find((n: any) => n.id === nodeId)
@@ -1042,7 +1039,7 @@ const WorkflowContent = React.memo(() => {
executePasteOperation( executePasteOperation(
'paste', 'paste',
calculatePasteOffset(clipboard, getViewportCenter()), calculatePasteOffset(clipboard, getViewportCenter(), blocks),
targetContainer, targetContainer,
flowPosition // Pass the click position so blocks are centered at where user right-clicked flowPosition // Pass the click position so blocks are centered at where user right-clicked
) )
@@ -1054,6 +1051,7 @@ const WorkflowContent = React.memo(() => {
screenToFlowPosition, screenToFlowPosition,
contextMenuPosition, contextMenuPosition,
isPointInLoopNode, isPointInLoopNode,
blocks,
]) ])
const handleContextDuplicate = useCallback(() => { const handleContextDuplicate = useCallback(() => {
@@ -1164,7 +1162,10 @@ const WorkflowContent = React.memo(() => {
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') { } else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
if (effectivePermissions.canEdit && hasClipboard()) { if (effectivePermissions.canEdit && hasClipboard()) {
event.preventDefault() event.preventDefault()
executePasteOperation('paste', calculatePasteOffset(clipboard, getViewportCenter())) executePasteOperation(
'paste',
calculatePasteOffset(clipboard, getViewportCenter(), blocks)
)
} }
} }
} }
@@ -1186,6 +1187,7 @@ const WorkflowContent = React.memo(() => {
clipboard, clipboard,
getViewportCenter, getViewportCenter,
executePasteOperation, executePasteOperation,
blocks,
]) ])
/** /**
@@ -2160,6 +2162,8 @@ const WorkflowContent = React.memo(() => {
// Local state for nodes - allows smooth drag without store updates on every frame // Local state for nodes - allows smooth drag without store updates on every frame
const [displayNodes, setDisplayNodes] = useState<Node[]>([]) const [displayNodes, setDisplayNodes] = useState<Node[]>([])
// Sync derivedNodes to displayNodes while preserving selection state
// This effect handles both normal sync and pending selection from paste/duplicate
useEffect(() => { useEffect(() => {
// Check for pending selection (from paste/duplicate), otherwise preserve existing selection // Check for pending selection (from paste/duplicate), otherwise preserve existing selection
if (pendingSelection && pendingSelection.length > 0) { if (pendingSelection && pendingSelection.length > 0) {
@@ -2189,7 +2193,6 @@ const WorkflowContent = React.memo(() => {
}, [derivedNodes, blocks, pendingSelection, clearPendingSelection]) }, [derivedNodes, blocks, pendingSelection, clearPendingSelection])
// Phase 2: When displayNodes updates, check if pending zoom blocks are ready // Phase 2: When displayNodes updates, check if pending zoom blocks are ready
// (Phase 1 is located earlier in the file where pendingZoomBlockIdsRef is defined)
useEffect(() => { useEffect(() => {
const pendingBlockIds = pendingZoomBlockIdsRef.current const pendingBlockIds = pendingZoomBlockIdsRef.current
if (!pendingBlockIds || pendingBlockIds.size === 0) { if (!pendingBlockIds || pendingBlockIds.size === 0) {
@@ -2380,40 +2383,6 @@ const WorkflowContent = React.memo(() => {
resizeLoopNodesWrapper() resizeLoopNodesWrapper()
}, [derivedNodes, resizeLoopNodesWrapper, isWorkflowReady]) }, [derivedNodes, resizeLoopNodesWrapper, isWorkflowReady])
/** Cleans up orphaned nodes with invalid parent references after deletion. */
useEffect(() => {
if (!isWorkflowReady) return
// Create a mapping of node IDs to check for missing parent references
const nodeIds = new Set(Object.keys(blocks))
// Check for nodes with invalid parent references and collect updates
const orphanedUpdates: Array<{
id: string
position: { x: number; y: number }
parentId: string
}> = []
Object.entries(blocks).forEach(([id, block]) => {
const parentId = block.data?.parentId
// If block has a parent reference but parent no longer exists
if (parentId && !nodeIds.has(parentId)) {
logger.warn('Found orphaned node with invalid parent reference', {
nodeId: id,
missingParentId: parentId,
})
const absolutePosition = getNodeAbsolutePosition(id)
orphanedUpdates.push({ id, position: absolutePosition, parentId: '' })
}
})
// Batch update all orphaned nodes at once
if (orphanedUpdates.length > 0) {
batchUpdateBlocksWithParent(orphanedUpdates)
}
}, [blocks, batchUpdateBlocksWithParent, getNodeAbsolutePosition, isWorkflowReady])
/** Handles edge removal changes. */ /** Handles edge removal changes. */
const onEdgesChange = useCallback( const onEdgesChange = useCallback(
(changes: any) => { (changes: any) => {

View File

@@ -22,8 +22,6 @@ import {
WorkflowBuilder, WorkflowBuilder,
} from '@sim/testing' } from '@sim/testing'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { useWorkflowStore } from '@/stores/workflows/workflow/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store'
describe('workflow store', () => { describe('workflow store', () => {
@@ -365,30 +363,6 @@ describe('workflow store', () => {
}) })
}) })
describe('duplicateBlock', () => {
it('should duplicate a block', () => {
const { addBlock, duplicateBlock } = useWorkflowStore.getState()
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
duplicateBlock('original')
const { blocks } = useWorkflowStore.getState()
const blockIds = Object.keys(blocks)
expect(blockIds.length).toBe(2)
const duplicatedId = blockIds.find((id) => id !== 'original')
expect(duplicatedId).toBeDefined()
if (duplicatedId) {
expect(blocks[duplicatedId].type).toBe('agent')
expect(blocks[duplicatedId].name).toContain('Original Agent')
expect(blocks[duplicatedId].position.x).not.toBe(0)
}
})
})
describe('batchUpdatePositions', () => { describe('batchUpdatePositions', () => {
it('should update block position', () => { it('should update block position', () => {
const { addBlock, batchUpdatePositions } = useWorkflowStore.getState() const { addBlock, batchUpdatePositions } = useWorkflowStore.getState()
@@ -452,29 +426,6 @@ describe('workflow store', () => {
expect(state.loops.loop1.forEachItems).toBe('["a", "b", "c"]') expect(state.loops.loop1.forEachItems).toBe('["a", "b", "c"]')
}) })
it('should regenerate loops when updateLoopCollection is called', () => {
const { addBlock, updateLoopCollection } = useWorkflowStore.getState()
addBlock(
'loop1',
'loop',
'Test Loop',
{ x: 0, y: 0 },
{
loopType: 'forEach',
collection: '["item1", "item2"]',
}
)
updateLoopCollection('loop1', '["item1", "item2", "item3"]')
const state = useWorkflowStore.getState()
expect(state.blocks.loop1?.data?.collection).toBe('["item1", "item2", "item3"]')
expect(state.loops.loop1).toBeDefined()
expect(state.loops.loop1.forEachItems).toBe('["item1", "item2", "item3"]')
})
it('should clamp loop count between 1 and 1000', () => { it('should clamp loop count between 1 and 1000', () => {
const { addBlock, updateLoopCount } = useWorkflowStore.getState() const { addBlock, updateLoopCount } = useWorkflowStore.getState()
@@ -599,118 +550,6 @@ describe('workflow store', () => {
}) })
}) })
describe('mode switching', () => {
it('should toggle advanced mode on a block', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
let state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(false)
toggleBlockAdvancedMode('agent1')
state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(true)
toggleBlockAdvancedMode('agent1')
state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(false)
})
it('should preserve systemPrompt and userPrompt when switching modes', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
const { setState: setSubBlockState } = useSubBlockStore
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
setSubBlockState({
workflowValues: {
'test-workflow': {
agent1: {
systemPrompt: 'You are a helpful assistant',
userPrompt: 'Hello, how are you?',
},
},
},
})
toggleBlockAdvancedMode('agent1')
let subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'Hello, how are you?'
)
toggleBlockAdvancedMode('agent1')
subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'Hello, how are you?'
)
})
it('should preserve memories when switching from advanced to basic mode', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
const { setState: setSubBlockState } = useSubBlockStore
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
toggleBlockAdvancedMode('agent1')
setSubBlockState({
workflowValues: {
'test-workflow': {
agent1: {
systemPrompt: 'You are a helpful assistant',
userPrompt: 'What did we discuss?',
memories: [
{ role: 'user', content: 'My name is John' },
{ role: 'assistant', content: 'Nice to meet you, John!' },
],
},
},
},
})
toggleBlockAdvancedMode('agent1')
const subBlockState = useSubBlockStore.getState()
expect(subBlockState.workflowValues['test-workflow'].agent1.systemPrompt).toBe(
'You are a helpful assistant'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.userPrompt).toBe(
'What did we discuss?'
)
expect(subBlockState.workflowValues['test-workflow'].agent1.memories).toEqual([
{ role: 'user', content: 'My name is John' },
{ role: 'assistant', content: 'Nice to meet you, John!' },
])
})
it('should handle mode switching when no subblock values exist', () => {
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
expect(useWorkflowStore.getState().blocks.agent1?.advancedMode).toBe(false)
expect(() => toggleBlockAdvancedMode('agent1')).not.toThrow()
const state = useWorkflowStore.getState()
expect(state.blocks.agent1?.advancedMode).toBe(true)
})
it('should not throw when toggling non-existent block', () => {
const { toggleBlockAdvancedMode } = useWorkflowStore.getState()
expect(() => toggleBlockAdvancedMode('non-existent')).not.toThrow()
})
})
describe('workflow state management', () => { describe('workflow state management', () => {
it('should work with WorkflowBuilder for complex setups', () => { it('should work with WorkflowBuilder for complex setups', () => {
const workflowState = WorkflowBuilder.linear(3).build() const workflowState = WorkflowBuilder.linear(3).build()

View File

@@ -2,20 +2,16 @@ import { createLogger } from '@sim/logger'
import type { Edge } from 'reactflow' import type { Edge } from 'reactflow'
import { create } from 'zustand' import { create } from 'zustand'
import { devtools } from 'zustand/middleware' import { devtools } from 'zustand/middleware'
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants' import { CONTAINER_DIMENSIONS } from '@/lib/workflows/blocks/block-dimensions'
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs' import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import { getBlock } from '@/blocks' import { getBlock } from '@/blocks'
import type { SubBlockConfig } from '@/blocks/types' import type { SubBlockConfig } from '@/blocks/types'
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants' import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { import { filterNewEdges, filterValidEdges } from '@/stores/workflows/utils'
filterNewEdges,
filterValidEdges,
getUniqueBlockName,
mergeSubblockState,
} from '@/stores/workflows/utils'
import type { import type {
BlockState,
Position, Position,
SubBlockState, SubBlockState,
WorkflowState, WorkflowState,
@@ -139,8 +135,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
...(parentId && { parentId, extent: extent || 'parent' }), ...(parentId && { parentId, extent: extent || 'parent' }),
} }
const newState = { const newBlocks = {
blocks: {
...get().blocks, ...get().blocks,
[id]: { [id]: {
id, id,
@@ -156,13 +151,14 @@ export const useWorkflowStore = create<WorkflowStore>()(
height: blockProperties?.height ?? 0, height: blockProperties?.height ?? 0,
data: nodeData, data: nodeData,
}, },
},
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
} }
set(newState) set({
blocks: newBlocks,
edges: [...get().edges],
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
get().updateLastSaved() get().updateLastSaved()
return return
} }
@@ -215,8 +211,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
const triggerMode = blockProperties?.triggerMode ?? false const triggerMode = blockProperties?.triggerMode ?? false
const outputs = getBlockOutputs(type, subBlocks, triggerMode) const outputs = getBlockOutputs(type, subBlocks, triggerMode)
const newState = { const newBlocks = {
blocks: {
...get().blocks, ...get().blocks,
[id]: { [id]: {
id, id,
@@ -233,13 +228,14 @@ export const useWorkflowStore = create<WorkflowStore>()(
layout: {}, layout: {},
data: nodeData, data: nodeData,
}, },
},
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
} }
set(newState) set({
blocks: newBlocks,
edges: [...get().edges],
loops: generateLoopBlocks(newBlocks),
parallels: generateParallelBlocks(newBlocks),
})
get().updateLastSaved() get().updateLastSaved()
}, },
@@ -448,6 +444,41 @@ export const useWorkflowStore = create<WorkflowStore>()(
delete newBlocks[blockId] delete newBlocks[blockId]
}) })
// Clean up orphaned nodes - blocks whose parent was removed but weren't descendants
// This can happen in edge cases (e.g., data inconsistency, external modifications)
const remainingBlockIds = new Set(Object.keys(newBlocks))
const CONTAINER_OFFSET = {
x: CONTAINER_DIMENSIONS.LEFT_PADDING,
y: CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING,
}
Object.entries(newBlocks).forEach(([blockId, block]) => {
const parentId = block.data?.parentId
if (parentId && !remainingBlockIds.has(parentId)) {
// Parent was removed - convert to absolute position and clear parentId
// Child positions are relative to container content area (after header + padding)
let absoluteX = block.position.x
let absoluteY = block.position.y
// Traverse up the parent chain, adding position + container offset for each level
let currentParentId: string | undefined = parentId
while (currentParentId) {
const parent: BlockState | undefined = currentBlocks[currentParentId]
if (!parent) break
absoluteX += parent.position.x + CONTAINER_OFFSET.x
absoluteY += parent.position.y + CONTAINER_OFFSET.y
currentParentId = parent.data?.parentId
}
const { parentId: _removed, extent: _removedExtent, ...restData } = block.data || {}
newBlocks[blockId] = {
...block,
position: { x: absoluteX, y: absoluteY },
data: Object.keys(restData).length > 0 ? restData : undefined,
}
}
})
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
if (activeWorkflowId) { if (activeWorkflowId) {
const subBlockStore = useSubBlockStore.getState() const subBlockStore = useSubBlockStore.getState()
@@ -584,7 +615,20 @@ export const useWorkflowStore = create<WorkflowStore>()(
options?: { updateLastSaved?: boolean } options?: { updateLastSaved?: boolean }
) => { ) => {
set((state) => { set((state) => {
const nextBlocks = workflowState.blocks || {} const incomingBlocks = workflowState.blocks || {}
const nextBlocks: typeof incomingBlocks = {}
for (const [id, block] of Object.entries(incomingBlocks)) {
if (block.data?.parentId && !incomingBlocks[block.data.parentId]) {
nextBlocks[id] = {
...block,
data: { ...block.data, parentId: undefined, extent: undefined },
}
} else {
nextBlocks[id] = block
}
}
const nextEdges = filterValidEdges(workflowState.edges || [], nextBlocks) const nextEdges = filterValidEdges(workflowState.edges || [], nextBlocks)
const nextLoops = const nextLoops =
Object.keys(workflowState.loops || {}).length > 0 Object.keys(workflowState.loops || {}).length > 0
@@ -635,66 +679,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved() get().updateLastSaved()
}, },
duplicateBlock: (id: string) => {
const block = get().blocks[id]
if (!block) return
const newId = crypto.randomUUID()
const offsetPosition = {
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
}
const newName = getUniqueBlockName(block.name, get().blocks)
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
const mergedBlock = mergeSubblockState(get().blocks, activeWorkflowId || undefined, id)[id]
const newSubBlocks = Object.entries(mergedBlock.subBlocks).reduce(
(acc, [subId, subBlock]) => ({
...acc,
[subId]: {
...subBlock,
value: JSON.parse(JSON.stringify(subBlock.value)),
},
}),
{}
)
const newState = {
blocks: {
...get().blocks,
[newId]: {
...block,
id: newId,
name: newName,
position: offsetPosition,
subBlocks: newSubBlocks,
},
},
edges: [...get().edges],
loops: get().generateLoopBlocks(),
parallels: get().generateParallelBlocks(),
}
if (activeWorkflowId) {
const subBlockValues =
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
useSubBlockStore.setState((state) => ({
workflowValues: {
...state.workflowValues,
[activeWorkflowId]: {
...state.workflowValues[activeWorkflowId],
[newId]: JSON.parse(JSON.stringify(subBlockValues)),
},
},
}))
}
set(newState)
get().updateLastSaved()
},
setBlockHandles: (id: string, horizontalHandles: boolean) => { setBlockHandles: (id: string, horizontalHandles: boolean) => {
const block = get().blocks[id] const block = get().blocks[id]
if (!block || block.horizontalHandles === horizontalHandles) return if (!block || block.horizontalHandles === horizontalHandles) return
@@ -890,27 +874,10 @@ export const useWorkflowStore = create<WorkflowStore>()(
get().updateLastSaved() get().updateLastSaved()
}, },
setBlockTriggerMode: (id: string, triggerMode: boolean) => {
set((state) => ({
blocks: {
...state.blocks,
[id]: {
...state.blocks[id],
triggerMode,
},
},
edges: [...state.edges],
loops: { ...state.loops },
}))
get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => { updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => {
set((state) => { set((state) => {
const block = state.blocks[id] const block = state.blocks[id]
if (!block) { if (!block) {
logger.warn(`Cannot update layout metrics: Block ${id} not found in workflow store`)
return state return state
} }
@@ -932,7 +899,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
} }
}) })
get().updateLastSaved() get().updateLastSaved()
// No sync needed for layout changes, just visual
}, },
updateLoopCount: (loopId: string, count: number) => updateLoopCount: (loopId: string, count: number) =>
@@ -1050,30 +1016,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
} }
}), }),
updateLoopCollection: (loopId: string, collection: string) => {
const store = get()
const block = store.blocks[loopId]
if (!block || block.type !== 'loop') return
const loopType = block.data?.loopType || 'for'
if (loopType === 'while') {
store.setLoopWhileCondition(loopId, collection)
} else if (loopType === 'doWhile') {
store.setLoopDoWhileCondition(loopId, collection)
} else if (loopType === 'forEach') {
store.setLoopForEachItems(loopId, collection)
} else {
// Default to forEach-style storage for backward compatibility
store.setLoopForEachItems(loopId, collection)
}
},
// Function to convert UI loop blocks to execution format
generateLoopBlocks: () => {
return generateLoopBlocks(get().blocks)
},
triggerUpdate: () => { triggerUpdate: () => {
set((state) => ({ set((state) => ({
...state, ...state,
@@ -1161,28 +1103,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
} }
}, },
toggleBlockAdvancedMode: (id: string) => {
const block = get().blocks[id]
if (!block) return
const newState = {
blocks: {
...get().blocks,
[id]: {
...block,
advancedMode: !block.advancedMode,
},
},
edges: [...get().edges],
loops: { ...get().loops },
}
set(newState)
get().triggerUpdate()
// Note: Socket.IO handles real-time sync automatically
},
// Parallel block methods implementation // Parallel block methods implementation
updateParallelCount: (parallelId: string, count: number) => { updateParallelCount: (parallelId: string, count: number) => {
const block = get().blocks[parallelId] const block = get().blocks[parallelId]
@@ -1208,7 +1128,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState) set(newState)
get().updateLastSaved() get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
}, },
updateParallelCollection: (parallelId: string, collection: string) => { updateParallelCollection: (parallelId: string, collection: string) => {
@@ -1235,7 +1154,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState) set(newState)
get().updateLastSaved() get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
}, },
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => { updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => {
@@ -1262,12 +1180,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
set(newState) set(newState)
get().updateLastSaved() get().updateLastSaved()
// Note: Socket.IO handles real-time sync automatically
},
// Function to convert UI parallel blocks to execution format
generateParallelBlocks: () => {
return generateParallelBlocks(get().blocks)
}, },
setDragStartPosition: (position) => { setDragStartPosition: (position) => {

View File

@@ -214,7 +214,6 @@ export interface WorkflowActions {
clear: () => Partial<WorkflowState> clear: () => Partial<WorkflowState>
updateLastSaved: () => void updateLastSaved: () => void
setBlockEnabled: (id: string, enabled: boolean) => void setBlockEnabled: (id: string, enabled: boolean) => void
duplicateBlock: (id: string) => void
setBlockHandles: (id: string, horizontalHandles: boolean) => void setBlockHandles: (id: string, horizontalHandles: boolean) => void
updateBlockName: ( updateBlockName: (
id: string, id: string,
@@ -225,23 +224,18 @@ export interface WorkflowActions {
} }
setBlockAdvancedMode: (id: string, advancedMode: boolean) => void setBlockAdvancedMode: (id: string, advancedMode: boolean) => void
setBlockCanonicalMode: (id: string, canonicalId: string, mode: 'basic' | 'advanced') => void setBlockCanonicalMode: (id: string, canonicalId: string, mode: 'basic' | 'advanced') => void
setBlockTriggerMode: (id: string, triggerMode: boolean) => void
updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void updateBlockLayoutMetrics: (id: string, dimensions: { width: number; height: number }) => void
triggerUpdate: () => void triggerUpdate: () => void
updateLoopCount: (loopId: string, count: number) => void updateLoopCount: (loopId: string, count: number) => void
updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void updateLoopType: (loopId: string, loopType: 'for' | 'forEach' | 'while' | 'doWhile') => void
updateLoopCollection: (loopId: string, collection: string) => void
setLoopForEachItems: (loopId: string, items: any) => void setLoopForEachItems: (loopId: string, items: any) => void
setLoopWhileCondition: (loopId: string, condition: string) => void setLoopWhileCondition: (loopId: string, condition: string) => void
setLoopDoWhileCondition: (loopId: string, condition: string) => void setLoopDoWhileCondition: (loopId: string, condition: string) => void
updateParallelCount: (parallelId: string, count: number) => void updateParallelCount: (parallelId: string, count: number) => void
updateParallelCollection: (parallelId: string, collection: string) => void updateParallelCollection: (parallelId: string, collection: string) => void
updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void updateParallelType: (parallelId: string, parallelType: 'count' | 'collection') => void
generateLoopBlocks: () => Record<string, Loop>
generateParallelBlocks: () => Record<string, Parallel>
setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void setNeedsRedeploymentFlag: (needsRedeployment: boolean) => void
revertToDeployedState: (deployedState: WorkflowState) => void revertToDeployedState: (deployedState: WorkflowState) => void
toggleBlockAdvancedMode: (id: string) => void
setDragStartPosition: (position: DragStartPosition | null) => void setDragStartPosition: (position: DragStartPosition | null) => void
getDragStartPosition: () => DragStartPosition | null getDragStartPosition: () => DragStartPosition | null
getWorkflowState: () => WorkflowState getWorkflowState: () => WorkflowState