Repository navigation
Hide validation errors on dependent fields emptied by a dependency change #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
bocharsky-bw
merged 5 commits into
SymfonyCasts:main
from
Amoifr:fix-50-revalidation-on-option-change
Oct 21, 2026
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
aecf3dc
Hide validation errors on dependent fields emptied by a dependency ch…
Amoifr 3965717
Detect dependency changes against the previous render instead of the …
Amoifr 2669106
Skip disabled and omitted empty dependencies when detecting changes
Amoifr 0c5d8f0
Merge remote-tracking branch 'upstream/main' into fix-50-revalidation…
Amoifr 885212c
Submit the rendered dynamic state in the edge case tests
Amoifr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| <?php | ||
|
|
||
| /* | ||
| * This file is part of the SymfonyCasts DynamicForms package. | ||
| * Copyright (c) SymfonyCasts <https://symfonycasts.com/> | ||
| * For the full copyright and license information, please view the LICENSE | ||
| * file that was distributed with this source code. | ||
| */ | ||
|
|
||
| namespace Symfonycasts\DynamicForms\Tests; | ||
|
|
||
| use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; | ||
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | ||
| use Symfony\Component\Form\Extension\Core\Type\FormType; | ||
| use Symfony\Component\Form\Extension\Core\Type\TextType; | ||
| use Symfony\Component\Form\FormInterface; | ||
| use Symfony\Component\Validator\Constraints\NotBlank; | ||
| use Symfonycasts\DynamicForms\DependentField; | ||
| use Symfonycasts\DynamicForms\DynamicFormBuilder; | ||
| use Symfonycasts\DynamicForms\Tests\fixtures\DynamicFormsTestKernel; | ||
| use Symfonycasts\DynamicForms\Tests\fixtures\Enum\DynamicTestMeal; | ||
| use Symfonycasts\DynamicForms\Tests\fixtures\TestConstrainedDynamicForm; | ||
| use Zenstruck\Browser\Test\HasBrowser; | ||
|
|
||
| /** | ||
| * A dependent field is emptied by the UI whenever one of its dependencies | ||
| * changes. Its validation errors (e.g. NotBlank) must not be rendered in | ||
| * that case, in the same way transformation failures are already hidden. | ||
| * | ||
| * @see https://github.com/SymfonyCasts/dynamic-forms/issues/50 | ||
| */ | ||
| class ConstrainedDependentFieldTest extends KernelTestCase | ||
| { | ||
| use HasBrowser; | ||
|
|
||
| public function testNoErrorIsRenderedWhenReturningToThePreviousOption(): void | ||
| { | ||
| $browser = $this->browser(); | ||
| // pick Dinner, then Pizza | ||
| $browser->visit('/constrained-form') | ||
| ->selectFieldOption('Meal', 'Dinner') | ||
| ->click('Submit Form') | ||
| ->selectFieldOption('Main food', 'Pizza') | ||
| ->click('Submit Form') | ||
| ->assertSee('Is Form Valid: yes') | ||
| ; | ||
|
|
||
| // change the meal: the submitted "pizza" is not a valid choice anymore, | ||
| // the transformation failure is hidden but the form is invalid | ||
| $browser->selectFieldOption('Meal', 'Breakfast') | ||
| ->click('Submit Form') | ||
| ->assertSee('Is Form Valid: no') | ||
| ->assertNotSee('This food is not served at this meal.') | ||
| ; | ||
|
|
||
| // change the meal back: "mainFood" is submitted empty, its NotBlank | ||
| // violation must be hidden too, but the form must remain invalid | ||
| $browser->selectFieldOption('Meal', 'Dinner') | ||
| ->click('Submit Form') | ||
| ->assertSee('Is Form Valid: no') | ||
| ->assertNotSee('This value should not be blank.') | ||
| ; | ||
|
|
||
| // selecting a food again makes the form valid | ||
| $browser->selectFieldOption('Main food', 'Pasta') | ||
| ->click('Submit Form') | ||
| ->assertSee('Is Form Valid: yes') | ||
| ; | ||
| } | ||
|
|
||
| public function testErrorIsRenderedWhenTheDependencyDidNotChange(): void | ||
| { | ||
| $browser = $this->browser(); | ||
| // "Breakfast" is the initial data: submitting it unchanged with an | ||
| // empty mainFood is a real mistake, the NotBlank violation must render | ||
| $browser->visit('/constrained-form') | ||
| ->click('Submit Form') | ||
| ->assertSee('Is Form Valid: no') | ||
| ->assertSee('This value should not be blank.') | ||
| ; | ||
| } | ||
|
|
||
| public function testErrorIsRenderedOnSubmitAfterDependencyChangedInPreviousRender(): void | ||
| { | ||
| $browser = $this->browser() | ||
| ->visit('/constrained-form') | ||
| ->selectFieldOption('Meal', 'Dinner') | ||
| ->click('Submit Form') // re-render after the dependency changed: fine to hide | ||
| ->assertNotSee('This value should not be blank.') | ||
| ; | ||
|
|
||
| // Meal is still "Dinner", the user submits without picking a food | ||
| $browser->click('Submit Form') | ||
| ->assertSee('Is Form Valid: no') | ||
| ->assertSee('This value should not be blank.') | ||
| ; | ||
| } | ||
|
|
||
| public function testErrorIsRenderedWhenTheRenderedStateIsMissing(): void | ||
| { | ||
| $form = self::getContainer()->get('form.factory')->create(TestConstrainedDynamicForm::class, [ | ||
| 'meal' => DynamicTestMeal::Breakfast, | ||
| ]); | ||
|
|
||
| // "meal" changed, but without the rendered state there is no way to | ||
| // know it: the error must not be hidden | ||
| $form->submit(['meal' => 'dinner', 'mainFood' => '']); | ||
|
|
||
| $this->assertFalse($form->isValid()); | ||
| $this->assertCount(1, $form->get('mainFood')->getErrors()); | ||
| } | ||
|
|
||
| public function testErrorIsRenderedWhenADisabledDependencyIsNotSubmitted(): void | ||
| { | ||
| $depOptions = ['choices' => ['A' => 'a', 'B' => 'b'], 'disabled' => true]; | ||
| $renderedState = $this->renderDynamicState('b', $depOptions); | ||
|
|
||
| // browsers do not submit disabled fields, Symfony keeps "b" | ||
| $form = $this->createFormWithDependency('b', $depOptions); | ||
| $form->submit(['notes' => '', '__dynamic_state' => $renderedState]); | ||
|
|
||
| $this->assertFalse($form->isValid()); | ||
| $this->assertCount(1, $form->get('notes')->getErrors()); | ||
| } | ||
|
|
||
| public function testErrorIsRenderedWhenAnEmptyMultipleDependencyIsNotSubmitted(): void | ||
| { | ||
| $depOptions = ['choices' => ['A' => 'a', 'B' => 'b'], 'multiple' => true]; | ||
| $renderedState = $this->renderDynamicState([], $depOptions); | ||
|
|
||
| // browsers omit a multiple choice field with nothing selected | ||
| $form = $this->createFormWithDependency([], $depOptions); | ||
| $form->submit(['notes' => '', '__dynamic_state' => $renderedState]); | ||
|
|
||
| $this->assertFalse($form->isValid()); | ||
| $this->assertCount(1, $form->get('notes')->getErrors()); | ||
| } | ||
|
|
||
| private function renderDynamicState(mixed $depData, array $depOptions): string | ||
| { | ||
| return $this->createFormWithDependency($depData, $depOptions)->createView()['__dynamic_state']->vars['value']; | ||
| } | ||
|
|
||
| private function createFormWithDependency(mixed $depData, array $depOptions): FormInterface | ||
| { | ||
| $builder = new DynamicFormBuilder( | ||
| self::getContainer()->get('form.factory')->createBuilder(FormType::class, ['dep' => $depData]) | ||
| ); | ||
| $builder->add('dep', ChoiceType::class, $depOptions); | ||
| $builder->addDependent('notes', ['dep'], static function (DependentField $field) { | ||
| $field->add(TextType::class, ['constraints' => [new NotBlank()]]); | ||
| }); | ||
|
|
||
| return $builder->getForm(); | ||
| } | ||
|
|
||
| protected static function getKernelClass(): string | ||
| { | ||
| return DynamicFormsTestKernel::class; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I played with a few more edge cases and found two where the original problem comes back: the error is hidden on every submit because a field the browser doesn't send is treated as "changed":
$data[$dependency]is missing and becomes'', while the rendered state holds the real value (e.g."b"). E.g. an edit form where "country" is disabled: theNotBlankerror on the dependent "state" field is never shown.multiple: true, expanded or not) where the rendered state is[], but the browser omits the field entirely, so it becomes''and[] !== ''.To reproduce (the dependent
notesfield hasNotBlank; both assertions fail on the current branch):We probably should skip disabled dependencies in
detectChangedDependencies()(Symfony ignores their submitted data anyway), e.g.if ($form->get($dependency)->isDisabled()) { continue; }. Or innormalizeDependencyValue(), normalize an empty array to''as well, likenull.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great catches, thanks! Both are fixed with your two tests (they failed before the change):
detectChangedDependencies(), and their rendered value is carried over to the next state;''likenull, on both sides of the comparison, so a state rendered as[]also matches an omitted field.I also added the README note about
__dynamic_statein the "Write custom JavaScript" section and rewrote the PR description. Hopefully the edge case collection is complete now, but I won't bet my keyboard on it. 😄