Added tag dropdown when user drags in connection tag or types in '<' that shows them what fields are available. Works for short-input and long-input

This commit is contained in:
Waleed Latif
2025-02-01 22:48:28 -08:00
parent 23d4998310
commit 6eadc0ae34
7 changed files with 282 additions and 52 deletions
@@ -5,6 +5,8 @@ import { useState, useRef } from 'react'
import { SubBlockConfig } from '@/blocks/types'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-dropdown'
import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown'
import { useWorkflowStore } from '@/stores/workflow/store'
interface LongInputProps {
placeholder?: string
@@ -23,6 +25,7 @@ export function LongInput({
}: LongInputProps) {
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
const [showEnvVars, setShowEnvVars] = useState(false)
const [showTags, setShowTags] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -34,9 +37,15 @@ export function LongInput({
const newCursorPosition = e.target.selectionStart ?? 0
setValue(newValue)
setCursorPosition(newCursorPosition)
const { show, searchTerm } = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(show)
setSearchTerm(searchTerm)
// Check for environment variables trigger
const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
// Sync scroll position between textarea and overlay
@@ -48,41 +57,53 @@ export function LongInput({
}
// Drag and Drop handlers
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
if (config?.connectionDroppable === false) return
e.preventDefault()
}
const handleDrop = (e: React.DragEvent<HTMLTextAreaElement>) => {
if (config?.connectionDroppable === false) return
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const isValidConnectionBlock =
data.type === 'connectionBlock' &&
data.connectionData.sourceBlockId === blockId
if (!isValidConnectionBlock) return
// Get current cursor position or append to end
const dropPosition = textareaRef.current?.selectionStart ?? value?.toString().length ?? 0
// Insert '<' at drop position to trigger the dropdown
const currentValue = value?.toString() ?? ''
const connectionName = data.connectionData.name
.replace(/\s+/g, '')
.toLowerCase()
const outputSuffix =
data.connectionData.outputType === 'any'
? 'res'
: data.connectionData.outputType
const newValue = currentValue.slice(0, dropPosition) + '<' + currentValue.slice(dropPosition)
// Focus the textarea first
textareaRef.current?.focus()
const newValue = `${currentValue}<${connectionName}.${outputSuffix}>`
setValue(newValue)
// Update all state in a single batch
Promise.resolve().then(() => {
setValue(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
// Set cursor position after state updates
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.selectionStart = dropPosition + 1
textareaRef.current.selectionEnd = dropPosition + 1
}
}, 0)
})
} catch (error) {
console.error('Failed to parse drop data:', error)
}
}
const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
e.preventDefault()
}
// Handle key combinations
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
setShowEnvVars(false)
setShowTags(false)
}
}
@@ -106,6 +127,7 @@ export function LongInput({
onKeyDown={handleKeyDown}
onFocus={() => {
setShowEnvVars(false)
setShowTags(false)
setSearchTerm('')
}}
/>
@@ -126,6 +148,16 @@ export function LongInput({
setSearchTerm('')
}}
/>
<TagDropdown
visible={showTags}
onSelect={setValue}
blockId={blockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
}}
/>
</div>
)
}
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils'
import { SubBlockConfig } from '@/blocks/types'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { EnvVarDropdown, checkEnvVarTrigger } from '@/components/ui/env-var-dropdown'
import { TagDropdown, checkTagTrigger } from '@/components/ui/tag-dropdown'
interface ShortInputProps {
placeholder?: string
@@ -25,6 +26,7 @@ export function ShortInput({
}: ShortInputProps) {
const [isFocused, setIsFocused] = useState(false)
const [showEnvVars, setShowEnvVars] = useState(false)
const [showTags, setShowTags] = useState(false)
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
const [searchTerm, setSearchTerm] = useState('')
const [cursorPosition, setCursorPosition] = useState(0)
@@ -37,9 +39,15 @@ export function ShortInput({
const newCursorPosition = e.target.selectionStart ?? 0
setValue(newValue)
setCursorPosition(newCursorPosition)
const { show, searchTerm } = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(show)
setSearchTerm(searchTerm)
// Check for environment variables trigger
const envVarTrigger = checkEnvVarTrigger(newValue, newCursorPosition)
setShowEnvVars(envVarTrigger.show)
setSearchTerm(envVarTrigger.show ? envVarTrigger.searchTerm : '')
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
// Sync scroll position between input and overlay
@@ -63,41 +71,53 @@ export function ShortInput({
}, [value, isFocused])
// Drag and Drop handlers
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
if (config?.connectionDroppable === false) return
e.preventDefault()
}
const handleDrop = (e: React.DragEvent<HTMLInputElement>) => {
if (config?.connectionDroppable === false) return
e.preventDefault()
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data.type !== 'connectionBlock') return
const isValidConnectionBlock =
data.type === 'connectionBlock' &&
data.connectionData.sourceBlockId === blockId
if (!isValidConnectionBlock) return
// Get current cursor position or append to end
const dropPosition = inputRef.current?.selectionStart ?? value?.toString().length ?? 0
// Insert '<' at drop position to trigger the dropdown
const currentValue = value?.toString() ?? ''
const connectionName = data.connectionData.name
.replace(/\s+/g, '')
.toLowerCase()
const outputSuffix =
data.connectionData.outputType === 'any'
? 'res'
: data.connectionData.outputType
const newValue = currentValue.slice(0, dropPosition) + '<' + currentValue.slice(dropPosition)
// Focus the input first
inputRef.current?.focus()
const newValue = `${currentValue}<${connectionName}.${outputSuffix}>`
setValue(newValue)
// Update all state in a single batch
Promise.resolve().then(() => {
setValue(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
// Set cursor position after state updates
setTimeout(() => {
if (inputRef.current) {
inputRef.current.selectionStart = dropPosition + 1
inputRef.current.selectionEnd = dropPosition + 1
}
}, 0)
})
} catch (error) {
console.error('Failed to parse drop data:', error)
}
}
const handleDragOver = (e: React.DragEvent<HTMLInputElement>) => {
e.preventDefault()
}
// Handle key combinations
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
setShowEnvVars(false)
setShowTags(false)
}
}
@@ -124,6 +144,7 @@ export function ShortInput({
onFocus={() => {
setIsFocused(true)
setShowEnvVars(false)
setShowTags(false)
setSearchTerm('')
}}
onBlur={() => setIsFocused(false)}
@@ -152,6 +173,16 @@ export function ShortInput({
setSearchTerm('')
}}
/>
<TagDropdown
visible={showTags}
onSelect={setValue}
blockId={blockId}
inputValue={value?.toString() ?? ''}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
}}
/>
</div>
)
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function useBlockConnections(blockId: string) {
return {
id: sourceBlock.id,
type: sourceBlock.type,
outputType: 'response',
outputType: Object.keys(sourceBlock.outputs || {}),
name: sourceBlock.name,
}
})
+3 -3
View File
@@ -9,9 +9,9 @@ export type BlockCategory = 'basic' | 'advanced'
export type PrimitiveValueType = 'string' | 'number' | 'json' | 'boolean' | 'any'
export type ValueType = PrimitiveValueType | Record<string, PrimitiveValueType>
export interface BlockOutput {
response: ValueType
}
export type BlockOutput =
| PrimitiveValueType
| { [key: string]: BlockOutput }
export type ParamType = 'string' | 'number' | 'boolean' | 'json'
+1 -1
View File
@@ -50,7 +50,7 @@ export function resolveOutputType(
for (const [key, outputConfig] of Object.entries(outputs)) {
// If no dependencies, use the type directly
if (!outputConfig.dependsOn) {
resolvedOutputs[key] = { response: outputConfig.type }
resolvedOutputs[key] = outputConfig.type
continue
}
+167
View File
@@ -0,0 +1,167 @@
import React, { useState, useEffect, useMemo } from 'react'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflow/store'
interface TagDropdownProps {
visible: boolean
onSelect: (newValue: string) => void
blockId: string
className?: string
inputValue: string
cursorPosition: number
onClose?: () => void
}
export const TagDropdown: React.FC<TagDropdownProps> = ({
visible,
onSelect,
blockId,
className,
inputValue,
cursorPosition,
onClose,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0)
// Get available tags from workflow state
const blocks = useWorkflowStore((state) => state.blocks)
const edges = useWorkflowStore((state) => state.edges)
// Get source block and compute tags
const { tags } = useMemo(() => {
const sourceEdge = edges.find(edge => edge.target === blockId)
const sourceBlock = sourceEdge ? blocks[sourceEdge.source] : null
if (!sourceBlock) {
return { tags: [] }
}
// Get all available output paths recursively
const getOutputPaths = (obj: any, prefix = ''): string[] => {
// If we're at a primitive type or null, return the current path
if (typeof obj !== 'object' || obj === null) {
return prefix ? [prefix] : []
}
// If we have a type field, this is a block output definition
if ('type' in obj) {
return getOutputPaths(obj.type, prefix)
}
// Otherwise, traverse the object's properties
return Object.entries(obj).flatMap(([key, value]) => {
const newPrefix = prefix ? `${prefix}.${key}` : key
return getOutputPaths(value, newPrefix)
})
}
// Get all output paths starting from the outputs object
const outputPaths = getOutputPaths(sourceBlock.outputs)
const blockName = sourceBlock.name || sourceBlock.type
// Format tags with block name and output paths
return {
tags: outputPaths.map(path => `${blockName.replace(/\s+/g, '').toLowerCase()}.${path}`)
}
}, [blocks, edges, blockId])
// Handle tag selection
const handleTagSelect = (tag: string) => {
const textBeforeCursor = inputValue.slice(0, cursorPosition)
const textAfterCursor = inputValue.slice(cursorPosition)
// Find the position of the last '<' before cursor
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
if (lastOpenBracket === -1) return
const newValue = textBeforeCursor.slice(0, lastOpenBracket) +
'<' + tag + '>' +
textAfterCursor
onSelect(newValue)
onClose?.()
}
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent) => {
if (!visible || tags.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev =>
prev < tags.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => prev > 0 ? prev - 1 : prev)
break
case 'Enter':
e.preventDefault()
handleTagSelect(tags[selectedIndex])
break
case 'Escape':
e.preventDefault()
onClose?.()
break
}
}
// Add and remove keyboard event listener
useEffect(() => {
if (visible) {
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}
}, [visible, selectedIndex, tags])
// Don't render if not visible or no tags
if (!visible || tags.length === 0) return null
return (
<div
className={cn(
"absolute z-[9999] w-full mt-1 overflow-hidden bg-popover rounded-md border shadow-md",
className
)}
>
<div className="py-1">
{tags.map((tag, index) => (
<button
key={tag}
className={cn(
"w-full px-3 py-1.5 text-sm text-left",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground focus:outline-none",
index === selectedIndex && "bg-accent text-accent-foreground"
)}
onMouseEnter={() => setSelectedIndex(index)}
onMouseDown={(e) => {
e.preventDefault() // Prevent input blur
handleTagSelect(tag)
}}
>
{tag}
</button>
))}
</div>
</div>
)
}
// Helper function to check for '<' trigger
export const checkTagTrigger = (text: string, cursorPosition: number): { show: boolean } => {
if (cursorPosition >= 1) {
const textBeforeCursor = text.slice(0, cursorPosition)
const lastOpenBracket = textBeforeCursor.lastIndexOf('<')
const lastCloseBracket = textBeforeCursor.lastIndexOf('>')
// Show if we have an unclosed '<' that's not part of a completed tag
if (lastOpenBracket !== -1 && (lastCloseBracket === -1 || lastCloseBracket < lastOpenBracket)) {
return { show: true }
}
}
return { show: false }
}
+1 -1
View File
@@ -141,7 +141,7 @@ export class Executor {
// Update block log with success
blockLog.success = true
blockLog.output = output.response
blockLog.output = output
return output
} catch (error) {
// Update block log with error