mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 23:17:59 -05:00
improvement(variables): add error context for duplicate variable names, only check for collision when focus is lost (#1791)
* improvement(variables): add error context for duplicate variable names, only check for collision when focus is lost * disallow empty variable names, performance optimizations * safety guard against empty variables names
This commit is contained in:
@@ -29,7 +29,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { validateName } from '@/lib/utils'
|
||||
import { cn, validateName } from '@/lib/utils'
|
||||
import { useCollaborativeWorkflow } from '@/hooks/use-collaborative-workflow'
|
||||
import { useVariablesStore } from '@/stores/panel/variables/store'
|
||||
import type { Variable, VariableType } from '@/stores/panel/variables/types'
|
||||
@@ -37,6 +37,17 @@ import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||
|
||||
const logger = createLogger('Variables')
|
||||
|
||||
const TYPE_CONFIG: Record<VariableType, { icon: string; placeholder: string }> = {
|
||||
plain: { icon: 'Abc', placeholder: 'Plain text value' },
|
||||
number: { icon: '123', placeholder: '42' },
|
||||
boolean: { icon: '0/1', placeholder: 'true' },
|
||||
object: { icon: '{}', placeholder: '{\n "key": "value"\n}' },
|
||||
array: { icon: '[]', placeholder: '[\n 1,\n 2,\n 3\n]' },
|
||||
string: { icon: 'Abc', placeholder: 'Plain text value' },
|
||||
}
|
||||
|
||||
const VARIABLE_TYPES: VariableType[] = ['plain', 'number', 'boolean', 'object', 'array']
|
||||
|
||||
export function Variables() {
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
const { getVariablesByWorkflowId } = useVariablesStore()
|
||||
@@ -47,17 +58,33 @@ export function Variables() {
|
||||
collaborativeDuplicateVariable,
|
||||
} = useCollaborativeWorkflow()
|
||||
|
||||
// Get variables for the current workflow
|
||||
const workflowVariables = activeWorkflowId ? getVariablesByWorkflowId(activeWorkflowId) : []
|
||||
|
||||
// Track editor references
|
||||
const editorRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
// Track which variables are currently being edited
|
||||
const [_activeEditors, setActiveEditors] = useState<Record<string, boolean>>({})
|
||||
|
||||
// Collapsed state per variable
|
||||
const [collapsedById, setCollapsedById] = useState<Record<string, boolean>>({})
|
||||
const [localNames, setLocalNames] = useState<Record<string, string>>({})
|
||||
const [nameErrors, setNameErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const clearLocalState = (variableId: string) => {
|
||||
setLocalNames((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[variableId]
|
||||
return updated
|
||||
})
|
||||
setNameErrors((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[variableId]
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const clearError = (variableId: string) => {
|
||||
setNameErrors((prev) => {
|
||||
if (!prev[variableId]) return prev
|
||||
const updated = { ...prev }
|
||||
delete updated[variableId]
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const toggleCollapsed = (variableId: string) => {
|
||||
setCollapsedById((prev) => ({
|
||||
@@ -66,97 +93,75 @@ export function Variables() {
|
||||
}))
|
||||
}
|
||||
|
||||
// Handle variable name change with validation
|
||||
const handleVariableNameChange = (variableId: string, newName: string) => {
|
||||
const validatedName = validateName(newName)
|
||||
collaborativeUpdateVariable(variableId, 'name', validatedName)
|
||||
setLocalNames((prev) => ({
|
||||
...prev,
|
||||
[variableId]: validatedName,
|
||||
}))
|
||||
clearError(variableId)
|
||||
}
|
||||
|
||||
const isDuplicateName = (variableId: string, name: string): boolean => {
|
||||
if (!name.trim()) return false
|
||||
return workflowVariables.some((v) => v.id !== variableId && v.name === name.trim())
|
||||
}
|
||||
|
||||
const handleVariableNameBlur = (variableId: string) => {
|
||||
const localName = localNames[variableId]
|
||||
if (localName === undefined) return
|
||||
|
||||
const trimmedName = localName.trim()
|
||||
|
||||
if (!trimmedName) {
|
||||
setNameErrors((prev) => ({
|
||||
...prev,
|
||||
[variableId]: 'Variable name cannot be empty',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (isDuplicateName(variableId, trimmedName)) {
|
||||
setNameErrors((prev) => ({
|
||||
...prev,
|
||||
[variableId]: 'Two variables cannot have the same name',
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
collaborativeUpdateVariable(variableId, 'name', trimmedName)
|
||||
clearLocalState(variableId)
|
||||
}
|
||||
|
||||
const handleVariableNameKeyDown = (
|
||||
variableId: string,
|
||||
e: React.KeyboardEvent<HTMLInputElement>
|
||||
) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddVariable = () => {
|
||||
if (!activeWorkflowId) return
|
||||
|
||||
const id = collaborativeAddVariable({
|
||||
collaborativeAddVariable({
|
||||
name: '',
|
||||
type: 'string',
|
||||
value: '',
|
||||
workflowId: activeWorkflowId,
|
||||
})
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
const getTypeIcon = (type: VariableType) => {
|
||||
switch (type) {
|
||||
case 'plain':
|
||||
return 'Abc'
|
||||
case 'number':
|
||||
return '123'
|
||||
case 'boolean':
|
||||
return '0/1'
|
||||
case 'object':
|
||||
return '{}'
|
||||
case 'array':
|
||||
return '[]'
|
||||
case 'string':
|
||||
return 'Abc'
|
||||
default:
|
||||
return '?'
|
||||
}
|
||||
}
|
||||
|
||||
const getPlaceholder = (type: VariableType) => {
|
||||
switch (type) {
|
||||
case 'plain':
|
||||
return 'Plain text value'
|
||||
case 'number':
|
||||
return '42'
|
||||
case 'boolean':
|
||||
return 'true'
|
||||
case 'object':
|
||||
return '{\n "key": "value"\n}'
|
||||
case 'array':
|
||||
return '[\n 1,\n 2,\n 3\n]'
|
||||
case 'string':
|
||||
return 'Plain text value'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const getEditorLanguage = (type: VariableType) => {
|
||||
switch (type) {
|
||||
case 'object':
|
||||
case 'array':
|
||||
case 'boolean':
|
||||
case 'number':
|
||||
case 'plain':
|
||||
return 'javascript'
|
||||
default:
|
||||
return 'javascript'
|
||||
}
|
||||
}
|
||||
const getTypeIcon = (type: VariableType) => TYPE_CONFIG[type]?.icon ?? '?'
|
||||
const getPlaceholder = (type: VariableType) => TYPE_CONFIG[type]?.placeholder ?? ''
|
||||
|
||||
const handleEditorChange = (variable: Variable, newValue: string) => {
|
||||
collaborativeUpdateVariable(variable.id, 'value', newValue)
|
||||
}
|
||||
|
||||
const handleEditorBlur = (variableId: string) => {
|
||||
setActiveEditors((prev) => ({
|
||||
...prev,
|
||||
[variableId]: false,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleEditorFocus = (variableId: string) => {
|
||||
setActiveEditors((prev) => ({
|
||||
...prev,
|
||||
[variableId]: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const formatValue = (variable: Variable) => {
|
||||
if (variable.value === '') return ''
|
||||
|
||||
return typeof variable.value === 'string' ? variable.value : JSON.stringify(variable.value)
|
||||
}
|
||||
|
||||
@@ -213,13 +218,37 @@ export function Variables() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const variableIds = new Set(workflowVariables.map((v) => v.id))
|
||||
Object.keys(editorRefs.current).forEach((id) => {
|
||||
if (!workflowVariables.some((v) => v.id === id)) {
|
||||
if (!variableIds.has(id)) {
|
||||
delete editorRefs.current[id]
|
||||
}
|
||||
})
|
||||
}, [workflowVariables])
|
||||
|
||||
useEffect(() => {
|
||||
setLocalNames((prev) => {
|
||||
const variableIds = new Set(workflowVariables.map((v) => v.id))
|
||||
const updated = { ...prev }
|
||||
let changed = false
|
||||
|
||||
Object.keys(updated).forEach((id) => {
|
||||
if (!variableIds.has(id)) {
|
||||
delete updated[id]
|
||||
changed = true
|
||||
} else {
|
||||
const variable = workflowVariables.find((v) => v.id === id)
|
||||
if (variable && updated[id] === variable.name) {
|
||||
delete updated[id]
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? updated : prev
|
||||
})
|
||||
}, [workflowVariables])
|
||||
|
||||
return (
|
||||
<div className='h-full pt-2'>
|
||||
{workflowVariables.length === 0 ? (
|
||||
@@ -239,106 +268,95 @@ export function Variables() {
|
||||
{workflowVariables.map((variable) => (
|
||||
<div key={variable.id} className='space-y-2'>
|
||||
{/* Header: Variable name | Variable type | Options dropdown */}
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
className='h-9 flex-1 rounded-lg border-none bg-secondary/50 px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
placeholder='Variable name'
|
||||
value={variable.name}
|
||||
onChange={(e) => handleVariableNameChange(variable.id, e.target.value)}
|
||||
/>
|
||||
<div className='space-y-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Input
|
||||
className={cn(
|
||||
'h-9 flex-1 rounded-lg border-none px-3 font-normal text-sm ring-0 ring-offset-0 placeholder:text-muted-foreground focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0',
|
||||
nameErrors[variable.id]
|
||||
? 'border border-red-500 bg-[#F6D2D2] outline-none ring-0 focus:border-red-500 dark:bg-[#442929]'
|
||||
: 'bg-secondary/50'
|
||||
)}
|
||||
placeholder='Variable name'
|
||||
value={localNames[variable.id] ?? variable.name}
|
||||
onChange={(e) => handleVariableNameChange(variable.id, e.target.value)}
|
||||
onBlur={() => handleVariableNameBlur(variable.id)}
|
||||
onKeyDown={(e) => handleVariableNameKeyDown(variable.id, e)}
|
||||
/>
|
||||
|
||||
{/* Type selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className='flex h-9 w-16 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-secondary/50 px-3'>
|
||||
<span className='font-normal text-sm'>{getTypeIcon(variable.type)}</span>
|
||||
<ChevronDown className='ml-1 h-3 w-3 text-muted-foreground' />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'plain')}
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
{/* Type selector */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className='flex h-9 w-16 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-secondary/50 px-3'>
|
||||
<span className='font-normal text-sm'>{getTypeIcon(variable.type)}</span>
|
||||
<ChevronDown className='ml-1 h-3 w-3 text-muted-foreground' />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>Abc</div>
|
||||
<span className='font-[380]'>Plain</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'number')}
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>123</div>
|
||||
<span className='font-[380]'>Number</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'boolean')}
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>0/1</div>
|
||||
<span className='font-[380]'>Boolean</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'object')}
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>{'{}'}</div>
|
||||
<span className='font-[380]'>Object</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeUpdateVariable(variable.id, 'type', 'array')}
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>[]</div>
|
||||
<span className='font-[380]'>Array</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{VARIABLE_TYPES.map((type) => (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
onClick={() => collaborativeUpdateVariable(variable.id, 'type', type)}
|
||||
className='flex cursor-pointer items-center rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<div className='mr-2 w-5 text-center font-[380] text-sm'>
|
||||
{TYPE_CONFIG[type].icon}
|
||||
</div>
|
||||
<span className='font-[380] capitalize'>{type}</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Options dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-9 w-9 shrink-0 rounded-lg bg-secondary/50 p-0 text-muted-foreground hover:bg-secondary/70 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
{/* Options dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-9 w-9 shrink-0 rounded-lg bg-secondary/50 p-0 text-muted-foreground hover:bg-secondary/70 focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||
>
|
||||
<MoreVertical className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
<MoreVertical className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align='end'
|
||||
className='min-w-32 rounded-lg border-[#E5E5E5] bg-[#FFFFFF] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toggleCollapsed(variable.id)}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
{(collapsedById[variable.id] ?? false) ? (
|
||||
<Maximize2 className='mr-2 h-4 w-4 text-muted-foreground' />
|
||||
) : (
|
||||
<Minimize2 className='mr-2 h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
{(collapsedById[variable.id] ?? false) ? 'Expand' : 'Collapse'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeDuplicateVariable(variable.id)}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<Copy className='mr-2 h-4 w-4 text-muted-foreground' />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeDeleteVariable(variable.id)}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-destructive text-sm hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive'
|
||||
>
|
||||
<Trash className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuItem
|
||||
onClick={() => toggleCollapsed(variable.id)}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
{(collapsedById[variable.id] ?? false) ? (
|
||||
<Maximize2 className='mr-2 h-4 w-4 text-muted-foreground' />
|
||||
) : (
|
||||
<Minimize2 className='mr-2 h-4 w-4 text-muted-foreground' />
|
||||
)}
|
||||
{(collapsedById[variable.id] ?? false) ? 'Expand' : 'Collapse'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeDuplicateVariable(variable.id)}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
||||
>
|
||||
<Copy className='mr-2 h-4 w-4 text-muted-foreground' />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => collaborativeDeleteVariable(variable.id)}
|
||||
className='cursor-pointer rounded-md px-3 py-2 font-[380] text-destructive text-sm hover:bg-destructive/10 focus:bg-destructive/10 focus:text-destructive'
|
||||
>
|
||||
<Trash className='mr-2 h-4 w-4' />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{nameErrors[variable.id] && (
|
||||
<div className='mt-1 text-red-400 text-xs'>{nameErrors[variable.id]}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Value area */}
|
||||
@@ -379,18 +397,12 @@ export function Variables() {
|
||||
<Editor
|
||||
key={`editor-${variable.id}-${variable.type}`}
|
||||
value={formatValue(variable)}
|
||||
onValueChange={handleEditorChange.bind(null, variable)}
|
||||
onBlur={() => handleEditorBlur(variable.id)}
|
||||
onFocus={() => handleEditorFocus(variable.id)}
|
||||
onValueChange={(newValue) => handleEditorChange(variable, newValue)}
|
||||
highlight={(code) =>
|
||||
// Only apply syntax highlighting for non-basic text types
|
||||
variable.type === 'plain' || variable.type === 'string'
|
||||
? code
|
||||
: highlight(
|
||||
code,
|
||||
languages[getEditorLanguage(variable.type)],
|
||||
getEditorLanguage(variable.type)
|
||||
)
|
||||
: highlight(code, languages.javascript, 'javascript')
|
||||
}
|
||||
padding={0}
|
||||
style={{
|
||||
|
||||
@@ -7,53 +7,39 @@ import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
||||
|
||||
const logger = createLogger('VariablesStore')
|
||||
|
||||
/**
|
||||
* Check if variable format is valid according to type without modifying it
|
||||
* Only provides validation feedback - does not change the value
|
||||
*/
|
||||
function validateVariable(variable: Variable): string | undefined {
|
||||
try {
|
||||
// We only care about the validation result, not the parsed value
|
||||
switch (variable.type) {
|
||||
case 'number':
|
||||
// Check if it's a valid number
|
||||
if (Number.isNaN(Number(variable.value))) {
|
||||
return 'Not a valid number'
|
||||
}
|
||||
break
|
||||
case 'boolean':
|
||||
// Check if it's a valid boolean
|
||||
if (!/^(true|false)$/i.test(String(variable.value).trim())) {
|
||||
return 'Expected "true" or "false"'
|
||||
}
|
||||
break
|
||||
case 'object':
|
||||
// Check if it's a valid JSON object
|
||||
try {
|
||||
// Handle both JavaScript and JSON syntax
|
||||
const valueToEvaluate = String(variable.value).trim()
|
||||
|
||||
// Basic security check to prevent arbitrary code execution
|
||||
if (!valueToEvaluate.startsWith('{') || !valueToEvaluate.endsWith('}')) {
|
||||
return 'Not a valid object format'
|
||||
}
|
||||
|
||||
// Use Function constructor to safely evaluate the object expression
|
||||
// This handles both JSON and JS object literal syntax
|
||||
const parsed = new Function(`return ${valueToEvaluate}`)()
|
||||
|
||||
// Verify it's actually an object (not array or null)
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return 'Not a valid object'
|
||||
}
|
||||
|
||||
return undefined // Valid object
|
||||
return undefined
|
||||
} catch (e) {
|
||||
logger.error('Object parsing error:', e)
|
||||
return 'Invalid object syntax'
|
||||
}
|
||||
case 'array':
|
||||
// Check if it's a valid JSON array
|
||||
try {
|
||||
const parsed = JSON.parse(String(variable.value))
|
||||
if (!Array.isArray(parsed)) {
|
||||
@@ -70,23 +56,16 @@ function validateVariable(variable: Variable): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a variable from 'string' type to 'plain' type
|
||||
* Handles the value conversion appropriately
|
||||
*/
|
||||
function migrateStringToPlain(variable: Variable): Variable {
|
||||
if (variable.type !== 'string') {
|
||||
return variable
|
||||
}
|
||||
|
||||
// Convert string type to plain
|
||||
const updated = {
|
||||
...variable,
|
||||
type: 'plain' as const,
|
||||
}
|
||||
|
||||
// For plain text, we want to preserve values exactly as they are,
|
||||
// including any quote characters that may be part of the text
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -128,12 +107,9 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
addVariable: (variable, providedId?: string) => {
|
||||
const id = providedId || crypto.randomUUID()
|
||||
|
||||
// Get variables for this workflow
|
||||
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
|
||||
|
||||
// Auto-generate variable name if not provided or it's a default pattern name
|
||||
if (!variable.name || /^variable\d+$/.test(variable.name)) {
|
||||
// Find the highest existing Variable N number
|
||||
const existingNumbers = workflowVariables
|
||||
.map((v) => {
|
||||
const match = v.name.match(/^variable(\d+)$/)
|
||||
@@ -141,28 +117,23 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
})
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
|
||||
// Set new number to max + 1, or 1 if none exist
|
||||
const nextNumber = existingNumbers.length > 0 ? Math.max(...existingNumbers) + 1 : 1
|
||||
|
||||
variable.name = `variable${nextNumber}`
|
||||
}
|
||||
|
||||
// Ensure name uniqueness within the workflow
|
||||
let uniqueName = variable.name
|
||||
let nameIndex = 1
|
||||
|
||||
// Check if name already exists in this workflow
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${variable.name} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
|
||||
// Check for type conversion - only for backward compatibility
|
||||
if (variable.type === 'string') {
|
||||
variable.type = 'plain'
|
||||
}
|
||||
|
||||
// Create the new variable with empty value
|
||||
const newVariable: Variable = {
|
||||
id,
|
||||
workflowId: variable.workflowId,
|
||||
@@ -172,7 +143,6 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
// Check for validation errors without modifying the value
|
||||
const validationError = validateVariable(newVariable)
|
||||
if (validationError) {
|
||||
newVariable.validationError = validationError
|
||||
@@ -192,69 +162,44 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
set((state) => {
|
||||
if (!state.variables[id]) return state
|
||||
|
||||
// If name is being updated, ensure it's unique
|
||||
if (update.name !== undefined) {
|
||||
const oldVariable = state.variables[id]
|
||||
const oldVariableName = oldVariable.name
|
||||
const workflowId = oldVariable.workflowId
|
||||
const workflowVariables = Object.values(state.variables).filter(
|
||||
(v) => v.workflowId === workflowId && v.id !== id
|
||||
)
|
||||
const newName = update.name.trim()
|
||||
|
||||
let uniqueName = update.name
|
||||
let nameIndex = 1
|
||||
|
||||
// Only check uniqueness for non-empty names
|
||||
// Empty names don't need to be unique as they're temporary states
|
||||
if (uniqueName.trim() !== '') {
|
||||
// Check if name already exists in this workflow
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${update.name} (${nameIndex})`
|
||||
nameIndex++
|
||||
}
|
||||
}
|
||||
|
||||
// Always update references in subblocks when name changes, even if empty
|
||||
// This ensures references are updated even when name is completely cleared
|
||||
if (uniqueName !== oldVariableName) {
|
||||
// Update references in subblock store
|
||||
if (!newName) {
|
||||
update = { ...update }
|
||||
update.name = undefined
|
||||
} else if (newName !== oldVariableName) {
|
||||
const subBlockStore = useSubBlockStore.getState()
|
||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
||||
|
||||
if (activeWorkflowId) {
|
||||
// Get the workflow values for the active workflow
|
||||
const workflowValues = subBlockStore.workflowValues[activeWorkflowId] || {}
|
||||
const updatedWorkflowValues = { ...workflowValues }
|
||||
|
||||
// Loop through blocks
|
||||
Object.entries(workflowValues).forEach(([blockId, blockValues]) => {
|
||||
// Loop through subblocks and update references
|
||||
Object.entries(blockValues as Record<string, any>).forEach(
|
||||
([subBlockId, value]) => {
|
||||
const oldVarName = oldVariableName.replace(/\s+/g, '').toLowerCase()
|
||||
const newVarName = uniqueName.replace(/\s+/g, '').toLowerCase()
|
||||
const newVarName = newName.replace(/\s+/g, '').toLowerCase()
|
||||
const regex = new RegExp(`<variable\.${oldVarName}>`, 'gi')
|
||||
|
||||
// Use a recursive function to handle all object types
|
||||
updatedWorkflowValues[blockId][subBlockId] = updateReferences(
|
||||
value,
|
||||
regex,
|
||||
`<variable.${newVarName}>`
|
||||
)
|
||||
|
||||
// Helper function to recursively update references in any data structure
|
||||
function updateReferences(value: any, regex: RegExp, replacement: string): any {
|
||||
// Handle string values
|
||||
if (typeof value === 'string') {
|
||||
return regex.test(value) ? value.replace(regex, replacement) : value
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => updateReferences(item, regex, replacement))
|
||||
}
|
||||
|
||||
// Handle objects
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result = { ...value }
|
||||
for (const key in result) {
|
||||
@@ -263,14 +208,12 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
return result
|
||||
}
|
||||
|
||||
// Return unchanged for other types
|
||||
return value
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
// Update the subblock store with the new values
|
||||
useSubBlockStore.setState({
|
||||
workflowValues: {
|
||||
...subBlockStore.workflowValues,
|
||||
@@ -279,26 +222,19 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Update with unique name
|
||||
update = { ...update, name: uniqueName }
|
||||
}
|
||||
|
||||
// If type is being updated to 'string', convert it to 'plain' instead
|
||||
if (update.type === 'string') {
|
||||
update = { ...update, type: 'plain' }
|
||||
}
|
||||
|
||||
// Create updated variable to check for validation
|
||||
const updatedVariable: Variable = {
|
||||
...state.variables[id],
|
||||
...update,
|
||||
validationError: undefined, // Initialize property to be updated later
|
||||
validationError: undefined,
|
||||
}
|
||||
|
||||
// If the type or value changed, check for validation errors
|
||||
if (update.type || update.value !== undefined) {
|
||||
// Only add validation feedback - never modify the value
|
||||
updatedVariable.validationError = validateVariable(updatedVariable)
|
||||
}
|
||||
|
||||
@@ -329,13 +265,11 @@ export const useVariablesStore = create<VariablesStore>()(
|
||||
const variable = state.variables[id]
|
||||
const newId = providedId || crypto.randomUUID()
|
||||
|
||||
// Ensure the duplicated name is unique
|
||||
const workflowVariables = get().getVariablesByWorkflowId(variable.workflowId)
|
||||
const baseName = `${variable.name} (copy)`
|
||||
let uniqueName = baseName
|
||||
let nameIndex = 1
|
||||
|
||||
// Check if name already exists in this workflow
|
||||
while (workflowVariables.some((v) => v.name === uniqueName)) {
|
||||
uniqueName = `${baseName} (${nameIndex})`
|
||||
nameIndex++
|
||||
|
||||
Reference in New Issue
Block a user