Skip to content
Merged
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,11 @@ form to its usual endpoint (or any endpoint that will submit the form), take
the HTML response, extract the parts that need to be re-rendered and then replace
the HTML on the page.

Make sure to also replace the hidden `__dynamic_state` field: it holds the values
of the dependencies as they were rendered. If it keeps the state of the first
render, the validation errors of the dependent fields will be hidden again on
the next submit.

This is a non-trivial task and there may be room for improvement in this
library to make this easier. If you have ideas, please open an issue!

Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"symfony/twig-bundle": "^5.4|^6.4|^7.0|^8.0",
"twig/twig": "^2.15|^3.0",
"symfony/options-resolver": "^5.4|^6.4|^7.0|^8.0",
"phpunit/phpunit": "^9.6"
"phpunit/phpunit": "^9.6",
"symfony/validator": "^5.4|^6.4|^7.0|^8.0"
},
"minimum-stability": "dev",
"prefer-stable": true,
Expand Down
172 changes: 170 additions & 2 deletions src/DynamicFormBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,20 @@ class DynamicFormBuilder implements FormBuilderInterface, \IteratorAggregate
private array $preSetDataDependencyData = [];
private array $postSubmitDependencyData = [];

/**
* Names of the dependencies whose submitted value differs from the value
* rendered on the previous request.
*
* @var string[]
*/
private array $changedDependencies = [];

public function __construct(private FormBuilderInterface $builder)
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$this->form = $event->getForm();
$this->preSetDataDependencyData = [];
$this->changedDependencies = [];
$this->initializeListeners();

// A fake hidden field where we can "store" an error if a dependent form
Expand All @@ -66,14 +75,32 @@ public function __construct(private FormBuilderInterface $builder)
'error_bubbling' => false,
]);
}

// A fake hidden field carrying the values of the dependencies as
// they were rendered, so the next submit can tell which of them
// the user actually changed since the previous render.
if (!$this->form->has('__dynamic_state')) {
$this->form->add('__dynamic_state', HiddenType::class, [
'mapped' => false,
]);
}
}, 100);

$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event) {
$this->storeRenderedDependencyState($event);
});

$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
$this->detectChangedDependencies($event);
});

$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
$this->postSubmitDependencyData = [];
});
// guarantee later than core ValidationListener
$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
$this->clearDataOnTransformationError($event);
$this->clearErrorsOnEmptiedDependentFields($event);
}, -1);
}

Expand Down Expand Up @@ -123,10 +150,151 @@ public function clearDataOnTransformationError(FormEvent $event): void
// We need to make sure that the form doesn't submit successfully,
// but we also don't want to render a validation error on any field.
// So, we jam the error into a hidden field, which doesn't render errors.
if ($form->get('__dynamic_error')->isValid()) {
$form->get('__dynamic_error')->addError(new FormError('Some dynamic fields have errors.'));
$this->addHiddenError($form);
}
}

/**
* Hides the validation errors of a dependent field that was emptied because
* a field it depends on just changed (e.g. user selected "Pizza" as the main
* food, then changed "Meal" back and forth: "mainFood" is re-rendered empty,
* so a NotBlank-style violation on it is only noise). Like for transformation
* failures above, the error is moved to the hidden field: nothing is
* rendered, but the form remains invalid.
*/
private function clearErrorsOnEmptiedDependentFields(FormEvent $event): void
{
if (!$this->changedDependencies) {
return;
}

$form = $event->getForm();
$errorsCleared = false;
foreach ($this->dependentFieldConfigs as $dependentFieldConfig) {
if (!array_intersect($dependentFieldConfig->dependencies, $this->changedDependencies)) {
continue;
}

if (!$form->has($dependentFieldConfig->name)) {
continue;
}

$subForm = $form->get($dependentFieldConfig->name);
if (!$subForm instanceof ClearableErrorsInterface || !$subForm->isEmpty()) {
continue;
}

if (0 === \count($subForm->getErrors(false))) {
continue;
}

$subForm->clearErrors();
$errorsCleared = true;
}

if ($errorsCleared) {
$this->addHiddenError($form);
}
}

private function storeRenderedDependencyState(FormEvent $event): void
{
$form = $event->getForm();
$state = [];
foreach ($this->getDependencyNames() as $dependency) {
if ($form->has($dependency)) {
$state[$dependency] = $this->normalizeDependencyValue($form->get($dependency)->getViewData());
}
}

$form->get('__dynamic_state')->setData(json_encode($state));
}

/**
* Compares the submitted dependency values with the ones rendered on the
* previous request, then replaces the submitted state with the current
* values, so the next render carries them.
*
* Without a usable state (e.g. the field was not rendered), no dependency
* is considered changed: rendering an extra error is safer than silently
* blocking the submit.
*/
private function detectChangedDependencies(FormEvent $event): void
{
$this->changedDependencies = [];

$data = $event->getData();
if (!\is_array($data)) {
return;
}

$previousState = \is_string($data['__dynamic_state'] ?? null) ? json_decode($data['__dynamic_state'], true) : null;

$currentState = [];
foreach ($this->getDependencyNames() as $dependency) {
if (!$event->getForm()->has($dependency)) {
continue;
}

// browsers do not submit disabled fields, and Symfony ignores
// their submitted data anyway: such a dependency cannot change
$dependencyForm = $event->getForm()->get($dependency);
if ($dependencyForm->isDisabled()) {
$currentState[$dependency] = $this->normalizeDependencyValue($dependencyForm->getViewData());

continue;
}

$currentState[$dependency] = $this->normalizeDependencyValue($data[$dependency] ?? null);

Copy link
Copy Markdown
Member

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":

  1. Browsers don't submit disabled fields, so $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: the NotBlank error on the dependent "state" field is never shown.
  2. Problem with multiple choice with nothing selected (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 notes field has NotBlank; both assertions fail on the current branch):

// disabled dependency, browser does not send "dep"
$form->submit(['notes' => '', '__dynamic_state' => '{"dep":"b"}']);
$this->assertCount(1, $form->get('notes')->getErrors());

// multiple choice with nothing selected, browser does not send "dep"
$form->submit(['notes' => '', '__dynamic_state' => '{"dep":[]}']);
$this->assertCount(1, $form->get('notes')->getErrors());

We probably should skip disabled dependencies in detectChangedDependencies() (Symfony ignores their submitted data anyway), e.g. if ($form->get($dependency)->isDisabled()) { continue; }. Or in normalizeDependencyValue(), normalize an empty array to '' as well, like null.

Copy link
Copy Markdown
Contributor Author

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):

  • disabled dependencies are skipped in detectChangedDependencies(), and their rendered value is carried over to the next state;
  • an empty array is normalized to '' like null, on both sides of the comparison, so a state rendered as [] also matches an omitted field.

I also added the README note about __dynamic_state in 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. 😄


if (\is_array($previousState)
&& \array_key_exists($dependency, $previousState)
&& $this->normalizeDependencyValue($previousState[$dependency]) !== $currentState[$dependency]
) {
$this->changedDependencies[] = $dependency;
}
}

$data['__dynamic_state'] = json_encode($currentState);
$event->setData($data);
}

/**
* @return string[]
*/
private function getDependencyNames(): array
{
$dependencies = [];
foreach ($this->dependentFieldConfigs as $dependentFieldConfig) {
$dependencies = [...$dependencies, ...$dependentFieldConfig->dependencies];
}

return array_values(array_unique($dependencies));
}

/**
* Makes a rendered view value comparable with a raw submitted one: a
* missing or null value is submitted as an empty string, and so is a
* multiple choice with nothing selected, which browsers omit entirely.
*/
private function normalizeDependencyValue(mixed $value): mixed
{
if (null === $value || [] === $value) {
return '';
}

if (\is_array($value)) {
return array_map(fn (mixed $item): mixed => $this->normalizeDependencyValue($item), $value);
}

return \is_scalar($value) ? (string) $value : $value;
}

private function addHiddenError(FormInterface $form): void
{
if ($form->get('__dynamic_error')->isValid()) {
$form->get('__dynamic_error')->addError(new FormError('Some dynamic fields have errors.'));
}
}

private function executeReadyCallbacks(array $availableDependencyData, string $eventName): void
Expand Down
161 changes: 161 additions & 0 deletions tests/ConstrainedDependentFieldTest.php
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;
}
}
Loading