mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-28 00:08:21 -05:00
fix(workflow): use panel-aware viewport center for paste and block placement (#3024)
This commit is contained in:
@@ -26,8 +26,9 @@ vi.mock('@/serializer', () => ({
|
||||
Serializer: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/server-utils', () => ({
|
||||
mergeSubblockState: vi.fn().mockReturnValue({}),
|
||||
vi.mock('@/lib/workflows/subblocks', () => ({
|
||||
mergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
|
||||
mergeSubBlockValues: vi.fn().mockReturnValue({}),
|
||||
}))
|
||||
|
||||
const mockDecryptSecret = vi.fn()
|
||||
|
||||
@@ -66,7 +66,6 @@ import { useWorkspaceEnvironment } from '@/hooks/queries/environment'
|
||||
import { useAutoConnect, useSnapToGridSize } from '@/hooks/queries/general-settings'
|
||||
import { useCanvasViewport } from '@/hooks/use-canvas-viewport'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
||||
import { useCanvasModeStore } from '@/stores/canvas-mode'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
@@ -99,26 +98,6 @@ const logger = createLogger('Workflow')
|
||||
|
||||
const DEFAULT_PASTE_OFFSET = { x: 50, y: 50 }
|
||||
|
||||
/**
|
||||
* Gets the center of the current viewport in flow coordinates
|
||||
*/
|
||||
function getViewportCenter(
|
||||
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
|
||||
): { x: number; y: number } {
|
||||
const flowContainer = document.querySelector('.react-flow')
|
||||
if (!flowContainer) {
|
||||
return screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
})
|
||||
}
|
||||
const rect = flowContainer.getBoundingClientRect()
|
||||
return screenToFlowPosition({
|
||||
x: rect.width / 2,
|
||||
y: rect.height / 2,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the offset to paste blocks at viewport center
|
||||
*/
|
||||
@@ -126,7 +105,7 @@ 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 }
|
||||
viewportCenter: { x: number; y: number }
|
||||
): { x: number; y: number } {
|
||||
if (!clipboard) return DEFAULT_PASTE_OFFSET
|
||||
|
||||
@@ -155,8 +134,6 @@ function calculatePasteOffset(
|
||||
)
|
||||
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
|
||||
|
||||
const viewportCenter = getViewportCenter(screenToFlowPosition)
|
||||
|
||||
return {
|
||||
x: viewportCenter.x - clipboardCenter.x,
|
||||
y: viewportCenter.y - clipboardCenter.y,
|
||||
@@ -266,7 +243,7 @@ const WorkflowContent = React.memo(() => {
|
||||
const router = useRouter()
|
||||
const reactFlowInstance = useReactFlow()
|
||||
const { screenToFlowPosition, getNodes, setNodes, getIntersectingNodes } = reactFlowInstance
|
||||
const { fitViewToBounds } = useCanvasViewport(reactFlowInstance)
|
||||
const { fitViewToBounds, getViewportCenter } = useCanvasViewport(reactFlowInstance)
|
||||
const { emitCursorUpdate } = useSocket()
|
||||
|
||||
const workspaceId = params.workspaceId as string
|
||||
@@ -338,8 +315,6 @@ const WorkflowContent = React.memo(() => {
|
||||
const isVariablesOpen = useVariablesStore((state) => state.isOpen)
|
||||
const isChatOpen = useChatStore((state) => state.isChatOpen)
|
||||
|
||||
// Permission config for invitation control
|
||||
const { isInvitationsDisabled } = usePermissionConfig()
|
||||
const snapGrid: [number, number] = useMemo(
|
||||
() => [snapToGridSize, snapToGridSize],
|
||||
[snapToGridSize]
|
||||
@@ -901,11 +876,125 @@ const WorkflowContent = React.memo(() => {
|
||||
* Consolidates shared logic for context paste, duplicate, and keyboard paste.
|
||||
*/
|
||||
const executePasteOperation = useCallback(
|
||||
(operation: 'paste' | 'duplicate', pasteOffset: { x: number; y: number }) => {
|
||||
const pasteData = preparePasteData(pasteOffset)
|
||||
(
|
||||
operation: 'paste' | 'duplicate',
|
||||
pasteOffset: { x: number; y: number },
|
||||
targetContainer?: {
|
||||
loopId: string
|
||||
loopPosition: { x: number; y: number }
|
||||
dimensions: { width: number; height: number }
|
||||
} | null,
|
||||
pasteTargetPosition?: { x: number; y: number }
|
||||
) => {
|
||||
// For context menu paste into a subflow, calculate offset to center blocks at click position
|
||||
// Skip click-position centering if blocks came from inside a subflow (relative coordinates)
|
||||
let effectiveOffset = pasteOffset
|
||||
if (targetContainer && pasteTargetPosition && clipboard) {
|
||||
const clipboardBlocks = Object.values(clipboard.blocks)
|
||||
// Only use click-position centering for top-level blocks (absolute coordinates)
|
||||
// Blocks with parentId have relative positions that can't be mixed with absolute click position
|
||||
const hasNestedBlocks = clipboardBlocks.some((b) => b.data?.parentId)
|
||||
if (clipboardBlocks.length > 0 && !hasNestedBlocks) {
|
||||
const minX = Math.min(...clipboardBlocks.map((b) => b.position.x))
|
||||
const maxX = Math.max(
|
||||
...clipboardBlocks.map((b) => b.position.x + BLOCK_DIMENSIONS.FIXED_WIDTH)
|
||||
)
|
||||
const minY = Math.min(...clipboardBlocks.map((b) => b.position.y))
|
||||
const maxY = Math.max(
|
||||
...clipboardBlocks.map((b) => b.position.y + BLOCK_DIMENSIONS.MIN_HEIGHT)
|
||||
)
|
||||
const clipboardCenter = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }
|
||||
effectiveOffset = {
|
||||
x: pasteTargetPosition.x - clipboardCenter.x,
|
||||
y: pasteTargetPosition.y - clipboardCenter.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pasteData = preparePasteData(effectiveOffset)
|
||||
if (!pasteData) return
|
||||
|
||||
const pastedBlocksArray = Object.values(pasteData.blocks)
|
||||
let pastedBlocksArray = Object.values(pasteData.blocks)
|
||||
|
||||
// If pasting into a subflow, adjust blocks to be children of that subflow
|
||||
if (targetContainer) {
|
||||
// Check if any pasted block is a trigger - triggers cannot be in subflows
|
||||
const hasTrigger = pastedBlocksArray.some((b) => TriggerUtils.isTriggerBlock(b))
|
||||
if (hasTrigger) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: 'Triggers cannot be placed inside loop or parallel subflows.',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if any pasted block is a subflow - subflows cannot be nested
|
||||
const hasSubflow = pastedBlocksArray.some((b) => b.type === 'loop' || b.type === 'parallel')
|
||||
if (hasSubflow) {
|
||||
addNotification({
|
||||
level: 'error',
|
||||
message: 'Subflows cannot be nested inside other subflows.',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Adjust each block's position to be relative to the container and set parentId
|
||||
pastedBlocksArray = pastedBlocksArray.map((block) => {
|
||||
// For blocks already nested (have parentId), positions are already relative - use as-is
|
||||
// For top-level blocks, convert absolute position to relative by subtracting container position
|
||||
const wasNested = Boolean(block.data?.parentId)
|
||||
const relativePosition = wasNested
|
||||
? { x: block.position.x, y: block.position.y }
|
||||
: {
|
||||
x: block.position.x - targetContainer.loopPosition.x,
|
||||
y: block.position.y - targetContainer.loopPosition.y,
|
||||
}
|
||||
|
||||
// Clamp position to keep block inside container (below header)
|
||||
const clampedPosition = {
|
||||
x: Math.max(
|
||||
CONTAINER_DIMENSIONS.LEFT_PADDING,
|
||||
Math.min(
|
||||
relativePosition.x,
|
||||
targetContainer.dimensions.width -
|
||||
BLOCK_DIMENSIONS.FIXED_WIDTH -
|
||||
CONTAINER_DIMENSIONS.RIGHT_PADDING
|
||||
)
|
||||
),
|
||||
y: Math.max(
|
||||
CONTAINER_DIMENSIONS.HEADER_HEIGHT + CONTAINER_DIMENSIONS.TOP_PADDING,
|
||||
Math.min(
|
||||
relativePosition.y,
|
||||
targetContainer.dimensions.height -
|
||||
BLOCK_DIMENSIONS.MIN_HEIGHT -
|
||||
CONTAINER_DIMENSIONS.BOTTOM_PADDING
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
position: clampedPosition,
|
||||
data: {
|
||||
...block.data,
|
||||
parentId: targetContainer.loopId,
|
||||
extent: 'parent',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// Update pasteData.blocks with the modified blocks
|
||||
pasteData.blocks = pastedBlocksArray.reduce(
|
||||
(acc, block) => {
|
||||
acc[block.id] = block
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, (typeof pastedBlocksArray)[0]>
|
||||
)
|
||||
}
|
||||
|
||||
const validation = validateTriggerPaste(pastedBlocksArray, blocks, operation)
|
||||
if (!validation.isValid) {
|
||||
addNotification({
|
||||
@@ -926,21 +1015,46 @@ const WorkflowContent = React.memo(() => {
|
||||
pasteData.parallels,
|
||||
pasteData.subBlockValues
|
||||
)
|
||||
|
||||
// Resize container if we pasted into a subflow
|
||||
if (targetContainer) {
|
||||
resizeLoopNodesWrapper()
|
||||
}
|
||||
},
|
||||
[
|
||||
preparePasteData,
|
||||
blocks,
|
||||
clipboard,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
collaborativeBatchAddBlocks,
|
||||
setPendingSelection,
|
||||
resizeLoopNodesWrapper,
|
||||
]
|
||||
)
|
||||
|
||||
const handleContextPaste = useCallback(() => {
|
||||
if (!hasClipboard()) return
|
||||
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
|
||||
}, [hasClipboard, executePasteOperation, clipboard, screenToFlowPosition])
|
||||
|
||||
// Convert context menu position to flow coordinates and check if inside a subflow
|
||||
const flowPosition = screenToFlowPosition(contextMenuPosition)
|
||||
const targetContainer = isPointInLoopNode(flowPosition)
|
||||
|
||||
executePasteOperation(
|
||||
'paste',
|
||||
calculatePasteOffset(clipboard, getViewportCenter()),
|
||||
targetContainer,
|
||||
flowPosition // Pass the click position so blocks are centered at where user right-clicked
|
||||
)
|
||||
}, [
|
||||
hasClipboard,
|
||||
executePasteOperation,
|
||||
clipboard,
|
||||
getViewportCenter,
|
||||
screenToFlowPosition,
|
||||
contextMenuPosition,
|
||||
isPointInLoopNode,
|
||||
])
|
||||
|
||||
const handleContextDuplicate = useCallback(() => {
|
||||
copyBlocks(contextMenuBlocks.map((b) => b.id))
|
||||
@@ -1006,10 +1120,6 @@ const WorkflowContent = React.memo(() => {
|
||||
setIsChatOpen(!isChatOpen)
|
||||
}, [])
|
||||
|
||||
const handleContextInvite = useCallback(() => {
|
||||
window.dispatchEvent(new CustomEvent('open-invite-modal'))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cleanup: (() => void) | null = null
|
||||
|
||||
@@ -1054,7 +1164,7 @@ const WorkflowContent = React.memo(() => {
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'v') {
|
||||
if (effectivePermissions.canEdit && hasClipboard()) {
|
||||
event.preventDefault()
|
||||
executePasteOperation('paste', calculatePasteOffset(clipboard, screenToFlowPosition))
|
||||
executePasteOperation('paste', calculatePasteOffset(clipboard, getViewportCenter()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1074,7 +1184,7 @@ const WorkflowContent = React.memo(() => {
|
||||
hasClipboard,
|
||||
effectivePermissions.canEdit,
|
||||
clipboard,
|
||||
screenToFlowPosition,
|
||||
getViewportCenter,
|
||||
executePasteOperation,
|
||||
])
|
||||
|
||||
@@ -1507,7 +1617,7 @@ const WorkflowContent = React.memo(() => {
|
||||
if (!type) return
|
||||
if (type === 'connectionBlock') return
|
||||
|
||||
const basePosition = getViewportCenter(screenToFlowPosition)
|
||||
const basePosition = getViewportCenter()
|
||||
|
||||
if (type === 'loop' || type === 'parallel') {
|
||||
const id = crypto.randomUUID()
|
||||
@@ -1576,7 +1686,7 @@ const WorkflowContent = React.memo(() => {
|
||||
)
|
||||
}
|
||||
}, [
|
||||
screenToFlowPosition,
|
||||
getViewportCenter,
|
||||
blocks,
|
||||
addBlock,
|
||||
effectivePermissions.canEdit,
|
||||
|
||||
@@ -57,31 +57,16 @@ function getVisibleCanvasBounds(): VisibleBounds {
|
||||
* Gets the center of the visible canvas in screen coordinates.
|
||||
*/
|
||||
function getVisibleCanvasCenter(): { x: number; y: number } {
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const sidebarWidth = Number.parseInt(style.getPropertyValue('--sidebar-width') || '0', 10)
|
||||
const panelWidth = Number.parseInt(style.getPropertyValue('--panel-width') || '0', 10)
|
||||
const terminalHeight = Number.parseInt(style.getPropertyValue('--terminal-height') || '0', 10)
|
||||
const bounds = getVisibleCanvasBounds()
|
||||
|
||||
const flowContainer = document.querySelector('.react-flow')
|
||||
if (!flowContainer) {
|
||||
const visibleWidth = window.innerWidth - sidebarWidth - panelWidth
|
||||
const visibleHeight = window.innerHeight - terminalHeight
|
||||
return {
|
||||
x: sidebarWidth + visibleWidth / 2,
|
||||
y: visibleHeight / 2,
|
||||
}
|
||||
}
|
||||
|
||||
const rect = flowContainer.getBoundingClientRect()
|
||||
|
||||
// Calculate actual visible area in screen coordinates
|
||||
const visibleLeft = Math.max(rect.left, sidebarWidth)
|
||||
const visibleRight = Math.min(rect.right, window.innerWidth - panelWidth)
|
||||
const visibleBottom = Math.min(rect.bottom, window.innerHeight - terminalHeight)
|
||||
const rect = flowContainer?.getBoundingClientRect()
|
||||
const containerLeft = rect?.left ?? 0
|
||||
const containerTop = rect?.top ?? 0
|
||||
|
||||
return {
|
||||
x: (visibleLeft + visibleRight) / 2,
|
||||
y: (rect.top + visibleBottom) / 2,
|
||||
x: containerLeft + bounds.offsetLeft + bounds.width / 2,
|
||||
y: containerTop + bounds.height / 2,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
loadDeployedWorkflowState,
|
||||
loadWorkflowFromNormalizedTables,
|
||||
} from '@/lib/workflows/persistence/utils'
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
|
||||
import { Executor } from '@/executor'
|
||||
@@ -26,7 +27,6 @@ import type {
|
||||
import type { ExecutionResult, NormalizedBlockOutput } from '@/executor/types'
|
||||
import { hasExecutionResult } from '@/executor/utils/errors'
|
||||
import { Serializer } from '@/serializer'
|
||||
import { mergeSubblockState } from '@/stores/workflows/server-utils'
|
||||
|
||||
const logger = createLogger('ExecutionCore')
|
||||
|
||||
@@ -172,8 +172,7 @@ export async function executeWorkflowCore(
|
||||
logger.info(`[${requestId}] Using deployed workflow state (deployed execution)`)
|
||||
}
|
||||
|
||||
// Merge block states
|
||||
const mergedStates = mergeSubblockState(blocks)
|
||||
const mergedStates = mergeSubblockStateWithValues(blocks)
|
||||
|
||||
const personalEnvUserId =
|
||||
metadata.isClientSession && metadata.sessionUserId
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Server-Safe Workflow Utilities
|
||||
*
|
||||
* This file contains workflow utility functions that can be safely imported
|
||||
* by server-side API routes without causing client/server boundary violations.
|
||||
*
|
||||
* Unlike the main utils.ts file, this does NOT import any client-side stores
|
||||
* or React hooks, making it safe for use in Next.js API routes.
|
||||
*/
|
||||
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
/**
|
||||
* Server-safe version of mergeSubblockState for API routes
|
||||
*
|
||||
* Merges workflow block states with provided subblock values while maintaining block structure.
|
||||
* This version takes explicit subblock values instead of reading from client stores.
|
||||
*
|
||||
* @param blocks - Block configurations from workflow state
|
||||
* @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value
|
||||
* @param blockId - Optional specific block ID to merge (merges all if not provided)
|
||||
* @returns Merged block states with updated values
|
||||
*/
|
||||
export function mergeSubblockState(
|
||||
blocks: Record<string, BlockState>,
|
||||
subBlockValues: Record<string, Record<string, any>> = {},
|
||||
blockId?: string
|
||||
): Record<string, BlockState> {
|
||||
return mergeSubblockStateWithValues(blocks, subBlockValues, blockId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-safe async version of mergeSubblockState for API routes
|
||||
*
|
||||
* Asynchronously merges workflow block states with provided subblock values.
|
||||
* This version takes explicit subblock values instead of reading from client stores.
|
||||
*
|
||||
* @param blocks - Block configurations from workflow state
|
||||
* @param subBlockValues - Object containing subblock values keyed by blockId -> subBlockId -> value
|
||||
* @param blockId - Optional specific block ID to merge (merges all if not provided)
|
||||
* @returns Promise resolving to merged block states with updated values
|
||||
*/
|
||||
export async function mergeSubblockStateAsync(
|
||||
blocks: Record<string, BlockState>,
|
||||
subBlockValues: Record<string, Record<string, any>> = {},
|
||||
blockId?: string
|
||||
): Promise<Record<string, BlockState>> {
|
||||
// Since we're not reading from client stores, we can just return the sync version
|
||||
// The async nature was only needed for the client-side store operations
|
||||
return mergeSubblockState(blocks, subBlockValues, blockId)
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from '@sim/testing'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeName } from '@/executor/constants'
|
||||
import { getUniqueBlockName } from './utils'
|
||||
import { getUniqueBlockName, regenerateBlockIds } from './utils'
|
||||
|
||||
describe('normalizeName', () => {
|
||||
it.concurrent('should convert to lowercase', () => {
|
||||
@@ -223,3 +223,213 @@ describe('getUniqueBlockName', () => {
|
||||
expect(getUniqueBlockName('myblock', existingBlocks)).toBe('myblock 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('regenerateBlockIds', () => {
|
||||
const positionOffset = { x: 50, y: 50 }
|
||||
|
||||
it('should preserve parentId and use same offset when duplicating a block inside an existing subflow', () => {
|
||||
const loopId = 'loop-1'
|
||||
const childId = 'child-1'
|
||||
|
||||
const existingBlocks = {
|
||||
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
|
||||
}
|
||||
|
||||
const blocksToCopy = {
|
||||
[childId]: createAgentBlock({
|
||||
id: childId,
|
||||
name: 'Agent 1',
|
||||
position: { x: 100, y: 50 },
|
||||
data: { parentId: loopId, extent: 'parent' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
positionOffset, // { x: 50, y: 50 } - small offset, used as-is
|
||||
existingBlocks,
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const newBlocks = Object.values(result.blocks)
|
||||
expect(newBlocks).toHaveLength(1)
|
||||
|
||||
const duplicatedBlock = newBlocks[0]
|
||||
expect(duplicatedBlock.data?.parentId).toBe(loopId)
|
||||
expect(duplicatedBlock.data?.extent).toBe('parent')
|
||||
expect(duplicatedBlock.position).toEqual({ x: 150, y: 100 })
|
||||
})
|
||||
|
||||
it('should clear parentId when parent does not exist in paste set or existing blocks', () => {
|
||||
const nonExistentParentId = 'non-existent-loop'
|
||||
const childId = 'child-1'
|
||||
|
||||
const blocksToCopy = {
|
||||
[childId]: createAgentBlock({
|
||||
id: childId,
|
||||
name: 'Agent 1',
|
||||
position: { x: 100, y: 50 },
|
||||
data: { parentId: nonExistentParentId, extent: 'parent' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
positionOffset,
|
||||
{},
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const newBlocks = Object.values(result.blocks)
|
||||
expect(newBlocks).toHaveLength(1)
|
||||
|
||||
const duplicatedBlock = newBlocks[0]
|
||||
expect(duplicatedBlock.data?.parentId).toBeUndefined()
|
||||
expect(duplicatedBlock.data?.extent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should remap parentId when copying both parent and child together', () => {
|
||||
const loopId = 'loop-1'
|
||||
const childId = 'child-1'
|
||||
|
||||
const blocksToCopy = {
|
||||
[loopId]: createLoopBlock({
|
||||
id: loopId,
|
||||
name: 'Loop 1',
|
||||
position: { x: 200, y: 200 },
|
||||
}),
|
||||
[childId]: createAgentBlock({
|
||||
id: childId,
|
||||
name: 'Agent 1',
|
||||
position: { x: 100, y: 50 },
|
||||
data: { parentId: loopId, extent: 'parent' },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
positionOffset,
|
||||
{},
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const newBlocks = Object.values(result.blocks)
|
||||
expect(newBlocks).toHaveLength(2)
|
||||
|
||||
const newLoop = newBlocks.find((b) => b.type === 'loop')
|
||||
const newChild = newBlocks.find((b) => b.type === 'agent')
|
||||
|
||||
expect(newLoop).toBeDefined()
|
||||
expect(newChild).toBeDefined()
|
||||
expect(newChild!.data?.parentId).toBe(newLoop!.id)
|
||||
expect(newChild!.data?.extent).toBe('parent')
|
||||
|
||||
expect(newLoop!.position).toEqual({ x: 250, y: 250 })
|
||||
expect(newChild!.position).toEqual({ x: 100, y: 50 })
|
||||
})
|
||||
|
||||
it('should apply offset to top-level blocks', () => {
|
||||
const blockId = 'block-1'
|
||||
|
||||
const blocksToCopy = {
|
||||
[blockId]: createAgentBlock({
|
||||
id: blockId,
|
||||
name: 'Agent 1',
|
||||
position: { x: 100, y: 100 },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
positionOffset,
|
||||
{},
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const newBlocks = Object.values(result.blocks)
|
||||
expect(newBlocks).toHaveLength(1)
|
||||
expect(newBlocks[0].position).toEqual({ x: 150, y: 150 })
|
||||
})
|
||||
|
||||
it('should generate unique names for duplicated blocks', () => {
|
||||
const blockId = 'block-1'
|
||||
|
||||
const existingBlocks = {
|
||||
existing: createAgentBlock({ id: 'existing', name: 'Agent 1' }),
|
||||
}
|
||||
|
||||
const blocksToCopy = {
|
||||
[blockId]: createAgentBlock({
|
||||
id: blockId,
|
||||
name: 'Agent 1',
|
||||
position: { x: 100, y: 100 },
|
||||
}),
|
||||
}
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
positionOffset,
|
||||
existingBlocks,
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const newBlocks = Object.values(result.blocks)
|
||||
expect(newBlocks).toHaveLength(1)
|
||||
expect(newBlocks[0].name).toBe('Agent 2')
|
||||
})
|
||||
|
||||
it('should ignore large viewport offset for blocks inside existing subflows', () => {
|
||||
const loopId = 'loop-1'
|
||||
const childId = 'child-1'
|
||||
|
||||
const existingBlocks = {
|
||||
[loopId]: createLoopBlock({ id: loopId, name: 'Loop 1' }),
|
||||
}
|
||||
|
||||
const blocksToCopy = {
|
||||
[childId]: createAgentBlock({
|
||||
id: childId,
|
||||
name: 'Agent 1',
|
||||
position: { x: 100, y: 50 },
|
||||
data: { parentId: loopId, extent: 'parent' },
|
||||
}),
|
||||
}
|
||||
|
||||
const largeViewportOffset = { x: 2000, y: 1500 }
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
largeViewportOffset,
|
||||
existingBlocks,
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const duplicatedBlock = Object.values(result.blocks)[0]
|
||||
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
|
||||
expect(duplicatedBlock.data?.parentId).toBe(loopId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { mergeSubBlockValues, mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { isAnnotationOnlyBlock, normalizeName } from '@/executor/constants'
|
||||
@@ -16,7 +17,8 @@ import type {
|
||||
} from '@/stores/workflows/workflow/types'
|
||||
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
|
||||
|
||||
const WEBHOOK_SUBBLOCK_FIELDS = ['webhookId', 'triggerPath']
|
||||
/** Threshold to detect viewport-based offsets vs small duplicate offsets */
|
||||
const LARGE_OFFSET_THRESHOLD = 300
|
||||
|
||||
/**
|
||||
* Checks if an edge is valid (source and target exist, not annotation-only, target is not a trigger)
|
||||
@@ -204,64 +206,6 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
|
||||
}
|
||||
}
|
||||
|
||||
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 baseSubBlocks: Record<string, SubBlockState> = sourceBlock.subBlocks
|
||||
? JSON.parse(JSON.stringify(sourceBlock.subBlocks))
|
||||
: {}
|
||||
|
||||
WEBHOOK_SUBBLOCK_FIELDS.forEach((field) => {
|
||||
if (field in baseSubBlocks) {
|
||||
delete baseSubBlocks[field]
|
||||
}
|
||||
})
|
||||
|
||||
const mergedSubBlocks = mergeSubBlockValues(baseSubBlocks, filteredSubBlockValues) as Record<
|
||||
string,
|
||||
SubBlockState
|
||||
>
|
||||
|
||||
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
|
||||
@@ -348,78 +292,6 @@ export function mergeSubblockState(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronously merges workflow block states with subblock values
|
||||
* Ensures all values are properly resolved before returning
|
||||
*
|
||||
* @param blocks - Block configurations from workflow store
|
||||
* @param workflowId - ID of the workflow to merge values for
|
||||
* @param blockId - Optional specific block ID to merge (merges all if not provided)
|
||||
* @returns Promise resolving to merged block states with updated values
|
||||
*/
|
||||
export async function mergeSubblockStateAsync(
|
||||
blocks: Record<string, BlockState>,
|
||||
workflowId?: string,
|
||||
blockId?: string
|
||||
): Promise<Record<string, BlockState>> {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
|
||||
if (workflowId) {
|
||||
const workflowValues = subBlockStore.workflowValues[workflowId] || {}
|
||||
return mergeSubblockStateWithValues(blocks, workflowValues, blockId)
|
||||
}
|
||||
|
||||
const blocksToProcess = blockId ? { [blockId]: blocks[blockId] } : blocks
|
||||
|
||||
// Process blocks in parallel for better performance
|
||||
const processedBlockEntries = await Promise.all(
|
||||
Object.entries(blocksToProcess).map(async ([id, block]) => {
|
||||
// Skip if block is undefined or doesn't have subBlocks
|
||||
if (!block || !block.subBlocks) {
|
||||
return [id, block] as const
|
||||
}
|
||||
|
||||
// Process all subblocks in parallel
|
||||
const subBlockEntries = await Promise.all(
|
||||
Object.entries(block.subBlocks).map(async ([subBlockId, subBlock]) => {
|
||||
// Skip if subBlock is undefined
|
||||
if (!subBlock) {
|
||||
return null
|
||||
}
|
||||
|
||||
const storedValue = subBlockStore.getValue(id, subBlockId)
|
||||
|
||||
return [
|
||||
subBlockId,
|
||||
{
|
||||
...subBlock,
|
||||
value: (storedValue !== undefined && storedValue !== null
|
||||
? storedValue
|
||||
: subBlock.value) as SubBlockState['value'],
|
||||
},
|
||||
] as const
|
||||
})
|
||||
)
|
||||
|
||||
// Convert entries back to an object
|
||||
const mergedSubBlocks = Object.fromEntries(
|
||||
subBlockEntries.filter((entry): entry is readonly [string, SubBlockState] => entry !== null)
|
||||
) as Record<string, SubBlockState>
|
||||
|
||||
// Return the full block state with updated subBlocks (including orphaned values)
|
||||
return [
|
||||
id,
|
||||
{
|
||||
...block,
|
||||
subBlocks: mergedSubBlocks,
|
||||
},
|
||||
] as const
|
||||
})
|
||||
)
|
||||
|
||||
return Object.fromEntries(processedBlockEntries) as Record<string, BlockState>
|
||||
}
|
||||
|
||||
function updateValueReferences(value: unknown, nameMap: Map<string, string>): unknown {
|
||||
if (typeof value === 'string') {
|
||||
let updatedValue = value
|
||||
@@ -444,14 +316,10 @@ function updateValueReferences(value: unknown, nameMap: Map<string, string>): un
|
||||
|
||||
function updateBlockReferences(
|
||||
blocks: Record<string, BlockState>,
|
||||
idMap: Map<string, string>,
|
||||
nameMap: Map<string, string>,
|
||||
clearTriggerRuntimeValues = false
|
||||
): void {
|
||||
Object.entries(blocks).forEach(([_, block]) => {
|
||||
// NOTE: parentId remapping is handled in regenerateBlockIds' second pass.
|
||||
// Do NOT remap parentId here as it would incorrectly clear already-mapped IDs.
|
||||
|
||||
if (block.subBlocks) {
|
||||
Object.entries(block.subBlocks).forEach(([subBlockId, subBlock]) => {
|
||||
if (clearTriggerRuntimeValues && TRIGGER_RUNTIME_SUBBLOCK_IDS.includes(subBlockId)) {
|
||||
@@ -533,7 +401,7 @@ export function regenerateWorkflowIds(
|
||||
})
|
||||
}
|
||||
|
||||
updateBlockReferences(newBlocks, blockIdMap, nameMap, clearTriggerRuntimeValues)
|
||||
updateBlockReferences(newBlocks, nameMap, clearTriggerRuntimeValues)
|
||||
|
||||
return {
|
||||
blocks: newBlocks,
|
||||
@@ -574,13 +442,36 @@ export function regenerateBlockIds(
|
||||
const newNormalizedName = normalizeName(newName)
|
||||
nameMap.set(oldNormalizedName, newNormalizedName)
|
||||
|
||||
// Check if this block has a parent that's also being copied
|
||||
// If so, it's a nested block and should keep its relative position (no offset)
|
||||
// Only top-level blocks (no parent in the paste set) get the position offset
|
||||
// Determine position offset based on parent relationship:
|
||||
// 1. Parent also being copied: keep exact relative position (parent itself will be offset)
|
||||
// 2. Parent exists in existing workflow: use provided offset, but cap large viewport-based
|
||||
// offsets since they don't make sense for relative positions
|
||||
// 3. Top-level block (no parent): apply full paste offset
|
||||
const hasParentInPasteSet = block.data?.parentId && blocks[block.data.parentId]
|
||||
const newPosition = hasParentInPasteSet
|
||||
? { x: block.position.x, y: block.position.y } // Keep relative position
|
||||
: { x: block.position.x + positionOffset.x, y: block.position.y + positionOffset.y }
|
||||
const hasParentInExistingWorkflow =
|
||||
block.data?.parentId && existingBlockNames[block.data.parentId]
|
||||
|
||||
let newPosition: Position
|
||||
if (hasParentInPasteSet) {
|
||||
// Parent also being copied - keep exact relative position
|
||||
newPosition = { x: block.position.x, y: block.position.y }
|
||||
} else if (hasParentInExistingWorkflow) {
|
||||
// Block stays in existing subflow - use provided offset unless it's viewport-based (large)
|
||||
const isLargeOffset =
|
||||
Math.abs(positionOffset.x) > LARGE_OFFSET_THRESHOLD ||
|
||||
Math.abs(positionOffset.y) > LARGE_OFFSET_THRESHOLD
|
||||
const effectiveOffset = isLargeOffset ? DEFAULT_DUPLICATE_OFFSET : positionOffset
|
||||
newPosition = {
|
||||
x: block.position.x + effectiveOffset.x,
|
||||
y: block.position.y + effectiveOffset.y,
|
||||
}
|
||||
} else {
|
||||
// Top-level block - apply full paste offset
|
||||
newPosition = {
|
||||
x: block.position.x + positionOffset.x,
|
||||
y: block.position.y + positionOffset.y,
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder block - we'll update parentId in second pass
|
||||
const newBlock: BlockState = {
|
||||
@@ -602,19 +493,30 @@ export function regenerateBlockIds(
|
||||
})
|
||||
|
||||
// Second pass: update parentId references for nested blocks
|
||||
// If a block's parent is also being pasted, map to new parentId; otherwise clear it
|
||||
// If a block's parent is also being pasted, map to new parentId
|
||||
// If parent exists in existing workflow, keep the original parentId (block stays in same subflow)
|
||||
// Otherwise clear the parentId
|
||||
Object.entries(newBlocks).forEach(([, block]) => {
|
||||
if (block.data?.parentId) {
|
||||
const oldParentId = block.data.parentId
|
||||
const newParentId = blockIdMap.get(oldParentId)
|
||||
|
||||
if (newParentId) {
|
||||
// Parent is being pasted - map to new parent ID
|
||||
block.data = {
|
||||
...block.data,
|
||||
parentId: newParentId,
|
||||
extent: 'parent',
|
||||
}
|
||||
} else if (existingBlockNames[oldParentId]) {
|
||||
// Parent exists in existing workflow - keep original parentId (block stays in same subflow)
|
||||
block.data = {
|
||||
...block.data,
|
||||
parentId: oldParentId,
|
||||
extent: 'parent',
|
||||
}
|
||||
} else {
|
||||
// Parent doesn't exist anywhere - clear the relationship
|
||||
block.data = { ...block.data, parentId: undefined, extent: undefined }
|
||||
}
|
||||
}
|
||||
@@ -647,7 +549,7 @@ export function regenerateBlockIds(
|
||||
}
|
||||
})
|
||||
|
||||
updateBlockReferences(newBlocks, blockIdMap, nameMap, false)
|
||||
updateBlockReferences(newBlocks, nameMap, false)
|
||||
|
||||
Object.entries(newSubBlockValues).forEach(([_, blockValues]) => {
|
||||
Object.keys(blockValues).forEach((subBlockId) => {
|
||||
|
||||
Reference in New Issue
Block a user