refactor(form-builder): pre-publish check reads form_field_bindings; drop binding JSON columns
This commit is contained in:
@@ -128,28 +128,24 @@ final class VerifyFormsDataIntegrity extends Command
|
|||||||
->havingRaw('COUNT(*) > 1')
|
->havingRaw('COUNT(*) > 1')
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
// Binding registry cross-check
|
// Binding registry cross-check (WS-5a: relational form_field_bindings).
|
||||||
$binding = (array) config('form_binding', []);
|
$registry = (array) config('form_binding', []);
|
||||||
$badBindings = 0;
|
$badBindings = 0;
|
||||||
$invalidBindings = DB::table('form_fields')->whereNotNull('binding')->select('binding')->get();
|
$bindingRows = DB::table('form_field_bindings')
|
||||||
foreach ($invalidBindings as $row) {
|
->select(['mode', 'target_entity', 'target_attribute'])
|
||||||
$b = is_string($row->binding) ? json_decode($row->binding, true) : null;
|
->get();
|
||||||
if (! is_array($b) || ! isset($b['mode'], $b['entity'], $b['column'])) {
|
foreach ($bindingRows as $row) {
|
||||||
|
if (! in_array($row->mode, ['entity_owned', 'mirrored'], true)) {
|
||||||
$badBindings++;
|
$badBindings++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! in_array($b['mode'], ['entity_owned', 'mirrored'], true)) {
|
if (! isset($registry[$row->target_entity][$row->target_attribute])) {
|
||||||
$badBindings++;
|
$badBindings++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (! isset($binding[$b['entity']][$b['column']])) {
|
if (($registry[$row->target_entity][$row->target_attribute]['writable'] ?? false) !== true) {
|
||||||
$badBindings++;
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (($binding[$b['entity']][$b['column']]['writable'] ?? false) !== true) {
|
|
||||||
$badBindings++;
|
$badBindings++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Requests\Api\V1\FormBuilder\UpdateFormFieldLibraryRequest;
|
|||||||
use App\Http\Resources\FormBuilder\FormFieldLibraryResource;
|
use App\Http\Resources\FormBuilder\FormFieldLibraryResource;
|
||||||
use App\Models\FormBuilder\FormFieldLibrary;
|
use App\Models\FormBuilder\FormFieldLibrary;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
|
use App\Services\FormBuilder\FormFieldBindingService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@@ -17,6 +18,10 @@ use Illuminate\Support\Str;
|
|||||||
|
|
||||||
final class FormFieldLibraryController extends Controller
|
final class FormFieldLibraryController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly FormFieldBindingService $bindingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(Organisation $organisation): AnonymousResourceCollection
|
public function index(Organisation $organisation): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
Gate::authorize('viewAny', [FormFieldLibrary::class, $organisation]);
|
Gate::authorize('viewAny', [FormFieldLibrary::class, $organisation]);
|
||||||
@@ -42,6 +47,7 @@ final class FormFieldLibraryController extends Controller
|
|||||||
Gate::authorize('create', [FormFieldLibrary::class, $organisation]);
|
Gate::authorize('create', [FormFieldLibrary::class, $organisation]);
|
||||||
|
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
$bindingSpec = $this->extractBindingSpec($data);
|
||||||
$data['organisation_id'] = $organisation->id;
|
$data['organisation_id'] = $organisation->id;
|
||||||
$data['is_system'] = false;
|
$data['is_system'] = false;
|
||||||
$data['is_active'] ??= true;
|
$data['is_active'] ??= true;
|
||||||
@@ -50,6 +56,10 @@ final class FormFieldLibraryController extends Controller
|
|||||||
/** @var FormFieldLibrary $library */
|
/** @var FormFieldLibrary $library */
|
||||||
$library = FormFieldLibrary::create($data);
|
$library = FormFieldLibrary::create($data);
|
||||||
|
|
||||||
|
if ($bindingSpec !== null) {
|
||||||
|
$this->bindingService->replaceBindings($library, [$bindingSpec]);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->created(new FormFieldLibraryResource($library));
|
return $this->created(new FormFieldLibraryResource($library));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,12 +68,46 @@ final class FormFieldLibraryController extends Controller
|
|||||||
$this->assertSameOrg($organisation, $fieldLibrary);
|
$this->assertSameOrg($organisation, $fieldLibrary);
|
||||||
Gate::authorize('update', $fieldLibrary);
|
Gate::authorize('update', $fieldLibrary);
|
||||||
|
|
||||||
$fieldLibrary->fill($request->validated());
|
$data = $request->validated();
|
||||||
|
$bindingProvided = array_key_exists('default_binding', $data);
|
||||||
|
$bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null;
|
||||||
|
|
||||||
|
$fieldLibrary->fill($data);
|
||||||
$fieldLibrary->save();
|
$fieldLibrary->save();
|
||||||
|
|
||||||
|
if ($bindingProvided) {
|
||||||
|
$this->bindingService->replaceBindings(
|
||||||
|
$fieldLibrary,
|
||||||
|
$bindingSpec === null ? [] : [$bindingSpec],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->success(new FormFieldLibraryResource($fieldLibrary));
|
return $this->success(new FormFieldLibraryResource($fieldLibrary));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null
|
||||||
|
*/
|
||||||
|
private function extractBindingSpec(array &$data): ?array
|
||||||
|
{
|
||||||
|
if (! array_key_exists('default_binding', $data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = $data['default_binding'];
|
||||||
|
unset($data['default_binding']);
|
||||||
|
if (! is_array($raw) || $raw === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'target_entity' => (string) ($raw['entity'] ?? ''),
|
||||||
|
'target_attribute' => (string) ($raw['column'] ?? ''),
|
||||||
|
'mode' => (string) ($raw['mode'] ?? ''),
|
||||||
|
'sync_direction' => isset($raw['sync_direction']) ? (string) $raw['sync_direction'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function destroy(Organisation $organisation, FormFieldLibrary $fieldLibrary): JsonResponse
|
public function destroy(Organisation $organisation, FormFieldLibrary $fieldLibrary): JsonResponse
|
||||||
{
|
{
|
||||||
$this->assertSameOrg($organisation, $fieldLibrary);
|
$this->assertSameOrg($organisation, $fieldLibrary);
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ final class FormField extends Model
|
|||||||
'is_unique',
|
'is_unique',
|
||||||
'is_pii',
|
'is_pii',
|
||||||
'display_width',
|
'display_width',
|
||||||
'binding',
|
|
||||||
'conditional_logic',
|
'conditional_logic',
|
||||||
'role_restrictions',
|
'role_restrictions',
|
||||||
'translations',
|
'translations',
|
||||||
@@ -72,7 +71,6 @@ final class FormField extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'options' => 'array',
|
'options' => 'array',
|
||||||
'validation_rules' => 'array',
|
'validation_rules' => 'array',
|
||||||
'binding' => 'array',
|
|
||||||
'conditional_logic' => 'array',
|
'conditional_logic' => 'array',
|
||||||
'role_restrictions' => 'array',
|
'role_restrictions' => 'array',
|
||||||
'translations' => 'array',
|
'translations' => 'array',
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ final class FormFieldLibrary extends Model
|
|||||||
'validation_rules',
|
'validation_rules',
|
||||||
'default_is_required',
|
'default_is_required',
|
||||||
'default_is_filterable',
|
'default_is_filterable',
|
||||||
'default_binding',
|
|
||||||
'translations',
|
'translations',
|
||||||
'description',
|
'description',
|
||||||
'is_active',
|
'is_active',
|
||||||
@@ -48,7 +47,6 @@ final class FormFieldLibrary extends Model
|
|||||||
protected $casts = [
|
protected $casts = [
|
||||||
'options' => 'array',
|
'options' => 'array',
|
||||||
'validation_rules' => 'array',
|
'validation_rules' => 'array',
|
||||||
'default_binding' => 'array',
|
|
||||||
'translations' => 'array',
|
'translations' => 'array',
|
||||||
'default_is_required' => 'bool',
|
'default_is_required' => 'bool',
|
||||||
'default_is_filterable' => 'bool',
|
'default_is_filterable' => 'bool',
|
||||||
|
|||||||
@@ -36,11 +36,17 @@ final class FormFieldService
|
|||||||
$data['form_schema_id'] = $schema->id;
|
$data['form_schema_id'] = $schema->id;
|
||||||
$data['sort_order'] ??= $this->nextSortOrder($schema);
|
$data['sort_order'] ??= $this->nextSortOrder($schema);
|
||||||
|
|
||||||
|
$bindingSpec = $this->extractBindingSpec($data);
|
||||||
|
|
||||||
$this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null);
|
$this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null);
|
||||||
|
|
||||||
/** @var FormField $field */
|
/** @var FormField $field */
|
||||||
$field = FormField::create($data);
|
$field = FormField::create($data);
|
||||||
|
|
||||||
|
if ($bindingSpec !== null) {
|
||||||
|
$this->bindingService->replaceBindings($field, [$bindingSpec]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->schemaService->bumpVersion($schema);
|
$this->schemaService->bumpVersion($schema);
|
||||||
$field->logFieldChange('field.created');
|
$field->logFieldChange('field.created');
|
||||||
|
|
||||||
@@ -56,7 +62,13 @@ final class FormFieldService
|
|||||||
$schema = $field->schema;
|
$schema = $field->schema;
|
||||||
$this->assertNotFrozenForStructural($schema, $data);
|
$this->assertNotFrozenForStructural($schema, $data);
|
||||||
|
|
||||||
if (array_key_exists('binding', $data) && $data['binding'] !== $field->binding) {
|
$bindingProvided = array_key_exists('binding', $data);
|
||||||
|
$rawBinding = $bindingProvided ? $data['binding'] : null;
|
||||||
|
$bindingSpec = $bindingProvided ? $this->extractBindingSpec($data) : null;
|
||||||
|
|
||||||
|
$currentBindingShape = $this->bindingService->toJsonShape($field->bindings()->first());
|
||||||
|
|
||||||
|
if ($bindingProvided && $this->bindingChanged($currentBindingShape, $rawBinding)) {
|
||||||
$this->assertBindingChangeAllowed($field, $forceBindingChange);
|
$this->assertBindingChangeAllowed($field, $forceBindingChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +77,7 @@ final class FormFieldService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$before = [
|
$before = [
|
||||||
'binding' => $field->binding,
|
'binding' => $currentBindingShape,
|
||||||
'is_filterable' => $field->is_filterable,
|
'is_filterable' => $field->is_filterable,
|
||||||
'is_pii' => $field->is_pii,
|
'is_pii' => $field->is_pii,
|
||||||
'field_type' => $field->field_type,
|
'field_type' => $field->field_type,
|
||||||
@@ -74,12 +86,16 @@ final class FormFieldService
|
|||||||
$field->fill($data);
|
$field->fill($data);
|
||||||
$field->save();
|
$field->save();
|
||||||
|
|
||||||
|
if ($bindingProvided) {
|
||||||
|
$this->bindingService->replaceBindings($field, $bindingSpec === null ? [] : [$bindingSpec]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->schemaService->bumpVersion($schema);
|
$this->schemaService->bumpVersion($schema);
|
||||||
|
|
||||||
$field->logFieldChange('field.updated', [
|
$field->logFieldChange('field.updated', [
|
||||||
'old' => $before,
|
'old' => $before,
|
||||||
'new' => [
|
'new' => [
|
||||||
'binding' => $field->binding,
|
'binding' => $this->bindingService->toJsonShape($field->bindings()->first()),
|
||||||
'is_filterable' => $field->is_filterable,
|
'is_filterable' => $field->is_filterable,
|
||||||
'is_pii' => $field->is_pii,
|
'is_pii' => $field->is_pii,
|
||||||
'field_type' => $field->field_type,
|
'field_type' => $field->field_type,
|
||||||
@@ -93,6 +109,51 @@ final class FormFieldService
|
|||||||
return $field->refresh();
|
return $field->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @return array{target_entity:string,target_attribute:string,mode:string,sync_direction?:?string}|null
|
||||||
|
*/
|
||||||
|
private function extractBindingSpec(array &$data): ?array
|
||||||
|
{
|
||||||
|
if (! array_key_exists('binding', $data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = $data['binding'];
|
||||||
|
unset($data['binding']);
|
||||||
|
if (! is_array($raw) || $raw === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'target_entity' => (string) ($raw['entity'] ?? ''),
|
||||||
|
'target_attribute' => (string) ($raw['column'] ?? ''),
|
||||||
|
'mode' => (string) ($raw['mode'] ?? ''),
|
||||||
|
'sync_direction' => isset($raw['sync_direction']) ? (string) $raw['sync_direction'] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $current Pre-WS-5a ARCH §6.3 shape
|
||||||
|
* @param array<string, mixed>|null $next
|
||||||
|
*/
|
||||||
|
private function bindingChanged(?array $current, ?array $next): bool
|
||||||
|
{
|
||||||
|
$normalise = static function (?array $value): array {
|
||||||
|
if ($value === null || $value === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mode' => (string) ($value['mode'] ?? ''),
|
||||||
|
'entity' => (string) ($value['entity'] ?? ''),
|
||||||
|
'column' => (string) ($value['column'] ?? ''),
|
||||||
|
'sync_direction' => (string) ($value['sync_direction'] ?? ''),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return $normalise($current) !== $normalise($next);
|
||||||
|
}
|
||||||
|
|
||||||
public function delete(FormField $field, ?string $confirmedName = null): void
|
public function delete(FormField $field, ?string $confirmedName = null): void
|
||||||
{
|
{
|
||||||
$schema = $field->schema;
|
$schema = $field->schema;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Exceptions\FormBuilder\EditLockConflictException;
|
|||||||
use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException;
|
use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException;
|
||||||
use App\FormBuilder\Purposes\PurposeRegistry;
|
use App\FormBuilder\Purposes\PurposeRegistry;
|
||||||
use App\Models\FormBuilder\FormField;
|
use App\Models\FormBuilder\FormField;
|
||||||
|
use App\Models\FormBuilder\FormFieldBinding;
|
||||||
use App\Models\FormBuilder\FormSchema;
|
use App\Models\FormBuilder\FormSchema;
|
||||||
use App\Models\FormBuilder\FormSchemaSection;
|
use App\Models\FormBuilder\FormSchemaSection;
|
||||||
use App\Models\FormBuilder\FormSubmission;
|
use App\Models\FormBuilder\FormSubmission;
|
||||||
@@ -133,8 +134,8 @@ final class FormSchemaService
|
|||||||
* purpose is bound by at least one field on the schema.
|
* purpose is bound by at least one field on the schema.
|
||||||
*
|
*
|
||||||
* Binding paths follow Pattern A notation (`{entity}.{attribute}`).
|
* Binding paths follow Pattern A notation (`{entity}.{attribute}`).
|
||||||
* In v1.0 we read `form_fields.binding` JSON; WS-5a will switch this
|
* Sourced from the relational `form_field_bindings` table (WS-5a;
|
||||||
* to the relational `form_field_bindings` table.
|
* ARCH §6.7, §17.3).
|
||||||
*/
|
*/
|
||||||
private function assertRequiredBindingsPresent(FormSchema $schema): void
|
private function assertRequiredBindingsPresent(FormSchema $schema): void
|
||||||
{
|
{
|
||||||
@@ -151,19 +152,16 @@ final class FormSchemaService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$bound = [];
|
$fieldIds = $schema->fields()->pluck('id');
|
||||||
foreach ($schema->fields as $field) {
|
|
||||||
$binding = $field->binding;
|
$bound = FormFieldBinding::query()
|
||||||
if (! is_array($binding)) {
|
->withoutGlobalScopes()
|
||||||
continue;
|
->where('owner_type', 'form_field')
|
||||||
}
|
->whereIn('owner_id', $fieldIds)
|
||||||
$entity = (string) ($binding['entity'] ?? '');
|
->get(['target_entity', 'target_attribute'])
|
||||||
$column = (string) ($binding['column'] ?? '');
|
->map(fn (FormFieldBinding $b) => $b->target_entity.'.'.$b->target_attribute)
|
||||||
if ($entity === '' || $column === '') {
|
->unique()
|
||||||
continue;
|
->all();
|
||||||
}
|
|
||||||
$bound[] = $entity.'.'.$column;
|
|
||||||
}
|
|
||||||
|
|
||||||
$missing = array_values(array_diff($required, $bound));
|
$missing = array_values(array_diff($required, $bound));
|
||||||
if ($missing !== []) {
|
if ($missing !== []) {
|
||||||
|
|||||||
@@ -291,18 +291,13 @@ final class FormValueService
|
|||||||
|
|
||||||
private function writeEntityMirror(FormSubmission $submission, FormField $field, mixed $raw): void
|
private function writeEntityMirror(FormSubmission $submission, FormField $field, mixed $raw): void
|
||||||
{
|
{
|
||||||
$binding = $field->binding;
|
$binding = $field->bindings()->first();
|
||||||
if (! is_array($binding) || ($binding['mode'] ?? null) === null) {
|
if ($binding === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$mode = (string) $binding['mode'];
|
$entity = (string) $binding->target_entity;
|
||||||
if (! in_array($mode, ['entity_owned', 'mirrored'], true)) {
|
$column = (string) $binding->target_attribute;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$entity = (string) ($binding['entity'] ?? '');
|
|
||||||
$column = (string) ($binding['column'] ?? '');
|
|
||||||
if ($entity === '' || $column === '') {
|
if ($entity === '' || $column === '') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
'company' => [
|
'company' => [
|
||||||
|
'name' => ['type' => 'string', 'label' => 'Bedrijfsnaam', 'writable' => true],
|
||||||
'contact_first_name' => ['type' => 'string', 'label' => 'Contact voornaam', 'writable' => true],
|
'contact_first_name' => ['type' => 'string', 'label' => 'Contact voornaam', 'writable' => true],
|
||||||
'contact_last_name' => ['type' => 'string', 'label' => 'Contact achternaam', 'writable' => true],
|
'contact_last_name' => ['type' => 'string', 'label' => 'Contact achternaam', 'writable' => true],
|
||||||
'contact_email' => ['type' => 'string', 'label' => 'Contact e-mail', 'writable' => true],
|
'contact_email' => ['type' => 'string', 'label' => 'Contact e-mail', 'writable' => true],
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ final class FormFieldFactory extends Factory
|
|||||||
'is_unique' => false,
|
'is_unique' => false,
|
||||||
'is_pii' => false,
|
'is_pii' => false,
|
||||||
'display_width' => FormFieldDisplayWidth::FULL,
|
'display_width' => FormFieldDisplayWidth::FULL,
|
||||||
'binding' => null,
|
|
||||||
'conditional_logic' => null,
|
'conditional_logic' => null,
|
||||||
'role_restrictions' => null,
|
'role_restrictions' => null,
|
||||||
'translations' => null,
|
'translations' => null,
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ final class FormFieldLibraryFactory extends Factory
|
|||||||
'validation_rules' => null,
|
'validation_rules' => null,
|
||||||
'default_is_required' => false,
|
'default_is_required' => false,
|
||||||
'default_is_filterable' => false,
|
'default_is_filterable' => false,
|
||||||
'default_binding' => null,
|
|
||||||
'translations' => null,
|
'translations' => null,
|
||||||
'description' => fake('nl_NL')->sentence(),
|
'description' => fake('nl_NL')->sentence(),
|
||||||
'is_active' => true,
|
'is_active' => true,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WS-5a commit 3 of 4 — drops the legacy `form_fields.binding` and
|
||||||
|
* `form_field_library.default_binding` JSON columns now that every reader
|
||||||
|
* (pre-publish check, snapshot writer, API resources, mirror writer, field
|
||||||
|
* CRUD path, and library CRUD path) has been switched to the relational
|
||||||
|
* `form_field_bindings` table.
|
||||||
|
*
|
||||||
|
* Rollback re-adds both columns as nullable JSON but does NOT attempt to
|
||||||
|
* backfill. The full rollback path for WS-5a as a whole is to first roll
|
||||||
|
* back the table-creation migration (`2026_04_25_100000_create_form_field_bindings_table`),
|
||||||
|
* which genuinely reconstructs the JSON. Rolling back *only* this
|
||||||
|
* column-drop migration leaves the JSON columns empty on purpose —
|
||||||
|
* callers that need the old JSON state must roll back both steps.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (Schema::hasColumn('form_fields', 'binding')) {
|
||||||
|
Schema::table('form_fields', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('binding');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Schema::hasColumn('form_field_library', 'default_binding')) {
|
||||||
|
Schema::table('form_field_library', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('default_binding');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasColumn('form_fields', 'binding')) {
|
||||||
|
Schema::table('form_fields', function (Blueprint $table): void {
|
||||||
|
$table->json('binding')->nullable()->after('display_width');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Schema::hasColumn('form_field_library', 'default_binding')) {
|
||||||
|
Schema::table('form_field_library', function (Blueprint $table): void {
|
||||||
|
$table->json('default_binding')->nullable()->after('default_is_filterable');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -99,7 +99,7 @@ final class FormBuilderDevSeeder
|
|||||||
'is_required' => $field['is_required'] ?? false,
|
'is_required' => $field['is_required'] ?? false,
|
||||||
'is_filterable' => $field['is_filterable'] ?? false,
|
'is_filterable' => $field['is_filterable'] ?? false,
|
||||||
'is_pii' => $field['is_pii'] ?? false,
|
'is_pii' => $field['is_pii'] ?? false,
|
||||||
'binding' => null,
|
'binding' => null, // Pattern B — snapshot embeds null for form-owned fields.
|
||||||
'conditional_logic' => null,
|
'conditional_logic' => null,
|
||||||
'translations' => null,
|
'translations' => null,
|
||||||
'value_storage_hint' => $field['type']->recommendedValueStorageHint()->value,
|
'value_storage_hint' => $field['type']->recommendedValueStorageHint()->value,
|
||||||
@@ -372,7 +372,6 @@ final class FormBuilderDevSeeder
|
|||||||
'is_admin_only' => false,
|
'is_admin_only' => false,
|
||||||
'is_pii' => false,
|
'is_pii' => false,
|
||||||
'display_width' => $def['display_width'] ?? 'full',
|
'display_width' => $def['display_width'] ?? 'full',
|
||||||
'binding' => null,
|
|
||||||
'role_restrictions' => null,
|
'role_restrictions' => null,
|
||||||
'value_storage_hint' => $def['value_storage_hint'] ?? FormValueStorageHint::JSON,
|
'value_storage_hint' => $def['value_storage_hint'] ?? FormValueStorageHint::JSON,
|
||||||
'sort_order' => $sortOrder + 1,
|
'sort_order' => $sortOrder + 1,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\FormBuilder\Bindings;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
final class BindingJsonColumnsDroppedTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_form_fields_has_no_binding_column(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_form_field_library_has_no_default_binding_column(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(Schema::hasColumn('form_field_library', 'default_binding'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace Tests\Feature\FormBuilder\Bindings;
|
namespace Tests\Feature\FormBuilder\Bindings;
|
||||||
|
|
||||||
use App\Models\FormBuilder\FormField;
|
|
||||||
use App\Models\FormBuilder\FormFieldLibrary;
|
|
||||||
use App\Models\FormBuilder\FormSchema;
|
use App\Models\FormBuilder\FormSchema;
|
||||||
use App\Models\Organisation;
|
use App\Models\Organisation;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
@@ -15,44 +13,48 @@ use Illuminate\Support\Str;
|
|||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seeds pre-migration JSON into `form_fields.binding` and
|
* Rolls back both WS-5a migrations (drop-columns + create-table), seeds
|
||||||
* `form_field_library.default_binding`, then runs the migration forward
|
* pre-migration JSON into `form_fields.binding` and
|
||||||
* and back asserting both directions.
|
* `form_field_library.default_binding`, then runs the migrations forward
|
||||||
|
* and back asserting:
|
||||||
*
|
*
|
||||||
* Forward: rows land in `form_field_bindings` with the correct
|
* - forward: rows land in form_field_bindings with the correct
|
||||||
* owner_type/owner_id + translated columns. Backward: JSON is
|
* owner_type/owner_id + translated columns; legacy JSON columns are
|
||||||
* reconstructed into the source tables, table dropped.
|
* dropped afterwards.
|
||||||
|
* - backward: the rollback pair genuinely reconstructs the JSON shape
|
||||||
|
* before dropping the table.
|
||||||
|
*
|
||||||
|
* The "roll back both steps" contract is explicitly documented in
|
||||||
|
* `2026_04_25_100001_drop_binding_json_columns.php`.
|
||||||
*/
|
*/
|
||||||
final class FormFieldBindingMigrationTest extends TestCase
|
final class FormFieldBindingMigrationTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
use RefreshDatabase;
|
||||||
|
|
||||||
private const MIGRATION_PATH = 'database/migrations/2026_04_25_100000_create_form_field_bindings_table.php';
|
public function test_forward_migrations_backfill_rows_from_both_json_sources(): void
|
||||||
|
|
||||||
public function test_forward_migration_backfills_rows_from_both_json_sources(): void
|
|
||||||
{
|
{
|
||||||
// Start from a clean slate where form_field_bindings does not exist.
|
// Roll back: drop_binding_json_columns → create_form_field_bindings.
|
||||||
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||||
$this->assertFalse(Schema::hasTable('form_field_bindings'));
|
$this->assertFalse(Schema::hasTable('form_field_bindings'));
|
||||||
|
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
|
||||||
|
$this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding'));
|
||||||
|
|
||||||
[$fieldAId, $fieldCId, $fieldDId] = $this->seedFieldsWithBindingJson();
|
[$fieldAId, $fieldCId, $fieldDId] = $this->seedFieldsWithBindingJson();
|
||||||
[$libAId, $libCId] = $this->seedLibraryWithBindingJson();
|
[$libAId, $libCId] = $this->seedLibraryWithBindingJson();
|
||||||
|
|
||||||
$this->artisan('migrate', [
|
$this->artisan('migrate')->assertSuccessful();
|
||||||
'--path' => self::MIGRATION_PATH,
|
|
||||||
'--realpath' => false,
|
|
||||||
])->assertSuccessful();
|
|
||||||
|
|
||||||
$this->assertTrue(Schema::hasTable('form_field_bindings'));
|
$this->assertTrue(Schema::hasTable('form_field_bindings'));
|
||||||
|
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
|
||||||
|
$this->assertFalse(Schema::hasColumn('form_field_library', 'default_binding'));
|
||||||
|
|
||||||
$rows = DB::table('form_field_bindings')->orderBy('owner_type')->orderBy('owner_id')->get();
|
$rows = DB::table('form_field_bindings')->get();
|
||||||
$this->assertCount(5, $rows, 'Expected 3 field + 2 library rows');
|
$this->assertCount(5, $rows, 'Expected 3 field + 2 library rows');
|
||||||
|
|
||||||
$fieldRowA = DB::table('form_field_bindings')
|
$fieldRowA = DB::table('form_field_bindings')
|
||||||
->where('owner_type', 'form_field')
|
->where('owner_type', 'form_field')
|
||||||
->where('owner_id', $fieldAId)
|
->where('owner_id', $fieldAId)
|
||||||
->first();
|
->first();
|
||||||
$this->assertNotNull($fieldRowA);
|
|
||||||
$this->assertSame('person', $fieldRowA->target_entity);
|
$this->assertSame('person', $fieldRowA->target_entity);
|
||||||
$this->assertSame('email', $fieldRowA->target_attribute);
|
$this->assertSame('email', $fieldRowA->target_attribute);
|
||||||
$this->assertSame('entity_owned', $fieldRowA->mode);
|
$this->assertSame('entity_owned', $fieldRowA->mode);
|
||||||
@@ -65,7 +67,6 @@ final class FormFieldBindingMigrationTest extends TestCase
|
|||||||
->where('owner_type', 'form_field')
|
->where('owner_type', 'form_field')
|
||||||
->where('owner_id', $fieldCId)
|
->where('owner_id', $fieldCId)
|
||||||
->first();
|
->first();
|
||||||
$this->assertNotNull($fieldRowC);
|
|
||||||
$this->assertSame('mirrored', $fieldRowC->mode);
|
$this->assertSame('mirrored', $fieldRowC->mode);
|
||||||
$this->assertSame('write_on_submit', $fieldRowC->sync_direction);
|
$this->assertSame('write_on_submit', $fieldRowC->sync_direction);
|
||||||
$this->assertSame('user_profile', $fieldRowC->target_entity);
|
$this->assertSame('user_profile', $fieldRowC->target_entity);
|
||||||
@@ -75,14 +76,12 @@ final class FormFieldBindingMigrationTest extends TestCase
|
|||||||
->where('owner_type', 'form_field')
|
->where('owner_type', 'form_field')
|
||||||
->where('owner_id', $fieldDId)
|
->where('owner_id', $fieldDId)
|
||||||
->first();
|
->first();
|
||||||
$this->assertNotNull($fieldRowD);
|
|
||||||
$this->assertSame('entity_owned', $fieldRowD->mode);
|
$this->assertSame('entity_owned', $fieldRowD->mode);
|
||||||
|
|
||||||
$libRowA = DB::table('form_field_bindings')
|
$libRowA = DB::table('form_field_bindings')
|
||||||
->where('owner_type', 'form_field_library')
|
->where('owner_type', 'form_field_library')
|
||||||
->where('owner_id', $libAId)
|
->where('owner_id', $libAId)
|
||||||
->first();
|
->first();
|
||||||
$this->assertNotNull($libRowA);
|
|
||||||
$this->assertSame('person', $libRowA->target_entity);
|
$this->assertSame('person', $libRowA->target_entity);
|
||||||
$this->assertSame('first_name', $libRowA->target_attribute);
|
$this->assertSame('first_name', $libRowA->target_attribute);
|
||||||
$this->assertSame('entity_owned', $libRowA->mode);
|
$this->assertSame('entity_owned', $libRowA->mode);
|
||||||
@@ -91,46 +90,45 @@ final class FormFieldBindingMigrationTest extends TestCase
|
|||||||
->where('owner_type', 'form_field_library')
|
->where('owner_type', 'form_field_library')
|
||||||
->where('owner_id', $libCId)
|
->where('owner_id', $libCId)
|
||||||
->first();
|
->first();
|
||||||
$this->assertNotNull($libRowC);
|
|
||||||
$this->assertSame('mirrored', $libRowC->mode);
|
$this->assertSame('mirrored', $libRowC->mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_rollback_reconstructs_json_and_drops_table(): void
|
public function test_rollback_reconstructs_json_and_drops_table(): void
|
||||||
{
|
{
|
||||||
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
$this->artisan('migrate:rollback', ['--step' => 2])->assertSuccessful();
|
||||||
[$fieldAId, , ] = $this->seedFieldsWithBindingJson();
|
[$fieldAId, , ] = $this->seedFieldsWithBindingJson();
|
||||||
[$libAId, ] = $this->seedLibraryWithBindingJson();
|
[$libAId, ] = $this->seedLibraryWithBindingJson();
|
||||||
|
|
||||||
$this->artisan('migrate', [
|
$this->artisan('migrate')->assertSuccessful();
|
||||||
'--path' => self::MIGRATION_PATH,
|
|
||||||
'--realpath' => false,
|
|
||||||
])->assertSuccessful();
|
|
||||||
|
|
||||||
// Wipe the source JSON to prove the rollback writes back from rows.
|
// Fully-forward state: columns gone, rows in form_field_bindings.
|
||||||
DB::table('form_fields')->where('id', $fieldAId)->update(['binding' => null]);
|
$this->assertFalse(Schema::hasColumn('form_fields', 'binding'));
|
||||||
DB::table('form_field_library')->where('id', $libAId)->update(['default_binding' => null]);
|
$this->assertSame(5, DB::table('form_field_bindings')->count());
|
||||||
|
|
||||||
|
// Step back over drop_binding_json_columns → columns reappear empty.
|
||||||
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
||||||
|
$this->assertTrue(Schema::hasColumn('form_fields', 'binding'));
|
||||||
|
$this->assertNull(DB::table('form_fields')->where('id', $fieldAId)->value('binding'));
|
||||||
|
|
||||||
|
// Step back over create_form_field_bindings → JSON reconstructed.
|
||||||
|
$this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful();
|
||||||
$this->assertFalse(Schema::hasTable('form_field_bindings'));
|
$this->assertFalse(Schema::hasTable('form_field_bindings'));
|
||||||
|
|
||||||
$field = DB::table('form_fields')->where('id', $fieldAId)->first();
|
$field = DB::table('form_fields')->where('id', $fieldAId)->first();
|
||||||
$this->assertNotNull($field->binding);
|
$this->assertNotNull($field->binding);
|
||||||
$json = json_decode((string) $field->binding, true);
|
|
||||||
$this->assertSame([
|
$this->assertSame([
|
||||||
'mode' => 'entity_owned',
|
'mode' => 'entity_owned',
|
||||||
'entity' => 'person',
|
'entity' => 'person',
|
||||||
'column' => 'email',
|
'column' => 'email',
|
||||||
], $json);
|
], json_decode((string) $field->binding, true));
|
||||||
|
|
||||||
$lib = DB::table('form_field_library')->where('id', $libAId)->first();
|
$lib = DB::table('form_field_library')->where('id', $libAId)->first();
|
||||||
$this->assertNotNull($lib->default_binding);
|
$this->assertNotNull($lib->default_binding);
|
||||||
$libJson = json_decode((string) $lib->default_binding, true);
|
|
||||||
$this->assertSame([
|
$this->assertSame([
|
||||||
'mode' => 'entity_owned',
|
'mode' => 'entity_owned',
|
||||||
'entity' => 'person',
|
'entity' => 'person',
|
||||||
'column' => 'first_name',
|
'column' => 'first_name',
|
||||||
], $libJson);
|
], json_decode((string) $lib->default_binding, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{0:string,1:string,2:string} */
|
/** @return array{0:string,1:string,2:string} */
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\FormBuilder\Bindings;
|
||||||
|
|
||||||
|
use App\Enums\FormBuilder\FormPurpose;
|
||||||
|
use App\Exceptions\FormBuilder\PurposeRequirementsNotMetException;
|
||||||
|
use App\Models\FormBuilder\FormField;
|
||||||
|
use App\Models\FormBuilder\FormSchema;
|
||||||
|
use App\Models\Organisation;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\FormBuilder\FormSchemaService;
|
||||||
|
use Database\Seeders\RoleSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pre-publish check (`FormSchemaService::publish()`,
|
||||||
|
* `PurposeRequirementsNotMetException`, ARCH §17.3) now reads from
|
||||||
|
* `form_field_bindings`. External contract (purposeSlug +
|
||||||
|
* missingBindings[]) unchanged.
|
||||||
|
*/
|
||||||
|
final class PublishChecksRelationalBindingsTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Organisation $org;
|
||||||
|
|
||||||
|
private User $actor;
|
||||||
|
|
||||||
|
private FormSchemaService $service;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->seed(RoleSeeder::class);
|
||||||
|
|
||||||
|
$this->org = Organisation::factory()->create();
|
||||||
|
$this->actor = User::factory()->create();
|
||||||
|
$this->org->users()->attach($this->actor, ['role' => 'org_admin']);
|
||||||
|
$this->actor->assignRole('org_admin');
|
||||||
|
setPermissionsTeamId($this->org->id);
|
||||||
|
|
||||||
|
$this->service = $this->app->make(FormSchemaService::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_publish_succeeds_when_all_required_bindings_are_in_relational_table(): void
|
||||||
|
{
|
||||||
|
$schema = $this->service->create(
|
||||||
|
$this->org,
|
||||||
|
['name' => 'ER', 'purpose' => FormPurpose::EVENT_REGISTRATION->value],
|
||||||
|
$this->actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
FormField::factory()->withEntityBinding('person', 'email')->create(['form_schema_id' => $schema->id]);
|
||||||
|
FormField::factory()->withEntityBinding('person', 'first_name')->create(['form_schema_id' => $schema->id]);
|
||||||
|
FormField::factory()->withEntityBinding('person', 'last_name')->create(['form_schema_id' => $schema->id]);
|
||||||
|
|
||||||
|
$published = $this->service->publish($schema->fresh(), $this->actor);
|
||||||
|
|
||||||
|
$this->assertTrue((bool) $published->is_published);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_publish_fails_when_required_binding_missing_reports_exact_paths(): void
|
||||||
|
{
|
||||||
|
$schema = $this->service->create(
|
||||||
|
$this->org,
|
||||||
|
['name' => 'ER-partial', 'purpose' => FormPurpose::EVENT_REGISTRATION->value],
|
||||||
|
$this->actor,
|
||||||
|
);
|
||||||
|
FormField::factory()->withEntityBinding('person', 'email')->create(['form_schema_id' => $schema->id]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->service->publish($schema->fresh(), $this->actor);
|
||||||
|
$this->fail('Expected PurposeRequirementsNotMetException');
|
||||||
|
} catch (PurposeRequirementsNotMetException $e) {
|
||||||
|
$this->assertSame('event_registration', $e->purposeSlug);
|
||||||
|
$this->assertSame(['person.first_name', 'person.last_name'], $e->missingBindings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_publish_ignores_bindings_belonging_to_other_schemas(): void
|
||||||
|
{
|
||||||
|
$schemaA = $this->service->create(
|
||||||
|
$this->org,
|
||||||
|
['name' => 'A', 'purpose' => FormPurpose::SUPPLIER_INTAKE->value],
|
||||||
|
$this->actor,
|
||||||
|
);
|
||||||
|
$schemaB = $this->service->create(
|
||||||
|
$this->org,
|
||||||
|
['name' => 'B', 'purpose' => FormPurpose::SUPPLIER_INTAKE->value],
|
||||||
|
$this->actor,
|
||||||
|
);
|
||||||
|
|
||||||
|
FormField::factory()->withEntityBinding('company', 'contact_email')->create(['form_schema_id' => $schemaB->id]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->service->publish($schemaA->fresh(), $this->actor);
|
||||||
|
$this->fail('Expected PurposeRequirementsNotMetException');
|
||||||
|
} catch (PurposeRequirementsNotMetException $e) {
|
||||||
|
$this->assertSame('supplier_intake', $e->purposeSlug);
|
||||||
|
$this->assertSame(['company.name'], $e->missingBindings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,7 +71,6 @@ final class FormFieldApiTest extends TestCase
|
|||||||
Sanctum::actingAs($this->admin);
|
Sanctum::actingAs($this->admin);
|
||||||
$field = FormField::factory()->create([
|
$field = FormField::factory()->create([
|
||||||
'form_schema_id' => $this->schema->id,
|
'form_schema_id' => $this->schema->id,
|
||||||
'binding' => null,
|
|
||||||
]);
|
]);
|
||||||
FormSubmission::factory()->create([
|
FormSubmission::factory()->create([
|
||||||
'form_schema_id' => $this->schema->id,
|
'form_schema_id' => $this->schema->id,
|
||||||
@@ -92,7 +91,6 @@ final class FormFieldApiTest extends TestCase
|
|||||||
Sanctum::actingAs($this->admin);
|
Sanctum::actingAs($this->admin);
|
||||||
$field = FormField::factory()->create([
|
$field = FormField::factory()->create([
|
||||||
'form_schema_id' => $this->schema->id,
|
'form_schema_id' => $this->schema->id,
|
||||||
'binding' => null,
|
|
||||||
]);
|
]);
|
||||||
FormSubmission::factory()->create([
|
FormSubmission::factory()->create([
|
||||||
'form_schema_id' => $this->schema->id,
|
'form_schema_id' => $this->schema->id,
|
||||||
|
|||||||
@@ -148,12 +148,13 @@ final class PurposeSchemaLifecycleTest extends TestCase
|
|||||||
|
|
||||||
private function addBindingField(FormSchema $schema, string $entity, string $column, string $slug): FormField
|
private function addBindingField(FormSchema $schema, string $entity, string $column, string $slug): FormField
|
||||||
{
|
{
|
||||||
return FormField::factory()->create([
|
return FormField::factory()
|
||||||
|
->withEntityBinding($entity, $column)
|
||||||
|
->create([
|
||||||
'form_schema_id' => $schema->id,
|
'form_schema_id' => $schema->id,
|
||||||
'field_type' => FormFieldType::TEXT,
|
'field_type' => FormFieldType::TEXT,
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
'label' => ucfirst($slug),
|
'label' => ucfirst($slug),
|
||||||
'binding' => ['mode' => 'entity_owned', 'entity' => $entity, 'column' => $column],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,20 +32,29 @@ final class FormFieldTest extends TestCase
|
|||||||
$this->assertIsString($field->fresh()->field_type);
|
$this->assertIsString($field->fresh()->field_type);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_form_field_casts_binding_and_translations_to_array(): void
|
public function test_form_field_casts_translations_to_array(): void
|
||||||
{
|
{
|
||||||
$field = FormField::factory()->create([
|
$field = FormField::factory()->create([
|
||||||
'binding' => ['mode' => 'entity_owned', 'entity' => 'person', 'column' => 'first_name'],
|
|
||||||
'translations' => ['en' => ['label' => 'First name']],
|
'translations' => ['en' => ['label' => 'First name']],
|
||||||
]);
|
]);
|
||||||
$fresh = $field->fresh();
|
$fresh = $field->fresh();
|
||||||
|
|
||||||
$this->assertIsArray($fresh->binding);
|
|
||||||
$this->assertSame('entity_owned', $fresh->binding['mode']);
|
|
||||||
$this->assertIsArray($fresh->translations);
|
$this->assertIsArray($fresh->translations);
|
||||||
$this->assertSame('First name', $fresh->translations['en']['label']);
|
$this->assertSame('First name', $fresh->translations['en']['label']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_form_field_bindings_relation_exposes_relational_rows(): void
|
||||||
|
{
|
||||||
|
$field = FormField::factory()
|
||||||
|
->withEntityBinding('person', 'first_name')
|
||||||
|
->create();
|
||||||
|
|
||||||
|
$binding = $field->bindings()->first();
|
||||||
|
$this->assertNotNull($binding);
|
||||||
|
$this->assertSame('person', $binding->target_entity);
|
||||||
|
$this->assertSame('first_name', $binding->target_attribute);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_form_field_has_many_values(): void
|
public function test_form_field_has_many_values(): void
|
||||||
{
|
{
|
||||||
$schema = FormSchema::factory()->create();
|
$schema = FormSchema::factory()->create();
|
||||||
|
|||||||
Reference in New Issue
Block a user