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

@ -51,11 +51,10 @@ const (
// Defines values for UserIdType. // Defines values for UserIdType.
const ( const (
Email UserIdType = "email" Email UserIdType = "email"
Guid UserIdType = "guid" ForeignkeyGuid UserIdType = "foreignkey guid"
Phone UserIdType = "phone" Phone UserIdType = "phone"
Username UserIdType = "username" Username UserIdType = "username"
ForeignKey UserIdType = "foreignkey"
) )
// AccessCode defines model for AccessCode. // AccessCode defines model for AccessCode.
@ -139,6 +138,7 @@ type CreateFormRequest struct {
PdfUrl *string `json:"pdfUrl,omitempty"` PdfUrl *string `json:"pdfUrl,omitempty"`
Questions []QuestionInput `json:"questions"` Questions []QuestionInput `json:"questions"`
RequiresAccessCode *bool `json:"requiresAccessCode,omitempty"` RequiresAccessCode *bool `json:"requiresAccessCode,omitempty"`
Sections *[]SectionInput `json:"sections,omitempty"`
SignatureRequired *bool `json:"signatureRequired,omitempty"` SignatureRequired *bool `json:"signatureRequired,omitempty"`
Title string `json:"title"` Title string `json:"title"`
} }
@ -175,6 +175,7 @@ type Form struct {
PdfUrl *string `json:"pdfUrl,omitempty"` PdfUrl *string `json:"pdfUrl,omitempty"`
Questions []Question `json:"questions"` Questions []Question `json:"questions"`
RequiresAccessCode bool `json:"requiresAccessCode"` RequiresAccessCode bool `json:"requiresAccessCode"`
Sections *[]Section `json:"sections,omitempty"`
// SignatureRequired Whether a digital signature is captured at the end of the form // SignatureRequired Whether a digital signature is captured at the end of the form
SignatureRequired *bool `json:"signatureRequired,omitempty"` SignatureRequired *bool `json:"signatureRequired,omitempty"`
@ -261,6 +262,9 @@ type Question struct {
Position int `json:"position"` Position int `json:"position"`
Required bool `json:"required"` Required bool `json:"required"`
// SectionId Identifier of the section this question belongs to
SectionId *openapi_types.UUID `json:"sectionId,omitempty"`
// Type * `boolean` – true/false // Type * `boolean` – true/false
// * `number` – numeric answer // * `number` – numeric answer
// * `select` – one option from a configured list // * `select` – one option from a configured list
@ -281,6 +285,9 @@ type QuestionInput struct {
Placeholder *string `json:"placeholder,omitempty"` Placeholder *string `json:"placeholder,omitempty"`
Required bool `json:"required"` Required bool `json:"required"`
// SectionId Identifier of the section this question belongs to
SectionId *openapi_types.UUID `json:"sectionId,omitempty"`
// Type * `boolean` – true/false // Type * `boolean` – true/false
// * `number` – numeric answer // * `number` – numeric answer
// * `select` – one option from a configured list // * `select` – one option from a configured list
@ -302,6 +309,23 @@ type QuestionOption struct {
// * `date` - calendar date in `YYYY-MM-DD` form // * `date` - calendar date in `YYYY-MM-DD` form
type QuestionType string 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. // Signature defines model for Signature.
type Signature struct { type Signature struct {
DataUrl string `json:"dataUrl"` DataUrl string `json:"dataUrl"`
@ -412,6 +436,7 @@ type UpdateFormRequest struct {
PdfUrl *string `json:"pdfUrl,omitempty"` PdfUrl *string `json:"pdfUrl,omitempty"`
Questions []QuestionInput `json:"questions"` Questions []QuestionInput `json:"questions"`
RequiresAccessCode *bool `json:"requiresAccessCode,omitempty"` RequiresAccessCode *bool `json:"requiresAccessCode,omitempty"`
Sections *[]SectionInput `json:"sections,omitempty"`
SignatureRequired *bool `json:"signatureRequired,omitempty"` SignatureRequired *bool `json:"signatureRequired,omitempty"`
Title string `json:"title"` Title string `json:"title"`
} }
@ -2354,77 +2379,80 @@ func (sh *strictHandler) GetSubmissionOfflinePdf(ctx *gin.Context, submissionId
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
"AAA=",
} }
// GetSwagger returns the content of the embedded swagger specification file // 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 { for _, q := range f.Questions {
questions = append(questions, toAPIQuestion(q)) 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{ return Form{
Id: uuid.MustParse(f.ID), Id: uuid.MustParse(f.ID),
FormGroupId: uuid.MustParse(f.FormGroupID), FormGroupId: uuid.MustParse(f.FormGroupID),
@ -34,6 +49,7 @@ func toAPIForm(f *database.Form) Form {
CreatedAt: f.CreatedAt, CreatedAt: f.CreatedAt,
UpdatedAt: f.UpdatedAt, UpdatedAt: f.UpdatedAt,
Questions: questions, Questions: questions,
Sections: sections,
} }
} }
@ -48,6 +64,10 @@ func toAPIQuestion(q database.Question) Question {
Placeholder: q.Placeholder, Placeholder: q.Placeholder,
Multiline: boolPtrIfSet(q.Multiline, QuestionType(q.Type) == Text), 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 { if q.MinValue != nil {
v := float32(*q.MinValue) v := float32(*q.MinValue)
out.MinValue = &v out.MinValue = &v
@ -180,6 +200,10 @@ func questionInputsToRecords(inputs []QuestionInput) []database.Question {
Required: q.Required, Required: q.Required,
Placeholder: q.Placeholder, Placeholder: q.Placeholder,
} }
if q.SectionId != nil {
sid := q.SectionId.String()
rec.SectionID = &sid
}
if q.MinValue != nil { if q.MinValue != nil {
v := float64(*q.MinValue) v := float64(*q.MinValue)
rec.MinValue = &v rec.MinValue = &v

View File

@ -140,10 +140,35 @@ func (s *Server) CreateForm(ctx context.Context, req CreateFormRequestObject) (C
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
Questions: questionInputsToRecords(req.Body.Questions), 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 { for i := range form.Questions {
form.Questions[i].ID = uuid.NewString() form.Questions[i].ID = uuid.NewString()
form.Questions[i].FormID = form.ID 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 { 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, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
Questions: questionInputsToRecords(req.Body.Questions), Questions: questionInputsToRecords(req.Body.Questions),
Sections: sectionsFromInputs(req.Body.Sections),
} }
if req.Body.RequiresAccessCode != nil { if req.Body.RequiresAccessCode != nil {
form.RequiresAccessCode = *req.Body.RequiresAccessCode form.RequiresAccessCode = *req.Body.RequiresAccessCode
@ -247,9 +273,31 @@ func (s *Server) UpdateForm(ctx context.Context, req UpdateFormRequestObject) (U
if req.Body.SignatureRequired != nil { if req.Body.SignatureRequired != nil {
form.SignatureRequired = *req.Body.SignatureRequired 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 { for i := range form.Questions {
form.Questions[i].ID = uuid.NewString() form.Questions[i].ID = uuid.NewString()
form.Questions[i].FormID = form.ID 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 { 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 { func validUserIdType(t UserIdType) bool {
switch t { switch t {
case Email, Username, Phone, ForeignKey, Guid: case Email, Username, Phone, ForeignkeyGuid:
return true return true
default: default:
return false 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_required INTEGER NOT NULL DEFAULT 0;
ALTER TABLE forms ADD COLUMN external_reference_label TEXT; ALTER TABLE forms ADD COLUMN external_reference_label TEXT;
ALTER TABLE submissions ADD COLUMN external_reference 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

@ -159,9 +159,15 @@ func (s *sqliteStore) UpdateForm(ctx context.Context, form *Form) error {
return ErrNotFound return ErrNotFound
} }
if _, err := tx.ExecContext(ctx, `DELETE FROM questions WHERE form_id = ?`, form.ID); err != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM questions WHERE form_id = ?`, form.ID); err != nil {
return err 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 { for i := range form.Questions {
form.Questions[i].FormID = form.ID form.Questions[i].FormID = form.ID
if err := insertQuestion(ctx, tx, &form.Questions[i]); err != nil { 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() 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 { func insertForm(ctx context.Context, tx *sql.Tx, form *Form) error {
_, err := tx.ExecContext(ctx, `INSERT INTO forms ( _, err := tx.ExecContext(ctx, `INSERT INTO forms (
id, form_group_id, version, title, description, introduction, is_active, 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 { if q.MaxValue != nil {
maxV = sql.NullFloat64{Float64: *q.MaxValue, Valid: true} 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 ( _, err = tx.ExecContext(ctx, `INSERT INTO questions (
id, form_id, position, question_key, label, description, type, required, 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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
q.ID, q.FormID, q.Position, q.Key, q.Label, nullString(q.Description), q.ID, q.FormID, q.Position, q.Key, q.Label, nullString(q.Description),
q.Type, boolToInt(q.Required), string(options), minV, maxV, 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 err != nil {
if strings.Contains(err.Error(), "UNIQUE") { if strings.Contains(err.Error(), "UNIQUE") {
@ -243,7 +267,12 @@ func (s *sqliteStore) GetForm(ctx context.Context, id string) (*Form, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
sections, err := s.listSections(ctx, id)
if err != nil {
return nil, err
}
form.Questions = questions form.Questions = questions
form.Sections = sections
return form, nil 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) { func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Question, error) {
rows, err := s.db.QueryContext(ctx, `SELECT rows, err := s.db.QueryContext(ctx, `SELECT
id, form_id, position, question_key, label, description, type, required, 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) FROM questions WHERE form_id = ? ORDER BY position`, formID)
if err != nil { if err != nil {
return nil, err return nil, err
@ -300,15 +329,20 @@ func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Quest
var ( var (
q Question q Question
description, placeholder sql.NullString description, placeholder sql.NullString
optionsJSON string sectionID sql.NullString
minV, maxV sql.NullFloat64 minV, maxV sql.NullFloat64
opts string
required, multiline int required, multiline int
) )
if err := rows.Scan(&q.ID, &q.FormID, &q.Position, &q.Key, &q.Label, if err := rows.Scan(&q.ID, &q.FormID, &q.Position, &q.Key, &q.Label,
&description, &q.Type, &required, &optionsJSON, &minV, &maxV, &description, &q.Type, &required, &opts, &minV, &maxV, &placeholder,
&placeholder, &multiline); err != nil { &multiline, &sectionID); err != nil {
return nil, err return nil, err
} }
if sectionID.Valid && sectionID.String != "" {
sid := sectionID.String
q.SectionID = &sid
}
q.Description = fromNullString(description) q.Description = fromNullString(description)
q.Placeholder = fromNullString(placeholder) q.Placeholder = fromNullString(placeholder)
q.Required = required == 1 q.Required = required == 1
@ -321,8 +355,8 @@ func (s *sqliteStore) listQuestions(ctx context.Context, formID string) ([]Quest
v := maxV.Float64 v := maxV.Float64
q.MaxValue = &v q.MaxValue = &v
} }
if optionsJSON != "" && optionsJSON != "null" { if opts != "" && opts != "null" {
if err := json.Unmarshal([]byte(optionsJSON), &q.Options); err != nil { if err := json.Unmarshal([]byte(opts), &q.Options); err != nil {
return nil, fmt.Errorf("decode options for question %s: %w", q.ID, err) 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() 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) { 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, 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, f.pdf_url, f.external_reference_required, f.external_reference_label,

View File

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

View File

@ -584,6 +584,30 @@ components:
type: string type: string
label: label:
type: string 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: Question:
type: object type: object
required: [id, position, key, label, type, required] required: [id, position, key, label, type, required]
@ -621,6 +645,10 @@ components:
multiline: multiline:
type: boolean type: boolean
description: Whether a `text` answer may span multiple lines description: Whether a `text` answer may span multiple lines
sectionId:
type: string
format: uuid
description: Identifier of the section this question belongs to
FormSummary: FormSummary:
type: object type: object
required: [id, formGroupId, version, title, isActive, questionCount, createdAt, updatedAt] required: [id, formGroupId, version, title, isActive, questionCount, createdAt, updatedAt]
@ -708,6 +736,10 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/Question' $ref: '#/components/schemas/Question'
sections:
type: array
items:
$ref: '#/components/schemas/Section'
createdAt: createdAt:
type: string type: string
format: date-time format: date-time
@ -740,6 +772,10 @@ components:
type: string type: string
multiline: multiline:
type: boolean type: boolean
sectionId:
type: string
format: uuid
description: Identifier of the section this question belongs to
CreateFormRequest: CreateFormRequest:
type: object type: object
required: [title, questions] required: [title, questions]
@ -774,6 +810,10 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/QuestionInput' $ref: '#/components/schemas/QuestionInput'
sections:
type: array
items:
$ref: '#/components/schemas/SectionInput'
UpdateFormRequest: UpdateFormRequest:
type: object type: object
required: [title, questions] required: [title, questions]
@ -808,6 +848,10 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/QuestionInput' $ref: '#/components/schemas/QuestionInput'
sections:
type: array
items:
$ref: '#/components/schemas/SectionInput'
UserIdType: UserIdType:
type: string type: string
enum: [email, username, phone, foreignkey guid] 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, 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. 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 When submitting form, questions are grouped together by section with each section being prefixed
have an identifier such as letter or number. Question within a section will have a new by the section title and text. A section will have an identifier such as letter or number. Question within
number that is prefixed by the section identifier. 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 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 a drawn sigature, a picture file with signature, name of person and

View File

@ -5,9 +5,10 @@ interface QuestionFieldProps {
question: Question question: Question
value: unknown value: unknown
onChange: (value: unknown) => void 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 id = `q-${question.id}`
const descriptionId = question.description ? `${id}-description` : undefined const descriptionId = question.description ? `${id}-description` : undefined
const isText = question.type === 'text' const isText = question.type === 'text'
@ -145,19 +146,21 @@ export function QuestionField({ question, value, onChange }: QuestionFieldProps)
if (isText) { if (isText) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{label} {showLabel && label}
{description} {showLabel && description}
{control} {control}
</div> </div>
) )
} }
return ( 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"}>
<div className="space-y-1"> {showLabel && (
{label} <div className="space-y-1">
{description} {label}
</div> {description}
</div>
)}
<div>{control}</div> <div>{control}</div>
</div> </div>
) )

View File

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

View File

@ -32,6 +32,7 @@ const userIdTypes: Array<{ value: UserIdType; label: string }> = [
{ value: 'phone', label: 'Phone number' }, { value: 'phone', label: 'Phone number' },
{ value: 'foreignkey', label: 'Provided key' }, { value: 'foreignkey', label: 'Provided key' },
{ value: 'guid', label: 'GUID' }, { value: 'guid', label: 'GUID' },
{ value: 'foreignkey guid', label: 'Foreign key or GUID' },
] ]
function isBlank(value: unknown): boolean { function isBlank(value: unknown): boolean {
@ -67,12 +68,13 @@ export function FillFormPage() {
const [pdfError, setPdfError] = useState<string | null>(null) const [pdfError, setPdfError] = useState<string | null>(null)
// Map option values to preferred placeholder texts // Map option values to preferred placeholder texts
const placeholders = { const placeholders: Record<string, string> = {
email: "Enter your email (e.g., name@example.com)", email: "Enter your email (e.g., name@example.com)",
phone: "Enter your phone number (e.g., +61403555012)", phone: "Enter your phone number (e.g., +61403555012)",
foreignkey: "Use your supplied key", foreignkey: "Use your supplied key",
username: "Enter your handle (e.g., @username)", username: "Enter your handle (e.g., @username)",
guid: "Enter your identifier" guid: "Enter your identifier",
'foreignkey guid': "Enter your identifier"
}; };
useEffect(() => { useEffect(() => {
@ -547,17 +549,99 @@ export function FillFormPage() {
</button> </button>
</div> </div>
{form.questions.map((question) => ( {(() => {
<div key={question.id} className="card"> const sections = [...(form.sections ?? [])]
<QuestionField const sectionMap = new Map(sections.map((s) => [s.id, s]))
question={question} const sectionOrder = sections.map((s) => s.id)
value={answers[question.id]} const counters = new Map<string, number>()
onChange={(value) =>
setAnswers((current) => ({ ...current, [question.id]: value })) 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])
} }
/> }
</div> }
))}
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 }))}
showLabel={false}
/>
</div>
)
})}
</section>,
)
}
return elements
})()}
{form.signatureRequired && ( {form.signatureRequired && (
<section className="card space-y-4"> <section className="card space-y-4">

View File

@ -57,6 +57,7 @@ export function FormBuilderPage() {
const [contactEmail, setContactEmail] = useState('') const [contactEmail, setContactEmail] = useState('')
const [contactPhone, setContactPhone] = useState('') const [contactPhone, setContactPhone] = useState('')
const [contactUrl, setContactUrl] = useState('') const [contactUrl, setContactUrl] = useState('')
const [sections, setSections] = useState<Array<{ id: string; identifier: string; title: string; description: string }>>([])
const [questions, setQuestions] = useState<QuestionInput[]>([emptyQuestion()]) const [questions, setQuestions] = useState<QuestionInput[]>([emptyQuestion()])
useEffect(() => { useEffect(() => {
@ -79,6 +80,14 @@ export function FormBuilderPage() {
setContactEmail(form.contactEmail ?? '') setContactEmail(form.contactEmail ?? '')
setContactPhone(form.contactPhone ?? '') setContactPhone(form.contactPhone ?? '')
setContactUrl(form.contactUrl ?? '') setContactUrl(form.contactUrl ?? '')
setSections(
(form.sections ?? []).map((s) => ({
id: s.id,
identifier: s.identifier ?? '',
title: s.title ?? '',
description: s.description ?? '',
})),
)
setQuestions( setQuestions(
form.questions.map((q) => ({ form.questions.map((q) => ({
key: q.key, key: q.key,
@ -91,6 +100,7 @@ export function FormBuilderPage() {
maxValue: q.maxValue, maxValue: q.maxValue,
placeholder: q.placeholder, placeholder: q.placeholder,
multiline: q.multiline, 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) { async function save(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
setError(null) setError(null)
@ -184,6 +213,13 @@ export function FormBuilderPage() {
contactEmail: contactEmail.trim() || undefined, contactEmail: contactEmail.trim() || undefined,
contactPhone: contactPhone.trim() || undefined, contactPhone: contactPhone.trim() || undefined,
contactUrl: contactUrl.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) => { questions: questions.map((q) => {
const out: QuestionInput = { const out: QuestionInput = {
key: q.key.trim(), key: q.key.trim(),
@ -192,6 +228,7 @@ export function FormBuilderPage() {
required: q.required, required: q.required,
description: q.description?.trim() || undefined, description: q.description?.trim() || undefined,
placeholder: q.placeholder?.trim() || undefined, placeholder: q.placeholder?.trim() || undefined,
sectionId: q.sectionId || undefined,
} }
if (q.type === 'number') { if (q.type === 'number') {
out.minValue = q.minValue out.minValue = q.minValue
@ -394,15 +431,55 @@ export function FormBuilderPage() {
)} )}
</section> </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"> <section className="card space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold">Questions</h2> <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> <p className="text-sm text-slate-500 dark:text-slate-400">Questions may not have formatting directives.</p>
</div> </div>
<button type="button" className="btn-secondary" onClick={addQuestion}>
Add question
</button>
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@ -412,6 +489,21 @@ export function FormBuilderPage() {
<div> <div>
<p className="text-sm font-semibold">Question {index + 1}</p> <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> <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>
<div className="flex gap-2"> <div className="flex gap-2">
<button type="button" className="btn-secondary py-1 text-xs" onClick={() => suggestKey(index)}> <button type="button" className="btn-secondary py-1 text-xs" onClick={() => suggestKey(index)}>
@ -568,7 +660,25 @@ export function FormBuilderPage() {
</article> </article>
))} ))}
</div> </div>
<div>
<button type="button" className="btn-secondary" onClick={addQuestion}>
Add question
</button>
</div>
</section> </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> </form>
) )
} }