feat(portal): persist submitter details through draft lifecycle

Adds submitterName/submitterEmail state and setters to useFormDraft and
wires them through start/saveDraft/submit. Previously the Contactgegevens
name/email were held in a local page ref and never made it into any
request body, so submissions landed in the DB with NULL submitter fields
and a mid-form reload wiped whatever the user had typed.

- useFormDraft: internal submitterName/submitterEmail refs with setters
  that mark the draft dirty (same debounced-PUT path as field values),
  sessionStorage resume via draft_submitter:{token}, and a
  MISSING_SUBMITTER guard in submitForm so empty fields surface as
  submitError without hitting the endpoint.
- register/[public_token].vue: deletes the local submitter refs and
  reads/writes through the composable; onSubmit pre-validates and
  bounces the user back to the Contactgegevens step with a snackbar
  when fields are missing.
- SaveDraftBody / SubmitBody: optional public_submitter_name and
  public_submitter_email per the documented backend contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-23 14:08:13 +02:00
parent 102b6006fa
commit 3ecd4daee1
4 changed files with 1255 additions and 0 deletions

View File

@@ -0,0 +1,356 @@
import { ref, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
import {
extractErrorBody,
extractRetryAfterSeconds,
useCreateFormDraft,
useSaveFormDraft,
useSubmitForm,
} from '@/composables/api/usePublicForm'
import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@/types/formBuilder'
/** sessionStorage key for reusing an idempotency key across reloads. */
export function draftIdempotencyKey(token: string): string {
return `draft_idem:${token}`
}
/** sessionStorage key for resuming submitter name/email across reloads. */
export function draftSubmitterStorageKey(token: string): string {
return `draft_submitter:${token}`
}
function generateIdempotencyKey(): string {
const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (arr: Uint8Array) => Uint8Array } }).crypto
if (c?.randomUUID) {
// UUID v4 (36 chars) exceeds backend max:30. Backend expects 6..30
// chars so compress to 24 hex chars (still collision-resistant).
return c.randomUUID().replace(/-/g, '').slice(0, 24)
}
if (c?.getRandomValues) {
const buf = new Uint8Array(12)
c.getRandomValues(buf)
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('')
}
// Last-resort fallback — still within 6..30.
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`.slice(0, 30)
}
interface UseFormDraftOptions {
/** Preferred locale string for `submitted_in_locale` (e.g. `"nl"`). */
locale?: string
/** Debounce for auto-save after a field blur, in ms. */
debounceMs?: number
}
export type SubmitterClientError = Error & { code: 'MISSING_SUBMITTER' }
export interface UseFormDraftReturn {
submission: Ref<PublicFormSubmission | null>
values: Ref<FormValues>
submitterName: Ref<string>
submitterEmail: Ref<string>
isStarting: Ref<boolean>
isSaving: Ref<boolean>
isSubmitting: Ref<boolean>
lastSavedAt: Ref<Date | null>
saveError: Ref<unknown>
submitError: Ref<unknown>
start: () => Promise<PublicFormSubmission | null>
setValue: (slug: string, value: unknown) => void
saveField: (slug: string, value: unknown) => void
setSubmitterName: (v: string) => void
setSubmitterEmail: (v: string) => void
saveDraftNow: () => Promise<void>
submitForm: () => Promise<PublicFormSubmission | null>
clearSession: () => void
}
export function useFormDraft(
token: Ref<string | null | undefined>,
options: UseFormDraftOptions = {},
): UseFormDraftReturn {
const debounceMs = options.debounceMs ?? 800
const submission = ref<PublicFormSubmission | null>(null)
const values = ref<FormValues>({})
const dirty = ref<FormValues>({})
const submitterName = ref<string>('')
const submitterEmail = ref<string>('')
const submitterDirty = ref(false)
const isStarting = ref(false)
const isSaving = ref(false)
const isSubmitting = ref(false)
const lastSavedAt = ref<Date | null>(null)
const saveError = ref<unknown>(null)
const submitError = ref<unknown>(null)
const firstInteractedAt = ref<string | null>(null)
const { mutateAsync: startDraft } = useCreateFormDraft(token)
const { mutateAsync: saveDraft } = useSaveFormDraft(token)
const { mutateAsync: submitMutation } = useSubmitForm(token)
function readStoredKey(): string | null {
const t = token.value
if (!t) return null
try {
return sessionStorage.getItem(draftIdempotencyKey(t))
}
catch {
return null
}
}
function writeStoredKey(value: string): void {
const t = token.value
if (!t) return
try {
sessionStorage.setItem(draftIdempotencyKey(t), value)
}
catch {
// storage disabled — drafts won't survive reload
}
}
function persistSubmitter(): void {
const t = token.value
if (!t) return
try {
sessionStorage.setItem(
draftSubmitterStorageKey(t),
JSON.stringify({ name: submitterName.value, email: submitterEmail.value }),
)
}
catch {
// storage disabled
}
}
function restoreSubmitter(): void {
const t = token.value
if (!t) return
try {
const raw = sessionStorage.getItem(draftSubmitterStorageKey(t))
if (!raw) return
const parsed = JSON.parse(raw) as { name?: unknown; email?: unknown }
if (typeof parsed.name === 'string') submitterName.value = parsed.name
if (typeof parsed.email === 'string') submitterEmail.value = parsed.email
}
catch {
// malformed — ignore
}
}
function clearSession(): void {
const t = token.value
if (!t) return
try {
sessionStorage.removeItem(draftIdempotencyKey(t))
sessionStorage.removeItem(draftSubmitterStorageKey(t))
}
catch {
// noop
}
}
async function start(): Promise<PublicFormSubmission | null> {
if (!token.value) return null
if (submission.value) return submission.value
isStarting.value = true
try {
// Hydrate submitter state from a prior session *before* posting the
// draft so the first create call already carries any name/email the
// user had typed on a previous mount.
restoreSubmitter()
let key = readStoredKey()
if (!key) {
key = generateIdempotencyKey()
writeStoredKey(key)
}
const created = await startDraft({
idempotency_key: key,
opened_at: new Date().toISOString(),
submitted_in_locale: options.locale,
public_submitter_name: submitterName.value || undefined,
public_submitter_email: submitterEmail.value || undefined,
})
submission.value = created
const hydrated: FormValues = {}
for (const [slug, entry] of Object.entries(created.values ?? {})) {
hydrated[slug] = entry?.value
}
values.value = { ...hydrated, ...values.value }
return created
}
finally {
isStarting.value = false
}
}
function setValue(slug: string, value: unknown): void {
values.value = { ...values.value, [slug]: value }
dirty.value = { ...dirty.value, [slug]: value }
}
function setSubmitterName(v: string): void {
submitterName.value = v
submitterDirty.value = true
persistSubmitter()
}
function setSubmitterEmail(v: string): void {
submitterEmail.value = v
submitterDirty.value = true
persistSubmitter()
}
function markInteracted(): void {
if (!firstInteractedAt.value) firstInteractedAt.value = new Date().toISOString()
}
async function flushDirty(): Promise<void> {
if (!submission.value) return
const keys = Object.keys(dirty.value)
const hadSubmitterDirty = submitterDirty.value
if (keys.length === 0 && !hadSubmitterDirty) return
const snapshot = { ...dirty.value }
dirty.value = {}
submitterDirty.value = false
isSaving.value = true
saveError.value = null
try {
const body: SaveDraftBody = {
first_interacted_at: firstInteractedAt.value ?? undefined,
public_submitter_name: submitterName.value || undefined,
public_submitter_email: submitterEmail.value || undefined,
}
if (keys.length > 0) body.values = snapshot
const updated = await saveDraft({
submissionId: submission.value.id,
body,
})
submission.value = updated
lastSavedAt.value = new Date()
}
catch (err) {
// Restore the dirty set so the next save retries these values.
dirty.value = { ...snapshot, ...dirty.value }
if (hadSubmitterDirty) submitterDirty.value = true
saveError.value = err
}
finally {
isSaving.value = false
}
}
function saveField(slug: string, value: unknown): void {
markInteracted()
setValue(slug, value)
}
async function saveDraftNow(): Promise<void> {
markInteracted()
if (!submission.value) await start()
await flushDirty()
}
// Debounced background save whenever the dirty surface changes — values
// or submitter.
watchDebounced(
() => Object.keys(dirty.value).length + (submitterDirty.value ? 1 : 0),
count => {
if (count > 0 && submission.value) void flushDirty()
},
{ debounce: debounceMs },
)
// Abandon draft session when token changes (hot route swap / dev).
watch(token, () => {
submission.value = null
values.value = {}
dirty.value = {}
submitterName.value = ''
submitterEmail.value = ''
submitterDirty.value = false
lastSavedAt.value = null
})
async function submitForm(): Promise<PublicFormSubmission | null> {
submitError.value = null
// Submitter name/email are required at final submit. Surface a
// client-side error without hitting the endpoint so the page can
// bounce the user back to the Contactgegevens step.
const name = submitterName.value.trim()
const email = submitterEmail.value.trim()
if (!name || !email) {
const err = new Error('Submitter name and email are required.') as SubmitterClientError
err.code = 'MISSING_SUBMITTER'
submitError.value = err
return null
}
if (!submission.value) {
await start()
if (!submission.value) return null
}
// Flush any pending auto-save so the submit merges with server state.
await flushDirty()
isSubmitting.value = true
try {
const submitted = await submitMutation({
submissionId: submission.value.id,
body: {
values: values.value,
public_submitter_name: submitterName.value,
public_submitter_email: submitterEmail.value,
},
})
submission.value = submitted
clearSession()
return submitted
}
catch (err) {
submitError.value = err
const body = extractErrorBody(err)
if (body?.code === 'SUBMISSION_ALREADY_SUBMITTED') clearSession()
return null
}
finally {
isSubmitting.value = false
}
}
return {
submission,
values,
submitterName,
submitterEmail,
isStarting,
isSaving,
isSubmitting,
lastSavedAt,
saveError,
submitError,
start,
setValue,
saveField,
setSubmitterName,
setSubmitterEmail,
saveDraftNow,
submitForm,
clearSession,
}
}
export { extractErrorBody, extractRetryAfterSeconds }

View File

@@ -0,0 +1,535 @@
<script setup lang="ts">
import { emailValidator } from '@core/utils/validators'
import FieldRenderer from '@/components/public-form/FieldRenderer.vue'
import FormConfirmation from '@/components/public-form/FormConfirmation.vue'
import FormErrorState from '@/components/public-form/FormErrorState.vue'
import FormStepper from '@/components/public-form/FormStepper.vue'
import SubmitterDetails from '@/components/public-form/SubmitterDetails.vue'
import { extractErrorBody, useFetchPublicFormSchema } from '@/composables/api/usePublicForm'
import { useFormDraft } from '@/composables/useFormDraft'
import { isStepValid, useFormSteps } from '@/composables/useFormSteps'
import { FormFieldType } from '@/types/formBuilder'
import type { FormErrorCode, PublicFormField } from '@/types/formBuilder'
definePage({
name: 'public-form-register',
meta: {
layout: 'blank',
requiresAuth: false,
},
})
const route = useRoute('public-form-register')
const token = computed(() => {
const raw = route.params.public_token
if (Array.isArray(raw)) return raw[0] ?? ''
return raw ?? ''
})
const tokenRef = computed<string | null>(() => token.value || null)
const schemaQuery = useFetchPublicFormSchema(tokenRef)
const draft = useFormDraft(tokenRef, {
locale: 'nl',
})
// Start the draft as soon as the schema resolves successfully.
watch(() => schemaQuery.data.value?.id, async id => {
if (id && !draft.submission.value) {
await draft.start()
}
}, { immediate: true })
const steps = useFormSteps(schemaQuery.data)
const currentStep = ref(0)
const justSubmitted = ref(false)
const showSaveToast = ref(false)
const rateLimitedToast = ref(false)
const submitterToast = ref(false)
const serverFieldErrors = ref<Record<string, string[]>>({})
const submitterErrors = ref<{ name?: string; email?: string }>({})
const submitterValid = computed(() => {
const hasName = draft.submitterName.value.trim().length > 0
const hasEmail = draft.submitterEmail.value.trim().length > 0
const emailOk = emailValidator(draft.submitterEmail.value) === true
return hasName && hasEmail && emailOk
})
const activeStep = computed(() => steps.value[currentStep.value])
const isActiveStepValid = computed(() =>
activeStep.value ? isStepValid(activeStep.value, draft.values.value, submitterValid.value) : true,
)
const saveStatusText = computed(() => {
if (draft.isSaving.value) return 'Opslaan...'
if (draft.lastSavedAt.value) {
const hh = String(draft.lastSavedAt.value.getHours()).padStart(2, '0')
const mm = String(draft.lastSavedAt.value.getMinutes()).padStart(2, '0')
return `Concept opgeslagen om ${hh}:${mm}`
}
return ''
})
const terminalErrorCode = computed<FormErrorCode | null>(() => {
const err = schemaQuery.error.value
if (!err) return null
const body = extractErrorBody(err)
const axiosErr = err as { response?: { status?: number } }
const status = axiosErr.response?.status
const code = body?.code as FormErrorCode | undefined
if (code === 'TOKEN_EXPIRED' || code === 'TOKEN_REVOKED'
|| code === 'SCHEMA_NOT_FOUND' || code === 'SCHEMA_UNPUBLISHED') {
return code
}
if (status === 404) return 'SCHEMA_NOT_FOUND'
if (status === 410) return 'TOKEN_EXPIRED'
return null
})
const submitErrorCode = computed<FormErrorCode | null>(() => {
const err = draft.submitError.value
if (!err) return null
const body = extractErrorBody(err)
const code = body?.code as FormErrorCode | undefined
if (code === 'SUBMISSION_ALREADY_SUBMITTED'
|| code === 'RATE_LIMITED'
|| code === 'SCHEMA_UNPUBLISHED') {
return code
}
return null
})
function onFieldValue(slug: string, value: unknown): void {
draft.setValue(slug, value)
if (serverFieldErrors.value[slug]) {
const next = { ...serverFieldErrors.value }
delete next[slug]
serverFieldErrors.value = next
}
}
function onFieldBlur(slug: string, value: unknown): void {
draft.saveField(slug, value)
}
async function nextStep(): Promise<void> {
if (!isActiveStepValid.value) return
if (currentStep.value < steps.value.length - 1) {
currentStep.value++
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
function prevStep(): void {
if (currentStep.value > 0) {
currentStep.value--
window.scrollTo({ top: 0, behavior: 'smooth' })
}
}
async function onSaveDraft(): Promise<void> {
await draft.saveDraftNow()
if (!draft.saveError.value) {
showSaveToast.value = true
}
else {
const body = extractErrorBody(draft.saveError.value)
if (body?.code === 'RATE_LIMITED') rateLimitedToast.value = true
}
}
function onSubmitterNameUpdate(v: string): void {
draft.setSubmitterName(v)
if (submitterErrors.value.name) submitterErrors.value = { ...submitterErrors.value, name: undefined }
}
function onSubmitterEmailUpdate(v: string): void {
draft.setSubmitterEmail(v)
if (submitterErrors.value.email) submitterErrors.value = { ...submitterErrors.value, email: undefined }
}
function validateSubmitter(): boolean {
const errs: { name?: string; email?: string } = {}
const name = draft.submitterName.value.trim()
const email = draft.submitterEmail.value.trim()
if (!name) errs.name = 'Vul je naam in.'
if (!email) errs.email = 'Vul je e-mailadres in.'
else if (emailValidator(email) !== true) errs.email = 'Vul een geldig e-mailadres in.'
submitterErrors.value = errs
return !errs.name && !errs.email
}
async function onSubmit(): Promise<void> {
serverFieldErrors.value = {}
if (!validateSubmitter()) {
const idx = steps.value.findIndex(s => s.kind === 'submitter')
if (idx >= 0) currentStep.value = idx
submitterToast.value = true
return
}
const result = await draft.submitForm()
if (result) {
justSubmitted.value = true
window.scrollTo({ top: 0 })
return
}
const err = draft.submitError.value
const body = extractErrorBody(err)
if (body?.errors) serverFieldErrors.value = body.errors
if (body?.code === 'RATE_LIMITED') rateLimitedToast.value = true
}
function serverErrorFor(slug: string): string[] {
return serverFieldErrors.value[slug] ?? serverFieldErrors.value[`values.${slug}`] ?? []
}
function retryFetch(): void {
void schemaQuery.refetch()
}
function answerableForReview(field: PublicFormField): boolean {
return field.field_type !== FormFieldType.HEADING
&& field.field_type !== FormFieldType.PARAGRAPH
}
function formatReviewValue(field: PublicFormField): string {
const v = draft.values.value[field.slug]
if (v === null || v === undefined || v === '') return '—'
if (Array.isArray(v)) return v.length > 0 ? v.map(String).join(', ') : '—'
if (typeof v === 'boolean') return v ? 'Ja' : 'Nee'
return String(v)
}
</script>
<template>
<div class="public-form-page">
<!-- Terminal fetch error -->
<FormErrorState
v-if="terminalErrorCode"
:error-code="terminalErrorCode"
:show-retry="false"
/>
<!-- Generic fetch error (retryable) -->
<FormErrorState
v-else-if="schemaQuery.isError.value && !schemaQuery.data.value"
@retry="retryFetch"
/>
<!-- Loading -->
<div
v-else-if="schemaQuery.isLoading.value || !schemaQuery.data.value"
class="d-flex justify-center pa-4"
>
<VCard
flat
:max-width="720"
class="w-100 pa-4"
>
<VSkeletonLoader type="article" />
</VCard>
</div>
<!-- Already-submitted terminal -->
<FormErrorState
v-else-if="submitErrorCode === 'SUBMISSION_ALREADY_SUBMITTED'"
error-code="SUBMISSION_ALREADY_SUBMITTED"
:show-retry="false"
/>
<!-- Post-submit confirmation -->
<FormConfirmation
v-else-if="justSubmitted"
:steps="steps"
:values="draft.values.value"
:submitter-name="draft.submitterName.value"
:submitter-email="draft.submitterEmail.value"
/>
<!-- Data state -->
<div v-else>
<!-- Save-status top bar -->
<div
v-if="saveStatusText || draft.saveError.value"
class="d-flex justify-end px-4 pt-3"
>
<VChip
v-if="saveStatusText"
size="small"
variant="tonal"
:color="draft.isSaving.value ? 'primary' : 'success'"
>
<template
v-if="draft.isSaving.value"
#prepend
>
<VProgressCircular
indeterminate
size="14"
width="2"
class="me-2"
/>
</template>
{{ saveStatusText }}
</VChip>
<VChip
v-else-if="draft.saveError.value"
size="small"
variant="tonal"
color="warning"
>
<VIcon
start
icon="tabler-cloud-off"
size="16"
/>
Opslaan mislukt
</VChip>
</div>
<VContainer class="public-form-container">
<VCard
v-if="schemaQuery.data.value"
flat
class="pa-4 mb-4"
>
<h1 class="text-h5 mb-1">
{{ schemaQuery.data.value.name }}
</h1>
<p
v-if="schemaQuery.data.value.description"
class="text-body-2 text-medium-emphasis mb-0"
>
{{ schemaQuery.data.value.description }}
</p>
</VCard>
<!-- Stepper navigation -->
<VCard
flat
class="pa-4 mb-4"
>
<FormStepper
v-model:current-step="currentStep"
:steps="steps"
:is-active-step-valid="isActiveStepValid"
/>
</VCard>
<!-- Current step -->
<VCard
flat
class="pa-4 pa-sm-6"
>
<div
v-if="activeStep"
class="mb-6"
>
<p class="text-caption text-medium-emphasis mb-1">
Stap {{ currentStep + 1 }} van {{ steps.length }}
</p>
<h2 class="text-h5 mb-1">
{{ activeStep.title }}
</h2>
<p
v-if="activeStep.subtitle"
class="text-body-2 text-medium-emphasis mb-0"
>
{{ activeStep.subtitle }}
</p>
</div>
<!-- Submitter step -->
<SubmitterDetails
v-if="activeStep && activeStep.kind === 'submitter'"
:name="draft.submitterName.value"
:email="draft.submitterEmail.value"
:errors="submitterErrors"
@update:name="onSubmitterNameUpdate"
@update:email="onSubmitterEmailUpdate"
@blur="draft.saveDraftNow"
/>
<!-- Review step -->
<div v-else-if="activeStep && activeStep.kind === 'review'">
<VRow>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
Naam
</p>
<p class="text-body-2 mb-0">
{{ draft.submitterName.value || '—' }}
</p>
</VCol>
<VCol
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
E-mailadres
</p>
<p class="text-body-2 mb-0">
{{ draft.submitterEmail.value || '—' }}
</p>
</VCol>
</VRow>
<template
v-for="step in steps"
:key="step.key"
>
<template v-if="step.kind !== 'submitter' && step.kind !== 'review'">
<VDivider class="my-5" />
<h3 class="text-subtitle-1 font-weight-medium mb-3">
{{ step.title }}
</h3>
<VRow>
<VCol
v-for="field in step.fields.filter(answerableForReview)"
:key="field.id"
cols="12"
sm="6"
>
<p class="text-caption text-medium-emphasis mb-1">
{{ field.label }}
</p>
<p class="text-body-2 mb-0">
{{ formatReviewValue(field) }}
</p>
</VCol>
</VRow>
</template>
</template>
</div>
<!-- Content steps -->
<div v-else-if="activeStep">
<VRow>
<FieldRenderer
v-for="field in activeStep.fields"
:key="field.id"
:field="field"
:model-value="draft.values.value[field.slug]"
:all-values="draft.values.value"
:error-messages="serverErrorFor(field.slug)"
@update:model-value="v => onFieldValue(field.slug, v)"
@blur="onFieldBlur(field.slug, draft.values.value[field.slug])"
/>
</VRow>
</div>
<VDivider class="my-6" />
<div class="d-flex flex-wrap justify-space-between align-center ga-3">
<VBtn
v-if="currentStep > 0"
variant="outlined"
color="secondary"
@click="prevStep"
>
<VIcon
icon="tabler-arrow-left"
start
/>
Vorige
</VBtn>
<div v-else />
<div class="d-flex flex-wrap ga-3">
<VBtn
v-if="activeStep && activeStep.kind !== 'review'"
variant="text"
color="primary"
:loading="draft.isSaving.value"
:disabled="!draft.submission.value"
@click="onSaveDraft"
>
Sla op als concept
</VBtn>
<VBtn
v-if="currentStep < steps.length - 1"
color="primary"
:disabled="!isActiveStepValid"
@click="nextStep"
>
Volgende
<VIcon
icon="tabler-arrow-right"
end
/>
</VBtn>
<VBtn
v-else
color="success"
:loading="draft.isSubmitting.value"
@click="onSubmit"
>
<VIcon
icon="tabler-send"
start
/>
Verstuur
</VBtn>
</div>
</div>
</VCard>
</VContainer>
</div>
<VSnackbar
v-model="showSaveToast"
:timeout="2500"
color="success"
location="top"
>
Concept opgeslagen.
</VSnackbar>
<VSnackbar
v-model="rateLimitedToast"
:timeout="4000"
color="warning"
location="top"
>
Even geduld, we proberen het zo opnieuw.
</VSnackbar>
<VSnackbar
v-model="submitterToast"
:timeout="3000"
color="error"
location="top"
>
Vul eerst je contactgegevens in
</VSnackbar>
</div>
</template>
<style scoped>
.public-form-container {
max-inline-size: 960px;
}
.public-form-page {
min-block-size: 100dvh;
}
</style>

View File

@@ -0,0 +1,190 @@
// Mirrors backend form builder enums and public resources.
// Source of truth: api/app/Enums/FormBuilder/* and
// api/app/Http/Resources/FormBuilder/PublicForm(Schema|Submission)Resource.php
export const FormFieldType = {
TEXT: 'TEXT',
TEXTAREA: 'TEXTAREA',
EMAIL: 'EMAIL',
PHONE: 'PHONE',
NUMBER: 'NUMBER',
DATE: 'DATE',
DATETIME: 'DATETIME',
BOOLEAN: 'BOOLEAN',
RADIO: 'RADIO',
SELECT: 'SELECT',
MULTISELECT: 'MULTISELECT',
CHECKBOX_LIST: 'CHECKBOX_LIST',
FILE_UPLOAD: 'FILE_UPLOAD',
IMAGE_UPLOAD: 'IMAGE_UPLOAD',
SIGNATURE: 'SIGNATURE',
TAG_PICKER: 'TAG_PICKER',
HEADING: 'HEADING',
PARAGRAPH: 'PARAGRAPH',
URL: 'URL',
SECTION_PRIORITY: 'SECTION_PRIORITY',
AVAILABILITY_PICKER: 'AVAILABILITY_PICKER',
TABLE_ROWS: 'TABLE_ROWS',
} as const
export type FormFieldType = typeof FormFieldType[keyof typeof FormFieldType]
// Backend only ships 'full' | 'half' today; 'third' | 'quarter' are
// forward-compat placeholders matching the future ARCH design. Layout
// maps unknown widths to full width.
export type FormFieldDisplayWidth = 'full' | 'half' | 'third' | 'quarter'
export const ConditionalOperator = {
equals: 'equals',
not_equals: 'not_equals',
contains: 'contains',
not_contains: 'not_contains',
in: 'in',
not_in: 'not_in',
greater_than: 'greater_than',
less_than: 'less_than',
empty: 'empty',
not_empty: 'not_empty',
} as const
export type ConditionalOperator = typeof ConditionalOperator[keyof typeof ConditionalOperator]
export interface ConditionalRule {
field_slug: string
operator: ConditionalOperator
value?: unknown
}
export interface ConditionalGroup {
all?: Array<ConditionalRule | ConditionalGroup>
any?: Array<ConditionalRule | ConditionalGroup>
}
export interface ConditionalLogic {
show_when?: ConditionalGroup
}
export interface FormFieldValidationRules {
min?: number
max?: number
pattern?: string
min_selections?: number
max_selections?: number
tag_categories?: string[]
[key: string]: unknown
}
export type FieldOption = string | { label: string; description?: string | null; value?: string | number | null }
export interface AvailableTag {
id: string
name: string
category: string
}
export interface PublicFormField {
id: string
slug: string
field_type: FormFieldType
label: string
help_text: string | null
options: FieldOption[] | null
available_tags: AvailableTag[] | null
validation_rules: FormFieldValidationRules | null
is_required: boolean
display_width: FormFieldDisplayWidth
conditional_logic: ConditionalLogic | null
sort_order: number
form_schema_section_id: string | null
}
export interface PublicFormSection {
id: string
slug: string
name: string
description: string | null
sort_order: number
}
export interface PublicFormSchema {
id: string
name: string
slug: string
purpose: string
description: string | null
locale: string
version: number
opened_at: string
consent_version: string | null
submission_deadline: string | null
section_level_submit: boolean
sections: PublicFormSection[]
fields: PublicFormField[]
}
export type FormSubmissionStatus = 'draft' | 'submitted' | 'reviewed' | 'rejected' | 'approved'
export interface PublicFormSubmissionValue {
value: unknown
value_anonymised: boolean
}
export interface PublicFormSubmissionIdentityMatch {
status: 'pending' | 'matched' | 'none'
message: string
}
export interface PublicFormSubmission {
id: string
form_schema_id: string
status: FormSubmissionStatus
auto_save_count: number
submitted_in_locale: string | null
schema_version_at_submit: number | null
schema_drift: boolean
values: Record<string, PublicFormSubmissionValue>
identity_match: PublicFormSubmissionIdentityMatch | null
opened_at: string | null
first_interacted_at: string | null
submitted_at: string | null
submission_duration_seconds: number | null
created_at: string | null
updated_at: string | null
}
export type FormErrorCode =
| 'TOKEN_EXPIRED'
| 'TOKEN_REVOKED'
| 'SCHEMA_UNPUBLISHED'
| 'SCHEMA_NOT_FOUND'
| 'SUBMISSION_ALREADY_SUBMITTED'
| 'SUBMISSION_NOT_FOUND'
| 'RATE_LIMITED'
export interface PublicFormErrorBody {
message: string
code?: FormErrorCode | string
errors?: Record<string, string[]>
}
export type FormValues = Record<string, unknown>
export interface StartDraftBody {
idempotency_key: string
opened_at?: string
submitted_in_locale?: string
public_submitter_name?: string
public_submitter_email?: string
}
export interface SaveDraftBody {
values?: FormValues
first_interacted_at?: string
public_submitter_name?: string
public_submitter_email?: string
}
export interface SubmitBody {
values?: FormValues
captcha_token?: string
public_submitter_name?: string
public_submitter_email?: string
}

View File

@@ -0,0 +1,174 @@
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
vi.mock('@/lib/axios', () => {
const post = vi.fn()
const put = vi.fn()
const get = vi.fn()
return { apiClient: { post, put, get } }
})
import { apiClient } from '@/lib/axios'
import { draftIdempotencyKey, useFormDraft } from '@/composables/useFormDraft'
interface MockedApi {
post: ReturnType<typeof vi.fn>
put: ReturnType<typeof vi.fn>
get: ReturnType<typeof vi.fn>
}
const mocked = apiClient as unknown as MockedApi
function mountWithDraft(token: string, options?: Parameters<typeof useFormDraft>[1]) {
const draftRef: { value: ReturnType<typeof useFormDraft> | null } = { value: null }
const Host = defineComponent({
setup() {
draftRef.value = useFormDraft(ref(token), options)
return () => h('div')
},
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
const wrapper = mount(Host, {
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
})
return { wrapper, draftRef }
}
function submissionFixture(id: string) {
return {
id,
form_schema_id: 'sc_1',
status: 'draft',
auto_save_count: 0,
submitted_in_locale: 'nl',
schema_version_at_submit: null,
schema_drift: false,
values: {},
identity_match: null,
opened_at: null,
first_interacted_at: null,
submitted_at: null,
submission_duration_seconds: null,
created_at: null,
updated_at: null,
}
}
function apiSuccess<T>(body: T) {
return { data: { data: body, message: 'ok', success: true } }
}
function flush(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}
describe('useFormDraft', () => {
const TOKEN = 'TKN123'
beforeEach(() => {
sessionStorage.clear()
vi.clearAllMocks()
})
afterEach(() => {
sessionStorage.clear()
})
it('derives the sessionStorage key from the token', () => {
expect(draftIdempotencyKey('abc')).toBe('draft_idem:abc')
})
it('generates and stores an idempotency_key on first start', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value
expect(draft).toBeTruthy()
await draft!.start()
await flush()
expect(mocked.post).toHaveBeenCalledTimes(1)
const [, body] = mocked.post.mock.calls[0]
expect(body.idempotency_key).toMatch(/^[a-f0-9]{8,30}$/i)
expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBe(body.idempotency_key)
wrapper.unmount()
})
it('reuses the idempotency_key across remounts in the same session', async () => {
mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1')))
const first = mountWithDraft(TOKEN)
await first.draftRef.value!.start()
await flush()
const firstKey = mocked.post.mock.calls[0][1].idempotency_key
first.wrapper.unmount()
mocked.post.mockClear()
mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1')))
const second = mountWithDraft(TOKEN)
await second.draftRef.value!.start()
await flush()
const secondKey = mocked.post.mock.calls[0][1].idempotency_key
expect(secondKey).toBe(firstKey)
second.wrapper.unmount()
})
it('debounces field saves and flushes on saveDraftNow', async () => {
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
mocked.put.mockResolvedValue(apiSuccess({ ...submissionFixture('s1'), auto_save_count: 1 }))
const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 })
const draft = draftRef.value!
await draft.start()
await flush()
expect(mocked.put).not.toHaveBeenCalled()
draft.saveField('name', 'Alice')
draft.saveField('name', 'Alice B')
expect(mocked.put).not.toHaveBeenCalled()
await draft.saveDraftNow()
expect(mocked.put).toHaveBeenCalledTimes(1)
const [, body] = mocked.put.mock.calls[0]
expect(body.values).toEqual({ name: 'Alice B' })
wrapper.unmount()
})
it('clears the sessionStorage key after a successful submit', async () => {
mocked.post
.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start draft
.mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' })) // submit
const { draftRef, wrapper } = mountWithDraft(TOKEN)
const draft = draftRef.value!
await draft.start()
await flush()
expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeTruthy()
// submitForm guards against empty submitter fields — populate them
// so the composable actually hits the submit endpoint.
draft.setSubmitterName('Test')
draft.setSubmitterEmail('test@example.nl')
const submitted = await draft.submitForm()
await flush()
expect(submitted?.status).toBe('submitted')
expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeNull()
wrapper.unmount()
})
})