feat: [US-057] - Variable management UI in project settings

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda 2026-01-23 04:34:15 -03:00
parent 6a87e7a70b
commit 285320a4fe
1 changed files with 325 additions and 29 deletions

View File

@ -373,29 +373,168 @@ function CharacterForm({ formData, formError, onChange, onSave, onCancel, saveLa
) )
} }
// Variables Tab (placeholder for US-057) // Variables Tab
type VariablesTabProps = { type VariablesTabProps = {
variables: Variable[] variables: Variable[]
onChange: (variables: Variable[]) => void onChange: (variables: Variable[]) => void
getUsageCount: (variableId: string) => number getUsageCount: (variableId: string) => number
} }
function VariablesTab({ variables }: VariablesTabProps) { type VariableType = 'numeric' | 'string' | 'boolean'
type VariableFormData = {
name: string
type: VariableType
initialValue: string
description: string
}
const defaultInitialValues: Record<VariableType, string> = {
numeric: '0',
string: '',
boolean: 'false',
}
function parseInitialValue(type: VariableType, raw: string): number | string | boolean {
switch (type) {
case 'numeric':
return Number(raw) || 0
case 'boolean':
return raw === 'true'
case 'string':
return raw
}
}
function VariablesTab({ variables, onChange, getUsageCount }: VariablesTabProps) {
const [isAdding, setIsAdding] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [formData, setFormData] = useState<VariableFormData>({ name: '', type: 'numeric', initialValue: '0', description: '' })
const [formError, setFormError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const resetForm = () => {
setFormData({ name: '', type: 'numeric', initialValue: '0', description: '' })
setFormError(null)
}
const validateName = (name: string, excludeId?: string): boolean => {
if (!name.trim()) {
setFormError('Name is required')
return false
}
const duplicate = variables.find(
(v) => v.name.toLowerCase() === name.trim().toLowerCase() && v.id !== excludeId
)
if (duplicate) {
setFormError('A variable with this name already exists')
return false
}
setFormError(null)
return true
}
const handleAdd = () => {
if (!validateName(formData.name)) return
const newVariable: Variable = {
id: nanoid(),
name: formData.name.trim(),
type: formData.type,
initialValue: parseInitialValue(formData.type, formData.initialValue),
description: formData.description.trim() || undefined,
}
onChange([...variables, newVariable])
setIsAdding(false)
resetForm()
}
const handleEdit = (variable: Variable) => {
setEditingId(variable.id)
setFormData({
name: variable.name,
type: variable.type,
initialValue: String(variable.initialValue),
description: variable.description || '',
})
setFormError(null)
setIsAdding(false)
}
const handleSaveEdit = () => {
if (!editingId) return
if (!validateName(formData.name, editingId)) return
onChange(
variables.map((v) =>
v.id === editingId
? {
...v,
name: formData.name.trim(),
type: formData.type,
initialValue: parseInitialValue(formData.type, formData.initialValue),
description: formData.description.trim() || undefined,
}
: v
)
)
setEditingId(null)
resetForm()
}
const handleDelete = (id: string) => {
const usageCount = getUsageCount(id)
if (usageCount > 0 && deleteConfirm !== id) {
setDeleteConfirm(id)
return
}
onChange(variables.filter((v) => v.id !== id))
setDeleteConfirm(null)
}
const handleCancelForm = () => {
setIsAdding(false)
setEditingId(null)
resetForm()
}
return ( return (
<div> <div>
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-zinc-600 dark:text-zinc-400"> <p className="text-sm text-zinc-600 dark:text-zinc-400">
Define variables that can be referenced in variable nodes and edge conditions. Define variables that can be referenced in variable nodes and edge conditions.
</p> </p>
{variables.length === 0 ? ( {!isAdding && !editingId && (
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500"> <button
No variables defined yet. Variable management will be available in a future update. onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
</p> className="rounded bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
) : ( >
<div className="mt-4 space-y-2"> Add Variable
</button>
)}
</div>
{/* Variable List */}
<div className="space-y-2">
{variables.map((variable) => ( {variables.map((variable) => (
<div key={variable.id} className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700"> <div key={variable.id}>
{editingId === variable.id ? (
<VariableForm
formData={formData}
formError={formError}
onChange={setFormData}
onSave={handleSaveEdit}
onCancel={handleCancelForm}
saveLabel="Save"
/>
) : (
<div className="flex items-center justify-between rounded-lg border border-zinc-200 px-4 py-3 dark:border-zinc-700">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="rounded bg-zinc-200 px-1.5 py-0.5 text-xs font-medium text-zinc-700 dark:bg-zinc-700 dark:text-zinc-300"> <span className={`rounded px-1.5 py-0.5 text-xs font-medium ${
variable.type === 'numeric'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: variable.type === 'string'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
}`}>
{variable.type} {variable.type}
</span> </span>
<div> <div>
@ -409,13 +548,170 @@ function VariablesTab({ variables }: VariablesTabProps) {
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-3">
<span className="text-xs text-zinc-500 dark:text-zinc-400"> <span className="text-xs text-zinc-500 dark:text-zinc-400">
Initial: {String(variable.initialValue)} Initial: {String(variable.initialValue)}
</span> </span>
<div className="flex items-center gap-2">
{deleteConfirm === variable.id && (
<span className="mr-2 text-xs text-orange-600 dark:text-orange-400">
Used in {getUsageCount(variable.id)} node(s). Delete anyway?
</span>
)}
<button
onClick={() => handleEdit(variable)}
className="rounded px-2 py-1 text-sm text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
Edit
</button>
<button
onClick={() => handleDelete(variable.id)}
className={`rounded px-2 py-1 text-sm ${
deleteConfirm === variable.id
? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-700'
}`}
>
Delete
</button>
</div>
</div>
</div>
)}
</div> </div>
))} ))}
{variables.length === 0 && !isAdding && (
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
No variables defined yet. Click &quot;Add Variable&quot; to create one.
</p>
)}
</div>
{/* Add Form */}
{isAdding && (
<div className="mt-4">
<VariableForm
formData={formData}
formError={formError}
onChange={setFormData}
onSave={handleAdd}
onCancel={handleCancelForm}
saveLabel="Add"
/>
</div> </div>
)} )}
</div> </div>
) )
} }
// Variable Form
type VariableFormProps = {
formData: VariableFormData
formError: string | null
onChange: (data: VariableFormData) => void
onSave: () => void
onCancel: () => void
saveLabel: string
}
function VariableForm({ formData, formError, onChange, onSave, onCancel, saveLabel }: VariableFormProps) {
const handleTypeChange = (newType: VariableType) => {
onChange({ ...formData, type: newType, initialValue: defaultInitialValues[newType] })
}
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Name *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => onChange({ ...formData, name: e.target.value })}
placeholder="Variable name"
autoFocus
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Type *
</label>
<select
value={formData.type}
onChange={(e) => handleTypeChange(e.target.value as VariableType)}
className="block rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50"
>
<option value="numeric">Numeric</option>
<option value="string">String</option>
<option value="boolean">Boolean</option>
</select>
</div>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Initial Value *
</label>
{formData.type === 'boolean' ? (
<select
value={formData.initialValue}
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50"
>
<option value="false">false</option>
<option value="true">true</option>
</select>
) : formData.type === 'numeric' ? (
<input
type="number"
value={formData.initialValue}
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
) : (
<input
type="text"
value={formData.initialValue}
onChange={(e) => onChange({ ...formData, initialValue: e.target.value })}
placeholder="Initial value"
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
)}
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-700 dark:text-zinc-300">
Description
</label>
<input
type="text"
value={formData.description}
onChange={(e) => onChange({ ...formData, description: e.target.value })}
placeholder="Optional description"
className="block w-full rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm text-zinc-900 placeholder-zinc-400 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-zinc-600 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder-zinc-500"
/>
</div>
{formError && (
<p className="text-sm text-red-600 dark:text-red-400">{formError}</p>
)}
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-sm font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600"
>
Cancel
</button>
<button
onClick={onSave}
className="rounded-md bg-blue-500 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-zinc-800"
>
{saveLabel}
</button>
</div>
</div>
</div>
)
}