feat: add ParameterCard and ParameterSchemaBuilder — visual builder + JSON toggle
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
317
frontend/src/components/script-editor/ParameterCard.tsx
Normal file
317
frontend/src/components/script-editor/ParameterCard.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, GripVertical, Trash2, Plus, X } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import type { ScriptParameter, ScriptParameterOption, ScriptParameterValidation } from '@/types'
|
||||
|
||||
const PARAM_TYPES = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'password', label: 'Password' },
|
||||
{ value: 'textarea', label: 'Textarea' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'boolean', label: 'Boolean' },
|
||||
{ value: 'select', label: 'Select' },
|
||||
{ value: 'multi_text', label: 'Multi-text' },
|
||||
] as const
|
||||
|
||||
interface Props {
|
||||
param: ScriptParameter
|
||||
index: number
|
||||
onChange: (index: number, updated: ScriptParameter) => void
|
||||
onRemove: (index: number) => void
|
||||
onMoveUp: (index: number) => void
|
||||
onMoveDown: (index: number) => void
|
||||
isFirst: boolean
|
||||
isLast: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ParameterCard({
|
||||
param, index, onChange, onRemove, onMoveUp, onMoveDown, isFirst, isLast, disabled,
|
||||
}: Props) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
|
||||
const update = (patch: Partial<ScriptParameter>) => {
|
||||
onChange(index, { ...param, ...patch })
|
||||
}
|
||||
|
||||
const updateOption = (optIndex: number, patch: Partial<ScriptParameterOption>) => {
|
||||
const options = [...(param.options ?? [])]
|
||||
options[optIndex] = { ...options[optIndex], ...patch }
|
||||
update({ options })
|
||||
}
|
||||
|
||||
const addOption = () => {
|
||||
const options = [...(param.options ?? []), { value: '', label: '' }]
|
||||
update({ options })
|
||||
}
|
||||
|
||||
const removeOption = (optIndex: number) => {
|
||||
const options = (param.options ?? []).filter((_, i) => i !== optIndex)
|
||||
update({ options })
|
||||
}
|
||||
|
||||
const updateValidation = (patch: Partial<ScriptParameterValidation>) => {
|
||||
update({ validation: { ...(param.validation ?? {}), ...patch } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors"
|
||||
>
|
||||
<GripVertical size={14} className="text-muted-foreground/50 shrink-0" />
|
||||
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
|
||||
<span className="text-sm font-medium text-foreground flex-1 text-left">
|
||||
{param.label || param.key || `Parameter ${index + 1}`}
|
||||
</span>
|
||||
<span className="font-label text-[0.625rem] text-muted-foreground uppercase">{param.type}</span>
|
||||
{param.required && <span className="text-red-400 text-xs">*</span>}
|
||||
</button>
|
||||
|
||||
{/* Body */}
|
||||
{expanded && (
|
||||
<div className="px-3 py-3 space-y-3 border-t border-border">
|
||||
{/* Row 1: key + label */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Key (used in {{key}})</label>
|
||||
<Input
|
||||
value={param.key}
|
||||
onChange={e => update({ key: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })}
|
||||
placeholder="param_key"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Label</label>
|
||||
<Input
|
||||
value={param.label}
|
||||
onChange={e => update({ label: e.target.value })}
|
||||
placeholder="Display Label"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: type + group */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Type</label>
|
||||
<select
|
||||
value={param.type}
|
||||
onChange={e => update({ type: e.target.value as ScriptParameter['type'] })}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{PARAM_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Group (optional)</label>
|
||||
<Input
|
||||
value={param.group ?? ''}
|
||||
onChange={e => update({ group: e.target.value || null })}
|
||||
placeholder="e.g. User Identity"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: placeholder + help text */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Placeholder</label>
|
||||
<Input
|
||||
value={param.placeholder ?? ''}
|
||||
onChange={e => update({ placeholder: e.target.value || null })}
|
||||
placeholder="Placeholder text"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Help text</label>
|
||||
<Input
|
||||
value={param.help_text ?? ''}
|
||||
onChange={e => update({ help_text: e.target.value || null })}
|
||||
placeholder="Help text shown below field"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: toggles */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.required}
|
||||
onChange={e => update({ required: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Required
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={param.sensitive}
|
||||
onChange={e => update({ sensitive: e.target.checked })}
|
||||
disabled={disabled}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
Sensitive (redacted in logs)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Default value */}
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Default value</label>
|
||||
<Input
|
||||
value={param.default !== null && param.default !== undefined ? String(param.default) : ''}
|
||||
onChange={e => update({ default: e.target.value || null })}
|
||||
placeholder="Default value"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Select options (only for select type) */}
|
||||
{param.type === 'select' && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Options</label>
|
||||
<div className="space-y-1.5">
|
||||
{(param.options ?? []).map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={opt.value}
|
||||
onChange={e => updateOption(i, { value: e.target.value })}
|
||||
placeholder="value"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Input
|
||||
value={opt.label}
|
||||
onChange={e => updateOption(i, { label: e.target.value })}
|
||||
placeholder="label"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(i)}
|
||||
disabled={disabled}
|
||||
className="p-1 text-muted-foreground hover:text-rose-500 transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
<Plus size={12} /> Add option
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation (for text/number types) */}
|
||||
{(param.type === 'text' || param.type === 'number' || param.type === 'textarea') && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">Validation (optional)</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{param.type === 'number' ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-[0.625rem] text-muted-foreground">Min value</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={param.validation?.min_value ?? ''}
|
||||
onChange={e => updateValidation({ min_value: e.target.value ? Number(e.target.value) : undefined })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[0.625rem] text-muted-foreground">Max value</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={param.validation?.max_value ?? ''}
|
||||
onChange={e => updateValidation({ max_value: e.target.value ? Number(e.target.value) : undefined })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="text-[0.625rem] text-muted-foreground">Min length</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={param.validation?.min_length ?? ''}
|
||||
onChange={e => updateValidation({ min_length: e.target.value ? Number(e.target.value) : undefined })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[0.625rem] text-muted-foreground">Max length</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={param.validation?.max_length ?? ''}
|
||||
onChange={e => updateValidation({ max_length: e.target.value ? Number(e.target.value) : undefined })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-[0.625rem] text-muted-foreground">Pattern (regex)</label>
|
||||
<Input
|
||||
value={param.validation?.pattern ?? ''}
|
||||
onChange={e => updateValidation({ pattern: e.target.value || undefined })}
|
||||
placeholder="^[a-z]+$"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions row */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-border">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveUp(index)}
|
||||
disabled={isFirst || disabled}
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
|
||||
>
|
||||
↑ Up
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoveDown(index)}
|
||||
disabled={isLast || disabled}
|
||||
className="text-xs text-muted-foreground hover:text-foreground disabled:opacity-30 px-1.5 py-0.5"
|
||||
>
|
||||
↓ Down
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(index)}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1 text-xs text-rose-500 hover:text-rose-400 transition-colors px-1.5 py-0.5"
|
||||
>
|
||||
<Trash2 size={12} /> Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
172
frontend/src/components/script-editor/ParameterSchemaBuilder.tsx
Normal file
172
frontend/src/components/script-editor/ParameterSchemaBuilder.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState } from 'react'
|
||||
import { Plus, Code, List } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ParameterCard } from './ParameterCard'
|
||||
import type { ScriptParameter, ScriptParametersSchema } from '@/types'
|
||||
|
||||
interface Props {
|
||||
schema: ScriptParametersSchema
|
||||
onChange: (schema: ScriptParametersSchema) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function newParameter(order: number): ScriptParameter {
|
||||
return {
|
||||
key: '',
|
||||
label: '',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: null,
|
||||
group: null,
|
||||
order,
|
||||
help_text: null,
|
||||
options: null,
|
||||
default: null,
|
||||
validation: null,
|
||||
sensitive: false,
|
||||
}
|
||||
}
|
||||
|
||||
export function ParameterSchemaBuilder({ schema, onChange, disabled }: Props) {
|
||||
const [mode, setMode] = useState<'visual' | 'json'>('visual')
|
||||
const [jsonText, setJsonText] = useState('')
|
||||
const [jsonError, setJsonError] = useState<string | null>(null)
|
||||
|
||||
const parameters = schema.parameters ?? []
|
||||
|
||||
const updateParams = (params: ScriptParameter[]) => {
|
||||
onChange({ parameters: params })
|
||||
}
|
||||
|
||||
const handleParamChange = (index: number, updated: ScriptParameter) => {
|
||||
const next = [...parameters]
|
||||
next[index] = updated
|
||||
updateParams(next)
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
updateParams(parameters.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index === 0) return
|
||||
const next = [...parameters]
|
||||
;[next[index - 1], next[index]] = [next[index], next[index - 1]]
|
||||
next.forEach((p, i) => { p.order = i + 1 })
|
||||
updateParams(next)
|
||||
}
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index === parameters.length - 1) return
|
||||
const next = [...parameters]
|
||||
;[next[index], next[index + 1]] = [next[index + 1], next[index]]
|
||||
next.forEach((p, i) => { p.order = i + 1 })
|
||||
updateParams(next)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
updateParams([...parameters, newParameter(parameters.length + 1)])
|
||||
}
|
||||
|
||||
const switchToJson = () => {
|
||||
setJsonText(JSON.stringify(schema, null, 2))
|
||||
setJsonError(null)
|
||||
setMode('json')
|
||||
}
|
||||
|
||||
const switchToVisual = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText)
|
||||
if (!parsed.parameters || !Array.isArray(parsed.parameters)) {
|
||||
setJsonError('JSON must have a "parameters" array')
|
||||
return
|
||||
}
|
||||
onChange(parsed as ScriptParametersSchema)
|
||||
setJsonError(null)
|
||||
setMode('visual')
|
||||
} catch (e) {
|
||||
setJsonError(`Invalid JSON: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Mode toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mode === 'json' ? switchToVisual() : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 font-label text-xs px-3 py-1.5 rounded-full border transition-all',
|
||||
mode === 'visual'
|
||||
? 'bg-primary/10 border-primary/30 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<List size={12} /> Visual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mode === 'visual' ? switchToJson() : undefined}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 font-label text-xs px-3 py-1.5 rounded-full border transition-all',
|
||||
mode === 'json'
|
||||
? 'bg-primary/10 border-primary/30 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Code size={12} /> JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'visual' ? (
|
||||
<>
|
||||
{parameters.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No parameters defined. Add one to create dynamic form fields.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{parameters.map((param, i) => (
|
||||
<ParameterCard
|
||||
key={i}
|
||||
param={param}
|
||||
index={i}
|
||||
onChange={handleParamChange}
|
||||
onRemove={handleRemove}
|
||||
onMoveUp={handleMoveUp}
|
||||
onMoveDown={handleMoveDown}
|
||||
isFirst={i === 0}
|
||||
isLast={i === parameters.length - 1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={disabled}
|
||||
className="flex items-center gap-1.5 text-sm text-primary hover:underline self-start"
|
||||
>
|
||||
<Plus size={14} /> Add Parameter
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={e => { setJsonText(e.target.value); setJsonError(null) }}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[300px] resize-y font-label text-sm bg-card border border-border rounded-xl p-4 text-foreground focus:outline-none focus:border-[rgba(6,182,212,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder='{ "parameters": [...] }'
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-xs text-rose-500">{jsonError}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user