mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-28 08:18:09 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
276ce665e4 | ||
|
|
08bad4da9f | ||
|
|
37dbfe393a | ||
|
|
503f676910 | ||
|
|
f2ca90ae6f | ||
|
|
fe4fd47b9d |
@@ -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) => {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,30 +135,30 @@ 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,
|
type,
|
||||||
type,
|
name,
|
||||||
name,
|
position,
|
||||||
position,
|
subBlocks: {},
|
||||||
subBlocks: {},
|
outputs: {},
|
||||||
outputs: {},
|
enabled: blockProperties?.enabled ?? true,
|
||||||
enabled: blockProperties?.enabled ?? true,
|
horizontalHandles: blockProperties?.horizontalHandles ?? true,
|
||||||
horizontalHandles: blockProperties?.horizontalHandles ?? true,
|
advancedMode: blockProperties?.advancedMode ?? false,
|
||||||
advancedMode: blockProperties?.advancedMode ?? false,
|
triggerMode: blockProperties?.triggerMode ?? false,
|
||||||
triggerMode: blockProperties?.triggerMode ?? false,
|
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,31 +211,31 @@ 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,
|
type,
|
||||||
type,
|
name,
|
||||||
name,
|
position,
|
||||||
position,
|
subBlocks,
|
||||||
subBlocks,
|
outputs,
|
||||||
outputs,
|
enabled: blockProperties?.enabled ?? true,
|
||||||
enabled: blockProperties?.enabled ?? true,
|
horizontalHandles: blockProperties?.horizontalHandles ?? true,
|
||||||
horizontalHandles: blockProperties?.horizontalHandles ?? true,
|
advancedMode: blockProperties?.advancedMode ?? false,
|
||||||
advancedMode: blockProperties?.advancedMode ?? false,
|
triggerMode: triggerMode,
|
||||||
triggerMode: triggerMode,
|
height: blockProperties?.height ?? 0,
|
||||||
height: blockProperties?.height ?? 0,
|
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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user