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/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/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 4a6b499..bf801f3 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 @@ -1823,24 +1826,28 @@ public function testBindList_stringContext():void { 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(2); + $bindMatcher = self::exactly(3); $listBinder->expects($bindMatcher) ->method("bindListData") ->willReturnCallback(function( array $listData, Element|Document $context, - ?string $templateName, - ?callable $callback, - )use($bindMatcher, $subComponent1, $subComponent2):int { + ?string $templateName = null, + ?callable $callback = null, + ) use($bindMatcher, $documentElement, $subComponent1, $subComponent2):int { match($bindMatcher->numberOfInvocations()) { - 1 => self::assertEquals([["A"], $subComponent1], [$listData, $context]), - 2 => self::assertEquals([["B"], $subComponent2], [$listData, $context]), + 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::assertNotNull($callback); + self::assertNull($templateName); + self::assertIsCallable($callback); + return 0; }); @@ -1854,9 +1861,10 @@ public function testBindListCallback_stringContext():void { self::createStub(BindableCache::class), ); - $callback = fn(Element $template, mixed $listItem, int|string $key):mixed => $listItem; - $sut->bindListCallback(["A"], $callback, "#subcomponent-1"); - $sut->bindListCallback(["B"], $callback, $subComponent2); + $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 { @@ -1963,6 +1971,111 @@ private function bindCleanupMixedDebug(DocumentBinder $sut):string { return "test/phpunit/DocumentBinderTest.php:$line"; } + 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")); + } + + 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(); diff --git a/test/phpunit/ListElementCollectionTest.php b/test/phpunit/ListElementCollectionTest.php index 0d7db91..6037a36 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; @@ -160,6 +161,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(); diff --git a/test/phpunit/TestHelper/HTMLPageContent.php b/test/phpunit/TestHelper/HTMLPageContent.php index e775dfe..db586d5 100644 --- a/test/phpunit/TestHelper/HTMLPageContent.php +++ b/test/phpunit/TestHelper/HTMLPageContent.php @@ -1363,6 +1363,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); }