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:
@@ -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');
|
||||
|
||||
|
||||
@@ -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 () => [
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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']]],
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Options;
|
||||
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Rolls back the WS-5d backfill migration, seeds pre-WS-5d JSON into
|
||||
* `form_fields.options`, `form_field_library.options`, and the
|
||||
* snapshot blobs, then runs the migration forward and back asserting:
|
||||
*
|
||||
* - Forward: rows land in form_field_options with the correct
|
||||
* owner_type/owner_id/value/label/sort_order and translations
|
||||
* stripped from the per-locale parallel options[] arrays;
|
||||
* snapshots rewritten to rich shape.
|
||||
* - Backward: the rollback pair reconstructs the pre-WS-5d JSON
|
||||
* shape on every owner row + snapshot.
|
||||
*/
|
||||
final class FormFieldOptionsBackfillTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_forward_migration_backfills_rows_strips_translations_and_rewrites_snapshots(): void
|
||||
{
|
||||
// Roll back only the backfill migration (latest WS-5d step).
|
||||
// Leaves the form_field_options table in place, JSON columns
|
||||
// present on the source tables, and snapshots in pre-WS-5d shape.
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\FormBuilder\Options;
|
||||
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Models\FormBuilder\FormFieldOption;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use App\Services\FormBuilder\FormFieldOptionService;
|
||||
use App\Services\FormBuilder\FormFieldService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* WS-5d commit 2 — confirms FormFieldService::insertFromLibrary copies
|
||||
* options via FormFieldOptionService::copyOptions instead of JSON-copy.
|
||||
* Each library option row is cloned with new ID, owner pointed at the
|
||||
* new field; translations and sort_order preserved row-for-row.
|
||||
*/
|
||||
final class FormFieldServiceInsertFromLibraryOptionsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_insert_from_library_clones_options_via_service_path(): void
|
||||
{
|
||||
$org = Organisation::factory()->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 : []);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user