diff --git a/backend/internal/api/generated.go b/backend/internal/api/generated.go index 6991adf..74f4955 100644 --- a/backend/internal/api/generated.go +++ b/backend/internal/api/generated.go @@ -51,11 +51,10 @@ const ( // Defines values for UserIdType. const ( - Email UserIdType = "email" - Guid UserIdType = "guid" - Phone UserIdType = "phone" - Username UserIdType = "username" - ForeignKey UserIdType = "foreignkey" + Email UserIdType = "email" + ForeignkeyGuid UserIdType = "foreignkey guid" + Phone UserIdType = "phone" + Username UserIdType = "username" ) // 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 diff --git a/backend/internal/api/mapping.go b/backend/internal/api/mapping.go index b2b7028..144c23e 100644 --- a/backend/internal/api/mapping.go +++ b/backend/internal/api/mapping.go @@ -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 diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index cee0422..4820b20 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -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 diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 90f017b..1dca65e 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -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); `, }, } diff --git a/backend/internal/database/sqlite.go b/backend/internal/database/sqlite.go index 273823f..f2d8a73 100644 --- a/backend/internal/database/sqlite.go +++ b/backend/internal/database/sqlite.go @@ -159,9 +159,15 @@ func (s *sqliteStore) UpdateForm(ctx context.Context, form *Form) error { return ErrNotFound } - if _, err := tx.ExecContext(ctx, `DELETE FROM questions WHERE form_id = ?`, form.ID); err != nil { - return err - } + 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, §ionID); 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, diff --git a/backend/internal/database/store.go b/backend/internal/database/store.go index c5ae807..8211f3f 100644 --- a/backend/internal/database/store.go +++ b/backend/internal/database/store.go @@ -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. diff --git a/backend/openapi/openapi.yaml b/backend/openapi/openapi.yaml index fac0a32..e9688d0 100644 --- a/backend/openapi/openapi.yaml +++ b/backend/openapi/openapi.yaml @@ -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] diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 3ad7c07..8821fcb 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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 diff --git a/frontend/src/components/QuestionField.tsx b/frontend/src/components/QuestionField.tsx index 012b9db..83292fd 100644 --- a/frontend/src/components/QuestionField.tsx +++ b/frontend/src/components/QuestionField.tsx @@ -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 (
- {label} - {description} + {showLabel && label} + {showLabel && description} {control}
) } return ( -
-
- {label} - {description} -
+
+ {showLabel && ( +
+ {label} + {description} +
+ )}
{control}
) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e6b9674..0a7121e 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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 diff --git a/frontend/src/pages/FillFormPage.tsx b/frontend/src/pages/FillFormPage.tsx index 3be1cec..7526f57 100644 --- a/frontend/src/pages/FillFormPage.tsx +++ b/frontend/src/pages/FillFormPage.tsx @@ -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(null) // Map option values to preferred placeholder texts - const placeholders = { + const placeholders: Record = { 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() {
- {form.questions.map((question) => ( -
- - setAnswers((current) => ({ ...current, [question.id]: value })) + {(() => { + 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() + + 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( +
+
+ {next}. {q.label} + {q.required && ( + + * + + )} +
+ {q.description &&

{q.description}

} + setAnswers((current) => ({ ...current, [q.id]: value }))} + showLabel={false} + /> +
, + ) + } + for (const [sid, qs] of sectioned) { + const sec = sectionMap.get(sid) + elements.push( +
+
+
+ {sec?.identifier && {sec.identifier}} + {sec?.title &&

{sec.title}

} +
+ {sec?.description &&

{sec.description}

} +
+ {qs.map((question) => { + const next = (counters.get(sid) ?? 0) + 1 + counters.set(sid, next) + const prefix = sec?.identifier ? `${sec.identifier}${next}. ` : `${next}. ` + return ( +
+
+ {prefix} + {question.label} + {question.required && ( + + * + + )} +
+ {question.description && ( +

{question.description}

+ )} + setAnswers((current) => ({ ...current, [question.id]: value }))} + showLabel={false} + /> +
+ ) + })} +
, + ) + } + return elements + })()} {form.signatureRequired && (
diff --git a/frontend/src/pages/FormBuilderPage.tsx b/frontend/src/pages/FormBuilderPage.tsx index 7b7c61e..255c3c5 100644 --- a/frontend/src/pages/FormBuilderPage.tsx +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -57,6 +57,7 @@ export function FormBuilderPage() { const [contactEmail, setContactEmail] = useState('') const [contactPhone, setContactPhone] = useState('') const [contactUrl, setContactUrl] = useState('') + const [sections, setSections] = useState>([]) const [questions, setQuestions] = useState([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() { )}
+
+
+
+

Sections

+

+ Group questions with leading text; questions in a section get numbers prefixed by the section identifier. +

+
+
+
+ {sections.length === 0 &&

No sections defined.

} + {sections.map((section, index) => ( +
+
+

Section {index + 1}

+ +
+
+
+ + updateSection(index, { identifier: e.target.value })} placeholder="e.g. A or 1" /> +
+
+ + updateSection(index, { title: e.target.value })} placeholder="Section heading" /> +
+
+
+ +