mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-01 10:14:56 -05:00
Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8528fbe2d2 | ||
|
|
31fdd2be13 | ||
|
|
028bc652c2 | ||
|
|
c6bf5cd58c | ||
|
|
11dc18a80d | ||
|
|
ab4e9dc72f | ||
|
|
1c58c35bd8 | ||
|
|
d63a5cb504 | ||
|
|
8bd5d41723 | ||
|
|
c12931bc50 | ||
|
|
e9c4251c1c | ||
|
|
cc2be33d6b | ||
|
|
45371e521e | ||
|
|
0ce0f98aa5 | ||
|
|
dff1c9d083 | ||
|
|
b09f683072 | ||
|
|
a8bb0db660 | ||
|
|
af82820a28 | ||
|
|
4372841797 | ||
|
|
5e8c843241 | ||
|
|
7bf3d73ee6 | ||
|
|
7ffc11a738 | ||
|
|
be578e2ed7 | ||
|
|
f415e5edc4 | ||
|
|
13a6e6c3fa | ||
|
|
f5ab7f21ae | ||
|
|
bfb6fffe38 | ||
|
|
4fbec0a43f | ||
|
|
585f5e365b | ||
|
|
3792bdd252 | ||
|
|
eb5d1f3e5b | ||
|
|
54ab82c8dd | ||
|
|
f895bf469b | ||
|
|
dd3209af06 | ||
|
|
b6ba3b50a7 | ||
|
|
b304233062 | ||
|
|
57e4b49bd6 | ||
|
|
e12dd204ed | ||
|
|
3d9d9cbc54 | ||
|
|
0f4ec962ad | ||
|
|
4827866f9a | ||
|
|
3e697d9ed9 | ||
|
|
4431a1a484 | ||
|
|
4d1a9a3f22 | ||
|
|
eb07a080fb |
@@ -27,16 +27,16 @@ All API responses include information about your workflow execution limits and u
|
|||||||
"limits": {
|
"limits": {
|
||||||
"workflowExecutionRateLimit": {
|
"workflowExecutionRateLimit": {
|
||||||
"sync": {
|
"sync": {
|
||||||
"requestsPerMinute": 150, // Sustained rate limit per minute
|
"requestsPerMinute": 60, // Sustained rate limit per minute
|
||||||
"maxBurst": 300, // Maximum burst capacity
|
"maxBurst": 120, // Maximum burst capacity
|
||||||
"remaining": 298, // Current tokens available (up to maxBurst)
|
"remaining": 118, // Current tokens available (up to maxBurst)
|
||||||
"resetAt": "..." // When tokens next refill
|
"resetAt": "..." // When tokens next refill
|
||||||
},
|
},
|
||||||
"async": {
|
"async": {
|
||||||
"requestsPerMinute": 1000, // Sustained rate limit per minute
|
"requestsPerMinute": 200, // Sustained rate limit per minute
|
||||||
"maxBurst": 2000, // Maximum burst capacity
|
"maxBurst": 400, // Maximum burst capacity
|
||||||
"remaining": 1998, // Current tokens available
|
"remaining": 398, // Current tokens available
|
||||||
"resetAt": "..." // When tokens next refill
|
"resetAt": "..." // When tokens next refill
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"usage": {
|
"usage": {
|
||||||
@@ -107,28 +107,28 @@ Query workflow execution logs with extensive filtering options.
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"nextCursor": "eyJzIjoiMjAyNS0wMS0wMVQxMjozNDo1Ni43ODlaIiwiaWQiOiJsb2dfYWJjMTIzIn0",
|
"nextCursor": "eyJzIjoiMjAyNS0wMS0wMVQxMjozNDo1Ni43ODlaIiwiaWQiOiJsb2dfYWJjMTIzIn0",
|
||||||
"limits": {
|
"limits": {
|
||||||
"workflowExecutionRateLimit": {
|
"workflowExecutionRateLimit": {
|
||||||
"sync": {
|
"sync": {
|
||||||
"requestsPerMinute": 150,
|
"requestsPerMinute": 60,
|
||||||
"maxBurst": 300,
|
"maxBurst": 120,
|
||||||
"remaining": 298,
|
"remaining": 118,
|
||||||
"resetAt": "2025-01-01T12:35:56.789Z"
|
"resetAt": "2025-01-01T12:35:56.789Z"
|
||||||
},
|
|
||||||
"async": {
|
|
||||||
"requestsPerMinute": 1000,
|
|
||||||
"maxBurst": 2000,
|
|
||||||
"remaining": 1998,
|
|
||||||
"resetAt": "2025-01-01T12:35:56.789Z"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"usage": {
|
"async": {
|
||||||
"currentPeriodCost": 1.234,
|
"requestsPerMinute": 200,
|
||||||
"limit": 10,
|
"maxBurst": 400,
|
||||||
"plan": "pro",
|
"remaining": 398,
|
||||||
"isExceeded": false
|
"resetAt": "2025-01-01T12:35:56.789Z"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"usage": {
|
||||||
|
"currentPeriodCost": 1.234,
|
||||||
|
"limit": 10,
|
||||||
|
"plan": "pro",
|
||||||
|
"isExceeded": false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
</Tab>
|
</Tab>
|
||||||
@@ -188,15 +188,15 @@ Retrieve detailed information about a specific log entry.
|
|||||||
"limits": {
|
"limits": {
|
||||||
"workflowExecutionRateLimit": {
|
"workflowExecutionRateLimit": {
|
||||||
"sync": {
|
"sync": {
|
||||||
"requestsPerMinute": 150,
|
"requestsPerMinute": 60,
|
||||||
"maxBurst": 300,
|
"maxBurst": 120,
|
||||||
"remaining": 298,
|
"remaining": 118,
|
||||||
"resetAt": "2025-01-01T12:35:56.789Z"
|
"resetAt": "2025-01-01T12:35:56.789Z"
|
||||||
},
|
},
|
||||||
"async": {
|
"async": {
|
||||||
"requestsPerMinute": 1000,
|
"requestsPerMinute": 200,
|
||||||
"maxBurst": 2000,
|
"maxBurst": 400,
|
||||||
"remaining": 1998,
|
"remaining": 398,
|
||||||
"resetAt": "2025-01-01T12:35:56.789Z"
|
"resetAt": "2025-01-01T12:35:56.789Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -477,10 +477,10 @@ The API uses a **token bucket algorithm** for rate limiting, providing fair usag
|
|||||||
|
|
||||||
| Plan | Requests/Minute | Burst Capacity |
|
| Plan | Requests/Minute | Burst Capacity |
|
||||||
|------|-----------------|----------------|
|
|------|-----------------|----------------|
|
||||||
| Free | 30 | 60 |
|
| Free | 10 | 20 |
|
||||||
| Pro | 100 | 200 |
|
| Pro | 30 | 60 |
|
||||||
| Team | 200 | 400 |
|
| Team | 60 | 120 |
|
||||||
| Enterprise | 500 | 1000 |
|
| Enterprise | 120 | 240 |
|
||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
- Tokens refill at `requestsPerMinute` rate
|
- Tokens refill at `requestsPerMinute` rate
|
||||||
|
|||||||
@@ -170,16 +170,16 @@ curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" htt
|
|||||||
"rateLimit": {
|
"rateLimit": {
|
||||||
"sync": {
|
"sync": {
|
||||||
"isLimited": false,
|
"isLimited": false,
|
||||||
"requestsPerMinute": 150,
|
"requestsPerMinute": 25,
|
||||||
"maxBurst": 300,
|
"maxBurst": 50,
|
||||||
"remaining": 300,
|
"remaining": 50,
|
||||||
"resetAt": "2025-09-08T22:51:55.999Z"
|
"resetAt": "2025-09-08T22:51:55.999Z"
|
||||||
},
|
},
|
||||||
"async": {
|
"async": {
|
||||||
"isLimited": false,
|
"isLimited": false,
|
||||||
"requestsPerMinute": 1000,
|
"requestsPerMinute": 200,
|
||||||
"maxBurst": 2000,
|
"maxBurst": 400,
|
||||||
"remaining": 2000,
|
"remaining": 400,
|
||||||
"resetAt": "2025-09-08T22:51:56.155Z"
|
"resetAt": "2025-09-08T22:51:56.155Z"
|
||||||
},
|
},
|
||||||
"authType": "api"
|
"authType": "api"
|
||||||
@@ -206,11 +206,11 @@ curl -X GET -H "X-API-Key: YOUR_API_KEY" -H "Content-Type: application/json" htt
|
|||||||
|
|
||||||
Different subscription plans have different usage limits:
|
Different subscription plans have different usage limits:
|
||||||
|
|
||||||
| Plan | Monthly Usage Included | Rate Limits (per minute) |
|
| Plan | Monthly Usage Limit | Rate Limits (per minute) |
|
||||||
|------|------------------------|-------------------------|
|
|------|-------------------|-------------------------|
|
||||||
| **Free** | $20 | 50 sync, 200 async |
|
| **Free** | $20 | 5 sync, 10 async |
|
||||||
| **Pro** | $20 (adjustable) | 150 sync, 1,000 async |
|
| **Pro** | $100 | 10 sync, 50 async |
|
||||||
| **Team** | $40/seat (pooled, adjustable) | 300 sync, 2,500 async |
|
| **Team** | $500 (pooled) | 50 sync, 100 async |
|
||||||
| **Enterprise** | Custom | Custom |
|
| **Enterprise** | Custom | Custom |
|
||||||
|
|
||||||
## Billing Model
|
## Billing Model
|
||||||
|
|||||||
@@ -180,11 +180,6 @@ A quick lookup for everyday actions in the Sim workflow editor. For keyboard sho
|
|||||||
<td>Right-click → **Enable/Disable**</td>
|
<td>Right-click → **Enable/Disable**</td>
|
||||||
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
|
<td><ActionImage src="/static/quick-reference/disable-block.png" alt="Disable block" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>Lock/Unlock a block</td>
|
|
||||||
<td>Hover block → Click lock icon (Admin only)</td>
|
|
||||||
<td><ActionImage src="/static/quick-reference/lock-block.png" alt="Lock block" /></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>Toggle handle orientation</td>
|
<td>Toggle handle orientation</td>
|
||||||
<td>Right-click → **Toggle Handles**</td>
|
<td>Right-click → **Toggle Handles**</td>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { BlockInfoCard } from "@/components/ui/block-info-card"
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MANUAL-CONTENT-START:intro */}
|
{/* MANUAL-CONTENT-START:intro */}
|
||||||
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.
|
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.
|
||||||
|
|
||||||
With Pulse, you can:
|
With Pulse, you can:
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 34 KiB |
@@ -1,11 +1,10 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useSocket } from '@/app/workspace/providers/socket-provider'
|
|
||||||
import {
|
import {
|
||||||
useWorkspacePermissionsQuery,
|
useWorkspacePermissionsQuery,
|
||||||
type WorkspacePermissions,
|
type WorkspacePermissions,
|
||||||
@@ -58,42 +57,14 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
|||||||
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
|
const [hasShownOfflineNotification, setHasShownOfflineNotification] = useState(false)
|
||||||
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
|
const hasOperationError = useOperationQueueStore((state) => state.hasOperationError)
|
||||||
const addNotification = useNotificationStore((state) => state.addNotification)
|
const addNotification = useNotificationStore((state) => state.addNotification)
|
||||||
const removeNotification = useNotificationStore((state) => state.removeNotification)
|
|
||||||
const { isReconnecting } = useSocket()
|
|
||||||
const reconnectingNotificationIdRef = useRef<string | null>(null)
|
|
||||||
|
|
||||||
const isOfflineMode = hasOperationError
|
const isOfflineMode = hasOperationError
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isReconnecting && !reconnectingNotificationIdRef.current && !isOfflineMode) {
|
|
||||||
const id = addNotification({
|
|
||||||
level: 'error',
|
|
||||||
message: 'Reconnecting...',
|
|
||||||
})
|
|
||||||
reconnectingNotificationIdRef.current = id
|
|
||||||
} else if (!isReconnecting && reconnectingNotificationIdRef.current) {
|
|
||||||
removeNotification(reconnectingNotificationIdRef.current)
|
|
||||||
reconnectingNotificationIdRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (reconnectingNotificationIdRef.current) {
|
|
||||||
removeNotification(reconnectingNotificationIdRef.current)
|
|
||||||
reconnectingNotificationIdRef.current = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isReconnecting, isOfflineMode, addNotification, removeNotification])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOfflineMode || hasShownOfflineNotification) {
|
if (!isOfflineMode || hasShownOfflineNotification) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reconnectingNotificationIdRef.current) {
|
|
||||||
removeNotification(reconnectingNotificationIdRef.current)
|
|
||||||
reconnectingNotificationIdRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
addNotification({
|
addNotification({
|
||||||
level: 'error',
|
level: 'error',
|
||||||
@@ -107,7 +78,7 @@ export function WorkspacePermissionsProvider({ children }: WorkspacePermissionsP
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to add offline notification', { error })
|
logger.error('Failed to add offline notification', { error })
|
||||||
}
|
}
|
||||||
}, [addNotification, removeNotification, hasShownOfflineNotification, isOfflineMode])
|
}, [addNotification, hasShownOfflineNotification, isOfflineMode])
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: workspacePermissions,
|
data: workspacePermissions,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { memo, useCallback } from 'react'
|
import { memo, useCallback } from 'react'
|
||||||
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, Lock, LogOut, Unlock } from 'lucide-react'
|
import { ArrowLeftRight, ArrowUpDown, Circle, CircleOff, LogOut } from 'lucide-react'
|
||||||
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
|
import { Button, Copy, PlayOutline, Tooltip, Trash2 } from '@/components/emcn'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
|
||||||
@@ -49,7 +49,6 @@ export const ActionBar = memo(
|
|||||||
collaborativeBatchRemoveBlocks,
|
collaborativeBatchRemoveBlocks,
|
||||||
collaborativeBatchToggleBlockEnabled,
|
collaborativeBatchToggleBlockEnabled,
|
||||||
collaborativeBatchToggleBlockHandles,
|
collaborativeBatchToggleBlockHandles,
|
||||||
collaborativeBatchToggleLocked,
|
|
||||||
} = useCollaborativeWorkflow()
|
} = useCollaborativeWorkflow()
|
||||||
const { setPendingSelection } = useWorkflowRegistry()
|
const { setPendingSelection } = useWorkflowRegistry()
|
||||||
const { handleRunFromBlock } = useWorkflowExecution()
|
const { handleRunFromBlock } = useWorkflowExecution()
|
||||||
@@ -85,28 +84,16 @@ export const ActionBar = memo(
|
|||||||
)
|
)
|
||||||
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
|
}, [blockId, addNotification, collaborativeBatchAddBlocks, setPendingSelection])
|
||||||
|
|
||||||
const {
|
const { isEnabled, horizontalHandles, parentId, parentType } = useWorkflowStore(
|
||||||
isEnabled,
|
|
||||||
horizontalHandles,
|
|
||||||
parentId,
|
|
||||||
parentType,
|
|
||||||
isLocked,
|
|
||||||
isParentLocked,
|
|
||||||
isParentDisabled,
|
|
||||||
} = useWorkflowStore(
|
|
||||||
useCallback(
|
useCallback(
|
||||||
(state) => {
|
(state) => {
|
||||||
const block = state.blocks[blockId]
|
const block = state.blocks[blockId]
|
||||||
const parentId = block?.data?.parentId
|
const parentId = block?.data?.parentId
|
||||||
const parentBlock = parentId ? state.blocks[parentId] : undefined
|
|
||||||
return {
|
return {
|
||||||
isEnabled: block?.enabled ?? true,
|
isEnabled: block?.enabled ?? true,
|
||||||
horizontalHandles: block?.horizontalHandles ?? false,
|
horizontalHandles: block?.horizontalHandles ?? false,
|
||||||
parentId,
|
parentId,
|
||||||
parentType: parentBlock?.type,
|
parentType: parentId ? state.blocks[parentId]?.type : undefined,
|
||||||
isLocked: block?.locked ?? false,
|
|
||||||
isParentLocked: parentBlock?.locked ?? false,
|
|
||||||
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[blockId]
|
[blockId]
|
||||||
@@ -174,27 +161,25 @@ export const ActionBar = memo(
|
|||||||
{!isNoteBlock && !isInsideSubflow && (
|
{!isNoteBlock && !isInsideSubflow && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<span className='inline-flex'>
|
<Button
|
||||||
<Button
|
variant='ghost'
|
||||||
variant='ghost'
|
onClick={(e) => {
|
||||||
onClick={(e) => {
|
e.stopPropagation()
|
||||||
e.stopPropagation()
|
if (canRunFromBlock && !disabled) {
|
||||||
if (canRunFromBlock && !disabled) {
|
handleRunFromBlockClick()
|
||||||
handleRunFromBlockClick()
|
}
|
||||||
}
|
}}
|
||||||
}}
|
className={ACTION_BUTTON_STYLES}
|
||||||
className={ACTION_BUTTON_STYLES}
|
disabled={disabled || !canRunFromBlock}
|
||||||
disabled={disabled || !canRunFromBlock}
|
>
|
||||||
>
|
<PlayOutline className={ICON_SIZE} />
|
||||||
<PlayOutline className={ICON_SIZE} />
|
</Button>
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='top'>
|
<Tooltip.Content side='top'>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (disabled) return getTooltipMessage('Run from block')
|
if (disabled) return getTooltipMessage('Run from block')
|
||||||
if (isExecuting) return 'Execution in progress'
|
if (isExecuting) return 'Execution in progress'
|
||||||
if (!dependenciesSatisfied) return 'Run previous blocks first'
|
if (!dependenciesSatisfied) return 'Run upstream blocks first'
|
||||||
return 'Run from block'
|
return 'Run from block'
|
||||||
})()}
|
})()}
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
@@ -208,54 +193,18 @@ export const ActionBar = memo(
|
|||||||
variant='ghost'
|
variant='ghost'
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
// Can't enable if parent is disabled (must enable parent first)
|
if (!disabled) {
|
||||||
const cantEnable = !isEnabled && isParentDisabled
|
|
||||||
if (!disabled && !isLocked && !isParentLocked && !cantEnable) {
|
|
||||||
collaborativeBatchToggleBlockEnabled([blockId])
|
collaborativeBatchToggleBlockEnabled([blockId])
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={ACTION_BUTTON_STYLES}
|
className={ACTION_BUTTON_STYLES}
|
||||||
disabled={
|
disabled={disabled}
|
||||||
disabled || isLocked || isParentLocked || (!isEnabled && isParentDisabled)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
{isEnabled ? <Circle className={ICON_SIZE} /> : <CircleOff className={ICON_SIZE} />}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='top'>
|
<Tooltip.Content side='top'>
|
||||||
{isLocked || isParentLocked
|
{getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
||||||
? 'Block is locked'
|
|
||||||
: !isEnabled && isParentDisabled
|
|
||||||
? 'Parent container is disabled'
|
|
||||||
: getTooltipMessage(isEnabled ? 'Disable Block' : 'Enable Block')}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{userPermissions.canAdmin && (
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
// Can't unlock a block if its parent container is locked
|
|
||||||
if (!disabled && !(isLocked && isParentLocked)) {
|
|
||||||
collaborativeBatchToggleLocked([blockId])
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={ACTION_BUTTON_STYLES}
|
|
||||||
disabled={disabled || (isLocked && isParentLocked)}
|
|
||||||
>
|
|
||||||
{isLocked ? <Unlock className={ICON_SIZE} /> : <Lock className={ICON_SIZE} />}
|
|
||||||
</Button>
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content side='top'>
|
|
||||||
{isLocked && isParentLocked
|
|
||||||
? 'Parent container is locked'
|
|
||||||
: isLocked
|
|
||||||
? 'Unlock Block'
|
|
||||||
: 'Lock Block'}
|
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
@@ -288,12 +237,12 @@ export const ActionBar = memo(
|
|||||||
variant='ghost'
|
variant='ghost'
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!disabled && !isLocked && !isParentLocked) {
|
if (!disabled) {
|
||||||
collaborativeBatchToggleBlockHandles([blockId])
|
collaborativeBatchToggleBlockHandles([blockId])
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={ACTION_BUTTON_STYLES}
|
className={ACTION_BUTTON_STYLES}
|
||||||
disabled={disabled || isLocked || isParentLocked}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{horizontalHandles ? (
|
{horizontalHandles ? (
|
||||||
<ArrowLeftRight className={ICON_SIZE} />
|
<ArrowLeftRight className={ICON_SIZE} />
|
||||||
@@ -303,9 +252,7 @@ export const ActionBar = memo(
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='top'>
|
<Tooltip.Content side='top'>
|
||||||
{isLocked || isParentLocked
|
{getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
||||||
? 'Block is locked'
|
|
||||||
: getTooltipMessage(horizontalHandles ? 'Vertical Ports' : 'Horizontal Ports')}
|
|
||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
@@ -317,23 +264,19 @@ export const ActionBar = memo(
|
|||||||
variant='ghost'
|
variant='ghost'
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!disabled && userPermissions.canEdit && !isLocked && !isParentLocked) {
|
if (!disabled && userPermissions.canEdit) {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
|
new CustomEvent('remove-from-subflow', { detail: { blockIds: [blockId] } })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={ACTION_BUTTON_STYLES}
|
className={ACTION_BUTTON_STYLES}
|
||||||
disabled={disabled || !userPermissions.canEdit || isLocked || isParentLocked}
|
disabled={disabled || !userPermissions.canEdit}
|
||||||
>
|
>
|
||||||
<LogOut className={ICON_SIZE} />
|
<LogOut className={ICON_SIZE} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='top'>
|
<Tooltip.Content side='top'>{getTooltipMessage('Remove from Subflow')}</Tooltip.Content>
|
||||||
{isLocked || isParentLocked
|
|
||||||
? 'Block is locked'
|
|
||||||
: getTooltipMessage('Remove from Subflow')}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -343,19 +286,17 @@ export const ActionBar = memo(
|
|||||||
variant='ghost'
|
variant='ghost'
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (!disabled && !isLocked && !isParentLocked) {
|
if (!disabled) {
|
||||||
collaborativeBatchRemoveBlocks([blockId])
|
collaborativeBatchRemoveBlocks([blockId])
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={ACTION_BUTTON_STYLES}
|
className={ACTION_BUTTON_STYLES}
|
||||||
disabled={disabled || isLocked || isParentLocked}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Trash2 className={ICON_SIZE} />
|
<Trash2 className={ICON_SIZE} />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='top'>
|
<Tooltip.Content side='top'>{getTooltipMessage('Delete Block')}</Tooltip.Content>
|
||||||
{isLocked || isParentLocked ? 'Block is locked' : getTooltipMessage('Delete Block')}
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,9 +20,6 @@ export interface BlockInfo {
|
|||||||
horizontalHandles: boolean
|
horizontalHandles: boolean
|
||||||
parentId?: string
|
parentId?: string
|
||||||
parentType?: string
|
parentType?: string
|
||||||
locked?: boolean
|
|
||||||
isParentLocked?: boolean
|
|
||||||
isParentDisabled?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,17 +46,10 @@ export interface BlockMenuProps {
|
|||||||
showRemoveFromSubflow?: boolean
|
showRemoveFromSubflow?: boolean
|
||||||
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
|
/** Whether run from block is available (has snapshot, was executed, not inside subflow) */
|
||||||
canRunFromBlock?: boolean
|
canRunFromBlock?: boolean
|
||||||
/** Whether to disable edit actions (user can't edit OR blocks are locked) */
|
|
||||||
disableEdit?: boolean
|
disableEdit?: boolean
|
||||||
/** Whether the user has edit permission (ignoring locked state) */
|
|
||||||
userCanEdit?: boolean
|
|
||||||
isExecuting?: boolean
|
isExecuting?: boolean
|
||||||
/** Whether the selected block is a trigger (has no incoming edges) */
|
/** Whether the selected block is a trigger (has no incoming edges) */
|
||||||
isPositionalTrigger?: boolean
|
isPositionalTrigger?: boolean
|
||||||
/** Callback to toggle locked state of selected blocks */
|
|
||||||
onToggleLocked?: () => void
|
|
||||||
/** Whether the user has admin permissions */
|
|
||||||
canAdmin?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,22 +78,13 @@ export function BlockMenu({
|
|||||||
showRemoveFromSubflow = false,
|
showRemoveFromSubflow = false,
|
||||||
canRunFromBlock = false,
|
canRunFromBlock = false,
|
||||||
disableEdit = false,
|
disableEdit = false,
|
||||||
userCanEdit = true,
|
|
||||||
isExecuting = false,
|
isExecuting = false,
|
||||||
isPositionalTrigger = false,
|
isPositionalTrigger = false,
|
||||||
onToggleLocked,
|
|
||||||
canAdmin = false,
|
|
||||||
}: BlockMenuProps) {
|
}: BlockMenuProps) {
|
||||||
const isSingleBlock = selectedBlocks.length === 1
|
const isSingleBlock = selectedBlocks.length === 1
|
||||||
|
|
||||||
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
const allEnabled = selectedBlocks.every((b) => b.enabled)
|
||||||
const allDisabled = 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)
|
|
||||||
// Can't unlock blocks that have locked parents
|
|
||||||
const hasBlockWithLockedParent = selectedBlocks.some((b) => b.locked && b.isParentLocked)
|
|
||||||
// Can't enable blocks that have disabled parents
|
|
||||||
const hasBlockWithDisabledParent = selectedBlocks.some((b) => !b.enabled && b.isParentDisabled)
|
|
||||||
|
|
||||||
const hasSingletonBlock = selectedBlocks.some(
|
const hasSingletonBlock = selectedBlocks.some(
|
||||||
(b) =>
|
(b) =>
|
||||||
@@ -127,12 +108,6 @@ export function BlockMenu({
|
|||||||
return 'Toggle Enabled'
|
return 'Toggle Enabled'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getToggleLockedLabel = () => {
|
|
||||||
if (allLocked) return 'Unlock'
|
|
||||||
if (allUnlocked) return 'Lock'
|
|
||||||
return 'Toggle Lock'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
open={isOpen}
|
open={isOpen}
|
||||||
@@ -164,7 +139,7 @@ export function BlockMenu({
|
|||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
className='group'
|
className='group'
|
||||||
disabled={!userCanEdit || !hasClipboard}
|
disabled={disableEdit || !hasClipboard}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPaste()
|
onPaste()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -175,7 +150,7 @@ export function BlockMenu({
|
|||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
{!hasSingletonBlock && (
|
{!hasSingletonBlock && (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
disabled={!userCanEdit}
|
disabled={disableEdit}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onDuplicate()
|
onDuplicate()
|
||||||
onClose()
|
onClose()
|
||||||
@@ -189,15 +164,13 @@ export function BlockMenu({
|
|||||||
{!allNoteBlocks && <PopoverDivider />}
|
{!allNoteBlocks && <PopoverDivider />}
|
||||||
{!allNoteBlocks && (
|
{!allNoteBlocks && (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
disabled={disableEdit || hasBlockWithDisabledParent}
|
disabled={disableEdit}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!disableEdit && !hasBlockWithDisabledParent) {
|
onToggleEnabled()
|
||||||
onToggleEnabled()
|
onClose()
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{hasBlockWithDisabledParent ? 'Parent is disabled' : getToggleEnabledLabel()}
|
{getToggleEnabledLabel()}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
{!allNoteBlocks && !isSubflow && (
|
{!allNoteBlocks && !isSubflow && (
|
||||||
@@ -222,19 +195,6 @@ export function BlockMenu({
|
|||||||
Remove from Subflow
|
Remove from Subflow
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)}
|
)}
|
||||||
{canAdmin && onToggleLocked && (
|
|
||||||
<PopoverItem
|
|
||||||
disabled={hasBlockWithLockedParent}
|
|
||||||
onClick={() => {
|
|
||||||
if (!hasBlockWithLockedParent) {
|
|
||||||
onToggleLocked()
|
|
||||||
onClose()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{hasBlockWithLockedParent ? 'Parent is locked' : getToggleLockedLabel()}
|
|
||||||
</PopoverItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Single block actions */}
|
{/* Single block actions */}
|
||||||
{isSingleBlock && <PopoverDivider />}
|
{isSingleBlock && <PopoverDivider />}
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ export interface CanvasMenuProps {
|
|||||||
canUndo?: boolean
|
canUndo?: boolean
|
||||||
canRedo?: boolean
|
canRedo?: boolean
|
||||||
isInvitationsDisabled?: boolean
|
isInvitationsDisabled?: boolean
|
||||||
/** Whether the workflow has locked blocks (disables auto-layout) */
|
|
||||||
hasLockedBlocks?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,7 +60,6 @@ export function CanvasMenu({
|
|||||||
disableEdit = false,
|
disableEdit = false,
|
||||||
canUndo = false,
|
canUndo = false,
|
||||||
canRedo = false,
|
canRedo = false,
|
||||||
hasLockedBlocks = false,
|
|
||||||
}: CanvasMenuProps) {
|
}: CanvasMenuProps) {
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
@@ -132,12 +129,11 @@ export function CanvasMenu({
|
|||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
className='group'
|
className='group'
|
||||||
disabled={disableEdit || hasLockedBlocks}
|
disabled={disableEdit}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAutoLayout()
|
onAutoLayout()
|
||||||
onClose()
|
onClose()
|
||||||
}}
|
}}
|
||||||
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
|
|
||||||
>
|
>
|
||||||
<span>Auto-layout</span>
|
<span>Auto-layout</span>
|
||||||
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
|
<span className='ml-auto opacity-70 group-hover:opacity-100'>⇧L</span>
|
||||||
|
|||||||
@@ -1,443 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment node
|
|
||||||
*/
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
|
|
||||||
interface StoredTool {
|
|
||||||
type: string
|
|
||||||
title?: string
|
|
||||||
toolId?: string
|
|
||||||
params?: Record<string, string>
|
|
||||||
customToolId?: string
|
|
||||||
schema?: any
|
|
||||||
code?: string
|
|
||||||
operation?: string
|
|
||||||
usageControl?: 'auto' | 'force' | 'none'
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMcpToolAlreadySelected = (selectedTools: StoredTool[], mcpToolId: string): boolean => {
|
|
||||||
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCustomToolAlreadySelected = (
|
|
||||||
selectedTools: StoredTool[],
|
|
||||||
customToolId: string
|
|
||||||
): boolean => {
|
|
||||||
return selectedTools.some(
|
|
||||||
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWorkflowAlreadySelected = (selectedTools: StoredTool[], workflowId: string): boolean => {
|
|
||||||
return selectedTools.some(
|
|
||||||
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('isMcpToolAlreadySelected', () => {
|
|
||||||
describe('basic functionality', () => {
|
|
||||||
it.concurrent('returns false when selectedTools is empty', () => {
|
|
||||||
expect(isMcpToolAlreadySelected([], 'mcp-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns false when MCP tool is not in selectedTools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'different-mcp-tool', title: 'Different Tool' },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns true when MCP tool is already selected', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-123', title: 'My MCP Tool' },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns true when MCP tool is one of many selected tools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-1' },
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-123', title: 'My MCP Tool' },
|
|
||||||
{ type: 'workflow_input', toolId: 'workflow_executor' },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('type discrimination', () => {
|
|
||||||
it.concurrent('does not match non-MCP tools with same toolId', () => {
|
|
||||||
const selectedTools: StoredTool[] = [{ type: 'http_request', toolId: 'mcp-tool-123' }]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('does not match custom tools even with toolId set', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'custom-tool', toolId: 'custom-mcp-tool-123', customToolId: 'db-id' },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('multiple MCP tools', () => {
|
|
||||||
it.concurrent('correctly identifies first of multiple MCP tools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-1', title: 'Tool 1' },
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-2', title: 'Tool 2' },
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-3', title: 'Tool 3' },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-1')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('correctly identifies middle MCP tool', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-1', title: 'Tool 1' },
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-2', title: 'Tool 2' },
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-3', title: 'Tool 3' },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-2')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('correctly identifies last MCP tool', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-1', title: 'Tool 1' },
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-2', title: 'Tool 2' },
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-3', title: 'Tool 3' },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-3')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns false for non-existent MCP tool among many', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-1', title: 'Tool 1' },
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-2', title: 'Tool 2' },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'mcp-tool-999')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('isCustomToolAlreadySelected', () => {
|
|
||||||
describe('basic functionality', () => {
|
|
||||||
it.concurrent('returns false when selectedTools is empty', () => {
|
|
||||||
expect(isCustomToolAlreadySelected([], 'custom-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns false when custom tool is not in selectedTools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'custom-tool', customToolId: 'different-custom-tool' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns true when custom tool is already selected', () => {
|
|
||||||
const selectedTools: StoredTool[] = [{ type: 'custom-tool', customToolId: 'custom-tool-123' }]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns true when custom tool is one of many selected tools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-1', title: 'MCP Tool' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-tool-123' },
|
|
||||||
{ type: 'http_request', toolId: 'http_request_tool' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('type discrimination', () => {
|
|
||||||
it.concurrent('does not match non-custom tools with similar IDs', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'custom-tool-123', title: 'MCP with similar ID' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('does not match MCP tools even if customToolId happens to match', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-id', customToolId: 'custom-tool-123' } as StoredTool,
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('legacy inline custom tools', () => {
|
|
||||||
it.concurrent('does not match legacy inline tools without customToolId', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'custom-tool',
|
|
||||||
title: 'Legacy Tool',
|
|
||||||
toolId: 'custom-myFunction',
|
|
||||||
schema: { function: { name: 'myFunction' } },
|
|
||||||
code: 'return true',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('does not false-positive on legacy tools when checking for database tool', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'custom-tool',
|
|
||||||
title: 'Legacy Tool',
|
|
||||||
schema: { function: { name: 'sameName' } },
|
|
||||||
code: 'return true',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'db-tool-1')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('multiple custom tools', () => {
|
|
||||||
it.concurrent('correctly identifies first of multiple custom tools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-1' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-2' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-3' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-1')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('correctly identifies middle custom tool', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-1' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-2' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-3' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-2')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('correctly identifies last custom tool', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-1' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-2' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-3' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-3')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns false for non-existent custom tool among many', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-1' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-2' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-999')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('mixed tool types', () => {
|
|
||||||
it.concurrent('correctly identifies custom tool in mixed list', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-tool-1', title: 'MCP Tool' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-tool-123' },
|
|
||||||
{ type: 'http_request', toolId: 'http_request' },
|
|
||||||
{ type: 'workflow_input', toolId: 'workflow_executor' },
|
|
||||||
{ type: 'custom-tool', title: 'Legacy', schema: {}, code: '' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-tool-123')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('does not confuse MCP toolId with custom customToolId', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'shared-id-123', title: 'MCP Tool' },
|
|
||||||
{ type: 'custom-tool', customToolId: 'different-id' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'shared-id-123')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('isWorkflowAlreadySelected', () => {
|
|
||||||
describe('basic functionality', () => {
|
|
||||||
it.concurrent('returns false when selectedTools is empty', () => {
|
|
||||||
expect(isWorkflowAlreadySelected([], 'workflow-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns false when workflow is not in selectedTools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'different-workflow' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns true when workflow is already selected', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'workflow-123' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('returns true when workflow is one of many selected tools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'mcp-1', title: 'MCP Tool' },
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'workflow-123' },
|
|
||||||
},
|
|
||||||
{ type: 'custom-tool', customToolId: 'custom-1' },
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('type discrimination', () => {
|
|
||||||
it.concurrent('does not match non-workflow_input tools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'workflow-123', params: { workflowId: 'workflow-123' } },
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('does not match workflow_input without params', () => {
|
|
||||||
const selectedTools: StoredTool[] = [{ type: 'workflow_input', toolId: 'workflow_executor' }]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('does not match workflow_input with different workflowId in params', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'other-workflow' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-123')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('multiple workflows', () => {
|
|
||||||
it.concurrent('allows different workflows to be selected', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'workflow-a' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'workflow-b' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-a')).toBe(true)
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-b')).toBe(true)
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-c')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('correctly identifies specific workflow among many', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'workflow-1' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'workflow-2' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'workflow-3' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-2')).toBe(true)
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-999')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('duplicate prevention integration scenarios', () => {
|
|
||||||
describe('add then try to re-add', () => {
|
|
||||||
it.concurrent('prevents re-adding the same MCP tool', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'mcp',
|
|
||||||
toolId: 'planetscale-query',
|
|
||||||
title: 'PlanetScale Query',
|
|
||||||
params: { serverId: 'server-1' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'planetscale-query')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('prevents re-adding the same custom tool', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'custom-tool',
|
|
||||||
customToolId: 'my-custom-tool-uuid',
|
|
||||||
usageControl: 'auto',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'my-custom-tool-uuid')).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('prevents re-adding the same workflow', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'my-workflow-uuid' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'my-workflow-uuid')).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('remove then re-add', () => {
|
|
||||||
it.concurrent('allows re-adding MCP tool after removal', () => {
|
|
||||||
const selectedToolsAfterRemoval: StoredTool[] = []
|
|
||||||
expect(isMcpToolAlreadySelected(selectedToolsAfterRemoval, 'planetscale-query')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('allows re-adding custom tool after removal', () => {
|
|
||||||
const selectedToolsAfterRemoval: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'some-other-tool', title: 'Other' },
|
|
||||||
]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedToolsAfterRemoval, 'my-custom-tool-uuid')).toBe(
|
|
||||||
false
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('allows re-adding workflow after removal', () => {
|
|
||||||
const selectedToolsAfterRemoval: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'some-tool', title: 'Other' },
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedToolsAfterRemoval, 'my-workflow-uuid')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('different tools with similar names', () => {
|
|
||||||
it.concurrent('allows adding different MCP tools from same server', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{ type: 'mcp', toolId: 'server1-tool-a', title: 'Tool A', params: { serverId: 'server1' } },
|
|
||||||
]
|
|
||||||
expect(isMcpToolAlreadySelected(selectedTools, 'server1-tool-b')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('allows adding different custom tools', () => {
|
|
||||||
const selectedTools: StoredTool[] = [{ type: 'custom-tool', customToolId: 'custom-a' }]
|
|
||||||
expect(isCustomToolAlreadySelected(selectedTools, 'custom-b')).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it.concurrent('allows adding different workflows', () => {
|
|
||||||
const selectedTools: StoredTool[] = [
|
|
||||||
{
|
|
||||||
type: 'workflow_input',
|
|
||||||
toolId: 'workflow_executor',
|
|
||||||
params: { workflowId: 'workflow-a' },
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(isWorkflowAlreadySelected(selectedTools, 'workflow-b')).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1226,40 +1226,6 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
return selectedTools.some((tool) => tool.toolId === toolId)
|
return selectedTools.some((tool) => tool.toolId === toolId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if an MCP tool is already selected.
|
|
||||||
*
|
|
||||||
* @param mcpToolId - The MCP tool identifier to check
|
|
||||||
* @returns `true` if the MCP tool is already selected
|
|
||||||
*/
|
|
||||||
const isMcpToolAlreadySelected = (mcpToolId: string): boolean => {
|
|
||||||
return selectedTools.some((tool) => tool.type === 'mcp' && tool.toolId === mcpToolId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a custom tool is already selected.
|
|
||||||
*
|
|
||||||
* @param customToolId - The custom tool identifier to check
|
|
||||||
* @returns `true` if the custom tool is already selected
|
|
||||||
*/
|
|
||||||
const isCustomToolAlreadySelected = (customToolId: string): boolean => {
|
|
||||||
return selectedTools.some(
|
|
||||||
(tool) => tool.type === 'custom-tool' && tool.customToolId === customToolId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a workflow is already selected.
|
|
||||||
*
|
|
||||||
* @param workflowId - The workflow identifier to check
|
|
||||||
* @returns `true` if the workflow is already selected
|
|
||||||
*/
|
|
||||||
const isWorkflowAlreadySelected = (workflowId: string): boolean => {
|
|
||||||
return selectedTools.some(
|
|
||||||
(tool) => tool.type === 'workflow_input' && tool.params?.workflowId === workflowId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a block supports multiple operations.
|
* Checks if a block supports multiple operations.
|
||||||
*
|
*
|
||||||
@@ -1779,29 +1745,24 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
if (!permissionConfig.disableCustomTools && customTools.length > 0) {
|
if (!permissionConfig.disableCustomTools && customTools.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
section: 'Custom Tools',
|
section: 'Custom Tools',
|
||||||
items: customTools.map((customTool) => {
|
items: customTools.map((customTool) => ({
|
||||||
const alreadySelected = isCustomToolAlreadySelected(customTool.id)
|
label: customTool.title,
|
||||||
return {
|
value: `custom-${customTool.id}`,
|
||||||
label: customTool.title,
|
iconElement: createToolIcon('#3B82F6', WrenchIcon),
|
||||||
value: `custom-${customTool.id}`,
|
onSelect: () => {
|
||||||
iconElement: createToolIcon('#3B82F6', WrenchIcon),
|
const newTool: StoredTool = {
|
||||||
disabled: isPreview || alreadySelected,
|
type: 'custom-tool',
|
||||||
onSelect: () => {
|
customToolId: customTool.id,
|
||||||
if (alreadySelected) return
|
usageControl: 'auto',
|
||||||
const newTool: StoredTool = {
|
isExpanded: true,
|
||||||
type: 'custom-tool',
|
}
|
||||||
customToolId: customTool.id,
|
setStoreValue([
|
||||||
usageControl: 'auto',
|
...selectedTools.map((tool) => ({ ...tool, isExpanded: false })),
|
||||||
isExpanded: true,
|
newTool,
|
||||||
}
|
])
|
||||||
setStoreValue([
|
setOpen(false)
|
||||||
...selectedTools.map((tool) => ({ ...tool, isExpanded: false })),
|
},
|
||||||
newTool,
|
})),
|
||||||
])
|
|
||||||
setOpen(false)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1811,13 +1772,11 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
section: 'MCP Tools',
|
section: 'MCP Tools',
|
||||||
items: availableMcpTools.map((mcpTool) => {
|
items: availableMcpTools.map((mcpTool) => {
|
||||||
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
|
const server = mcpServers.find((s) => s.id === mcpTool.serverId)
|
||||||
const alreadySelected = isMcpToolAlreadySelected(mcpTool.id)
|
|
||||||
return {
|
return {
|
||||||
label: mcpTool.name,
|
label: mcpTool.name,
|
||||||
value: `mcp-${mcpTool.id}`,
|
value: `mcp-${mcpTool.id}`,
|
||||||
iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon),
|
iconElement: createToolIcon(mcpTool.bgColor || '#6366F1', mcpTool.icon || McpIcon),
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
if (alreadySelected) return
|
|
||||||
const newTool: StoredTool = {
|
const newTool: StoredTool = {
|
||||||
type: 'mcp',
|
type: 'mcp',
|
||||||
title: mcpTool.name,
|
title: mcpTool.name,
|
||||||
@@ -1837,7 +1796,7 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
}
|
}
|
||||||
handleMcpToolSelect(newTool, true)
|
handleMcpToolSelect(newTool, true)
|
||||||
},
|
},
|
||||||
disabled: isPreview || disabled || alreadySelected,
|
disabled: isPreview || disabled,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -1851,17 +1810,12 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
if (builtInTools.length > 0) {
|
if (builtInTools.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
section: 'Built-in Tools',
|
section: 'Built-in Tools',
|
||||||
items: builtInTools.map((block) => {
|
items: builtInTools.map((block) => ({
|
||||||
const toolId = getToolIdForOperation(block.type, undefined)
|
label: block.name,
|
||||||
const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false
|
value: `builtin-${block.type}`,
|
||||||
return {
|
iconElement: createToolIcon(block.bgColor, block.icon),
|
||||||
label: block.name,
|
onSelect: () => handleSelectTool(block),
|
||||||
value: `builtin-${block.type}`,
|
})),
|
||||||
iconElement: createToolIcon(block.bgColor, block.icon),
|
|
||||||
disabled: isPreview || alreadySelected,
|
|
||||||
onSelect: () => handleSelectTool(block),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1869,17 +1823,12 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
if (integrations.length > 0) {
|
if (integrations.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
section: 'Integrations',
|
section: 'Integrations',
|
||||||
items: integrations.map((block) => {
|
items: integrations.map((block) => ({
|
||||||
const toolId = getToolIdForOperation(block.type, undefined)
|
label: block.name,
|
||||||
const alreadySelected = toolId ? isToolAlreadySelected(toolId, block.type) : false
|
value: `builtin-${block.type}`,
|
||||||
return {
|
iconElement: createToolIcon(block.bgColor, block.icon),
|
||||||
label: block.name,
|
onSelect: () => handleSelectTool(block),
|
||||||
value: `builtin-${block.type}`,
|
})),
|
||||||
iconElement: createToolIcon(block.bgColor, block.icon),
|
|
||||||
disabled: isPreview || alreadySelected,
|
|
||||||
onSelect: () => handleSelectTool(block),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1887,33 +1836,29 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
if (availableWorkflows.length > 0) {
|
if (availableWorkflows.length > 0) {
|
||||||
groups.push({
|
groups.push({
|
||||||
section: 'Workflows',
|
section: 'Workflows',
|
||||||
items: availableWorkflows.map((workflow) => {
|
items: availableWorkflows.map((workflow) => ({
|
||||||
const alreadySelected = isWorkflowAlreadySelected(workflow.id)
|
label: workflow.name,
|
||||||
return {
|
value: `workflow-${workflow.id}`,
|
||||||
label: workflow.name,
|
iconElement: createToolIcon('#6366F1', WorkflowIcon),
|
||||||
value: `workflow-${workflow.id}`,
|
onSelect: () => {
|
||||||
iconElement: createToolIcon('#6366F1', WorkflowIcon),
|
const newTool: StoredTool = {
|
||||||
onSelect: () => {
|
type: 'workflow_input',
|
||||||
if (alreadySelected) return
|
title: 'Workflow',
|
||||||
const newTool: StoredTool = {
|
toolId: 'workflow_executor',
|
||||||
type: 'workflow_input',
|
params: {
|
||||||
title: 'Workflow',
|
workflowId: workflow.id,
|
||||||
toolId: 'workflow_executor',
|
},
|
||||||
params: {
|
isExpanded: true,
|
||||||
workflowId: workflow.id,
|
usageControl: 'auto',
|
||||||
},
|
}
|
||||||
isExpanded: true,
|
setStoreValue([
|
||||||
usageControl: 'auto',
|
...selectedTools.map((tool) => ({ ...tool, isExpanded: false })),
|
||||||
}
|
newTool,
|
||||||
setStoreValue([
|
])
|
||||||
...selectedTools.map((tool) => ({ ...tool, isExpanded: false })),
|
setOpen(false)
|
||||||
newTool,
|
},
|
||||||
])
|
disabled: isPreview || disabled,
|
||||||
setOpen(false)
|
})),
|
||||||
},
|
|
||||||
disabled: isPreview || disabled || alreadySelected,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1932,11 +1877,6 @@ export const ToolInput = memo(function ToolInput({
|
|||||||
permissionConfig.disableCustomTools,
|
permissionConfig.disableCustomTools,
|
||||||
permissionConfig.disableMcpTools,
|
permissionConfig.disableMcpTools,
|
||||||
availableWorkflows,
|
availableWorkflows,
|
||||||
getToolIdForOperation,
|
|
||||||
isToolAlreadySelected,
|
|
||||||
isMcpToolAlreadySelected,
|
|
||||||
isCustomToolAlreadySelected,
|
|
||||||
isWorkflowAlreadySelected,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
const toolRequiresOAuth = (toolId: string): boolean => {
|
const toolRequiresOAuth = (toolId: string): boolean => {
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ import {
|
|||||||
ChevronUp,
|
ChevronUp,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
|
||||||
Pencil,
|
Pencil,
|
||||||
Unlock,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
@@ -48,7 +46,6 @@ import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
|||||||
import { usePanelEditorStore } from '@/stores/panel'
|
import { usePanelEditorStore } from '@/stores/panel'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
|
||||||
|
|
||||||
/** Stable empty object to avoid creating new references */
|
/** Stable empty object to avoid creating new references */
|
||||||
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
|
const EMPTY_SUBBLOCK_VALUES = {} as Record<string, any>
|
||||||
@@ -113,14 +110,6 @@ export function Editor() {
|
|||||||
// Get user permissions
|
// Get user permissions
|
||||||
const userPermissions = useUserPermissionsContext()
|
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
|
// Get active workflow ID
|
||||||
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
const activeWorkflowId = useWorkflowRegistry((state) => state.activeWorkflowId)
|
||||||
|
|
||||||
@@ -161,7 +150,9 @@ export function Editor() {
|
|||||||
blockSubBlockValues,
|
blockSubBlockValues,
|
||||||
canonicalIndex
|
canonicalIndex
|
||||||
)
|
)
|
||||||
const displayAdvancedOptions = canEditBlock ? advancedMode : advancedMode || advancedValuesPresent
|
const displayAdvancedOptions = userPermissions.canEdit
|
||||||
|
? advancedMode
|
||||||
|
: advancedMode || advancedValuesPresent
|
||||||
|
|
||||||
const hasAdvancedOnlyFields = useMemo(() => {
|
const hasAdvancedOnlyFields = useMemo(() => {
|
||||||
for (const subBlock of subBlocksForCanonical) {
|
for (const subBlock of subBlocksForCanonical) {
|
||||||
@@ -228,14 +219,13 @@ export function Editor() {
|
|||||||
collaborativeSetBlockCanonicalMode,
|
collaborativeSetBlockCanonicalMode,
|
||||||
collaborativeUpdateBlockName,
|
collaborativeUpdateBlockName,
|
||||||
collaborativeToggleBlockAdvancedMode,
|
collaborativeToggleBlockAdvancedMode,
|
||||||
collaborativeBatchToggleLocked,
|
|
||||||
} = useCollaborativeWorkflow()
|
} = useCollaborativeWorkflow()
|
||||||
|
|
||||||
// Advanced mode toggle handler
|
// Advanced mode toggle handler
|
||||||
const handleToggleAdvancedMode = useCallback(() => {
|
const handleToggleAdvancedMode = useCallback(() => {
|
||||||
if (!currentBlockId || !canEditBlock) return
|
if (!currentBlockId || !userPermissions.canEdit) return
|
||||||
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
collaborativeToggleBlockAdvancedMode(currentBlockId)
|
||||||
}, [currentBlockId, canEditBlock, collaborativeToggleBlockAdvancedMode])
|
}, [currentBlockId, userPermissions.canEdit, collaborativeToggleBlockAdvancedMode])
|
||||||
|
|
||||||
// Rename state
|
// Rename state
|
||||||
const [isRenaming, setIsRenaming] = useState(false)
|
const [isRenaming, setIsRenaming] = useState(false)
|
||||||
@@ -246,10 +236,10 @@ export function Editor() {
|
|||||||
* Handles starting the rename process.
|
* Handles starting the rename process.
|
||||||
*/
|
*/
|
||||||
const handleStartRename = useCallback(() => {
|
const handleStartRename = useCallback(() => {
|
||||||
if (!canEditBlock || !currentBlock) return
|
if (!userPermissions.canEdit || !currentBlock) return
|
||||||
setEditedName(currentBlock.name || '')
|
setEditedName(currentBlock.name || '')
|
||||||
setIsRenaming(true)
|
setIsRenaming(true)
|
||||||
}, [canEditBlock, currentBlock])
|
}, [userPermissions.canEdit, currentBlock])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles saving the renamed block.
|
* Handles saving the renamed block.
|
||||||
@@ -368,36 +358,6 @@ export function Editor() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className='flex shrink-0 items-center gap-[8px]'>
|
<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 */}
|
|
||||||
{isLocked && currentBlock && (
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
{userPermissions.canAdmin && currentBlock.locked && !isParentLocked ? (
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
className='p-0'
|
|
||||||
onClick={() => collaborativeBatchToggleLocked([currentBlockId!])}
|
|
||||||
aria-label='Unlock block'
|
|
||||||
>
|
|
||||||
<Unlock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
{isParentLocked
|
|
||||||
? 'Parent container is locked'
|
|
||||||
: userPermissions.canAdmin && currentBlock.locked
|
|
||||||
? 'Unlock block'
|
|
||||||
: 'Block is locked'}
|
|
||||||
</p>
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
)}
|
|
||||||
{/* Rename button */}
|
{/* Rename button */}
|
||||||
{currentBlock && (
|
{currentBlock && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
@@ -406,7 +366,7 @@ export function Editor() {
|
|||||||
variant='ghost'
|
variant='ghost'
|
||||||
className='p-0'
|
className='p-0'
|
||||||
onClick={isRenaming ? handleSaveRename : handleStartRename}
|
onClick={isRenaming ? handleSaveRename : handleStartRename}
|
||||||
disabled={!canEditBlock}
|
disabled={!userPermissions.canEdit}
|
||||||
aria-label={isRenaming ? 'Save name' : 'Rename block'}
|
aria-label={isRenaming ? 'Save name' : 'Rename block'}
|
||||||
>
|
>
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
@@ -474,7 +434,7 @@ export function Editor() {
|
|||||||
incomingConnections={incomingConnections}
|
incomingConnections={incomingConnections}
|
||||||
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
|
handleConnectionsResizeMouseDown={handleConnectionsResizeMouseDown}
|
||||||
toggleConnectionsCollapsed={toggleConnectionsCollapsed}
|
toggleConnectionsCollapsed={toggleConnectionsCollapsed}
|
||||||
userCanEdit={canEditBlock}
|
userCanEdit={userPermissions.canEdit}
|
||||||
isConnectionsAtMinHeight={isConnectionsAtMinHeight}
|
isConnectionsAtMinHeight={isConnectionsAtMinHeight}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -582,14 +542,14 @@ export function Editor() {
|
|||||||
config={subBlock}
|
config={subBlock}
|
||||||
isPreview={false}
|
isPreview={false}
|
||||||
subBlockValues={subBlockState}
|
subBlockValues={subBlockState}
|
||||||
disabled={!canEditBlock}
|
disabled={!userPermissions.canEdit}
|
||||||
fieldDiffStatus={undefined}
|
fieldDiffStatus={undefined}
|
||||||
allowExpandInPreview={false}
|
allowExpandInPreview={false}
|
||||||
canonicalToggle={
|
canonicalToggle={
|
||||||
isCanonicalSwap && canonicalMode && canonicalId
|
isCanonicalSwap && canonicalMode && canonicalId
|
||||||
? {
|
? {
|
||||||
mode: canonicalMode,
|
mode: canonicalMode,
|
||||||
disabled: !canEditBlock,
|
disabled: !userPermissions.canEdit,
|
||||||
onToggle: () => {
|
onToggle: () => {
|
||||||
if (!currentBlockId) return
|
if (!currentBlockId) return
|
||||||
const nextMode =
|
const nextMode =
|
||||||
@@ -619,7 +579,7 @@ export function Editor() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{hasAdvancedOnlyFields && canEditBlock && (
|
{hasAdvancedOnlyFields && userPermissions.canEdit && (
|
||||||
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
|
||||||
<div
|
<div
|
||||||
className='h-[1.25px] flex-1'
|
className='h-[1.25px] flex-1'
|
||||||
@@ -664,7 +624,7 @@ export function Editor() {
|
|||||||
config={subBlock}
|
config={subBlock}
|
||||||
isPreview={false}
|
isPreview={false}
|
||||||
subBlockValues={subBlockState}
|
subBlockValues={subBlockState}
|
||||||
disabled={!canEditBlock}
|
disabled={!userPermissions.canEdit}
|
||||||
fieldDiffStatus={undefined}
|
fieldDiffStatus={undefined}
|
||||||
allowExpandInPreview={false}
|
allowExpandInPreview={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -45,13 +45,11 @@ import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowI
|
|||||||
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
import { useDeleteWorkflow, useImportWorkflow } from '@/app/workspace/[workspaceId]/w/hooks'
|
||||||
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
import { usePermissionConfig } from '@/hooks/use-permission-config'
|
||||||
import { useChatStore } from '@/stores/chat/store'
|
import { useChatStore } from '@/stores/chat/store'
|
||||||
import { useNotificationStore } from '@/stores/notifications/store'
|
|
||||||
import type { PanelTab } from '@/stores/panel'
|
import type { PanelTab } from '@/stores/panel'
|
||||||
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
|
import { usePanelStore, useVariablesStore as usePanelVariablesStore } from '@/stores/panel'
|
||||||
import { useVariablesStore } from '@/stores/variables/store'
|
import { useVariablesStore } from '@/stores/variables/store'
|
||||||
import { getWorkflowWithValues } from '@/stores/workflows'
|
import { getWorkflowWithValues } from '@/stores/workflows'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
|
||||||
|
|
||||||
const logger = createLogger('Panel')
|
const logger = createLogger('Panel')
|
||||||
/**
|
/**
|
||||||
@@ -121,11 +119,6 @@ export const Panel = memo(function Panel() {
|
|||||||
hydration.phase === 'state-loading'
|
hydration.phase === 'state-loading'
|
||||||
const { handleAutoLayout: autoLayoutWithFitView } = useAutoLayout(activeWorkflowId || null)
|
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
|
// Delete workflow hook
|
||||||
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
|
const { isDeleting, handleDeleteWorkflow } = useDeleteWorkflow({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -237,24 +230,11 @@ export const Panel = memo(function Panel() {
|
|||||||
|
|
||||||
setIsAutoLayouting(true)
|
setIsAutoLayouting(true)
|
||||||
try {
|
try {
|
||||||
const result = await autoLayoutWithFitView()
|
await autoLayoutWithFitView()
|
||||||
if (!result.success && result.error) {
|
|
||||||
useNotificationStore.getState().addNotification({
|
|
||||||
level: 'info',
|
|
||||||
message: result.error,
|
|
||||||
workflowId: activeWorkflowId || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsAutoLayouting(false)
|
setIsAutoLayouting(false)
|
||||||
}
|
}
|
||||||
}, [
|
}, [isExecuting, userPermissions.canEdit, isAutoLayouting, autoLayoutWithFitView])
|
||||||
isExecuting,
|
|
||||||
userPermissions.canEdit,
|
|
||||||
isAutoLayouting,
|
|
||||||
autoLayoutWithFitView,
|
|
||||||
activeWorkflowId,
|
|
||||||
])
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles exporting workflow as JSON
|
* Handles exporting workflow as JSON
|
||||||
@@ -424,10 +404,7 @@ export const Panel = memo(function Panel() {
|
|||||||
<PopoverContent align='start' side='bottom' sideOffset={8}>
|
<PopoverContent align='start' side='bottom' sideOffset={8}>
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
onClick={handleAutoLayout}
|
onClick={handleAutoLayout}
|
||||||
disabled={
|
disabled={isExecuting || !userPermissions.canEdit || isAutoLayouting}
|
||||||
isExecuting || !userPermissions.canEdit || isAutoLayouting || hasLockedBlocks
|
|
||||||
}
|
|
||||||
title={hasLockedBlocks ? 'Unlock blocks to use auto-layout' : undefined}
|
|
||||||
>
|
>
|
||||||
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
|
<Layout className='h-3 w-3' animate={isAutoLayouting} variant='clockwise' />
|
||||||
<span>Auto layout</span>
|
<span>Auto layout</span>
|
||||||
|
|||||||
@@ -80,7 +80,6 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const isEnabled = currentBlock?.enabled ?? true
|
const isEnabled = currentBlock?.enabled ?? true
|
||||||
const isLocked = currentBlock?.locked ?? false
|
|
||||||
const isPreview = data?.isPreview || false
|
const isPreview = data?.isPreview || false
|
||||||
|
|
||||||
// Focus state
|
// Focus state
|
||||||
@@ -201,10 +200,7 @@ export const SubflowNodeComponent = memo(({ data, id, selected }: NodeProps<Subf
|
|||||||
{blockName}
|
{blockName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center gap-1'>
|
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
|
||||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isPreview && (
|
{!isPreview && (
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ export interface UseBlockStateReturn {
|
|||||||
diffStatus: DiffStatus
|
diffStatus: DiffStatus
|
||||||
/** Whether this is a deleted block in diff mode */
|
/** Whether this is a deleted block in diff mode */
|
||||||
isDeletedBlock: boolean
|
isDeletedBlock: boolean
|
||||||
/** Whether the block is locked */
|
|
||||||
isLocked: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,11 +40,6 @@ export function useBlockState(
|
|||||||
? (data.blockState?.enabled ?? true)
|
? (data.blockState?.enabled ?? true)
|
||||||
: (currentBlock?.enabled ?? true)
|
: (currentBlock?.enabled ?? true)
|
||||||
|
|
||||||
// Determine if block is locked
|
|
||||||
const isLocked = data.isPreview
|
|
||||||
? (data.blockState?.locked ?? false)
|
|
||||||
: (currentBlock?.locked ?? false)
|
|
||||||
|
|
||||||
// Get diff status
|
// Get diff status
|
||||||
const diffStatus: DiffStatus =
|
const diffStatus: DiffStatus =
|
||||||
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
|
currentWorkflow.isDiffMode && currentBlock && hasDiffStatus(currentBlock)
|
||||||
@@ -75,6 +68,5 @@ export function useBlockState(
|
|||||||
isActive,
|
isActive,
|
||||||
diffStatus,
|
diffStatus,
|
||||||
isDeletedBlock: isDeletedBlock ?? false,
|
isDeletedBlock: isDeletedBlock ?? false,
|
||||||
isLocked,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -672,7 +672,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
|||||||
currentWorkflow,
|
currentWorkflow,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
isLocked,
|
|
||||||
handleClick,
|
handleClick,
|
||||||
hasRing,
|
hasRing,
|
||||||
ringStyles,
|
ringStyles,
|
||||||
@@ -1101,7 +1100,7 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
|||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='relative z-10 flex flex-shrink-0 items-center gap-1'>
|
<div className='relative z-10 flex flex-shrink-0 items-center gap-2'>
|
||||||
{isWorkflowSelector &&
|
{isWorkflowSelector &&
|
||||||
childWorkflowId &&
|
childWorkflowId &&
|
||||||
typeof childIsDeployed === 'boolean' &&
|
typeof childIsDeployed === 'boolean' &&
|
||||||
@@ -1134,7 +1133,6 @@ export const WorkflowBlock = memo(function WorkflowBlock({
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
{!isEnabled && <Badge variant='gray-secondary'>disabled</Badge>}
|
||||||
{isLocked && <Badge variant='gray-secondary'>locked</Badge>}
|
|
||||||
|
|
||||||
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
|
{type === 'schedule' && shouldShowScheduleBadge && scheduleInfo?.isDisabled && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export function useBlockVisual({
|
|||||||
isActive: isExecuting,
|
isActive: isExecuting,
|
||||||
diffStatus,
|
diffStatus,
|
||||||
isDeletedBlock,
|
isDeletedBlock,
|
||||||
isLocked,
|
|
||||||
} = useBlockState(blockId, currentWorkflow, data)
|
} = useBlockState(blockId, currentWorkflow, data)
|
||||||
|
|
||||||
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
const currentBlockId = usePanelEditorStore((state) => state.currentBlockId)
|
||||||
@@ -104,7 +103,6 @@ export function useBlockVisual({
|
|||||||
currentWorkflow,
|
currentWorkflow,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
isEnabled,
|
isEnabled,
|
||||||
isLocked,
|
|
||||||
handleClick,
|
handleClick,
|
||||||
hasRing,
|
hasRing,
|
||||||
ringStyles,
|
ringStyles,
|
||||||
|
|||||||
@@ -31,8 +31,7 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
|
|||||||
nodes.map((n) => {
|
nodes.map((n) => {
|
||||||
const block = blocks[n.id]
|
const block = blocks[n.id]
|
||||||
const parentId = block?.data?.parentId
|
const parentId = block?.data?.parentId
|
||||||
const parentBlock = parentId ? blocks[parentId] : undefined
|
const parentType = parentId ? blocks[parentId]?.type : undefined
|
||||||
const parentType = parentBlock?.type
|
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: block?.type || '',
|
type: block?.type || '',
|
||||||
@@ -40,9 +39,6 @@ export function useCanvasContextMenu({ blocks, getNodes, setNodes }: UseCanvasCo
|
|||||||
horizontalHandles: block?.horizontalHandles ?? false,
|
horizontalHandles: block?.horizontalHandles ?? false,
|
||||||
parentId,
|
parentId,
|
||||||
parentType,
|
parentType,
|
||||||
locked: block?.locked ?? false,
|
|
||||||
isParentLocked: parentBlock?.locked ?? false,
|
|
||||||
isParentDisabled: parentBlock ? !parentBlock.enabled : false,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
[blocks]
|
[blocks]
|
||||||
|
|||||||
@@ -52,16 +52,6 @@ export async function applyAutoLayoutAndUpdateStore(
|
|||||||
return { success: false, error: 'No blocks to layout' }
|
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
|
// Merge with default options
|
||||||
const layoutOptions = {
|
const layoutOptions = {
|
||||||
spacing: {
|
spacing: {
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of filtering protected blocks from a deletion operation
|
|
||||||
*/
|
|
||||||
export interface FilterProtectedBlocksResult {
|
|
||||||
/** Block IDs that can be deleted (not protected) */
|
|
||||||
deletableIds: string[]
|
|
||||||
/** Block IDs that are protected and cannot be deleted */
|
|
||||||
protectedIds: string[]
|
|
||||||
/** Whether all blocks are protected (deletion should be cancelled entirely) */
|
|
||||||
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 if either its source or target block is protected.
|
|
||||||
*
|
|
||||||
* @param edge - The edge to check (must have source and target)
|
|
||||||
* @param blocks - Record of all blocks in the workflow
|
|
||||||
* @returns True if the edge is protected
|
|
||||||
*/
|
|
||||||
export function isEdgeProtected(
|
|
||||||
edge: { source: string; target: string },
|
|
||||||
blocks: Record<string, BlockState>
|
|
||||||
): boolean {
|
|
||||||
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters out protected blocks from a list of block IDs for deletion.
|
|
||||||
* Protected blocks are those that are locked or inside a locked container.
|
|
||||||
*
|
|
||||||
* @param blockIds - Array of block IDs to filter
|
|
||||||
* @param blocks - Record of all blocks in the workflow
|
|
||||||
* @returns Result containing deletable IDs, protected IDs, and whether all are protected
|
|
||||||
*/
|
|
||||||
export function filterProtectedBlocks(
|
|
||||||
blockIds: string[],
|
|
||||||
blocks: Record<string, BlockState>
|
|
||||||
): FilterProtectedBlocksResult {
|
|
||||||
const protectedIds = blockIds.filter((id) => isBlockProtected(id, blocks))
|
|
||||||
const deletableIds = blockIds.filter((id) => !protectedIds.includes(id))
|
|
||||||
|
|
||||||
return {
|
|
||||||
deletableIds,
|
|
||||||
protectedIds,
|
|
||||||
allProtected: protectedIds.length === blockIds.length && blockIds.length > 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if any blocks in the selection are protected.
|
|
||||||
* Useful for determining if edit actions should be disabled.
|
|
||||||
*
|
|
||||||
* @param blockIds - Array of block IDs to check
|
|
||||||
* @param blocks - Record of all blocks in the workflow
|
|
||||||
* @returns True if any block is protected
|
|
||||||
*/
|
|
||||||
export function hasProtectedBlocks(
|
|
||||||
blockIds: string[],
|
|
||||||
blocks: Record<string, BlockState>
|
|
||||||
): boolean {
|
|
||||||
return blockIds.some((id) => isBlockProtected(id, blocks))
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
export * from './auto-layout-utils'
|
export * from './auto-layout-utils'
|
||||||
export * from './block-protection-utils'
|
|
||||||
export * from './block-ring-utils'
|
export * from './block-ring-utils'
|
||||||
export * from './node-position-utils'
|
export * from './node-position-utils'
|
||||||
export * from './workflow-canvas-helpers'
|
export * from './workflow-canvas-helpers'
|
||||||
|
|||||||
@@ -55,10 +55,7 @@ import {
|
|||||||
clearDragHighlights,
|
clearDragHighlights,
|
||||||
computeClampedPositionUpdates,
|
computeClampedPositionUpdates,
|
||||||
estimateBlockDimensions,
|
estimateBlockDimensions,
|
||||||
filterProtectedBlocks,
|
|
||||||
getClampedPositionForNode,
|
getClampedPositionForNode,
|
||||||
isBlockProtected,
|
|
||||||
isEdgeProtected,
|
|
||||||
isInEditableElement,
|
isInEditableElement,
|
||||||
resolveParentChildSelectionConflicts,
|
resolveParentChildSelectionConflicts,
|
||||||
validateTriggerPaste,
|
validateTriggerPaste,
|
||||||
@@ -546,7 +543,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
collaborativeBatchRemoveBlocks,
|
collaborativeBatchRemoveBlocks,
|
||||||
collaborativeBatchToggleBlockEnabled,
|
collaborativeBatchToggleBlockEnabled,
|
||||||
collaborativeBatchToggleBlockHandles,
|
collaborativeBatchToggleBlockHandles,
|
||||||
collaborativeBatchToggleLocked,
|
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
} = useCollaborativeWorkflow()
|
} = useCollaborativeWorkflow()
|
||||||
@@ -1073,27 +1069,8 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
const handleContextDelete = useCallback(() => {
|
const handleContextDelete = useCallback(() => {
|
||||||
const blockIds = contextMenuBlocks.map((b) => b.id)
|
const blockIds = contextMenuBlocks.map((b) => b.id)
|
||||||
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(blockIds, blocks)
|
collaborativeBatchRemoveBlocks(blockIds)
|
||||||
|
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks])
|
||||||
if (protectedIds.length > 0) {
|
|
||||||
if (allProtected) {
|
|
||||||
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 (deletableIds.length > 0) {
|
|
||||||
collaborativeBatchRemoveBlocks(deletableIds)
|
|
||||||
}
|
|
||||||
}, [contextMenuBlocks, collaborativeBatchRemoveBlocks, addNotification, activeWorkflowId, blocks])
|
|
||||||
|
|
||||||
const handleContextToggleEnabled = useCallback(() => {
|
const handleContextToggleEnabled = useCallback(() => {
|
||||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
const blockIds = contextMenuBlocks.map((block) => block.id)
|
||||||
@@ -1105,11 +1082,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
collaborativeBatchToggleBlockHandles(blockIds)
|
collaborativeBatchToggleBlockHandles(blockIds)
|
||||||
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
}, [contextMenuBlocks, collaborativeBatchToggleBlockHandles])
|
||||||
|
|
||||||
const handleContextToggleLocked = useCallback(() => {
|
|
||||||
const blockIds = contextMenuBlocks.map((block) => block.id)
|
|
||||||
collaborativeBatchToggleLocked(blockIds)
|
|
||||||
}, [contextMenuBlocks, collaborativeBatchToggleLocked])
|
|
||||||
|
|
||||||
const handleContextRemoveFromSubflow = useCallback(() => {
|
const handleContextRemoveFromSubflow = useCallback(() => {
|
||||||
const blocksToRemove = contextMenuBlocks.filter(
|
const blocksToRemove = contextMenuBlocks.filter(
|
||||||
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
(block) => block.parentId && (block.parentType === 'loop' || block.parentType === 'parallel')
|
||||||
@@ -1979,6 +1951,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const loadingWorkflowRef = useRef<string | null>(null)
|
const loadingWorkflowRef = useRef<string | null>(null)
|
||||||
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
|
const currentWorkflowExists = Boolean(workflows[workflowIdParam])
|
||||||
|
|
||||||
|
/** Initializes workflow when it exists in registry and needs hydration. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentId = workflowIdParam
|
const currentId = workflowIdParam
|
||||||
const currentWorkspaceHydration = hydration.workspaceId
|
const currentWorkspaceHydration = hydration.workspaceId
|
||||||
@@ -2155,7 +2128,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
parentId: block.data?.parentId,
|
parentId: block.data?.parentId,
|
||||||
extent: block.data?.extent || undefined,
|
extent: block.data?.extent || undefined,
|
||||||
dragHandle: '.workflow-drag-handle',
|
dragHandle: '.workflow-drag-handle',
|
||||||
draggable: !isBlockProtected(block.id, blocks),
|
|
||||||
data: {
|
data: {
|
||||||
...block.data,
|
...block.data,
|
||||||
name: block.name,
|
name: block.name,
|
||||||
@@ -2191,7 +2163,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
position,
|
position,
|
||||||
parentId: block.data?.parentId,
|
parentId: block.data?.parentId,
|
||||||
dragHandle,
|
dragHandle,
|
||||||
draggable: !isBlockProtected(block.id, blocks),
|
|
||||||
extent: (() => {
|
extent: (() => {
|
||||||
// Clamp children to subflow body (exclude header)
|
// Clamp children to subflow body (exclude header)
|
||||||
const parentId = block.data?.parentId as string | undefined
|
const parentId = block.data?.parentId as string | undefined
|
||||||
@@ -2520,18 +2491,12 @@ const WorkflowContent = React.memo(() => {
|
|||||||
const edgeIdsToRemove = changes
|
const edgeIdsToRemove = changes
|
||||||
.filter((change: any) => change.type === 'remove')
|
.filter((change: any) => change.type === 'remove')
|
||||||
.map((change: any) => change.id)
|
.map((change: any) => change.id)
|
||||||
.filter((edgeId: string) => {
|
|
||||||
// Prevent removing edges connected to protected blocks
|
|
||||||
const edge = edges.find((e) => e.id === edgeId)
|
|
||||||
if (!edge) return true
|
|
||||||
return !isEdgeProtected(edge, blocks)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (edgeIdsToRemove.length > 0) {
|
if (edgeIdsToRemove.length > 0) {
|
||||||
collaborativeBatchRemoveEdges(edgeIdsToRemove)
|
collaborativeBatchRemoveEdges(edgeIdsToRemove)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[collaborativeBatchRemoveEdges, edges, blocks]
|
[collaborativeBatchRemoveEdges]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2593,16 +2558,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
if (!sourceNode || !targetNode) return
|
if (!sourceNode || !targetNode) return
|
||||||
|
|
||||||
// Prevent connections to/from protected blocks
|
|
||||||
if (isEdgeProtected(connection, blocks)) {
|
|
||||||
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)
|
// Get parent information (handle container start node case)
|
||||||
const sourceParentId =
|
const sourceParentId =
|
||||||
blocks[sourceNode.id]?.data?.parentId ||
|
blocks[sourceNode.id]?.data?.parentId ||
|
||||||
@@ -2665,7 +2620,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
connectionCompletedRef.current = true
|
connectionCompletedRef.current = true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[addEdge, getNodes, blocks, addNotification, activeWorkflowId]
|
[addEdge, getNodes, blocks]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2760,9 +2715,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
// Only consider container nodes that aren't the dragged node
|
// Only consider container nodes that aren't the dragged node
|
||||||
if (n.type !== 'subflowNode' || n.id === node.id) return false
|
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
|
// Get the container's absolute position
|
||||||
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
const containerAbsolutePos = getNodeAbsolutePosition(n.id)
|
||||||
|
|
||||||
@@ -2855,11 +2807,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
/** Captures initial parent ID and position when drag starts. */
|
/** Captures initial parent ID and position when drag starts. */
|
||||||
const onNodeDragStart = useCallback(
|
const onNodeDragStart = useCallback(
|
||||||
(_event: React.MouseEvent, node: any) => {
|
(_event: React.MouseEvent, node: any) => {
|
||||||
// Prevent dragging protected blocks
|
|
||||||
if (isBlockProtected(node.id, blocks)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the original parent ID when starting to drag
|
// Store the original parent ID when starting to drag
|
||||||
const currentParentId = blocks[node.id]?.data?.parentId || null
|
const currentParentId = blocks[node.id]?.data?.parentId || null
|
||||||
setDragStartParentId(currentParentId)
|
setDragStartParentId(currentParentId)
|
||||||
@@ -2888,7 +2835,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[blocks, setDragStartPosition, getNodes, setPotentialParentId]
|
[blocks, setDragStartPosition, getNodes, potentialParentId, setPotentialParentId]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Handles node drag stop to establish parent-child relationships. */
|
/** Handles node drag stop to establish parent-child relationships. */
|
||||||
@@ -2950,18 +2897,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
|
// Don't process parent changes if the node hasn't actually changed parent or is being moved within same parent
|
||||||
if (potentialParentId === dragStartParentId) return
|
if (potentialParentId === dragStartParentId) return
|
||||||
|
|
||||||
// Prevent moving locked blocks out of locked containers
|
|
||||||
// Unlocked blocks (e.g., duplicates) can be moved out freely
|
|
||||||
if (dragStartParentId && blocks[dragStartParentId]?.locked && blocks[node.id]?.locked) {
|
|
||||||
addNotification({
|
|
||||||
level: 'info',
|
|
||||||
message: 'Cannot move locked 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
|
// Check if this is a starter block - starter blocks should never be in containers
|
||||||
const isStarterBlock = node.data?.type === 'starter'
|
const isStarterBlock = node.data?.type === 'starter'
|
||||||
if (isStarterBlock) {
|
if (isStarterBlock) {
|
||||||
@@ -3358,16 +3293,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
/** Stable delete handler to avoid creating new function references per edge. */
|
/** Stable delete handler to avoid creating new function references per edge. */
|
||||||
const handleEdgeDelete = useCallback(
|
const handleEdgeDelete = useCallback(
|
||||||
(edgeId: string) => {
|
(edgeId: string) => {
|
||||||
// Prevent removing edges connected to protected blocks
|
|
||||||
const edge = edges.find((e) => e.id === edgeId)
|
|
||||||
if (edge && isEdgeProtected(edge, blocks)) {
|
|
||||||
addNotification({
|
|
||||||
level: 'info',
|
|
||||||
message: 'Cannot remove connections from locked blocks',
|
|
||||||
workflowId: activeWorkflowId || undefined,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
removeEdge(edgeId)
|
removeEdge(edgeId)
|
||||||
// Remove this edge from selection (find by edge ID value)
|
// Remove this edge from selection (find by edge ID value)
|
||||||
setSelectedEdges((prev) => {
|
setSelectedEdges((prev) => {
|
||||||
@@ -3380,7 +3305,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[removeEdge, edges, blocks, addNotification, activeWorkflowId]
|
[removeEdge]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
|
/** Transforms edges to include selection state and delete handlers. Memoized to prevent re-renders. */
|
||||||
@@ -3421,15 +3346,9 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
// Handle edge deletion first (edges take priority if selected)
|
// Handle edge deletion first (edges take priority if selected)
|
||||||
if (selectedEdges.size > 0) {
|
if (selectedEdges.size > 0) {
|
||||||
// Get all selected edge IDs and filter out edges connected to protected blocks
|
// Get all selected edge IDs and batch delete them
|
||||||
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
|
const edgeIds = Array.from(selectedEdges.values())
|
||||||
const edge = edges.find((e) => e.id === edgeId)
|
collaborativeBatchRemoveEdges(edgeIds)
|
||||||
if (!edge) return true
|
|
||||||
return !isEdgeProtected(edge, blocks)
|
|
||||||
})
|
|
||||||
if (edgeIds.length > 0) {
|
|
||||||
collaborativeBatchRemoveEdges(edgeIds)
|
|
||||||
}
|
|
||||||
setSelectedEdges(new Map())
|
setSelectedEdges(new Map())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -3446,29 +3365,7 @@ const WorkflowContent = React.memo(() => {
|
|||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const selectedIds = selectedNodes.map((node) => node.id)
|
const selectedIds = selectedNodes.map((node) => node.id)
|
||||||
const { deletableIds, protectedIds, allProtected } = filterProtectedBlocks(
|
collaborativeBatchRemoveBlocks(selectedIds)
|
||||||
selectedIds,
|
|
||||||
blocks
|
|
||||||
)
|
|
||||||
|
|
||||||
if (protectedIds.length > 0) {
|
|
||||||
if (allProtected) {
|
|
||||||
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 (deletableIds.length > 0) {
|
|
||||||
collaborativeBatchRemoveBlocks(deletableIds)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
@@ -3479,10 +3376,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
getNodes,
|
getNodes,
|
||||||
collaborativeBatchRemoveBlocks,
|
collaborativeBatchRemoveBlocks,
|
||||||
effectivePermissions.canEdit,
|
effectivePermissions.canEdit,
|
||||||
blocks,
|
|
||||||
edges,
|
|
||||||
addNotification,
|
|
||||||
activeWorkflowId,
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -3603,18 +3496,12 @@ const WorkflowContent = React.memo(() => {
|
|||||||
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
(b) => b.parentId && (b.parentType === 'loop' || b.parentType === 'parallel')
|
||||||
)}
|
)}
|
||||||
canRunFromBlock={runFromBlockState.canRun}
|
canRunFromBlock={runFromBlockState.canRun}
|
||||||
disableEdit={
|
disableEdit={!effectivePermissions.canEdit}
|
||||||
!effectivePermissions.canEdit ||
|
|
||||||
contextMenuBlocks.some((b) => b.locked || b.isParentLocked)
|
|
||||||
}
|
|
||||||
userCanEdit={effectivePermissions.canEdit}
|
|
||||||
isExecuting={isExecuting}
|
isExecuting={isExecuting}
|
||||||
isPositionalTrigger={
|
isPositionalTrigger={
|
||||||
contextMenuBlocks.length === 1 &&
|
contextMenuBlocks.length === 1 &&
|
||||||
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
|
edges.filter((e) => e.target === contextMenuBlocks[0]?.id).length === 0
|
||||||
}
|
}
|
||||||
onToggleLocked={handleContextToggleLocked}
|
|
||||||
canAdmin={effectivePermissions.canAdmin}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CanvasMenu
|
<CanvasMenu
|
||||||
@@ -3637,7 +3524,6 @@ const WorkflowContent = React.memo(() => {
|
|||||||
disableEdit={!effectivePermissions.canEdit}
|
disableEdit={!effectivePermissions.canEdit}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
canRedo={canRedo}
|
canRedo={canRedo}
|
||||||
hasLockedBlocks={Object.values(blocks).some((b) => b.locked)}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { SlackMonoIcon } from '@/components/icons'
|
|||||||
import type { PlanFeature } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card'
|
import type { PlanFeature } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/components/plan-card'
|
||||||
|
|
||||||
export const PRO_PLAN_FEATURES: PlanFeature[] = [
|
export const PRO_PLAN_FEATURES: PlanFeature[] = [
|
||||||
{ icon: Zap, text: '150 runs per minute (sync)' },
|
{ icon: Zap, text: '25 runs per minute (sync)' },
|
||||||
{ icon: Clock, text: '1,000 runs per minute (async)' },
|
{ icon: Clock, text: '200 runs per minute (async)' },
|
||||||
{ icon: HardDrive, text: '50GB file storage' },
|
{ icon: HardDrive, text: '50GB file storage' },
|
||||||
{ icon: Building2, text: 'Unlimited workspaces' },
|
{ icon: Building2, text: 'Unlimited workspaces' },
|
||||||
{ icon: Users, text: 'Unlimited invites' },
|
{ icon: Users, text: 'Unlimited invites' },
|
||||||
@@ -22,8 +22,8 @@ export const PRO_PLAN_FEATURES: PlanFeature[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const TEAM_PLAN_FEATURES: PlanFeature[] = [
|
export const TEAM_PLAN_FEATURES: PlanFeature[] = [
|
||||||
{ icon: Zap, text: '300 runs per minute (sync)' },
|
{ icon: Zap, text: '75 runs per minute (sync)' },
|
||||||
{ icon: Clock, text: '2,500 runs per minute (async)' },
|
{ icon: Clock, text: '500 runs per minute (async)' },
|
||||||
{ icon: HardDrive, text: '500GB file storage (pooled)' },
|
{ icon: HardDrive, text: '500GB file storage (pooled)' },
|
||||||
{ icon: Building2, text: 'Unlimited workspaces' },
|
{ icon: Building2, text: 'Unlimited workspaces' },
|
||||||
{ icon: Users, text: 'Unlimited invites' },
|
{ icon: Users, text: 'Unlimited invites' },
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ interface SocketContextType {
|
|||||||
socket: Socket | null
|
socket: Socket | null
|
||||||
isConnected: boolean
|
isConnected: boolean
|
||||||
isConnecting: boolean
|
isConnecting: boolean
|
||||||
isReconnecting: boolean
|
|
||||||
authFailed: boolean
|
authFailed: boolean
|
||||||
currentWorkflowId: string | null
|
currentWorkflowId: string | null
|
||||||
currentSocketId: string | null
|
currentSocketId: string | null
|
||||||
@@ -67,16 +66,9 @@ interface SocketContextType {
|
|||||||
blockId: string,
|
blockId: string,
|
||||||
subblockId: string,
|
subblockId: string,
|
||||||
value: any,
|
value: any,
|
||||||
operationId: string | undefined,
|
operationId?: string
|
||||||
workflowId: string
|
|
||||||
) => void
|
|
||||||
emitVariableUpdate: (
|
|
||||||
variableId: string,
|
|
||||||
field: string,
|
|
||||||
value: any,
|
|
||||||
operationId: string | undefined,
|
|
||||||
workflowId: string
|
|
||||||
) => void
|
) => void
|
||||||
|
emitVariableUpdate: (variableId: string, field: string, value: any, operationId?: string) => void
|
||||||
|
|
||||||
emitCursorUpdate: (cursor: { x: number; y: number } | null) => void
|
emitCursorUpdate: (cursor: { x: number; y: number } | null) => void
|
||||||
emitSelectionUpdate: (selection: { type: 'block' | 'edge' | 'none'; id?: string }) => void
|
emitSelectionUpdate: (selection: { type: 'block' | 'edge' | 'none'; id?: string }) => void
|
||||||
@@ -96,7 +88,6 @@ const SocketContext = createContext<SocketContextType>({
|
|||||||
socket: null,
|
socket: null,
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
isConnecting: false,
|
isConnecting: false,
|
||||||
isReconnecting: false,
|
|
||||||
authFailed: false,
|
authFailed: false,
|
||||||
currentWorkflowId: null,
|
currentWorkflowId: null,
|
||||||
currentSocketId: null,
|
currentSocketId: null,
|
||||||
@@ -131,7 +122,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
const [socket, setSocket] = useState<Socket | null>(null)
|
const [socket, setSocket] = useState<Socket | null>(null)
|
||||||
const [isConnected, setIsConnected] = useState(false)
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
const [isConnecting, setIsConnecting] = useState(false)
|
const [isConnecting, setIsConnecting] = useState(false)
|
||||||
const [isReconnecting, setIsReconnecting] = useState(false)
|
|
||||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null)
|
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null)
|
||||||
const [currentSocketId, setCurrentSocketId] = useState<string | null>(null)
|
const [currentSocketId, setCurrentSocketId] = useState<string | null>(null)
|
||||||
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
const [presenceUsers, setPresenceUsers] = useState<PresenceUser[]>([])
|
||||||
@@ -246,19 +236,20 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
setCurrentWorkflowId(null)
|
setCurrentWorkflowId(null)
|
||||||
setPresenceUsers([])
|
setPresenceUsers([])
|
||||||
|
|
||||||
// socket.active indicates if auto-reconnect will happen
|
logger.info('Socket disconnected', {
|
||||||
if (socketInstance.active) {
|
reason,
|
||||||
setIsReconnecting(true)
|
})
|
||||||
logger.info('Socket disconnected, will auto-reconnect', { reason })
|
|
||||||
} else {
|
|
||||||
setIsReconnecting(false)
|
|
||||||
logger.info('Socket disconnected, no auto-reconnect', { reason })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
socketInstance.on('connect_error', (error: Error) => {
|
socketInstance.on('connect_error', (error: any) => {
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
logger.error('Socket connection error:', { message: error.message })
|
logger.error('Socket connection error:', {
|
||||||
|
message: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
description: error.description,
|
||||||
|
type: error.type,
|
||||||
|
transport: error.transport,
|
||||||
|
})
|
||||||
|
|
||||||
// Check if this is an authentication failure
|
// Check if this is an authentication failure
|
||||||
const isAuthError =
|
const isAuthError =
|
||||||
@@ -270,41 +261,43 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
logger.warn(
|
logger.warn(
|
||||||
'Authentication failed - stopping reconnection attempts. User may need to refresh/re-login.'
|
'Authentication failed - stopping reconnection attempts. User may need to refresh/re-login.'
|
||||||
)
|
)
|
||||||
|
// Stop reconnection attempts to prevent infinite loop
|
||||||
socketInstance.disconnect()
|
socketInstance.disconnect()
|
||||||
|
// Reset state to allow re-initialization when session is restored
|
||||||
setSocket(null)
|
setSocket(null)
|
||||||
setAuthFailed(true)
|
setAuthFailed(true)
|
||||||
setIsReconnecting(false)
|
|
||||||
initializedRef.current = false
|
initializedRef.current = false
|
||||||
} else if (socketInstance.active) {
|
|
||||||
// Temporary failure, will auto-reconnect
|
|
||||||
setIsReconnecting(true)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Reconnection events are on the Manager (socket.io), not the socket itself
|
socketInstance.on('reconnect', (attemptNumber) => {
|
||||||
socketInstance.io.on('reconnect', (attemptNumber) => {
|
|
||||||
setIsConnected(true)
|
setIsConnected(true)
|
||||||
setIsReconnecting(false)
|
|
||||||
setCurrentSocketId(socketInstance.id ?? null)
|
setCurrentSocketId(socketInstance.id ?? null)
|
||||||
logger.info('Socket reconnected successfully', {
|
logger.info('Socket reconnected successfully', {
|
||||||
attemptNumber,
|
attemptNumber,
|
||||||
socketId: socketInstance.id,
|
socketId: socketInstance.id,
|
||||||
transport: socketInstance.io.engine?.transport?.name,
|
transport: socketInstance.io.engine?.transport?.name,
|
||||||
})
|
})
|
||||||
|
// Note: join-workflow is handled by the useEffect watching isConnected
|
||||||
})
|
})
|
||||||
|
|
||||||
socketInstance.io.on('reconnect_attempt', (attemptNumber) => {
|
socketInstance.on('reconnect_attempt', (attemptNumber) => {
|
||||||
setIsReconnecting(true)
|
logger.info('Socket reconnection attempt (fresh token will be generated)', {
|
||||||
logger.info('Socket reconnection attempt', { attemptNumber })
|
attemptNumber,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
socketInstance.io.on('reconnect_error', (error: Error) => {
|
socketInstance.on('reconnect_error', (error: any) => {
|
||||||
logger.error('Socket reconnection error:', { message: error.message })
|
logger.error('Socket reconnection error:', {
|
||||||
|
message: error.message,
|
||||||
|
attemptNumber: error.attemptNumber,
|
||||||
|
type: error.type,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
socketInstance.io.on('reconnect_failed', () => {
|
socketInstance.on('reconnect_failed', () => {
|
||||||
logger.error('Socket reconnection failed - all attempts exhausted')
|
logger.error('Socket reconnection failed - all attempts exhausted')
|
||||||
setIsReconnecting(false)
|
|
||||||
setIsConnecting(false)
|
setIsConnecting(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -636,7 +629,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
|
|
||||||
if (commit) {
|
if (commit) {
|
||||||
socket.emit('workflow-operation', {
|
socket.emit('workflow-operation', {
|
||||||
workflowId: currentWorkflowId,
|
|
||||||
operation,
|
operation,
|
||||||
target,
|
target,
|
||||||
payload,
|
payload,
|
||||||
@@ -653,7 +645,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingPositionUpdates.current.set(blockId, {
|
pendingPositionUpdates.current.set(blockId, {
|
||||||
workflowId: currentWorkflowId,
|
|
||||||
operation,
|
operation,
|
||||||
target,
|
target,
|
||||||
payload,
|
payload,
|
||||||
@@ -675,7 +666,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
socket.emit('workflow-operation', {
|
socket.emit('workflow-operation', {
|
||||||
workflowId: currentWorkflowId,
|
|
||||||
operation,
|
operation,
|
||||||
target,
|
target,
|
||||||
payload,
|
payload,
|
||||||
@@ -688,51 +678,47 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const emitSubblockUpdate = useCallback(
|
const emitSubblockUpdate = useCallback(
|
||||||
(
|
(blockId: string, subblockId: string, value: any, operationId?: string) => {
|
||||||
blockId: string,
|
if (socket && currentWorkflowId) {
|
||||||
subblockId: string,
|
socket.emit('subblock-update', {
|
||||||
value: any,
|
blockId,
|
||||||
operationId: string | undefined,
|
subblockId,
|
||||||
workflowId: string
|
value,
|
||||||
) => {
|
timestamp: Date.now(),
|
||||||
if (!socket) {
|
operationId,
|
||||||
logger.warn('Cannot emit subblock update: no socket connection', { workflowId, blockId })
|
})
|
||||||
return
|
} else {
|
||||||
|
logger.warn('Cannot emit subblock update: no socket connection or workflow room', {
|
||||||
|
hasSocket: !!socket,
|
||||||
|
currentWorkflowId,
|
||||||
|
blockId,
|
||||||
|
subblockId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
socket.emit('subblock-update', {
|
|
||||||
workflowId,
|
|
||||||
blockId,
|
|
||||||
subblockId,
|
|
||||||
value,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
operationId,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[socket]
|
[socket, currentWorkflowId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const emitVariableUpdate = useCallback(
|
const emitVariableUpdate = useCallback(
|
||||||
(
|
(variableId: string, field: string, value: any, operationId?: string) => {
|
||||||
variableId: string,
|
if (socket && currentWorkflowId) {
|
||||||
field: string,
|
socket.emit('variable-update', {
|
||||||
value: any,
|
variableId,
|
||||||
operationId: string | undefined,
|
field,
|
||||||
workflowId: string
|
value,
|
||||||
) => {
|
timestamp: Date.now(),
|
||||||
if (!socket) {
|
operationId,
|
||||||
logger.warn('Cannot emit variable update: no socket connection', { workflowId, variableId })
|
})
|
||||||
return
|
} else {
|
||||||
|
logger.warn('Cannot emit variable update: no socket connection or workflow room', {
|
||||||
|
hasSocket: !!socket,
|
||||||
|
currentWorkflowId,
|
||||||
|
variableId,
|
||||||
|
field,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
socket.emit('variable-update', {
|
|
||||||
workflowId,
|
|
||||||
variableId,
|
|
||||||
field,
|
|
||||||
value,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
operationId,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[socket]
|
[socket, currentWorkflowId]
|
||||||
)
|
)
|
||||||
|
|
||||||
const lastCursorEmit = useRef(0)
|
const lastCursorEmit = useRef(0)
|
||||||
@@ -808,7 +794,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
socket,
|
socket,
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
isReconnecting,
|
|
||||||
authFailed,
|
authFailed,
|
||||||
currentWorkflowId,
|
currentWorkflowId,
|
||||||
currentSocketId,
|
currentSocketId,
|
||||||
@@ -835,7 +820,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) {
|
|||||||
socket,
|
socket,
|
||||||
isConnected,
|
isConnected,
|
||||||
isConnecting,
|
isConnecting,
|
||||||
isReconnecting,
|
|
||||||
authFailed,
|
authFailed,
|
||||||
currentWorkflowId,
|
currentWorkflowId,
|
||||||
currentSocketId,
|
currentSocketId,
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ interface FreeTierUpgradeEmailProps {
|
|||||||
|
|
||||||
const proFeatures = [
|
const proFeatures = [
|
||||||
{ label: '$20/month', desc: 'in credits included' },
|
{ label: '$20/month', desc: 'in credits included' },
|
||||||
{ label: '150 runs/min', desc: 'sync executions' },
|
{ label: '25 runs/min', desc: 'sync executions' },
|
||||||
{ label: '1,000 runs/min', desc: 'async executions' },
|
{ label: '200 runs/min', desc: 'async executions' },
|
||||||
{ label: '50GB storage', desc: 'for files & assets' },
|
{ label: '50GB storage', desc: 'for files & assets' },
|
||||||
{ label: 'Unlimited', desc: 'workspaces & invites' },
|
{ label: 'Unlimited', desc: 'workspaces & invites' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -458,8 +458,8 @@ export function getCodeEditorProps(options?: {
|
|||||||
'caret-[var(--text-primary)] dark:caret-white',
|
'caret-[var(--text-primary)] dark:caret-white',
|
||||||
// Font smoothing
|
// Font smoothing
|
||||||
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
|
'[-webkit-font-smoothing:antialiased] [-moz-osx-font-smoothing:grayscale]',
|
||||||
// Disable interaction for streaming/preview/disabled
|
// Disable interaction for streaming/preview
|
||||||
(isStreaming || isPreview || disabled) && 'pointer-events-none'
|
(isStreaming || isPreview) && 'pointer-events-none'
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,11 +212,11 @@ export class WorkflowBlockHandler implements BlockHandler {
|
|||||||
/**
|
/**
|
||||||
* Parses a potentially nested workflow error message to extract:
|
* Parses a potentially nested workflow error message to extract:
|
||||||
* - The chain of workflow names
|
* - The chain of workflow names
|
||||||
* - The actual root error message (preserving the block name prefix for the failing block)
|
* - The actual root error message (preserving the block prefix for the failing block)
|
||||||
*
|
*
|
||||||
* Handles formats like:
|
* Handles formats like:
|
||||||
* - "workflow-name" failed: error
|
* - "workflow-name" failed: error
|
||||||
* - Block Name: "workflow-name" failed: error
|
* - [block_type] Block Name: "workflow-name" failed: error
|
||||||
* - Workflow chain: A → B | error
|
* - Workflow chain: A → B | error
|
||||||
*/
|
*/
|
||||||
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
|
private parseNestedWorkflowError(message: string): { chain: string[]; rootError: string } {
|
||||||
@@ -234,8 +234,8 @@ export class WorkflowBlockHandler implements BlockHandler {
|
|||||||
|
|
||||||
// Extract workflow names from patterns like:
|
// Extract workflow names from patterns like:
|
||||||
// - "workflow-name" failed:
|
// - "workflow-name" failed:
|
||||||
// - Block Name: "workflow-name" failed:
|
// - [block_type] Block Name: "workflow-name" failed:
|
||||||
const workflowPattern = /(?:\[[^\]]+\]\s*)?(?:[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
|
const workflowPattern = /(?:\[[^\]]+\]\s*[^:]+:\s*)?"([^"]+)"\s*failed:\s*/g
|
||||||
let match: RegExpExecArray | null
|
let match: RegExpExecArray | null
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ export class WorkflowBlockHandler implements BlockHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The root error is everything after the last match
|
// The root error is everything after the last match
|
||||||
// Keep the block name prefix (e.g., Function 1:) so we know which block failed
|
// Keep the block prefix (e.g., [function] Function 1:) so we know which block failed
|
||||||
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
|
const rootError = lastIndex > 0 ? remaining.slice(lastIndex) : remaining
|
||||||
|
|
||||||
return { chain, rootError: rootError.trim() || 'Unknown error' }
|
return { chain, rootError: rootError.trim() || 'Unknown error' }
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function buildBlockExecutionError(details: BlockExecutionErrorDetails): E
|
|||||||
const blockName = details.block.metadata?.name || details.block.id
|
const blockName = details.block.metadata?.name || details.block.id
|
||||||
const blockType = details.block.metadata?.id || 'unknown'
|
const blockType = details.block.metadata?.id || 'unknown'
|
||||||
|
|
||||||
const error = new Error(`${blockName}: ${errorMessage}`)
|
const error = new Error(`[${blockType}] ${blockName}: ${errorMessage}`)
|
||||||
|
|
||||||
Object.assign(error, {
|
Object.assign(error, {
|
||||||
blockId: details.block.id,
|
blockId: details.block.id,
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ export function useCollaborativeWorkflow() {
|
|||||||
cancelOperationsForVariable,
|
cancelOperationsForVariable,
|
||||||
} = useOperationQueue()
|
} = useOperationQueue()
|
||||||
|
|
||||||
|
const isInActiveRoom = useCallback(() => {
|
||||||
|
return !!currentWorkflowId && activeWorkflowId === currentWorkflowId
|
||||||
|
}, [currentWorkflowId, activeWorkflowId])
|
||||||
|
|
||||||
// Register emit functions with operation queue store
|
// Register emit functions with operation queue store
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
registerEmitFunctions(
|
registerEmitFunctions(
|
||||||
@@ -158,19 +162,10 @@ export function useCollaborativeWorkflow() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWorkflowOperation = (data: any) => {
|
const handleWorkflowOperation = (data: any) => {
|
||||||
const { operation, target, payload, userId, metadata } = data
|
const { operation, target, payload, userId } = data
|
||||||
|
|
||||||
if (isApplyingRemoteChange.current) return
|
if (isApplyingRemoteChange.current) return
|
||||||
|
|
||||||
// Filter broadcasts by workflowId to prevent cross-workflow updates
|
|
||||||
if (metadata?.workflowId && metadata.workflowId !== activeWorkflowId) {
|
|
||||||
logger.debug('Ignoring workflow operation for different workflow', {
|
|
||||||
broadcastWorkflowId: metadata.workflowId,
|
|
||||||
activeWorkflowId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Received ${operation} on ${target} from user ${userId}`)
|
logger.info(`Received ${operation} on ${target} from user ${userId}`)
|
||||||
|
|
||||||
// Apply the operation to local state
|
// Apply the operation to local state
|
||||||
@@ -409,20 +404,6 @@ export function useCollaborativeWorkflow() {
|
|||||||
logger.info('Successfully applied batch-toggle-handles from remote user')
|
logger.info('Successfully applied batch-toggle-handles from remote user')
|
||||||
break
|
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: {
|
case BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT: {
|
||||||
const { updates } = payload
|
const { updates } = payload
|
||||||
logger.info('Received batch-update-parent from remote user', {
|
logger.info('Received batch-update-parent from remote user', {
|
||||||
@@ -455,24 +436,16 @@ export function useCollaborativeWorkflow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubblockUpdate = (data: any) => {
|
const handleSubblockUpdate = (data: any) => {
|
||||||
const { workflowId, blockId, subblockId, value, userId } = data
|
const { blockId, subblockId, value, userId } = data
|
||||||
|
|
||||||
if (isApplyingRemoteChange.current) return
|
if (isApplyingRemoteChange.current) return
|
||||||
|
|
||||||
// Filter broadcasts by workflowId to prevent cross-workflow updates
|
|
||||||
if (workflowId && workflowId !== activeWorkflowId) {
|
|
||||||
logger.debug('Ignoring subblock update for different workflow', {
|
|
||||||
broadcastWorkflowId: workflowId,
|
|
||||||
activeWorkflowId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Received subblock update from user ${userId}: ${blockId}.${subblockId}`)
|
logger.info(`Received subblock update from user ${userId}: ${blockId}.${subblockId}`)
|
||||||
|
|
||||||
isApplyingRemoteChange.current = true
|
isApplyingRemoteChange.current = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// The setValue function automatically uses the active workflow ID
|
||||||
useSubBlockStore.getState().setValue(blockId, subblockId, value)
|
useSubBlockStore.getState().setValue(blockId, subblockId, value)
|
||||||
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
|
const blockType = useWorkflowStore.getState().blocks?.[blockId]?.type
|
||||||
if (activeWorkflowId && blockType === 'function' && subblockId === 'code') {
|
if (activeWorkflowId && blockType === 'function' && subblockId === 'code') {
|
||||||
@@ -486,19 +459,10 @@ export function useCollaborativeWorkflow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleVariableUpdate = (data: any) => {
|
const handleVariableUpdate = (data: any) => {
|
||||||
const { workflowId, variableId, field, value, userId } = data
|
const { variableId, field, value, userId } = data
|
||||||
|
|
||||||
if (isApplyingRemoteChange.current) return
|
if (isApplyingRemoteChange.current) return
|
||||||
|
|
||||||
// Filter broadcasts by workflowId to prevent cross-workflow updates
|
|
||||||
if (workflowId && workflowId !== activeWorkflowId) {
|
|
||||||
logger.debug('Ignoring variable update for different workflow', {
|
|
||||||
broadcastWorkflowId: workflowId,
|
|
||||||
activeWorkflowId,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Received variable update from user ${userId}: ${variableId}.${field}`)
|
logger.info(`Received variable update from user ${userId}: ${variableId}.${field}`)
|
||||||
|
|
||||||
isApplyingRemoteChange.current = true
|
isApplyingRemoteChange.current = true
|
||||||
@@ -659,9 +623,13 @@ export function useCollaborativeWorkflow() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue operations if we have an active workflow - queue handles socket readiness
|
if (!isInActiveRoom()) {
|
||||||
if (!activeWorkflowId) {
|
logger.debug('Skipping operation - not in active workflow', {
|
||||||
logger.debug('Skipping operation - no active workflow', { operation, target })
|
currentWorkflowId,
|
||||||
|
activeWorkflowId,
|
||||||
|
operation,
|
||||||
|
target,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,13 +642,20 @@ export function useCollaborativeWorkflow() {
|
|||||||
target,
|
target,
|
||||||
payload,
|
payload,
|
||||||
},
|
},
|
||||||
workflowId: activeWorkflowId,
|
workflowId: activeWorkflowId || '',
|
||||||
userId: session?.user?.id || 'unknown',
|
userId: session?.user?.id || 'unknown',
|
||||||
})
|
})
|
||||||
|
|
||||||
localAction()
|
localAction()
|
||||||
},
|
},
|
||||||
[addToQueue, session?.user?.id, isBaselineDiffView, activeWorkflowId]
|
[
|
||||||
|
addToQueue,
|
||||||
|
session?.user?.id,
|
||||||
|
isBaselineDiffView,
|
||||||
|
activeWorkflowId,
|
||||||
|
isInActiveRoom,
|
||||||
|
currentWorkflowId,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collaborativeBatchUpdatePositions = useCallback(
|
const collaborativeBatchUpdatePositions = useCallback(
|
||||||
@@ -694,8 +669,8 @@ export function useCollaborativeWorkflow() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeWorkflowId) {
|
if (!isInActiveRoom()) {
|
||||||
logger.debug('Skipping batch position update - no active workflow')
|
logger.debug('Skipping batch position update - not in active workflow')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,7 +714,7 @@ export function useCollaborativeWorkflow() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
|
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, undoRedo]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collaborativeUpdateBlockName = useCallback(
|
const collaborativeUpdateBlockName = useCallback(
|
||||||
@@ -837,27 +812,14 @@ export function useCollaborativeWorkflow() {
|
|||||||
|
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
|
|
||||||
const currentBlocks = useWorkflowStore.getState().blocks
|
|
||||||
const previousStates: Record<string, boolean> = {}
|
const previousStates: Record<string, boolean> = {}
|
||||||
const validIds: string[] = []
|
const validIds: string[] = []
|
||||||
|
|
||||||
// For each ID, collect non-locked blocks and their children for undo/redo
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const block = currentBlocks[id]
|
const block = useWorkflowStore.getState().blocks[id]
|
||||||
if (!block) continue
|
if (block) {
|
||||||
|
previousStates[id] = block.enabled
|
||||||
// Skip locked blocks
|
validIds.push(id)
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -896,8 +858,8 @@ export function useCollaborativeWorkflow() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeWorkflowId) {
|
if (!isInActiveRoom()) {
|
||||||
logger.debug('Skipping batch update parent - no active workflow')
|
logger.debug('Skipping batch update parent - not in active workflow')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -966,7 +928,7 @@ export function useCollaborativeWorkflow() {
|
|||||||
|
|
||||||
logger.debug('Batch updated parent for blocks', { updateCount: updates.length })
|
logger.debug('Batch updated parent for blocks', { updateCount: updates.length })
|
||||||
},
|
},
|
||||||
[isBaselineDiffView, undoRedo, addToQueue, activeWorkflowId, session?.user?.id]
|
[isBaselineDiffView, isInActiveRoom, undoRedo, addToQueue, activeWorkflowId, session?.user?.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collaborativeToggleBlockAdvancedMode = useCallback(
|
const collaborativeToggleBlockAdvancedMode = useCallback(
|
||||||
@@ -1019,25 +981,12 @@ export function useCollaborativeWorkflow() {
|
|||||||
|
|
||||||
if (ids.length === 0) return
|
if (ids.length === 0) return
|
||||||
|
|
||||||
const blocks = useWorkflowStore.getState().blocks
|
|
||||||
|
|
||||||
// Helper to check if a block is protected (locked or inside locked parent)
|
|
||||||
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 previousStates: Record<string, boolean> = {}
|
||||||
const validIds: string[] = []
|
const validIds: string[] = []
|
||||||
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const block = blocks[id]
|
const block = useWorkflowStore.getState().blocks[id]
|
||||||
// Skip locked blocks and blocks inside locked containers
|
if (block) {
|
||||||
if (block && !isProtected(id)) {
|
|
||||||
previousStates[id] = block.horizontalHandles ?? false
|
previousStates[id] = block.horizontalHandles ?? false
|
||||||
validIds.push(id)
|
validIds.push(id)
|
||||||
}
|
}
|
||||||
@@ -1065,66 +1014,14 @@ export function useCollaborativeWorkflow() {
|
|||||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
|
[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(
|
const collaborativeBatchAddEdges = useCallback(
|
||||||
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
|
(edges: Edge[], options?: { skipUndoRedo?: boolean }) => {
|
||||||
if (isBaselineDiffView) {
|
if (isBaselineDiffView) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeWorkflowId) {
|
if (!isInActiveRoom()) {
|
||||||
logger.debug('Skipping batch add edges - no active workflow')
|
logger.debug('Skipping batch add edges - not in active workflow')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1158,7 +1055,7 @@ export function useCollaborativeWorkflow() {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, undoRedo]
|
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id, isInActiveRoom, undoRedo]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collaborativeBatchRemoveEdges = useCallback(
|
const collaborativeBatchRemoveEdges = useCallback(
|
||||||
@@ -1167,8 +1064,8 @@ export function useCollaborativeWorkflow() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeWorkflowId) {
|
if (!isInActiveRoom()) {
|
||||||
logger.debug('Skipping batch remove edges - no active workflow')
|
logger.debug('Skipping batch remove edges - not in active workflow')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1216,7 +1113,7 @@ export function useCollaborativeWorkflow() {
|
|||||||
logger.info('Batch removed edges', { count: validEdgeIds.length })
|
logger.info('Batch removed edges', { count: validEdgeIds.length })
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session, undoRedo]
|
[isBaselineDiffView, isInActiveRoom, addToQueue, activeWorkflowId, session, undoRedo]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collaborativeSetSubblockValue = useCallback(
|
const collaborativeSetSubblockValue = useCallback(
|
||||||
@@ -1251,9 +1148,11 @@ export function useCollaborativeWorkflow() {
|
|||||||
// Best-effort; do not block on clearing
|
// Best-effort; do not block on clearing
|
||||||
}
|
}
|
||||||
|
|
||||||
// Queue socket operation if we have an active workflow
|
// Only emit to socket if in active room
|
||||||
if (!activeWorkflowId) {
|
if (!isInActiveRoom()) {
|
||||||
logger.debug('Local update applied, skipping socket queue - no active workflow', {
|
logger.debug('Local update applied, skipping socket emit - not in active workflow', {
|
||||||
|
currentWorkflowId,
|
||||||
|
activeWorkflowId,
|
||||||
blockId,
|
blockId,
|
||||||
subblockId,
|
subblockId,
|
||||||
})
|
})
|
||||||
@@ -1275,7 +1174,14 @@ export function useCollaborativeWorkflow() {
|
|||||||
userId: session?.user?.id || 'unknown',
|
userId: session?.user?.id || 'unknown',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[activeWorkflowId, addToQueue, session?.user?.id, isBaselineDiffView]
|
[
|
||||||
|
currentWorkflowId,
|
||||||
|
activeWorkflowId,
|
||||||
|
addToQueue,
|
||||||
|
session?.user?.id,
|
||||||
|
isBaselineDiffView,
|
||||||
|
isInActiveRoom,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Immediate tag selection (uses queue but processes immediately, no debouncing)
|
// Immediate tag selection (uses queue but processes immediately, no debouncing)
|
||||||
@@ -1287,8 +1193,13 @@ export function useCollaborativeWorkflow() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeWorkflowId) {
|
if (!isInActiveRoom()) {
|
||||||
logger.debug('Skipping tag selection - no active workflow', { blockId, subblockId })
|
logger.debug('Skipping tag selection - not in active workflow', {
|
||||||
|
currentWorkflowId,
|
||||||
|
activeWorkflowId,
|
||||||
|
blockId,
|
||||||
|
subblockId,
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1309,7 +1220,14 @@ export function useCollaborativeWorkflow() {
|
|||||||
userId: session?.user?.id || 'unknown',
|
userId: session?.user?.id || 'unknown',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[isBaselineDiffView, addToQueue, activeWorkflowId, session?.user?.id]
|
[
|
||||||
|
isBaselineDiffView,
|
||||||
|
addToQueue,
|
||||||
|
currentWorkflowId,
|
||||||
|
activeWorkflowId,
|
||||||
|
session?.user?.id,
|
||||||
|
isInActiveRoom,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collaborativeUpdateLoopType = useCallback(
|
const collaborativeUpdateLoopType = useCallback(
|
||||||
@@ -1596,8 +1514,8 @@ export function useCollaborativeWorkflow() {
|
|||||||
subBlockValues: Record<string, Record<string, unknown>> = {},
|
subBlockValues: Record<string, Record<string, unknown>> = {},
|
||||||
options?: { skipUndoRedo?: boolean }
|
options?: { skipUndoRedo?: boolean }
|
||||||
) => {
|
) => {
|
||||||
if (!activeWorkflowId) {
|
if (!isInActiveRoom()) {
|
||||||
logger.debug('Skipping batch add blocks - no active workflow')
|
logger.debug('Skipping batch add blocks - not in active workflow')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1650,7 +1568,7 @@ export function useCollaborativeWorkflow() {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
[addToQueue, activeWorkflowId, session?.user?.id, isBaselineDiffView, undoRedo]
|
[addToQueue, activeWorkflowId, session?.user?.id, isBaselineDiffView, isInActiveRoom, undoRedo]
|
||||||
)
|
)
|
||||||
|
|
||||||
const collaborativeBatchRemoveBlocks = useCallback(
|
const collaborativeBatchRemoveBlocks = useCallback(
|
||||||
@@ -1659,8 +1577,8 @@ export function useCollaborativeWorkflow() {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!activeWorkflowId) {
|
if (!isInActiveRoom()) {
|
||||||
logger.debug('Skipping batch remove blocks - no active workflow')
|
logger.debug('Skipping batch remove blocks - not in active workflow')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1744,6 +1662,7 @@ export function useCollaborativeWorkflow() {
|
|||||||
addToQueue,
|
addToQueue,
|
||||||
activeWorkflowId,
|
activeWorkflowId,
|
||||||
session?.user?.id,
|
session?.user?.id,
|
||||||
|
isInActiveRoom,
|
||||||
cancelOperationsForBlock,
|
cancelOperationsForBlock,
|
||||||
undoRedo,
|
undoRedo,
|
||||||
]
|
]
|
||||||
@@ -1761,7 +1680,6 @@ export function useCollaborativeWorkflow() {
|
|||||||
collaborativeToggleBlockAdvancedMode,
|
collaborativeToggleBlockAdvancedMode,
|
||||||
collaborativeSetBlockCanonicalMode,
|
collaborativeSetBlockCanonicalMode,
|
||||||
collaborativeBatchToggleBlockHandles,
|
collaborativeBatchToggleBlockHandles,
|
||||||
collaborativeBatchToggleLocked,
|
|
||||||
collaborativeBatchAddBlocks,
|
collaborativeBatchAddBlocks,
|
||||||
collaborativeBatchRemoveBlocks,
|
collaborativeBatchRemoveBlocks,
|
||||||
collaborativeBatchAddEdges,
|
collaborativeBatchAddEdges,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
type BatchRemoveEdgesOperation,
|
type BatchRemoveEdgesOperation,
|
||||||
type BatchToggleEnabledOperation,
|
type BatchToggleEnabledOperation,
|
||||||
type BatchToggleHandlesOperation,
|
type BatchToggleHandlesOperation,
|
||||||
type BatchToggleLockedOperation,
|
|
||||||
type BatchUpdateParentOperation,
|
type BatchUpdateParentOperation,
|
||||||
captureLatestEdges,
|
captureLatestEdges,
|
||||||
captureLatestSubBlockValues,
|
captureLatestSubBlockValues,
|
||||||
@@ -30,6 +29,7 @@ import {
|
|||||||
useUndoRedoStore,
|
useUndoRedoStore,
|
||||||
} from '@/stores/undo-redo'
|
} from '@/stores/undo-redo'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
import type { BlockState } from '@/stores/workflows/workflow/types'
|
import type { BlockState } from '@/stores/workflows/workflow/types'
|
||||||
|
|
||||||
@@ -416,36 +416,6 @@ export function useUndoRedo() {
|
|||||||
[activeWorkflowId, userId]
|
[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 () => {
|
const undo = useCallback(async () => {
|
||||||
if (!activeWorkflowId) return
|
if (!activeWorkflowId) return
|
||||||
|
|
||||||
@@ -534,9 +504,47 @@ export function useUndoRedo() {
|
|||||||
userId,
|
userId,
|
||||||
})
|
})
|
||||||
|
|
||||||
useWorkflowStore
|
blocksToAdd.forEach((block) => {
|
||||||
.getState()
|
useWorkflowStore
|
||||||
.batchAddBlocks(blocksToAdd, edgeSnapshots || [], subBlockValues || {})
|
.getState()
|
||||||
|
.addBlock(
|
||||||
|
block.id,
|
||||||
|
block.type,
|
||||||
|
block.name,
|
||||||
|
block.position,
|
||||||
|
block.data,
|
||||||
|
block.data?.parentId,
|
||||||
|
block.data?.extent,
|
||||||
|
{
|
||||||
|
enabled: block.enabled,
|
||||||
|
horizontalHandles: block.horizontalHandles,
|
||||||
|
advancedMode: block.advancedMode,
|
||||||
|
triggerMode: block.triggerMode,
|
||||||
|
height: block.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
|
||||||
|
useSubBlockStore.setState((state) => ({
|
||||||
|
workflowValues: {
|
||||||
|
...state.workflowValues,
|
||||||
|
[activeWorkflowId]: {
|
||||||
|
...state.workflowValues[activeWorkflowId],
|
||||||
|
...subBlockValues,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edgeSnapshots && edgeSnapshots.length > 0) {
|
||||||
|
const edgesToAdd = edgeSnapshots.filter(
|
||||||
|
(edge) => !useWorkflowStore.getState().edges.find((e) => e.id === edge.id)
|
||||||
|
)
|
||||||
|
if (edgesToAdd.length > 0) {
|
||||||
|
useWorkflowStore.getState().batchAddEdges(edgesToAdd)
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
|
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_EDGES: {
|
||||||
@@ -808,9 +816,7 @@ export function useUndoRedo() {
|
|||||||
const toggleOp = entry.inverse as BatchToggleEnabledOperation
|
const toggleOp = entry.inverse as BatchToggleEnabledOperation
|
||||||
const { blockIds, previousStates } = toggleOp.data
|
const { blockIds, previousStates } = toggleOp.data
|
||||||
|
|
||||||
// Restore all blocks in previousStates (includes children of containers)
|
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||||
const allBlockIds = Object.keys(previousStates)
|
|
||||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
|
||||||
if (validBlockIds.length === 0) {
|
if (validBlockIds.length === 0) {
|
||||||
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
|
logger.debug('Undo batch-toggle-enabled skipped; no blocks exist')
|
||||||
break
|
break
|
||||||
@@ -821,14 +827,14 @@ export function useUndoRedo() {
|
|||||||
operation: {
|
operation: {
|
||||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||||
target: OPERATION_TARGETS.BLOCKS,
|
target: OPERATION_TARGETS.BLOCKS,
|
||||||
payload: { blockIds, previousStates },
|
payload: { blockIds: validBlockIds, previousStates },
|
||||||
},
|
},
|
||||||
workflowId: activeWorkflowId,
|
workflowId: activeWorkflowId,
|
||||||
userId,
|
userId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use setBlockEnabled to directly restore to previous state
|
// Use setBlockEnabled to directly restore to previous state
|
||||||
// This restores all affected blocks including children of containers
|
// This is more robust than conditional toggle in collaborative scenarios
|
||||||
validBlockIds.forEach((blockId) => {
|
validBlockIds.forEach((blockId) => {
|
||||||
useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId])
|
useWorkflowStore.getState().setBlockEnabled(blockId, previousStates[blockId])
|
||||||
})
|
})
|
||||||
@@ -862,36 +868,6 @@ export function useUndoRedo() {
|
|||||||
})
|
})
|
||||||
break
|
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: {
|
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||||
const applyDiffInverse = entry.inverse as any
|
const applyDiffInverse = entry.inverse as any
|
||||||
const { baselineSnapshot } = applyDiffInverse.data
|
const { baselineSnapshot } = applyDiffInverse.data
|
||||||
@@ -1109,9 +1085,47 @@ export function useUndoRedo() {
|
|||||||
userId,
|
userId,
|
||||||
})
|
})
|
||||||
|
|
||||||
useWorkflowStore
|
blocksToAdd.forEach((block) => {
|
||||||
.getState()
|
useWorkflowStore
|
||||||
.batchAddBlocks(blocksToAdd, edgeSnapshots || [], subBlockValues || {})
|
.getState()
|
||||||
|
.addBlock(
|
||||||
|
block.id,
|
||||||
|
block.type,
|
||||||
|
block.name,
|
||||||
|
block.position,
|
||||||
|
block.data,
|
||||||
|
block.data?.parentId,
|
||||||
|
block.data?.extent,
|
||||||
|
{
|
||||||
|
enabled: block.enabled,
|
||||||
|
horizontalHandles: block.horizontalHandles,
|
||||||
|
advancedMode: block.advancedMode,
|
||||||
|
triggerMode: block.triggerMode,
|
||||||
|
height: block.height,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
|
||||||
|
useSubBlockStore.setState((state) => ({
|
||||||
|
workflowValues: {
|
||||||
|
...state.workflowValues,
|
||||||
|
[activeWorkflowId]: {
|
||||||
|
...state.workflowValues[activeWorkflowId],
|
||||||
|
...subBlockValues,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (edgeSnapshots && edgeSnapshots.length > 0) {
|
||||||
|
const edgesToAdd = edgeSnapshots.filter(
|
||||||
|
(edge) => !useWorkflowStore.getState().edges.find((e) => e.id === edge.id)
|
||||||
|
)
|
||||||
|
if (edgesToAdd.length > 0) {
|
||||||
|
useWorkflowStore.getState().batchAddEdges(edgesToAdd)
|
||||||
|
}
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: {
|
case UNDO_REDO_OPERATIONS.BATCH_REMOVE_BLOCKS: {
|
||||||
@@ -1428,9 +1442,7 @@ export function useUndoRedo() {
|
|||||||
const toggleOp = entry.operation as BatchToggleEnabledOperation
|
const toggleOp = entry.operation as BatchToggleEnabledOperation
|
||||||
const { blockIds, previousStates } = toggleOp.data
|
const { blockIds, previousStates } = toggleOp.data
|
||||||
|
|
||||||
// Process all blocks in previousStates (includes children of containers)
|
const validBlockIds = blockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
||||||
const allBlockIds = Object.keys(previousStates)
|
|
||||||
const validBlockIds = allBlockIds.filter((id) => useWorkflowStore.getState().blocks[id])
|
|
||||||
if (validBlockIds.length === 0) {
|
if (validBlockIds.length === 0) {
|
||||||
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
|
logger.debug('Redo batch-toggle-enabled skipped; no blocks exist')
|
||||||
break
|
break
|
||||||
@@ -1441,18 +1453,16 @@ export function useUndoRedo() {
|
|||||||
operation: {
|
operation: {
|
||||||
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
operation: BLOCKS_OPERATIONS.BATCH_TOGGLE_ENABLED,
|
||||||
target: OPERATION_TARGETS.BLOCKS,
|
target: OPERATION_TARGETS.BLOCKS,
|
||||||
payload: { blockIds, previousStates },
|
payload: { blockIds: validBlockIds, previousStates },
|
||||||
},
|
},
|
||||||
workflowId: activeWorkflowId,
|
workflowId: activeWorkflowId,
|
||||||
userId,
|
userId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Compute target state the same way batchToggleEnabled does:
|
// Use setBlockEnabled to directly set to toggled state
|
||||||
// use !firstBlock.enabled, where firstBlock is blockIds[0]
|
// Redo sets to !previousStates (the state after the original toggle)
|
||||||
const firstBlockId = blockIds[0]
|
|
||||||
const targetEnabled = !previousStates[firstBlockId]
|
|
||||||
validBlockIds.forEach((blockId) => {
|
validBlockIds.forEach((blockId) => {
|
||||||
useWorkflowStore.getState().setBlockEnabled(blockId, targetEnabled)
|
useWorkflowStore.getState().setBlockEnabled(blockId, !previousStates[blockId])
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1484,38 +1494,6 @@ export function useUndoRedo() {
|
|||||||
})
|
})
|
||||||
break
|
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,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Compute target state the same way batchToggleLocked does:
|
|
||||||
// use !firstBlock.locked, where firstBlock is blockIds[0]
|
|
||||||
const firstBlockId = blockIds[0]
|
|
||||||
const targetLocked = !previousStates[firstBlockId]
|
|
||||||
validBlockIds.forEach((blockId) => {
|
|
||||||
useWorkflowStore.getState().setBlockLocked(blockId, targetLocked)
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
case UNDO_REDO_OPERATIONS.APPLY_DIFF: {
|
||||||
// Redo apply-diff means re-applying the proposed state with diff markers
|
// Redo apply-diff means re-applying the proposed state with diff markers
|
||||||
const applyDiffOp = entry.operation as any
|
const applyDiffOp = entry.operation as any
|
||||||
@@ -1837,7 +1815,6 @@ export function useUndoRedo() {
|
|||||||
recordBatchUpdateParent,
|
recordBatchUpdateParent,
|
||||||
recordBatchToggleEnabled,
|
recordBatchToggleEnabled,
|
||||||
recordBatchToggleHandles,
|
recordBatchToggleHandles,
|
||||||
recordBatchToggleLocked,
|
|
||||||
recordApplyDiff,
|
recordApplyDiff,
|
||||||
recordAcceptDiff,
|
recordAcceptDiff,
|
||||||
recordRejectDiff,
|
recordRejectDiff,
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ type SkippedItemType =
|
|||||||
| 'block_not_found'
|
| 'block_not_found'
|
||||||
| 'invalid_block_type'
|
| 'invalid_block_type'
|
||||||
| 'block_not_allowed'
|
| 'block_not_allowed'
|
||||||
| 'block_locked'
|
|
||||||
| 'tool_not_allowed'
|
| 'tool_not_allowed'
|
||||||
| 'invalid_edge_target'
|
| 'invalid_edge_target'
|
||||||
| 'invalid_edge_source'
|
| 'invalid_edge_source'
|
||||||
@@ -619,7 +618,6 @@ function createBlockFromParams(
|
|||||||
subBlocks: {},
|
subBlocks: {},
|
||||||
outputs: outputs,
|
outputs: outputs,
|
||||||
data: parentId ? { parentId, extent: 'parent' as const } : {},
|
data: parentId ? { parentId, extent: 'parent' as const } : {},
|
||||||
locked: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add validated inputs as subBlocks
|
// Add validated inputs as subBlocks
|
||||||
@@ -1522,24 +1520,6 @@ function applyOperationsToWorkflowState(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if block is locked or inside a locked container
|
|
||||||
const deleteBlock = modifiedState.blocks[block_id]
|
|
||||||
const deleteParentId = deleteBlock.data?.parentId as string | undefined
|
|
||||||
const deleteParentLocked = deleteParentId
|
|
||||||
? modifiedState.blocks[deleteParentId]?.locked
|
|
||||||
: false
|
|
||||||
if (deleteBlock.locked || deleteParentLocked) {
|
|
||||||
logSkippedItem(skippedItems, {
|
|
||||||
type: 'block_locked',
|
|
||||||
operationType: 'delete',
|
|
||||||
blockId: block_id,
|
|
||||||
reason: deleteParentLocked
|
|
||||||
? `Block "${block_id}" is inside locked container "${deleteParentId}" and cannot be deleted`
|
|
||||||
: `Block "${block_id}" is locked and cannot be deleted`,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find all child blocks to remove
|
// Find all child blocks to remove
|
||||||
const blocksToRemove = new Set<string>([block_id])
|
const blocksToRemove = new Set<string>([block_id])
|
||||||
const findChildren = (parentId: string) => {
|
const findChildren = (parentId: string) => {
|
||||||
@@ -1575,21 +1555,6 @@ function applyOperationsToWorkflowState(
|
|||||||
|
|
||||||
const block = modifiedState.blocks[block_id]
|
const block = modifiedState.blocks[block_id]
|
||||||
|
|
||||||
// Check if block is locked or inside a locked container
|
|
||||||
const editParentId = block.data?.parentId as string | undefined
|
|
||||||
const editParentLocked = editParentId ? modifiedState.blocks[editParentId]?.locked : false
|
|
||||||
if (block.locked || editParentLocked) {
|
|
||||||
logSkippedItem(skippedItems, {
|
|
||||||
type: 'block_locked',
|
|
||||||
operationType: 'edit',
|
|
||||||
blockId: block_id,
|
|
||||||
reason: editParentLocked
|
|
||||||
? `Block "${block_id}" is inside locked container "${editParentId}" and cannot be edited`
|
|
||||||
: `Block "${block_id}" is locked and cannot be edited`,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure block has essential properties
|
// Ensure block has essential properties
|
||||||
if (!block.type) {
|
if (!block.type) {
|
||||||
logger.warn(`Block ${block_id} missing type property, skipping edit`, {
|
logger.warn(`Block ${block_id} missing type property, skipping edit`, {
|
||||||
@@ -2157,19 +2122,6 @@ function applyOperationsToWorkflowState(
|
|||||||
|
|
||||||
// Handle nested nodes (for loops/parallels created from scratch)
|
// Handle nested nodes (for loops/parallels created from scratch)
|
||||||
if (params.nestedNodes) {
|
if (params.nestedNodes) {
|
||||||
// Defensive check: verify parent is not locked before adding children
|
|
||||||
// (Parent was just created with locked: false, but check for consistency)
|
|
||||||
const parentBlock = modifiedState.blocks[block_id]
|
|
||||||
if (parentBlock?.locked) {
|
|
||||||
logSkippedItem(skippedItems, {
|
|
||||||
type: 'block_locked',
|
|
||||||
operationType: 'add_nested_nodes',
|
|
||||||
blockId: block_id,
|
|
||||||
reason: `Container "${block_id}" is locked - cannot add nested nodes`,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
|
Object.entries(params.nestedNodes).forEach(([childId, childBlock]: [string, any]) => {
|
||||||
// Validate childId is a valid string
|
// Validate childId is a valid string
|
||||||
if (!isValidKey(childId)) {
|
if (!isValidKey(childId)) {
|
||||||
@@ -2257,18 +2209,6 @@ function applyOperationsToWorkflowState(
|
|||||||
break
|
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') {
|
if (subflowBlock.type !== 'loop' && subflowBlock.type !== 'parallel') {
|
||||||
logger.error('Subflow block has invalid type', {
|
logger.error('Subflow block has invalid type', {
|
||||||
subflowId,
|
subflowId,
|
||||||
@@ -2307,17 +2247,6 @@ function applyOperationsToWorkflowState(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if existing block is locked
|
|
||||||
if (existingBlock.locked) {
|
|
||||||
logSkippedItem(skippedItems, {
|
|
||||||
type: 'block_locked',
|
|
||||||
operationType: 'insert_into_subflow',
|
|
||||||
blockId: block_id,
|
|
||||||
reason: `Block "${block_id}" is locked and cannot be moved into a subflow`,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Moving existing block into subflow - just update parent
|
// Moving existing block into subflow - just update parent
|
||||||
existingBlock.data = {
|
existingBlock.data = {
|
||||||
...existingBlock.data,
|
...existingBlock.data,
|
||||||
@@ -2463,30 +2392,6 @@ function applyOperationsToWorkflowState(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if block is locked
|
|
||||||
if (block.locked) {
|
|
||||||
logSkippedItem(skippedItems, {
|
|
||||||
type: 'block_locked',
|
|
||||||
operationType: 'extract_from_subflow',
|
|
||||||
blockId: block_id,
|
|
||||||
reason: `Block "${block_id}" is locked and cannot be extracted from subflow`,
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if parent subflow is locked
|
|
||||||
const parentSubflow = modifiedState.blocks[subflowId]
|
|
||||||
if (parentSubflow?.locked) {
|
|
||||||
logSkippedItem(skippedItems, {
|
|
||||||
type: 'block_locked',
|
|
||||||
operationType: 'extract_from_subflow',
|
|
||||||
blockId: block_id,
|
|
||||||
reason: `Subflow "${subflowId}" is locked - cannot extract block "${block_id}"`,
|
|
||||||
details: { subflowId },
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify it's actually a child of this subflow
|
// Verify it's actually a child of this subflow
|
||||||
if (block.data?.parentId !== subflowId) {
|
if (block.data?.parentId !== subflowId) {
|
||||||
logger.warn('Block is not a child of specified subflow', {
|
logger.warn('Block is not a child of specified subflow', {
|
||||||
|
|||||||
@@ -161,14 +161,14 @@ export const env = createEnv({
|
|||||||
// Rate Limiting Configuration
|
// Rate Limiting Configuration
|
||||||
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
|
RATE_LIMIT_WINDOW_MS: z.string().optional().default('60000'), // Rate limit window duration in milliseconds (default: 1 minute)
|
||||||
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
|
MANUAL_EXECUTION_LIMIT: z.string().optional().default('999999'),// Manual execution bypass value (effectively unlimited)
|
||||||
RATE_LIMIT_FREE_SYNC: z.string().optional().default('50'), // Free tier sync API executions per minute
|
RATE_LIMIT_FREE_SYNC: z.string().optional().default('10'), // Free tier sync API executions per minute
|
||||||
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('200'), // Free tier async API executions per minute
|
RATE_LIMIT_FREE_ASYNC: z.string().optional().default('50'), // Free tier async API executions per minute
|
||||||
RATE_LIMIT_PRO_SYNC: z.string().optional().default('150'), // Pro tier sync API executions per minute
|
RATE_LIMIT_PRO_SYNC: z.string().optional().default('25'), // Pro tier sync API executions per minute
|
||||||
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('1000'), // Pro tier async API executions per minute
|
RATE_LIMIT_PRO_ASYNC: z.string().optional().default('200'), // Pro tier async API executions per minute
|
||||||
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('300'), // Team tier sync API executions per minute
|
RATE_LIMIT_TEAM_SYNC: z.string().optional().default('75'), // Team tier sync API executions per minute
|
||||||
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('2500'), // Team tier async API executions per minute
|
RATE_LIMIT_TEAM_ASYNC: z.string().optional().default('500'), // Team tier async API executions per minute
|
||||||
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('600'), // Enterprise tier sync API executions per minute
|
RATE_LIMIT_ENTERPRISE_SYNC: z.string().optional().default('150'), // Enterprise tier sync API executions per minute
|
||||||
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('5000'), // Enterprise tier async API executions per minute
|
RATE_LIMIT_ENTERPRISE_ASYNC: z.string().optional().default('1000'), // Enterprise tier async API executions per minute
|
||||||
|
|
||||||
// Knowledge Base Processing Configuration - Shared across all processing methods
|
// Knowledge Base Processing Configuration - Shared across all processing methods
|
||||||
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)
|
KB_CONFIG_MAX_DURATION: z.number().optional().default(600), // Max processing duration in seconds (10 minutes)
|
||||||
|
|||||||
@@ -28,24 +28,24 @@ function createBucketConfig(ratePerMinute: number, burstMultiplier = 2): TokenBu
|
|||||||
|
|
||||||
export const RATE_LIMITS: Record<SubscriptionPlan, RateLimitConfig> = {
|
export const RATE_LIMITS: Record<SubscriptionPlan, RateLimitConfig> = {
|
||||||
free: {
|
free: {
|
||||||
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 50),
|
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_SYNC) || 10),
|
||||||
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 200),
|
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_FREE_ASYNC) || 50),
|
||||||
apiEndpoint: createBucketConfig(30),
|
apiEndpoint: createBucketConfig(10),
|
||||||
},
|
},
|
||||||
pro: {
|
pro: {
|
||||||
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 150),
|
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_SYNC) || 25),
|
||||||
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 1000),
|
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_PRO_ASYNC) || 200),
|
||||||
apiEndpoint: createBucketConfig(100),
|
apiEndpoint: createBucketConfig(30),
|
||||||
},
|
},
|
||||||
team: {
|
team: {
|
||||||
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 300),
|
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_SYNC) || 75),
|
||||||
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 2500),
|
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_TEAM_ASYNC) || 500),
|
||||||
apiEndpoint: createBucketConfig(200),
|
apiEndpoint: createBucketConfig(60),
|
||||||
},
|
},
|
||||||
enterprise: {
|
enterprise: {
|
||||||
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 600),
|
sync: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_SYNC) || 150),
|
||||||
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 5000),
|
async: createBucketConfig(Number.parseInt(env.RATE_LIMIT_ENTERPRISE_ASYNC) || 1000),
|
||||||
apiEndpoint: createBucketConfig(500),
|
apiEndpoint: createBucketConfig(120),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,11 +199,10 @@ export class McpClient {
|
|||||||
protocolVersion: this.getNegotiatedVersion(),
|
protocolVersion: this.getNegotiatedVersion(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const sdkResult = await this.client.callTool(
|
const sdkResult = await this.client.callTool({
|
||||||
{ name: toolCall.name, arguments: toolCall.arguments },
|
name: toolCall.name,
|
||||||
undefined,
|
arguments: toolCall.arguments,
|
||||||
{ timeout: 600000 } // 10 minutes - override SDK's 60s default
|
})
|
||||||
)
|
|
||||||
|
|
||||||
return sdkResult as McpToolResult
|
return sdkResult as McpToolResult
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -296,26 +296,6 @@ describe('hasWorkflowChanged', () => {
|
|||||||
})
|
})
|
||||||
expect(hasWorkflowChanged(state1, state2)).toBe(true)
|
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', () => {
|
describe('SubBlock Changes', () => {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ export function generateWorkflowDiffSummary(
|
|||||||
}
|
}
|
||||||
// Check other block properties (boolean fields)
|
// Check other block properties (boolean fields)
|
||||||
// Use !! to normalize: null/undefined/false are all equivalent (falsy)
|
// Use !! to normalize: null/undefined/false are all equivalent (falsy)
|
||||||
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode', 'locked'] as const
|
const blockFields = ['horizontalHandles', 'advancedMode', 'triggerMode'] as const
|
||||||
for (const field of blockFields) {
|
for (const field of blockFields) {
|
||||||
if (!!currentBlock[field] !== !!previousBlock[field]) {
|
if (!!currentBlock[field] !== !!previousBlock[field]) {
|
||||||
changes.push({
|
changes.push({
|
||||||
|
|||||||
@@ -100,7 +100,6 @@ function buildStartBlockState(
|
|||||||
triggerMode: false,
|
triggerMode: false,
|
||||||
height: 0,
|
height: 0,
|
||||||
data: {},
|
data: {},
|
||||||
locked: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { blockState, subBlockValues }
|
return { blockState, subBlockValues }
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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,7 +215,6 @@ function hasBlockChanged(currentBlock: BlockState, proposedBlock: BlockState): b
|
|||||||
if (currentBlock.name !== proposedBlock.name) return true
|
if (currentBlock.name !== proposedBlock.name) return true
|
||||||
if (currentBlock.enabled !== proposedBlock.enabled) return true
|
if (currentBlock.enabled !== proposedBlock.enabled) return true
|
||||||
if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true
|
if (currentBlock.triggerMode !== proposedBlock.triggerMode) return true
|
||||||
if (!!currentBlock.locked !== !!proposedBlock.locked) return true
|
|
||||||
|
|
||||||
// Compare subBlocks
|
// Compare subBlocks
|
||||||
const currentSubKeys = Object.keys(currentBlock.subBlocks || {})
|
const currentSubKeys = Object.keys(currentBlock.subBlocks || {})
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ export async function duplicateWorkflow(
|
|||||||
parentId: newParentId,
|
parentId: newParentId,
|
||||||
extent: newExtent,
|
extent: newExtent,
|
||||||
data: updatedData,
|
data: updatedData,
|
||||||
locked: false, // Duplicated blocks should always be unlocked
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,7 +226,6 @@ export async function loadWorkflowFromNormalizedTables(
|
|||||||
subBlocks: (block.subBlocks as BlockState['subBlocks']) || {},
|
subBlocks: (block.subBlocks as BlockState['subBlocks']) || {},
|
||||||
outputs: (block.outputs as BlockState['outputs']) || {},
|
outputs: (block.outputs as BlockState['outputs']) || {},
|
||||||
data: blockData,
|
data: blockData,
|
||||||
locked: block.locked,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
blocksMap[block.id] = assembled
|
blocksMap[block.id] = assembled
|
||||||
@@ -364,7 +363,6 @@ export async function saveWorkflowToNormalizedTables(
|
|||||||
data: block.data || {},
|
data: block.data || {},
|
||||||
parentId: block.data?.parentId || null,
|
parentId: block.data?.parentId || null,
|
||||||
extent: block.data?.extent || null,
|
extent: block.data?.extent || null,
|
||||||
locked: block.locked ?? false,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await tx.insert(workflowBlocks).values(blockInserts)
|
await tx.insert(workflowBlocks).values(blockInserts)
|
||||||
@@ -629,8 +627,7 @@ export function regenerateWorkflowStateIds(state: RegenerateStateInput): Regener
|
|||||||
// Regenerate blocks with updated references
|
// Regenerate blocks with updated references
|
||||||
Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
|
Object.entries(state.blocks || {}).forEach(([oldId, block]) => {
|
||||||
const newId = blockIdMapping.get(oldId)!
|
const newId = blockIdMapping.get(oldId)!
|
||||||
// Duplicated blocks are always unlocked so users can edit them
|
const newBlock: BlockState = { ...block, id: newId }
|
||||||
const newBlock: BlockState = { ...block, id: newId, locked: false }
|
|
||||||
|
|
||||||
// Update parentId reference if it exists
|
// Update parentId reference if it exists
|
||||||
if (newBlock.data?.parentId) {
|
if (newBlock.data?.parentId) {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export const BLOCKS_OPERATIONS = {
|
|||||||
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
||||||
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
||||||
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
||||||
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
|
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
|
export type BlocksOperation = (typeof BLOCKS_OPERATIONS)[keyof typeof BLOCKS_OPERATIONS]
|
||||||
@@ -86,7 +85,6 @@ export const UNDO_REDO_OPERATIONS = {
|
|||||||
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
BATCH_UPDATE_PARENT: 'batch-update-parent',
|
||||||
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
BATCH_TOGGLE_ENABLED: 'batch-toggle-enabled',
|
||||||
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
BATCH_TOGGLE_HANDLES: 'batch-toggle-handles',
|
||||||
BATCH_TOGGLE_LOCKED: 'batch-toggle-locked',
|
|
||||||
APPLY_DIFF: 'apply-diff',
|
APPLY_DIFF: 'apply-diff',
|
||||||
ACCEPT_DIFF: 'accept-diff',
|
ACCEPT_DIFF: 'accept-diff',
|
||||||
REJECT_DIFF: 'reject-diff',
|
REJECT_DIFF: 'reject-diff',
|
||||||
|
|||||||
@@ -507,37 +507,7 @@ async function handleBlocksOperationTx(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (blocks && blocks.length > 0) {
|
if (blocks && blocks.length > 0) {
|
||||||
// Fetch existing blocks to check for locked parents
|
const blockValues = blocks.map((block: Record<string, unknown>) => {
|
||||||
const existingBlocks = await tx
|
|
||||||
.select({ id: workflowBlocks.id, locked: workflowBlocks.locked })
|
|
||||||
.from(workflowBlocks)
|
|
||||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
|
||||||
|
|
||||||
type ExistingBlockRecord = (typeof existingBlocks)[number]
|
|
||||||
const lockedParentIds = new Set(
|
|
||||||
existingBlocks
|
|
||||||
.filter((b: ExistingBlockRecord) => b.locked)
|
|
||||||
.map((b: ExistingBlockRecord) => b.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Filter out blocks being added to locked parents
|
|
||||||
const allowedBlocks = (blocks as Array<Record<string, unknown>>).filter((block) => {
|
|
||||||
const parentId = (block.data as Record<string, unknown> | null)?.parentId as
|
|
||||||
| string
|
|
||||||
| undefined
|
|
||||||
if (parentId && lockedParentIds.has(parentId)) {
|
|
||||||
logger.info(`Skipping block ${block.id} - parent ${parentId} is locked`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (allowedBlocks.length === 0) {
|
|
||||||
logger.info('All blocks filtered out due to locked parents, skipping add')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockValues = allowedBlocks.map((block: Record<string, unknown>) => {
|
|
||||||
const blockId = block.id as string
|
const blockId = block.id as string
|
||||||
const mergedSubBlocks = mergeSubBlockValues(
|
const mergedSubBlocks = mergeSubBlockValues(
|
||||||
block.subBlocks as Record<string, unknown>,
|
block.subBlocks as Record<string, unknown>,
|
||||||
@@ -559,7 +529,6 @@ async function handleBlocksOperationTx(
|
|||||||
advancedMode: (block.advancedMode as boolean) ?? false,
|
advancedMode: (block.advancedMode as boolean) ?? false,
|
||||||
triggerMode: (block.triggerMode as boolean) ?? false,
|
triggerMode: (block.triggerMode as boolean) ?? false,
|
||||||
height: (block.height as number) || 0,
|
height: (block.height as number) || 0,
|
||||||
locked: (block.locked as boolean) ?? false,
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -568,7 +537,7 @@ async function handleBlocksOperationTx(
|
|||||||
// Create subflow entries for loop/parallel blocks (skip if already in payload)
|
// Create subflow entries for loop/parallel blocks (skip if already in payload)
|
||||||
const loopIds = new Set(loops ? Object.keys(loops) : [])
|
const loopIds = new Set(loops ? Object.keys(loops) : [])
|
||||||
const parallelIds = new Set(parallels ? Object.keys(parallels) : [])
|
const parallelIds = new Set(parallels ? Object.keys(parallels) : [])
|
||||||
for (const block of allowedBlocks) {
|
for (const block of blocks) {
|
||||||
const blockId = block.id as string
|
const blockId = block.id as string
|
||||||
if (block.type === 'loop' && !loopIds.has(blockId)) {
|
if (block.type === 'loop' && !loopIds.has(blockId)) {
|
||||||
await tx.insert(workflowSubflows).values({
|
await tx.insert(workflowSubflows).values({
|
||||||
@@ -597,7 +566,7 @@ async function handleBlocksOperationTx(
|
|||||||
|
|
||||||
// Update parent subflow node lists
|
// Update parent subflow node lists
|
||||||
const parentIds = new Set<string>()
|
const parentIds = new Set<string>()
|
||||||
for (const block of allowedBlocks) {
|
for (const block of blocks) {
|
||||||
const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined
|
const parentId = (block.data as Record<string, unknown>)?.parentId as string | undefined
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
parentIds.add(parentId)
|
parentIds.add(parentId)
|
||||||
@@ -655,74 +624,44 @@ async function handleBlocksOperationTx(
|
|||||||
|
|
||||||
logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`)
|
logger.info(`Batch removing ${ids.length} blocks from workflow ${workflowId}`)
|
||||||
|
|
||||||
// Fetch all blocks to check lock status and filter out protected blocks
|
|
||||||
const allBlocks = await tx
|
|
||||||
.select({
|
|
||||||
id: workflowBlocks.id,
|
|
||||||
type: workflowBlocks.type,
|
|
||||||
locked: workflowBlocks.locked,
|
|
||||||
data: workflowBlocks.data,
|
|
||||||
})
|
|
||||||
.from(workflowBlocks)
|
|
||||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
|
||||||
|
|
||||||
type BlockRecord = (typeof allBlocks)[number]
|
|
||||||
const blocksById: Record<string, BlockRecord> = Object.fromEntries(
|
|
||||||
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))
|
|
||||||
if (deletableIds.length === 0) {
|
|
||||||
logger.info('All requested blocks are protected, skipping deletion')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletableIds.length < ids.length) {
|
|
||||||
logger.info(
|
|
||||||
`Filtered out ${ids.length - deletableIds.length} protected blocks from deletion`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all block IDs including children of subflows
|
// Collect all block IDs including children of subflows
|
||||||
const allBlocksToDelete = new Set<string>(deletableIds)
|
const allBlocksToDelete = new Set<string>(ids)
|
||||||
|
|
||||||
for (const id of deletableIds) {
|
for (const id of ids) {
|
||||||
const block = blocksById[id]
|
const blockToRemove = await tx
|
||||||
if (block && isSubflowBlockType(block.type)) {
|
.select({ type: workflowBlocks.type })
|
||||||
// Include all children of the subflow (they should be deleted with parent)
|
.from(workflowBlocks)
|
||||||
for (const b of allBlocks) {
|
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
const parentId = (b.data as Record<string, unknown> | null)?.parentId
|
.limit(1)
|
||||||
if (parentId === id) {
|
|
||||||
allBlocksToDelete.add(b.id)
|
if (blockToRemove.length > 0 && isSubflowBlockType(blockToRemove[0].type)) {
|
||||||
}
|
const childBlocks = await tx
|
||||||
}
|
.select({ id: workflowBlocks.id })
|
||||||
|
.from(workflowBlocks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(workflowBlocks.workflowId, workflowId),
|
||||||
|
sql`${workflowBlocks.data}->>'parentId' = ${id}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
childBlocks.forEach((child: { id: string }) => allBlocksToDelete.add(child.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockIdsArray = Array.from(allBlocksToDelete)
|
const blockIdsArray = Array.from(allBlocksToDelete)
|
||||||
|
|
||||||
// Collect parent IDs BEFORE deleting blocks (use blocksById, already fetched)
|
// Collect parent IDs BEFORE deleting blocks
|
||||||
const parentIds = new Set<string>()
|
const parentIds = new Set<string>()
|
||||||
for (const id of deletableIds) {
|
for (const id of ids) {
|
||||||
const block = blocksById[id]
|
const parentInfo = await tx
|
||||||
const parentId = (block?.data as Record<string, unknown> | null)?.parentId as
|
.select({ parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'` })
|
||||||
| string
|
.from(workflowBlocks)
|
||||||
| undefined
|
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
if (parentId) {
|
.limit(1)
|
||||||
parentIds.add(parentId)
|
|
||||||
|
if (parentInfo.length > 0 && parentInfo[0].parentId) {
|
||||||
|
parentIds.add(parentInfo[0].parentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,61 +741,22 @@ async function handleBlocksOperationTx(
|
|||||||
`Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}`
|
`Batch toggling enabled state for ${blockIds.length} blocks in workflow ${workflowId}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get all blocks in workflow to find children and check locked state
|
const blocks = await tx
|
||||||
const allBlocks = await tx
|
.select({ id: workflowBlocks.id, enabled: workflowBlocks.enabled })
|
||||||
.select({
|
|
||||||
id: workflowBlocks.id,
|
|
||||||
enabled: workflowBlocks.enabled,
|
|
||||||
locked: workflowBlocks.locked,
|
|
||||||
type: workflowBlocks.type,
|
|
||||||
data: workflowBlocks.data,
|
|
||||||
})
|
|
||||||
.from(workflowBlocks)
|
.from(workflowBlocks)
|
||||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
.where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds)))
|
||||||
|
|
||||||
type BlockRecord = (typeof allBlocks)[number]
|
for (const block of blocks) {
|
||||||
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 toggleable block
|
|
||||||
if (blocksToToggle.size === 0) break
|
|
||||||
const firstToggleableId = Array.from(blocksToToggle)[0]
|
|
||||||
const firstBlock = blocksById[firstToggleableId]
|
|
||||||
if (!firstBlock) break
|
|
||||||
const targetEnabled = !firstBlock.enabled
|
|
||||||
|
|
||||||
// Update all affected blocks
|
|
||||||
for (const blockId of blocksToToggle) {
|
|
||||||
await tx
|
await tx
|
||||||
.update(workflowBlocks)
|
.update(workflowBlocks)
|
||||||
.set({
|
.set({
|
||||||
enabled: targetEnabled,
|
enabled: !block.enabled,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
.where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Batch toggled enabled state for ${blocksToToggle.size} blocks`)
|
logger.debug(`Batch toggled enabled state for ${blocks.length} blocks`)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,118 +768,22 @@ async function handleBlocksOperationTx(
|
|||||||
|
|
||||||
logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`)
|
logger.info(`Batch toggling handles for ${blockIds.length} blocks in workflow ${workflowId}`)
|
||||||
|
|
||||||
// Fetch all blocks to check lock status and filter out protected blocks
|
const blocks = await tx
|
||||||
const allBlocks = await tx
|
.select({ id: workflowBlocks.id, horizontalHandles: workflowBlocks.horizontalHandles })
|
||||||
.select({
|
|
||||||
id: workflowBlocks.id,
|
|
||||||
horizontalHandles: workflowBlocks.horizontalHandles,
|
|
||||||
locked: workflowBlocks.locked,
|
|
||||||
data: workflowBlocks.data,
|
|
||||||
})
|
|
||||||
.from(workflowBlocks)
|
.from(workflowBlocks)
|
||||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
.where(and(eq(workflowBlocks.workflowId, workflowId), inArray(workflowBlocks.id, blockIds)))
|
||||||
|
|
||||||
type HandleBlockRecord = (typeof allBlocks)[number]
|
for (const block of blocks) {
|
||||||
const blocksById: Record<string, HandleBlockRecord> = Object.fromEntries(
|
|
||||||
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))
|
|
||||||
if (blocksToToggle.length === 0) {
|
|
||||||
logger.info('All requested blocks are protected, skipping handles toggle')
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const blockId of blocksToToggle) {
|
|
||||||
const block = blocksById[blockId]
|
|
||||||
await tx
|
await tx
|
||||||
.update(workflowBlocks)
|
.update(workflowBlocks)
|
||||||
.set({
|
.set({
|
||||||
horizontalHandles: !block.horizontalHandles,
|
horizontalHandles: !block.horizontalHandles,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(and(eq(workflowBlocks.id, blockId), eq(workflowBlocks.workflowId, workflowId)))
|
.where(and(eq(workflowBlocks.id, block.id), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Batch toggled handles for ${blocksToToggle.length} blocks`)
|
logger.debug(`Batch toggled handles for ${blocks.length} blocks`)
|
||||||
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 toggleable block
|
|
||||||
if (blocksToToggle.size === 0) break
|
|
||||||
const firstToggleableId = Array.from(blocksToToggle)[0]
|
|
||||||
const firstBlock = blocksById[firstToggleableId]
|
|
||||||
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
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -991,54 +795,19 @@ async function handleBlocksOperationTx(
|
|||||||
|
|
||||||
logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`)
|
logger.info(`Batch updating parent for ${updates.length} blocks in workflow ${workflowId}`)
|
||||||
|
|
||||||
// Fetch all blocks to check lock status
|
|
||||||
const allBlocks = await tx
|
|
||||||
.select({
|
|
||||||
id: workflowBlocks.id,
|
|
||||||
locked: workflowBlocks.locked,
|
|
||||||
data: workflowBlocks.data,
|
|
||||||
})
|
|
||||||
.from(workflowBlocks)
|
|
||||||
.where(eq(workflowBlocks.workflowId, workflowId))
|
|
||||||
|
|
||||||
type ParentBlockRecord = (typeof allBlocks)[number]
|
|
||||||
const blocksById: Record<string, ParentBlockRecord> = Object.fromEntries(
|
|
||||||
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) {
|
for (const update of updates) {
|
||||||
const { id, parentId, position } = update
|
const { id, parentId, position } = update
|
||||||
if (!id) continue
|
if (!id) continue
|
||||||
|
|
||||||
// Skip protected blocks (locked or inside locked container)
|
|
||||||
if (isProtected(id)) {
|
|
||||||
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`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch current parent to update subflow node lists
|
// Fetch current parent to update subflow node lists
|
||||||
const existing = blocksById[id]
|
const [existing] = await tx
|
||||||
const existingParentId = (existing?.data as Record<string, unknown> | null)?.parentId as
|
.select({
|
||||||
| string
|
id: workflowBlocks.id,
|
||||||
| undefined
|
parentId: sql<string | null>`${workflowBlocks.data}->>'parentId'`,
|
||||||
|
})
|
||||||
|
.from(workflowBlocks)
|
||||||
|
.where(and(eq(workflowBlocks.id, id), eq(workflowBlocks.workflowId, workflowId)))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
logger.warn(`Block ${id} not found for batch-update-parent`)
|
logger.warn(`Block ${id} not found for batch-update-parent`)
|
||||||
@@ -1083,8 +852,8 @@ async function handleBlocksOperationTx(
|
|||||||
await updateSubflowNodeList(tx, workflowId, parentId)
|
await updateSubflowNodeList(tx, workflowId, parentId)
|
||||||
}
|
}
|
||||||
// If the block had a previous parent, update that parent's node list as well
|
// If the block had a previous parent, update that parent's node list as well
|
||||||
if (existingParentId && existingParentId !== parentId) {
|
if (existing?.parentId && existing.parentId !== parentId) {
|
||||||
await updateSubflowNodeList(tx, workflowId, existingParentId)
|
await updateSubflowNodeList(tx, workflowId, existing.parentId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1429,7 +1198,6 @@ async function handleWorkflowOperationTx(
|
|||||||
advancedMode: block.advancedMode ?? false,
|
advancedMode: block.advancedMode ?? false,
|
||||||
triggerMode: block.triggerMode ?? false,
|
triggerMode: block.triggerMode ?? false,
|
||||||
height: block.height || 0,
|
height: block.height || 0,
|
||||||
locked: block.locked ?? false,
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
await tx.insert(workflowBlocks).values(blockValues)
|
await tx.insert(workflowBlocks).values(blockValues)
|
||||||
|
|||||||
@@ -39,23 +39,16 @@ export function cleanupPendingSubblocksForSocket(socketId: string): void {
|
|||||||
|
|
||||||
export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
|
export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
|
||||||
socket.on('subblock-update', async (data) => {
|
socket.on('subblock-update', async (data) => {
|
||||||
const {
|
const { blockId, subblockId, value, timestamp, operationId } = data
|
||||||
workflowId: payloadWorkflowId,
|
|
||||||
blockId,
|
|
||||||
subblockId,
|
|
||||||
value,
|
|
||||||
timestamp,
|
|
||||||
operationId,
|
|
||||||
} = data
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
const workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
||||||
const session = await roomManager.getUserSession(socket.id)
|
const session = await roomManager.getUserSession(socket.id)
|
||||||
|
|
||||||
if (!sessionWorkflowId || !session) {
|
if (!workflowId || !session) {
|
||||||
logger.debug(`Ignoring subblock update: socket not connected to any workflow room`, {
|
logger.debug(`Ignoring subblock update: socket not connected to any workflow room`, {
|
||||||
socketId: socket.id,
|
socketId: socket.id,
|
||||||
hasWorkflowId: !!sessionWorkflowId,
|
hasWorkflowId: !!workflowId,
|
||||||
hasSession: !!session,
|
hasSession: !!session,
|
||||||
})
|
})
|
||||||
socket.emit('operation-forbidden', {
|
socket.emit('operation-forbidden', {
|
||||||
@@ -68,24 +61,6 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowId = payloadWorkflowId || sessionWorkflowId
|
|
||||||
|
|
||||||
if (payloadWorkflowId && payloadWorkflowId !== sessionWorkflowId) {
|
|
||||||
logger.warn('Workflow ID mismatch in subblock update', {
|
|
||||||
payloadWorkflowId,
|
|
||||||
sessionWorkflowId,
|
|
||||||
socketId: socket.id,
|
|
||||||
})
|
|
||||||
if (operationId) {
|
|
||||||
socket.emit('operation-failed', {
|
|
||||||
operationId,
|
|
||||||
error: 'Workflow ID mismatch',
|
|
||||||
retryable: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasRoom = await roomManager.hasWorkflowRoom(workflowId)
|
const hasRoom = await roomManager.hasWorkflowRoom(workflowId)
|
||||||
if (!hasRoom) {
|
if (!hasRoom) {
|
||||||
logger.debug(`Ignoring subblock update: workflow room not found`, {
|
logger.debug(`Ignoring subblock update: workflow room not found`, {
|
||||||
@@ -207,17 +182,20 @@ async function flushSubblockUpdate(
|
|||||||
if (updateSuccessful) {
|
if (updateSuccessful) {
|
||||||
// Broadcast to room excluding all senders (works cross-pod via Redis adapter)
|
// Broadcast to room excluding all senders (works cross-pod via Redis adapter)
|
||||||
const senderSocketIds = [...pending.opToSocket.values()]
|
const senderSocketIds = [...pending.opToSocket.values()]
|
||||||
const broadcastPayload = {
|
|
||||||
workflowId,
|
|
||||||
blockId,
|
|
||||||
subblockId,
|
|
||||||
value,
|
|
||||||
timestamp,
|
|
||||||
}
|
|
||||||
if (senderSocketIds.length > 0) {
|
if (senderSocketIds.length > 0) {
|
||||||
io.to(workflowId).except(senderSocketIds).emit('subblock-update', broadcastPayload)
|
io.to(workflowId).except(senderSocketIds).emit('subblock-update', {
|
||||||
|
blockId,
|
||||||
|
subblockId,
|
||||||
|
value,
|
||||||
|
timestamp,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
io.to(workflowId).emit('subblock-update', broadcastPayload)
|
io.to(workflowId).emit('subblock-update', {
|
||||||
|
blockId,
|
||||||
|
subblockId,
|
||||||
|
value,
|
||||||
|
timestamp,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm all coalesced operationIds (io.to(socketId) works cross-pod)
|
// Confirm all coalesced operationIds (io.to(socketId) works cross-pod)
|
||||||
|
|||||||
@@ -35,16 +35,16 @@ export function cleanupPendingVariablesForSocket(socketId: string): void {
|
|||||||
|
|
||||||
export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
|
export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) {
|
||||||
socket.on('variable-update', async (data) => {
|
socket.on('variable-update', async (data) => {
|
||||||
const { workflowId: payloadWorkflowId, variableId, field, value, timestamp, operationId } = data
|
const { variableId, field, value, timestamp, operationId } = data
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sessionWorkflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
const workflowId = await roomManager.getWorkflowIdForSocket(socket.id)
|
||||||
const session = await roomManager.getUserSession(socket.id)
|
const session = await roomManager.getUserSession(socket.id)
|
||||||
|
|
||||||
if (!sessionWorkflowId || !session) {
|
if (!workflowId || !session) {
|
||||||
logger.debug(`Ignoring variable update: socket not connected to any workflow room`, {
|
logger.debug(`Ignoring variable update: socket not connected to any workflow room`, {
|
||||||
socketId: socket.id,
|
socketId: socket.id,
|
||||||
hasWorkflowId: !!sessionWorkflowId,
|
hasWorkflowId: !!workflowId,
|
||||||
hasSession: !!session,
|
hasSession: !!session,
|
||||||
})
|
})
|
||||||
socket.emit('operation-forbidden', {
|
socket.emit('operation-forbidden', {
|
||||||
@@ -57,24 +57,6 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowId = payloadWorkflowId || sessionWorkflowId
|
|
||||||
|
|
||||||
if (payloadWorkflowId && payloadWorkflowId !== sessionWorkflowId) {
|
|
||||||
logger.warn('Workflow ID mismatch in variable update', {
|
|
||||||
payloadWorkflowId,
|
|
||||||
sessionWorkflowId,
|
|
||||||
socketId: socket.id,
|
|
||||||
})
|
|
||||||
if (operationId) {
|
|
||||||
socket.emit('operation-failed', {
|
|
||||||
operationId,
|
|
||||||
error: 'Workflow ID mismatch',
|
|
||||||
retryable: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasRoom = await roomManager.hasWorkflowRoom(workflowId)
|
const hasRoom = await roomManager.hasWorkflowRoom(workflowId)
|
||||||
if (!hasRoom) {
|
if (!hasRoom) {
|
||||||
logger.debug(`Ignoring variable update: workflow room not found`, {
|
logger.debug(`Ignoring variable update: workflow room not found`, {
|
||||||
@@ -197,17 +179,20 @@ async function flushVariableUpdate(
|
|||||||
if (updateSuccessful) {
|
if (updateSuccessful) {
|
||||||
// Broadcast to room excluding all senders (works cross-pod via Redis adapter)
|
// Broadcast to room excluding all senders (works cross-pod via Redis adapter)
|
||||||
const senderSocketIds = [...pending.opToSocket.values()]
|
const senderSocketIds = [...pending.opToSocket.values()]
|
||||||
const broadcastPayload = {
|
|
||||||
workflowId,
|
|
||||||
variableId,
|
|
||||||
field,
|
|
||||||
value,
|
|
||||||
timestamp,
|
|
||||||
}
|
|
||||||
if (senderSocketIds.length > 0) {
|
if (senderSocketIds.length > 0) {
|
||||||
io.to(workflowId).except(senderSocketIds).emit('variable-update', broadcastPayload)
|
io.to(workflowId).except(senderSocketIds).emit('variable-update', {
|
||||||
|
variableId,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
timestamp,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
io.to(workflowId).emit('variable-update', broadcastPayload)
|
io.to(workflowId).emit('variable-update', {
|
||||||
|
variableId,
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
timestamp,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Confirm all coalesced operationIds (io.to(socketId) works cross-pod)
|
// Confirm all coalesced operationIds (io.to(socketId) works cross-pod)
|
||||||
|
|||||||
@@ -214,12 +214,6 @@ describe('checkRolePermission', () => {
|
|||||||
readAllowed: false,
|
readAllowed: false,
|
||||||
},
|
},
|
||||||
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
{ operation: 'toggle-handles', adminAllowed: true, writeAllowed: true, readAllowed: false },
|
||||||
{
|
|
||||||
operation: 'batch-toggle-locked',
|
|
||||||
adminAllowed: true,
|
|
||||||
writeAllowed: false, // Admin-only operation
|
|
||||||
readAllowed: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
operation: 'batch-update-positions',
|
operation: 'batch-update-positions',
|
||||||
adminAllowed: true,
|
adminAllowed: true,
|
||||||
|
|||||||
@@ -14,10 +14,7 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('SocketPermissions')
|
const logger = createLogger('SocketPermissions')
|
||||||
|
|
||||||
// Admin-only operations (require admin role)
|
// All write operations (admin and write roles have same permissions)
|
||||||
const ADMIN_ONLY_OPERATIONS: string[] = [BLOCKS_OPERATIONS.BATCH_TOGGLE_LOCKED]
|
|
||||||
|
|
||||||
// Write operations (admin and write roles both have these permissions)
|
|
||||||
const WRITE_OPERATIONS: string[] = [
|
const WRITE_OPERATIONS: string[] = [
|
||||||
// Block operations
|
// Block operations
|
||||||
BLOCK_OPERATIONS.UPDATE_POSITION,
|
BLOCK_OPERATIONS.UPDATE_POSITION,
|
||||||
@@ -54,7 +51,7 @@ const READ_OPERATIONS: string[] = [
|
|||||||
|
|
||||||
// Define operation permissions based on role
|
// Define operation permissions based on role
|
||||||
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||||
admin: [...ADMIN_ONLY_OPERATIONS, ...WRITE_OPERATIONS],
|
admin: WRITE_OPERATIONS,
|
||||||
write: WRITE_OPERATIONS,
|
write: WRITE_OPERATIONS,
|
||||||
read: READ_OPERATIONS,
|
read: READ_OPERATIONS,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,17 +208,6 @@ export const BatchToggleHandlesSchema = z.object({
|
|||||||
operationId: z.string().optional(),
|
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({
|
export const BatchUpdateParentSchema = z.object({
|
||||||
operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT),
|
operation: z.literal(BLOCKS_OPERATIONS.BATCH_UPDATE_PARENT),
|
||||||
target: z.literal(OPERATION_TARGETS.BLOCKS),
|
target: z.literal(OPERATION_TARGETS.BLOCKS),
|
||||||
@@ -242,7 +231,6 @@ export const WorkflowOperationSchema = z.union([
|
|||||||
BatchRemoveBlocksSchema,
|
BatchRemoveBlocksSchema,
|
||||||
BatchToggleEnabledSchema,
|
BatchToggleEnabledSchema,
|
||||||
BatchToggleHandlesSchema,
|
BatchToggleHandlesSchema,
|
||||||
BatchToggleLockedSchema,
|
|
||||||
BatchUpdateParentSchema,
|
BatchUpdateParentSchema,
|
||||||
EdgeOperationSchema,
|
EdgeOperationSchema,
|
||||||
BatchAddEdgesSchema,
|
BatchAddEdgesSchema,
|
||||||
|
|||||||
@@ -24,40 +24,16 @@ let emitWorkflowOperation:
|
|||||||
| ((operation: string, target: string, payload: any, operationId?: string) => void)
|
| ((operation: string, target: string, payload: any, operationId?: string) => void)
|
||||||
| null = null
|
| null = null
|
||||||
let emitSubblockUpdate:
|
let emitSubblockUpdate:
|
||||||
| ((
|
| ((blockId: string, subblockId: string, value: any, operationId?: string) => void)
|
||||||
blockId: string,
|
|
||||||
subblockId: string,
|
|
||||||
value: any,
|
|
||||||
operationId: string | undefined,
|
|
||||||
workflowId: string
|
|
||||||
) => void)
|
|
||||||
| null = null
|
| null = null
|
||||||
let emitVariableUpdate:
|
let emitVariableUpdate:
|
||||||
| ((
|
| ((variableId: string, field: string, value: any, operationId?: string) => void)
|
||||||
variableId: string,
|
|
||||||
field: string,
|
|
||||||
value: any,
|
|
||||||
operationId: string | undefined,
|
|
||||||
workflowId: string
|
|
||||||
) => void)
|
|
||||||
| null = null
|
| null = null
|
||||||
|
|
||||||
export function registerEmitFunctions(
|
export function registerEmitFunctions(
|
||||||
workflowEmit: (operation: string, target: string, payload: any, operationId?: string) => void,
|
workflowEmit: (operation: string, target: string, payload: any, operationId?: string) => void,
|
||||||
subblockEmit: (
|
subblockEmit: (blockId: string, subblockId: string, value: any, operationId?: string) => void,
|
||||||
blockId: string,
|
variableEmit: (variableId: string, field: string, value: any, operationId?: string) => void,
|
||||||
subblockId: string,
|
|
||||||
value: any,
|
|
||||||
operationId: string | undefined,
|
|
||||||
workflowId: string
|
|
||||||
) => void,
|
|
||||||
variableEmit: (
|
|
||||||
variableId: string,
|
|
||||||
field: string,
|
|
||||||
value: any,
|
|
||||||
operationId: string | undefined,
|
|
||||||
workflowId: string
|
|
||||||
) => void,
|
|
||||||
workflowId: string | null
|
workflowId: string | null
|
||||||
) {
|
) {
|
||||||
emitWorkflowOperation = workflowEmit
|
emitWorkflowOperation = workflowEmit
|
||||||
@@ -220,16 +196,14 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!retryable) {
|
if (!retryable) {
|
||||||
logger.error(
|
logger.debug('Operation marked as non-retryable, removing from queue', { operationId })
|
||||||
'Operation failed with non-retryable error - state out of sync, triggering offline mode',
|
|
||||||
{
|
|
||||||
operationId,
|
|
||||||
operation: operation.operation.operation,
|
|
||||||
target: operation.operation.target,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
get().triggerOfflineMode()
|
set((state) => ({
|
||||||
|
operations: state.operations.filter((op) => op.id !== operationId),
|
||||||
|
isProcessing: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
get().processNextOperation()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,23 +305,11 @@ export const useOperationQueueStore = create<OperationQueueState>((set, get) =>
|
|||||||
const { operation: op, target, payload } = nextOperation.operation
|
const { operation: op, target, payload } = nextOperation.operation
|
||||||
if (op === 'subblock-update' && target === 'subblock') {
|
if (op === 'subblock-update' && target === 'subblock') {
|
||||||
if (emitSubblockUpdate) {
|
if (emitSubblockUpdate) {
|
||||||
emitSubblockUpdate(
|
emitSubblockUpdate(payload.blockId, payload.subblockId, payload.value, nextOperation.id)
|
||||||
payload.blockId,
|
|
||||||
payload.subblockId,
|
|
||||||
payload.value,
|
|
||||||
nextOperation.id,
|
|
||||||
nextOperation.workflowId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else if (op === 'variable-update' && target === 'variable') {
|
} else if (op === 'variable-update' && target === 'variable') {
|
||||||
if (emitVariableUpdate) {
|
if (emitVariableUpdate) {
|
||||||
emitVariableUpdate(
|
emitVariableUpdate(payload.variableId, payload.field, payload.value, nextOperation.id)
|
||||||
payload.variableId,
|
|
||||||
payload.field,
|
|
||||||
payload.value,
|
|
||||||
nextOperation.id,
|
|
||||||
nextOperation.workflowId
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (emitWorkflowOperation) {
|
if (emitWorkflowOperation) {
|
||||||
|
|||||||
@@ -97,14 +97,6 @@ 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 {
|
export interface ApplyDiffOperation extends BaseOperation {
|
||||||
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
|
type: typeof UNDO_REDO_OPERATIONS.APPLY_DIFF
|
||||||
data: {
|
data: {
|
||||||
@@ -144,7 +136,6 @@ export type Operation =
|
|||||||
| BatchUpdateParentOperation
|
| BatchUpdateParentOperation
|
||||||
| BatchToggleEnabledOperation
|
| BatchToggleEnabledOperation
|
||||||
| BatchToggleHandlesOperation
|
| BatchToggleHandlesOperation
|
||||||
| BatchToggleLockedOperation
|
|
||||||
| ApplyDiffOperation
|
| ApplyDiffOperation
|
||||||
| AcceptDiffOperation
|
| AcceptDiffOperation
|
||||||
| RejectDiffOperation
|
| RejectDiffOperation
|
||||||
|
|||||||
@@ -167,15 +167,6 @@ 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: {
|
default: {
|
||||||
const exhaustiveCheck: never = operation
|
const exhaustiveCheck: never = operation
|
||||||
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
throw new Error(`Unhandled operation type: ${(exhaustiveCheck as Operation).type}`)
|
||||||
|
|||||||
@@ -432,104 +432,4 @@ describe('regenerateBlockIds', () => {
|
|||||||
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
|
expect(duplicatedBlock.position).toEqual({ x: 280, y: 70 })
|
||||||
expect(duplicatedBlock.data?.parentId).toBe(loopId)
|
expect(duplicatedBlock.data?.parentId).toBe(loopId)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should unlock pasted block when source is locked', () => {
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Pasted blocks are always unlocked so users can edit them
|
|
||||||
const pastedBlock = newBlocks[0]
|
|
||||||
expect(pastedBlock.locked).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should keep pasted block unlocked when source is unlocked', () => {
|
|
||||||
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 unlock all pasted blocks regardless of source locked state', () => {
|
|
||||||
const lockedId = 'locked-1'
|
|
||||||
const unlockedId = 'unlocked-1'
|
|
||||||
|
|
||||||
const blocksToCopy = {
|
|
||||||
[lockedId]: createAgentBlock({
|
|
||||||
id: lockedId,
|
|
||||||
name: 'Originally Locked Agent',
|
|
||||||
position: { x: 100, y: 50 },
|
|
||||||
locked: true,
|
|
||||||
}),
|
|
||||||
[unlockedId]: createFunctionBlock({
|
|
||||||
id: unlockedId,
|
|
||||||
name: 'Originally 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)
|
|
||||||
|
|
||||||
// All pasted blocks should be unlocked so users can edit them
|
|
||||||
for (const block of newBlocks) {
|
|
||||||
expect(block.locked).toBe(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ export function prepareBlockState(options: PrepareBlockStateOptions): BlockState
|
|||||||
advancedMode: false,
|
advancedMode: false,
|
||||||
triggerMode,
|
triggerMode,
|
||||||
height: 0,
|
height: 0,
|
||||||
locked: false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,8 +481,6 @@ export function regenerateBlockIds(
|
|||||||
position: newPosition,
|
position: newPosition,
|
||||||
// Temporarily keep data as-is, we'll fix parentId in second pass
|
// Temporarily keep data as-is, we'll fix parentId in second pass
|
||||||
data: block.data ? { ...block.data } : block.data,
|
data: block.data ? { ...block.data } : block.data,
|
||||||
// Duplicated blocks are always unlocked so users can edit them
|
|
||||||
locked: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
newBlocks[newId] = newBlock
|
newBlocks[newId] = newBlock
|
||||||
@@ -511,15 +508,15 @@ export function regenerateBlockIds(
|
|||||||
parentId: newParentId,
|
parentId: newParentId,
|
||||||
extent: 'parent',
|
extent: 'parent',
|
||||||
}
|
}
|
||||||
} else if (existingBlockNames[oldParentId] && !existingBlockNames[oldParentId].locked) {
|
} else if (existingBlockNames[oldParentId]) {
|
||||||
// Parent exists in existing workflow and is not locked - keep original parentId
|
// Parent exists in existing workflow - keep original parentId (block stays in same subflow)
|
||||||
block.data = {
|
block.data = {
|
||||||
...block.data,
|
...block.data,
|
||||||
parentId: oldParentId,
|
parentId: oldParentId,
|
||||||
extent: 'parent',
|
extent: 'parent',
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Parent doesn't exist anywhere OR parent is locked - clear the relationship
|
// Parent doesn't exist anywhere - clear the relationship
|
||||||
block.data = { ...block.data, parentId: undefined, extent: undefined }
|
block.data = { ...block.data, parentId: undefined, extent: undefined }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,49 +26,6 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
|||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to add a single block using batchAddBlocks.
|
|
||||||
* Provides a simpler interface for tests.
|
|
||||||
*/
|
|
||||||
function addBlock(
|
|
||||||
id: string,
|
|
||||||
type: string,
|
|
||||||
name: string,
|
|
||||||
position: { x: number; y: number },
|
|
||||||
data?: Record<string, unknown>,
|
|
||||||
parentId?: string,
|
|
||||||
extent?: 'parent',
|
|
||||||
blockProperties?: {
|
|
||||||
enabled?: boolean
|
|
||||||
horizontalHandles?: boolean
|
|
||||||
advancedMode?: boolean
|
|
||||||
triggerMode?: boolean
|
|
||||||
height?: number
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const blockData = {
|
|
||||||
...data,
|
|
||||||
...(parentId && { parentId, extent: extent || 'parent' }),
|
|
||||||
}
|
|
||||||
|
|
||||||
useWorkflowStore.getState().batchAddBlocks([
|
|
||||||
{
|
|
||||||
id,
|
|
||||||
type,
|
|
||||||
name,
|
|
||||||
position,
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: blockProperties?.enabled ?? true,
|
|
||||||
horizontalHandles: blockProperties?.horizontalHandles ?? true,
|
|
||||||
advancedMode: blockProperties?.advancedMode ?? false,
|
|
||||||
triggerMode: blockProperties?.triggerMode ?? false,
|
|
||||||
height: blockProperties?.height ?? 0,
|
|
||||||
data: blockData,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('workflow store', () => {
|
describe('workflow store', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
const localStorageMock = createMockStorage()
|
const localStorageMock = createMockStorage()
|
||||||
@@ -82,8 +39,10 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('batchAddBlocks (via addBlock helper)', () => {
|
describe('addBlock', () => {
|
||||||
it('should add a block with correct default properties', () => {
|
it('should add a block with correct default properties', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('agent-1', 'agent', 'My Agent', { x: 100, y: 200 })
|
addBlock('agent-1', 'agent', 'My Agent', { x: 100, y: 200 })
|
||||||
|
|
||||||
const { blocks } = useWorkflowStore.getState()
|
const { blocks } = useWorkflowStore.getState()
|
||||||
@@ -94,6 +53,8 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should add a block with parent relationship for containers', () => {
|
it('should add a block with parent relationship for containers', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
|
addBlock('loop-1', 'loop', 'My Loop', { x: 0, y: 0 }, { loopType: 'for', count: 3 })
|
||||||
addBlock(
|
addBlock(
|
||||||
'child-1',
|
'child-1',
|
||||||
@@ -112,6 +73,8 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should add multiple blocks correctly', () => {
|
it('should add multiple blocks correctly', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
||||||
addBlock('block-2', 'agent', 'Agent', { x: 200, y: 0 })
|
addBlock('block-2', 'agent', 'Agent', { x: 200, y: 0 })
|
||||||
addBlock('block-3', 'function', 'Function', { x: 400, y: 0 })
|
addBlock('block-3', 'function', 'Function', { x: 400, y: 0 })
|
||||||
@@ -124,6 +87,8 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should create a block with default properties when no blockProperties provided', () => {
|
it('should create a block with default properties when no blockProperties provided', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('agent1', 'agent', 'Test Agent', { x: 100, y: 200 })
|
addBlock('agent1', 'agent', 'Test Agent', { x: 100, y: 200 })
|
||||||
|
|
||||||
const state = useWorkflowStore.getState()
|
const state = useWorkflowStore.getState()
|
||||||
@@ -140,6 +105,8 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should create a block with custom blockProperties for regular blocks', () => {
|
it('should create a block with custom blockProperties for regular blocks', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'agent1',
|
'agent1',
|
||||||
'agent',
|
'agent',
|
||||||
@@ -167,6 +134,8 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should create a loop block with custom blockProperties', () => {
|
it('should create a loop block with custom blockProperties', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'loop1',
|
'loop1',
|
||||||
'loop',
|
'loop',
|
||||||
@@ -194,6 +163,8 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should create a parallel block with custom blockProperties', () => {
|
it('should create a parallel block with custom blockProperties', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'parallel1',
|
'parallel1',
|
||||||
'parallel',
|
'parallel',
|
||||||
@@ -221,6 +192,8 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle partial blockProperties (only some properties provided)', () => {
|
it('should handle partial blockProperties (only some properties provided)', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'agent1',
|
'agent1',
|
||||||
'agent',
|
'agent',
|
||||||
@@ -243,6 +216,8 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle blockProperties with parent relationships', () => {
|
it('should handle blockProperties with parent relationships', () => {
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('loop1', 'loop', 'Parent Loop', { x: 0, y: 0 })
|
addBlock('loop1', 'loop', 'Parent Loop', { x: 0, y: 0 })
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
@@ -274,7 +249,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('batchRemoveBlocks', () => {
|
describe('batchRemoveBlocks', () => {
|
||||||
it('should remove a block', () => {
|
it('should remove a block', () => {
|
||||||
const { batchRemoveBlocks } = useWorkflowStore.getState()
|
const { addBlock, batchRemoveBlocks } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
||||||
batchRemoveBlocks(['block-1'])
|
batchRemoveBlocks(['block-1'])
|
||||||
@@ -284,7 +259,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should remove connected edges when block is removed', () => {
|
it('should remove connected edges when block is removed', () => {
|
||||||
const { batchAddEdges, batchRemoveBlocks } = useWorkflowStore.getState()
|
const { addBlock, batchAddEdges, batchRemoveBlocks } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
||||||
addBlock('block-2', 'function', 'Middle', { x: 200, y: 0 })
|
addBlock('block-2', 'function', 'Middle', { x: 200, y: 0 })
|
||||||
@@ -311,7 +286,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('batchAddEdges', () => {
|
describe('batchAddEdges', () => {
|
||||||
it('should add an edge between two blocks', () => {
|
it('should add an edge between two blocks', () => {
|
||||||
const { batchAddEdges } = useWorkflowStore.getState()
|
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
||||||
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
||||||
@@ -323,7 +298,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not add duplicate connections', () => {
|
it('should not add duplicate connections', () => {
|
||||||
const { batchAddEdges } = useWorkflowStore.getState()
|
const { addBlock, batchAddEdges } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
||||||
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
||||||
@@ -338,7 +313,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('batchRemoveEdges', () => {
|
describe('batchRemoveEdges', () => {
|
||||||
it('should remove an edge by id', () => {
|
it('should remove an edge by id', () => {
|
||||||
const { batchAddEdges, batchRemoveEdges } = useWorkflowStore.getState()
|
const { addBlock, batchAddEdges, batchRemoveEdges } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
||||||
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
||||||
@@ -360,7 +335,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('clear', () => {
|
describe('clear', () => {
|
||||||
it('should clear all blocks and edges', () => {
|
it('should clear all blocks and edges', () => {
|
||||||
const { batchAddEdges, clear } = useWorkflowStore.getState()
|
const { addBlock, batchAddEdges, clear } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
||||||
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
||||||
@@ -376,7 +351,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('batchToggleEnabled', () => {
|
describe('batchToggleEnabled', () => {
|
||||||
it('should toggle block enabled state', () => {
|
it('should toggle block enabled state', () => {
|
||||||
const { batchToggleEnabled } = useWorkflowStore.getState()
|
const { addBlock, batchToggleEnabled } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
||||||
|
|
||||||
@@ -392,7 +367,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('duplicateBlock', () => {
|
describe('duplicateBlock', () => {
|
||||||
it('should duplicate a block', () => {
|
it('should duplicate a block', () => {
|
||||||
const { duplicateBlock } = useWorkflowStore.getState()
|
const { addBlock, duplicateBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
|
addBlock('original', 'agent', 'Original Agent', { x: 0, y: 0 })
|
||||||
|
|
||||||
@@ -416,7 +391,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('batchUpdatePositions', () => {
|
describe('batchUpdatePositions', () => {
|
||||||
it('should update block position', () => {
|
it('should update block position', () => {
|
||||||
const { batchUpdatePositions } = useWorkflowStore.getState()
|
const { addBlock, batchUpdatePositions } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
addBlock('block-1', 'function', 'Test', { x: 0, y: 0 })
|
||||||
|
|
||||||
@@ -429,7 +404,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('loop management', () => {
|
describe('loop management', () => {
|
||||||
it('should regenerate loops when updateLoopCount is called', () => {
|
it('should regenerate loops when updateLoopCount is called', () => {
|
||||||
const { updateLoopCount } = useWorkflowStore.getState()
|
const { addBlock, updateLoopCount } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'loop1',
|
'loop1',
|
||||||
@@ -453,7 +428,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should regenerate loops when updateLoopType is called', () => {
|
it('should regenerate loops when updateLoopType is called', () => {
|
||||||
const { updateLoopType } = useWorkflowStore.getState()
|
const { addBlock, updateLoopType } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'loop1',
|
'loop1',
|
||||||
@@ -478,7 +453,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should regenerate loops when updateLoopCollection is called', () => {
|
it('should regenerate loops when updateLoopCollection is called', () => {
|
||||||
const { updateLoopCollection } = useWorkflowStore.getState()
|
const { addBlock, updateLoopCollection } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'loop1',
|
'loop1',
|
||||||
@@ -501,7 +476,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should clamp loop count between 1 and 1000', () => {
|
it('should clamp loop count between 1 and 1000', () => {
|
||||||
const { updateLoopCount } = useWorkflowStore.getState()
|
const { addBlock, updateLoopCount } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'loop1',
|
'loop1',
|
||||||
@@ -527,7 +502,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('parallel management', () => {
|
describe('parallel management', () => {
|
||||||
it('should regenerate parallels when updateParallelCount is called', () => {
|
it('should regenerate parallels when updateParallelCount is called', () => {
|
||||||
const { updateParallelCount } = useWorkflowStore.getState()
|
const { addBlock, updateParallelCount } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'parallel1',
|
'parallel1',
|
||||||
@@ -550,7 +525,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should regenerate parallels when updateParallelCollection is called', () => {
|
it('should regenerate parallels when updateParallelCollection is called', () => {
|
||||||
const { updateParallelCollection } = useWorkflowStore.getState()
|
const { addBlock, updateParallelCollection } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'parallel1',
|
'parallel1',
|
||||||
@@ -577,7 +552,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should clamp parallel count between 1 and 20', () => {
|
it('should clamp parallel count between 1 and 20', () => {
|
||||||
const { updateParallelCount } = useWorkflowStore.getState()
|
const { addBlock, updateParallelCount } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'parallel1',
|
'parallel1',
|
||||||
@@ -600,7 +575,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should regenerate parallels when updateParallelType is called', () => {
|
it('should regenerate parallels when updateParallelType is called', () => {
|
||||||
const { updateParallelType } = useWorkflowStore.getState()
|
const { addBlock, updateParallelType } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock(
|
addBlock(
|
||||||
'parallel1',
|
'parallel1',
|
||||||
@@ -626,7 +601,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('mode switching', () => {
|
describe('mode switching', () => {
|
||||||
it('should toggle advanced mode on a block', () => {
|
it('should toggle advanced mode on a block', () => {
|
||||||
const { toggleBlockAdvancedMode } = useWorkflowStore.getState()
|
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
|
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
|
||||||
|
|
||||||
@@ -643,7 +618,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve systemPrompt and userPrompt when switching modes', () => {
|
it('should preserve systemPrompt and userPrompt when switching modes', () => {
|
||||||
const { toggleBlockAdvancedMode } = useWorkflowStore.getState()
|
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
|
||||||
const { setState: setSubBlockState } = useSubBlockStore
|
const { setState: setSubBlockState } = useSubBlockStore
|
||||||
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
|
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
|
||||||
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
|
addBlock('agent1', 'agent', 'Test Agent', { x: 0, y: 0 })
|
||||||
@@ -676,7 +651,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should preserve memories when switching from advanced to basic mode', () => {
|
it('should preserve memories when switching from advanced to basic mode', () => {
|
||||||
const { toggleBlockAdvancedMode } = useWorkflowStore.getState()
|
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
|
||||||
const { setState: setSubBlockState } = useSubBlockStore
|
const { setState: setSubBlockState } = useSubBlockStore
|
||||||
|
|
||||||
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
|
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
|
||||||
@@ -716,7 +691,7 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle mode switching when no subblock values exist', () => {
|
it('should handle mode switching when no subblock values exist', () => {
|
||||||
const { toggleBlockAdvancedMode } = useWorkflowStore.getState()
|
const { addBlock, toggleBlockAdvancedMode } = useWorkflowStore.getState()
|
||||||
|
|
||||||
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
|
useWorkflowRegistry.setState({ activeWorkflowId: 'test-workflow' })
|
||||||
|
|
||||||
@@ -778,7 +753,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('replaceWorkflowState', () => {
|
describe('replaceWorkflowState', () => {
|
||||||
it('should replace entire workflow state', () => {
|
it('should replace entire workflow state', () => {
|
||||||
const { replaceWorkflowState } = useWorkflowStore.getState()
|
const { addBlock, replaceWorkflowState } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('old-1', 'function', 'Old', { x: 0, y: 0 })
|
addBlock('old-1', 'function', 'Old', { x: 0, y: 0 })
|
||||||
|
|
||||||
@@ -794,7 +769,7 @@ describe('workflow store', () => {
|
|||||||
|
|
||||||
describe('getWorkflowState', () => {
|
describe('getWorkflowState', () => {
|
||||||
it('should return current workflow state', () => {
|
it('should return current workflow state', () => {
|
||||||
const { getWorkflowState } = useWorkflowStore.getState()
|
const { addBlock, getWorkflowState } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
addBlock('block-1', 'starter', 'Start', { x: 0, y: 0 })
|
||||||
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
addBlock('block-2', 'function', 'End', { x: 200, y: 0 })
|
||||||
@@ -807,560 +782,6 @@ describe('workflow store', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('loop/parallel regeneration optimization', () => {
|
|
||||||
it('should NOT regenerate loops when adding a regular block without parentId', () => {
|
|
||||||
// Add a loop first
|
|
||||||
addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 })
|
|
||||||
|
|
||||||
const stateAfterLoop = useWorkflowStore.getState()
|
|
||||||
const loopsAfterLoop = stateAfterLoop.loops
|
|
||||||
|
|
||||||
// Add a regular block (no parentId)
|
|
||||||
addBlock('agent-1', 'agent', 'Agent 1', { x: 200, y: 0 })
|
|
||||||
|
|
||||||
const stateAfterAgent = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Loops should be unchanged (same content)
|
|
||||||
expect(Object.keys(stateAfterAgent.loops)).toEqual(Object.keys(loopsAfterLoop))
|
|
||||||
expect(stateAfterAgent.loops['loop-1'].nodes).toEqual(loopsAfterLoop['loop-1'].nodes)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should regenerate loops when adding a child to a loop', () => {
|
|
||||||
// Add a loop
|
|
||||||
addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 })
|
|
||||||
|
|
||||||
const stateAfterLoop = useWorkflowStore.getState()
|
|
||||||
expect(stateAfterLoop.loops['loop-1'].nodes).toEqual([])
|
|
||||||
|
|
||||||
// Add a child block to the loop
|
|
||||||
addBlock(
|
|
||||||
'child-1',
|
|
||||||
'function',
|
|
||||||
'Child 1',
|
|
||||||
{ x: 50, y: 50 },
|
|
||||||
{ parentId: 'loop-1' },
|
|
||||||
'loop-1',
|
|
||||||
'parent'
|
|
||||||
)
|
|
||||||
|
|
||||||
const stateAfterChild = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Loop should now include the child
|
|
||||||
expect(stateAfterChild.loops['loop-1'].nodes).toContain('child-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should NOT regenerate parallels when adding a child to a loop', () => {
|
|
||||||
// Add both a loop and a parallel
|
|
||||||
addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 })
|
|
||||||
addBlock('parallel-1', 'parallel', 'Parallel 1', { x: 300, y: 0 }, { count: 3 })
|
|
||||||
|
|
||||||
const stateAfterContainers = useWorkflowStore.getState()
|
|
||||||
const parallelsAfterContainers = stateAfterContainers.parallels
|
|
||||||
|
|
||||||
// Add a child to the loop (not the parallel)
|
|
||||||
addBlock(
|
|
||||||
'child-1',
|
|
||||||
'function',
|
|
||||||
'Child 1',
|
|
||||||
{ x: 50, y: 50 },
|
|
||||||
{ parentId: 'loop-1' },
|
|
||||||
'loop-1',
|
|
||||||
'parent'
|
|
||||||
)
|
|
||||||
|
|
||||||
const stateAfterChild = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Parallels should be unchanged
|
|
||||||
expect(stateAfterChild.parallels['parallel-1'].nodes).toEqual(
|
|
||||||
parallelsAfterContainers['parallel-1'].nodes
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should regenerate parallels when adding a child to a parallel', () => {
|
|
||||||
// Add a parallel
|
|
||||||
addBlock('parallel-1', 'parallel', 'Parallel 1', { x: 0, y: 0 }, { count: 3 })
|
|
||||||
|
|
||||||
const stateAfterParallel = useWorkflowStore.getState()
|
|
||||||
expect(stateAfterParallel.parallels['parallel-1'].nodes).toEqual([])
|
|
||||||
|
|
||||||
// Add a child block to the parallel
|
|
||||||
addBlock(
|
|
||||||
'child-1',
|
|
||||||
'function',
|
|
||||||
'Child 1',
|
|
||||||
{ x: 50, y: 50 },
|
|
||||||
{ parentId: 'parallel-1' },
|
|
||||||
'parallel-1',
|
|
||||||
'parent'
|
|
||||||
)
|
|
||||||
|
|
||||||
const stateAfterChild = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Parallel should now include the child
|
|
||||||
expect(stateAfterChild.parallels['parallel-1'].nodes).toContain('child-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle adding blocks in any order and produce correct final state', () => {
|
|
||||||
// Add child BEFORE the loop (simulating undo-redo edge case)
|
|
||||||
// Note: The child's parentId points to a loop that doesn't exist yet
|
|
||||||
addBlock(
|
|
||||||
'child-1',
|
|
||||||
'function',
|
|
||||||
'Child 1',
|
|
||||||
{ x: 50, y: 50 },
|
|
||||||
{ parentId: 'loop-1' },
|
|
||||||
'loop-1',
|
|
||||||
'parent'
|
|
||||||
)
|
|
||||||
|
|
||||||
// At this point, the child exists but loop doesn't
|
|
||||||
const stateAfterChild = useWorkflowStore.getState()
|
|
||||||
expect(stateAfterChild.blocks['child-1']).toBeDefined()
|
|
||||||
expect(stateAfterChild.loops['loop-1']).toBeUndefined()
|
|
||||||
|
|
||||||
// Now add the loop
|
|
||||||
addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 })
|
|
||||||
|
|
||||||
// Final state should be correct - loop should include the child
|
|
||||||
const finalState = useWorkflowStore.getState()
|
|
||||||
expect(finalState.loops['loop-1']).toBeDefined()
|
|
||||||
expect(finalState.loops['loop-1'].nodes).toContain('child-1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('batchAddBlocks optimization', () => {
|
|
||||||
it('should NOT regenerate loops/parallels when adding regular blocks', () => {
|
|
||||||
const { batchAddBlocks } = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Set up initial state with a loop
|
|
||||||
useWorkflowStore.setState({
|
|
||||||
blocks: {
|
|
||||||
'loop-1': {
|
|
||||||
id: 'loop-1',
|
|
||||||
type: 'loop',
|
|
||||||
name: 'Loop 1',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: true,
|
|
||||||
horizontalHandles: true,
|
|
||||||
advancedMode: false,
|
|
||||||
triggerMode: false,
|
|
||||||
height: 0,
|
|
||||||
data: { loopType: 'for', count: 5 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
edges: [],
|
|
||||||
loops: {
|
|
||||||
'loop-1': {
|
|
||||||
id: 'loop-1',
|
|
||||||
nodes: [],
|
|
||||||
iterations: 5,
|
|
||||||
loopType: 'for',
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
parallels: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
const stateBefore = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Add regular blocks (no parentId, not loop/parallel type)
|
|
||||||
batchAddBlocks([
|
|
||||||
{
|
|
||||||
id: 'agent-1',
|
|
||||||
type: 'agent',
|
|
||||||
name: 'Agent 1',
|
|
||||||
position: { x: 200, y: 0 },
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'function-1',
|
|
||||||
type: 'function',
|
|
||||||
name: 'Function 1',
|
|
||||||
position: { x: 400, y: 0 },
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const stateAfter = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Loops should be unchanged
|
|
||||||
expect(stateAfter.loops['loop-1'].nodes).toEqual(stateBefore.loops['loop-1'].nodes)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should regenerate loops when batch adding a loop block', () => {
|
|
||||||
const { batchAddBlocks } = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
batchAddBlocks([
|
|
||||||
{
|
|
||||||
id: 'loop-1',
|
|
||||||
type: 'loop',
|
|
||||||
name: 'Loop 1',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: true,
|
|
||||||
data: { loopType: 'for', count: 5 },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const state = useWorkflowStore.getState()
|
|
||||||
expect(state.loops['loop-1']).toBeDefined()
|
|
||||||
expect(state.loops['loop-1'].iterations).toBe(5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should regenerate loops when batch adding a child of a loop', () => {
|
|
||||||
const { batchAddBlocks } = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// First add a loop
|
|
||||||
batchAddBlocks([
|
|
||||||
{
|
|
||||||
id: 'loop-1',
|
|
||||||
type: 'loop',
|
|
||||||
name: 'Loop 1',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: true,
|
|
||||||
data: { loopType: 'for', count: 5 },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// Then add a child
|
|
||||||
batchAddBlocks([
|
|
||||||
{
|
|
||||||
id: 'child-1',
|
|
||||||
type: 'function',
|
|
||||||
name: 'Child 1',
|
|
||||||
position: { x: 50, y: 50 },
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: true,
|
|
||||||
data: { parentId: 'loop-1' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const state = useWorkflowStore.getState()
|
|
||||||
expect(state.loops['loop-1'].nodes).toContain('child-1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should correctly handle batch adding loop and its children together', () => {
|
|
||||||
const { batchAddBlocks } = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Add loop and child in same batch
|
|
||||||
batchAddBlocks([
|
|
||||||
{
|
|
||||||
id: 'loop-1',
|
|
||||||
type: 'loop',
|
|
||||||
name: 'Loop 1',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: true,
|
|
||||||
data: { loopType: 'for', count: 5 },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'child-1',
|
|
||||||
type: 'function',
|
|
||||||
name: 'Child 1',
|
|
||||||
position: { x: 50, y: 50 },
|
|
||||||
subBlocks: {},
|
|
||||||
outputs: {},
|
|
||||||
enabled: true,
|
|
||||||
data: { parentId: 'loop-1' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const state = useWorkflowStore.getState()
|
|
||||||
expect(state.loops['loop-1']).toBeDefined()
|
|
||||||
expect(state.loops['loop-1'].nodes).toContain('child-1')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('edge operations should not affect loops/parallels', () => {
|
|
||||||
it('should preserve loops when adding edges', () => {
|
|
||||||
const { batchAddEdges } = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Create a loop with a child
|
|
||||||
addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 })
|
|
||||||
addBlock(
|
|
||||||
'child-1',
|
|
||||||
'function',
|
|
||||||
'Child 1',
|
|
||||||
{ x: 50, y: 50 },
|
|
||||||
{ parentId: 'loop-1' },
|
|
||||||
'loop-1',
|
|
||||||
'parent'
|
|
||||||
)
|
|
||||||
addBlock('external-1', 'function', 'External', { x: 300, y: 0 })
|
|
||||||
|
|
||||||
const stateBeforeEdge = useWorkflowStore.getState()
|
|
||||||
const loopsBeforeEdge = stateBeforeEdge.loops
|
|
||||||
|
|
||||||
// Add an edge (should not affect loops)
|
|
||||||
batchAddEdges([{ id: 'e1', source: 'loop-1', target: 'external-1' }])
|
|
||||||
|
|
||||||
const stateAfterEdge = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Loops should be unchanged
|
|
||||||
expect(stateAfterEdge.loops['loop-1'].nodes).toEqual(loopsBeforeEdge['loop-1'].nodes)
|
|
||||||
expect(stateAfterEdge.loops['loop-1'].iterations).toEqual(
|
|
||||||
loopsBeforeEdge['loop-1'].iterations
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should preserve loops when removing edges', () => {
|
|
||||||
const { batchAddEdges, batchRemoveEdges } = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Create a loop with a child and an edge
|
|
||||||
addBlock('loop-1', 'loop', 'Loop 1', { x: 0, y: 0 }, { loopType: 'for', count: 5 })
|
|
||||||
addBlock(
|
|
||||||
'child-1',
|
|
||||||
'function',
|
|
||||||
'Child 1',
|
|
||||||
{ x: 50, y: 50 },
|
|
||||||
{ parentId: 'loop-1' },
|
|
||||||
'loop-1',
|
|
||||||
'parent'
|
|
||||||
)
|
|
||||||
addBlock('external-1', 'function', 'External', { x: 300, y: 0 })
|
|
||||||
batchAddEdges([{ id: 'e1', source: 'loop-1', target: 'external-1' }])
|
|
||||||
|
|
||||||
const stateBeforeRemove = useWorkflowStore.getState()
|
|
||||||
const loopsBeforeRemove = stateBeforeRemove.loops
|
|
||||||
|
|
||||||
// Remove the edge
|
|
||||||
batchRemoveEdges(['e1'])
|
|
||||||
|
|
||||||
const stateAfterRemove = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Loops should be unchanged
|
|
||||||
expect(stateAfterRemove.loops['loop-1'].nodes).toEqual(loopsBeforeRemove['loop-1'].nodes)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('batchToggleLocked', () => {
|
|
||||||
it('should toggle block locked state', () => {
|
|
||||||
const { 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 { 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 { 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 { 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 { 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 { 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 unlock duplicate when duplicating a locked block', () => {
|
|
||||||
const { 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 be unlocked so users can edit it
|
|
||||||
expect(blocks[duplicatedId].locked).toBe(false)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should create unlocked duplicate when duplicating an unlocked block', () => {
|
|
||||||
const { 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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should place duplicate outside locked container when duplicating block inside locked loop', () => {
|
|
||||||
const { batchToggleLocked, duplicateBlock } = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Create a loop with a child block
|
|
||||||
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'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Lock the loop (which cascades to the child)
|
|
||||||
batchToggleLocked(['loop-1'])
|
|
||||||
expect(useWorkflowStore.getState().blocks['child-1'].locked).toBe(true)
|
|
||||||
|
|
||||||
// Duplicate the child block
|
|
||||||
duplicateBlock('child-1')
|
|
||||||
|
|
||||||
const { blocks } = useWorkflowStore.getState()
|
|
||||||
const blockIds = Object.keys(blocks)
|
|
||||||
|
|
||||||
expect(blockIds.length).toBe(3) // loop, original child, duplicate
|
|
||||||
|
|
||||||
const duplicatedId = blockIds.find((id) => id !== 'loop-1' && id !== 'child-1')
|
|
||||||
expect(duplicatedId).toBeDefined()
|
|
||||||
|
|
||||||
if (duplicatedId) {
|
|
||||||
// Duplicate should be unlocked
|
|
||||||
expect(blocks[duplicatedId].locked).toBe(false)
|
|
||||||
// Duplicate should NOT have a parentId (placed outside the locked container)
|
|
||||||
expect(blocks[duplicatedId].data?.parentId).toBeUndefined()
|
|
||||||
// Original should still be inside the loop
|
|
||||||
expect(blocks['child-1'].data?.parentId).toBe('loop-1')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should keep duplicate inside unlocked container when duplicating block inside unlocked loop', () => {
|
|
||||||
const { duplicateBlock } = useWorkflowStore.getState()
|
|
||||||
|
|
||||||
// Create a loop with a child block (not locked)
|
|
||||||
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'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Duplicate the child block (loop is NOT locked)
|
|
||||||
duplicateBlock('child-1')
|
|
||||||
|
|
||||||
const { blocks } = useWorkflowStore.getState()
|
|
||||||
const blockIds = Object.keys(blocks)
|
|
||||||
const duplicatedId = blockIds.find((id) => id !== 'loop-1' && id !== 'child-1')
|
|
||||||
|
|
||||||
if (duplicatedId) {
|
|
||||||
// Duplicate should still be inside the loop since it's not locked
|
|
||||||
expect(blocks[duplicatedId].data?.parentId).toBe('loop-1')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('updateBlockName', () => {
|
describe('updateBlockName', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useWorkflowStore.setState({
|
useWorkflowStore.setState({
|
||||||
@@ -1370,6 +791,8 @@ describe('workflow store', () => {
|
|||||||
parallels: {},
|
parallels: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { addBlock } = useWorkflowStore.getState()
|
||||||
|
|
||||||
addBlock('block1', 'agent', 'Column AD', { x: 0, y: 0 })
|
addBlock('block1', 'agent', 'Column AD', { x: 0, y: 0 })
|
||||||
addBlock('block2', 'function', 'Employee Length', { x: 100, y: 0 })
|
addBlock('block2', 'function', 'Employee Length', { x: 100, y: 0 })
|
||||||
addBlock('block3', 'starter', 'Start', { x: 200, y: 0 })
|
addBlock('block3', 'starter', 'Start', { x: 200, y: 0 })
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Edge } from 'reactflow'
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { devtools } from 'zustand/middleware'
|
import { devtools } from 'zustand/middleware'
|
||||||
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
import { DEFAULT_DUPLICATE_OFFSET } from '@/lib/workflows/autolayout/constants'
|
||||||
|
import { getBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
|
||||||
|
import { getBlock } from '@/blocks'
|
||||||
import type { SubBlockConfig } from '@/blocks/types'
|
import type { SubBlockConfig } from '@/blocks/types'
|
||||||
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
|
import { normalizeName, RESERVED_BLOCK_NAMES } from '@/executor/constants'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
@@ -112,6 +114,135 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
set({ needsRedeployment })
|
set({ needsRedeployment })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addBlock: (
|
||||||
|
id: string,
|
||||||
|
type: string,
|
||||||
|
name: string,
|
||||||
|
position: Position,
|
||||||
|
data?: Record<string, any>,
|
||||||
|
parentId?: string,
|
||||||
|
extent?: 'parent',
|
||||||
|
blockProperties?: {
|
||||||
|
enabled?: boolean
|
||||||
|
horizontalHandles?: boolean
|
||||||
|
advancedMode?: boolean
|
||||||
|
triggerMode?: boolean
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const blockConfig = getBlock(type)
|
||||||
|
// For custom nodes like loop and parallel that don't use BlockConfig
|
||||||
|
if (!blockConfig && (type === 'loop' || type === 'parallel')) {
|
||||||
|
// Merge parentId and extent into data if provided
|
||||||
|
const nodeData = {
|
||||||
|
...data,
|
||||||
|
...(parentId && { parentId, extent: extent || 'parent' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
blocks: {
|
||||||
|
...get().blocks,
|
||||||
|
[id]: {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
position,
|
||||||
|
subBlocks: {},
|
||||||
|
outputs: {},
|
||||||
|
enabled: blockProperties?.enabled ?? true,
|
||||||
|
horizontalHandles: blockProperties?.horizontalHandles ?? true,
|
||||||
|
advancedMode: blockProperties?.advancedMode ?? false,
|
||||||
|
triggerMode: blockProperties?.triggerMode ?? false,
|
||||||
|
height: blockProperties?.height ?? 0,
|
||||||
|
data: nodeData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
edges: [...get().edges],
|
||||||
|
loops: get().generateLoopBlocks(),
|
||||||
|
parallels: get().generateParallelBlocks(),
|
||||||
|
}
|
||||||
|
|
||||||
|
set(newState)
|
||||||
|
get().updateLastSaved()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!blockConfig) return
|
||||||
|
|
||||||
|
// Merge parentId and extent into data for regular blocks
|
||||||
|
const nodeData = {
|
||||||
|
...data,
|
||||||
|
...(parentId && { parentId, extent: extent || 'parent' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const subBlocks: Record<string, SubBlockState> = {}
|
||||||
|
const subBlockStore = useSubBlockStore.getState()
|
||||||
|
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||||
|
|
||||||
|
blockConfig.subBlocks.forEach((subBlock) => {
|
||||||
|
const subBlockId = subBlock.id
|
||||||
|
const initialValue = resolveInitialSubblockValue(subBlock)
|
||||||
|
const normalizedValue =
|
||||||
|
initialValue !== undefined && initialValue !== null ? initialValue : null
|
||||||
|
|
||||||
|
subBlocks[subBlockId] = {
|
||||||
|
id: subBlockId,
|
||||||
|
type: subBlock.type,
|
||||||
|
value: normalizedValue as SubBlockState['value'],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeWorkflowId) {
|
||||||
|
try {
|
||||||
|
const valueToStore =
|
||||||
|
initialValue !== undefined ? cloneInitialSubblockValue(initialValue) : null
|
||||||
|
subBlockStore.setValue(id, subBlockId, valueToStore)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to seed sub-block store value during block creation', {
|
||||||
|
blockId: id,
|
||||||
|
subBlockId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn('Cannot seed sub-block store value: activeWorkflowId not available', {
|
||||||
|
blockId: id,
|
||||||
|
subBlockId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get outputs based on trigger mode
|
||||||
|
const triggerMode = blockProperties?.triggerMode ?? false
|
||||||
|
const outputs = getBlockOutputs(type, subBlocks, triggerMode)
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
blocks: {
|
||||||
|
...get().blocks,
|
||||||
|
[id]: {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
position,
|
||||||
|
subBlocks,
|
||||||
|
outputs,
|
||||||
|
enabled: blockProperties?.enabled ?? true,
|
||||||
|
horizontalHandles: blockProperties?.horizontalHandles ?? true,
|
||||||
|
advancedMode: blockProperties?.advancedMode ?? false,
|
||||||
|
triggerMode: triggerMode,
|
||||||
|
height: blockProperties?.height ?? 0,
|
||||||
|
layout: {},
|
||||||
|
data: nodeData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
edges: [...get().edges],
|
||||||
|
loops: get().generateLoopBlocks(),
|
||||||
|
parallels: get().generateParallelBlocks(),
|
||||||
|
}
|
||||||
|
|
||||||
|
set(newState)
|
||||||
|
get().updateLastSaved()
|
||||||
|
},
|
||||||
|
|
||||||
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => {
|
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const block = state.blocks[id]
|
const block = state.blocks[id]
|
||||||
@@ -207,7 +338,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
triggerMode?: boolean
|
triggerMode?: boolean
|
||||||
height?: number
|
height?: number
|
||||||
data?: Record<string, any>
|
data?: Record<string, any>
|
||||||
locked?: boolean
|
|
||||||
}>,
|
}>,
|
||||||
edges?: Edge[],
|
edges?: Edge[],
|
||||||
subBlockValues?: Record<string, Record<string, unknown>>,
|
subBlockValues?: Record<string, Record<string, unknown>>,
|
||||||
@@ -232,7 +362,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
triggerMode: block.triggerMode ?? false,
|
triggerMode: block.triggerMode ?? false,
|
||||||
height: block.height ?? 0,
|
height: block.height ?? 0,
|
||||||
data: block.data,
|
data: block.data,
|
||||||
locked: block.locked ?? false,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,27 +386,11 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only regenerate loops/parallels if we're adding blocks that affect them:
|
|
||||||
// - Adding a loop/parallel container block
|
|
||||||
// - Adding a block as a child of a loop/parallel (has parentId pointing to one)
|
|
||||||
const needsLoopRegeneration = blocks.some(
|
|
||||||
(block) =>
|
|
||||||
block.type === 'loop' ||
|
|
||||||
(block.data?.parentId && newBlocks[block.data.parentId]?.type === 'loop')
|
|
||||||
)
|
|
||||||
const needsParallelRegeneration = blocks.some(
|
|
||||||
(block) =>
|
|
||||||
block.type === 'parallel' ||
|
|
||||||
(block.data?.parentId && newBlocks[block.data.parentId]?.type === 'parallel')
|
|
||||||
)
|
|
||||||
|
|
||||||
set({
|
set({
|
||||||
blocks: newBlocks,
|
blocks: newBlocks,
|
||||||
edges: newEdges,
|
edges: newEdges,
|
||||||
loops: needsLoopRegeneration ? generateLoopBlocks(newBlocks) : { ...get().loops },
|
loops: generateLoopBlocks(newBlocks),
|
||||||
parallels: needsParallelRegeneration
|
parallels: generateParallelBlocks(newBlocks),
|
||||||
? generateParallelBlocks(newBlocks)
|
|
||||||
: { ...get().parallels },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
|
if (subBlockValues && Object.keys(subBlockValues).length > 0) {
|
||||||
@@ -367,69 +480,24 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
batchToggleEnabled: (ids: string[]) => {
|
batchToggleEnabled: (ids: string[]) => {
|
||||||
if (ids.length === 0) return
|
const newBlocks = { ...get().blocks }
|
||||||
|
|
||||||
const currentBlocks = get().blocks
|
|
||||||
const newBlocks = { ...currentBlocks }
|
|
||||||
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
|
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const block = currentBlocks[id]
|
if (newBlocks[id]) {
|
||||||
if (!block) continue
|
newBlocks[id] = { ...newBlocks[id], enabled: !newBlocks[id].enabled }
|
||||||
|
|
||||||
// Skip locked blocks entirely (including their children)
|
|
||||||
if (block.locked) continue
|
|
||||||
|
|
||||||
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] })
|
set({ blocks: newBlocks, edges: [...get().edges] })
|
||||||
get().updateLastSaved()
|
get().updateLastSaved()
|
||||||
},
|
},
|
||||||
|
|
||||||
batchToggleHandles: (ids: string[]) => {
|
batchToggleHandles: (ids: string[]) => {
|
||||||
const currentBlocks = get().blocks
|
const newBlocks = { ...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) {
|
for (const id of ids) {
|
||||||
if (!newBlocks[id] || isProtected(id)) continue
|
if (newBlocks[id]) {
|
||||||
newBlocks[id] = {
|
newBlocks[id] = {
|
||||||
...newBlocks[id],
|
...newBlocks[id],
|
||||||
horizontalHandles: !newBlocks[id].horizontalHandles,
|
horizontalHandles: !newBlocks[id].horizontalHandles,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
set({ blocks: newBlocks, edges: [...get().edges] })
|
set({ blocks: newBlocks, edges: [...get().edges] })
|
||||||
@@ -461,9 +529,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
set({
|
set({
|
||||||
blocks: { ...blocks },
|
blocks: { ...blocks },
|
||||||
edges: newEdges,
|
edges: newEdges,
|
||||||
// Edges don't affect loop/parallel structure (determined by parentId), skip regeneration
|
loops: generateLoopBlocks(blocks),
|
||||||
loops: { ...get().loops },
|
parallels: generateParallelBlocks(blocks),
|
||||||
parallels: { ...get().parallels },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
get().updateLastSaved()
|
get().updateLastSaved()
|
||||||
@@ -477,9 +544,8 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
set({
|
set({
|
||||||
blocks: { ...blocks },
|
blocks: { ...blocks },
|
||||||
edges: newEdges,
|
edges: newEdges,
|
||||||
// Edges don't affect loop/parallel structure (determined by parentId), skip regeneration
|
loops: generateLoopBlocks(blocks),
|
||||||
loops: { ...get().loops },
|
parallels: generateParallelBlocks(blocks),
|
||||||
parallels: { ...get().parallels },
|
|
||||||
})
|
})
|
||||||
|
|
||||||
get().updateLastSaved()
|
get().updateLastSaved()
|
||||||
@@ -574,33 +640,9 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
if (!block) return
|
if (!block) return
|
||||||
|
|
||||||
const newId = crypto.randomUUID()
|
const newId = crypto.randomUUID()
|
||||||
|
const offsetPosition = {
|
||||||
// Check if block is inside a locked container - if so, place duplicate outside
|
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
|
||||||
const parentId = block.data?.parentId
|
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
|
||||||
const parentBlock = parentId ? get().blocks[parentId] : undefined
|
|
||||||
const isParentLocked = parentBlock?.locked ?? false
|
|
||||||
|
|
||||||
// If parent is locked, calculate position outside the container
|
|
||||||
let offsetPosition: Position
|
|
||||||
const newData = block.data ? { ...block.data } : undefined
|
|
||||||
|
|
||||||
if (isParentLocked && parentBlock) {
|
|
||||||
// Place duplicate outside the locked container (to the right of it)
|
|
||||||
const containerWidth = parentBlock.data?.width ?? 400
|
|
||||||
offsetPosition = {
|
|
||||||
x: parentBlock.position.x + containerWidth + 50,
|
|
||||||
y: parentBlock.position.y,
|
|
||||||
}
|
|
||||||
// Remove parent relationship since we're placing outside
|
|
||||||
if (newData) {
|
|
||||||
newData.parentId = undefined
|
|
||||||
newData.extent = undefined
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
offsetPosition = {
|
|
||||||
x: block.position.x + DEFAULT_DUPLICATE_OFFSET.x,
|
|
||||||
y: block.position.y + DEFAULT_DUPLICATE_OFFSET.y,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newName = getUniqueBlockName(block.name, get().blocks)
|
const newName = getUniqueBlockName(block.name, get().blocks)
|
||||||
@@ -628,8 +670,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
name: newName,
|
name: newName,
|
||||||
position: offsetPosition,
|
position: offsetPosition,
|
||||||
subBlocks: newSubBlocks,
|
subBlocks: newSubBlocks,
|
||||||
locked: false,
|
|
||||||
data: newData,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
edges: [...get().edges],
|
edges: [...get().edges],
|
||||||
@@ -1237,70 +1277,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
|||||||
getDragStartPosition: () => {
|
getDragStartPosition: () => {
|
||||||
return get().dragStartPosition || null
|
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' }
|
{ name: 'workflow-store' }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -87,7 +87,6 @@ export interface BlockState {
|
|||||||
triggerMode?: boolean
|
triggerMode?: boolean
|
||||||
data?: BlockData
|
data?: BlockData
|
||||||
layout?: BlockLayoutState
|
layout?: BlockLayoutState
|
||||||
locked?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubBlockState {
|
export interface SubBlockState {
|
||||||
@@ -132,7 +131,6 @@ export interface Loop {
|
|||||||
whileCondition?: string // JS expression that evaluates to boolean (for while loops)
|
whileCondition?: string // JS expression that evaluates to boolean (for while loops)
|
||||||
doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops)
|
doWhileCondition?: string // JS expression that evaluates to boolean (for do-while loops)
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
locked?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Parallel {
|
export interface Parallel {
|
||||||
@@ -142,7 +140,6 @@ export interface Parallel {
|
|||||||
count?: number // Number of parallel executions for count-based parallel
|
count?: number // Number of parallel executions for count-based parallel
|
||||||
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
|
parallelType?: 'count' | 'collection' // Explicit parallel type to avoid inference bugs
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
locked?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Variable {
|
export interface Variable {
|
||||||
@@ -178,6 +175,22 @@ export interface WorkflowState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowActions {
|
export interface WorkflowActions {
|
||||||
|
addBlock: (
|
||||||
|
id: string,
|
||||||
|
type: string,
|
||||||
|
name: string,
|
||||||
|
position: Position,
|
||||||
|
data?: Record<string, any>,
|
||||||
|
parentId?: string,
|
||||||
|
extent?: 'parent',
|
||||||
|
blockProperties?: {
|
||||||
|
enabled?: boolean
|
||||||
|
horizontalHandles?: boolean
|
||||||
|
advancedMode?: boolean
|
||||||
|
triggerMode?: boolean
|
||||||
|
height?: number
|
||||||
|
}
|
||||||
|
) => void
|
||||||
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void
|
updateNodeDimensions: (id: string, dimensions: { width: number; height: number }) => void
|
||||||
batchUpdateBlocksWithParent: (
|
batchUpdateBlocksWithParent: (
|
||||||
updates: Array<{
|
updates: Array<{
|
||||||
@@ -236,8 +249,6 @@ export interface WorkflowActions {
|
|||||||
workflowState: WorkflowState,
|
workflowState: WorkflowState,
|
||||||
options?: { updateLastSaved?: boolean }
|
options?: { updateLastSaved?: boolean }
|
||||||
) => void
|
) => void
|
||||||
setBlockLocked: (id: string, locked: boolean) => void
|
|
||||||
batchToggleLocked: (ids: string[]) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowStore = WorkflowState & WorkflowActions
|
export type WorkflowStore = WorkflowState & WorkflowActions
|
||||||
|
|||||||
@@ -125,8 +125,8 @@ app:
|
|||||||
|
|
||||||
# Rate Limiting Configuration (per minute)
|
# Rate Limiting Configuration (per minute)
|
||||||
RATE_LIMIT_WINDOW_MS: "60000" # Rate limit window duration (1 minute)
|
RATE_LIMIT_WINDOW_MS: "60000" # Rate limit window duration (1 minute)
|
||||||
RATE_LIMIT_FREE_SYNC: "50" # Sync API executions per minute
|
RATE_LIMIT_FREE_SYNC: "10" # Sync API executions per minute
|
||||||
RATE_LIMIT_FREE_ASYNC: "200" # Async API executions per minute
|
RATE_LIMIT_FREE_ASYNC: "50" # Async API executions per minute
|
||||||
|
|
||||||
# UI Branding & Whitelabeling Configuration
|
# UI Branding & Whitelabeling Configuration
|
||||||
NEXT_PUBLIC_BRAND_NAME: "Sim" # Custom brand name
|
NEXT_PUBLIC_BRAND_NAME: "Sim" # Custom brand name
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE "workflow_blocks" ADD COLUMN "locked" boolean DEFAULT false NOT NULL;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1044,13 +1044,6 @@
|
|||||||
"when": 1769656977701,
|
"when": 1769656977701,
|
||||||
"tag": "0149_next_cerise",
|
"tag": "0149_next_cerise",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 150,
|
|
||||||
"version": "7",
|
|
||||||
"when": 1769897862156,
|
|
||||||
"tag": "0150_flimsy_hemingway",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,6 @@ export const workflowBlocks = pgTable(
|
|||||||
isWide: boolean('is_wide').notNull().default(false),
|
isWide: boolean('is_wide').notNull().default(false),
|
||||||
advancedMode: boolean('advanced_mode').notNull().default(false),
|
advancedMode: boolean('advanced_mode').notNull().default(false),
|
||||||
triggerMode: boolean('trigger_mode').notNull().default(false),
|
triggerMode: boolean('trigger_mode').notNull().default(false),
|
||||||
locked: boolean('locked').notNull().default(false),
|
|
||||||
height: decimal('height').notNull().default('0'),
|
height: decimal('height').notNull().default('0'),
|
||||||
|
|
||||||
subBlocks: jsonb('sub_blocks').notNull().default('{}'),
|
subBlocks: jsonb('sub_blocks').notNull().default('{}'),
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export interface BlockFactoryOptions {
|
|||||||
triggerMode?: boolean
|
triggerMode?: boolean
|
||||||
data?: BlockData
|
data?: BlockData
|
||||||
parentId?: string
|
parentId?: string
|
||||||
locked?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,7 +67,6 @@ export function createBlock(options: BlockFactoryOptions = {}): any {
|
|||||||
height: options.height ?? 0,
|
height: options.height ?? 0,
|
||||||
advancedMode: options.advancedMode ?? false,
|
advancedMode: options.advancedMode ?? false,
|
||||||
triggerMode: options.triggerMode ?? false,
|
triggerMode: options.triggerMode ?? false,
|
||||||
locked: options.locked ?? false,
|
|
||||||
data: Object.keys(data).length > 0 ? data : undefined,
|
data: Object.keys(data).length > 0 ? data : undefined,
|
||||||
layout: {},
|
layout: {},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user