mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(blocks): remap condition/router IDs when duplicating blocks (#3533)
* fix(blocks): remap condition/router IDs when duplicating blocks
Condition and router blocks embed IDs in the format `{blockId}-{suffix}`
inside their subBlock values and edge sourceHandles. When blocks were
duplicated, these IDs were not updated to reference the new block ID,
causing duplicate handle IDs and broken edge routing.
Fixes all four duplication paths: single block duplicate, copy/paste,
workflow duplication (server-side), and workflow import.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(blocks): deep-clone subBlocks before mutating condition IDs
Shallow copy of subBlocks meant remapConditionIds could mutate the
source data (clipboard on repeated paste, or input workflowState on
import). Deep-clone subBlocks in both regenerateBlockIds and
regenerateWorkflowIds to prevent this.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix(blocks): remap condition IDs in regenerateWorkflowStateIds (template use)
The template use code path was missing condition/router ID remapping,
causing broken condition blocks when creating workflows from templates.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
68d207df94
commit
6bcbd15ee6
@@ -0,0 +1,57 @@
|
||||
import { EDGE } from '@/executor/constants'
|
||||
|
||||
/**
|
||||
* Remaps condition/router block IDs in a parsed conditions array.
|
||||
* Condition IDs use the format `{blockId}-{suffix}` and must be updated
|
||||
* when a block is duplicated to reference the new block ID.
|
||||
*
|
||||
* @param conditions - Parsed array of condition block objects with `id` fields
|
||||
* @param oldBlockId - The original block ID prefix to replace
|
||||
* @param newBlockId - The new block ID prefix
|
||||
* @returns Whether any IDs were changed (mutates in place)
|
||||
*/
|
||||
export function remapConditionBlockIds(
|
||||
conditions: Array<{ id: string; [key: string]: unknown }>,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): boolean {
|
||||
let changed = false
|
||||
const prefix = `${oldBlockId}-`
|
||||
for (const condition of conditions) {
|
||||
if (typeof condition.id === 'string' && condition.id.startsWith(prefix)) {
|
||||
const suffix = condition.id.slice(oldBlockId.length)
|
||||
condition.id = `${newBlockId}${suffix}`
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** Handle prefixes that embed block-scoped condition/route IDs */
|
||||
const HANDLE_PREFIXES = [EDGE.CONDITION_PREFIX, EDGE.ROUTER_PREFIX] as const
|
||||
|
||||
/**
|
||||
* Remaps a condition or router edge sourceHandle from the old block ID to the new one.
|
||||
* Handle formats:
|
||||
* - Condition: `condition-{blockId}-{suffix}`
|
||||
* - Router V2: `router-{blockId}-{suffix}`
|
||||
*
|
||||
* @returns The remapped handle string, or the original if no remapping needed
|
||||
*/
|
||||
export function remapConditionEdgeHandle(
|
||||
sourceHandle: string,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): string {
|
||||
for (const handlePrefix of HANDLE_PREFIXES) {
|
||||
if (!sourceHandle.startsWith(handlePrefix)) continue
|
||||
|
||||
const innerId = sourceHandle.slice(handlePrefix.length)
|
||||
if (!innerId.startsWith(`${oldBlockId}-`)) continue
|
||||
|
||||
const suffix = innerId.slice(oldBlockId.length)
|
||||
return `${handlePrefix}${newBlockId}${suffix}`
|
||||
}
|
||||
|
||||
return sourceHandle
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull, min } from 'drizzle-orm'
|
||||
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
||||
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
|
||||
import type { Variable } from '@/stores/panel/variables/types'
|
||||
@@ -77,6 +78,40 @@ function remapVariableIdsInSubBlocks(
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps condition/router block IDs within subBlocks when a block is duplicated.
|
||||
* Returns a new object without mutating the input.
|
||||
*/
|
||||
function remapConditionIdsInSubBlocks(
|
||||
subBlocks: Record<string, any>,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): Record<string, any> {
|
||||
const updated: Record<string, any> = {}
|
||||
|
||||
for (const [key, subBlock] of Object.entries(subBlocks)) {
|
||||
if (
|
||||
subBlock &&
|
||||
typeof subBlock === 'object' &&
|
||||
(subBlock.type === 'condition-input' || subBlock.type === 'router-input') &&
|
||||
typeof subBlock.value === 'string'
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(subBlock.value)
|
||||
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
|
||||
updated[key] = { ...subBlock, value: JSON.stringify(parsed) }
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
updated[key] = subBlock
|
||||
}
|
||||
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a workflow with all its blocks, edges, and subflows
|
||||
* This is a shared helper used by both the workflow duplicate API and folder duplicate API
|
||||
@@ -259,6 +294,15 @@ export async function duplicateWorkflow(
|
||||
)
|
||||
}
|
||||
|
||||
// Remap condition/router IDs to use the new block ID
|
||||
if (updatedSubBlocks && typeof updatedSubBlocks === 'object') {
|
||||
updatedSubBlocks = remapConditionIdsInSubBlocks(
|
||||
updatedSubBlocks as Record<string, any>,
|
||||
block.id,
|
||||
newBlockId
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...block,
|
||||
id: newBlockId,
|
||||
@@ -286,15 +330,24 @@ export async function duplicateWorkflow(
|
||||
.where(eq(workflowEdges.workflowId, sourceWorkflowId))
|
||||
|
||||
if (sourceEdges.length > 0) {
|
||||
const newEdges = sourceEdges.map((edge) => ({
|
||||
...edge,
|
||||
id: crypto.randomUUID(), // Generate new edge ID
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId,
|
||||
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}))
|
||||
const newEdges = sourceEdges.map((edge) => {
|
||||
const newSourceBlockId = blockIdMapping.get(edge.sourceBlockId) || edge.sourceBlockId
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMapping.has(edge.sourceBlockId)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.sourceBlockId, newSourceBlockId)
|
||||
: edge.sourceHandle
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: crypto.randomUUID(),
|
||||
workflowId: newWorkflowId,
|
||||
sourceBlockId: newSourceBlockId,
|
||||
targetBlockId: blockIdMapping.get(edge.targetBlockId) || edge.targetBlockId,
|
||||
sourceHandle: newSourceHandle,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
})
|
||||
|
||||
await tx.insert(workflowEdges).values(newEdges)
|
||||
logger.info(`[${requestId}] Copied ${sourceEdges.length} edges with updated block references`)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { and, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||
import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import type { DbOrTx } from '@/lib/db/types'
|
||||
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import {
|
||||
backfillCanonicalModes,
|
||||
migrateSubblockIds,
|
||||
@@ -833,7 +834,12 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
|
||||
Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
|
||||
const newId = blockIdMapping.get(oldId)!
|
||||
// Duplicated blocks are always unlocked so users can edit them
|
||||
const newBlock: BlockState = { ...block, id: newId, locked: false }
|
||||
const newBlock: BlockState = {
|
||||
...block,
|
||||
id: newId,
|
||||
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
|
||||
locked: false,
|
||||
}
|
||||
|
||||
// Update parentId reference if it exists
|
||||
if (newBlock.data?.parentId) {
|
||||
@@ -857,6 +863,21 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
|
||||
updatedSubBlock.value = blockIdMapping.get(updatedSubBlock.value) ?? updatedSubBlock.value
|
||||
}
|
||||
|
||||
// Remap condition/router IDs embedded in condition-input/router-input subBlocks
|
||||
if (
|
||||
(updatedSubBlock.type === 'condition-input' || updatedSubBlock.type === 'router-input') &&
|
||||
typeof updatedSubBlock.value === 'string'
|
||||
) {
|
||||
try {
|
||||
const parsed = JSON.parse(updatedSubBlock.value)
|
||||
if (Array.isArray(parsed) && remapConditionBlockIds(parsed, oldId, newId)) {
|
||||
updatedSubBlock.value = JSON.stringify(parsed)
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
|
||||
updatedSubBlocks[subId] = updatedSubBlock
|
||||
})
|
||||
newBlock.subBlocks = updatedSubBlocks
|
||||
@@ -871,12 +892,17 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
|
||||
const newId = edgeIdMapping.get(edge.id)!
|
||||
const newSource = blockIdMapping.get(edge.source) || edge.source
|
||||
const newTarget = blockIdMapping.get(edge.target) || edge.target
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMapping.has(edge.source)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
|
||||
: edge.sourceHandle
|
||||
|
||||
newEdges.push({
|
||||
...edge,
|
||||
id: newId,
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
sourceHandle: newSourceHandle,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Edge } from 'reactflow'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||
import { remapConditionBlockIds, remapConditionEdgeHandle } from '@/lib/workflows/condition-ids'
|
||||
import { mergeSubblockStateWithValues } from '@/lib/workflows/subblocks'
|
||||
import { buildDefaultCanonicalModes } from '@/lib/workflows/subblocks/visibility'
|
||||
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
|
||||
@@ -363,13 +364,15 @@ export function regenerateWorkflowIds(
|
||||
const nameMap = new Map<string, string>()
|
||||
const newBlocks: Record<string, BlockState> = {}
|
||||
|
||||
// First pass: generate new IDs
|
||||
// First pass: generate new IDs and remap condition/router IDs in subBlocks
|
||||
Object.entries(workflowState.blocks).forEach(([oldId, block]) => {
|
||||
const newId = uuidv4()
|
||||
blockIdMap.set(oldId, newId)
|
||||
const oldNormalizedName = normalizeName(block.name)
|
||||
nameMap.set(oldNormalizedName, oldNormalizedName)
|
||||
newBlocks[newId] = { ...block, id: newId }
|
||||
const newBlock = { ...block, id: newId, subBlocks: JSON.parse(JSON.stringify(block.subBlocks)) }
|
||||
remapConditionIds(newBlock.subBlocks, {}, oldId, newId)
|
||||
newBlocks[newId] = newBlock
|
||||
})
|
||||
|
||||
// Second pass: update parentId references
|
||||
@@ -385,12 +388,21 @@ export function regenerateWorkflowIds(
|
||||
}
|
||||
})
|
||||
|
||||
const newEdges = workflowState.edges.map((edge) => ({
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
const newEdges = workflowState.edges.map((edge) => {
|
||||
const newSource = blockIdMap.get(edge.source) || edge.source
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMap.has(edge.source)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
|
||||
: edge.sourceHandle
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: newSource,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
sourceHandle: newSourceHandle,
|
||||
}
|
||||
})
|
||||
|
||||
const newLoops: Record<string, Loop> = {}
|
||||
if (workflowState.loops) {
|
||||
@@ -429,6 +441,37 @@ export function regenerateWorkflowIds(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remaps condition/router block IDs within subBlock values when a block is duplicated.
|
||||
* Mutates both `subBlocks` and `subBlockValues` in place (callers must pass cloned data).
|
||||
*/
|
||||
export function remapConditionIds(
|
||||
subBlocks: Record<string, SubBlockState>,
|
||||
subBlockValues: Record<string, unknown>,
|
||||
oldBlockId: string,
|
||||
newBlockId: string
|
||||
): void {
|
||||
for (const [subBlockId, subBlock] of Object.entries(subBlocks)) {
|
||||
if (subBlock.type !== 'condition-input' && subBlock.type !== 'router-input') continue
|
||||
|
||||
const value = subBlockValues[subBlockId] ?? subBlock.value
|
||||
if (typeof value !== 'string') continue
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (!Array.isArray(parsed)) continue
|
||||
|
||||
if (remapConditionBlockIds(parsed, oldBlockId, newBlockId)) {
|
||||
const newValue = JSON.stringify(parsed)
|
||||
subBlock.value = newValue
|
||||
subBlockValues[subBlockId] = newValue
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function regenerateBlockIds(
|
||||
blocks: Record<string, BlockState>,
|
||||
edges: Edge[],
|
||||
@@ -497,6 +540,7 @@ export function regenerateBlockIds(
|
||||
id: newId,
|
||||
name: newName,
|
||||
position: newPosition,
|
||||
subBlocks: JSON.parse(JSON.stringify(block.subBlocks)),
|
||||
// Temporarily keep data as-is, we'll fix parentId in second pass
|
||||
data: block.data ? { ...block.data } : block.data,
|
||||
// Duplicated blocks are always unlocked so users can edit them
|
||||
@@ -510,6 +554,9 @@ export function regenerateBlockIds(
|
||||
if (subBlockValues[oldId]) {
|
||||
newSubBlockValues[newId] = JSON.parse(JSON.stringify(subBlockValues[oldId]))
|
||||
}
|
||||
|
||||
// Remap condition/router IDs in the duplicated block
|
||||
remapConditionIds(newBlock.subBlocks, newSubBlockValues[newId] || {}, oldId, newId)
|
||||
})
|
||||
|
||||
// Second pass: update parentId references for nested blocks
|
||||
@@ -542,12 +589,21 @@ export function regenerateBlockIds(
|
||||
}
|
||||
})
|
||||
|
||||
const newEdges = edges.map((edge) => ({
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: blockIdMap.get(edge.source) || edge.source,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
}))
|
||||
const newEdges = edges.map((edge) => {
|
||||
const newSource = blockIdMap.get(edge.source) || edge.source
|
||||
const newSourceHandle =
|
||||
edge.sourceHandle && blockIdMap.has(edge.source)
|
||||
? remapConditionEdgeHandle(edge.sourceHandle, edge.source, newSource)
|
||||
: edge.sourceHandle
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: uuidv4(),
|
||||
source: newSource,
|
||||
target: blockIdMap.get(edge.target) || edge.target,
|
||||
sourceHandle: newSourceHandle,
|
||||
}
|
||||
})
|
||||
|
||||
const newLoops: Record<string, Loop> = {}
|
||||
Object.entries(loops).forEach(([oldLoopId, loop]) => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
filterValidEdges,
|
||||
getUniqueBlockName,
|
||||
mergeSubblockState,
|
||||
remapConditionIds,
|
||||
} from '@/stores/workflows/utils'
|
||||
import type {
|
||||
Position,
|
||||
@@ -611,6 +612,21 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
{}
|
||||
)
|
||||
|
||||
// Remap condition/router IDs in the duplicated subBlocks
|
||||
const clonedSubBlockValues = activeWorkflowId
|
||||
? JSON.parse(
|
||||
JSON.stringify(
|
||||
useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[id] || {}
|
||||
)
|
||||
)
|
||||
: {}
|
||||
remapConditionIds(
|
||||
newSubBlocks as Record<string, SubBlockState>,
|
||||
clonedSubBlockValues,
|
||||
id,
|
||||
newId
|
||||
)
|
||||
|
||||
const newState = {
|
||||
blocks: {
|
||||
...get().blocks,
|
||||
@@ -630,14 +646,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
}
|
||||
|
||||
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)),
|
||||
[newId]: clonedSubBlockValues,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user