parent
be2cfc8bfd
commit
56602612c8
|
|
@ -51,10 +51,11 @@ const (
|
|||
|
||||
// Defines values for UserIdType.
|
||||
const (
|
||||
Email UserIdType = "email"
|
||||
Guid UserIdType = "guid"
|
||||
Phone UserIdType = "phone"
|
||||
Username UserIdType = "username"
|
||||
Email UserIdType = "email"
|
||||
Guid UserIdType = "guid"
|
||||
Phone UserIdType = "phone"
|
||||
Username UserIdType = "username"
|
||||
ForeignKey UserIdType = "foreignkey"
|
||||
)
|
||||
|
||||
// AccessCode defines model for AccessCode.
|
||||
|
|
@ -382,7 +383,7 @@ type SubmitFormRequest struct {
|
|||
// Tracking Client supplied tracking details. IP and user agent are captured server-side.
|
||||
Tracking *ClientTracking `json:"tracking,omitempty"`
|
||||
|
||||
// UserId Respondent identifier captured at the start (email, username, phone or GUID)
|
||||
// UserId Respondent identifier captured at the start (email, username, phone, foreignkey or GUID)
|
||||
UserId string `json:"userId"`
|
||||
UserIdType UserIdType `json:"userIdType"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -449,7 +449,7 @@ func (s *Server) SubmitForm(ctx context.Context, req SubmitFormRequestObject) (S
|
|||
|
||||
userIdType := UserIdType(strings.ToLower(strings.TrimSpace(string(req.Body.UserIdType))))
|
||||
if !validUserIdType(userIdType) {
|
||||
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userIdType must be one of email, username, phone or guid", "validation_error")}, nil
|
||||
return SubmitForm400JSONResponse{ErrorJSONResponse: errJSON("userIdType must be one of email, username, phone, foreign key or guid", "validation_error")}, nil
|
||||
}
|
||||
userId := strings.TrimSpace(req.Body.UserId)
|
||||
if userId == "" {
|
||||
|
|
@ -1045,7 +1045,7 @@ func toFloat(v interface{}) (float64, bool) {
|
|||
|
||||
func validUserIdType(t UserIdType) bool {
|
||||
switch t {
|
||||
case Email, Username, Phone, Guid:
|
||||
case Email, Username, Phone, ForeignKey, Guid:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ func renderSubmitterDetails(doc *fpdf.Fpdf, form *database.Form) {
|
|||
doc.SetXY(pdfLeftMargin, y)
|
||||
doc.CellFormat(26, 6, "Identifier type:", "", 0, "L", false, 0, "")
|
||||
x := pdfLeftMargin + 28
|
||||
for _, label := range []string{"Email", "Username", "Phone", "GUID"} {
|
||||
for _, label := range []string{"Email", "Username", "Phone", "ForeignKey", "GUID"} {
|
||||
drawCheckbox(doc, x, y+1)
|
||||
doc.SetXY(x+5, y)
|
||||
doc.CellFormat(0, 6, label, "", 0, "L", false, 0, "")
|
||||
|
|
|
|||
|
|
@ -5,9 +5,13 @@ info:
|
|||
description: |
|
||||
API for **Kererū forms**, a simple survey/form capture system.
|
||||
|
||||
Forms are versioned and can be inactivated. A form contains an ordered set of
|
||||
Forms are versioned and can be inactivated.
|
||||
|
||||
A form contains an ordered set of
|
||||
questions whose answers are one of: true/false, numeric, a drop-down selection,
|
||||
or free-form text. Responses are identified by a user identifier captured at the
|
||||
date or free-form text.
|
||||
|
||||
Responses are identified by a user identifier captured at the
|
||||
start of the form. A form may optionally require a single-use access code.
|
||||
contact:
|
||||
name: Kererū Forms
|
||||
|
|
@ -806,7 +810,7 @@ components:
|
|||
$ref: '#/components/schemas/QuestionInput'
|
||||
UserIdType:
|
||||
type: string
|
||||
enum: [email, username, phone, guid]
|
||||
enum: [email, username, phone, foreignkey guid]
|
||||
Answer:
|
||||
type: object
|
||||
required: [questionId, value]
|
||||
|
|
@ -822,7 +826,7 @@ components:
|
|||
properties:
|
||||
userId:
|
||||
type: string
|
||||
description: Respondent identifier captured at the start (email, username, phone or GUID)
|
||||
description: Respondent identifier captured at the start (email, username, phone, foreignkey or GUID)
|
||||
userIdType:
|
||||
$ref: '#/components/schemas/UserIdType'
|
||||
accessCode:
|
||||
|
|
|
|||
|
|
@ -82,8 +82,8 @@ the question along with the answwer.
|
|||
## Database Schema
|
||||
|
||||
The schema is to allow for multiple forms and capture the user input
|
||||
using an user identifier such as email, username, international phine number or GUID.
|
||||
The identifier is captured at the start.
|
||||
using an user identifier such as email, username, international phone number,
|
||||
foreign key or GUID. The identifier is captured at the start.
|
||||
|
||||
Forms can be versioned and include a logo and company name plus contact details that is
|
||||
linked to the form. Existing forms can be edited which will generate a new version number
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ All timestamps are stored as RFC 3339 UTC strings. IDs are UUIDs (TEXT).
|
|||
|
||||
Form definitions: non-empty title, unique keys (`[A-Za-z][A-Za-z0-9_-]*`), supported question types, select questions require unique non-empty option values, numeric bounds ordered.
|
||||
|
||||
Submissions: identifier type must be `email|username|phone|guid`; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.
|
||||
Submissions: identifier type must be `email|username|phone|foreignkey|guid`; email identifiers are parsed; answers must reference questions of that form; duplicates rejected; required questions enforced; type checks for boolean/number/select/text; numeric bounds; select values must exist; text length capped and single-line questions reject newlines.
|
||||
|
||||
Access codes are consumed in the same SQL transaction as the submission insert. The conditional `UPDATE ... WHERE used = 0` makes reuse safe under concurrency; failure rolls back the submission.
|
||||
|
||||
|
|
|
|||
|
|
@ -33,15 +33,15 @@ export function QuestionField({ question, value, onChange }: QuestionFieldProps)
|
|||
|
||||
if (question.type === 'boolean') {
|
||||
control = (
|
||||
<div id={id} className="flex gap-6 pt-1" role="radiogroup" aria-label={question.label}>
|
||||
<div id={id} className="flex gap-6 pt-1" role="checkboxgroup" aria-label={question.label}>
|
||||
{[
|
||||
{ label: 'True', checked: value === true },
|
||||
{ label: 'False', checked: value === false },
|
||||
].map((option) => (
|
||||
<label key={option.label} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
name={`radio-${question.id}`}
|
||||
type="checkbox"
|
||||
name={`checkbox-${question.id}`}
|
||||
checked={option.checked}
|
||||
onChange={() => onChange(option.label === 'True')}
|
||||
className="h-4 w-4 accent-brand-600"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export type QuestionType = 'boolean' | 'date' | 'number' | 'select' | 'text'
|
||||
|
||||
export type UserIdType = 'email' | 'username' | 'phone' | 'guid'
|
||||
export type UserIdType = 'email' | 'username' | 'phone' | 'foreignkey' | 'guid'
|
||||
|
||||
export type SignatureType = 'drawn' | 'image'
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const userIdTypes: Array<{ value: UserIdType; label: string }> = [
|
|||
{ value: 'email', label: 'Email address' },
|
||||
{ value: 'username', label: 'Username' },
|
||||
{ value: 'phone', label: 'Phone number' },
|
||||
{ value: 'foreignkey', label: 'Provided key' },
|
||||
{ value: 'guid', label: 'GUID' },
|
||||
]
|
||||
|
||||
|
|
@ -65,6 +66,15 @@ export function FillFormPage() {
|
|||
const [downloadingPdf, setDownloadingPdf] = useState(false)
|
||||
const [pdfError, setPdfError] = useState<string | null>(null)
|
||||
|
||||
// Map option values to preferred placeholder texts
|
||||
const placeholders = {
|
||||
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"
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return
|
||||
if (submissionLocked) {
|
||||
|
|
@ -390,7 +400,7 @@ export function FillFormPage() {
|
|||
<legend className="label">How would you like to respond?</legend>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
name="completionMode"
|
||||
checked={completionMode === 'online'}
|
||||
onChange={() => setCompletionMode('online')}
|
||||
|
|
@ -405,7 +415,7 @@ export function FillFormPage() {
|
|||
</label>
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
type="checkbox"
|
||||
name="completionMode"
|
||||
checked={completionMode === 'offline'}
|
||||
onChange={() => setCompletionMode('offline')}
|
||||
|
|
@ -466,7 +476,7 @@ export function FillFormPage() {
|
|||
className="input"
|
||||
value={userId}
|
||||
onChange={(event) => setUserId(event.target.value)}
|
||||
placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'}
|
||||
placeholder={placeholders[userIdType] || 'Enter your identifier'} //placeholder={userIdType === 'email' ? 'you@example.com' : 'Enter your identifier'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,24 +42,28 @@ export function HomePage() {
|
|||
<div className="space-y-8">
|
||||
<section className="card bg-gradient-to-br from-brand-600 to-brand-800 text-white dark:from-brand-800 dark:to-brand-950">
|
||||
<p className="text-sm font-semibold uppercase tracking-widest text-brand-100">Kererū Forms</p>
|
||||
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Simple, versioned surveys</h1>
|
||||
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Simple, versioned forms</h1>
|
||||
<p className="mt-2 max-w-2xl text-brand-50">
|
||||
Pick a form below to share your feedback. Forms are versioned, so your previous answers are
|
||||
never shown again.
|
||||
Pick a form below to complete. Forms can also be thought of as surveys.
|
||||
</p>
|
||||
{(user?.canAdmin || user?.canView) && (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
|
||||
Manage forms
|
||||
</Link>
|
||||
{user?.canAdmin && (
|
||||
<Link
|
||||
to="/manage/new"
|
||||
className="btn border border-white/40 text-white hover:bg-white/10"
|
||||
>
|
||||
Create a form
|
||||
<div>
|
||||
<p className="mt-2 max-w-2xl text-brand-50">
|
||||
Use the buttons below to manage forms or view submitted forms.
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
<Link to="/manage" className="btn bg-white text-brand-800 hover:bg-brand-50">
|
||||
Manage forms
|
||||
</Link>
|
||||
)}
|
||||
{user?.canAdmin && (
|
||||
<Link
|
||||
to="/manage/new"
|
||||
className="btn border border-white/40 text-white hover:bg-white/10"
|
||||
>
|
||||
Create a form
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
|
@ -67,65 +71,65 @@ export function HomePage() {
|
|||
{submissionLocked ? (
|
||||
<SignInPrompt message="Sign in with your organisation account to view and complete forms." />
|
||||
) : (
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">Available forms</h2>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{forms.length} active {forms.length === 1 ? 'form' : 'forms'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-slate-500 dark:text-slate-400">Loading forms…</p>}
|
||||
|
||||
{error && (
|
||||
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
|
||||
{error}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">Available forms</h2>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{forms.length} active {forms.length === 1 ? 'form' : 'forms'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && forms.length === 0 && (
|
||||
<div className="card text-center">
|
||||
<p className="text-lg font-semibold">No active forms yet</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Create one from the management section to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{loading && <p className="text-slate-500 dark:text-slate-400">Loading forms…</p>}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{forms.map((form) => (
|
||||
<article key={form.id} className="card flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{form.logoUrl && (
|
||||
<img
|
||||
src={form.logoUrl}
|
||||
alt=""
|
||||
className="h-8 w-auto"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold">{form.title}</h3>
|
||||
{error && (
|
||||
<div className="card border-red-300 text-red-700 dark:border-red-900 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && forms.length === 0 && (
|
||||
<div className="card text-center">
|
||||
<p className="text-lg font-semibold">No active forms yet</p>
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
Create one from the management section to get started.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{forms.map((form) => (
|
||||
<article key={form.id} className="card flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{form.logoUrl && (
|
||||
<img
|
||||
src={form.logoUrl}
|
||||
alt=""
|
||||
className="h-8 w-auto"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold">{form.title}</h3>
|
||||
</div>
|
||||
<StatusBadge active={form.isActive} />
|
||||
</div>
|
||||
<StatusBadge active={form.isActive} />
|
||||
</div>
|
||||
{form.description && (
|
||||
<p className="mt-2 line-clamp-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{form.description}
|
||||
{form.description && (
|
||||
<p className="mt-2 line-clamp-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{form.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
Version {form.version} · {form.questionCount} questions
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
Version {form.version} · {form.questionCount} questions
|
||||
</p>
|
||||
</div>
|
||||
<Link to={`/forms/${form.id}`} className="btn-primary mt-5 w-full">
|
||||
Open form
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<Link to={`/forms/${form.id}`} className="btn-primary mt-5 w-full">
|
||||
Open form
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ describe('StatusBadge', () => {
|
|||
})
|
||||
|
||||
describe('QuestionField', () => {
|
||||
it('renders boolean radios', () => {
|
||||
it('renders boolean checkboxes', () => {
|
||||
const onChange = vi.fn()
|
||||
const { getByLabelText } = render(
|
||||
<QuestionField
|
||||
|
|
|
|||
Loading…
Reference in New Issue