fix(variables): added the same input vars mapping from workflow block, added type validation to variables block, updated UI (#1761)

* fix(variables): added the same input vars mapping from workflow block, added type validation to variables block, updated UI

* cleanup

* lint
This commit is contained in:
Waleed
2025-10-29 01:22:10 -07:00
committed by GitHub
parent 7be9941bc9
commit fcf947df22
7 changed files with 228 additions and 84 deletions

View File

@@ -6,7 +6,6 @@ import { checkTagTrigger, TagDropdown } from '@/components/ui/tag-dropdown'
import { cn } from '@/lib/utils'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/components/sub-block/hooks/use-sub-block-value'
import { useAccessibleReferencePrefixes } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-accessible-reference-prefixes'
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
interface InputFormatField {
name: string
@@ -57,24 +56,20 @@ interface InputMappingProps {
isPreview?: boolean
previewValue?: any
disabled?: boolean
isConnecting?: boolean
}
// Simple mapping UI: for each field in child Input Trigger's inputFormat, render an input with TagDropdown support
export function InputMapping({
blockId,
subBlockId,
isPreview = false,
previewValue,
disabled = false,
isConnecting = false,
}: InputMappingProps) {
const [mapping, setMapping] = useSubBlockValue(blockId, subBlockId)
const [selectedWorkflowId] = useSubBlockValue(blockId, 'workflowId')
const { workflows } = useWorkflowRegistry.getState()
// Fetch child workflow state via registry API endpoint, using cached metadata when possible
// Here we rely on live store; the serializer/executor will resolve at runtime too.
// We only need the inputFormat from an Input Trigger in the selected child workflow state.
const [childInputFields, setChildInputFields] = useState<Array<{ name: string; type?: string }>>(
[]
)
@@ -97,7 +92,6 @@ export function InputMapping({
}
const { data } = await res.json()
const blocks = (data?.state?.blocks as Record<string, unknown>) || {}
// Prefer new input_trigger
const triggerEntry = Object.entries(blocks).find(([, b]) => isInputTriggerBlock(b))
if (triggerEntry && isInputTriggerBlock(triggerEntry[1])) {
const inputFormat = triggerEntry[1].subBlocks?.inputFormat?.value
@@ -110,7 +104,6 @@ export function InputMapping({
}
}
// Fallback: legacy starter block inputFormat (subBlocks or config.params)
const starterEntry = Object.entries(blocks).find(([, b]) => isStarterBlock(b))
if (starterEntry && isStarterBlock(starterEntry[1])) {
const starter = starterEntry[1]
@@ -217,6 +210,7 @@ export function InputMapping({
subBlockId={subBlockId}
disabled={isPreview || disabled}
accessiblePrefixes={accessiblePrefixes}
isConnecting={isConnecting}
/>
)
})}
@@ -224,7 +218,6 @@ export function InputMapping({
)
}
// Individual field component with TagDropdown support
function InputMappingField({
fieldName,
fieldType,
@@ -234,6 +227,7 @@ function InputMappingField({
subBlockId,
disabled,
accessiblePrefixes,
isConnecting,
}: {
fieldName: string
fieldType?: string
@@ -243,9 +237,12 @@ function InputMappingField({
subBlockId: string
disabled: boolean
accessiblePrefixes: Set<string> | undefined
isConnecting?: boolean
}) {
const [showTags, setShowTags] = useState(false)
const [cursorPosition, setCursorPosition] = useState(0)
const [activeSourceBlockId, setActiveSourceBlockId] = useState<string | null>(null)
const [dragHighlight, setDragHighlight] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
@@ -261,12 +258,10 @@ function InputMappingField({
onChange(newValue)
setCursorPosition(newCursorPosition)
// Check for tag trigger
const tagTrigger = checkTagTrigger(newValue, newCursorPosition)
setShowTags(tagTrigger.show)
}
// Sync scroll position between input and overlay
const handleScroll = (e: React.UIEvent<HTMLInputElement>) => {
if (overlayRef.current) {
overlayRef.current.scrollLeft = e.currentTarget.scrollLeft
@@ -281,6 +276,47 @@ function InputMappingField({
const handleTagSelect = (newValue: string) => {
onChange(newValue)
setShowTags(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragHighlight(false)
const input = inputRef.current
input?.focus()
if (input) {
const dropPosition = input.selectionStart ?? value.length
const newValue = `${value.slice(0, dropPosition)}<${value.slice(dropPosition)}`
onChange(newValue)
setCursorPosition(dropPosition + 1)
setShowTags(true)
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data?.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
} catch {}
setTimeout(() => {
if (input && typeof input.selectionStart === 'number') {
input.selectionStart = dropPosition + 1
input.selectionEnd = dropPosition + 1
}
}, 0)
}
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
setDragHighlight(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setDragHighlight(false)
}
return (
@@ -298,7 +334,9 @@ function InputMappingField({
ref={inputRef}
className={cn(
'allow-scroll h-9 w-full overflow-auto text-transparent caret-foreground placeholder:text-muted-foreground/50',
'border border-input bg-white transition-colors duration-200 dark:border-input/60 dark:bg-background'
'border border-input bg-white transition-colors duration-200 dark:border-input/60 dark:bg-background',
dragHighlight && 'ring-2 ring-blue-500 ring-offset-2',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
)}
type='text'
value={value}
@@ -311,6 +349,9 @@ function InputMappingField({
}}
onScroll={handleScroll}
onKeyDown={handleKeyDown}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
autoComplete='off'
style={{ overflowX: 'auto' }}
disabled={disabled}
@@ -335,11 +376,12 @@ function InputMappingField({
visible={showTags}
onSelect={handleTagSelect}
blockId={blockId}
activeSourceBlockId={null}
activeSourceBlockId={activeSourceBlockId}
inputValue={value}
cursorPosition={cursorPosition}
onClose={() => {
setShowTags(false)
setActiveSourceBlockId(null)
}}
/>
</div>

View File

@@ -1,7 +1,6 @@
import { useRef, useState } from 'react'
import { Plus, Trash } from 'lucide-react'
import { useParams } from 'next/navigation'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { formatDisplayText } from '@/components/ui/formatted-text'
import { Input } from '@/components/ui/input'
@@ -177,19 +176,39 @@ export function VariablesInput({
const handleDrop = (e: React.DragEvent, assignmentId: string) => {
e.preventDefault()
setDragHighlight((prev) => ({ ...prev, [assignmentId]: false }))
const input = valueInputRefs.current[assignmentId]
input?.focus()
const tag = e.dataTransfer.getData('text/plain')
if (tag?.startsWith('<')) {
if (input) {
const assignment = assignments.find((a) => a.id === assignmentId)
if (!assignment) return
const currentValue = assignment?.value || ''
const dropPosition = (input as any).selectionStart ?? currentValue.length
const newValue = `${currentValue.slice(0, dropPosition)}<${currentValue.slice(dropPosition)}`
updateAssignment(assignmentId, { value: newValue })
setActiveFieldId(assignmentId)
setCursorPosition(dropPosition + 1)
setShowTags(true)
const currentValue = assignment.value || ''
updateAssignment(assignmentId, { value: currentValue + tag })
try {
const data = JSON.parse(e.dataTransfer.getData('application/json'))
if (data?.connectionData?.sourceBlockId) {
setActiveSourceBlockId(data.connectionData.sourceBlockId)
}
} catch {}
setTimeout(() => {
const el = valueInputRefs.current[assignmentId]
if (el && typeof (el as any).selectionStart === 'number') {
;(el as any).selectionStart = dropPosition + 1
;(el as any).selectionEnd = dropPosition + 1
}
}, 0)
}
}
const handleDragOver = (e: React.DragEvent, assignmentId: string) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
setDragHighlight((prev) => ({ ...prev, [assignmentId]: true }))
}
@@ -200,8 +219,48 @@ export function VariablesInput({
if (isPreview && (!assignments || assignments.length === 0)) {
return (
<div className='flex items-center justify-center rounded-md border border-border/40 border-dashed bg-muted/20 p-4 text-center text-muted-foreground text-sm'>
No variable assignments defined
<div className='flex flex-col items-center justify-center rounded-md border border-border/40 border-dashed bg-muted/20 py-8 text-center'>
<svg
className='mb-3 h-10 w-10 text-muted-foreground/40'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={1.5}
d='M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
/>
</svg>
<p className='mb-1 font-medium text-foreground text-sm'>No variable assignments defined</p>
<p className='text-muted-foreground text-xs'>
Add variables in the Variables panel to get started
</p>
</div>
)
}
if (!isPreview && hasNoWorkflowVariables && assignments.length === 0) {
return (
<div className='flex flex-col items-center justify-center rounded-lg border border-border/50 bg-muted/30 p-8 text-center'>
<svg
className='mb-3 h-10 w-10 text-muted-foreground/60'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={1.5}
d='M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'
/>
</svg>
<p className='font-medium text-muted-foreground text-sm'>No variables found</p>
<p className='mt-1 text-muted-foreground/80 text-xs'>
Add variables in the Variables panel to get started
</p>
</div>
)
}
@@ -214,22 +273,29 @@ export function VariablesInput({
return (
<div
key={assignment.id}
className='group relative rounded-lg border border-border/60 bg-background p-3 transition-all hover:border-border'
className='group relative rounded-lg border border-border/50 bg-background/50 p-3 transition-all hover:border-border hover:bg-background'
>
{!isPreview && !disabled && (
<Button
variant='ghost'
size='icon'
className='absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100'
className='absolute top-2 right-2 h-6 w-6 opacity-0 transition-opacity group-hover:opacity-100'
onClick={() => removeAssignment(assignment.id)}
>
<Trash className='h-3.5 w-3.5' />
<Trash className='h-3.5 w-3.5 text-muted-foreground hover:text-destructive' />
</Button>
)}
<div className='space-y-3'>
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>Variable</Label>
<div className='flex items-center justify-between pr-8'>
<Label className='text-xs'>Variable</Label>
{assignment.variableName && (
<span className='rounded-md bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground'>
{assignment.type}
</span>
)}
</div>
<Select
value={assignment.variableId || assignment.variableName || ''}
onValueChange={(value) => {
@@ -240,7 +306,7 @@ export function VariablesInput({
}}
disabled={isPreview || disabled}
>
<SelectTrigger className='h-9 bg-white dark:bg-background'>
<SelectTrigger className='h-9 border border-input bg-white dark:border-input/60 dark:bg-background'>
<SelectValue placeholder='Select a variable...' />
</SelectTrigger>
<SelectContent>
@@ -249,12 +315,7 @@ export function VariablesInput({
return availableVars.length > 0 ? (
availableVars.map((variable) => (
<SelectItem key={variable.id} value={variable.id}>
<div className='flex items-center gap-2'>
<span>{variable.name}</span>
<Badge variant='outline' className='text-[10px]'>
{variable.type}
</Badge>
</div>
{variable.name}
</SelectItem>
))
) : (
@@ -276,16 +337,7 @@ export function VariablesInput({
</div>
<div className='space-y-1.5'>
<Label className='text-muted-foreground text-xs'>Type</Label>
<Input
value={assignment.type || 'string'}
disabled={true}
className='h-9 bg-muted/50 text-muted-foreground'
/>
</div>
<div className='relative space-y-1.5'>
<Label className='text-muted-foreground text-xs'>Value</Label>
<Label className='text-xs'>Value</Label>
{assignment.type === 'object' || assignment.type === 'array' ? (
<Textarea
ref={(el) => {
@@ -306,7 +358,7 @@ export function VariablesInput({
}
disabled={isPreview || disabled}
className={cn(
'min-h-[120px] border border-input bg-white font-mono text-sm dark:bg-background',
'min-h-[120px] border border-input bg-white font-mono text-sm placeholder:text-muted-foreground/50 dark:border-input/60 dark:bg-background',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
)}
@@ -331,7 +383,7 @@ export function VariablesInput({
placeholder={`${assignment.type} value`}
disabled={isPreview || disabled}
className={cn(
'h-9 bg-white text-transparent caret-foreground dark:bg-background',
'h-9 border border-input bg-white text-transparent caret-foreground placeholder:text-muted-foreground/50 dark:border-input/60 dark:bg-background',
dragHighlight[assignment.id] && 'ring-2 ring-blue-500 ring-offset-2',
isConnecting && 'ring-2 ring-blue-500 ring-offset-2'
)}
@@ -375,20 +427,15 @@ export function VariablesInput({
</div>
) : null}
{!isPreview && !disabled && (
{!isPreview && !disabled && !hasNoWorkflowVariables && (
<Button
onClick={addAssignment}
variant='outline'
size='sm'
className='w-full border-dashed'
disabled={hasNoWorkflowVariables || allVariablesAssigned}
className='h-9 w-full border-dashed'
disabled={allVariablesAssigned}
>
{!hasNoWorkflowVariables && <Plus className='mr-2 h-4 w-4' />}
{hasNoWorkflowVariables
? 'No variables found'
: allVariablesAssigned
? 'All Variables Assigned'
: 'Add Variable Assignment'}
<Plus className='mr-2 h-4 w-4' />
{allVariablesAssigned ? 'All Variables Assigned' : 'Add Variable Assignment'}
</Button>
)}
</div>

View File

@@ -471,6 +471,7 @@ export const SubBlock = memo(
isPreview={isPreview}
previewValue={previewValue}
disabled={isDisabled}
isConnecting={isConnecting}
/>
)
}

View File

@@ -39,9 +39,7 @@ export const VariablesBlock: BlockConfig = {
},
},
outputs: {
assignments: {
type: 'json',
description: 'JSON object mapping variable names to their assigned values',
},
// Dynamic outputs - each assigned variable will be available as a top-level output
// For example, if you assign variable1=5, you can reference it as <variables_block.variable1>
},
}

View File

@@ -27,7 +27,7 @@ export const WaitBlock: BlockConfig = {
title: 'Wait Amount',
type: 'short-input',
layout: 'half',
description: 'How long to wait. Max: 600 seconds or 10 minutes',
description: 'Max: 600 seconds or 10 minutes',
placeholder: '10',
value: () => '10',
required: true,

View File

@@ -419,6 +419,21 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const outputPaths = generateOutputPaths(blockConfig.outputs)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (sourceBlock.type === 'variables') {
// For variables block, show assigned variable names as outputs
const variablesValue = getSubBlockValue(activeSourceBlockId, 'variables')
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
const validAssignments = variablesValue.filter((assignment: { variableName?: string }) =>
assignment?.variableName?.trim()
)
blockTags = validAssignments.map(
(assignment: { variableName: string }) =>
`${normalizedBlockName}.${assignment.variableName.trim()}`
)
} else {
blockTags = [normalizedBlockName]
}
} else if (responseFormat) {
const schemaFields = extractFieldsFromSchema(responseFormat)
if (schemaFields.length > 0) {
@@ -765,6 +780,21 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const outputPaths = generateOutputPaths(blockConfig.outputs)
blockTags = outputPaths.map((path) => `${normalizedBlockName}.${path}`)
}
} else if (accessibleBlock.type === 'variables') {
// For variables block, show assigned variable names as outputs
const variablesValue = getSubBlockValue(accessibleBlockId, 'variables')
if (variablesValue && Array.isArray(variablesValue) && variablesValue.length > 0) {
const validAssignments = variablesValue.filter((assignment: { variableName?: string }) =>
assignment?.variableName?.trim()
)
blockTags = validAssignments.map(
(assignment: { variableName: string }) =>
`${normalizedBlockName}.${assignment.variableName.trim()}`
)
} else {
blockTags = [normalizedBlockName]
}
} else if (responseFormat) {
const schemaFields = extractFieldsFromSchema(responseFormat)
if (schemaFields.length > 0) {

View File

@@ -29,17 +29,13 @@ export class VariablesBlockHandler implements BlockHandler {
})
try {
// Initialize workflowVariables if not present
if (!context.workflowVariables) {
context.workflowVariables = {}
}
// Parse variable assignments from the custom input
const assignments = this.parseAssignments(inputs.variables)
// Update context.workflowVariables with new values
for (const assignment of assignments) {
// Find the variable by ID or name
const existingEntry = assignment.variableId
? [assignment.variableId, context.workflowVariables[assignment.variableId]]
: Object.entries(context.workflowVariables).find(
@@ -47,7 +43,6 @@ export class VariablesBlockHandler implements BlockHandler {
)
if (existingEntry?.[1]) {
// Update existing variable value
const [id, variable] = existingEntry
context.workflowVariables[id] = {
...variable,
@@ -68,15 +63,12 @@ export class VariablesBlockHandler implements BlockHandler {
})),
})
// Return assignments as a JSON object mapping variable names to values
const assignmentsOutput: Record<string, any> = {}
const output: Record<string, any> = {}
for (const assignment of assignments) {
assignmentsOutput[assignment.variableName] = assignment.value
output[assignment.variableName] = assignment.value
}
return {
assignments: assignmentsOutput,
}
return output
} catch (error: any) {
logger.error('Variables block execution failed:', error)
throw new Error(`Variables block execution failed: ${error.message}`)
@@ -97,7 +89,7 @@ export class VariablesBlockHandler implements BlockHandler {
if (assignment?.variableName?.trim()) {
const name = assignment.variableName.trim()
const type = assignment.type || 'string'
const value = this.parseValueByType(assignment.value, type)
const value = this.parseValueByType(assignment.value, type, name)
result.push({
variableId: assignment.variableId,
@@ -111,9 +103,8 @@ export class VariablesBlockHandler implements BlockHandler {
return result
}
private parseValueByType(value: any, type: string): any {
// Handle null/undefined early
if (value === null || value === undefined) {
private parseValueByType(value: any, type: string, variableName?: string): any {
if (value === null || value === undefined || value === '') {
if (type === 'number') return 0
if (type === 'boolean') return false
if (type === 'array') return []
@@ -121,7 +112,6 @@ export class VariablesBlockHandler implements BlockHandler {
return ''
}
// Handle plain and string types (plain is for backward compatibility)
if (type === 'string' || type === 'plain') {
return typeof value === 'string' ? value : String(value)
}
@@ -129,35 +119,71 @@ export class VariablesBlockHandler implements BlockHandler {
if (type === 'number') {
if (typeof value === 'number') return value
if (typeof value === 'string') {
const num = Number(value)
return Number.isNaN(num) ? 0 : num
const trimmed = value.trim()
if (trimmed === '') return 0
const num = Number(trimmed)
if (Number.isNaN(num)) {
throw new Error(
`Invalid number value for variable "${variableName || 'unknown'}": "${value}". Expected a valid number.`
)
}
return num
}
return 0
throw new Error(
`Invalid type for variable "${variableName || 'unknown'}": expected number, got ${typeof value}`
)
}
if (type === 'boolean') {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
return value.toLowerCase() === 'true'
const lower = value.toLowerCase().trim()
if (lower === 'true') return true
if (lower === 'false') return false
throw new Error(
`Invalid boolean value for variable "${variableName || 'unknown'}": "${value}". Expected "true" or "false".`
)
}
return Boolean(value)
}
if (type === 'object' || type === 'array') {
if (typeof value === 'object' && value !== null) {
if (type === 'array' && !Array.isArray(value)) {
throw new Error(
`Invalid array value for variable "${variableName || 'unknown'}": expected an array, got an object`
)
}
if (type === 'object' && Array.isArray(value)) {
throw new Error(
`Invalid object value for variable "${variableName || 'unknown'}": expected an object, got an array`
)
}
return value
}
if (typeof value === 'string' && value.trim()) {
try {
return JSON.parse(value)
} catch {
return type === 'array' ? [] : {}
const parsed = JSON.parse(value)
if (type === 'array' && !Array.isArray(parsed)) {
throw new Error(
`Invalid array value for variable "${variableName || 'unknown'}": parsed value is not an array`
)
}
if (type === 'object' && (Array.isArray(parsed) || typeof parsed !== 'object')) {
throw new Error(
`Invalid object value for variable "${variableName || 'unknown'}": parsed value is not an object`
)
}
return parsed
} catch (error: any) {
throw new Error(
`Invalid JSON for variable "${variableName || 'unknown'}": ${error.message}`
)
}
}
return type === 'array' ? [] : {}
}
// Default: return value as-is
return value
}
}