diff --git a/api/app/Http/Resources/FormBuilder/FormFieldLibraryResource.php b/api/app/Http/Resources/FormBuilder/FormFieldLibraryResource.php index a8723a54..9908d40b 100644 --- a/api/app/Http/Resources/FormBuilder/FormFieldLibraryResource.php +++ b/api/app/Http/Resources/FormBuilder/FormFieldLibraryResource.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Resources\FormBuilder; use App\Models\FormBuilder\FormFieldLibrary; +use App\Services\FormBuilder\FormFieldBindingService; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -30,7 +31,9 @@ final class FormFieldLibraryResource extends JsonResource 'validation_rules' => $this->validation_rules, 'default_is_required' => (bool) $this->default_is_required, 'default_is_filterable' => (bool) $this->default_is_filterable, - 'default_binding' => $this->default_binding, + 'default_binding' => app(FormFieldBindingService::class)->toJsonShape( + $this->resource->bindings->first(), + ), 'translations' => $this->translations, 'description' => $this->description, 'usage_count' => (int) ($this->usage_count ?? 0), diff --git a/api/app/Http/Resources/FormBuilder/FormFieldResource.php b/api/app/Http/Resources/FormBuilder/FormFieldResource.php index c65c0a23..74a552b3 100644 --- a/api/app/Http/Resources/FormBuilder/FormFieldResource.php +++ b/api/app/Http/Resources/FormBuilder/FormFieldResource.php @@ -7,6 +7,7 @@ namespace App\Http\Resources\FormBuilder; use App\Enums\FormBuilder\FormFieldType; use App\Models\FormBuilder\FormField; use App\Models\PersonTag; +use App\Services\FormBuilder\FormFieldBindingService; use App\Services\FormBuilder\FormLocaleResolver; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -49,7 +50,9 @@ final class FormFieldResource extends JsonResource 'is_unique' => (bool) $this->is_unique, 'is_pii' => (bool) $this->is_pii, 'display_width' => $this->display_width instanceof \BackedEnum ? $this->display_width->value : $this->display_width, - 'binding' => $this->binding, + 'binding' => app(FormFieldBindingService::class)->toJsonShape( + $this->resource->bindings->first(), + ), 'conditional_logic' => $this->conditional_logic, 'role_restrictions' => $this->role_restrictions, 'translations' => $this->translations, diff --git a/api/app/Services/FormBuilder/FormFieldBindingService.php b/api/app/Services/FormBuilder/FormFieldBindingService.php new file mode 100644 index 00000000..9516669b --- /dev/null +++ b/api/app/Services/FormBuilder/FormFieldBindingService.php @@ -0,0 +1,199 @@ + + */ + public function bindingsFor(FormField|FormFieldLibrary $owner): Collection + { + $type = $this->ownerTypeFor($owner); + + return FormFieldBinding::query() + ->where('owner_type', $type) + ->where('owner_id', $owner->getKey()) + ->get(); + } + + /** + * Replace the set of bindings on an owner transactionally. Callers pass + * an array of binding specs; each spec is validated against the entity- + * column registry before anything is written. An empty array clears all + * bindings for the owner (Pattern B). + * + * @param list $bindingData + */ + public function replaceBindings(FormField|FormFieldLibrary $owner, array $bindingData): void + { + foreach ($bindingData as $spec) { + $this->assertSpecValid($spec); + } + + $ownerType = $this->ownerTypeFor($owner); + + DB::transaction(function () use ($owner, $ownerType, $bindingData): void { + FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', $ownerType) + ->where('owner_id', $owner->getKey()) + ->delete(); + + foreach ($bindingData as $spec) { + FormFieldBinding::query()->withoutGlobalScopes()->create([ + 'owner_type' => $ownerType, + 'owner_id' => $owner->getKey(), + 'target_entity' => $spec['target_entity'], + 'target_attribute' => $spec['target_attribute'], + 'mode' => $spec['mode'], + 'sync_direction' => $spec['sync_direction'] ?? null, + 'merge_strategy' => $spec['merge_strategy'] + ?? FormFieldBindingMergeStrategy::Overwrite->value, + 'trust_level' => $spec['trust_level'] ?? 50, + 'is_identity_key' => $spec['is_identity_key'] ?? false, + ]); + } + + if ($owner instanceof FormField) { + $owner->logFieldChange('field.bindings_replaced', [ + 'count' => count($bindingData), + ]); + } + }); + } + + /** + * Row-copy from a library entry to a freshly-inserted field (ARCH §6.7, + * addendum Q3). Preserves every binding column; only owner_type / + * owner_id are rewritten. + */ + public function copyBindings(FormFieldLibrary $from, FormField $to): void + { + $bindings = $this->bindingsFor($from); + + if ($bindings->isEmpty()) { + return; + } + + DB::transaction(function () use ($bindings, $to): void { + foreach ($bindings as $binding) { + FormFieldBinding::query()->withoutGlobalScopes()->create([ + 'owner_type' => 'form_field', + 'owner_id' => $to->id, + 'target_entity' => $binding->target_entity, + 'target_attribute' => $binding->target_attribute, + 'mode' => $binding->mode instanceof FormFieldBindingMode + ? $binding->mode->value + : (string) $binding->mode, + 'sync_direction' => $binding->sync_direction, + 'merge_strategy' => $binding->merge_strategy instanceof FormFieldBindingMergeStrategy + ? $binding->merge_strategy->value + : (string) $binding->merge_strategy, + 'trust_level' => (int) $binding->trust_level, + 'is_identity_key' => (bool) $binding->is_identity_key, + ]); + } + }); + } + + /** + * Serialise a binding row into the ARCH §6.3 JSON shape. Returned null + * if no binding is given — callers can pipe directly into snapshot / + * resource output (Pattern B = null). + * + * @return array{mode:string,entity:string,column:string,sync_direction?:string}|null + */ + public function toJsonShape(?FormFieldBinding $binding): ?array + { + if ($binding === null) { + return null; + } + + $mode = $binding->mode instanceof FormFieldBindingMode + ? $binding->mode->value + : (string) $binding->mode; + + $shape = [ + 'mode' => $mode, + 'entity' => $binding->target_entity, + 'column' => $binding->target_attribute, + ]; + if ($binding->sync_direction !== null && $binding->sync_direction !== '') { + $shape['sync_direction'] = $binding->sync_direction; + } + + return $shape; + } + + private function ownerTypeFor(FormField|FormFieldLibrary $owner): string + { + return $owner instanceof FormField ? 'form_field' : 'form_field_library'; + } + + /** @param array $spec */ + private function assertSpecValid(array $spec): void + { + $entity = (string) ($spec['target_entity'] ?? ''); + $attribute = (string) ($spec['target_attribute'] ?? ''); + $mode = (string) ($spec['mode'] ?? ''); + + if ($entity === '' || $attribute === '') { + throw new \InvalidArgumentException( + 'Binding spec requires target_entity and target_attribute.', + ); + } + + if (FormFieldBindingMode::tryFrom($mode) === null) { + throw new \InvalidArgumentException( + "Binding spec mode '{$mode}' is not a valid FormFieldBindingMode.", + ); + } + + if (array_key_exists('merge_strategy', $spec)) { + $strategy = (string) $spec['merge_strategy']; + if (FormFieldBindingMergeStrategy::tryFrom($strategy) === null) { + throw new \InvalidArgumentException( + "Binding spec merge_strategy '{$strategy}' is not a valid FormFieldBindingMergeStrategy.", + ); + } + } + + $registry = (array) config('form_binding.'.$entity); + if ($registry === []) { + throw new \InvalidArgumentException( + "Binding target_entity '{$entity}' is not registered in config/form_binding.php.", + ); + } + + if (! array_key_exists($attribute, $registry)) { + throw new \InvalidArgumentException( + "Binding target_attribute '{$entity}.{$attribute}' is not registered in config/form_binding.php.", + ); + } + } +} diff --git a/api/app/Services/FormBuilder/FormFieldService.php b/api/app/Services/FormBuilder/FormFieldService.php index 920c2610..7f677cc1 100644 --- a/api/app/Services/FormBuilder/FormFieldService.php +++ b/api/app/Services/FormBuilder/FormFieldService.php @@ -26,6 +26,7 @@ final class FormFieldService { public function __construct( private readonly FormSchemaService $schemaService, + private readonly FormFieldBindingService $bindingService, ) {} public function create(FormSchema $schema, array $data): FormField @@ -140,7 +141,6 @@ final class FormFieldService 'validation_rules' => $library->validation_rules, 'is_required' => (bool) $library->default_is_required, 'is_filterable' => (bool) $library->default_is_filterable, - 'binding' => $library->default_binding, 'translations' => $library->translations, 'sort_order' => $this->nextSortOrder($schema), ], $overrides); @@ -154,6 +154,8 @@ final class FormFieldService /** @var FormField $field */ $field = FormField::create($data); + $this->bindingService->copyBindings($library, $field); + FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count'); $this->schemaService->bumpVersion($schema); diff --git a/api/app/Services/FormBuilder/FormSubmissionService.php b/api/app/Services/FormBuilder/FormSubmissionService.php index 6eba14f2..a9f149d7 100644 --- a/api/app/Services/FormBuilder/FormSubmissionService.php +++ b/api/app/Services/FormBuilder/FormSubmissionService.php @@ -33,6 +33,7 @@ final class FormSubmissionService public function __construct( private readonly FormLocaleResolver $localeResolver, private readonly FormValueService $valueService, + private readonly FormFieldBindingService $bindingService, ) {} /** @@ -199,7 +200,7 @@ final class FormSubmissionService */ private function buildSnapshot(FormSchema $schema): array { - $schema->loadMissing(['fields', 'sections']); + $schema->loadMissing(['fields.bindings', 'sections']); return [ 'schema_version' => $schema->version, @@ -235,7 +236,7 @@ final class FormSubmissionService 'is_required' => (bool) $f->is_required, 'is_filterable' => (bool) $f->is_filterable, 'is_pii' => (bool) $f->is_pii, - 'binding' => $f->binding, + 'binding' => $this->bindingService->toJsonShape($f->bindings->first()), 'conditional_logic' => $f->conditional_logic, 'translations' => $f->translations, 'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint, diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingServiceTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingServiceTest.php new file mode 100644 index 00000000..48a63af3 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingServiceTest.php @@ -0,0 +1,230 @@ +service = $this->app->make(FormFieldBindingService::class); + $this->org = Organisation::factory()->create(); + $this->schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]); + } + + public function test_replace_bindings_is_transactional_and_swaps_old_for_new(): void + { + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + + $this->service->replaceBindings($field, [[ + 'target_entity' => 'person', + 'target_attribute' => 'first_name', + 'mode' => FormFieldBindingMode::EntityOwned->value, + ]]); + + $rows = FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->get(); + + $this->assertCount(1, $rows); + $this->assertSame('first_name', $rows->first()->target_attribute); + } + + public function test_replace_bindings_with_empty_array_clears_all(): void + { + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + + $this->service->replaceBindings($field, []); + + $this->assertSame(0, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + } + + public function test_replace_bindings_rejects_unregistered_target_entity(): void + { + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("target_entity 'unicorn' is not registered"); + + $this->service->replaceBindings($field, [[ + 'target_entity' => 'unicorn', + 'target_attribute' => 'email', + 'mode' => FormFieldBindingMode::EntityOwned->value, + ]]); + } + + public function test_replace_bindings_rejects_unregistered_target_attribute(): void + { + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("target_attribute 'person.made_up_column'"); + + $this->service->replaceBindings($field, [[ + 'target_entity' => 'person', + 'target_attribute' => 'made_up_column', + 'mode' => FormFieldBindingMode::EntityOwned->value, + ]]); + } + + public function test_replace_bindings_rejects_invalid_mode(): void + { + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage("mode 'form_owned'"); + + $this->service->replaceBindings($field, [[ + 'target_entity' => 'person', + 'target_attribute' => 'email', + 'mode' => 'form_owned', + ]]); + } + + public function test_replace_bindings_logs_activity_on_change(): void + { + Activity::query()->delete(); + + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + $this->service->replaceBindings($field, [[ + 'target_entity' => 'person', + 'target_attribute' => 'email', + 'mode' => FormFieldBindingMode::EntityOwned->value, + ]]); + + $entry = Activity::query() + ->where('subject_type', $field->getMorphClass()) + ->where('subject_id', $field->id) + ->where('description', 'field.bindings_replaced') + ->first(); + + $this->assertNotNull($entry); + } + + public function test_copy_bindings_preserves_every_column(): void + { + $library = FormFieldLibrary::factory()->create(['organisation_id' => $this->org->id]); + + $source = FormFieldBinding::factory()->forLibrary($library)->create([ + 'target_entity' => 'user_profile', + 'target_attribute' => 'bio', + 'mode' => FormFieldBindingMode::Mirrored->value, + 'sync_direction' => 'write_on_submit', + 'merge_strategy' => FormFieldBindingMergeStrategy::Append->value, + 'trust_level' => 80, + 'is_identity_key' => true, + ]); + + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + $this->service->copyBindings($library->fresh(), $field); + + $copy = FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->first(); + + $this->assertNotNull($copy); + $this->assertSame($source->target_entity, $copy->target_entity); + $this->assertSame($source->target_attribute, $copy->target_attribute); + $this->assertSame(FormFieldBindingMode::Mirrored, $copy->mode); + $this->assertSame('write_on_submit', $copy->sync_direction); + $this->assertSame(FormFieldBindingMergeStrategy::Append, $copy->merge_strategy); + $this->assertSame(80, $copy->trust_level); + $this->assertTrue($copy->is_identity_key); + } + + public function test_copy_bindings_is_noop_when_source_has_none(): void + { + $library = FormFieldLibrary::factory()->create(['organisation_id' => $this->org->id]); + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + + $this->service->copyBindings($library, $field); + + $this->assertSame(0, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + } + + public function test_to_json_shape_matches_arch_6_3_for_entity_owned(): void + { + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + $binding = FormFieldBinding::factory() + ->forField($field) + ->entityOwned('person', 'email') + ->create(); + + $this->assertSame([ + 'mode' => 'entity_owned', + 'entity' => 'person', + 'column' => 'email', + ], $this->service->toJsonShape($binding)); + } + + public function test_to_json_shape_matches_arch_6_3_for_mirrored(): void + { + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + $binding = FormFieldBinding::factory() + ->forField($field) + ->mirrored('user_profile', 'emergency_contact_name') + ->create(); + + $this->assertSame([ + 'mode' => 'mirrored', + 'entity' => 'user_profile', + 'column' => 'emergency_contact_name', + 'sync_direction' => 'write_on_submit', + ], $this->service->toJsonShape($binding)); + } + + public function test_to_json_shape_returns_null_for_no_binding(): void + { + $this->assertNull($this->service->toJsonShape(null)); + } + + public function test_bindings_for_returns_only_owner_bindings(): void + { + $field = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + $other = FormField::factory()->create(['form_schema_id' => $this->schema->id]); + + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'phone')->create(); + FormFieldBinding::factory()->forField($other)->entityOwned('person', 'first_name')->create(); + + $this->assertCount(2, $this->service->bindingsFor($field)); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldResourceBindingOutputTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldResourceBindingOutputTest.php new file mode 100644 index 00000000..a4ebda39 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldResourceBindingOutputTest.php @@ -0,0 +1,84 @@ +seed(RoleSeeder::class); + $this->org = Organisation::factory()->create(); + $this->admin = User::factory()->create(); + $this->org->users()->attach($this->admin, ['role' => 'org_admin']); + } + + public function test_form_field_resource_serialises_entity_owned_binding(): void + { + Sanctum::actingAs($this->admin); + $schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}/fields"); + $response->assertOk(); + $payload = collect($response->json('data'))->firstWhere('id', $field->id); + $this->assertSame([ + 'mode' => 'entity_owned', + 'entity' => 'person', + 'column' => 'email', + ], $payload['binding']); + } + + public function test_form_field_resource_returns_null_binding_when_none(): void + { + Sanctum::actingAs($this->admin); + $schema = FormSchema::factory()->create(['organisation_id' => $this->org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/schemas/{$schema->id}/fields"); + $response->assertOk(); + $payload = collect($response->json('data'))->firstWhere('id', $field->id); + $this->assertNull($payload['binding']); + } + + public function test_form_field_library_resource_serialises_default_binding(): void + { + Sanctum::actingAs($this->admin); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $this->org->id]); + FormFieldBinding::factory()->forLibrary($library)->mirrored('user_profile', 'bio')->create(); + + $response = $this->getJson("/api/v1/organisations/{$this->org->id}/forms/field-library/{$library->id}"); + $response->assertOk(); + $response->assertJsonPath('data.default_binding', [ + 'mode' => 'mirrored', + 'entity' => 'user_profile', + 'column' => 'bio', + 'sync_direction' => 'write_on_submit', + ]); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/InsertFromLibraryCopiesBindingsTest.php b/api/tests/Feature/FormBuilder/Bindings/InsertFromLibraryCopiesBindingsTest.php new file mode 100644 index 00000000..33ed2d07 --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/InsertFromLibraryCopiesBindingsTest.php @@ -0,0 +1,66 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $library = FormFieldLibrary::factory()->create([ + 'organisation_id' => $org->id, + 'usage_count' => 0, + ]); + + FormFieldBinding::factory()->forLibrary($library)->entityOwned('person', 'email')->create(); + FormFieldBinding::factory()->forLibrary($library)->mirrored('user_profile', 'bio')->create(); + + $service = $this->app->make(FormFieldService::class); + $field = $service->insertFromLibrary($schema, $library); + + $copied = FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->orderBy('target_attribute') + ->get(); + + $this->assertCount(2, $copied); + $this->assertSame(['bio', 'email'], $copied->pluck('target_attribute')->all()); + $this->assertSame(FormFieldBindingMode::Mirrored, $copied->firstWhere('target_attribute', 'bio')->mode); + $this->assertSame(FormFieldBindingMode::EntityOwned, $copied->firstWhere('target_attribute', 'email')->mode); + + $this->assertSame(1, (int) $library->fresh()->usage_count); + } + + public function test_insert_from_library_without_bindings_creates_field_without_bindings(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $library = FormFieldLibrary::factory()->create(['organisation_id' => $org->id]); + + $service = $this->app->make(FormFieldService::class); + $field = $service->insertFromLibrary($schema, $library); + + $this->assertSame(0, FormFieldBinding::query() + ->withoutGlobalScopes() + ->where('owner_type', 'form_field') + ->where('owner_id', $field->id) + ->count(), + ); + } +} diff --git a/api/tests/Feature/FormBuilder/Bindings/SchemaSnapshotEmbedsBindingFromRelationalTableTest.php b/api/tests/Feature/FormBuilder/Bindings/SchemaSnapshotEmbedsBindingFromRelationalTableTest.php new file mode 100644 index 00000000..d19cb76c --- /dev/null +++ b/api/tests/Feature/FormBuilder/Bindings/SchemaSnapshotEmbedsBindingFromRelationalTableTest.php @@ -0,0 +1,119 @@ +schemaWithSnapshot(); + + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::EMAIL->value, + 'slug' => 'email', + 'label' => 'E-mail', + ]); + FormFieldBinding::factory()->forField($field)->entityOwned('person', 'email')->create(); + + $submission = $this->submitFor($schema); + $snapshot = $submission->fresh()->schema_snapshot; + + $this->assertIsArray($snapshot); + $embedded = collect($snapshot['fields'])->firstWhere('id', $field->id); + $this->assertNotNull($embedded); + $this->assertSame([ + 'mode' => 'entity_owned', + 'entity' => 'person', + 'column' => 'email', + ], $embedded['binding']); + } + + public function test_snapshot_embeds_mirrored_binding_with_sync_direction(): void + { + $schema = $this->schemaWithSnapshot(); + + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'noodcontact', + 'label' => 'Noodcontact', + ]); + FormFieldBinding::factory()->forField($field)->mirrored('user_profile', 'emergency_contact_name')->create(); + + $submission = $this->submitFor($schema); + $snapshot = $submission->fresh()->schema_snapshot; + + $embedded = collect($snapshot['fields'])->firstWhere('id', $field->id); + $this->assertSame([ + 'mode' => 'mirrored', + 'entity' => 'user_profile', + 'column' => 'emergency_contact_name', + 'sync_direction' => 'write_on_submit', + ], $embedded['binding']); + } + + public function test_snapshot_embeds_null_binding_for_form_owned_field(): void + { + $schema = $this->schemaWithSnapshot(); + + $field = FormField::factory()->create([ + 'form_schema_id' => $schema->id, + 'field_type' => FormFieldType::TEXT->value, + 'slug' => 'motivatie', + 'label' => 'Motivatie', + ]); + + $submission = $this->submitFor($schema); + $snapshot = $submission->fresh()->schema_snapshot; + + $embedded = collect($snapshot['fields'])->firstWhere('id', $field->id); + $this->assertNull($embedded['binding']); + } + + private function schemaWithSnapshot(): FormSchema + { + $org = Organisation::factory()->create(); + + return FormSchema::factory()->create([ + 'organisation_id' => $org->id, + 'purpose' => FormPurpose::USER_PROFILE->value, + 'snapshot_mode' => FormSchemaSnapshotMode::ALWAYS->value, + ]); + } + + private function submitFor(FormSchema $schema): FormSubmission + { + $user = User::factory()->create(); + $service = $this->app->make(FormSubmissionService::class); + + $submission = $service->createDraft($schema, $user, $user); + + return $service->submit($submission, $user); + } +}