feat(form-field): backfill form_fields.options to form_field_options

Atomic data migration. Every options datum in the database — in
form_fields and form_field_library, their translations bags, and the
form_submissions.schema_snapshot + form_templates.schema_snapshot JSON
blobs — is converted to the new relational rich-shape representation.

Strict dispatch per §17.4.4 / §8.7 convention:
  - Fail on field_type ∉ {RADIO, SELECT, MULTISELECT, CHECKBOX_LIST}
    carrying non-null options (post-WS-5b TAG_PICKER seed-bug indicator)
  - Fail on non-flat-string-array options shape
  - Fail on translations.{locale}.options[] length mismatch
  - Fail on non-string / >255-char translated labels
  - Fail on any residual translations.{locale}.options key after
    step C migration

Snapshot rewrite in-place: both form_submissions.schema_snapshot and
form_templates.schema_snapshot walk fields[*] and rewrite options to
the new rich-shape, strip per-locale options[] from the parallel
translations bag. Zero-compromise directive — no reader tolerance for
pre-WS-5d shape in commit 3 onwards.

Rollback reconstructs JSON column shapes plus translations bags.
Forward+back pair safe as a unit; partial rollback unsupported.

FormFieldService::insertFromLibrary switches from JSON-copy to
FormFieldOptionService::copyOptions row-clone per addendum Q3 row-copy
mandate. The field's own translations bag no longer carries
{locale}.options keys — those live on option rows now.

Seeders and factories switch to service-level option creation:
  - FormBuilderDevSeeder.canonicalFields keeps flat-string options as
    its data shape; FormField::create no longer receives an options
    key, the post-create FormFieldOptionService::replaceOptions call
    inserts the rich rows. The same applies to
    seedEventRegistrationShowcaseSchema. The vergoedingstype field's
    legacy {label, description} object shape (a pre-WS-5d seed-bug
    that the strict backfill would reject) is normalised to flat
    strings; the descriptions are dropped.
  - seedSystemTemplates embeds rich-shape options in the template
    snapshot — no flat-array snapshot data remains in newly-seeded
    rows.
  - FormFieldFactory + FormFieldLibraryFactory drop the options
    default; new ::withOptions() helper accepts either flat strings
    (each becomes value+label) or full spec arrays and routes through
    the service.

JSON columns (form_fields.options, form_field_library.options) remain
present and writable via fillable; column-drop lands in commit 5.
Reads from the JSON column still exist in resources, snapshot writer,
FormRequests, FormValueService, and FilterRegistryController — commit
3 switches those all atomically.

Migration step-count tests in WS-5a/b/c bumped by 1 to account for
the new backfill_form_field_options migration on the migration stack.

Tests: 1182 → 1193 green (+11 tests / +56 assertions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 02:21:26 +02:00
parent 11588623c5
commit 15e4e49d8c
10 changed files with 1216 additions and 38 deletions

View File

@@ -16,6 +16,7 @@ use App\Models\FormBuilder\FormFieldConditionalLogicCondition;
use App\Models\FormBuilder\FormFieldConditionalLogicGroup;
use App\Models\FormBuilder\FormFieldValidationRule;
use App\Models\FormBuilder\FormSchema;
use App\Services\FormBuilder\FormFieldOptionService;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
@@ -48,9 +49,6 @@ final class FormFieldFactory extends Factory
'slug' => Str::slug($label).'-'.Str::lower(Str::random(4)),
'label' => $label,
'help_text' => fake()->boolean(30) ? fake('nl_NL')->sentence() : null,
'options' => $fieldType === FormFieldType::SELECT
? ['Optie A', 'Optie B', 'Optie C']
: null,
'is_required' => fake()->boolean(40),
'is_filterable' => false,
'is_portal_visible' => true,
@@ -66,6 +64,35 @@ final class FormFieldFactory extends Factory
];
}
/**
* Attach option rows in `form_field_options` after the field is
* persisted. Replaces populating the legacy `options` JSON column
* (WS-5d commit 2). Pass either flat strings (each becomes
* value+label) or full spec arrays.
*
* @param list<string|array{value:string,label:string,sort_order?:int,translations?:array<string,string>}> $values
*/
public function withOptions(array $values): static
{
return $this->afterCreating(function (FormField $field) use ($values): void {
$specs = [];
foreach (array_values($values) as $i => $entry) {
if (is_string($entry)) {
$specs[] = ['value' => $entry, 'label' => $entry, 'sort_order' => $i];
continue;
}
$specs[] = [
'value' => (string) $entry['value'],
'label' => (string) $entry['label'],
'sort_order' => $entry['sort_order'] ?? $i,
'translations' => $entry['translations'] ?? null,
];
}
app(FormFieldOptionService::class)->replaceOptions($field, $specs);
});
}
public function ofType(FormFieldType $type): static
{
return $this->state(fn () => [

View File

@@ -11,6 +11,7 @@ use App\Models\FormBuilder\FormFieldBinding;
use App\Models\FormBuilder\FormFieldLibrary;
use App\Models\FormBuilder\FormFieldValidationRule;
use App\Models\Organisation;
use App\Services\FormBuilder\FormFieldOptionService;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
@@ -34,7 +35,6 @@ final class FormFieldLibraryFactory extends Factory
'field_type' => FormFieldType::TEXT->value,
'label' => fake('nl_NL')->words(2, true),
'help_text' => null,
'options' => null,
'default_is_required' => false,
'default_is_filterable' => false,
'translations' => null,
@@ -43,6 +43,34 @@ final class FormFieldLibraryFactory extends Factory
];
}
/**
* Attach option rows in `form_field_options` after the library entry
* is persisted. Replaces populating the legacy `options` JSON column
* (WS-5d commit 2).
*
* @param list<string|array{value:string,label:string,sort_order?:int,translations?:array<string,string>}> $values
*/
public function withOptions(array $values): static
{
return $this->afterCreating(function (FormFieldLibrary $library) use ($values): void {
$specs = [];
foreach (array_values($values) as $i => $entry) {
if (is_string($entry)) {
$specs[] = ['value' => $entry, 'label' => $entry, 'sort_order' => $i];
continue;
}
$specs[] = [
'value' => (string) $entry['value'],
'label' => (string) $entry['label'],
'sort_order' => $entry['sort_order'] ?? $i,
'translations' => $entry['translations'] ?? null,
];
}
app(FormFieldOptionService::class)->replaceOptions($library, $specs);
});
}
public function system(): static
{
return $this->state(fn () => ['is_system' => true]);

View File

@@ -0,0 +1,578 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* WS-5d commit 2 atomic data migration. Every options datum in the
* database in form_fields and form_field_library, their translations
* bags, and the form_submissions.schema_snapshot +
* form_templates.schema_snapshot JSON blobs is converted to the new
* relational rich-shape representation.
*
* Strict dispatch per §17.4.4 / §8.7 convention:
*
* - FAIL on field_type {RADIO, SELECT, MULTISELECT, CHECKBOX_LIST}
* carrying non-null options (post-WS-5b TAG_PICKER seed-bug
* indicator).
* - FAIL on non-flat-string-array options shape.
* - FAIL on translations.{locale}.options[] length mismatch.
* - FAIL on non-string / >255-char translated labels.
* - FAIL on any residual translations.{locale}.options key after
* step C migration.
*
* Forward+back pair safe as a unit; partial rollback unsupported.
* Pair with `2026_04_27_100002_drop_form_field_options_json_columns`
* to restore the full pre-WS-5d data shape.
*/
return new class extends Migration
{
private const OPTION_BEARING_TYPES = ['RADIO', 'SELECT', 'MULTISELECT', 'CHECKBOX_LIST'];
public function up(): void
{
DB::transaction(function (): void {
$this->backfillOptionsTable('form_fields', 'form_field');
$this->backfillOptionsTable('form_field_library', 'form_field_library');
$this->backfillTranslations('form_fields', 'form_field');
$this->backfillTranslations('form_field_library', 'form_field_library');
$this->verifyTranslationsCleanedOrFail();
$this->rewriteSnapshots('form_submissions');
$this->rewriteSnapshots('form_templates');
});
}
public function down(): void
{
if (! Schema::hasTable('form_field_options')) {
return;
}
DB::transaction(function (): void {
$this->reconstructJsonAndTranslations('form_fields', 'form_field');
$this->reconstructJsonAndTranslations('form_field_library', 'form_field_library');
$this->revertSnapshots('form_submissions');
$this->revertSnapshots('form_templates');
DB::table('form_field_options')->delete();
});
}
/**
* Step A / B read pre-WS-5d JSON column rows and emit one
* form_field_options row per option string, sort_order = index.
*/
private function backfillOptionsTable(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'options')) {
return;
}
$rows = DB::table($table)
->whereNotNull('options')
->orderBy('id')
->get(['id', 'field_type', 'options']);
$now = now();
$inserts = [];
foreach ($rows as $row) {
$decoded = is_string($row->options)
? json_decode((string) $row->options, true)
: $row->options;
if ($decoded === null || $decoded === []) {
continue;
}
$fieldType = (string) $row->field_type;
if (! in_array($fieldType, self::OPTION_BEARING_TYPES, true)) {
throw new RuntimeException(sprintf(
'Stale options on %s row %s (type=%s). Expected null for field types '
.'outside the RADIO/SELECT/MULTISELECT/CHECKBOX_LIST set. Post-WS-5b '
.'TAG_PICKER options should live in form_field_configs. Fix the '
.'offending row and re-run the migration.',
$table,
$row->id,
$fieldType,
));
}
if (! is_array($decoded) || ! array_is_list($decoded)) {
throw new RuntimeException(sprintf(
'Unexpected options shape on %s row %s. Expected flat string array, '
.'got: %s. Fix the offending row and re-run.',
$table,
$row->id,
json_encode($decoded),
));
}
foreach ($decoded as $entry) {
if (! is_string($entry)) {
throw new RuntimeException(sprintf(
'Unexpected options shape on %s row %s. Expected flat string array, '
.'got entry: %s. Fix the offending row and re-run.',
$table,
$row->id,
json_encode($entry),
));
}
}
foreach ($decoded as $i => $entry) {
$inserts[] = [
'id' => (string) Str::ulid(),
'owner_type' => $ownerType,
'owner_id' => (string) $row->id,
'value' => (string) $entry,
'label' => (string) $entry,
'sort_order' => $i,
'translations' => null,
'created_at' => $now,
'updated_at' => $now,
];
}
}
if ($inserts === []) {
return;
}
foreach (array_chunk($inserts, 500) as $batch) {
DB::table('form_field_options')->insert($batch);
}
}
/**
* Step C for each owner row with translations.{locale}.options[],
* write each translated label onto its corresponding option row
* (matched by sort_order) and strip the per-locale options array
* from the translations bag.
*/
private function backfillTranslations(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'translations')) {
return;
}
$rows = DB::table($table)
->whereNotNull('translations')
->orderBy('id')
->get(['id', 'translations']);
foreach ($rows as $row) {
$bag = json_decode((string) $row->translations, true);
if (! is_array($bag)) {
continue;
}
$expectedCount = DB::table('form_field_options')
->where('owner_type', $ownerType)
->where('owner_id', (string) $row->id)
->count();
$changed = false;
foreach ($bag as $locale => $localeData) {
if (! is_array($localeData) || ! array_key_exists('options', $localeData)) {
continue;
}
$localeOptions = $localeData['options'];
if (! is_array($localeOptions) || ! array_is_list($localeOptions)) {
throw new RuntimeException(sprintf(
'Unexpected translations.%s.options shape on %s row %s. '
.'Expected flat array, got: %s.',
$locale,
$table,
$row->id,
json_encode($localeOptions),
));
}
if (count($localeOptions) !== $expectedCount) {
throw new RuntimeException(sprintf(
'Translations length mismatch on %s row %s locale %s: '
.'%d translations vs %d options.',
$table,
$row->id,
$locale,
count($localeOptions),
$expectedCount,
));
}
foreach ($localeOptions as $i => $translated) {
if (! is_string($translated) || $translated === '' || strlen($translated) > 255) {
throw new RuntimeException(sprintf(
'Invalid translated label on %s row %s locale %s index %d: '
.'must be a non-empty string ≤255 chars; got %s.',
$table,
$row->id,
$locale,
$i,
json_encode($translated),
));
}
$optionRow = DB::table('form_field_options')
->where('owner_type', $ownerType)
->where('owner_id', (string) $row->id)
->where('sort_order', $i)
->first();
if ($optionRow === null) {
throw new RuntimeException(sprintf(
'No option row at sort_order %d for %s row %s while '
.'migrating locale %s translations.',
$i,
$table,
$row->id,
$locale,
));
}
$existing = $optionRow->translations === null
? []
: (json_decode((string) $optionRow->translations, true) ?: []);
$existing[$locale] = $translated;
DB::table('form_field_options')
->where('id', $optionRow->id)
->update(['translations' => json_encode($existing)]);
}
unset($bag[$locale]['options']);
if ($bag[$locale] === []) {
unset($bag[$locale]);
}
$changed = true;
}
if ($changed) {
DB::table($table)->where('id', $row->id)->update([
'translations' => $bag === [] ? null : json_encode($bag),
]);
}
}
}
/**
* Step C verification no per-locale options[] key may survive on
* either source table after step C. Belt-and-braces guard against
* silent residuals.
*/
private function verifyTranslationsCleanedOrFail(): void
{
foreach (['form_fields', 'form_field_library'] as $table) {
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'translations')) {
continue;
}
$rows = DB::table($table)->whereNotNull('translations')->get(['id', 'translations']);
foreach ($rows as $row) {
$bag = json_decode((string) $row->translations, true);
if (! is_array($bag)) {
continue;
}
foreach ($bag as $locale => $data) {
if (is_array($data) && array_key_exists('options', $data)) {
throw new RuntimeException(sprintf(
'Residual translations.%s.options key on %s row %s after '
.'step C migration. This indicates step C did not run '
.'exhaustively — investigate before retrying.',
$locale,
$table,
$row->id,
));
}
}
}
}
}
/**
* Step D walk every snapshot's fields[*] and rewrite options to
* the new rich-shape array; strip the parallel
* translations.{locale}.options[] bag from each field's translations.
*/
private function rewriteSnapshots(string $table): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'schema_snapshot')) {
return;
}
$rows = DB::table($table)
->whereNotNull('schema_snapshot')
->orderBy('id')
->get(['id', 'schema_snapshot']);
foreach ($rows as $row) {
$snapshot = json_decode((string) $row->schema_snapshot, true);
if (! is_array($snapshot) || ! isset($snapshot['fields']) || ! is_array($snapshot['fields'])) {
continue;
}
$touched = false;
foreach ($snapshot['fields'] as $idx => $field) {
if (! is_array($field) || ! array_key_exists('options', $field) || $field['options'] === null) {
continue;
}
$fieldType = (string) ($field['field_type'] ?? '');
if (! in_array($fieldType, self::OPTION_BEARING_TYPES, true)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s has options but field_type is %s. '
.'Seed-bug — fix source data.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
$fieldType,
));
}
$originalOptions = $field['options'];
if (! is_array($originalOptions) || ! array_is_list($originalOptions)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s: unexpected options shape. '
.'Expected flat string array, got: %s.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
json_encode($originalOptions),
));
}
foreach ($originalOptions as $entry) {
if (! is_string($entry)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s: option entry not a string: %s.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
json_encode($entry),
));
}
}
$fieldTranslations = is_array($field['translations'] ?? null) ? $field['translations'] : [];
$newOptions = [];
foreach ($originalOptions as $i => $entry) {
$rich = [
'value' => (string) $entry,
'label' => (string) $entry,
'sort_order' => $i,
];
$perOptionTranslations = [];
foreach ($fieldTranslations as $locale => $localeData) {
if (! is_array($localeData) || ! isset($localeData['options'])) {
continue;
}
$localeOptions = $localeData['options'];
if (! is_array($localeOptions) || ! array_is_list($localeOptions)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s translations.%s.options: '
.'expected flat array, got %s.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
$locale,
json_encode($localeOptions),
));
}
if (count($localeOptions) !== count($originalOptions)) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s locale %s: translations '
.'length %d vs options length %d.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
$locale,
count($localeOptions),
count($originalOptions),
));
}
$translated = $localeOptions[$i];
if (! is_string($translated) || $translated === '' || strlen($translated) > 255) {
throw new RuntimeException(sprintf(
'Snapshot %s row %s field %s locale %s index %d: '
.'translated label must be a non-empty string ≤255 chars.',
$table,
$row->id,
$field['slug'] ?? '<unknown>',
$locale,
$i,
));
}
$perOptionTranslations[$locale] = $translated;
}
if ($perOptionTranslations !== []) {
$rich['translations'] = $perOptionTranslations;
}
$newOptions[] = $rich;
}
$snapshot['fields'][$idx]['options'] = $newOptions;
// Strip {locale}.options from this field's per-locale bag.
$cleanedTranslations = [];
foreach ($fieldTranslations as $locale => $localeData) {
if (is_array($localeData)) {
unset($localeData['options']);
if ($localeData !== []) {
$cleanedTranslations[$locale] = $localeData;
}
} else {
$cleanedTranslations[$locale] = $localeData;
}
}
$snapshot['fields'][$idx]['translations'] = $cleanedTranslations === []
? null
: $cleanedTranslations;
$touched = true;
}
if ($touched) {
DB::table($table)->where('id', $row->id)->update([
'schema_snapshot' => json_encode($snapshot),
]);
}
}
}
/**
* Rollback step A' / B' / C' read the relational rows back into the
* JSON column on the source tables, and rebuild
* translations.{locale}.options[] parallel arrays from the per-row
* translations bag.
*/
private function reconstructJsonAndTranslations(string $table, string $ownerType): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'options')) {
return;
}
$rowsByOwner = DB::table('form_field_options')
->where('owner_type', $ownerType)
->orderBy('owner_id')
->orderBy('sort_order')
->get();
$grouped = [];
foreach ($rowsByOwner as $row) {
$grouped[(string) $row->owner_id][] = $row;
}
foreach ($grouped as $ownerId => $rows) {
$values = array_map(static fn ($r): string => (string) $r->value, $rows);
DB::table($table)->where('id', $ownerId)->update([
'options' => json_encode($values),
]);
$perLocale = [];
foreach ($rows as $i => $r) {
$bag = $r->translations === null
? []
: (json_decode((string) $r->translations, true) ?: []);
foreach ($bag as $locale => $translated) {
$perLocale[$locale] ??= array_fill(0, count($rows), null);
$perLocale[$locale][$i] = (string) $translated;
}
}
if ($perLocale !== []) {
$existing = DB::table($table)->where('id', $ownerId)->value('translations');
$existingBag = $existing === null
? []
: (json_decode((string) $existing, true) ?: []);
if (! is_array($existingBag)) {
$existingBag = [];
}
foreach ($perLocale as $locale => $optionsList) {
$hasNonNull = false;
foreach ($optionsList as $entry) {
if ($entry !== null) {
$hasNonNull = true;
break;
}
}
if (! $hasNonNull) {
continue;
}
$existingBag[$locale] ??= [];
if (! is_array($existingBag[$locale])) {
$existingBag[$locale] = [];
}
$existingBag[$locale]['options'] = $optionsList;
}
DB::table($table)->where('id', $ownerId)->update([
'translations' => json_encode($existingBag),
]);
}
}
}
/**
* Rollback for snapshots convert rich-shape options back to flat
* string arrays + rebuild translations.{locale}.options[] parallel
* arrays from per-entry translations.
*/
private function revertSnapshots(string $table): void
{
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'schema_snapshot')) {
return;
}
$rows = DB::table($table)->whereNotNull('schema_snapshot')->get(['id', 'schema_snapshot']);
foreach ($rows as $row) {
$snapshot = json_decode((string) $row->schema_snapshot, true);
if (! is_array($snapshot) || ! isset($snapshot['fields']) || ! is_array($snapshot['fields'])) {
continue;
}
$touched = false;
foreach ($snapshot['fields'] as $idx => $field) {
if (! is_array($field) || ! isset($field['options']) || ! is_array($field['options'])) {
continue;
}
$opts = $field['options'];
if ($opts === [] || ! is_array($opts[0] ?? null)) {
continue;
}
$values = [];
$perLocale = [];
$count = count($opts);
foreach ($opts as $i => $rich) {
if (! is_array($rich)) {
continue;
}
$values[] = (string) ($rich['value'] ?? '');
$bag = is_array($rich['translations'] ?? null) ? $rich['translations'] : [];
foreach ($bag as $locale => $translated) {
$perLocale[$locale] ??= array_fill(0, $count, null);
$perLocale[$locale][$i] = (string) $translated;
}
}
$snapshot['fields'][$idx]['options'] = $values;
if ($perLocale !== []) {
$existing = is_array($field['translations'] ?? null) ? $field['translations'] : [];
foreach ($perLocale as $locale => $list) {
$existing[$locale] ??= [];
if (! is_array($existing[$locale])) {
$existing[$locale] = [];
}
$existing[$locale]['options'] = $list;
}
$snapshot['fields'][$idx]['translations'] = $existing;
}
$touched = true;
}
if ($touched) {
DB::table($table)->where('id', $row->id)->update([
'schema_snapshot' => json_encode($snapshot),
]);
}
}
}
};

View File

@@ -20,6 +20,7 @@ use App\Models\Organisation;
use App\Models\Person;
use App\Models\PersonTag;
use App\Models\UserOrganisationTag;
use App\Services\FormBuilder\FormFieldOptionService;
use App\Services\FormBuilder\FormSubmissionService;
use App\Services\FormBuilder\FormValueService;
use Illuminate\Console\Command;
@@ -50,11 +51,7 @@ final class FormBuilderDevSeeder
['type' => FormFieldType::SELECT, 'slug' => 'shirtmaat', 'label' => 'Shirtmaat', 'options' => ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'], 'is_filterable' => true, 'display_width' => 'half'],
['type' => FormFieldType::MULTISELECT, 'slug' => 'dieetwensen', 'label' => 'Dieetwensen', 'options' => ['Vegetarisch', 'Veganistisch', 'Halal', 'Glutenvrij', 'Lactosevrij', "Geen pinda's", 'Geen noten'], 'is_filterable' => true, 'display_width' => 'half'],
['type' => FormFieldType::HEADING, 'slug' => 'vergoeding', 'label' => 'Vergoeding', 'help_text' => 'Kies hoe je wilt worden bedankt voor je inzet', 'display_width' => 'full'],
['type' => FormFieldType::RADIO, 'slug' => 'vergoedingstype', 'label' => 'Vergoedingstype', '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'],
], 'is_required' => true, 'display_width' => 'full'],
['type' => FormFieldType::RADIO, 'slug' => 'vergoedingstype', 'label' => 'Vergoedingstype', 'options' => ['Pro Deo', 'Entreeticket', 'Vrijwilligersvergoeding'], 'is_required' => true, 'display_width' => 'full'],
['type' => FormFieldType::HEADING, 'slug' => 'noodcontact', 'label' => 'Noodcontact', 'help_text' => 'Wie kunnen we bereiken bij calamiteiten?', 'display_width' => 'full'],
['type' => FormFieldType::TEXT, 'slug' => 'noodcontact-naam', 'label' => 'Noodcontact naam', 'is_pii' => true, 'display_width' => 'half'],
['type' => FormFieldType::TEXT, 'slug' => 'noodcontact-telefoon', 'label' => 'Noodcontact telefoon', 'is_pii' => true, 'display_width' => 'half'],
@@ -94,7 +91,7 @@ final class FormBuilderDevSeeder
'label' => $field['label'],
'help_text' => $field['help_text'] ?? null,
'section_slug' => null,
'options' => $field['options'] ?? null,
'options' => self::richOptionsForSnapshot($field['options'] ?? null),
'validation_rules' => null,
'is_required' => $field['is_required'] ?? false,
'is_filterable' => $field['is_filterable'] ?? false,
@@ -149,14 +146,15 @@ final class FormBuilderDevSeeder
'auto_save_enabled' => false,
]);
$optionService = app(FormFieldOptionService::class);
foreach (self::canonicalFields() as $sortOrder => $field) {
FormField::create([
$created = FormField::create([
'form_schema_id' => $schema->id,
'field_type' => $field['type']->value,
'slug' => $field['slug'],
'label' => $field['label'],
'help_text' => $field['help_text'] ?? null,
'options' => $field['options'] ?? null,
'is_required' => $field['is_required'] ?? false,
'is_filterable' => $field['is_filterable'] ?? false,
'is_portal_visible' => true,
@@ -166,11 +164,58 @@ final class FormBuilderDevSeeder
'value_storage_hint' => $field['type']->recommendedValueStorageHint(),
'sort_order' => $sortOrder + 1,
]);
$specs = self::optionSpecsFor($field['options'] ?? null);
if ($specs !== []) {
$optionService->replaceOptions($created, $specs);
}
}
return $schema;
}
/**
* Convert a flat string options array (canonical seeder shape) into
* the rich-shape spec list consumed by FormFieldOptionService.
*
* @param list<string>|null $options
* @return list<array{value:string,label:string,sort_order:int}>
*/
private static function optionSpecsFor(?array $options): array
{
if ($options === null || $options === []) {
return [];
}
$specs = [];
foreach ($options as $i => $entry) {
$entry = (string) $entry;
$specs[] = [
'value' => $entry,
'label' => $entry,
'sort_order' => $i,
];
}
return $specs;
}
/**
* Convert a flat string options array into the rich-shape JSON used
* inside form_templates / form_submissions snapshots. Returns null
* for option-less field types (preserves snapshot null contract).
*
* @param list<string>|null $options
* @return list<array{value:string,label:string,sort_order:int}>|null
*/
private static function richOptionsForSnapshot(?array $options): ?array
{
if ($options === null || $options === []) {
return null;
}
return self::optionSpecsFor($options);
}
/**
* For each person with status applied/approved/no_show on this event,
* create one FormSubmission with a realistic handful of FormValues.
@@ -359,6 +404,7 @@ final class FormBuilderDevSeeder
]);
$ruleService = app(\App\Services\FormBuilder\FormFieldValidationRuleService::class);
$optionService = app(FormFieldOptionService::class);
foreach (self::showcaseFieldDefinitions() as $sortOrder => $def) {
$field = FormField::create([
@@ -367,7 +413,6 @@ final class FormBuilderDevSeeder
'slug' => $def['slug'],
'label' => $def['label'],
'help_text' => $def['help_text'] ?? null,
'options' => $def['options'] ?? null,
'is_required' => $def['is_required'] ?? false,
'is_filterable' => $def['is_filterable'] ?? false,
'is_portal_visible' => true,
@@ -379,6 +424,11 @@ final class FormBuilderDevSeeder
'sort_order' => $sortOrder + 1,
]);
$specs = self::optionSpecsFor($def['options'] ?? null);
if ($specs !== []) {
$optionService->replaceOptions($field, $specs);
}
// Relational validation rules (WS-5b). The SECTION_PRIORITY
// field carries the UI soft cap as a `max_selected` row; other
// fields in the showcase have no rules yet.