diff --git a/api/app/Exceptions/FormBuilder/InvalidConditionalLogicSpecException.php b/api/app/Exceptions/FormBuilder/InvalidConditionalLogicSpecException.php new file mode 100644 index 00000000..2812ca7d --- /dev/null +++ b/api/app/Exceptions/FormBuilder/InvalidConditionalLogicSpecException.php @@ -0,0 +1,18 @@ +service->create($formSchema, $request->validated()); - } catch (FrozenSchemaException|CyclicDependencyException $e) { + } catch (FrozenSchemaException|CyclicDependencyException|InvalidConditionalLogicSpecException $e) { return $this->error($e->getMessage(), 422); } @@ -67,7 +68,7 @@ final class FormFieldController extends Controller $field = $this->service->update($formField, $data, $force); } catch (BindingChangeBlockedException $e) { return $this->error($e->getMessage(), 422); - } catch (FrozenSchemaException|CyclicDependencyException $e) { + } catch (FrozenSchemaException|CyclicDependencyException|InvalidConditionalLogicSpecException $e) { return $this->error($e->getMessage(), 422); } diff --git a/api/app/Http/Resources/FormBuilder/FormFieldResource.php b/api/app/Http/Resources/FormBuilder/FormFieldResource.php index 42fcba98..254b8877 100644 --- a/api/app/Http/Resources/FormBuilder/FormFieldResource.php +++ b/api/app/Http/Resources/FormBuilder/FormFieldResource.php @@ -8,6 +8,7 @@ use App\Enums\FormBuilder\FormFieldType; use App\Models\FormBuilder\FormField; use App\Models\PersonTag; use App\Services\FormBuilder\FormFieldBindingService; +use App\Services\FormBuilder\FormFieldConditionalLogicService; use App\Services\FormBuilder\FormFieldConfigService; use App\Services\FormBuilder\FormFieldValidationRuleService; use App\Services\FormBuilder\FormLocaleResolver; @@ -60,7 +61,9 @@ final class FormFieldResource extends JsonResource 'binding' => app(FormFieldBindingService::class)->toJsonShape( $this->resource->bindings->first(), ), - 'conditional_logic' => $this->conditional_logic, + 'conditional_logic' => app(FormFieldConditionalLogicService::class)->toJsonShape( + $this->resource->rootConditionalLogicGroup(), + ), 'role_restrictions' => $this->role_restrictions, 'translations' => $this->translations, 'value_storage_hint' => $this->value_storage_hint instanceof \BackedEnum ? $this->value_storage_hint->value : $this->value_storage_hint, diff --git a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php index 0b2e9626..d8bac9f9 100644 --- a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php +++ b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php @@ -9,6 +9,7 @@ use App\Models\FormBuilder\FormField; use App\Models\FormBuilder\FormSchema; use App\Models\PersonTag; use App\Models\Scopes\OrganisationScope; +use App\Services\FormBuilder\FormFieldConditionalLogicService; use App\Services\FormBuilder\FormFieldConfigService; use App\Services\FormBuilder\FormFieldValidationRuleService; use Illuminate\Http\Request; @@ -89,7 +90,9 @@ final class PublicFormSchemaResource extends JsonResource 'configs' => app(FormFieldConfigService::class)->toJsonShape($f->configs), 'is_required' => (bool) $f->is_required, 'display_width' => $f->display_width instanceof \BackedEnum ? $f->display_width->value : $f->display_width, - 'conditional_logic' => $f->conditional_logic, + 'conditional_logic' => app(FormFieldConditionalLogicService::class)->toJsonShape( + $f->rootConditionalLogicGroup(), + ), 'sort_order' => (int) $f->sort_order, 'form_schema_section_id' => $f->form_schema_section_id, ]; diff --git a/api/app/Services/FormBuilder/FormFieldConditionalLogicService.php b/api/app/Services/FormBuilder/FormFieldConditionalLogicService.php new file mode 100644 index 00000000..7dded8fd --- /dev/null +++ b/api/app/Services/FormBuilder/FormFieldConditionalLogicService.php @@ -0,0 +1,467 @@ +conditionalLogicGroups() + ->whereNull('parent_group_id') + ->with($this->nestedEagerLoad(5)) + ->first(); + + return $root; + } + + /** + * Replace the full conditional-logic tree on a field transactionally. + * Validates structure, operators, field_slug references, and cross- + * field cycles before any write lands. Emits the semantic activity-log + * entry on the owning FormField. + * + * Passing `null` clears the logic entirely (matches the legacy + * `conditional_logic = null` JSON state). + * + * @param array|null $tree root group shape (no + * `show_when` wrapper — callers unwrap the wrapper before calling) + */ + public function replaceLogic(FormField $field, ?array $tree): void + { + if ($tree === null || $tree === []) { + DB::transaction(function () use ($field): void { + $this->deleteExistingTree($field); + $field->logFieldChange('field.conditional_logic_replaced', [ + 'count' => 0, + ]); + }); + + return; + } + + $this->assertSpecsValid($tree); + $this->assertFieldSlugsResolve($field, $tree); + $this->assertNoCycles($field, $tree); + + DB::transaction(function () use ($field, $tree): void { + $this->deleteExistingTree($field); + $this->insertNode($field->id, null, $tree, 0); + $field->logFieldChange('field.conditional_logic_replaced', [ + 'count' => $this->countNodes($tree), + ]); + }); + } + + /** + * Serialise a root group (plus its subtree) back to the ARCH §8 + * `{show_when: {...}}` JSON shape. Returns null when the field has no + * logic — matches the pre-WS-5c `conditional_logic = null` state. + */ + public function toJsonShape(?FormFieldConditionalLogicGroup $root): ?array + { + if ($root === null) { + return null; + } + + return [ + 'show_when' => $this->renderGroup($root), + ]; + } + + /** + * Public guard the FormRequests invoke in their `after()` hook (WS-5c + * commit 3 strict validator on save). Rejects bad tree structure at + * the HTTP boundary before any write lands. + * + * @param array $tree root group shape + */ + public function assertSpecsValid(array $tree): void + { + $this->assertNodeValid($tree, isRoot: true); + } + + /** + * Cross-field cycle detection (ARCH §8; contract preserved from the + * pre-WS-5c `FormFieldService::assertNoConditionalCycle`). Builds a + * dependency graph over every field in the owning schema — this field's + * proposed tree plus the persisted trees of every sibling — and runs + * DFS from this field's slug. A back-edge raises. + * + * @param array $tree proposed root group shape + */ + public function assertNoCycles(FormField $field, array $tree): void + { + $subjectSlug = (string) $field->slug; + $dependsOn = $this->extractDependencySlugs($tree); + + if ($dependsOn === []) { + return; + } + + $adjacency = $this->buildSchemaAdjacency($field); + $adjacency[$subjectSlug] = $dependsOn; + + $visiting = []; + $visited = []; + $walk = function (string $node) use (&$walk, &$adjacency, &$visiting, &$visited, $subjectSlug): void { + if (isset($visited[$node])) { + return; + } + if (isset($visiting[$node])) { + throw CyclicDependencyException::forField($subjectSlug); + } + $visiting[$node] = true; + foreach ($adjacency[$node] ?? [] as $next) { + $walk($next); + } + unset($visiting[$node]); + $visited[$node] = true; + }; + + $walk($subjectSlug); + } + + // --- internals ---------------------------------------------------- + + private function deleteExistingTree(FormField $field): void + { + // Cascade: deleting the root groups deletes all descendants and + // all conditions via the DB-level ON DELETE CASCADE chain. + FormFieldConditionalLogicGroup::query() + ->withoutGlobalScopes() + ->where('form_field_id', $field->id) + ->whereNull('parent_group_id') + ->get() + ->each(fn (FormFieldConditionalLogicGroup $g) => $g->delete()); + + // Defensive: clear any orphaned descendants (should never exist + // after the root delete, but we guard against partial failures). + FormFieldConditionalLogicGroup::query() + ->withoutGlobalScopes() + ->where('form_field_id', $field->id) + ->delete(); + } + + /** + * @param array $node + */ + private function insertNode(string $fieldId, ?string $parentGroupId, array $node, int $sortOrder): void + { + if (isset($node['field_slug'])) { + $operator = FormFieldConditionalLogicConditionOperator::from((string) $node['operator']); + + FormFieldConditionalLogicCondition::query()->withoutGlobalScopes()->create([ + 'group_id' => $parentGroupId, + 'field_slug' => (string) $node['field_slug'], + 'comparison_operator' => $operator->value, + 'value' => $operator->isValueless() ? null : ($node['value'] ?? null), + 'sort_order' => $sortOrder, + ]); + + return; + } + + $groupOperator = FormFieldConditionalLogicGroupOperator::from((string) $node['operator']); + + $group = FormFieldConditionalLogicGroup::query()->withoutGlobalScopes()->create([ + 'form_field_id' => $fieldId, + 'parent_group_id' => $parentGroupId, + 'operator' => $groupOperator->value, + 'sort_order' => $sortOrder, + ]); + + /** @var array> $children */ + $children = $node['children'] ?? []; + foreach ($children as $childSortOrder => $child) { + $this->insertNode($fieldId, $group->id, $child, (int) $childSortOrder); + } + } + + /** + * @param array $node + */ + private function assertNodeValid(array $node, bool $isRoot): void + { + if (isset($node['field_slug'])) { + if ($isRoot) { + throw new InvalidConditionalLogicSpecException( + 'Conditional logic tree root must be a group (with an "all" or "any" operator), not a bare condition.', + ); + } + + $rawOperator = (string) ($node['operator'] ?? ''); + if (FormFieldConditionalLogicConditionOperator::tryFrom($rawOperator) === null) { + throw new InvalidConditionalLogicSpecException( + "Unknown comparison operator '{$rawOperator}'. Valid operators: " + .implode(', ', array_map( + fn (FormFieldConditionalLogicConditionOperator $op) => $op->value, + FormFieldConditionalLogicConditionOperator::cases(), + )) + .'.', + ); + } + + $slug = (string) ($node['field_slug'] ?? ''); + if ($slug === '') { + throw new InvalidConditionalLogicSpecException( + 'Condition requires a non-empty field_slug.', + ); + } + + return; + } + + $rawOperator = (string) ($node['operator'] ?? ''); + if (FormFieldConditionalLogicGroupOperator::tryFrom($rawOperator) === null) { + throw new InvalidConditionalLogicSpecException( + "Unknown group operator '{$rawOperator}'. Valid operators: all, any.", + ); + } + + $children = $node['children'] ?? null; + if (! is_array($children) || $children === []) { + throw new InvalidConditionalLogicSpecException( + 'Conditional logic groups must have at least one child (group or condition).', + ); + } + + foreach ($children as $child) { + if (! is_array($child)) { + throw new InvalidConditionalLogicSpecException( + 'Group children must be arrays (group or condition nodes).', + ); + } + $this->assertNodeValid($child, isRoot: false); + } + } + + /** + * @param array $tree + */ + private function assertFieldSlugsResolve(FormField $field, array $tree): void + { + $slugs = $this->extractDependencySlugs($tree); + if ($slugs === []) { + return; + } + + $known = FormField::query() + ->withoutGlobalScopes() + ->where('form_schema_id', $field->form_schema_id) + ->pluck('slug') + ->all(); + + $missing = array_values(array_diff($slugs, $known)); + if ($missing !== []) { + throw new InvalidConditionalLogicSpecException( + 'Conditional logic references unknown field_slug(s): '.implode(', ', $missing).'.', + ); + } + } + + /** + * @param array $tree + * @return list + */ + private function extractDependencySlugs(array $tree): array + { + $out = []; + $walk = function (array $node) use (&$walk, &$out): void { + if (isset($node['field_slug'])) { + $out[] = (string) $node['field_slug']; + + return; + } + foreach ($node['children'] ?? [] as $child) { + if (is_array($child)) { + $walk($child); + } + } + }; + $walk($tree); + + return array_values(array_unique($out)); + } + + /** + * @param array $tree + */ + private function countNodes(array $tree): int + { + $count = 0; + $walk = function (array $node) use (&$walk, &$count): void { + $count++; + foreach ($node['children'] ?? [] as $child) { + if (is_array($child)) { + $walk($child); + } + } + }; + $walk($tree); + + return $count; + } + + /** + * Build a slug → list-of-dependent-slugs map for every OTHER field in + * the owning schema. Subject slug is excluded; caller merges its + * proposed deps in separately. Reads from the relational tables — the + * post-WS-5c source of truth. + * + * @return array> + */ + private function buildSchemaAdjacency(FormField $subject): array + { + $siblings = FormField::query() + ->withoutGlobalScopes() + ->where('form_schema_id', $subject->form_schema_id) + ->where('id', '!=', $subject->id) + ->get(['id', 'slug']); + + if ($siblings->isEmpty()) { + return []; + } + + $adjacency = []; + foreach ($siblings as $sibling) { + $root = $sibling->rootConditionalLogicGroup(); + if ($root === null) { + continue; + } + $deps = $this->collectConditionSlugsFromPersistedTree($root); + if ($deps !== []) { + $adjacency[(string) $sibling->slug] = $deps; + } + } + + return $adjacency; + } + + /** + * @return list + */ + private function collectConditionSlugsFromPersistedTree(FormFieldConditionalLogicGroup $root): array + { + $slugs = []; + $walk = function (FormFieldConditionalLogicGroup $group) use (&$walk, &$slugs): void { + foreach ($group->conditions as $condition) { + $slugs[] = (string) $condition->field_slug; + } + foreach ($group->childGroups as $child) { + $walk($child); + } + }; + $walk($root); + + return array_values(array_unique($slugs)); + } + + /** + * @return array + */ + private function renderGroup(FormFieldConditionalLogicGroup $group): array + { + $conditions = $group->conditions->map(fn (FormFieldConditionalLogicCondition $c) => [ + 'node' => $this->renderCondition($c), + 'sort_order' => (int) $c->sort_order, + 'id' => (string) $c->id, + ]); + $children = $group->childGroups->map(fn (FormFieldConditionalLogicGroup $g) => [ + 'node' => $this->renderGroup($g), + 'sort_order' => (int) $g->sort_order, + 'id' => (string) $g->id, + ]); + + // Interleave by sort_order, stable secondary by id. + $merged = $conditions->concat($children) + ->sortBy([ + ['sort_order', 'asc'], + ['id', 'asc'], + ]) + ->values() + ->map(fn (array $entry): array => $entry['node']) + ->all(); + + $operatorValue = $group->operator instanceof FormFieldConditionalLogicGroupOperator + ? $group->operator->value + : (string) $group->operator; + + return [$operatorValue => $merged]; + } + + /** + * @return array + */ + private function renderCondition(FormFieldConditionalLogicCondition $condition): array + { + $operator = $condition->comparison_operator instanceof FormFieldConditionalLogicConditionOperator + ? $condition->comparison_operator + : FormFieldConditionalLogicConditionOperator::from((string) $condition->comparison_operator); + + $row = [ + 'field_slug' => (string) $condition->field_slug, + 'operator' => $operator->value, + ]; + + if (! $operator->isValueless()) { + $row['value'] = $condition->value; + } + + return $row; + } + + /** + * Depth-bounded eager-load spec for logicFor. Builds nested + * `childGroups.childGroups...` strings up to the given depth, with + * `conditions` on each level. + * + * @return list + */ + private function nestedEagerLoad(int $depth): array + { + $loads = ['conditions']; + $prefix = ''; + for ($i = 0; $i < $depth; $i++) { + $prefix = $prefix === '' ? 'childGroups' : $prefix.'.childGroups'; + $loads[] = $prefix; + $loads[] = $prefix.'.conditions'; + } + + return $loads; + } +} diff --git a/api/app/Services/FormBuilder/FormFieldService.php b/api/app/Services/FormBuilder/FormFieldService.php index 80ab2692..efb73e32 100644 --- a/api/app/Services/FormBuilder/FormFieldService.php +++ b/api/app/Services/FormBuilder/FormFieldService.php @@ -29,6 +29,7 @@ final class FormFieldService private readonly FormFieldBindingService $bindingService, private readonly FormFieldValidationRuleService $validationRuleService, private readonly FormFieldConfigService $configService, + private readonly FormFieldConditionalLogicService $conditionalLogicService, ) {} public function create(FormSchema $schema, array $data): FormField @@ -40,8 +41,7 @@ final class FormFieldService $bindingSpec = $this->extractBindingSpec($data); $validationRuleSpecs = $this->extractValidationRuleSpecs($data); - - $this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null); + [$conditionalTree, $conditionalProvided] = $this->extractConditionalLogicTree($data); /** @var FormField $field */ $field = FormField::create($data); @@ -54,6 +54,15 @@ final class FormFieldService $this->validationRuleService->replaceRules($field, $validationRuleSpecs); } + if ($conditionalProvided) { + // Cycle check runs inside the service — reads the schema's + // relational adjacency and throws on a back-edge. + if ($conditionalTree !== null) { + $this->conditionalLogicService->assertNoCycles($field, $conditionalTree); + } + $this->conditionalLogicService->replaceLogic($field, $conditionalTree); + } + $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.created'); @@ -76,18 +85,22 @@ final class FormFieldService $validationRulesProvided = array_key_exists('validation_rules', $data); $validationRuleSpecs = $validationRulesProvided ? $this->extractValidationRuleSpecs($data) : null; + [$conditionalTree, $conditionalProvided] = $this->extractConditionalLogicTree($data); + $currentBindingShape = $this->bindingService->toJsonShape($field->bindings()->first()); + $currentConditionalShape = $this->conditionalLogicService->toJsonShape($field->rootConditionalLogicGroup()); if ($bindingProvided && $this->bindingChanged($currentBindingShape, $rawBinding)) { $this->assertBindingChangeAllowed($field, $forceBindingChange); } - if (array_key_exists('conditional_logic', $data)) { - $this->assertNoConditionalCycle($schema, $field, $data['conditional_logic'], $data['slug'] ?? $field->slug); + if ($conditionalProvided && $conditionalTree !== null) { + $this->conditionalLogicService->assertNoCycles($field, $conditionalTree); } $before = [ 'binding' => $currentBindingShape, + 'conditional_logic' => $currentConditionalShape, 'is_filterable' => $field->is_filterable, 'is_pii' => $field->is_pii, 'field_type' => $field->field_type, @@ -104,12 +117,17 @@ final class FormFieldService $this->validationRuleService->replaceRules($field, $validationRuleSpecs ?? []); } + if ($conditionalProvided) { + $this->conditionalLogicService->replaceLogic($field, $conditionalTree); + } + $this->schemaService->bumpVersion($schema); $field->logFieldChange('field.updated', [ 'old' => $before, 'new' => [ 'binding' => $this->bindingService->toJsonShape($field->bindings()->first()), + 'conditional_logic' => $this->conditionalLogicService->toJsonShape($field->fresh()?->rootConditionalLogicGroup()), 'is_filterable' => $field->is_filterable, 'is_pii' => $field->is_pii, 'field_type' => $field->field_type, @@ -305,89 +323,78 @@ final class FormFieldService } } - private function assertNoConditionalCycle(FormSchema $schema, ?FormField $subject, mixed $conditionalLogic, ?string $subjectSlug): void + /** + * Extract `conditional_logic` from incoming write-data. Returns a + * tuple `[tree, wasProvided]`: `tree` is the root-group spec (or + * null when the caller cleared logic); `wasProvided` is false when + * the key was absent from the request — we must distinguish "no + * change requested" from "cleared to null". + * + * Strips the `show_when` wrapper (controllers submit the outer JSON + * shape as-is) and rewrites nested `{"all"|"any": [...]}` nodes into + * the service's internal `{"operator", "children"}` form. + * + * @param array $data + * @return array{0: array|null, 1: bool} + */ + private function extractConditionalLogicTree(array &$data): array { - if ($conditionalLogic === null || $subjectSlug === null) { - return; + if (! array_key_exists('conditional_logic', $data)) { + return [null, false]; + } + $raw = $data['conditional_logic']; + unset($data['conditional_logic']); + + if ($raw === null || $raw === [] || ! is_array($raw)) { + return [null, true]; } - $dependsOn = $this->extractConditionSlugs($conditionalLogic); - if ($dependsOn === []) { - return; + if (isset($raw['show_when']) && is_array($raw['show_when'])) { + /** @var array $rootGroup */ + $rootGroup = $raw['show_when']; + } else { + /** @var array $rootGroup */ + $rootGroup = $raw; } - $adjacency = $this->buildConditionalAdjacency($schema, $subject, $subjectSlug, $dependsOn); - - $visiting = []; - $visited = []; - $walk = function (string $node) use (&$walk, &$adjacency, &$visiting, &$visited, $subjectSlug): void { - if (isset($visited[$node])) { - return; - } - if (isset($visiting[$node])) { - throw CyclicDependencyException::forField($subjectSlug); - } - $visiting[$node] = true; - foreach ($adjacency[$node] ?? [] as $next) { - $walk($next); - } - unset($visiting[$node]); - $visited[$node] = true; - }; - - $walk($subjectSlug); + return [$this->normaliseLegacyGroupShape($rootGroup), true]; } /** - * @return array + * @param array $node + * @return array */ - private function extractConditionSlugs(mixed $logic): array + private function normaliseLegacyGroupShape(array $node): array { - if (! is_array($logic)) { - return []; + if (isset($node['field_slug'])) { + return $node; } - $slugs = []; - $walk = function ($node) use (&$walk, &$slugs): void { - if (! is_array($node)) { - return; - } - if (isset($node['field_slug'])) { - $slugs[] = (string) $node['field_slug']; - } - foreach ($node as $child) { + + if (isset($node['operator'], $node['children']) && is_array($node['children'])) { + $children = []; + foreach ($node['children'] as $child) { if (is_array($child)) { - $walk($child); + $children[] = $this->normaliseLegacyGroupShape($child); } } - }; - $walk($logic); - return array_values(array_unique($slugs)); - } + return ['operator' => $node['operator'], 'children' => $children]; + } - /** - * @param array $seedDeps - * @return array> - */ - private function buildConditionalAdjacency(FormSchema $schema, ?FormField $subject, string $subjectSlug, array $seedDeps): array - { - $fields = FormField::query() - ->where('form_schema_id', $schema->id) - ->get(['id', 'slug', 'conditional_logic']); + foreach (['all', 'any'] as $candidate) { + if (isset($node[$candidate]) && is_array($node[$candidate])) { + $children = []; + foreach ($node[$candidate] as $child) { + if (is_array($child)) { + $children[] = $this->normaliseLegacyGroupShape($child); + } + } - $adjacency = []; - foreach ($fields as $f) { - if ($subject !== null && $f->id === $subject->id) { - continue; - } - $deps = $this->extractConditionSlugs($f->conditional_logic); - if ($deps !== []) { - $adjacency[$f->slug] = $deps; + return ['operator' => $candidate, 'children' => $children]; } } - $adjacency[$subjectSlug] = $seedDeps; - return $adjacency; + return $node; } /** diff --git a/api/app/Services/FormBuilder/FormSubmissionService.php b/api/app/Services/FormBuilder/FormSubmissionService.php index 47635a97..39c13875 100644 --- a/api/app/Services/FormBuilder/FormSubmissionService.php +++ b/api/app/Services/FormBuilder/FormSubmissionService.php @@ -36,6 +36,7 @@ final class FormSubmissionService private readonly FormFieldBindingService $bindingService, private readonly FormFieldValidationRuleService $validationRuleService, private readonly FormFieldConfigService $configService, + private readonly FormFieldConditionalLogicService $conditionalLogicService, ) {} /** @@ -240,7 +241,7 @@ final class FormSubmissionService 'is_filterable' => (bool) $f->is_filterable, 'is_pii' => (bool) $f->is_pii, 'binding' => $this->bindingService->toJsonShape($f->bindings->first()), - 'conditional_logic' => $f->conditional_logic, + 'conditional_logic' => $this->conditionalLogicService->toJsonShape($f->rootConditionalLogicGroup()), 'translations' => $f->translations, 'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint, 'sort_order' => $f->sort_order, diff --git a/api/database/migrations/2026_04_26_100002_backfill_form_field_conditional_logic.php b/api/database/migrations/2026_04_26_100002_backfill_form_field_conditional_logic.php new file mode 100644 index 00000000..1b2f609b --- /dev/null +++ b/api/database/migrations/2026_04_26_100002_backfill_form_field_conditional_logic.php @@ -0,0 +1,249 @@ +whereNotNull('conditional_logic') + ->orderBy('id') + ->get(['id', 'conditional_logic']); + + foreach ($rows as $row) { + $decoded = json_decode((string) $row->conditional_logic, true); + if (! is_array($decoded) || $decoded === []) { + continue; + } + + $this->assertLegacyShape((string) $row->id, $decoded); + + /** @var array $rootGroup */ + $rootGroup = $decoded['show_when']; + $this->insertNode((string) $row->id, null, $rootGroup, 0); + } + }); + } + + public function down(): void + { + if ( + ! Schema::hasTable('form_field_conditional_logic_groups') + || ! Schema::hasTable('form_field_conditional_logic_conditions') + || ! Schema::hasTable('form_fields') + || ! Schema::hasColumn('form_fields', 'conditional_logic') + ) { + return; + } + + DB::transaction(function (): void { + $rootGroups = DB::table('form_field_conditional_logic_groups') + ->whereNull('parent_group_id') + ->orderBy('form_field_id') + ->get(['id', 'form_field_id', 'operator']); + + foreach ($rootGroups as $root) { + $json = [ + 'show_when' => $this->renderGroup((string) $root->id, (string) $root->operator), + ]; + DB::table('form_fields') + ->where('id', $root->form_field_id) + ->update(['conditional_logic' => json_encode($json, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)]); + } + + DB::table('form_field_conditional_logic_conditions')->delete(); + DB::table('form_field_conditional_logic_groups')->delete(); + }); + } + + /** + * @param array $decoded + */ + private function assertLegacyShape(string $fieldId, array $decoded): void + { + $allowedRootKeys = ['show_when']; + $unknownKeys = array_diff(array_keys($decoded), $allowedRootKeys); + if ($unknownKeys !== []) { + throw new RuntimeException(sprintf( + 'form_fields.id=%s conditional_logic has unknown top-level key(s): %s. ' + .'ARCH §8 allows only "show_when"; Phase A seed-scan (2026-04-26) confirmed ' + .'no other keys in the wild. Fix source data before re-running the migration.', + $fieldId, + implode(', ', $unknownKeys), + )); + } + + if (! isset($decoded['show_when']) || ! is_array($decoded['show_when'])) { + throw new RuntimeException(sprintf( + 'form_fields.id=%s conditional_logic.show_when is missing or not a group.', + $fieldId, + )); + } + } + + /** + * @param array $node a group node has the shape + * `["all"|"any" => [ ]]`; a condition leaf has + * `["field_slug" => ..., "operator" => ..., "value" => ...]`. + */ + private function insertNode(string $fieldId, ?string $parentGroupId, array $node, int $sortOrder): void + { + if (isset($node['field_slug'])) { + $rawOperator = (string) ($node['operator'] ?? ''); + $operator = FormFieldConditionalLogicConditionOperator::tryFrom($rawOperator); + if ($operator === null) { + throw new RuntimeException(sprintf( + "form_fields.id=%s conditional_logic contains unknown comparison operator '%s'. " + .'Valid operators: equals, not_equals, contains, not_contains, in, not_in, ' + .'greater_than, less_than, empty, not_empty. Fix source data and re-run.', + $fieldId, + $rawOperator, + )); + } + + $value = $operator->isValueless() ? null : ($node['value'] ?? null); + + DB::table('form_field_conditional_logic_conditions')->insert([ + 'id' => (string) Str::ulid(), + 'group_id' => $parentGroupId, + 'field_slug' => (string) ($node['field_slug'] ?? ''), + 'comparison_operator' => $operator->value, + 'value' => $value === null ? null : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'sort_order' => $sortOrder, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return; + } + + // Group node: exactly one of "all" / "any" carries the children list. + $operatorValue = null; + $children = []; + foreach (['all', 'any'] as $candidate) { + if (array_key_exists($candidate, $node) && is_array($node[$candidate])) { + $operatorValue = $candidate; + $children = $node[$candidate]; + break; + } + } + + if ($operatorValue === null) { + throw new RuntimeException(sprintf( + 'form_fields.id=%s conditional_logic contains a group with no "all" or "any" key. ' + .'Fix source data and re-run.', + $fieldId, + )); + } + + $groupOperator = FormFieldConditionalLogicGroupOperator::from($operatorValue); + + $groupId = (string) Str::ulid(); + DB::table('form_field_conditional_logic_groups')->insert([ + 'id' => $groupId, + 'form_field_id' => $fieldId, + 'parent_group_id' => $parentGroupId, + 'operator' => $groupOperator->value, + 'sort_order' => $sortOrder, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + foreach ($children as $childSortOrder => $child) { + if (! is_array($child)) { + throw new RuntimeException(sprintf( + 'form_fields.id=%s conditional_logic child at position %d is not an array. Fix source data and re-run.', + $fieldId, + (int) $childSortOrder, + )); + } + $this->insertNode($fieldId, $groupId, $child, (int) $childSortOrder); + } + } + + /** + * @return array + */ + private function renderGroup(string $groupId, string $operator): array + { + $conditions = DB::table('form_field_conditional_logic_conditions') + ->where('group_id', $groupId) + ->orderBy('sort_order') + ->orderBy('id') + ->get(['id', 'field_slug', 'comparison_operator', 'value', 'sort_order']); + + $childGroups = DB::table('form_field_conditional_logic_groups') + ->where('parent_group_id', $groupId) + ->orderBy('sort_order') + ->orderBy('id') + ->get(['id', 'operator', 'sort_order']); + + $rendered = []; + foreach ($conditions as $row) { + $opEnum = FormFieldConditionalLogicConditionOperator::from((string) $row->comparison_operator); + $node = [ + 'field_slug' => (string) $row->field_slug, + 'operator' => $opEnum->value, + ]; + if (! $opEnum->isValueless()) { + $node['value'] = $row->value === null ? null : json_decode((string) $row->value, true); + } + $rendered[] = [ + 'sort_order' => (int) $row->sort_order, + 'id' => (string) $row->id, + 'node' => $node, + ]; + } + foreach ($childGroups as $row) { + $rendered[] = [ + 'sort_order' => (int) $row->sort_order, + 'id' => (string) $row->id, + 'node' => $this->renderGroup((string) $row->id, (string) $row->operator), + ]; + } + + usort($rendered, function (array $a, array $b): int { + return $a['sort_order'] <=> $b['sort_order'] + ?: strcmp($a['id'], $b['id']); + }); + + $children = array_map(static fn (array $entry): array => $entry['node'], $rendered); + + return [$operator => $children]; + } +}; diff --git a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php index aef74cda..6cb40747 100644 --- a/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php +++ b/api/tests/Feature/FormBuilder/Bindings/FormFieldBindingMigrationTest.php @@ -33,12 +33,13 @@ final class FormFieldBindingMigrationTest extends TestCase public function test_forward_migrations_backfill_rows_from_both_json_sources(): void { - // Roll back to pre-WS-5a state: 2 WS-5c migrations - // (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) = 9. - $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); + // Roll back to pre-WS-5a state: 3 WS-5c migrations + // (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' => 10])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_bindings')); $this->assertTrue(Schema::hasColumn('form_fields', 'binding')); $this->assertTrue(Schema::hasColumn('form_field_library', 'default_binding')); @@ -99,8 +100,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 (9 migrations). - $this->artisan('migrate:rollback', ['--step' => 9])->assertSuccessful(); + // Walk back the full WS-5c + WS-5b + WS-5a stack (10 migrations). + $this->artisan('migrate:rollback', ['--step' => 10])->assertSuccessful(); [$fieldAId, , ] = $this->seedFieldsWithBindingJson(); [$libAId, ] = $this->seedLibraryWithBindingJson(); @@ -110,11 +111,11 @@ 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 (2 migrations) + WS-5b (5 migrations) in one + // 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' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $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 new file mode 100644 index 00000000..5325ea46 --- /dev/null +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicBackfillTest.php @@ -0,0 +1,228 @@ +artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + $this->assertTrue(Schema::hasColumn('form_fields', 'conditional_logic')); + + $fieldId = $this->seedFieldWithJson([ + 'show_when' => [ + 'all' => [ + ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + [ + 'any' => [ + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'], + ], + ], + ['field_slug' => 'status', 'operator' => 'empty'], + ], + ], + ]); + + $this->artisan('migrate')->assertSuccessful(); + + // Root group: `all`, no parent, 2 conditions + 1 subgroup. + $rootGroup = DB::table('form_field_conditional_logic_groups') + ->where('form_field_id', $fieldId) + ->whereNull('parent_group_id') + ->first(); + $this->assertNotNull($rootGroup); + $this->assertSame('all', $rootGroup->operator); + + $conditions = DB::table('form_field_conditional_logic_conditions') + ->where('group_id', $rootGroup->id) + ->orderBy('sort_order') + ->get(); + $this->assertCount(2, $conditions); + $this->assertSame('gate', $conditions[0]->field_slug); + $this->assertSame('equals', $conditions[0]->comparison_operator); + $this->assertSame('yes', json_decode((string) $conditions[0]->value, true)); + + $this->assertSame('status', $conditions[1]->field_slug); + $this->assertSame('empty', $conditions[1]->comparison_operator); + $this->assertNull($conditions[1]->value, 'valueless operator stores null'); + + // Subgroup: `any`, 2 conditions. + $subGroup = DB::table('form_field_conditional_logic_groups') + ->where('parent_group_id', $rootGroup->id) + ->first(); + $this->assertNotNull($subGroup); + $this->assertSame('any', $subGroup->operator); + $subConditions = DB::table('form_field_conditional_logic_conditions') + ->where('group_id', $subGroup->id) + ->orderBy('sort_order') + ->get(); + $this->assertCount(2, $subConditions); + $this->assertSame('region', $subConditions[0]->field_slug); + $this->assertSame('NL', json_decode((string) $subConditions[0]->value, true)); + } + + public function test_rollback_reconstructs_canonical_json(): void + { + // Starting state: fully migrated. Seed relational rows bypassing the + // service, then roll back one step (backfill) — it should repopulate + // the JSON column. + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $fieldId = (string) Str::ulid(); + DB::table('form_fields')->insert([ + 'id' => $fieldId, + 'form_schema_id' => $schema->id, + 'field_type' => 'TEXT', + 'slug' => 'subject', + 'label' => 'Subject', + 'value_storage_hint' => 'json', + 'sort_order' => 0, + 'conditional_logic' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $rootId = (string) Str::ulid(); + DB::table('form_field_conditional_logic_groups')->insert([ + 'id' => $rootId, + 'form_field_id' => $fieldId, + 'parent_group_id' => null, + 'operator' => 'all', + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('form_field_conditional_logic_conditions')->insert([ + 'id' => (string) Str::ulid(), + 'group_id' => $rootId, + 'field_slug' => 'gate', + 'comparison_operator' => 'equals', + 'value' => json_encode('yes'), + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $subGroupId = (string) Str::ulid(); + DB::table('form_field_conditional_logic_groups')->insert([ + 'id' => $subGroupId, + 'form_field_id' => $fieldId, + 'parent_group_id' => $rootId, + 'operator' => 'any', + 'sort_order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('form_field_conditional_logic_conditions')->insert([ + 'id' => (string) Str::ulid(), + 'group_id' => $subGroupId, + 'field_slug' => 'region', + 'comparison_operator' => 'equals', + 'value' => json_encode('NL'), + 'sort_order' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Roll back only the backfill migration — writes the JSON back. + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + + $reconstructed = DB::table('form_fields') + ->where('id', $fieldId) + ->value('conditional_logic'); + $this->assertNotNull($reconstructed); + $json = json_decode((string) $reconstructed, true); + $this->assertSame([ + 'show_when' => [ + 'all' => [ + ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + [ + 'any' => [ + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], + ], + ], + ], + ], + ], $json); + + // Relational tables cleared after reconstruction. + $this->assertSame(0, DB::table('form_field_conditional_logic_groups')->count()); + $this->assertSame(0, DB::table('form_field_conditional_logic_conditions')->count()); + } + + public function test_unknown_top_level_key_fails_migration(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + + $this->seedFieldWithJson([ + 'hide_when' => ['all' => [['field_slug' => 'x', 'operator' => 'equals', 'value' => 1]]], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/hide_when/'); + $this->artisan('migrate'); + } + + public function test_unknown_comparison_operator_fails_migration(): void + { + $this->artisan('migrate:rollback', ['--step' => 1])->assertSuccessful(); + + $this->seedFieldWithJson([ + 'show_when' => ['all' => [['field_slug' => 'x', 'operator' => 'matches_regex', 'value' => 'y']]], + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/matches_regex/'); + $this->artisan('migrate'); + } + + /** @param array $json */ + private function seedFieldWithJson(array $json): string + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + + $id = (string) Str::ulid(); + DB::table('form_fields')->insert([ + 'id' => $id, + 'form_schema_id' => $schema->id, + 'field_type' => 'TEXT', + 'slug' => 'f-'.Str::lower(Str::random(4)), + 'label' => 'field', + 'value_storage_hint' => 'json', + 'sort_order' => 0, + 'conditional_logic' => json_encode($json), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $id; + } +} diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicCycleDetectionTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicCycleDetectionTest.php new file mode 100644 index 00000000..5a5ae92d --- /dev/null +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicCycleDetectionTest.php @@ -0,0 +1,124 @@ +seedSchemaWithTwoFields(); + $service = app(FormFieldConditionalLogicService::class); + + // A depends on B. + $service->replaceLogic($fieldA, [ + 'operator' => 'all', + 'children' => [ + ['field_slug' => $fieldB->slug, 'operator' => 'equals', 'value' => 'y'], + ], + ]); + + // Proposing B depends on A would close the A → B → A cycle. + $this->expectException(CyclicDependencyException::class); + $service->assertNoCycles($fieldB, [ + 'operator' => 'all', + 'children' => [ + ['field_slug' => $fieldA->slug, 'operator' => 'equals', 'value' => 'x'], + ], + ]); + } + + public function test_three_node_cycle_rejected(): void + { + [$schema, $fieldA, $fieldB, $fieldC] = $this->seedSchemaWithThreeFields(); + $service = app(FormFieldConditionalLogicService::class); + + // A → B, B → C + $service->replaceLogic($fieldA, [ + 'operator' => 'all', + 'children' => [['field_slug' => $fieldB->slug, 'operator' => 'equals', 'value' => 'y']], + ]); + $service->replaceLogic($fieldB, [ + 'operator' => 'all', + 'children' => [['field_slug' => $fieldC->slug, 'operator' => 'equals', 'value' => 'y']], + ]); + + // Proposing C → A closes A → B → C → A. + $this->expectException(CyclicDependencyException::class); + $service->assertNoCycles($fieldC, [ + 'operator' => 'all', + 'children' => [['field_slug' => $fieldA->slug, 'operator' => 'equals', 'value' => 'x']], + ]); + } + + public function test_diamond_is_accepted_no_cycle(): void + { + [$schema, $fieldA, $fieldB, $fieldC] = $this->seedSchemaWithThreeFields(); + $service = app(FormFieldConditionalLogicService::class); + + // A → B and C → B (two fields depend on the same field, no cycle). + $service->replaceLogic($fieldA, [ + 'operator' => 'all', + 'children' => [['field_slug' => $fieldB->slug, 'operator' => 'equals', 'value' => 'y']], + ]); + + $service->assertNoCycles($fieldC, [ + 'operator' => 'all', + 'children' => [['field_slug' => $fieldB->slug, 'operator' => 'equals', 'value' => 'y']], + ]); + + $this->expectNotToPerformAssertions(); + } + + public function test_self_reference_rejected(): void + { + [$schema, $fieldA] = $this->seedSchemaWithTwoFields(); + $service = app(FormFieldConditionalLogicService::class); + + $this->expectException(CyclicDependencyException::class); + $service->assertNoCycles($fieldA, [ + 'operator' => 'all', + 'children' => [['field_slug' => $fieldA->slug, 'operator' => 'equals', 'value' => 'x']], + ]); + } + + /** @return array{0:FormSchema,1:FormField,2:FormField} */ + private function seedSchemaWithTwoFields(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $fieldA = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'a']); + $fieldB = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'b']); + + return [$schema, $fieldA, $fieldB]; + } + + /** @return array{0:FormSchema,1:FormField,2:FormField,3:FormField} */ + private function seedSchemaWithThreeFields(): array + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $fieldA = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'a']); + $fieldB = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'b']); + $fieldC = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'c']); + + return [$schema, $fieldA, $fieldB, $fieldC]; + } +} diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicSnapshotAndResourceParityTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicSnapshotAndResourceParityTest.php new file mode 100644 index 00000000..3c27998a --- /dev/null +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/ConditionalLogicSnapshotAndResourceParityTest.php @@ -0,0 +1,76 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'subject']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'gate']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'region']); + + app(FormFieldConditionalLogicService::class)->replaceLogic($field, [ + 'operator' => 'all', + 'children' => [ + ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + [ + 'operator' => 'any', + 'children' => [ + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'], + ], + ], + ], + ]); + + $resource = new FormFieldResource($field->fresh()); + $payload = $resource->toArray(Request::create('/', 'GET')); + + $this->assertSame([ + 'show_when' => [ + 'all' => [ + ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + [ + 'any' => [ + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'], + ], + ], + ], + ], + ], $payload['conditional_logic']); + } + + public function test_form_field_resource_yields_null_when_no_logic(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id]); + + $resource = new FormFieldResource($field->fresh()); + $payload = $resource->toArray(Request::create('/', 'GET')); + + $this->assertNull($payload['conditional_logic']); + } +} diff --git a/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicServiceTest.php b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicServiceTest.php new file mode 100644 index 00000000..44f44f5e --- /dev/null +++ b/api/tests/Feature/FormBuilder/ConditionalLogic/FormFieldConditionalLogicServiceTest.php @@ -0,0 +1,231 @@ +create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'subject']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'gate']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'region']); + + $service = app(FormFieldConditionalLogicService::class); + $service->replaceLogic($field, [ + 'operator' => FormFieldConditionalLogicGroupOperator::All->value, + 'children' => [ + ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + [ + 'operator' => FormFieldConditionalLogicGroupOperator::Any->value, + 'children' => [ + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'], + ], + ], + ], + ]); + + $this->assertSame(2, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame(3, FormFieldConditionalLogicCondition::query()->count()); + $this->assertDatabaseHas('activity_log', [ + 'description' => 'field.conditional_logic_replaced', + 'subject_type' => 'form_field', + 'subject_id' => $field->id, + ]); + } + + public function test_replace_logic_is_transactional_and_replaces_existing_tree(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'subject']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'gate']); + + $service = app(FormFieldConditionalLogicService::class); + $service->replaceLogic($field, [ + 'operator' => 'all', + 'children' => [ + ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + ], + ]); + $this->assertSame(1, FormFieldConditionalLogicCondition::query()->count()); + + $service->replaceLogic($field, [ + 'operator' => 'any', + 'children' => [ + ['field_slug' => 'gate', 'operator' => 'not_equals', 'value' => 'no'], + ], + ]); + + // Full replacement — old rows gone, new rows in place. + $this->assertSame(1, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame(1, FormFieldConditionalLogicCondition::query()->count()); + $condition = FormFieldConditionalLogicCondition::query()->first(); + $this->assertSame('not_equals', $condition->comparison_operator->value); + } + + public function test_replace_logic_with_null_clears_tree(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'subject']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'gate']); + + $service = app(FormFieldConditionalLogicService::class); + $service->replaceLogic($field, [ + 'operator' => 'all', + 'children' => [ + ['field_slug' => 'gate', 'operator' => 'empty'], + ], + ]); + + $service->replaceLogic($field, null); + + $this->assertSame(0, FormFieldConditionalLogicGroup::query()->count()); + $this->assertSame(0, FormFieldConditionalLogicCondition::query()->count()); + } + + public function test_assert_specs_valid_rejects_unknown_operator(): void + { + $service = app(FormFieldConditionalLogicService::class); + $this->expectException(InvalidConditionalLogicSpecException::class); + $service->assertSpecsValid([ + 'operator' => 'all', + 'children' => [ + ['field_slug' => 'x', 'operator' => 'nonsense_op', 'value' => 1], + ], + ]); + } + + public function test_assert_specs_valid_rejects_root_condition(): void + { + $service = app(FormFieldConditionalLogicService::class); + $this->expectException(InvalidConditionalLogicSpecException::class); + $service->assertSpecsValid([ + 'field_slug' => 'x', + 'operator' => 'equals', + 'value' => 'y', + ]); + } + + public function test_assert_specs_valid_rejects_empty_group(): void + { + $service = app(FormFieldConditionalLogicService::class); + $this->expectException(InvalidConditionalLogicSpecException::class); + $service->assertSpecsValid([ + 'operator' => 'all', + 'children' => [], + ]); + } + + public function test_replace_logic_rejects_unknown_field_slug(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'subject']); + + $service = app(FormFieldConditionalLogicService::class); + $this->expectException(InvalidConditionalLogicSpecException::class); + $this->expectExceptionMessageMatches('/ghost_slug/'); + $service->replaceLogic($field, [ + 'operator' => 'all', + 'children' => [ + ['field_slug' => 'ghost_slug', 'operator' => 'equals', 'value' => 'x'], + ], + ]); + } + + public function test_to_json_shape_reconstructs_canonical_show_when(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'subject']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'gate']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'region']); + + $service = app(FormFieldConditionalLogicService::class); + $service->replaceLogic($field, [ + 'operator' => 'all', + 'children' => [ + ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + [ + 'operator' => 'any', + 'children' => [ + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], + ['field_slug' => 'region', 'operator' => 'empty'], + ], + ], + ], + ]); + + $shape = $service->toJsonShape($field->fresh()->rootConditionalLogicGroup()); + $this->assertNotNull($shape); + $this->assertSame([ + 'show_when' => [ + 'all' => [ + ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'], + [ + 'any' => [ + ['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'], + ['field_slug' => 'region', 'operator' => 'empty'], + ], + ], + ], + ], + ], $shape); + } + + public function test_to_json_shape_returns_null_when_no_logic(): void + { + $service = app(FormFieldConditionalLogicService::class); + $this->assertNull($service->toJsonShape(null)); + } + + public function test_valueless_condition_stores_null_and_omits_value_in_json(): void + { + $org = Organisation::factory()->create(); + $schema = FormSchema::factory()->create(['organisation_id' => $org->id]); + $field = FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'subject']); + FormField::factory()->create(['form_schema_id' => $schema->id, 'slug' => 'gate']); + + $service = app(FormFieldConditionalLogicService::class); + $service->replaceLogic($field, [ + 'operator' => 'all', + 'children' => [ + ['field_slug' => 'gate', 'operator' => 'not_empty', 'value' => 'this_should_be_dropped'], + ], + ]); + + $condition = FormFieldConditionalLogicCondition::query()->first(); + $this->assertNull($condition->value); + $this->assertSame(FormFieldConditionalLogicConditionOperator::NotEmpty, $condition->comparison_operator); + + $shape = $service->toJsonShape($field->fresh()->rootConditionalLogicGroup()); + $this->assertSame([ + 'show_when' => [ + 'all' => [ + ['field_slug' => 'gate', 'operator' => 'not_empty'], + ], + ], + ], $shape); + } +} diff --git a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php index 3697dcfc..cd210440 100644 --- a/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php +++ b/api/tests/Feature/FormBuilder/Configs/FormFieldConfigBackfillAndDropTest.php @@ -30,7 +30,7 @@ final class FormFieldConfigBackfillAndDropTest extends TestCase // Roll back 2 WS-5c migrations + 5 WS-5b migrations = 7, to get the // pre-WS-5b state where the JSON column still exists on form_fields // / form_field_library. - $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $fieldId = $this->seedField([ diff --git a/api/tests/Feature/FormBuilder/FormFieldApiTest.php b/api/tests/Feature/FormBuilder/FormFieldApiTest.php index ae5214c4..6139b2b5 100644 --- a/api/tests/Feature/FormBuilder/FormFieldApiTest.php +++ b/api/tests/Feature/FormBuilder/FormFieldApiTest.php @@ -110,14 +110,23 @@ final class FormFieldApiTest extends TestCase { Sanctum::actingAs($this->admin); - // field A depends on field B - $fieldA = FormField::factory()->create([ - 'form_schema_id' => $this->schema->id, - 'slug' => 'a', - 'conditional_logic' => ['show_when' => ['all' => [['field_slug' => 'b', 'operator' => 'equals', 'value' => true]]]], - ]); + // field A depends on field B — relational rows via factory helper + // (WS-5c commit 2 moved cycle detection to the relational-backed + // `FormFieldConditionalLogicService::assertNoCycles`). + $fieldA = FormField::factory() + ->withConditionalLogic([ + 'operator' => 'all', + 'children' => [ + ['field_slug' => 'b', 'operator' => 'equals', 'value' => true], + ], + ]) + ->create([ + 'form_schema_id' => $this->schema->id, + 'slug' => 'a', + ]); $fieldB = FormField::factory()->create(['form_schema_id' => $this->schema->id, 'slug' => 'b']); + // Updating fieldB to depend on fieldA would close the A → B → A loop. $response = $this->putJson( "/api/v1/organisations/{$this->org->id}/forms/schemas/{$this->schema->id}/fields/{$fieldB->id}", ['conditional_logic' => ['show_when' => ['all' => [['field_slug' => 'a', 'operator' => 'equals', 'value' => true]]]]], diff --git a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php index bbc60d8e..6274ac7c 100644 --- a/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php +++ b/api/tests/Feature/FormBuilder/ValidationRules/FormFieldValidationRuleBackfillTest.php @@ -37,7 +37,7 @@ final class FormFieldValidationRuleBackfillTest extends TestCase // validation-rules-backfill + create-validation-rules) = 7. // Brings us to the pre-WS-5b state: validation_rules JSON column // present, no relational tables for WS-5b. - $this->artisan('migrate:rollback', ['--step' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $this->assertFalse(Schema::hasTable('form_field_validation_rules')); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); @@ -98,7 +98,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' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TAG_PICKER', @@ -122,7 +122,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' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $fieldId = $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -149,7 +149,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' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'TEXT', @@ -166,7 +166,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' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $this->seedFieldWithJson([ 'field_type' => 'BOOLEAN', @@ -185,7 +185,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' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); [$numberId] = $this->seedFields(); $this->artisan('migrate')->assertSuccessful(); @@ -200,7 +200,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' => 7])->assertSuccessful(); + $this->artisan('migrate:rollback', ['--step' => 8])->assertSuccessful(); $this->assertTrue(Schema::hasColumn('form_fields', 'validation_rules')); $field = DB::table('form_fields')->where('id', $numberId)->first();