diff --git a/src/Dom/Node/Form.php b/src/Dom/Node/Form.php index 722a8b5..3617771 100644 --- a/src/Dom/Node/Form.php +++ b/src/Dom/Node/Form.php @@ -28,21 +28,47 @@ 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 + $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 $directDescendants; + } + + // Find nodes in all the document that match the selector and have a "form" attribute that matches the form's id. + $referencingNodes = $this->ancestors()->last() + ?->descendants($selector) + ->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 !== $referencingNodes && $referencingNodes->count() > 0) { + // Merge descendant nodes and nodes with a matching "form" attribute. + $directDescendants->crawler()->addNodes(\iterator_to_array($referencingNodes->crawler()->getIterator())); + } + + return $directDescendants; + } } 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..7e51357 --- /dev/null +++ b/tests/Fixtures/form_page.html @@ -0,0 +1,51 @@ + + + + + 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'));