366 lines
11 KiB
Vue
366 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import draggable from 'vuedraggable'
|
|
import { useDisplay } from 'vuetify'
|
|
import { usePublicFormSections } from '@/composables/api/usePublicFormSections'
|
|
import { usePublicFormToken } from '@/composables/publicFormInjection'
|
|
import type { PublicFormField, PublicFormSectionOption, SectionPriorityValue } from '@form-schema/types/formBuilder'
|
|
import { getValidatorsForField, runValidators } from '@form-schema/utils/formValidation'
|
|
|
|
const props = defineProps<{
|
|
field: PublicFormField
|
|
modelValue: unknown
|
|
errorMessages?: string[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', v: SectionPriorityValue[]): void
|
|
(e: 'blur'): void
|
|
}>()
|
|
|
|
const token = usePublicFormToken()
|
|
const { data: sections, isLoading, isError, refetch } = usePublicFormSections(token)
|
|
const { mobile } = useDisplay()
|
|
|
|
const HARD_CAP = 5
|
|
const warnedOnMalformedValue = { value: false }
|
|
|
|
function selfHealIncoming(value: unknown): SectionPriorityValue[] {
|
|
if (!Array.isArray(value)) return []
|
|
|
|
const cleaned: SectionPriorityValue[] = []
|
|
for (const entry of value) {
|
|
if (entry === null || typeof entry !== 'object') continue
|
|
const obj = entry as Record<string, unknown>
|
|
const id = obj.section_id
|
|
const prio = obj.priority
|
|
if (typeof id !== 'string' || typeof prio !== 'number') continue
|
|
cleaned.push({ section_id: id, priority: prio })
|
|
}
|
|
|
|
// If the inbound array had data but none of it matched the shape,
|
|
// warn once so future misuses are spotted in dev.
|
|
if (import.meta.env.DEV && value.length > 0 && cleaned.length === 0 && !warnedOnMalformedValue.value) {
|
|
console.warn('[FieldSectionPriority] modelValue not in {section_id, priority}[] shape — self-healing to [].')
|
|
warnedOnMalformedValue.value = true
|
|
}
|
|
|
|
return cleaned
|
|
}
|
|
|
|
const ranked = ref<SectionPriorityValue[]>(selfHealIncoming(props.modelValue))
|
|
|
|
watch(() => props.modelValue, v => {
|
|
ranked.value = selfHealIncoming(v)
|
|
}, { deep: true })
|
|
|
|
const maxPriorities = computed(() => {
|
|
// WS-5b canonicalised the legacy `max_priorities` key to `max_selected`;
|
|
// the field's cap (if any) is surfaced under `validation_rules.max_selected`.
|
|
const raw = props.field.validation_rules?.max_selected
|
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
|
|
return Math.min(raw, HARD_CAP)
|
|
}
|
|
|
|
return HARD_CAP
|
|
})
|
|
|
|
const sectionsById = computed<Record<string, PublicFormSectionOption>>(() => {
|
|
const list = sections.value ?? []
|
|
const map: Record<string, PublicFormSectionOption> = {}
|
|
for (const s of list) map[s.id] = s
|
|
|
|
return map
|
|
})
|
|
|
|
const unrankedPool = computed<PublicFormSectionOption[]>(() => {
|
|
const list = sections.value ?? []
|
|
const rankedIds = new Set(ranked.value.map(r => r.section_id))
|
|
|
|
return list.filter(s => !rankedIds.has(s.id))
|
|
})
|
|
|
|
const isEmpty = computed(() => !sections.value || sections.value.length === 0)
|
|
const rankedFull = computed(() => ranked.value.length >= maxPriorities.value)
|
|
|
|
const rules = computed(() => getValidatorsForField(props.field))
|
|
const clientError = computed(() => {
|
|
const res = runValidators(rules.value, ranked.value)
|
|
|
|
return res === true ? null : res
|
|
})
|
|
const displayedErrors = computed(() => {
|
|
if (props.errorMessages && props.errorMessages.length > 0) return props.errorMessages
|
|
if (clientError.value) return [clientError.value]
|
|
|
|
return []
|
|
})
|
|
|
|
function reassignPriorities(list: SectionPriorityValue[]): SectionPriorityValue[] {
|
|
return list.map((item, index) => ({ section_id: item.section_id, priority: index + 1 }))
|
|
}
|
|
|
|
function emitNow(): void {
|
|
emit('update:modelValue', ranked.value)
|
|
emit('blur')
|
|
}
|
|
|
|
function rankSection(section: PublicFormSectionOption): void {
|
|
if (rankedFull.value) return
|
|
ranked.value = reassignPriorities([
|
|
...ranked.value,
|
|
{ section_id: section.id, priority: ranked.value.length + 1 },
|
|
])
|
|
emitNow()
|
|
}
|
|
|
|
function unrankAt(index: number): void {
|
|
const next = [...ranked.value]
|
|
next.splice(index, 1)
|
|
ranked.value = reassignPriorities(next)
|
|
emitNow()
|
|
}
|
|
|
|
function onDragEnd(): void {
|
|
// vuedraggable already mutated ranked.value via v-model — we just
|
|
// renumber priorities and emit.
|
|
ranked.value = reassignPriorities(ranked.value)
|
|
emitNow()
|
|
}
|
|
|
|
function sectionNameFor(id: string): string {
|
|
return sectionsById.value[id]?.name ?? ''
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div class="text-body-2 mb-1 text-high-emphasis">
|
|
{{ field.label }}<span
|
|
v-if="field.is_required"
|
|
class="text-error"
|
|
> *</span>
|
|
</div>
|
|
<p
|
|
v-if="field.help_text"
|
|
class="text-caption text-medium-emphasis mb-2"
|
|
>
|
|
{{ field.help_text }}
|
|
</p>
|
|
|
|
<VSkeletonLoader
|
|
v-if="isLoading"
|
|
type="article"
|
|
/>
|
|
|
|
<VAlert
|
|
v-else-if="isError"
|
|
type="error"
|
|
variant="tonal"
|
|
density="comfortable"
|
|
>
|
|
<div class="d-flex flex-wrap align-center justify-space-between ga-3">
|
|
<span>Kon secties niet laden.</span>
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
@click="refetch()"
|
|
>
|
|
Opnieuw proberen
|
|
</VBtn>
|
|
</div>
|
|
</VAlert>
|
|
|
|
<VAlert
|
|
v-else-if="isEmpty"
|
|
type="info"
|
|
variant="tonal"
|
|
density="comfortable"
|
|
>
|
|
Er zijn nog geen secties gepubliceerd voor registratie.
|
|
</VAlert>
|
|
|
|
<div v-else>
|
|
<!-- Ranked list -->
|
|
<div class="mb-2 text-caption text-medium-emphasis">
|
|
Jouw voorkeuren ({{ ranked.length }} / {{ maxPriorities }})
|
|
</div>
|
|
|
|
<draggable
|
|
v-model="ranked"
|
|
item-key="section_id"
|
|
handle=".section-priority-handle"
|
|
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"
|
|
@end="onDragEnd"
|
|
>
|
|
<template #item="{ element, index }">
|
|
<VCard
|
|
variant="outlined"
|
|
class="section-priority-ranked-item d-flex align-center pa-3 mb-2"
|
|
:aria-label="`Voorkeur ${index + 1}: ${sectionNameFor(element.section_id)}`"
|
|
>
|
|
<div class="section-priority-rank me-3 text-primary font-weight-bold text-h6">
|
|
{{ index + 1 }}
|
|
</div>
|
|
<VIcon
|
|
v-if="sectionsById[element.section_id]?.icon"
|
|
:icon="sectionsById[element.section_id]!.icon!"
|
|
size="18"
|
|
class="me-2 text-medium-emphasis"
|
|
/>
|
|
<div class="flex-grow-1">
|
|
<div class="text-body-1">
|
|
{{ sectionNameFor(element.section_id) }}
|
|
</div>
|
|
<div
|
|
v-if="sectionsById[element.section_id]?.registration_description"
|
|
class="text-caption text-medium-emphasis"
|
|
>
|
|
{{ sectionsById[element.section_id]!.registration_description }}
|
|
</div>
|
|
</div>
|
|
<VIcon
|
|
v-if="!mobile"
|
|
class="section-priority-handle me-2 text-disabled"
|
|
style="cursor: grab;"
|
|
icon="tabler-grip-vertical"
|
|
size="18"
|
|
/>
|
|
<VBtn
|
|
icon="tabler-x"
|
|
size="small"
|
|
variant="text"
|
|
:aria-label="`Verwijder ${sectionNameFor(element.section_id)} uit je voorkeuren`"
|
|
@click="unrankAt(index)"
|
|
/>
|
|
</VCard>
|
|
</template>
|
|
</draggable>
|
|
|
|
<div
|
|
v-if="ranked.length === 0"
|
|
class="text-caption text-medium-emphasis mb-4"
|
|
>
|
|
Tik of sleep een sectie hieronder om je eerste voorkeur te kiezen.
|
|
</div>
|
|
|
|
<!-- Unranked pool -->
|
|
<div class="mb-2 text-caption text-medium-emphasis">
|
|
Nog te kiezen
|
|
</div>
|
|
<VRow dense>
|
|
<VCol
|
|
v-for="section in unrankedPool"
|
|
:key="section.id"
|
|
cols="12"
|
|
sm="6"
|
|
>
|
|
<VCard
|
|
variant="outlined"
|
|
class="section-priority-unranked-card pa-3"
|
|
:class="{ 'section-priority-unranked-disabled': rankedFull }"
|
|
role="button"
|
|
tabindex="0"
|
|
:aria-label="`Voeg ${section.name} toe aan je voorkeuren`"
|
|
:aria-disabled="rankedFull"
|
|
@click="rankSection(section)"
|
|
@keydown.enter.prevent="rankSection(section)"
|
|
@keydown.space.prevent="rankSection(section)"
|
|
>
|
|
<div class="d-flex align-center">
|
|
<VIcon
|
|
v-if="section.icon"
|
|
:icon="section.icon"
|
|
size="18"
|
|
class="me-2 text-medium-emphasis"
|
|
/>
|
|
<div class="flex-grow-1">
|
|
<div class="text-body-1">
|
|
{{ section.name }}
|
|
</div>
|
|
<div
|
|
v-if="section.registration_description"
|
|
class="text-caption text-medium-emphasis"
|
|
>
|
|
{{ section.registration_description }}
|
|
</div>
|
|
</div>
|
|
<VIcon
|
|
v-if="!rankedFull"
|
|
icon="tabler-plus"
|
|
size="18"
|
|
class="text-primary"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="rankedFull"
|
|
class="text-caption text-medium-emphasis mt-1"
|
|
>
|
|
Maximaal {{ maxPriorities }} voorkeuren
|
|
</div>
|
|
</VCard>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<div
|
|
v-if="displayedErrors.length"
|
|
class="text-caption text-error mt-2"
|
|
>
|
|
{{ displayedErrors[0] }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Vuetify's VCard disabled state isn't contextual enough for an
|
|
"over-cap" affordance — we need it to look dimmed without blocking
|
|
assistive tech, which Vuetify's :disabled would do. */
|
|
.section-priority-unranked-card {
|
|
cursor: pointer;
|
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
|
}
|
|
|
|
.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 {
|
|
cursor: not-allowed;
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.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>
|