diff --git a/api/app/Exceptions/FormBuilder/InvalidOptionSpecException.php b/api/app/Exceptions/FormBuilder/InvalidOptionSpecException.php new file mode 100644 index 00000000..c3b47280 --- /dev/null +++ b/api/app/Exceptions/FormBuilder/InvalidOptionSpecException.php @@ -0,0 +1,19 @@ +morphMany(FormFieldConfig::class, 'owner'); } + public function options(): MorphMany + { + return $this->morphMany(FormFieldOption::class, 'owner') + ->orderBy('sort_order'); + } + public function conditionalLogicGroups(): HasMany { return $this->hasMany(FormFieldConditionalLogicGroup::class, 'form_field_id'); diff --git a/api/app/Models/FormBuilder/FormFieldLibrary.php b/api/app/Models/FormBuilder/FormFieldLibrary.php index 792c0895..d3ce490e 100644 --- a/api/app/Models/FormBuilder/FormFieldLibrary.php +++ b/api/app/Models/FormBuilder/FormFieldLibrary.php @@ -77,4 +77,10 @@ final class FormFieldLibrary extends Model { return $this->morphMany(FormFieldConfig::class, 'owner'); } + + public function options(): MorphMany + { + return $this->morphMany(FormFieldOption::class, 'owner') + ->orderBy('sort_order'); + } } diff --git a/api/app/Models/FormBuilder/FormFieldOption.php b/api/app/Models/FormBuilder/FormFieldOption.php new file mode 100644 index 00000000..e7f29709 --- /dev/null +++ b/api/app/Models/FormBuilder/FormFieldOption.php @@ -0,0 +1,87 @@ +|null $translations + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + */ +final class FormFieldOption extends Model +{ + use HasFactory; + use HasUlids; + + protected $table = 'form_field_options'; + + protected static function booted(): void + { + static::addGlobalScope(new FormFieldOptionScope()); + } + + protected $fillable = [ + 'owner_type', + 'owner_id', + 'value', + 'label', + 'sort_order', + 'translations', + ]; + + /** @var array */ + protected $casts = [ + 'translations' => 'array', + 'sort_order' => 'int', + ]; + + public function owner(): MorphTo + { + return $this->morphTo('owner', 'owner_type', 'owner_id'); + } + + /** + * Single source of truth for the per-row JSON shape consumed by + * snapshot writer, API resources, and activity-log diff + * reconstruction. Empty translations bag is omitted (preserves the + * current contract for monolingual schemas). + * + * @return array{value:string,label:string,sort_order:int,translations?:array} + */ + public function toJsonShape(): array + { + $shape = [ + 'value' => $this->value, + 'label' => $this->label, + 'sort_order' => $this->sort_order, + ]; + + if (is_array($this->translations) && $this->translations !== []) { + $shape['translations'] = $this->translations; + } + + return $shape; + } +} diff --git a/api/app/Models/Scopes/FormFieldOptionScope.php b/api/app/Models/Scopes/FormFieldOptionScope.php new file mode 100644 index 00000000..c7be15d4 --- /dev/null +++ b/api/app/Models/Scopes/FormFieldOptionScope.php @@ -0,0 +1,107 @@ +resolveOrganisationId(); + if ($orgId === null) { + return; + } + + $fieldIds = FormField::query() + ->withoutGlobalScope(OrganisationScope::class) + ->whereIn( + 'form_schema_id', + FormSchema::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'), + ) + ->select('id'); + + $libraryIds = FormFieldLibrary::query() + ->withoutGlobalScope(OrganisationScope::class) + ->where('organisation_id', $orgId) + ->select('id'); + + $table = $model->getTable(); + + $builder->where(function (Builder $outer) use ($table, $fieldIds, $libraryIds): void { + $outer->where(function (Builder $q) use ($table, $fieldIds): void { + $q->where("$table.owner_type", 'form_field') + ->whereIn("$table.owner_id", $fieldIds); + })->orWhere(function (Builder $q) use ($table, $libraryIds): void { + $q->where("$table.owner_type", 'form_field_library') + ->whereIn("$table.owner_id", $libraryIds); + }); + }); + } + + private function resolveOrganisationId(): ?string + { + if ($this->organisationId !== null) { + return $this->organisationId; + } + + $route = request()->route(); + if ($route === null) { + return null; + } + + $org = $route->parameter('organisation'); + + if ($org instanceof \App\Models\Organisation) { + return $org->id; + } + + if (is_string($org) && $org !== '') { + return $org; + } + + $event = $route->parameter('event'); + if ($event instanceof \App\Models\Event) { + return $event->organisation_id; + } + + return null; + } +} diff --git a/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php b/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php index 081d1774..6cc77df8 100644 --- a/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php +++ b/api/app/Observers/FormBuilder/FormFieldChildTablesCascadeObserver.php @@ -20,6 +20,7 @@ use Illuminate\Support\Facades\Schema; * per addendum Q3: library * is explicitly out of * scope for conditional_logic) + * - `form_field_options` (WS-5d — both owner types) * * The conditions sibling table cascades automatically via the `group_id` * FK on the groups table — no direct entry here. @@ -59,6 +60,13 @@ final class FormFieldChildTablesCascadeObserver ->delete(); } + if (Schema::hasTable('form_field_options')) { + DB::table('form_field_options') + ->where('owner_type', $ownerType) + ->where('owner_id', $ownerId) + ->delete(); + } + // Conditional-logic groups only apply to FormField (addendum Q3: // library mirror is out of scope). Condition rows cascade via // the group_id FK on the conditions table. diff --git a/api/app/Services/FormBuilder/FormFieldOptionService.php b/api/app/Services/FormBuilder/FormFieldOptionService.php new file mode 100644 index 00000000..e3cb8203 --- /dev/null +++ b/api/app/Services/FormBuilder/FormFieldOptionService.php @@ -0,0 +1,246 @@ + + */ + public function optionsFor(FormField|FormFieldLibrary $owner): Collection + { + $type = $this->ownerTypeFor($owner); + + return FormFieldOption::query() + ->where('owner_type', $type) + ->where('owner_id', $owner->getKey()) + ->orderBy('sort_order') + ->get(); + } + + /** + * Replace the full option set on an owner transactionally. Validates + * every spec's shape (value / label / sort_order / translations / + * uniqueness) before any write lands. + * + * @param list}> $specs + * @return Collection + */ + public function replaceOptions(FormField|FormFieldLibrary $owner, array $specs): Collection + { + $this->assertSpecsValid($specs); + + $ownerType = $this->ownerTypeFor($owner); + + return DB::transaction(function () use ($owner, $ownerType, $specs): Collection { + FormFieldOption::query() + ->withoutGlobalScopes() + ->where('owner_type', $ownerType) + ->where('owner_id', $owner->getKey()) + ->delete(); + + foreach ($specs as $spec) { + FormFieldOption::query()->withoutGlobalScopes()->create([ + 'owner_type' => $ownerType, + 'owner_id' => $owner->getKey(), + 'value' => $spec['value'], + 'label' => $spec['label'], + 'sort_order' => $spec['sort_order'], + 'translations' => $this->normaliseTranslations($spec['translations'] ?? null), + ]); + } + + $fresh = $this->optionsFor($owner); + + if ($owner instanceof FormField) { + $owner->logFieldChange('field.options_replaced', [ + 'options' => $this->toJsonShape($fresh), + ]); + } + + return $fresh; + }); + } + + /** + * Row-copy from a library entry to a freshly-inserted field + * (addendum Q3 row-copy mandate). Every column is preserved; only + * `owner_type` / `owner_id` change. No activity-log emit — the + * wrapping library-insert emits its own field-creation event. + * + * @return Collection + */ + public function copyOptions(FormFieldLibrary|FormField $from, FormField|FormFieldLibrary $to): Collection + { + $rows = $this->optionsFor($from); + + if ($rows->isEmpty()) { + return $rows; + } + + $toType = $this->ownerTypeFor($to); + + return DB::transaction(function () use ($rows, $to, $toType): Collection { + foreach ($rows as $row) { + FormFieldOption::query()->withoutGlobalScopes()->create([ + 'owner_type' => $toType, + 'owner_id' => $to->getKey(), + 'value' => $row->value, + 'label' => $row->label, + 'sort_order' => $row->sort_order, + 'translations' => is_array($row->translations) && $row->translations !== [] + ? $row->translations + : null, + ]); + } + + return $this->optionsFor($to); + }); + } + + /** + * Serialise an option collection into the rich-shape array consumed + * by snapshot writer, API resources, and FilterRegistryController. + * Returns the mapped list (possibly empty); callers handle + * empty-collection → `null` emit at the resource boundary. + * + * @param Collection $options + * @return list}> + */ + public function toJsonShape(Collection $options): array + { + return $options + ->sortBy('sort_order') + ->values() + ->map(fn (FormFieldOption $option) => $option->toJsonShape()) + ->all(); + } + + /** + * Public spec-shape gate — used by FormRequests (WS-5d commit 3) to + * reject malformed specs at the HTTP boundary before any write + * lands. + * + * @param list> $specs + */ + public function assertSpecsValid(array $specs): void + { + $seenValues = []; + + foreach ($specs as $index => $spec) { + if (! is_array($spec)) { + throw new InvalidOptionSpecException( + "Option spec at index {$index} must be an array.", + ); + } + + $value = $spec['value'] ?? null; + if (! is_string($value) || $value === '' || strlen($value) > 255) { + throw new InvalidOptionSpecException( + "Option spec at index {$index}: 'value' is required, must be a non-empty string ≤255 chars.", + ); + } + + $label = $spec['label'] ?? null; + if (! is_string($label) || $label === '' || strlen($label) > 255) { + throw new InvalidOptionSpecException( + "Option spec at index {$index}: 'label' is required, must be a non-empty string ≤255 chars.", + ); + } + + if (! array_key_exists('sort_order', $spec) + || ! is_int($spec['sort_order']) + || $spec['sort_order'] < 0) { + throw new InvalidOptionSpecException( + "Option spec at index {$index}: 'sort_order' is required, must be a non-negative integer.", + ); + } + + if (array_key_exists('translations', $spec) && $spec['translations'] !== null) { + $translations = $spec['translations']; + if (! is_array($translations)) { + throw new InvalidOptionSpecException( + "Option spec at index {$index}: 'translations' must be an associative array of locale ⇒ string.", + ); + } + foreach ($translations as $locale => $translated) { + if (! is_string($locale) || preg_match(self::LOCALE_PATTERN, $locale) !== 1) { + throw new InvalidOptionSpecException( + "Option spec at index {$index}: invalid locale key '{$locale}' (expected BCP-47 short form e.g. 'nl', 'en_GB').", + ); + } + if (! is_string($translated) || $translated === '' || strlen($translated) > 255) { + throw new InvalidOptionSpecException( + "Option spec at index {$index} locale '{$locale}': translated label must be a non-empty string ≤255 chars.", + ); + } + } + } + + if (in_array($value, $seenValues, true)) { + throw new InvalidOptionSpecException( + "Option spec at index {$index}: duplicate value '{$value}' within the spec list.", + ); + } + $seenValues[] = $value; + } + } + + private function ownerTypeFor(FormField|FormFieldLibrary $owner): string + { + return $owner instanceof FormField ? 'form_field' : 'form_field_library'; + } + + /** + * Empty translation bags are stored as NULL — keeps query semantics + * clean and avoids whitespace differences between equivalent rows. + * + * @param array|null $translations + * @return array|null + */ + private function normaliseTranslations(?array $translations): ?array + { + if ($translations === null || $translations === []) { + return null; + } + + return $translations; + } +} diff --git a/api/database/factories/FormBuilder/FormFieldOptionFactory.php b/api/database/factories/FormBuilder/FormFieldOptionFactory.php new file mode 100644 index 00000000..f60cf409 --- /dev/null +++ b/api/database/factories/FormBuilder/FormFieldOptionFactory.php @@ -0,0 +1,63 @@ + */ +final class FormFieldOptionFactory extends Factory +{ + protected $model = FormFieldOption::class; + + /** @return array */ + public function definition(): array + { + $value = $this->faker->unique()->slug(2); + + return [ + 'owner_type' => 'form_field', + 'owner_id' => FormField::factory(), + 'value' => $value, + 'label' => ucfirst(str_replace('-', ' ', $value)), + 'sort_order' => 0, + 'translations' => null, + ]; + } + + public function forField(FormField $field): static + { + return $this->state(fn () => [ + 'owner_type' => 'form_field', + 'owner_id' => $field->id, + ]); + } + + public function forLibrary(FormFieldLibrary $library): static + { + return $this->state(fn () => [ + 'owner_type' => 'form_field_library', + 'owner_id' => $library->id, + ]); + } + + /** + * @param list $locales + */ + public function withTranslations(array $locales = ['en', 'de']): static + { + return $this->state(function (array $attributes) use ($locales) { + $base = (string) ($attributes['label'] ?? 'Option'); + $bag = []; + foreach ($locales as $locale) { + $bag[$locale] = $base.' ('.$locale.')'; + } + + return ['translations' => $bag]; + }); + } +} diff --git a/api/database/migrations/2026_04_27_100000_create_form_field_options_table.php b/api/database/migrations/2026_04_27_100000_create_form_field_options_table.php new file mode 100644 index 00000000..008a2e01 --- /dev/null +++ b/api/database/migrations/2026_04_27_100000_create_form_field_options_table.php @@ -0,0 +1,61 @@ +": "" } — JSON nullable + * + * UNIQUE(owner_type, owner_id, value) is the seed-bug guard: duplicate + * values per field have no semantic meaning and must fail at both the + * service-level (assertSpecsValid) and the DB-level. Index name + * `ffo_owner_value_unique` is a stable handle for future migrations. + * + * No soft delete — options are physical state, not audit. Submission + * snapshots carry the historical shape (matches §17.4 / §17.5 sibling + * convention). Cascade on owner delete is handled by + * FormFieldChildTablesCascadeObserver. + */ +return new class extends Migration +{ + public function up(): void + { + Schema::create('form_field_options', function (Blueprint $table) { + $table->ulid('id')->primary(); + $table->string('owner_type', 40); + $table->ulid('owner_id'); + $table->string('value', 255); + $table->string('label', 255); + $table->unsignedInteger('sort_order')->default(0); + $table->json('translations')->nullable(); + $table->timestamps(); + + $table->index( + ['owner_type', 'owner_id', 'sort_order'], + 'ffo_owner_sort_idx', + ); + $table->unique( + ['owner_type', 'owner_id', 'value'], + 'ffo_owner_value_unique', + ); + }); + } + + public function down(): void + { + Schema::dropIfExists('form_field_options'); + } +}; diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index 9dfc41a5..4ecad6df 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -33,13 +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: 3 WS-5c migrations - // (backfill-conditional-logic, create-conditional-logic-conditions, + // Roll back to pre-WS-5a state: 1 WS-5d migration (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) = 10. - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + // 2 WS-5a migrations (drop-binding-cols, create-bindings) = 12. + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -100,8 +101,8 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_rollback_reconstructs_json_and_drops_table(): void { - // Walk back the full WS-5c + WS-5b + WS-5a stack (10 migrations). - $this->artisan('migrate:rollback', ['--step' => 11])->assertSuccessful(); + // Walk back the full WS-5d + WS-5c + WS-5b + WS-5a stack (12 migrations). + $this->artisan('migrate:rollback', ['--step' => 12])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); @@ -111,11 +112,13 @@ 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-5c (3 migrations) + WS-5b (5 migrations) in one - // go → restores the pre-WS-5b state (conditional-logic, - // validation-rules and configs tables gone, validation_rules JSON - // columns reappear on source tables; binding contract intact). - $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); + // Step back over WS-5d (1 migration) + 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->assertFalse(Schema::hasTable('form_field_options')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_groups')); $this->assertFalse(Schema::hasTable('form_field_conditional_logic_conditions')); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php index 5483fd6a..716b1bed 100644 --- a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -31,8 +31,10 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_forward_backfill_builds_nested_tree_from_legacy_json(): void { - // Roll back only the backfill migration (latest WS-5c step). - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + // 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(); $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); $fieldId = $this->seedFieldWithJson([ @@ -153,7 +155,7 @@ final class ConditionalLogicBackfillTest extends TestCase ]); // Roll back only the backfill migration — writes the JSON back. - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); $reconstructed = DB::table('form_fields') ->where('id', $fieldId) @@ -180,7 +182,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_top_level_key_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); $this->seedFieldWithJson([ 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], @@ -193,7 +195,7 @@ final class ConditionalLogicBackfillTest extends TestCase public function test_unknown_comparison_operator_fails_migration(): void { - $this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 3])->assertSuccessful(); $this->seedFieldWithJson([ 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], diff --git a/api/tests/Feature/FormBuilder/Options/FormFieldOptionServiceAndScopeTest.php b/api/tests/Feature/FormBuilder/Options/FormFieldOptionServiceAndScopeTest.php new file mode 100644 index 00000000..3d05ebd0 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Options/FormFieldOptionServiceAndScopeTest.php @@ -0,0 +1,442 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldOption::factory()->forField($field)->create([ + 'value' => 'xs', 'label' => 'XS', 'sort_order' => 0, + ]); + FormFieldOption::factory()->forField($field)->create([ + 'value' => 'sm', 'label' => 'S', 'sort_order' => 1, + ]); + + // Commit 1 coexistence: form_fields.options JSON cast still exists, + // so $field->options resolves to the array attribute rather than + // the morphMany. Explicit relation call until commit 3 drops the + // cast. + $options = $field->fresh()->options()->get(); + $this->assertCount(2, $options); + $this->assertSame(['xs', 'sm'], $options->pluck('value')->all()); + $this->assertSame(FormField::class, $options->first()->fresh()->owner::class); + } + + public function test_library_morph_many_options_resolves(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldOption::factory()->forLibrary($library)->create([ + 'value' => 'a', 'label' => 'A', 'sort_order' => 0, + ]); + + $this->assertSame( + FormFieldLibrary::class, + $library->fresh()->options()->get()->first()->fresh()->owner::class, + ); + } + + public function test_to_json_shape_omits_empty_translations(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $option = FormFieldOption::factory()->forField($field)->create([ + 'value' => 'a', 'label' => 'A', 'sort_order' => 0, 'translations' => null, + ]); + + $this->assertSame( + ['value' => 'a', 'label' => 'A', 'sort_order' => 0], + $option->toJsonShape(), + ); + } + + public function test_to_json_shape_includes_translations(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $option = FormFieldOption::factory()->forField($field)->create([ + 'value' => 'sm', 'label' => 'Small', 'sort_order' => 1, + 'translations' => ['nl' => 'Klein', 'de' => 'Klein'], + ]); + + $this->assertSame( + [ + 'value' => 'sm', + 'label' => 'Small', + 'sort_order' => 1, + 'translations' => ['nl' => 'Klein', 'de' => 'Klein'], + ], + $option->toJsonShape(), + ); + } + + public function test_unique_constraint_blocks_duplicate_value_per_owner(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldOption::factory()->forField($field)->create([ + 'value' => 'dup', 'label' => 'A', 'sort_order' => 0, + ]); + + $this->expectException(QueryException::class); + FormFieldOption::factory()->forField($field)->create([ + 'value' => 'dup', 'label' => 'B', 'sort_order' => 1, + ]); + } + + public function test_options_for_returns_options_in_sort_order(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + FormFieldOption::factory()->forField($field)->create(['value' => 'c', 'label' => 'C', 'sort_order' => 2]); + FormFieldOption::factory()->forField($field)->create(['value' => 'a', 'label' => 'A', 'sort_order' => 0]); + FormFieldOption::factory()->forField($field)->create(['value' => 'b', 'label' => 'B', 'sort_order' => 1]); + + $options = app(FormFieldOptionService::class)->optionsFor($field); + $this->assertSame(['a', 'b', 'c'], $options->pluck('value')->all()); + } + + public function test_replace_options_creates_rows_transactionally(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $service = app(FormFieldOptionService::class); + $service->replaceOptions($field, [ + ['value' => 'red', 'label' => 'Red', 'sort_order' => 0], + ['value' => 'green', 'label' => 'Green', 'sort_order' => 1], + ]); + + $this->assertSame(['red', 'green'], $service->optionsFor($field)->pluck('value')->all()); + } + + public function test_replace_options_invalid_specs_roll_back_no_partial_state(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $service = app(FormFieldOptionService::class); + $service->replaceOptions($field, [ + ['value' => 'old', 'label' => 'Old', 'sort_order' => 0], + ]); + + try { + $service->replaceOptions($field, [ + ['value' => 'new1', 'label' => 'New 1', 'sort_order' => 0], + ['value' => '', 'label' => 'bad', 'sort_order' => 1], + ]); + $this->fail('Expected InvalidOptionSpecException.'); + } catch (InvalidOptionSpecException) { + // expected + } + + $this->assertSame(['old'], $service->optionsFor($field)->pluck('value')->all()); + } + + public function test_replace_options_emits_activity_log_on_field_only(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + $service = app(FormFieldOptionService::class); + + $service->replaceOptions($field, [ + ['value' => 'a', 'label' => 'A', 'sort_order' => 0], + ]); + $service->replaceOptions($library, [ + ['value' => 'b', 'label' => 'B', 'sort_order' => 0], + ]); + + $fieldEvent = Activity::query() + ->where('subject_type', 'form_field') + ->where('subject_id', $field->id) + ->where('description', 'field.options_replaced') + ->first(); + $this->assertNotNull($fieldEvent); + $this->assertSame( + [['value' => 'a', 'label' => 'A', 'sort_order' => 0]], + $fieldEvent->properties->get('options'), + ); + + $this->assertNull(Activity::query() + ->where('subject_type', 'form_field_library') + ->where('description', 'field.options_replaced') + ->first()); + } + + public function test_copy_options_clones_every_row_including_translations_and_sort_order(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldOption::factory()->forLibrary($library)->create([ + 'value' => 'one', 'label' => 'One', 'sort_order' => 1, + 'translations' => ['nl' => 'Een'], + ]); + FormFieldOption::factory()->forLibrary($library)->create([ + 'value' => 'two', 'label' => 'Two', 'sort_order' => 0, + 'translations' => null, + ]); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + app(FormFieldOptionService::class)->copyOptions($library, $field); + + $copied = app(FormFieldOptionService::class)->optionsFor($field); + $this->assertCount(2, $copied); + $this->assertSame(['two', 'one'], $copied->pluck('value')->all()); + $this->assertSame(['nl' => 'Een'], $copied->firstWhere('value', 'one')->translations); + $this->assertNull($copied->firstWhere('value', 'two')->translations); + } + + public function test_copy_options_emits_no_activity_log(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldOption::factory()->forLibrary($library)->create(['value' => 'a', 'label' => 'A', 'sort_order' => 0]); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + app(FormFieldOptionService::class)->copyOptions($library, $field); + + $this->assertNull(Activity::query() + ->where('description', 'field.options_replaced') + ->first()); + } + + public function test_to_json_shape_byte_equal_to_contract(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $service = app(FormFieldOptionService::class); + $service->replaceOptions($field, [ + ['value' => 'red', 'label' => 'Red', 'sort_order' => 0], + ['value' => 'green', 'label' => 'Green', 'sort_order' => 1, 'translations' => ['nl' => 'Groen']], + ]); + + $shape = $service->toJsonShape($service->optionsFor($field)); + $this->assertSame( + [ + ['value' => 'red', 'label' => 'Red', 'sort_order' => 0], + ['value' => 'green', 'label' => 'Green', 'sort_order' => 1, 'translations' => ['nl' => 'Groen']], + ], + $shape, + ); + } + + public function test_assert_specs_valid_rejects_non_array_spec(): void + { + $this->expectException(InvalidOptionSpecException::class); + app(FormFieldOptionService::class)->assertSpecsValid(['not-an-array']); + } + + public function test_assert_specs_valid_rejects_missing_or_oversized_value(): void + { + $service = app(FormFieldOptionService::class); + + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['label' => 'L', 'sort_order' => 0], + ])); + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => '', 'label' => 'L', 'sort_order' => 0], + ])); + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => str_repeat('x', 256), 'label' => 'L', 'sort_order' => 0], + ])); + } + + public function test_assert_specs_valid_rejects_missing_or_oversized_label(): void + { + $service = app(FormFieldOptionService::class); + + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => 'v', 'sort_order' => 0], + ])); + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => 'v', 'label' => '', 'sort_order' => 0], + ])); + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => 'v', 'label' => str_repeat('y', 256), 'sort_order' => 0], + ])); + } + + public function test_assert_specs_valid_rejects_bad_sort_order(): void + { + $service = app(FormFieldOptionService::class); + + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => 'v', 'label' => 'L'], + ])); + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => 'v', 'label' => 'L', 'sort_order' => '0'], + ])); + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => 'v', 'label' => 'L', 'sort_order' => -1], + ])); + } + + public function test_assert_specs_valid_rejects_translations_not_array(): void + { + $this->expectException(InvalidOptionSpecException::class); + app(FormFieldOptionService::class)->assertSpecsValid([ + ['value' => 'v', 'label' => 'L', 'sort_order' => 0, 'translations' => 'not-array'], + ]); + } + + public function test_assert_specs_valid_rejects_invalid_locale_key(): void + { + $this->expectException(InvalidOptionSpecException::class); + app(FormFieldOptionService::class)->assertSpecsValid([ + ['value' => 'v', 'label' => 'L', 'sort_order' => 0, 'translations' => ['XX' => 'bad']], + ]); + } + + public function test_assert_specs_valid_rejects_invalid_translated_value(): void + { + $service = app(FormFieldOptionService::class); + + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => 'v', 'label' => 'L', 'sort_order' => 0, 'translations' => ['nl' => '']], + ])); + $this->assertThrowsInvalidSpec(fn () => $service->assertSpecsValid([ + ['value' => 'v', 'label' => 'L', 'sort_order' => 0, 'translations' => ['nl' => str_repeat('a', 256)]], + ])); + } + + public function test_assert_specs_valid_rejects_duplicate_values(): void + { + $this->expectException(InvalidOptionSpecException::class); + app(FormFieldOptionService::class)->assertSpecsValid([ + ['value' => 'dup', 'label' => 'A', 'sort_order' => 0], + ['value' => 'dup', 'label' => 'B', 'sort_order' => 1], + ]); + } + + public function test_scope_isolates_options_per_organisation_both_owner_types(): void + { + [$orgA, $fieldA, $libraryA] = $this->seedOrgWithOptions(); + [$orgB, $fieldB, $libraryB] = $this->seedOrgWithOptions(); + + $this->withOrgRoute($orgA); + $ids = FormFieldOption::query()->pluck('owner_id')->sort()->values()->all(); + $expected = collect([$fieldA->id, $libraryA->id])->sort()->values()->all(); + $this->assertSame($expected, $ids); + + // Escape hatch. + $this->assertSame( + 4, + FormFieldOption::query()->withoutGlobalScope(FormFieldOptionScope::class)->count(), + ); + $this->assertSame(2, FormFieldOption::query()->count()); + } + + public function test_cascade_options_deleted_on_field_soft_delete(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldOption::factory()->forField($field)->create(['value' => 'a', 'label' => 'A']); + + $this->assertSame(1, FormFieldOption::query()->withoutGlobalScopes() + ->where('owner_id', $field->id)->count()); + + $field->delete(); // soft delete on FormField + $this->assertSame(0, FormFieldOption::query()->withoutGlobalScopes() + ->where('owner_id', $field->id)->count()); + } + + public function test_cascade_options_deleted_on_field_force_delete(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldOption::factory()->forField($field)->create(['value' => 'a', 'label' => 'A']); + + $field->forceDelete(); + $this->assertSame(0, FormFieldOption::query()->withoutGlobalScopes() + ->where('owner_id', $field->id)->count()); + } + + public function test_cascade_options_deleted_on_library_delete(): void + { + $org = Organisation::factory()->create(); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldOption::factory()->forLibrary($library)->create(['value' => 'a', 'label' => 'A']); + + $library->delete(); + $this->assertSame(0, FormFieldOption::query()->withoutGlobalScopes() + ->where('owner_id', $library->id)->count()); + } + + /** @return array{0:Organisation,1:FormField,2:FormFieldLibrary} */ + private function seedOrgWithOptions(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + FormFieldOption::factory()->forField($field)->create(['value' => 'fld-'.$field->id, 'label' => 'F']); + FormFieldOption::factory()->forLibrary($library)->create(['value' => 'lib-'.$library->id, 'label' => 'L']); + + return [$org, $field, $library]; + } + + private function withOrgRoute(Organisation $org): void + { + $route = new Route(['GET'], '/_test', static fn () => null); + $route->bind(request()); + $route->setParameter('organisation', $org); + request()->setRouteResolver(static fn () => $route); + } + + private function assertThrowsInvalidSpec(callable $fn): void + { + try { + $fn(); + } catch (InvalidOptionSpecException) { + $this->assertTrue(true); + + return; + } + $this->fail('Expected InvalidOptionSpecException, none thrown.'); + } +} diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index 750a5c4b..ade2dd44 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -31,13 +31,15 @@ final class FormFieldValidationRuleBackfillTest extends TestCase public function test_forward_migration_backfills_rows_with_field_type_dispatch(): void { - // Roll back: 2 WS-5c migrations (create-conditional-logic-conditions, + // Roll back: 1 WS-5d migration (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) = 7. + // validation-rules-backfill + create-validation-rules) = 10. // Brings us to the pre-WS-5b state: validation_rules JSON column - // present, no relational tables for WS-5b. - $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); + // present, no relational tables for WS-5b/c/d. + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -98,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' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -122,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' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -149,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' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -166,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' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -185,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' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -200,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' => 9])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first();