feat: HEADING field type for registration forms — replace section property with structural field

Replace the per-field `section` text property with a dedicated HEADING field type that
organizers add as a separate block for visual grouping. Also fixes duplicate heading bug
on portal radio fields, replaces cramped VBtnToggle with VSelect for field width, and
adds grouped field type dropdown with structure/input categories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 16:40:41 +02:00
parent 9718e27029
commit d57dcdb616
27 changed files with 667 additions and 480 deletions

View File

@@ -4,7 +4,7 @@ import { usePersonTagCategories } from '@/composables/api/usePersonTags'
import { requiredValidator } from '@core/utils/validators'
import type { RegistrationFormField, RegistrationFormFieldCreateDTO } from '@/types/registration-form-field'
import type { RegistrationFieldType, FieldDisplayWidth } from '@/types/registration-field-template'
import { FIELD_TYPE_LABELS, FIELD_TYPES_WITH_OPTIONS } from '@/types/registration-field-template'
import { FIELD_TYPES_WITH_OPTIONS } from '@/types/registration-field-template'
import type { AxiosError } from 'axios'
import type { ApiErrorResponse } from '@/types/auth'
@@ -24,7 +24,21 @@ const modelValue = defineModel<boolean>({ default: false })
const orgIdRef = computed(() => props.orgId)
const { data: tagCategories } = usePersonTagCategories(orgIdRef)
const fieldTypeOptions = Object.entries(FIELD_TYPE_LABELS).map(([value, title]) => ({ title, value }))
const fieldTypeItems = [
{ type: 'subheader', title: 'Structuur' },
{ title: 'Kop / Sectie-titel', value: 'heading', props: { prependIcon: 'tabler-heading' } },
{ type: 'divider' },
{ type: 'subheader', title: 'Invoervelden' },
{ title: 'Tekstveld', value: 'text', props: { prependIcon: 'tabler-letter-t' } },
{ title: 'Tekstvak (groot)', value: 'textarea', props: { prependIcon: 'tabler-text-size' } },
{ title: 'Getal', value: 'number', props: { prependIcon: 'tabler-hash' } },
{ title: 'Toggle (ja/nee)', value: 'boolean', props: { prependIcon: 'tabler-toggle-left' } },
{ title: 'Dropdown', value: 'select', props: { prependIcon: 'tabler-list' } },
{ title: 'Meervoudige keuze', value: 'multiselect', props: { prependIcon: 'tabler-checklist' } },
{ title: 'Keuzerondjes', value: 'radio', props: { prependIcon: 'tabler-circle-dot' } },
{ title: 'Checkbox', value: 'checkbox', props: { prependIcon: 'tabler-checkbox' } },
{ title: 'Tags & Vaardigheden', value: 'tag_picker', props: { prependIcon: 'tabler-tags' } },
]
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
@@ -43,7 +57,6 @@ const defaultForm = () => ({
is_filterable: false,
is_portal_visible: true,
is_admin_only: false,
section: '',
help_text: '',
display_width: 'full' as FieldDisplayWidth,
})
@@ -54,6 +67,8 @@ const dialogTitle = computed(() =>
props.field ? 'Veld bewerken' : 'Nieuw veld',
)
const isHeading = computed(() => form.value.field_type === 'heading')
const showOptions = computed(() =>
(FIELD_TYPES_WITH_OPTIONS as readonly string[]).includes(form.value.field_type),
)
@@ -75,7 +90,6 @@ watch(modelValue, (open) => {
is_filterable: props.field.is_filterable,
is_portal_visible: props.field.is_portal_visible,
is_admin_only: props.field.is_admin_only,
section: props.field.section ?? '',
help_text: props.field.help_text ?? '',
display_width: props.field.display_width ?? 'full',
}
@@ -101,11 +115,6 @@ function onSubmit() {
errors.value = {}
const payload: Partial<RegistrationFormFieldCreateDTO> = {
label: form.value.label,
is_required: form.value.is_required,
is_filterable: form.value.is_filterable,
is_portal_visible: form.value.is_portal_visible,
is_admin_only: form.value.is_admin_only,
section: form.value.section || null,
help_text: form.value.help_text || null,
display_width: form.value.display_width,
}
@@ -114,23 +123,30 @@ function onSubmit() {
payload.field_type = form.value.field_type as RegistrationFieldType
}
if (showOptions.value) {
payload.options = form.value.options
.filter(o => o.label.trim() !== '')
.map(o => o.description.trim()
? { label: o.label, description: o.description }
: { label: o.label },
)
}
else {
payload.options = null
}
if (!isHeading.value) {
payload.is_required = form.value.is_required
payload.is_filterable = form.value.is_filterable
payload.is_portal_visible = form.value.is_portal_visible
payload.is_admin_only = form.value.is_admin_only
if (showTagCategory.value) {
payload.tag_category = form.value.tag_category || null
}
else {
payload.tag_category = null
if (showOptions.value) {
payload.options = form.value.options
.filter(o => o.label.trim() !== '')
.map(o => o.description.trim()
? { label: o.label, description: o.description }
: { label: o.label },
)
}
else {
payload.options = null
}
if (showTagCategory.value) {
payload.tag_category = form.value.tag_category || null
}
else {
payload.tag_category = null
}
}
emit('save', payload)
@@ -164,22 +180,11 @@ defineExpose({ setErrors })
>
<VCardText>
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.label"
label="Label"
placeholder="Hoe heet dit veld?"
:rules="[requiredValidator]"
:error-messages="errors.label"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.field_type"
label="Type"
:items="fieldTypeOptions"
:items="fieldTypeItems"
:rules="[requiredValidator]"
:error-messages="errors.field_type"
:disabled="!!field"
@@ -188,170 +193,185 @@ defineExpose({ setErrors })
/>
</VCol>
<!-- Options (conditional) -->
<VCol
v-if="showOptions"
cols="12"
>
<label class="text-body-2 mb-2 d-block">Opties</label>
<div
v-for="(option, index) in form.options"
:key="index"
class="mb-3"
>
<VCard
variant="outlined"
class="pa-3 position-relative"
<!-- HEADING fields: simplified UI -->
<template v-if="isHeading">
<VCol cols="12">
<AppTextField
v-model="form.label"
label="Kop tekst"
placeholder="bijv. Persoonlijke gegevens"
:rules="[requiredValidator]"
:error-messages="errors.label"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.help_text"
label="Omschrijving (optioneel)"
placeholder="Korte toelichting onder de kop"
:error-messages="errors.help_text"
hint="Verschijnt in kleinere tekst onder de kop"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VAlert
type="info"
variant="tonal"
density="compact"
icon="tabler-info-circle"
>
<VRow dense>
<VCol cols="12">
<AppTextField
v-model="option.label"
:placeholder="`Optie ${index + 1}`"
density="compact"
hide-details
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="option.description"
label="Beschrijving (optioneel)"
density="compact"
placeholder="Korte toelichting die onder de optie verschijnt"
hint="Max 200 tekens"
counter="200"
maxlength="200"
/>
</VCol>
</VRow>
<VBtn
icon="tabler-x"
variant="text"
color="error"
size="small"
class="position-absolute"
style="inset-block-start: 4px; inset-inline-end: 4px"
@click="removeOption(index)"
/>
</VCard>
</div>
<VBtn
variant="tonal"
size="small"
prepend-icon="tabler-plus"
@click="addOption"
>
Optie toevoegen
</VBtn>
<p
v-if="errors.options"
class="text-error text-caption mt-1"
>
{{ errors.options }}
</p>
</VCol>
Een kop groepeert de velden hieronder visueel. Het verzamelt geen antwoorden.
</VAlert>
</VCol>
</template>
<!-- Tag category (conditional) -->
<VCol
v-if="showTagCategory"
cols="12"
>
<AppAutocomplete
v-model="form.tag_category"
label="Tag-categorie"
:items="tagCategories ?? []"
clearable
:error-messages="errors.tag_category"
placeholder="Alle tags (laat leeg voor alle categorieën)"
/>
</VCol>
<!-- Regular input fields: full UI -->
<template v-else>
<VCol cols="12">
<AppTextField
v-model="form.label"
label="Label"
placeholder="Hoe heet dit veld?"
:rules="[requiredValidator]"
:error-messages="errors.label"
autofocus
autocomplete="one-time-code"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.section"
label="Sectie"
placeholder="bijv. Over jou"
:error-messages="errors.section"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<label class="text-body-2 text-medium-emphasis d-block mb-1">
Veldbreedte
</label>
<VBtnToggle
v-model="form.display_width"
mandatory
density="compact"
<!-- Options (conditional) -->
<VCol
v-if="showOptions"
cols="12"
>
<label class="text-body-2 mb-2 d-block">Opties</label>
<div
v-for="(option, index) in form.options"
:key="index"
class="mb-3"
>
<VCard
variant="outlined"
class="pa-3 position-relative"
>
<VRow dense>
<VCol cols="12">
<AppTextField
v-model="option.label"
:placeholder="`Optie ${index + 1}`"
density="compact"
hide-details
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="option.description"
label="Beschrijving (optioneel)"
density="compact"
placeholder="Korte toelichting die onder de optie verschijnt"
hint="Max 200 tekens"
counter="200"
maxlength="200"
/>
</VCol>
</VRow>
<VBtn
icon="tabler-x"
variant="text"
color="error"
size="small"
class="position-absolute"
style="inset-block-start: 4px; inset-inline-end: 4px"
@click="removeOption(index)"
/>
</VCard>
</div>
<VBtn
value="full"
variant="tonal"
size="small"
prepend-icon="tabler-plus"
@click="addOption"
>
<VIcon
icon="tabler-layout-distribute-horizontal"
size="16"
class="me-1"
/>
Volledig
Optie toevoegen
</VBtn>
<VBtn
value="half"
size="small"
<p
v-if="errors.options"
class="text-error text-caption mt-1"
>
<VIcon
icon="tabler-layout-columns"
size="16"
class="me-1"
/>
Half
</VBtn>
</VBtnToggle>
</VCol>
<VCol cols="12">
<AppTextarea
v-model="form.help_text"
label="Helptekst"
placeholder="Toelichting die onder het veld wordt getoond"
:error-messages="errors.help_text"
rows="2"
/>
</VCol>
{{ errors.options }}
</p>
</VCol>
<!-- Toggles -->
<VCol cols="12">
<div class="d-flex flex-wrap gap-x-6 gap-y-2">
<VSwitch
v-model="form.is_required"
label="Verplicht"
hide-details
density="compact"
<!-- Tag category (conditional) -->
<VCol
v-if="showTagCategory"
cols="12"
>
<AppAutocomplete
v-model="form.tag_category"
label="Tag-categorie"
:items="tagCategories ?? []"
clearable
:error-messages="errors.tag_category"
placeholder="Alle tags (laat leeg voor alle categorieën)"
/>
<VSwitch
v-model="form.is_filterable"
label="Filterbaar bij inplannen"
hide-details
density="compact"
</VCol>
<VCol cols="12">
<AppSelect
v-model="form.display_width"
:items="[
{ title: 'Volledige breedte', value: 'full' },
{ title: 'Halve breedte', value: 'half' },
]"
label="Veldbreedte"
:hint="form.display_width === 'half' ? 'Wordt naast een ander half-breed veld geplaatst' : ''"
persistent-hint
/>
<VSwitch
v-model="form.is_portal_visible"
label="Zichtbaar voor deelnemer"
hide-details
density="compact"
</VCol>
<VCol cols="12">
<AppTextarea
v-model="form.help_text"
label="Helptekst"
placeholder="Toelichting die onder het veld wordt getoond"
:error-messages="errors.help_text"
rows="2"
/>
<VSwitch
v-model="form.is_admin_only"
label="Alleen voor organisator"
hide-details
density="compact"
/>
</div>
</VCol>
</VCol>
<!-- Toggles -->
<VCol cols="12">
<div class="d-flex flex-wrap gap-x-6 gap-y-2">
<VSwitch
v-model="form.is_required"
label="Verplicht"
hide-details
density="compact"
/>
<VSwitch
v-model="form.is_filterable"
label="Filterbaar bij inplannen"
hide-details
density="compact"
/>
<VSwitch
v-model="form.is_portal_visible"
label="Zichtbaar voor deelnemer"
hide-details
density="compact"
/>
<VSwitch
v-model="form.is_admin_only"
label="Alleen voor organisator"
hide-details
density="compact"
/>
</div>
</VCol>
</template>
</VRow>
</VCardText>
<VCardActions>