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