feat(portal): implement TAG_PICKER, AVAILABILITY_PICKER, SECTION_PRIORITY field types
- FieldTagPicker: VAutocomplete multiple with grouped category slots, empty/null category normalised to "Overig", empty-state info alert when the server delivers no tags. - FieldAvailabilityPicker: date-grouped checkbox list, festival-aware via usePublicFormTimeSlots. Event-name subheaders only surface when the time-slots span multiple events. Time format strips seconds. - FieldSectionPriority: tap-to-rank + drag-to-reorder via vuedraggable for desktop; mobile tap-only. Renumbers priorities on every mutation. Self-heals malformed modelValue. UI soft cap via validation_rules.max_priorities clamped to the backend hard cap of 5. - FieldRenderer: three new types removed from isStubbed. - publicFormInjection: page-level provide/inject for the public token. - IdentityMatchBanner: prefers backend-provided Dutch copy with frontend defaults as defensive fallback. - FormConfirmation wires the banner inline. - usePublicFormTimeSlots and usePublicFormSections TanStack composables. - 40 new Vitest assertions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
// Expose mutable state for the mocked composable so each test can steer
|
||||
// loading / error / data scenarios without a vue-query harness.
|
||||
const state = {
|
||||
data: ref<Array<Record<string, unknown>> | undefined>(undefined),
|
||||
isLoading: ref(false),
|
||||
isError: ref(false),
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/composables/api/usePublicFormTimeSlots', () => ({
|
||||
usePublicFormTimeSlots: () => state,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/publicFormInjection', () => ({
|
||||
usePublicFormToken: () => ref('TKN'),
|
||||
providePublicFormToken: () => {},
|
||||
}))
|
||||
|
||||
import FieldAvailabilityPicker from '@/components/public-form/FieldAvailabilityPicker.vue'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type { PublicFormField, PublicFormTimeSlot } from '@/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): PublicFormField {
|
||||
return {
|
||||
id: 'f_1',
|
||||
slug: 'beschikbaarheid',
|
||||
field_type: FormFieldType.AVAILABILITY_PICKER,
|
||||
label: 'Wanneer ben je beschikbaar?',
|
||||
help_text: null,
|
||||
options: null,
|
||||
available_tags: null,
|
||||
validation_rules: null,
|
||||
is_required: false,
|
||||
display_width: 'full',
|
||||
conditional_logic: null,
|
||||
sort_order: 1,
|
||||
form_schema_section_id: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function slot(partial: Partial<PublicFormTimeSlot>): PublicFormTimeSlot {
|
||||
return {
|
||||
id: partial.id ?? '01A',
|
||||
name: partial.name ?? 'Vrijdag avond',
|
||||
date: partial.date ?? '2026-07-10',
|
||||
start_time: partial.start_time ?? '18:00:00',
|
||||
end_time: partial.end_time ?? '23:00:00',
|
||||
duration_hours: partial.duration_hours ?? 5,
|
||||
event_id: partial.event_id ?? 'evt_1',
|
||||
event_name: partial.event_name ?? 'Echt Feesten',
|
||||
}
|
||||
}
|
||||
|
||||
function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) {
|
||||
return mount(FieldAvailabilityPicker, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
VSkeletonLoader: { name: 'VSkeletonLoader', template: '<div class="v-skeleton-stub"/>' },
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type'],
|
||||
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
VBtn: {
|
||||
name: 'VBtn',
|
||||
template: '<button class="v-btn-stub" @click="$emit(\'click\')"><slot/></button>',
|
||||
},
|
||||
VCheckbox: {
|
||||
name: 'VCheckbox',
|
||||
props: ['modelValue'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<label class="v-checkbox-stub">
|
||||
<input type="checkbox" :checked="modelValue" @change="$emit('update:modelValue', ($event.target).checked)"/>
|
||||
<slot name="label"/>
|
||||
</label>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('FieldAvailabilityPicker', () => {
|
||||
beforeEach(() => {
|
||||
state.data.value = undefined
|
||||
state.isLoading.value = false
|
||||
state.isError.value = false
|
||||
state.refetch = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the skeleton while loading', () => {
|
||||
state.isLoading.value = true
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-skeleton-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the error alert with a retry button when isError', async () => {
|
||||
state.isError.value = true
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').attributes('data-type')).toBe('error')
|
||||
await w.find('.v-btn-stub').trigger('click')
|
||||
expect(state.refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the info empty-state when the slots list is empty', () => {
|
||||
state.data.value = []
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').attributes('data-type')).toBe('info')
|
||||
expect(w.text()).toContain('Er zijn nog geen tijdsloten beschikbaar.')
|
||||
})
|
||||
|
||||
it('groups slots by date with Dutch weekday labels', () => {
|
||||
state.data.value = [
|
||||
slot({ id: 'a', date: '2026-07-11', name: 'Za middag' }),
|
||||
slot({ id: 'b', date: '2026-07-12', name: 'Zo middag' }),
|
||||
]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
// "zaterdag 11 juli" — capitalised. Rendered with nl-NL locale.
|
||||
expect(w.text()).toMatch(/Zaterdag\s*11\s*juli/)
|
||||
expect(w.text()).toMatch(/Zondag\s*12\s*juli/)
|
||||
})
|
||||
|
||||
it('adds event-name subheaders when multiple events are present', () => {
|
||||
state.data.value = [
|
||||
slot({ id: 'a', event_id: 'e1', event_name: 'Parent festival' }),
|
||||
slot({ id: 'b', event_id: 'e2', event_name: 'Dag 1' }),
|
||||
]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.text()).toContain('Parent festival')
|
||||
expect(w.text()).toContain('Dag 1')
|
||||
})
|
||||
|
||||
it('omits event-name subheaders in the single-event case', () => {
|
||||
state.data.value = [
|
||||
slot({ id: 'a', event_id: 'e1', event_name: 'Only event' }),
|
||||
slot({ id: 'b', event_id: 'e1', event_name: 'Only event' }),
|
||||
]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
// Only event_name appears somewhere in the DOM? In the single-event
|
||||
// case, the subheader should NOT be rendered — count the occurrences
|
||||
// to verify only checkbox labels (slot names) appear, not the event
|
||||
// name as a standalone subheader.
|
||||
const text = w.text()
|
||||
// The event_name "Only event" should not appear as a subheader; slot
|
||||
// names are different ("Vrijdag avond"), so event_name shouldn't be
|
||||
// found anywhere in the visible text.
|
||||
expect(text).not.toContain('Only event')
|
||||
})
|
||||
|
||||
it('emits update:modelValue as string[] of time_slot IDs on toggle', async () => {
|
||||
state.data.value = [slot({ id: 'alpha' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
const checkbox = w.find('.v-checkbox-stub input')
|
||||
await checkbox.setValue(true)
|
||||
const emits = w.emitted('update:modelValue') as unknown as string[][][]
|
||||
expect(emits?.[0][0]).toEqual(['alpha'])
|
||||
})
|
||||
|
||||
it('formats time with seconds stripped', () => {
|
||||
state.data.value = [slot({ start_time: '08:00:00', end_time: '13:00:00' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.text()).toContain('08:00–13:00')
|
||||
expect(w.text()).not.toContain('08:00:00')
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,9 @@ function mountRenderer(field: PublicFormField, allValues: Record<string, unknown
|
||||
FieldSelect: { name: 'FieldSelect', template: '<div class="field-select-stub"/>' },
|
||||
FieldMultiselect: { name: 'FieldMultiselect', template: '<div class="field-multiselect-stub"/>' },
|
||||
FieldCheckboxList: { name: 'FieldCheckboxList', template: '<div class="field-checkboxlist-stub"/>' },
|
||||
FieldTagPicker: { name: 'FieldTagPicker', template: '<div class="field-tagpicker-stub"/>' },
|
||||
FieldAvailabilityPicker: { name: 'FieldAvailabilityPicker', template: '<div class="field-availabilitypicker-stub"/>' },
|
||||
FieldSectionPriority: { name: 'FieldSectionPriority', template: '<div class="field-sectionpriority-stub"/>' },
|
||||
FieldHeading: { name: 'FieldHeading', template: '<div class="field-heading-stub"/>' },
|
||||
FieldParagraph: { name: 'FieldParagraph', template: '<div class="field-paragraph-stub"/>' },
|
||||
FieldUrl: { name: 'FieldUrl', template: '<div class="field-url-stub"/>' },
|
||||
@@ -61,6 +64,9 @@ describe('FieldRenderer', () => {
|
||||
[FormFieldType.SELECT, 'field-select-stub'],
|
||||
[FormFieldType.MULTISELECT, 'field-multiselect-stub'],
|
||||
[FormFieldType.CHECKBOX_LIST, 'field-checkboxlist-stub'],
|
||||
[FormFieldType.TAG_PICKER, 'field-tagpicker-stub'],
|
||||
[FormFieldType.AVAILABILITY_PICKER, 'field-availabilitypicker-stub'],
|
||||
[FormFieldType.SECTION_PRIORITY, 'field-sectionpriority-stub'],
|
||||
[FormFieldType.HEADING, 'field-heading-stub'],
|
||||
[FormFieldType.PARAGRAPH, 'field-paragraph-stub'],
|
||||
[FormFieldType.URL, 'field-url-stub'],
|
||||
@@ -70,9 +76,6 @@ describe('FieldRenderer', () => {
|
||||
})
|
||||
|
||||
it.each<string>([
|
||||
FormFieldType.TAG_PICKER,
|
||||
FormFieldType.AVAILABILITY_PICKER,
|
||||
FormFieldType.SECTION_PRIORITY,
|
||||
FormFieldType.FILE_UPLOAD,
|
||||
FormFieldType.IMAGE_UPLOAD,
|
||||
FormFieldType.SIGNATURE,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const state = {
|
||||
data: ref<Array<Record<string, unknown>> | undefined>(undefined),
|
||||
isLoading: ref(false),
|
||||
isError: ref(false),
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/composables/api/usePublicFormSections', () => ({
|
||||
usePublicFormSections: () => state,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/publicFormInjection', () => ({
|
||||
usePublicFormToken: () => ref('TKN'),
|
||||
providePublicFormToken: () => {},
|
||||
}))
|
||||
|
||||
// Vuetify's useDisplay touches window.matchMedia which jsdom doesn't
|
||||
// implement; stub to always return desktop for consistent behaviour.
|
||||
vi.mock('vuetify', () => ({
|
||||
useDisplay: () => ({ mobile: ref(false) }),
|
||||
}))
|
||||
|
||||
// vuedraggable depends on SortableJS which pokes the DOM on mount; a
|
||||
// minimal slot-passthrough stub is enough for the tap-based behaviour
|
||||
// under test.
|
||||
vi.mock('vuedraggable', () => ({
|
||||
default: {
|
||||
name: 'draggable',
|
||||
props: ['modelValue'],
|
||||
template: '<div class="draggable-stub"><template v-for="(el, i) in modelValue" :key="i"><slot name="item" :element="el" :index="i"/></template></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
import FieldSectionPriority from '@/components/public-form/FieldSectionPriority.vue'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type { PublicFormField, PublicFormSectionOption } from '@/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): PublicFormField {
|
||||
return {
|
||||
id: 'f_1',
|
||||
slug: 'sectie_voorkeur',
|
||||
field_type: FormFieldType.SECTION_PRIORITY,
|
||||
label: 'Bij welke sectie wil je werken?',
|
||||
help_text: null,
|
||||
options: null,
|
||||
available_tags: null,
|
||||
validation_rules: null,
|
||||
is_required: false,
|
||||
display_width: 'full',
|
||||
conditional_logic: null,
|
||||
sort_order: 1,
|
||||
form_schema_section_id: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function section(partial: Partial<PublicFormSectionOption>): PublicFormSectionOption {
|
||||
return {
|
||||
id: partial.id ?? '01A',
|
||||
name: partial.name ?? 'Bar',
|
||||
category: partial.category ?? null,
|
||||
icon: partial.icon ?? null,
|
||||
registration_description: partial.registration_description ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) {
|
||||
return mount(FieldSectionPriority, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
VSkeletonLoader: { name: 'VSkeletonLoader', template: '<div class="v-skeleton-stub"/>' },
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type'],
|
||||
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
VBtn: {
|
||||
name: 'VBtn',
|
||||
props: ['ariaLabel', 'icon'],
|
||||
template: '<button class="v-btn-stub" @click="$emit(\'click\')"><slot/></button>',
|
||||
},
|
||||
VCard: {
|
||||
name: 'VCard',
|
||||
// Do not re-emit click/keydown — the parent's @click listener
|
||||
// falls through to the root element, and emitting + fallthrough
|
||||
// would wire the handler twice.
|
||||
inheritAttrs: true,
|
||||
template: '<div class="v-card-stub"><slot/></div>',
|
||||
},
|
||||
VRow: { name: 'VRow', template: '<div class="v-row-stub"><slot/></div>' },
|
||||
VCol: { name: 'VCol', template: '<div class="v-col-stub"><slot/></div>' },
|
||||
VIcon: { name: 'VIcon', template: '<i class="v-icon-stub"/>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('FieldSectionPriority', () => {
|
||||
beforeEach(() => {
|
||||
state.data.value = undefined
|
||||
state.isLoading.value = false
|
||||
state.isError.value = false
|
||||
state.refetch = vi.fn()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the skeleton while loading', () => {
|
||||
state.isLoading.value = true
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-skeleton-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders the error alert with retry wiring', async () => {
|
||||
state.isError.value = true
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').attributes('data-type')).toBe('error')
|
||||
await w.find('.v-btn-stub').trigger('click')
|
||||
expect(state.refetch).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders the info empty-state when no sections are published', () => {
|
||||
state.data.value = []
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').attributes('data-type')).toBe('info')
|
||||
expect(w.text()).toContain('Er zijn nog geen secties gepubliceerd voor registratie.')
|
||||
})
|
||||
|
||||
it('renders all sections in the unranked pool initially', () => {
|
||||
state.data.value = [section({ id: 'a', name: 'Bar' }), section({ id: 'b', name: 'Hospitality' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
expect(w.text()).toContain('Bar')
|
||||
expect(w.text()).toContain('Hospitality')
|
||||
})
|
||||
|
||||
it('tap-to-rank moves a section to the ranked list at priority 1', async () => {
|
||||
state.data.value = [section({ id: 'a', name: 'Bar' }), section({ id: 'b', name: 'Hospitality' })]
|
||||
const w = mountPicker({ field: field(), modelValue: [] })
|
||||
|
||||
// First unranked card tap
|
||||
const cards = w.findAll('.v-card-stub')
|
||||
await cards[0].trigger('click')
|
||||
|
||||
const emits = w.emitted('update:modelValue') as unknown as Array<Array<Array<{ section_id: string; priority: number }>>>
|
||||
const last = emits[emits.length - 1][0]
|
||||
expect(last).toEqual([{ section_id: 'a', priority: 1 }])
|
||||
})
|
||||
|
||||
it('tapping a second section lands it at priority 2', async () => {
|
||||
state.data.value = [section({ id: 'a' }), section({ id: 'b' })]
|
||||
const w = mountPicker({
|
||||
field: field(),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
|
||||
// Only section b is still in the pool — it renders as a card.
|
||||
const poolCards = w.findAll('.v-card-stub').filter(c => c.text().length > 0)
|
||||
await poolCards[poolCards.length - 1].trigger('click')
|
||||
|
||||
const emits = w.emitted('update:modelValue') as unknown as Array<Array<Array<{ section_id: string; priority: number }>>>
|
||||
const last = emits[emits.length - 1][0]
|
||||
expect(last).toEqual([
|
||||
{ section_id: 'a', priority: 1 },
|
||||
{ section_id: 'b', priority: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
it('respects validation_rules.max_priorities when present', async () => {
|
||||
state.data.value = [section({ id: 'a' }), section({ id: 'b' })]
|
||||
const w = mountPicker({
|
||||
field: field({ validation_rules: { max_priorities: 1 } }),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
|
||||
// Cap is 1; the only unranked card should be disabled per the
|
||||
// rendered "Maximaal" hint.
|
||||
expect(w.text()).toContain('Maximaal 1 voorkeuren')
|
||||
})
|
||||
|
||||
it('self-heals an incoming string[] modelValue to []', () => {
|
||||
state.data.value = [section({ id: 'a' })]
|
||||
const w = mountPicker({
|
||||
field: field(),
|
||||
modelValue: ['a', 'b'], // wrong shape (string[])
|
||||
})
|
||||
|
||||
// Still renders fine — no crash, empty ranked list, both sections
|
||||
// in the pool.
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(false)
|
||||
expect(w.text()).toContain('Bar')
|
||||
})
|
||||
|
||||
it('clamps max_priorities to the hard cap of 5 when the rule is too high', () => {
|
||||
state.data.value = [
|
||||
section({ id: 'a' }),
|
||||
section({ id: 'b' }),
|
||||
section({ id: 'c' }),
|
||||
section({ id: 'd' }),
|
||||
section({ id: 'e' }),
|
||||
]
|
||||
const ranked = [
|
||||
{ section_id: 'a', priority: 1 },
|
||||
{ section_id: 'b', priority: 2 },
|
||||
{ section_id: 'c', priority: 3 },
|
||||
{ section_id: 'd', priority: 4 },
|
||||
{ section_id: 'e', priority: 5 },
|
||||
]
|
||||
const w = mountPicker({
|
||||
field: field({ validation_rules: { max_priorities: 99 } as Record<string, unknown> }),
|
||||
modelValue: ranked,
|
||||
})
|
||||
|
||||
// Cap falls back to 5 — counter reads "5 / 5" rather than "5 / 99".
|
||||
expect(w.text()).toContain('5 / 5')
|
||||
})
|
||||
|
||||
it('exposes the ranked counter in the UI copy', () => {
|
||||
state.data.value = [section({ id: 'a' })]
|
||||
const w = mountPicker({
|
||||
field: field(),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
|
||||
expect(w.text()).toContain('1 / 5')
|
||||
})
|
||||
})
|
||||
148
apps/portal/tests/components/public-form/FieldTagPicker.spec.ts
Normal file
148
apps/portal/tests/components/public-form/FieldTagPicker.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import FieldTagPicker from '@/components/public-form/FieldTagPicker.vue'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type { AvailableTag, PublicFormField } from '@/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): PublicFormField {
|
||||
return {
|
||||
id: 'f_1',
|
||||
slug: 'vaardigheden',
|
||||
field_type: FormFieldType.TAG_PICKER,
|
||||
label: 'Vaardigheden',
|
||||
help_text: null,
|
||||
options: null,
|
||||
available_tags: null,
|
||||
validation_rules: null,
|
||||
is_required: false,
|
||||
display_width: 'full',
|
||||
conditional_logic: null,
|
||||
sort_order: 1,
|
||||
form_schema_section_id: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
function tag(partial: Partial<AvailableTag>): AvailableTag {
|
||||
return { id: partial.id ?? 't_1', name: partial.name ?? 'EHBO', category: partial.category ?? 'Veiligheid' }
|
||||
}
|
||||
|
||||
function mountPicker(props: { field: PublicFormField; modelValue: unknown; errorMessages?: string[] }) {
|
||||
return mount(FieldTagPicker, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: { name: 'VAlert', template: '<div class="v-alert-stub"><slot/></div>' },
|
||||
AppAutocomplete: {
|
||||
name: 'AppAutocomplete',
|
||||
props: ['modelValue', 'items', 'label', 'hint', 'required', 'errorMessages'],
|
||||
emits: ['update:modelValue', 'blur'],
|
||||
template: `<div class="app-autocomplete-stub" :data-label="label">
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
class="stub-item"
|
||||
:data-value="item.value"
|
||||
:data-category="item.category"
|
||||
@click="$emit('update:modelValue', [...(modelValue || []), item.value])"
|
||||
>{{ item.title }}</button>
|
||||
<button
|
||||
class="stub-unselect"
|
||||
@click="$emit('update:modelValue', (modelValue || []).slice(0, -1))"
|
||||
>unselect-last</button>
|
||||
</div>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('FieldTagPicker', () => {
|
||||
it('renders the info empty-state when available_tags is null', () => {
|
||||
const w = mountPicker({ field: field({ available_tags: null }), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(true)
|
||||
expect(w.text()).toContain('Er zijn nog geen tags beschikbaar')
|
||||
})
|
||||
|
||||
it('renders the info empty-state when available_tags is an empty array', () => {
|
||||
const w = mountPicker({ field: field({ available_tags: [] }), modelValue: [] })
|
||||
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('normalises null/empty category to "Overig"', () => {
|
||||
const w = mountPicker({
|
||||
field: field({ available_tags: [tag({ id: 'a', name: 'Onbekend', category: '' })] }),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
const items = w.findAll('.stub-item')
|
||||
expect(items[0].attributes('data-category')).toBe('Overig')
|
||||
})
|
||||
|
||||
it('renders one item per tag with its id as value', () => {
|
||||
const w = mountPicker({
|
||||
field: field({
|
||||
available_tags: [
|
||||
tag({ id: 'a', name: 'EHBO', category: 'Veiligheid' }),
|
||||
tag({ id: 'b', name: 'BHV', category: 'Veiligheid' }),
|
||||
],
|
||||
}),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
const items = w.findAll('.stub-item')
|
||||
expect(items.length).toBe(2)
|
||||
expect(items.map(i => i.attributes('data-value'))).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('emits update:modelValue as string[] of tag IDs on selection', async () => {
|
||||
const w = mountPicker({
|
||||
field: field({ available_tags: [tag({ id: 'a', name: 'EHBO' })] }),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
await w.find('.stub-item').trigger('click')
|
||||
const emits = w.emitted('update:modelValue') as unknown as string[][][]
|
||||
expect(emits?.[0][0]).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('unselecting a tag re-emits the trimmed array', async () => {
|
||||
const w = mountPicker({
|
||||
field: field({ available_tags: [tag({ id: 'a' }), tag({ id: 'b' })] }),
|
||||
modelValue: ['a', 'b'],
|
||||
})
|
||||
|
||||
await w.find('.stub-unselect').trigger('click')
|
||||
const emits = w.emitted('update:modelValue') as unknown as string[][][]
|
||||
expect(emits?.[0][0]).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('renders the required indicator in the empty-state when is_required', () => {
|
||||
const w = mountPicker({
|
||||
field: field({ available_tags: null, is_required: true }),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
expect(w.find('.text-error').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sorts items grouped by category', () => {
|
||||
const w = mountPicker({
|
||||
field: field({
|
||||
available_tags: [
|
||||
tag({ id: 'a', category: 'Zeta' }),
|
||||
tag({ id: 'b', category: 'Alpha' }),
|
||||
tag({ id: 'c', category: 'Alpha' }),
|
||||
],
|
||||
}),
|
||||
modelValue: [],
|
||||
})
|
||||
|
||||
const items = w.findAll('.stub-item')
|
||||
const cats = items.map(i => i.attributes('data-category'))
|
||||
// Alpha tags come first, then Zeta.
|
||||
expect(cats).toEqual(['Alpha', 'Alpha', 'Zeta'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import IdentityMatchBanner from '@/components/public-form/IdentityMatchBanner.vue'
|
||||
|
||||
function mountBanner(props: { status: 'pending' | 'matched' | 'none' | null; message?: string | null }) {
|
||||
return mount(IdentityMatchBanner, {
|
||||
props,
|
||||
global: {
|
||||
stubs: {
|
||||
VAlert: {
|
||||
name: 'VAlert',
|
||||
props: ['type', 'variant', 'prominent'],
|
||||
template: '<div class="v-alert-stub" :data-type="type"><slot/></div>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('IdentityMatchBanner', () => {
|
||||
it('renders nothing when status is null', () => {
|
||||
const w = mountBanner({ status: null })
|
||||
|
||||
expect(w.find('.v-alert-stub').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders the pending banner with info type and backend message when provided', () => {
|
||||
const w = mountBanner({
|
||||
status: 'pending',
|
||||
message: 'We controleren of je al bekend bent bij de organisator.',
|
||||
})
|
||||
|
||||
const alert = w.find('.v-alert-stub')
|
||||
expect(alert.exists()).toBe(true)
|
||||
expect(alert.attributes('data-type')).toBe('info')
|
||||
expect(w.text()).toContain('We controleren')
|
||||
})
|
||||
|
||||
it('renders the matched banner with success type and backend message when provided', () => {
|
||||
const w = mountBanner({
|
||||
status: 'matched',
|
||||
message: 'Je account is gekoppeld aan een bekende deelnemer.',
|
||||
})
|
||||
|
||||
const alert = w.find('.v-alert-stub')
|
||||
expect(alert.exists()).toBe(true)
|
||||
expect(alert.attributes('data-type')).toBe('success')
|
||||
expect(w.text()).toContain('gekoppeld')
|
||||
})
|
||||
|
||||
it('falls back to frontend copy when backend message is missing', () => {
|
||||
const w = mountBanner({ status: 'none', message: null })
|
||||
|
||||
const alert = w.find('.v-alert-stub')
|
||||
expect(alert.exists()).toBe(true)
|
||||
expect(alert.attributes('data-type')).toBe('success')
|
||||
expect(w.text()).toContain('Aanmelding ontvangen')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user