From 4411e19910e31a805915fb88f182632e2231f096 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 19 Mar 2026 11:28:54 +0100 Subject: [PATCH 1/6] Add `FieldsetElement::validatePartial()` to fix required errors When a form is submitted via autosubmit, only partial validation runs. For fieldset elements, a single child having a value was enough to trigger full validation of the entire fieldset, causing required errors to appear for fields that had just been revealed and were still empty. Add a `validatePartial()` method to `FieldsetElement` that checks each child element individually for whether it has a value before validating it, and recurses into nested fieldsets. `Form::validatePartial()` is updated to detect fieldset elements and call `validatePartial()` on them. --- src/Form.php | 9 +++++++-- src/FormElement/FieldsetElement.php | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/Form.php b/src/Form.php index c0ae6654..cf2a4725 100644 --- a/src/Form.php +++ b/src/Form.php @@ -6,9 +6,9 @@ use ipl\Html\Contract\FormDecoration; use ipl\Html\Contract\FormElement; use ipl\Html\Contract\FormSubmitElement; -use ipl\Html\Contract\MutableHtml; use ipl\Html\FormDecoration\DecoratorChain; use ipl\Html\FormDecoration\FormDecorationResult; +use ipl\Html\FormElement\FieldsetElement; use ipl\Html\FormElement\FormElements; use ipl\Stdlib\Messages; use Psr\Http\Message\ServerRequestInterface; @@ -358,7 +358,12 @@ public function validatePartial() foreach ($this->getElements() as $element) { if ($element->hasValue()) { - $element->validate(); + if ($element instanceof FieldsetElement) { + // Validate only the elements of the fieldset that have a value + $element->validatePartial(); + } else { + $element->validate(); + } } } diff --git a/src/FormElement/FieldsetElement.php b/src/FormElement/FieldsetElement.php index 978745f4..394837ec 100644 --- a/src/FormElement/FieldsetElement.php +++ b/src/FormElement/FieldsetElement.php @@ -70,6 +70,29 @@ public function setValue($value) return $this; } + /** + * Validate all elements that have a value + * + * @return $this + */ + public function validatePartial(): static + { + $this->ensureAssembled(); + + foreach ($this->getElements() as $element) { + if ($element->hasValue()) { + if ($element instanceof self) { + // Validate only the elements of the fieldset that have a value + $element->validatePartial(); + } else { + $element->validate(); + } + } + } + + return $this; + } + public function validate() { $this->ensureAssembled(); From 6710808784e5bd931b44b0f9991120c14da3af55 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 19 Mar 2026 15:17:38 +0100 Subject: [PATCH 2/6] Add `validatePartial()` to the `FormElements` trait and interface When a form is submitted via autosubmit, it is only partially validated. For sub elements implementing the `FormElements` interface it was enough for one child to have a value to trigger full validation of the entire container, causing "field is required" errors on fields that had just appeared and were still empty. `validatePartial()` is declared to the interface to future implementors are required to provide it as well, and added to the trait to satisfy the declaration from the interface with a default implementation. --- src/Contract/FormElements.php | 7 +++++++ src/Form.php | 24 ------------------------ src/FormElement/FieldsetElement.php | 23 ----------------------- src/FormElement/FormElements.php | 22 ++++++++++++++++++++++ 4 files changed, 29 insertions(+), 47 deletions(-) diff --git a/src/Contract/FormElements.php b/src/Contract/FormElements.php index 224a4047..a1031d9c 100644 --- a/src/Contract/FormElements.php +++ b/src/Contract/FormElements.php @@ -100,4 +100,11 @@ public function getValues(); * @return $this */ public function populate(iterable $values); + + /** + * Validate all elements that have a value + * + * @return $this + */ + public function validatePartial(): static; } diff --git a/src/Form.php b/src/Form.php index cf2a4725..93f8be1a 100644 --- a/src/Form.php +++ b/src/Form.php @@ -8,7 +8,6 @@ use ipl\Html\Contract\FormSubmitElement; use ipl\Html\FormDecoration\DecoratorChain; use ipl\Html\FormDecoration\FormDecorationResult; -use ipl\Html\FormElement\FieldsetElement; use ipl\Html\FormElement\FormElements; use ipl\Stdlib\Messages; use Psr\Http\Message\ServerRequestInterface; @@ -347,29 +346,6 @@ public function validate() return $this; } - /** - * Validate all elements that have a value - * - * @return $this - */ - public function validatePartial() - { - $this->ensureAssembled(); - - foreach ($this->getElements() as $element) { - if ($element->hasValue()) { - if ($element instanceof FieldsetElement) { - // Validate only the elements of the fieldset that have a value - $element->validatePartial(); - } else { - $element->validate(); - } - } - } - - return $this; - } - public function remove(ValidHtml $content) { if ($this->submitButton === $content) { diff --git a/src/FormElement/FieldsetElement.php b/src/FormElement/FieldsetElement.php index 394837ec..978745f4 100644 --- a/src/FormElement/FieldsetElement.php +++ b/src/FormElement/FieldsetElement.php @@ -70,29 +70,6 @@ public function setValue($value) return $this; } - /** - * Validate all elements that have a value - * - * @return $this - */ - public function validatePartial(): static - { - $this->ensureAssembled(); - - foreach ($this->getElements() as $element) { - if ($element->hasValue()) { - if ($element instanceof self) { - // Validate only the elements of the fieldset that have a value - $element->validatePartial(); - } else { - $element->validate(); - } - } - } - - return $this; - } - public function validate() { $this->ensureAssembled(); diff --git a/src/FormElement/FormElements.php b/src/FormElement/FormElements.php index 0a81036b..a7847d4f 100644 --- a/src/FormElement/FormElements.php +++ b/src/FormElement/FormElements.php @@ -601,4 +601,26 @@ protected function beforeRender(): void protected function onElementRegistered(FormElement $element) { } + + /** + * Validate all elements that have a value + * + * @return $this + */ + public function validatePartial(): static + { + $this->ensureAssembled(); + + foreach ($this->getElements() as $element) { + if ($element->hasValue()) { + if ($element instanceof \ipl\Html\Contract\FormElements) { + $element->validatePartial(); + } else { + $element->validate(); + } + } + } + + return $this; + } } From 14c28aa7eff42b041470fdd551589ed8a02bf6ff Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 20 Mar 2026 12:00:43 +0100 Subject: [PATCH 3/6] Introduce `FormElements::yieldElements()` for partial validation Instead of requiring `validatePartial()` in the `Contract\FormElements` interface (and thus implementing it in the `FormElements` trait), introduce a recursive `yieldElements()` generator that yields all sub form elements, delegating into nested `FormElements` containers. Compared to the previous approach, `validatePartial()` no longer needs to be part of `Contract\FormElements`. The earlier implementation checked `instanceof FormElements` to recurse manually. `yieldElements()` makes that recursion reusable and separates traversal from validation logic. --- src/Contract/FormElements.php | 17 ++++++++------- src/Form.php | 18 ++++++++++++++++ src/FormElement/FormElements.php | 36 +++++++++++++------------------- 3 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/Contract/FormElements.php b/src/Contract/FormElements.php index a1031d9c..22ece1df 100644 --- a/src/Contract/FormElements.php +++ b/src/Contract/FormElements.php @@ -3,6 +3,7 @@ namespace ipl\Html\Contract; use Evenement\EventEmitterInterface; +use Generator; use InvalidArgumentException; interface FormElements extends EventEmitterInterface @@ -17,6 +18,15 @@ interface FormElements extends EventEmitterInterface */ public function getElements(); + /** + * Yield all sub form elements by traversing the form elements tree recursively + * + * Recurses into nested {@see FormElements} instances instead of yielding them directly. + * + * @return Generator + */ + public function yieldElements(): Generator; + /** * Get whether the given element exists * @@ -100,11 +110,4 @@ public function getValues(); * @return $this */ public function populate(iterable $values); - - /** - * Validate all elements that have a value - * - * @return $this - */ - public function validatePartial(): static; } diff --git a/src/Form.php b/src/Form.php index 93f8be1a..8e5aad8f 100644 --- a/src/Form.php +++ b/src/Form.php @@ -346,6 +346,24 @@ public function validate() return $this; } + /** + * Validate all elements that have a value + * + * @return $this + */ + public function validatePartial() + { + $this->ensureAssembled(); + + foreach ($this->yieldElements() as $element) { + if ($element->hasValue()) { + $element->validate(); + } + } + + return $this; + } + public function remove(ValidHtml $content) { if ($this->submitButton === $content) { diff --git a/src/FormElement/FormElements.php b/src/FormElement/FormElements.php index a7847d4f..7ca85cdd 100644 --- a/src/FormElement/FormElements.php +++ b/src/FormElement/FormElements.php @@ -2,6 +2,7 @@ namespace ipl\Html\FormElement; +use Generator; use InvalidArgumentException; use ipl\Html\Contract\DecorableFormElement; use ipl\Html\Contract\DefaultFormElementDecoration; @@ -106,6 +107,19 @@ public function getElements() return $this->elements; } + public function yieldElements(): Generator + { + $this->ensureAssembled(); + + foreach ($this->elements as $element) { + if ($element instanceof \ipl\Html\Contract\FormElements) { + yield from $element->yieldElements(); + } else { + yield $element; + } + } + } + public function hasElement($element) { if (is_string($element)) { @@ -601,26 +615,4 @@ protected function beforeRender(): void protected function onElementRegistered(FormElement $element) { } - - /** - * Validate all elements that have a value - * - * @return $this - */ - public function validatePartial(): static - { - $this->ensureAssembled(); - - foreach ($this->getElements() as $element) { - if ($element->hasValue()) { - if ($element instanceof \ipl\Html\Contract\FormElements) { - $element->validatePartial(); - } else { - $element->validate(); - } - } - } - - return $this; - } } From 45ebe6d0ed8c794334fe564d7ab2a3f4da12eb61 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Fri, 20 Mar 2026 13:57:59 +0100 Subject: [PATCH 4/6] Move `yieldElements()` from `FormElements` interface/trait to `Form` Partial validation is driven solely by `Form`, via `hasBeenSent()` and `hasBeenSubmitted()`. Exposing `yieldElements()` on the `FormElements` interface would force every implementor to provide a method that currently only makes sense in a form's validation context. Moving it to `Form` as a protected helper method keeps traversal an implementation detail of the form. Unlike the trait based approach, `FormElements` implementors are no longer required to satisfy a contract that has no relevance to them. --- src/Contract/FormElements.php | 10 ---------- src/Form.php | 29 ++++++++++++++++++++++++++--- src/FormElement/FormElements.php | 14 -------------- 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/src/Contract/FormElements.php b/src/Contract/FormElements.php index 22ece1df..224a4047 100644 --- a/src/Contract/FormElements.php +++ b/src/Contract/FormElements.php @@ -3,7 +3,6 @@ namespace ipl\Html\Contract; use Evenement\EventEmitterInterface; -use Generator; use InvalidArgumentException; interface FormElements extends EventEmitterInterface @@ -18,15 +17,6 @@ interface FormElements extends EventEmitterInterface */ public function getElements(); - /** - * Yield all sub form elements by traversing the form elements tree recursively - * - * Recurses into nested {@see FormElements} instances instead of yielding them directly. - * - * @return Generator - */ - public function yieldElements(): Generator; - /** * Get whether the given element exists * diff --git a/src/Form.php b/src/Form.php index 8e5aad8f..7e32ec32 100644 --- a/src/Form.php +++ b/src/Form.php @@ -2,6 +2,7 @@ namespace ipl\Html; +use Generator; use ipl\Html\Contract\DefaultFormElementDecoration; use ipl\Html\Contract\FormDecoration; use ipl\Html\Contract\FormElement; @@ -353,9 +354,7 @@ public function validate() */ public function validatePartial() { - $this->ensureAssembled(); - - foreach ($this->yieldElements() as $element) { + foreach ($this->yieldElements($this) as $element) { if ($element->hasValue()) { $element->validate(); } @@ -441,4 +440,28 @@ protected function beforeRender(): void $this->applyDecoration(); } + + /** + * Yield all sub form elements of $from by traversing the form elements tree recursively + * + * Recurses into nested {@see FormElements} instances instead of yielding them directly. + * + * @param Contract\FormElements $from Entry point for traversing + * + * @return Generator + */ + protected function yieldElements(Contract\FormElements $from): Generator + { + if ($from instanceof HtmlDocument) { + $from->ensureAssembled(); + } + + foreach ($from->getElements() as $element) { + if ($element instanceof Contract\FormElements) { + yield from $this->yieldElements($element); + } else { + yield $element; + } + } + } } diff --git a/src/FormElement/FormElements.php b/src/FormElement/FormElements.php index 7ca85cdd..0a81036b 100644 --- a/src/FormElement/FormElements.php +++ b/src/FormElement/FormElements.php @@ -2,7 +2,6 @@ namespace ipl\Html\FormElement; -use Generator; use InvalidArgumentException; use ipl\Html\Contract\DecorableFormElement; use ipl\Html\Contract\DefaultFormElementDecoration; @@ -107,19 +106,6 @@ public function getElements() return $this->elements; } - public function yieldElements(): Generator - { - $this->ensureAssembled(); - - foreach ($this->elements as $element) { - if ($element instanceof \ipl\Html\Contract\FormElements) { - yield from $element->yieldElements(); - } else { - yield $element; - } - } - } - public function hasElement($element) { if (is_string($element)) { From 1e22b9b27d76200143d34a3c4b6ebaecc9600a75 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 26 Mar 2026 09:28:32 +0100 Subject: [PATCH 5/6] Rename `yieldElements()` to `yieldElementsRecursive()` Describe the recursive behavior of the method explicit in its name. --- src/Form.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Form.php b/src/Form.php index 7e32ec32..8a643126 100644 --- a/src/Form.php +++ b/src/Form.php @@ -354,7 +354,7 @@ public function validate() */ public function validatePartial() { - foreach ($this->yieldElements($this) as $element) { + foreach ($this->yieldElementsRecursive($this) as $element) { if ($element->hasValue()) { $element->validate(); } @@ -450,7 +450,7 @@ protected function beforeRender(): void * * @return Generator */ - protected function yieldElements(Contract\FormElements $from): Generator + protected function yieldElementsRecursive(Contract\FormElements $from): Generator { if ($from instanceof HtmlDocument) { $from->ensureAssembled(); @@ -458,7 +458,7 @@ protected function yieldElements(Contract\FormElements $from): Generator foreach ($from->getElements() as $element) { if ($element instanceof Contract\FormElements) { - yield from $this->yieldElements($element); + yield from $this->yieldElementsRecursive($element); } else { yield $element; } From 61961e92f71b80d9f7b3756df83be04fd8b40162 Mon Sep 17 00:00:00 2001 From: Johannes Rauh Date: Thu, 26 Mar 2026 10:15:17 +0100 Subject: [PATCH 6/6] Add tests for `Form::validatePartial()` with fieldset elements Tests cover two cases: - A fieldset with mixed children: only the child that has a value should be validated, while the empty required sibling should produce no error. - A doubly nested fieldset: `validatePartial()` should recurse all the way down and validate the nested element. --- tests/FormTest.php | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/FormTest.php b/tests/FormTest.php index 117d9b6c..a047d737 100644 --- a/tests/FormTest.php +++ b/tests/FormTest.php @@ -4,6 +4,7 @@ use ipl\Html\Form; use ipl\Html\FormElement\BaseFormElement; +use ipl\Html\FormElement\FieldsetElement; use ipl\Html\Test\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -63,6 +64,46 @@ public function testEscapeReservedChars(): void $this->assertSame(Form::escapeReservedChars('foo-bar123'), 'foo-bar123'); } + public function testValidatePartialOnlyValidatesFieldsetChildrenWithAValue(): void + { + $fieldset = (new FieldsetElement('set')) + ->addElement('text', 'filled', ['required' => true]) + ->addElement('text', 'empty', ['required' => true]); + $fieldset->getElement('filled')->setValue('value'); + + $form = (new Form()) + ->addElement($fieldset) + ->validatePartial(); + + $this->assertTrue( + $form->getElement('set')->getElement('filled')->isValid(), + 'Fieldset child with a value is not validated during partial validation' + ); + $this->assertEmpty( + $form->getElement('set')->getElement('empty')->getMessages(), + 'Empty fieldset child produces a required error during partial validation' + ); + } + + public function testValidatePartialRecursesIntoNestedFieldsets(): void + { + $innerFieldset = (new FieldsetElement('inner')) + ->addElement('text', 'deep', ['required' => true]); + $innerFieldset->getElement('deep')->setValue('value'); + + $outerFieldset = (new FieldsetElement('outer')) + ->addElement($innerFieldset); + + $form = (new Form()) + ->addElement($outerFieldset) + ->validatePartial(); + + $this->assertTrue( + $form->getElement('outer')->getElement('inner')->getElement('deep')->isValid(), + 'Nested fieldset child with a value is not validated during partial validation' + ); + } + public function testFormElementsWithReservedCharsInName(): void { $form = new Form();