feat: [US-057] - Variable management UI in project settings
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
6a87e7a70b
commit
285320a4fe
|
|
@ -373,49 +373,345 @@ function CharacterForm({ formData, formError, onChange, onSave, onCancel, saveLa
|
|||
)
|
||||
}
|
||||
|
||||
// Variables Tab (placeholder for US-057)
|
||||
// Variables Tab
|
||||
type VariablesTabProps = {
|
||||
variables: Variable[]
|
||||
onChange: (variables: Variable[]) => void
|
||||
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 (
|
||||
<div>
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Define variables that can be referenced in variable nodes and edge conditions.
|
||||
</p>
|
||||
{variables.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-400 dark:text-zinc-500">
|
||||
No variables defined yet. Variable management will be available in a future update.
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
Define variables that can be referenced in variable nodes and edge conditions.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{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 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">
|
||||
{variable.type}
|
||||
</span>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
|
||||
{variable.name}
|
||||
{!isAdding && !editingId && (
|
||||
<button
|
||||
onClick={() => { setIsAdding(true); resetForm(); setDeleteConfirm(null) }}
|
||||
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"
|
||||
>
|
||||
Add Variable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variable List */}
|
||||
<div className="space-y-2">
|
||||
{variables.map((variable) => (
|
||||
<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">
|
||||
<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}
|
||||
</span>
|
||||
{variable.description && (
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{variable.description}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-50">
|
||||
{variable.name}
|
||||
</span>
|
||||
{variable.description && (
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{variable.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Initial: {String(variable.initialValue)}
|
||||
</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>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Initial: {String(variable.initialValue)}
|
||||
</span>
|
||||
</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 "Add Variable" 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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue