From ca921e6ad648ee651d3d64414e0a323102c96c88 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Fri, 10 Apr 2026 11:22:46 +0100 Subject: [PATCH 1/3] fix: store parent with most-deeply-nested element when expanding for #504 --- src/ListElementCollection.php | 29 ++++++++++ test/phpunit/DocumentBinderTest.php | 64 ++++++++++++++++++++- test/phpunit/TestHelper/HTMLPageContent.php | 37 ++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/ListElementCollection.php b/src/ListElementCollection.php index 5c153cf..39d25ce 100644 --- a/src/ListElementCollection.php +++ b/src/ListElementCollection.php @@ -3,6 +3,7 @@ use Gt\Dom\Document; use Gt\Dom\Element; +use Throwable; class ListElementCollection { /** @var array */ @@ -69,6 +70,34 @@ private function findMatch(Element $context):ListElement { $contextPath ); + $matchedElement = null; + $matchedDepth = -1; + foreach($this->elementKVP as $name => $element) { + try { + $listItemParent = $element->getListItemParent(); + } + catch(Throwable) { + continue; + } + + if(!$listItemParent instanceof Element) { + continue; + } + if($listItemParent !== $context && !$context->contains($listItemParent)) { + continue; + } + + $depth = substr_count((string)(new NodePathCalculator($listItemParent)), "/"); + if($depth > $matchedDepth) { + $matchedElement = $element; + $matchedDepth = $depth; + } + } + + if($matchedElement) { + return $matchedElement; + } + foreach($this->elementKVP as $name => $element) { if($contextPath === $name) { continue; diff --git a/test/phpunit/DocumentBinderTest.php b/test/phpunit/DocumentBinderTest.php index 1c3c140..ab204bd 100644 --- a/test/phpunit/DocumentBinderTest.php +++ b/test/phpunit/DocumentBinderTest.php @@ -12,6 +12,8 @@ use Gt\DomTemplate\BindableCache; use Gt\DomTemplate\BindGetter; use Gt\DomTemplate\BindValue; +use Gt\DomTemplate\ComponentBinder; +use Gt\DomTemplate\ComponentExpander; use Gt\DomTemplate\DocumentBinder; use Gt\DomTemplate\ElementBinder; use Gt\DomTemplate\HTMLAttributeBinder; @@ -20,6 +22,7 @@ use Gt\DomTemplate\InvalidBindPropertyException; use Gt\DomTemplate\ListBinder; use Gt\DomTemplate\ListElementCollection; +use Gt\DomTemplate\PartialContent; use Gt\DomTemplate\PlaceholderBinder; use Gt\DomTemplate\TableBinder; use Gt\DomTemplate\TableElementNotFoundInContextException; @@ -35,7 +38,7 @@ use Traversable; use ArrayIterator; -class DocumentBinderTest extends TestCase { +class DocumentBinderTest extends PartialContentTestCase { /** * If the developer forgets to add a bind property (the bit after the * colon in `data-bind:text`, we should let them know with a friendly @@ -1710,6 +1713,65 @@ public function getCanReject():bool { self::assertTrue($buttonReject->hasAttribute("disabled")); } + public function testBindList_multipleComponent():void { + $document = new HTMLDocument(HTMLPageContent::HTML_PAGE_WITH_TWO_LIST_COMPONENTS); + $componentExpander = new ComponentExpander( + $document, + self::mockPartialContent( + "_component", + [ + "global-header" => HTMLPageContent::HTML_GLOBAL_HEADER, + "simple-list" => HTMLPageContent::HTML_SIMPLE_LIST, + ] + ) + ); + $componentExpander->expand(); + $dependencies = $this->documentBinderDependencies($document); + + $sut = new DocumentBinder($document); + $sut->setDependencies(...$dependencies); + + $linkList = [ + ["name" => "First link", "link" => "/1"], + ["name" => "Second link", "link" => "/2"], + ["name" => "Third link", "link" => "/3"], + ]; + $peopleList = [ + "active" => [ + ["name" => "Andrew"], + ["name" => "Becca"], + ], + "inactive" => [ + ["name" => "Charlie"], + ["name" => "Devi"], + ["name" => "Elle"], + ["name" => "Frankie"], + ], + ]; + + $globalHeaderEl = $document->querySelector("global-header"); + [$simpleListEl1, $simpleListEl2] = iterator_to_array($document->querySelectorAll("simple-list")); + + $globalheaderBinder = new ComponentBinder($document); + $globalheaderBinder->setDependencies(...$dependencies); + $globalheaderBinder->setComponentBinderDependencies($globalHeaderEl); + $globalheaderBinder->bindList($linkList); + + $simpleListBinder1 = new ComponentBinder($document); + $simpleListBinder1->setDependencies(...$dependencies); + $simpleListBinder1->setComponentBinderDependencies($simpleListEl1); + $simpleListBinder1->bindList($peopleList["active"]); + + $simpleListBinder2 = new ComponentBinder($document); + $simpleListBinder2->setDependencies(...$dependencies); + $simpleListBinder2->setComponentBinderDependencies($simpleListEl2); + $simpleListBinder2->bindList($peopleList["inactive"]); + + self::assertCount(3, $globalHeaderEl->querySelectorAll("a")); + self::assertCount(2, $simpleListEl1->querySelectorAll("li")); + self::assertCount(4, $simpleListEl2->querySelectorAll("li")); + } + private function documentBinderDependencies(HTMLDocument $document, mixed...$otherObjectList):array { $htmlAttributeBinder = new HTMLAttributeBinder(); $htmlAttributeCollection = new HTMLAttributeCollection(); diff --git a/test/phpunit/TestHelper/HTMLPageContent.php b/test/phpunit/TestHelper/HTMLPageContent.php index e9b833e..c66c490 100644 --- a/test/phpunit/TestHelper/HTMLPageContent.php +++ b/test/phpunit/TestHelper/HTMLPageContent.php @@ -1283,6 +1283,43 @@ class HTMLPageContent { HTML; + const HTML_PAGE_WITH_TWO_LIST_COMPONENTS = << + +Two components, each with lists + + +

Test page

+ +
+

Simple list 1

+ + +

Simple lilst 2

+ +
+HTML; + + const HTML_GLOBAL_HEADER = << + + +HTML; + + const HTML_SIMPLE_LIST = << +
  • +

    Name: unnamed

    +
  • + +HTML; + + + public static function createHTML(string $html = ""):HTMLDocument { return new HTMLDocument($html); } From 82d028985064ab324bd0a087abb3be3267668e36 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Fri, 10 Apr 2026 13:23:37 +0100 Subject: [PATCH 2/3] fix: apply fix for #504 to bindListCallback too closes #504 --- src/ComponentBinder.php | 20 +++++++++++++ src/DocumentBinder.php | 4 +++ test/phpunit/DocumentBinderTest.php | 46 +++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/ComponentBinder.php b/src/ComponentBinder.php index c9e8eaf..e5d7144 100644 --- a/src/ComponentBinder.php +++ b/src/ComponentBinder.php @@ -30,6 +30,26 @@ public function bindList( return parent::bindList($listData, $context, $templateName); } + public function bindListCallback( + iterable $listData, + callable $callback, + null|string|Element $context = null, + ?string $templateName = null + ):int { + if(is_string($context)) { + $context = $this->stringToContext($context); + } + + if($context) { + $this->checkElementContainedWithinComponent($context); + } + else { + $context = $this->componentElement; + } + + return parent::bindListCallback($listData, $callback, $context, $templateName); + } + protected function bind( ?string $key, mixed $value, diff --git a/src/DocumentBinder.php b/src/DocumentBinder.php index e39b8a5..9a0d90a 100644 --- a/src/DocumentBinder.php +++ b/src/DocumentBinder.php @@ -149,6 +149,10 @@ public function bindListCallback( null|string|Element $context = null, ?string $templateName = null ):int { + if(is_string($context)) { + $context = $this->stringToContext($context); + } + if(!$context) { $context = $this->document; } diff --git a/test/phpunit/DocumentBinderTest.php b/test/phpunit/DocumentBinderTest.php index ab204bd..9aa2b53 100644 --- a/test/phpunit/DocumentBinderTest.php +++ b/test/phpunit/DocumentBinderTest.php @@ -1772,6 +1772,52 @@ public function testBindList_multipleComponent():void { self::assertCount(4, $simpleListEl2->querySelectorAll("li")); } + public function testBindListCallback_multipleComponent():void { + $document = new HTMLDocument(HTMLPageContent::HTML_PAGE_WITH_TWO_LIST_COMPONENTS); + $componentExpander = new ComponentExpander( + $document, + self::mockPartialContent( + "_component", + [ + "global-header" => HTMLPageContent::HTML_GLOBAL_HEADER, + "simple-list" => HTMLPageContent::HTML_SIMPLE_LIST, + ] + ) + ); + $componentExpander->expand(); + $dependencies = $this->documentBinderDependencies($document); + + [$simpleListEl1, $simpleListEl2] = iterator_to_array($document->querySelectorAll("simple-list")); + $callback = fn(Element $template, array $row):array => $row; + + $simpleListBinder1 = new ComponentBinder($document); + $simpleListBinder1->setDependencies(...$dependencies); + $simpleListBinder1->setComponentBinderDependencies($simpleListEl1); + $simpleListBinder1->bindListCallback( + [ + ["name" => "Andrew"], + ["name" => "Becca"], + ], + $callback, + ); + + $simpleListBinder2 = new ComponentBinder($document); + $simpleListBinder2->setDependencies(...$dependencies); + $simpleListBinder2->setComponentBinderDependencies($simpleListEl2); + $simpleListBinder2->bindListCallback( + [ + ["name" => "Charlie"], + ["name" => "Devi"], + ["name" => "Elle"], + ["name" => "Frankie"], + ], + $callback, + ); + + self::assertCount(2, $simpleListEl1->querySelectorAll("li")); + self::assertCount(4, $simpleListEl2->querySelectorAll("li")); + } + private function documentBinderDependencies(HTMLDocument $document, mixed...$otherObjectList):array { $htmlAttributeBinder = new HTMLAttributeBinder(); $htmlAttributeCollection = new HTMLAttributeCollection(); From de8976b37c6ffb6cc04b597bdaf33c463ab579a9 Mon Sep 17 00:00:00 2001 From: Greg Bowler Date: Fri, 10 Apr 2026 16:26:48 +0100 Subject: [PATCH 3/3] test: improve coverage --- test/phpunit/ComponentBinderTest.php | 44 ++++++++++++++++++++++ test/phpunit/DocumentBinderTest.php | 43 +++++++++++++++++++++ test/phpunit/ListElementCollectionTest.php | 28 ++++++++++++++ 3 files changed, 115 insertions(+) diff --git a/test/phpunit/ComponentBinderTest.php b/test/phpunit/ComponentBinderTest.php index 03c1d60..e10c0a9 100644 --- a/test/phpunit/ComponentBinderTest.php +++ b/test/phpunit/ComponentBinderTest.php @@ -180,6 +180,50 @@ public function testBindList_stringContext():void { $sut->bindList(["List", "for", "main component"]); } + public function testBindListCallback_stringContext():void { + $document = new HTMLDocument(HTMLPageContent::HTML_COMPONENT_WITH_ATTRIBUTE_NESTED); + $componentElement = $document->querySelector("example-component"); + $subComponent1 = $document->querySelector("#subcomponent-1"); + $subComponent2 = $document->querySelector("#subcomponent-2"); + + $listBinder = self::createMock(ListBinder::class); + $bindMatcher = self::exactly(3); + $listBinder->expects($bindMatcher) + ->method("bindListData") + ->willReturnCallback(function( + array $listData, + Element $context, + ?string $templateName = null, + ?callable $callback = null, + ) use($bindMatcher, $componentElement, $subComponent1, $subComponent2):int { + match($bindMatcher->numberOfInvocations()) { + 1 => self::assertEquals([["List", "for", "component 2"], $subComponent2], [$listData, $context]), + 2 => self::assertEquals([["List", "for", "component 1"], $subComponent1], [$listData, $context]), + 3 => self::assertEquals([["List", "for", "main component"], $componentElement], [$listData, $context]), + }; + self::assertNull($templateName); + self::assertIsCallable($callback); + + return 0; + }); + + $sut = new ComponentBinder($document); + $sut->setDependencies( + self::createStub(ElementBinder::class), + self::createStub(PlaceholderBinder::class), + self::createStub(TableBinder::class), + $listBinder, + self::createStub(ListElementCollection::class), + self::createStub(BindableCache::class), + ); + $sut->setComponentBinderDependencies($componentElement); + + $callback = static fn(Element $template, array $row):array => $row; + $sut->bindListCallback(["List", "for", "component 2"], $callback, "#subcomponent-2"); + $sut->bindListCallback(["List", "for", "component 1"], $callback, "#subcomponent-1"); + $sut->bindListCallback(["List", "for", "main component"], $callback); + } + public function testBindValue_stringContext():void { $document = new HTMLDocument(HTMLPageContent::HTML_COMPONENT_WITH_ATTRIBUTE_NESTED); $componentElement = $document->querySelector("example-component"); diff --git a/test/phpunit/DocumentBinderTest.php b/test/phpunit/DocumentBinderTest.php index 9aa2b53..bf5b3ee 100644 --- a/test/phpunit/DocumentBinderTest.php +++ b/test/phpunit/DocumentBinderTest.php @@ -1658,6 +1658,49 @@ public function testBindList_stringContext():void { $sut->bindList(["List", "for", "main component"]); } + public function testBindListCallback_stringContext():void { + $document = new HTMLDocument(HTMLPageContent::HTML_COMPONENT_WITH_ATTRIBUTE_NESTED); + $documentElement = $document->documentElement; + $subComponent1 = $document->querySelector("#subcomponent-1"); + $subComponent2 = $document->querySelector("#subcomponent-2"); + + $listBinder = self::createMock(ListBinder::class); + $bindMatcher = self::exactly(3); + $listBinder->expects($bindMatcher) + ->method("bindListData") + ->willReturnCallback(function( + array $listData, + Element|Document $context, + ?string $templateName = null, + ?callable $callback = null, + ) use($bindMatcher, $documentElement, $subComponent1, $subComponent2):int { + match($bindMatcher->numberOfInvocations()) { + 1 => self::assertEquals([["List", "for", "component 2"], $subComponent2], [$listData, $context]), + 2 => self::assertEquals([["List", "for", "component 1"], $subComponent1], [$listData, $context]), + 3 => self::assertEquals([["List", "for", "main component"], $documentElement], [$listData, $context]), + }; + self::assertNull($templateName); + self::assertIsCallable($callback); + + return 0; + }); + + $sut = new DocumentBinder($document); + $sut->setDependencies( + self::createStub(ElementBinder::class), + self::createStub(PlaceholderBinder::class), + self::createStub(TableBinder::class), + $listBinder, + self::createStub(ListElementCollection::class), + self::createStub(BindableCache::class), + ); + + $callback = static fn(Element $template, array $row):array => $row; + $sut->bindListCallback(["List", "for", "component 2"], $callback, "#subcomponent-2"); + $sut->bindListCallback(["List", "for", "component 1"], $callback, "#subcomponent-1"); + $sut->bindListCallback(["List", "for", "main component"], $callback); + } + public function testBindValue_stringContext():void { $document = new HTMLDocument(HTMLPageContent::HTML_COMPONENT_WITH_ATTRIBUTE_NESTED); $documentElement = $document->documentElement; diff --git a/test/phpunit/ListElementCollectionTest.php b/test/phpunit/ListElementCollectionTest.php index 6f6fc7a..67cd8e4 100644 --- a/test/phpunit/ListElementCollectionTest.php +++ b/test/phpunit/ListElementCollectionTest.php @@ -7,6 +7,7 @@ use Gt\DomTemplate\HTMLAttributeBinder; use Gt\DomTemplate\HTMLAttributeCollection; use Gt\DomTemplate\ListBinder; +use Gt\DomTemplate\ListElement; use Gt\DomTemplate\ListElementCollection; use Gt\DomTemplate\ListElementNotFoundInContextException; use Gt\DomTemplate\PlaceholderBinder; @@ -162,6 +163,33 @@ public function testConstructor_nonListChildrenArePreservedInOrder():void { self::assertSame("This list item will always show at the end", $ulChildren[3]->textContent); } + public function testGet_noName_fallsBackToPathMatchingWhenListParentDetached():void { + $document = new HTMLDocument( + "
      " + ); + $sut = new ListElementCollection($document); + $nonElementParent = self::createMock(ListElement::class); + $nonElementParent->method("getListItemParent") + ->willReturn(self::createStub(\Gt\Dom\Node::class)); + $listElement = self::createMock(ListElement::class); + $listElement->method("getListItemParent") + ->willThrowException(new \TypeError("Detached")); + + $reflection = new \ReflectionProperty($sut, "elementKVP"); + $reflection->setValue( + $sut, + [ + "/html/body/aside/span" => $nonElementParent, + "/html/body/section[@id='target']/ul" => $listElement, + ] + ); + + self::assertSame( + $listElement, + $sut->get($document->querySelector("#target")) + ); + } + private function elementBinderDependencies(HTMLDocument $document, mixed...$otherObjectList):array { $htmlAttributeBinder = new HTMLAttributeBinder(); $htmlAttributeCollection = new HTMLAttributeCollection();