diff --git a/modules/backend/behaviors/RelationController.php b/modules/backend/behaviors/RelationController.php index c171ee4e79..ae095fc8fd 100644 --- a/modules/backend/behaviors/RelationController.php +++ b/modules/backend/behaviors/RelationController.php @@ -536,8 +536,13 @@ protected function isNestedField() * a) AMBIENT - the nesting stack is non-empty, meaning a manage * form is actively rendering right now (see * relationMakePartial()) and this field is nested directly - * under whatever's on top of it. $model is trusted as-is - it's - * the widget's own bound model. + * under whatever's on top of it. $model canNOT be trusted + * here: initRelation() is reached as + * relationRender() -> validateField() -> initRelation($this->model), + * and $this->model is the record of the ENCLOSING relation, + * not of the manage form actually rendering. Those coincide at + * one level of nesting and diverge at two, so the parent is + * walked from the root exactly as in (b). * b) RECONSTRUCTED - $field is already a bracket path (e.g. * "items[5][taxes]"), walked from the root to find the actual * model. This is the common case for anything other than an @@ -574,7 +579,9 @@ protected function resolveNestedContext($model, $field) if ($ambientPath !== null) { $this->nestedField = $ambientPath . '[' . $field . ']'; - return [$model, $field, true]; + [$hops] = $this->parseBracketField($this->nestedField); + + return [$this->resolveModelForHops($this->rootModel, $hops), $field, true]; } if (strpos($field, '[') !== false) { diff --git a/modules/backend/tests/behaviors/RelationControllerNestedTest.php b/modules/backend/tests/behaviors/RelationControllerNestedTest.php index 880f34bd8a..4feea5b362 100644 --- a/modules/backend/tests/behaviors/RelationControllerNestedTest.php +++ b/modules/backend/tests/behaviors/RelationControllerNestedTest.php @@ -918,6 +918,57 @@ public function testAmbientFieldDistinguishesBetweenSiblingItems() $this->assertNotEquals($nestedFieldForA, $nestedFieldForB, 'Two sibling items\' ambient contexts must resolve to distinct qualified fields'); } + /** + * Two levels of ambient nesting - an Item's manage form rendering + * a sub-item's manage form, which itself renders a relation field. + * + * The model RelationManager hands initRelation() is always + * $this->model, i.e. the record of the ENCLOSING relation. At one + * level of nesting that IS the parent, so trusting it works. At + * two it is the grandparent, and trusting it resolves the leaf + * relation against the wrong ancestor - in the real world a + * BadMethodCallException, since the grandparent's class usually + * has no such relation at all. + */ + public function testAmbientFieldTwoLevelsDeepResolvesInnermostAncestorNotEnclosingRecord() + { + $order = RelationTestOrder::create(['name' => 'Order 1']); + $itemA = RelationTestItem::create(['order_id' => $order->id, 'name' => 'Item A']); + $subItem = RelationTestItem::create(['parent_item_id' => $itemA->id, 'name' => 'Sub A1']); + + $controller = $this->makeController($order); + $behavior = $controller->asExtension(RelationController::class); + $controller->initRelation($order, 'items'); + + // Item A's manage form is rendering; its sub-items field + // resolves ambiently against it. + $this->pushNestingStack($behavior, "items[{$itemA->id}]"); + $controller->initRelation($itemA, 'items'); + + // The sub-item's own manage form is now rendering INSIDE Item + // A's, and a relation field within it resolves ambiently. + $this->pushNestingStack($behavior, "items[{$itemA->id}][items][{$subItem->id}]"); + + // Item A, not the sub-item: exactly what + // validateField() -> initRelation($this->model) passes here. + $controller->initRelation($itemA, 'taxes'); + + $this->popNestingStack($behavior); + $this->popNestingStack($behavior); + + $this->assertEquals( + "items[{$itemA->id}][items][{$subItem->id}][taxes]", + $this->readProtectedProperty($behavior, 'nestedField'), + 'Two levels of ambient nesting should qualify the field against the whole stack path' + ); + + $this->assertEquals( + $subItem->id, + $this->readProtectedProperty($behavior, 'model')->id, + 'A field nested two levels deep must resolve against the innermost ancestor, not the enclosing record' + ); + } + /** * Narrower test for relationMakePartial() specifically - confirms * it actually pushes before rendering and pops after, regardless