mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
+55
-23
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+54
-23
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user