diff --git a/apps/portal/auto-imports.d.ts b/apps/portal/auto-imports.d.ts index 9202cf73..300451ce 100644 --- a/apps/portal/auto-imports.d.ts +++ b/apps/portal/auto-imports.d.ts @@ -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 readonly formatDate: UnwrapRef readonly formatDateToMonthShort: UnwrapRef + readonly formatFieldValue: UnwrapRef readonly generateDeviceFingerprint: UnwrapRef readonly getActivePinia: UnwrapRef readonly getCurrentInstance: UnwrapRef diff --git a/apps/portal/src/components/public-form/FieldSectionPriority.vue b/apps/portal/src/components/public-form/FieldSectionPriority.vue index 423e5c13..3c7cd501 100644 --- a/apps/portal/src/components/public-form/FieldSectionPriority.vue +++ b/apps/portal/src/components/public-form/FieldSectionPriority.vue @@ -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; +} diff --git a/apps/portal/src/components/public-form/FormConfirmation.vue b/apps/portal/src/components/public-form/FormConfirmation.vue index 8d84c9df..37ca815f 100644 --- a/apps/portal/src/components/public-form/FormConfirmation.vue +++ b/apps/portal/src/components/public-form/FormConfirmation.vue @@ -1,6 +1,10 @@ diff --git a/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts b/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts index d86bea85..e35380e9 100644 --- a/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts +++ b/apps/portal/tests/components/public-form/FieldSectionPriority.spec.ts @@ -30,8 +30,8 @@ vi.mock('vuetify', () => ({ vi.mock('vuedraggable', () => ({ default: { name: 'draggable', - props: ['modelValue'], - template: '
', + props: ['modelValue', 'ghostClass', 'dragClass', 'chosenClass', 'itemKey', 'handle', 'animation'], + template: '
', }, })) @@ -234,4 +234,32 @@ describe('FieldSectionPriority', () => { expect(w.text()).toContain('1 / 5') }) + + it('wires ghost-class / drag-class / chosen-class through to ', () => { + 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') + }) }) diff --git a/apps/portal/tests/unit/formatFieldValue.spec.ts b/apps/portal/tests/unit/formatFieldValue.spec.ts new file mode 100644 index 00000000..5baff42d --- /dev/null +++ b/apps/portal/tests/unit/formatFieldValue.spec.ts @@ -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 { + 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 { + return { id: partial.id ?? 't_1', name: partial.name ?? 'Tag', category: partial.category ?? '' } +} + +function timeSlot(partial: Partial): 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 { + 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') + }) + }) +})