Finished if block completely

This commit is contained in:
Emir Karabeg
2025-02-08 04:26:48 -08:00
parent 2999176c7a
commit 1e627af93a
5 changed files with 478 additions and 80 deletions

View File

@@ -0,0 +1,455 @@
import { useEffect, useRef, useState } from 'react'
import { ChevronDown, ChevronUp, Plus, Trash } from 'lucide-react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/themes/prism.css'
import Editor from 'react-simple-code-editor'
import { Handle, Position, useUpdateNodeInternals } from 'reactflow'
import { Button } from '@/components/ui/button'
import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-dropdown'
import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
interface ConditionalBlock {
id: string
title: string
value: string
}
interface ConditionInputProps {
blockId: string
subBlockId: string
isConnecting: boolean
}
export function ConditionInput({ blockId, subBlockId, isConnecting }: ConditionInputProps) {
const [storeValue, setStoreValue] = useSubBlockValue(blockId, subBlockId)
const [lineCount, setLineCount] = useState(1)
const [showTags, setShowTags] = useState(false)
const [showEnvVars, setShowEnvVars] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const editorRef = useRef<HTMLDivElement>(null)
const [visualLineHeights, setVisualLineHeights] = useState<{ [key: string]: number[] }>({})
const updateNodeInternals = useUpdateNodeInternals()
// Initialize conditional blocks with empty values
const [conditionalBlocks, setConditionalBlocks] = useState<ConditionalBlock[]>([
{ id: crypto.randomUUID(), title: 'if', value: '' },
])
// Sync store value with conditional blocks on initial load
useEffect(() => {
if (storeValue !== null) {
try {
const parsedValue = JSON.parse(storeValue.toString())
if (Array.isArray(parsedValue)) {
setConditionalBlocks(parsedValue)
}
} catch {
// If the store value isn't valid JSON, initialize with default block
setConditionalBlocks([{ id: crypto.randomUUID(), title: 'if', value: '' }])
}
}
}, [])
// Update store whenever conditional blocks change
useEffect(() => {
setStoreValue(JSON.stringify(conditionalBlocks))
updateNodeInternals(`${blockId}-${subBlockId}`)
}, [conditionalBlocks, blockId, subBlockId])
// Update block value
const updateBlockValue = (blockId: string, newValue: string) => {
setConditionalBlocks((blocks) =>
blocks.map((block) => (block.id === blockId ? { ...block, value: newValue } : block))
)
}
// Update the line counting logic to be block-specific
useEffect(() => {
if (!editorRef.current) return
const calculateVisualLines = () => {
const preElement = editorRef.current?.querySelector('pre')
if (!preElement) return
const newVisualLineHeights: { [key: string]: number[] } = {}
conditionalBlocks.forEach((block) => {
const lines = block.value.split('\n')
const blockVisualHeights: number[] = []
// Create a hidden container with the same width as the editor
const container = document.createElement('div')
container.style.cssText = `
position: absolute;
visibility: hidden;
width: ${preElement.clientWidth}px;
font-family: ${window.getComputedStyle(preElement).fontFamily};
font-size: ${window.getComputedStyle(preElement).fontSize};
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
`
document.body.appendChild(container)
// Process each line
lines.forEach((line) => {
const lineDiv = document.createElement('div')
if (line.includes('<') && line.includes('>')) {
const parts = line.split(/(<[^>]+>)/g)
parts.forEach((part) => {
const span = document.createElement('span')
span.textContent = part
if (part.startsWith('<') && part.endsWith('>')) {
span.style.color = 'rgb(153, 0, 85)'
}
lineDiv.appendChild(span)
})
} else {
lineDiv.textContent = line || ' '
}
container.appendChild(lineDiv)
const actualHeight = lineDiv.getBoundingClientRect().height
const lineUnits = Math.ceil(actualHeight / 21)
blockVisualHeights.push(lineUnits)
container.removeChild(lineDiv)
})
document.body.removeChild(container)
newVisualLineHeights[block.id] = blockVisualHeights
})
setVisualLineHeights(newVisualLineHeights)
}
calculateVisualLines()
const resizeObserver = new ResizeObserver(calculateVisualLines)
resizeObserver.observe(editorRef.current)
return () => resizeObserver.disconnect()
}, [conditionalBlocks])
// Modify the line numbers rendering to be block-specific
const renderLineNumbers = (blockId: string) => {
const numbers: JSX.Element[] = []
let lineNumber = 1
const blockHeights = visualLineHeights[blockId] || []
blockHeights.forEach((height) => {
for (let i = 0; i < height; i++) {
numbers.push(
<div
key={`${lineNumber}-${i}`}
className={cn('text-xs text-muted-foreground leading-[21px]', i > 0 && 'invisible')}
>
{lineNumber}
</div>
)
}
lineNumber++
})
return numbers
}
// Handle drops from connection blocks
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
// Get current cursor position from the textarea
const textarea = editorRef.current?.querySelector('textarea')
const dropPosition =
textarea?.selectionStart ?? conditionalBlocks.map((block) => block.value).join('\n').length
// Insert '<' at drop position to trigger the dropdown
const newValue =
conditionalBlocks
.map((block) => block.value)
.join('\n')
.slice(0, dropPosition) +
'<' +
conditionalBlocks
.map((block) => block.value)
.join('\n')
.slice(dropPosition)
updateBlockValue(data.connectionData?.sourceBlockId || '', newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
if (data.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
// Set cursor position after state updates
setTimeout(() => {
if (textarea) {
textarea.selectionStart = dropPosition + 1
textarea.selectionEnd = dropPosition + 1
textarea.focus()
}
}, 0)
} catch (error) {
console.error('Failed to parse drop data:', error)
}
}
// Handle tag selection
const handleTagSelect = (newValue: string) => {
updateBlockValue(activeSourceBlockId || '', newValue)
setShowTags(false)
setActiveSourceBlockId(null)
}
// Handle environment variable selection
const handleEnvVarSelect = (newValue: string) => {
updateBlockValue(activeSourceBlockId || '', newValue)
setShowEnvVars(false)
}
// Update block titles based on position
const updateBlockTitles = (blocks: ConditionalBlock[]): ConditionalBlock[] => {
return blocks.map((block, index) => ({
...block,
title: index === 0 ? 'if' : index === blocks.length - 1 ? 'else' : 'else if',
}))
}
// Update these functions to use updateBlockTitles
const addBlock = (afterId: string) => {
const blockIndex = conditionalBlocks.findIndex((block) => block.id === afterId)
const newBlock = { id: crypto.randomUUID(), title: '', value: '' }
const newBlocks = [...conditionalBlocks]
newBlocks.splice(blockIndex + 1, 0, newBlock)
setConditionalBlocks(updateBlockTitles(newBlocks))
}
const removeBlock = (id: string) => {
if (conditionalBlocks.length === 1) return
setConditionalBlocks((blocks) => updateBlockTitles(blocks.filter((block) => block.id !== id)))
}
const moveBlock = (id: string, direction: 'up' | 'down') => {
const blockIndex = conditionalBlocks.findIndex((block) => block.id === id)
if (
(direction === 'up' && blockIndex === 0) ||
(direction === 'down' && blockIndex === conditionalBlocks.length - 1)
)
return
const newBlocks = [...conditionalBlocks]
const targetIndex = direction === 'up' ? blockIndex - 1 : blockIndex + 1
;[newBlocks[blockIndex], newBlocks[targetIndex]] = [
newBlocks[targetIndex],
newBlocks[blockIndex],
]
setConditionalBlocks(updateBlockTitles(newBlocks))
}
return (
<div className="space-y-4">
{conditionalBlocks.map((block, index) => (
<div
key={block.id}
className="overflow-visible rounded-lg border bg-background group relative"
>
<div className="flex h-10 items-center justify-between border-b bg-card px-3">
<span className="text-sm font-medium">{block.title}</span>
<Handle
type="source"
position={Position.Right}
id={`condition-${block.id}`}
className={cn(
'!w-3.5 !h-3.5',
'!bg-white !rounded-full !border !border-gray-200',
'group-hover:!border-blue-500',
'!transition-border !duration-150 !cursor-crosshair',
'!absolute !z-50',
'!right-[-25px]'
)}
data-nodeid={`${blockId}-${subBlockId}`}
data-handleid={`condition-${block.id}`}
style={{
top: '50%',
transform: 'translateY(-50%)',
}}
isConnectableStart={true}
isConnectableEnd={false}
/>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => addBlock(block.id)}
className="h-8 w-8"
>
<Plus className="h-4 w-4" />
<span className="sr-only">Add Block</span>
</Button>
</TooltipTrigger>
<TooltipContent>Add Block</TooltipContent>
</Tooltip>
<div className="flex items-center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => moveBlock(block.id, 'up')}
disabled={index === 0}
className="h-8 w-8"
>
<ChevronUp className="h-4 w-4" />
<span className="sr-only">Move Up</span>
</Button>
</TooltipTrigger>
<TooltipContent>Move Up</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => moveBlock(block.id, 'down')}
disabled={index === conditionalBlocks.length - 1}
className="h-8 w-8"
>
<ChevronDown className="h-4 w-4" />
<span className="sr-only">Move Down</span>
</Button>
</TooltipTrigger>
<TooltipContent>Move Down</TooltipContent>
</Tooltip>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => removeBlock(block.id)}
disabled={conditionalBlocks.length === 1}
className="h-8 w-8 text-destructive hover:text-destructive"
>
<Trash className="h-4 w-4" />
<span className="sr-only">Delete Block</span>
</Button>
</TooltipTrigger>
<TooltipContent>Delete Condition</TooltipContent>
</Tooltip>
</div>
</div>
<div
className={cn(
'relative min-h-[100px] rounded-md bg-background font-mono text-sm',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
)}
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
>
{/* Line numbers */}
<div
className="absolute left-0 top-0 bottom-0 w-[30px] bg-muted/30 flex flex-col items-end pr-3 pt-3 select-none"
aria-hidden="true"
>
{renderLineNumbers(block.id)}
</div>
<div className="pl-[30px] pt-0 mt-0 relative" ref={editorRef}>
{block.value.length === 0 && (
<div className="absolute left-[42px] top-[12px] text-muted-foreground/50 select-none pointer-events-none">
{'<response> === true'}
</div>
)}
<Editor
value={block.value}
onValueChange={(newCode) => {
updateBlockValue(block.id, newCode)
// Check for tag trigger and environment variable trigger
const textarea = editorRef.current?.querySelector('textarea')
if (textarea) {
const pos = textarea.selectionStart
setCursorPosition(pos)
const tagTrigger = checkTagTrigger(newCode, pos)
setShowTags(tagTrigger.show)
if (!tagTrigger.show) {
setActiveSourceBlockId(null)
}
const envVarTrigger = checkEnvVarTrigger(newCode, pos)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
}
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setShowTags(false)
setShowEnvVars(false)
}
}}
highlight={(code) => highlight(code, languages.javascript, 'javascript')}
padding={12}
style={{
fontFamily: 'inherit',
minHeight: '46px',
lineHeight: '21px',
}}
className="focus:outline-none"
textareaClassName="focus:outline-none focus:ring-0 bg-transparent"
/>
{showEnvVars && (
<EnvVarDropdown
visible={showEnvVars}
onSelect={handleEnvVarSelect}
searchTerm={searchTerm}
inputValue={block.value}
cursorPosition={cursorPosition}
onClose={() => {
setShowEnvVars(false)
setSearchTerm('')
}}
/>
)}
{showTags && (
<TagDropdown
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={activeSourceBlockId}
inputValue={block.value}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
/>
)}
</div>
</div>
</div>
))}
</div>
)
}

View File

@@ -2,6 +2,7 @@ import { Label } from '@/components/ui/label'
import { SubBlockConfig } from '../../../../../../../blocks/types'
import { CheckboxList } from './components/checkbox-list'
import { Code } from './components/code'
import { ConditionInput } from './components/condition-input'
import { Dropdown } from './components/dropdown'
import { LongInput } from './components/long-input'
import { ShortInput } from './components/short-input'
@@ -89,6 +90,10 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
layout={config.layout}
/>
)
case 'condition-input':
return (
<ConditionInput blockId={blockId} subBlockId={config.id} isConnecting={isConnecting} />
)
default:
return null
}

View File

@@ -26,7 +26,6 @@ interface SubBlockPosition {
export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBlockProps) {
const { toolbar, workflow } = config
// Dragging connection state
const [isConnecting, setIsConnecting] = useState(false)
const isEnabled = useWorkflowStore((state) => state.blocks[id]?.enabled ?? true)
const horizontalHandles = useWorkflowStore(
@@ -36,62 +35,12 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
const [editedName, setEditedName] = useState('')
const updateBlockName = useWorkflowStore((state) => state.updateBlockName)
const blockRef = useRef<HTMLDivElement>(null)
const [subBlockPositions, setSubBlockPositions] = useState<SubBlockPosition[]>([])
const updateNodeInternals = useUpdateNodeInternals()
// Add a small delay to ensure DOM is ready
// Add effect to update node internals when handles change
useEffect(() => {
const calculatePositions = () => {
if (!blockRef.current) return
// Add setTimeout to ensure styles are applied
setTimeout(() => {
const positions: SubBlockPosition[] = []
const blockRect = blockRef.current?.getBoundingClientRect()
if (!blockRect) return
workflow.subBlocks
.filter((block) => block.outputHandle)
.forEach((block) => {
const subBlockElement = blockRef.current?.querySelector(
`[data-subblock-id="${block.id}"]`
)
if (subBlockElement) {
const subBlockRect = subBlockElement.getBoundingClientRect()
positions.push({
id: block.id,
top: subBlockRect.bottom - blockRect.top - 25,
})
}
})
setSubBlockPositions(positions)
updateNodeInternals(id)
}, 0)
}
// Calculate initial positions with a slight delay
const initialTimer = setTimeout(calculatePositions, 50)
// Use ResizeObserver to detect size changes and recalculate
const resizeObserver = new ResizeObserver(() => {
calculatePositions()
})
if (blockRef.current) {
resizeObserver.observe(blockRef.current)
}
// Recalculate on window resize
window.addEventListener('resize', calculatePositions)
return () => {
clearTimeout(initialTimer)
resizeObserver.disconnect()
window.removeEventListener('resize', calculatePositions)
}
}, [workflow.subBlocks, id, updateNodeInternals])
updateNodeInternals(id)
}, [id, horizontalHandles])
function groupSubBlocks(subBlocks: SubBlockConfig[]) {
// Filter out hidden subblocks
@@ -157,6 +106,7 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
<Handle
type="target"
position={horizontalHandles ? Position.Left : Position.Top}
id="target"
className={cn(
'!w-3.5 !h-3.5',
'!bg-white !rounded-full !border !border-gray-200',
@@ -164,6 +114,10 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
'!transition-border !duration-150 !cursor-crosshair',
horizontalHandles ? '!left-[-7px]' : '!top-[-7px]'
)}
data-nodeid={id}
data-handleid="target"
isConnectableStart={false}
isConnectableEnd={true}
/>
<div className="flex items-center justify-between p-3 border-b workflow-drag-handle cursor-grab [&:active]:cursor-grabbing">
@@ -208,7 +162,6 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
<div
key={`${id}-${rowIndex}-${blockIndex}`}
className={`space-y-1 ${subBlock.layout === 'half' ? 'flex-1' : 'w-full'}`}
data-subblock-id={subBlock.id}
>
<SubBlock blockId={id} config={subBlock} isConnecting={isConnecting} />
</div>
@@ -217,11 +170,12 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
))}
</div>
{/* Main output handle */}
{subBlockPositions.length === 0 && (
{/* Main output handle - only render if not a condition block */}
{type !== 'condition' && (
<Handle
type="source"
position={horizontalHandles ? Position.Right : Position.Bottom}
id="source"
className={cn(
'!w-3.5 !h-3.5',
'!bg-white !rounded-full !border !border-gray-200',
@@ -229,26 +183,12 @@ export function WorkflowBlock({ id, type, config, name, selected }: WorkflowBloc
'!transition-border !duration-150 !cursor-crosshair',
horizontalHandles ? '!right-[-7px]' : '!bottom-[-7px]'
)}
data-nodeid={id}
data-handleid="source"
isConnectableStart={true}
isConnectableEnd={false}
/>
)}
{/* Subblock output handles */}
{subBlockPositions.map((position) => (
<Handle
key={`${id}-${position.id}`}
type="source"
position={Position.Right}
id={`output-${position.id}`}
style={{ top: position.top }}
className={cn(
'!w-3.5 !h-3.5',
'!bg-white !rounded-full !border !border-gray-200',
'!transition-border !duration-150 !cursor-crosshair',
'group-hover:!border-blue-500',
'!right-[-7px]'
)}
/>
))}
</Card>
)
}

View File

@@ -28,9 +28,8 @@ export const ConditionBlock: BlockConfig<CodeExecutionOutput> = {
},
subBlocks: [
{
id: 'if',
title: 'if',
type: 'code',
id: 'conditions',
type: 'condition-input',
layout: 'full',
},
],

View File

@@ -19,7 +19,7 @@ export type SubBlockType =
| 'switch'
| 'tool-input'
| 'checkbox-list'
| 'condition-input'
export type SubBlockLayout = 'full' | 'half'
// Tool output type utilities
@@ -75,7 +75,6 @@ export interface SubBlockConfig {
placeholder?: string
password?: boolean
connectionDroppable?: boolean
outputHandle?: boolean
hidden?: boolean
value?: (params: Record<string, any>) => string
minimizable?: boolean