Files
hackerspace.sh/components/forms/form-renderer.tsx
T
Moheeb Zara 03bb3a7466 feat(forms): builder UI, management pages, and public fill page (Phase 3)
- components/forms/form-renderer.tsx: one controlled renderer for all 8
  field types, shared by the builder preview and the public page
- components/forms/form-builder.tsx: easy builder — label-only field
  creation (keys auto-derived and de-duped at save), one-click type
  buttons, up/down reordering, inline options editor, live preview;
  edit mode adds publish/close/draft/delete and copy-public-link
- /forms directory, /forms/new, /forms/[id]/edit, /forms/[id]/results
  (CSV download). Server-gated by lib/forms-guard.ts (forms.manage via
  the same RPC the RLS uses; RLS remains the real boundary)
- public app/f/[slug] (outside the app group): getPublicForm serves
  only published public forms — members-only forms are never exposed
  there; waiver consent, anon email capture, signed-in gate for
  public_auth, thank-you state
- sidebar "Forms & waivers" link; sonner toasts (codebase convention)
- ARCHITECTURE + HANDOFF updated

285/285 tests, build clean, all four routes compiled. No deploy.
2026-05-16 15:03:48 -07:00

155 lines
4.8 KiB
TypeScript

'use client'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
import type { FormField } from '@/lib/forms-schema'
// Renders a form's field schema as a controlled, fillable form. Shared by the
// builder's live preview and the public /f/[slug] page so the two can never
// drift.
export function FormRenderer({
fields,
values,
onChange,
disabled,
idPrefix = 'f',
}: {
fields: FormField[]
values: Record<string, unknown>
onChange: (key: string, value: unknown) => void
disabled?: boolean
idPrefix?: string
}) {
if (fields.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No fields yet. Add one to see it here.
</p>
)
}
return (
<div className="space-y-5">
{fields.map(field => {
const id = `${idPrefix}-${field.key}`
const v = values[field.key]
return (
<div key={field.key} className="space-y-1.5">
{field.type !== 'checkbox' && (
<Label htmlFor={id}>
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
)}
{field.type === 'short_text' && (
<Input
id={id}
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'long_text' && (
<Textarea
id={id}
rows={4}
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'email' && (
<Input
id={id}
type="email"
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'number' && (
<Input
id={id}
type="number"
value={v === undefined || v === null ? '' : String(v)}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'date' && (
<Input
id={id}
type="date"
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
/>
)}
{field.type === 'checkbox' && (
<div className="flex items-start gap-2">
<Checkbox
id={id}
checked={v === true}
disabled={disabled}
onCheckedChange={c => onChange(field.key, c === true)}
/>
<Label htmlFor={id} className="font-normal leading-snug">
{field.label}
{field.required && <span className="text-destructive"> *</span>}
</Label>
</div>
)}
{field.type === 'select' && (
<select
id={id}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm disabled:opacity-50"
value={(v as string) ?? ''}
disabled={disabled}
onChange={e => onChange(field.key, e.target.value)}
>
<option value="">Select…</option>
{(field.options ?? []).map(o => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
)}
{field.type === 'radio' && (
<div className="space-y-1.5" role="radiogroup" aria-label={field.label}>
{(field.options ?? []).map(o => (
<label key={o} className="flex items-center gap-2 text-sm">
<input
type="radio"
name={id}
value={o}
checked={v === o}
disabled={disabled}
onChange={() => onChange(field.key, o)}
/>
{o}
</label>
))}
</div>
)}
{field.help && field.type !== 'checkbox' && (
<p className="text-xs text-muted-foreground">{field.help}</p>
)}
</div>
)
})}
</div>
)
}