kereru-forms/frontend/src/pages/FormBuilderPage.tsx

685 lines
26 KiB
TypeScript

import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, ApiError } from '../lib/api'
import type { Form, QuestionInput, QuestionOption, QuestionType } from '../lib/types'
const questionTypes: Array<{ value: QuestionType; label: string; hint: string }> = [
{ value: 'text', label: 'Text', hint: 'Free-form text (single or multiline)' },
{ value: 'boolean', label: 'True / false', hint: 'Yes/no style answer' },
{ value: 'number', label: 'Number', hint: 'Numeric input with optional bounds' },
{ value: 'select', label: 'Select', hint: 'Choose one from a list' },
{ value: 'date', label: 'Date', hint: 'Calendar date in YYYY-MM-DD format' },
]
function emptyQuestion(): QuestionInput {
return {
key: `q${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
label: '',
type: 'text',
required: false,
multiline: false,
}
}
function emptyOption(): QuestionOption {
return { value: '', label: '' }
}
function slugKey(value: string): string {
return (
value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 64) || 'question'
)
}
export function FormBuilderPage() {
const { formId } = useParams<{ formId?: string }>()
const navigate = useNavigate()
const isEdit = Boolean(formId)
const [loading, setLoading] = useState(isEdit)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [introduction, setIntroduction] = useState('')
const [requiresAccessCode, setRequiresAccessCode] = useState(false)
const [signatureRequired, setSignatureRequired] = useState(false)
const [externalReferenceRequired, setExternalReferenceRequired] = useState(false)
const [externalReferenceLabel, setExternalReferenceLabel] = useState('')
const [logoUrl, setLogoUrl] = useState('')
const [pdfUrl, setPdfUrl] = useState('')
const [companyName, setCompanyName] = useState('')
const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('')
const [contactUrl, setContactUrl] = useState('')
const [sections, setSections] = useState<Array<{ id: string; identifier: string; title: string; description: string }>>([])
const [questions, setQuestions] = useState<QuestionInput[]>([emptyQuestion()])
useEffect(() => {
if (!formId) return
let cancelled = false
api
.getForm(formId)
.then((form: Form) => {
if (cancelled) return
setTitle(form.title)
setDescription(form.description ?? '')
setIntroduction(form.introduction ?? '')
setRequiresAccessCode(form.requiresAccessCode)
setSignatureRequired(Boolean(form.signatureRequired))
setExternalReferenceRequired(Boolean(form.externalReferenceRequired))
setExternalReferenceLabel(form.externalReferenceLabel ?? '')
setLogoUrl(form.logoUrl ?? '')
setPdfUrl(form.pdfUrl ?? '')
setCompanyName(form.companyName ?? '')
setContactEmail(form.contactEmail ?? '')
setContactPhone(form.contactPhone ?? '')
setContactUrl(form.contactUrl ?? '')
setSections(
(form.sections ?? []).map((s) => ({
id: s.id,
identifier: s.identifier ?? '',
title: s.title ?? '',
description: s.description ?? '',
})),
)
setQuestions(
form.questions.map((q) => ({
key: q.key,
label: q.label,
description: q.description,
type: q.type,
required: q.required,
options: q.options ? q.options.map((o) => ({ ...o })) : undefined,
minValue: q.minValue,
maxValue: q.maxValue,
placeholder: q.placeholder,
multiline: q.multiline,
sectionId: q.sectionId,
})),
)
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : 'Could not load the form')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [formId])
function addQuestion() {
setQuestions((current) => [...current, emptyQuestion()])
}
function updateQuestion(index: number, patch: Partial<QuestionInput>) {
setQuestions((current) => {
const next = [...current]
next[index] = { ...next[index], ...patch }
return next
})
}
function suggestKey(index: number) {
setQuestions((current) => {
const next = [...current]
next[index] = { ...next[index], key: slugKey(next[index].label) }
return next
})
}
function removeQuestion(index: number) {
setQuestions((current) => current.filter((_, i) => i !== index))
}
function addOption(index: number) {
setQuestions((current) => {
const next = [...current]
const options = next[index].options ? [...next[index].options] : []
options.push(emptyOption())
next[index] = { ...next[index], options }
return next
})
}
function updateOption(qIndex: number, oIndex: number, patch: Partial<QuestionOption>) {
setQuestions((current) => {
const next = [...current]
const options = next[qIndex].options ? [...next[qIndex].options] : []
options[oIndex] = { ...options[oIndex], ...patch }
next[qIndex] = { ...next[qIndex], options }
return next
})
}
function removeOption(qIndex: number, oIndex: number) {
setQuestions((current) => {
const next = [...current]
const options = (next[qIndex].options ?? []).filter((_, i) => i !== oIndex)
next[qIndex] = { ...next[qIndex], options }
return next
})
}
function addSection() {
setSections((current) => [
...current,
{ id: crypto.randomUUID ? crypto.randomUUID() : `s-${Date.now()}`, identifier: String.fromCharCode(65 + current.length), title: '', description: '' },
])
}
function updateSection(index: number, patch: Partial<{ identifier: string; title: string; description: string }>) {
setSections((current) => {
const next = [...current]
next[index] = { ...next[index], ...patch }
return next
})
}
function removeSection(index: number) {
setSections((current) => current.filter((_, i) => i !== index))
}
async function save(event: React.FormEvent) {
event.preventDefault()
setError(null)
if (title.trim() === '') {
setError('Title is required.')
return
}
if (questions.length === 0) {
setError('Add at least one question.')
return
}
const payload = {
title: title.trim(),
description: description.trim() || undefined,
introduction: introduction.trim() || undefined,
requiresAccessCode,
signatureRequired,
externalReferenceRequired,
externalReferenceLabel: externalReferenceLabel.trim() || undefined,
logoUrl: logoUrl.trim() || undefined,
pdfUrl: pdfUrl.trim() || undefined,
companyName: companyName.trim() || undefined,
contactEmail: contactEmail.trim() || undefined,
contactPhone: contactPhone.trim() || undefined,
contactUrl: contactUrl.trim() || undefined,
sections: sections
.filter((s) => s.identifier.trim())
.map((s) => ({
identifier: s.identifier.trim(),
title: s.title.trim() || undefined,
description: s.description.trim() || undefined,
})),
questions: questions.map((q) => {
const out: QuestionInput = {
key: q.key.trim(),
label: q.label.trim(),
type: q.type,
required: q.required,
description: q.description?.trim() || undefined,
placeholder: q.placeholder?.trim() || undefined,
sectionId: q.sectionId || undefined,
}
if (q.type === 'number') {
out.minValue = q.minValue
out.maxValue = q.maxValue
}
if (q.type === 'text') {
out.multiline = q.multiline
}
if (q.type === 'select') {
out.options = (q.options ?? [])
.filter((o) => o.value.trim() && o.label.trim())
.map((o) => ({ value: o.value.trim(), label: o.label.trim() }))
}
return out
}),
}
setSaving(true)
try {
if (isEdit && formId) {
const updated = await api.updateForm(formId, payload)
navigate(`/manage/forms/${updated.id}`)
} else {
const created = await api.createForm(payload)
navigate(`/manage/forms/${created.id}`)
}
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Could not save the form')
} finally {
setSaving(false)
}
}
if (loading) {
return <p className="text-slate-500 dark:text-slate-400">Loading form…</p>
}
return (
<form onSubmit={save} className="space-y-6">
<header className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400">
<Link to="/manage" className="hover:text-brand-600 dark:hover:text-brand-400">
Management
</Link>
<span>/</span>
<span>{isEdit ? 'Edit form' : 'Create form'}</span>
</div>
<h1 className="mt-1 text-2xl font-bold">{isEdit ? 'Edit form' : 'Create form'}</h1>
<p className="text-slate-500 dark:text-slate-400">
{isEdit
? 'Saving creates a new version; earlier submissions keep their original definition.'
: 'Build a new versioned form.'}
</p>
</div>
<div className="flex gap-2">
<Link to="/manage" className="btn-secondary">
Cancel
</Link>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save form'}
</button>
</div>
</header>
{error && (
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">{error}</div>
)}
<section className="card space-y-4">
<h2 className="text-lg font-semibold">Form details</h2>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<label htmlFor="title" className="label">
Title *
</label>
<input id="title" className="input" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div className="md:col-span-2">
<label htmlFor="description" className="label">
Description
</label>
<textarea
id="description"
className="input min-h-[4rem]"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="md:col-span-2">
<label htmlFor="introduction" className="label">
Introduction (optional section)
</label>
<textarea
id="introduction"
className="input min-h-[6rem]"
value={introduction}
onChange={(e) => setIntroduction(e.target.value)}
placeholder="Explain purpose, background and support details."
/>
</div>
<div>
<label htmlFor="company" className="label">
Company name
</label>
<input id="company" className="input" value={companyName} onChange={(e) => setCompanyName(e.target.value)} />
</div>
<div>
<label htmlFor="logo" className="label">
Logo URL
</label>
<input
id="logo"
className="input"
type="url"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
placeholder="https://example.com/logo.png"
/>
</div>
<div>
<label htmlFor="pdf" className="label">
Linked PDF URL
</label>
<input
id="pdf"
className="input"
type="url"
value={pdfUrl}
onChange={(e) => setPdfUrl(e.target.value)}
placeholder="https://example.com/prebuilt-form.pdf"
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Optional prebuilt PDF used for printable and offline copies.
</p>
</div>
<div>
<label htmlFor="email" className="label">
Contact email
</label>
<input id="email" className="input" type="email" value={contactEmail} onChange={(e) => setContactEmail(e.target.value)} />
</div>
<div>
<label htmlFor="phone" className="label">
Contact phone
</label>
<input id="phone" className="input" value={contactPhone} onChange={(e) => setContactPhone(e.target.value)} />
</div>
<div>
<label htmlFor="url" className="label">
Contact URL
</label>
<input id="url" className="input" value={contactUrl} onChange={(e) => setContactUrl(e.target.value)} />
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={requiresAccessCode}
onChange={(e) => setRequiresAccessCode(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require single-use access codes
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={signatureRequired}
onChange={(e) => setSignatureRequired(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require a digital signature at the end of the form
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={externalReferenceRequired}
onChange={(e) => setExternalReferenceRequired(e.target.checked)}
className="h-4 w-4 accent-brand-600"
/>
Require an external reference from respondents
</label>
{externalReferenceRequired && (
<div>
<label htmlFor="externalReferenceLabel" className="label">
External reference label
</label>
<input
id="externalReferenceLabel"
className="input"
value={externalReferenceLabel}
onChange={(e) => setExternalReferenceLabel(e.target.value)}
placeholder="External reference"
maxLength={100}
/>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
Shown to respondents and stored with each response, for example “Invoice number”.
</p>
</div>
)}
</section>
<section className="card space-y-4">
<header className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Sections</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">
Group questions with leading text; questions in a section get numbers prefixed by the section identifier.
</p>
</div>
</header>
<div className="space-y-3">
{sections.length === 0 && <p className="text-sm text-slate-500 dark:text-slate-400">No sections defined.</p>}
{sections.map((section, index) => (
<article key={section.id} className="rounded-lg border border-slate-200 p-4 dark:border-slate-700">
<header className="mb-3 flex items-center justify-between">
<p className="text-sm font-semibold text-slate-700 dark:text-slate-200">Section {index + 1}</p>
<button type="button" className="text-sm text-red-600 hover:underline" onClick={() => removeSection(index)}>
Remove
</button>
</header>
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="label">Identifier</label>
<input className="input" value={section.identifier} onChange={(e) => updateSection(index, { identifier: e.target.value })} placeholder="e.g. A or 1" />
</div>
<div className="sm:col-span-2">
<label className="label">Title</label>
<input className="input" value={section.title} onChange={(e) => updateSection(index, { title: e.target.value })} placeholder="Section heading" />
</div>
</div>
<div className="mt-3">
<label className="label">Description</label>
<textarea className="input min-h-[4rem]" value={section.description} onChange={(e) => updateSection(index, { description: e.target.value })} />
</div>
</article>
))}
</div>
<div>
<button type="button" className="btn-secondary" onClick={addSection}>
Add section
</button>
</div>
</section>
<section className="card space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Questions</h2>
<p className="text-sm text-slate-500 dark:text-slate-400">Questions may not have formatting directives.</p>
</div>
</div>
<div className="space-y-4">
{questions.map((question, index) => (
<article key={index} className="rounded-xl border border-slate-200 p-4 dark:border-slate-700">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-semibold">Question {index + 1}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">Key used to store answers</p>
<div className="mt-2">
<label className="label text-xs">Section</label>
<select
className="input max-w-xs"
value={question.sectionId ?? ''}
onChange={(e) => updateQuestion(index, { sectionId: e.target.value || undefined })}
>
<option value="">No section</option>
{sections.map((s) => (
<option key={s.id} value={s.id}>
{s.identifier || 'Section'}{s.title ? ` - ${s.title}` : ''}
</option>
))}
</select>
</div>
</div>
<div className="flex gap-2">
<button type="button" className="btn-secondary py-1 text-xs" onClick={() => suggestKey(index)}>
Suggest key
</button>
{questions.length > 1 && (
<button type="button" className="btn-danger py-1 text-xs" onClick={() => removeQuestion(index)}>
Remove
</button>
)}
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<div>
<label className="label">Label *</label>
<input
className="input"
value={question.label}
onChange={(e) => updateQuestion(index, { label: e.target.value })}
required
/>
</div>
<div>
<label className="label">Key *</label>
<input
className="input font-mono text-sm"
value={question.key}
onChange={(e) => updateQuestion(index, { key: e.target.value })}
required
/>
</div>
<div className="md:col-span-2">
<label className="label">Description</label>
<input
className="input"
value={question.description ?? ''}
onChange={(e) => updateQuestion(index, { description: e.target.value })}
/>
</div>
<div>
<label className="label">Type</label>
<select
className="input"
value={question.type}
onChange={(e) => updateQuestion(index, { type: e.target.value as QuestionType, options: undefined })}
>
{questionTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{questionTypes.find((t) => t.value === question.type)?.hint}
</p>
</div>
<div>
<label className="label">Placeholder</label>
<input
className="input"
value={question.placeholder ?? ''}
onChange={(e) => updateQuestion(index, { placeholder: e.target.value })}
/>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={question.required}
onChange={(e) => updateQuestion(index, { required: e.target.checked })}
className="h-4 w-4 accent-brand-600"
/>
Required
</label>
{question.type === 'text' && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(question.multiline)}
onChange={(e) => updateQuestion(index, { multiline: e.target.checked })}
className="h-4 w-4 accent-brand-600"
/>
Multiline
</label>
)}
</div>
{question.type === 'number' && (
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="label">Min value</label>
<input
type="number"
className="input"
value={question.minValue ?? ''}
onChange={(e) =>
updateQuestion(index, { minValue: e.target.value === '' ? undefined : Number(e.target.value) })
}
/>
</div>
<div>
<label className="label">Max value</label>
<input
type="number"
className="input"
value={question.maxValue ?? ''}
onChange={(e) =>
updateQuestion(index, { maxValue: e.target.value === '' ? undefined : Number(e.target.value) })
}
/>
</div>
</div>
)}
{question.type === 'select' && (
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Options</p>
<button type="button" className="btn-secondary py-1 text-xs" onClick={() => addOption(index)}>
Add option
</button>
</div>
{(question.options ?? []).map((option, oIndex) => (
<div key={oIndex} className="grid gap-2 sm:grid-cols-[1fr,1fr,auto]">
<input
className="input"
placeholder="Label"
value={option.label}
onChange={(e) => updateOption(index, oIndex, { label: e.target.value })}
/>
<input
className="input font-mono text-sm"
placeholder="Value"
value={option.value}
onChange={(e) => updateOption(index, oIndex, { value: e.target.value })}
/>
<button
type="button"
className="btn-danger py-1 text-xs"
onClick={() => removeOption(index, oIndex)}
>
Remove
</button>
</div>
))}
{(question.options ?? []).length === 0 && (
<p className="text-sm text-slate-500 dark:text-slate-400">Add at least one option.</p>
)}
</div>
)}
</article>
))}
</div>
<div>
<button type="button" className="btn-secondary" onClick={addQuestion}>
Add question
</button>
</div>
</section>
<div>
<div className="flex gap-2">
<Link to="/manage" className="btn-secondary">
Cancel
</Link>
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving…' : 'Save form'}
</button>
</div>
</div>
</form>
)
}