- {isParentLocked
- ? 'Parent container is locked'
+ {isAncestorLocked
+ ? 'Ancestor container is locked'
: userPermissions.canAdmin && currentBlock.locked
? 'Unlock block'
: 'Block is locked'}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
index 81507596317..9118e0dd9b7 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/subflow-node.tsx
@@ -1,4 +1,4 @@
-import { memo, useMemo, useRef } from 'react'
+import { memo, useMemo } from 'react'
import { RepeatIcon, SplitIcon } from 'lucide-react'
import { Handle, type NodeProps, Position, useReactFlow } from 'reactflow'
import { Badge } from '@/components/emcn'
@@ -28,6 +28,28 @@ export interface SubflowNodeData {
executionStatus?: 'success' | 'error' | 'not-executed'
}
+const HANDLE_STYLE = {
+ top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`,
+ transform: 'translateY(-50%)',
+} as const
+
+/**
+ * Reusable class names for Handle components.
+ * Matches the styling pattern from workflow-block.tsx.
+ */
+const getHandleClasses = (position: 'left' | 'right') => {
+ const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
+ const colorClasses = '!bg-[var(--workflow-edge)]'
+
+ const positionClasses = {
+ left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
+ right:
+ '!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
+ }
+
+ return cn(baseClasses, colorClasses, positionClasses[position])
+}
+
/**
* Subflow node component for loop and parallel execution containers.
* Renders a resizable container with a header displaying the block name and icon,
@@ -38,7 +60,6 @@ export interface SubflowNodeData {
*/
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps) => {
const { getNodes } = useReactFlow()
- const blockRef = useRef(null)
const userPermissions = useUserPermissionsContext()
const currentWorkflow = useCurrentWorkflow()
@@ -52,7 +73,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === id
@@ -84,7 +104,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps {
- const baseClasses = '!z-[10] !cursor-crosshair !border-none !transition-[colors] !duration-150'
- const colorClasses = '!bg-[var(--workflow-edge)]'
-
- const positionClasses = {
- left: '!left-[-8px] !h-5 !w-[7px] !rounded-l-[2px] !rounded-r-none hover:!left-[-11px] hover:!w-[10px] hover:!rounded-l-full',
- right:
- '!right-[-8px] !h-5 !w-[7px] !rounded-r-[2px] !rounded-l-none hover:!right-[-11px] hover:!w-[10px] hover:!rounded-r-full',
- }
-
- return cn(baseClasses, colorClasses, positionClasses[position])
- }
-
- const getHandleStyle = () => {
- return { top: `${HANDLE_POSITIONS.DEFAULT_Y_OFFSET}px`, transform: 'translateY(-50%)' }
- }
-
/**
* Determine the ring styling based on subflow state priority:
* 1. Focused (selected in editor), selected (shift-click/box), or preview selected - blue ring
@@ -127,46 +126,37 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps {
+ if (!hasRing) return undefined
+ if (isFocused || isSelected || isPreviewSelected) return 'var(--brand-secondary)'
+ if (diffStatus === 'new') return 'var(--brand-tertiary-2)'
+ if (diffStatus === 'edited') return 'var(--warning)'
+ if (runPathStatus === 'success') {
+ return executionStatus ? 'var(--brand-tertiary-2)' : 'var(--border-success)'
+ }
+ if (runPathStatus === 'error') return 'var(--text-error)'
+ return undefined
+ }
+ const ringColor = getRingColor()
return (
setCurrentBlockId(id)}
- className={cn(
- 'workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
- )}
+ className='workflow-drag-handle flex cursor-grab items-center justify-between rounded-t-[8px] border-[var(--border)] border-b bg-[var(--surface-2)] py-[8px] pr-[12px] pl-[8px] [&:active]:cursor-grabbing'
style={{ pointerEvents: 'auto' }}
>
@@ -209,6 +197,17 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps
+ {/*
+ * Click-catching background — selects this subflow when the body area is clicked.
+ * No event bubbling concern: ReactFlow renders child nodes as viewport-level siblings,
+ * not as DOM children of this component, so child clicks never reach this div.
+ */}
+
setCurrentBlockId(id)}
+ />
+
{!isPreview && (
{/* Subflow Start */}
@@ -266,7 +262,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps
): boolean {
- const block = blocks[blockId]
- if (!block) return false
-
- // Block is locked directly
- if (block.locked) return true
-
- // Block is inside a locked container
- const parentId = block.data?.parentId
- if (parentId && blocks[parentId]?.locked) return true
-
- return false
-}
-
/**
* Checks if an edge is protected from modification.
* An edge is protected only if its target block is protected.
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
index 3b4559fde92..577af370cb3 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx
@@ -196,17 +196,14 @@ const edgeTypes: EdgeTypes = {
const defaultEdgeOptions = { type: 'custom' }
const reactFlowStyles = [
- 'bg-[var(--bg)]',
'[&_.react-flow__edges]:!z-0',
'[&_.react-flow__node]:z-[21]',
'[&_.react-flow__handle]:!z-[30]',
- '[&_.react-flow__edge-labels]:!z-[60]',
- '[&_.react-flow__pane]:!bg-[var(--bg)]',
+ '[&_.react-flow__edge-labels]:!z-[1001]',
'[&_.react-flow__pane]:select-none',
'[&_.react-flow__selectionpane]:select-none',
- '[&_.react-flow__renderer]:!bg-[var(--bg)]',
- '[&_.react-flow__viewport]:!bg-[var(--bg)]',
'[&_.react-flow__background]:hidden',
+ '[&_.react-flow__node-subflowNode.selected]:!shadow-none',
].join(' ')
const reactFlowFitViewOptions = { padding: 0.6, maxZoom: 1.0 } as const
const reactFlowProOptions = { hideAttribution: true } as const
@@ -2412,6 +2409,12 @@ const WorkflowContent = React.memo(() => {
const nodeType = block.type === 'note' ? 'noteBlock' : 'workflowBlock'
const dragHandle = block.type === 'note' ? '.note-drag-handle' : '.workflow-drag-handle'
+ // Compute zIndex for blocks inside containers so they render above the
+ // parent subflow's interactive body area (which needs pointer-events for
+ // click-to-select). Container nodes use zIndex: depth (0, 1, 2...),
+ // so child blocks use a baseline that is always above any container.
+ const childZIndex = block.data?.parentId ? 1000 : undefined
+
// Create stable node object - React Flow will handle shallow comparison
nodeArray.push({
id: block.id,
@@ -2420,6 +2423,7 @@ const WorkflowContent = React.memo(() => {
parentId: block.data?.parentId,
dragHandle,
draggable: !isBlockProtected(block.id, blocks),
+ ...(childZIndex !== undefined && { zIndex: childZIndex }),
extent: (() => {
// Clamp children to subflow body (exclude header)
const parentId = block.data?.parentId as string | undefined
@@ -3768,21 +3772,20 @@ const WorkflowContent = React.memo(() => {
return (
- {/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
-
+ {!isWorkflowReady && (
+
+ )}
{isWorkflowReady && (
<>
@@ -3835,7 +3838,7 @@ const WorkflowContent = React.memo(() => {
noWheelClassName='allow-scroll'
edgesFocusable={true}
edgesUpdatable={effectivePermissions.canEdit}
- className={`workflow-container h-full transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
+ className={`workflow-container h-full bg-[var(--bg)] transition-opacity duration-150 ${reactFlowStyles} ${isCanvasReady ? 'opacity-100' : 'opacity-0'} ${isHandMode ? 'canvas-mode-hand' : 'canvas-mode-cursor'}`}
onNodeDrag={effectivePermissions.canEdit ? onNodeDrag : undefined}
onNodeDragStop={effectivePermissions.canEdit ? onNodeDragStop : undefined}
onSelectionDragStart={effectivePermissions.canEdit ? onSelectionDragStart : undefined}
@@ -3847,7 +3850,7 @@ const WorkflowContent = React.memo(() => {
elevateEdgesOnSelect={true}
onlyRenderVisibleElements={false}
deleteKeyCode={null}
- elevateNodesOnSelect={true}
+ elevateNodesOnSelect={false}
autoPanOnConnect={effectivePermissions.canEdit}
autoPanOnNodeDrag={effectivePermissions.canEdit}
/>
diff --git a/apps/sim/hooks/use-collaborative-workflow.ts b/apps/sim/hooks/use-collaborative-workflow.ts
index 83fdd5ed17b..db3ac1a6c66 100644
--- a/apps/sim/hooks/use-collaborative-workflow.ts
+++ b/apps/sim/hooks/use-collaborative-workflow.ts
@@ -27,6 +27,7 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
import { filterNewEdges, filterValidEdges, mergeSubblockState } from '@/stores/workflows/utils'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
import type { BlockState, Loop, Parallel, Position } from '@/stores/workflows/workflow/types'
+import { findAllDescendantNodes, isBlockProtected } from '@/stores/workflows/workflow/utils'
const logger = createLogger('CollaborativeWorkflow')
@@ -748,9 +749,7 @@ export function useCollaborativeWorkflow() {
const block = blocks[id]
if (block) {
- const parentId = block.data?.parentId
- const isParentLocked = parentId ? blocks[parentId]?.locked : false
- if (block.locked || isParentLocked) {
+ if (isBlockProtected(id, blocks)) {
logger.error('Cannot rename locked block')
useNotificationStore.getState().addNotification({
level: 'info',
@@ -858,21 +857,21 @@ export function useCollaborativeWorkflow() {
const previousStates: Record
= {}
const validIds: string[] = []
- // For each ID, collect non-locked blocks and their children for undo/redo
+ // For each ID, collect non-locked blocks and their descendants for undo/redo
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
- // Skip locked blocks
- if (block.locked) continue
+ // Skip protected blocks (locked or inside a locked ancestor)
+ if (isBlockProtected(id, currentBlocks)) continue
validIds.push(id)
previousStates[id] = block.enabled
- // If it's a loop or parallel, also capture children's previous states for undo/redo
+ // If it's a loop or parallel, also capture descendants' previous states for undo/redo
if (block.type === 'loop' || block.type === 'parallel') {
- Object.entries(currentBlocks).forEach(([blockId, b]) => {
- if (b.data?.parentId === id && !b.locked) {
- previousStates[blockId] = b.enabled
+ findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
+ if (!isBlockProtected(descId, currentBlocks)) {
+ previousStates[descId] = currentBlocks[descId]?.enabled ?? true
}
})
}
@@ -1038,21 +1037,12 @@ export function useCollaborativeWorkflow() {
const blocks = useWorkflowStore.getState().blocks
- const isProtected = (blockId: string): boolean => {
- const block = blocks[blockId]
- if (!block) return false
- if (block.locked) return true
- const parentId = block.data?.parentId
- if (parentId && blocks[parentId]?.locked) return true
- return false
- }
-
const previousStates: Record = {}
const validIds: string[] = []
for (const id of ids) {
const block = blocks[id]
- if (block && !isProtected(id)) {
+ if (block && !isBlockProtected(id, blocks)) {
previousStates[id] = block.horizontalHandles ?? false
validIds.push(id)
}
@@ -1100,10 +1090,8 @@ export function useCollaborativeWorkflow() {
previousStates[id] = block.locked ?? false
if (block.type === 'loop' || block.type === 'parallel') {
- Object.entries(currentBlocks).forEach(([blockId, b]) => {
- if (b.data?.parentId === id) {
- previousStates[blockId] = b.locked ?? false
- }
+ findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
+ previousStates[descId] = currentBlocks[descId]?.locked ?? false
})
}
}
diff --git a/apps/sim/socket/database/operations.ts b/apps/sim/socket/database/operations.ts
index 51245f763b3..d157feb0f41 100644
--- a/apps/sim/socket/database/operations.ts
+++ b/apps/sim/socket/database/operations.ts
@@ -39,6 +39,56 @@ const db = socketDb
const DEFAULT_LOOP_ITERATIONS = 5
const DEFAULT_PARALLEL_COUNT = 5
+/** Minimal block shape needed for protection and descendant checks */
+interface DbBlockRef {
+ id: string
+ locked?: boolean | null
+ data: unknown
+}
+
+/**
+ * Checks if a block is protected (locked or inside a locked ancestor).
+ * Works with raw DB records.
+ */
+function isDbBlockProtected(blockId: string, blocksById: Record): boolean {
+ const block = blocksById[blockId]
+ if (!block) return false
+ if (block.locked) return true
+ const visited = new Set()
+ let parentId = (block.data as Record | null)?.parentId as string | undefined
+ while (parentId && !visited.has(parentId)) {
+ visited.add(parentId)
+ if (blocksById[parentId]?.locked) return true
+ parentId = (blocksById[parentId]?.data as Record | null)?.parentId as
+ | string
+ | undefined
+ }
+ return false
+}
+
+/**
+ * Finds all descendant block IDs of a container (recursive).
+ * Works with raw DB block arrays.
+ */
+function findDbDescendants(containerId: string, allBlocks: DbBlockRef[]): string[] {
+ const descendants: string[] = []
+ const visited = new Set()
+ const stack = [containerId]
+ while (stack.length > 0) {
+ const current = stack.pop()!
+ if (visited.has(current)) continue
+ visited.add(current)
+ for (const b of allBlocks) {
+ const pid = (b.data as Record | null)?.parentId
+ if (pid === current) {
+ descendants.push(b.id)
+ stack.push(b.id)
+ }
+ }
+ }
+ return descendants
+}
+
/**
* Shared function to handle auto-connect edge insertion
* @param tx - Database transaction
@@ -753,20 +803,8 @@ async function handleBlocksOperationTx(
allBlocks.map((b: BlockRecord) => [b.id, b])
)
- // Helper to check if a block is protected (locked or inside locked parent)
- const isProtected = (blockId: string): boolean => {
- const block = blocksById[blockId]
- if (!block) return false
- if (block.locked) return true
- const parentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (parentId && blocksById[parentId]?.locked) return true
- return false
- }
-
// Filter out protected blocks from deletion request
- const deletableIds = ids.filter((id) => !isProtected(id))
+ const deletableIds = ids.filter((id) => !isDbBlockProtected(id, blocksById))
if (deletableIds.length === 0) {
logger.info('All requested blocks are protected, skipping deletion')
return
@@ -778,18 +816,14 @@ async function handleBlocksOperationTx(
)
}
- // Collect all block IDs including children of subflows
+ // Collect all block IDs including all descendants of subflows
const allBlocksToDelete = new Set(deletableIds)
for (const id of deletableIds) {
const block = blocksById[id]
if (block && isSubflowBlockType(block.type)) {
- // Include all children of the subflow (they should be deleted with parent)
- for (const b of allBlocks) {
- const parentId = (b.data as Record | null)?.parentId
- if (parentId === id) {
- allBlocksToDelete.add(b.id)
- }
+ for (const descId of findDbDescendants(id, allBlocks)) {
+ allBlocksToDelete.add(descId)
}
}
}
@@ -902,19 +936,18 @@ async function handleBlocksOperationTx(
)
const blocksToToggle = new Set()
- // Collect all blocks to toggle including children of containers
+ // Collect all blocks to toggle including descendants of containers
for (const id of blockIds) {
const block = blocksById[id]
- if (!block || block.locked) continue
+ if (!block || isDbBlockProtected(id, blocksById)) continue
blocksToToggle.add(id)
- // If it's a loop or parallel, also include all children
+ // If it's a loop or parallel, also include all non-locked descendants
if (block.type === 'loop' || block.type === 'parallel') {
- for (const b of allBlocks) {
- const parentId = (b.data as Record | null)?.parentId
- if (parentId === id && !b.locked) {
- blocksToToggle.add(b.id)
+ for (const descId of findDbDescendants(id, allBlocks)) {
+ if (!isDbBlockProtected(descId, blocksById)) {
+ blocksToToggle.add(descId)
}
}
}
@@ -966,20 +999,10 @@ async function handleBlocksOperationTx(
allBlocks.map((b: HandleBlockRecord) => [b.id, b])
)
- // Helper to check if a block is protected (locked or inside locked parent)
- const isProtected = (blockId: string): boolean => {
- const block = blocksById[blockId]
- if (!block) return false
- if (block.locked) return true
- const parentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (parentId && blocksById[parentId]?.locked) return true
- return false
- }
-
// Filter to only toggle handles on unprotected blocks
- const blocksToToggle = blockIds.filter((id) => blocksById[id] && !isProtected(id))
+ const blocksToToggle = blockIds.filter(
+ (id) => blocksById[id] && !isDbBlockProtected(id, blocksById)
+ )
if (blocksToToggle.length === 0) {
logger.info('All requested blocks are protected, skipping handles toggle')
break
@@ -1025,20 +1048,17 @@ async function handleBlocksOperationTx(
)
const blocksToToggle = new Set()
- // Collect all blocks to toggle including children of containers
+ // Collect all blocks to toggle including descendants of containers
for (const id of blockIds) {
const block = blocksById[id]
if (!block) continue
blocksToToggle.add(id)
- // If it's a loop or parallel, also include all children
+ // If it's a loop or parallel, also include all descendants
if (block.type === 'loop' || block.type === 'parallel') {
- for (const b of allBlocks) {
- const parentId = (b.data as Record | null)?.parentId
- if (parentId === id) {
- blocksToToggle.add(b.id)
- }
+ for (const descId of findDbDescendants(id, allBlocks)) {
+ blocksToToggle.add(descId)
}
}
}
@@ -1088,31 +1108,19 @@ async function handleBlocksOperationTx(
allBlocks.map((b: ParentBlockRecord) => [b.id, b])
)
- // Helper to check if a block is protected (locked or inside locked parent)
- const isProtected = (blockId: string): boolean => {
- const block = blocksById[blockId]
- if (!block) return false
- if (block.locked) return true
- const currentParentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (currentParentId && blocksById[currentParentId]?.locked) return true
- return false
- }
-
for (const update of updates) {
const { id, parentId, position } = update
if (!id) continue
// Skip protected blocks (locked or inside locked container)
- if (isProtected(id)) {
+ if (isDbBlockProtected(id, blocksById)) {
logger.info(`Skipping block ${id} parent update - block is protected`)
continue
}
- // Skip if trying to move into a locked container
- if (parentId && blocksById[parentId]?.locked) {
- logger.info(`Skipping block ${id} parent update - target parent ${parentId} is locked`)
+ // Skip if trying to move into a locked container (or any of its ancestors)
+ if (parentId && isDbBlockProtected(parentId, blocksById)) {
+ logger.info(`Skipping block ${id} parent update - target parent ${parentId} is protected`)
continue
}
@@ -1235,18 +1243,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
}
}
- const isBlockProtected = (blockId: string): boolean => {
- const block = blocksById[blockId]
- if (!block) return false
- if (block.locked) return true
- const parentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (parentId && blocksById[parentId]?.locked) return true
- return false
- }
-
- if (isBlockProtected(payload.target)) {
+ if (isDbBlockProtected(payload.target, blocksById)) {
logger.info(`Skipping edge add - target block is protected`)
break
}
@@ -1334,18 +1331,7 @@ async function handleEdgeOperationTx(tx: any, workflowId: string, operation: str
}
}
- const isBlockProtected = (blockId: string): boolean => {
- const block = blocksById[blockId]
- if (!block) return false
- if (block.locked) return true
- const parentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (parentId && blocksById[parentId]?.locked) return true
- return false
- }
-
- if (isBlockProtected(edgeToRemove.targetBlockId)) {
+ if (isDbBlockProtected(edgeToRemove.targetBlockId, blocksById)) {
logger.info(`Skipping edge remove - target block is protected`)
break
}
@@ -1455,19 +1441,8 @@ async function handleEdgesOperationTx(
}
}
- const isBlockProtected = (blockId: string): boolean => {
- const block = blocksById[blockId]
- if (!block) return false
- if (block.locked) return true
- const parentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (parentId && blocksById[parentId]?.locked) return true
- return false
- }
-
const safeEdgeIds = edgesToRemove
- .filter((e: EdgeToRemove) => !isBlockProtected(e.targetBlockId))
+ .filter((e: EdgeToRemove) => !isDbBlockProtected(e.targetBlockId, blocksById))
.map((e: EdgeToRemove) => e.id)
if (safeEdgeIds.length === 0) {
@@ -1552,20 +1527,9 @@ async function handleEdgesOperationTx(
}
}
- const isBlockProtected = (blockId: string): boolean => {
- const block = blocksById[blockId]
- if (!block) return false
- if (block.locked) return true
- const parentId = (block.data as Record | null)?.parentId as
- | string
- | undefined
- if (parentId && blocksById[parentId]?.locked) return true
- return false
- }
-
// Filter edges - only add edges where target block is not protected
const safeEdges = (edges as Array>).filter(
- (e) => !isBlockProtected(e.target as string)
+ (e) => !isDbBlockProtected(e.target as string, blocksById)
)
if (safeEdges.length === 0) {
diff --git a/apps/sim/stores/workflows/workflow/store.ts b/apps/sim/stores/workflows/workflow/store.ts
index ed8db278fa2..ba9c9bd35fb 100644
--- a/apps/sim/stores/workflows/workflow/store.ts
+++ b/apps/sim/stores/workflows/workflow/store.ts
@@ -20,8 +20,10 @@ import type {
WorkflowStore,
} from '@/stores/workflows/workflow/types'
import {
+ findAllDescendantNodes,
generateLoopBlocks,
generateParallelBlocks,
+ isBlockProtected,
wouldCreateCycle,
} from '@/stores/workflows/workflow/utils'
@@ -374,21 +376,21 @@ export const useWorkflowStore = create()(
const blocksToToggle = new Set()
// For each ID, collect blocks to toggle (skip locked blocks entirely)
- // If it's a container, also include non-locked children
+ // If it's a container, also include non-locked descendants
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
- // Skip locked blocks entirely (including their children)
- if (block.locked) continue
+ // Skip protected blocks entirely (locked or inside a locked ancestor)
+ if (isBlockProtected(id, currentBlocks)) continue
blocksToToggle.add(id)
- // If it's a loop or parallel, also include non-locked children
+ // If it's a loop or parallel, also include non-locked descendants
if (block.type === 'loop' || block.type === 'parallel') {
- Object.entries(currentBlocks).forEach(([blockId, b]) => {
- if (b.data?.parentId === id && !b.locked) {
- blocksToToggle.add(blockId)
+ findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
+ if (!isBlockProtected(descId, currentBlocks)) {
+ blocksToToggle.add(descId)
}
})
}
@@ -415,18 +417,8 @@ export const useWorkflowStore = create()(
const currentBlocks = get().blocks
const newBlocks = { ...currentBlocks }
- // Helper to check if a block is protected (locked or inside locked parent)
- const isProtected = (blockId: string): boolean => {
- const block = currentBlocks[blockId]
- if (!block) return false
- if (block.locked) return true
- const parentId = block.data?.parentId
- if (parentId && currentBlocks[parentId]?.locked) return true
- return false
- }
-
for (const id of ids) {
- if (!newBlocks[id] || isProtected(id)) continue
+ if (!newBlocks[id] || isBlockProtected(id, currentBlocks)) continue
newBlocks[id] = {
...newBlocks[id],
horizontalHandles: !newBlocks[id].horizontalHandles,
@@ -1267,19 +1259,17 @@ export const useWorkflowStore = create()(
const blocksToToggle = new Set()
// For each ID, collect blocks to toggle
- // If it's a container, also include all children
+ // If it's a container, also include all descendants
for (const id of ids) {
const block = currentBlocks[id]
if (!block) continue
blocksToToggle.add(id)
- // If it's a loop or parallel, also include all children
+ // If it's a loop or parallel, also include all descendants
if (block.type === 'loop' || block.type === 'parallel') {
- Object.entries(currentBlocks).forEach(([blockId, b]) => {
- if (b.data?.parentId === id) {
- blocksToToggle.add(blockId)
- }
+ findAllDescendantNodes(id, currentBlocks).forEach((descId) => {
+ blocksToToggle.add(descId)
})
}
}
diff --git a/apps/sim/stores/workflows/workflow/utils.ts b/apps/sim/stores/workflows/workflow/utils.ts
index dc200792a2a..65d9f3fb20c 100644
--- a/apps/sim/stores/workflows/workflow/utils.ts
+++ b/apps/sim/stores/workflows/workflow/utils.ts
@@ -143,21 +143,56 @@ export function findAllDescendantNodes(
blocks: Record
): string[] {
const descendants: string[] = []
- const findDescendants = (parentId: string) => {
- const children = Object.values(blocks)
- .filter((block) => block.data?.parentId === parentId)
- .map((block) => block.id)
-
- children.forEach((childId) => {
- descendants.push(childId)
- findDescendants(childId)
- })
+ const visited = new Set()
+ const stack = [containerId]
+ while (stack.length > 0) {
+ const current = stack.pop()!
+ if (visited.has(current)) continue
+ visited.add(current)
+ for (const block of Object.values(blocks)) {
+ if (block.data?.parentId === current) {
+ descendants.push(block.id)
+ stack.push(block.id)
+ }
+ }
}
-
- findDescendants(containerId)
return descendants
}
+/**
+ * Checks if any ancestor container of a block is locked.
+ * Unlike {@link isBlockProtected}, this ignores the block's own locked state.
+ *
+ * @param blockId - The ID of the block to check
+ * @param blocks - Record of all blocks in the workflow
+ * @returns True if any ancestor is locked
+ */
+export function isAncestorProtected(blockId: string, blocks: Record): boolean {
+ const visited = new Set()
+ let parentId = blocks[blockId]?.data?.parentId
+ while (parentId && !visited.has(parentId)) {
+ visited.add(parentId)
+ if (blocks[parentId]?.locked) return true
+ parentId = blocks[parentId]?.data?.parentId
+ }
+ return false
+}
+
+/**
+ * Checks if a block is protected from editing/deletion.
+ * A block is protected if it is locked or if any ancestor container is locked.
+ *
+ * @param blockId - The ID of the block to check
+ * @param blocks - Record of all blocks in the workflow
+ * @returns True if the block is protected
+ */
+export function isBlockProtected(blockId: string, blocks: Record): boolean {
+ const block = blocks[blockId]
+ if (!block) return false
+ if (block.locked) return true
+ return isAncestorProtected(blockId, blocks)
+}
+
/**
* Builds a complete collection of loops from the UI blocks
*