fix(portal): review display, hover overlay, and drag ghost for complex field types
- Extract formatFieldValue helper for shared use between review step
and confirmation page — one source of truth for TAG_PICKER,
AVAILABILITY_PICKER, and SECTION_PRIORITY display, so the raw-ID
and [object Object] leaks from two parallel stringifiers can't
regress on either side.
- TAG_PICKER: lookup via field.available_tags (server-inlined).
- AVAILABILITY_PICKER: lookup via usePublicFormTimeSlots, strip
seconds. "Laden…" while the cache warms.
- SECTION_PRIORITY: defensive shape-guard prevents [object Object]
leaks, sorted priority-prefixed rendering ("1. Bar, 2. Hospitality").
- Subtle primary-tinted hover (4% primary, primary border) replacing
the near-black Vuetify default overlay on unranked section cards.
- Explicit ghost-class / drag-class / chosen-class on vuedraggable
with solid drag-clone + elevation shadow and a 30%-opacity silhouette
at the origin, so mid-drag text no longer overlaps.
- 17 new formatFieldValue unit assertions + 2 new FieldSectionPriority
assertions locking in the draggable classes and the disabled-card
toggle at max.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
apps/portal/auto-imports.d.ts
vendored
2
apps/portal/auto-imports.d.ts
vendored
@@ -58,6 +58,7 @@ declare global {
|
||||
const extractRetryAfterSeconds: typeof import('./src/composables/useFormDraft')['extractRetryAfterSeconds']
|
||||
const formatDate: typeof import('./src/@core/utils/formatters')['formatDate']
|
||||
const formatDateToMonthShort: typeof import('./src/@core/utils/formatters')['formatDateToMonthShort']
|
||||
const formatFieldValue: typeof import('./src/composables/formatFieldValue')['formatFieldValue']
|
||||
const generateDeviceFingerprint: typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
@@ -439,6 +440,7 @@ declare module 'vue' {
|
||||
readonly extractRetryAfterSeconds: UnwrapRef<typeof import('./src/composables/useFormDraft')['extractRetryAfterSeconds']>
|
||||
readonly formatDate: UnwrapRef<typeof import('./src/@core/utils/formatters')['formatDate']>
|
||||
readonly formatDateToMonthShort: UnwrapRef<typeof import('./src/@core/utils/formatters')['formatDateToMonthShort']>
|
||||
readonly formatFieldValue: UnwrapRef<typeof import('./src/composables/formatFieldValue')['formatFieldValue']>
|
||||
readonly generateDeviceFingerprint: UnwrapRef<typeof import('./src/utils/deviceFingerprint')['generateDeviceFingerprint']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
readonly getCurrentInstance: UnwrapRef<typeof import('vue')['getCurrentInstance']>
|
||||
|
||||
@@ -187,7 +187,10 @@ function sectionNameFor(id: string): string {
|
||||
v-model="ranked"
|
||||
item-key="section_id"
|
||||
handle=".section-priority-handle"
|
||||
:animation="180"
|
||||
ghost-class="section-priority-ghost"
|
||||
chosen-class="section-priority-chosen"
|
||||
drag-class="section-priority-drag"
|
||||
:animation="150"
|
||||
:delay="100"
|
||||
:delay-on-touch-only="true"
|
||||
class="section-priority-ranked mb-4"
|
||||
@@ -318,12 +321,13 @@ function sectionNameFor(id: string): string {
|
||||
assistive tech, which Vuetify's :disabled would do. */
|
||||
.section-priority-unranked-card {
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.section-priority-unranked-card:hover,
|
||||
.section-priority-unranked-card:focus-visible {
|
||||
background-color: rgb(var(--v-theme-surface-variant));
|
||||
.section-priority-unranked-card:hover:not(.section-priority-unranked-disabled),
|
||||
.section-priority-unranked-card:focus-visible:not(.section-priority-unranked-disabled) {
|
||||
background-color: rgb(var(--v-theme-primary) / 0.04);
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
.section-priority-unranked-disabled {
|
||||
@@ -331,13 +335,29 @@ function sectionNameFor(id: string): string {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.section-priority-unranked-disabled:hover,
|
||||
.section-priority-unranked-disabled:focus-visible {
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.section-priority-rank {
|
||||
min-inline-size: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* vuedraggable drag states — SortableJS applies these classes. The
|
||||
defaults leave the drag-clone semi-transparent while the ghost
|
||||
stays solid at the origin, which produces text overlap mid-drag. */
|
||||
.section-priority-ghost {
|
||||
opacity: 0.3;
|
||||
background: rgb(var(--v-theme-surface-bright));
|
||||
}
|
||||
|
||||
.section-priority-drag {
|
||||
/* !important overrides SortableJS's inline opacity: 0.8 default so
|
||||
the drag-clone reads as solid + elevated. */
|
||||
opacity: 1 !important;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.15);
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.section-priority-chosen {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import IdentityMatchBanner from './IdentityMatchBanner.vue'
|
||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
||||
import { formatFieldValue } from '@/composables/formatFieldValue'
|
||||
import type { FormStep } from '@/composables/useFormSteps'
|
||||
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type { FormValues, PublicFormField, PublicFormSubmissionIdentityMatch } from '@/types/formBuilder'
|
||||
|
||||
@@ -12,13 +16,20 @@ const props = defineProps<{
|
||||
identityMatch?: PublicFormSubmissionIdentityMatch | null
|
||||
}>()
|
||||
|
||||
function displayValue(field: PublicFormField): string {
|
||||
const v = props.values[field.slug]
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (Array.isArray(v)) return v.length > 0 ? v.map(String).join(', ') : '—'
|
||||
if (typeof v === 'boolean') return v ? 'Ja' : 'Nee'
|
||||
// TanStack Query calls — these hit the same cache the field components
|
||||
// populated during the form render (5-minute staleTime), so there's no
|
||||
// extra network round-trip on the confirmation page.
|
||||
const token = usePublicFormToken()
|
||||
const timeSlotsQuery = usePublicFormTimeSlots(token)
|
||||
const sectionsQuery = usePublicFormSections(token)
|
||||
|
||||
return String(v)
|
||||
function displayValue(field: PublicFormField): string {
|
||||
return formatFieldValue(
|
||||
field,
|
||||
props.values[field.slug],
|
||||
timeSlotsQuery.data.value,
|
||||
sectionsQuery.data.value,
|
||||
)
|
||||
}
|
||||
|
||||
function isAnswerable(field: PublicFormField): boolean {
|
||||
|
||||
137
apps/portal/src/composables/formatFieldValue.ts
Normal file
137
apps/portal/src/composables/formatFieldValue.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type {
|
||||
PublicFormField,
|
||||
PublicFormSectionOption,
|
||||
PublicFormTimeSlot,
|
||||
SectionPriorityValue,
|
||||
} from '@/types/formBuilder'
|
||||
|
||||
const EMPTY = '—'
|
||||
const LOADING = 'Laden…'
|
||||
const UNKNOWN_TAG = '(onbekende tag)'
|
||||
const UNKNOWN_TIME_SLOT = '(onbekend tijdslot)'
|
||||
const UNKNOWN_SECTION = '(onbekende sectie)'
|
||||
|
||||
// Single source of truth for how a submitted value is rendered on the
|
||||
// review step and the post-submit confirmation page. Shared so the
|
||||
// stringified-id / [object Object] bugs fixed in S3a PR 2.2 can't
|
||||
// regress via a naive caller.
|
||||
//
|
||||
// `timeSlots` / `sections` are intentionally accepted as raw arrays (or
|
||||
// undefined when the underlying TanStack Query is still fetching).
|
||||
// Callers pass the cached `.data.value` from usePublicFormTimeSlots /
|
||||
// usePublicFormSections; this keeps the formatter side-effect-free and
|
||||
// trivial to unit-test.
|
||||
export function formatFieldValue(
|
||||
field: PublicFormField,
|
||||
value: unknown,
|
||||
timeSlots: readonly PublicFormTimeSlot[] | undefined,
|
||||
sections: readonly PublicFormSectionOption[] | undefined,
|
||||
): string {
|
||||
if (isEmptyValue(value)) return EMPTY
|
||||
|
||||
switch (field.field_type) {
|
||||
case FormFieldType.TAG_PICKER:
|
||||
return formatTagPicker(field, value)
|
||||
case FormFieldType.AVAILABILITY_PICKER:
|
||||
return formatAvailabilityPicker(value, timeSlots)
|
||||
case FormFieldType.SECTION_PRIORITY:
|
||||
return formatSectionPriority(value, sections)
|
||||
case FormFieldType.BOOLEAN:
|
||||
return value ? 'Ja' : 'Nee'
|
||||
default:
|
||||
return formatScalarOrList(value)
|
||||
}
|
||||
}
|
||||
|
||||
function isEmptyValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined || value === '') return true
|
||||
if (Array.isArray(value) && value.length === 0) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function formatTagPicker(field: PublicFormField, value: unknown): string {
|
||||
if (!Array.isArray(value)) return EMPTY
|
||||
|
||||
const byId = new Map<string, string>()
|
||||
for (const tag of field.available_tags ?? []) byId.set(tag.id, tag.name)
|
||||
|
||||
const parts = value
|
||||
.map(v => (typeof v === 'string' ? v : String(v)))
|
||||
.map(id => byId.get(id) ?? UNKNOWN_TAG)
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : EMPTY
|
||||
}
|
||||
|
||||
function formatAvailabilityPicker(
|
||||
value: unknown,
|
||||
timeSlots: readonly PublicFormTimeSlot[] | undefined,
|
||||
): string {
|
||||
if (!Array.isArray(value)) return EMPTY
|
||||
if (timeSlots === undefined) return LOADING
|
||||
|
||||
const byId = new Map<string, PublicFormTimeSlot>()
|
||||
for (const slot of timeSlots) byId.set(slot.id, slot)
|
||||
|
||||
const parts = value
|
||||
.map(v => (typeof v === 'string' ? v : String(v)))
|
||||
.map(id => {
|
||||
const slot = byId.get(id)
|
||||
if (!slot) return UNKNOWN_TIME_SLOT
|
||||
|
||||
return `${slot.name} (${stripSeconds(slot.start_time)}–${stripSeconds(slot.end_time)})`
|
||||
})
|
||||
|
||||
return parts.length > 0 ? parts.join(', ') : EMPTY
|
||||
}
|
||||
|
||||
function formatSectionPriority(
|
||||
value: unknown,
|
||||
sections: readonly PublicFormSectionOption[] | undefined,
|
||||
): string {
|
||||
// Defensive shape-guard: if the value isn't {section_id, priority}[],
|
||||
// fall back to EMPTY rather than leaking `[object Object]`.
|
||||
if (!Array.isArray(value)) return EMPTY
|
||||
|
||||
const entries: SectionPriorityValue[] = []
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') return EMPTY
|
||||
const obj = entry as Record<string, unknown>
|
||||
if (typeof obj.section_id !== 'string' || typeof obj.priority !== 'number') {
|
||||
return EMPTY
|
||||
}
|
||||
entries.push({ section_id: obj.section_id, priority: obj.priority })
|
||||
}
|
||||
if (entries.length === 0) return EMPTY
|
||||
if (sections === undefined) return LOADING
|
||||
|
||||
const byId = new Map<string, PublicFormSectionOption>()
|
||||
for (const section of sections) byId.set(section.id, section)
|
||||
|
||||
// Input may be out of order; the review/confirmation copy is "1. Foo,
|
||||
// 2. Bar" so sort by priority ascending before rendering.
|
||||
const sorted = [...entries].sort((a, b) => a.priority - b.priority)
|
||||
|
||||
return sorted
|
||||
.map(({ section_id, priority }) => {
|
||||
const name = byId.get(section_id)?.name ?? UNKNOWN_SECTION
|
||||
|
||||
return `${priority}. ${name}`
|
||||
})
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function formatScalarOrList(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value.map(v => String(v)).join(', ') : EMPTY
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function stripSeconds(t: string): string {
|
||||
const parts = t.split(':')
|
||||
|
||||
return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : t
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import FormErrorState from '@/components/public-form/FormErrorState.vue'
|
||||
import FormStepper from '@/components/public-form/FormStepper.vue'
|
||||
import SubmitterDetails from '@/components/public-form/SubmitterDetails.vue'
|
||||
import { extractErrorBody, useFetchPublicFormSchema } from '@/composables/api/usePublicForm'
|
||||
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
||||
import { usePublicFormTimeSlots } from '@/composables/api/usePublicFormTimeSlots'
|
||||
import { useFormDraft } from '@/composables/useFormDraft'
|
||||
import { isStepValid, useFormSteps } from '@/composables/useFormSteps'
|
||||
import { formatFieldValue } from '@/composables/formatFieldValue'
|
||||
import { providePublicFormToken } from '@/composables/publicFormInjection'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type { FormErrorCode, PublicFormField } from '@/types/formBuilder'
|
||||
@@ -37,6 +40,14 @@ providePublicFormToken(token)
|
||||
|
||||
const schemaQuery = useFetchPublicFormSchema(tokenRef)
|
||||
|
||||
// Sibling endpoints — fetched at page level so the review step and
|
||||
// FormConfirmation can human-label AVAILABILITY_PICKER /
|
||||
// SECTION_PRIORITY values via formatFieldValue. Shares the same
|
||||
// 5-minute TanStack Query cache used by the field components, so
|
||||
// this is a free hit when those fields are rendered on screen.
|
||||
const timeSlotsQuery = usePublicFormTimeSlots(token)
|
||||
const sectionsQuery = usePublicFormSections(token)
|
||||
|
||||
const draft = useFormDraft(tokenRef, {
|
||||
locale: 'nl',
|
||||
})
|
||||
@@ -212,12 +223,12 @@ function answerableForReview(field: PublicFormField): boolean {
|
||||
}
|
||||
|
||||
function formatReviewValue(field: PublicFormField): string {
|
||||
const v = draft.values.value[field.slug]
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (Array.isArray(v)) return v.length > 0 ? v.map(String).join(', ') : '—'
|
||||
if (typeof v === 'boolean') return v ? 'Ja' : 'Nee'
|
||||
|
||||
return String(v)
|
||||
return formatFieldValue(
|
||||
field,
|
||||
draft.values.value[field.slug],
|
||||
timeSlotsQuery.data.value,
|
||||
sectionsQuery.data.value,
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ vi.mock('vuetify', () => ({
|
||||
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>',
|
||||
props: ['modelValue', 'ghostClass', 'dragClass', 'chosenClass', 'itemKey', 'handle', 'animation'],
|
||||
template: '<div class="draggable-stub" :data-ghost-class="ghostClass" :data-drag-class="dragClass" :data-chosen-class="chosenClass"><template v-for="(el, i) in modelValue" :key="i"><slot name="item" :element="el" :index="i"/></template></div>',
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -234,4 +234,32 @@ describe('FieldSectionPriority', () => {
|
||||
|
||||
expect(w.text()).toContain('1 / 5')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
it('toggles the disabled class on unranked cards when max is reached', () => {
|
||||
state.data.value = [section({ id: 'a' }), section({ id: 'b' })]
|
||||
|
||||
// Not at cap — unranked cards must not carry the disabled marker.
|
||||
const notFull = mountPicker({
|
||||
field: field({ validation_rules: { max_priorities: 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.
|
||||
const full = mountPicker({
|
||||
field: field({ validation_rules: { max_priorities: 1 } }),
|
||||
modelValue: [{ section_id: 'a', priority: 1 }],
|
||||
})
|
||||
expect(full.html()).toContain('section-priority-unranked-disabled')
|
||||
})
|
||||
})
|
||||
|
||||
188
apps/portal/tests/unit/formatFieldValue.spec.ts
Normal file
188
apps/portal/tests/unit/formatFieldValue.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatFieldValue } from '@/composables/formatFieldValue'
|
||||
import { FormFieldType } from '@/types/formBuilder'
|
||||
import type {
|
||||
AvailableTag,
|
||||
PublicFormField,
|
||||
PublicFormSectionOption,
|
||||
PublicFormTimeSlot,
|
||||
} from '@/types/formBuilder'
|
||||
|
||||
function field(partial: Partial<PublicFormField> = {}): PublicFormField {
|
||||
return {
|
||||
id: partial.id ?? 'f',
|
||||
slug: partial.slug ?? 'slug',
|
||||
field_type: partial.field_type ?? FormFieldType.TEXT,
|
||||
label: partial.label ?? 'Label',
|
||||
help_text: partial.help_text ?? null,
|
||||
options: partial.options ?? null,
|
||||
available_tags: partial.available_tags ?? null,
|
||||
validation_rules: partial.validation_rules ?? null,
|
||||
is_required: partial.is_required ?? false,
|
||||
display_width: partial.display_width ?? 'full',
|
||||
conditional_logic: partial.conditional_logic ?? null,
|
||||
sort_order: partial.sort_order ?? 0,
|
||||
form_schema_section_id: partial.form_schema_section_id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function tag(partial: Partial<AvailableTag>): AvailableTag {
|
||||
return { id: partial.id ?? 't_1', name: partial.name ?? 'Tag', category: partial.category ?? '' }
|
||||
}
|
||||
|
||||
function timeSlot(partial: Partial<PublicFormTimeSlot>): PublicFormTimeSlot {
|
||||
return {
|
||||
id: partial.id ?? 'ts_1',
|
||||
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 ?? 'Event',
|
||||
}
|
||||
}
|
||||
|
||||
function section(partial: Partial<PublicFormSectionOption>): PublicFormSectionOption {
|
||||
return {
|
||||
id: partial.id ?? 's_1',
|
||||
name: partial.name ?? 'Section',
|
||||
category: partial.category ?? null,
|
||||
icon: partial.icon ?? null,
|
||||
registration_description: partial.registration_description ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatFieldValue', () => {
|
||||
describe('empty values', () => {
|
||||
it.each<[unknown]>([
|
||||
[null],
|
||||
[undefined],
|
||||
[''],
|
||||
[[]],
|
||||
])('returns "—" for empty value %s on any field type', v => {
|
||||
expect(formatFieldValue(field({ field_type: FormFieldType.TEXT }), v, [], [])).toBe('—')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TAG_PICKER', () => {
|
||||
it('maps IDs to tag names using field.available_tags', () => {
|
||||
const f = field({
|
||||
field_type: FormFieldType.TAG_PICKER,
|
||||
available_tags: [tag({ id: 'a', name: 'Tapper' }), tag({ id: 'b', name: 'Barista' })],
|
||||
})
|
||||
|
||||
expect(formatFieldValue(f, ['a', 'b'], undefined, undefined)).toBe('Tapper, Barista')
|
||||
})
|
||||
|
||||
it('labels unknown tag IDs as "(onbekende tag)"', () => {
|
||||
const f = field({
|
||||
field_type: FormFieldType.TAG_PICKER,
|
||||
available_tags: [tag({ id: 'a', name: 'Tapper' })],
|
||||
})
|
||||
|
||||
expect(formatFieldValue(f, ['a', 'zzz'], undefined, undefined)).toBe('Tapper, (onbekende tag)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AVAILABILITY_PICKER', () => {
|
||||
it('maps IDs to "name (start–end)" with seconds stripped', () => {
|
||||
const f = field({ field_type: FormFieldType.AVAILABILITY_PICKER })
|
||||
const slots = [timeSlot({ id: 'x', name: 'Vrijwilligers afbraak zondag', start_time: '10:00:00', end_time: '16:00:00' })]
|
||||
|
||||
expect(formatFieldValue(f, ['x'], slots, undefined))
|
||||
.toBe('Vrijwilligers afbraak zondag (10:00–16:00)')
|
||||
})
|
||||
|
||||
it('labels unknown time_slot IDs as "(onbekend tijdslot)"', () => {
|
||||
const f = field({ field_type: FormFieldType.AVAILABILITY_PICKER })
|
||||
const slots = [timeSlot({ id: 'x' })]
|
||||
|
||||
expect(formatFieldValue(f, ['x', 'y'], slots, undefined))
|
||||
.toBe('Vrijdag avond (18:00–23:00), (onbekend tijdslot)')
|
||||
})
|
||||
|
||||
it('returns "Laden…" while the time-slots query is still fetching', () => {
|
||||
const f = field({ field_type: FormFieldType.AVAILABILITY_PICKER })
|
||||
|
||||
expect(formatFieldValue(f, ['x'], undefined, undefined)).toBe('Laden…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SECTION_PRIORITY', () => {
|
||||
it('renders "N. Name, …" sorted by priority', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
const sections = [
|
||||
section({ id: 's_1', name: 'Hoofdpodium Bar' }),
|
||||
section({ id: 's_2', name: 'Theatertent Bar' }),
|
||||
]
|
||||
|
||||
expect(formatFieldValue(
|
||||
f,
|
||||
[{ section_id: 's_1', priority: 1 }, { section_id: 's_2', priority: 2 }],
|
||||
undefined,
|
||||
sections,
|
||||
)).toBe('1. Hoofdpodium Bar, 2. Theatertent Bar')
|
||||
})
|
||||
|
||||
it('re-sorts unordered input by priority before rendering', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
const sections = [section({ id: 's_1', name: 'A' }), section({ id: 's_2', name: 'B' })]
|
||||
|
||||
expect(formatFieldValue(
|
||||
f,
|
||||
[{ section_id: 's_2', priority: 2 }, { section_id: 's_1', priority: 1 }],
|
||||
undefined,
|
||||
sections,
|
||||
)).toBe('1. A, 2. B')
|
||||
})
|
||||
|
||||
it('labels unknown section IDs as "(onbekende sectie)"', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
const sections = [section({ id: 's_1', name: 'Known' })]
|
||||
|
||||
expect(formatFieldValue(
|
||||
f,
|
||||
[{ section_id: 's_1', priority: 1 }, { section_id: 'missing', priority: 2 }],
|
||||
undefined,
|
||||
sections,
|
||||
)).toBe('1. Known, 2. (onbekende sectie)')
|
||||
})
|
||||
|
||||
it('returns "—" when the value shape is malformed (defensive guard)', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
|
||||
// Flat string[] — wrong shape, must not leak [object Object].
|
||||
expect(formatFieldValue(f, ['s_1', 's_2'], undefined, [section({ id: 's_1', name: 'Known' })])).toBe('—')
|
||||
})
|
||||
|
||||
it('returns "Laden…" while the sections query is still fetching', () => {
|
||||
const f = field({ field_type: FormFieldType.SECTION_PRIORITY })
|
||||
|
||||
expect(formatFieldValue(
|
||||
f,
|
||||
[{ section_id: 's_1', priority: 1 }],
|
||||
undefined,
|
||||
undefined,
|
||||
)).toBe('Laden…')
|
||||
})
|
||||
})
|
||||
|
||||
describe('scalars', () => {
|
||||
it('formats BOOLEAN true as "Ja", false as "Nee"', () => {
|
||||
const f = field({ field_type: FormFieldType.BOOLEAN })
|
||||
expect(formatFieldValue(f, true, undefined, undefined)).toBe('Ja')
|
||||
expect(formatFieldValue(f, false, undefined, undefined)).toBe('Nee')
|
||||
})
|
||||
|
||||
it('stringifies unknown-type array values via join', () => {
|
||||
const f = field({ field_type: FormFieldType.MULTISELECT })
|
||||
expect(formatFieldValue(f, ['A', 'B'], undefined, undefined)).toBe('A, B')
|
||||
})
|
||||
|
||||
it('stringifies scalars for text-like types', () => {
|
||||
const f = field({ field_type: FormFieldType.TEXT })
|
||||
expect(formatFieldValue(f, 'hello', undefined, undefined)).toBe('hello')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user