mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-01 10:14:56 -05:00
feat(canvas): added the ability to lock blocks
This commit is contained in:
@@ -11,7 +11,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
||||
/>
|
||||
|
||||
{/* MANUAL-CONTENT-START:intro */}
|
||||
The [Pulse](https://www.pulseapi.com/) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
|
||||
The [Pulse](https://www.runpulse.com) tool enables seamless extraction of text and structured content from a wide variety of documents—including PDFs, images, and Office files—using state-of-the-art OCR (Optical Character Recognition) powered by Pulse. Designed for automated agentic workflows, Pulse Parser makes it easy to unlock valuable information trapped in unstructured documents and integrate the extracted content directly into your workflow.
|
||||
|
||||
With Pulse, you can:
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
|
||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
|
||||
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||
@@ -49,6 +49,7 @@ export const ActionBar = memo(
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
collaborativeBatchToggleLocked,
|
||||
} = useCollaborativeWorkflow()
|
||||
const { setPendingSelection } = useWorkflowRegistry()
|
||||
const { handleRunFromBlock } = useWorkflowExecution()
|
||||
@@ -84,21 +85,25 @@ export const ActionBar = memo(
|
||||
)
|
||||
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
|
||||
|
||||
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const block = state.blocks[blockId]
|
||||
const parentId = block?.data?.parentId
|
||||
return {
|
||||
isEnabled: block?.enabled ?? true,
|
||||
horizontalHandles: block?.horizontalHandles ?? false,
|
||||
parentId,
|
||||
parentType: parentId ? state.blocks[parentId]?.type : undefined,
|
||||
}
|
||||
},
|
||||
[blockId]
|
||||
const { isEnabled, horizontalHandles, parentId, parentType, isLocked, isParentLocked } =
|
||||
useWorkflowStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const block = state.blocks[blockId]
|
||||
const parentId = block?.data?.parentId
|
||||
const parentBlock = parentId ? state.blocks[parentId] : undefined
|
||||
return {
|
||||
isEnabled: block?.enabled ?? true,
|
||||
horizontalHandles: block?.horizontalHandles ?? false,
|
||||
parentId,
|
||||
parentType: parentBlock?.type,
|
||||
isLocked: block?.locked ?? false,
|
||||
isParentLocked: parentBlock?.locked ?? false,
|
||||
}
|
||||
},
|
||||
[blockId]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { isExecuting, getLastExecutionSnapshot } = useExecutionStore()
|
||||
@@ -161,25 +166,27 @@ export const ActionBar = memo(
|
||||
{!isNoteBlock && !isInsideSubflow && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (canRunFromBlock && !disabled) {
|
||||
handleRunFromBlockClick()
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled || !canRunFromBlock}
|
||||
>
|
||||
<PlayOutline className={ICON_SIZE} />
|
||||
</Button>
|
||||
<span className='inline-flex'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (canRunFromBlock && !disabled) {
|
||||
handleRunFromBlockClick()
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled || !canRunFromBlock}
|
||||
>
|
||||
<PlayOutline className={ICON_SIZE} />
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{(() => {
|
||||
if (disabled) return getTooltipMessage('Run from block')
|
||||
if (isExecuting) return 'Execution in progress'
|
||||
if (!dependenciesSatisfied) return 'Run upstream blocks first'
|
||||
if (!dependenciesSatisfied) return 'Run previous blocks first'
|
||||
return 'Run from block'
|
||||
})()}
|
||||
</Tooltip.Content>
|
||||
@@ -193,22 +200,42 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
if (!disabled && !isLocked) {
|
||||
collaborativeBatchToggleBlockEnabled([blockId])
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLocked}
|
||||
>
|
||||
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
||||
{isLocked
|
||||
? 'Block is locked'
|
||||
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{userPermissions.canAdmin && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
collaborativeBatchToggleLocked([blockId])
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
>
|
||||
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{isLocked ? 'Unlock Block' : 'Lock Block'}</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{!isStartBlock && !isResponseBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -237,12 +264,12 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
if (!disabled && !isLocked) {
|
||||
collaborativeBatchToggleBlockHandles([blockId])
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLocked}
|
||||
>
|
||||
{horizontalHandles ? (
|
||||
<ArrowLeftRight className={ICON_SIZE} />
|
||||
@@ -252,7 +279,9 @@ export const ActionBar = memo(
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
{isLocked
|
||||
? 'Block is locked'
|
||||
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
@@ -264,19 +293,23 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled && userPermissions.canEdit) {
|
||||
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
|
||||
)
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled || !userPermissions.canEdit}
|
||||
disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked}
|
||||
>
|
||||
<LogOut className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>
|
||||
{isLocked || isParentLocked
|
||||
? 'Block is locked'
|
||||
: getTooltipMessage('Remove from Subflow')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
@@ -286,17 +319,19 @@ export const ActionBar = memo(
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!disabled) {
|
||||
if (!disabled && !isLocked) {
|
||||
collaborativeBatchRemoveBlocks([blockId])
|
||||
}
|
||||
}}
|
||||
className={ACTION_BUTTON_STYLES}
|
||||
disabled={disabled}
|
||||
disabled={disabled || isLocked}
|
||||
>
|
||||
<Trash2 className={ICON_SIZE} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
|
||||
<Tooltip.Content side='top'>
|
||||
{isLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface BlockInfo {
|
||||
horizontalHandles: boolean
|
||||
parentId?: string
|
||||
parentType?: string
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,10 +47,17 @@ export interface BlockMenuProps {
|
||||
showRemoveFromSubflow?: boolean
|
||||
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
|
||||
canRunFromBlock?: boolean
|
||||
/** Whether to disable edit actions (user can't edit OR blocks are locked) */
|
||||
disableEdit?: boolean
|
||||
/** Whether the user has edit permission (ignoring locked state) */
|
||||
userCanEdit?: boolean
|
||||
isExecuting?: boolean
|
||||
/** Whether the selected block is a trigger (has no incoming edges) */
|
||||
isPositionalTrigger?: boolean
|
||||
/** Callback to toggle locked state of selected blocks */
|
||||
onToggleLocked?: () => void
|
||||
/** Whether the user has admin permissions */
|
||||
canAdmin?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,13 +86,18 @@ export function BlockMenu({
|
||||
showRemoveFromSubflow = false,
|
||||
canRunFromBlock = false,
|
||||
disableEdit = false,
|
||||
userCanEdit = true,
|
||||
isExecuting = false,
|
||||
isPositionalTrigger = false,
|
||||
onToggleLocked,
|
||||
canAdmin = false,
|
||||
}: BlockMenuProps) {
|
||||
const isSingleBlock = selectedBlocks.length === 1
|
||||
|
||||
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
||||
const allDisabled = selectedBlocks.every((b) => !b.enabled)
|
||||
const allLocked = selectedBlocks.every((b) => b.locked)
|
||||
const allUnlocked = selectedBlocks.every((b) => !b.locked)
|
||||
|
||||
const hasSingletonBlock = selectedBlocks.some(
|
||||
(b) =>
|
||||
@@ -108,6 +121,12 @@ export function BlockMenu({
|
||||
return 'Toggle Enabled'
|
||||
}
|
||||
|
||||
const getToggleLockedLabel = () => {
|
||||
if (allLocked) return 'Unlock'
|
||||
if (allUnlocked) return 'Lock'
|
||||
return 'Toggle Lock'
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={isOpen}
|
||||
@@ -150,7 +169,7 @@ export function BlockMenu({
|
||||
</PopoverItem>
|
||||
{!hasSingletonBlock && (
|
||||
<PopoverItem
|
||||
disabled={disableEdit}
|
||||
disabled={!userCanEdit}
|
||||
onClick={() => {
|
||||
onDuplicate()
|
||||
onClose()
|
||||
@@ -195,6 +214,16 @@ export function BlockMenu({
|
||||
Remove from Subflow
|
||||
</PopoverItem>
|
||||
)}
|
||||
{canAdmin && onToggleLocked && (
|
||||
<PopoverItem
|
||||
onClick={() => {
|
||||
onToggleLocked()
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
{getToggleLockedLabel()}
|
||||
</PopoverItem>
|
||||
)}
|
||||
|
||||
{/* Single block actions */}
|
||||
{isSingleBlock && <PopoverDivider />}
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface CanvasMenuProps {
|
||||
canUndo?: boolean
|
||||
canRedo?: boolean
|
||||
isInvitationsDisabled?: boolean
|
||||
/** Whether the workflow has locked blocks (disables auto-layout) */
|
||||
hasLockedBlocks?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,6 +62,7 @@ export function CanvasMenu({
|
||||
disableEdit = false,
|
||||
canUndo = false,
|
||||
canRedo = false,
|
||||
hasLockedBlocks = false,
|
||||
}: CanvasMenuProps) {
|
||||
return (
|
||||
<Popover
|
||||
@@ -129,11 +132,12 @@ export function CanvasMenu({
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
className='group'
|
||||
disabled={disableEdit}
|
||||
disabled={disableEdit || hasLockedBlocks}
|
||||
onClick={() => {
|
||||
onAutoLayout()
|
||||
onClose()
|
||||
}}
|
||||
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
|
||||
>
|
||||
<span>Auto-layout</span>
|
||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronUp,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
} from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
@@ -46,6 +47,7 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { usePanelEditorStore } from '@/stores/panel'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
/** Stable empty object to avoid creating new references */
|
||||
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
|
||||
@@ -110,6 +112,14 @@ export function Editor() {
|
||||
// Get user permissions
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
// Check if block is locked (or inside a locked container) 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 canEditBlock = userPermissions.canEdit && !isLocked
|
||||
|
||||
// Get active workflow ID
|
||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||
|
||||
@@ -150,9 +160,7 @@ export function Editor() {
|
||||
blockSubBlockValues,
|
||||
canonicalIndex
|
||||
)
|
||||
const displayAdvancedOptions = userPermissions.canEdit
|
||||
? advancedMode
|
||||
: advancedMode || advancedValuesPresent
|
||||
const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent
|
||||
|
||||
const hasAdvancedOnlyFields = useMemo(() => {
|
||||
for (const subBlock of subBlocksForCanonical) {
|
||||
@@ -223,9 +231,9 @@ export function Editor() {
|
||||
|
||||
// Advanced mode toggle handler
|
||||
const handleToggleAdvancedMode = useCallback(() => {
|
||||
if (!currentBlockId || !userPermissions.canEdit) return
|
||||
if (!currentBlockId || !canEditBlock) return
|
||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||
}, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode])
|
||||
|
||||
// Rename state
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
@@ -236,10 +244,10 @@ export function Editor() {
|
||||
* Handles starting the rename process.
|
||||
*/
|
||||
const handleStartRename = useCallback(() => {
|
||||
if (!userPermissions.canEdit || !currentBlock) return
|
||||
if (!canEditBlock || !currentBlock) return
|
||||
setEditedName(currentBlock.name || '')
|
||||
setIsRenaming(true)
|
||||
}, [userPermissions.canEdit, currentBlock])
|
||||
}, [canEditBlock, currentBlock])
|
||||
|
||||
/**
|
||||
* Handles saving the renamed block.
|
||||
@@ -358,6 +366,19 @@ export function Editor() {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
||||
{/* Locked indicator */}
|
||||
{isLocked && currentBlock && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='flex items-center justify-center'>
|
||||
<Lock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Block is locked</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* Rename button */}
|
||||
{currentBlock && (
|
||||
<Tooltip.Root>
|
||||
@@ -366,7 +387,7 @@ export function Editor() {
|
||||
variant='ghost'
|
||||
className='p-0'
|
||||
onClick={isRenaming ? handleSaveRename : handleStartRename}
|
||||
disabled={!userPermissions.canEdit}
|
||||
disabled={!canEditBlock}
|
||||
aria-label={isRenaming ? 'Save name' : 'Rename block'}
|
||||
>
|
||||
{isRenaming ? (
|
||||
@@ -434,7 +455,7 @@ export function Editor() {
|
||||
incomingConnections={incomingConnections}
|
||||
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
|
||||
toggleConnectionsCollapsed={toggleConnectionsCollapsed}
|
||||
userCanEdit={userPermissions.canEdit}
|
||||
userCanEdit={canEditBlock}
|
||||
isConnectionsAtMinHeight={isConnectionsAtMinHeight}
|
||||
/>
|
||||
) : (
|
||||
@@ -542,14 +563,14 @@ export function Editor() {
|
||||
config={subBlock}
|
||||
isPreview={false}
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!userPermissions.canEdit}
|
||||
disabled={!canEditBlock}
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
canonicalToggle={
|
||||
isCanonicalSwap && canonicalMode && canonicalId
|
||||
? {
|
||||
mode: canonicalMode,
|
||||
disabled: !userPermissions.canEdit,
|
||||
disabled: !canEditBlock,
|
||||
onToggle: () => {
|
||||
if (!currentBlockId) return
|
||||
const nextMode =
|
||||
@@ -579,7 +600,7 @@ export function Editor() {
|
||||
)
|
||||
})}
|
||||
|
||||
{hasAdvancedOnlyFields && userPermissions.canEdit && (
|
||||
{hasAdvancedOnlyFields && canEditBlock && (
|
||||
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
||||
<div
|
||||
className='h-[1.25px] flex-1'
|
||||
@@ -624,7 +645,7 @@ export function Editor() {
|
||||
config={subBlock}
|
||||
isPreview={false}
|
||||
subBlockValues={subBlockState}
|
||||
disabled={!userPermissions.canEdit}
|
||||
disabled={!canEditBlock}
|
||||
fieldDiffStatus={undefined}
|
||||
allowExpandInPreview={false}
|
||||
/>
|
||||
|
||||
@@ -45,11 +45,13 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
|
||||
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||
import { useChatStore } from '@/stores/chat/store'
|
||||
import { useNotificationStore } from '@/stores/notifications/store'
|
||||
import type { PanelTab } from '@/stores/panel'
|
||||
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
|
||||
import { useVariablesStore } from '@/stores/variables/store'
|
||||
import { getWorkflowWithValues } from '@/stores/workflows'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
const logger = createLogger('Panel')
|
||||
/**
|
||||
@@ -119,6 +121,11 @@ export const Panel = memo(function Panel() {
|
||||
hydration.phase === 'state-loading'
|
||||
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
|
||||
|
||||
// Check for locked blocks (disables auto-layout)
|
||||
const hasLockedBlocks = useWorkflowStore((state) =>
|
||||
Object.values(state.blocks).some((block) => block.locked)
|
||||
)
|
||||
|
||||
// Delete workflow hook
|
||||
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
|
||||
workspaceId,
|
||||
@@ -230,11 +237,24 @@ export const Panel = memo(function Panel() {
|
||||
|
||||
setIsAutoLayouting(true)
|
||||
try {
|
||||
await autoLayoutWithFitView()
|
||||
const result = await autoLayoutWithFitView()
|
||||
if (!result.success && result.error) {
|
||||
useNotificationStore.getState().addNotification({
|
||||
level: 'info',
|
||||
message: result.error,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsAutoLayouting(false)
|
||||
}
|
||||
}, [isExecuting, userPermissions.canEdit, isAutoLayouting, autoLayoutWithFitView])
|
||||
}, [
|
||||
isExecuting,
|
||||
userPermissions.canEdit,
|
||||
isAutoLayouting,
|
||||
autoLayoutWithFitView,
|
||||
activeWorkflowId,
|
||||
])
|
||||
|
||||
/**
|
||||
* Handles exporting workflow as JSON
|
||||
@@ -404,7 +424,10 @@ export const Panel = memo(function Panel() {
|
||||
<PopoverContent align='start' side='bottom' sideOffset={8}>
|
||||
<PopoverItem
|
||||
onClick={handleAutoLayout}
|
||||
disabled={isExecuting || !userPermissions.canEdit || isAutoLayouting}
|
||||
disabled={
|
||||
isExecuting || !userPermissions.canEdit || isAutoLayouting || hasLockedBlocks
|
||||
}
|
||||
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
|
||||
>
|
||||
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
|
||||
<span>Auto layout</span>
|
||||
|
||||
@@ -80,6 +80,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
: undefined
|
||||
|
||||
const isEnabled = currentBlock?.enabled ?? true
|
||||
const isLocked = currentBlock?.locked ?? false
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Focus state
|
||||
@@ -200,7 +201,10 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
||||
{blockName}
|
||||
</span>
|
||||
</div>
|
||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
<div className='flex items-center gap-1'>
|
||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isPreview && (
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface UseBlockStateReturn {
|
||||
diffStatus: DiffStatus
|
||||
/** Whether this is a deleted block in diff mode */
|
||||
isDeletedBlock: boolean
|
||||
/** Whether the block is locked */
|
||||
isLocked: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,11 @@ export function useBlockState(
|
||||
? (data.blockState?.enabled ?? true)
|
||||
: (currentBlock?.enabled ?? true)
|
||||
|
||||
// Determine if block is locked
|
||||
const isLocked = data.isPreview
|
||||
? (data.blockState?.locked ?? false)
|
||||
: (currentBlock?.locked ?? false)
|
||||
|
||||
// Get diff status
|
||||
const diffStatus: DiffStatus =
|
||||
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
|
||||
@@ -68,5 +75,6 @@ export function useBlockState(
|
||||
isActive,
|
||||
diffStatus,
|
||||
isDeletedBlock: isDeletedBlock ?? false,
|
||||
isLocked,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,6 +672,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
currentWorkflow,
|
||||
activeWorkflowId,
|
||||
isEnabled,
|
||||
isLocked,
|
||||
handleClick,
|
||||
hasRing,
|
||||
ringStyles,
|
||||
@@ -1100,7 +1101,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='relative z-10 flex flex-shrink-0 items-center gap-2'>
|
||||
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
|
||||
{isWorkflowSelector &&
|
||||
childWorkflowId &&
|
||||
typeof childIsDeployed === 'boolean' &&
|
||||
@@ -1133,6 +1134,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
||||
|
||||
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
|
||||
<Tooltip.Root>
|
||||
|
||||
@@ -47,6 +47,7 @@ export function useBlockVisual({
|
||||
isActive: isExecuting,
|
||||
diffStatus,
|
||||
isDeletedBlock,
|
||||
isLocked,
|
||||
} = useBlockState(blockId, currentWorkflow, data)
|
||||
|
||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||
@@ -103,6 +104,7 @@ export function useBlockVisual({
|
||||
currentWorkflow,
|
||||
activeWorkflowId,
|
||||
isEnabled,
|
||||
isLocked,
|
||||
handleClick,
|
||||
hasRing,
|
||||
ringStyles,
|
||||
|
||||
@@ -39,6 +39,7 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
|
||||
horizontalHandles: block?.horizontalHandles ?? false,
|
||||
parentId,
|
||||
parentType,
|
||||
locked: block?.locked ?? false,
|
||||
}
|
||||
}),
|
||||
[blocks]
|
||||
|
||||
@@ -52,6 +52,16 @@ export async function applyAutoLayoutAndUpdateStore(
|
||||
return { success: false, error: 'No blocks to layout' }
|
||||
}
|
||||
|
||||
// Check for locked blocks - auto-layout is disabled when blocks are locked
|
||||
const hasLockedBlocks = Object.values(blocks).some((block) => block.locked)
|
||||
if (hasLockedBlocks) {
|
||||
logger.info('Auto layout skipped: workflow contains locked blocks', { workflowId })
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-layout is disabled when blocks are locked. Unlock blocks to use auto-layout.',
|
||||
}
|
||||
}
|
||||
|
||||
// Merge with default options
|
||||
const layoutOptions = {
|
||||
spacing: {
|
||||
|
||||
@@ -543,6 +543,7 @@ const WorkflowContent = React.memo(() => {
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeBatchToggleBlockEnabled,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
collaborativeBatchToggleLocked,
|
||||
undo,
|
||||
redo,
|
||||
} = useCollaborativeWorkflow()
|
||||
@@ -1068,9 +1069,31 @@ const WorkflowContent = React.memo(() => {
|
||||
}, [contextMenuBlocks, copyBlocks, executePasteOperation])
|
||||
|
||||
const handleContextDelete = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
collaborativeBatchRemoveBlocks(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
|
||||
let blockIds = contextMenuBlocks.map((b) => b.id)
|
||||
// Filter out locked blocks and blocks inside locked containers
|
||||
const protectedBlockIds = contextMenuBlocks
|
||||
.filter((b) => b.locked || (b.parentId && blocks[b.parentId]?.locked))
|
||||
.map((b) => b.id)
|
||||
if (protectedBlockIds.length > 0) {
|
||||
blockIds = blockIds.filter((id) => !protectedBlockIds.includes(id))
|
||||
if (protectedBlockIds.length === contextMenuBlocks.length) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot delete locked blocks or blocks inside locked containers',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: `Skipped ${protectedBlockIds.length} protected block(s)`,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
}
|
||||
if (blockIds.length > 0) {
|
||||
collaborativeBatchRemoveBlocks(blockIds)
|
||||
}
|
||||
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks, addNotification, activeWorkflowId, blocks])
|
||||
|
||||
const handleContextToggleEnabled = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
||||
@@ -1082,6 +1105,11 @@ const WorkflowContent = React.memo(() => {
|
||||
collaborativeBatchToggleBlockHandles(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
||||
|
||||
const handleContextToggleLocked = useCallback(() => {
|
||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
||||
collaborativeBatchToggleLocked(blockIds)
|
||||
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
|
||||
|
||||
const handleContextRemoveFromSubflow = useCallback(() => {
|
||||
const blocksToRemove = contextMenuBlocks.filter(
|
||||
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
||||
@@ -1951,7 +1979,6 @@ const WorkflowContent = React.memo(() => {
|
||||
const loadingWorkflowRef = useRef<string | null>(null)
|
||||
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
|
||||
|
||||
/** Initializes workflow when it exists in registry and needs hydration. */
|
||||
useEffect(() => {
|
||||
const currentId = workflowIdParam
|
||||
const currentWorkspaceHydration = hydration.workspaceId
|
||||
@@ -2128,6 +2155,7 @@ const WorkflowContent = React.memo(() => {
|
||||
parentId: block.data?.parentId,
|
||||
extent: block.data?.extent || undefined,
|
||||
dragHandle: '.workflow-drag-handle',
|
||||
draggable: !block.locked,
|
||||
data: {
|
||||
...block.data,
|
||||
name: block.name,
|
||||
@@ -2163,6 +2191,7 @@ const WorkflowContent = React.memo(() => {
|
||||
position,
|
||||
parentId: block.data?.parentId,
|
||||
dragHandle,
|
||||
draggable: !block.locked,
|
||||
extent: (() => {
|
||||
// Clamp children to subflow body (exclude header)
|
||||
const parentId = block.data?.parentId as string | undefined
|
||||
@@ -2491,12 +2520,29 @@ const WorkflowContent = React.memo(() => {
|
||||
const edgeIdsToRemove = changes
|
||||
.filter((change: any) => change.type === 'remove')
|
||||
.map((change: any) => change.id)
|
||||
.filter((edgeId: string) => {
|
||||
// Prevent removing edges connected to locked blocks or blocks inside locked containers
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return true
|
||||
const sourceBlock = blocks[edge.source]
|
||||
const targetBlock = blocks[edge.target]
|
||||
const sourceParentLocked =
|
||||
sourceBlock?.data?.parentId && blocks[sourceBlock.data.parentId]?.locked
|
||||
const targetParentLocked =
|
||||
targetBlock?.data?.parentId && blocks[targetBlock.data.parentId]?.locked
|
||||
return (
|
||||
!sourceBlock?.locked &&
|
||||
!targetBlock?.locked &&
|
||||
!sourceParentLocked &&
|
||||
!targetParentLocked
|
||||
)
|
||||
})
|
||||
|
||||
if (edgeIdsToRemove.length > 0) {
|
||||
collaborativeBatchRemoveEdges(edgeIdsToRemove)
|
||||
}
|
||||
},
|
||||
[collaborativeBatchRemoveEdges]
|
||||
[collaborativeBatchRemoveEdges, edges, blocks]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -2558,6 +2604,27 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
if (!sourceNode || !targetNode) return
|
||||
|
||||
// Prevent connections to/from locked blocks or blocks inside locked containers
|
||||
const sourceBlock = blocks[connection.source]
|
||||
const targetBlock = blocks[connection.target]
|
||||
const sourceParentLocked =
|
||||
sourceBlock?.data?.parentId && blocks[sourceBlock.data.parentId]?.locked
|
||||
const targetParentLocked =
|
||||
targetBlock?.data?.parentId && blocks[targetBlock.data.parentId]?.locked
|
||||
if (
|
||||
sourceBlock?.locked ||
|
||||
targetBlock?.locked ||
|
||||
sourceParentLocked ||
|
||||
targetParentLocked
|
||||
) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot connect to locked blocks or blocks inside locked containers',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get parent information (handle container start node case)
|
||||
const sourceParentId =
|
||||
blocks[sourceNode.id]?.data?.parentId ||
|
||||
@@ -2620,7 +2687,7 @@ const WorkflowContent = React.memo(() => {
|
||||
connectionCompletedRef.current = true
|
||||
}
|
||||
},
|
||||
[addEdge, getNodes, blocks]
|
||||
[addEdge, getNodes, blocks, addNotification, activeWorkflowId]
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -2715,6 +2782,9 @@ const WorkflowContent = React.memo(() => {
|
||||
// Only consider container nodes that aren't the dragged node
|
||||
if (n.type !== 'subflowNode' || n.id === node.id) return false
|
||||
|
||||
// Don't allow dropping into locked containers
|
||||
if (blocks[n.id]?.locked) return false
|
||||
|
||||
// Get the container's absolute position
|
||||
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||
|
||||
@@ -2807,6 +2877,12 @@ const WorkflowContent = React.memo(() => {
|
||||
/** Captures initial parent ID and position when drag starts. */
|
||||
const onNodeDragStart = useCallback(
|
||||
(_event: React.MouseEvent, node: any) => {
|
||||
// Prevent dragging locked blocks
|
||||
const block = blocks[node.id]
|
||||
if (block?.locked) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store the original parent ID when starting to drag
|
||||
const currentParentId = blocks[node.id]?.data?.parentId || null
|
||||
setDragStartParentId(currentParentId)
|
||||
@@ -2835,7 +2911,14 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
})
|
||||
},
|
||||
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
|
||||
[
|
||||
blocks,
|
||||
setDragStartPosition,
|
||||
getNodes,
|
||||
potentialParentId,
|
||||
setPotentialParentId,
|
||||
effectivePermissions.canAdmin,
|
||||
]
|
||||
)
|
||||
|
||||
/** Handles node drag stop to establish parent-child relationships. */
|
||||
@@ -2897,6 +2980,17 @@ const WorkflowContent = React.memo(() => {
|
||||
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
|
||||
if (potentialParentId === dragStartParentId) return
|
||||
|
||||
// Prevent moving blocks out of locked containers
|
||||
if (dragStartParentId && blocks[dragStartParentId]?.locked) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot move blocks out of locked containers',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
setPotentialParentId(dragStartParentId) // Reset to original parent
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a starter block - starter blocks should never be in containers
|
||||
const isStarterBlock = node.data?.type === 'starter'
|
||||
if (isStarterBlock) {
|
||||
@@ -3293,6 +3387,29 @@ const WorkflowContent = React.memo(() => {
|
||||
/** Stable delete handler to avoid creating new function references per edge. */
|
||||
const handleEdgeDelete = useCallback(
|
||||
(edgeId: string) => {
|
||||
// Prevent removing edges connected to locked blocks or blocks inside locked containers
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (edge) {
|
||||
const sourceBlock = blocks[edge.source]
|
||||
const targetBlock = blocks[edge.target]
|
||||
const sourceParentLocked =
|
||||
sourceBlock?.data?.parentId && blocks[sourceBlock.data.parentId]?.locked
|
||||
const targetParentLocked =
|
||||
targetBlock?.data?.parentId && blocks[targetBlock.data.parentId]?.locked
|
||||
if (
|
||||
sourceBlock?.locked ||
|
||||
targetBlock?.locked ||
|
||||
sourceParentLocked ||
|
||||
targetParentLocked
|
||||
) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot remove connections from locked blocks',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
removeEdge(edgeId)
|
||||
// Remove this edge from selection (find by edge ID value)
|
||||
setSelectedEdges((prev) => {
|
||||
@@ -3305,7 +3422,7 @@ const WorkflowContent = React.memo(() => {
|
||||
return next
|
||||
})
|
||||
},
|
||||
[removeEdge]
|
||||
[removeEdge, edges, blocks, addNotification, activeWorkflowId]
|
||||
)
|
||||
|
||||
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
|
||||
@@ -3346,9 +3463,26 @@ const WorkflowContent = React.memo(() => {
|
||||
|
||||
// Handle edge deletion first (edges take priority if selected)
|
||||
if (selectedEdges.size > 0) {
|
||||
// Get all selected edge IDs and batch delete them
|
||||
const edgeIds = Array.from(selectedEdges.values())
|
||||
collaborativeBatchRemoveEdges(edgeIds)
|
||||
// Get all selected edge IDs and filter out edges connected to locked blocks or blocks inside locked containers
|
||||
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return true
|
||||
const sourceBlock = blocks[edge.source]
|
||||
const targetBlock = blocks[edge.target]
|
||||
const sourceParentLocked =
|
||||
sourceBlock?.data?.parentId && blocks[sourceBlock.data.parentId]?.locked
|
||||
const targetParentLocked =
|
||||
targetBlock?.data?.parentId && blocks[targetBlock.data.parentId]?.locked
|
||||
return (
|
||||
!sourceBlock?.locked &&
|
||||
!targetBlock?.locked &&
|
||||
!sourceParentLocked &&
|
||||
!targetParentLocked
|
||||
)
|
||||
})
|
||||
if (edgeIds.length > 0) {
|
||||
collaborativeBatchRemoveEdges(edgeIds)
|
||||
}
|
||||
setSelectedEdges(new Map())
|
||||
return
|
||||
}
|
||||
@@ -3364,8 +3498,32 @@ const WorkflowContent = React.memo(() => {
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const selectedIds = selectedNodes.map((node) => node.id)
|
||||
collaborativeBatchRemoveBlocks(selectedIds)
|
||||
let selectedIds = selectedNodes.map((node) => node.id)
|
||||
// Filter out locked blocks and blocks inside locked containers
|
||||
const protectedIds = selectedIds.filter(
|
||||
(id) =>
|
||||
blocks[id]?.locked ||
|
||||
(blocks[id]?.data?.parentId && blocks[blocks[id]?.data?.parentId]?.locked)
|
||||
)
|
||||
if (protectedIds.length > 0) {
|
||||
selectedIds = selectedIds.filter((id) => !protectedIds.includes(id))
|
||||
if (protectedIds.length === selectedNodes.length) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot delete locked blocks or blocks inside locked containers',
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: `Skipped ${protectedIds.length} protected block(s)`,
|
||||
workflowId: activeWorkflowId || undefined,
|
||||
})
|
||||
}
|
||||
if (selectedIds.length > 0) {
|
||||
collaborativeBatchRemoveBlocks(selectedIds)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
@@ -3376,6 +3534,10 @@ const WorkflowContent = React.memo(() => {
|
||||
getNodes,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
effectivePermissions.canEdit,
|
||||
blocks,
|
||||
edges,
|
||||
addNotification,
|
||||
activeWorkflowId,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -3496,12 +3658,19 @@ const WorkflowContent = React.memo(() => {
|
||||
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
||||
)}
|
||||
canRunFromBlock={runFromBlockState.canRun}
|
||||
disableEdit={!effectivePermissions.canEdit}
|
||||
disableEdit={
|
||||
!effectivePermissions.canEdit ||
|
||||
contextMenuBlocks.some((b) => b.locked) ||
|
||||
contextMenuBlocks.some((b) => b.parentId && blocks[b.parentId]?.locked)
|
||||
}
|
||||
userCanEdit={effectivePermissions.canEdit}
|
||||
isExecuting={isExecuting}
|
||||
isPositionalTrigger={
|
||||
contextMenuBlocks.length === 1 &&
|
||||
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
|
||||
}
|
||||
onToggleLocked={handleContextToggleLocked}
|
||||
canAdmin={effectivePermissions.canAdmin}
|
||||
/>
|
||||
|
||||
<CanvasMenu
|
||||
@@ -3524,6 +3693,7 @@ const WorkflowContent = React.memo(() => {
|
||||
disableEdit={!effectivePermissions.canEdit}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -404,6 +404,20 @@ export function useCollaborativeWorkflow() {
|
||||
logger.info('Successfully applied batch-toggle-handles from remote user')
|
||||
break
|
||||
}
|
||||
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
|
||||
const { blockIds } = payload
|
||||
logger.info('Received batch-toggle-locked from remote user', {
|
||||
userId,
|
||||
count: (blockIds || []).length,
|
||||
})
|
||||
|
||||
if (blockIds && blockIds.length > 0) {
|
||||
useWorkflowStore.getState().batchToggleLocked(blockIds)
|
||||
}
|
||||
|
||||
logger.info('Successfully applied batch-toggle-locked from remote user')
|
||||
break
|
||||
}
|
||||
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
|
||||
const { updates } = payload
|
||||
logger.info('Received batch-update-parent from remote user', {
|
||||
@@ -812,14 +826,27 @@ export function useCollaborativeWorkflow() {
|
||||
|
||||
if (ids.length === 0) return
|
||||
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
// For each ID, collect non-locked blocks and their children for undo/redo
|
||||
for (const id of ids) {
|
||||
const block = useWorkflowStore.getState().blocks[id]
|
||||
if (block) {
|
||||
previousStates[id] = block.enabled
|
||||
validIds.push(id)
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
// Skip locked blocks
|
||||
if (block.locked) 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 (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id && !b.locked) {
|
||||
previousStates[blockId] = b.enabled
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1014,6 +1041,58 @@ export function useCollaborativeWorkflow() {
|
||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeBatchToggleLocked = useCallback(
|
||||
(ids: string[]) => {
|
||||
if (isBaselineDiffView) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ids.length === 0) return
|
||||
|
||||
const currentBlocks = useWorkflowStore.getState().blocks
|
||||
const previousStates: Record<string, boolean> = {}
|
||||
const validIds: string[] = []
|
||||
|
||||
// For each ID, collect blocks and their children for undo/redo
|
||||
for (const id of ids) {
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
validIds.push(id)
|
||||
previousStates[id] = block.locked ?? false
|
||||
|
||||
// If it's a loop or parallel, also capture children's previous states for undo/redo
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id) {
|
||||
previousStates[blockId] = b.locked ?? false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (validIds.length === 0) return
|
||||
|
||||
const operationId = crypto.randomUUID()
|
||||
|
||||
addToQueue({
|
||||
id: operationId,
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds: validIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId || '',
|
||||
userId: session?.user?.id || 'unknown',
|
||||
})
|
||||
|
||||
useWorkflowStore.getState().batchToggleLocked(validIds)
|
||||
|
||||
undoRedo.recordBatchToggleLocked(validIds, previousStates)
|
||||
},
|
||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
|
||||
)
|
||||
|
||||
const collaborativeBatchAddEdges = useCallback(
|
||||
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
|
||||
if (isBaselineDiffView) {
|
||||
@@ -1680,6 +1759,7 @@ export function useCollaborativeWorkflow() {
|
||||
collaborativeToggleBlockAdvancedMode,
|
||||
collaborativeSetBlockCanonicalMode,
|
||||
collaborativeBatchToggleBlockHandles,
|
||||
collaborativeBatchToggleLocked,
|
||||
collaborativeBatchAddBlocks,
|
||||
collaborativeBatchRemoveBlocks,
|
||||
collaborativeBatchAddEdges,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type BatchRemoveEdgesOperation,
|
||||
type BatchToggleEnabledOperation,
|
||||
type BatchToggleHandlesOperation,
|
||||
type BatchToggleLockedOperation,
|
||||
type BatchUpdateParentOperation,
|
||||
captureLatestEdges,
|
||||
captureLatestSubBlockValues,
|
||||
@@ -416,6 +417,36 @@ export function useUndoRedo() {
|
||||
[activeWorkflowId, userId]
|
||||
)
|
||||
|
||||
const recordBatchToggleLocked = useCallback(
|
||||
(blockIds: string[], previousStates: Record<string, boolean>) => {
|
||||
if (!activeWorkflowId || blockIds.length === 0) return
|
||||
|
||||
const operation: BatchToggleLockedOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockIds, previousStates },
|
||||
}
|
||||
|
||||
const inverse: BatchToggleLockedOperation = {
|
||||
id: crypto.randomUUID(),
|
||||
type: UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
timestamp: Date.now(),
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
data: { blockIds, previousStates },
|
||||
}
|
||||
|
||||
const entry = createOperationEntry(operation, inverse)
|
||||
useUndoRedoStore.getState().push(activeWorkflowId, userId, entry)
|
||||
|
||||
logger.debug('Recorded batch toggle locked', { blockIds, previousStates })
|
||||
},
|
||||
[activeWorkflowId, userId]
|
||||
)
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
@@ -816,7 +847,9 @@ export function useUndoRedo() {
|
||||
const toggleOp = entry.inverse as BatchToggleEnabledOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
// Restore all blocks in previousStates (includes children of containers)
|
||||
const allBlockIds = Object.keys(previousStates)
|
||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
|
||||
break
|
||||
@@ -827,14 +860,14 @@ export function useUndoRedo() {
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds: validBlockIds, previousStates },
|
||||
payload: { blockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Use setBlockEnabled to directly restore to previous state
|
||||
// This is more robust than conditional toggle in collaborative scenarios
|
||||
// This restores all affected blocks including children of containers
|
||||
validBlockIds.forEach((blockId) => {
|
||||
useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId])
|
||||
})
|
||||
@@ -868,6 +901,36 @@ export function useUndoRedo() {
|
||||
})
|
||||
break
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
|
||||
const toggleOp = entry.inverse as BatchToggleLockedOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
// Restore all blocks in previousStates (includes children of containers)
|
||||
const allBlockIds = Object.keys(previousStates)
|
||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Undo batch-toggle-locked skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Use setBlockLocked to directly restore to previous state
|
||||
// This restores all affected blocks including children of containers
|
||||
validBlockIds.forEach((blockId) => {
|
||||
useWorkflowStore.getState().setBlockLocked(blockId, previousStates[blockId])
|
||||
})
|
||||
break
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||
const applyDiffInverse = entry.inverse as any
|
||||
const { baselineSnapshot } = applyDiffInverse.data
|
||||
@@ -1442,7 +1505,9 @@ export function useUndoRedo() {
|
||||
const toggleOp = entry.operation as BatchToggleEnabledOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
// Process all blocks in previousStates (includes children of containers)
|
||||
const allBlockIds = Object.keys(previousStates)
|
||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
|
||||
break
|
||||
@@ -1453,7 +1518,7 @@ export function useUndoRedo() {
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds: validBlockIds, previousStates },
|
||||
payload: { blockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
@@ -1494,6 +1559,36 @@ export function useUndoRedo() {
|
||||
})
|
||||
break
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED: {
|
||||
const toggleOp = entry.operation as BatchToggleLockedOperation
|
||||
const { blockIds, previousStates } = toggleOp.data
|
||||
|
||||
// Process all blocks in previousStates (includes children of containers)
|
||||
const allBlockIds = Object.keys(previousStates)
|
||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||
if (validBlockIds.length === 0) {
|
||||
logger.debug('Redo batch-toggle-locked skipped; no blocks exist')
|
||||
break
|
||||
}
|
||||
|
||||
addToQueue({
|
||||
id: opId,
|
||||
operation: {
|
||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
target: OPERATION_TARGETS.BLOCKS,
|
||||
payload: { blockIds, previousStates },
|
||||
},
|
||||
workflowId: activeWorkflowId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// Use setBlockLocked to directly set to toggled state
|
||||
// Redo sets to !previousStates (the state after the original toggle)
|
||||
validBlockIds.forEach((blockId) => {
|
||||
useWorkflowStore.getState().setBlockLocked(blockId, !previousStates[blockId])
|
||||
})
|
||||
break
|
||||
}
|
||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||
// Redo apply-diff means re-applying the proposed state with diff markers
|
||||
const applyDiffOp = entry.operation as any
|
||||
@@ -1815,6 +1910,7 @@ export function useUndoRedo() {
|
||||
recordBatchUpdateParent,
|
||||
recordBatchToggleEnabled,
|
||||
recordBatchToggleHandles,
|
||||
recordBatchToggleLocked,
|
||||
recordApplyDiff,
|
||||
recordAcceptDiff,
|
||||
recordRejectDiff,
|
||||
|
||||
@@ -54,6 +54,7 @@ type SkippedItemType =
|
||||
| 'block_not_found'
|
||||
| 'invalid_block_type'
|
||||
| 'block_not_allowed'
|
||||
| 'block_locked'
|
||||
| 'tool_not_allowed'
|
||||
| 'invalid_edge_target'
|
||||
| 'invalid_edge_source'
|
||||
@@ -618,6 +619,7 @@ function createBlockFromParams(
|
||||
subBlocks: {},
|
||||
outputs: outputs,
|
||||
data: parentId ? { parentId, extent: 'parent' as const } : {},
|
||||
locked: false,
|
||||
}
|
||||
|
||||
// Add validated inputs as subBlocks
|
||||
@@ -1520,6 +1522,17 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
// Check if block is locked
|
||||
if (modifiedState.blocks[block_id].locked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'delete',
|
||||
blockId: block_id,
|
||||
reason: `Block "${block_id}" is locked and cannot be deleted`,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Find all child blocks to remove
|
||||
const blocksToRemove = new Set<string>([block_id])
|
||||
const findChildren = (parentId: string) => {
|
||||
@@ -1555,6 +1568,17 @@ function applyOperationsToWorkflowState(
|
||||
|
||||
const block = modifiedState.blocks[block_id]
|
||||
|
||||
// Check if block is locked
|
||||
if (block.locked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'edit',
|
||||
blockId: block_id,
|
||||
reason: `Block "${block_id}" is locked and cannot be edited`,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Ensure block has essential properties
|
||||
if (!block.type) {
|
||||
logger.warn(`Block ${block_id} missing type property, skipping edit`, {
|
||||
@@ -2209,6 +2233,18 @@ function applyOperationsToWorkflowState(
|
||||
break
|
||||
}
|
||||
|
||||
// Check if subflow is locked
|
||||
if (subflowBlock.locked) {
|
||||
logSkippedItem(skippedItems, {
|
||||
type: 'block_locked',
|
||||
operationType: 'insert_into_subflow',
|
||||
blockId: block_id,
|
||||
reason: `Subflow "${subflowId}" is locked - cannot insert block "${block_id}"`,
|
||||
details: { subflowId },
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') {
|
||||
logger.error('Subflow block has invalid type', {
|
||||
subflowId,
|
||||
|
||||
@@ -296,6 +296,26 @@ describe('hasWorkflowChanged', () => {
|
||||
})
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should detect locked/unlocked changes', () => {
|
||||
const state1 = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1', { locked: false }) },
|
||||
})
|
||||
const state2 = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1', { locked: true }) },
|
||||
})
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
||||
})
|
||||
|
||||
it.concurrent('should not detect changes when locked state is the same', () => {
|
||||
const state1 = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1', { locked: true }) },
|
||||
})
|
||||
const state2 = createWorkflowState({
|
||||
blocks: { block1: createBlock('block1', { locked: true }) },
|
||||
})
|
||||
expect(hasWorkflowChanged(state1, state2)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SubBlock Changes', () => {
|
||||
|
||||
@@ -157,7 +157,7 @@ export function generateWorkflowDiffSummary(
|
||||
}
|
||||
// Check other block properties (boolean fields)
|
||||
// Use !! to normalize: null/undefined/false are all equivalent (falsy)
|
||||
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const
|
||||
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] as const
|
||||
for (const field of blockFields) {
|
||||
if (!!currentBlock[field] !== !!previousBlock[field]) {
|
||||
changes.push({
|
||||
|
||||
@@ -100,6 +100,7 @@ function buildStartBlockState(
|
||||
triggerMode: false,
|
||||
height: 0,
|
||||
data: {},
|
||||
locked: false,
|
||||
}
|
||||
|
||||
return { blockState, subBlockValues }
|
||||
|
||||
173
apps/sim/lib/workflows/diff/diff-engine.test.ts
Normal file
173
apps/sim/lib/workflows/diff/diff-engine.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
|
||||
// Mock all external dependencies before imports
|
||||
vi.mock('@sim/logger', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: {
|
||||
getState: () => ({
|
||||
getWorkflowState: () => ({ blocks: {}, edges: [], loops: {}, parallels: {} }),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/utils', () => ({
|
||||
mergeSubblockState: (blocks: Record<string, BlockState>) => blocks,
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/sanitization/key-validation', () => ({
|
||||
isValidKey: (key: string) => key !== 'undefined' && key !== 'null' && key !== '',
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/autolayout', () => ({
|
||||
transferBlockHeights: vi.fn(),
|
||||
applyTargetedLayout: (blocks: Record<string, BlockState>) => blocks,
|
||||
applyAutoLayout: () => ({ success: true, blocks: {} }),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/workflows/autolayout/constants', () => ({
|
||||
DEFAULT_HORIZONTAL_SPACING: 500,
|
||||
DEFAULT_VERTICAL_SPACING: 400,
|
||||
DEFAULT_LAYOUT_OPTIONS: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/utils', () => ({
|
||||
generateLoopBlocks: () => ({}),
|
||||
generateParallelBlocks: () => ({}),
|
||||
}))
|
||||
|
||||
import { WorkflowDiffEngine } from './diff-engine'
|
||||
|
||||
function createMockBlock(overrides: Partial<BlockState> = {}): BlockState {
|
||||
return {
|
||||
id: 'block-1',
|
||||
type: 'agent',
|
||||
name: 'Test Block',
|
||||
enabled: true,
|
||||
position: { x: 0, y: 0 },
|
||||
subBlocks: {},
|
||||
outputs: {},
|
||||
...overrides,
|
||||
} as BlockState
|
||||
}
|
||||
|
||||
function createMockWorkflowState(blocks: Record<string, BlockState>): WorkflowState {
|
||||
return {
|
||||
blocks,
|
||||
edges: [],
|
||||
loops: {},
|
||||
parallels: {},
|
||||
}
|
||||
}
|
||||
|
||||
describe('WorkflowDiffEngine', () => {
|
||||
let engine: WorkflowDiffEngine
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new WorkflowDiffEngine()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('hasBlockChanged detection', () => {
|
||||
describe('locked state changes', () => {
|
||||
it.concurrent(
|
||||
'should detect when block locked state changes from false to true',
|
||||
async () => {
|
||||
const freshEngine = new WorkflowDiffEngine()
|
||||
const baseline = createMockWorkflowState({
|
||||
'block-1': createMockBlock({ id: 'block-1', locked: false }),
|
||||
})
|
||||
|
||||
const proposed = createMockWorkflowState({
|
||||
'block-1': createMockBlock({ id: 'block-1', locked: true }),
|
||||
})
|
||||
|
||||
const result = await freshEngine.createDiffFromWorkflowState(
|
||||
proposed,
|
||||
undefined,
|
||||
baseline
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.diff?.diffAnalysis?.edited_blocks).toContain('block-1')
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should not detect change when locked state is the same', async () => {
|
||||
const freshEngine = new WorkflowDiffEngine()
|
||||
const baseline = createMockWorkflowState({
|
||||
'block-1': createMockBlock({ id: 'block-1', locked: true }),
|
||||
})
|
||||
|
||||
const proposed = createMockWorkflowState({
|
||||
'block-1': createMockBlock({ id: 'block-1', locked: true }),
|
||||
})
|
||||
|
||||
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.diff?.diffAnalysis?.edited_blocks).not.toContain('block-1')
|
||||
})
|
||||
|
||||
it.concurrent('should detect change when locked goes from undefined to true', async () => {
|
||||
const freshEngine = new WorkflowDiffEngine()
|
||||
const baseline = createMockWorkflowState({
|
||||
'block-1': createMockBlock({ id: 'block-1' }), // locked undefined
|
||||
})
|
||||
|
||||
const proposed = createMockWorkflowState({
|
||||
'block-1': createMockBlock({ id: 'block-1', locked: true }),
|
||||
})
|
||||
|
||||
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
// The hasBlockChanged function uses !!locked for comparison
|
||||
// so undefined -> true should be detected as a change
|
||||
expect(result.diff?.diffAnalysis?.edited_blocks).toContain('block-1')
|
||||
})
|
||||
|
||||
it.concurrent('should not detect change when both locked states are falsy', async () => {
|
||||
const freshEngine = new WorkflowDiffEngine()
|
||||
const baseline = createMockWorkflowState({
|
||||
'block-1': createMockBlock({ id: 'block-1' }), // locked undefined
|
||||
})
|
||||
|
||||
const proposed = createMockWorkflowState({
|
||||
'block-1': createMockBlock({ id: 'block-1', locked: false }), // locked false
|
||||
})
|
||||
|
||||
const result = await freshEngine.createDiffFromWorkflowState(proposed, undefined, baseline)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
// undefined and false should both be falsy, so !! comparison makes them equal
|
||||
expect(result.diff?.diffAnalysis?.edited_blocks).not.toContain('block-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('diff lifecycle', () => {
|
||||
it.concurrent('should start with no diff', () => {
|
||||
const freshEngine = new WorkflowDiffEngine()
|
||||
expect(freshEngine.hasDiff()).toBe(false)
|
||||
expect(freshEngine.getCurrentDiff()).toBeUndefined()
|
||||
})
|
||||
|
||||
it.concurrent('should clear diff', () => {
|
||||
const freshEngine = new WorkflowDiffEngine()
|
||||
freshEngine.clearDiff()
|
||||
expect(freshEngine.hasDiff()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -215,6 +215,7 @@ function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): b
|
||||
if (currentBlock.name !== proposedBlock.name) return true
|
||||
if (currentBlock.enabled !== proposedBlock.enabled) return true
|
||||
if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true
|
||||
if (!!currentBlock.locked !== !!proposedBlock.locked) return true
|
||||
|
||||
// Compare subBlocks
|
||||
const currentSubKeys = Object.keys(currentBlock.subBlocks || {})
|
||||
|
||||
@@ -226,6 +226,7 @@ export async function loadWorkflowFromNormalizedTables(
|
||||
subBlocks: (block.subBlocks as BlockState['subBlocks']) || {},
|
||||
outputs: (block.outputs as BlockState['outputs']) || {},
|
||||
data: blockData,
|
||||
locked: block.locked,
|
||||
}
|
||||
|
||||
blocksMap[block.id] = assembled
|
||||
@@ -363,6 +364,7 @@ export async function saveWorkflowToNormalizedTables(
|
||||
data: block.data || {},
|
||||
parentId: block.data?.parentId || null,
|
||||
extent: block.data?.extent || null,
|
||||
locked: block.locked ?? false,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowBlocks).values(blockInserts)
|
||||
|
||||
@@ -17,6 +17,7 @@ export const BLOCKS_OPERATIONS = {
|
||||
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
||||
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
||||
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
||||
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
|
||||
} as const
|
||||
|
||||
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
|
||||
@@ -85,6 +86,7 @@ export const UNDO_REDO_OPERATIONS = {
|
||||
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
||||
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
||||
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
||||
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
|
||||
APPLY_DIFF: 'apply-diff',
|
||||
ACCEPT_DIFF: 'accept-diff',
|
||||
REJECT_DIFF: 'reject-diff',
|
||||
|
||||
@@ -529,6 +529,7 @@ async function handleBlocksOperationTx(
|
||||
advancedMode: (block.advancedMode as boolean) ?? false,
|
||||
triggerMode: (block.triggerMode as boolean) ?? false,
|
||||
height: (block.height as number) || 0,
|
||||
locked: (block.locked as boolean) ?? false,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -741,22 +742,59 @@ async function handleBlocksOperationTx(
|
||||
`Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}`
|
||||
)
|
||||
|
||||
const blocks = await tx
|
||||
.select({ id: workflowBlocks.id, enabled: workflowBlocks.enabled })
|
||||
// Get all blocks in workflow to find children and check locked state
|
||||
const allBlocks = await tx
|
||||
.select({
|
||||
id: workflowBlocks.id,
|
||||
enabled: workflowBlocks.enabled,
|
||||
locked: workflowBlocks.locked,
|
||||
type: workflowBlocks.type,
|
||||
data: workflowBlocks.data,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds)))
|
||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
||||
|
||||
for (const block of blocks) {
|
||||
type BlockRecord = (typeof allBlocks)[number]
|
||||
const blocksById: Record<string, BlockRecord> = Object.fromEntries(
|
||||
allBlocks.map((b: BlockRecord) => [b.id, b])
|
||||
)
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// Collect all blocks to toggle including children of containers
|
||||
for (const id of blockIds) {
|
||||
const block = blocksById[id]
|
||||
if (!block || block.locked) continue
|
||||
|
||||
blocksToToggle.add(id)
|
||||
|
||||
// If it's a loop or parallel, also include all children
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine target enabled state based on first block
|
||||
const firstBlock = blocksById[blockIds[0]]
|
||||
if (!firstBlock) break
|
||||
const targetEnabled = !firstBlock.enabled
|
||||
|
||||
// Update all affected blocks
|
||||
for (const blockId of blocksToToggle) {
|
||||
await tx
|
||||
.update(workflowBlocks)
|
||||
.set({
|
||||
enabled: !block.enabled,
|
||||
enabled: targetEnabled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId)))
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
}
|
||||
|
||||
logger.debug(`Batch toggled enabled state for ${blocks.length} blocks`)
|
||||
logger.debug(`Batch toggled enabled state for ${blocksToToggle.size} blocks`)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -787,6 +825,69 @@ async function handleBlocksOperationTx(
|
||||
break
|
||||
}
|
||||
|
||||
case BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED: {
|
||||
const { blockIds } = payload
|
||||
if (!Array.isArray(blockIds) || blockIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
logger.info(`Batch toggling locked for ${blockIds.length} blocks in workflow ${workflowId}`)
|
||||
|
||||
// Get all blocks in workflow to find children
|
||||
const allBlocks = await tx
|
||||
.select({
|
||||
id: workflowBlocks.id,
|
||||
locked: workflowBlocks.locked,
|
||||
type: workflowBlocks.type,
|
||||
data: workflowBlocks.data,
|
||||
})
|
||||
.from(workflowBlocks)
|
||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
||||
|
||||
type LockedBlockRecord = (typeof allBlocks)[number]
|
||||
const blocksById: Record<string, LockedBlockRecord> = Object.fromEntries(
|
||||
allBlocks.map((b: LockedBlockRecord) => [b.id, b])
|
||||
)
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// Collect all blocks to toggle including children 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 (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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine target locked state based on first block
|
||||
const firstBlock = blocksById[blockIds[0]]
|
||||
if (!firstBlock) break
|
||||
const targetLocked = !firstBlock.locked
|
||||
|
||||
// Update all affected blocks
|
||||
for (const blockId of blocksToToggle) {
|
||||
await tx
|
||||
.update(workflowBlocks)
|
||||
.set({
|
||||
locked: targetLocked,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
||||
}
|
||||
|
||||
logger.debug(`Batch toggled locked for ${blocksToToggle.size} blocks`)
|
||||
break
|
||||
}
|
||||
|
||||
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
|
||||
const { updates } = payload
|
||||
if (!Array.isArray(updates) || updates.length === 0) {
|
||||
@@ -1198,6 +1299,7 @@ async function handleWorkflowOperationTx(
|
||||
advancedMode: block.advancedMode ?? false,
|
||||
triggerMode: block.triggerMode ?? false,
|
||||
height: block.height || 0,
|
||||
locked: block.locked ?? false,
|
||||
}))
|
||||
|
||||
await tx.insert(workflowBlocks).values(blockValues)
|
||||
|
||||
@@ -214,6 +214,12 @@ describe('checkRolePermission', () => {
|
||||
readAllowed: false,
|
||||
},
|
||||
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||
{
|
||||
operation: 'batch-toggle-locked',
|
||||
adminAllowed: true,
|
||||
writeAllowed: true,
|
||||
readAllowed: false,
|
||||
},
|
||||
{
|
||||
operation: 'batch-update-positions',
|
||||
adminAllowed: true,
|
||||
|
||||
@@ -30,6 +30,7 @@ const WRITE_OPERATIONS: string[] = [
|
||||
BLOCKS_OPERATIONS.BATCH_REMOVE_BLOCKS,
|
||||
BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||
BLOCKS_OPERATIONS.BATCH_TOGGLE_HANDLES,
|
||||
BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED,
|
||||
BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT,
|
||||
// Edge operations
|
||||
EDGE_OPERATIONS.ADD,
|
||||
|
||||
@@ -208,6 +208,17 @@ export const BatchToggleHandlesSchema = z.object({
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchToggleLockedSchema = z.object({
|
||||
operation: z.literal(BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED),
|
||||
target: z.literal(OPERATION_TARGETS.BLOCKS),
|
||||
payload: z.object({
|
||||
blockIds: z.array(z.string()),
|
||||
previousStates: z.record(z.boolean()),
|
||||
}),
|
||||
timestamp: z.number(),
|
||||
operationId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BatchUpdateParentSchema = z.object({
|
||||
operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT),
|
||||
target: z.literal(OPERATION_TARGETS.BLOCKS),
|
||||
@@ -231,6 +242,7 @@ export const WorkflowOperationSchema = z.union([
|
||||
BatchRemoveBlocksSchema,
|
||||
BatchToggleEnabledSchema,
|
||||
BatchToggleHandlesSchema,
|
||||
BatchToggleLockedSchema,
|
||||
BatchUpdateParentSchema,
|
||||
EdgeOperationSchema,
|
||||
BatchAddEdgesSchema,
|
||||
|
||||
@@ -97,6 +97,14 @@ export interface BatchToggleHandlesOperation extends BaseOperation {
|
||||
}
|
||||
}
|
||||
|
||||
export interface BatchToggleLockedOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED
|
||||
data: {
|
||||
blockIds: string[]
|
||||
previousStates: Record<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApplyDiffOperation extends BaseOperation {
|
||||
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
|
||||
data: {
|
||||
@@ -136,6 +144,7 @@ export type Operation =
|
||||
| BatchUpdateParentOperation
|
||||
| BatchToggleEnabledOperation
|
||||
| BatchToggleHandlesOperation
|
||||
| BatchToggleLockedOperation
|
||||
| ApplyDiffOperation
|
||||
| AcceptDiffOperation
|
||||
| RejectDiffOperation
|
||||
|
||||
@@ -167,6 +167,15 @@ export function createInverseOperation(operation: Operation): Operation {
|
||||
},
|
||||
}
|
||||
|
||||
case UNDO_REDO_OPERATIONS.BATCH_TOGGLE_LOCKED:
|
||||
return {
|
||||
...operation,
|
||||
data: {
|
||||
blockIds: operation.data.blockIds,
|
||||
previousStates: operation.data.previousStates,
|
||||
},
|
||||
}
|
||||
|
||||
default: {
|
||||
const exhaustiveCheck: never = operation
|
||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||
|
||||
@@ -432,4 +432,104 @@ describe('regenerateBlockIds', () => {
|
||||
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
|
||||
expect(duplicatedBlock.data?.parentId).toBe(loopId)
|
||||
})
|
||||
|
||||
it('should preserve locked state when pasting a locked block', () => {
|
||||
const blockId = 'block-1'
|
||||
|
||||
const blocksToCopy = {
|
||||
[blockId]: createAgentBlock({
|
||||
id: blockId,
|
||||
name: 'Locked Agent',
|
||||
position: { x: 100, y: 50 },
|
||||
locked: true,
|
||||
}),
|
||||
}
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
positionOffset,
|
||||
{},
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const newBlocks = Object.values(result.blocks)
|
||||
expect(newBlocks).toHaveLength(1)
|
||||
|
||||
const pastedBlock = newBlocks[0]
|
||||
expect(pastedBlock.locked).toBe(true)
|
||||
})
|
||||
|
||||
it('should preserve unlocked state when pasting an unlocked block', () => {
|
||||
const blockId = 'block-1'
|
||||
|
||||
const blocksToCopy = {
|
||||
[blockId]: createAgentBlock({
|
||||
id: blockId,
|
||||
name: 'Unlocked Agent',
|
||||
position: { x: 100, y: 50 },
|
||||
locked: false,
|
||||
}),
|
||||
}
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
positionOffset,
|
||||
{},
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const newBlocks = Object.values(result.blocks)
|
||||
expect(newBlocks).toHaveLength(1)
|
||||
|
||||
const pastedBlock = newBlocks[0]
|
||||
expect(pastedBlock.locked).toBe(false)
|
||||
})
|
||||
|
||||
it('should preserve mixed locked states when pasting multiple blocks', () => {
|
||||
const lockedId = 'locked-1'
|
||||
const unlockedId = 'unlocked-1'
|
||||
|
||||
const blocksToCopy = {
|
||||
[lockedId]: createAgentBlock({
|
||||
id: lockedId,
|
||||
name: 'Locked Agent',
|
||||
position: { x: 100, y: 50 },
|
||||
locked: true,
|
||||
}),
|
||||
[unlockedId]: createFunctionBlock({
|
||||
id: unlockedId,
|
||||
name: 'Unlocked Function',
|
||||
position: { x: 200, y: 50 },
|
||||
locked: false,
|
||||
}),
|
||||
}
|
||||
|
||||
const result = regenerateBlockIds(
|
||||
blocksToCopy,
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
{},
|
||||
positionOffset,
|
||||
{},
|
||||
getUniqueBlockName
|
||||
)
|
||||
|
||||
const newBlocks = Object.values(result.blocks)
|
||||
expect(newBlocks).toHaveLength(2)
|
||||
|
||||
const lockedBlock = newBlocks.find((b) => b.name.includes('Locked'))
|
||||
const unlockedBlock = newBlocks.find((b) => b.name.includes('Unlocked'))
|
||||
|
||||
expect(lockedBlock?.locked).toBe(true)
|
||||
expect(unlockedBlock?.locked).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -203,6 +203,7 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
|
||||
advancedMode: false,
|
||||
triggerMode,
|
||||
height: 0,
|
||||
locked: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,6 +482,7 @@ export function regenerateBlockIds(
|
||||
position: newPosition,
|
||||
// Temporarily keep data as-is, we'll fix parentId in second pass
|
||||
data: block.data ? { ...block.data } : block.data,
|
||||
// locked state is preserved via spread (same as Figma)
|
||||
}
|
||||
|
||||
newBlocks[newId] = newBlock
|
||||
|
||||
@@ -782,6 +782,155 @@ describe('workflow store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('batchToggleLocked', () => {
|
||||
it('should toggle block locked state', () => {
|
||||
const { addBlock, batchToggleLocked } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
||||
|
||||
// Initial state is undefined (falsy)
|
||||
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBeFalsy()
|
||||
|
||||
batchToggleLocked(['block-1'])
|
||||
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
|
||||
|
||||
batchToggleLocked(['block-1'])
|
||||
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(false)
|
||||
})
|
||||
|
||||
it('should cascade lock to children when locking a loop', () => {
|
||||
const { addBlock, batchToggleLocked } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
|
||||
addBlock(
|
||||
'child-1',
|
||||
'function',
|
||||
'Child',
|
||||
{ x: 50, y: 50 },
|
||||
{ parentId: 'loop-1' },
|
||||
'loop-1',
|
||||
'parent'
|
||||
)
|
||||
|
||||
batchToggleLocked(['loop-1'])
|
||||
|
||||
const { blocks } = useWorkflowStore.getState()
|
||||
expect(blocks['loop-1'].locked).toBe(true)
|
||||
expect(blocks['child-1'].locked).toBe(true)
|
||||
})
|
||||
|
||||
it('should cascade unlock to children when unlocking a parallel', () => {
|
||||
const { addBlock, batchToggleLocked } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('parallel-1', 'parallel', 'My Parallel', { x: 0, y: 0 }, { count: 3 })
|
||||
addBlock(
|
||||
'child-1',
|
||||
'function',
|
||||
'Child',
|
||||
{ x: 50, y: 50 },
|
||||
{ parentId: 'parallel-1' },
|
||||
'parallel-1',
|
||||
'parent'
|
||||
)
|
||||
|
||||
// Lock first
|
||||
batchToggleLocked(['parallel-1'])
|
||||
expect(useWorkflowStore.getState().blocks['child-1'].locked).toBe(true)
|
||||
|
||||
// Unlock
|
||||
batchToggleLocked(['parallel-1'])
|
||||
|
||||
const { blocks } = useWorkflowStore.getState()
|
||||
expect(blocks['parallel-1'].locked).toBe(false)
|
||||
expect(blocks['child-1'].locked).toBe(false)
|
||||
})
|
||||
|
||||
it('should toggle multiple blocks at once', () => {
|
||||
const { addBlock, batchToggleLocked } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('block-1', 'function', 'Test 1', { x: 0, y: 0 })
|
||||
addBlock('block-2', 'function', 'Test 2', { x: 100, y: 0 })
|
||||
|
||||
batchToggleLocked(['block-1', 'block-2'])
|
||||
|
||||
const { blocks } = useWorkflowStore.getState()
|
||||
expect(blocks['block-1'].locked).toBe(true)
|
||||
expect(blocks['block-2'].locked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setBlockLocked', () => {
|
||||
it('should set block locked state', () => {
|
||||
const { addBlock, setBlockLocked } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
||||
|
||||
setBlockLocked('block-1', true)
|
||||
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
|
||||
|
||||
setBlockLocked('block-1', false)
|
||||
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(false)
|
||||
})
|
||||
|
||||
it('should not update if locked state is already the target value', () => {
|
||||
const { addBlock, setBlockLocked } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
||||
|
||||
// First set to true
|
||||
setBlockLocked('block-1', true)
|
||||
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
|
||||
|
||||
// Setting to true again should still be true
|
||||
setBlockLocked('block-1', true)
|
||||
expect(useWorkflowStore.getState().blocks['block-1'].locked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('duplicateBlock with locked', () => {
|
||||
it('should preserve locked state when duplicating a locked block', () => {
|
||||
const { addBlock, setBlockLocked, duplicateBlock } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
|
||||
setBlockLocked('original', true)
|
||||
|
||||
expect(useWorkflowStore.getState().blocks.original.locked).toBe(true)
|
||||
|
||||
duplicateBlock('original')
|
||||
|
||||
const { blocks } = useWorkflowStore.getState()
|
||||
const blockIds = Object.keys(blocks)
|
||||
|
||||
expect(blockIds.length).toBe(2)
|
||||
|
||||
const duplicatedId = blockIds.find((id) => id !== 'original')
|
||||
expect(duplicatedId).toBeDefined()
|
||||
|
||||
if (duplicatedId) {
|
||||
// Original should still be locked
|
||||
expect(blocks.original.locked).toBe(true)
|
||||
// Duplicate should also be locked (preserves state like Figma)
|
||||
expect(blocks[duplicatedId].locked).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('should preserve unlocked state when duplicating an unlocked block', () => {
|
||||
const { addBlock, duplicateBlock } = useWorkflowStore.getState()
|
||||
|
||||
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
|
||||
|
||||
duplicateBlock('original')
|
||||
|
||||
const { blocks } = useWorkflowStore.getState()
|
||||
const blockIds = Object.keys(blocks)
|
||||
const duplicatedId = blockIds.find((id) => id !== 'original')
|
||||
|
||||
if (duplicatedId) {
|
||||
expect(blocks[duplicatedId].locked).toBeFalsy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateBlockName', () => {
|
||||
beforeEach(() => {
|
||||
useWorkflowStore.setState({
|
||||
|
||||
@@ -128,6 +128,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
advancedMode?: boolean
|
||||
triggerMode?: boolean
|
||||
height?: number
|
||||
locked?: boolean
|
||||
}
|
||||
) => {
|
||||
const blockConfig = getBlock(type)
|
||||
@@ -155,6 +156,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
triggerMode: blockProperties?.triggerMode ?? false,
|
||||
height: blockProperties?.height ?? 0,
|
||||
data: nodeData,
|
||||
locked: blockProperties?.locked ?? false,
|
||||
},
|
||||
},
|
||||
edges: [...get().edges],
|
||||
@@ -232,6 +234,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
height: blockProperties?.height ?? 0,
|
||||
layout: {},
|
||||
data: nodeData,
|
||||
locked: blockProperties?.locked ?? false,
|
||||
},
|
||||
},
|
||||
edges: [...get().edges],
|
||||
@@ -338,6 +341,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
triggerMode?: boolean
|
||||
height?: number
|
||||
data?: Record<string, any>
|
||||
locked?: boolean
|
||||
}>,
|
||||
edges?: Edge[],
|
||||
subBlockValues?: Record<string, Record<string, unknown>>,
|
||||
@@ -362,6 +366,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
triggerMode: block.triggerMode ?? false,
|
||||
height: block.height ?? 0,
|
||||
data: block.data,
|
||||
locked: block.locked ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,12 +485,46 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
|
||||
batchToggleEnabled: (ids: string[]) => {
|
||||
const newBlocks = { ...get().blocks }
|
||||
if (ids.length === 0) return
|
||||
|
||||
const currentBlocks = get().blocks
|
||||
const newBlocks = { ...currentBlocks }
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// For each ID, collect blocks to toggle (skip locked blocks)
|
||||
// If it's a container, also include non-locked children
|
||||
for (const id of ids) {
|
||||
if (newBlocks[id]) {
|
||||
newBlocks[id] = { ...newBlocks[id], enabled: !newBlocks[id].enabled }
|
||||
const block = currentBlocks[id]
|
||||
if (!block) continue
|
||||
|
||||
// Skip locked blocks
|
||||
if (!block.locked) {
|
||||
blocksToToggle.add(id)
|
||||
}
|
||||
|
||||
// If it's a loop or parallel, also include non-locked children
|
||||
if (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id && !b.locked) {
|
||||
blocksToToggle.add(blockId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If no blocks can be toggled, exit early
|
||||
if (blocksToToggle.size === 0) return
|
||||
|
||||
// Determine target enabled state based on first toggleable block
|
||||
const firstToggleableId = Array.from(blocksToToggle)[0]
|
||||
const firstBlock = currentBlocks[firstToggleableId]
|
||||
const targetEnabled = !firstBlock.enabled
|
||||
|
||||
// Apply the enabled state to all toggleable blocks
|
||||
for (const blockId of blocksToToggle) {
|
||||
newBlocks[blockId] = { ...newBlocks[blockId], enabled: targetEnabled }
|
||||
}
|
||||
|
||||
set({ blocks: newBlocks, edges: [...get().edges] })
|
||||
get().updateLastSaved()
|
||||
},
|
||||
@@ -670,6 +709,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
name: newName,
|
||||
position: offsetPosition,
|
||||
subBlocks: newSubBlocks,
|
||||
// locked state is preserved via spread (same as Figma)
|
||||
},
|
||||
},
|
||||
edges: [...get().edges],
|
||||
@@ -1277,6 +1317,70 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
getDragStartPosition: () => {
|
||||
return get().dragStartPosition || null
|
||||
},
|
||||
|
||||
setBlockLocked: (id: string, locked: boolean) => {
|
||||
const block = get().blocks[id]
|
||||
if (!block || block.locked === locked) return
|
||||
|
||||
const newState = {
|
||||
blocks: {
|
||||
...get().blocks,
|
||||
[id]: {
|
||||
...block,
|
||||
locked,
|
||||
},
|
||||
},
|
||||
edges: [...get().edges],
|
||||
loops: { ...get().loops },
|
||||
parallels: { ...get().parallels },
|
||||
}
|
||||
|
||||
set(newState)
|
||||
get().updateLastSaved()
|
||||
},
|
||||
|
||||
batchToggleLocked: (ids: string[]) => {
|
||||
if (ids.length === 0) return
|
||||
|
||||
const currentBlocks = get().blocks
|
||||
const newBlocks = { ...currentBlocks }
|
||||
const blocksToToggle = new Set<string>()
|
||||
|
||||
// For each ID, collect blocks to toggle
|
||||
// If it's a container, also include all children
|
||||
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 (block.type === 'loop' || block.type === 'parallel') {
|
||||
Object.entries(currentBlocks).forEach(([blockId, b]) => {
|
||||
if (b.data?.parentId === id) {
|
||||
blocksToToggle.add(blockId)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If no blocks found, exit early
|
||||
if (blocksToToggle.size === 0) return
|
||||
|
||||
// Determine target locked state based on first block in original ids
|
||||
const firstBlock = currentBlocks[ids[0]]
|
||||
if (!firstBlock) return
|
||||
|
||||
const targetLocked = !firstBlock.locked
|
||||
|
||||
// Apply the locked state to all blocks
|
||||
for (const blockId of blocksToToggle) {
|
||||
newBlocks[blockId] = { ...newBlocks[blockId], locked: targetLocked }
|
||||
}
|
||||
|
||||
set({ blocks: newBlocks, edges: [...get().edges] })
|
||||
get().updateLastSaved()
|
||||
},
|
||||
}),
|
||||
{ name: 'workflow-store' }
|
||||
)
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface BlockState {
|
||||
triggerMode?: boolean
|
||||
data?: BlockData
|
||||
layout?: BlockLayoutState
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface SubBlockState {
|
||||
@@ -131,6 +132,7 @@ export interface Loop {
|
||||
whileCondition?: string // JS expression that evaluates to boolean (for while loops)
|
||||
doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops)
|
||||
enabled: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface Parallel {
|
||||
@@ -140,6 +142,7 @@ export interface Parallel {
|
||||
count?: number // Number of parallel executions for count-based parallel
|
||||
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
|
||||
enabled: boolean
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
export interface Variable {
|
||||
@@ -189,6 +192,7 @@ export interface WorkflowActions {
|
||||
advancedMode?: boolean
|
||||
triggerMode?: boolean
|
||||
height?: number
|
||||
locked?: boolean
|
||||
}
|
||||
) => void
|
||||
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void
|
||||
@@ -249,6 +253,8 @@ export interface WorkflowActions {
|
||||
workflowState: WorkflowState,
|
||||
options?: { updateLastSaved?: boolean }
|
||||
) => void
|
||||
setBlockLocked: (id: string, locked: boolean) => void
|
||||
batchToggleLocked: (ids: string[]) => void
|
||||
}
|
||||
|
||||
export type WorkflowStore = WorkflowState & WorkflowActions
|
||||
|
||||
1
packages/db/migrations/0150_flimsy_hemingway.sql
Normal file
1
packages/db/migrations/0150_flimsy_hemingway.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "workflow_blocks" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;
|
||||
10354
packages/db/migrations/meta/0150_snapshot.json
Normal file
10354
packages/db/migrations/meta/0150_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1044,6 +1044,13 @@
|
||||
"when": 1769656977701,
|
||||
"tag": "0149_next_cerise",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 150,
|
||||
"version": "7",
|
||||
"when": 1769897862156,
|
||||
"tag": "0150_flimsy_hemingway",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ export const workflowBlocks = pgTable(
|
||||
isWide: boolean('is_wide').notNull().default(false),
|
||||
advancedMode: boolean('advanced_mode').notNull().default(false),
|
||||
triggerMode: boolean('trigger_mode').notNull().default(false),
|
||||
locked: boolean('locked').notNull().default(false),
|
||||
height: decimal('height').notNull().default('0'),
|
||||
|
||||
subBlocks: jsonb('sub_blocks').notNull().default('{}'),
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BlockFactoryOptions {
|
||||
triggerMode?: boolean
|
||||
data?: BlockData
|
||||
parentId?: string
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +68,7 @@ export function createBlock(options: BlockFactoryOptions = {}): any {
|
||||
height: options.height ?? 0,
|
||||
advancedMode: options.advancedMode ?? false,
|
||||
triggerMode: options.triggerMode ?? false,
|
||||
locked: options.locked ?? false,
|
||||
data: Object.keys(data).length > 0 ? data : undefined,
|
||||
layout: {},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user