323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useMemo, useState } from 'react'
|
|
import Combobox from '@/components/editor/Combobox'
|
|
import type { ComboboxItem } from '@/components/editor/Combobox'
|
|
import { useEditorContext } from '@/components/editor/EditorContext'
|
|
import type { Condition } from '@/types/flowchart'
|
|
|
|
type ConditionEditorProps = {
|
|
edgeId: string
|
|
condition: Condition | undefined
|
|
onChange: (edgeId: string, condition: Condition | undefined) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
export default function ConditionEditor({
|
|
edgeId,
|
|
condition,
|
|
onChange,
|
|
onClose,
|
|
}: ConditionEditorProps) {
|
|
const { variables, onAddVariable } = useEditorContext()
|
|
|
|
const [showAddForm, setShowAddForm] = useState(false)
|
|
const [newName, setNewName] = useState('')
|
|
const [newType, setNewType] = useState<'numeric' | 'string' | 'boolean'>('numeric')
|
|
|
|
const variableItems: ComboboxItem[] = useMemo(
|
|
() =>
|
|
variables.map((v) => ({
|
|
id: v.id,
|
|
label: v.name,
|
|
badge: v.type,
|
|
})),
|
|
[variables]
|
|
)
|
|
|
|
const selectedVariable = useMemo(() => {
|
|
if (!condition?.variableId) return undefined
|
|
return variables.find((v) => v.id === condition.variableId)
|
|
}, [condition?.variableId, variables])
|
|
|
|
const hasInvalidReference = useMemo(() => {
|
|
if (!condition?.variableId) return false
|
|
return !variables.some((v) => v.id === condition.variableId)
|
|
}, [condition?.variableId, variables])
|
|
|
|
// Determine operators based on variable type
|
|
const availableOperators = useMemo(() => {
|
|
if (!selectedVariable || selectedVariable.type === 'numeric') {
|
|
return [
|
|
{ value: '==', label: '==' },
|
|
{ value: '!=', label: '!=' },
|
|
{ value: '>', label: '>' },
|
|
{ value: '<', label: '<' },
|
|
{ value: '>=', label: '>=' },
|
|
{ value: '<=', label: '<=' },
|
|
] as const
|
|
}
|
|
// string and boolean only support == and !=
|
|
return [
|
|
{ value: '==', label: '==' },
|
|
{ value: '!=', label: '!=' },
|
|
] as const
|
|
}, [selectedVariable])
|
|
|
|
const handleVariableSelect = useCallback(
|
|
(variableId: string) => {
|
|
const variable = variables.find((v) => v.id === variableId)
|
|
const defaultValue = variable
|
|
? variable.type === 'numeric'
|
|
? 0
|
|
: variable.type === 'boolean'
|
|
? false
|
|
: ''
|
|
: 0
|
|
// Reset operator if current one is not valid for new type
|
|
const validOperator =
|
|
variable && variable.type !== 'numeric' && condition?.operator && !['==', '!='].includes(condition.operator)
|
|
? '=='
|
|
: condition?.operator || '=='
|
|
|
|
onChange(edgeId, {
|
|
variableName: variable?.name || '',
|
|
variableId,
|
|
operator: validOperator as Condition['operator'],
|
|
value: defaultValue,
|
|
})
|
|
},
|
|
[variables, condition?.operator, edgeId, onChange]
|
|
)
|
|
|
|
const handleOperatorChange = useCallback(
|
|
(operator: string) => {
|
|
if (!condition) return
|
|
onChange(edgeId, {
|
|
...condition,
|
|
operator: operator as Condition['operator'],
|
|
})
|
|
},
|
|
[condition, edgeId, onChange]
|
|
)
|
|
|
|
const handleValueChange = useCallback(
|
|
(value: number | string | boolean) => {
|
|
if (!condition) return
|
|
onChange(edgeId, {
|
|
...condition,
|
|
value,
|
|
})
|
|
},
|
|
[condition, edgeId, onChange]
|
|
)
|
|
|
|
const handleRemoveCondition = useCallback(() => {
|
|
onChange(edgeId, undefined)
|
|
onClose()
|
|
}, [edgeId, onChange, onClose])
|
|
|
|
const handleAddNew = useCallback(() => {
|
|
setShowAddForm(true)
|
|
setNewName('')
|
|
setNewType('numeric')
|
|
}, [])
|
|
|
|
const handleSubmitNew = useCallback(() => {
|
|
if (!newName.trim()) return
|
|
const defaultValue = newType === 'numeric' ? 0 : newType === 'boolean' ? false : ''
|
|
const newId = onAddVariable(newName.trim(), newType, defaultValue)
|
|
onChange(edgeId, {
|
|
variableName: newName.trim(),
|
|
variableId: newId,
|
|
operator: '==',
|
|
value: defaultValue,
|
|
})
|
|
setShowAddForm(false)
|
|
}, [newName, newType, onAddVariable, edgeId, onChange])
|
|
|
|
const handleCancelNew = useCallback(() => {
|
|
setShowAddForm(false)
|
|
}, [])
|
|
|
|
// Render value input based on variable type
|
|
const renderValueInput = () => {
|
|
const varType = selectedVariable?.type || 'numeric'
|
|
|
|
if (varType === 'boolean') {
|
|
return (
|
|
<select
|
|
value={String(condition?.value ?? false)}
|
|
onChange={(e) => handleValueChange(e.target.value === 'true')}
|
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
|
>
|
|
<option value="true">true</option>
|
|
<option value="false">false</option>
|
|
</select>
|
|
)
|
|
}
|
|
|
|
if (varType === 'string') {
|
|
return (
|
|
<input
|
|
type="text"
|
|
value={String(condition?.value ?? '')}
|
|
onChange={(e) => handleValueChange(e.target.value)}
|
|
placeholder="Value..."
|
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
|
/>
|
|
)
|
|
}
|
|
|
|
// numeric
|
|
return (
|
|
<input
|
|
type="number"
|
|
value={typeof condition?.value === 'number' ? condition.value : 0}
|
|
onChange={(e) => handleValueChange(parseFloat(e.target.value) || 0)}
|
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
|
<div className="relative w-full max-w-sm rounded-lg border border-zinc-200 bg-white p-4 shadow-xl dark:border-zinc-700 dark:bg-zinc-800">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
|
Edge Condition
|
|
</h3>
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded p-0.5 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-700 dark:hover:text-zinc-300"
|
|
>
|
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Variable selector */}
|
|
<div className="mb-3">
|
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
|
Variable
|
|
</label>
|
|
<div className={hasInvalidReference ? 'rounded ring-2 ring-orange-500' : ''}>
|
|
<Combobox
|
|
items={variableItems}
|
|
value={condition?.variableId}
|
|
onChange={handleVariableSelect}
|
|
placeholder="Select variable..."
|
|
onAddNew={handleAddNew}
|
|
/>
|
|
</div>
|
|
{hasInvalidReference && (
|
|
<div className="mt-1 text-xs text-orange-600 dark:text-orange-400">
|
|
Variable not found
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Inline add form */}
|
|
{showAddForm && (
|
|
<div className="mb-3 rounded border border-zinc-300 bg-zinc-50 p-2 dark:border-zinc-600 dark:bg-zinc-900">
|
|
<div className="mb-1 text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
|
New variable
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<input
|
|
type="text"
|
|
value={newName}
|
|
onChange={(e) => setNewName(e.target.value)}
|
|
placeholder="Name"
|
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white dark:placeholder-zinc-400"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleSubmitNew()
|
|
if (e.key === 'Escape') handleCancelNew()
|
|
}}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
<div className="mt-1.5 flex items-center gap-1.5">
|
|
<select
|
|
value={newType}
|
|
onChange={(e) => setNewType(e.target.value as 'numeric' | 'string' | 'boolean')}
|
|
className="rounded border border-zinc-300 bg-white px-2 py-0.5 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
|
>
|
|
<option value="numeric">numeric</option>
|
|
<option value="string">string</option>
|
|
<option value="boolean">boolean</option>
|
|
</select>
|
|
</div>
|
|
<div className="mt-1.5 flex justify-end gap-1">
|
|
<button
|
|
onClick={handleCancelNew}
|
|
className="rounded px-2 py-0.5 text-xs text-zinc-500 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleSubmitNew}
|
|
disabled={!newName.trim()}
|
|
className="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700 disabled:opacity-50"
|
|
>
|
|
Add
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Operator and value (shown when variable is selected) */}
|
|
{condition?.variableId && (
|
|
<>
|
|
<div className="mb-3">
|
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
|
Operator
|
|
</label>
|
|
<select
|
|
value={condition.operator || '=='}
|
|
onChange={(e) => handleOperatorChange(e.target.value)}
|
|
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm focus:border-blue-500 focus:outline-none dark:border-zinc-600 dark:bg-zinc-700 dark:text-white"
|
|
>
|
|
{availableOperators.map((op) => (
|
|
<option key={op.value} value={op.value}>
|
|
{op.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="mb-3">
|
|
<label className="mb-1 block text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
|
Value
|
|
</label>
|
|
{renderValueInput()}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-between">
|
|
{condition?.variableId ? (
|
|
<button
|
|
onClick={handleRemoveCondition}
|
|
className="rounded px-2 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
|
>
|
|
Remove condition
|
|
</button>
|
|
) : (
|
|
<div />
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|