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:
356
apps/portal/src/composables/useFormDraft.ts
Normal file
356
apps/portal/src/composables/useFormDraft.ts
Normal 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 }
|
||||||
535
apps/portal/src/pages/register/[public_token].vue
Normal file
535
apps/portal/src/pages/register/[public_token].vue
Normal 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>
|
||||||
190
apps/portal/src/types/formBuilder.ts
Normal file
190
apps/portal/src/types/formBuilder.ts
Normal 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
|
||||||
|
}
|
||||||
174
apps/portal/tests/composables/useFormDraft.test.ts
Normal file
174
apps/portal/tests/composables/useFormDraft.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user