From 15e4e49d8c906d5ebb19aa0fee11d9cd76cd4b7c Mon Sep 17 00:00:00 2001 From: "bert.hausmans" Date: Sat, 25 Apr 2026 02:21:26 +0200 Subject: [PATCH] feat(form-field): backfill form_fields.options to form_field_options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../Services/FormBuilder/FormFieldService.php | 7 +- .../FormBuilder/FormFieldFactory.php | 33 +- .../FormBuilder/FormFieldLibraryFactory.php | 30 +- ..._27_100001_backfill_form_field_options.php | 578 ++++++++++++++++++ api/database/seeders/FormBuilderDevSeeder.php | 68 ++- .../FormFieldBindingMigrationTest.php | 16 +- .../ConditionalLogicBackfillTest.php | 15 +- .../Options/FormFieldOptionsBackfillTest.php | 410 +++++++++++++ ...eldServiceInsertFromLibraryOptionsTest.php | 79 +++ .../FormFieldValidationRuleBackfillTest.php | 18 +- 10 files changed, 1216 insertions(+), 38 deletions(-) create mode 100644 api/database/migrations/2026_04_27_100001_backfill_form_field_options.php create mode 100644 api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php create mode 100644 api/tests/Feature/FormBuilder/Options/FormFieldServiceInsertFromLibraryOptionsTest.php diff --git a/api/app/Services/FormBuilder/FormFieldService.php b/api/app/Services/FormBuilder/FormFieldService.php index 62b56c70..b776cfea 100644 --- a/api/app/Services/FormBuilder/FormFieldService.php +++ b/api/app/Services/FormBuilder/FormFieldService.php @@ -30,6 +30,7 @@ final class FormFieldService private readonly FormFieldValidationRuleService $validationRuleService, private readonly FormFieldConfigService $configService, private readonly FormFieldConditionalLogicService $conditionalLogicService, + private readonly FormFieldOptionService $optionService, ) {} public function create(FormSchema $schema, array $data): FormField @@ -270,13 +271,16 @@ final class FormFieldService 'slug' => $this->ensureUniqueSlug($schema, $library->slug), 'label' => $library->label, 'help_text' => $library->help_text, - 'options' => $library->options, 'is_required' => (bool) $library->default_is_required, 'is_filterable' => (bool) $library->default_is_filterable, 'translations' => $library->translations, 'sort_order' => $this->nextSortOrder($schema), ], $overrides); + // Options: post-WS-5d row-clone via the service. The legacy JSON + // column is no longer copied. + unset($data['options']); + if (! isset($data['slug']) || $data['slug'] === '') { $data['slug'] = $this->ensureUniqueSlug($schema, $library->slug); } else { @@ -289,6 +293,7 @@ final class FormFieldService $this->bindingService->copyBindings($library, $field); $this->validationRuleService->copyRules($library, $field); $this->configService->copyConfigs($library, $field); + $this->optionService->copyOptions($library, $field); FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count'); diff --git a/api/database/factories/FormBuilder/FormFieldFactory.php b/api/database/factories/FormBuilder/FormFieldFactory.php index 691d3455..382a959d 100644 --- a/api/database/factories/FormBuilder/FormFieldFactory.php +++ b/api/database/factories/FormBuilder/FormFieldFactory.php @@ -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}> $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 () => [ diff --git a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php index ef1dbd94..df7c3a0d 100644 --- a/api/database/factories/FormBuilder/FormFieldLibraryFactory.php +++ b/api/database/factories/FormBuilder/FormFieldLibraryFactory.php @@ -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}> $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]); diff --git a/api/database/migrations/2026_04_27_100001_backfill_form_field_options.php b/api/database/migrations/2026_04_27_100001_backfill_form_field_options.php new file mode 100644 index 00000000..afa0cf20 --- /dev/null +++ b/api/database/migrations/2026_04_27_100001_backfill_form_field_options.php @@ -0,0 +1,578 @@ +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'] ?? '', + $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'] ?? '', + 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'] ?? '', + 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'] ?? '', + $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'] ?? '', + $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'] ?? '', + $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), + ]); + } + } + } +}; diff --git a/api/database/seeders/FormBuilderDevSeeder.php b/api/database/seeders/FormBuilderDevSeeder.php index 0287829f..dfbdc17f 100644 --- a/api/database/seeders/FormBuilderDevSeeder.php +++ b/api/database/seeders/FormBuilderDevSeeder.php @@ -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|null $options + * @return list + */ + 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|null $options + * @return list|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. diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 4ecad6df..abd87715 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -33,14 +33,14 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_forward_migrations_backfill_rows_from_both_json_sources(): void { - // Roll back to pre-WS-5a state: 1 WS-5d migration (create-options) + - // 4 WS-5c migrations (drop-conditional-logic-col, + // Roll back to pre-WS-5a state: 2 WS-5d migrations (backfill-options, + // create-options) + 4 WS-5c migrations (drop-conditional-logic-col, // backfill-conditional-logic, create-conditional-logic-conditions, // create-conditional-logic-groups) + 5 WS-5b migrations // (drop-validation-cols, configs-backfill, create-configs, // validation-rules-backfill, create-validation-rules) + - // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 12. - $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); + // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 13. + $this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -101,8 +101,8 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { - // Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (12 migrations). - $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); + // Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (13 migrations). + $this->artisan('migrate:rollback', ['--step' => 13])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); @@ -112,12 +112,12 @@ final class FormFieldBindingMigrationTest extends TestCase $this->assertFalse(Schema::hasColumn('form_fields', 'binding')); $this->assertSame(5, DB::table('form_field_bindings')->count()); - // Step back over WS-5d (1 migration) + WS-5c (4 migrations) + + // Step back over WS-5d (2 migrations) + WS-5c (4 migrations) + // WS-5b (5 migrations) in one go → restores the pre-WS-5b state // (conditional-logic, validation-rules, configs and options tables // gone, validation_rules JSON columns reappear on source tables; // binding contract intact). - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_options')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index 716b1bed..9ac4baa2 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -31,10 +31,11 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_forward_backfill_builds_nested_tree_from_legacy_json(): void { - // Roll back the WS-5d create-options + WS-5c drop-cl-col + WS-5c - // backfill-cl migrations to land in the conditional-logic JSON-era - // state with no relational form_field_options table yet. - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + // Roll back the WS-5d backfill-options + create-options + WS-5c + // drop-cl-col + WS-5c backfill-cl migrations to land in the + // conditional-logic JSON-era state with no relational + // form_field_options table yet. + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); $fieldId = $this->seedFieldWithJson([ @@ -155,7 +156,7 @@ final class ConditionalLogicBackfillTest extends TestCase ]); // Roll back only the backfill migration — writes the JSON back. - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) @@ -182,7 +183,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedFieldWithJson([ 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], @@ -195,7 +196,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_comparison_operator_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 4])->assertSuccessful(); $this->seedFieldWithJson([ 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php new file mode 100644 index 00000000..40b2e2fd --- /dev/null +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionsBackfillTest.php @@ -0,0 +1,410 @@ +artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->assertTrue(Schema::hasTable('form_field_options')); + $this->assertTrue(Schema::hasColumn('form_fields', 'options')); + + [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); + $submissionId = $this->seedSubmissionWithSnapshot($selectId); + $templateId = $this->seedTemplateWithSnapshot(); + + $this->artisan('migrate')->assertSuccessful(); + + // Forward state: form_field_options rows present. + $selectOptions = DB::table('form_field_options') + ->where('owner_type', 'form_field') + ->where('owner_id', $selectId) + ->orderBy('sort_order') + ->get(); + $this->assertCount(3, $selectOptions); + $this->assertSame(['XS', 'S', 'M'], $selectOptions->pluck('value')->all()); + $this->assertSame(['XS', 'S', 'M'], $selectOptions->pluck('label')->all()); + // Translations from form_fields.translations.{locale}.options moved + // onto each option row, indexed by sort_order. + $small = $selectOptions->firstWhere('sort_order', 1); + $this->assertSame(['de' => 'Klein'], json_decode((string) $small->translations, true)); + $xs = $selectOptions->firstWhere('sort_order', 0); + $this->assertSame(['de' => 'Größe XS'], json_decode((string) $xs->translations, true)); + + $multiOptions = DB::table('form_field_options') + ->where('owner_type', 'form_field') + ->where('owner_id', $multiId) + ->orderBy('sort_order') + ->get(); + $this->assertCount(2, $multiOptions); + + $libraryOptions = DB::table('form_field_options') + ->where('owner_type', 'form_field_library') + ->where('owner_id', $libraryId) + ->orderBy('sort_order') + ->get(); + $this->assertCount(2, $libraryOptions); + $this->assertSame(['lib_a', 'lib_b'], $libraryOptions->pluck('value')->all()); + + // Per-locale options[] stripped from form_fields.translations. + $remaining = DB::table('form_fields')->where('id', $selectId)->value('translations'); + $bag = json_decode((string) $remaining, true); + $this->assertIsArray($bag); + foreach ($bag as $locale => $localeBag) { + $this->assertArrayNotHasKey('options', $localeBag, "locale {$locale} retained options key"); + } + + // Submission snapshot rewritten to rich shape. + $submission = DB::table('form_submissions')->where('id', $submissionId)->first(); + $snapshot = json_decode((string) $submission->schema_snapshot, true); + $field = $snapshot['fields'][0]; + $this->assertSame([ + ['value' => 'XS', 'label' => 'XS', 'sort_order' => 0, 'translations' => ['de' => 'Größe XS']], + ['value' => 'S', 'label' => 'S', 'sort_order' => 1, 'translations' => ['de' => 'Klein']], + ['value' => 'M', 'label' => 'M', 'sort_order' => 2, 'translations' => ['de' => 'Mittel']], + ], $field['options']); + // Field-level translations bag has the {locale}.options key + // stripped. + if (is_array($field['translations'] ?? null)) { + foreach ($field['translations'] as $locale => $localeBag) { + $this->assertArrayNotHasKey('options', $localeBag); + } + } + + // Template snapshot rewritten the same way. + $template = DB::table('form_templates')->where('id', $templateId)->first(); + $tplSnap = json_decode((string) $template->schema_snapshot, true); + $this->assertSame( + [ + ['value' => 'A', 'label' => 'A', 'sort_order' => 0], + ['value' => 'B', 'label' => 'B', 'sort_order' => 1], + ], + $tplSnap['fields'][0]['options'], + ); + } + + public function test_rollback_reconstructs_json_columns_and_snapshots(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + [$selectId, $multiId, $libraryId] = $this->seedFieldsAndLibraryWithJson(); + $submissionId = $this->seedSubmissionWithSnapshot($selectId); + + $this->artisan('migrate')->assertSuccessful(); + $this->assertSame( + 7, + DB::table('form_field_options')->count(), + '3 + 2 + 2 owner rows', + ); + + // Step back over only the backfill migration → JSON columns repopulate + // and snapshots revert to flat-string-array shape. + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->assertSame(0, DB::table('form_field_options')->count()); + + $select = DB::table('form_fields')->where('id', $selectId)->first(); + $this->assertSame(['XS', 'S', 'M'], json_decode((string) $select->options, true)); + $bag = json_decode((string) $select->translations, true); + $this->assertSame(['Größe XS', 'Klein', 'Mittel'], $bag['de']['options']); + + $library = DB::table('form_field_library')->where('id', $libraryId)->first(); + $this->assertSame(['lib_a', 'lib_b'], json_decode((string) $library->options, true)); + + $submission = DB::table('form_submissions')->where('id', $submissionId)->first(); + $snapshot = json_decode((string) $submission->schema_snapshot, true); + $this->assertSame(['XS', 'S', 'M'], $snapshot['fields'][0]['options']); + $this->assertSame(['Größe XS', 'Klein', 'Mittel'], $snapshot['fields'][0]['translations']['de']['options']); + } + + public function test_fails_when_options_present_on_non_option_field_type(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->seedFieldWithOptions('TAG_PICKER', ['Veiligheid', 'Horeca']); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Stale options on form_fields.*type=TAG_PICKER/'); + $this->artisan('migrate')->assertSuccessful(); + } + + public function test_fails_when_options_contains_non_string_entry(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + + $this->seedFieldWithOptionsRaw('SELECT', json_encode([ + ['label' => 'A'], + ['label' => 'B'], + ])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Expected flat string array/'); + $this->artisan('migrate')->assertSuccessful(); + } + + public function test_fails_when_options_is_object_shape(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + + $this->seedFieldWithOptionsRaw('SELECT', json_encode([ + 'XS' => 'Extra small', + 'S' => 'Small', + ])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Expected flat string array/'); + $this->artisan('migrate')->assertSuccessful(); + } + + public function test_fails_on_translations_length_mismatch(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S', 'M']), json_encode([ + 'de' => ['options' => ['Klein', 'Mittel']], // 2 vs 3 + ])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Translations length mismatch/'); + $this->artisan('migrate')->assertSuccessful(); + } + + public function test_fails_on_non_string_translation(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS', 'S']), json_encode([ + 'de' => ['options' => ['Klein', 42]], + ])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Invalid translated label/'); + $this->artisan('migrate')->assertSuccessful(); + } + + public function test_fails_on_oversized_translation(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->seedFieldWithOptionsRaw('SELECT', json_encode(['XS']), json_encode([ + 'de' => ['options' => [str_repeat('x', 256)]], + ])); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Invalid translated label/'); + $this->artisan('migrate')->assertSuccessful(); + } + + public function test_fails_when_snapshot_options_present_on_non_option_field_type(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->seedTemplateWithSnapshotRaw([ + 'fields' => [[ + 'id' => (string) Str::ulid(), + 'slug' => 'tags', + 'field_type' => 'TAG_PICKER', + 'label' => 'Tags', + 'options' => ['Veiligheid'], + ]], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/Snapshot.*field_type is TAG_PICKER/'); + $this->artisan('migrate')->assertSuccessful(); + } + + /** @return array{0:string,1:string,2:string} */ + private function seedFieldsAndLibraryWithJson(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $selectId = (string) Str::ulid(); + $multiId = (string) Str::ulid(); + + DB::table('form_fields')->insert([ + 'id' => $selectId, + 'form_schema_id' => $schema->id, + 'field_type' => 'SELECT', + 'slug' => 'shirtmaat', + 'label' => 'Shirtmaat', + 'options' => json_encode(['XS', 'S', 'M']), + 'translations' => json_encode([ + 'de' => ['label' => 'Größe', 'options' => ['Größe XS', 'Klein', 'Mittel']], + 'en' => ['label' => 'Size'], + ]), + 'value_storage_hint' => 'string', + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('form_fields')->insert([ + 'id' => $multiId, + 'form_schema_id' => $schema->id, + 'field_type' => 'MULTISELECT', + 'slug' => 'dieet', + 'label' => 'Dieet', + 'options' => json_encode(['Vegan', 'Halal']), + 'translations' => null, + 'value_storage_hint' => 'json', + 'sort_order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $libraryId = (string) Str::ulid(); + DB::table('form_field_library')->insert([ + 'id' => $libraryId, + 'organisation_id' => $org->id, + 'name' => 'Lib Select', + 'slug' => 'lib-select', + 'field_type' => 'SELECT', + 'label' => 'Library', + 'options' => json_encode(['lib_a', 'lib_b']), + 'default_is_required' => false, + 'default_is_filterable' => false, + 'usage_count' => 0, + 'is_system' => false, + 'is_active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return [$selectId, $multiId, $libraryId]; + } + + private function seedSubmissionWithSnapshot(string $fieldId): string + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $submissionId = (string) Str::ulid(); + DB::table('form_submissions')->insert([ + 'id' => $submissionId, + 'organisation_id' => $org->id, + 'form_schema_id' => $schema->id, + 'subject_type' => 'person', + 'subject_id' => (string) Str::ulid(), + 'status' => 'submitted', + 'is_test' => false, + 'submitted_in_locale' => 'nl', + 'schema_snapshot' => json_encode([ + 'fields' => [[ + 'id' => $fieldId, + 'slug' => 'shirtmaat', + 'field_type' => 'SELECT', + 'label' => 'Shirtmaat', + 'options' => ['XS', 'S', 'M'], + 'translations' => [ + 'de' => ['label' => 'Größe', 'options' => ['Größe XS', 'Klein', 'Mittel']], + ], + ]], + ]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $submissionId; + } + + private function seedTemplateWithSnapshot(): string + { + $org = Organisation::factory()->create(); + + $templateId = (string) Str::ulid(); + DB::table('form_templates')->insert([ + 'id' => $templateId, + 'organisation_id' => $org->id, + 'name' => 'Tpl', + 'slug' => 'tpl', + 'purpose' => 'event_registration', + 'description' => null, + 'schema_snapshot' => json_encode([ + 'fields' => [[ + 'id' => (string) Str::ulid(), + 'slug' => 'choice', + 'field_type' => 'RADIO', + 'label' => 'Choice', + 'options' => ['A', 'B'], + ]], + ]), + 'is_active' => true, + 'is_system' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $templateId; + } + + private function seedFieldWithOptions(string $fieldType, array $options): string + { + return $this->seedFieldWithOptionsRaw($fieldType, json_encode($options)); + } + + private function seedFieldWithOptionsRaw(string $fieldType, string $optionsJson, ?string $translationsJson = null): string + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $id = (string) Str::ulid(); + DB::table('form_fields')->insert([ + 'id' => $id, + 'form_schema_id' => $schema->id, + 'field_type' => $fieldType, + 'slug' => 'fld-'.Str::lower(Str::random(4)), + 'label' => 'Test', + 'options' => $optionsJson, + 'translations' => $translationsJson, + 'value_storage_hint' => 'string', + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $id; + } + + private function seedTemplateWithSnapshotRaw(array $snapshot): string + { + $org = Organisation::factory()->create(); + $id = (string) Str::ulid(); + DB::table('form_templates')->insert([ + 'id' => $id, + 'organisation_id' => $org->id, + 'name' => 'Tpl', + 'slug' => 'tpl-'.Str::lower(Str::random(4)), + 'purpose' => 'event_registration', + 'description' => null, + 'schema_snapshot' => json_encode($snapshot), + 'is_active' => true, + 'is_system' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $id; + } +} diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldServiceInsertFromLibraryOptionsTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldServiceInsertFromLibraryOptionsTest.php new file mode 100644 index 00000000..e55bcfbd --- /dev/null +++ b/api/tests/Feature/FormBuilder/Options/FormFieldServiceInsertFromLibraryOptionsTest.php @@ -0,0 +1,79 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $library = FormFieldLibrary::factory() + ->withOptions([ + ['value' => 'red', 'label' => 'Red', 'sort_order' => 0, 'translations' => ['nl' => 'Rood']], + ['value' => 'green', 'label' => 'Green', 'sort_order' => 1], + ]) + ->create(['organisation_id' => $org->id]); + + $field = app(FormFieldService::class)->insertFromLibrary($schema, $library); + + $optionService = app(FormFieldOptionService::class); + $copied = $optionService->optionsFor($field); + $libraryOptions = $optionService->optionsFor($library); + + $this->assertCount(2, $copied); + $this->assertSame(['red', 'green'], $copied->pluck('value')->all()); + $this->assertSame([0, 1], $copied->pluck('sort_order')->all()); + $this->assertSame(['nl' => 'Rood'], $copied->firstWhere('value', 'red')->translations); + $this->assertNull($copied->firstWhere('value', 'green')->translations); + + // Distinct row IDs — options are CLONED, not shared. + $this->assertNotSame( + $libraryOptions->pluck('id')->sort()->values()->all(), + $copied->pluck('id')->sort()->values()->all(), + ); + } + + public function test_insert_from_library_does_not_carry_legacy_options_translations_key(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $library = FormFieldLibrary::factory() + ->withOptions(['a', 'b']) + ->create([ + 'organisation_id' => $org->id, + // Library carries label/help_text translations only — no + // {locale}.options[] parallel array. WS-5d strips that key + // from the FormField's translations bag too. + 'translations' => ['nl' => ['label' => 'Bibliotheek']], + ]); + + $field = app(FormFieldService::class)->insertFromLibrary($schema, $library); + + $bag = $field->fresh()->translations ?? []; + if (is_array($bag)) { + foreach ($bag as $localeBag) { + $this->assertArrayNotHasKey('options', is_array($localeBag) ? $localeBag : []); + } + } + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index ade2dd44..62dd2335 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -31,15 +31,15 @@ final class FormFieldValidationRuleBackfillTest extends TestCase public function test_forward_migration_backfills_rows_with_field_type_dispatch(): void { - // Roll back: 1 WS-5d migration (create-options) + + // Roll back: 2 WS-5d migrations (backfill-options, create-options) + // 4 WS-5c migrations (drop-conditional-logic-col, // backfill-conditional-logic, create-conditional-logic-conditions, // create-conditional-logic-groups) + 5 WS-5b migrations // (drop-cols + configs-backfill + create-configs + - // validation-rules-backfill + create-validation-rules) = 10. + // validation-rules-backfill + create-validation-rules) = 11. // Brings us to the pre-WS-5b state: validation_rules JSON column // present, no relational tables for WS-5b/c/d. - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -100,7 +100,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -124,7 +124,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -151,7 +151,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -168,7 +168,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // (validation_rules JSON column present; no relational tables for // WS-5b). Step count: drop-cols + configs-backfill + create-configs // + validation-rules-backfill + create-validation-rules = 5. - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -187,7 +187,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // full-back-then-full-forward cycle — rolling back all WS-5b // migrations restores the pre-WS-5b state (columns present on // source tables; validation rules relational table gone). - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -202,7 +202,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // Roll back WS-5b fully → column reappears and carries canonical JSON // reconstructed from the relational rows. - $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first();