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