feat: registration form field display_width and option descriptions

Add configurable column widths (full/half) and optional descriptions
for radio/select/checkbox options on registration form fields.

- Migration adds display_width column to both tables
- FieldDisplayWidth enum with smart defaults per field type
- normalized_options accessor for backwards-compatible option format
- Portal form renderer uses display_width for VRow/VCol grid layout
- Radio/select/checkbox options render with descriptions
- Admin field editor supports display_width toggle and description input
- System templates updated with appropriate widths and descriptions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 07:46:36 +02:00
parent c4a23b6763
commit 9718e27029
25 changed files with 634 additions and 84 deletions

View File

@@ -3,7 +3,7 @@ import { VForm } from 'vuetify/components/VForm'
import { usePersonTagCategories } from '@/composables/api/usePersonTags'
import { requiredValidator } from '@core/utils/validators'
import type { RegistrationFormField, RegistrationFormFieldCreateDTO } from '@/types/registration-form-field'
import type { RegistrationFieldType } from '@/types/registration-field-template'
import type { RegistrationFieldType, FieldDisplayWidth } from '@/types/registration-field-template'
import { FIELD_TYPE_LABELS, FIELD_TYPES_WITH_OPTIONS } from '@/types/registration-field-template'
import type { AxiosError } from 'axios'
import type { ApiErrorResponse } from '@/types/auth'
@@ -29,10 +29,15 @@ const fieldTypeOptions = Object.entries(FIELD_TYPE_LABELS).map(([value, title])
const errors = ref<Record<string, string>>({})
const refVForm = ref<VForm>()
interface OptionEntry {
label: string
description: string
}
const defaultForm = () => ({
label: '',
field_type: 'text' as string,
options: [] as string[],
options: [] as OptionEntry[],
tag_category: null as string | null,
is_required: false,
is_filterable: false,
@@ -40,6 +45,7 @@ const defaultForm = () => ({
is_admin_only: false,
section: '',
help_text: '',
display_width: 'full' as FieldDisplayWidth,
})
const form = ref(defaultForm())
@@ -61,7 +67,9 @@ watch(modelValue, (open) => {
form.value = {
label: props.field.label,
field_type: props.field.field_type,
options: props.field.options ? [...props.field.options] : [],
options: props.field.normalized_options
? props.field.normalized_options.map(o => ({ label: o.label, description: o.description ?? '' }))
: [],
tag_category: props.field.tag_category,
is_required: props.field.is_required,
is_filterable: props.field.is_filterable,
@@ -69,6 +77,7 @@ watch(modelValue, (open) => {
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',
}
}
else {
@@ -78,7 +87,7 @@ watch(modelValue, (open) => {
})
function addOption() {
form.value.options.push('')
form.value.options.push({ label: '', description: '' })
}
function removeOption(index: number) {
@@ -98,6 +107,7 @@ function onSubmit() {
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,
}
if (!props.field) {
@@ -105,7 +115,12 @@ function onSubmit() {
}
if (showOptions.value) {
payload.options = form.value.options.filter(o => o.trim() !== '')
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
@@ -180,23 +195,45 @@ defineExpose({ setErrors })
>
<label class="text-body-2 mb-2 d-block">Opties</label>
<div
v-for="(_, index) in form.options"
v-for="(option, index) in form.options"
:key="index"
class="d-flex align-center gap-x-2 mb-2"
class="mb-3"
>
<AppTextField
v-model="form.options[index]"
:placeholder="`Optie ${index + 1}`"
density="compact"
hide-details
/>
<VBtn
icon="tabler-x"
variant="text"
size="small"
color="error"
@click="removeOption(index)"
/>
<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
variant="tonal"
@@ -243,7 +280,39 @@ defineExpose({ setErrors })
<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"
>
<VBtn
value="full"
size="small"
>
<VIcon
icon="tabler-layout-distribute-horizontal"
size="16"
class="me-1"
/>
Volledig
</VBtn>
<VBtn
value="half"
size="small"
>
<VIcon
icon="tabler-layout-columns"
size="16"
class="me-1"
/>
Half
</VBtn>
</VBtnToggle>
</VCol>
<VCol cols="12">
<AppTextarea
v-model="form.help_text"