diff --git a/apps/app/.eslintrc.cjs b/apps/app/.eslintrc.cjs index 75d33856..4c9a83d6 100644 --- a/apps/app/.eslintrc.cjs +++ b/apps/app/.eslintrc.cjs @@ -208,26 +208,41 @@ module.exports = { // The `lib → stores` edge is intentionally disallowed; lib/axios.ts // uses dynamic `await import('@/stores/...')` for its 4 store reads // so the static-import surface stays clean. - // Sub-zone enforcement (components/{organizer,portal,shared}) is a - // backlog item (TECH-WS3-BOUNDARIES-SUBZONES); it lands after the - // §4.2 consolidation directory layout. + // + // WS-3 PR-B1 activated TECH-WS3-BOUNDARIES-SUBZONES: components and + // pages now have organizer/portal/shared sub-zones (§4.2 charter). + // The cross-context edges (organizer ↛ portal, shared ↛ portal/organizer) + // are forbidden so a future portal-only refactor cannot leak into + // the organizer surface and vice versa. + // // FORWARD-FLAG: when src/plugins/1.router/ migrates to src/router/ - // in a later WS-3 PR, add `{ type: 'router', pattern: 'src/router/**' }` - // to boundaries/elements and `{ from: 'router', allow: ['types', - // 'utils', 'lib', 'plugins', 'stores'] }` to the rules. + // in a later WS-3 PR (TECH-WS3-BOUNDARIES-ROUTER-ZONE), add + // `{ type: 'router', pattern: 'src/router/**' }` to boundaries/elements + // and `{ from: 'router', allow: ['types', 'utils', 'lib', 'plugins', + // 'stores'] }` to the rules. 'boundaries/element-types': ['error', { default: 'disallow', rules: [ { from: 'types', allow: ['types'] }, { from: 'utils', allow: ['types', 'utils'] }, { from: 'lib', allow: ['types', 'utils', 'lib'] }, - { from: 'plugins', allow: ['types', 'utils', 'lib', 'plugins', 'stores'] }, - { from: 'composables', allow: ['types', 'utils', 'lib', 'composables', 'stores'] }, - { from: 'stores', allow: ['types', 'utils', 'lib', 'composables', 'stores'] }, + { from: 'plugins', allow: ['types', 'utils', 'lib', 'plugins', 'stores', 'stores-portal'] }, + { from: 'composables-forms', allow: ['types', 'utils', 'lib', 'composables-forms'] }, + { from: 'composables', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] }, + { from: 'stores-portal', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal'] }, + { from: 'stores', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores'] }, { from: 'navigation', allow: ['types', 'utils', 'navigation'] }, - { from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'stores', 'components'] }, - { from: 'layouts', allow: ['types', 'utils', 'lib', 'composables', 'stores', 'navigation', 'components', 'layouts'] }, - { from: 'pages', allow: ['types', 'utils', 'lib', 'composables', 'stores', 'navigation', 'components', 'layouts'] }, + { from: 'components-shared', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'components-shared'] }, + { from: 'components-portal', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'components-shared', 'components-portal'] }, + { from: 'components-organizer', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components-shared', 'components-organizer'] }, + { from: 'components', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'components', 'components-shared', 'components-organizer'] }, + { from: 'layouts', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'navigation', 'components', 'components-shared', 'components-portal', 'components-organizer', 'layouts'] }, + + { from: 'pages-register', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'plugins', 'components-shared', 'layouts'] }, + { from: 'pages-portal', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'navigation', 'components-shared', 'components-portal', 'layouts', 'plugins'] }, + { from: 'pages-organizer', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'navigation', 'components', 'components-shared', 'components-organizer', 'layouts', 'plugins'] }, + { from: 'pages-platform', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'navigation', 'components', 'components-shared', 'components-organizer', 'layouts', 'plugins'] }, + { from: 'pages', allow: ['types', 'utils', 'lib', 'composables', 'composables-forms', 'stores', 'stores-portal', 'navigation', 'components', 'components-shared', 'components-portal', 'components-organizer', 'layouts'] }, ], }], 'boundaries/no-unknown': 'off', // External packages are fine. @@ -239,17 +254,33 @@ module.exports = { typescript: {}, }, - // Element-type assignment: first-match-wins. Order matters. + // Element-type assignment: first-match-wins. Order matters — narrower + // sub-zones declared before their broader parents. 'boundaries/elements': [ { type: 'types', pattern: 'src/types/**' }, { type: 'utils', pattern: 'src/utils/**' }, { type: 'lib', pattern: 'src/lib/**' }, { type: 'plugins', pattern: 'src/plugins/**' }, + { type: 'composables-forms', pattern: 'src/composables/forms/**' }, { type: 'composables', pattern: 'src/composables/**' }, + { type: 'stores-portal', pattern: 'src/stores/portal/**' }, { type: 'stores', pattern: 'src/stores/**' }, { type: 'navigation', pattern: 'src/navigation/**' }, + + // components/shared/** is the canonical sub-zone. components/auth/** + // and components/settings/** are folded in here as legacy cross-context + // siblings (MFA dialogs + password-requirements widgets used by both + // organizer reset-password and portal wachtwoord-instellen / profiel + // flows). PR-B2 may rehome these under components/shared/{auth,settings}/. + { type: 'components-shared', pattern: 'src/components/{shared,auth,settings}/**' }, + { type: 'components-portal', pattern: 'src/components/portal/**' }, + { type: 'components-organizer', pattern: 'src/components/organizer/**' }, { type: 'components', pattern: 'src/components/**' }, { type: 'layouts', pattern: 'src/layouts/**' }, + { type: 'pages-register', pattern: 'src/pages/register/**' }, + { type: 'pages-portal', pattern: 'src/pages/portal/**' }, + { type: 'pages-platform', pattern: 'src/pages/platform/**' }, + { type: 'pages-organizer', pattern: 'src/pages/{events,members,organisation,account-settings,dashboard,invitations}/**' }, { type: 'pages', pattern: 'src/pages/**' }, ], 'boundaries/ignore': [ diff --git a/apps/app/auto-imports.d.ts b/apps/app/auto-imports.d.ts index d1511d10..e8747446 100644 --- a/apps/app/auto-imports.d.ts +++ b/apps/app/auto-imports.d.ts @@ -10,6 +10,8 @@ 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_LOCALE_KEY: typeof import('./src/composables/publicFormInjection')['PUBLIC_FORM_LOCALE_KEY'] + 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'] @@ -46,10 +48,14 @@ declare global { const defineLoader: typeof import('vue-router/auto')['defineLoader'] const definePage: typeof import('unplugin-vue-router/runtime')['definePage'] const defineStore: typeof import('pinia')['defineStore'] + const draftIdempotencyKey: typeof import('./src/composables/useFormDraft')['draftIdempotencyKey'] + const draftSubmitterStorageKey: typeof import('./src/composables/useFormDraft')['draftSubmitterStorageKey'] const eagerComputed: typeof import('@vueuse/core')['eagerComputed'] const effectScope: typeof import('vue')['effectScope'] const emailValidator: typeof import('./src/@core/utils/validators')['emailValidator'] const extendRef: typeof import('@vueuse/core')['extendRef'] + const extractErrorBody: typeof import('./src/composables/useFormDraft')['extractErrorBody'] + const extractRetryAfterSeconds: typeof import('./src/composables/useFormDraft')['extractRetryAfterSeconds'] const formatDate: typeof import('./src/@core/utils/formatters')['formatDate'] const formatDateToMonthShort: typeof import('./src/@core/utils/formatters')['formatDateToMonthShort'] const generateDeviceFingerprint: typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint'] @@ -113,6 +119,8 @@ 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 providePublicFormLocale: typeof import('./src/composables/publicFormInjection')['providePublicFormLocale'] + 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'] @@ -235,6 +243,7 @@ declare global { const useFloor: typeof import('@vueuse/math')['useFloor'] const useFocus: typeof import('@vueuse/core')['useFocus'] const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin'] + const useFormDraft: typeof import('./src/composables/useFormDraft')['useFormDraft'] const useFps: typeof import('@vueuse/core')['useFps'] const useFullscreen: typeof import('@vueuse/core')['useFullscreen'] const useGamepad: typeof import('@vueuse/core')['useGamepad'] @@ -289,6 +298,8 @@ declare global { const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion'] const usePrevious: typeof import('@vueuse/core')['usePrevious'] const useProjection: typeof import('@vueuse/math')['useProjection'] + const usePublicFormLocale: typeof import('./src/composables/publicFormInjection')['usePublicFormLocale'] + 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'] @@ -382,6 +393,8 @@ declare module 'vue' { interface ComponentCustomProperties { readonly COOKIE_MAX_AGE_1_YEAR: UnwrapRef readonly EffectScope: UnwrapRef + readonly PUBLIC_FORM_LOCALE_KEY: UnwrapRef + readonly PUBLIC_FORM_TOKEN_KEY: UnwrapRef readonly acceptHMRUpdate: UnwrapRef readonly alphaDashValidator: UnwrapRef readonly alphaValidator: UnwrapRef @@ -417,10 +430,14 @@ declare module 'vue' { readonly defineComponent: UnwrapRef readonly definePage: UnwrapRef readonly defineStore: UnwrapRef + readonly draftIdempotencyKey: UnwrapRef + readonly draftSubmitterStorageKey: UnwrapRef readonly eagerComputed: UnwrapRef readonly effectScope: UnwrapRef readonly emailValidator: UnwrapRef readonly extendRef: UnwrapRef + readonly extractErrorBody: UnwrapRef + readonly extractRetryAfterSeconds: UnwrapRef readonly formatDate: UnwrapRef readonly formatDateToMonthShort: UnwrapRef readonly generateDeviceFingerprint: UnwrapRef @@ -483,6 +500,8 @@ declare module 'vue' { readonly prefixWithPlus: UnwrapRef readonly provide: UnwrapRef readonly provideLocal: UnwrapRef + readonly providePublicFormLocale: UnwrapRef + readonly providePublicFormToken: UnwrapRef readonly reactify: UnwrapRef readonly reactifyObject: UnwrapRef readonly reactive: UnwrapRef @@ -599,6 +618,7 @@ declare module 'vue' { readonly useFloor: UnwrapRef readonly useFocus: UnwrapRef readonly useFocusWithin: UnwrapRef + readonly useFormDraft: UnwrapRef readonly useFps: UnwrapRef readonly useFullscreen: UnwrapRef readonly useGamepad: UnwrapRef @@ -652,6 +672,8 @@ declare module 'vue' { readonly usePreferredReducedMotion: UnwrapRef readonly usePrevious: UnwrapRef readonly useProjection: UnwrapRef + readonly usePublicFormLocale: UnwrapRef + readonly usePublicFormToken: UnwrapRef readonly useRafFn: UnwrapRef readonly useRefHistory: UnwrapRef readonly useResizeObserver: UnwrapRef diff --git a/apps/app/components.d.ts b/apps/app/components.d.ts index dc10f3de..d9e515be 100644 --- a/apps/app/components.d.ts +++ b/apps/app/components.d.ts @@ -33,6 +33,7 @@ declare module 'vue' { CardStatisticsHorizontal: typeof import('./src/@core/components/cards/CardStatisticsHorizontal.vue')['default'] CardStatisticsVertical: typeof import('./src/@core/components/cards/CardStatisticsVertical.vue')['default'] CardStatisticsVerticalSimple: typeof import('./src/@core/components/CardStatisticsVerticalSimple.vue')['default'] + ClaimenTab: typeof import('./src/components/portal/event/ClaimenTab.vue')['default'] CompanyDialog: typeof import('./src/components/organisation/CompanyDialog.vue')['default'] ConfirmDialog: typeof import('./src/components/dialogs/ConfirmDialog.vue')['default'] CreateAppDialog: typeof import('./src/components/dialogs/CreateAppDialog.vue')['default'] @@ -57,6 +58,7 @@ declare module 'vue' { DialogCloseBtn: typeof import('./src/@core/components/DialogCloseBtn.vue')['default'] DismissFailureDialog: typeof import('./src/components/form-failures/DismissFailureDialog.vue')['default'] DropZone: typeof import('./src/@core/components/DropZone.vue')['default'] + DuplicateSubmissionHint: typeof import('./src/components/shared/public-form/DuplicateSubmissionHint.vue')['default'] EditEventDialog: typeof import('./src/components/events/EditEventDialog.vue')['default'] EditOrganisationDialog: typeof import('./src/components/organisations/EditOrganisationDialog.vue')['default'] EditPersonDialog: typeof import('./src/components/persons/EditPersonDialog.vue')['default'] @@ -66,15 +68,39 @@ declare module 'vue' { EmailTemplatesTab: typeof import('./src/components/organisation/EmailTemplatesTab.vue')['default'] EnableOneTimePasswordDialog: typeof import('./src/components/dialogs/EnableOneTimePasswordDialog.vue')['default'] ErrorHeader: typeof import('./src/components/ErrorHeader.vue')['default'] + EventCard: typeof import('./src/components/portal/EventCard.vue')['default'] EventMetricCards: typeof import('./src/components/events/EventMetricCards.vue')['default'] EventTabsNav: typeof import('./src/components/events/EventTabsNav.vue')['default'] + FieldAvailabilityPicker: typeof import('./src/components/shared/public-form/FieldAvailabilityPicker.vue')['default'] + FieldBoolean: typeof import('./src/components/shared/public-form/FieldBoolean.vue')['default'] + FieldCheckboxList: typeof import('./src/components/shared/public-form/FieldCheckboxList.vue')['default'] + FieldDate: typeof import('./src/components/shared/public-form/FieldDate.vue')['default'] + FieldEmail: typeof import('./src/components/shared/public-form/FieldEmail.vue')['default'] + FieldHeading: typeof import('./src/components/shared/public-form/FieldHeading.vue')['default'] + FieldMultiselect: typeof import('./src/components/shared/public-form/FieldMultiselect.vue')['default'] + FieldNumber: typeof import('./src/components/shared/public-form/FieldNumber.vue')['default'] + FieldParagraph: typeof import('./src/components/shared/public-form/FieldParagraph.vue')['default'] + FieldPhone: typeof import('./src/components/shared/public-form/FieldPhone.vue')['default'] + FieldRadio: typeof import('./src/components/shared/public-form/FieldRadio.vue')['default'] + FieldRenderer: typeof import('./src/components/shared/public-form/FieldRenderer.vue')['default'] + FieldSectionPriority: typeof import('./src/components/shared/public-form/FieldSectionPriority.vue')['default'] + FieldSelect: typeof import('./src/components/shared/public-form/FieldSelect.vue')['default'] + FieldTagPicker: typeof import('./src/components/shared/public-form/FieldTagPicker.vue')['default'] + FieldText: typeof import('./src/components/shared/public-form/FieldText.vue')['default'] + FieldTextarea: typeof import('./src/components/shared/public-form/FieldTextarea.vue')['default'] + FieldUrl: typeof import('./src/components/shared/public-form/FieldUrl.vue')['default'] + FormConfirmation: typeof import('./src/components/shared/public-form/FormConfirmation.vue')['default'] + FormErrorState: typeof import('./src/components/shared/public-form/FormErrorState.vue')['default'] FormFailureDetail: typeof import('./src/components/form-failures/FormFailureDetail.vue')['default'] FormFailuresTable: typeof import('./src/components/form-failures/FormFailuresTable.vue')['default'] + FormStepper: typeof import('./src/components/shared/public-form/FormStepper.vue')['default'] I18n: typeof import('./src/@core/components/I18n.vue')['default'] + IdentityMatchBanner: typeof import('./src/components/shared/public-form/IdentityMatchBanner.vue')['default'] ImageUploadField: typeof import('./src/components/common/ImageUploadField.vue')['default'] ImpersonateDialog: typeof import('./src/components/platform/ImpersonateDialog.vue')['default'] ImpersonationBanner: typeof import('./src/components/platform/ImpersonationBanner.vue')['default'] ImportFromEventDialog: typeof import('./src/components/event/ImportFromEventDialog.vue')['default'] + InformatieTab: typeof import('./src/components/portal/event/InformatieTab.vue')['default'] InfoTooltip: typeof import('./src/components/common/InfoTooltip.vue')['default'] InviteMemberDialog: typeof import('./src/components/members/InviteMemberDialog.vue')['default'] MfaChallengeCard: typeof import('./src/components/auth/MfaChallengeCard.vue')['default'] @@ -85,6 +111,7 @@ declare module 'vue' { Notifications: typeof import('./src/@core/components/Notifications.vue')['default'] NotificationsTab: typeof import('./src/components/account-settings/NotificationsTab.vue')['default'] OrganisationSwitcher: typeof import('./src/components/layout/OrganisationSwitcher.vue')['default'] + OverzichtTab: typeof import('./src/components/portal/event/OverzichtTab.vue')['default'] PasswordRequirements: typeof import('./src/components/auth/PasswordRequirements.vue')['default'] PaymentProvidersDialog: typeof import('./src/components/dialogs/PaymentProvidersDialog.vue')['default'] PersonDetailPanel: typeof import('./src/components/persons/PersonDetailPanel.vue')['default'] @@ -95,6 +122,7 @@ declare module 'vue' { RegistrationFieldTemplatesTab: typeof import('./src/components/organisation/RegistrationFieldTemplatesTab.vue')['default'] ResolveFailureDialog: typeof import('./src/components/form-failures/ResolveFailureDialog.vue')['default'] RetryFailureDialog: typeof import('./src/components/form-failures/RetryFailureDialog.vue')['default'] + RoosterTab: typeof import('./src/components/portal/event/RoosterTab.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] ScrollToTop: typeof import('./src/@core/components/ScrollToTop.vue')['default'] @@ -109,11 +137,14 @@ declare module 'vue' { ShareProjectDialog: typeof import('./src/components/dialogs/ShareProjectDialog.vue')['default'] ShiftDetailPanel: typeof import('./src/components/shifts/ShiftDetailPanel.vue')['default'] Shortcuts: typeof import('./src/@core/components/Shortcuts.vue')['default'] + StatusCard: typeof import('./src/components/portal/StatusCard.vue')['default'] + SubmitterDetails: typeof import('./src/components/shared/public-form/SubmitterDetails.vue')['default'] TablePagination: typeof import('./src/@core/components/TablePagination.vue')['default'] TemplatePickerDialog: typeof import('./src/components/event/TemplatePickerDialog.vue')['default'] ThemeSwitcher: typeof import('./src/@core/components/ThemeSwitcher.vue')['default'] TiptapEditor: typeof import('./src/@core/components/TiptapEditor.vue')['default'] TwoFactorAuthDialog: typeof import('./src/components/dialogs/TwoFactorAuthDialog.vue')['default'] + UserAvatarMenu: typeof import('./src/components/portal/UserAvatarMenu.vue')['default'] UserInfoEditDialog: typeof import('./src/components/dialogs/UserInfoEditDialog.vue')['default'] } } diff --git a/apps/app/env.d.ts b/apps/app/env.d.ts index 552bc501..15d88625 100644 --- a/apps/app/env.d.ts +++ b/apps/app/env.d.ts @@ -16,8 +16,15 @@ declare module 'vue-router' { subject?: string layoutWrapperClasses?: string navActiveLink?: RouteLocationRaw - layout?: 'blank' | 'default' + layout?: 'blank' | 'default' | 'OrganizerLayout' | 'PortalLayout' | 'PublicLayout' unauthenticatedOnly?: boolean public?: boolean + + // WS-3 PR-B1 — added for the consolidated SPA. PR-B2 evolves these. + requiresAuth?: boolean + context?: 'organizer' | 'portal' + requiresToken?: boolean + navMode?: 'event' | 'platform' + navTitle?: string } } diff --git a/apps/portal/src/components/portal/EventCard.vue b/apps/app/src/components/portal/EventCard.vue similarity index 92% rename from apps/portal/src/components/portal/EventCard.vue rename to apps/app/src/components/portal/EventCard.vue index 275163fc..5ce7cfc0 100644 --- a/apps/portal/src/components/portal/EventCard.vue +++ b/apps/app/src/components/portal/EventCard.vue @@ -6,10 +6,14 @@ const props = defineProps<{ }>() function statusColor(status: string): string { - if (status === 'approved') return 'success' - if (status === 'pending' || status === 'applied') return 'warning' - if (status === 'invited') return 'info' - if (status === 'rejected') return 'error' + if (status === 'approved') + return 'success' + if (status === 'pending' || status === 'applied') + return 'warning' + if (status === 'invited') + return 'info' + if (status === 'rejected') + return 'error' return 'secondary' } @@ -33,9 +37,8 @@ function formatDates(startDate: string, endDate: string): string { const end = new Date(`${endDate}T12:00:00`) const opts: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'long', year: 'numeric' } - if (startDate === endDate) { + if (startDate === endDate) return start.toLocaleDateString('nl-NL', opts) - } return `${start.toLocaleDateString('nl-NL', opts)} – ${end.toLocaleDateString('nl-NL', opts)}` } diff --git a/apps/portal/src/components/portal/StatusCard.vue b/apps/app/src/components/portal/StatusCard.vue similarity index 99% rename from apps/portal/src/components/portal/StatusCard.vue rename to apps/app/src/components/portal/StatusCard.vue index 5d9ddd64..b380d00d 100644 --- a/apps/portal/src/components/portal/StatusCard.vue +++ b/apps/app/src/components/portal/StatusCard.vue @@ -13,7 +13,8 @@ const emit = defineEmits<{ }>() const registeredLabel = computed(() => { - if (!props.registeredAt) return null + if (!props.registeredAt) + return null try { return new Date(props.registeredAt).toLocaleDateString('nl-NL', { day: 'numeric', diff --git a/apps/portal/src/components/portal/UserAvatarMenu.vue b/apps/app/src/components/portal/UserAvatarMenu.vue similarity index 92% rename from apps/portal/src/components/portal/UserAvatarMenu.vue rename to apps/app/src/components/portal/UserAvatarMenu.vue index 65d58968..8c7ed491 100644 --- a/apps/portal/src/components/portal/UserAvatarMenu.vue +++ b/apps/app/src/components/portal/UserAvatarMenu.vue @@ -1,12 +1,13 @@ diff --git a/apps/portal/src/components/public-form/FieldParagraph.vue b/apps/app/src/components/shared/public-form/FieldParagraph.vue similarity index 72% rename from apps/portal/src/components/public-form/FieldParagraph.vue rename to apps/app/src/components/shared/public-form/FieldParagraph.vue index 17f86883..12e97a92 100644 --- a/apps/portal/src/components/public-form/FieldParagraph.vue +++ b/apps/app/src/components/shared/public-form/FieldParagraph.vue @@ -1,5 +1,5 @@ diff --git a/apps/portal/src/components/public-form/IdentityMatchBanner.vue b/apps/app/src/components/shared/public-form/IdentityMatchBanner.vue similarity index 91% rename from apps/portal/src/components/public-form/IdentityMatchBanner.vue rename to apps/app/src/components/shared/public-form/IdentityMatchBanner.vue index 2b5fe491..be4b9564 100644 --- a/apps/portal/src/components/public-form/IdentityMatchBanner.vue +++ b/apps/app/src/components/shared/public-form/IdentityMatchBanner.vue @@ -14,11 +14,13 @@ const FALLBACK_TITLE: Record, string> = { matched: 'Gegevens gekoppeld', none: 'Aanmelding ontvangen', } + const FALLBACK_BODY: Record, 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, 'info' | 'success'> = { pending: 'info', matched: 'success', @@ -26,21 +28,25 @@ const TYPE: Record, 'info' | 'success'> = { } const body = computed(() => { - if (!props.status) return '' + if (!props.status) + return '' const backend = (props.message ?? '').trim() - if (backend) return backend + if (backend) + return backend return FALLBACK_BODY[props.status] }) const title = computed(() => { - if (!props.status) return '' + if (!props.status) + return '' return FALLBACK_TITLE[props.status] }) const alertType = computed(() => { - if (!props.status) return 'info' + if (!props.status) + return 'info' return TYPE[props.status] }) diff --git a/apps/portal/src/components/public-form/SubmitterDetails.vue b/apps/app/src/components/shared/public-form/SubmitterDetails.vue similarity index 99% rename from apps/portal/src/components/public-form/SubmitterDetails.vue rename to apps/app/src/components/shared/public-form/SubmitterDetails.vue index 85640ca0..c92b9395 100644 --- a/apps/portal/src/components/public-form/SubmitterDetails.vue +++ b/apps/app/src/components/shared/public-form/SubmitterDetails.vue @@ -17,6 +17,7 @@ const nameModel = computed({ get: () => props.name, set: v => emit('update:name', v), }) + const emailModel = computed({ get: () => props.email, set: v => emit('update:email', v), @@ -26,6 +27,7 @@ const nameRules = [ (v: unknown) => (requiredValidator(v) === true ? true : 'Vul je naam in.'), (v: unknown) => (v === null || v === undefined || String(v).length <= 150 ? true : 'Maximaal 150 tekens.'), ] + const emailRules = [ (v: unknown) => (requiredValidator(v) === true ? true : 'Vul je e-mailadres in.'), (v: unknown) => (emailValidator(v) === true ? true : 'Vul een geldig e-mailadres in.'), diff --git a/apps/portal/tests/unit/DuplicateSubmissionHint.spec.ts b/apps/app/src/components/shared/public-form/__tests__/DuplicateSubmissionHint.spec.ts similarity index 92% rename from apps/portal/tests/unit/DuplicateSubmissionHint.spec.ts rename to apps/app/src/components/shared/public-form/__tests__/DuplicateSubmissionHint.spec.ts index d4c07123..b40fbcda 100644 --- a/apps/portal/tests/unit/DuplicateSubmissionHint.spec.ts +++ b/apps/app/src/components/shared/public-form/__tests__/DuplicateSubmissionHint.spec.ts @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import DuplicateSubmissionHint from '@/components/public-form/DuplicateSubmissionHint.vue' -import type { PublicFormSubmissionDuplicate } from '@form-schema/types/formBuilder' +import DuplicateSubmissionHint from '@/components/shared/public-form/DuplicateSubmissionHint.vue' +import type { PublicFormSubmissionDuplicate } from '@/types/forms/formBuilder' function mountHint(data: PublicFormSubmissionDuplicate | null) { return mount(DuplicateSubmissionHint, { @@ -73,6 +73,7 @@ describe('DuplicateSubmissionHint', () => { }) const alert = w.find('.v-alert-stub') + expect(alert.exists()).toBe(true) expect(alert.attributes('data-type')).toBe('warning') expect(alert.attributes('data-variant')).toBe('tonal') diff --git a/apps/portal/tests/components/public-form/FieldAvailabilityPicker.spec.ts b/apps/app/src/components/shared/public-form/__tests__/FieldAvailabilityPicker.spec.ts similarity index 94% rename from apps/portal/tests/components/public-form/FieldAvailabilityPicker.spec.ts rename to apps/app/src/components/shared/public-form/__tests__/FieldAvailabilityPicker.spec.ts index 5e4a3111..cbf9e0f3 100644 --- a/apps/portal/tests/components/public-form/FieldAvailabilityPicker.spec.ts +++ b/apps/app/src/components/shared/public-form/__tests__/FieldAvailabilityPicker.spec.ts @@ -1,11 +1,15 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' +import type { PublicFormField, PublicFormTimeSlot, PublicFormTimeSlot as _PublicFormTimeSlot } from '@/types/forms/formBuilder' + +import FieldAvailabilityPicker from '@/components/shared/public-form/FieldAvailabilityPicker.vue' +import { FormFieldType } from '@/types/forms/formBuilder' // 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), + data: ref<_PublicFormTimeSlot[] | undefined>(undefined), isLoading: ref(false), isError: ref(false), refetch: vi.fn(), @@ -20,10 +24,6 @@ vi.mock('@/composables/publicFormInjection', () => ({ providePublicFormToken: () => {}, })) -import FieldAvailabilityPicker from '@/components/public-form/FieldAvailabilityPicker.vue' -import { FormFieldType } from '@form-schema/types/formBuilder' -import type { PublicFormField, PublicFormTimeSlot } from '@form-schema/types/formBuilder' - function field(partial: Partial = {}): PublicFormField { return { id: 'f_1', @@ -99,6 +99,7 @@ describe('FieldAvailabilityPicker', () => { 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) @@ -106,6 +107,7 @@ describe('FieldAvailabilityPicker', () => { 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') @@ -115,6 +117,7 @@ describe('FieldAvailabilityPicker', () => { 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') @@ -126,6 +129,7 @@ describe('FieldAvailabilityPicker', () => { 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. @@ -138,6 +142,7 @@ describe('FieldAvailabilityPicker', () => { 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') @@ -149,6 +154,7 @@ describe('FieldAvailabilityPicker', () => { 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 @@ -156,6 +162,7 @@ describe('FieldAvailabilityPicker', () => { // 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. @@ -164,16 +171,21 @@ describe('FieldAvailabilityPicker', () => { 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') diff --git a/apps/portal/tests/components/public-form/FieldOptionsLocale.spec.ts b/apps/app/src/components/shared/public-form/__tests__/FieldOptionsLocale.spec.ts similarity index 83% rename from apps/portal/tests/components/public-form/FieldOptionsLocale.spec.ts rename to apps/app/src/components/shared/public-form/__tests__/FieldOptionsLocale.spec.ts index 05575547..5a4a64b7 100644 --- a/apps/portal/tests/components/public-form/FieldOptionsLocale.spec.ts +++ b/apps/app/src/components/shared/public-form/__tests__/FieldOptionsLocale.spec.ts @@ -1,13 +1,14 @@ +/* eslint-disable vue/one-component-per-file -- TODO TECH-WS3-PORTAL-LINT-CLEANUP — locale-fallback test sweeps multiple Wrapper SFCs to inject distinct providePublicFormLocale calls per case */ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import { computed, defineComponent, h, ref } from 'vue' -import FieldCheckboxList from '@/components/public-form/FieldCheckboxList.vue' -import FieldMultiselect from '@/components/public-form/FieldMultiselect.vue' -import FieldRadio from '@/components/public-form/FieldRadio.vue' -import FieldSelect from '@/components/public-form/FieldSelect.vue' +import { defineComponent, h, ref } from 'vue' +import FieldCheckboxList from '@/components/shared/public-form/FieldCheckboxList.vue' +import FieldMultiselect from '@/components/shared/public-form/FieldMultiselect.vue' +import FieldRadio from '@/components/shared/public-form/FieldRadio.vue' +import FieldSelect from '@/components/shared/public-form/FieldSelect.vue' import { providePublicFormLocale } from '@/composables/publicFormInjection' -import { FormFieldType } from '@form-schema/types/formBuilder' -import type { OptionSpec, PublicFormField } from '@form-schema/types/formBuilder' +import { FormFieldType } from '@/types/forms/formBuilder' +import type { OptionSpec, PublicFormField } from '@/types/forms/formBuilder' const stubs = { VRadioGroup: { name: 'VRadioGroup', template: '
' }, @@ -36,11 +37,11 @@ const stubs = { }, } -function fieldOf(field_type: PublicFormField['field_type'], options: OptionSpec[]): PublicFormField { +function fieldOf(fieldType: PublicFormField['field_type'], options: OptionSpec[]): PublicFormField { return { id: 'f_1', slug: 'choice', - field_type, + field_type: fieldType, label: 'Choice', help_text: null, options, @@ -54,6 +55,7 @@ function fieldOf(field_type: PublicFormField['field_type'], options: OptionSpec[ } } +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- TODO TECH-WS3-PORTAL-LINT-CLEANUP — `h(component, ...)` accepts Component | string; tests pass SFCs whose typed shape varies per case function harness(component: any, field: PublicFormField, locale: string) { // Tiny wrapper that calls providePublicFormLocale before rendering the // target component, mimicking what [public_token].vue does at the page @@ -154,6 +156,7 @@ describe('Option-bearing field locale resolution', () => { }) }, }) + const wrapper = mount(Wrapper, { global: { stubs } }) const radios = wrapper.findAll('.v-radio-stub') diff --git a/apps/portal/tests/components/public-form/FieldRenderer.test.ts b/apps/app/src/components/shared/public-form/__tests__/FieldRenderer.test.ts similarity index 96% rename from apps/portal/tests/components/public-form/FieldRenderer.test.ts rename to apps/app/src/components/shared/public-form/__tests__/FieldRenderer.test.ts index ed2f26fa..4909294a 100644 --- a/apps/portal/tests/components/public-form/FieldRenderer.test.ts +++ b/apps/app/src/components/shared/public-form/__tests__/FieldRenderer.test.ts @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import FieldRenderer from '@/components/public-form/FieldRenderer.vue' -import { FormFieldType } from '@form-schema/types/formBuilder' -import type { PublicFormField } from '@form-schema/types/formBuilder' +import FieldRenderer from '@/components/shared/public-form/FieldRenderer.vue' +import { FormFieldType } from '@/types/forms/formBuilder' +import type { PublicFormField } from '@/types/forms/formBuilder' function makeField(partial: Partial): PublicFormField { return { @@ -72,6 +72,7 @@ describe('FieldRenderer', () => { [FormFieldType.URL, 'field-url-stub'], ])('dispatches to the right component for %s', (fieldType, className) => { const wrapper = mountRenderer(makeField({ field_type: fieldType as typeof FormFieldType[keyof typeof FormFieldType] })) + expect(wrapper.find(`.${className}`).exists()).toBe(true) }) @@ -83,6 +84,7 @@ describe('FieldRenderer', () => { FormFieldType.DATETIME, ])('renders placeholder alert for out-of-scope type %s', fieldType => { const wrapper = mountRenderer(makeField({ field_type: fieldType as typeof FormFieldType[keyof typeof FormFieldType] })) + expect(wrapper.find('.v-alert-stub').exists()).toBe(true) expect(wrapper.text()).toContain('binnenkort ondersteund') }) @@ -96,10 +98,12 @@ describe('FieldRenderer', () => { }) const hidden = mountRenderer(field, { gate: 'no' }) + expect(hidden.find('.field-text-stub').exists()).toBe(false) expect(hidden.find('.v-col-stub').exists()).toBe(false) const shown = mountRenderer(field, { gate: 'yes' }) + expect(shown.find('.field-text-stub').exists()).toBe(true) }) }) diff --git a/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts b/apps/app/src/components/shared/public-form/__tests__/FieldSectionPriority.spec.ts similarity index 96% rename from apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts rename to apps/app/src/components/shared/public-form/__tests__/FieldSectionPriority.spec.ts index 0a35c812..49c122eb 100644 --- a/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts +++ b/apps/app/src/components/shared/public-form/__tests__/FieldSectionPriority.spec.ts @@ -1,9 +1,13 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref } from 'vue' +import type { PublicFormField, PublicFormSectionOption, PublicFormSectionOption as _PublicFormSectionOption } from '@/types/forms/formBuilder' + +import FieldSectionPriority from '@/components/shared/public-form/FieldSectionPriority.vue' +import { FormFieldType } from '@/types/forms/formBuilder' const state = { - data: ref> | undefined>(undefined), + data: ref<_PublicFormSectionOption[] | undefined>(undefined), isLoading: ref(false), isError: ref(false), refetch: vi.fn(), @@ -35,10 +39,6 @@ vi.mock('vuedraggable', () => ({ }, })) -import FieldSectionPriority from '@/components/public-form/FieldSectionPriority.vue' -import { FormFieldType } from '@form-schema/types/formBuilder' -import type { PublicFormField, PublicFormSectionOption } from '@form-schema/types/formBuilder' - function field(partial: Partial = {}): PublicFormField { return { id: 'f_1', @@ -86,6 +86,7 @@ function mountPicker(props: { field: PublicFormField; modelValue: unknown; error }, 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. @@ -114,6 +115,7 @@ describe('FieldSectionPriority', () => { 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) @@ -121,6 +123,7 @@ describe('FieldSectionPriority', () => { 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') @@ -130,6 +133,7 @@ describe('FieldSectionPriority', () => { 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') @@ -138,6 +142,7 @@ describe('FieldSectionPriority', () => { 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') @@ -146,19 +151,23 @@ describe('FieldSectionPriority', () => { 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 }], @@ -166,10 +175,12 @@ describe('FieldSectionPriority', () => { // 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 }, @@ -178,6 +189,7 @@ describe('FieldSectionPriority', () => { 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_selected: 1 } }), modelValue: [{ section_id: 'a', priority: 1 }], @@ -190,6 +202,7 @@ describe('FieldSectionPriority', () => { 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[]) @@ -209,6 +222,7 @@ describe('FieldSectionPriority', () => { section({ id: 'd' }), section({ id: 'e' }), ] + const ranked = [ { section_id: 'a', priority: 1 }, { section_id: 'b', priority: 2 }, @@ -216,6 +230,7 @@ describe('FieldSectionPriority', () => { { section_id: 'd', priority: 4 }, { section_id: 'e', priority: 5 }, ] + const w = mountPicker({ field: field({ validation_rules: { max_selected: 99 } as Record }), modelValue: ranked, @@ -227,6 +242,7 @@ describe('FieldSectionPriority', () => { 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 }], @@ -237,9 +253,11 @@ describe('FieldSectionPriority', () => { it('wires ghost-class / drag-class / chosen-class through to ', () => { state.data.value = [section({ id: 'a' })] + const w = mountPicker({ field: field(), modelValue: [] }) const d = w.find('.draggable-stub') + expect(d.attributes('data-ghost-class')).toBe('section-priority-ghost') expect(d.attributes('data-drag-class')).toBe('section-priority-drag') expect(d.attributes('data-chosen-class')).toBe('section-priority-chosen') @@ -253,6 +271,7 @@ describe('FieldSectionPriority', () => { field: field({ validation_rules: { max_selected: 3 } }), modelValue: [{ section_id: 'a', priority: 1 }], }) + expect(notFull.html()).not.toContain('section-priority-unranked-disabled') // Hit the cap — remaining unranked cards switch to the disabled class. @@ -260,6 +279,7 @@ describe('FieldSectionPriority', () => { field: field({ validation_rules: { max_selected: 1 } }), modelValue: [{ section_id: 'a', priority: 1 }], }) + expect(full.html()).toContain('section-priority-unranked-disabled') }) }) diff --git a/apps/portal/tests/components/public-form/FieldTagPicker.spec.ts b/apps/app/src/components/shared/public-form/__tests__/FieldTagPicker.spec.ts similarity index 95% rename from apps/portal/tests/components/public-form/FieldTagPicker.spec.ts rename to apps/app/src/components/shared/public-form/__tests__/FieldTagPicker.spec.ts index ce42d540..e0aea991 100644 --- a/apps/portal/tests/components/public-form/FieldTagPicker.spec.ts +++ b/apps/app/src/components/shared/public-form/__tests__/FieldTagPicker.spec.ts @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import FieldTagPicker from '@/components/public-form/FieldTagPicker.vue' -import { FormFieldType } from '@form-schema/types/formBuilder' -import type { AvailableTag, PublicFormField } from '@form-schema/types/formBuilder' +import FieldTagPicker from '@/components/shared/public-form/FieldTagPicker.vue' +import { FormFieldType } from '@/types/forms/formBuilder' +import type { AvailableTag, PublicFormField } from '@/types/forms/formBuilder' function field(partial: Partial = {}): PublicFormField { return { @@ -78,6 +78,7 @@ describe('FieldTagPicker', () => { }) const items = w.findAll('.stub-item') + expect(items[0].attributes('data-category')).toBe('Overig') }) @@ -93,6 +94,7 @@ describe('FieldTagPicker', () => { }) const items = w.findAll('.stub-item') + expect(items.length).toBe(2) expect(items.map(i => i.attributes('data-value'))).toEqual(['a', 'b']) }) @@ -104,7 +106,9 @@ describe('FieldTagPicker', () => { }) await w.find('.stub-item').trigger('click') + const emits = w.emitted('update:modelValue') as unknown as string[][][] + expect(emits?.[0][0]).toEqual(['a']) }) @@ -115,7 +119,9 @@ describe('FieldTagPicker', () => { }) await w.find('.stub-unselect').trigger('click') + const emits = w.emitted('update:modelValue') as unknown as string[][][] + expect(emits?.[0][0]).toEqual(['a']) }) @@ -142,6 +148,7 @@ describe('FieldTagPicker', () => { 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/app/src/components/shared/public-form/__tests__/IdentityMatchBanner.spec.ts similarity index 95% rename from apps/portal/tests/components/public-form/IdentityMatchBanner.spec.ts rename to apps/app/src/components/shared/public-form/__tests__/IdentityMatchBanner.spec.ts index 87f0cb84..d4a84474 100644 --- a/apps/portal/tests/components/public-form/IdentityMatchBanner.spec.ts +++ b/apps/app/src/components/shared/public-form/__tests__/IdentityMatchBanner.spec.ts @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import IdentityMatchBanner from '@/components/public-form/IdentityMatchBanner.vue' +import IdentityMatchBanner from '@/components/shared/public-form/IdentityMatchBanner.vue' function mountBanner(props: { status: 'pending' | 'matched' | 'none' | null; message?: string | null }) { return mount(IdentityMatchBanner, { @@ -31,6 +31,7 @@ describe('IdentityMatchBanner', () => { }) 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') @@ -43,6 +44,7 @@ describe('IdentityMatchBanner', () => { }) const alert = w.find('.v-alert-stub') + expect(alert.exists()).toBe(true) expect(alert.attributes('data-type')).toBe('success') expect(w.text()).toContain('gekoppeld') @@ -52,6 +54,7 @@ describe('IdentityMatchBanner', () => { 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/useFormDraft.submitter.test.ts b/apps/app/src/composables/__tests__/useFormDraft.submitter.test.ts similarity index 99% rename from apps/portal/tests/composables/useFormDraft.submitter.test.ts rename to apps/app/src/composables/__tests__/useFormDraft.submitter.test.ts index 1a6d7bf2..e75c3256 100644 --- a/apps/portal/tests/composables/useFormDraft.submitter.test.ts +++ b/apps/app/src/composables/__tests__/useFormDraft.submitter.test.ts @@ -3,6 +3,9 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, ref } from 'vue' +import { apiClient } from '@/lib/axios' +import { draftSubmitterStorageKey, useFormDraft } from '@/composables/useFormDraft' + vi.mock('@/lib/axios', () => { const post = vi.fn() const put = vi.fn() @@ -11,9 +14,6 @@ vi.mock('@/lib/axios', () => { return { apiClient: { post, put, get } } }) -import { apiClient } from '@/lib/axios' -import { draftSubmitterStorageKey, useFormDraft } from '@/composables/useFormDraft' - interface MockedApi { post: ReturnType put: ReturnType @@ -34,6 +34,7 @@ function mountWithDraft(token: string, options?: Parameters }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const wrapper = mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] }, }) @@ -105,7 +106,9 @@ describe('useFormDraft - submitter details', () => { await draft.saveDraftNow() expect(mocked.put).toHaveBeenCalledTimes(1) + const [, body] = mocked.put.mock.calls[0] + expect(body.public_submitter_name).toBe('Test User') wrapper.unmount() @@ -125,7 +128,9 @@ describe('useFormDraft - submitter details', () => { await draft.saveDraftNow() expect(mocked.put).toHaveBeenCalledTimes(1) + const [, body] = mocked.put.mock.calls[0] + expect(body.public_submitter_email).toBe('user@example.nl') wrapper.unmount() @@ -146,11 +151,14 @@ describe('useFormDraft - submitter details', () => { draft.setSubmitterEmail('alice@example.nl') const result = await draft.submitForm() + await flush() expect(result).toBeTruthy() expect(mocked.post).toHaveBeenCalledTimes(2) + const [, submitBody] = mocked.post.mock.calls[1] + expect(submitBody.public_submitter_name).toBe('Alice') expect(submitBody.public_submitter_email).toBe('alice@example.nl') @@ -174,9 +182,12 @@ describe('useFormDraft - submitter details', () => { const result = await draft.submitForm() expect(result).toBeNull() + const err = draft.submitError.value as { code?: string } | null + expect(err).toBeTruthy() expect(err?.code).toBe('MISSING_SUBMITTER') + // Submit endpoint was NOT called — only the start POST is in the log. expect(mocked.post).toHaveBeenCalledTimes(1) @@ -193,11 +204,15 @@ describe('useFormDraft - submitter details', () => { await flush() draft.setSubmitterName('Bob') + // email intentionally left empty const result = await draft.submitForm() + expect(result).toBeNull() + const err = draft.submitError.value as { code?: string } | null + expect(err?.code).toBe('MISSING_SUBMITTER') expect(mocked.post).toHaveBeenCalledTimes(1) @@ -221,6 +236,7 @@ describe('useFormDraft - submitter details', () => { expect(draft.submitterEmail.value).toBe('resumed@example.nl') const [, body] = mocked.post.mock.calls[0] + expect(body.public_submitter_name).toBe('Resumed') expect(body.public_submitter_email).toBe('resumed@example.nl') @@ -235,8 +251,11 @@ describe('useFormDraft - submitter details', () => { draft.setSubmitterEmail('persist@example.nl') const raw = sessionStorage.getItem(draftSubmitterStorageKey(TOKEN)) + expect(raw).toBeTruthy() + const parsed = JSON.parse(raw as string) + expect(parsed).toEqual({ name: 'Persist Me', email: 'persist@example.nl' }) wrapper.unmount() diff --git a/apps/portal/tests/composables/useFormDraft.test.ts b/apps/app/src/composables/__tests__/useFormDraft.test.ts similarity index 99% rename from apps/portal/tests/composables/useFormDraft.test.ts rename to apps/app/src/composables/__tests__/useFormDraft.test.ts index b98f421d..0cf7c514 100644 --- a/apps/portal/tests/composables/useFormDraft.test.ts +++ b/apps/app/src/composables/__tests__/useFormDraft.test.ts @@ -3,6 +3,9 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, ref } from 'vue' +import { apiClient } from '@/lib/axios' +import { draftIdempotencyKey, useFormDraft } from '@/composables/useFormDraft' + vi.mock('@/lib/axios', () => { const post = vi.fn() const put = vi.fn() @@ -11,9 +14,6 @@ vi.mock('@/lib/axios', () => { return { apiClient: { post, put, get } } }) -import { apiClient } from '@/lib/axios' -import { draftIdempotencyKey, useFormDraft } from '@/composables/useFormDraft' - interface MockedApi { post: ReturnType put: ReturnType @@ -34,6 +34,7 @@ function mountWithDraft(token: string, options?: Parameters }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }) + const wrapper = mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] }, }) @@ -90,13 +91,16 @@ describe('useFormDraft', () => { const { draftRef, wrapper } = mountWithDraft(TOKEN) const draft = draftRef.value + expect(draft).toBeTruthy() await draft!.start() await flush() expect(mocked.post).toHaveBeenCalledTimes(1) + const [, body] = mocked.post.mock.calls[0] + expect(body.idempotency_key).toMatch(/^[a-f0-9]{8,30}$/i) expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBe(body.idempotency_key) @@ -107,17 +111,22 @@ describe('useFormDraft', () => { mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1'))) const first = mountWithDraft(TOKEN) + await first.draftRef.value!.start() await flush() + const firstKey = mocked.post.mock.calls[0][1].idempotency_key + first.wrapper.unmount() mocked.post.mockClear() mocked.post.mockResolvedValue(apiSuccess(submissionFixture('s1'))) const second = mountWithDraft(TOKEN) + await second.draftRef.value!.start() await flush() + const secondKey = mocked.post.mock.calls[0][1].idempotency_key expect(secondKey).toBe(firstKey) @@ -141,7 +150,9 @@ describe('useFormDraft', () => { await draft.saveDraftNow() expect(mocked.put).toHaveBeenCalledTimes(1) + const [, body] = mocked.put.mock.calls[0] + expect(body.values).toEqual({ name: 'Alice B' }) wrapper.unmount() @@ -165,6 +176,7 @@ describe('useFormDraft', () => { draft.setSubmitterEmail('test@example.nl') const submitted = await draft.submitForm() + await flush() expect(submitted?.status).toBe('submitted') expect(sessionStorage.getItem(draftIdempotencyKey(TOKEN))).toBeNull() diff --git a/apps/portal/tests/composables/api/usePublicFormSections.spec.ts b/apps/app/src/composables/api/__tests__/usePublicFormSections.spec.ts similarity index 93% rename from apps/portal/tests/composables/api/usePublicFormSections.spec.ts rename to apps/app/src/composables/api/__tests__/usePublicFormSections.spec.ts index cdeccad4..c015decc 100644 --- a/apps/portal/tests/composables/api/usePublicFormSections.spec.ts +++ b/apps/app/src/composables/api/__tests__/usePublicFormSections.spec.ts @@ -3,14 +3,14 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, ref } from 'vue' +import { apiClient } from '@/lib/axios' +import { usePublicFormSections } from '@/composables/api/usePublicFormSections' +import type { PublicFormSectionOption } from '@/types/forms/formBuilder' + vi.mock('@/lib/axios', () => ({ apiClient: { get: vi.fn() }, })) -import { apiClient } from '@/lib/axios' -import { usePublicFormSections } from '@/composables/api/usePublicFormSections' -import type { PublicFormSectionOption } from '@form-schema/types/formBuilder' - interface MockedApi { get: ReturnType } const mocked = apiClient as unknown as MockedApi @@ -26,6 +26,7 @@ function mountHook(tokenValue: string) { }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] }, }) @@ -48,14 +49,20 @@ function flush(): Promise { } describe('usePublicFormSections', () => { - beforeEach(() => { vi.clearAllMocks() }) - afterEach(() => { vi.clearAllMocks() }) + 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() @@ -65,6 +72,7 @@ describe('usePublicFormSections', () => { it('is disabled when the token ref is empty', async () => { const { result } = mountHook('') + await flush() expect(mocked.get).not.toHaveBeenCalled() @@ -75,6 +83,7 @@ describe('usePublicFormSections', () => { mocked.get.mockRejectedValueOnce(new Error('boom')) const { result } = mountHook('TKN99') + await flush() await flush() diff --git a/apps/portal/tests/composables/api/usePublicFormTimeSlots.spec.ts b/apps/app/src/composables/api/__tests__/usePublicFormTimeSlots.spec.ts similarity index 93% rename from apps/portal/tests/composables/api/usePublicFormTimeSlots.spec.ts rename to apps/app/src/composables/api/__tests__/usePublicFormTimeSlots.spec.ts index 48d5e9bc..df036fc1 100644 --- a/apps/portal/tests/composables/api/usePublicFormTimeSlots.spec.ts +++ b/apps/app/src/composables/api/__tests__/usePublicFormTimeSlots.spec.ts @@ -3,14 +3,14 @@ import { mount } from '@vue/test-utils' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, h, ref } from 'vue' +import { apiClient } from '@/lib/axios' +import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots' +import type { PublicFormTimeSlot } from '@/types/forms/formBuilder' + vi.mock('@/lib/axios', () => ({ apiClient: { get: vi.fn() }, })) -import { apiClient } from '@/lib/axios' -import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots' -import type { PublicFormTimeSlot } from '@form-schema/types/formBuilder' - interface MockedApi { get: ReturnType } const mocked = apiClient as unknown as MockedApi @@ -26,6 +26,7 @@ function mountHook(tokenValue: string) { }) const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const wrapper = mount(Host, { global: { plugins: [[VueQueryPlugin, { queryClient }]] }, }) @@ -51,14 +52,20 @@ function flush(): Promise { } describe('usePublicFormTimeSlots', () => { - beforeEach(() => { vi.clearAllMocks() }) - afterEach(() => { vi.clearAllMocks() }) + 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() @@ -68,6 +75,7 @@ describe('usePublicFormTimeSlots', () => { it('is disabled when the token ref is empty', async () => { const { result } = mountHook('') + await flush() expect(mocked.get).not.toHaveBeenCalled() @@ -78,6 +86,7 @@ describe('usePublicFormTimeSlots', () => { mocked.get.mockRejectedValueOnce(new Error('network')) const { result } = mountHook('TKN99') + await flush() await flush() diff --git a/apps/portal/src/composables/api/usePortalProfile.ts b/apps/app/src/composables/api/portal/usePortalProfile.ts similarity index 100% rename from apps/portal/src/composables/api/usePortalProfile.ts rename to apps/app/src/composables/api/portal/usePortalProfile.ts diff --git a/apps/portal/src/composables/api/usePortalShifts.ts b/apps/app/src/composables/api/portal/usePortalShifts.ts similarity index 97% rename from apps/portal/src/composables/api/usePortalShifts.ts rename to apps/app/src/composables/api/portal/usePortalShifts.ts index a76d071a..c67867fe 100644 --- a/apps/portal/src/composables/api/usePortalShifts.ts +++ b/apps/app/src/composables/api/portal/usePortalShifts.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' import type { AllMyShiftsEventGroup, AvailableShiftsDay, MyShiftsResponse } from '@/types/portal-shift' diff --git a/apps/portal/src/composables/api/useVolunteerRegistration.ts b/apps/app/src/composables/api/portal/useVolunteerRegistration.ts similarity index 94% rename from apps/portal/src/composables/api/useVolunteerRegistration.ts rename to apps/app/src/composables/api/portal/useVolunteerRegistration.ts index f73772fe..523ef093 100644 --- a/apps/portal/src/composables/api/useVolunteerRegistration.ts +++ b/apps/app/src/composables/api/portal/useVolunteerRegistration.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation } from '@tanstack/vue-query' +import { useMutation, useQuery } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' import type { EventRegistrationData, VolunteerRegistrationForm } from '@/types/registration' diff --git a/apps/portal/src/composables/api/usePublicForm.ts b/apps/app/src/composables/api/usePublicForm.ts similarity index 86% rename from apps/portal/src/composables/api/usePublicForm.ts rename to apps/app/src/composables/api/usePublicForm.ts index b3b19a09..4186c8bf 100644 --- a/apps/portal/src/composables/api/usePublicForm.ts +++ b/apps/app/src/composables/api/usePublicForm.ts @@ -9,7 +9,7 @@ import type { SaveDraftBody, StartDraftBody, SubmitBody, -} from '@form-schema/types/formBuilder' +} from '@/types/forms/formBuilder' interface ApiResponse { data: T @@ -31,7 +31,8 @@ export function useFetchPublicFormSchema(token: MaybeRefOrGetter toValue(token)], queryFn: async (): Promise => { const t = toValue(token) - if (!t) throw new Error('Missing public_token') + if (!t) + throw new Error('Missing public_token') const { data } = await apiClient.get>(`/public/forms/${t}`) return data.data @@ -39,7 +40,8 @@ export function useFetchPublicFormSchema(token: MaybeRefOrGetter !!toValue(token), retry: (failureCount, error) => { const status = (error as PublicFormAxiosError | undefined)?.response?.status - if (status && TERMINAL_STATUSES.has(status)) return false + if (status && TERMINAL_STATUSES.has(status)) + return false return failureCount < 1 }, @@ -51,7 +53,9 @@ export function useCreateFormDraft(token: MaybeRefOrGetter => { const t = toValue(token) - if (!t) throw new Error('Missing public_token') + if (!t) + throw new Error('Missing public_token') + const { data } = await apiClient.post>( `/public/forms/${t}/submissions`, body, @@ -66,7 +70,9 @@ export function useSaveFormDraft(token: MaybeRefOrGetter => { const t = toValue(token) - if (!t) throw new Error('Missing public_token') + if (!t) + throw new Error('Missing public_token') + const { data } = await apiClient.put>( `/public/forms/${t}/submissions/${submissionId}`, body, @@ -81,7 +87,9 @@ export function useSubmitForm(token: MaybeRefOrGetter return useMutation({ mutationFn: async ({ submissionId, body }: { submissionId: string; body: SubmitBody }): Promise => { const t = toValue(token) - if (!t) throw new Error('Missing public_token') + if (!t) + throw new Error('Missing public_token') + const { data } = await apiClient.post>( `/public/forms/${t}/submissions/${submissionId}/submit`, body, @@ -95,7 +103,8 @@ export function useSubmitForm(token: MaybeRefOrGetter export function extractErrorBody(err: unknown): PublicFormErrorBody | null { const axiosErr = err as PublicFormAxiosError | undefined const body = axiosErr?.response?.data - if (body && typeof body === 'object' && 'message' in body) return body as PublicFormErrorBody + if (body && typeof body === 'object' && 'message' in body) + return body as PublicFormErrorBody return null } @@ -103,7 +112,8 @@ export function extractErrorBody(err: unknown): PublicFormErrorBody | null { export function extractRetryAfterSeconds(err: unknown): number | null { const axiosErr = err as PublicFormAxiosError | undefined const raw = axiosErr?.response?.headers?.['retry-after'] - if (!raw) return null + if (!raw) + return null const n = Number(raw) return Number.isFinite(n) && n >= 0 ? n : null diff --git a/apps/portal/src/composables/api/usePublicFormSections.ts b/apps/app/src/composables/api/usePublicFormSections.ts similarity index 85% rename from apps/portal/src/composables/api/usePublicFormSections.ts rename to apps/app/src/composables/api/usePublicFormSections.ts index 9b337155..d92bc013 100644 --- a/apps/portal/src/composables/api/usePublicFormSections.ts +++ b/apps/app/src/composables/api/usePublicFormSections.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' -import type { PublicFormSectionOption } from '@form-schema/types/formBuilder' +import type { PublicFormSectionOption } from '@/types/forms/formBuilder' interface ApiResponse { data: T @@ -14,7 +14,9 @@ export function usePublicFormSections(token: Ref) { queryKey: ['public-form', token, 'sections'], queryFn: async (): Promise => { const t = token.value - if (!t) throw new Error('Missing public_token') + if (!t) + throw new Error('Missing public_token') + const { data } = await apiClient.get>( `/public/forms/${t}/sections`, ) diff --git a/apps/portal/src/composables/api/usePublicFormTimeSlots.ts b/apps/app/src/composables/api/usePublicFormTimeSlots.ts similarity index 86% rename from apps/portal/src/composables/api/usePublicFormTimeSlots.ts rename to apps/app/src/composables/api/usePublicFormTimeSlots.ts index 24a3cfbe..74c742dc 100644 --- a/apps/portal/src/composables/api/usePublicFormTimeSlots.ts +++ b/apps/app/src/composables/api/usePublicFormTimeSlots.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/vue-query' import type { Ref } from 'vue' import { apiClient } from '@/lib/axios' -import type { PublicFormTimeSlot } from '@form-schema/types/formBuilder' +import type { PublicFormTimeSlot } from '@/types/forms/formBuilder' interface ApiResponse { data: T @@ -15,7 +15,9 @@ export function usePublicFormTimeSlots(token: Ref) { queryKey: ['public-form', token, 'time-slots'], queryFn: async (): Promise => { const t = token.value - if (!t) throw new Error('Missing public_token') + if (!t) + throw new Error('Missing public_token') + const { data } = await apiClient.get>( `/public/forms/${t}/time-slots`, ) diff --git a/apps/portal/tests/unit/formatFieldValue.spec.ts b/apps/app/src/composables/forms/composables/__tests__/formatFieldValue.spec.ts similarity index 97% rename from apps/portal/tests/unit/formatFieldValue.spec.ts rename to apps/app/src/composables/forms/composables/__tests__/formatFieldValue.spec.ts index b13740df..8cb80133 100644 --- a/apps/portal/tests/unit/formatFieldValue.spec.ts +++ b/apps/app/src/composables/forms/composables/__tests__/formatFieldValue.spec.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' -import { formatFieldValue } from '@form-schema/composables/formatFieldValue' -import { FormFieldType } from '@form-schema/types/formBuilder' +import { formatFieldValue } from '@/composables/forms/composables/formatFieldValue' +import { FormFieldType } from '@/types/forms/formBuilder' import type { AvailableTag, PublicFormField, PublicFormSectionOption, PublicFormTimeSlot, -} from '@form-schema/types/formBuilder' +} from '@/types/forms/formBuilder' function field(partial: Partial = {}): PublicFormField { return { @@ -112,6 +112,7 @@ describe('formatFieldValue', () => { describe('SECTION_PRIORITY', () => { it('renders "N. Name, …" sorted by priority', () => { const f = field({ field_type: FormFieldType.SECTION_PRIORITY }) + const sections = [ section({ id: 's_1', name: 'Hoofdpodium Bar' }), section({ id: 's_2', name: 'Theatertent Bar' }), @@ -171,17 +172,20 @@ describe('formatFieldValue', () => { describe('scalars', () => { it('formats BOOLEAN true as "Ja", false as "Nee"', () => { const f = field({ field_type: FormFieldType.BOOLEAN }) + expect(formatFieldValue(f, true, undefined, undefined)).toBe('Ja') expect(formatFieldValue(f, false, undefined, undefined)).toBe('Nee') }) it('stringifies unknown-type array values via join', () => { const f = field({ field_type: FormFieldType.MULTISELECT }) + expect(formatFieldValue(f, ['A', 'B'], undefined, undefined)).toBe('A, B') }) it('stringifies scalars for text-like types', () => { const f = field({ field_type: FormFieldType.TEXT }) + expect(formatFieldValue(f, 'hello', undefined, undefined)).toBe('hello') }) }) diff --git a/apps/portal/tests/composables/useConditionalLogic.test.ts b/apps/app/src/composables/forms/composables/__tests__/useConditionalLogic.test.ts similarity index 95% rename from apps/portal/tests/composables/useConditionalLogic.test.ts rename to apps/app/src/composables/forms/composables/__tests__/useConditionalLogic.test.ts index 55f3484c..b4417d14 100644 --- a/apps/portal/tests/composables/useConditionalLogic.test.ts +++ b/apps/app/src/composables/forms/composables/__tests__/useConditionalLogic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { evaluateConditionalLogic } from '@form-schema/composables/useConditionalLogic' -import type { ConditionalLogic } from '@form-schema/types/formBuilder' +import { evaluateConditionalLogic } from '@/composables/forms/composables/useConditionalLogic' +import type { ConditionalLogic } from '@/types/forms/formBuilder' describe('evaluateConditionalLogic', () => { it('returns true when logic is null or undefined', () => { @@ -13,12 +13,14 @@ describe('evaluateConditionalLogic', () => { const logic: ConditionalLogic = { show_when: { all: [{ field_slug: 'foo', operator: 'equals', value: 'bar' }] }, } + expect(evaluateConditionalLogic(logic, { foo: 'bar' })).toBe(true) expect(evaluateConditionalLogic(logic, { foo: 'baz' })).toBe(false) const negative: ConditionalLogic = { show_when: { all: [{ field_slug: 'foo', operator: 'not_equals', value: 'bar' }] }, } + expect(evaluateConditionalLogic(negative, { foo: 'baz' })).toBe(true) expect(evaluateConditionalLogic(negative, { foo: 'bar' })).toBe(false) }) @@ -27,6 +29,7 @@ describe('evaluateConditionalLogic', () => { const logic: ConditionalLogic = { show_when: { all: [{ field_slug: 'tags', operator: 'in', value: ['a', 'b'] }] }, } + expect(evaluateConditionalLogic(logic, { tags: 'a' })).toBe(true) expect(evaluateConditionalLogic(logic, { tags: 'c' })).toBe(false) expect(evaluateConditionalLogic(logic, { tags: ['c', 'a'] })).toBe(true) @@ -34,12 +37,14 @@ describe('evaluateConditionalLogic', () => { const negative: ConditionalLogic = { show_when: { all: [{ field_slug: 'tags', operator: 'not_in', value: ['a'] }] }, } + expect(evaluateConditionalLogic(negative, { tags: 'a' })).toBe(false) expect(evaluateConditionalLogic(negative, { tags: ['b', 'c'] })).toBe(true) }) it('handles empty / not_empty across string, array, null', () => { const empty: ConditionalLogic = { show_when: { all: [{ field_slug: 'x', operator: 'empty' }] } } + expect(evaluateConditionalLogic(empty, {})).toBe(true) expect(evaluateConditionalLogic(empty, { x: null })).toBe(true) expect(evaluateConditionalLogic(empty, { x: '' })).toBe(true) @@ -47,6 +52,7 @@ describe('evaluateConditionalLogic', () => { expect(evaluateConditionalLogic(empty, { x: 'hi' })).toBe(false) const notEmpty: ConditionalLogic = { show_when: { all: [{ field_slug: 'x', operator: 'not_empty' }] } } + expect(evaluateConditionalLogic(notEmpty, { x: 'hi' })).toBe(true) expect(evaluateConditionalLogic(notEmpty, {})).toBe(false) }) @@ -65,6 +71,7 @@ describe('evaluateConditionalLogic', () => { ], }, } + expect(evaluateConditionalLogic(logic, { role: 'admin', region: 'BE' })).toBe(true) expect(evaluateConditionalLogic(logic, { role: 'admin', region: 'DE' })).toBe(false) expect(evaluateConditionalLogic(logic, { role: 'guest', region: 'NL' })).toBe(false) @@ -74,12 +81,14 @@ describe('evaluateConditionalLogic', () => { const eq: ConditionalLogic = { show_when: { all: [{ field_slug: 'missing', operator: 'equals', value: 'x' }] }, } + expect(() => evaluateConditionalLogic(eq, {})).not.toThrow() expect(evaluateConditionalLogic(eq, {})).toBe(false) const emptyCheck: ConditionalLogic = { show_when: { all: [{ field_slug: 'missing', operator: 'empty' }] }, } + expect(evaluateConditionalLogic(emptyCheck, {})).toBe(true) }) @@ -87,6 +96,7 @@ describe('evaluateConditionalLogic', () => { const gt: ConditionalLogic = { show_when: { all: [{ field_slug: 'age', operator: 'greater_than', value: 18 }] }, } + expect(evaluateConditionalLogic(gt, { age: 20 })).toBe(true) expect(evaluateConditionalLogic(gt, { age: 17 })).toBe(false) expect(evaluateConditionalLogic(gt, { age: 'not-a-number' })).toBe(false) diff --git a/packages/form-schema/src/composables/formatFieldValue.ts b/apps/app/src/composables/forms/composables/formatFieldValue.ts similarity index 80% rename from packages/form-schema/src/composables/formatFieldValue.ts rename to apps/app/src/composables/forms/composables/formatFieldValue.ts index 5d38b5a4..fb8adccd 100644 --- a/packages/form-schema/src/composables/formatFieldValue.ts +++ b/apps/app/src/composables/forms/composables/formatFieldValue.ts @@ -1,10 +1,10 @@ -import { FormFieldType } from '../types/formBuilder' +import { FormFieldType } from '@/types/forms/formBuilder' import type { PublicFormField, PublicFormSectionOption, PublicFormTimeSlot, SectionPriorityValue, -} from '../types/formBuilder' +} from '@/types/forms/formBuilder' const EMPTY = '—' const LOADING = 'Laden…' @@ -28,7 +28,8 @@ export function formatFieldValue( timeSlots: readonly PublicFormTimeSlot[] | undefined, sections: readonly PublicFormSectionOption[] | undefined, ): string { - if (isEmptyValue(value)) return EMPTY + if (isEmptyValue(value)) + return EMPTY switch (field.field_type) { case FormFieldType.TAG_PICKER: @@ -45,14 +46,15 @@ export function formatFieldValue( } function isEmptyValue(value: unknown): boolean { - if (value === null || value === undefined || value === '') return true - if (Array.isArray(value) && value.length === 0) return true - - return false + return value === null + || value === undefined + || value === '' + || (Array.isArray(value) && value.length === 0) } function formatTagPicker(field: PublicFormField, value: unknown): string { - if (!Array.isArray(value)) return EMPTY + if (!Array.isArray(value)) + return EMPTY const byId = new Map() for (const tag of field.available_tags ?? []) byId.set(tag.id, tag.name) @@ -68,8 +70,10 @@ function formatAvailabilityPicker( value: unknown, timeSlots: readonly PublicFormTimeSlot[] | undefined, ): string { - if (!Array.isArray(value)) return EMPTY - if (timeSlots === undefined) return LOADING + if (!Array.isArray(value)) + return EMPTY + if (timeSlots === undefined) + return LOADING const byId = new Map() for (const slot of timeSlots) byId.set(slot.id, slot) @@ -78,7 +82,8 @@ function formatAvailabilityPicker( .map(v => (typeof v === 'string' ? v : String(v))) .map(id => { const slot = byId.get(id) - if (!slot) return UNKNOWN_TIME_SLOT + if (!slot) + return UNKNOWN_TIME_SLOT return `${slot.name} (${stripSeconds(slot.start_time)}–${stripSeconds(slot.end_time)})` }) @@ -92,19 +97,23 @@ function formatSectionPriority( ): string { // Defensive shape-guard: if the value isn't {section_id, priority}[], // fall back to EMPTY rather than leaking `[object Object]`. - if (!Array.isArray(value)) return EMPTY + if (!Array.isArray(value)) + return EMPTY const entries: SectionPriorityValue[] = [] for (const entry of value) { - if (!entry || typeof entry !== 'object') return EMPTY - const obj = entry as Record - if (typeof obj.section_id !== 'string' || typeof obj.priority !== 'number') { + if (!entry || typeof entry !== 'object') return EMPTY - } + const obj = entry as Record + if (typeof obj.section_id !== 'string' || typeof obj.priority !== 'number') + return EMPTY + entries.push({ section_id: obj.section_id, priority: obj.priority }) } - if (entries.length === 0) return EMPTY - if (sections === undefined) return LOADING + if (entries.length === 0) + return EMPTY + if (sections === undefined) + return LOADING const byId = new Map() for (const section of sections) byId.set(section.id, section) @@ -114,18 +123,18 @@ function formatSectionPriority( const sorted = [...entries].sort((a, b) => a.priority - b.priority) return sorted - .map(({ section_id, priority }) => { - const name = byId.get(section_id)?.name ?? UNKNOWN_SECTION + .map(entry => { + const sectionId = entry.section_id + const name = byId.get(sectionId)?.name ?? UNKNOWN_SECTION - return `${priority}. ${name}` + return `${entry.priority}. ${name}` }) .join(', ') } function formatScalarOrList(value: unknown): string { - if (Array.isArray(value)) { + if (Array.isArray(value)) return value.length > 0 ? value.map(v => String(v)).join(', ') : EMPTY - } return String(value) } diff --git a/packages/form-schema/src/composables/useConditionalLogic.ts b/apps/app/src/composables/forms/composables/useConditionalLogic.ts similarity index 63% rename from packages/form-schema/src/composables/useConditionalLogic.ts rename to apps/app/src/composables/forms/composables/useConditionalLogic.ts index 74878765..3563ccf6 100644 --- a/packages/form-schema/src/composables/useConditionalLogic.ts +++ b/apps/app/src/composables/forms/composables/useConditionalLogic.ts @@ -4,18 +4,22 @@ import type { ConditionalOperator, ConditionalRule, FormValues, -} from '../types/formBuilder' +} from '@/types/forms/formBuilder' function isEmptyValue(v: unknown): boolean { - if (v === null || v === undefined) return true - if (typeof v === 'string') return v.length === 0 - if (Array.isArray(v)) return v.length === 0 + if (v === null || v === undefined) + return true + if (typeof v === 'string') + return v.length === 0 + if (Array.isArray(v)) + return v.length === 0 return false } function toComparable(v: unknown): string | number | boolean { - if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') + return v return String(v ?? '') } @@ -31,54 +35,70 @@ function evaluateRule(rule: ConditionalRule, values: FormValues): boolean { case 'not_empty': return !isEmptyValue(actual) case 'equals': - if (isEmptyValue(actual) && !isEmptyValue(expected)) return false + if (isEmptyValue(actual) && !isEmptyValue(expected)) + return false return toComparable(actual) === toComparable(expected) case 'not_equals': - if (isEmptyValue(actual) && !isEmptyValue(expected)) return true + if (isEmptyValue(actual) && !isEmptyValue(expected)) + return true return toComparable(actual) !== toComparable(expected) case 'contains': { - if (isEmptyValue(actual)) return false - if (Array.isArray(actual)) return actual.map(toComparable).includes(toComparable(expected)) + if (isEmptyValue(actual)) + return false + if (Array.isArray(actual)) + return actual.map(toComparable).includes(toComparable(expected)) return String(actual).includes(String(expected ?? '')) } case 'not_contains': { - if (isEmptyValue(actual)) return true - if (Array.isArray(actual)) return !actual.map(toComparable).includes(toComparable(expected)) + if (isEmptyValue(actual)) + return true + if (Array.isArray(actual)) + return !actual.map(toComparable).includes(toComparable(expected)) return !String(actual).includes(String(expected ?? '')) } case 'in': { - if (isEmptyValue(actual)) return false - if (!Array.isArray(expected)) return false + if (isEmptyValue(actual)) + return false + if (!Array.isArray(expected)) + return false const exp = expected.map(toComparable) - if (Array.isArray(actual)) return actual.some(a => exp.includes(toComparable(a))) + if (Array.isArray(actual)) + return actual.some(a => exp.includes(toComparable(a))) return exp.includes(toComparable(actual)) } case 'not_in': { - if (isEmptyValue(actual)) return true - if (!Array.isArray(expected)) return true + if (isEmptyValue(actual)) + return true + if (!Array.isArray(expected)) + return true const exp = expected.map(toComparable) - if (Array.isArray(actual)) return !actual.some(a => exp.includes(toComparable(a))) + if (Array.isArray(actual)) + return !actual.some(a => exp.includes(toComparable(a))) return !exp.includes(toComparable(actual)) } case 'greater_than': { - if (isEmptyValue(actual)) return false + if (isEmptyValue(actual)) + return false const a = Number(actual) const e = Number(expected) - if (Number.isNaN(a) || Number.isNaN(e)) return false + if (Number.isNaN(a) || Number.isNaN(e)) + return false return a > e } case 'less_than': { - if (isEmptyValue(actual)) return false + if (isEmptyValue(actual)) + return false const a = Number(actual) const e = Number(expected) - if (Number.isNaN(a) || Number.isNaN(e)) return false + if (Number.isNaN(a) || Number.isNaN(e)) + return false return a < e } @@ -95,7 +115,8 @@ function evaluateGroup(group: ConditionalGroup, values: FormValues): boolean { if (Array.isArray(group.all) && group.all.length > 0) { for (const node of group.all) { const ok = isGroup(node) ? evaluateGroup(node, values) : evaluateRule(node, values) - if (!ok) return false + if (!ok) + return false } return true @@ -103,7 +124,8 @@ function evaluateGroup(group: ConditionalGroup, values: FormValues): boolean { if (Array.isArray(group.any) && group.any.length > 0) { for (const node of group.any) { const ok = isGroup(node) ? evaluateGroup(node, values) : evaluateRule(node, values) - if (ok) return true + if (ok) + return true } return false @@ -121,7 +143,8 @@ export function evaluateConditionalLogic( logic: ConditionalLogic | null | undefined, values: FormValues, ): boolean { - if (!logic || !logic.show_when) return true + if (!logic || !logic.show_when) + return true return evaluateGroup(logic.show_when, values) } diff --git a/packages/form-schema/src/composables/useFormSteps.ts b/apps/app/src/composables/forms/composables/useFormSteps.ts similarity index 86% rename from packages/form-schema/src/composables/useFormSteps.ts rename to apps/app/src/composables/forms/composables/useFormSteps.ts index eb2bfce8..442082ed 100644 --- a/packages/form-schema/src/composables/useFormSteps.ts +++ b/apps/app/src/composables/forms/composables/useFormSteps.ts @@ -1,9 +1,9 @@ import { computed } from 'vue' import type { ComputedRef, Ref } from 'vue' import { evaluateConditionalLogic } from './useConditionalLogic' -import { FormFieldType } from '../types/formBuilder' -import type { FormValues, PublicFormField, PublicFormSchema } from '../types/formBuilder' -import { isFieldValueEmpty } from '../utils/formValidation' +import { FormFieldType } from '@/types/forms/formBuilder' +import type { FormValues, PublicFormField, PublicFormSchema } from '@/types/forms/formBuilder' +import { isFieldValueEmpty } from '@/utils/forms/formValidation' export type StepKind = 'submitter' | 'section' | 'heading_group' | 'flat' | 'review' @@ -16,7 +16,8 @@ export interface FormStep { } function partitionByHeading(fields: PublicFormField[]): FormStep[] { - if (fields.length === 0) return [] + if (fields.length === 0) + return [] const out: FormStep[] = [] let current: FormStep | null = null @@ -53,6 +54,7 @@ function partitionByHeading(fields: PublicFormField[]): FormStep[] { export function useFormSteps(schema: Ref): ComputedRef { return computed(() => { const s = schema.value + const submitterStep: FormStep = { key: 'submitter', kind: 'submitter', @@ -60,6 +62,7 @@ export function useFormSteps(schema: Ref): subtitle: 'Zo kunnen we contact met je opnemen', fields: [], } + const reviewStep: FormStep = { key: 'review', kind: 'review', @@ -68,7 +71,8 @@ export function useFormSteps(schema: Ref): fields: [], } - if (!s) return [submitterStep, reviewStep] + if (!s) + return [submitterStep, reviewStep] const sorted = [...s.fields].sort((a, b) => a.sort_order - b.sort_order) @@ -78,6 +82,7 @@ export function useFormSteps(schema: Ref): const sectionsSorted = [...s.sections].sort((a, b) => a.sort_order - b.sort_order) for (const section of sectionsSorted) { const fields = sorted.filter(f => f.form_schema_section_id === section.id) + steps.push({ key: `section-${section.id}`, kind: 'section', @@ -124,14 +129,20 @@ export function isStepValid( values: FormValues, submitterValid: boolean, ): boolean { - if (step.kind === 'submitter') return submitterValid - if (step.kind === 'review') return true + if (step.kind === 'submitter') + return submitterValid + if (step.kind === 'review') + return true for (const field of step.fields) { - if (field.field_type === FormFieldType.HEADING || field.field_type === FormFieldType.PARAGRAPH) continue - if (!field.is_required) continue - if (!evaluateConditionalLogic(field.conditional_logic, values)) continue - if (isFieldValueEmpty(values[field.slug])) return false + if (field.field_type === FormFieldType.HEADING || field.field_type === FormFieldType.PARAGRAPH) + continue + if (!field.is_required) + continue + if (!evaluateConditionalLogic(field.conditional_logic, values)) + continue + if (isFieldValueEmpty(values[field.slug])) + return false } return true diff --git a/apps/portal/src/composables/publicFormInjection.ts b/apps/app/src/composables/publicFormInjection.ts similarity index 98% rename from apps/portal/src/composables/publicFormInjection.ts rename to apps/app/src/composables/publicFormInjection.ts index 09222408..8709589c 100644 --- a/apps/portal/src/composables/publicFormInjection.ts +++ b/apps/app/src/composables/publicFormInjection.ts @@ -13,9 +13,8 @@ export function providePublicFormToken(token: Ref): void { export function usePublicFormToken(): Ref { const token = inject(PUBLIC_FORM_TOKEN_KEY) - if (!token) { + if (!token) throw new Error('usePublicFormToken: no token provided. Did you forget providePublicFormToken in the page?') - } return token } diff --git a/apps/portal/src/composables/useFormDraft.ts b/apps/app/src/composables/useFormDraft.ts similarity index 89% rename from apps/portal/src/composables/useFormDraft.ts rename to apps/app/src/composables/useFormDraft.ts index 37f1902a..57778c23 100644 --- a/apps/portal/src/composables/useFormDraft.ts +++ b/apps/app/src/composables/useFormDraft.ts @@ -7,7 +7,7 @@ import { useSaveFormDraft, useSubmitForm, } from '@/composables/api/usePublicForm' -import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@form-schema/types/formBuilder' +import type { FormValues, PublicFormSubmission, SaveDraftBody } from '@/types/forms/formBuilder' /** sessionStorage key for reusing an idempotency key across reloads. */ export function draftIdempotencyKey(token: string): string { @@ -28,17 +28,21 @@ function generateIdempotencyKey(): string { } if (c?.getRandomValues) { const buf = new Uint8Array(12) + c.getRandomValues(buf) return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('') } + // Last-resort fallback — still within 6..30. return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`.slice(0, 30) } interface UseFormDraftOptions { + /** Preferred locale string for `submitted_in_locale` (e.g. `"nl"`). */ locale?: string + /** Debounce for auto-save after a field blur, in ms. */ debounceMs?: number } @@ -93,7 +97,8 @@ export function useFormDraft( function readStoredKey(): string | null { const t = token.value - if (!t) return null + if (!t) + return null try { return sessionStorage.getItem(draftIdempotencyKey(t)) } @@ -104,7 +109,8 @@ export function useFormDraft( function writeStoredKey(value: string): void { const t = token.value - if (!t) return + if (!t) + return try { sessionStorage.setItem(draftIdempotencyKey(t), value) } @@ -115,7 +121,8 @@ export function useFormDraft( function persistSubmitter(): void { const t = token.value - if (!t) return + if (!t) + return try { sessionStorage.setItem( draftSubmitterStorageKey(t), @@ -129,13 +136,17 @@ export function useFormDraft( function restoreSubmitter(): void { const t = token.value - if (!t) return + if (!t) + return try { const raw = sessionStorage.getItem(draftSubmitterStorageKey(t)) - if (!raw) return + if (!raw) + return const parsed = JSON.parse(raw) as { name?: unknown; email?: unknown } - if (typeof parsed.name === 'string') submitterName.value = parsed.name - if (typeof parsed.email === 'string') submitterEmail.value = parsed.email + if (typeof parsed.name === 'string') + submitterName.value = parsed.name + if (typeof parsed.email === 'string') + submitterEmail.value = parsed.email } catch { // malformed — ignore @@ -144,7 +155,8 @@ export function useFormDraft( function clearSession(): void { const t = token.value - if (!t) return + if (!t) + return try { sessionStorage.removeItem(draftIdempotencyKey(t)) sessionStorage.removeItem(draftSubmitterStorageKey(t)) @@ -155,8 +167,10 @@ export function useFormDraft( } async function start(): Promise { - if (!token.value) return null - if (submission.value) return submission.value + if (!token.value) + return null + if (submission.value) + return submission.value isStarting.value = true try { // Hydrate submitter state from a prior session *before* posting the @@ -169,6 +183,7 @@ export function useFormDraft( key = generateIdempotencyKey() writeStoredKey(key) } + const created = await startDraft({ idempotency_key: key, opened_at: new Date().toISOString(), @@ -176,12 +191,13 @@ export function useFormDraft( public_submitter_name: submitterName.value || undefined, public_submitter_email: submitterEmail.value || undefined, }) + submission.value = created const hydrated: FormValues = {} - for (const [slug, entry] of Object.entries(created.values ?? {})) { + for (const [slug, entry] of Object.entries(created.values ?? {})) hydrated[slug] = entry?.value - } + values.value = { ...hydrated, ...values.value } return created @@ -209,16 +225,20 @@ export function useFormDraft( } function markInteracted(): void { - if (!firstInteractedAt.value) firstInteractedAt.value = new Date().toISOString() + if (!firstInteractedAt.value) + firstInteractedAt.value = new Date().toISOString() } async function flushDirty(): Promise { - if (!submission.value) return + if (!submission.value) + return const keys = Object.keys(dirty.value) const hadSubmitterDirty = submitterDirty.value - if (keys.length === 0 && !hadSubmitterDirty) return + if (keys.length === 0 && !hadSubmitterDirty) + return const snapshot = { ...dirty.value } + dirty.value = {} submitterDirty.value = false isSaving.value = true @@ -229,19 +249,23 @@ export function useFormDraft( public_submitter_name: submitterName.value || undefined, public_submitter_email: submitterEmail.value || undefined, } - if (keys.length > 0) body.values = snapshot + + if (keys.length > 0) + body.values = snapshot const updated = await saveDraft({ submissionId: submission.value.id, body, }) + submission.value = updated lastSavedAt.value = new Date() } catch (err) { // Restore the dirty set so the next save retries these values. dirty.value = { ...snapshot, ...dirty.value } - if (hadSubmitterDirty) submitterDirty.value = true + if (hadSubmitterDirty) + submitterDirty.value = true saveError.value = err } finally { @@ -256,7 +280,8 @@ export function useFormDraft( async function saveDraftNow(): Promise { markInteracted() - if (!submission.value) await start() + if (!submission.value) + await start() await flushDirty() } @@ -265,7 +290,8 @@ export function useFormDraft( watchDebounced( () => Object.keys(dirty.value).length + (submitterDirty.value ? 1 : 0), count => { - if (count > 0 && submission.value) void flushDirty() + if (count > 0 && submission.value) + void flushDirty() }, { debounce: debounceMs }, ) @@ -291,6 +317,7 @@ export function useFormDraft( const email = submitterEmail.value.trim() if (!name || !email) { const err = new Error('Submitter name and email are required.') as SubmitterClientError + err.code = 'MISSING_SUBMITTER' submitError.value = err @@ -299,8 +326,10 @@ export function useFormDraft( if (!submission.value) { await start() - if (!submission.value) return null + if (!submission.value) + return null } + // Flush any pending auto-save so the submit merges with server state. await flushDirty() @@ -314,6 +343,7 @@ export function useFormDraft( public_submitter_email: submitterEmail.value, }, }) + submission.value = submitted clearSession() @@ -321,8 +351,10 @@ export function useFormDraft( } catch (err) { submitError.value = err + const body = extractErrorBody(err) - if (body?.code === 'SUBMISSION_ALREADY_SUBMITTED') clearSession() + if (body?.code === 'SUBMISSION_ALREADY_SUBMITTED') + clearSession() return null } diff --git a/apps/app/src/layouts/PortalLayout.vue b/apps/app/src/layouts/PortalLayout.vue index dac0597e..43ba3c4d 100644 --- a/apps/app/src/layouts/PortalLayout.vue +++ b/apps/app/src/layouts/PortalLayout.vue @@ -1,32 +1,232 @@ diff --git a/apps/app/src/layouts/__tests__/PortalLayout.spec.ts b/apps/app/src/layouts/__tests__/PortalLayout.spec.ts index b818a535..481049f0 100644 --- a/apps/app/src/layouts/__tests__/PortalLayout.spec.ts +++ b/apps/app/src/layouts/__tests__/PortalLayout.spec.ts @@ -1,38 +1,86 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' -import PortalLayout from '../PortalLayout.vue' +import { createPinia, setActivePinia } from 'pinia' + +// Mock auto-imported composables / external pinia stores so the layout's +// diff --git a/apps/portal/src/pages/shifts/index.vue b/apps/app/src/pages/portal/shifts/index.vue similarity index 96% rename from apps/portal/src/pages/shifts/index.vue rename to apps/app/src/pages/portal/shifts/index.vue index 02bedc8a..9f544b26 100644 --- a/apps/portal/src/pages/shifts/index.vue +++ b/apps/app/src/pages/portal/shifts/index.vue @@ -1,17 +1,18 @@