From 91e40c2920e64188d060b57001b774763c444f22 Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 01:51:56 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20add=20ParameterCard=20and=20ParameterSc?= =?UTF-8?q?hemaBuilder=20=E2=80=94=20visual=20builder=20+=20JSON=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../script-editor/ParameterCard.tsx | 317 ++++++++++++++++++ .../script-editor/ParameterSchemaBuilder.tsx | 172 ++++++++++ 2 files changed, 489 insertions(+) create mode 100644 frontend/src/components/script-editor/ParameterCard.tsx create mode 100644 frontend/src/components/script-editor/ParameterSchemaBuilder.tsx diff --git a/frontend/src/components/script-editor/ParameterCard.tsx b/frontend/src/components/script-editor/ParameterCard.tsx new file mode 100644 index 00000000..a4abf0f8 --- /dev/null +++ b/frontend/src/components/script-editor/ParameterCard.tsx @@ -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) => { + onChange(index, { ...param, ...patch }) + } + + const updateOption = (optIndex: number, patch: Partial) => { + 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) => { + update({ validation: { ...(param.validation ?? {}), ...patch } }) + } + + return ( +
+ {/* Header */} + + + {/* Body */} + {expanded && ( +
+ {/* Row 1: key + label */} +
+
+ + update({ key: e.target.value.replace(/[^a-zA-Z0-9_]/g, '') })} + placeholder="param_key" + disabled={disabled} + /> +
+
+ + update({ label: e.target.value })} + placeholder="Display Label" + disabled={disabled} + /> +
+
+ + {/* Row 2: type + group */} +
+
+ + +
+
+ + update({ group: e.target.value || null })} + placeholder="e.g. User Identity" + disabled={disabled} + /> +
+
+ + {/* Row 3: placeholder + help text */} +
+
+ + update({ placeholder: e.target.value || null })} + placeholder="Placeholder text" + disabled={disabled} + /> +
+
+ + update({ help_text: e.target.value || null })} + placeholder="Help text shown below field" + disabled={disabled} + /> +
+
+ + {/* Row 4: toggles */} +
+ + +
+ + {/* Default value */} +
+ + update({ default: e.target.value || null })} + placeholder="Default value" + disabled={disabled} + /> +
+ + {/* Select options (only for select type) */} + {param.type === 'select' && ( +
+ +
+ {(param.options ?? []).map((opt, i) => ( +
+ updateOption(i, { value: e.target.value })} + placeholder="value" + disabled={disabled} + /> + updateOption(i, { label: e.target.value })} + placeholder="label" + disabled={disabled} + /> + +
+ ))} + +
+
+ )} + + {/* Validation (for text/number types) */} + {(param.type === 'text' || param.type === 'number' || param.type === 'textarea') && ( +
+ +
+ {param.type === 'number' ? ( + <> +
+ + updateValidation({ min_value: e.target.value ? Number(e.target.value) : undefined })} + disabled={disabled} + /> +
+
+ + updateValidation({ max_value: e.target.value ? Number(e.target.value) : undefined })} + disabled={disabled} + /> +
+ + ) : ( + <> +
+ + updateValidation({ min_length: e.target.value ? Number(e.target.value) : undefined })} + disabled={disabled} + /> +
+
+ + updateValidation({ max_length: e.target.value ? Number(e.target.value) : undefined })} + disabled={disabled} + /> +
+ + )} +
+ + updateValidation({ pattern: e.target.value || undefined })} + placeholder="^[a-z]+$" + disabled={disabled} + /> +
+
+
+ )} + + {/* Actions row */} +
+
+ + +
+ +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/script-editor/ParameterSchemaBuilder.tsx b/frontend/src/components/script-editor/ParameterSchemaBuilder.tsx new file mode 100644 index 00000000..a607fe4a --- /dev/null +++ b/frontend/src/components/script-editor/ParameterSchemaBuilder.tsx @@ -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(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 ( +
+ {/* Mode toggle */} +
+ + +
+ + {mode === 'visual' ? ( + <> + {parameters.length === 0 ? ( +

+ No parameters defined. Add one to create dynamic form fields. +

+ ) : ( +
+ {parameters.map((param, i) => ( + + ))} +
+ )} + + + ) : ( + <> +