improvement(subflow): consolidated parallel/loop tags and collaborativeUpdate (#931)

* fix(console): fix typo

* improvement(subflows): consolidated subflows tags
This commit is contained in:
Waleed Latif
2025-08-10 17:19:21 -07:00
committed by GitHub
parent e271ed86b6
commit df16382a19
20 changed files with 271 additions and 636 deletions
@@ -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'
@@ -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)
})
})
@@ -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>
)
}
@@ -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,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', () => ({
@@ -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>
</>
@@ -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,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', () => ({
@@ -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')
@@ -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
@@ -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
@@ -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'
+1 -1
View File
@@ -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]
+24 -11
View File
@@ -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)
}
}
+93 -121
View File
@@ -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)
+2 -2
View File
@@ -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
},
},
}
+2 -2
View File
@@ -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