Added sections

main
Tom Peltonen 2026-10-25 21:53:17 +11:00
parent a60e75b5c6
commit a6e97a2e9c
12 changed files with 628 additions and 116 deletions

View File

@ -52,10 +52,9 @@ const (
// Defines values for UserIdType.
const (
Email UserIdType = "email"
Guid UserIdType = "guid"
ForeignkeyGuid UserIdType = "foreignkey guid"
Phone UserIdType = "phone"
Username UserIdType = "username"
ForeignKey UserIdType = "foreignkey"
)
// AccessCode defines model for AccessCode.
@ -139,6 +138,7 @@ type CreateFormRequest struct {
PdfUrl *string `json:"pdfUrl,omitempty"`
Questions []QuestionInput `json:"questions"`
RequiresAccessCode *bool `json:"requiresAccessCode,omitempty"`
Sections *[]SectionInput `json:"sections,omitempty"`
SignatureRequired *bool `json:"signatureRequired,omitempty"`
Title string `json:"title"`
}
@ -175,6 +175,7 @@ type Form struct {
PdfUrl *string `json:"pdfUrl,omitempty"`
Questions []Question `json:"questions"`
RequiresAccessCode bool `json:"requiresAccessCode"`
Sections *[]Section `json:"sections,omitempty"`
// SignatureRequired Whether a digital signature is captured at the end of the form
SignatureRequired *bool `json:"signatureRequired,omitempty"`
@ -261,6 +262,9 @@ type Question struct {
Position int `json:"position"`
Required bool `json:"required"`
// SectionId Identifier of the section this question belongs to
SectionId *openapi_types.UUID `json:"sectionId,omitempty"`
// Type * `boolean` – true/false
// * `number` – numeric answer
// * `select` – one option from a configured list
@ -281,6 +285,9 @@ type QuestionInput struct {
Placeholder *string `json:"placeholder,omitempty"`
Required bool `json:"required"`
// SectionId Identifier of the section this question belongs to
SectionId *openapi_types.UUID `json:"sectionId,omitempty"`
// Type * `boolean` – true/false
// * `number` – numeric answer
// * `select` – one option from a configured list
@ -302,6 +309,23 @@ type QuestionOption struct {
// * `date` - calendar date in `YYYY-MM-DD` form
type QuestionType string
// Section defines model for Section.
type Section struct {
Description *string `json:"description,omitempty"`
Id openapi_types.UUID `json:"id"`
// Identifier Section identifier such as letter or number used to prefix question numbers
Identifier string `json:"identifier"`
Title *string `json:"title,omitempty"`
}
// SectionInput defines model for SectionInput.
type SectionInput struct {
Description *string `json:"description,omitempty"`
Identifier string `json:"identifier"`
Title *string `json:"title,omitempty"`
}
// Signature defines model for Signature.
type Signature struct {
DataUrl string `json:"dataUrl"`
@ -412,6 +436,7 @@ type UpdateFormRequest struct {
PdfUrl *string `json:"pdfUrl,omitempty"`
Questions []QuestionInput `json:"questions"`
RequiresAccessCode *bool `json:"requiresAccessCode,omitempty"`
Sections *[]SectionInput `json:"sections,omitempty"`
SignatureRequired *bool `json:"signatureRequired,omitempty"`
Title string `json:"title"`
}
@ -2354,77 +2379,80 @@ func (sh *strictHandler) GetSubmissionOfflinePdf(ctx *gin.Context, submissionId
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"AAA=",
}
// GetSwagger returns the content of the embedded swagger specification file

View File

@ -13,6 +13,21 @@ func toAPIForm(f *database.Form) Form {
for _, q := range f.Questions {
questions = append(questions, toAPIQuestion(q))
}
var sections *[]Section
if len(f.Sections) > 0 {
s := make([]Section, 0, len(f.Sections))
for _, sec := range f.Sections {
s = append(s, Section{
Id: uuid.MustParse(sec.ID),
Identifier: sec.Identifier,
Title: sec.Title,
Description: sec.Description,
})
}
sections = &s
} else {
sections = &[]Section{}
}
return Form{
Id: uuid.MustParse(f.ID),
FormGroupId: uuid.MustParse(f.FormGroupID),
@ -34,6 +49,7 @@ func toAPIForm(f *database.Form) Form {
CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt,
Questions: questions,
Sections: sections,
}
}
@ -48,6 +64,10 @@ func toAPIQuestion(q database.Question) Question {
Placeholder: q.Placeholder,
Multiline: boolPtrIfSet(q.Multiline, QuestionType(q.Type) == Text),
}
if q.SectionID != nil && *q.SectionID != "" {
sid := uuid.MustParse(*q.SectionID)
out.SectionId = &sid
}
if q.MinValue != nil {
v := float32(*q.MinValue)
out.MinValue = &v
@ -180,6 +200,10 @@ func questionInputsToRecords(inputs []QuestionInput) []database.Question {
Required: q.Required,
Placeholder: q.Placeholder,
}
if q.SectionId != nil {
sid := q.SectionId.String()
rec.SectionID = &sid
}
if q.MinValue != nil {
v := float64(*q.MinValue)
rec.MinValue = &v

View File

@ -140,10 +140,35 @@ func (s *Server) CreateForm(ctx context.Context, req CreateFormRequestObject) (C
CreatedAt: now,
UpdatedAt: now,
Questions: questionInputsToRecords(req.Body.Questions),
Sections: sectionsFromInputs(req.Body.Sections),
}
if err := assignSectionIDs(form); err != nil {
return CreateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
// Ensure section IDs are set and attached to form
for i := range form.Sections {
form.Sections[i].FormID = form.ID
if form.Sections[i].ID == "" {
form.Sections[i].ID = uuid.NewString()
}
}
for i := range form.Questions {
form.Questions[i].ID = uuid.NewString()
form.Questions[i].FormID = form.ID
// If sectionId is set, ensure it points to a valid section on this form
if form.Questions[i].SectionID != nil && *form.Questions[i].SectionID != "" {
sid := *form.Questions[i].SectionID
found := false
for _, sec := range form.Sections {
if sec.ID == sid {
found = true
break
}
}
if !found {
form.Questions[i].SectionID = nil
}
}
}
if err := s.store.CreateForm(ctx, form); err != nil {
@ -240,6 +265,7 @@ func (s *Server) UpdateForm(ctx context.Context, req UpdateFormRequestObject) (U
CreatedAt: now,
UpdatedAt: now,
Questions: questionInputsToRecords(req.Body.Questions),
Sections: sectionsFromInputs(req.Body.Sections),
}
if req.Body.RequiresAccessCode != nil {
form.RequiresAccessCode = *req.Body.RequiresAccessCode
@ -247,9 +273,31 @@ func (s *Server) UpdateForm(ctx context.Context, req UpdateFormRequestObject) (U
if req.Body.SignatureRequired != nil {
form.SignatureRequired = *req.Body.SignatureRequired
}
if err := assignSectionIDs(form); err != nil {
return UpdateForm400JSONResponse{ErrorJSONResponse: errJSON(err.Error(), "validation_error")}, nil
}
for i := range form.Sections {
form.Sections[i].FormID = form.ID
if form.Sections[i].ID == "" {
form.Sections[i].ID = uuid.NewString()
}
}
for i := range form.Questions {
form.Questions[i].ID = uuid.NewString()
form.Questions[i].FormID = form.ID
if form.Questions[i].SectionID != nil && *form.Questions[i].SectionID != "" {
sid := *form.Questions[i].SectionID
found := false
for _, sec := range form.Sections {
if sec.ID == sid {
found = true
break
}
}
if !found {
form.Questions[i].SectionID = nil
}
}
}
if err := s.store.CreateForm(ctx, form); err != nil {
@ -1043,9 +1091,74 @@ func toFloat(v interface{}) (float64, bool) {
}
}
func sectionsFromInputs(inputs *[]SectionInput) []database.Section {
if inputs == nil {
return nil
}
out := make([]database.Section, 0, len(*inputs))
for i, s := range *inputs {
ident := strings.TrimSpace(s.Identifier)
if ident == "" {
continue
}
out = append(out, database.Section{
ID: uuid.NewString(),
Position: i,
Identifier: ident,
Title: s.Title,
Description: s.Description,
})
}
return out
}
func assignSectionIDs(form *database.Form) error {
if len(form.Sections) == 0 {
return nil
}
for i := range form.Sections {
if strings.TrimSpace(form.Sections[i].Identifier) == "" {
return errors.New("section identifier is required")
}
if form.Sections[i].ID == "" {
form.Sections[i].ID = uuid.NewString()
}
form.Sections[i].Position = i
}
// Map questions that have sectionId set to valid UUIDs of created sections if needed
// Build map by identifier in case frontend passes different forms
byIdent := make(map[string]string, len(form.Sections))
for i := range form.Sections {
byIdent[form.Sections[i].Identifier] = form.Sections[i].ID
}
for i := range form.Questions {
if form.Questions[i].SectionID != nil && *form.Questions[i].SectionID != "" {
sid := *form.Questions[i].SectionID
// If it's already a valid UUID of one of our sections, keep it
found := false
for _, sec := range form.Sections {
if sec.ID == sid {
found = true
break
}
}
if !found {
// Try to match by identifier as fallback
if mapped, ok := byIdent[sid]; ok {
form.Questions[i].SectionID = &mapped
continue
}
// Otherwise clear to avoid FK violation
form.Questions[i].SectionID = nil
}
}
}
return nil
}
func validUserIdType(t UserIdType) bool {
switch t {
case Email, Username, Phone, ForeignKey, Guid:
case Email, Username, Phone, ForeignkeyGuid:
return true
default:
return false

View File

@ -115,6 +115,24 @@ ALTER TABLE submissions ADD COLUMN offline_pdf BLOB;
ALTER TABLE forms ADD COLUMN external_reference_required INTEGER NOT NULL DEFAULT 0;
ALTER TABLE forms ADD COLUMN external_reference_label TEXT;
ALTER TABLE submissions ADD COLUMN external_reference TEXT;
`,
},
{
version: 4,
name: "question sections",
statements: `
CREATE TABLE IF NOT EXISTS sections (
id TEXT PRIMARY KEY,
form_id TEXT NOT NULL REFERENCES forms (id) ON DELETE CASCADE,
position INTEGER NOT NULL,
identifier TEXT NOT NULL,
title TEXT,
description TEXT
);
CREATE INDEX IF NOT EXISTS idx_sections_form ON sections (form_id);
ALTER TABLE questions ADD COLUMN section_id TEXT REFERENCES sections (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_questions_section ON questions (section_id);
`,
},
}

View File

@ -162,6 +162,12 @@ func (s *sqliteStore) UpdateForm(ctx context.Context, form *Form) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM questions WHERE form_id = ?`, form.ID); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM sections WHERE form_id = ?`, form.ID); err != nil {
return err
}
if err := s.saveSections(ctx, tx, form.ID, form.Sections); err != nil {
return err
}
for i := range form.Questions {
form.Questions[i].FormID = form.ID
if err := insertQuestion(ctx, tx, &form.Questions[i]); err != nil {
@ -171,6 +177,20 @@ func (s *sqliteStore) UpdateForm(ctx context.Context, form *Form) error {
return tx.Commit()
}
func (s *sqliteStore) saveSections(ctx context.Context, tx *sql.Tx, formID string, sections []Section) error {
for i, sec := range sections {
if sec.ID == "" {
sec.ID = newID()
}
if _, err := tx.ExecContext(ctx, `INSERT INTO sections (id, form_id, position, identifier, title, description) VALUES (?, ?, ?, ?, ?, ?)`,
sec.ID, formID, i, sec.Identifier, nullString(sec.Title), nullString(sec.Description),
); err != nil {
return err
}
}
return nil
}
func insertForm(ctx context.Context, tx *sql.Tx, form *Form) error {
_, err := tx.ExecContext(ctx, `INSERT INTO forms (
id, form_group_id, version, title, description, introduction, is_active,
@ -208,13 +228,17 @@ func insertQuestion(ctx context.Context, tx *sql.Tx, q *Question) error {
if q.MaxValue != nil {
maxV = sql.NullFloat64{Float64: *q.MaxValue, Valid: true}
}
var sectionID sql.NullString
if q.SectionID != nil && *q.SectionID != "" {
sectionID = sql.NullString{String: *q.SectionID, Valid: true}
}
_, err = tx.ExecContext(ctx, `INSERT INTO questions (
id, form_id, position, question_key, label, description, type, required,
options_json, min_value, max_value, placeholder, multiline
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
options_json, min_value, max_value, placeholder, multiline, section_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
q.ID, q.FormID, q.Position, q.Key, q.Label, nullString(q.Description),
q.Type, boolToInt(q.Required), string(options), minV, maxV,
nullString(q.Placeholder), boolToInt(q.Multiline),
nullString(q.Placeholder), boolToInt(q.Multiline), sectionID,
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE") {
@ -243,7 +267,12 @@ func (s *sqliteStore) GetForm(ctx context.Context, id string) (*Form, error) {
if err != nil {
return nil, err
}
sections, err := s.listSections(ctx, id)
if err != nil {
return nil, err
}
form.Questions = questions
form.Sections = sections
return form, nil
}
@ -288,7 +317,7 @@ func scanForm(row interface{ Scan(...any) error }) (*Form, error) {
func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Question, error) {
rows, err := s.db.QueryContext(ctx, `SELECT
id, form_id, position, question_key, label, description, type, required,
options_json, min_value, max_value, placeholder, multiline
options_json, min_value, max_value, placeholder, multiline, section_id
FROM questions WHERE form_id = ? ORDER BY position`, formID)
if err != nil {
return nil, err
@ -300,15 +329,20 @@ func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Quest
var (
q Question
description, placeholder sql.NullString
optionsJSON string
sectionID sql.NullString
minV, maxV sql.NullFloat64
opts string
required, multiline int
)
if err := rows.Scan(&q.ID, &q.FormID, &q.Position, &q.Key, &q.Label,
&description, &q.Type, &required, &optionsJSON, &minV, &maxV,
&placeholder, &multiline); err != nil {
&description, &q.Type, &required, &opts, &minV, &maxV, &placeholder,
&multiline, &sectionID); err != nil {
return nil, err
}
if sectionID.Valid && sectionID.String != "" {
sid := sectionID.String
q.SectionID = &sid
}
q.Description = fromNullString(description)
q.Placeholder = fromNullString(placeholder)
q.Required = required == 1
@ -321,8 +355,8 @@ func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Quest
v := maxV.Float64
q.MaxValue = &v
}
if optionsJSON != "" && optionsJSON != "null" {
if err := json.Unmarshal([]byte(optionsJSON), &q.Options); err != nil {
if opts != "" && opts != "null" {
if err := json.Unmarshal([]byte(opts), &q.Options); err != nil {
return nil, fmt.Errorf("decode options for question %s: %w", q.ID, err)
}
}
@ -331,6 +365,28 @@ func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Quest
return questions, rows.Err()
}
func (s *sqliteStore) listSections(ctx context.Context, formID string) ([]Section, error) {
rows, err := s.db.QueryContext(ctx, `SELECT id, form_id, position, identifier, title, description FROM sections WHERE form_id = ? ORDER BY position`, formID)
if err != nil {
return nil, err
}
defer rows.Close()
var sections []Section
for rows.Next() {
var (
sec Section
title, description sql.NullString
)
if err := rows.Scan(&sec.ID, &sec.FormID, &sec.Position, &sec.Identifier, &title, &description); err != nil {
return nil, err
}
sec.Title = fromNullString(title)
sec.Description = fromNullString(description)
sections = append(sections, sec)
}
return sections, rows.Err()
}
func (s *sqliteStore) ListForms(ctx context.Context, includeInactive bool) ([]FormSummary, error) {
query := `SELECT f.id, f.form_group_id, f.version, f.title, f.description, f.logo_url,
f.pdf_url, f.external_reference_required, f.external_reference_label,

View File

@ -25,10 +25,21 @@ type QuestionOption struct {
Label string `json:"label"`
}
// Section groups questions within a form.
type Section struct {
ID string
FormID string
Position int
Identifier string
Title *string
Description *string
}
// Question is a single question belonging to a form version.
type Question struct {
ID string
FormID string
SectionID *string
Position int
Key string
Label string
@ -64,6 +75,7 @@ type Form struct {
CreatedAt time.Time
UpdatedAt time.Time
Questions []Question
Sections []Section
}
// FormSummary is a lightweight form listing entry.

View File

@ -584,6 +584,30 @@ components:
type: string
label:
type: string
Section:
type: object
required: [id, identifier]
properties:
id:
type: string
format: uuid
identifier:
type: string
description: Section identifier such as letter or number used to prefix question numbers
title:
type: string
description:
type: string
SectionInput:
type: object
required: [identifier]
properties:
identifier:
type: string
title:
type: string
description:
type: string
Question:
type: object
required: [id, position, key, label, type, required]
@ -621,6 +645,10 @@ components:
multiline:
type: boolean
description: Whether a `text` answer may span multiple lines
sectionId:
type: string
format: uuid
description: Identifier of the section this question belongs to
FormSummary:
type: object
required: [id, formGroupId, version, title, isActive, questionCount, createdAt, updatedAt]
@ -708,6 +736,10 @@ components:
type: array
items:
$ref: '#/components/schemas/Question'
sections:
type: array
items:
$ref: '#/components/schemas/Section'
createdAt:
type: string
format: date-time
@ -740,6 +772,10 @@ components:
type: string
multiline:
type: boolean
sectionId:
type: string
format: uuid
description: Identifier of the section this question belongs to
CreateFormRequest:
type: object
required: [title, questions]
@ -774,6 +810,10 @@ components:
type: array
items:
$ref: '#/components/schemas/QuestionInput'
sections:
type: array
items:
$ref: '#/components/schemas/SectionInput'
UpdateFormRequest:
type: object
required: [title, questions]
@ -808,6 +848,10 @@ components:
type: array
items:
$ref: '#/components/schemas/QuestionInput'
sections:
type: array
items:
$ref: '#/components/schemas/SectionInput'
UserIdType:
type: string
enum: [email, username, phone, foreignkey guid]

View File

@ -63,9 +63,12 @@ the form and once used the code cannot be used again.
The questions are to be presented on one line with question at left and answer options on right,
except for free form text which is input below the question. Questions are to be numbered.
Questions can be grouped together with leading text as a section. A section will
have an identifier such as letter or number. Question within a section will have a new
number that is prefixed by the section identifier.
When submitting form, questions are grouped together by section with each section being prefixed
by the section title and text. A section will have an identifier such as letter or number. Question within
a section will have a new number that is prefixed by the section identifier. Any questions without
a section will be displayed first before questions linked to sections.
The sections are also to be included in the generated PDF.
A form can have a digital signature defined at the end which can capture
a drawn sigature, a picture file with signature, name of person and

View File

@ -5,9 +5,10 @@ interface QuestionFieldProps {
question: Question
value: unknown
onChange: (value: unknown) => void
showLabel?: boolean
}
export function QuestionField({ question, value, onChange }: QuestionFieldProps) {
export function QuestionField({ question, value, onChange, showLabel = true }: QuestionFieldProps) {
const id = `q-${question.id}`
const descriptionId = question.description ? `${id}-description` : undefined
const isText = question.type === 'text'
@ -145,19 +146,21 @@ export function QuestionField({ question, value, onChange }: QuestionFieldProps)
if (isText) {
return (
<div className="space-y-2">
{label}
{description}
{showLabel && label}
{showLabel && description}
{control}
</div>
)
}
return (
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-start">
<div className={showLabel ? "grid gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] sm:items-start" : "space-y-2"}>
{showLabel && (
<div className="space-y-1">
{label}
{description}
</div>
)}
<div>{control}</div>
</div>
)

View File

@ -1,6 +1,6 @@
export type QuestionType = 'boolean' | 'date' | 'number' | 'select' | 'text'
export type UserIdType = 'email' | 'username' | 'phone' | 'foreignkey' | 'guid'
export type UserIdType = 'email' | 'username' | 'phone' | 'foreignkey' | 'guid' | 'foreignkey guid'
export type SignatureType = 'drawn' | 'image'
@ -20,6 +20,19 @@ export interface QuestionOption {
label: string
}
export interface Section {
id: string
identifier: string
title?: string
description?: string
}
export interface SectionInput {
identifier: string
title?: string
description?: string
}
export interface Question {
id: string
key: string
@ -32,6 +45,7 @@ export interface Question {
maxValue?: number
placeholder?: string
multiline?: boolean
sectionId?: string
}
export interface QuestionInput {
@ -45,6 +59,7 @@ export interface QuestionInput {
maxValue?: number
placeholder?: string
multiline?: boolean
sectionId?: string
}
export interface FormSummary {
@ -84,6 +99,7 @@ export interface Form {
createdAt: string
updatedAt: string
questions: Question[]
sections?: Section[]
}
export interface CreateFormRequest {
@ -101,6 +117,7 @@ export interface CreateFormRequest {
contactPhone?: string
contactUrl?: string
questions: QuestionInput[]
sections?: SectionInput[]
}
export type UpdateFormRequest = CreateFormRequest

View File

@ -32,6 +32,7 @@ const userIdTypes: Array<{ value: UserIdType; label: string }> = [
{ value: 'phone', label: 'Phone number' },
{ value: 'foreignkey', label: 'Provided key' },
{ value: 'guid', label: 'GUID' },
{ value: 'foreignkey guid', label: 'Foreign key or GUID' },
]
function isBlank(value: unknown): boolean {
@ -67,12 +68,13 @@ export function FillFormPage() {
const [pdfError, setPdfError] = useState<string | null>(null)
// Map option values to preferred placeholder texts
const placeholders = {
const placeholders: Record<string, string> = {
email: "Enter your email (e.g., name@example.com)",
phone: "Enter your phone number (e.g., +61403555012)",
foreignkey: "Use your supplied key",
username: "Enter your handle (e.g., @username)",
guid: "Enter your identifier"
guid: "Enter your identifier",
'foreignkey guid': "Enter your identifier"
};
useEffect(() => {
@ -547,17 +549,99 @@ export function FillFormPage() {
</button>
</div>
{form.questions.map((question) => (
{(() => {
const sections = [...(form.sections ?? [])]
const sectionMap = new Map(sections.map((s) => [s.id, s]))
const sectionOrder = sections.map((s) => s.id)
const counters = new Map<string, number>()
const unsectioned = form.questions.filter((q) => !q.sectionId)
const sectioned: Array<[string, typeof form.questions]> = []
for (const sid of sectionOrder) {
const qs = form.questions.filter((q) => q.sectionId === sid)
if (qs.length > 0) sectioned.push([sid, qs])
}
// also include any sectioned not in order list
for (const q of form.questions) {
if (q.sectionId && !sectionOrder.includes(q.sectionId)) {
const existing = sectioned.find(([, qs]) => qs.some((x) => x.id === q.id))
if (!existing) {
// group separately
const sid = q.sectionId
const qs = form.questions.filter((x) => x.sectionId === sid)
if (!sectioned.some(([id]) => id === sid)) sectioned.push([sid, qs])
}
}
}
const elements: JSX.Element[] = []
for (const q of unsectioned) {
const globalKey = '__global__'
const next = (counters.get(globalKey) ?? 0) + 1
counters.set(globalKey, next)
elements.push(
<div key={q.id} className="card">
<div className="mb-2 text-sm font-semibold text-slate-900 dark:text-slate-100">
{next}. {q.label}
{q.required && (
<span className="ml-1 text-red-600 dark:text-red-400" aria-label="required">
*
</span>
)}
</div>
{q.description && <p className="mb-2 text-sm text-slate-500 dark:text-slate-400">{q.description}</p>}
<QuestionField
question={q}
value={answers[q.id]}
onChange={(value) => setAnswers((current) => ({ ...current, [q.id]: value }))}
showLabel={false}
/>
</div>,
)
}
for (const [sid, qs] of sectioned) {
const sec = sectionMap.get(sid)
elements.push(
<section key={sid} className="space-y-4">
<header className="card bg-slate-50/80 dark:bg-slate-900/50">
<div className="flex items-baseline gap-2">
{sec?.identifier && <span className="text-lg font-bold">{sec.identifier}</span>}
{sec?.title && <h2 className="text-lg font-semibold">{sec.title}</h2>}
</div>
{sec?.description && <p className="mt-1 text-sm text-slate-600 dark:text-slate-300 whitespace-pre-line">{sec.description}</p>}
</header>
{qs.map((question) => {
const next = (counters.get(sid) ?? 0) + 1
counters.set(sid, next)
const prefix = sec?.identifier ? `${sec.identifier}${next}. ` : `${next}. `
return (
<div key={question.id} className="card">
<div className="mb-2 text-sm font-semibold text-slate-900 dark:text-slate-100">
{prefix}
{question.label}
{question.required && (
<span className="ml-1 text-red-600 dark:text-red-400" aria-label="required">
*
</span>
)}
</div>
{question.description && (
<p className="mb-2 text-sm text-slate-500 dark:text-slate-400">{question.description}</p>
)}
<QuestionField
question={question}
value={answers[question.id]}
onChange={(value) =>
setAnswers((current) => ({ ...current, [question.id]: value }))
}
onChange={(value) => setAnswers((current) => ({ ...current, [question.id]: value }))}
showLabel={false}
/>
</div>
))}
)
})}
</section>,
)
}
return elements
})()}
{form.signatureRequired && (
<section className="card space-y-4">

View File

@ -57,6 +57,7 @@ export function FormBuilderPage() {
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(() => {
@ -79,6 +80,14 @@ export function FormBuilderPage() {
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,
@ -91,6 +100,7 @@ export function FormBuilderPage() {
maxValue: q.maxValue,
placeholder: q.placeholder,
multiline: q.multiline,
sectionId: q.sectionId,
})),
)
})
@ -158,6 +168,25 @@ export function FormBuilderPage() {
})
}
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)
@ -184,6 +213,13 @@ export function FormBuilderPage() {
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(),
@ -192,6 +228,7 @@ export function FormBuilderPage() {
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
@ -394,15 +431,55 @@ export function FormBuilderPage() {
)}
</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>
<button type="button" className="btn-secondary" onClick={addQuestion}>
Add question
</button>
</div>
<div className="space-y-4">
@ -412,6 +489,21 @@ export function FormBuilderPage() {
<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)}>
@ -568,7 +660,25 @@ export function FormBuilderPage() {
</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>
)
}