Files
crewli/apps/portal/src/components/public-form/FieldSectionPriority.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>