From ad61c042ee4259de12806e54b80627e20d3de43a Mon Sep 17 00:00:00 2001 From: Maelan LE BORGNE Date: Thu, 19 Jun 2025 11:39:45 +0200 Subject: [PATCH 1/2] feat: handle form attribute --- src/Dom/Node/Form.php | 39 ++++++++++++++-- src/Dom/Node/Form/Element.php | 7 ++- tests/Fixtures/form_page.html | 47 +++++++++++++++++++ tests/Node/FormTest.php | 87 +++++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 tests/Fixtures/form_page.html diff --git a/src/Dom/Node/Form.php b/src/Dom/Node/Form.php index 722a8b5..8fcc022 100644 --- a/src/Dom/Node/Form.php +++ b/src/Dom/Node/Form.php @@ -11,6 +11,7 @@ namespace Zenstruck\Dom\Node; +use Symfony\Component\DomCrawler\Crawler; use Zenstruck\Dom\Node; use Zenstruck\Dom\Node\Form\Button; use Zenstruck\Dom\Node\Form\Field; @@ -28,21 +29,53 @@ final class Form extends Node public function fields(Selector|string|callable $selector = Field::SELECTOR): Nodes { - return $this->descendants($selector); + return $this->findNodesForForm($selector); } public function buttons(): Nodes { - return $this->descendants(Button::SELECTOR); + return $this->findNodesForForm(Button::SELECTOR); } public function submitButtons(): Nodes { - return $this->descendants('input[type="submit"],button[type="submit"]'); + return $this->findNodesForForm('input[type="submit"],button[type="submit"]'); } public function submitButton(): ?Button { return $this->submitButtons()->first()?->ensure(Button::class); } + + private function findNodesForForm(Selector|string|callable $selector): Nodes + { + $formId = $this->attributes()->get('id'); + + // Filter out nodes that explicitly have a "form" attribute + $directDescendantsCrawler = $this->descendants($selector) + ->crawler() + ->reduce(function (Crawler $crawler) { + return !$crawler->getNode(0)?->attributes?->getNamedItem('form'); + }); + + // If the form doesn't have an id, return the nodes that match the selector and that don't have a "form" attribute. + if (!\is_string($formId) || '' === $formId) { + return Nodes::create($directDescendantsCrawler, $this->session); + } + + // Find nodes in all the document that match the selector and have a "form" attribute that matches the form's id. + $referencingNodesCrawler = $this->ancestors()->last() + ?->descendants($selector) + ->crawler() + ->reduce(function (Crawler $crawler) use ($formId) { + return $formId === $crawler->getNode(0)?->attributes?->getNamedItem('form')?->nodeValue; + }); + + if (null !== $referencingNodesCrawler && $referencingNodesCrawler->count() > 0) { + // Merge descendant nodes and nodes with a matching "form" attribute. + $directDescendantsCrawler->addNodes(\iterator_to_array($referencingNodesCrawler->getIterator())); + } + + return Nodes::create($directDescendantsCrawler, $this->session); + } } diff --git a/src/Dom/Node/Form/Element.php b/src/Dom/Node/Form/Element.php index e38db04..392aeed 100644 --- a/src/Dom/Node/Form/Element.php +++ b/src/Dom/Node/Form/Element.php @@ -13,6 +13,7 @@ use Zenstruck\Dom\Node; use Zenstruck\Dom\Node\Form; +use Zenstruck\Dom\Selector; /** * @author Kevin Bond @@ -21,6 +22,10 @@ abstract class Element extends Node { final public function form(): ?Form { - return $this->closest('form')?->ensure(Form::class); + if (\is_string($formId = $this->attributes()->get('form'))) { + $form = $this->ancestors()->last()?->descendant(Selector::id($formId))?->ensure(Form::class); + } + + return $form ?? $this->closest('form')?->ensure(Form::class); } } diff --git a/tests/Fixtures/form_page.html b/tests/Fixtures/form_page.html new file mode 100644 index 0000000..ad3e96f --- /dev/null +++ b/tests/Fixtures/form_page.html @@ -0,0 +1,47 @@ + + + + + meta title + + + +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+ +
+ + + + + + diff --git a/tests/Node/FormTest.php b/tests/Node/FormTest.php index ea8364b..2817b2f 100644 --- a/tests/Node/FormTest.php +++ b/tests/Node/FormTest.php @@ -17,6 +17,7 @@ use Zenstruck\Dom; use Zenstruck\Dom\Node\Form; use Zenstruck\Dom\Node\Form\Button; +use Zenstruck\Dom\Node\Form\Element; use Zenstruck\Dom\Node\Form\Field; use Zenstruck\Dom\Node\Form\Field\Checkbox; use Zenstruck\Dom\Node\Form\Field\Input; @@ -363,6 +364,92 @@ public function radio_collection_returns_all_radios_with_same_name(): void } } + #[Test] + public function form_without_id(): void + { + $dom = new Dom(\file_get_contents(__DIR__.'/../Fixtures/form_page.html')); + $form = $dom->find(Selector::css('[data-test-id=form_1]'))->ensure(Form::class); + + $formFields = $form->fields(); + $formButtons = $form->buttons(); + $submitButtons = $form->submitButtons(); + $submitButton = $form->submitButton(); + + // Check that only expected fields and buttons are returned + $this->assertCount(5, $formFields); + $fieldIds = $formFields->map(fn (Element $field) => $field->id()); + $this->assertContains('input_1', $fieldIds); + $this->assertContains('input_2', $fieldIds); + $this->assertContains('input_5', $fieldIds); + $this->assertContains('input_6', $fieldIds); + $this->assertContains('input_7', $fieldIds); + + $this->assertCount(3, $formButtons); + $buttonIds = $formButtons->map(fn (Element $button) => $button->id()); + $this->assertContains('input_5', $buttonIds); + $this->assertContains('input_6', $buttonIds); + $this->assertContains('input_7', $buttonIds); + + $this->assertCount(2, $form->submitButtons()); + $submitButtonIds = $submitButtons->map(fn (Button $button) => $button->id()); + $this->assertContains('input_5', $submitButtonIds); + $this->assertContains('input_6', $submitButtonIds); + + $this->assertInstanceOf(Button::class, $submitButton); + $this->assertSame('input_5', $submitButton->id()); + + foreach ($formFields as $field) { + $this->assertInstanceOf(Element::class, $field); + // Check that the form is correctly identified + $this->assertEquals('form_1', $field->form()?->attributes()->get('data-test-id'), 'The field should identify the correct form'); + // And that it does not have a "form" attribute + $this->assertFalse($field->attributes()->has('form'), 'Field should not have a "form" attribute'); + } + } + + + #[Test] + public function form_with_id(): void + { + $dom = new Dom(\file_get_contents(__DIR__.'/../Fixtures/form_page.html')); + $form = $dom->find(Selector::css('[data-test-id=form_2]'))->ensure(Form::class); + + $formFields = $form->fields(); + $formButtons = $form->buttons(); + $submitButtons = $form->submitButtons(); + $submitButton = $form->submitButton(); + + // Check that only expected fields and buttons are returned + $this->assertCount(7, $formFields); + $fieldIds = $formFields->map(fn (Element $field) => $field->id()); + $this->assertContains('input_3', $fieldIds); + $this->assertContains('input_8', $fieldIds); + $this->assertContains('input_9', $fieldIds); + $this->assertContains('input_10', $fieldIds); + $this->assertContains('input_11', $fieldIds); + $this->assertContains('input_13', $fieldIds); + $this->assertContains('input_15', $fieldIds); + + $this->assertCount(2, $formButtons); + $buttonIds = $formButtons->map(fn (Element $button) => $button->id()); + $this->assertContains('input_8', $buttonIds); + $this->assertContains('input_9', $buttonIds); + + $this->assertCount(2, $submitButtons); + $submitButtonIds = $submitButtons->map(fn (Button $button) => $button->id()); + $this->assertContains('input_8', $submitButtonIds); + $this->assertContains('input_9', $submitButtonIds); + + $this->assertInstanceOf(Button::class, $submitButton); + $this->assertSame('input_8', $submitButton->id()); + + foreach ($formFields as $field) { + $this->assertInstanceOf(Element::class, $field); + // Check that the form is correctly identified + $this->assertEquals('form_2', $field->form()?->attributes()->get('data-test-id'), 'The field should identify the correct form'); + } + } + private function dom(): Dom { return new Dom(\file_get_contents(__DIR__.'/../Fixtures/page.html')); From f57911d4bbc9a9cb23349c4ddad007d97644566d Mon Sep 17 00:00:00 2001 From: Maelan LE BORGNE Date: Thu, 19 Jun 2025 12:20:47 +0200 Subject: [PATCH 2/2] feat: use xpath selector --- src/Dom/Node/Form.php | 23 ++++++++--------------- tests/Fixtures/form_page.html | 8 ++++++-- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/Dom/Node/Form.php b/src/Dom/Node/Form.php index 8fcc022..3617771 100644 --- a/src/Dom/Node/Form.php +++ b/src/Dom/Node/Form.php @@ -11,7 +11,6 @@ namespace Zenstruck\Dom\Node; -use Symfony\Component\DomCrawler\Crawler; use Zenstruck\Dom\Node; use Zenstruck\Dom\Node\Form\Button; use Zenstruck\Dom\Node\Form\Field; @@ -52,30 +51,24 @@ private function findNodesForForm(Selector|string|callable $selector): Nodes $formId = $this->attributes()->get('id'); // Filter out nodes that explicitly have a "form" attribute - $directDescendantsCrawler = $this->descendants($selector) - ->crawler() - ->reduce(function (Crawler $crawler) { - return !$crawler->getNode(0)?->attributes?->getNamedItem('form'); - }); + $directDescendants = $this->descendants($selector) + ->filter(Selector::xpath('(descendant-or-self::input | descendant-or-self::button | descendant-or-self::select | descendant-or-self::textarea)[not(@form)]')); // If the form doesn't have an id, return the nodes that match the selector and that don't have a "form" attribute. if (!\is_string($formId) || '' === $formId) { - return Nodes::create($directDescendantsCrawler, $this->session); + return $directDescendants; } // Find nodes in all the document that match the selector and have a "form" attribute that matches the form's id. - $referencingNodesCrawler = $this->ancestors()->last() + $referencingNodes = $this->ancestors()->last() ?->descendants($selector) - ->crawler() - ->reduce(function (Crawler $crawler) use ($formId) { - return $formId === $crawler->getNode(0)?->attributes?->getNamedItem('form')?->nodeValue; - }); + ->filter(Selector::xpath(\sprintf('(descendant-or-self::input | descendant-or-self::button | descendant-or-self::select | descendant-or-self::textarea)[@form="%s"]', $formId)));; - if (null !== $referencingNodesCrawler && $referencingNodesCrawler->count() > 0) { + if (null !== $referencingNodes && $referencingNodes->count() > 0) { // Merge descendant nodes and nodes with a matching "form" attribute. - $directDescendantsCrawler->addNodes(\iterator_to_array($referencingNodesCrawler->getIterator())); + $directDescendants->crawler()->addNodes(\iterator_to_array($referencingNodes->crawler()->getIterator())); } - return Nodes::create($directDescendantsCrawler, $this->session); + return $directDescendants; } } diff --git a/tests/Fixtures/form_page.html b/tests/Fixtures/form_page.html index ad3e96f..7e51357 100644 --- a/tests/Fixtures/form_page.html +++ b/tests/Fixtures/form_page.html @@ -18,7 +18,7 @@ - + @@ -33,7 +33,11 @@
- +