Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions src/Dom/Node/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
7 changes: 6 additions & 1 deletion src/Dom/Node/Form/Element.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Zenstruck\Dom\Node;
use Zenstruck\Dom\Node\Form;
use Zenstruck\Dom\Selector;

/**
* @author Kevin Bond <kevinbond@gmail.com>
Expand All @@ -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);
}
}
51 changes: 51 additions & 0 deletions tests/Fixtures/form_page.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>meta title</title>
<meta name="description" content="meta description">
</head>
<body class="body-class">
<form action="/submit-form-1" method="post" enctype="multipart/form-data" data-test-id="form_1">
<label for="input_1">Input 1</label>
<input id="input_1" name="input_1" type="text" value="input 1"/>

<label for="input_2">Input 2</label>
<select id="input_2" name="input_2">
<option value="option1">Option 1</option>
<option value="option2" selected>Option 2</option>
<option value="option3">Option 3</option>
</select>

<label for="input_3">Input for form 2</label>
<textarea id="input_3" name="input_3" form="form_2">Input for form 2</textarea>

<label for="input_4">Input for form 3</label>
<input id="input_4" name="input_4" form="form_3" type="checkbox">

<input id="input_5" name="input_5" type="submit" value="Submit"/>
<button id="input_6" name="input_6" type="submit">Submit</button>
<button id="input_7" name="input_7" type="button">Non-submit</button>
<input id="input_8" name="input_8" form="form_2" type="submit"/>
<button id="input_9" name="input_9" form="form_2" type="submit">Submit for form 2</button>
</form>

<form id="form_2" action="/submit-form-2" method="get" enctype="multipart/form-data" data-test-id="form_2">
<input id="input_10" name="input_10" type="text"/>
<input id="input_11" name="input_11" form="form_2" type="checkbox">
<select id="input_12" name="input_12" form="form_3">
<option value="option1">Option 1</option>
<option value="option2" selected>Option 2</option>
<option value="option3">Option 3</option>
</select>
</form>

<form id="form_3" action="/submit-form-3" method="get" enctype="multipart/form-data" data-test-id="form_3">
<input id="input_13" name="input_14" form="form_2"/>
</form>

<input id="input_14" name="input_15" type="text"/>
<input id="input_15" name="input_16" form="form_2" type="checkbox">
<input id="input_16" name="input_17" form="form_3" type="checkbox">
</body>
</html>
87 changes: 87 additions & 0 deletions tests/Node/FormTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'));
Expand Down