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('