mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
improvement(subflow): consolidated parallel/loop tags and collaborativeUpdate (#931)
* fix(console): fix typo * improvement(subflows): consolidated subflows tags
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
export { ControlBar } from './control-bar/control-bar'
|
||||
export { ErrorBoundary } from './error/index'
|
||||
export { LoopNodeComponent } from './loop-node/loop-node'
|
||||
export { Panel } from './panel/panel'
|
||||
export { ParallelNodeComponent } from './parallel-node/parallel-node'
|
||||
export { SkeletonLoading } from './skeleton-loading/skeleton-loading'
|
||||
export { LoopNodeComponent } from './subflows/loop/loop-node'
|
||||
export { ParallelNodeComponent } from './subflows/parallel/parallel-node'
|
||||
export { WandPromptBar } from './wand-prompt-bar/wand-prompt-bar'
|
||||
export { WorkflowBlock } from './workflow-block/workflow-block'
|
||||
export { WorkflowEdge } from './workflow-edge/workflow-edge'
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
// Mock the store
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
useWorkflowStore: vi.fn(),
|
||||
}))
|
||||
|
||||
describe('LoopBadges Store Integration', () => {
|
||||
const mockUpdateLoopType = vi.fn()
|
||||
const mockUpdateLoopCount = vi.fn()
|
||||
const mockUpdateLoopCollection = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
;(useWorkflowStore as any).mockImplementation((selector: any) => {
|
||||
const state = {
|
||||
updateLoopType: mockUpdateLoopType,
|
||||
updateLoopCount: mockUpdateLoopCount,
|
||||
updateLoopCollection: mockUpdateLoopCollection,
|
||||
}
|
||||
return selector(state)
|
||||
})
|
||||
})
|
||||
|
||||
it('should call updateLoopType when changing loop type', () => {
|
||||
// When we update loop type in the UI, it should call the store method
|
||||
const nodeId = 'loop1'
|
||||
const newType = 'forEach'
|
||||
|
||||
// Simulate the handler being called
|
||||
mockUpdateLoopType(nodeId, newType)
|
||||
|
||||
expect(mockUpdateLoopType).toHaveBeenCalledWith(nodeId, newType)
|
||||
})
|
||||
|
||||
it('should call updateLoopCount when changing loop count', () => {
|
||||
const nodeId = 'loop1'
|
||||
const newCount = 15
|
||||
|
||||
// Simulate the handler being called
|
||||
mockUpdateLoopCount(nodeId, newCount)
|
||||
|
||||
expect(mockUpdateLoopCount).toHaveBeenCalledWith(nodeId, newCount)
|
||||
})
|
||||
|
||||
it('should call updateLoopCollection when changing collection', () => {
|
||||
const nodeId = 'loop1'
|
||||
const newCollection = '["item1", "item2", "item3"]'
|
||||
|
||||
// Simulate the handler being called
|
||||
mockUpdateLoopCollection(nodeId, newCollection)
|
||||
|
||||
expect(mockUpdateLoopCollection).toHaveBeenCalledWith(nodeId, newCollection)
|
||||
})
|
||||
})
|
||||
-329
@@ -1,329 +0,0 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import { highlight, languages } from 'prismjs'
|
||||
import Editor from 'react-simple-code-editor'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
|
||||
interface ParallelNodeData {
|
||||
width?: number
|
||||
height?: number
|
||||
parentId?: string
|
||||
state?: string
|
||||
type?: string
|
||||
extent?: 'parent'
|
||||
parallelType?: 'count' | 'collection'
|
||||
count?: number
|
||||
collection?: string | any[] | Record<string, any>
|
||||
isPreview?: boolean
|
||||
executionState?: {
|
||||
currentExecution: number
|
||||
isExecuting: boolean
|
||||
startTime: number | null
|
||||
endTime: number | null
|
||||
}
|
||||
}
|
||||
|
||||
interface ParallelBadgesProps {
|
||||
nodeId: string
|
||||
data: ParallelNodeData
|
||||
}
|
||||
|
||||
export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
|
||||
// Check if this is preview mode
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Get parallel configuration from the workflow store (single source of truth)
|
||||
const { parallels } = useWorkflowStore()
|
||||
const parallelConfig = parallels[nodeId]
|
||||
|
||||
// Use parallel config as primary source, fallback to data for backward compatibility
|
||||
const configCount = parallelConfig?.count ?? data?.count ?? 5
|
||||
const configDistribution = parallelConfig?.distribution ?? data?.collection ?? ''
|
||||
// For parallel type, use the block's parallelType data property as the source of truth
|
||||
// Don't infer it from whether distribution exists, as that causes unwanted switching
|
||||
const configParallelType = data?.parallelType || 'collection'
|
||||
|
||||
// Derive values directly from props - no useState needed for synchronized data
|
||||
const parallelType = configParallelType
|
||||
const iterations = configCount
|
||||
const distributionString =
|
||||
typeof configDistribution === 'string'
|
||||
? configDistribution
|
||||
: JSON.stringify(configDistribution) || ''
|
||||
|
||||
// Use actual values directly for display, temporary state only for active editing
|
||||
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
|
||||
const inputValue = tempInputValue ?? iterations.toString()
|
||||
const editorValue = distributionString
|
||||
const [typePopoverOpen, setTypePopoverOpen] = useState(false)
|
||||
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
|
||||
const [showTagDropdown, setShowTagDropdown] = useState(false)
|
||||
const [cursorPosition, setCursorPosition] = useState(0)
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// Get collaborative functions
|
||||
const {
|
||||
collaborativeUpdateParallelCount,
|
||||
collaborativeUpdateParallelCollection,
|
||||
collaborativeUpdateParallelType,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Handle parallel type change
|
||||
const handleParallelTypeChange = useCallback(
|
||||
(newType: 'count' | 'collection') => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
// Use single collaborative function that handles all the state changes atomically
|
||||
collaborativeUpdateParallelType(nodeId, newType)
|
||||
|
||||
setTypePopoverOpen(false)
|
||||
},
|
||||
[nodeId, collaborativeUpdateParallelType, isPreview]
|
||||
)
|
||||
|
||||
// Handle iterations input change
|
||||
const handleIterationsChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
|
||||
const numValue = Number.parseInt(sanitizedValue)
|
||||
|
||||
if (!Number.isNaN(numValue)) {
|
||||
setTempInputValue(Math.min(20, numValue).toString())
|
||||
} else {
|
||||
setTempInputValue(sanitizedValue)
|
||||
}
|
||||
},
|
||||
[isPreview]
|
||||
)
|
||||
|
||||
// Handle iterations save
|
||||
const handleIterationsSave = useCallback(() => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
const value = Number.parseInt(inputValue)
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
const newValue = Math.min(20, Math.max(1, value))
|
||||
// Update the collaborative state - this will cause iterations to be derived from props
|
||||
collaborativeUpdateParallelCount(nodeId, newValue)
|
||||
}
|
||||
// Clear temporary input state to show the actual value
|
||||
setTempInputValue(null)
|
||||
setConfigPopoverOpen(false)
|
||||
}, [inputValue, nodeId, collaborativeUpdateParallelCount, isPreview])
|
||||
|
||||
// Handle editor change and check for tag trigger
|
||||
const handleEditorChange = useCallback(
|
||||
(value: string) => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
// Update collaborative state directly - no local state needed
|
||||
collaborativeUpdateParallelCollection(nodeId, value)
|
||||
|
||||
// Get the textarea element and cursor position
|
||||
const textarea = editorContainerRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
textareaRef.current = textarea
|
||||
const position = textarea.selectionStart || 0
|
||||
setCursorPosition(position)
|
||||
|
||||
// Check for tag trigger
|
||||
const tagTrigger = checkTagTrigger(value, position)
|
||||
setShowTagDropdown(tagTrigger.show)
|
||||
}
|
||||
},
|
||||
[nodeId, collaborativeUpdateParallelCollection, isPreview]
|
||||
)
|
||||
|
||||
// Handle tag selection
|
||||
const handleTagSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
// Update collaborative state directly - no local state needed
|
||||
collaborativeUpdateParallelCollection(nodeId, newValue)
|
||||
setShowTagDropdown(false)
|
||||
|
||||
// Focus back on the editor after selection
|
||||
setTimeout(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.focus()
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
[nodeId, collaborativeUpdateParallelCollection, isPreview]
|
||||
)
|
||||
|
||||
// Handle key events
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowTagDropdown(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
|
||||
{/* Parallel Type Badge */}
|
||||
<Popover
|
||||
open={!isPreview && typePopoverOpen}
|
||||
onOpenChange={isPreview ? undefined : setTypePopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
|
||||
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
|
||||
'flex items-center gap-1'
|
||||
)}
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
>
|
||||
{parallelType === 'count' ? 'Parallel Count' : 'Parallel Each'}
|
||||
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
{!isPreview && (
|
||||
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
|
||||
<div className='space-y-2'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>Parallel Type</div>
|
||||
<div className='space-y-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
|
||||
parallelType === 'count' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleParallelTypeChange('count')}
|
||||
>
|
||||
<span className='text-sm'>Parallel Count</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
|
||||
parallelType === 'collection' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleParallelTypeChange('collection')}
|
||||
>
|
||||
<span className='text-sm'>Parallel Each</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Iterations/Collection Badge */}
|
||||
<Popover
|
||||
open={!isPreview && configPopoverOpen}
|
||||
onOpenChange={isPreview ? undefined : setConfigPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'border-border bg-background/80 py-0.5 pr-1.5 pl-2.5 font-medium text-foreground text-sm backdrop-blur-sm',
|
||||
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
|
||||
'flex items-center gap-1'
|
||||
)}
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
>
|
||||
{parallelType === 'count' ? `Iterations: ${iterations}` : 'Items'}
|
||||
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
{!isPreview && (
|
||||
<PopoverContent
|
||||
className={cn('p-3', parallelType !== 'count' ? 'w-72' : 'w-48')}
|
||||
align='center'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>
|
||||
{parallelType === 'count' ? 'Parallel Iterations' : 'Parallel Items'}
|
||||
</div>
|
||||
|
||||
{parallelType === 'count' ? (
|
||||
// Number input for count-based parallel
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='text'
|
||||
value={inputValue}
|
||||
onChange={handleIterationsChange}
|
||||
onBlur={handleIterationsSave}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleIterationsSave()}
|
||||
className='h-8 text-sm'
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Code editor for collection-based parallel
|
||||
<div className='relative'>
|
||||
<div
|
||||
ref={editorContainerRef}
|
||||
className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'
|
||||
>
|
||||
{editorValue === '' && (
|
||||
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
|
||||
['item1', 'item2', 'item3']
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
value={editorValue}
|
||||
onValueChange={handleEditorChange}
|
||||
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
|
||||
padding={0}
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
lineHeight: '21px',
|
||||
}}
|
||||
className='w-full focus:outline-none'
|
||||
textareaClassName='focus:outline-none focus:ring-0 bg-transparent resize-none w-full overflow-hidden whitespace-pre-wrap'
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowTagDropdown(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-2 text-[10px] text-muted-foreground'>
|
||||
Array or object to use for parallel execution. Type "{'<'}" to reference other
|
||||
blocks.
|
||||
</div>
|
||||
{showTagDropdown && (
|
||||
<TagDropdown
|
||||
visible={showTagDropdown}
|
||||
onSelect={handleTagSelect}
|
||||
blockId={nodeId}
|
||||
activeSourceBlockId={null}
|
||||
inputValue={editorValue}
|
||||
cursorPosition={cursorPosition}
|
||||
onClose={() => setShowTagDropdown(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parallelType === 'count' && (
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
Enter a number between 1 and 20
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+117
-82
@@ -12,50 +12,80 @@ import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import 'prismjs/components/prism-javascript'
|
||||
import 'prismjs/themes/prism.css'
|
||||
|
||||
interface LoopNodeData {
|
||||
type IterationType = 'loop' | 'parallel'
|
||||
type LoopType = 'for' | 'forEach'
|
||||
type ParallelType = 'count' | 'collection'
|
||||
|
||||
interface IterationNodeData {
|
||||
width?: number
|
||||
height?: number
|
||||
parentId?: string
|
||||
state?: string
|
||||
type?: string
|
||||
extent?: 'parent'
|
||||
loopType?: 'for' | 'forEach'
|
||||
loopType?: LoopType
|
||||
parallelType?: ParallelType
|
||||
// Common
|
||||
count?: number
|
||||
collection?: string | any[] | Record<string, any>
|
||||
isPreview?: boolean
|
||||
executionState?: {
|
||||
currentIteration: number
|
||||
currentIteration?: number
|
||||
currentExecution?: number
|
||||
isExecuting: boolean
|
||||
startTime: number | null
|
||||
endTime: number | null
|
||||
}
|
||||
}
|
||||
|
||||
interface LoopBadgesProps {
|
||||
interface IterationBadgesProps {
|
||||
nodeId: string
|
||||
data: LoopNodeData
|
||||
data: IterationNodeData
|
||||
iterationType: IterationType
|
||||
}
|
||||
|
||||
export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
|
||||
// Check if this is preview mode
|
||||
const CONFIG = {
|
||||
loop: {
|
||||
typeLabels: { for: 'For Loop', forEach: 'For Each' },
|
||||
typeKey: 'loopType' as const,
|
||||
storeKey: 'loops' as const,
|
||||
maxIterations: 100,
|
||||
configKeys: {
|
||||
iterations: 'iterations' as const,
|
||||
items: 'forEachItems' as const,
|
||||
},
|
||||
},
|
||||
parallel: {
|
||||
typeLabels: { count: 'Parallel Count', collection: 'Parallel Each' },
|
||||
typeKey: 'parallelType' as const,
|
||||
storeKey: 'parallels' as const,
|
||||
maxIterations: 20,
|
||||
configKeys: {
|
||||
iterations: 'count' as const,
|
||||
items: 'distribution' as const,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export function IterationBadges({ nodeId, data, iterationType }: IterationBadgesProps) {
|
||||
const config = CONFIG[iterationType]
|
||||
const isPreview = data?.isPreview || false
|
||||
|
||||
// Get loop configuration from the workflow store (single source of truth)
|
||||
const { loops } = useWorkflowStore()
|
||||
const loopConfig = loops[nodeId]
|
||||
// Get configuration from the workflow store
|
||||
const store = useWorkflowStore()
|
||||
const nodeConfig = store[config.storeKey][nodeId]
|
||||
|
||||
// Use loop config as primary source, fallback to data for backward compatibility
|
||||
const configIterations = loopConfig?.iterations ?? data?.count ?? 5
|
||||
const configLoopType = loopConfig?.loopType ?? data?.loopType ?? 'for'
|
||||
const configCollection = loopConfig?.forEachItems ?? data?.collection ?? ''
|
||||
// Determine current type and values
|
||||
const currentType = (data?.[config.typeKey] ||
|
||||
(iterationType === 'loop' ? 'for' : 'count')) as any
|
||||
const configIterations = (nodeConfig as any)?.[config.configKeys.iterations] ?? data?.count ?? 5
|
||||
const configCollection = (nodeConfig as any)?.[config.configKeys.items] ?? data?.collection ?? ''
|
||||
|
||||
// Derive values directly from props - no useState needed for synchronized data
|
||||
const loopType = configLoopType
|
||||
const iterations = configIterations
|
||||
const collectionString =
|
||||
typeof configCollection === 'string' ? configCollection : JSON.stringify(configCollection) || ''
|
||||
|
||||
// Use actual values directly for display, temporary state only for active editing
|
||||
// State management
|
||||
const [tempInputValue, setTempInputValue] = useState<string | null>(null)
|
||||
const inputValue = tempInputValue ?? iterations.toString()
|
||||
const editorValue = collectionString
|
||||
@@ -69,88 +99,87 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
|
||||
// Get collaborative functions
|
||||
const {
|
||||
collaborativeUpdateLoopType,
|
||||
collaborativeUpdateLoopCount,
|
||||
collaborativeUpdateLoopCollection,
|
||||
collaborativeUpdateParallelType,
|
||||
collaborativeUpdateIterationCount,
|
||||
collaborativeUpdateIterationCollection,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Handle loop type change
|
||||
const handleLoopTypeChange = useCallback(
|
||||
(newType: 'for' | 'forEach') => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
// Update the collaborative state - this will cause the component to re-render with new derived values
|
||||
collaborativeUpdateLoopType(nodeId, newType)
|
||||
// Handle type change
|
||||
const handleTypeChange = useCallback(
|
||||
(newType: any) => {
|
||||
if (isPreview) return
|
||||
if (iterationType === 'loop') {
|
||||
collaborativeUpdateLoopType(nodeId, newType)
|
||||
} else {
|
||||
collaborativeUpdateParallelType(nodeId, newType)
|
||||
}
|
||||
setTypePopoverOpen(false)
|
||||
},
|
||||
[nodeId, collaborativeUpdateLoopType, isPreview]
|
||||
[nodeId, iterationType, collaborativeUpdateLoopType, collaborativeUpdateParallelType, isPreview]
|
||||
)
|
||||
|
||||
// Handle iterations input change
|
||||
const handleIterationsChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
if (isPreview) return
|
||||
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
|
||||
const numValue = Number.parseInt(sanitizedValue)
|
||||
|
||||
if (!Number.isNaN(numValue)) {
|
||||
setTempInputValue(Math.min(100, numValue).toString())
|
||||
setTempInputValue(Math.min(config.maxIterations, numValue).toString())
|
||||
} else {
|
||||
setTempInputValue(sanitizedValue)
|
||||
}
|
||||
},
|
||||
[isPreview]
|
||||
[isPreview, config.maxIterations]
|
||||
)
|
||||
|
||||
// Handle iterations save
|
||||
const handleIterationsSave = useCallback(() => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
if (isPreview) return
|
||||
const value = Number.parseInt(inputValue)
|
||||
|
||||
if (!Number.isNaN(value)) {
|
||||
const newValue = Math.min(100, Math.max(1, value))
|
||||
// Update the collaborative state - this will cause iterations to be derived from props
|
||||
collaborativeUpdateLoopCount(nodeId, newValue)
|
||||
const newValue = Math.min(config.maxIterations, Math.max(1, value))
|
||||
collaborativeUpdateIterationCount(nodeId, iterationType, newValue)
|
||||
}
|
||||
// Clear temporary input state to show the actual value
|
||||
setTempInputValue(null)
|
||||
setConfigPopoverOpen(false)
|
||||
}, [inputValue, nodeId, collaborativeUpdateLoopCount, isPreview])
|
||||
}, [
|
||||
inputValue,
|
||||
nodeId,
|
||||
iterationType,
|
||||
collaborativeUpdateIterationCount,
|
||||
isPreview,
|
||||
config.maxIterations,
|
||||
])
|
||||
|
||||
// Handle editor change with tag dropdown support
|
||||
// Handle editor change
|
||||
const handleEditorChange = useCallback(
|
||||
(value: string) => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
if (isPreview) return
|
||||
collaborativeUpdateIterationCollection(nodeId, iterationType, value)
|
||||
|
||||
// Update collaborative state directly - no local state needed
|
||||
collaborativeUpdateLoopCollection(nodeId, value)
|
||||
|
||||
// Get the textarea element from the editor
|
||||
const textarea = editorContainerRef.current?.querySelector('textarea')
|
||||
if (textarea) {
|
||||
textareaRef.current = textarea
|
||||
const cursorPos = textarea.selectionStart || 0
|
||||
setCursorPosition(cursorPos)
|
||||
|
||||
// Check for tag trigger
|
||||
const triggerCheck = checkTagTrigger(value, cursorPos)
|
||||
setShowTagDropdown(triggerCheck.show)
|
||||
}
|
||||
},
|
||||
[nodeId, collaborativeUpdateLoopCollection, isPreview]
|
||||
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
|
||||
)
|
||||
|
||||
// Handle tag selection
|
||||
const handleTagSelect = useCallback(
|
||||
(newValue: string) => {
|
||||
if (isPreview) return // Don't allow changes in preview mode
|
||||
|
||||
// Update collaborative state directly - no local state needed
|
||||
collaborativeUpdateLoopCollection(nodeId, newValue)
|
||||
if (isPreview) return
|
||||
collaborativeUpdateIterationCollection(nodeId, iterationType, newValue)
|
||||
setShowTagDropdown(false)
|
||||
|
||||
// Focus back on the editor after a short delay
|
||||
setTimeout(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
@@ -158,12 +187,20 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
|
||||
}
|
||||
}, 0)
|
||||
},
|
||||
[nodeId, collaborativeUpdateLoopCollection, isPreview]
|
||||
[nodeId, iterationType, collaborativeUpdateIterationCollection, isPreview]
|
||||
)
|
||||
|
||||
// Determine if we're in count mode or collection mode
|
||||
const isCountMode =
|
||||
(iterationType === 'loop' && currentType === 'for') ||
|
||||
(iterationType === 'parallel' && currentType === 'count')
|
||||
|
||||
// Get type options
|
||||
const typeOptions = Object.entries(config.typeLabels)
|
||||
|
||||
return (
|
||||
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
|
||||
{/* Loop Type Badge */}
|
||||
{/* Type Badge */}
|
||||
<Popover
|
||||
open={!isPreview && typePopoverOpen}
|
||||
onOpenChange={isPreview ? undefined : setTypePopoverOpen}
|
||||
@@ -178,40 +215,36 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
|
||||
)}
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
>
|
||||
{loopType === 'for' ? 'For Loop' : 'For Each'}
|
||||
{config.typeLabels[currentType as keyof typeof config.typeLabels]}
|
||||
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
{!isPreview && (
|
||||
<PopoverContent className='w-48 p-3' align='center' onClick={(e) => e.stopPropagation()}>
|
||||
<div className='space-y-2'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>Loop Type</div>
|
||||
<div className='font-medium text-muted-foreground text-xs'>
|
||||
{iterationType === 'loop' ? 'Loop Type' : 'Parallel Type'}
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
|
||||
loopType === 'for' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleLoopTypeChange('for')}
|
||||
>
|
||||
<span className='text-sm'>For Loop</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
|
||||
loopType === 'forEach' ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleLoopTypeChange('forEach')}
|
||||
>
|
||||
<span className='text-sm'>For Each</span>
|
||||
</div>
|
||||
{typeOptions.map(([typeValue, typeLabel]) => (
|
||||
<div
|
||||
key={typeValue}
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5',
|
||||
currentType === typeValue ? 'bg-accent' : 'hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => handleTypeChange(typeValue)}
|
||||
>
|
||||
<span className='text-sm'>{typeLabel}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
|
||||
{/* Iterations/Collection Badge */}
|
||||
{/* Configuration Badge */}
|
||||
<Popover
|
||||
open={!isPreview && configPopoverOpen}
|
||||
onOpenChange={isPreview ? undefined : setConfigPopoverOpen}
|
||||
@@ -226,23 +259,25 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
|
||||
)}
|
||||
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
|
||||
>
|
||||
{loopType === 'for' ? `Iterations: ${iterations}` : 'Items'}
|
||||
{isCountMode ? `Iterations: ${iterations}` : 'Items'}
|
||||
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
{!isPreview && (
|
||||
<PopoverContent
|
||||
className={cn('p-3', loopType !== 'for' ? 'w-72' : 'w-48')}
|
||||
className={cn('p-3', !isCountMode ? 'w-72' : 'w-48')}
|
||||
align='center'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='font-medium text-muted-foreground text-xs'>
|
||||
{loopType === 'for' ? 'Loop Iterations' : 'Collection Items'}
|
||||
{isCountMode
|
||||
? `${iterationType === 'loop' ? 'Loop' : 'Parallel'} Iterations`
|
||||
: `${iterationType === 'loop' ? 'Collection' : 'Parallel'} Items`}
|
||||
</div>
|
||||
|
||||
{loopType === 'for' ? (
|
||||
// Number input for 'for' loops
|
||||
{isCountMode ? (
|
||||
// Number input for count-based mode
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
type='text'
|
||||
@@ -255,7 +290,7 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Code editor for 'forEach' loops
|
||||
// Code editor for collection-based mode
|
||||
<div ref={editorContainerRef} className='relative'>
|
||||
<div className='relative min-h-[80px] rounded-md border border-input bg-background px-3 pt-2 pb-3 font-mono text-sm'>
|
||||
{editorValue === '' && (
|
||||
@@ -293,9 +328,9 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loopType === 'for' && (
|
||||
{isCountMode && (
|
||||
<div className='text-[10px] text-muted-foreground'>
|
||||
Enter a number between 1 and 100
|
||||
Enter a number between 1 and {config.maxIterations}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node'
|
||||
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
+3
-3
@@ -6,9 +6,9 @@ import { StartIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useCurrentWorkflow } from '../../hooks'
|
||||
import { LoopBadges } from './components/loop-badges'
|
||||
|
||||
// Add these styles to your existing global CSS file or create a separate CSS module
|
||||
const LoopNodeStyles: React.FC = () => {
|
||||
@@ -245,7 +245,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
|
||||
/>
|
||||
|
||||
{/* Loop Configuration Badges */}
|
||||
<LoopBadges nodeId={id} data={data} />
|
||||
<IterationBadges nodeId={id} data={data} iterationType='loop' />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
+1
-1
@@ -9,7 +9,7 @@ export const ParallelTool = {
|
||||
bgColor: '#FEE12B',
|
||||
data: {
|
||||
label: 'Parallel',
|
||||
parallelType: 'collection' as 'collection' | 'count',
|
||||
parallelType: 'count' as 'collection' | 'count',
|
||||
count: 5,
|
||||
collection: '',
|
||||
extent: 'parent',
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node'
|
||||
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
vi.mock('@/stores/workflows/workflow/store', () => ({
|
||||
+3
-3
@@ -6,9 +6,9 @@ import { StartIcon } from '@/components/icons'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IterationBadges } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/components/iteration-badges/iteration-badges'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useCurrentWorkflow } from '../../hooks'
|
||||
import { ParallelBadges } from './components/parallel-badges'
|
||||
|
||||
const ParallelNodeStyles: React.FC = () => {
|
||||
return (
|
||||
@@ -263,7 +263,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
|
||||
/>
|
||||
|
||||
{/* Parallel Configuration Badges */}
|
||||
<ParallelBadges nodeId={id} data={data} />
|
||||
<IterationBadges nodeId={id} data={data} iterationType='parallel' />
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
@@ -17,9 +17,20 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import { ControlBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/control-bar/control-bar'
|
||||
import { DiffControls } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/diff-controls'
|
||||
import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error/index'
|
||||
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node'
|
||||
import { Panel } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel'
|
||||
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node'
|
||||
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
|
||||
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { useCurrentWorkflow } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import {
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
getNodeHierarchy,
|
||||
isPointInLoopNode,
|
||||
resizeLoopNodes,
|
||||
updateNodeParent as updateNodeParentUtil,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/utils'
|
||||
import { getBlock } from '@/blocks'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useStreamCleanup } from '@/hooks/use-stream-cleanup'
|
||||
@@ -31,17 +42,6 @@ import { useGeneralStore } from '@/stores/settings/general/store'
|
||||
import { useWorkflowDiffStore } from '@/stores/workflow-diff/store'
|
||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
import { WorkflowBlock } from './components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from './components/workflow-edge/workflow-edge'
|
||||
import { useCurrentWorkflow } from './hooks'
|
||||
import {
|
||||
getNodeAbsolutePosition,
|
||||
getNodeDepth,
|
||||
getNodeHierarchy,
|
||||
isPointInLoopNode,
|
||||
resizeLoopNodes,
|
||||
updateNodeParent as updateNodeParentUtil,
|
||||
} from './utils'
|
||||
|
||||
const logger = createLogger('Workflow')
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-config'
|
||||
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||
|
||||
type LoopToolbarItemProps = {
|
||||
disabled?: boolean
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-config'
|
||||
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||
|
||||
type ParallelToolbarItemProps = {
|
||||
disabled?: boolean
|
||||
|
||||
+2
-2
@@ -15,8 +15,8 @@ import 'reactflow/dist/style.css'
|
||||
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/loop-node/loop-node'
|
||||
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/parallel-node/parallel-node'
|
||||
import { LoopNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-node'
|
||||
import { ParallelNodeComponent } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-node'
|
||||
import { WorkflowBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||
import { WorkflowEdge } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-edge/workflow-edge'
|
||||
import { getBlock } from '@/blocks'
|
||||
|
||||
@@ -1522,7 +1522,7 @@ export class Executor {
|
||||
iterationType = 'parallel'
|
||||
} else {
|
||||
// Check if this block is inside a loop
|
||||
const containingLoopId = this.resolver.loopsByBlockId?.get(block.id)
|
||||
const containingLoopId = this.resolver.getContainingLoopId(block.id)
|
||||
if (containingLoopId) {
|
||||
const currentIteration = context.loopIterations.get(containingLoopId)
|
||||
const loop = context.workflow?.loops?.[containingLoopId]
|
||||
|
||||
@@ -22,6 +22,7 @@ export class InputResolver {
|
||||
private blockById: Map<string, SerializedBlock>
|
||||
private blockByNormalizedName: Map<string, SerializedBlock>
|
||||
private loopsByBlockId: Map<string, string> // Maps block ID to containing loop ID
|
||||
private parallelsByBlockId: Map<string, string> // Maps block ID to containing parallel ID
|
||||
|
||||
constructor(
|
||||
private workflow: SerializedWorkflow,
|
||||
@@ -61,6 +62,14 @@ export class InputResolver {
|
||||
this.loopsByBlockId.set(blockId, loopId)
|
||||
}
|
||||
}
|
||||
|
||||
// Create efficient parallel lookup map
|
||||
this.parallelsByBlockId = new Map()
|
||||
for (const [parallelId, parallel] of Object.entries(workflow.parallels || {})) {
|
||||
for (const blockId of parallel.nodes) {
|
||||
this.parallelsByBlockId.set(blockId, parallelId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -651,15 +660,8 @@ export class InputResolver {
|
||||
|
||||
// Special case for "parallel" references - allows accessing parallel properties
|
||||
if (blockRef.toLowerCase() === 'parallel') {
|
||||
// Find which parallel this block belongs to
|
||||
let containingParallelId: string | undefined
|
||||
|
||||
for (const [parallelId, parallel] of Object.entries(context.workflow?.parallels || {})) {
|
||||
if (parallel.nodes.includes(currentBlock.id)) {
|
||||
containingParallelId = parallelId
|
||||
break
|
||||
}
|
||||
}
|
||||
// Find which parallel this block belongs to using efficient lookup
|
||||
const containingParallelId = this.parallelsByBlockId.get(currentBlock.id)
|
||||
|
||||
if (containingParallelId) {
|
||||
const formattedValue = this.resolveParallelReference(
|
||||
@@ -1091,8 +1093,10 @@ export class InputResolver {
|
||||
}
|
||||
|
||||
// Special case: blocks in the same parallel can reference each other
|
||||
for (const [parallelId, parallel] of Object.entries(this.workflow.parallels || {})) {
|
||||
if (parallel.nodes.includes(currentBlockId)) {
|
||||
const currentBlockParallel = this.parallelsByBlockId.get(currentBlockId)
|
||||
if (currentBlockParallel) {
|
||||
const parallel = this.workflow.parallels?.[currentBlockParallel]
|
||||
if (parallel) {
|
||||
for (const nodeId of parallel.nodes) {
|
||||
accessibleBlocks.add(nodeId)
|
||||
}
|
||||
@@ -1854,4 +1858,13 @@ export class InputResolver {
|
||||
getContainingLoopId(blockId: string): string | undefined {
|
||||
return this.loopsByBlockId.get(blockId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the containing parallel ID for a block
|
||||
* @param blockId - The ID of the block
|
||||
* @returns The containing parallel ID or undefined if not in a parallel
|
||||
*/
|
||||
getContainingParallelId(blockId: string): string | undefined {
|
||||
return this.parallelsByBlockId.get(blockId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,36 +937,6 @@ export function useCollaborativeWorkflow() {
|
||||
[executeQueuedOperation, workflowStore, subBlockStore, activeWorkflowId]
|
||||
)
|
||||
|
||||
const collaborativeUpdateLoopCount = useCallback(
|
||||
(loopId: string, count: number) => {
|
||||
// Get current state BEFORE making changes
|
||||
const currentBlock = workflowStore.blocks[loopId]
|
||||
if (!currentBlock || currentBlock.type !== 'loop') return
|
||||
|
||||
// Find child nodes before state changes
|
||||
const childNodes = Object.values(workflowStore.blocks)
|
||||
.filter((b) => b.data?.parentId === loopId)
|
||||
.map((b) => b.id)
|
||||
|
||||
// Get current values to preserve them
|
||||
const currentLoopType = currentBlock.data?.loopType || 'for'
|
||||
const currentCollection = currentBlock.data?.collection || ''
|
||||
|
||||
const config = {
|
||||
id: loopId,
|
||||
nodes: childNodes,
|
||||
iterations: count,
|
||||
loopType: currentLoopType,
|
||||
forEachItems: currentCollection,
|
||||
}
|
||||
|
||||
executeQueuedOperation('update', 'subflow', { id: loopId, type: 'loop', config }, () =>
|
||||
workflowStore.updateLoopCount(loopId, count)
|
||||
)
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
const collaborativeUpdateLoopType = useCallback(
|
||||
(loopId: string, loopType: 'for' | 'forEach') => {
|
||||
const currentBlock = workflowStore.blocks[loopId]
|
||||
@@ -994,93 +964,6 @@ export function useCollaborativeWorkflow() {
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
const collaborativeUpdateLoopCollection = useCallback(
|
||||
(loopId: string, collection: string) => {
|
||||
const currentBlock = workflowStore.blocks[loopId]
|
||||
if (!currentBlock || currentBlock.type !== 'loop') return
|
||||
|
||||
const childNodes = Object.values(workflowStore.blocks)
|
||||
.filter((b) => b.data?.parentId === loopId)
|
||||
.map((b) => b.id)
|
||||
|
||||
const currentIterations = currentBlock.data?.count || 5
|
||||
const currentLoopType = currentBlock.data?.loopType || 'for'
|
||||
|
||||
const config = {
|
||||
id: loopId,
|
||||
nodes: childNodes,
|
||||
iterations: currentIterations,
|
||||
loopType: currentLoopType,
|
||||
forEachItems: collection,
|
||||
}
|
||||
|
||||
executeQueuedOperation('update', 'subflow', { id: loopId, type: 'loop', config }, () =>
|
||||
workflowStore.updateLoopCollection(loopId, collection)
|
||||
)
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
const collaborativeUpdateParallelCount = useCallback(
|
||||
(parallelId: string, count: number) => {
|
||||
const currentBlock = workflowStore.blocks[parallelId]
|
||||
if (!currentBlock || currentBlock.type !== 'parallel') return
|
||||
|
||||
const childNodes = Object.values(workflowStore.blocks)
|
||||
.filter((b) => b.data?.parentId === parallelId)
|
||||
.map((b) => b.id)
|
||||
|
||||
const currentDistribution = currentBlock.data?.collection || ''
|
||||
const currentParallelType = currentBlock.data?.parallelType || 'collection'
|
||||
|
||||
const config = {
|
||||
id: parallelId,
|
||||
nodes: childNodes,
|
||||
count: Math.max(1, Math.min(20, count)), // Clamp between 1-20
|
||||
distribution: currentDistribution,
|
||||
parallelType: currentParallelType,
|
||||
}
|
||||
|
||||
executeQueuedOperation(
|
||||
'update',
|
||||
'subflow',
|
||||
{ id: parallelId, type: 'parallel', config },
|
||||
() => workflowStore.updateParallelCount(parallelId, count)
|
||||
)
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
const collaborativeUpdateParallelCollection = useCallback(
|
||||
(parallelId: string, collection: string) => {
|
||||
const currentBlock = workflowStore.blocks[parallelId]
|
||||
if (!currentBlock || currentBlock.type !== 'parallel') return
|
||||
|
||||
const childNodes = Object.values(workflowStore.blocks)
|
||||
.filter((b) => b.data?.parentId === parallelId)
|
||||
.map((b) => b.id)
|
||||
|
||||
const currentCount = currentBlock.data?.count || 5
|
||||
const currentParallelType = currentBlock.data?.parallelType || 'collection'
|
||||
|
||||
const config = {
|
||||
id: parallelId,
|
||||
nodes: childNodes,
|
||||
count: currentCount,
|
||||
distribution: collection,
|
||||
parallelType: currentParallelType,
|
||||
}
|
||||
|
||||
executeQueuedOperation(
|
||||
'update',
|
||||
'subflow',
|
||||
{ id: parallelId, type: 'parallel', config },
|
||||
() => workflowStore.updateParallelCollection(parallelId, collection)
|
||||
)
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
const collaborativeUpdateParallelType = useCallback(
|
||||
(parallelId: string, parallelType: 'count' | 'collection') => {
|
||||
const currentBlock = workflowStore.blocks[parallelId]
|
||||
@@ -1122,6 +1005,95 @@ export function useCollaborativeWorkflow() {
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
// Unified iteration management functions - count and collection only
|
||||
const collaborativeUpdateIterationCount = useCallback(
|
||||
(nodeId: string, iterationType: 'loop' | 'parallel', count: number) => {
|
||||
const currentBlock = workflowStore.blocks[nodeId]
|
||||
if (!currentBlock || currentBlock.type !== iterationType) return
|
||||
|
||||
const childNodes = Object.values(workflowStore.blocks)
|
||||
.filter((b) => b.data?.parentId === nodeId)
|
||||
.map((b) => b.id)
|
||||
|
||||
if (iterationType === 'loop') {
|
||||
const currentLoopType = currentBlock.data?.loopType || 'for'
|
||||
const currentCollection = currentBlock.data?.collection || ''
|
||||
|
||||
const config = {
|
||||
id: nodeId,
|
||||
nodes: childNodes,
|
||||
iterations: Math.max(1, Math.min(100, count)), // Clamp between 1-100 for loops
|
||||
loopType: currentLoopType,
|
||||
forEachItems: currentCollection,
|
||||
}
|
||||
|
||||
executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'loop', config }, () =>
|
||||
workflowStore.updateLoopCount(nodeId, count)
|
||||
)
|
||||
} else {
|
||||
const currentDistribution = currentBlock.data?.collection || ''
|
||||
const currentParallelType = currentBlock.data?.parallelType || 'count'
|
||||
|
||||
const config = {
|
||||
id: nodeId,
|
||||
nodes: childNodes,
|
||||
count: Math.max(1, Math.min(20, count)), // Clamp between 1-20 for parallels
|
||||
distribution: currentDistribution,
|
||||
parallelType: currentParallelType,
|
||||
}
|
||||
|
||||
executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'parallel', config }, () =>
|
||||
workflowStore.updateParallelCount(nodeId, count)
|
||||
)
|
||||
}
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
const collaborativeUpdateIterationCollection = useCallback(
|
||||
(nodeId: string, iterationType: 'loop' | 'parallel', collection: string) => {
|
||||
const currentBlock = workflowStore.blocks[nodeId]
|
||||
if (!currentBlock || currentBlock.type !== iterationType) return
|
||||
|
||||
const childNodes = Object.values(workflowStore.blocks)
|
||||
.filter((b) => b.data?.parentId === nodeId)
|
||||
.map((b) => b.id)
|
||||
|
||||
if (iterationType === 'loop') {
|
||||
const currentIterations = currentBlock.data?.count || 5
|
||||
const currentLoopType = currentBlock.data?.loopType || 'for'
|
||||
|
||||
const config = {
|
||||
id: nodeId,
|
||||
nodes: childNodes,
|
||||
iterations: currentIterations,
|
||||
loopType: currentLoopType,
|
||||
forEachItems: collection,
|
||||
}
|
||||
|
||||
executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'loop', config }, () =>
|
||||
workflowStore.updateLoopCollection(nodeId, collection)
|
||||
)
|
||||
} else {
|
||||
const currentCount = currentBlock.data?.count || 5
|
||||
const currentParallelType = currentBlock.data?.parallelType || 'count'
|
||||
|
||||
const config = {
|
||||
id: nodeId,
|
||||
nodes: childNodes,
|
||||
count: currentCount,
|
||||
distribution: collection,
|
||||
parallelType: currentParallelType,
|
||||
}
|
||||
|
||||
executeQueuedOperation('update', 'subflow', { id: nodeId, type: 'parallel', config }, () =>
|
||||
workflowStore.updateParallelCollection(nodeId, collection)
|
||||
)
|
||||
}
|
||||
},
|
||||
[executeQueuedOperation, workflowStore]
|
||||
)
|
||||
|
||||
return {
|
||||
// Connection status
|
||||
isConnected,
|
||||
@@ -1151,13 +1123,13 @@ export function useCollaborativeWorkflow() {
|
||||
collaborativeSetTagSelection,
|
||||
|
||||
// Collaborative loop/parallel operations
|
||||
collaborativeUpdateLoopCount,
|
||||
collaborativeUpdateLoopType,
|
||||
collaborativeUpdateLoopCollection,
|
||||
collaborativeUpdateParallelCount,
|
||||
collaborativeUpdateParallelCollection,
|
||||
collaborativeUpdateParallelType,
|
||||
|
||||
// Unified iteration operations
|
||||
collaborativeUpdateIterationCount,
|
||||
collaborativeUpdateIterationCollection,
|
||||
|
||||
// Direct access to stores for non-collaborative operations
|
||||
workflowStore,
|
||||
subBlockStore,
|
||||
|
||||
@@ -126,9 +126,9 @@ describe('workflow store', () => {
|
||||
)
|
||||
|
||||
// Try to set count above max
|
||||
updateLoopCount('loop1', 100)
|
||||
updateLoopCount('loop1', 150)
|
||||
let state = useWorkflowStore.getState()
|
||||
expect(state.blocks.loop1?.data?.count).toBe(50)
|
||||
expect(state.blocks.loop1?.data?.count).toBe(100)
|
||||
|
||||
// Try to set count below min
|
||||
updateLoopCount('loop1', 0)
|
||||
@@ -178,6 +178,7 @@ describe('workflow store', () => {
|
||||
{
|
||||
count: 3,
|
||||
collection: '["item1", "item2"]',
|
||||
parallelType: 'collection',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -216,7 +217,7 @@ describe('workflow store', () => {
|
||||
// Try to set count above max
|
||||
updateParallelCount('parallel1', 100)
|
||||
let state = useWorkflowStore.getState()
|
||||
expect(state.blocks.parallel1?.data?.count).toBe(50)
|
||||
expect(state.blocks.parallel1?.data?.count).toBe(20)
|
||||
|
||||
// Try to set count below min
|
||||
updateParallelCount('parallel1', 0)
|
||||
|
||||
@@ -792,7 +792,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
count: Math.max(1, Math.min(50, count)), // Clamp between 1-50
|
||||
count: Math.max(1, Math.min(100, count)), // Clamp between 1-100
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1100,7 +1100,7 @@ export const useWorkflowStore = create<WorkflowStoreWithHistory>()(
|
||||
...block,
|
||||
data: {
|
||||
...block.data,
|
||||
count: Math.max(1, Math.min(50, count)), // Clamp between 1-50
|
||||
count: Math.max(1, Math.min(20, count)), // Clamp between 1-20
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ export function convertParallelBlockToParallel(
|
||||
const parallelBlock = blocks[parallelBlockId]
|
||||
if (!parallelBlock || parallelBlock.type !== 'parallel') return undefined
|
||||
|
||||
// Get the parallel type from block data, defaulting to 'collection' for backward compatibility
|
||||
const parallelType = parallelBlock.data?.parallelType || 'collection'
|
||||
// Get the parallel type from block data, defaulting to 'count' for consistency
|
||||
const parallelType = parallelBlock.data?.parallelType || 'count'
|
||||
|
||||
// Validate parallelType against allowed values
|
||||
const validParallelTypes = ['collection', 'count'] as const
|
||||
|
||||
Reference in New Issue
Block a user