From 9718e2702958b39b8037aee9439c4aec585c6b74 Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Thu, 16 Apr 2026 07:46:36 +0200 Subject: [PATCH] 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) --- api/app/Enums/FieldDisplayWidth.php | 38 +++++ .../V1/PublicRegistrationDataController.php | 2 + .../StoreRegistrationFieldTemplateRequest.php | 6 +- .../V1/StoreRegistrationFormFieldRequest.php | 6 +- ...UpdateRegistrationFieldTemplateRequest.php | 7 +- .../V1/UpdateRegistrationFormFieldRequest.php | 7 +- .../V1/RegistrationFieldTemplateResource.php | 2 + .../Api/V1/RegistrationFormFieldResource.php | 2 + api/app/Models/RegistrationFieldTemplate.php | 22 +++ api/app/Models/RegistrationFormField.php | 22 +++ .../RegistrationFieldTemplateService.php | 35 +++-- .../Services/RegistrationFormFieldService.php | 7 + .../RegistrationFieldTemplateFactory.php | 5 + .../RegistrationFormFieldFactory.php | 15 +- ...ay_width_to_registration_fields_tables.php | 32 +++++ .../RegistrationFormFieldTest.php | 132 ++++++++++++++++++ .../event/RegistrationFieldCard.vue | 20 ++- .../event/RegistrationFieldFormDialog.vue | 111 ++++++++++++--- .../RegistrationFieldTemplatesTab.vue | 113 ++++++++++++--- .../src/types/registration-field-template.ts | 16 ++- apps/app/src/types/registration-form-field.ts | 16 ++- .../portal/src/pages/register/[eventSlug].vue | 80 ++++++++--- apps/portal/src/types/registration.ts | 11 +- dev-docs/API.md | 8 ++ dev-docs/SCHEMA.md | 3 +- 25 files changed, 634 insertions(+), 84 deletions(-) create mode 100644 api/app/Enums/FieldDisplayWidth.php create mode 100644 api/database/migrations/2026_04_17_200000_add_display_width_to_registration_fields_tables.php diff --git a/api/app/Enums/FieldDisplayWidth.php b/api/app/Enums/FieldDisplayWidth.php new file mode 100644 index 00000000..4fcfabde --- /dev/null +++ b/api/app/Enums/FieldDisplayWidth.php @@ -0,0 +1,38 @@ + 'Volledige breedte', + self::HALF => 'Halve breedte', + }; + } + + /** + * Return the default display width for a given field type. + */ + public static function defaultForFieldType(RegistrationFieldType $fieldType): self + { + return match ($fieldType) { + RegistrationFieldType::TEXT, + RegistrationFieldType::NUMBER, + RegistrationFieldType::SELECT, + RegistrationFieldType::BOOLEAN => self::HALF, + + RegistrationFieldType::TEXTAREA, + RegistrationFieldType::RADIO, + RegistrationFieldType::CHECKBOX, + RegistrationFieldType::MULTISELECT, + RegistrationFieldType::TAG_PICKER => self::FULL, + }; + } +} diff --git a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php index c8900ff2..06ac0c21 100644 --- a/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php +++ b/api/app/Http/Controllers/Api/V1/PublicRegistrationDataController.php @@ -97,10 +97,12 @@ final class PublicRegistrationDataController extends Controller 'slug' => $field->slug, 'field_type' => $field->field_type->value, 'options' => $field->options, + 'normalized_options' => $field->normalized_options, 'tag_category' => $field->tag_category, 'is_required' => $field->is_required, 'section' => $field->section, 'help_text' => $field->help_text, + 'display_width' => $field->display_width->value, ]; if ($field->field_type === \App\Enums\RegistrationFieldType::TAG_PICKER) { diff --git a/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php b/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php index dcb173fb..ef319c8c 100644 --- a/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreRegistrationFieldTemplateRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Enums\FieldDisplayWidth; use App\Enums\RegistrationFieldType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -29,7 +30,9 @@ final class StoreRegistrationFieldTemplateRequest extends FormRequest $type?->prohibitsOptions() ? 'prohibited' : 'nullable', 'array', ], - 'options.*' => ['string', 'max:255'], + 'options.*' => ['required'], + 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], + 'options.*.description' => ['nullable', 'string', 'max:200'], 'tag_category' => [ $type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited', 'string', @@ -42,6 +45,7 @@ final class StoreRegistrationFieldTemplateRequest extends FormRequest 'section' => ['nullable', 'string', 'max:100'], 'help_text' => ['nullable', 'string', 'max:5000'], 'sort_order' => ['nullable', 'integer', 'min:0'], + 'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))], ]; } } diff --git a/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php b/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php index c2a6431c..d17e26d3 100644 --- a/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php +++ b/api/app/Http/Requests/Api/V1/StoreRegistrationFormFieldRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Enums\FieldDisplayWidth; use App\Enums\RegistrationFieldType; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; @@ -29,7 +30,9 @@ final class StoreRegistrationFormFieldRequest extends FormRequest $type?->prohibitsOptions() ? 'prohibited' : 'nullable', 'array', ], - 'options.*' => ['string', 'max:255'], + 'options.*' => ['required'], + 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], + 'options.*.description' => ['nullable', 'string', 'max:200'], 'tag_category' => [ $type === RegistrationFieldType::TAG_PICKER ? 'nullable' : 'prohibited', 'string', @@ -42,6 +45,7 @@ final class StoreRegistrationFormFieldRequest extends FormRequest 'section' => ['nullable', 'string', 'max:100'], 'help_text' => ['nullable', 'string', 'max:5000'], 'sort_order' => ['nullable', 'integer', 'min:0'], + 'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))], ]; } } diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php index 949fea77..bac5249c 100644 --- a/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateRegistrationFieldTemplateRequest.php @@ -4,8 +4,10 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Enums\FieldDisplayWidth; use App\Enums\RegistrationFieldType; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class UpdateRegistrationFieldTemplateRequest extends FormRequest { @@ -20,7 +22,9 @@ final class UpdateRegistrationFieldTemplateRequest extends FormRequest return [ 'label' => ['sometimes', 'string', 'max:255'], 'options' => ['nullable', 'array'], - 'options.*' => ['string', 'max:255'], + 'options.*' => ['required'], + 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], + 'options.*.description' => ['nullable', 'string', 'max:200'], 'tag_category' => ['nullable', 'string', 'max:50'], 'is_required' => ['nullable', 'boolean'], 'is_filterable' => ['nullable', 'boolean'], @@ -29,6 +33,7 @@ final class UpdateRegistrationFieldTemplateRequest extends FormRequest 'section' => ['nullable', 'string', 'max:100'], 'help_text' => ['nullable', 'string', 'max:5000'], 'sort_order' => ['nullable', 'integer', 'min:0'], + 'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))], ]; } } diff --git a/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php b/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php index d344bb90..3e8339ea 100644 --- a/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php +++ b/api/app/Http/Requests/Api/V1/UpdateRegistrationFormFieldRequest.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace App\Http\Requests\Api\V1; +use App\Enums\FieldDisplayWidth; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; final class UpdateRegistrationFormFieldRequest extends FormRequest { @@ -19,7 +21,9 @@ final class UpdateRegistrationFormFieldRequest extends FormRequest return [ 'label' => ['sometimes', 'string', 'max:255'], 'options' => ['nullable', 'array'], - 'options.*' => ['string', 'max:255'], + 'options.*' => ['required'], + 'options.*.label' => ['required_if:options.*,array', 'string', 'max:255'], + 'options.*.description' => ['nullable', 'string', 'max:200'], 'tag_category' => ['nullable', 'string', 'max:50'], 'is_required' => ['nullable', 'boolean'], 'is_portal_visible' => ['nullable', 'boolean'], @@ -28,6 +32,7 @@ final class UpdateRegistrationFormFieldRequest extends FormRequest 'section' => ['nullable', 'string', 'max:100'], 'help_text' => ['nullable', 'string', 'max:5000'], 'sort_order' => ['nullable', 'integer', 'min:0'], + 'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))], ]; } } diff --git a/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php b/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php index 6a069d87..f35ff6c6 100644 --- a/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php +++ b/api/app/Http/Resources/Api/V1/RegistrationFieldTemplateResource.php @@ -18,6 +18,7 @@ final class RegistrationFieldTemplateResource extends JsonResource 'slug' => $this->slug, 'field_type' => $this->field_type->value, 'options' => $this->options, + 'normalized_options' => $this->normalized_options, 'tag_category' => $this->tag_category, 'is_required' => $this->is_required, 'is_filterable' => $this->is_filterable, @@ -26,6 +27,7 @@ final class RegistrationFieldTemplateResource extends JsonResource 'section' => $this->section, 'help_text' => $this->help_text, 'sort_order' => $this->sort_order, + 'display_width' => $this->display_width->value, 'is_system' => $this->is_system, 'is_active' => $this->is_active, 'created_at' => $this->created_at->toIso8601String(), diff --git a/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php b/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php index b06cbfef..c7fac0d5 100644 --- a/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php +++ b/api/app/Http/Resources/Api/V1/RegistrationFormFieldResource.php @@ -20,6 +20,7 @@ final class RegistrationFormFieldResource extends JsonResource 'slug' => $this->slug, 'field_type' => $this->field_type->value, 'options' => $this->options, + 'normalized_options' => $this->normalized_options, 'tag_category' => $this->tag_category, 'is_required' => $this->is_required, 'is_portal_visible' => $this->is_portal_visible, @@ -28,6 +29,7 @@ final class RegistrationFormFieldResource extends JsonResource 'section' => $this->section, 'help_text' => $this->help_text, 'sort_order' => $this->sort_order, + 'display_width' => $this->display_width->value, 'created_at' => $this->created_at->toIso8601String(), 'updated_at' => $this->updated_at->toIso8601String(), 'available_tags' => $this->when( diff --git a/api/app/Models/RegistrationFieldTemplate.php b/api/app/Models/RegistrationFieldTemplate.php index e62e103a..cb822edf 100644 --- a/api/app/Models/RegistrationFieldTemplate.php +++ b/api/app/Models/RegistrationFieldTemplate.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\FieldDisplayWidth; use App\Enums\RegistrationFieldType; use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; @@ -36,6 +37,7 @@ final class RegistrationFieldTemplate extends Model 'section', 'help_text', 'sort_order', + 'display_width', 'is_system', 'is_active', ]; @@ -50,11 +52,31 @@ final class RegistrationFieldTemplate extends Model 'is_portal_visible' => 'boolean', 'is_admin_only' => 'boolean', 'sort_order' => 'integer', + 'display_width' => FieldDisplayWidth::class, 'is_system' => 'boolean', 'is_active' => 'boolean', ]; } + /** @return array|null */ + public function getNormalizedOptionsAttribute(): ?array + { + if ($this->options === null) { + return null; + } + + return collect($this->options)->map(function (mixed $option): array { + if (is_string($option)) { + return ['label' => $option, 'description' => null]; + } + + return [ + 'label' => $option['label'] ?? (string) $option, + 'description' => $option['description'] ?? null, + ]; + })->toArray(); + } + public function organisation(): BelongsTo { return $this->belongsTo(Organisation::class); diff --git a/api/app/Models/RegistrationFormField.php b/api/app/Models/RegistrationFormField.php index 1203e9c7..f4e4e0aa 100644 --- a/api/app/Models/RegistrationFormField.php +++ b/api/app/Models/RegistrationFormField.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use App\Enums\FieldDisplayWidth; use App\Enums\RegistrationFieldType; use App\Models\Scopes\OrganisationScope; use Illuminate\Database\Eloquent\Builder; @@ -40,6 +41,7 @@ final class RegistrationFormField extends Model 'section', 'help_text', 'sort_order', + 'display_width', ]; protected function casts(): array @@ -52,6 +54,7 @@ final class RegistrationFormField extends Model 'is_admin_only' => 'boolean', 'is_filterable' => 'boolean', 'sort_order' => 'integer', + 'display_width' => FieldDisplayWidth::class, ]; } @@ -65,6 +68,25 @@ final class RegistrationFormField extends Model return $this->hasMany(PersonFieldValue::class, 'registration_form_field_id'); } + /** @return array|null */ + public function getNormalizedOptionsAttribute(): ?array + { + if ($this->options === null) { + return null; + } + + return collect($this->options)->map(function (mixed $option): array { + if (is_string($option)) { + return ['label' => $option, 'description' => null]; + } + + return [ + 'label' => $option['label'] ?? (string) $option, + 'description' => $option['description'] ?? null, + ]; + })->toArray(); + } + public function isMultiValue(): bool { return $this->field_type->isMultiValue(); diff --git a/api/app/Services/RegistrationFieldTemplateService.php b/api/app/Services/RegistrationFieldTemplateService.php index 48bc8022..3cfe2ed3 100644 --- a/api/app/Services/RegistrationFieldTemplateService.php +++ b/api/app/Services/RegistrationFieldTemplateService.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\FieldDisplayWidth; +use App\Enums\RegistrationFieldType; use App\Models\Event; use App\Models\Organisation; use App\Models\RegistrationFieldTemplate; @@ -24,6 +26,11 @@ final class RegistrationFieldTemplateService { $data['slug'] = $this->generateUniqueSlug($organisation, $data['label']); + if (!isset($data['display_width'])) { + $fieldType = RegistrationFieldType::from($data['field_type']); + $data['display_width'] = FieldDisplayWidth::defaultForFieldType($fieldType)->value; + } + $template = $organisation->registrationFieldTemplates()->create($data); $activityLogger = activity('registration_templates') @@ -111,6 +118,7 @@ final class RegistrationFieldTemplateService 'section' => $template->section, 'help_text' => $template->help_text, 'sort_order' => $maxOrder + 1, + 'display_width' => $template->display_width, ]); $activityLogger = activity('registration_fields') @@ -132,17 +140,21 @@ final class RegistrationFieldTemplateService public static function seedSystemTemplates(Organisation $organisation): void { $templates = [ - ['label' => 'Shirtmaat', 'field_type' => 'select', 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, 'sort_order' => 1], - ['label' => 'Dieetwensen', 'field_type' => 'multiselect', 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', 'Geen pinda\'s', 'Geen noten'], 'is_filterable' => true, 'sort_order' => 2], - ['label' => 'Vergoeding', 'field_type' => 'radio', 'options' => ['Pro Deo', 'Entreeticket', 'Vrijwilligersvergoeding'], 'section' => 'Vergoeding', 'sort_order' => 3], - ['label' => 'Toestemming gegevensverwerking', 'field_type' => 'boolean', 'is_required' => true, 'section' => 'Toestemming', 'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens ten behoeve van de organisatie van dit evenement, conform de Algemene Verordening Gegevensbescherming (AVG).', 'sort_order' => 4], - ['label' => 'Noodcontact naam', 'field_type' => 'text', 'section' => 'Noodcontact', 'sort_order' => 5], - ['label' => 'Noodcontact telefoon', 'field_type' => 'text', 'section' => 'Noodcontact', 'sort_order' => 6], - ['label' => 'EHBO / BHV diploma', 'field_type' => 'boolean', 'is_filterable' => true, 'sort_order' => 7], - ['label' => 'Rijbewijs', 'field_type' => 'boolean', 'is_filterable' => true, 'sort_order' => 8], - ['label' => 'Eerder vrijwilliger geweest', 'field_type' => 'boolean', 'is_filterable' => true, 'sort_order' => 9], - ['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_category' => null, 'is_filterable' => true, 'sort_order' => 10], - ['label' => 'Opmerkingen', 'field_type' => 'textarea', 'sort_order' => 11], + ['label' => 'Shirtmaat', 'field_type' => 'select', 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 1], + ['label' => 'Dieetwensen', 'field_type' => 'multiselect', 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', 'Geen pinda\'s', 'Geen noten'], 'is_filterable' => true, 'display_width' => 'full', 'sort_order' => 2], + ['label' => 'Vergoeding', 'field_type' => 'radio', 'options' => [ + ['label' => 'Pro Deo', 'description' => 'Je werkt als vrijwilliger zonder financiële vergoeding'], + ['label' => 'Entreeticket', 'description' => 'Je ontvangt een gratis festivalticket als dank voor je inzet'], + ['label' => 'Vrijwilligersvergoeding', 'description' => 'Je ontvangt een vergoeding conform de vrijwilligersregeling'], + ], 'section' => 'Vergoeding', 'display_width' => 'full', 'sort_order' => 3], + ['label' => 'Toestemming gegevensverwerking', 'field_type' => 'boolean', 'is_required' => true, 'section' => 'Toestemming', 'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens ten behoeve van de organisatie van dit evenement, conform de Algemene Verordening Gegevensbescherming (AVG).', 'display_width' => 'full', 'sort_order' => 4], + ['label' => 'Noodcontact naam', 'field_type' => 'text', 'section' => 'Noodcontact', 'display_width' => 'half', 'sort_order' => 5], + ['label' => 'Noodcontact telefoon', 'field_type' => 'text', 'section' => 'Noodcontact', 'display_width' => 'half', 'sort_order' => 6], + ['label' => 'EHBO / BHV diploma', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 7], + ['label' => 'Rijbewijs', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 8], + ['label' => 'Eerder vrijwilliger geweest', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 9], + ['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_category' => null, 'is_filterable' => true, 'display_width' => 'full', 'sort_order' => 10], + ['label' => 'Opmerkingen', 'field_type' => 'textarea', 'display_width' => 'full', 'sort_order' => 11], ]; foreach ($templates as $data) { @@ -155,6 +167,7 @@ final class RegistrationFieldTemplateService 'is_filterable' => $data['is_filterable'] ?? false, 'is_portal_visible' => true, 'is_admin_only' => false, + 'display_width' => $data['display_width'] ?? 'full', ]); } } diff --git a/api/app/Services/RegistrationFormFieldService.php b/api/app/Services/RegistrationFormFieldService.php index a544e1e2..5d2fffe6 100644 --- a/api/app/Services/RegistrationFormFieldService.php +++ b/api/app/Services/RegistrationFormFieldService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Services; +use App\Enums\FieldDisplayWidth; use App\Enums\RegistrationFieldType; use App\Models\Event; use App\Models\Person; @@ -30,6 +31,11 @@ final class RegistrationFormFieldService { $data['slug'] = $this->generateUniqueSlug($event, $data['label']); + if (!isset($data['display_width'])) { + $fieldType = RegistrationFieldType::from($data['field_type']); + $data['display_width'] = FieldDisplayWidth::defaultForFieldType($fieldType)->value; + } + $field = RegistrationFormField::create([ 'event_id' => $event->id, ...$data, @@ -173,6 +179,7 @@ final class RegistrationFormFieldService 'section' => $sourceField->section, 'help_text' => $sourceField->help_text, 'sort_order' => ++$maxOrder, + 'display_width' => $sourceField->display_width, ]); $created->push($field); diff --git a/api/database/factories/RegistrationFieldTemplateFactory.php b/api/database/factories/RegistrationFieldTemplateFactory.php index 7946e90a..fe2a768a 100644 --- a/api/database/factories/RegistrationFieldTemplateFactory.php +++ b/api/database/factories/RegistrationFieldTemplateFactory.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Database\Factories; +use App\Enums\FieldDisplayWidth; use App\Enums\RegistrationFieldType; use App\Models\Organisation; use App\Models\RegistrationFieldTemplate; @@ -34,6 +35,7 @@ final class RegistrationFieldTemplateFactory extends Factory 'section' => null, 'help_text' => null, 'sort_order' => fake()->numberBetween(0, 20), + 'display_width' => FieldDisplayWidth::FULL, 'is_system' => false, 'is_active' => true, ]; @@ -57,6 +59,7 @@ final class RegistrationFieldTemplateFactory extends Factory 'field_type' => RegistrationFieldType::SELECT, 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, + 'display_width' => FieldDisplayWidth::HALF, ]); } @@ -67,6 +70,7 @@ final class RegistrationFieldTemplateFactory extends Factory 'slug' => 'ehbo-bhv-diploma', 'field_type' => RegistrationFieldType::BOOLEAN, 'is_filterable' => true, + 'display_width' => FieldDisplayWidth::HALF, ]); } @@ -77,6 +81,7 @@ final class RegistrationFieldTemplateFactory extends Factory 'slug' => 'certificaten-vaardigheden', 'field_type' => RegistrationFieldType::TAG_PICKER, 'is_filterable' => true, + 'display_width' => FieldDisplayWidth::FULL, ]); } } diff --git a/api/database/factories/RegistrationFormFieldFactory.php b/api/database/factories/RegistrationFormFieldFactory.php index 31635bc3..2dc591ec 100644 --- a/api/database/factories/RegistrationFormFieldFactory.php +++ b/api/database/factories/RegistrationFormFieldFactory.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Database\Factories; +use App\Enums\FieldDisplayWidth; use App\Enums\RegistrationFieldType; use App\Models\Event; use App\Models\RegistrationFormField; @@ -34,6 +35,7 @@ final class RegistrationFormFieldFactory extends Factory 'section' => null, 'help_text' => null, 'sort_order' => fake()->numberBetween(0, 20), + 'display_width' => FieldDisplayWidth::FULL, ]; } @@ -44,6 +46,7 @@ final class RegistrationFormFieldFactory extends Factory 'slug' => 'noodcontact-naam', 'field_type' => RegistrationFieldType::TEXT, 'section' => 'Noodcontact', + 'display_width' => FieldDisplayWidth::HALF, ]); } @@ -55,6 +58,7 @@ final class RegistrationFormFieldFactory extends Factory 'field_type' => RegistrationFieldType::SELECT, 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, + 'display_width' => FieldDisplayWidth::HALF, ]); } @@ -66,6 +70,7 @@ final class RegistrationFormFieldFactory extends Factory 'field_type' => RegistrationFieldType::MULTISELECT, 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', 'Geen pinda\'s', 'Geen noten'], 'is_filterable' => true, + 'display_width' => FieldDisplayWidth::FULL, ]); } @@ -78,6 +83,7 @@ final class RegistrationFormFieldFactory extends Factory 'is_required' => true, 'section' => 'Toestemming', 'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens conform de AVG.', + 'display_width' => FieldDisplayWidth::FULL, ]); } @@ -88,6 +94,7 @@ final class RegistrationFormFieldFactory extends Factory 'slug' => 'vaardigheden', 'field_type' => RegistrationFieldType::TAG_PICKER, 'is_filterable' => true, + 'display_width' => FieldDisplayWidth::FULL, ]); } @@ -97,8 +104,13 @@ final class RegistrationFormFieldFactory extends Factory 'label' => 'Vergoeding', 'slug' => 'vergoeding', 'field_type' => RegistrationFieldType::RADIO, - 'options' => ['Pro Deo', 'Entreeticket', 'Vrijwilligersvergoeding'], + 'options' => [ + ['label' => 'Pro Deo', 'description' => 'Je werkt als vrijwilliger zonder financiële vergoeding'], + ['label' => 'Entreeticket', 'description' => 'Je ontvangt een gratis festivalticket als dank voor je inzet'], + ['label' => 'Vrijwilligersvergoeding', 'description' => 'Je ontvangt een vergoeding conform de vrijwilligersregeling'], + ], 'section' => 'Vergoeding', + 'display_width' => FieldDisplayWidth::FULL, ]); } @@ -108,6 +120,7 @@ final class RegistrationFormFieldFactory extends Factory 'label' => 'Opmerkingen', 'slug' => 'opmerkingen', 'field_type' => RegistrationFieldType::TEXTAREA, + 'display_width' => FieldDisplayWidth::FULL, ]); } } diff --git a/api/database/migrations/2026_04_17_200000_add_display_width_to_registration_fields_tables.php b/api/database/migrations/2026_04_17_200000_add_display_width_to_registration_fields_tables.php new file mode 100644 index 00000000..5e969b92 --- /dev/null +++ b/api/database/migrations/2026_04_17_200000_add_display_width_to_registration_fields_tables.php @@ -0,0 +1,32 @@ +string('display_width', 10)->default('full')->after('sort_order'); + }); + + Schema::table('registration_field_templates', function (Blueprint $table) { + $table->string('display_width', 10)->default('full')->after('sort_order'); + }); + } + + public function down(): void + { + Schema::table('registration_form_fields', function (Blueprint $table) { + $table->dropColumn('display_width'); + }); + + Schema::table('registration_field_templates', function (Blueprint $table) { + $table->dropColumn('display_width'); + }); + } +}; diff --git a/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php b/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php index 3283fcf1..f0873c10 100644 --- a/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php +++ b/api/tests/Feature/RegistrationFormField/RegistrationFormFieldTest.php @@ -360,4 +360,136 @@ class RegistrationFormFieldTest extends TestCase $response->assertUnauthorized(); } + + public function test_store_field_with_display_width(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ + 'label' => 'Noodcontact naam', + 'field_type' => 'text', + 'display_width' => 'half', + ]); + + $response->assertCreated() + ->assertJsonPath('data.display_width', 'half'); + + $this->assertDatabaseHas('registration_form_fields', [ + 'event_id' => $this->event->id, + 'slug' => 'noodcontact-naam', + 'display_width' => 'half', + ]); + } + + public function test_store_field_defaults_display_width_by_type(): void + { + Sanctum::actingAs($this->orgAdmin); + + // Text fields default to 'half' + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ + 'label' => 'Korte tekst', + 'field_type' => 'text', + ]); + + $response->assertCreated() + ->assertJsonPath('data.display_width', 'half'); + + // Textarea fields default to 'full' + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ + 'label' => 'Opmerkingen', + 'field_type' => 'textarea', + ]); + + $response->assertCreated() + ->assertJsonPath('data.display_width', 'full'); + } + + public function test_update_field_display_width(): void + { + $field = RegistrationFormField::factory()->create([ + 'event_id' => $this->event->id, + 'display_width' => 'full', + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->putJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields/{$field->id}", [ + 'display_width' => 'half', + ]); + + $response->assertOk() + ->assertJsonPath('data.display_width', 'half'); + } + + public function test_store_field_with_option_descriptions(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ + 'label' => 'Vergoeding', + 'field_type' => 'radio', + 'options' => [ + ['label' => 'Pro Deo', 'description' => 'Geen vergoeding'], + ['label' => 'Entreeticket', 'description' => 'Gratis ticket'], + ], + ]); + + $response->assertCreated() + ->assertJsonPath('data.normalized_options.0.label', 'Pro Deo') + ->assertJsonPath('data.normalized_options.0.description', 'Geen vergoeding') + ->assertJsonPath('data.normalized_options.1.label', 'Entreeticket') + ->assertJsonPath('data.normalized_options.1.description', 'Gratis ticket'); + } + + public function test_options_backwards_compatible_with_string_array(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [ + 'label' => 'Shirtmaat', + 'field_type' => 'select', + 'options' => ['XS', 'S', 'M', 'L'], + ]); + + $response->assertCreated() + ->assertJsonPath('data.normalized_options.0.label', 'XS') + ->assertJsonPath('data.normalized_options.0.description', null) + ->assertJsonPath('data.normalized_options.3.label', 'L'); + } + + public function test_normalized_options_converts_strings_to_objects(): void + { + $field = RegistrationFormField::factory()->selectField()->create([ + 'event_id' => $this->event->id, + ]); + + $this->assertNotNull($field->normalized_options); + $this->assertIsArray($field->normalized_options); + + // Each option should be an array with label and description keys + foreach ($field->normalized_options as $option) { + $this->assertArrayHasKey('label', $option); + $this->assertArrayHasKey('description', $option); + $this->assertIsString($option['label']); + } + } + + public function test_index_returns_display_width_and_normalized_options(): void + { + RegistrationFormField::factory()->radioField()->create([ + 'event_id' => $this->event->id, + 'sort_order' => 0, + ]); + + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields"); + + $response->assertOk(); + $field = $response->json('data.0'); + $this->assertArrayHasKey('display_width', $field); + $this->assertArrayHasKey('normalized_options', $field); + $this->assertEquals('full', $field['display_width']); + $this->assertNotNull($field['normalized_options']); + } } diff --git a/apps/app/src/components/event/RegistrationFieldCard.vue b/apps/app/src/components/event/RegistrationFieldCard.vue index b7f53175..e3066d26 100644 --- a/apps/app/src/components/event/RegistrationFieldCard.vue +++ b/apps/app/src/components/event/RegistrationFieldCard.vue @@ -1,5 +1,6 @@ @@ -59,10 +61,10 @@ function formatOptions(options: string[] | null): string {
- Opties: {{ formatOptions(field.options) }} + Opties: {{ formatOptions(field.normalized_options) }}
@@ -83,6 +85,14 @@ function formatOptions(options: string[] | null): string {
+ + Halve breedte + >({}) const refVForm = ref() +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 }) >
- - + + + + + + + + + + +
+ > + + + + + Volledig + + + + Half + + + () const showSuccess = ref(false) const successMessage = ref('') +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, @@ -58,6 +63,7 @@ const defaultForm = () => ({ section: '', help_text: '', sort_order: 0, + display_width: 'full' as FieldDisplayWidth, }) const form = ref(defaultForm()) @@ -90,7 +96,9 @@ function openEditDialog(template: RegistrationFieldTemplate) { form.value = { label: template.label, field_type: template.field_type, - options: template.options ? [...template.options] : [], + options: template.normalized_options + ? template.normalized_options.map(o => ({ label: o.label, description: o.description ?? '' })) + : [], tag_category: template.tag_category, is_required: template.is_required, is_filterable: template.is_filterable, @@ -99,13 +107,14 @@ function openEditDialog(template: RegistrationFieldTemplate) { section: template.section ?? '', help_text: template.help_text ?? '', sort_order: template.sort_order, + display_width: template.display_width ?? 'full', } errors.value = {} isDialogOpen.value = true } function addOption() { - form.value.options.push('') + form.value.options.push({ label: '', description: '' }) } function removeOption(index: number) { @@ -126,6 +135,7 @@ function onSubmit() { section: form.value.section || null, help_text: form.value.help_text || null, sort_order: form.value.sort_order, + display_width: form.value.display_width, } if (!editingTemplate.value) { @@ -133,7 +143,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 @@ -414,23 +429,45 @@ function activate(template: RegistrationFieldTemplate) { >
- - + + + + + + + + + + +
+ + + + + + Volledig + + + + Half + + + > { diff --git a/apps/app/src/types/registration-form-field.ts b/apps/app/src/types/registration-form-field.ts index ee74492a..c4bda5fa 100644 --- a/apps/app/src/types/registration-form-field.ts +++ b/apps/app/src/types/registration-form-field.ts @@ -1,12 +1,22 @@ import type { RegistrationFieldType } from './registration-field-template' +export type FieldDisplayWidth = 'full' | 'half' + +export interface NormalizedOption { + label: string + description: string | null +} + +export type FieldOption = string | { label: string; description?: string | null } + export interface RegistrationFormField { id: string event_id: string label: string slug: string field_type: RegistrationFieldType - options: string[] | null + options: FieldOption[] | null + normalized_options: NormalizedOption[] | null tag_category: string | null is_required: boolean is_portal_visible: boolean @@ -15,6 +25,7 @@ export interface RegistrationFormField { section: string | null help_text: string | null sort_order: number + display_width: FieldDisplayWidth created_at: string updated_at: string available_tags?: Array<{ id: string; name: string; category: string | null }> @@ -23,7 +34,7 @@ export interface RegistrationFormField { export interface RegistrationFormFieldCreateDTO { label: string field_type: RegistrationFieldType - options?: string[] | null + options?: FieldOption[] | null tag_category?: string | null is_required?: boolean is_portal_visible?: boolean @@ -32,6 +43,7 @@ export interface RegistrationFormFieldCreateDTO { section?: string | null help_text?: string | null sort_order?: number + display_width?: FieldDisplayWidth } export interface RegistrationFormFieldUpdateDTO extends Partial> {} diff --git a/apps/portal/src/pages/register/[eventSlug].vue b/apps/portal/src/pages/register/[eventSlug].vue index 724a01c6..41abc10b 100644 --- a/apps/portal/src/pages/register/[eventSlug].vue +++ b/apps/portal/src/pages/register/[eventSlug].vue @@ -1063,7 +1063,7 @@ async function onSubmit() { v-for="field in group.fields" :key="field.id" cols="12" - :md="field.field_type === 'textarea' || field.field_type === 'checkbox' ? '12' : '6'" + :md="field.display_width === 'half' ? 6 : 12" >
+ > + + + > + +
@@ -1147,14 +1173,25 @@ async function onSubmit() { {{ field.help_text }}

+ @update:model-value="(v: boolean | null) => toggleCheckboxOption(field.slug, opt.label, v)" + > + +
+ > + +
diff --git a/apps/portal/src/types/registration.ts b/apps/portal/src/types/registration.ts index 9f50b526..72effd39 100644 --- a/apps/portal/src/types/registration.ts +++ b/apps/portal/src/types/registration.ts @@ -9,22 +9,31 @@ export type RegistrationFieldType = | 'number' | 'tag_picker' +export type FieldDisplayWidth = 'full' | 'half' + export interface RegistrationTagOption { id: string name: string category: string | null } +export interface NormalizedOption { + label: string + description: string | null +} + export interface RegistrationField { id: string label: string slug: string field_type: RegistrationFieldType - options: string[] | null + options: (string | { label: string; description?: string | null })[] | null + normalized_options: NormalizedOption[] | null tag_category: string | null is_required: boolean section: string | null help_text: string | null + display_width: FieldDisplayWidth available_tags?: RegistrationTagOption[] } diff --git a/dev-docs/API.md b/dev-docs/API.md index 2ef4dd8b..faf66bcf 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -711,6 +711,14 @@ Creates a COPY of the template as an event field. The copy is independent — ch Copies all `registration_form_fields` from the source event. Source must belong to the same organisation. Existing fields on the target event are kept. +### Response Fields + +Each registration form field response includes: + +- `options` — raw stored format (string array or object array, for backwards compatibility) +- `normalized_options` — always `[{label, description}]` format (null when field has no options). Descriptions are null when not set. Use this for rendering. +- `display_width` — `"full"` or `"half"`, controls form layout column width. Auto-set based on field type when not explicitly provided. + ### Tag Picker Fields For `tag_picker` fields: the API response includes `available_tags` array (from `person_tags`, filtered by `tag_category` if set) so the frontend knows which tags to render as options. diff --git a/dev-docs/SCHEMA.md b/dev-docs/SCHEMA.md index 88027bfc..c6ebc8ab 100644 --- a/dev-docs/SCHEMA.md +++ b/dev-docs/SCHEMA.md @@ -1584,7 +1584,7 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; | `label` | string | Display label, e.g. "Heb je voedselallergiëen?" | | `slug` | string(100) | Auto-generated from label, used as stable key | | `field_type` | enum | `text\|textarea\|select\|multiselect\|checkbox\|radio\|boolean\|number\|tag_picker` | -| `options` | JSON nullable | For select/multiselect/radio/checkbox: array of option strings. NULL for tag_picker (options come from person_tags). JSON OK: opaque config. | +| `options` | JSON nullable | For select/multiselect/radio/checkbox: array of option strings OR option objects `{label, description?}`. NULL for tag_picker (options come from person_tags). JSON OK: opaque config. Both formats accepted; `normalized_options` accessor always returns objects. | | `tag_category` | string(50) null | Only for tag_picker: filter tags by this category. NULL = show all active tags. | | `is_required` | bool | Field must be filled in | | `is_portal_visible`| bool | Shown to person in registration form | @@ -1593,6 +1593,7 @@ $effectiveDate = $shift->end_date ?? $shift->timeSlot->date; | `section` | string(100) null | Form section grouping (e.g. "Vergoeding", "Toestemming") | | `help_text` | text nullable | Explanatory text shown below the field | | `sort_order` | int | Display order in form | +| `display_width` | string(10) | `full` (default) or `half` — controls form layout width | | `created_at` | timestamp | | | `updated_at` | timestamp | |