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:
2026-04-23 22:12:47 +02:00
parent 6f032a0311
commit e95f9a75f6
7 changed files with 421 additions and 24 deletions

View File

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

View 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 (startend)" 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:0016: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:0023: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')
})
})
})