diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx index 1e7c5e7e96..c85fc6dac4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/action-bar/action-bar.tsx @@ -12,9 +12,12 @@ interface ActionBarProps { } export function ActionBar({ blockId, blockType, disabled = false }: ActionBarProps) { - const { collaborativeRemoveBlock, collaborativeToggleBlockEnabled } = useCollaborativeWorkflow() - const toggleBlockHandles = useWorkflowStore((state) => state.toggleBlockHandles) - const duplicateBlock = useWorkflowStore((state) => state.duplicateBlock) + const { + collaborativeRemoveBlock, + collaborativeToggleBlockEnabled, + collaborativeDuplicateBlock, + collaborativeToggleBlockHandles, + } = useCollaborativeWorkflow() const isEnabled = useWorkflowStore((state) => state.blocks[blockId]?.enabled ?? true) const horizontalHandles = useWorkflowStore( (state) => state.blocks[blockId]?.horizontalHandles ?? false @@ -77,7 +80,7 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro size='sm' onClick={() => { if (!disabled) { - duplicateBlock(blockId) + collaborativeDuplicateBlock(blockId) } }} className={cn('text-gray-500', disabled && 'cursor-not-allowed opacity-50')} @@ -99,7 +102,7 @@ export function ActionBar({ blockId, blockType, disabled = false }: ActionBarPro size='sm' onClick={() => { if (!disabled) { - toggleBlockHandles(blockId) + collaborativeToggleBlockHandles(blockId) } }} className={cn('text-gray-500', disabled && 'cursor-not-allowed opacity-50')} diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts index d4d84eb794..f05111d055 100644 --- a/apps/sim/hooks/use-collaborative-workflow.ts +++ b/apps/sim/hooks/use-collaborative-workflow.ts @@ -146,6 +146,26 @@ export function useCollaborativeWorkflow() { // For now, we'll use the existing toggle method workflowStore.toggleBlockAdvancedMode(payload.id) break + case 'toggle-handles': { + // Apply the handles toggle - we need to set the specific value to ensure consistency + const currentBlock = workflowStore.blocks[payload.id] + if (currentBlock && currentBlock.horizontalHandles !== payload.horizontalHandles) { + workflowStore.toggleBlockHandles(payload.id) + } + break + } + case 'duplicate': + // Apply the duplicate operation by adding the new block + workflowStore.addBlock( + payload.id, + payload.type, + payload.name, + payload.position, + payload.data, + payload.parentId, + payload.extent + ) + break } } else if (target === 'edge') { switch (operation) { @@ -469,6 +489,100 @@ export function useCollaborativeWorkflow() { [workflowStore, emitWorkflowOperation] ) + const collaborativeToggleBlockHandles = useCallback( + (id: string) => { + // Get the current state before toggling + const currentBlock = workflowStore.blocks[id] + if (!currentBlock) return + + // Calculate the new horizontalHandles value + const newHorizontalHandles = !currentBlock.horizontalHandles + + // Apply locally first + workflowStore.toggleBlockHandles(id) + + // Emit with the calculated new value (don't rely on async state update) + if (!isApplyingRemoteChange.current) { + emitWorkflowOperation('toggle-handles', 'block', { + id, + horizontalHandles: newHorizontalHandles, + }) + } + }, + [workflowStore, emitWorkflowOperation] + ) + + const collaborativeDuplicateBlock = useCallback( + (sourceId: string) => { + const sourceBlock = workflowStore.blocks[sourceId] + if (!sourceBlock) return + + // Generate new ID and calculate position + const newId = crypto.randomUUID() + const offsetPosition = { + x: sourceBlock.position.x + 250, + y: sourceBlock.position.y + 20, + } + + // Generate new name with numbering + const match = sourceBlock.name.match(/(.*?)(\d+)?$/) + const newName = match?.[2] + ? `${match[1]}${Number.parseInt(match[2]) + 1}` + : `${sourceBlock.name} 1` + + // Create the complete block data for the socket operation + const duplicatedBlockData = { + sourceId, + id: newId, + type: sourceBlock.type, + name: newName, + position: offsetPosition, + data: sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {}, + subBlocks: sourceBlock.subBlocks ? JSON.parse(JSON.stringify(sourceBlock.subBlocks)) : {}, + outputs: sourceBlock.outputs ? JSON.parse(JSON.stringify(sourceBlock.outputs)) : {}, + parentId: sourceBlock.data?.parentId || null, + extent: sourceBlock.data?.extent || null, + enabled: sourceBlock.enabled ?? true, + horizontalHandles: sourceBlock.horizontalHandles ?? true, + isWide: sourceBlock.isWide ?? false, + height: sourceBlock.height || 0, + } + + // Apply locally first using addBlock to ensure consistent IDs + workflowStore.addBlock( + newId, + sourceBlock.type, + newName, + offsetPosition, + sourceBlock.data ? JSON.parse(JSON.stringify(sourceBlock.data)) : {}, + sourceBlock.data?.parentId, + sourceBlock.data?.extent + ) + + // Copy subblock values to the new block + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (activeWorkflowId) { + const subBlockValues = + useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[sourceId] || {} + useSubBlockStore.setState((state) => ({ + workflowValues: { + ...state.workflowValues, + [activeWorkflowId]: { + ...state.workflowValues[activeWorkflowId], + [newId]: JSON.parse(JSON.stringify(subBlockValues)), + }, + }, + })) + } + + // Then broadcast to other clients + if (!isApplyingRemoteChange.current) { + emitWorkflowOperation('duplicate', 'block', duplicatedBlockData) + } + }, + [workflowStore, emitWorkflowOperation] + ) + const collaborativeAddEdge = useCallback( (edge: Edge) => { // Apply locally first @@ -780,6 +894,8 @@ export function useCollaborativeWorkflow() { collaborativeUpdateParentId, collaborativeToggleBlockWide, collaborativeToggleBlockAdvancedMode, + collaborativeToggleBlockHandles, + collaborativeDuplicateBlock, collaborativeAddEdge, collaborativeRemoveEdge, collaborativeSetSubblockValue, diff --git a/apps/sim/socket-server/database/operations.ts b/apps/sim/socket-server/database/operations.ts index 056fc915ff..44092bdeb1 100644 --- a/apps/sim/socket-server/database/operations.ts +++ b/apps/sim/socket-server/database/operations.ts @@ -486,6 +486,122 @@ async function handleBlockOperationTx( break } + case 'toggle-handles': { + if (!payload.id || payload.horizontalHandles === undefined) { + throw new Error('Missing required fields for toggle handles operation') + } + + const updateResult = await tx + .update(workflowBlocks) + .set({ + horizontalHandles: payload.horizontalHandles, + updatedAt: new Date(), + }) + .where(and(eq(workflowBlocks.id, payload.id), eq(workflowBlocks.workflowId, workflowId))) + .returning({ id: workflowBlocks.id }) + + if (updateResult.length === 0) { + throw new Error(`Block ${payload.id} not found in workflow ${workflowId}`) + } + + logger.debug( + `Updated block handles: ${payload.id} -> ${payload.horizontalHandles ? 'horizontal' : 'vertical'}` + ) + break + } + + case 'duplicate': { + // Validate required fields for duplicate operation + if (!payload.sourceId || !payload.id || !payload.type || !payload.name || !payload.position) { + throw new Error('Missing required fields for duplicate block operation') + } + + logger.debug( + `[SERVER] Duplicating block: ${payload.type} (${payload.sourceId} -> ${payload.id})`, + { + isSubflowType: isSubflowBlockType(payload.type), + payload, + } + ) + + // Extract parentId and extent from payload + const parentId = payload.parentId || null + const extent = payload.extent || null + + try { + const insertData = { + id: payload.id, + workflowId, + type: payload.type, + name: payload.name, + positionX: payload.position.x, + positionY: payload.position.y, + data: payload.data || {}, + subBlocks: payload.subBlocks || {}, + outputs: payload.outputs || {}, + parentId, + extent, + enabled: payload.enabled ?? true, + horizontalHandles: payload.horizontalHandles ?? true, + isWide: payload.isWide ?? false, + height: payload.height || 0, + } + + await tx.insert(workflowBlocks).values(insertData) + } catch (insertError) { + logger.error(`[SERVER] ❌ Failed to insert duplicated block ${payload.id}:`, insertError) + throw insertError + } + + // Auto-create subflow entry for loop/parallel blocks + if (isSubflowBlockType(payload.type)) { + try { + const subflowConfig = + payload.type === SubflowType.LOOP + ? { + id: payload.id, + nodes: [], // Empty initially, will be populated when child blocks are added + iterations: payload.data?.count || DEFAULT_LOOP_ITERATIONS, + loopType: payload.data?.loopType || 'for', + forEachItems: payload.data?.collection || '', + } + : { + id: payload.id, + nodes: [], // Empty initially, will be populated when child blocks are added + distribution: payload.data?.collection || '', + } + + logger.debug( + `[SERVER] Auto-creating ${payload.type} subflow for duplicated block ${payload.id}:`, + subflowConfig + ) + + await tx.insert(workflowSubflows).values({ + id: payload.id, + workflowId, + type: payload.type, + config: subflowConfig, + }) + } catch (subflowError) { + logger.error( + `[SERVER] ❌ Failed to create ${payload.type} subflow for duplicated block ${payload.id}:`, + subflowError + ) + throw subflowError + } + } + + // If this block has a parent, update the parent's subflow node list + if (parentId) { + await updateSubflowNodeList(tx, workflowId, parentId) + } + + logger.debug( + `Duplicated block ${payload.sourceId} -> ${payload.id} (${payload.type}) in workflow ${workflowId}` + ) + break + } + // Add other block operations as needed default: logger.warn(`Unknown block operation: ${operation}`) diff --git a/apps/sim/socket-server/middleware/permissions.ts b/apps/sim/socket-server/middleware/permissions.ts index 4d4044b73e..ca5aa4af82 100644 --- a/apps/sim/socket-server/middleware/permissions.ts +++ b/apps/sim/socket-server/middleware/permissions.ts @@ -104,6 +104,7 @@ export async function verifyOperationPermission( 'update-parent', 'update-wide', 'update-advanced-mode', + 'toggle-handles', 'duplicate', ], admin: [ @@ -116,6 +117,7 @@ export async function verifyOperationPermission( 'update-parent', 'update-wide', 'update-advanced-mode', + 'toggle-handles', 'duplicate', ], member: [ @@ -128,6 +130,7 @@ export async function verifyOperationPermission( 'update-parent', 'update-wide', 'update-advanced-mode', + 'toggle-handles', 'duplicate', ], viewer: ['update-position'], // Viewers can only move things around diff --git a/apps/sim/socket-server/validation/schemas.ts b/apps/sim/socket-server/validation/schemas.ts index 3c4d713d24..a238f46f34 100644 --- a/apps/sim/socket-server/validation/schemas.ts +++ b/apps/sim/socket-server/validation/schemas.ts @@ -15,19 +15,21 @@ export const BlockOperationSchema = z.object({ 'update-parent', 'update-wide', 'update-advanced-mode', + 'toggle-handles', 'duplicate', ]), target: z.literal('block'), payload: z.object({ id: z.string(), + sourceId: z.string().optional(), // For duplicate operations type: z.string().optional(), name: z.string().optional(), position: PositionSchema.optional(), data: z.record(z.any()).optional(), subBlocks: z.record(z.any()).optional(), outputs: z.record(z.any()).optional(), - parentId: z.string().optional(), - extent: z.enum(['parent']).optional(), + parentId: z.string().nullable().optional(), + extent: z.enum(['parent']).nullable().optional(), enabled: z.boolean().optional(), horizontalHandles: z.boolean().optional(), isWide: z.boolean().optional(),