WebVNWrite/src/components/editor/ConditionEditor.tsx

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>
)
}