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:
2026-05-05 19:26:46 +02:00
parent e3452312d1
commit 5c689f42a0
74 changed files with 778 additions and 339 deletions

View File

@@ -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)}`
}

View File

@@ -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',

View File

@@ -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) ?? ''

View File

@@ -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="#"

View File

@@ -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 || ''

View File

@@ -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>

View File

@@ -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)
})

View File

@@ -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>

View File

@@ -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

View File

@@ -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)

View File

@@ -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),

View File

@@ -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),

View File

@@ -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

View File

@@ -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({

View File

@@ -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>

View File

@@ -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

View File

@@ -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),

View File

@@ -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<{

View File

@@ -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

View File

@@ -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"

View File

@@ -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({

View File

@@ -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

View File

@@ -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),

View File

@@ -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),

View File

@@ -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),

View File

@@ -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"

View File

@@ -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
})

View File

@@ -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>

View File

@@ -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]
})

View File

@@ -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.'),

View File

@@ -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')

View File

@@ -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:0013:00')

View File

@@ -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')

View File

@@ -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)
})
})

View File

@@ -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')
})
})

View File

@@ -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'])
})

View File

@@ -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')