312 lines
13 KiB
TypeScript
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
|
|
}
|