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) <noreply@anthropic.com>
This commit is contained in:
43
apps/portal/auto-imports.d.ts
vendored
43
apps/portal/auto-imports.d.ts
vendored
@@ -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<typeof import('vue')['EffectScope']>
|
||||
readonly PUBLIC_FORM_TOKEN_KEY: UnwrapRef<typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_TOKEN_KEY']>
|
||||
readonly acceptHMRUpdate: UnwrapRef<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly alphaDashValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['alphaDashValidator']>
|
||||
readonly alphaValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['alphaValidator']>
|
||||
readonly asyncComputed: UnwrapRef<typeof import('@vueuse/core')['asyncComputed']>
|
||||
@@ -405,8 +410,11 @@ declare module 'vue' {
|
||||
readonly controlledRef: UnwrapRef<typeof import('@vueuse/core')['controlledRef']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createEventHook: UnwrapRef<typeof import('@vueuse/core')['createEventHook']>
|
||||
readonly createGenericProjection: UnwrapRef<typeof import('@vueuse/math')['createGenericProjection']>
|
||||
readonly createGlobalState: UnwrapRef<typeof import('@vueuse/core')['createGlobalState']>
|
||||
readonly createInjectionState: UnwrapRef<typeof import('@vueuse/core')['createInjectionState']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly createProjection: UnwrapRef<typeof import('@vueuse/math')['createProjection']>
|
||||
readonly createReactiveFn: UnwrapRef<typeof import('@vueuse/core')['createReactiveFn']>
|
||||
readonly createReusableTemplate: UnwrapRef<typeof import('@vueuse/core')['createReusableTemplate']>
|
||||
readonly createSharedComposable: UnwrapRef<typeof import('@vueuse/core')['createSharedComposable']>
|
||||
@@ -418,6 +426,8 @@ declare module 'vue' {
|
||||
readonly debouncedWatch: UnwrapRef<typeof import('@vueuse/core')['debouncedWatch']>
|
||||
readonly defineAsyncComponent: UnwrapRef<typeof import('vue')['defineAsyncComponent']>
|
||||
readonly defineComponent: UnwrapRef<typeof import('vue')['defineComponent']>
|
||||
readonly definePage: UnwrapRef<typeof import('unplugin-vue-router/runtime')['definePage']>
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly draftIdempotencyKey: UnwrapRef<typeof import('./src/composables/useFormDraft')['draftIdempotencyKey']>
|
||||
readonly draftSubmitterStorageKey: UnwrapRef<typeof import('./src/composables/useFormDraft')['draftSubmitterStorageKey']>
|
||||
readonly eagerComputed: UnwrapRef<typeof import('@vueuse/core')['eagerComputed']>
|
||||
@@ -430,6 +440,7 @@ declare module 'vue' {
|
||||
readonly formatDate: UnwrapRef<typeof import('./src/@core/utils/formatters')['formatDate']>
|
||||
readonly formatDateToMonthShort: UnwrapRef<typeof import('./src/@core/utils/formatters')['formatDateToMonthShort']>
|
||||
readonly generateDeviceFingerprint: UnwrapRef<typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
readonly getCurrentScope: UnwrapRef<typeof import('vue')['getCurrentScope']>
|
||||
readonly getDeviceName: UnwrapRef<typeof import('./src/utils/deviceFingerprint')['getDeviceName']>
|
||||
@@ -454,11 +465,21 @@ declare module 'vue' {
|
||||
readonly isToday: UnwrapRef<typeof import('./src/@core/utils/helpers')['isToday']>
|
||||
readonly kFormatter: UnwrapRef<typeof import('./src/@core/utils/formatters')['kFormatter']>
|
||||
readonly lengthValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['lengthValidator']>
|
||||
readonly logicAnd: UnwrapRef<typeof import('@vueuse/math')['logicAnd']>
|
||||
readonly logicNot: UnwrapRef<typeof import('@vueuse/math')['logicNot']>
|
||||
readonly logicOr: UnwrapRef<typeof import('@vueuse/math')['logicOr']>
|
||||
readonly makeDestructurable: UnwrapRef<typeof import('@vueuse/core')['makeDestructurable']>
|
||||
readonly mapActions: UnwrapRef<typeof import('pinia')['mapActions']>
|
||||
readonly mapGetters: UnwrapRef<typeof import('pinia')['mapGetters']>
|
||||
readonly mapState: UnwrapRef<typeof import('pinia')['mapState']>
|
||||
readonly mapStores: UnwrapRef<typeof import('pinia')['mapStores']>
|
||||
readonly mapWritableState: UnwrapRef<typeof import('pinia')['mapWritableState']>
|
||||
readonly markRaw: UnwrapRef<typeof import('vue')['markRaw']>
|
||||
readonly nextTick: UnwrapRef<typeof import('vue')['nextTick']>
|
||||
readonly onActivated: UnwrapRef<typeof import('vue')['onActivated']>
|
||||
readonly onBeforeMount: UnwrapRef<typeof import('vue')['onBeforeMount']>
|
||||
readonly onBeforeRouteLeave: UnwrapRef<typeof import('vue-router/auto')['onBeforeRouteLeave']>
|
||||
readonly onBeforeRouteUpdate: UnwrapRef<typeof import('vue-router/auto')['onBeforeRouteUpdate']>
|
||||
readonly onBeforeUnmount: UnwrapRef<typeof import('vue')['onBeforeUnmount']>
|
||||
readonly onBeforeUpdate: UnwrapRef<typeof import('vue')['onBeforeUpdate']>
|
||||
readonly onClickOutside: UnwrapRef<typeof import('@vueuse/core')['onClickOutside']>
|
||||
@@ -481,6 +502,7 @@ declare module 'vue' {
|
||||
readonly prefixWithPlus: UnwrapRef<typeof import('./src/@core/utils/formatters')['prefixWithPlus']>
|
||||
readonly provide: UnwrapRef<typeof import('vue')['provide']>
|
||||
readonly provideLocal: UnwrapRef<typeof import('@vueuse/core')['provideLocal']>
|
||||
readonly providePublicFormToken: UnwrapRef<typeof import('./src/composables/publicFormInjection')['providePublicFormToken']>
|
||||
readonly reactify: UnwrapRef<typeof import('@vueuse/core')['reactify']>
|
||||
readonly reactifyObject: UnwrapRef<typeof import('@vueuse/core')['reactifyObject']>
|
||||
readonly reactive: UnwrapRef<typeof import('vue')['reactive']>
|
||||
@@ -503,9 +525,12 @@ declare module 'vue' {
|
||||
readonly resolveVuetifyTheme: UnwrapRef<typeof import('./src/@core/utils/vuetify')['resolveVuetifyTheme']>
|
||||
readonly rgbaToHex: UnwrapRef<typeof import('./src/@core/utils/colorConverter')['rgbaToHex']>
|
||||
readonly runValidators: UnwrapRef<typeof import('./src/utils/formValidation')['runValidators']>
|
||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||
readonly shallowReactive: UnwrapRef<typeof import('vue')['shallowReactive']>
|
||||
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
|
||||
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
|
||||
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
|
||||
readonly syncRef: UnwrapRef<typeof import('@vueuse/core')['syncRef']>
|
||||
readonly syncRefs: UnwrapRef<typeof import('@vueuse/core')['syncRefs']>
|
||||
readonly templateRef: UnwrapRef<typeof import('@vueuse/core')['templateRef']>
|
||||
@@ -526,6 +551,7 @@ declare module 'vue' {
|
||||
readonly unrefElement: UnwrapRef<typeof import('@vueuse/core')['unrefElement']>
|
||||
readonly until: UnwrapRef<typeof import('@vueuse/core')['until']>
|
||||
readonly urlValidator: UnwrapRef<typeof import('./src/@core/utils/validators')['urlValidator']>
|
||||
readonly useAbs: UnwrapRef<typeof import('@vueuse/math')['useAbs']>
|
||||
readonly useActiveElement: UnwrapRef<typeof import('@vueuse/core')['useActiveElement']>
|
||||
readonly useAnimate: UnwrapRef<typeof import('@vueuse/core')['useAnimate']>
|
||||
readonly useArrayDifference: UnwrapRef<typeof import('@vueuse/core')['useArrayDifference']>
|
||||
@@ -543,6 +569,7 @@ declare module 'vue' {
|
||||
readonly useAsyncQueue: UnwrapRef<typeof import('@vueuse/core')['useAsyncQueue']>
|
||||
readonly useAsyncState: UnwrapRef<typeof import('@vueuse/core')['useAsyncState']>
|
||||
readonly useAttrs: UnwrapRef<typeof import('vue')['useAttrs']>
|
||||
readonly useAverage: UnwrapRef<typeof import('@vueuse/math')['useAverage']>
|
||||
readonly useBase64: UnwrapRef<typeof import('@vueuse/core')['useBase64']>
|
||||
readonly useBattery: UnwrapRef<typeof import('@vueuse/core')['useBattery']>
|
||||
readonly useBluetooth: UnwrapRef<typeof import('@vueuse/core')['useBluetooth']>
|
||||
@@ -550,6 +577,8 @@ declare module 'vue' {
|
||||
readonly useBroadcastChannel: UnwrapRef<typeof import('@vueuse/core')['useBroadcastChannel']>
|
||||
readonly useBrowserLocation: UnwrapRef<typeof import('@vueuse/core')['useBrowserLocation']>
|
||||
readonly useCached: UnwrapRef<typeof import('@vueuse/core')['useCached']>
|
||||
readonly useCeil: UnwrapRef<typeof import('@vueuse/math')['useCeil']>
|
||||
readonly useClamp: UnwrapRef<typeof import('@vueuse/math')['useClamp']>
|
||||
readonly useClipboard: UnwrapRef<typeof import('@vueuse/core')['useClipboard']>
|
||||
readonly useClipboardItems: UnwrapRef<typeof import('@vueuse/core')['useClipboardItems']>
|
||||
readonly useCloned: UnwrapRef<typeof import('@vueuse/core')['useCloned']>
|
||||
@@ -588,6 +617,7 @@ declare module 'vue' {
|
||||
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
|
||||
readonly useFileDialog: UnwrapRef<typeof import('@vueuse/core')['useFileDialog']>
|
||||
readonly useFileSystemAccess: UnwrapRef<typeof import('@vueuse/core')['useFileSystemAccess']>
|
||||
readonly useFloor: UnwrapRef<typeof import('@vueuse/math')['useFloor']>
|
||||
readonly useFocus: UnwrapRef<typeof import('@vueuse/core')['useFocus']>
|
||||
readonly useFocusWithin: UnwrapRef<typeof import('@vueuse/core')['useFocusWithin']>
|
||||
readonly useFormDraft: UnwrapRef<typeof import('./src/composables/useFormDraft')['useFormDraft']>
|
||||
@@ -597,6 +627,7 @@ declare module 'vue' {
|
||||
readonly useGamepad: UnwrapRef<typeof import('@vueuse/core')['useGamepad']>
|
||||
readonly useGenerateImageVariant: UnwrapRef<typeof import('./src/@core/composable/useGenerateImageVariant')['useGenerateImageVariant']>
|
||||
readonly useGeolocation: UnwrapRef<typeof import('@vueuse/core')['useGeolocation']>
|
||||
readonly useI18n: UnwrapRef<typeof import('vue-i18n')['useI18n']>
|
||||
readonly useId: UnwrapRef<typeof import('vue')['useId']>
|
||||
readonly useIdle: UnwrapRef<typeof import('@vueuse/core')['useIdle']>
|
||||
readonly useImage: UnwrapRef<typeof import('@vueuse/core')['useImage']>
|
||||
@@ -609,10 +640,13 @@ declare module 'vue' {
|
||||
readonly useLocalStorage: UnwrapRef<typeof import('@vueuse/core')['useLocalStorage']>
|
||||
readonly useMagicKeys: UnwrapRef<typeof import('@vueuse/core')['useMagicKeys']>
|
||||
readonly useManualRefHistory: UnwrapRef<typeof import('@vueuse/core')['useManualRefHistory']>
|
||||
readonly useMath: UnwrapRef<typeof import('@vueuse/math')['useMath']>
|
||||
readonly useMax: UnwrapRef<typeof import('@vueuse/math')['useMax']>
|
||||
readonly useMediaControls: UnwrapRef<typeof import('@vueuse/core')['useMediaControls']>
|
||||
readonly useMediaQuery: UnwrapRef<typeof import('@vueuse/core')['useMediaQuery']>
|
||||
readonly useMemoize: UnwrapRef<typeof import('@vueuse/core')['useMemoize']>
|
||||
readonly useMemory: UnwrapRef<typeof import('@vueuse/core')['useMemory']>
|
||||
readonly useMin: UnwrapRef<typeof import('@vueuse/math')['useMin']>
|
||||
readonly useModel: UnwrapRef<typeof import('vue')['useModel']>
|
||||
readonly useMounted: UnwrapRef<typeof import('@vueuse/core')['useMounted']>
|
||||
readonly useMouse: UnwrapRef<typeof import('@vueuse/core')['useMouse']>
|
||||
@@ -633,16 +667,22 @@ declare module 'vue' {
|
||||
readonly usePointer: UnwrapRef<typeof import('@vueuse/core')['usePointer']>
|
||||
readonly usePointerLock: UnwrapRef<typeof import('@vueuse/core')['usePointerLock']>
|
||||
readonly usePointerSwipe: UnwrapRef<typeof import('@vueuse/core')['usePointerSwipe']>
|
||||
readonly usePrecision: UnwrapRef<typeof import('@vueuse/math')['usePrecision']>
|
||||
readonly usePreferredColorScheme: UnwrapRef<typeof import('@vueuse/core')['usePreferredColorScheme']>
|
||||
readonly usePreferredContrast: UnwrapRef<typeof import('@vueuse/core')['usePreferredContrast']>
|
||||
readonly usePreferredDark: UnwrapRef<typeof import('@vueuse/core')['usePreferredDark']>
|
||||
readonly usePreferredLanguages: UnwrapRef<typeof import('@vueuse/core')['usePreferredLanguages']>
|
||||
readonly usePreferredReducedMotion: UnwrapRef<typeof import('@vueuse/core')['usePreferredReducedMotion']>
|
||||
readonly usePrevious: UnwrapRef<typeof import('@vueuse/core')['usePrevious']>
|
||||
readonly useProjection: UnwrapRef<typeof import('@vueuse/math')['useProjection']>
|
||||
readonly usePublicFormToken: UnwrapRef<typeof import('./src/composables/publicFormInjection')['usePublicFormToken']>
|
||||
readonly useRafFn: UnwrapRef<typeof import('@vueuse/core')['useRafFn']>
|
||||
readonly useRefHistory: UnwrapRef<typeof import('@vueuse/core')['useRefHistory']>
|
||||
readonly useResizeObserver: UnwrapRef<typeof import('@vueuse/core')['useResizeObserver']>
|
||||
readonly useResponsiveLeftSidebar: UnwrapRef<typeof import('./src/@core/composable/useResponsiveSidebar')['useResponsiveLeftSidebar']>
|
||||
readonly useRound: UnwrapRef<typeof import('@vueuse/math')['useRound']>
|
||||
readonly useRoute: UnwrapRef<typeof import('vue-router/auto')['useRoute']>
|
||||
readonly useRouter: UnwrapRef<typeof import('vue-router/auto')['useRouter']>
|
||||
readonly useScreenOrientation: UnwrapRef<typeof import('@vueuse/core')['useScreenOrientation']>
|
||||
readonly useScreenSafeArea: UnwrapRef<typeof import('@vueuse/core')['useScreenSafeArea']>
|
||||
readonly useScriptTag: UnwrapRef<typeof import('@vueuse/core')['useScriptTag']>
|
||||
@@ -656,9 +696,9 @@ declare module 'vue' {
|
||||
readonly useSpeechRecognition: UnwrapRef<typeof import('@vueuse/core')['useSpeechRecognition']>
|
||||
readonly useSpeechSynthesis: UnwrapRef<typeof import('@vueuse/core')['useSpeechSynthesis']>
|
||||
readonly useStepper: UnwrapRef<typeof import('@vueuse/core')['useStepper']>
|
||||
readonly useStorage: UnwrapRef<typeof import('@vueuse/core')['useStorage']>
|
||||
readonly useStorageAsync: UnwrapRef<typeof import('@vueuse/core')['useStorageAsync']>
|
||||
readonly useStyleTag: UnwrapRef<typeof import('@vueuse/core')['useStyleTag']>
|
||||
readonly useSum: UnwrapRef<typeof import('@vueuse/math')['useSum']>
|
||||
readonly useSupported: UnwrapRef<typeof import('@vueuse/core')['useSupported']>
|
||||
readonly useSwipe: UnwrapRef<typeof import('@vueuse/core')['useSwipe']>
|
||||
readonly useTemplateRef: UnwrapRef<typeof import('vue')['useTemplateRef']>
|
||||
@@ -679,6 +719,7 @@ declare module 'vue' {
|
||||
readonly useToString: UnwrapRef<typeof import('@vueuse/core')['useToString']>
|
||||
readonly useToggle: UnwrapRef<typeof import('@vueuse/core')['useToggle']>
|
||||
readonly useTransition: UnwrapRef<typeof import('@vueuse/core')['useTransition']>
|
||||
readonly useTrunc: UnwrapRef<typeof import('@vueuse/math')['useTrunc']>
|
||||
readonly useUrlSearchParams: UnwrapRef<typeof import('@vueuse/core')['useUrlSearchParams']>
|
||||
readonly useUserMedia: UnwrapRef<typeof import('@vueuse/core')['useUserMedia']>
|
||||
readonly useVModel: UnwrapRef<typeof import('@vueuse/core')['useVModel']>
|
||||
|
||||
4
apps/portal/components.d.ts
vendored
4
apps/portal/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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"
|
||||
|
||||
18
apps/portal/pnpm-lock.yaml
generated
18
apps/portal/pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import type { PublicFormField, PublicFormTimeSlot } from '@/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const token = usePublicFormToken()
|
||||
const { data: slots, isLoading, isError, refetch } = usePublicFormTimeSlots(token)
|
||||
|
||||
const selected = computed<string[]>(() =>
|
||||
Array.isArray(props.modelValue) ? (props.modelValue as unknown[]).map(String) : [],
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, selected.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => !slots.value || slots.value.length === 0)
|
||||
|
||||
const hasMultipleEvents = computed(() => {
|
||||
if (!slots.value) return false
|
||||
|
||||
return new Set(slots.value.map(s => s.event_id)).size > 1
|
||||
})
|
||||
|
||||
interface DateGroup {
|
||||
date: string
|
||||
label: string
|
||||
events: Array<{ eventId: string; eventName: string; slots: PublicFormTimeSlot[] }>
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})
|
||||
|
||||
function formatDateLabel(iso: string): string {
|
||||
try {
|
||||
const d = new Date(`${iso}T00:00:00`)
|
||||
const parts = dateFormatter.format(d)
|
||||
|
||||
return parts.charAt(0).toUpperCase() + parts.slice(1)
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
function stripSeconds(t: string): string {
|
||||
// "08:00:00" → "08:00"
|
||||
const parts = t.split(':')
|
||||
|
||||
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : t
|
||||
}
|
||||
|
||||
const groups = computed<DateGroup[]>(() => {
|
||||
const data = slots.value ?? []
|
||||
|
||||
// Group by date → then by event_id within the date. Preserve the order
|
||||
// the server already sorted in (by date asc, start_time asc).
|
||||
const byDate = new Map<string, Map<string, { eventId: string; eventName: string; slots: PublicFormTimeSlot[] }>>()
|
||||
|
||||
for (const slot of data) {
|
||||
let events = byDate.get(slot.date)
|
||||
if (!events) {
|
||||
events = new Map()
|
||||
byDate.set(slot.date, events)
|
||||
}
|
||||
|
||||
let bucket = events.get(slot.event_id)
|
||||
if (!bucket) {
|
||||
bucket = { eventId: slot.event_id, eventName: slot.event_name, slots: [] }
|
||||
events.set(slot.event_id, bucket)
|
||||
}
|
||||
bucket.slots.push(slot)
|
||||
}
|
||||
|
||||
return Array.from(byDate.entries()).map(([date, eventsMap]) => ({
|
||||
date,
|
||||
label: formatDateLabel(date),
|
||||
events: Array.from(eventsMap.values()),
|
||||
}))
|
||||
})
|
||||
|
||||
function isChecked(id: string): boolean {
|
||||
return selected.value.includes(id)
|
||||
}
|
||||
|
||||
function toggle(id: string, checked: boolean | null): void {
|
||||
const next = [...selected.value]
|
||||
const idx = next.indexOf(id)
|
||||
if (checked) {
|
||||
if (idx === -1) next.push(id)
|
||||
} else if (idx !== -1) {
|
||||
next.splice(idx, 1)
|
||||
}
|
||||
emit('update:modelValue', next)
|
||||
emit('blur')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2 mb-1 text-high-emphasis">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
|
||||
<VSkeletonLoader
|
||||
v-if="isLoading"
|
||||
type="article"
|
||||
/>
|
||||
|
||||
<VAlert
|
||||
v-else-if="isError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between ga-3">
|
||||
<span>Kon beschikbaarheidsopties niet laden.</span>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="refetch()"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-else-if="isEmpty"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
Er zijn nog geen tijdsloten beschikbaar.
|
||||
</VAlert>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="availability-groups"
|
||||
>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
:key="group.date"
|
||||
class="availability-date-group mb-4"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-2">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
|
||||
<template
|
||||
v-for="ev in group.events"
|
||||
:key="`${group.date}-${ev.eventId}`"
|
||||
>
|
||||
<div
|
||||
v-if="hasMultipleEvents"
|
||||
class="text-caption text-medium-emphasis mb-1"
|
||||
>
|
||||
{{ ev.eventName }}
|
||||
</div>
|
||||
<VCheckbox
|
||||
v-for="slot in ev.slots"
|
||||
:key="slot.id"
|
||||
:model-value="isChecked(slot.id)"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
class="availability-slot-checkbox"
|
||||
@update:model-value="(v: boolean | null) => toggle(slot.id, v)"
|
||||
>
|
||||
<template #label>
|
||||
<div>
|
||||
<span class="text-body-1">{{ slot.name }}</span>
|
||||
<span class="text-caption text-medium-emphasis ml-2">
|
||||
({{ stripSeconds(slot.start_time) }}–{{ stripSeconds(slot.end_time) }})
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</VCheckbox>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="displayedErrors.length"
|
||||
class="text-caption text-error mt-1"
|
||||
>
|
||||
{{ displayedErrors[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Vuetify's default VCheckbox spacing is tight; adding a little block
|
||||
margin gives the date-grouped list an easier scan rhythm. */
|
||||
.availability-slot-checkbox {
|
||||
margin-block: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import FieldAvailabilityPicker from './FieldAvailabilityPicker.vue'
|
||||
import FieldBoolean from './FieldBoolean.vue'
|
||||
import FieldCheckboxList from './FieldCheckboxList.vue'
|
||||
import FieldDate from './FieldDate.vue'
|
||||
@@ -9,7 +10,9 @@ import FieldNumber from './FieldNumber.vue'
|
||||
import FieldParagraph from './FieldParagraph.vue'
|
||||
import FieldPhone from './FieldPhone.vue'
|
||||
import FieldRadio from './FieldRadio.vue'
|
||||
import FieldSectionPriority from './FieldSectionPriority.vue'
|
||||
import FieldSelect from './FieldSelect.vue'
|
||||
import FieldTagPicker from './FieldTagPicker.vue'
|
||||
import FieldText from './FieldText.vue'
|
||||
import FieldTextarea from './FieldTextarea.vue'
|
||||
import FieldUrl from './FieldUrl.vue'
|
||||
@@ -45,10 +48,7 @@ function colsFor(width: FormFieldDisplayWidth): number {
|
||||
const smCols = computed(() => colsFor(props.field.display_width))
|
||||
|
||||
const isStubbed = computed(() =>
|
||||
props.field.field_type === FormFieldType.TAG_PICKER
|
||||
|| props.field.field_type === FormFieldType.AVAILABILITY_PICKER
|
||||
|| props.field.field_type === FormFieldType.SECTION_PRIORITY
|
||||
|| props.field.field_type === FormFieldType.FILE_UPLOAD
|
||||
props.field.field_type === FormFieldType.FILE_UPLOAD
|
||||
|| props.field.field_type === FormFieldType.IMAGE_UPLOAD
|
||||
|| props.field.field_type === FormFieldType.SIGNATURE
|
||||
|| props.field.field_type === FormFieldType.TABLE_ROWS
|
||||
@@ -185,6 +185,33 @@ function onBlur(): void {
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldTagPicker
|
||||
v-else-if="field.field_type === FormFieldType.TAG_PICKER"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldAvailabilityPicker
|
||||
v-else-if="field.field_type === FormFieldType.AVAILABILITY_PICKER"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldSectionPriority
|
||||
v-else-if="field.field_type === FormFieldType.SECTION_PRIORITY"
|
||||
:field="field"
|
||||
:model-value="modelValue"
|
||||
:error-messages="errorMessages"
|
||||
@update:model-value="onUpdate"
|
||||
@blur="onBlur"
|
||||
/>
|
||||
|
||||
<FieldHeading
|
||||
v-else-if="field.field_type === FormFieldType.HEADING"
|
||||
:field="field"
|
||||
|
||||
343
apps/portal/src/components/public-form/FieldSectionPriority.vue
Normal file
343
apps/portal/src/components/public-form/FieldSectionPriority.vue
Normal file
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import type { PublicFormField, PublicFormSectionOption, SectionPriorityValue } from '@/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: SectionPriorityValue[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
const token = usePublicFormToken()
|
||||
const { data: sections, isLoading, isError, refetch } = usePublicFormSections(token)
|
||||
const { mobile } = useDisplay()
|
||||
|
||||
const HARD_CAP = 5
|
||||
const warnedOnMalformedValue = { value: false }
|
||||
|
||||
function selfHealIncoming(value: unknown): SectionPriorityValue[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
|
||||
const cleaned: SectionPriorityValue[] = []
|
||||
for (const entry of value) {
|
||||
if (entry === null || typeof entry !== 'object') continue
|
||||
const obj = entry as Record<string, unknown>
|
||||
const id = obj.section_id
|
||||
const prio = obj.priority
|
||||
if (typeof id !== 'string' || typeof prio !== 'number') continue
|
||||
cleaned.push({ section_id: id, priority: prio })
|
||||
}
|
||||
|
||||
// If the inbound array had data but none of it matched the shape,
|
||||
// warn once so future misuses are spotted in dev.
|
||||
if (import.meta.env.DEV && value.length > 0 && cleaned.length === 0 && !warnedOnMalformedValue.value) {
|
||||
console.warn('[FieldSectionPriority] modelValue not in {section_id, priority}[] shape — self-healing to [].')
|
||||
warnedOnMalformedValue.value = true
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
const ranked = ref<SectionPriorityValue[]>(selfHealIncoming(props.modelValue))
|
||||
|
||||
watch(() => props.modelValue, v => {
|
||||
ranked.value = selfHealIncoming(v)
|
||||
}, { deep: true })
|
||||
|
||||
const maxPriorities = computed(() => {
|
||||
const raw = props.field.validation_rules?.max_priorities
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
return Math.min(raw, HARD_CAP)
|
||||
}
|
||||
|
||||
return HARD_CAP
|
||||
})
|
||||
|
||||
const sectionsById = computed<Record<string, PublicFormSectionOption>>(() => {
|
||||
const list = sections.value ?? []
|
||||
const map: Record<string, PublicFormSectionOption> = {}
|
||||
for (const s of list) map[s.id] = s
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const unrankedPool = computed<PublicFormSectionOption[]>(() => {
|
||||
const list = sections.value ?? []
|
||||
const rankedIds = new Set(ranked.value.map(r => r.section_id))
|
||||
|
||||
return list.filter(s => !rankedIds.has(s.id))
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => !sections.value || sections.value.length === 0)
|
||||
const rankedFull = computed(() => ranked.value.length >= maxPriorities.value)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, ranked.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
|
||||
function reassignPriorities(list: SectionPriorityValue[]): SectionPriorityValue[] {
|
||||
return list.map((item, index) => ({ section_id: item.section_id, priority: index + 1 }))
|
||||
}
|
||||
|
||||
function emitNow(): void {
|
||||
emit('update:modelValue', ranked.value)
|
||||
emit('blur')
|
||||
}
|
||||
|
||||
function rankSection(section: PublicFormSectionOption): void {
|
||||
if (rankedFull.value) return
|
||||
ranked.value = reassignPriorities([
|
||||
...ranked.value,
|
||||
{ section_id: section.id, priority: ranked.value.length + 1 },
|
||||
])
|
||||
emitNow()
|
||||
}
|
||||
|
||||
function unrankAt(index: number): void {
|
||||
const next = [...ranked.value]
|
||||
next.splice(index, 1)
|
||||
ranked.value = reassignPriorities(next)
|
||||
emitNow()
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
// vuedraggable already mutated ranked.value via v-model — we just
|
||||
// renumber priorities and emit.
|
||||
ranked.value = reassignPriorities(ranked.value)
|
||||
emitNow()
|
||||
}
|
||||
|
||||
function sectionNameFor(id: string): string {
|
||||
return sectionsById.value[id]?.name ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-body-2 mb-1 text-high-emphasis">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="field.help_text"
|
||||
class="text-caption text-medium-emphasis mb-2"
|
||||
>
|
||||
{{ field.help_text }}
|
||||
</p>
|
||||
|
||||
<VSkeletonLoader
|
||||
v-if="isLoading"
|
||||
type="article"
|
||||
/>
|
||||
|
||||
<VAlert
|
||||
v-else-if="isError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="d-flex flex-wrap align-center justify-space-between ga-3">
|
||||
<span>Kon secties niet laden.</span>
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="refetch()"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<VAlert
|
||||
v-else-if="isEmpty"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
Er zijn nog geen secties gepubliceerd voor registratie.
|
||||
</VAlert>
|
||||
|
||||
<div v-else>
|
||||
<!-- Ranked list -->
|
||||
<div class="mb-2 text-caption text-medium-emphasis">
|
||||
Jouw voorkeuren ({{ ranked.length }} / {{ maxPriorities }})
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
v-model="ranked"
|
||||
item-key="section_id"
|
||||
handle=".section-priority-handle"
|
||||
:animation="180"
|
||||
:delay="100"
|
||||
:delay-on-touch-only="true"
|
||||
class="section-priority-ranked mb-4"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<VCard
|
||||
variant="outlined"
|
||||
class="section-priority-ranked-item d-flex align-center pa-3 mb-2"
|
||||
:aria-label="`Voorkeur ${index + 1}: ${sectionNameFor(element.section_id)}`"
|
||||
>
|
||||
<div class="section-priority-rank me-3 text-primary font-weight-bold text-h6">
|
||||
{{ index + 1 }}
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="sectionsById[element.section_id]?.icon"
|
||||
:icon="sectionsById[element.section_id]!.icon!"
|
||||
size="18"
|
||||
class="me-2 text-medium-emphasis"
|
||||
/>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-body-1">
|
||||
{{ sectionNameFor(element.section_id) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="sectionsById[element.section_id]?.registration_description"
|
||||
class="text-caption text-medium-emphasis"
|
||||
>
|
||||
{{ sectionsById[element.section_id]!.registration_description }}
|
||||
</div>
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="!mobile"
|
||||
class="section-priority-handle me-2 text-disabled"
|
||||
style="cursor: grab;"
|
||||
icon="tabler-grip-vertical"
|
||||
size="18"
|
||||
/>
|
||||
<VBtn
|
||||
icon="tabler-x"
|
||||
size="small"
|
||||
variant="text"
|
||||
:aria-label="`Verwijder ${sectionNameFor(element.section_id)} uit je voorkeuren`"
|
||||
@click="unrankAt(index)"
|
||||
/>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
|
||||
<div
|
||||
v-if="ranked.length === 0"
|
||||
class="text-caption text-medium-emphasis mb-4"
|
||||
>
|
||||
Tik of sleep een sectie hieronder om je eerste voorkeur te kiezen.
|
||||
</div>
|
||||
|
||||
<!-- Unranked pool -->
|
||||
<div class="mb-2 text-caption text-medium-emphasis">
|
||||
Nog te kiezen
|
||||
</div>
|
||||
<VRow dense>
|
||||
<VCol
|
||||
v-for="section in unrankedPool"
|
||||
:key="section.id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
>
|
||||
<VCard
|
||||
variant="outlined"
|
||||
class="section-priority-unranked-card pa-3"
|
||||
:class="{ 'section-priority-unranked-disabled': rankedFull }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`Voeg ${section.name} toe aan je voorkeuren`"
|
||||
:aria-disabled="rankedFull"
|
||||
@click="rankSection(section)"
|
||||
@keydown.enter.prevent="rankSection(section)"
|
||||
@keydown.space.prevent="rankSection(section)"
|
||||
>
|
||||
<div class="d-flex align-center">
|
||||
<VIcon
|
||||
v-if="section.icon"
|
||||
:icon="section.icon"
|
||||
size="18"
|
||||
class="me-2 text-medium-emphasis"
|
||||
/>
|
||||
<div class="flex-grow-1">
|
||||
<div class="text-body-1">
|
||||
{{ section.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="section.registration_description"
|
||||
class="text-caption text-medium-emphasis"
|
||||
>
|
||||
{{ section.registration_description }}
|
||||
</div>
|
||||
</div>
|
||||
<VIcon
|
||||
v-if="!rankedFull"
|
||||
icon="tabler-plus"
|
||||
size="18"
|
||||
class="text-primary"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="rankedFull"
|
||||
class="text-caption text-medium-emphasis mt-1"
|
||||
>
|
||||
Maximaal {{ maxPriorities }} voorkeuren
|
||||
</div>
|
||||
</VCard>
|
||||
</VCol>
|
||||
</VRow>
|
||||
|
||||
<div
|
||||
v-if="displayedErrors.length"
|
||||
class="text-caption text-error mt-2"
|
||||
>
|
||||
{{ displayedErrors[0] }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Vuetify's VCard disabled state isn't contextual enough for an
|
||||
"over-cap" affordance — we need it to look dimmed without blocking
|
||||
assistive tech, which Vuetify's :disabled would do. */
|
||||
.section-priority-unranked-card {
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms;
|
||||
}
|
||||
|
||||
.section-priority-unranked-card:hover,
|
||||
.section-priority-unranked-card:focus-visible {
|
||||
background-color: rgb(var(--v-theme-surface-variant));
|
||||
}
|
||||
|
||||
.section-priority-unranked-disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.section-priority-unranked-disabled:hover,
|
||||
.section-priority-unranked-disabled:focus-visible {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.section-priority-rank {
|
||||
min-inline-size: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
115
apps/portal/src/components/public-form/FieldTagPicker.vue
Normal file
115
apps/portal/src/components/public-form/FieldTagPicker.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvailableTag, PublicFormField } from '@/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
modelValue: unknown
|
||||
errorMessages?: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: string[]): void
|
||||
(e: 'blur'): void
|
||||
}>()
|
||||
|
||||
interface NormalizedTag {
|
||||
title: string
|
||||
value: string
|
||||
category: string
|
||||
}
|
||||
|
||||
const OVERIG = 'Overig'
|
||||
|
||||
function normalize(tag: AvailableTag): NormalizedTag {
|
||||
const category = (tag.category && tag.category.trim() !== '') ? tag.category : OVERIG
|
||||
|
||||
return { title: tag.name, value: tag.id, category }
|
||||
}
|
||||
|
||||
const items = computed<NormalizedTag[]>(() => {
|
||||
const tags = props.field.available_tags ?? []
|
||||
|
||||
// Server already orders within each category; we only need a stable
|
||||
// per-category grouping so the #item slot can emit a subheader when
|
||||
// the category flips.
|
||||
return [...tags].map(normalize).sort((a, b) => {
|
||||
if (a.category === b.category) return 0
|
||||
|
||||
return a.category < b.category ? -1 : 1
|
||||
})
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => items.value.length === 0)
|
||||
|
||||
const model = computed({
|
||||
get: () => (Array.isArray(props.modelValue) ? props.modelValue as string[] : []),
|
||||
set: (v: string[]) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
// Tracks the previous category rendered during the #item slot iteration
|
||||
// so we can emit a VListSubheader right before the first item in each
|
||||
// new category. VAutocomplete re-iterates on every render.
|
||||
let lastCategory: string | null = null
|
||||
|
||||
function shouldRenderSubheader(category: string): boolean {
|
||||
const flip = lastCategory !== category
|
||||
lastCategory = category
|
||||
|
||||
return flip
|
||||
}
|
||||
|
||||
function resetSubheaderTracker(): void {
|
||||
lastCategory = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<VAlert
|
||||
v-if="isEmpty"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-1">
|
||||
{{ field.label }}<span
|
||||
v-if="field.is_required"
|
||||
class="text-error"
|
||||
> *</span>
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
Er zijn nog geen tags beschikbaar voor dit formulier.
|
||||
</div>
|
||||
</VAlert>
|
||||
|
||||
<AppAutocomplete
|
||||
v-else
|
||||
v-model="model"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
:items="items"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:label="field.label"
|
||||
:hint="field.help_text ?? undefined"
|
||||
persistent-hint
|
||||
:rules="rules"
|
||||
:error-messages="errorMessages"
|
||||
:required="field.is_required"
|
||||
@update:menu="(open: boolean) => { if (open) resetSubheaderTracker() }"
|
||||
@update:model-value="emit('blur')"
|
||||
@blur="emit('blur')"
|
||||
>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<VListSubheader v-if="shouldRenderSubheader(item.raw.category)">
|
||||
{{ item.raw.category }}
|
||||
</VListSubheader>
|
||||
<VListItem v-bind="itemProps" />
|
||||
</template>
|
||||
</AppAutocomplete>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,13 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import IdentityMatchBanner from './IdentityMatchBanner.vue'
|
||||
import type { FormStep } from '@/composables/useFormSteps'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type { FormValues, PublicFormField } from '@/types/formBuilder'
|
||||
import type { FormValues, PublicFormField, PublicFormSubmissionIdentityMatch } from '@/types/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: FormStep[]
|
||||
values: FormValues
|
||||
submitterName?: string
|
||||
submitterEmail?: string
|
||||
identityMatch?: PublicFormSubmissionIdentityMatch | null
|
||||
}>()
|
||||
|
||||
function displayValue(field: PublicFormField): string {
|
||||
@@ -53,6 +55,16 @@ function answerableFields(step: FormStep): PublicFormField[] {
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText
|
||||
v-if="identityMatch"
|
||||
class="pa-6 pb-0"
|
||||
>
|
||||
<IdentityMatchBanner
|
||||
:status="identityMatch.status"
|
||||
:message="identityMatch.message"
|
||||
/>
|
||||
</VCardText>
|
||||
|
||||
<VCardText class="pa-6">
|
||||
<h3 class="text-subtitle-1 font-weight-medium mb-3">
|
||||
Contactgegevens
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
status: 'pending' | 'matched' | 'none' | null
|
||||
message?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Frontend fallbacks for each state — mirror the backend copy so the
|
||||
// banner still renders correctly if a future response trims `message`.
|
||||
// Backend `message` is authoritative (single source of truth for copy).
|
||||
const FALLBACK_TITLE: Record<Exclude<Props['status'], null>, string> = {
|
||||
pending: 'We controleren je gegevens',
|
||||
matched: 'Gegevens gekoppeld',
|
||||
none: 'Aanmelding ontvangen',
|
||||
}
|
||||
const FALLBACK_BODY: Record<Exclude<Props['status'], null>, string> = {
|
||||
pending: 'We kijken of je al bekend bent bij de organisator. Je gegevens worden automatisch gekoppeld zodra zij dit bevestigen.',
|
||||
matched: 'Je bent automatisch gekoppeld aan je bestaande account bij de organisator.',
|
||||
none: 'De organisator neemt contact met je op zodra je aanmelding is verwerkt.',
|
||||
}
|
||||
const TYPE: Record<Exclude<Props['status'], null>, 'info' | 'success'> = {
|
||||
pending: 'info',
|
||||
matched: 'success',
|
||||
none: 'success',
|
||||
}
|
||||
|
||||
const body = computed(() => {
|
||||
if (!props.status) return ''
|
||||
const backend = (props.message ?? '').trim()
|
||||
if (backend) return backend
|
||||
|
||||
return FALLBACK_BODY[props.status]
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.status) return ''
|
||||
|
||||
return FALLBACK_TITLE[props.status]
|
||||
})
|
||||
|
||||
const alertType = computed(() => {
|
||||
if (!props.status) return 'info'
|
||||
|
||||
return TYPE[props.status]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VAlert
|
||||
v-if="status"
|
||||
:type="alertType"
|
||||
variant="tonal"
|
||||
prominent
|
||||
class="identity-match-banner mb-4"
|
||||
>
|
||||
<div class="text-subtitle-1 font-weight-medium mb-1">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
{{ body }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</template>
|
||||
27
apps/portal/src/composables/api/usePublicFormSections.ts
Normal file
27
apps/portal/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 '@/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/portal/src/composables/api/usePublicFormTimeSlots.ts
Normal file
28
apps/portal/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 '@/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,
|
||||
})
|
||||
}
|
||||
21
apps/portal/src/composables/publicFormInjection.ts
Normal file
21
apps/portal/src/composables/publicFormInjection.ts
Normal file
@@ -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<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
|
||||
}
|
||||
@@ -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<string | null>(() => 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"
|
||||
/>
|
||||
|
||||
<!-- Data state -->
|
||||
|
||||
@@ -165,6 +165,30 @@ export interface PublicFormErrorBody {
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
|
||||
export interface StartDraftBody {
|
||||
|
||||
@@ -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<Array<Record<string, unknown>> | 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> = {}): 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>): 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: '<div class="v-skeleton-stub"/>' },
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type'],
|
||||
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
VBtn: {
|
||||
name: 'VBtn',
|
||||
template: '<button class="v-btn-stub" @click="$emit(\'click\')"><slot/></button>',
|
||||
},
|
||||
VCheckbox: {
|
||||
name: 'VCheckbox',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<label class="v-checkbox-stub">
|
||||
<input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', ($event.target).checked)"/>
|
||||
<slot name="label"/>
|
||||
</label>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,9 @@ function mountRenderer(field: PublicFormField, allValues: Record<string, unknown
|
||||
FieldSelect: { name: 'FieldSelect', template: '<div class="field-select-stub"/>' },
|
||||
FieldMultiselect: { name: 'FieldMultiselect', template: '<div class="field-multiselect-stub"/>' },
|
||||
FieldCheckboxList: { name: 'FieldCheckboxList', template: '<div class="field-checkboxlist-stub"/>' },
|
||||
FieldTagPicker: { name: 'FieldTagPicker', template: '<div class="field-tagpicker-stub"/>' },
|
||||
FieldAvailabilityPicker: { name: 'FieldAvailabilityPicker', template: '<div class="field-availabilitypicker-stub"/>' },
|
||||
FieldSectionPriority: { name: 'FieldSectionPriority', template: '<div class="field-sectionpriority-stub"/>' },
|
||||
FieldHeading: { name: 'FieldHeading', template: '<div class="field-heading-stub"/>' },
|
||||
FieldParagraph: { name: 'FieldParagraph', template: '<div class="field-paragraph-stub"/>' },
|
||||
FieldUrl: { name: 'FieldUrl', template: '<div class="field-url-stub"/>' },
|
||||
@@ -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<string>([
|
||||
FormFieldType.TAG_PICKER,
|
||||
FormFieldType.AVAILABILITY_PICKER,
|
||||
FormFieldType.SECTION_PRIORITY,
|
||||
FormFieldType.FILE_UPLOAD,
|
||||
FormFieldType.IMAGE_UPLOAD,
|
||||
FormFieldType.SIGNATURE,
|
||||
|
||||
@@ -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<Array<Record<string, unknown>> | 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: '<div class="draggable-stub"><template v-for="(el, i) in modelValue" :key="i"><slot name="item" :element="el" :index="i"/></template></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
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> = {}): 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>): 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: '<div class="v-skeleton-stub"/>' },
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type'],
|
||||
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
VBtn: {
|
||||
name: 'VBtn',
|
||||
props: ['ariaLabel', 'icon'],
|
||||
template: '<button class="v-btn-stub" @click="$emit(\'click\')"><slot/></button>',
|
||||
},
|
||||
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: '<div class="v-card-stub"><slot/></div>',
|
||||
},
|
||||
VRow: { name: 'VRow', template: '<div class="v-row-stub"><slot/></div>' },
|
||||
VCol: { name: 'VCol', template: '<div class="v-col-stub"><slot/></div>' },
|
||||
VIcon: { name: 'VIcon', template: '<i class="v-icon-stub"/>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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<Array<Array<{ section_id: string; priority: number }>>>
|
||||
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<Array<Array<{ section_id: string; priority: number }>>>
|
||||
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<string, unknown> }),
|
||||
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')
|
||||
})
|
||||
})
|
||||
148
apps/portal/tests/components/public-form/FieldTagPicker.spec.ts
Normal file
148
apps/portal/tests/components/public-form/FieldTagPicker.spec.ts
Normal file
@@ -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> = {}): 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>): 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: '<div class="v-alert-stub"><slot/></div>' },
|
||||
AppAutocomplete: {
|
||||
name: 'AppAutocomplete',
|
||||
props: ['modelValue', 'items', 'label', 'hint', 'required', 'errorMessages'],
|
||||
emits: ['update:modelValue', 'blur'],
|
||||
template: `<div class="app-autocomplete-stub" :data-label="label">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
class="stub-item"
|
||||
:data-value="item.value"
|
||||
:data-category="item.category"
|
||||
@click="$emit('update:modelValue', [...(modelValue || []), item.value])"
|
||||
>{{ item.title }}</button>
|
||||
<button
|
||||
class="stub-unselect"
|
||||
@click="$emit('update:modelValue', (modelValue || []).slice(0, -1))"
|
||||
>unselect-last</button>
|
||||
</div>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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'])
|
||||
})
|
||||
})
|
||||
@@ -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: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<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 '@/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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user