feat(router): wire portal/register pages, portal-context guard carve-out, lint cleanup
Routing wiring (Phase D of WS-3 PR-B1):
- apps/app/src/plugins/1.router/guards.ts: add a single early-return
carve-out before the org-selection redirect — `if (to.meta.context
=== 'portal') return`. Per ARCH-CONSOLIDATION-2026-04 §4.3,
meta.context is the canonical contract; PR-B2 evolves the guards
from this key to full context-aware logic (post-login landing,
context-switcher, role checks).
- apps/app/env.d.ts: extend RouteMeta with the new layout names
('OrganizerLayout' | 'PortalLayout' | 'PublicLayout'), context,
requiresAuth, requiresToken, navMode, navTitle.
- apps/app/typed-router.d.ts: regenerated by unplugin-vue-router to
pick up portal/* and register/* route names.
- Page meta finalisation: portal pages have layout: 'PortalLayout',
context: 'portal', preserving original requiresAuth + nav fields;
register pages have layout: 'PublicLayout' + public: true (the
apps/app guard convention for public routes, since meta.public is
what the existing guard recognises).
Form-types restructure (boundaries cleanup):
- apps/app/src/composables/forms/types/formBuilder.ts → src/types/forms/
- apps/app/src/composables/forms/utils/{formValidation,validators}.ts
→ src/utils/forms/
- All `@/composables/forms/{types,utils}/*` imports rewritten across
pages, components, composables, tests.
- This avoids a `types → composables` boundaries violation at
src/types/formSchema.ts which re-exports primitives from the
inlined form-schema. types/formSchema.ts now imports from
@/types/forms/formBuilder which is in the same boundaries zone.
Lint cleanup for moved portal sources (apps/portal had no
.eslintrc.cjs; the migrated code now has to pass apps/app's stricter
config):
- axios.isAxiosError → named import { isAxiosError }
(ClaimenTab, RoosterTab, profiel.vue)
- void schemaQuery.refetch() → schemaQuery.refetch()
(register/[public_token].vue)
- if-then-else collapsed to single boolean return (formatFieldValue)
- :delay-on-touch-only="true" → delay-on-touch-only shorthand
(FieldSectionPriority)
- ml-2 class → ms-2 (FieldAvailabilityPicker)
- multi-statement-per-line splits in profiel.vue + spec files
- unused emailConfigured ref removed (profiel.vue)
- one-component-per-file disabled with TODO TECH-WS3-PORTAL-LINT-CLEANUP
ref (FieldOptionsLocale.spec.ts — multi-Wrapper test pattern)
- restored `import Draggable from 'vuedraggable'` after lint:fix
removed it (template-only usage; the import IS needed)
- camelcase param renamed in FieldOptionsLocale harness factory
- typecheck nudge: spec state.data typed via PublicFormSectionOption[] /
PublicFormTimeSlot[] aliases instead of Record<string, unknown>
- PortalLayout.vue: explicit `import { useRoute, useRouter }` so the
vitest mock can intercept (the trimmed AutoImport set doesn't pull
vue-router's auto-imports)
Vitest: 23 / 162 passing. Lint: 0 errors / 0 new warnings (only the
pre-existing boundaries v5→v6 deprecation warnings remain). Typecheck:
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)}`
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,7 +6,8 @@ const router = useRouter()
|
||||
|
||||
const userInitials = computed(() => {
|
||||
const user = authStore.user
|
||||
if (!user) return '?'
|
||||
if (!user)
|
||||
return '?'
|
||||
const first = user.first_name?.charAt(0) ?? ''
|
||||
const last = user.last_name?.charAt(0) ?? ''
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useAvailableShifts, useClaimShift } from '@/composables/api/usePortalShifts'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useAvailableShifts, useClaimShift } from '@/composables/api/portal/usePortalShifts'
|
||||
import type { AvailableShift } from '@/types/portal-shift'
|
||||
import axios from 'axios'
|
||||
import type { ApiErrorResponse } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -36,19 +36,21 @@ function openClaimDialog(shift: AvailableShift, dayLabel: string, startTime: str
|
||||
}
|
||||
|
||||
async function confirmClaim() {
|
||||
if (!selectedShift.value) return
|
||||
if (!selectedShift.value)
|
||||
return
|
||||
|
||||
claimError.value = null
|
||||
|
||||
try {
|
||||
const result = await claimMutation.mutateAsync(selectedShift.value.id)
|
||||
|
||||
showConfirmDialog.value = false
|
||||
snackbarMessage.value = result.message
|
||||
snackbarColor.value = 'success'
|
||||
snackbar.value = true
|
||||
}
|
||||
catch (err: unknown) {
|
||||
claimError.value = axios.isAxiosError<ApiErrorResponse>(err)
|
||||
claimError.value = isAxiosError<ApiErrorResponse>(err)
|
||||
? err.response?.data?.message ?? 'Er is een fout opgetreden.'
|
||||
: 'Er is een fout opgetreden.'
|
||||
}
|
||||
@@ -62,7 +64,8 @@ function toggleDescription(shiftId: string) {
|
||||
}
|
||||
|
||||
function availabilityColor(slotsAvailable: number): string {
|
||||
if (slotsAvailable >= 3) return 'success'
|
||||
if (slotsAvailable >= 3)
|
||||
return 'success'
|
||||
|
||||
return 'warning'
|
||||
}
|
||||
@@ -110,7 +113,7 @@ function availabilityColor(slotsAvailable: number): string {
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="refetch()"
|
||||
@click="refetch"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
@@ -235,7 +238,7 @@ function availabilityColor(slotsAvailable: number): string {
|
||||
v-if="!expandedDescriptions.has(shift.id)"
|
||||
class="text-body-2 text-medium-emphasis mb-0"
|
||||
>
|
||||
{{ shift.description.length > 80 ? shift.description.slice(0, 80) + '...' : shift.description }}
|
||||
{{ shift.description.length > 80 ? `${shift.description.slice(0, 80)}...` : shift.description }}
|
||||
<a
|
||||
v-if="shift.description.length > 80"
|
||||
href="#"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import StatusCard from '@/components/portal/StatusCard.vue'
|
||||
import { usePortalStore } from '@/stores/portal/usePortalStore'
|
||||
import { useMyShifts } from '@/composables/api/usePortalShifts'
|
||||
import { useMyShifts } from '@/composables/api/portal/usePortalShifts'
|
||||
import type { PortalPersonPayload } from '@/types/portal'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -19,15 +19,18 @@ const { data: shifts } = useMyShifts(eventIdRef)
|
||||
|
||||
const effectiveStatus = computed(() => {
|
||||
const fromPerson = portal.currentPerson?.status
|
||||
if (fromPerson) return fromPerson
|
||||
if (fromPerson)
|
||||
return fromPerson
|
||||
|
||||
return portal.activeEvent?.person_status ?? 'pending'
|
||||
})
|
||||
|
||||
const statusVariant = computed((): 'pending' | 'approved' | 'rejected' => {
|
||||
const s = effectiveStatus.value
|
||||
if (s === 'approved') return 'approved'
|
||||
if (s === 'rejected') return 'rejected'
|
||||
if (s === 'approved')
|
||||
return 'approved'
|
||||
if (s === 'rejected')
|
||||
return 'rejected'
|
||||
|
||||
return 'pending'
|
||||
})
|
||||
@@ -40,12 +43,15 @@ const upcomingCount = computed(() => shifts.value?.upcoming.length ?? 0)
|
||||
|
||||
function formatNextShift(person: PortalPersonPayload | null): string | null {
|
||||
const list = person?.shift_assignments
|
||||
if (!list?.length) return null
|
||||
if (!list?.length)
|
||||
return null
|
||||
|
||||
const usable = list.filter(
|
||||
a => a.shift?.time_slot?.date && (a.status === 'approved' || a.status === 'pending_approval'),
|
||||
)
|
||||
if (!usable.length) return null
|
||||
|
||||
if (!usable.length)
|
||||
return null
|
||||
|
||||
usable.sort((a, b) => {
|
||||
const da = a.shift?.time_slot?.date ?? ''
|
||||
@@ -57,13 +63,15 @@ function formatNextShift(person: PortalPersonPayload | null): string | null {
|
||||
const a = usable[0]!
|
||||
const slot = a.shift?.time_slot
|
||||
const section = a.shift?.festival_section?.name
|
||||
if (!slot?.date) return null
|
||||
if (!slot?.date)
|
||||
return null
|
||||
|
||||
const dateStr = new Date(`${slot.date}T12:00:00`).toLocaleDateString('nl-NL', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})
|
||||
|
||||
const start = slot.start_time?.slice(0, 5) ?? ''
|
||||
const end = slot.end_time?.slice(0, 5) ?? ''
|
||||
const timePart = start && end ? `${start} – ${end}` : start || ''
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useMyShifts, useCancelAssignment } from '@/composables/api/usePortalShifts'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { useCancelAssignment, useMyShifts } from '@/composables/api/portal/usePortalShifts'
|
||||
import type { MyShiftAssignment } from '@/types/portal-shift'
|
||||
import axios from 'axios'
|
||||
import type { ApiErrorResponse } from '@/types/api'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -40,7 +40,8 @@ function openCancelDialog(assignment: MyShiftAssignment) {
|
||||
}
|
||||
|
||||
async function confirmCancel() {
|
||||
if (!cancelTarget.value) return
|
||||
if (!cancelTarget.value)
|
||||
return
|
||||
|
||||
cancelError.value = null
|
||||
|
||||
@@ -49,12 +50,13 @@ async function confirmCancel() {
|
||||
assignmentId: cancelTarget.value.assignment_id,
|
||||
reason: cancelReason.value || undefined,
|
||||
})
|
||||
|
||||
showCancelDialog.value = false
|
||||
snackbarMessage.value = result.message
|
||||
snackbar.value = true
|
||||
}
|
||||
catch (err: unknown) {
|
||||
cancelError.value = axios.isAxiosError<ApiErrorResponse>(err)
|
||||
cancelError.value = isAxiosError<ApiErrorResponse>(err)
|
||||
? err.response?.data?.message ?? 'Er is een fout opgetreden.'
|
||||
: 'Er is een fout opgetreden.'
|
||||
}
|
||||
@@ -103,7 +105,7 @@ async function confirmCancel() {
|
||||
<VBtn
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="refetch()"
|
||||
@click="refetch"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormSubmissionDuplicate } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormSubmissionDuplicate } from '@/types/forms/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
data: PublicFormSubmissionDuplicate | null
|
||||
@@ -19,10 +19,12 @@ const dutchDateFormatter = new Intl.DateTimeFormat('nl-NL', {
|
||||
})
|
||||
|
||||
function formatDutchDate(iso: string): string {
|
||||
if (!iso) return ''
|
||||
if (!iso)
|
||||
return ''
|
||||
try {
|
||||
return dutchDateFormatter.format(new Date(iso))
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -36,15 +38,18 @@ function fallbackBody(data: PublicFormSubmissionDuplicate): string {
|
||||
}
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.data) return ''
|
||||
if (!props.data)
|
||||
return ''
|
||||
|
||||
return props.data.title?.trim() || FALLBACK_TITLE
|
||||
})
|
||||
|
||||
const body = computed(() => {
|
||||
if (!props.data) return ''
|
||||
if (!props.data)
|
||||
return ''
|
||||
const fromBackend = props.data.body?.trim()
|
||||
if (fromBackend) return fromBackend
|
||||
if (fromBackend)
|
||||
return fromBackend
|
||||
|
||||
return fallbackBody(props.data)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import type { PublicFormField, PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField, PublicFormTimeSlot } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -23,14 +23,18 @@ const selected = computed<string[]>(() =>
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, selected.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
if (props.errorMessages && props.errorMessages.length > 0)
|
||||
return props.errorMessages
|
||||
if (clientError.value)
|
||||
return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
@@ -38,7 +42,8 @@ const displayedErrors = computed(() => {
|
||||
const isEmpty = computed(() => !slots.value || slots.value.length === 0)
|
||||
|
||||
const hasMultipleEvents = computed(() => {
|
||||
if (!slots.value) return false
|
||||
if (!slots.value)
|
||||
return false
|
||||
|
||||
return new Set(slots.value.map(s => s.event_id)).size > 1
|
||||
})
|
||||
@@ -61,7 +66,8 @@ function formatDateLabel(iso: string): string {
|
||||
const parts = dateFormatter.format(d)
|
||||
|
||||
return parts.charAt(0).toUpperCase() + parts.slice(1)
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
@@ -110,8 +116,10 @@ function toggle(id: string, checked: boolean | null): void {
|
||||
const next = [...selected.value]
|
||||
const idx = next.indexOf(id)
|
||||
if (checked) {
|
||||
if (idx === -1) next.push(id)
|
||||
} else if (idx !== -1) {
|
||||
if (idx === -1)
|
||||
next.push(id)
|
||||
}
|
||||
else if (idx !== -1) {
|
||||
next.splice(idx, 1)
|
||||
}
|
||||
emit('update:modelValue', next)
|
||||
@@ -150,7 +158,7 @@ function toggle(id: string, checked: boolean | null): void {
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="refetch()"
|
||||
@click="refetch"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
@@ -201,7 +209,7 @@ function toggle(id: string, checked: boolean | null): void {
|
||||
<template #label>
|
||||
<div>
|
||||
<span class="text-body-1">{{ slot.name }}</span>
|
||||
<span class="text-caption text-medium-emphasis ml-2">
|
||||
<span class="text-caption text-medium-emphasis ms-2">
|
||||
({{ stripSeconds(slot.start_time) }}–{{ stripSeconds(slot.end_time) }})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
|
||||
import { resolveOptionLabel } from '@/types/forms/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/utils/forms/formValidation'
|
||||
import { usePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -34,14 +34,18 @@ const selected = computed<string[]>(() =>
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, selected.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
if (props.errorMessages && props.errorMessages.length > 0)
|
||||
return props.errorMessages
|
||||
if (clientError.value)
|
||||
return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
@@ -54,7 +58,8 @@ function toggle(value: string, checked: boolean | null): void {
|
||||
const next = [...selected.value]
|
||||
const idx = next.indexOf(value)
|
||||
if (checked) {
|
||||
if (idx === -1) next.push(value)
|
||||
if (idx === -1)
|
||||
next.push(value)
|
||||
}
|
||||
else if (idx !== -1) {
|
||||
next.splice(idx, 1)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -14,6 +14,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -14,6 +14,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
|
||||
defineProps<{
|
||||
field: PublicFormField
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import { resolveOptionLabel } from '@/types/forms/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
import { usePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -28,6 +28,7 @@ const items = computed<RenderOption[]>(() =>
|
||||
title: resolveOptionLabel(opt, locale.value),
|
||||
})),
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -17,8 +17,10 @@ const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const inputValue = computed<string>(() => {
|
||||
const v = props.modelValue
|
||||
if (v === null || v === undefined) return ''
|
||||
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : ''
|
||||
if (v === null || v === undefined)
|
||||
return ''
|
||||
if (typeof v === 'number')
|
||||
return Number.isFinite(v) ? String(v) : ''
|
||||
|
||||
return String(v)
|
||||
})
|
||||
@@ -31,6 +33,7 @@ function onUpdate(raw: string | number | null) {
|
||||
return
|
||||
}
|
||||
const n = Number(s)
|
||||
|
||||
emit('update:modelValue', Number.isFinite(n) ? n : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
|
||||
defineProps<{
|
||||
field: PublicFormField
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -14,6 +14,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import { resolveOptionLabel } from '@/types/forms/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
import { usePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -17,8 +17,8 @@ import FieldText from './FieldText.vue'
|
||||
import FieldTextarea from './FieldTextarea.vue'
|
||||
import FieldUrl from './FieldUrl.vue'
|
||||
import { evaluateConditionalLogic } from '@/composables/forms/composables/useConditionalLogic'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { FormFieldDisplayWidth, FormValues, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { FormFieldType } from '@/types/forms/formBuilder'
|
||||
import type { FormFieldDisplayWidth, FormValues, PublicFormField } from '@/types/forms/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import type { PublicFormField, PublicFormSectionOption, SectionPriorityValue } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField, PublicFormSectionOption, SectionPriorityValue } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField, runValidators } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -25,15 +25,18 @@ const HARD_CAP = 5
|
||||
const warnedOnMalformedValue = { value: false }
|
||||
|
||||
function selfHealIncoming(value: unknown): SectionPriorityValue[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
if (!Array.isArray(value))
|
||||
return []
|
||||
|
||||
const cleaned: SectionPriorityValue[] = []
|
||||
for (const entry of value) {
|
||||
if (entry === null || typeof entry !== 'object') continue
|
||||
if (entry === null || typeof entry !== 'object')
|
||||
continue
|
||||
const obj = entry as Record<string, unknown>
|
||||
const id = obj.section_id
|
||||
const prio = obj.priority
|
||||
if (typeof id !== 'string' || typeof prio !== 'number') continue
|
||||
if (typeof id !== 'string' || typeof prio !== 'number')
|
||||
continue
|
||||
cleaned.push({ section_id: id, priority: prio })
|
||||
}
|
||||
|
||||
@@ -57,9 +60,8 @@ const maxPriorities = computed(() => {
|
||||
// WS-5b canonicalised the legacy `max_priorities` key to `max_selected`;
|
||||
// the field's cap (if any) is surfaced under `validation_rules.max_selected`.
|
||||
const raw = props.field.validation_rules?.max_selected
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0)
|
||||
return Math.min(raw, HARD_CAP)
|
||||
}
|
||||
|
||||
return HARD_CAP
|
||||
})
|
||||
@@ -83,14 +85,18 @@ const isEmpty = computed(() => !sections.value || sections.value.length === 0)
|
||||
const rankedFull = computed(() => ranked.value.length >= maxPriorities.value)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const clientError = computed(() => {
|
||||
const res = runValidators(rules.value, ranked.value)
|
||||
|
||||
return res === true ? null : res
|
||||
})
|
||||
|
||||
const displayedErrors = computed(() => {
|
||||
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
||||
if (clientError.value) return [clientError.value]
|
||||
if (props.errorMessages && props.errorMessages.length > 0)
|
||||
return props.errorMessages
|
||||
if (clientError.value)
|
||||
return [clientError.value]
|
||||
|
||||
return []
|
||||
})
|
||||
@@ -105,7 +111,8 @@ function emitNow(): void {
|
||||
}
|
||||
|
||||
function rankSection(section: PublicFormSectionOption): void {
|
||||
if (rankedFull.value) return
|
||||
if (rankedFull.value)
|
||||
return
|
||||
ranked.value = reassignPriorities([
|
||||
...ranked.value,
|
||||
{ section_id: section.id, priority: ranked.value.length + 1 },
|
||||
@@ -115,6 +122,7 @@ function rankSection(section: PublicFormSectionOption): void {
|
||||
|
||||
function unrankAt(index: number): void {
|
||||
const next = [...ranked.value]
|
||||
|
||||
next.splice(index, 1)
|
||||
ranked.value = reassignPriorities(next)
|
||||
emitNow()
|
||||
@@ -163,7 +171,7 @@ function sectionNameFor(id: string): string {
|
||||
<VBtn
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="refetch()"
|
||||
@click="refetch"
|
||||
>
|
||||
Opnieuw proberen
|
||||
</VBtn>
|
||||
@@ -185,7 +193,7 @@ function sectionNameFor(id: string): string {
|
||||
Jouw voorkeuren ({{ ranked.length }} / {{ maxPriorities }})
|
||||
</div>
|
||||
|
||||
<draggable
|
||||
<Draggable
|
||||
v-model="ranked"
|
||||
item-key="section_id"
|
||||
handle=".section-priority-handle"
|
||||
@@ -194,7 +202,7 @@ function sectionNameFor(id: string): string {
|
||||
drag-class="section-priority-drag"
|
||||
:animation="150"
|
||||
:delay="100"
|
||||
:delay-on-touch-only="true"
|
||||
delay-on-touch-only
|
||||
class="section-priority-ranked mb-4"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
@@ -240,7 +248,7 @@ function sectionNameFor(id: string): string {
|
||||
/>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
</Draggable>
|
||||
|
||||
<div
|
||||
v-if="ranked.length === 0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { resolveOptionLabel } from '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import { resolveOptionLabel } from '@/types/forms/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
import { usePublicFormLocale } from '@/composables/publicFormInjection'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -28,6 +28,7 @@ const items = computed<RenderOption[]>(() =>
|
||||
title: resolveOptionLabel(opt, locale.value),
|
||||
})),
|
||||
)
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { AvailableTag, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import type { AvailableTag, PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -34,7 +34,8 @@ const items = computed<NormalizedTag[]>(() => {
|
||||
// per-category grouping so the #item slot can emit a subheader when
|
||||
// the category flips.
|
||||
return [...tags].map(normalize).sort((a, b) => {
|
||||
if (a.category === b.category) return 0
|
||||
if (a.category === b.category)
|
||||
return 0
|
||||
|
||||
return a.category < b.category ? -1 : 1
|
||||
})
|
||||
@@ -56,6 +57,7 @@ let lastCategory: string | null = null
|
||||
|
||||
function shouldRenderSubheader(category: string): boolean {
|
||||
const flip = lastCategory !== category
|
||||
|
||||
lastCategory = category
|
||||
|
||||
return flip
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -14,6 +14,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -14,6 +14,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { getValidatorsForField } from '@/composables/forms/utils/formValidation'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
import { getValidatorsForField } from '@/utils/forms/formValidation'
|
||||
|
||||
const props = defineProps<{
|
||||
field: PublicFormField
|
||||
@@ -14,6 +14,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const rules = computed(() => getValidatorsForField(props.field))
|
||||
|
||||
const model = computed({
|
||||
get: () => (props.modelValue ?? '') as string,
|
||||
set: (v: string) => emit('update:modelValue', v),
|
||||
|
||||
@@ -6,13 +6,13 @@ import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots
|
||||
import { formatFieldValue } from '@/composables/forms/composables/formatFieldValue'
|
||||
import type { FormStep } from '@/composables/forms/composables/useFormSteps'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import { FormFieldType } from '@/types/forms/formBuilder'
|
||||
import type {
|
||||
FormValues,
|
||||
PublicFormField,
|
||||
PublicFormSubmissionDuplicate,
|
||||
PublicFormSubmissionIdentityMatch,
|
||||
} from '@/composables/forms/types/formBuilder'
|
||||
} from '@/types/forms/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
steps: FormStep[]
|
||||
@@ -77,8 +77,10 @@ function answerableFields(step: FormStep): PublicFormField[] {
|
||||
v-if="duplicateSubmission || identityMatch"
|
||||
class="pa-6 pb-0"
|
||||
>
|
||||
<!-- Duplicate hint first: it's about the act of submitting.
|
||||
Identity match second: it's about who you are. -->
|
||||
<!--
|
||||
Duplicate hint first: it's about the act of submitting.
|
||||
Identity match second: it's about who you are.
|
||||
-->
|
||||
<DuplicateSubmissionHint :data="duplicateSubmission ?? null" />
|
||||
<IdentityMatchBanner
|
||||
v-if="identityMatch"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormErrorCode } from '@/composables/forms/types/formBuilder'
|
||||
import type { FormErrorCode } from '@/types/forms/formBuilder'
|
||||
|
||||
const props = defineProps<{
|
||||
errorCode?: FormErrorCode | string
|
||||
@@ -74,7 +74,8 @@ const DEFAULT_COPY: Copy = {
|
||||
|
||||
const copy = computed<Copy>(() => {
|
||||
const code = props.errorCode
|
||||
if (code && code in COPY) return COPY[code]
|
||||
if (code && code in COPY)
|
||||
return COPY[code]
|
||||
|
||||
return DEFAULT_COPY
|
||||
})
|
||||
|
||||
@@ -22,8 +22,10 @@ const items = computed(() => props.steps.map(s => ({
|
||||
})))
|
||||
|
||||
function go(value: number): void {
|
||||
if (value > props.currentStep && !props.isActiveStepValid) return
|
||||
if (value < 0 || value >= props.steps.length) return
|
||||
if (value > props.currentStep && !props.isActiveStepValid)
|
||||
return
|
||||
if (value < 0 || value >= props.steps.length)
|
||||
return
|
||||
emit('update:currentStep', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -14,11 +14,13 @@ const FALLBACK_TITLE: Record<Exclude<Props['status'], null>, string> = {
|
||||
matched: 'Gegevens gekoppeld',
|
||||
none: 'Aanmelding ontvangen',
|
||||
}
|
||||
|
||||
const FALLBACK_BODY: Record<Exclude<Props['status'], null>, string> = {
|
||||
pending: 'We kijken of je al bekend bent bij de organisator. Je gegevens worden automatisch gekoppeld zodra zij dit bevestigen.',
|
||||
matched: 'Je bent automatisch gekoppeld aan je bestaande account bij de organisator.',
|
||||
none: 'De organisator neemt contact met je op zodra je aanmelding is verwerkt.',
|
||||
}
|
||||
|
||||
const TYPE: Record<Exclude<Props['status'], null>, 'info' | 'success'> = {
|
||||
pending: 'info',
|
||||
matched: 'success',
|
||||
@@ -26,21 +28,25 @@ const TYPE: Record<Exclude<Props['status'], null>, '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]
|
||||
})
|
||||
|
||||
@@ -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.'),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import DuplicateSubmissionHint from '@/components/shared/public-form/DuplicateSubmissionHint.vue'
|
||||
import type { PublicFormSubmissionDuplicate } from '@/composables/forms/types/formBuilder'
|
||||
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')
|
||||
|
||||
@@ -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<Array<Record<string, unknown>> | 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/shared/public-form/FieldAvailabilityPicker.vue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField, PublicFormTimeSlot } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): 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')
|
||||
|
||||
@@ -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 { 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 '@/composables/forms/types/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { FormFieldType } from '@/types/forms/formBuilder'
|
||||
import type { OptionSpec, PublicFormField } from '@/types/forms/formBuilder'
|
||||
|
||||
const stubs = {
|
||||
VRadioGroup: { name: 'VRadioGroup', template: '<div class="v-radio-group-stub"><slot/></div>' },
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FieldRenderer from '@/components/shared/public-form/FieldRenderer.vue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { FormFieldType } from '@/types/forms/formBuilder'
|
||||
import type { PublicFormField } from '@/types/forms/formBuilder'
|
||||
|
||||
function makeField(partial: Partial<PublicFormField>): 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Array<Record<string, unknown>> | 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/shared/public-form/FieldSectionPriority.vue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { PublicFormField, PublicFormSectionOption } from '@/composables/forms/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): 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<Array<Array<{ section_id: string; priority: number }>>>
|
||||
const last = emits[emits.length - 1][0]
|
||||
|
||||
expect(last).toEqual([{ section_id: 'a', priority: 1 }])
|
||||
})
|
||||
|
||||
it('tapping a second section lands it at priority 2', async () => {
|
||||
state.data.value = [section({ id: 'a' }), section({ id: 'b' })]
|
||||
|
||||
const w = mountPicker({
|
||||
field: field(),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
@@ -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<Array<Array<{ section_id: string; priority: number }>>>
|
||||
const last = emits[emits.length - 1][0]
|
||||
|
||||
expect(last).toEqual([
|
||||
{ section_id: 'a', priority: 1 },
|
||||
{ section_id: 'b', priority: 2 },
|
||||
@@ -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<string, unknown> }),
|
||||
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 <draggable>', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FieldTagPicker from '@/components/shared/public-form/FieldTagPicker.vue'
|
||||
import { FormFieldType } from '@/composables/forms/types/formBuilder'
|
||||
import type { AvailableTag, PublicFormField } from '@/composables/forms/types/formBuilder'
|
||||
import { FormFieldType } from '@/types/forms/formBuilder'
|
||||
import type { AvailableTag, PublicFormField } from '@/types/forms/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): 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'])
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user