Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/component
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
import { getSubBlockStableKey } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
import {
isAncestorProtected,
isBlockProtected,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/block-protection-utils'
import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
import { getBlock } from '@/blocks/registry'
import type { SubBlockType } from '@/blocks/types'
Expand Down Expand Up @@ -107,12 +111,11 @@ export function Editor() {

const userPermissions = useUserPermissionsContext()

// Check if block is locked (or inside a locked container) and compute edit permission
// Check if block is locked (or inside a locked ancestor) and compute edit permission
// Locked blocks cannot be edited by anyone (admins can only lock/unlock)
const blocks = useWorkflowStore((state) => state.blocks)
const parentId = currentBlock?.data?.parentId as string | undefined
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
const isLocked = (currentBlock?.locked ?? false) || isParentLocked
const isLocked = currentBlockId ? isBlockProtected(currentBlockId, blocks) : false
const isAncestorLocked = currentBlockId ? isAncestorProtected(currentBlockId, blocks) : false
const canEditBlock = userPermissions.canEdit && !isLocked

const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
Expand Down Expand Up @@ -247,10 +250,7 @@ export function Editor() {
const block = blocks[blockId]
if (!block) return

const parentId = block.data?.parentId as string | undefined
const isParentLocked = parentId ? (blocks[parentId]?.locked ?? false) : false
const isLocked = (block.locked ?? false) || isParentLocked
if (!userPermissions.canEdit || isLocked) return
if (!userPermissions.canEdit || isBlockProtected(blockId, blocks)) return

renamingBlockIdRef.current = blockId
setEditedName(block.name || '')
Expand Down Expand Up @@ -364,11 +364,11 @@ export function Editor() {
)}
</div>
<div className='flex shrink-0 items-center gap-[8px]'>
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked, and parent is not locked */}
{/* Locked indicator - clickable to unlock if user has admin permissions, block is locked directly, and not locked by an ancestor */}
{isLocked && currentBlock && (
<Tooltip.Root>
<Tooltip.Trigger asChild>
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
{userPermissions.canAdmin && currentBlock.locked && !isAncestorLocked ? (
<Button
variant='ghost'
className='p-0'
Expand All @@ -385,8 +385,8 @@ export function Editor() {
</Tooltip.Trigger>
<Tooltip.Content side='top'>
<p>
{isParentLocked
? 'Parent container is locked'
{isAncestorLocked
? 'Ancestor container is locked'
: userPermissions.canAdmin && currentBlock.locked
? 'Unlock block'
: 'Block is locked'}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -38,7 +60,6 @@ export interface SubflowNodeData {
*/
export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<SubflowNodeData>) => {
const { getNodes } = useReactFlow()
const blockRef = useRef<HTMLDivElement>(null)
const userPermissions = useUserPermissionsContext()

const currentWorkflow = useCurrentWorkflow()
Expand All @@ -52,7 +73,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
const isLocked = currentBlock?.locked ?? false
const isPreview = data?.isPreview || false

// Focus state
const setCurrentBlockId = usePanelEditorStore((state) => state.setCurrentBlockId)
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
const isFocused = currentBlockId === id
Expand Down Expand Up @@ -84,35 +104,14 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
}

return level
}, [id, data?.parentId, getNodes])
}, [data?.parentId, getNodes])

const startHandleId = data.kind === 'loop' ? 'loop-start-source' : 'parallel-start-source'
const endHandleId = data.kind === 'loop' ? 'loop-end-source' : 'parallel-end-source'
const BlockIcon = data.kind === 'loop' ? RepeatIcon : SplitIcon
const blockIconBg = data.kind === 'loop' ? '#2FB3FF' : '#FEE12B'
const blockName = data.name || (data.kind === 'loop' ? 'Loop' : 'Parallel')

/**
* Reusable styles and positioning 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])
}

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
Expand All @@ -127,46 +126,37 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
diffStatus === 'new' ||
diffStatus === 'edited' ||
!!runPathStatus

/**
* Compute the outline color for the subflow ring.
* Uses CSS outline instead of box-shadow ring because in ReactFlow v11,
* child nodes are DOM children of parent nodes and paint over the parent's
* internal ring overlay. Outline renders on the element's own compositing
* layer, so it stays visible above nested child nodes.
* Compute the ring color for the subflow selection indicator.
* Uses boxShadow (not CSS outline) to match the ring styling of regular workflow blocks.
* This works because ReactFlow renders child nodes as sibling divs at the viewport level
* (not as DOM children), so children at zIndex 1000 don't clip the parent's boxShadow.
*/
const outlineColor = hasRing
? isFocused || isSelected || isPreviewSelected
? 'var(--brand-secondary)'
: diffStatus === 'new'
? 'var(--brand-tertiary-2)'
: diffStatus === 'edited'
? 'var(--warning)'
: runPathStatus === 'success'
? executionStatus
? 'var(--brand-tertiary-2)'
: 'var(--border-success)'
: runPathStatus === 'error'
? 'var(--text-error)'
: undefined
: undefined
const getRingColor = (): string | undefined => {
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 (
<div className='group pointer-events-none relative'>
<div
ref={blockRef}
className={cn(
'relative select-none rounded-[8px] border border-[var(--border-1)]',
'transition-block-bg'
)}
className='relative select-none rounded-[8px] border border-[var(--border-1)] transition-block-bg'
style={{
width: data.width || 500,
height: data.height || 300,
position: 'relative',
overflow: 'visible',
pointerEvents: 'none',
...(outlineColor && {
outline: `1.75px solid ${outlineColor}`,
outlineOffset: '-1px',
...(ringColor && {
boxShadow: `0 0 0 1.75px ${ringColor}`,
}),
}}
data-node-id={id}
Expand All @@ -181,9 +171,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
{/* Header Section — only interactive area for dragging */}
<div
onClick={() => 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' }}
>
<div className='flex min-w-0 flex-1 items-center gap-[10px]'>
Expand All @@ -209,6 +197,17 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
</div>
</div>

{/*
* 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.
*/}
<div
className='absolute inset-0 top-[44px] rounded-b-[8px]'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
onClick={() => setCurrentBlockId(id)}
/>

{!isPreview && (
<div
className='absolute right-[8px] bottom-[8px] z-20 flex h-[32px] w-[32px] cursor-se-resize items-center justify-center text-muted-foreground'
Expand All @@ -217,12 +216,9 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
)}

<div
className='h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
className='relative h-[calc(100%-50px)] pt-[16px] pr-[80px] pb-[16px] pl-[16px]'
data-dragarea='true'
style={{
position: 'relative',
pointerEvents: 'none',
}}
style={{ pointerEvents: 'none' }}
>
{/* Subflow Start */}
<div
Expand Down Expand Up @@ -255,7 +251,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
position={Position.Left}
className={getHandleClasses('left')}
style={{
...getHandleStyle(),
...HANDLE_STYLE,
pointerEvents: 'auto',
}}
/>
Expand All @@ -266,7 +262,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
position={Position.Right}
className={getHandleClasses('right')}
style={{
...getHandleStyle(),
...HANDLE_STYLE,
pointerEvents: 'auto',
}}
id={endHandleId}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { BlockState } from '@/stores/workflows/workflow/types'
import { isAncestorProtected, isBlockProtected } from '@/stores/workflows/workflow/utils'

export { isAncestorProtected, isBlockProtected }

/**
* Result of filtering protected blocks from a deletion operation
Expand All @@ -12,28 +15,6 @@ export interface FilterProtectedBlocksResult {
allProtected: boolean
}

/**
* Checks if a block is protected from editing/deletion.
* A block is protected if it is locked or if its parent 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<string, BlockState>): 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.
Expand Down
Loading