refactor(portal): move composables, types, schemas; drop duplicates
Composables (apps/portal/src/composables → apps/app/src/composables/):
- useFormDraft, publicFormInjection → composables/ (root, used by
shared/public-form components)
- api/usePublicForm, api/usePublicFormSections,
api/usePublicFormTimeSlots → composables/api/ (no collisions)
- api/usePortalShifts, api/usePortalProfile, api/useVolunteerRegistration
→ composables/api/portal/ (subfolder per WS-3 PR-B1 charter to
leave room for organizer-side namesakes without clashes)
- api/useMfa → DELETED (apps/app version is a strict superset
with extra invalidateQueries calls and the admin-reset mutation)
Types (apps/portal/src/types → apps/app/src/types/):
- api, portal-shift, portal, registration → moved
- mfa → DELETED (byte-identical to apps/app/src/types/mfa.ts)
Schemas:
- apps/portal/src/schemas/registrationSchema.ts → apps/app/src/schemas/
Utils:
- deviceFingerprint, paginationMeta → DELETED (byte-identical
duplicates already in apps/app/src/utils/)
Lib:
- apps/portal/src/lib/{axios,query-client}.ts → DELETED. apps/app's
callback-bound axios (post-PR-A) and query-client are the
canonical versions. Portal pages currently importing
`@/lib/axios#apiClient` resolve to apps/app's apiClient with no
behavioral change for cookie-based requests.
Tests: 4 composable specs (useFormDraft x2, usePublicFormSections,
usePublicFormTimeSlots) moved into __tests__/ subdirs alongside
their composables.
@form-schema imports inside the moved files rewritten to
@/composables/forms/*.
Vitest now: 23 files / 162 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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 { draftSubmitterStorageKey, 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 - submitter details', () => {
|
||||
const TOKEN = 'TKN-sub'
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it('starts with empty submitterName and submitterEmail', () => {
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN)
|
||||
const draft = draftRef.value!
|
||||
|
||||
expect(draft.submitterName.value).toBe('')
|
||||
expect(draft.submitterEmail.value).toBe('')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('setSubmitterName marks the draft dirty — subsequent flush includes public_submitter_name in the PUT body', async () => {
|
||||
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
|
||||
mocked.put.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
|
||||
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 })
|
||||
const draft = draftRef.value!
|
||||
|
||||
await draft.start()
|
||||
await flush()
|
||||
|
||||
draft.setSubmitterName('Test User')
|
||||
await draft.saveDraftNow()
|
||||
|
||||
expect(mocked.put).toHaveBeenCalledTimes(1)
|
||||
const [, body] = mocked.put.mock.calls[0]
|
||||
expect(body.public_submitter_name).toBe('Test User')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('setSubmitterEmail flows through to the PUT body on flush', async () => {
|
||||
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
|
||||
mocked.put.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
|
||||
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN, { debounceMs: 200 })
|
||||
const draft = draftRef.value!
|
||||
|
||||
await draft.start()
|
||||
await flush()
|
||||
|
||||
draft.setSubmitterEmail('user@example.nl')
|
||||
await draft.saveDraftNow()
|
||||
|
||||
expect(mocked.put).toHaveBeenCalledTimes(1)
|
||||
const [, body] = mocked.put.mock.calls[0]
|
||||
expect(body.public_submitter_email).toBe('user@example.nl')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('submitForm sends submitter name and email in the POST /submit body', async () => {
|
||||
mocked.post
|
||||
.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start
|
||||
.mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' })) // submit
|
||||
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN)
|
||||
const draft = draftRef.value!
|
||||
|
||||
await draft.start()
|
||||
await flush()
|
||||
|
||||
draft.setSubmitterName('Alice')
|
||||
draft.setSubmitterEmail('alice@example.nl')
|
||||
|
||||
const result = await draft.submitForm()
|
||||
await flush()
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(mocked.post).toHaveBeenCalledTimes(2)
|
||||
const [, submitBody] = mocked.post.mock.calls[1]
|
||||
expect(submitBody.public_submitter_name).toBe('Alice')
|
||||
expect(submitBody.public_submitter_email).toBe('alice@example.nl')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// Contract: if submitter fields are empty at submit time, submitForm
|
||||
// returns null, sets submitError with code 'MISSING_SUBMITTER', and
|
||||
// does NOT hit the submit endpoint — the page is expected to read
|
||||
// submitError and bounce the user back to the Contactgegevens step.
|
||||
it('submitForm returns null and sets submitError when submitter fields are empty', async () => {
|
||||
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1'))) // start only
|
||||
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN)
|
||||
const draft = draftRef.value!
|
||||
|
||||
await draft.start()
|
||||
await flush()
|
||||
expect(mocked.post).toHaveBeenCalledTimes(1)
|
||||
|
||||
const result = await draft.submitForm()
|
||||
|
||||
expect(result).toBeNull()
|
||||
const err = draft.submitError.value as { code?: string } | null
|
||||
expect(err).toBeTruthy()
|
||||
expect(err?.code).toBe('MISSING_SUBMITTER')
|
||||
// Submit endpoint was NOT called — only the start POST is in the log.
|
||||
expect(mocked.post).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('submitForm refuses to submit when only one of name/email is set', async () => {
|
||||
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
|
||||
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN)
|
||||
const draft = draftRef.value!
|
||||
|
||||
await draft.start()
|
||||
await flush()
|
||||
|
||||
draft.setSubmitterName('Bob')
|
||||
// email intentionally left empty
|
||||
|
||||
const result = await draft.submitForm()
|
||||
expect(result).toBeNull()
|
||||
const err = draft.submitError.value as { code?: string } | null
|
||||
expect(err?.code).toBe('MISSING_SUBMITTER')
|
||||
expect(mocked.post).toHaveBeenCalledTimes(1)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('restores submitter details from sessionStorage on start and includes them in the initial POST body', async () => {
|
||||
sessionStorage.setItem(
|
||||
draftSubmitterStorageKey(TOKEN),
|
||||
JSON.stringify({ name: 'Resumed', email: 'resumed@example.nl' }),
|
||||
)
|
||||
mocked.post.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
|
||||
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN)
|
||||
const draft = draftRef.value!
|
||||
|
||||
await draft.start()
|
||||
await flush()
|
||||
|
||||
expect(draft.submitterName.value).toBe('Resumed')
|
||||
expect(draft.submitterEmail.value).toBe('resumed@example.nl')
|
||||
|
||||
const [, body] = mocked.post.mock.calls[0]
|
||||
expect(body.public_submitter_name).toBe('Resumed')
|
||||
expect(body.public_submitter_email).toBe('resumed@example.nl')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('persists submitter details to sessionStorage when set', () => {
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN)
|
||||
const draft = draftRef.value!
|
||||
|
||||
draft.setSubmitterName('Persist Me')
|
||||
draft.setSubmitterEmail('persist@example.nl')
|
||||
|
||||
const raw = sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))
|
||||
expect(raw).toBeTruthy()
|
||||
const parsed = JSON.parse(raw as string)
|
||||
expect(parsed).toEqual({ name: 'Persist Me', email: 'persist@example.nl' })
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('clears the submitter sessionStorage entry after a successful submit', async () => {
|
||||
mocked.post
|
||||
.mockResolvedValueOnce(apiSuccess(submissionFixture('s1')))
|
||||
.mockResolvedValueOnce(apiSuccess({ ...submissionFixture('s1'), status: 'submitted' }))
|
||||
|
||||
const { draftRef, wrapper } = mountWithDraft(TOKEN)
|
||||
const draft = draftRef.value!
|
||||
|
||||
await draft.start()
|
||||
await flush()
|
||||
|
||||
draft.setSubmitterName('Cleaner')
|
||||
draft.setSubmitterEmail('cleaner@example.nl')
|
||||
expect(sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))).toBeTruthy()
|
||||
|
||||
await draft.submitForm()
|
||||
await flush()
|
||||
|
||||
expect(sessionStorage.getItem(draftSubmitterStorageKey(TOKEN))).toBeNull()
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
174
apps/app/src/composables/__tests__/useFormDraft.test.ts
Normal file
174
apps/app/src/composables/__tests__/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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
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', () => ({
|
||||
apiClient: { get: vi.fn() },
|
||||
}))
|
||||
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
||||
import type { PublicFormSectionOption } from '@form-schema/types/formBuilder'
|
||||
|
||||
interface MockedApi { get: ReturnType<typeof vi.fn> }
|
||||
const mocked = apiClient as unknown as MockedApi
|
||||
|
||||
function mountHook(tokenValue: string) {
|
||||
const result: { query: ReturnType<typeof usePublicFormSections> | null } = { query: null }
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
result.query = usePublicFormSections(ref(tokenValue))
|
||||
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = mount(Host, {
|
||||
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
|
||||
})
|
||||
|
||||
return { wrapper, result }
|
||||
}
|
||||
|
||||
function section(partial: Partial<PublicFormSectionOption> = {}): PublicFormSectionOption {
|
||||
return {
|
||||
id: partial.id ?? '01B',
|
||||
name: partial.name ?? 'Bar',
|
||||
category: partial.category ?? 'Horeca',
|
||||
icon: partial.icon ?? 'tabler-beer',
|
||||
registration_description: partial.registration_description ?? 'Tappen en serveren',
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('usePublicFormSections', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
afterEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('fetches and parses PublicFormSectionOption[] on happy path', async () => {
|
||||
const s = section()
|
||||
mocked.get.mockResolvedValueOnce({ data: { data: [s] } })
|
||||
|
||||
const { result } = mountHook('TKN42')
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(mocked.get).toHaveBeenCalledWith('/public/forms/TKN42/sections')
|
||||
expect(result.query?.data.value).toEqual([s])
|
||||
})
|
||||
|
||||
it('is disabled when the token ref is empty', async () => {
|
||||
const { result } = mountHook('')
|
||||
await flush()
|
||||
|
||||
expect(mocked.get).not.toHaveBeenCalled()
|
||||
expect(result.query?.isFetching.value).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces errors via isError', async () => {
|
||||
mocked.get.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
const { result } = mountHook('TKN99')
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(result.query?.isError.value).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
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', () => ({
|
||||
apiClient: { get: vi.fn() },
|
||||
}))
|
||||
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
||||
import type { PublicFormTimeSlot } from '@form-schema/types/formBuilder'
|
||||
|
||||
interface MockedApi { get: ReturnType<typeof vi.fn> }
|
||||
const mocked = apiClient as unknown as MockedApi
|
||||
|
||||
function mountHook(tokenValue: string) {
|
||||
const result: { query: ReturnType<typeof usePublicFormTimeSlots> | null } = { query: null }
|
||||
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
result.query = usePublicFormTimeSlots(ref(tokenValue))
|
||||
|
||||
return () => h('div')
|
||||
},
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
const wrapper = mount(Host, {
|
||||
global: { plugins: [[VueQueryPlugin, { queryClient }]] },
|
||||
})
|
||||
|
||||
return { wrapper, result }
|
||||
}
|
||||
|
||||
function timeSlot(partial: Partial<PublicFormTimeSlot> = {}): PublicFormTimeSlot {
|
||||
return {
|
||||
id: partial.id ?? '01A',
|
||||
name: partial.name ?? 'Zaterdag middag',
|
||||
date: partial.date ?? '2026-07-11',
|
||||
start_time: partial.start_time ?? '12:00:00',
|
||||
end_time: partial.end_time ?? '18:00:00',
|
||||
duration_hours: partial.duration_hours ?? 6,
|
||||
event_id: partial.event_id ?? 'evt_1',
|
||||
event_name: partial.event_name ?? 'Echt Feesten 2026',
|
||||
}
|
||||
}
|
||||
|
||||
function flush(): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('usePublicFormTimeSlots', () => {
|
||||
beforeEach(() => { vi.clearAllMocks() })
|
||||
afterEach(() => { vi.clearAllMocks() })
|
||||
|
||||
it('fetches and parses PublicFormTimeSlot[] on happy path', async () => {
|
||||
const slot = timeSlot()
|
||||
mocked.get.mockResolvedValueOnce({ data: { data: [slot] } })
|
||||
|
||||
const { result } = mountHook('TKN42')
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(mocked.get).toHaveBeenCalledWith('/public/forms/TKN42/time-slots')
|
||||
expect(result.query?.data.value).toEqual([slot])
|
||||
})
|
||||
|
||||
it('is disabled when the token ref is empty', async () => {
|
||||
const { result } = mountHook('')
|
||||
await flush()
|
||||
|
||||
expect(mocked.get).not.toHaveBeenCalled()
|
||||
expect(result.query?.isFetching.value).toBe(false)
|
||||
})
|
||||
|
||||
it('surfaces errors via isError', async () => {
|
||||
mocked.get.mockRejectedValueOnce(new Error('network'))
|
||||
|
||||
const { result } = mountHook('TKN99')
|
||||
await flush()
|
||||
await flush()
|
||||
|
||||
expect(result.query?.isError.value).toBe(true)
|
||||
})
|
||||
})
|
||||
47
apps/app/src/composables/api/portal/usePortalProfile.ts
Normal file
47
apps/app/src/composables/api/portal/usePortalProfile.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useMutation } from '@tanstack/vue-query'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export interface ProfileUpdatePayload {
|
||||
event_id: string
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
phone?: string | null
|
||||
date_of_birth?: string | null
|
||||
remarks?: string | null
|
||||
}
|
||||
|
||||
export interface PasswordUpdatePayload {
|
||||
current_password: string
|
||||
password: string
|
||||
password_confirmation: string
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
return useMutation({
|
||||
mutationFn: async (payload: ProfileUpdatePayload) => {
|
||||
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
|
||||
'/portal/profile',
|
||||
payload,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdatePassword() {
|
||||
return useMutation({
|
||||
mutationFn: async (payload: PasswordUpdatePayload) => {
|
||||
const { data } = await apiClient.put<ApiResponse<{ message: string }>>(
|
||||
'/portal/password',
|
||||
payload,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
86
apps/app/src/composables/api/portal/usePortalShifts.ts
Normal file
86
apps/app/src/composables/api/portal/usePortalShifts.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import type { Ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type { AllMyShiftsEventGroup, AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export function useAllMyShifts() {
|
||||
return useQuery({
|
||||
queryKey: ['portal-all-my-shifts'],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<ApiResponse<AllMyShiftsEventGroup[]>>(
|
||||
'/portal/my-shifts',
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useAvailableShifts(eventId: Ref<string | null>) {
|
||||
return useQuery({
|
||||
queryKey: ['available-shifts', eventId],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<ApiResponse<AvailableShiftsDay[]>>(
|
||||
`/portal/events/${eventId.value}/available-shifts`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!eventId.value,
|
||||
})
|
||||
}
|
||||
|
||||
export function useMyShifts(eventId: Ref<string | null>) {
|
||||
return useQuery({
|
||||
queryKey: ['my-shifts', eventId],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<ApiResponse<MyShiftsResponse>>(
|
||||
`/portal/events/${eventId.value}/my-shifts`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!eventId.value,
|
||||
})
|
||||
}
|
||||
|
||||
export function useClaimShift(eventId: Ref<string | null>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (shiftId: string) => {
|
||||
const { data } = await apiClient.post<ApiResponse<{ assignment_id: string; status: string; message: string }>>(
|
||||
`/portal/events/${eventId.value}/shifts/${shiftId}/claim`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['available-shifts', eventId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['my-shifts', eventId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useCancelAssignment(eventId: Ref<string | null>) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ assignmentId, reason }: { assignmentId: string; reason?: string }) => {
|
||||
const { data } = await apiClient.post<ApiResponse<{ message: string }>>(
|
||||
`/portal/events/${eventId.value}/assignments/${assignmentId}/cancel`,
|
||||
reason ? { reason } : {},
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['available-shifts', eventId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['my-shifts', eventId] })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useQuery, useMutation } from '@tanstack/vue-query'
|
||||
import type { Ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type { EventRegistrationData, VolunteerRegistrationForm } from '@/types/registration'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
export function useRegistrationData(eventSlug: Ref<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['registration-data', eventSlug],
|
||||
queryFn: async () => {
|
||||
const { data } = await apiClient.get<ApiResponse<EventRegistrationData>>(
|
||||
`/public/events/${eventSlug.value}/registration-data`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!eventSlug.value,
|
||||
retry: false,
|
||||
})
|
||||
}
|
||||
|
||||
export interface VolunteerRegistrationResponse {
|
||||
person: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function useSubmitRegistration() {
|
||||
return useMutation({
|
||||
mutationFn: async ({ eventId, form }: { eventId: string; form: VolunteerRegistrationForm }) => {
|
||||
const { data } = await apiClient.post<ApiResponse<VolunteerRegistrationResponse>>(
|
||||
`/events/${eventId}/volunteer-register`,
|
||||
form,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
110
apps/app/src/composables/api/usePublicForm.ts
Normal file
110
apps/app/src/composables/api/usePublicForm.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import type { AxiosError } from 'axios'
|
||||
import type { MaybeRefOrGetter } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type {
|
||||
PublicFormErrorBody,
|
||||
PublicFormSchema,
|
||||
PublicFormSubmission,
|
||||
SaveDraftBody,
|
||||
StartDraftBody,
|
||||
SubmitBody,
|
||||
} from '@form-schema/types/formBuilder'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
message?: string
|
||||
success?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend standardises public-form errors as
|
||||
* { message, code?, errors? }
|
||||
* See api/app/Exceptions/FormBuilder/PublicFormApiException.php.
|
||||
*/
|
||||
export type PublicFormAxiosError = AxiosError<PublicFormErrorBody>
|
||||
|
||||
const TERMINAL_STATUSES = new Set([404, 410, 409])
|
||||
|
||||
export function useFetchPublicFormSchema(token: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return useQuery({
|
||||
queryKey: ['public-form-schema', () => toValue(token)],
|
||||
queryFn: async (): Promise<PublicFormSchema> => {
|
||||
const t = toValue(token)
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.get<ApiResponse<PublicFormSchema>>(`/public/forms/${t}`)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: () => !!toValue(token),
|
||||
retry: (failureCount, error) => {
|
||||
const status = (error as PublicFormAxiosError | undefined)?.response?.status
|
||||
if (status && TERMINAL_STATUSES.has(status)) return false
|
||||
|
||||
return failureCount < 1
|
||||
},
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
}
|
||||
|
||||
export function useCreateFormDraft(token: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return useMutation({
|
||||
mutationFn: async (body: StartDraftBody): Promise<PublicFormSubmission> => {
|
||||
const t = toValue(token)
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.post<ApiResponse<PublicFormSubmission>>(
|
||||
`/public/forms/${t}/submissions`,
|
||||
body,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSaveFormDraft(token: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ submissionId, body }: { submissionId: string; body: SaveDraftBody }): Promise<PublicFormSubmission> => {
|
||||
const t = toValue(token)
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.put<ApiResponse<PublicFormSubmission>>(
|
||||
`/public/forms/${t}/submissions/${submissionId}`,
|
||||
body,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useSubmitForm(token: MaybeRefOrGetter<string | null | undefined>) {
|
||||
return useMutation({
|
||||
mutationFn: async ({ submissionId, body }: { submissionId: string; body: SubmitBody }): Promise<PublicFormSubmission> => {
|
||||
const t = toValue(token)
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.post<ApiResponse<PublicFormSubmission>>(
|
||||
`/public/forms/${t}/submissions/${submissionId}/submit`,
|
||||
body,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function extractErrorBody(err: unknown): PublicFormErrorBody | null {
|
||||
const axiosErr = err as PublicFormAxiosError | undefined
|
||||
const body = axiosErr?.response?.data
|
||||
if (body && typeof body === 'object' && 'message' in body) return body as PublicFormErrorBody
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractRetryAfterSeconds(err: unknown): number | null {
|
||||
const axiosErr = err as PublicFormAxiosError | undefined
|
||||
const raw = axiosErr?.response?.headers?.['retry-after']
|
||||
if (!raw) return null
|
||||
const n = Number(raw)
|
||||
|
||||
return Number.isFinite(n) && n >= 0 ? n : null
|
||||
}
|
||||
27
apps/app/src/composables/api/usePublicFormSections.ts
Normal file
27
apps/app/src/composables/api/usePublicFormSections.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import type { Ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type { PublicFormSectionOption } from '@form-schema/types/formBuilder'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
// Sibling endpoint for SECTION_PRIORITY — festival-aware and dedup-by-name
|
||||
// per PublicFormController::sections (show_in_registration=true, standard).
|
||||
export function usePublicFormSections(token: Ref<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['public-form', token, 'sections'],
|
||||
queryFn: async (): Promise<PublicFormSectionOption[]> => {
|
||||
const t = token.value
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.get<ApiResponse<PublicFormSectionOption[]>>(
|
||||
`/public/forms/${t}/sections`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: computed(() => !!token.value),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
}
|
||||
28
apps/app/src/composables/api/usePublicFormTimeSlots.ts
Normal file
28
apps/app/src/composables/api/usePublicFormTimeSlots.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import type { Ref } from 'vue'
|
||||
import { apiClient } from '@/lib/axios'
|
||||
import type { PublicFormTimeSlot } from '@form-schema/types/formBuilder'
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
// Sibling endpoint for AVAILABILITY_PICKER — festival-aware per
|
||||
// PublicFormController::timeSlots (parent + children, VOLUNTEER only).
|
||||
// Cached for 5 minutes; data is effectively static during a session.
|
||||
export function usePublicFormTimeSlots(token: Ref<string>) {
|
||||
return useQuery({
|
||||
queryKey: ['public-form', token, 'time-slots'],
|
||||
queryFn: async (): Promise<PublicFormTimeSlot[]> => {
|
||||
const t = token.value
|
||||
if (!t) throw new Error('Missing public_token')
|
||||
const { data } = await apiClient.get<ApiResponse<PublicFormTimeSlot[]>>(
|
||||
`/public/forms/${t}/time-slots`,
|
||||
)
|
||||
|
||||
return data.data
|
||||
},
|
||||
enabled: computed(() => !!token.value),
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})
|
||||
}
|
||||
37
apps/app/src/composables/publicFormInjection.ts
Normal file
37
apps/app/src/composables/publicFormInjection.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
import { computed, inject, provide } from 'vue'
|
||||
|
||||
// Page-level provide/inject for the public form token. Sibling-endpoint
|
||||
// fetches (time-slots, sections) read it instead of receiving it as a
|
||||
// prop through FieldRenderer, which would couple every renderer to every
|
||||
// new sibling resource.
|
||||
export const PUBLIC_FORM_TOKEN_KEY: InjectionKey<Ref<string>> = Symbol('PublicFormToken')
|
||||
|
||||
export function providePublicFormToken(token: Ref<string>): void {
|
||||
provide(PUBLIC_FORM_TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function usePublicFormToken(): Ref<string> {
|
||||
const token = inject(PUBLIC_FORM_TOKEN_KEY)
|
||||
if (!token) {
|
||||
throw new Error('usePublicFormToken: no token provided. Did you forget providePublicFormToken in the page?')
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
// Page-level provide/inject for the active form locale. Used by
|
||||
// option-bearing field renderers (FieldRadio / FieldSelect /
|
||||
// FieldMultiselect / FieldCheckboxList) to resolve per-option
|
||||
// translations[locale] over the default option label (WS-5d §17.6).
|
||||
// Falls back to 'nl' (Crewli's default schema locale) when no provider
|
||||
// is on the tree — keeps standalone component tests light.
|
||||
export const PUBLIC_FORM_LOCALE_KEY: InjectionKey<Ref<string>> = Symbol('PublicFormLocale')
|
||||
|
||||
export function providePublicFormLocale(locale: Ref<string>): void {
|
||||
provide(PUBLIC_FORM_LOCALE_KEY, locale)
|
||||
}
|
||||
|
||||
export function usePublicFormLocale(): Ref<string> {
|
||||
return inject(PUBLIC_FORM_LOCALE_KEY, computed(() => 'nl'))
|
||||
}
|
||||
356
apps/app/src/composables/useFormDraft.ts
Normal file
356
apps/app/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 '@form-schema/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 }
|
||||
Reference in New Issue
Block a user