fix: added preview for subgroup nodes, changed position of child blocks in state

This commit is contained in:
Adam Gough
2025-05-28 13:15:36 -07:00
parent 6ffe5421c0
commit 3e1d7e718c
5 changed files with 472 additions and 332 deletions

View File

@@ -21,6 +21,7 @@ interface LoopNodeData {
loopType?: 'for' | 'forEach'
count?: number
collection?: string | any[] | Record<string, any>
isPreview?: boolean
executionState?: {
currentIteration: number
isExecuting: boolean
@@ -35,6 +36,9 @@ interface LoopBadgesProps {
}
export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
// Check if this is preview mode
const isPreview = data?.isPreview || false
// State
const [loopType, setLoopType] = useState(data?.loopType || 'for')
const [iterations, setIterations] = useState(data?.count || 5)
@@ -50,6 +54,8 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
// Get store methods
const updateNodeData = useCallback(
(updates: Partial<LoopNodeData>) => {
if (isPreview) return // Don't update in preview mode
useWorkflowStore.setState((state) => ({
blocks: {
...state.blocks,
@@ -63,7 +69,7 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
},
}))
},
[nodeId]
[nodeId, isPreview]
)
const updateLoopType = useWorkflowStore((state) => state.updateLoopType)
@@ -94,27 +100,36 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
// Handle loop type change
const handleLoopTypeChange = useCallback(
(newType: 'for' | 'forEach') => {
if (isPreview) return // Don't allow changes in preview mode
setLoopType(newType)
updateLoopType(nodeId, newType)
setTypePopoverOpen(false)
},
[nodeId, updateLoopType]
[nodeId, updateLoopType, isPreview]
)
// Handle iterations input change
const handleIterationsChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
const handleIterationsChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (isPreview) return // Don't allow changes in preview mode
if (!Number.isNaN(numValue)) {
setInputValue(Math.min(100, numValue).toString())
} else {
setInputValue(sanitizedValue)
}
}, [])
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
if (!Number.isNaN(numValue)) {
setInputValue(Math.min(100, numValue).toString())
} else {
setInputValue(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)) {
@@ -126,11 +141,13 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
setInputValue(iterations.toString())
}
setConfigPopoverOpen(false)
}, [inputValue, iterations, nodeId, updateLoopCount])
}, [inputValue, iterations, nodeId, updateLoopCount, isPreview])
// Handle editor change with tag dropdown support
const handleEditorChange = useCallback(
(value: string) => {
if (isPreview) return // Don't allow changes in preview mode
setEditorValue(value)
updateLoopCollection(nodeId, value)
@@ -146,12 +163,14 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
setShowTagDropdown(triggerCheck.show)
}
},
[nodeId, updateLoopCollection]
[nodeId, updateLoopCollection, isPreview]
)
// Handle tag selection
const handleTagSelect = useCallback(
(newValue: string) => {
if (isPreview) return // Don't allow changes in preview mode
setEditorValue(newValue)
updateLoopCollection(nodeId, newValue)
setShowTagDropdown(false)
@@ -164,137 +183,149 @@ export function LoopBadges({ nodeId, data }: LoopBadgesProps) {
}
}, 0)
},
[nodeId, updateLoopCollection]
[nodeId, updateLoopCollection, isPreview]
)
return (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Loop Type Badge */}
<Popover open={typePopoverOpen} onOpenChange={setTypePopoverOpen}>
<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',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{loopType === 'for' ? 'For Loop' : 'For Each'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
<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='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>
{!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='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>
</div>
</div>
</div>
</PopoverContent>
</PopoverContent>
)}
</Popover>
{/* Iterations/Collection Badge */}
<Popover open={configPopoverOpen} onOpenChange={setConfigPopoverOpen}>
<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',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
!isPreview && 'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
'flex items-center gap-1'
)}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
>
{loopType === 'for' ? `Iterations: ${iterations}` : 'Items'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
<PopoverContent
className={cn('p-3', loopType !== 'for' ? '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'}
</div>
{loopType === 'for' ? (
// Number input for 'for' loops
<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
/>
{!isPreview && (
<PopoverContent
className={cn('p-3', loopType !== 'for' ? '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'}
</div>
) : (
// Code editor for 'forEach' loops
<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 === '' && (
<div className='pointer-events-none absolute top-[8.5px] left-3 select-none text-muted-foreground/50'>
["item1", "item2", "item3"]
</div>
{loopType === 'for' ? (
// Number input for 'for' loops
<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 'forEach' loops
<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 === '' && (
<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'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to iterate over. Type "{'<'}" to reference other blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
<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'
/>
</div>
<div className='mt-2 text-[10px] text-muted-foreground'>
Array or object to iterate over. Type "{'<'}" to reference other blocks.
</div>
{showTagDropdown && (
<TagDropdown
visible={showTagDropdown}
onSelect={handleTagSelect}
blockId={nodeId}
activeSourceBlockId={null}
inputValue={editorValue}
cursorPosition={cursorPosition}
onClose={() => setShowTagDropdown(false)}
/>
)}
</div>
)}
)}
{loopType === 'for' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 100
</div>
)}
</div>
</PopoverContent>
{loopType === 'for' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 100
</div>
)}
</div>
</PopoverContent>
)}
</Popover>
</div>
)

View File

@@ -72,6 +72,9 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
const removeBlock = useWorkflowStore((state) => state.removeBlock)
const blockRef = useRef<HTMLDivElement>(null)
// Check if this is preview mode
const isPreview = data?.isPreview || false
// Determine nesting level by counting parents
const nestingLevel = useMemo(() => {
let level = 0
@@ -91,7 +94,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
const getNestedStyles = () => {
// Base styles
const styles: Record<string, string> = {
backgroundColor: data?.state === 'valid' ? 'rgba(34,197,94,0.05)' : 'transparent',
backgroundColor: 'transparent',
}
// Apply nested styles
@@ -118,7 +121,7 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
' relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid' && 'bg-[rgba(34,197,94,0.05)] ring-2 ring-[#2FB3FF]',
data?.state === 'valid',
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
)}
@@ -128,23 +131,27 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
position: 'relative',
overflow: 'visible',
...nestedStyles,
pointerEvents: 'all',
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='loopNode'
data-nesting-level={nestingLevel}
>
{/* Critical drag handle that controls only the loop node movement */}
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
{!isPreview && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Custom visible resize handle */}
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Child nodes container - Enable pointer events to allow dragging of children */}
<div
@@ -153,27 +160,29 @@ export const LoopNodeComponent = memo(({ data, selected, id }: NodeProps) => {
style={{
position: 'relative',
minHeight: '100%',
pointerEvents: 'auto',
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
{!isPreview && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
{/* Loop Start Block */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#2FB3FF] p-2'
style={{ pointerEvents: 'auto' }}
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role='loop-start'
data-extent='parent'

View File

@@ -21,6 +21,7 @@ interface ParallelNodeData {
parallelType?: 'count' | 'collection'
count?: number
collection?: string | any[] | Record<string, any>
isPreview?: boolean
executionState?: {
currentExecution: number
isExecuting: boolean
@@ -35,6 +36,9 @@ interface ParallelBadgesProps {
}
export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
// Check if this is preview mode
const isPreview = data?.isPreview || false
// State
const [parallelType, setParallelType] = useState<'count' | 'collection'>(
data?.parallelType || 'collection'
@@ -56,6 +60,8 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
// Update node data to include parallel type
const updateNodeData = useCallback(
(updates: Partial<ParallelNodeData>) => {
if (isPreview) return // Don't update in preview mode
useWorkflowStore.setState((state) => ({
blocks: {
...state.blocks,
@@ -69,7 +75,7 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
},
}))
},
[nodeId]
[nodeId, isPreview]
)
// Initialize state from data when it changes
@@ -94,6 +100,8 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
// Handle parallel type change
const handleParallelTypeChange = useCallback(
(newType: 'count' | 'collection') => {
if (isPreview) return // Don't allow changes in preview mode
setParallelType(newType)
updateNodeData({ parallelType: newType })
@@ -108,23 +116,38 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
setTypePopoverOpen(false)
},
[nodeId, iterations, editorValue, updateNodeData, updateParallelCount, updateParallelCollection]
[
nodeId,
iterations,
editorValue,
updateNodeData,
updateParallelCount,
updateParallelCollection,
isPreview,
]
)
// Handle iterations input change
const handleIterationsChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
const handleIterationsChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (isPreview) return // Don't allow changes in preview mode
if (!Number.isNaN(numValue)) {
setInputValue(Math.min(20, numValue).toString())
} else {
setInputValue(sanitizedValue)
}
}, [])
const sanitizedValue = e.target.value.replace(/[^0-9]/g, '')
const numValue = Number.parseInt(sanitizedValue)
if (!Number.isNaN(numValue)) {
setInputValue(Math.min(20, numValue).toString())
} else {
setInputValue(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)) {
@@ -136,11 +159,13 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
setInputValue(iterations.toString())
}
setConfigPopoverOpen(false)
}, [inputValue, iterations, nodeId, updateParallelCount])
}, [inputValue, iterations, nodeId, updateParallelCount, isPreview])
// Handle editor change and check for tag trigger
const handleEditorChange = useCallback(
(value: string) => {
if (isPreview) return // Don't allow changes in preview mode
setEditorValue(value)
updateParallelCollection(nodeId, value)
@@ -156,12 +181,14 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
setShowTagDropdown(tagTrigger.show)
}
},
[nodeId, updateParallelCollection]
[nodeId, updateParallelCollection, isPreview]
)
// Handle tag selection
const handleTagSelect = useCallback(
(newValue: string) => {
if (isPreview) return // Don't allow changes in preview mode
setEditorValue(newValue)
updateParallelCollection(nodeId, newValue)
setShowTagDropdown(false)
@@ -174,7 +201,7 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
}
}, 0)
},
[nodeId, updateParallelCollection]
[nodeId, updateParallelCollection, isPreview]
)
// Handle key events
@@ -187,141 +214,153 @@ export function ParallelBadges({ nodeId, data }: ParallelBadgesProps) {
return (
<div className='-top-9 absolute right-0 left-0 z-10 flex justify-between'>
{/* Parallel Type Badge */}
<Popover open={typePopoverOpen} onOpenChange={setTypePopoverOpen}>
<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',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
!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'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
<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>
{!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>
</div>
</PopoverContent>
</PopoverContent>
)}
</Popover>
{/* Iterations/Collection Badge */}
<Popover open={configPopoverOpen} onOpenChange={setConfigPopoverOpen}>
<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',
'cursor-pointer transition-colors duration-150 hover:bg-accent/50',
!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'}
<ChevronDown className='h-3 w-3 text-muted-foreground' />
{!isPreview && <ChevronDown className='h-3 w-3 text-muted-foreground' />}
</Badge>
</PopoverTrigger>
<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
/>
{!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>
) : (
// 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>
{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)}
/>
)}
<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>
{parallelType === 'count' && (
<div className='text-[10px] text-muted-foreground'>
Enter a number between 1 and 20
</div>
)}
</div>
</PopoverContent>
)}
</Popover>
</div>
)

View File

@@ -88,6 +88,9 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
const { getNodes } = useReactFlow()
const blockRef = useRef<HTMLDivElement>(null)
// Check if this is preview mode
const isPreview = data?.isPreview || false
// Determine nesting level by counting parents
const nestingLevel = useMemo(() => {
const maxDepth = 100 // Prevent infinite loops
@@ -108,7 +111,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
const getNestedStyles = () => {
// Base styles
const styles: Record<string, string> = {
backgroundColor: data?.state === 'valid' ? 'rgba(139, 195, 74, 0.05)' : 'transparent',
backgroundColor: 'transparent',
}
// Apply nested styles
@@ -135,7 +138,7 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
'relative cursor-default select-none',
'transition-block-bg transition-ring',
'z-[20]',
data?.state === 'valid' && 'bg-[rgba(139,195,74,0.05)] ring-2 ring-[#8BC34A]',
data?.state === 'valid',
nestingLevel > 0 &&
`border border-[0.5px] ${nestingLevel % 2 === 0 ? 'border-slate-300/60' : 'border-slate-400/60'}`
)}
@@ -145,23 +148,27 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
position: 'relative',
overflow: 'visible',
...nestedStyles,
pointerEvents: 'all',
pointerEvents: isPreview ? 'none' : 'all',
}}
data-node-id={id}
data-type='parallelNode'
data-nesting-level={nestingLevel}
>
{/* Critical drag handle that controls only the parallel node movement */}
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
{!isPreview && (
<div
className='workflow-drag-handle absolute top-0 right-0 left-0 z-10 h-10 cursor-move'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Custom visible resize handle */}
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
{!isPreview && (
<div
className='absolute right-2 bottom-2 z-20 flex h-8 w-8 cursor-se-resize items-center justify-center text-muted-foreground'
style={{ pointerEvents: 'auto' }}
/>
)}
{/* Child nodes container - Set pointerEvents to allow dragging of children */}
<div
@@ -170,27 +177,29 @@ export const ParallelNodeComponent = memo(({ data, selected, id }: NodeProps) =>
style={{
position: 'relative',
minHeight: '100%',
pointerEvents: 'auto',
pointerEvents: isPreview ? 'none' : 'auto',
}}
>
{/* Delete button - styled like in action-bar.tsx */}
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
useWorkflowStore.getState().removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
{!isPreview && (
<Button
variant='ghost'
size='sm'
onClick={(e) => {
e.stopPropagation()
useWorkflowStore.getState().removeBlock(id)
}}
className='absolute top-2 right-2 z-20 text-gray-500 opacity-0 transition-opacity duration-200 hover:text-red-600 group-hover:opacity-100'
style={{ pointerEvents: 'auto' }}
>
<Trash2 className='h-4 w-4' />
</Button>
)}
{/* Parallel Start Block */}
<div
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#8BC34A] p-2'
style={{ pointerEvents: 'auto' }}
className='-translate-y-1/2 absolute top-1/2 left-8 flex h-10 w-10 transform items-center justify-center rounded-md bg-[#FEE12B] p-2'
style={{ pointerEvents: isPreview ? 'none' : 'auto' }}
data-parent-id={id}
data-node-role='parallel-start'
data-extent='parent'

View File

@@ -15,7 +15,8 @@ import 'reactflow/dist/style.css'
import { createLogger } from '@/lib/logs/console-logger'
import { cn } from '@/lib/utils'
import { LoopTool } from '@/app/w/[id]/components/loop-node/loop-config'
import { LoopNodeComponent } from '@/app/w/[id]/components/loop-node/loop-node'
import { ParallelNodeComponent } from '@/app/w/[id]/components/parallel-node/parallel-node'
import { WorkflowBlock } from '@/app/w/[id]/components/workflow-block/workflow-block'
import { WorkflowEdge } from '@/app/w/[id]/components/workflow-edge/workflow-edge'
import { getBlock } from '@/blocks'
@@ -38,11 +39,11 @@ interface WorkflowPreviewProps {
defaultZoom?: number
}
// Define node types - using the actual workflow components
// Define node types - the components now handle preview mode internally
const nodeTypes: NodeTypes = {
workflowBlock: WorkflowBlock,
// loopLabel: LoopLabel,
// loopInput: LoopInput,
loopNode: LoopNodeComponent,
parallelNode: ParallelNodeComponent,
}
// Define edge types
@@ -92,25 +93,37 @@ export function WorkflowPreview({
[workflowState.edges]
)
// Helper function to calculate absolute position for child blocks
const calculateAbsolutePosition = (
block: any,
blocks: Record<string, any>
): { x: number; y: number } => {
// If no parent, use the block's position as-is
if (!block.data?.parentId) {
return block.position
}
// Find the parent block
const parentBlock = blocks[block.data.parentId]
if (!parentBlock) {
logger.warn(`Parent block not found for child block: ${block.id}`)
return block.position
}
// Recursively calculate parent's absolute position (for nested containers)
const parentAbsolutePosition = calculateAbsolutePosition(parentBlock, blocks)
// Add parent's absolute position to child's relative position
return {
x: parentAbsolutePosition.x + block.position.x,
y: parentAbsolutePosition.y + block.position.y,
}
}
// Transform blocks and loops into ReactFlow nodes
const nodes: Node[] = useMemo(() => {
const nodeArray: Node[] = []
// First, get all blocks with parent-child relationships
const blocksWithParents: Record<string, any> = {}
const topLevelBlocks: Record<string, any> = {}
// Categorize blocks as top-level or child blocks
Object.entries(workflowState.blocks).forEach(([blockId, block]) => {
if (block.data?.parentId) {
// This is a child block
blocksWithParents[blockId] = block
} else {
// This is a top-level block
topLevelBlocks[blockId] = block
}
})
// Add block nodes using the same approach as workflow.tsx
Object.entries(workflowState.blocks).forEach(([blockId, block]) => {
if (!block || !block.type) {
@@ -118,6 +131,49 @@ export function WorkflowPreview({
return
}
// Calculate absolute position for proper preview positioning
const absolutePosition = calculateAbsolutePosition(block, workflowState.blocks)
// Handle container nodes (loop and parallel) differently
if (block.type === 'loop') {
nodeArray.push({
id: block.id,
type: 'loopNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false,
data: {
...block.data,
width: block.data?.width || 500,
height: block.data?.height || 300,
state: 'valid',
isPreview: true,
},
})
return
}
if (block.type === 'parallel') {
nodeArray.push({
id: block.id,
type: 'parallelNode',
position: absolutePosition,
parentId: block.data?.parentId,
extent: block.data?.extent || undefined,
draggable: false,
data: {
...block.data,
width: block.data?.width || 500,
height: block.data?.height || 300,
state: 'valid',
isPreview: true,
},
})
return
}
// Handle regular blocks
const blockConfig = getBlock(block.type)
if (!blockConfig) {
logger.error(`No configuration found for block type: ${block.type}`, { blockId })
@@ -130,23 +186,23 @@ export function WorkflowPreview({
nodeArray.push({
id: blockId,
type: 'workflowBlock',
position: block.position,
position: absolutePosition,
draggable: false,
data: {
type: block.type,
config: blockConfig || (block.type === 'loop' ? LoopTool : null),
config: blockConfig,
name: block.name,
blockState: block,
isReadOnly: true, // Set read-only mode for preview
isPreview: true, // Indicate this is a preview
subBlockValues: subBlocksClone, // Use the deep clone to avoid reference issues
isReadOnly: true,
isPreview: true,
subBlockValues: subBlocksClone,
},
})
// Add children of this block if it's a loop
// Add children of this block if it's a loop (for nested blocks)
if (block.type === 'loop') {
// Find all children of this loop
const childBlocks = Object.entries(blocksWithParents).filter(
const childBlocks = Object.entries(workflowState.blocks).filter(
([_, childBlock]) => childBlock.data?.parentId === blockId
)
@@ -154,39 +210,35 @@ export function WorkflowPreview({
childBlocks.forEach(([childId, childBlock]) => {
const childConfig = getBlock(childBlock.type)
nodeArray.push({
id: childId,
type: 'workflowBlock',
// Position child blocks relative to the parent
position: {
x: block.position.x + 50, // Offset children to the right
y: block.position.y + (childBlock.position?.y || 100), // Preserve vertical positioning
},
data: {
type: childBlock.type,
config: childConfig,
name: childBlock.name,
blockState: childBlock,
showSubBlocks,
isChild: true,
parentId: blockId,
},
draggable: false,
})
if (childConfig) {
nodeArray.push({
id: childId,
type: 'workflowBlock',
// Position child blocks relative to the parent
position: {
x: block.position.x + 50, // Offset children to the right
y: block.position.y + (childBlock.position?.y || 100), // Preserve vertical positioning
},
data: {
type: childBlock.type,
config: childConfig,
name: childBlock.name,
blockState: childBlock,
showSubBlocks,
isChild: true,
parentId: blockId,
isReadOnly: true,
isPreview: true,
},
draggable: false,
})
}
})
}
})
return nodeArray
}, [
blocksStructure,
loopsStructure,
parallelsStructure,
showSubBlocks,
workflowState.blocks,
workflowState.loops,
workflowState.parallels,
])
}, [blocksStructure, loopsStructure, parallelsStructure, showSubBlocks, workflowState.blocks])
// Transform edges
const edges: Edge[] = useMemo(() => {
@@ -198,7 +250,7 @@ export function WorkflowPreview({
targetHandle: edge.targetHandle,
type: 'workflowEdge',
}))
}, [edgesStructure, workflowState.edges, workflowState.parallels])
}, [edgesStructure, workflowState.edges])
return (
<ReactFlowProvider>