mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
fix(subflows): recurse into all descendants for lock, enable, and protection checks (#3412)
* fix(subflows): recurse into all descendants for lock, enable, and protection checks * fix(subflows): prevent container resize on initial render and clean up code - Add canvasReadyRef to skip container dimension recalculation during ReactFlow init — position changes from extent clamping fired before block heights are measured, causing containers to resize on page load - Resolve globals.css merge conflict, remove global z-index overrides (handled via ReactFlow zIndex prop instead) - Clean up subflow-node: hoist static helpers to module scope, remove unused ref, fix nested ternary readability, rename outlineColor→ringColor Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(subflows): use full ancestor-chain protection for descendant enable-toggle The enable-toggle for descendants was checking only direct `locked` status instead of walking the full ancestor chain via `isBlockProtected`. This meant a block nested 2+ levels inside a locked subflow could still be toggled. Also added TSDoc clarifying why boxShadow works for subflow ring indicators. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * revert(subflows): remove canvasReadyRef height-gating approach The canvasReadyRef gating in onNodesChange didn't fully fix the container resize-on-load issue. Reverting to address properly later. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove unintentional edge-interaction CSS from globals Leftover from merge conflict resolution — not part of this PR's changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(editor): correct isAncestorLocked when block and ancestor both locked, restore fade-in transition isAncestorLocked was derived from isBlockProtected which short-circuits on block.locked, so a self-locked block inside a locked ancestor showed "Unlock block" instead of "Ancestor container is locked". Now walks the ancestor chain independently. Also restores the accidentally removed transition-opacity duration-150 class on the ReactFlow container. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(subflows): use full ancestor-chain protection for top-level enable-toggle, restore edge-label z-index The top-level block check in batchToggleEnabled used block.locked (self only) while descendants used isBlockProtected (full ancestor chain). A block inside a locked ancestor but not itself locked would bypass the check. Now all three layers (store, collaborative hook, DB operations) consistently use isBlockProtected/isDbBlockProtected at both levels. Also restores the accidentally removed edge-labels z-index rule, bumped from 60 to 1001 so labels render above child nodes (zIndex: 1000). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(subflows): extract isAncestorProtected utility, add cycle detection to all traversals - Extract isAncestorProtected from utils.ts so editor.tsx doesn't duplicate the ancestor-chain walk. isBlockProtected now delegates to it. - Add visited-set cycle detection to all ancestor walks (isBlockProtected, isAncestorProtected, isDbBlockProtected) and descendant searches (findAllDescendantNodes, findDbDescendants) to guard against corrupt parentId references. - Document why click-catching div has no event bubbling concern (ReactFlow renders children as viewport siblings, not DOM children). 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:
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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 || '')
|
||||
@@ -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'
|
||||
@@ -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'}
|
||||
|
||||
@@ -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<SubflowNodeData>) => {
|
||||
const { getNodes } = useReactFlow()
|
||||
const blockRef = useRef<HTMLDivElement>(null)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const currentWorkflow = useCurrentWorkflow()
|
||||
@@ -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
|
||||
@@ -84,7 +104,7 @@ 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'
|
||||
@@ -92,27 +112,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
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
|
||||
@@ -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}
|
||||
@@ -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]'>
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden'>
|
||||
<div className='relative h-full w-full flex-1'>
|
||||
{/* Loading spinner - always mounted, animation paused when hidden to avoid overhead */}
|
||||
<div
|
||||
className={`absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)] transition-opacity duration-150 ${isWorkflowReady ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
<div
|
||||
className={`h-[18px] w-[18px] rounded-full ${isWorkflowReady ? '' : 'animate-spin'}`}
|
||||
style={{
|
||||
background:
|
||||
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!isWorkflowReady && (
|
||||
<div className='absolute inset-0 z-[5] flex items-center justify-center bg-[var(--bg)]'>
|
||||
<div
|
||||
className='h-[18px] w-[18px] animate-spin rounded-full'
|
||||
style={{
|
||||
background:
|
||||
'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)',
|
||||
mask: 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
WebkitMask:
|
||||
'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
|
||||
@@ -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<string, boolean> = {}
|
||||
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<string, boolean> = {}
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, DbBlockRef>): boolean {
|
||||
const block = blocksById[blockId]
|
||||
if (!block) return false
|
||||
if (block.locked) return true
|
||||
const visited = new Set<string>()
|
||||
let parentId = (block.data as Record<string, unknown> | 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<string, unknown> | 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<string>()
|
||||
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<string, unknown> | 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<string, unknown> | 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<string>(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<string, unknown> | 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<string>()
|
||||
|
||||
// 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<string, unknown> | 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<string, unknown> | 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<string>()
|
||||
|
||||
// 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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<Record<string, unknown>>).filter(
|
||||
(e) => !isBlockProtected(e.target as string)
|
||||
(e) => !isDbBlockProtected(e.target as string, blocksById)
|
||||
)
|
||||
|
||||
if (safeEdges.length === 0) {
|
||||
|
||||
@@ -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<WorkflowStore>()(
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// 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<WorkflowStore>()(
|
||||
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<WorkflowStore>()(
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,21 +143,56 @@ export function findAllDescendantNodes(
|
||||
blocks: Record<string, BlockState>
|
||||
): 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<string>()
|
||||
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<string, BlockState>): boolean {
|
||||
const visited = new Set<string>()
|
||||
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<string, BlockState>): 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
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user