diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 7379d28..b529b95 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -6,6 +6,7 @@
$finder = (new \PhpCsFixer\Finder())
->in([__DIR__.'/src', __DIR__.'/tests'])
+ ->exclude('tests/fixtures/var')
;
return (new \PhpCsFixer\Config())
diff --git a/composer.json b/composer.json
index c59cfe9..f379ff7 100644
--- a/composer.json
+++ b/composer.json
@@ -21,7 +21,9 @@
"symfony/twig-bundle": "^5.4|^6.3|^7.0",
"twig/twig": "^2.15|^3.0",
"symfony/options-resolver": "^5.4|^6.3|^7.0",
- "phpunit/phpunit": "^9.6"
+ "phpunit/phpunit": "^9.6",
+ "symfony/panther": "^2.2",
+ "dbrekelmans/bdi": "dev-main"
},
"minimum-stability": "dev",
"autoload": {
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 576b574..277c968 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -17,6 +17,12 @@
+
+
+
+
+
+
@@ -34,4 +40,8 @@
+
+
+
+
diff --git a/src/DynamicFormBuilder.php b/src/DynamicFormBuilder.php
index adf2a73..c99bd9d 100644
--- a/src/DynamicFormBuilder.php
+++ b/src/DynamicFormBuilder.php
@@ -130,24 +130,101 @@ public function clearDataOnTransformationError(FormEvent $event): void
}
private function executeReadyCallbacks(array $availableDependencyData, string $eventName): void
+ {
+ $hasChanges = true;
+ $maxIterations = 10;
+ $iteration = 0;
+
+ while ($hasChanges && $iteration < $maxIterations) {
+ $hasChanges = false;
+ ++$iteration;
+
+ // First pass: handle removals and reset dependent callbacks
+ foreach ($this->dependentFieldConfigs as $dependentFieldConfig) {
+ if ($dependentFieldConfig->isReady($availableDependencyData, $eventName)) {
+ $dynamicField = $dependentFieldConfig->execute($availableDependencyData, $eventName);
+ $name = $dependentFieldConfig->name;
+ $fieldExisted = $this->form->has($name);
+
+ if (!$dynamicField->shouldBeAdded()) {
+ if ($fieldExisted) {
+ $this->form->remove($name);
+ $hasChanges = true;
+
+ // Reset callbacks for fields that depend on this removed field
+ $this->resetDependentCallbacks($name, $eventName);
+ }
+ continue;
+ }
+
+ // Field should be added - handle both new and existing fields
+ if ($fieldExisted) {
+ // Field exists but may need to be updated with new options
+ // Remove and re-add to ensure proper configuration
+ $this->form->remove($name);
+ $hasChanges = true;
+ }
+
+ // Add/re-add the field with current configuration
+ $this->builder->add($name, $dynamicField->getType(), $dynamicField->getOptions());
+ $this->initializeListeners([$name]);
+ // auto initialize mimics FormBuilder::getForm() behavior
+ $field = $this->builder->get($name)->setAutoInitialize(false)->getForm();
+ $this->form->add($field);
+
+ if (!$fieldExisted) {
+ $hasChanges = true;
+ }
+ }
+ }
+
+ // If we had changes, we need to re-evaluate all dependencies that might now be invalid
+ if ($hasChanges) {
+ $this->validateAndRemoveOrphanedFields($eventName);
+ }
+ }
+ }
+
+ /**
+ * Remove fields that should no longer exist because their dependencies are missing.
+ */
+ private function validateAndRemoveOrphanedFields(string $eventName): void
{
foreach ($this->dependentFieldConfigs as $dependentFieldConfig) {
- if ($dependentFieldConfig->isReady($availableDependencyData, $eventName)) {
- $dynamicField = $dependentFieldConfig->execute($availableDependencyData, $eventName);
- $name = $dependentFieldConfig->name;
+ $name = $dependentFieldConfig->name;
+
+ // If the field exists in the form, check if it should still exist
+ if ($this->form->has($name)) {
+ $hasAllDependencies = true;
+
+ foreach ($dependentFieldConfig->dependencies as $dependency) {
+ // Check if the dependency field exists and has appropriate data
+ if (!$this->form->has($dependency)) {
+ $hasAllDependencies = false;
+ break;
+ }
+ }
- if (!$dynamicField->shouldBeAdded()) {
+ // If dependencies are missing, remove the field and reset its callback
+ if (!$hasAllDependencies) {
$this->form->remove($name);
+ $dependentFieldConfig->callbackExecuted[$eventName] = false;
- continue;
+ // Reset callbacks for fields that depend on this removed field
+ $this->resetDependentCallbacks($name, $eventName);
}
+ }
+ }
+ }
- $this->builder->add($name, $dynamicField->getType(), $dynamicField->getOptions());
-
- $this->initializeListeners([$name]);
- // auto initialize mimics FormBuilder::getForm() behavior
- $field = $this->builder->get($name)->setAutoInitialize(false)->getForm();
- $this->form->add($field);
+ /**
+ * Reset callback execution status for fields that depend on a removed field.
+ */
+ private function resetDependentCallbacks(string $removedFieldName, string $eventName): void
+ {
+ foreach ($this->dependentFieldConfigs as $dependentFieldConfig) {
+ if (\in_array($removedFieldName, $dependentFieldConfig->dependencies)) {
+ $dependentFieldConfig->callbackExecuted[$eventName] = false;
}
}
}
diff --git a/tests/E2ETest.php b/tests/E2ETest.php
new file mode 100644
index 0000000..028551d
--- /dev/null
+++ b/tests/E2ETest.php
@@ -0,0 +1,62 @@
+
+ * 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\Component\Panther\PantherTestCase;
+use Symfonycasts\DynamicForms\Tests\fixtures\DynamicFormsTestKernel;
+use Zenstruck\Browser\Test\HasBrowser;
+
+class E2ETest extends PantherTestCase
+{
+ use HasBrowser;
+
+ public function testRecursiveDynamicFields()
+ {
+ $browser = $this->pantherBrowser();
+ $browser->visit('/form-pizza-selected')
+ // check for the hidden field
+ ->waitUntilSeeIn('//html', 'Is Form Valid: no')
+ ->assertSeeElement('#test_dynamic_form___dynamic_error')
+ ->assertSee('Pizza 🍕')
+ ->assertNotContains('