Files
floci-ui/packages/frontend/src/components/DynamicFormRenderer.tsx
T
2026-10-05 11:06:57 -04:00

312 lines
13 KiB
TypeScript

import {FormEvent, useRef, useState} from 'react'
import {Plus, Trash2} from 'lucide-react'
import type {FieldSchema, ServiceSchema} from '@/types/schema'
interface DynamicFormRendererProps {
schema: ServiceSchema
fields?: FieldSchema[]
initialValues?: Record<string, unknown>
isSubmitting: boolean
submitLabel?: string
pendingLabel?: string
submitError?: string | null
onCancel?: () => void
onSubmit: (values: Record<string, unknown>) => void
}
export function DynamicFormRenderer({
schema,
fields,
initialValues,
isSubmitting,
submitLabel = 'Create',
pendingLabel = 'Creating',
submitError,
onCancel,
onSubmit,
}: DynamicFormRendererProps) {
const activeFields = fields ?? schema.fields
const [values, setValues] = useState<Record<string, string>>(() =>
getInitialFormValues(activeFields, initialValues),
)
const [errors, setErrors] = useState<Record<string, string>>({})
const visibleFields = activeFields.filter((field) => !field.visibleWhen || field.visibleWhen.values.includes(values[field.visibleWhen.field] ?? ''))
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const nextErrors = validateValues(visibleFields, values)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
onSubmit(values)
}
return (
<form className="dynamic-form" onSubmit={submit} noValidate>
{visibleFields.map((field) => (
<FieldRow
key={field.name}
field={field}
required={isFieldRequired(field, values)}
maxLength={fieldMaxLength(field, values).value}
value={values[field.name] ?? ''}
error={errors[field.name]}
onChange={(value) => {
setValues((prev) => withDependentDefaults(activeFields, prev, field.name, value))
setErrors((prev) => {
const next = {...prev}
delete next[field.name]
return next
})
}}
/>
))}
<div style={{gridColumn: '1 / -1', justifySelf: 'end', display: 'flex', gap: '8px', alignItems: 'center', marginTop: 4}}>
{onCancel && (
<button className="button" type="button" disabled={isSubmitting} onClick={onCancel}>
Cancel
</button>
)}
<button className="button primary" type="submit" disabled={isSubmitting}>
{submitLabel === 'Create' && <Plus size={14}/>}
{isSubmitting ? pendingLabel : submitLabel}
</button>
</div>
{submitError && <div className="form-error" role="alert">{submitError}</div>}
</form>
)
}
function FieldRow({field, required, maxLength, value, error, onChange}: {field: FieldSchema; required: boolean; maxLength?: number; value: string; error?: string; onChange: (value: string) => void}) {
return (
<>
{field.group && <div className="dynamic-form-group">{field.group}</div>}
<label className={`dynamic-field${field.span ? ' dynamic-field--span' : ''}`}>
<span>
{field.label}
{required && <em className="field-required">*</em>}
</span>
<FieldInput field={field} required={required} maxLength={maxLength} value={value} invalid={Boolean(error)} messageId={`${field.name}-message`} onChange={onChange}/>
{(error || field.description) && (
<small id={`${field.name}-message`} className={error ? 'field-error' : undefined}>
{error ?? field.description}
</small>
)}
</label>
</>
)
}
function FieldInput({field, required, maxLength, value, invalid, messageId, onChange}: {field: FieldSchema; required: boolean; maxLength?: number; value: string; invalid: boolean; messageId: string; onChange: (value: string) => void}) {
if (field.type === 'select') {
return (
<select className={`input ${invalid ? 'invalid' : ''}`} value={value} required={required} aria-invalid={invalid || undefined} aria-describedby={invalid || field.description ? messageId : undefined} onChange={(event) => onChange(event.target.value)}>
<option value="">Default</option>
{(field.options ?? []).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)
}
if (field.type === 'textarea') {
return (
<textarea
className={`textarea code-textarea ${invalid ? 'invalid' : ''}`}
value={value}
required={required}
aria-invalid={invalid || undefined}
aria-describedby={invalid || field.description ? messageId : undefined}
minLength={field.validation?.minLength}
maxLength={maxLength}
rows={10}
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
placeholder={field.label}
/>
)
}
if (field.type === 'file') {
return (
<input
type="file"
className={`input file-input ${invalid ? 'invalid' : ''}`}
required={required}
accept={field.accept}
aria-invalid={invalid || undefined}
aria-describedby={invalid || field.description ? messageId : undefined}
onChange={(event) => {
const file = event.target.files?.[0]
if (!file) {
onChange('')
return
}
void readFileAsBase64(file).then(onChange)
}}
/>
)
}
if (field.type === 'key-value') {
return <KeyValueInput field={field} value={value} onChange={onChange}/>
}
return (
<input
type={field.type === 'password' ? 'password' : 'text'}
className={`input ${invalid ? 'invalid' : ''}`}
value={value}
required={required}
aria-invalid={invalid || undefined}
aria-describedby={invalid || field.description ? messageId : undefined}
minLength={field.validation?.minLength}
maxLength={maxLength}
pattern={field.validation?.pattern}
onChange={(event) => onChange(event.target.value)}
placeholder={field.label}
/>
)
}
function KeyValueInput({field, value, onChange}: {field: FieldSchema; value: string; onChange: (value: string) => void}) {
const [entries, setEntries] = useState(() => keyValueEntries(value))
const entriesRef = useRef(entries)
function commit(next: Array<{key: string; value: string}>) {
entriesRef.current = next
setEntries(next)
onChange(JSON.stringify(next))
}
function updateEntry(index: number, property: 'key' | 'value', nextValue: string) {
const next = entriesRef.current.map((entry, entryIndex) => entryIndex === index ? {...entry, [property]: nextValue} : entry)
commit(next)
}
return (
<div className="key-value-editor">
{entries.map((entry, index) => (
<div className="key-value-row" key={index}>
<input className="input" value={entry.key} placeholder="Variable name" aria-label={`${field.label} key ${index + 1}`} onChange={(event) => updateEntry(index, 'key', event.target.value)}/>
<input className="input" value={entry.value} placeholder="Value" aria-label={`${field.label} value ${index + 1}`} onChange={(event) => updateEntry(index, 'value', event.target.value)}/>
<button className="icon-btn" type="button" aria-label={`Remove ${entry.key || 'environment variable'}`} onClick={() => commit(entriesRef.current.filter((_, entryIndex) => entryIndex !== index))}><Trash2 size={13}/></button>
</div>
))}
<button className="button compact" type="button" aria-label="Add variable" onClick={() => commit([...entriesRef.current, {key: '', value: ''}])}><Plus size={13}/> Add variable</button>
</div>
)
}
function readFileAsBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error ?? new Error('Unable to read the selected file.'))
reader.onload = () => {
const result = reader.result
if (typeof result !== 'string') {
reject(new Error('Unable to read the selected file.'))
return
}
resolve(result.slice(result.indexOf(',') + 1))
}
reader.readAsDataURL(file)
})
}
function getInitialFormValues(
fields: FieldSchema[],
initialValues?: Record<string, unknown>,
): Record<string, string> {
const defaults = defaultValues(fields)
if (initialValues) {
for (const [key, val] of Object.entries(initialValues)) {
if (val !== undefined && val !== null) {
const field = fields.find((entry) => entry.name === key)
defaults[key] = field?.type === 'key-value' && typeof val === 'object'
? JSON.stringify(Object.entries(val as Record<string, unknown>).map(([entryKey, entryValue]) => ({key: entryKey, value: String(entryValue)})))
: String(val)
}
}
}
return defaults
}
function keyValueEntries(value: string): Array<{key: string; value: string}> {
if (!value) return []
try {
const parsed: unknown = JSON.parse(value)
if (!Array.isArray(parsed)) return []
return parsed.filter((entry): entry is {key: string; value: string} => Boolean(entry) && typeof entry === 'object' && typeof entry.key === 'string' && typeof entry.value === 'string')
} catch {
return []
}
}
function defaultValues(fields: FieldSchema[]): Record<string, string> {
const values = Object.fromEntries(fields.map((field) => [field.name, field.defaultValue ?? '']))
for (const field of fields) {
const conditional = field.defaultValueWhen
if (conditional) {
values[field.name] = conditional.values[values[conditional.field] ?? ''] ?? field.defaultValue ?? ''
}
}
return values
}
function withDependentDefaults(
fields: FieldSchema[],
currentValues: Record<string, string>,
changedField: string,
value: string,
): Record<string, string> {
const nextValues = {...currentValues, [changedField]: value}
for (const field of fields) {
const conditional = field.defaultValueWhen
if (conditional?.field === changedField) {
nextValues[field.name] = conditional.values[value] ?? field.defaultValue ?? ''
}
}
return nextValues
}
function validateValues(fields: FieldSchema[], values: Record<string, string>): Record<string, string> {
const errors: Record<string, string> = {}
for (const field of fields) {
const value = (values[field.name] ?? '').trim()
if (isFieldRequired(field, values) && !value) {
errors[field.name] = `${field.label} is required.`
continue
}
if (!value) continue
if (field.validation?.minLength && value.length < field.validation.minLength) {
errors[field.name] = field.validation.message ?? `${field.label} is too short.`
continue
}
const maxLength = fieldMaxLength(field, values)
if (maxLength.value && value.length > maxLength.value) {
errors[field.name] = maxLength.message ?? `${field.label} is too long.`
continue
}
if (field.validation?.pattern && !new RegExp(field.validation.pattern).test(value)) {
errors[field.name] = field.validation.message ?? `${field.label} is invalid.`
}
}
return errors
}
function fieldMaxLength(field: FieldSchema, values: Record<string, string>): {value?: number; message?: string} {
const conditional = field.validation?.maxLengthWhen
if (conditional && values[conditional.field] === conditional.equals) {
return {value: conditional.value, message: conditional.message}
}
return {value: field.validation?.maxLength, message: field.validation?.message}
}
function isFieldRequired(field: FieldSchema, values: Record<string, string>): boolean {
if (field.required) return true
if (!field.requiredWhen) return false
return values[field.requiredWhen.field] === field.requiredWhen.equals
}