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

@@ -32,7 +32,8 @@ enum FieldDisplayWidth: string
RegistrationFieldType::RADIO,
RegistrationFieldType::CHECKBOX,
RegistrationFieldType::MULTISELECT,
RegistrationFieldType::TAG_PICKER => self::FULL,
RegistrationFieldType::TAG_PICKER,
RegistrationFieldType::HEADING => self::FULL,
};
}
}

View File

@@ -15,6 +15,7 @@ enum RegistrationFieldType: string
case BOOLEAN = 'boolean';
case NUMBER = 'number';
case TAG_PICKER = 'tag_picker';
case HEADING = 'heading';
public function isMultiValue(): bool
{
@@ -28,6 +29,11 @@ enum RegistrationFieldType: string
public function prohibitsOptions(): bool
{
return in_array($this, [self::TEXT, self::TEXTAREA, self::BOOLEAN, self::NUMBER, self::TAG_PICKER], true);
return in_array($this, [self::TEXT, self::TEXTAREA, self::BOOLEAN, self::NUMBER, self::TAG_PICKER, self::HEADING], true);
}
public function isStructural(): bool
{
return $this === self::HEADING;
}
}

View File

@@ -100,7 +100,6 @@ final class PublicRegistrationDataController extends Controller
'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,
];

View File

@@ -22,9 +22,19 @@ final class StoreRegistrationFieldTemplateRequest extends FormRequest
$fieldType = $this->input('field_type');
$type = RegistrationFieldType::tryFrom($fieldType);
return [
$rules = [
'label' => ['required', 'string', 'max:255'],
'field_type' => ['required', Rule::in(array_column(RegistrationFieldType::cases(), 'value'))],
'help_text' => ['nullable', 'string', 'max:5000'],
'sort_order' => ['nullable', 'integer', 'min:0'],
'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))],
];
if ($type?->isStructural()) {
return $rules;
}
return array_merge($rules, [
'options' => [
$type?->requiresOptions() ? 'required' : 'nullable',
$type?->prohibitsOptions() ? 'prohibited' : 'nullable',
@@ -42,10 +52,6 @@ final class StoreRegistrationFieldTemplateRequest extends FormRequest
'is_filterable' => ['nullable', 'boolean'],
'is_portal_visible' => ['nullable', 'boolean'],
'is_admin_only' => ['nullable', 'boolean'],
'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'))],
];
]);
}
}

View File

@@ -22,9 +22,19 @@ final class StoreRegistrationFormFieldRequest extends FormRequest
$fieldType = $this->input('field_type');
$type = RegistrationFieldType::tryFrom($fieldType);
return [
$rules = [
'label' => ['required', 'string', 'max:255'],
'field_type' => ['required', Rule::in(array_column(RegistrationFieldType::cases(), 'value'))],
'help_text' => ['nullable', 'string', 'max:5000'],
'sort_order' => ['nullable', 'integer', 'min:0'],
'display_width' => ['sometimes', Rule::in(array_column(FieldDisplayWidth::cases(), 'value'))],
];
if ($type?->isStructural()) {
return $rules;
}
return array_merge($rules, [
'options' => [
$type?->requiresOptions() ? 'required' : 'nullable',
$type?->prohibitsOptions() ? 'prohibited' : 'nullable',
@@ -42,10 +52,6 @@ final class StoreRegistrationFormFieldRequest extends FormRequest
'is_portal_visible' => ['nullable', 'boolean'],
'is_admin_only' => ['nullable', 'boolean'],
'is_filterable' => ['nullable', 'boolean'],
'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'))],
];
]);
}
}

View File

@@ -30,7 +30,6 @@ final class UpdateRegistrationFieldTemplateRequest extends FormRequest
'is_filterable' => ['nullable', 'boolean'],
'is_portal_visible' => ['nullable', 'boolean'],
'is_admin_only' => ['nullable', 'boolean'],
'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'))],

View File

@@ -29,7 +29,6 @@ final class UpdateRegistrationFormFieldRequest extends FormRequest
'is_portal_visible' => ['nullable', 'boolean'],
'is_admin_only' => ['nullable', 'boolean'],
'is_filterable' => ['nullable', 'boolean'],
'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'))],

View File

@@ -24,7 +24,6 @@ final class RegistrationFieldTemplateResource extends JsonResource
'is_filterable' => $this->is_filterable,
'is_portal_visible' => $this->is_portal_visible,
'is_admin_only' => $this->is_admin_only,
'section' => $this->section,
'help_text' => $this->help_text,
'sort_order' => $this->sort_order,
'display_width' => $this->display_width->value,

View File

@@ -26,7 +26,6 @@ final class RegistrationFormFieldResource extends JsonResource
'is_portal_visible' => $this->is_portal_visible,
'is_admin_only' => $this->is_admin_only,
'is_filterable' => $this->is_filterable,
'section' => $this->section,
'help_text' => $this->help_text,
'sort_order' => $this->sort_order,
'display_width' => $this->display_width->value,

View File

@@ -34,7 +34,6 @@ final class RegistrationFieldTemplate extends Model
'is_filterable',
'is_portal_visible',
'is_admin_only',
'section',
'help_text',
'sort_order',
'display_width',

View File

@@ -38,7 +38,6 @@ final class RegistrationFormField extends Model
'is_portal_visible',
'is_admin_only',
'is_filterable',
'section',
'help_text',
'sort_order',
'display_width',

View File

@@ -115,7 +115,6 @@ final class RegistrationFieldTemplateService
'is_portal_visible' => $template->is_portal_visible,
'is_admin_only' => $template->is_admin_only,
'is_filterable' => $template->is_filterable,
'section' => $template->section,
'help_text' => $template->help_text,
'sort_order' => $maxOrder + 1,
'display_width' => $template->display_width,
@@ -140,21 +139,26 @@ 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, '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' => 'Persoonlijke voorkeuren', 'field_type' => 'heading', 'help_text' => 'Vertel ons wat we over jou moeten weten', 'display_width' => 'full', 'sort_order' => 1],
['label' => 'Shirtmaat', 'field_type' => 'select', 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 2],
['label' => 'Dieetwensen', 'field_type' => 'multiselect', 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', 'Geen pinda\'s', 'Geen noten'], 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 3],
['label' => 'Vergoeding', 'field_type' => 'heading', 'help_text' => 'Kies hoe je wilt worden bedankt voor je inzet', 'display_width' => 'full', 'sort_order' => 4],
['label' => 'Vergoedingstype', '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],
], 'is_required' => true, 'display_width' => 'full', 'sort_order' => 5],
['label' => 'Noodcontact', 'field_type' => 'heading', 'help_text' => 'Wie kunnen we bereiken bij calamiteiten?', 'display_width' => 'full', 'sort_order' => 6],
['label' => 'Noodcontact naam', 'field_type' => 'text', 'display_width' => 'half', 'sort_order' => 7],
['label' => 'Noodcontact telefoon', 'field_type' => 'text', 'display_width' => 'half', 'sort_order' => 8],
['label' => 'Ervaring & vaardigheden', 'field_type' => 'heading', 'help_text' => 'Welke diploma\'s en skills heb je?', 'display_width' => 'full', 'sort_order' => 9],
['label' => 'EHBO / BHV diploma', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 10],
['label' => 'Rijbewijs', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 11],
['label' => 'Eerder vrijwilliger geweest', 'field_type' => 'boolean', 'is_filterable' => true, 'display_width' => 'half', 'sort_order' => 12],
['label' => 'Certificaten & vaardigheden', 'field_type' => 'tag_picker', 'tag_category' => null, 'is_filterable' => true, 'display_width' => 'full', 'sort_order' => 13],
['label' => 'Aanvullende informatie', 'field_type' => 'heading', 'display_width' => 'full', 'sort_order' => 14],
['label' => 'Toestemming gegevensverwerking', 'field_type' => 'boolean', 'is_required' => true, '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' => 15],
['label' => 'Opmerkingen', 'field_type' => 'textarea', 'display_width' => 'full', 'sort_order' => 16],
];
foreach ($templates as $data) {

View File

@@ -111,7 +111,7 @@ final class RegistrationFormFieldService
DB::transaction(function () use ($person, $values, $fields): void {
foreach ($values as $slug => $rawValue) {
$field = $fields->get($slug);
if ($field === null) {
if ($field === null || $field->field_type->isStructural()) {
continue;
}
@@ -176,7 +176,6 @@ final class RegistrationFormFieldService
'is_portal_visible' => $sourceField->is_portal_visible,
'is_admin_only' => $sourceField->is_admin_only,
'is_filterable' => $sourceField->is_filterable,
'section' => $sourceField->section,
'help_text' => $sourceField->help_text,
'sort_order' => ++$maxOrder,
'display_width' => $sourceField->display_width,

View File

@@ -32,7 +32,6 @@ final class RegistrationFieldTemplateFactory extends Factory
'is_filterable' => false,
'is_portal_visible' => true,
'is_admin_only' => false,
'section' => null,
'help_text' => null,
'sort_order' => fake()->numberBetween(0, 20),
'display_width' => FieldDisplayWidth::FULL,
@@ -84,4 +83,15 @@ final class RegistrationFieldTemplateFactory extends Factory
'display_width' => FieldDisplayWidth::FULL,
]);
}
public function headingField(): static
{
return $this->state(fn () => [
'label' => 'Persoonlijke voorkeuren',
'slug' => 'persoonlijke-voorkeuren',
'field_type' => RegistrationFieldType::HEADING,
'help_text' => 'Vertel ons wat we over jou moeten weten',
'display_width' => FieldDisplayWidth::FULL,
]);
}
}

View File

@@ -32,7 +32,6 @@ final class RegistrationFormFieldFactory extends Factory
'is_portal_visible' => true,
'is_admin_only' => false,
'is_filterable' => false,
'section' => null,
'help_text' => null,
'sort_order' => fake()->numberBetween(0, 20),
'display_width' => FieldDisplayWidth::FULL,
@@ -45,7 +44,6 @@ final class RegistrationFormFieldFactory extends Factory
'label' => 'Noodcontact naam',
'slug' => 'noodcontact-naam',
'field_type' => RegistrationFieldType::TEXT,
'section' => 'Noodcontact',
'display_width' => FieldDisplayWidth::HALF,
]);
}
@@ -81,7 +79,6 @@ final class RegistrationFormFieldFactory extends Factory
'slug' => 'toestemming-gegevensverwerking',
'field_type' => RegistrationFieldType::BOOLEAN,
'is_required' => true,
'section' => 'Toestemming',
'help_text' => 'Ik geef toestemming voor de verwerking van mijn persoonsgegevens conform de AVG.',
'display_width' => FieldDisplayWidth::FULL,
]);
@@ -109,7 +106,6 @@ final class RegistrationFormFieldFactory extends Factory
['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,
]);
}
@@ -123,4 +119,15 @@ final class RegistrationFormFieldFactory extends Factory
'display_width' => FieldDisplayWidth::FULL,
]);
}
public function headingField(): static
{
return $this->state(fn () => [
'label' => 'Persoonlijke voorkeuren',
'slug' => 'persoonlijke-voorkeuren',
'field_type' => RegistrationFieldType::HEADING,
'help_text' => 'Vertel ons wat we over jou moeten weten',
'display_width' => FieldDisplayWidth::FULL,
]);
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('registration_field_templates', function (Blueprint $table) {
$table->dropColumn('section');
});
Schema::table('registration_form_fields', function (Blueprint $table) {
$table->dropColumn('section');
});
}
public function down(): void
{
Schema::table('registration_field_templates', function (Blueprint $table) {
$table->string('section', 100)->nullable()->after('is_admin_only');
});
Schema::table('registration_form_fields', function (Blueprint $table) {
$table->string('section', 100)->nullable()->after('is_filterable');
});
}
};

View File

@@ -156,7 +156,7 @@ class DevSeeder extends Seeder
\App\Services\RegistrationFieldTemplateService::seedSystemTemplates($this->org);
$this->command->info(' Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, 11 registration templates created');
$this->command->info(' Organisation, 8 users, 6 companies, 7 crowd types, 10 person tags, 16 registration templates created');
});
}

View File

@@ -152,7 +152,7 @@ class RegistrationFieldTemplateTest extends TestCase
->where('is_system', true)
->count();
$this->assertEquals(11, $count);
$this->assertEquals(16, $count);
}
public function test_create_field_from_template(): void

View File

@@ -492,4 +492,58 @@ class RegistrationFormFieldTest extends TestCase
$this->assertEquals('full', $field['display_width']);
$this->assertNotNull($field['normalized_options']);
}
public function test_store_heading_field_without_options(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [
'label' => 'Persoonlijke voorkeuren',
'field_type' => 'heading',
'help_text' => 'Vertel ons wat we over jou moeten weten',
]);
$response->assertCreated()
->assertJsonPath('data.field_type', 'heading')
->assertJsonPath('data.label', 'Persoonlijke voorkeuren')
->assertJsonPath('data.help_text', 'Vertel ons wat we over jou moeten weten')
->assertJsonPath('data.display_width', 'full');
}
public function test_heading_field_skips_options_validation(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [
'label' => 'Sectiekop',
'field_type' => 'heading',
]);
$response->assertCreated();
}
public function test_heading_field_display_width_defaults_to_full(): void
{
Sanctum::actingAs($this->orgAdmin);
$response = $this->postJson("/api/v1/organisations/{$this->organisation->id}/events/{$this->event->id}/registration-fields", [
'label' => 'Noodcontact',
'field_type' => 'heading',
]);
$response->assertCreated()
->assertJsonPath('data.display_width', 'full');
}
public function test_seeder_creates_heading_fields(): void
{
\App\Services\RegistrationFieldTemplateService::seedSystemTemplates($this->organisation);
$headingCount = \App\Models\RegistrationFieldTemplate::where('organisation_id', $this->organisation->id)
->where('is_system', true)
->where('field_type', 'heading')
->count();
$this->assertEquals(5, $headingCount);
}
}