refactor(form-field): extract legacy conditional_logic shape normaliser
Three byte-identical copies of `normaliseLegacyGroupShape` lived in
FormFieldService, StoreFormFieldRequest, and UpdateFormFieldRequest.
WS-5d (form_fields.options) would have been the fourth copy. Hoist
the helper to a single public static on FormFieldConditionalLogicService
and have all three call sites delegate.
Implementation:
- `FormFieldConditionalLogicService::normaliseLegacyShape(array)` —
pure recursive passthrough. Translates the ARCH §8 JSON group shape
(`{"all": [...]}` / `{"any": [...]}`) into the service's internal
`{"operator", "children"}` form. Does NOT validate; malformed shapes
return as-is and surface downstream as
`InvalidConditionalLogicSpecException` from `assertSpecsValid`.
- Group operator catalogue sourced from
`FormFieldConditionalLogicGroupOperator::values()` instead of an
`['all', 'any']` literal — single source of truth for future
operator additions.
- All three call sites switched to the static method. The two
FormRequests reach it via the existing `use` import; FormFieldService
sits in the same namespace.
Behaviour preserved exactly:
- Existing FormFieldApiTest (cyclic logic rejection),
FormFieldStrictConditionalLogicRequestTest (strict-validator
rejection paths), and FormFieldConditionalLogicServiceTest
(service-level paths) all green without modification.
New unit tests pin the passthrough contract (8 tests):
- Valid ALL / ANY translations
- Recursive nested-group translation (depth 2)
- Internal shape unchanged
- Condition leaf passthrough
- Unknown group key (`xor`) returned unchanged for downstream
`assertSpecsValid` to reject
- Empty array unchanged
- Non-array children stripped silently
Tests: 1150 → 1158 green (3110 → 3124 assertions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,4 +14,16 @@ enum FormFieldConditionalLogicGroupOperator: string
|
|||||||
{
|
{
|
||||||
case All = 'all';
|
case All = 'all';
|
||||||
case Any = 'any';
|
case Any = 'any';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String values of every case — used by the legacy-shape normaliser
|
||||||
|
* to walk `{"all": [...]}` / `{"any": [...]}` JSON nodes without
|
||||||
|
* duplicating the operator catalogue at the call site.
|
||||||
|
*
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_map(static fn (self $case): string => $case->value, self::cases());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ final class StoreFormFieldRequest extends FormRequest
|
|||||||
$root = isset($logic['show_when']) && is_array($logic['show_when'])
|
$root = isset($logic['show_when']) && is_array($logic['show_when'])
|
||||||
? $logic['show_when']
|
? $logic['show_when']
|
||||||
: $logic;
|
: $logic;
|
||||||
$normalised = $this->normaliseLegacyGroupShape($root);
|
$normalised = FormFieldConditionalLogicService::normaliseLegacyShape($root);
|
||||||
try {
|
try {
|
||||||
app(FormFieldConditionalLogicService::class)->assertSpecsValid($normalised);
|
app(FormFieldConditionalLogicService::class)->assertSpecsValid($normalised);
|
||||||
} catch (InvalidConditionalLogicSpecException $e) {
|
} catch (InvalidConditionalLogicSpecException $e) {
|
||||||
@@ -108,44 +108,4 @@ final class StoreFormFieldRequest extends FormRequest
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirror of FormFieldService::normaliseLegacyGroupShape — translates
|
|
||||||
* the ARCH §8 JSON group shape (`{"all": [...]}` / `{"any": [...]}`)
|
|
||||||
* to the service's internal `{"operator", "children"}` form so the
|
|
||||||
* boundary validator can reuse the service's canonical assertion.
|
|
||||||
*
|
|
||||||
* @param array<string, mixed> $node
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function normaliseLegacyGroupShape(array $node): array
|
|
||||||
{
|
|
||||||
if (isset($node['field_slug'])) {
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
if (isset($node['operator'], $node['children']) && is_array($node['children'])) {
|
|
||||||
$children = [];
|
|
||||||
foreach ($node['children'] as $child) {
|
|
||||||
if (is_array($child)) {
|
|
||||||
$children[] = $this->normaliseLegacyGroupShape($child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['operator' => $node['operator'], 'children' => $children];
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['operator' => $candidate, 'children' => $children];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ final class UpdateFormFieldRequest extends FormRequest
|
|||||||
$root = isset($logic['show_when']) && is_array($logic['show_when'])
|
$root = isset($logic['show_when']) && is_array($logic['show_when'])
|
||||||
? $logic['show_when']
|
? $logic['show_when']
|
||||||
: $logic;
|
: $logic;
|
||||||
$normalised = $this->normaliseLegacyGroupShape($root);
|
$normalised = FormFieldConditionalLogicService::normaliseLegacyShape($root);
|
||||||
try {
|
try {
|
||||||
app(FormFieldConditionalLogicService::class)->assertSpecsValid($normalised);
|
app(FormFieldConditionalLogicService::class)->assertSpecsValid($normalised);
|
||||||
} catch (InvalidConditionalLogicSpecException $e) {
|
} catch (InvalidConditionalLogicSpecException $e) {
|
||||||
@@ -111,39 +111,4 @@ final class UpdateFormFieldRequest extends FormRequest
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $node
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function normaliseLegacyGroupShape(array $node): array
|
|
||||||
{
|
|
||||||
if (isset($node['field_slug'])) {
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
if (isset($node['operator'], $node['children']) && is_array($node['children'])) {
|
|
||||||
$children = [];
|
|
||||||
foreach ($node['children'] as $child) {
|
|
||||||
if (is_array($child)) {
|
|
||||||
$children[] = $this->normaliseLegacyGroupShape($child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['operator' => $node['operator'], 'children' => $children];
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['operator' => $candidate, 'children' => $children];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,57 @@ final class FormFieldConditionalLogicService
|
|||||||
$this->assertNodeValid($tree, isRoot: true);
|
$this->assertNodeValid($tree, isRoot: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate the legacy ARCH §8 JSON group shape (`{"all": [...]}` /
|
||||||
|
* `{"any": [...]}`) into the service's internal tree form
|
||||||
|
* (`{"operator": "all"|"any", "children": [...]}`). Pure recursive
|
||||||
|
* passthrough — does NOT validate; malformed shapes are returned
|
||||||
|
* as-is and surface as `InvalidConditionalLogicSpecException` from
|
||||||
|
* the downstream `assertSpecsValid`. Static so both the service path
|
||||||
|
* (`FormFieldService::extractConditionalLogicTree`) and the boundary
|
||||||
|
* validators (Store/Update FormRequests' `after()` hooks) can call
|
||||||
|
* it without container resolution.
|
||||||
|
*
|
||||||
|
* Group operator catalogue is sourced from
|
||||||
|
* `FormFieldConditionalLogicGroupOperator::values()` so a future
|
||||||
|
* operator addition lands in one place.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $node
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function normaliseLegacyShape(array $node): array
|
||||||
|
{
|
||||||
|
if (isset($node['field_slug'])) {
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($node['operator'], $node['children']) && is_array($node['children'])) {
|
||||||
|
$children = [];
|
||||||
|
foreach ($node['children'] as $child) {
|
||||||
|
if (is_array($child)) {
|
||||||
|
$children[] = self::normaliseLegacyShape($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['operator' => $node['operator'], 'children' => $children];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (FormFieldConditionalLogicGroupOperator::values() as $candidate) {
|
||||||
|
if (isset($node[$candidate]) && is_array($node[$candidate])) {
|
||||||
|
$children = [];
|
||||||
|
foreach ($node[$candidate] as $child) {
|
||||||
|
if (is_array($child)) {
|
||||||
|
$children[] = self::normaliseLegacyShape($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['operator' => $candidate, 'children' => $children];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cross-field cycle detection (ARCH §8; contract preserved from the
|
* Cross-field cycle detection (ARCH §8; contract preserved from the
|
||||||
* pre-WS-5c `FormFieldService::assertNoConditionalCycle`). Builds a
|
* pre-WS-5c `FormFieldService::assertNoConditionalCycle`). Builds a
|
||||||
|
|||||||
@@ -367,44 +367,7 @@ final class FormFieldService
|
|||||||
$rootGroup = $raw;
|
$rootGroup = $raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
return [$this->normaliseLegacyGroupShape($rootGroup), true];
|
return [FormFieldConditionalLogicService::normaliseLegacyShape($rootGroup), true];
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $node
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function normaliseLegacyGroupShape(array $node): array
|
|
||||||
{
|
|
||||||
if (isset($node['field_slug'])) {
|
|
||||||
return $node;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isset($node['operator'], $node['children']) && is_array($node['children'])) {
|
|
||||||
$children = [];
|
|
||||||
foreach ($node['children'] as $child) {
|
|
||||||
if (is_array($child)) {
|
|
||||||
$children[] = $this->normaliseLegacyGroupShape($child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['operator' => $node['operator'], 'children' => $children];
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['operator' => $candidate, 'children' => $children];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $node;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Unit\Services\FormBuilder;
|
||||||
|
|
||||||
|
use App\Services\FormBuilder\FormFieldConditionalLogicService;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit-level coverage for the `normaliseLegacyShape` static helper —
|
||||||
|
* extracted in WS-5c commit 6 from three byte-identical copies (one in
|
||||||
|
* FormFieldService, two in the Store/Update FormRequests). The method
|
||||||
|
* is pure passthrough: malformed shapes are returned as-is, and surface
|
||||||
|
* downstream as `InvalidConditionalLogicSpecException` from
|
||||||
|
* `assertSpecsValid`. We pin that contract here.
|
||||||
|
*/
|
||||||
|
final class FormFieldConditionalLogicNormaliserTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_translates_all_group_to_internal_form(): void
|
||||||
|
{
|
||||||
|
$result = FormFieldConditionalLogicService::normaliseLegacyShape([
|
||||||
|
'all' => [
|
||||||
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'operator' => 'all',
|
||||||
|
'children' => [
|
||||||
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
||||||
|
],
|
||||||
|
], $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_translates_any_group_to_internal_form(): void
|
||||||
|
{
|
||||||
|
$result = FormFieldConditionalLogicService::normaliseLegacyShape([
|
||||||
|
'any' => [
|
||||||
|
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
|
||||||
|
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('any', $result['operator']);
|
||||||
|
$this->assertCount(2, $result['children']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_recursively_normalises_nested_groups(): void
|
||||||
|
{
|
||||||
|
$result = FormFieldConditionalLogicService::normaliseLegacyShape([
|
||||||
|
'all' => [
|
||||||
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
||||||
|
[
|
||||||
|
'any' => [
|
||||||
|
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'NL'],
|
||||||
|
['field_slug' => 'region', 'operator' => 'equals', 'value' => 'BE'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('all', $result['operator']);
|
||||||
|
$this->assertCount(2, $result['children']);
|
||||||
|
|
||||||
|
// First child: condition leaf — passthrough.
|
||||||
|
$this->assertSame(
|
||||||
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
||||||
|
$result['children'][0],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second child: nested ANY group — translated.
|
||||||
|
$this->assertSame('any', $result['children'][1]['operator']);
|
||||||
|
$this->assertCount(2, $result['children'][1]['children']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_passes_through_internal_shape_unchanged(): void
|
||||||
|
{
|
||||||
|
$internal = [
|
||||||
|
'operator' => 'all',
|
||||||
|
'children' => [
|
||||||
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertSame($internal, FormFieldConditionalLogicService::normaliseLegacyShape($internal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_passes_through_condition_leaf_unchanged(): void
|
||||||
|
{
|
||||||
|
// Conditions at root are technically invalid (a tree must start
|
||||||
|
// with a group); the normaliser returns the leaf as-is and the
|
||||||
|
// downstream `assertSpecsValid` produces the 422 error.
|
||||||
|
$leaf = ['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'];
|
||||||
|
|
||||||
|
$this->assertSame($leaf, FormFieldConditionalLogicService::normaliseLegacyShape($leaf));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_unknown_group_key_as_passthrough_for_downstream_assert(): void
|
||||||
|
{
|
||||||
|
// `xor` is not in the FormFieldConditionalLogicGroupOperator enum.
|
||||||
|
// Normaliser returns the node unchanged; assertSpecsValid will
|
||||||
|
// raise `InvalidConditionalLogicSpecException` with "Unknown
|
||||||
|
// group operator ''" because the passthrough lacks `operator`.
|
||||||
|
$node = [
|
||||||
|
'xor' => [
|
||||||
|
['field_slug' => 'a', 'operator' => 'equals', 'value' => 1],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertSame($node, FormFieldConditionalLogicService::normaliseLegacyShape($node));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_returns_empty_array_unchanged(): void
|
||||||
|
{
|
||||||
|
$this->assertSame([], FormFieldConditionalLogicService::normaliseLegacyShape([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_skips_non_array_children_silently(): void
|
||||||
|
{
|
||||||
|
// Defensive: malformed children that are scalars get stripped
|
||||||
|
// (existing behaviour — `if (is_array($child))` guard). Surfaces
|
||||||
|
// downstream as a "group requires at least one child" error if
|
||||||
|
// every child was scalar.
|
||||||
|
$result = FormFieldConditionalLogicService::normaliseLegacyShape([
|
||||||
|
'all' => [
|
||||||
|
'string-not-an-array',
|
||||||
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
||||||
|
42,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertCount(1, $result['children']);
|
||||||
|
$this->assertSame(
|
||||||
|
['field_slug' => 'gate', 'operator' => 'equals', 'value' => 'yes'],
|
||||||
|
$result['children'][0],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user