Added sections
parent
a60e75b5c6
commit
a6e97a2e9c
|
|
@ -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{
|
||||
|
||||
"H4sIAAAAAAAC/+w8627ktnqvQqj9sbuQZ7znbA9OHRSoY6+3k+zFXXsTBLGB4UjfzPBYIhWSGnsSGOg7",
|
||||
"9DH6HH2RPknxkZREjSh5xms7DpBf9ki8fPzuN+q3KBF5IThwraKD36KCSpqDBml+nQiZT1L8j/HoICqo",
|
||||
"XkZxxGkO0UE0ty/jSMIvJZOQRgdalhBHKllCTnEWDqE6OojKkuFIvS5wptKS8UV0e3uLk1UhuAKz31sp",
|
||||
"hcR/EsE1cI3/0qLIWEI1E3z8DyU4Pmt2+GcJ8+gg+qdxc4yxfavGdjWzSwoqkazARaIDuw2pdo5wgJuD",
|
||||
"Sx4mCSh1JFIwCJGiAKmZBTBxTzcOEkeJBKohPdStY6dUw55mOXTPHlf4uxtNccS2G1YqSD3oZkJkQHn1",
|
||||
"ZnvYbn2a/hyZzWpqGxS4vfxzX9bLiNk/ING47SFX1yC7aPylBIW02PL4K5qVBu1tMtrViXk7ImdmeEx4",
|
||||
"mc9AxsSdnghJMqY0EXNiV1QkhQJ4yviCCE70EkgFD8GtRx0EeOBWwPSf9jMkQqZffeZq+PewDjJcD07O",
|
||||
"l0ASWuhSQmoxM3waf5vBs5V6eST4nC26J8udUAAvc9ygoEpdIw7iSLA08dZrwDcv7pDfT5Pjo6OMAddu",
|
||||
"5+YgZ+UsZ0oxwRGwEM9vHDq3XBue3nfgD/C50hGdQ9NSL4Fr1EwDQifvOiNu8wXHbQLcXr8Pwi8qJF4J",
|
||||
"5YdpzngYrITyHxhch19CTlnWYlD7pFcpdR5b6xB4IUVmwWMachUc4h5QKek6rIbsInFzxOY8IRxZ9jmX",
|
||||
"NLnCLTriYt8TVaKRgZRoN5KkoCnL1IhMTgnlKUFaErrAwVR6IqZArkDuKZbCKIo36JCJhGZhZBQZTXrQ",
|
||||
"BHOQlqpd/LAcfhU8NPE2dHqjnBt7pj6DkfeQXSuttc3pDctRjF/v78dRzrj7Va/OuIYF8iseFubsJgyL",
|
||||
"Tzi7+GUvgOhlDECWF5SvP/ZxFXoKNNFvK77tG3C6DOOtHvBFhue3GCbwHm40SE6zz4ZuPIH3dAbZdkM/",
|
||||
"11gKySLjWoq0THq3zsRC9IFdpPO+V5XObwvjkJb6z8pk8KLUXUGtya3avlP3SIotOEXJGT65ZjqDuxnL",
|
||||
"DvMPFGKy2qnc0pWDavzw5nZYaEPk52fIyPdwUe/P+201ax4TtRTXnMyFND5XNZfIanJs3sENzYsMyISv",
|
||||
"BEvA+XMh6AbFqQ3Aj0vQS9TgzW6EpWhf5wwkYYpUpCUzmAsJBkREEkkoJzMgCl0GrcFz1jyGxZHvpCiL",
|
||||
"h3XoNzVA+1AT7y1RYP/CTZFRxtGGFaUshIKYzGhytZCi5KmxZWjthNSVjQturA4TzVY9Uryd3mkD+8n8",
|
||||
"QzNCZ0pkpQby5fN7dMopKSTMSpZpcnp8YnGeMX6F1lgQvWTor88ZZ2ah+CHV2YNrsj6mS9mCaZqRehJy",
|
||||
"XO1DUG0FgqeIj4rxgnzWpxjjqCzSXaV7BVK1Jbs27n0RYMXjzeS41sI1zwSR6NPJ10U+5H3K9KzMcyrX",
|
||||
"AZ36p0p7KpXW3ty8UIRmGXGMoCreVTQHkokFS2hWMfKD6cM/oFo6qlzrrgP9B5Hm9kl2Ed7/AJrZ8Lwt",
|
||||
"t0pTXZr/nGjgrKtgSqvA855BIniqWshgXP/tTRSKSzxMNMu/Hu2P9u9MdTnAmjVCp3ovFoz3Bix19sMH",
|
||||
"1kuJDANQDxzYuC8locUVhJXaV2Ui7LJukRBYnTxNMFkiJPvVZHHf8rQQjLdZu5QsRP7ELDsJZxqYUiVI",
|
||||
"J9Z3raQSUeyWfojtybcHd1PMavDiHgRs7uCdtwY4hPDaf+kg+i7rtqWevbJJx7aqPNN0lgHJabJkHMgV",
|
||||
"rJ3hY6pJoF4zvWQ84MR4UWvYoJ638rBwo8mLnK7RTuVlplnGOMSEC2KB1yZJwyQYJaVehjbK6c0P4Szp",
|
||||
"B5vlQPslrqtEqTnN1NrmKfG9FbeyM9u3Ji/St7LNmNx/5eqsQwZ/iuiZEmrz34glVVBu8YR+Bs5XQaMu",
|
||||
"itpTbi9+KkEB1xZSBRkkug3pTo61taohkTJ5r6XI0p4MVyEU0z1mzBevYNbAPNkOwnMcGzSMNQRWDCp+",
|
||||
"dct7E4Yk0yZKdhbPq55kf9abUvJ5fJBNhzltkFOeiPSPTNz7E/NTEVa2/VRZbWC9x0LYYRVQQyCcu9O3",
|
||||
"hfYVmToMTcn//dd/Ey1LGM9ppuCCv2oUDr7iZQ6SJU5nmNeVlONrwYFYipO5FDmhJDGm3MSnGVPazLBq",
|
||||
"B8fPJcCe8YmtplaMLzIgQjbK+qWZgt7hlOyRhGbAUyoJPiCMk+lPP/30096HD3vHx1Oj0y9Q4qoSUkX4",
|
||||
"OKpjJQsukg5u8A8uFCwtnVVBdkD+qKZ9EUJv3QKD9t1c8X6/fhsuruEPsrFj3OokDm4PyhAb1Uv26aUG",
|
||||
"Lxtm4eM7JOp3p2/fEZbTBSD9qImRXrhSAUkhESmkRLFfgfyFfPg2aIwr9LbX/4jBogscC5BKcJMjwTlD",
|
||||
"eO2J4cz7HZZ7JGIMUqBXklNJrzmKSpMkMo+Ic6dmUlwrJ7qGFDi2LDJBEfmWOCYDbJN/tFmnJVlmTbTn",
|
||||
"OCEsP3VxNODIG/WxvVVolcQDNqGT7whKzQ6dEjjgh/5IeGsPWMznqMRO0/nHIONW/tL1Eix5bDdJis9q",
|
||||
"oqAWxfBTQ0rcguT0+CQYnvhKayuGNLOqDM5O2smrhg7tVFdNXQDZE4XZV+dbyNKXZuRwi4lPxHrv1k5x",
|
||||
"zYltJARFr+bn3jzis2XDexH4+RFrKxLpwTowbeXj28JYJUkbaTSuyZIqMu2mosm/GUdpGkLd/fTb1ppt",
|
||||
"ow2tky72E7ilsqnGjPErG2RXDWv4lBJplCph/IJTLkxkqNZKQ97OOVM0ICbtHBOamFI8WvRCZCxZu0z0",
|
||||
"aFgDdiH/lir425vGFzD50tPjk4C+e+EBc8HRH6QHfktfkc6/mZnl4tFoNH05Ih9CfgVT5PU++fDt6ILf",
|
||||
"R11/kmzBENVzlgH3nI5aVz+4fm5q5Vvq241elZYgb/J7bWs8htmsJylNpSYvTP9ObBpY8OAxKZbG25fk",
|
||||
"3ZfJ8ct+BfKVWmJYbYe0gN+m0xZ+VhymqQSletognq7Nxh7ncAGtnP5QE84XkyH/s8flzx6XR+xx+dKS",
|
||||
"2MrXr1r3KtmP4sgIfxRHC3Q4LkMpbAVJKZlenyFeLH/OgEqQVael/XVSuSLf/XgebTZYv0eJJFUtYy9H",
|
||||
"q/vdj+cxah3KyafJ8RGZHBOT/SZihjELpGS2Nnrr7PSQlIrxxQXHn63U+R6aBDLPxLXJNJPT74/eWpNg",
|
||||
"yGhQbMBr1NpS68I2gTM+F1VzOU2MHLp2x8i1RPz7FeqEcoTug2o63b/Hp//7P+TEPO60kx+eTozJffWq",
|
||||
"Gmjmv3oVmyjMmGFVyhWsx678ahS1M9ejC37Bzcqms9AVnsC2arhKLeM00WxFNaQjcuiKuDbWU8T0OKdg",
|
||||
"uxE1EfMLXjMLuV4KBS7rYzcwyn9+4CWL4io7hPCmUhR7qbjmxCZcmODxBRdyI+UzIlUdyq5a2yFDRmr7",
|
||||
"JfuN0wW31snrd6gPltO1y0XRLFtXVWyDSr7IYK/EAxkZJMgNlvwZS8AVxRzRPkzOPTFr05Acnk68Il9V",
|
||||
"HTRpT+C0YNFB9NfR/uivKDFUL40UjJETx0ld4lqADibpOZkiw0/RZ5kKlibTuObrAuTckpoPMvYFR84m",
|
||||
"dIEUtubcS8gVUqxYCtKwiMYdCxuOKheK6lIiB02OL7iVMaoIJVYynNRVRezD08mIbEJdSW4D+QUvkdRT",
|
||||
"i4RMLBifWsyjIaNVY3v0DrTXKr5xveMv+/sPdrnD2yVww+Ow6Z5mgte4ozY3bQIrFwoixFVuhdChaXGk",
|
||||
"6UJVvdnRJS7jocOYdaECPPGJZ2tCV5RlpoBmghQurBa0RULTDlTTd9TBqin8unQ1KP2tSNcPhshWNfu2",
|
||||
"bX5QR9w+IhHbBe0AHc9MVpMYDw8tCIroGwtAaN0a0OruD45+vfXoFl+8FwvCbEHTCEHWsmlkTrNsRpOr",
|
||||
"YbYQLtsa5IszTTVkqMeMSFpFmjKVUJk25tCWhL+x0gquVEzghimtjNlR6zwHLddBtkEAOgR8E2hnEosF",
|
||||
"RkHo+nhOQHTwc9v8/3x5e7mJJbtHHxasT+uUZUdZHJVSAtem8eCRtYV3myPAaQ6QTRWgkEb34aNtEYjq",
|
||||
"x5C5uz/Yawdh3FoXpc8MfTYmQFmDajdjoEbkiwIyZTzJyhQm1qmAKdoC98z3NKwXQ17klNMF5AjcisH1",
|
||||
"ywCfMaVPnMvkXyD82d0b/KUEuW7cqY39I//GYApzWmY6OrCeSeBaD44pJLh7N/bGYWgXuzjq3tYGnQUv",
|
||||
"v5Lttgou/C7G7j2Xrvmqr6zNK5fzX3bQem09hitVDm3FSPb3pS24BySzuZjxSFane/NjK9Pz+sEAMGcL",
|
||||
"oP7E+Na2w213a7MrlbbVEhZdhNZNNRt0rDXC+DebmL21wpSBhi51j83zmrp32QY73GHjzTPAhgXIYWOj",
|
||||
"DbPD4H2WJ3z8/UdnsPOqNerR0dmyMQ5ZszWxlYauImgr7tAuzZCxuxmOdHEV5TaCm6zXIymQblrtiX3X",
|
||||
"PvpawFKPxru5q89BwOwZthKwruYZ2+DcRLSq1/dDs+TdTIyewgp7mbttjHCTY1DPhzTGnHvpDxsDdAxD",
|
||||
"iwZfJ94DDsImAR/LTwhcYX0Ed+EReMiCn7YI9kdVCs4HccWjXEjYnQ+D2sLGG5tfH3kAJj10S/9epv6w",
|
||||
"FUo9H0pWcO3iT46byPBBKLV5lXEz7JSQU8bJiik2y0zHoBeHzkpNEsq5sKqw0ITDtb3PZBo9VDdGbXb4",
|
||||
"vbhh84zPhx8ayHbiiMK2Bgw52KfpfDdUuzUDnxCaMU5NfB/4iNBGb9bxCUlFUhpWWQBHuBDnUuRNc4jn",
|
||||
"1DydI34srnkmaOq6JSTwFKT5Ds08CNkD+ukB+nnyMuim2eRFM/YpXLVu09YW1tYH8nk5bB6qPTvp59Ze",
|
||||
"esT2CfMgvtuGU2JrcarVn2hLcBicTm3DxpS8CHeUeLz68oLXNT610QY5IhOPqaviuSl8NV5DTChZ0Yyl",
|
||||
"8QUvuWl5mjadXlOSl0rbq6ruAy2Up65YosocUiI4UaWZEKpGNX1lj+SedhvXnjiN5TXrDgqEs5P3yWnt",
|
||||
"JkZv9v91l9Gv959C71oyGd6zd6YU6LC/2ha9gNKsrjs/ghN0lAnuxMjIjOF13VyuU4Rx0/qHzo6Do6lP",
|
||||
"VQ9sSx9hPJFGsUBqxdqfxBSx6fFvbKu8hBUTpfLf+y0HHblqsrdN5+eTZ2o/wrXFU1XGf26RUptQpkdy",
|
||||
"wLla1nel+xwqd5v6EV1Xt0NIk4BcscT0YVpA15si5gYsKyBriTJdLu6QnniNf2t+uHx138E9JfeIh99W",
|
||||
"lT4fPrN53QaNNrt7T68i8IVPn0Bf9Z3Py2Haj13z7d4dEUVDg09Nc/KThxfnftvw6fHJM6qLVOEFKnWv",
|
||||
"obkB11gLn2WeC3+YY8pVtVmonTCFFWSiQLaO4qiUmWvuOxiPTW/GUih98Pf9v+9Htkg8uAJ5sXpNZlTB",
|
||||
"y+G1xrRg49VrY7UdojbXtkrTWOscNK17NLwGQqcDu3CdtAM+u0gtve1v7aq++X5w0V4AjU7txKdU0xDt",
|
||||
"QuueBVvtlF9d9/J63fmnm20yBrBPk+OjPfxlvkljmtHa7Rbe+sjgt5e3/x8AAP//BveiqKBYAAA=",
|
||||
"H4sIAAAAAAAC/+x8627ktpL/qxT0/3+YGcjdnnNmD846WGAdezzbyVy8Y0+CIDbQbKm6m8cSqZBU253A",
|
||||
"wL7DPsY+x77IPsmCF0lUi5K7PbbjLPLJbomXYl1+VSwW9VuU8LzgDJmS0cFvUUEEyVGhML9OuMgnqf6P",
|
||||
"suggKohaRnHESI7RQTS3L+NI4C8lFZhGB0qUGEcyWWJOdC/dhKjoICpLqluqdaF7SiUoW0S3t7e6syw4",
|
||||
"k2jmeysEF/qfhDOFTOl/SVFkNCGKcjb+h+RMP2tm+P8C59FB9P/GzTLG9q0c29HMLCnKRNBCDxId2Gmg",
|
||||
"mjnSDVwfPeRhkqCURzxFwxDBCxSKWgIT93RjIXGUCCQK00PVWnZKFO4pmmN37XHFv7vZFEd0u2alxNSj",
|
||||
"bsZ5hoRVb7an7daX6c+RmayWtmGBm8tf92U9DJ/9AxOlpz1k8hpFl42/lCi1LLZc/opkpWF7W4x2dDBv",
|
||||
"R3BmmsfAynyGIga3euACMioV8DnYESWkWCBLKVsAZ6CWCBU9oKcedRjgkVsR07/az5hwkX71mqvm3+M6",
|
||||
"qHA9PDlfIiSkUKXA1HJmeDX+NINrK9XyiLM5XXRXljujQFbmeoKCSHmteRBHnKaJN15Dvnlxh/1+mhwf",
|
||||
"HWUUmXIzNws5K2c5lZJypgkL6fzGonOrteHufQv+gJ8rjOgsmpRqiUxpZBowOnHXGvU0X3S7TYLb4/dR",
|
||||
"+EWGzCsh7DDNKQuTlRD2A8Xr8EvMCc1aCmqf9IJS57H1DoEXgmeWPKowl8Em7gERgqzDMGQHiZslNusJ",
|
||||
"8ciqz7kgyZWeomMu9j3IUjsZTEG5lpCiIjSTI5icAmEpaFkCWejGRHgmJlGsUOxJmuIoijfkkPGEZGFm",
|
||||
"FBlJetiEcxRWql3+0Bx/5SzU8Ta0egPOjT+Tn9HYe8ivldbb5uSG5tqMX+/vx1FOmftVj06ZwoXWV71Y",
|
||||
"nNObMC2+4Ozgl70E6ihjgLK8IGz9sU+rdKRAEvW20tu+BqfLMN/qBl9EuH9LYQLv8UahYCT7bOTGEnxP",
|
||||
"Zpht1/RzzaWQLVKmBE/LpHfqjC94H9lFOu97VWF+2xiHUOrfK5fBilJ1DbUWt2zHTt0lSUx2m/nMduid",
|
||||
"WNIFI9oUh1mpqMrwbk21zXwOhbS2jlK3jA2xaj88uW0WmlAbyDO0jHvEvPc3pjZum8cgl/yawZwLE8RV",
|
||||
"fUFUnWPzDm9IXmQIE7biNEEXIIaoG7TPNgE/LlEttUtoZgOaaoc9pyiASqhECzOcc4GGRM0kSAiDGYLU",
|
||||
"MYhS6EV/nsLqlu8EL4uH3SFsQkp7URPvLThTBbwpMkKZdopFKQouMYYZSa4WgpcsNc5Ru08uVOU0gxPL",
|
||||
"w0TRVQ8sbAdkbWI/mX9IBmQmeVYqhC+f3+son0AhcFbSTMHp8YnleUbZlXbvHNSS6g3AnDJqBoofEh9/",
|
||||
"D2jcGhX7FDilC6pIBnUnrb11gEOUNS6Wat5WShzU2T6QjaOySHdFihUK2UaJOvLo255W9tJ0jmtEr/Uv",
|
||||
"KBBf5j6u+ZT3AfNZmedErAP4/Cc8PhU8tic3LySQLAOnCLLSXUlyhIwvaEKySpEfDFv/gBB3VMX93ej+",
|
||||
"D2LN7ZXsYrz/hiSzuYO23UpFVGn+c6ahe10F822FXu8ZJpylssUMytTf3kShTZPHiWb416P90f6deThH",
|
||||
"WDNGaFXv+YKy3t1UnZrxifXyNcME1A0HJu7Llyh+hWFQ+6o0iR3WDRIiq5NECmZyuKC/mhTzW5YWnLK2",
|
||||
"apeChsSfmGEn4TQIlbJE4cz6rpFkwovdciOxXfn25G6aWU1e3MOAzRm89dYEhxhex0IdRt/l3bbE2Sub",
|
||||
"EW1D5ZkiswwhJ8mSMoQrXDvHR2WT3b2maklZIIjxttRhh3reShLjjYIXOVlrP5WXmaIZZRgD42CJVyaD",
|
||||
"RAUakJIvQxPl5OaHcAr3g03BaP/Fr6ssrlnN1PrmKfjRihvZue1bk7TpG9mmc+4/crXWIYc/1eyZArHJ",
|
||||
"ec0lWRBm+aTjDN1fBp06L+oAuD34qUCJTFlKJWaYqDalOwXp1quGTMok5ZY8S3vSbwWXVPW4Md+8BsL7",
|
||||
"ULwyaQKjKkxxe6+2+s4w42whQfFtohb7YDuWnOu2QU9cL9naXWUgbnivwxAU2OzNznhw1XP0kfUm2Hyj",
|
||||
"GrSLYdUeVM0n0rX/a9p0f+35VITdSb8arDbE3OMDbbOKqCESzt3q25x+BVMnkin8z3/8JyhR4nhOMokX",
|
||||
"7FUDqfoVK3MUNHGoaF5XOKZfc4ZgVQzmgudAIDHBitmBZ1Qq08MCq24/F4h7Juq3vkhStsgQuGjc0UvT",
|
||||
"Rce/U9iDhGTIUiJAPwDKYPrTTz/9tPfhw97x8dR4rQtt4tUJXqVpcVTvBi25WnR4o//ogYIne1Va4rHc",
|
||||
"f7ONDEQBTtO9raYskyUQCRkqpU1CuA0ulNLuluwpRmMX9nUwibVlFtnQ7ZEZ0qxWXvserPJ5cH86h0ms",
|
||||
"8kEB+ogifZvZ3vM/SRdst11j/xZ0Gziq6Q/ikUOgaiWObo/KQZb0ya3hy0YE8/GdVr3vTt++A5qTBWpD",
|
||||
"JGY7/8IduUGKCU8xBUl/RfgLfPg2GDdW7G2P/5HkWMF9gUJyZtJ5us8QX3vSDeb9DsM9kjAGJdALyakg",
|
||||
"10xjXpPPNI/ARf4zwa+lw2AjCt22LDJONPOtcMzBh815k2acFkSaMbWd6w5hIKyLDAJ7TuMHto8nWqUl",
|
||||
"gWiik5oLWs0OFUe6wQ/9SZut0ZrP59obnabzj0HFrUL76yVa8diqLI1MjVC0O8yLDBWm4AaE0+OT4E7a",
|
||||
"B62tFNL0qpKNO6GTV1UwNFNdfeByHT0JA/vqfAtb+tK0HC7V8oVYz92aKa41sc2EoOnV+tyb8n62angv",
|
||||
"AT8/YW0lIjVYT0Fax1BtY6zy+Y01mhhzSSRMu6cm8C8m4p2GWHc/fNsa2TbKOTsnG34AWMV5GWVXdgtU",
|
||||
"FX7qpwSEAVWg7IIRxk0SQ66lwrx9PEK0AzEnJDGQxJS0aI9e8Iwmaxc0joYRsEv5t0Ti3940sYBJ7Z8e",
|
||||
"nwTw7oVHzAXTgT058Etji3T+zcwMF49Go+nLEXwIxRVUwut9+PDt6ILdB64/CbqgmtVzmiHzgo4aqx8c",
|
||||
"n5vSjy3xdqPmq2XIm/pe+xpPYTaPPqUiQsELUwcXm0IwvfAYiiVn9gwN6YJd4Vrrw7svk+OX/WDylYgx",
|
||||
"DOEhRPBL39pAQIvDNBUoZU9p0dOVrtnlHC6wdRQ1VNj2xRzs/Fk39mfd2B+pbuxLCwKqjURVX1sBSxRH",
|
||||
"BllsSFBBy0IHNpehUx2JSSmoWp/plVvdnyERKKrKaPvrpAp5vvvxPNq8EPFeWztUx3t7ufbu3/14HmtE",
|
||||
"Iww+TY6PYHIM5kAI+EzvjTCF2drg49npIZSSssUF0z9bp0l72vXAPOPX5vAFTr8/emtdjxGU4bYhr4HM",
|
||||
"pVKFvbRB2ZxXl0FIYmzclSdHruLoX6803pQjHabI5mbK9/rpf/8XnJjHnesfh6cT49pfvaoamv6vXsVm",
|
||||
"t2fcvSzFCtdjV5FgHIILC0YX7IKZkU0lsDuLRVsJ5YoXKCOJoiuiMB2Bbn/oahvsvlKCuZeQoq0gVsDn",
|
||||
"F6zWHbhecokuVWgnMfnB+YGXYYyrlKKmORW82Ev5NQObpaOcxRfMpPq42EgWWnqqc1o7fO38jEyJLXbu",
|
||||
"94gXzLpErx5oBG6FOVm7TCbJsnVV5WH4yhYZ7pV6ZcbYQauG1YWMJugOjZ0EP0zOPfNrCxQOTyfeIXh1",
|
||||
"em6y9MhIQaOD6K+j/dFftSURtTQmMdZqOU7qI+AFquAhFoOp1v6pDpSmnKbJNK6VvEAxt3Jng1p+wbSa",
|
||||
"A1loUdsYwkvnFoKvaIrC6IvSMxZ2Dyzd/leVQqvT5PiCWYMjEghYM3EmWBV5HJ5ORrBJdWXGDeUXrNSi",
|
||||
"nlomZHxB2dRyXntMUp0kRO9Qefc8Nu5m/WV//8FuZnmzBK5nHTZXHyhnNe+IPUoxuzm3/9QUVwkdIEPd",
|
||||
"4kiRhawuVkSXehiPHSZ+4DKgE59YtgayIjQzB8xmZ8S4hUR7iG7K5Wr5jjpcNYUR7rADpfqWp+sHY2Sr",
|
||||
"2uO27ZY0WNw+ohDbBR8BOZ6ZVCqYUFK7E22ibywBoXFrQquLe7r1661bt/TiPV8AtQf+xgiyloODOcmy",
|
||||
"GUmuhtWCuxRvUC/OFFGYaRwzJmmBNKUyISJtfKMtmfjGWiu6UgrAGyqVND5IrvMclVgH1UYT0BHgm0C5",
|
||||
"H18s9NZLhzpeRBAd/NyOBX6+vL3c5JKdo48LNnh2YNkBi6NSCGTKFOY8Mlp4V7ECmuYI2YQAqWV0Hz3a",
|
||||
"loEafoyYu/OjvTMU5q2NV/rc0GfjAqR1qHYyinIEXyTClLIkK1Oc2AgDp9oXuGd+2GFDGniRE0YWmGvi",
|
||||
"VhSvXwb0jEp14uIn//bvz+7S7y8linUTW23MH/nXfVOckzJT0YENUQJ38nSbQqC7NGevC4dmsYNr7G1N",
|
||||
"0Bnw8ivVbqu9hF/l272k1nVf9X3TeRV//tMOqNfGMT1SFd1WimR/X9qClIBlNreqHsnrdK9tbeV6Xj8Y",
|
||||
"AWZtAdafmCDbVoDu7m12ldK2KGHZBaQuOtuQY40I499sNvjWGlOGCrvSPTbPa+ne5Rtsc8eNN8+AG5Yg",
|
||||
"x42NMuWOgvd5nvDy9x9dwc6r0sFHZ2fLxzhmzdZgjze6QNAG7tAsTZOx+6yDlos7xm4zuEmvPRKAdPN3",
|
||||
"Txy79snXEpZ6Mt4tXH0OBmbXsJWBdZFnbDfnZkcre2M/7Za8a8XRU3hhL0W4jRNucgzy+YjGuHMv/WH3",
|
||||
"AB3H0JLB15n3QICwKcDHihMC988fIVx4BB2y5Kctgf1RQcHFICaRKCDnAnfXwyBa2P3G5qeDHkBJD93Q",
|
||||
"v5erP2xtpZ6PJCu6doknx83O8EEktXlteHPbKTAnlMGKSjrLTL2ptw+dlQoSwhi3UFgoYHht7/uZ6hLZ",
|
||||
"3aM2M/xe2rC5xuejDw1lO2lEYesRhgLs03S+G6vdmIHvf80oI2Z/H/gC2EZB2PEJpDwpjaoskGm6NM8F",
|
||||
"z5uKFC+oebpA/Jhfs4yT1JVoCGQpCvMRqXmQsgeM0wPy8+xlMEyzyYum7VOEat1KsS28rU/k8wrYPFZ7",
|
||||
"ftLPrb30hO0L5kFit42gxJ7FyVZRpD2C05vTqa0MmcKLcBmLp6svL1h9xic3ai9HMPGUujqlNwdfTdQQ",
|
||||
"A4EVyWgaX7CSmTqraVNeNoW8lMpe5XZfVyIsdYclsswxBc5AlqZD6DSqKWZ7pPC0Wy33xGksr0J40CCc",
|
||||
"n7xPTms3M3qz/8+7tH69/xS4a8VkdM/eKZSowvFq2/QCoFl9DuARgqCjjDNnRsZmjK6r5r6VBMpMvaEO",
|
||||
"dhwdzflU9cDdTaEsEQZYMLVm7XeiEmx6/Btbny9wRXkp/fd+/UHHrprsbVNu+uSZ2o94bflUHeM/t51S",
|
||||
"W1CmMHMguFrW3xLoC6jc1wYeMXR1M4SQBMWKJqb40xK63jQx12BZEVlblCl5cYv0zGv8W/PD5av7Fu6B",
|
||||
"3CMuflsofT56ZvO6DRttdveeUUXg87y+gL7qI72Xw7Ifu4rfvTt2FI0MPjUV0U++vTj3a5VPj0+e0blI",
|
||||
"tb3QoO5VUTfkGm/hq8xz0Q+zTLGqJgvVFqa4wowXWq2jOCpF5ir9DsZjU5ux5FId/H3/7/uRPSQeHAFe",
|
||||
"rF7DjEh8OTzWmBR0vHptvLZj1ObYFjSNt85RkbpGw6smdBjYpeukveGzg9TW2/5Qtuzr728u2gNop1MH",
|
||||
"8SlRJCS70LhnwVI76Z+ue3m9bv/TzTIZQ9inyfHRnv5lvtlkitHa5Rbe+FrBby9v/zcAAP//Cp51DF1c",
|
||||
"AAA=",
|
||||
}
|
||||
|
||||
// GetSwagger returns the content of the embedded swagger specification 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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="space-y-1">
|
||||
{label}
|
||||
{description}
|
||||
</div>
|
||||
<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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
<div key={question.id} className="card">
|
||||
<QuestionField
|
||||
question={question}
|
||||
value={answers[question.id]}
|
||||
onChange={(value) =>
|
||||
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<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])
|
||||
}
|
||||
/>
|
||||
</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 && (
|
||||
<section className="card space-y-4">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue