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

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