From 9256c05db0755add4d88ce6185631516f0cc80cf Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 23 Apr 2026 20:00:40 +0200 Subject: [PATCH] feat(portal): implement TAG_PICKER, AVAILABILITY_PICKER, SECTION_PRIORITY field types - FieldTagPicker: VAutocomplete multiple with grouped category slots, empty/null category normalised to "Overig", empty-state info alert when the server delivers no tags. - FieldAvailabilityPicker: date-grouped checkbox list, festival-aware via usePublicFormTimeSlots. Event-name subheaders only surface when the time-slots span multiple events. Time format strips seconds. - FieldSectionPriority: tap-to-rank + drag-to-reorder via vuedraggable for desktop; mobile tap-only. Renumbers priorities on every mutation. Self-heals malformed modelValue. UI soft cap via validation_rules.max_priorities clamped to the backend hard cap of 5. - FieldRenderer: three new types removed from isStubbed. - publicFormInjection: page-level provide/inject for the public token. - IdentityMatchBanner: prefers backend-provided Dutch copy with frontend defaults as defensive fallback. - FormConfirmation wires the banner inline. - usePublicFormTimeSlots and usePublicFormSections TanStack composables. - 40 new Vitest assertions. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/portal/auto-imports.d.ts | 43 ++- apps/portal/components.d.ts | 4 + apps/portal/package.json | 1 + apps/portal/pnpm-lock.yaml | 18 + .../public-form/FieldAvailabilityPicker.vue | 229 ++++++++++++ .../components/public-form/FieldRenderer.vue | 35 +- .../public-form/FieldSectionPriority.vue | 343 ++++++++++++++++++ .../components/public-form/FieldTagPicker.vue | 115 ++++++ .../public-form/FormConfirmation.vue | 14 +- .../public-form/IdentityMatchBanner.vue | 64 ++++ .../composables/api/usePublicFormSections.ts | 27 ++ .../composables/api/usePublicFormTimeSlots.ts | 28 ++ .../src/composables/publicFormInjection.ts | 21 ++ .../src/pages/register/[public_token].vue | 7 + apps/portal/src/types/formBuilder.ts | 24 ++ .../FieldAvailabilityPicker.spec.ts | 182 ++++++++++ .../public-form/FieldRenderer.test.ts | 9 +- .../public-form/FieldSectionPriority.spec.ts | 237 ++++++++++++ .../public-form/FieldTagPicker.spec.ts | 148 ++++++++ .../public-form/IdentityMatchBanner.spec.ts | 59 +++ .../api/usePublicFormSections.spec.ts | 83 +++++ .../api/usePublicFormTimeSlots.spec.ts | 86 +++++ 22 files changed, 1768 insertions(+), 9 deletions(-) create mode 100644 apps/portal/src/components/public-form/FieldAvailabilityPicker.vue create mode 100644 apps/portal/src/components/public-form/FieldSectionPriority.vue create mode 100644 apps/portal/src/components/public-form/FieldTagPicker.vue create mode 100644 apps/portal/src/components/public-form/IdentityMatchBanner.vue create mode 100644 apps/portal/src/composables/api/usePublicFormSections.ts create mode 100644 apps/portal/src/composables/api/usePublicFormTimeSlots.ts create mode 100644 apps/portal/src/composables/publicFormInjection.ts create mode 100644 apps/portal/tests/components/public-form/FieldAvailabilityPicker.spec.ts create mode 100644 apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts create mode 100644 apps/portal/tests/components/public-form/FieldTagPicker.spec.ts create mode 100644 apps/portal/tests/components/public-form/IdentityMatchBanner.spec.ts create mode 100644 apps/portal/tests/composables/api/usePublicFormSections.spec.ts create mode 100644 apps/portal/tests/composables/api/usePublicFormTimeSlots.spec.ts diff --git a/apps/portal/auto-imports.d.ts b/apps/portal/auto-imports.d.ts index a4601439..9202cf73 100644 --- a/apps/portal/auto-imports.d.ts +++ b/apps/portal/auto-imports.d.ts @@ -10,6 +10,7 @@ declare global { const COOKIE_MAX_AGE_1_YEAR: typeof import('./src/utils/constants')['COOKIE_MAX_AGE_1_YEAR'] const CreateUrl: typeof import('./src/@core/composable/CreateUrl')['CreateUrl'] const EffectScope: typeof import('vue')['EffectScope'] + const PUBLIC_FORM_TOKEN_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY'] const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] const alphaDashValidator: typeof import('./src/@core/utils/validators')['alphaDashValidator'] const alphaValidator: typeof import('./src/@core/utils/validators')['alphaValidator'] @@ -121,6 +122,7 @@ declare global { const prefixWithPlus: typeof import('./src/@core/utils/formatters')['prefixWithPlus'] const provide: typeof import('vue')['provide'] const provideLocal: typeof import('@vueuse/core')['provideLocal'] + const providePublicFormToken: typeof import('./src/composables/publicFormInjection')['providePublicFormToken'] const reactify: typeof import('@vueuse/core')['reactify'] const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] const reactive: typeof import('vue')['reactive'] @@ -298,6 +300,7 @@ declare global { const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion'] const usePrevious: typeof import('@vueuse/core')['usePrevious'] const useProjection: typeof import('@vueuse/math')['useProjection'] + const usePublicFormToken: typeof import('./src/composables/publicFormInjection')['usePublicFormToken'] const useRafFn: typeof import('@vueuse/core')['useRafFn'] const useRefHistory: typeof import('@vueuse/core')['useRefHistory'] const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver'] @@ -389,6 +392,8 @@ declare module 'vue' { interface GlobalComponents {} interface ComponentCustomProperties { readonly EffectScope: UnwrapRef + readonly PUBLIC_FORM_TOKEN_KEY: UnwrapRef + readonly acceptHMRUpdate: UnwrapRef readonly alphaDashValidator: UnwrapRef readonly alphaValidator: UnwrapRef readonly asyncComputed: UnwrapRef @@ -405,8 +410,11 @@ declare module 'vue' { readonly controlledRef: UnwrapRef readonly createApp: UnwrapRef readonly createEventHook: UnwrapRef + readonly createGenericProjection: UnwrapRef readonly createGlobalState: UnwrapRef readonly createInjectionState: UnwrapRef + readonly createPinia: UnwrapRef + readonly createProjection: UnwrapRef readonly createReactiveFn: UnwrapRef readonly createReusableTemplate: UnwrapRef readonly createSharedComposable: UnwrapRef @@ -418,6 +426,8 @@ declare module 'vue' { readonly debouncedWatch: UnwrapRef readonly defineAsyncComponent: UnwrapRef readonly defineComponent: UnwrapRef + readonly definePage: UnwrapRef + readonly defineStore: UnwrapRef readonly draftIdempotencyKey: UnwrapRef readonly draftSubmitterStorageKey: UnwrapRef readonly eagerComputed: UnwrapRef @@ -430,6 +440,7 @@ declare module 'vue' { readonly formatDate: UnwrapRef readonly formatDateToMonthShort: UnwrapRef readonly generateDeviceFingerprint: UnwrapRef + readonly getActivePinia: UnwrapRef readonly getCurrentInstance: UnwrapRef readonly getCurrentScope: UnwrapRef readonly getDeviceName: UnwrapRef @@ -454,11 +465,21 @@ declare module 'vue' { readonly isToday: UnwrapRef readonly kFormatter: UnwrapRef readonly lengthValidator: UnwrapRef + readonly logicAnd: UnwrapRef + readonly logicNot: UnwrapRef + readonly logicOr: UnwrapRef readonly makeDestructurable: UnwrapRef + readonly mapActions: UnwrapRef + readonly mapGetters: UnwrapRef + readonly mapState: UnwrapRef + readonly mapStores: UnwrapRef + readonly mapWritableState: UnwrapRef readonly markRaw: UnwrapRef readonly nextTick: UnwrapRef readonly onActivated: UnwrapRef readonly onBeforeMount: UnwrapRef + readonly onBeforeRouteLeave: UnwrapRef + readonly onBeforeRouteUpdate: UnwrapRef readonly onBeforeUnmount: UnwrapRef readonly onBeforeUpdate: UnwrapRef readonly onClickOutside: UnwrapRef @@ -481,6 +502,7 @@ declare module 'vue' { readonly prefixWithPlus: UnwrapRef readonly provide: UnwrapRef readonly provideLocal: UnwrapRef + readonly providePublicFormToken: UnwrapRef readonly reactify: UnwrapRef readonly reactifyObject: UnwrapRef readonly reactive: UnwrapRef @@ -503,9 +525,12 @@ declare module 'vue' { readonly resolveVuetifyTheme: UnwrapRef readonly rgbaToHex: UnwrapRef readonly runValidators: UnwrapRef + readonly setActivePinia: UnwrapRef + readonly setMapStoreSuffix: UnwrapRef readonly shallowReactive: UnwrapRef readonly shallowReadonly: UnwrapRef readonly shallowRef: UnwrapRef + readonly storeToRefs: UnwrapRef readonly syncRef: UnwrapRef readonly syncRefs: UnwrapRef readonly templateRef: UnwrapRef @@ -526,6 +551,7 @@ declare module 'vue' { readonly unrefElement: UnwrapRef readonly until: UnwrapRef readonly urlValidator: UnwrapRef + readonly useAbs: UnwrapRef readonly useActiveElement: UnwrapRef readonly useAnimate: UnwrapRef readonly useArrayDifference: UnwrapRef @@ -543,6 +569,7 @@ declare module 'vue' { readonly useAsyncQueue: UnwrapRef readonly useAsyncState: UnwrapRef readonly useAttrs: UnwrapRef + readonly useAverage: UnwrapRef readonly useBase64: UnwrapRef readonly useBattery: UnwrapRef readonly useBluetooth: UnwrapRef @@ -550,6 +577,8 @@ declare module 'vue' { readonly useBroadcastChannel: UnwrapRef readonly useBrowserLocation: UnwrapRef readonly useCached: UnwrapRef + readonly useCeil: UnwrapRef + readonly useClamp: UnwrapRef readonly useClipboard: UnwrapRef readonly useClipboardItems: UnwrapRef readonly useCloned: UnwrapRef @@ -588,6 +617,7 @@ declare module 'vue' { readonly useFetch: UnwrapRef readonly useFileDialog: UnwrapRef readonly useFileSystemAccess: UnwrapRef + readonly useFloor: UnwrapRef readonly useFocus: UnwrapRef readonly useFocusWithin: UnwrapRef readonly useFormDraft: UnwrapRef @@ -597,6 +627,7 @@ declare module 'vue' { readonly useGamepad: UnwrapRef readonly useGenerateImageVariant: UnwrapRef readonly useGeolocation: UnwrapRef + readonly useI18n: UnwrapRef readonly useId: UnwrapRef readonly useIdle: UnwrapRef readonly useImage: UnwrapRef @@ -609,10 +640,13 @@ declare module 'vue' { readonly useLocalStorage: UnwrapRef readonly useMagicKeys: UnwrapRef readonly useManualRefHistory: UnwrapRef + readonly useMath: UnwrapRef + readonly useMax: UnwrapRef readonly useMediaControls: UnwrapRef readonly useMediaQuery: UnwrapRef readonly useMemoize: UnwrapRef readonly useMemory: UnwrapRef + readonly useMin: UnwrapRef readonly useModel: UnwrapRef readonly useMounted: UnwrapRef readonly useMouse: UnwrapRef @@ -633,16 +667,22 @@ declare module 'vue' { readonly usePointer: UnwrapRef readonly usePointerLock: UnwrapRef readonly usePointerSwipe: UnwrapRef + readonly usePrecision: UnwrapRef readonly usePreferredColorScheme: UnwrapRef readonly usePreferredContrast: UnwrapRef readonly usePreferredDark: UnwrapRef readonly usePreferredLanguages: UnwrapRef readonly usePreferredReducedMotion: UnwrapRef readonly usePrevious: UnwrapRef + readonly useProjection: UnwrapRef + readonly usePublicFormToken: UnwrapRef readonly useRafFn: UnwrapRef readonly useRefHistory: UnwrapRef readonly useResizeObserver: UnwrapRef readonly useResponsiveLeftSidebar: UnwrapRef + readonly useRound: UnwrapRef + readonly useRoute: UnwrapRef + readonly useRouter: UnwrapRef readonly useScreenOrientation: UnwrapRef readonly useScreenSafeArea: UnwrapRef readonly useScriptTag: UnwrapRef @@ -656,9 +696,9 @@ declare module 'vue' { readonly useSpeechRecognition: UnwrapRef readonly useSpeechSynthesis: UnwrapRef readonly useStepper: UnwrapRef - readonly useStorage: UnwrapRef readonly useStorageAsync: UnwrapRef readonly useStyleTag: UnwrapRef + readonly useSum: UnwrapRef readonly useSupported: UnwrapRef readonly useSwipe: UnwrapRef readonly useTemplateRef: UnwrapRef @@ -679,6 +719,7 @@ declare module 'vue' { readonly useToString: UnwrapRef readonly useToggle: UnwrapRef readonly useTransition: UnwrapRef + readonly useTrunc: UnwrapRef readonly useUrlSearchParams: UnwrapRef readonly useUserMedia: UnwrapRef readonly useVModel: UnwrapRef diff --git a/apps/portal/components.d.ts b/apps/portal/components.d.ts index dc5b97e8..277ac411 100644 --- a/apps/portal/components.d.ts +++ b/apps/portal/components.d.ts @@ -34,6 +34,7 @@ declare module 'vue' { DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default'] DropZone: typeof import('./src/@core/components/DropZone.vue')['default'] EventCard: typeof import('./src/components/portal/EventCard.vue')['default'] + FieldAvailabilityPicker: typeof import('./src/components/public-form/FieldAvailabilityPicker.vue')['default'] FieldBoolean: typeof import('./src/components/public-form/FieldBoolean.vue')['default'] FieldCheckboxList: typeof import('./src/components/public-form/FieldCheckboxList.vue')['default'] FieldDate: typeof import('./src/components/public-form/FieldDate.vue')['default'] @@ -45,7 +46,9 @@ declare module 'vue' { FieldPhone: typeof import('./src/components/public-form/FieldPhone.vue')['default'] FieldRadio: typeof import('./src/components/public-form/FieldRadio.vue')['default'] FieldRenderer: typeof import('./src/components/public-form/FieldRenderer.vue')['default'] + FieldSectionPriority: typeof import('./src/components/public-form/FieldSectionPriority.vue')['default'] FieldSelect: typeof import('./src/components/public-form/FieldSelect.vue')['default'] + FieldTagPicker: typeof import('./src/components/public-form/FieldTagPicker.vue')['default'] FieldText: typeof import('./src/components/public-form/FieldText.vue')['default'] FieldTextarea: typeof import('./src/components/public-form/FieldTextarea.vue')['default'] FieldUrl: typeof import('./src/components/public-form/FieldUrl.vue')['default'] @@ -53,6 +56,7 @@ declare module 'vue' { FormErrorState: typeof import('./src/components/public-form/FormErrorState.vue')['default'] FormStepper: typeof import('./src/components/public-form/FormStepper.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default'] + IdentityMatchBanner: typeof import('./src/components/public-form/IdentityMatchBanner.vue')['default'] InformatieTab: typeof import('./src/components/event/InformatieTab.vue')['default'] MfaChallengeCard: typeof import('./src/components/auth/MfaChallengeCard.vue')['default'] MfaDisableDialog: typeof import('./src/components/settings/MfaDisableDialog.vue')['default'] diff --git a/apps/portal/package.json b/apps/portal/package.json index 96cb8180..fd67f609 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -59,6 +59,7 @@ "vue-router": "4.5.1", "vue3-apexcharts": "1.5.3", "vue3-perfect-scrollbar": "2.0.0", + "vuedraggable": "^4.1.0", "vuetify": "3.10.8", "webfontloader": "1.6.28", "zod": "^3.25.76" diff --git a/apps/portal/pnpm-lock.yaml b/apps/portal/pnpm-lock.yaml index 6d55bf31..c74db313 100644 --- a/apps/portal/pnpm-lock.yaml +++ b/apps/portal/pnpm-lock.yaml @@ -141,6 +141,9 @@ importers: vue3-perfect-scrollbar: specifier: 2.0.0 version: 2.0.0(vue@3.5.22(typescript@5.9.3)) + vuedraggable: + specifier: ^4.1.0 + version: 4.1.0(vue@3.5.22(typescript@5.9.3)) vuetify: specifier: 3.10.8 version: 3.10.8(typescript@5.9.3)(vite-plugin-vuetify@2.1.2)(vue@3.5.22(typescript@5.9.3)) @@ -4429,6 +4432,9 @@ packages: resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} engines: {node: '>=10'} + sortablejs@1.14.0: + resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5168,6 +5174,11 @@ packages: typescript: optional: true + vuedraggable@4.1.0: + resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==} + peerDependencies: + vue: ^3.0.1 + vuetify@3.10.8: resolution: {integrity: sha512-TV1bx8mUjOPbhmEsamm38/CBcVe5DHYepOZGE6aQJ2uxvg96B4k+QHgIJcD5uKVfKmxKkJRtHdEXyq6JP9wBtg==} peerDependencies: @@ -9985,6 +9996,8 @@ snapshots: astral-regex: 2.0.0 is-fullwidth-code-point: 3.0.0 + sortablejs@1.14.0: {} + source-map-js@1.2.1: {} source-map@0.6.1: @@ -10876,6 +10889,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 + vuedraggable@4.1.0(vue@3.5.22(typescript@5.9.3)): + dependencies: + sortablejs: 1.14.0 + vue: 3.5.22(typescript@5.9.3) + vuetify@3.10.8(typescript@5.9.3)(vite-plugin-vuetify@2.1.2)(vue@3.5.22(typescript@5.9.3)): dependencies: vue: 3.5.22(typescript@5.9.3) diff --git a/apps/portal/src/components/public-form/FieldAvailabilityPicker.vue b/apps/portal/src/components/public-form/FieldAvailabilityPicker.vue new file mode 100644 index 00000000..c9cf3db3 --- /dev/null +++ b/apps/portal/src/components/public-form/FieldAvailabilityPicker.vue @@ -0,0 +1,229 @@ + + + + + diff --git a/apps/portal/src/components/public-form/FieldRenderer.vue b/apps/portal/src/components/public-form/FieldRenderer.vue index 9f493a80..a4e2b78a 100644 --- a/apps/portal/src/components/public-form/FieldRenderer.vue +++ b/apps/portal/src/components/public-form/FieldRenderer.vue @@ -1,4 +1,5 @@ + + + + diff --git a/apps/portal/src/components/public-form/FieldTagPicker.vue b/apps/portal/src/components/public-form/FieldTagPicker.vue new file mode 100644 index 00000000..47ada402 --- /dev/null +++ b/apps/portal/src/components/public-form/FieldTagPicker.vue @@ -0,0 +1,115 @@ + + + diff --git a/apps/portal/src/components/public-form/FormConfirmation.vue b/apps/portal/src/components/public-form/FormConfirmation.vue index 0b7badba..8d84c9df 100644 --- a/apps/portal/src/components/public-form/FormConfirmation.vue +++ b/apps/portal/src/components/public-form/FormConfirmation.vue @@ -1,13 +1,15 @@ + + diff --git a/apps/portal/src/composables/api/usePublicFormSections.ts b/apps/portal/src/composables/api/usePublicFormSections.ts new file mode 100644 index 00000000..20dd2600 --- /dev/null +++ b/apps/portal/src/composables/api/usePublicFormSections.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { PublicFormSectionOption } from '@/types/formBuilder' + +interface ApiResponse { + 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) { + return useQuery({ + queryKey: ['public-form', token, 'sections'], + queryFn: async (): Promise => { + const t = token.value + if (!t) throw new Error('Missing public_token') + const { data } = await apiClient.get>( + `/public/forms/${t}/sections`, + ) + + return data.data + }, + enabled: computed(() => !!token.value), + staleTime: 1000 * 60 * 5, + }) +} diff --git a/apps/portal/src/composables/api/usePublicFormTimeSlots.ts b/apps/portal/src/composables/api/usePublicFormTimeSlots.ts new file mode 100644 index 00000000..13d3a195 --- /dev/null +++ b/apps/portal/src/composables/api/usePublicFormTimeSlots.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/vue-query' +import type { Ref } from 'vue' +import { apiClient } from '@/lib/axios' +import type { PublicFormTimeSlot } from '@/types/formBuilder' + +interface ApiResponse { + 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) { + return useQuery({ + queryKey: ['public-form', token, 'time-slots'], + queryFn: async (): Promise => { + const t = token.value + if (!t) throw new Error('Missing public_token') + const { data } = await apiClient.get>( + `/public/forms/${t}/time-slots`, + ) + + return data.data + }, + enabled: computed(() => !!token.value), + staleTime: 1000 * 60 * 5, + }) +} diff --git a/apps/portal/src/composables/publicFormInjection.ts b/apps/portal/src/composables/publicFormInjection.ts new file mode 100644 index 00000000..59b97daa --- /dev/null +++ b/apps/portal/src/composables/publicFormInjection.ts @@ -0,0 +1,21 @@ +import type { InjectionKey, Ref } from 'vue' +import { 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> = Symbol('PublicFormToken') + +export function providePublicFormToken(token: Ref): void { + provide(PUBLIC_FORM_TOKEN_KEY, token) +} + +export function usePublicFormToken(): Ref { + 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 +} diff --git a/apps/portal/src/pages/register/[public_token].vue b/apps/portal/src/pages/register/[public_token].vue index ea9159d3..637b9b8b 100644 --- a/apps/portal/src/pages/register/[public_token].vue +++ b/apps/portal/src/pages/register/[public_token].vue @@ -8,6 +8,7 @@ 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 { providePublicFormToken } from '@/composables/publicFormInjection' import { FormFieldType } from '@/types/formBuilder' import type { FormErrorCode, PublicFormField } from '@/types/formBuilder' @@ -29,6 +30,11 @@ const token = computed(() => { const tokenRef = computed(() => token.value || null) +// Provide the (always-present) string token ref to AVAILABILITY_PICKER / +// SECTION_PRIORITY renderers so they can fetch their sibling endpoints +// without prop drilling through FieldRenderer. +providePublicFormToken(token) + const schemaQuery = useFetchPublicFormSchema(tokenRef) const draft = useFormDraft(tokenRef, { @@ -258,6 +264,7 @@ function formatReviewValue(field: PublicFormField): string { :values="draft.values.value" :submitter-name="draft.submitterName.value" :submitter-email="draft.submitterEmail.value" + :identity-match="draft.submission.value?.identity_match ?? null" /> diff --git a/apps/portal/src/types/formBuilder.ts b/apps/portal/src/types/formBuilder.ts index 36fe68d1..17c9d800 100644 --- a/apps/portal/src/types/formBuilder.ts +++ b/apps/portal/src/types/formBuilder.ts @@ -165,6 +165,30 @@ export interface PublicFormErrorBody { errors?: Record } +export interface PublicFormTimeSlot { + id: string + name: string + date: string // YYYY-MM-DD + start_time: string // HH:MM:SS + end_time: string // HH:MM:SS + duration_hours: number | null + event_id: string + event_name: string +} + +export interface PublicFormSectionOption { + id: string + name: string + category: string | null + icon: string | null + registration_description: string | null +} + +export interface SectionPriorityValue { + section_id: string + priority: number +} + export type FormValues = Record export interface StartDraftBody { diff --git a/apps/portal/tests/components/public-form/FieldAvailabilityPicker.spec.ts b/apps/portal/tests/components/public-form/FieldAvailabilityPicker.spec.ts new file mode 100644 index 00000000..f0cf05e2 --- /dev/null +++ b/apps/portal/tests/components/public-form/FieldAvailabilityPicker.spec.ts @@ -0,0 +1,182 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +// Expose mutable state for the mocked composable so each test can steer +// loading / error / data scenarios without a vue-query harness. +const state = { + data: ref> | undefined>(undefined), + isLoading: ref(false), + isError: ref(false), + refetch: vi.fn(), +} + +vi.mock('@/composables/api/usePublicFormTimeSlots', () => ({ + usePublicFormTimeSlots: () => state, +})) + +vi.mock('@/composables/publicFormInjection', () => ({ + usePublicFormToken: () => ref('TKN'), + providePublicFormToken: () => {}, +})) + +import FieldAvailabilityPicker from '@/components/public-form/FieldAvailabilityPicker.vue' +import { FormFieldType } from '@/types/formBuilder' +import type { PublicFormField, PublicFormTimeSlot } from '@/types/formBuilder' + +function field(partial: Partial = {}): PublicFormField { + return { + id: 'f_1', + slug: 'beschikbaarheid', + field_type: FormFieldType.AVAILABILITY_PICKER, + label: 'Wanneer ben je beschikbaar?', + help_text: null, + options: null, + available_tags: null, + validation_rules: null, + is_required: false, + display_width: 'full', + conditional_logic: null, + sort_order: 1, + form_schema_section_id: null, + ...partial, + } +} + +function slot(partial: Partial): PublicFormTimeSlot { + return { + id: partial.id ?? '01A', + name: partial.name ?? 'Vrijdag avond', + date: partial.date ?? '2026-07-10', + start_time: partial.start_time ?? '18:00:00', + end_time: partial.end_time ?? '23:00:00', + duration_hours: partial.duration_hours ?? 5, + event_id: partial.event_id ?? 'evt_1', + event_name: partial.event_name ?? 'Echt Feesten', + } +} + +function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) { + return mount(FieldAvailabilityPicker, { + props, + global: { + stubs: { + VSkeletonLoader: { name: 'VSkeletonLoader', template: '
' }, + VAlert: { + name: 'VAlert', + props: ['type'], + template: '
', + }, + VBtn: { + name: 'VBtn', + template: '', + }, + VCheckbox: { + name: 'VCheckbox', + props: ['modelValue'], + emits: ['update:modelValue'], + template: ``, + }, + }, + }, + }) +} + +describe('FieldAvailabilityPicker', () => { + beforeEach(() => { + state.data.value = undefined + state.isLoading.value = false + state.isError.value = false + state.refetch = vi.fn() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('renders the skeleton while loading', () => { + state.isLoading.value = true + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.find('.v-skeleton-stub').exists()).toBe(true) + }) + + it('renders the error alert with a retry button when isError', async () => { + state.isError.value = true + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.find('.v-alert-stub').attributes('data-type')).toBe('error') + await w.find('.v-btn-stub').trigger('click') + expect(state.refetch).toHaveBeenCalled() + }) + + it('renders the info empty-state when the slots list is empty', () => { + state.data.value = [] + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.find('.v-alert-stub').attributes('data-type')).toBe('info') + expect(w.text()).toContain('Er zijn nog geen tijdsloten beschikbaar.') + }) + + it('groups slots by date with Dutch weekday labels', () => { + state.data.value = [ + slot({ id: 'a', date: '2026-07-11', name: 'Za middag' }), + slot({ id: 'b', date: '2026-07-12', name: 'Zo middag' }), + ] + const w = mountPicker({ field: field(), modelValue: [] }) + + // "zaterdag 11 juli" — capitalised. Rendered with nl-NL locale. + expect(w.text()).toMatch(/Zaterdag\s*11\s*juli/) + expect(w.text()).toMatch(/Zondag\s*12\s*juli/) + }) + + it('adds event-name subheaders when multiple events are present', () => { + state.data.value = [ + slot({ id: 'a', event_id: 'e1', event_name: 'Parent festival' }), + slot({ id: 'b', event_id: 'e2', event_name: 'Dag 1' }), + ] + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.text()).toContain('Parent festival') + expect(w.text()).toContain('Dag 1') + }) + + it('omits event-name subheaders in the single-event case', () => { + state.data.value = [ + slot({ id: 'a', event_id: 'e1', event_name: 'Only event' }), + slot({ id: 'b', event_id: 'e1', event_name: 'Only event' }), + ] + const w = mountPicker({ field: field(), modelValue: [] }) + + // Only event_name appears somewhere in the DOM? In the single-event + // case, the subheader should NOT be rendered — count the occurrences + // to verify only checkbox labels (slot names) appear, not the event + // name as a standalone subheader. + const text = w.text() + // The event_name "Only event" should not appear as a subheader; slot + // names are different ("Vrijdag avond"), so event_name shouldn't be + // found anywhere in the visible text. + expect(text).not.toContain('Only event') + }) + + it('emits update:modelValue as string[] of time_slot IDs on toggle', async () => { + state.data.value = [slot({ id: 'alpha' })] + const w = mountPicker({ field: field(), modelValue: [] }) + + const checkbox = w.find('.v-checkbox-stub input') + await checkbox.setValue(true) + const emits = w.emitted('update:modelValue') as unknown as string[][][] + expect(emits?.[0][0]).toEqual(['alpha']) + }) + + it('formats time with seconds stripped', () => { + state.data.value = [slot({ start_time: '08:00:00', end_time: '13:00:00' })] + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.text()).toContain('08:00–13:00') + expect(w.text()).not.toContain('08:00:00') + }) +}) diff --git a/apps/portal/tests/components/public-form/FieldRenderer.test.ts b/apps/portal/tests/components/public-form/FieldRenderer.test.ts index 3d96cec1..2a644c70 100644 --- a/apps/portal/tests/components/public-form/FieldRenderer.test.ts +++ b/apps/portal/tests/components/public-form/FieldRenderer.test.ts @@ -40,6 +40,9 @@ function mountRenderer(field: PublicFormField, allValues: Record' }, FieldMultiselect: { name: 'FieldMultiselect', template: '
' }, FieldCheckboxList: { name: 'FieldCheckboxList', template: '
' }, + FieldTagPicker: { name: 'FieldTagPicker', template: '
' }, + FieldAvailabilityPicker: { name: 'FieldAvailabilityPicker', template: '
' }, + FieldSectionPriority: { name: 'FieldSectionPriority', template: '
' }, FieldHeading: { name: 'FieldHeading', template: '
' }, FieldParagraph: { name: 'FieldParagraph', template: '
' }, FieldUrl: { name: 'FieldUrl', template: '
' }, @@ -61,6 +64,9 @@ describe('FieldRenderer', () => { [FormFieldType.SELECT, 'field-select-stub'], [FormFieldType.MULTISELECT, 'field-multiselect-stub'], [FormFieldType.CHECKBOX_LIST, 'field-checkboxlist-stub'], + [FormFieldType.TAG_PICKER, 'field-tagpicker-stub'], + [FormFieldType.AVAILABILITY_PICKER, 'field-availabilitypicker-stub'], + [FormFieldType.SECTION_PRIORITY, 'field-sectionpriority-stub'], [FormFieldType.HEADING, 'field-heading-stub'], [FormFieldType.PARAGRAPH, 'field-paragraph-stub'], [FormFieldType.URL, 'field-url-stub'], @@ -70,9 +76,6 @@ describe('FieldRenderer', () => { }) it.each([ - FormFieldType.TAG_PICKER, - FormFieldType.AVAILABILITY_PICKER, - FormFieldType.SECTION_PRIORITY, FormFieldType.FILE_UPLOAD, FormFieldType.IMAGE_UPLOAD, FormFieldType.SIGNATURE, diff --git a/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts b/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts new file mode 100644 index 00000000..d86bea85 --- /dev/null +++ b/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts @@ -0,0 +1,237 @@ +import { mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ref } from 'vue' + +const state = { + data: ref> | undefined>(undefined), + isLoading: ref(false), + isError: ref(false), + refetch: vi.fn(), +} + +vi.mock('@/composables/api/usePublicFormSections', () => ({ + usePublicFormSections: () => state, +})) + +vi.mock('@/composables/publicFormInjection', () => ({ + usePublicFormToken: () => ref('TKN'), + providePublicFormToken: () => {}, +})) + +// Vuetify's useDisplay touches window.matchMedia which jsdom doesn't +// implement; stub to always return desktop for consistent behaviour. +vi.mock('vuetify', () => ({ + useDisplay: () => ({ mobile: ref(false) }), +})) + +// vuedraggable depends on SortableJS which pokes the DOM on mount; a +// minimal slot-passthrough stub is enough for the tap-based behaviour +// under test. +vi.mock('vuedraggable', () => ({ + default: { + name: 'draggable', + props: ['modelValue'], + template: '
', + }, +})) + +import FieldSectionPriority from '@/components/public-form/FieldSectionPriority.vue' +import { FormFieldType } from '@/types/formBuilder' +import type { PublicFormField, PublicFormSectionOption } from '@/types/formBuilder' + +function field(partial: Partial = {}): PublicFormField { + return { + id: 'f_1', + slug: 'sectie_voorkeur', + field_type: FormFieldType.SECTION_PRIORITY, + label: 'Bij welke sectie wil je werken?', + help_text: null, + options: null, + available_tags: null, + validation_rules: null, + is_required: false, + display_width: 'full', + conditional_logic: null, + sort_order: 1, + form_schema_section_id: null, + ...partial, + } +} + +function section(partial: Partial): PublicFormSectionOption { + return { + id: partial.id ?? '01A', + name: partial.name ?? 'Bar', + category: partial.category ?? null, + icon: partial.icon ?? null, + registration_description: partial.registration_description ?? null, + } +} + +function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) { + return mount(FieldSectionPriority, { + props, + global: { + stubs: { + VSkeletonLoader: { name: 'VSkeletonLoader', template: '
' }, + VAlert: { + name: 'VAlert', + props: ['type'], + template: '
', + }, + VBtn: { + name: 'VBtn', + props: ['ariaLabel', 'icon'], + template: '', + }, + VCard: { + name: 'VCard', + // Do not re-emit click/keydown — the parent's @click listener + // falls through to the root element, and emitting + fallthrough + // would wire the handler twice. + inheritAttrs: true, + template: '
', + }, + VRow: { name: 'VRow', template: '
' }, + VCol: { name: 'VCol', template: '
' }, + VIcon: { name: 'VIcon', template: '' }, + }, + }, + }) +} + +describe('FieldSectionPriority', () => { + beforeEach(() => { + state.data.value = undefined + state.isLoading.value = false + state.isError.value = false + state.refetch = vi.fn() + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it('renders the skeleton while loading', () => { + state.isLoading.value = true + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.find('.v-skeleton-stub').exists()).toBe(true) + }) + + it('renders the error alert with retry wiring', async () => { + state.isError.value = true + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.find('.v-alert-stub').attributes('data-type')).toBe('error') + await w.find('.v-btn-stub').trigger('click') + expect(state.refetch).toHaveBeenCalled() + }) + + it('renders the info empty-state when no sections are published', () => { + state.data.value = [] + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.find('.v-alert-stub').attributes('data-type')).toBe('info') + expect(w.text()).toContain('Er zijn nog geen secties gepubliceerd voor registratie.') + }) + + it('renders all sections in the unranked pool initially', () => { + state.data.value = [section({ id: 'a', name: 'Bar' }), section({ id: 'b', name: 'Hospitality' })] + const w = mountPicker({ field: field(), modelValue: [] }) + + expect(w.text()).toContain('Bar') + expect(w.text()).toContain('Hospitality') + }) + + it('tap-to-rank moves a section to the ranked list at priority 1', async () => { + state.data.value = [section({ id: 'a', name: 'Bar' }), section({ id: 'b', name: 'Hospitality' })] + const w = mountPicker({ field: field(), modelValue: [] }) + + // First unranked card tap + const cards = w.findAll('.v-card-stub') + await cards[0].trigger('click') + + const emits = w.emitted('update:modelValue') as unknown as Array>> + const last = emits[emits.length - 1][0] + expect(last).toEqual([{ section_id: 'a', priority: 1 }]) + }) + + it('tapping a second section lands it at priority 2', async () => { + state.data.value = [section({ id: 'a' }), section({ id: 'b' })] + const w = mountPicker({ + field: field(), + modelValue: [{ section_id: 'a', priority: 1 }], + }) + + // Only section b is still in the pool — it renders as a card. + const poolCards = w.findAll('.v-card-stub').filter(c => c.text().length > 0) + await poolCards[poolCards.length - 1].trigger('click') + + const emits = w.emitted('update:modelValue') as unknown as Array>> + const last = emits[emits.length - 1][0] + expect(last).toEqual([ + { section_id: 'a', priority: 1 }, + { section_id: 'b', priority: 2 }, + ]) + }) + + it('respects validation_rules.max_priorities when present', async () => { + state.data.value = [section({ id: 'a' }), section({ id: 'b' })] + const w = mountPicker({ + field: field({ validation_rules: { max_priorities: 1 } }), + modelValue: [{ section_id: 'a', priority: 1 }], + }) + + // Cap is 1; the only unranked card should be disabled per the + // rendered "Maximaal" hint. + expect(w.text()).toContain('Maximaal 1 voorkeuren') + }) + + it('self-heals an incoming string[] modelValue to []', () => { + state.data.value = [section({ id: 'a' })] + const w = mountPicker({ + field: field(), + modelValue: ['a', 'b'], // wrong shape (string[]) + }) + + // Still renders fine — no crash, empty ranked list, both sections + // in the pool. + expect(w.find('.v-alert-stub').exists()).toBe(false) + expect(w.text()).toContain('Bar') + }) + + it('clamps max_priorities to the hard cap of 5 when the rule is too high', () => { + state.data.value = [ + section({ id: 'a' }), + section({ id: 'b' }), + section({ id: 'c' }), + section({ id: 'd' }), + section({ id: 'e' }), + ] + const ranked = [ + { section_id: 'a', priority: 1 }, + { section_id: 'b', priority: 2 }, + { section_id: 'c', priority: 3 }, + { section_id: 'd', priority: 4 }, + { section_id: 'e', priority: 5 }, + ] + const w = mountPicker({ + field: field({ validation_rules: { max_priorities: 99 } as Record }), + modelValue: ranked, + }) + + // Cap falls back to 5 — counter reads "5 / 5" rather than "5 / 99". + expect(w.text()).toContain('5 / 5') + }) + + it('exposes the ranked counter in the UI copy', () => { + state.data.value = [section({ id: 'a' })] + const w = mountPicker({ + field: field(), + modelValue: [{ section_id: 'a', priority: 1 }], + }) + + expect(w.text()).toContain('1 / 5') + }) +}) diff --git a/apps/portal/tests/components/public-form/FieldTagPicker.spec.ts b/apps/portal/tests/components/public-form/FieldTagPicker.spec.ts new file mode 100644 index 00000000..c1bdb045 --- /dev/null +++ b/apps/portal/tests/components/public-form/FieldTagPicker.spec.ts @@ -0,0 +1,148 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import FieldTagPicker from '@/components/public-form/FieldTagPicker.vue' +import { FormFieldType } from '@/types/formBuilder' +import type { AvailableTag, PublicFormField } from '@/types/formBuilder' + +function field(partial: Partial = {}): PublicFormField { + return { + id: 'f_1', + slug: 'vaardigheden', + field_type: FormFieldType.TAG_PICKER, + label: 'Vaardigheden', + help_text: null, + options: null, + available_tags: null, + validation_rules: null, + is_required: false, + display_width: 'full', + conditional_logic: null, + sort_order: 1, + form_schema_section_id: null, + ...partial, + } +} + +function tag(partial: Partial): AvailableTag { + return { id: partial.id ?? 't_1', name: partial.name ?? 'EHBO', category: partial.category ?? 'Veiligheid' } +} + +function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) { + return mount(FieldTagPicker, { + props, + global: { + stubs: { + VAlert: { name: 'VAlert', template: '
' }, + AppAutocomplete: { + name: 'AppAutocomplete', + props: ['modelValue', 'items', 'label', 'hint', 'required', 'errorMessages'], + emits: ['update:modelValue', 'blur'], + template: `
+ + +
`, + }, + }, + }, + }) +} + +describe('FieldTagPicker', () => { + it('renders the info empty-state when available_tags is null', () => { + const w = mountPicker({ field: field({ available_tags: null }), modelValue: [] }) + + expect(w.find('.v-alert-stub').exists()).toBe(true) + expect(w.text()).toContain('Er zijn nog geen tags beschikbaar') + }) + + it('renders the info empty-state when available_tags is an empty array', () => { + const w = mountPicker({ field: field({ available_tags: [] }), modelValue: [] }) + + expect(w.find('.v-alert-stub').exists()).toBe(true) + }) + + it('normalises null/empty category to "Overig"', () => { + const w = mountPicker({ + field: field({ available_tags: [tag({ id: 'a', name: 'Onbekend', category: '' })] }), + modelValue: [], + }) + + const items = w.findAll('.stub-item') + expect(items[0].attributes('data-category')).toBe('Overig') + }) + + it('renders one item per tag with its id as value', () => { + const w = mountPicker({ + field: field({ + available_tags: [ + tag({ id: 'a', name: 'EHBO', category: 'Veiligheid' }), + tag({ id: 'b', name: 'BHV', category: 'Veiligheid' }), + ], + }), + modelValue: [], + }) + + const items = w.findAll('.stub-item') + expect(items.length).toBe(2) + expect(items.map(i => i.attributes('data-value'))).toEqual(['a', 'b']) + }) + + it('emits update:modelValue as string[] of tag IDs on selection', async () => { + const w = mountPicker({ + field: field({ available_tags: [tag({ id: 'a', name: 'EHBO' })] }), + modelValue: [], + }) + + await w.find('.stub-item').trigger('click') + const emits = w.emitted('update:modelValue') as unknown as string[][][] + expect(emits?.[0][0]).toEqual(['a']) + }) + + it('unselecting a tag re-emits the trimmed array', async () => { + const w = mountPicker({ + field: field({ available_tags: [tag({ id: 'a' }), tag({ id: 'b' })] }), + modelValue: ['a', 'b'], + }) + + await w.find('.stub-unselect').trigger('click') + const emits = w.emitted('update:modelValue') as unknown as string[][][] + expect(emits?.[0][0]).toEqual(['a']) + }) + + it('renders the required indicator in the empty-state when is_required', () => { + const w = mountPicker({ + field: field({ available_tags: null, is_required: true }), + modelValue: [], + }) + + expect(w.find('.text-error').exists()).toBe(true) + }) + + it('sorts items grouped by category', () => { + const w = mountPicker({ + field: field({ + available_tags: [ + tag({ id: 'a', category: 'Zeta' }), + tag({ id: 'b', category: 'Alpha' }), + tag({ id: 'c', category: 'Alpha' }), + ], + }), + modelValue: [], + }) + + const items = w.findAll('.stub-item') + const cats = items.map(i => i.attributes('data-category')) + // Alpha tags come first, then Zeta. + expect(cats).toEqual(['Alpha', 'Alpha', 'Zeta']) + }) +}) diff --git a/apps/portal/tests/components/public-form/IdentityMatchBanner.spec.ts b/apps/portal/tests/components/public-form/IdentityMatchBanner.spec.ts new file mode 100644 index 00000000..87f0cb84 --- /dev/null +++ b/apps/portal/tests/components/public-form/IdentityMatchBanner.spec.ts @@ -0,0 +1,59 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import IdentityMatchBanner from '@/components/public-form/IdentityMatchBanner.vue' + +function mountBanner(props: { status: 'pending' | 'matched' | 'none' | null; message?: string | null }) { + return mount(IdentityMatchBanner, { + props, + global: { + stubs: { + VAlert: { + name: 'VAlert', + props: ['type', 'variant', 'prominent'], + template: '
', + }, + }, + }, + }) +} + +describe('IdentityMatchBanner', () => { + it('renders nothing when status is null', () => { + const w = mountBanner({ status: null }) + + expect(w.find('.v-alert-stub').exists()).toBe(false) + }) + + it('renders the pending banner with info type and backend message when provided', () => { + const w = mountBanner({ + status: 'pending', + message: 'We controleren of je al bekend bent bij de organisator.', + }) + + const alert = w.find('.v-alert-stub') + expect(alert.exists()).toBe(true) + expect(alert.attributes('data-type')).toBe('info') + expect(w.text()).toContain('We controleren') + }) + + it('renders the matched banner with success type and backend message when provided', () => { + const w = mountBanner({ + status: 'matched', + message: 'Je account is gekoppeld aan een bekende deelnemer.', + }) + + const alert = w.find('.v-alert-stub') + expect(alert.exists()).toBe(true) + expect(alert.attributes('data-type')).toBe('success') + expect(w.text()).toContain('gekoppeld') + }) + + it('falls back to frontend copy when backend message is missing', () => { + const w = mountBanner({ status: 'none', message: null }) + + const alert = w.find('.v-alert-stub') + expect(alert.exists()).toBe(true) + expect(alert.attributes('data-type')).toBe('success') + expect(w.text()).toContain('Aanmelding ontvangen') + }) +}) diff --git a/apps/portal/tests/composables/api/usePublicFormSections.spec.ts b/apps/portal/tests/composables/api/usePublicFormSections.spec.ts new file mode 100644 index 00000000..45be8e17 --- /dev/null +++ b/apps/portal/tests/composables/api/usePublicFormSections.spec.ts @@ -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 '@/types/formBuilder' + +interface MockedApi { get: ReturnType } +const mocked = apiClient as unknown as MockedApi + +function mountHook(tokenValue: string) { + const result: { query: ReturnType | 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 { + 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 { + 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) + }) +}) diff --git a/apps/portal/tests/composables/api/usePublicFormTimeSlots.spec.ts b/apps/portal/tests/composables/api/usePublicFormTimeSlots.spec.ts new file mode 100644 index 00000000..3dd597c1 --- /dev/null +++ b/apps/portal/tests/composables/api/usePublicFormTimeSlots.spec.ts @@ -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 '@/types/formBuilder' + +interface MockedApi { get: ReturnType } +const mocked = apiClient as unknown as MockedApi + +function mountHook(tokenValue: string) { + const result: { query: ReturnType | 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 { + 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 { + 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) + }) +})