Section {index + 1}
+ +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 (
{q.description}
} +{sec.description}
} +{question.description}
+ )} ++ Group questions with leading text; questions in a section get numbers prefixed by the section identifier. +
+No sections defined.
} + {sections.map((section, index) => ( +Section {index + 1}
+ +Questions may not have formatting directives.
Question {index + 1}
Key used to store answers
+