removed symfonycasts

This commit is contained in:
Eric Espie
2025-10-03 11:39:05 +02:00
parent 39fd879ca9
commit 4d9be0cfe4
11 changed files with 6 additions and 1057 deletions

View File

@@ -33,7 +33,6 @@
"symfony/twig-bundle": "~6.4.0",
"symfony/var-dumper": "~6.4.0",
"symfony/yaml": "~6.4.0",
"symfonycasts/dynamic-forms": "^0.1.3",
"tecnickcom/tcpdf": "^6.6.0",
"thenetworg/oauth2-azure": "^2.0"
},

View File

@@ -3155,9 +3155,6 @@ return array(
'Symfony\\Runtime\\Symfony\\Component\\HttpFoundation\\RequestRuntime' => $vendorDir . '/symfony/runtime/Internal/HttpFoundation/RequestRuntime.php',
'Symfony\\Runtime\\Symfony\\Component\\HttpFoundation\\ResponseRuntime' => $vendorDir . '/symfony/runtime/Internal/HttpFoundation/ResponseRuntime.php',
'Symfony\\Runtime\\Symfony\\Component\\HttpKernel\\HttpKernelInterfaceRuntime' => $vendorDir . '/symfony/runtime/Internal/HttpKernel/HttpKernelInterfaceRuntime.php',
'Symfonycasts\\DynamicForms\\DependentField' => $vendorDir . '/symfonycasts/dynamic-forms/src/DependentField.php',
'Symfonycasts\\DynamicForms\\DependentFieldConfig' => $vendorDir . '/symfonycasts/dynamic-forms/src/DependentFieldConfig.php',
'Symfonycasts\\DynamicForms\\DynamicFormBuilder' => $vendorDir . '/symfonycasts/dynamic-forms/src/DynamicFormBuilder.php',
'SynchroExceptionNotStarted' => $baseDir . '/application/exceptions/SynchroExceptionNotStarted.php',
'System' => $vendorDir . '/pear/pear-core-minimal/src/System.php',
'TCPDF' => $vendorDir . '/tecnickcom/tcpdf/tcpdf.php',

View File

@@ -10,16 +10,16 @@ return array(
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => $vendorDir . '/symfony/polyfill-mbstring/bootstrap.php',
'320cde22f66dd4f5d3fd621d3e88b98f' => $vendorDir . '/symfony/polyfill-ctype/bootstrap.php',
'662a729f963d39afe703c9d9b7ab4a8c' => $vendorDir . '/symfony/polyfill-php83/bootstrap.php',
'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'667aeda72477189d0494fecd327c3641' => $vendorDir . '/symfony/var-dumper/Resources/functions/dump.php',
'e69f7f6ee287b969198c3c9d6777bd38' => $vendorDir . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'89efb1254ef2d1c5d80096acd12c4098' => $vendorDir . '/twig/twig/src/Resources/core.php',
'ffecb95d45175fd40f75be8a23b34f90' => $vendorDir . '/twig/twig/src/Resources/debug.php',
'c7baa00073ee9c61edf148c51917cfb4' => $vendorDir . '/twig/twig/src/Resources/escaper.php',
'f844ccf1d25df8663951193c3fc307c8' => $vendorDir . '/twig/twig/src/Resources/string_loader.php',
'8825ede83f2f289127722d4e842cf7e8' => $vendorDir . '/symfony/polyfill-intl-grapheme/bootstrap.php',
'7b11c4dc42b3b3023073cb14e519683c' => $vendorDir . '/ralouphie/getallheaders/src/getallheaders.php',
'b6b991a57620e2fb6b2f66f03fe9ddc2' => $vendorDir . '/symfony/string/Resources/functions.php',
'8825ede83f2f289127722d4e842cf7e8' => $vendorDir . '/symfony/polyfill-intl-grapheme/bootstrap.php',
'f598d06aa772fa33d905e87be6398fb1' => $vendorDir . '/symfony/polyfill-intl-idn/bootstrap.php',
'b6b991a57620e2fb6b2f66f03fe9ddc2' => $vendorDir . '/symfony/string/Resources/functions.php',
'37a3dc5111fe8f707ab4c132ef1dbc62' => $vendorDir . '/guzzlehttp/guzzle/src/functions_include.php',
'6a47392539ca2329373e0d33e1dba053' => $vendorDir . '/symfony/polyfill-intl-icu/bootstrap.php',
'344f11dc3484aaed5cbde58e23513be4' => $vendorDir . '/apereo/phpcas/source/CAS.php',

View File

@@ -8,7 +8,6 @@ $baseDir = dirname($vendorDir);
return array(
'Twig\\' => array($vendorDir . '/twig/twig/src'),
'TheNetworg\\OAuth2\\Client\\' => array($vendorDir . '/thenetworg/oauth2-azure/src'),
'Symfonycasts\\DynamicForms\\' => array($vendorDir . '/symfonycasts/dynamic-forms/src'),
'Symfony\\Runtime\\Symfony\\Component\\' => array($vendorDir . '/symfony/runtime/Internal'),
'Symfony\\Polyfill\\Php83\\' => array($vendorDir . '/symfony/polyfill-php83'),
'Symfony\\Polyfill\\Mbstring\\' => array($vendorDir . '/symfony/polyfill-mbstring'),

View File

@@ -11,16 +11,16 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'0e6d7bf4a5811bfa5cf40c5ccd6fae6a' => __DIR__ . '/..' . '/symfony/polyfill-mbstring/bootstrap.php',
'320cde22f66dd4f5d3fd621d3e88b98f' => __DIR__ . '/..' . '/symfony/polyfill-ctype/bootstrap.php',
'662a729f963d39afe703c9d9b7ab4a8c' => __DIR__ . '/..' . '/symfony/polyfill-php83/bootstrap.php',
'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'667aeda72477189d0494fecd327c3641' => __DIR__ . '/..' . '/symfony/var-dumper/Resources/functions/dump.php',
'e69f7f6ee287b969198c3c9d6777bd38' => __DIR__ . '/..' . '/symfony/polyfill-intl-normalizer/bootstrap.php',
'89efb1254ef2d1c5d80096acd12c4098' => __DIR__ . '/..' . '/twig/twig/src/Resources/core.php',
'ffecb95d45175fd40f75be8a23b34f90' => __DIR__ . '/..' . '/twig/twig/src/Resources/debug.php',
'c7baa00073ee9c61edf148c51917cfb4' => __DIR__ . '/..' . '/twig/twig/src/Resources/escaper.php',
'f844ccf1d25df8663951193c3fc307c8' => __DIR__ . '/..' . '/twig/twig/src/Resources/string_loader.php',
'8825ede83f2f289127722d4e842cf7e8' => __DIR__ . '/..' . '/symfony/polyfill-intl-grapheme/bootstrap.php',
'7b11c4dc42b3b3023073cb14e519683c' => __DIR__ . '/..' . '/ralouphie/getallheaders/src/getallheaders.php',
'b6b991a57620e2fb6b2f66f03fe9ddc2' => __DIR__ . '/..' . '/symfony/string/Resources/functions.php',
'8825ede83f2f289127722d4e842cf7e8' => __DIR__ . '/..' . '/symfony/polyfill-intl-grapheme/bootstrap.php',
'f598d06aa772fa33d905e87be6398fb1' => __DIR__ . '/..' . '/symfony/polyfill-intl-idn/bootstrap.php',
'b6b991a57620e2fb6b2f66f03fe9ddc2' => __DIR__ . '/..' . '/symfony/string/Resources/functions.php',
'37a3dc5111fe8f707ab4c132ef1dbc62' => __DIR__ . '/..' . '/guzzlehttp/guzzle/src/functions_include.php',
'6a47392539ca2329373e0d33e1dba053' => __DIR__ . '/..' . '/symfony/polyfill-intl-icu/bootstrap.php',
'344f11dc3484aaed5cbde58e23513be4' => __DIR__ . '/..' . '/apereo/phpcas/source/CAS.php',
@@ -35,7 +35,6 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
),
'S' =>
array (
'Symfonycasts\\DynamicForms\\' => 26,
'Symfony\\Runtime\\Symfony\\Component\\' => 34,
'Symfony\\Polyfill\\Php83\\' => 23,
'Symfony\\Polyfill\\Mbstring\\' => 26,
@@ -126,10 +125,6 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
array (
0 => __DIR__ . '/..' . '/thenetworg/oauth2-azure/src',
),
'Symfonycasts\\DynamicForms\\' =>
array (
0 => __DIR__ . '/..' . '/symfonycasts/dynamic-forms/src',
),
'Symfony\\Runtime\\Symfony\\Component\\' =>
array (
0 => __DIR__ . '/..' . '/symfony/runtime/Internal',
@@ -3541,9 +3536,6 @@ class ComposerStaticInit7f81b4a2a468a061c306af5e447a9a9f
'Symfony\\Runtime\\Symfony\\Component\\HttpFoundation\\RequestRuntime' => __DIR__ . '/..' . '/symfony/runtime/Internal/HttpFoundation/RequestRuntime.php',
'Symfony\\Runtime\\Symfony\\Component\\HttpFoundation\\ResponseRuntime' => __DIR__ . '/..' . '/symfony/runtime/Internal/HttpFoundation/ResponseRuntime.php',
'Symfony\\Runtime\\Symfony\\Component\\HttpKernel\\HttpKernelInterfaceRuntime' => __DIR__ . '/..' . '/symfony/runtime/Internal/HttpKernel/HttpKernelInterfaceRuntime.php',
'Symfonycasts\\DynamicForms\\DependentField' => __DIR__ . '/..' . '/symfonycasts/dynamic-forms/src/DependentField.php',
'Symfonycasts\\DynamicForms\\DependentFieldConfig' => __DIR__ . '/..' . '/symfonycasts/dynamic-forms/src/DependentFieldConfig.php',
'Symfonycasts\\DynamicForms\\DynamicFormBuilder' => __DIR__ . '/..' . '/symfonycasts/dynamic-forms/src/DynamicFormBuilder.php',
'SynchroExceptionNotStarted' => __DIR__ . '/../..' . '/application/exceptions/SynchroExceptionNotStarted.php',
'System' => __DIR__ . '/..' . '/pear/pear-core-minimal/src/System.php',
'TCPDF' => __DIR__ . '/..' . '/tecnickcom/tcpdf/tcpdf.php',

View File

@@ -1,19 +0,0 @@
Copyright (c) SymfonyCasts <https://symfonycasts.com/>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -1,275 +0,0 @@
# Dynamic / Dependent Symfony Form Fields
[![CI](https://github.com/SymfonyCasts/dynamic-forms/actions/workflows/ci.yaml/badge.svg)](https://github.com/SymfonyCasts/dynamic-forms/actions/workflows/ci.yaml)
**NOTE**: This package is currently experimental. It seems to work great - but
forms are complex! If you find a bug, please open an issue!
Ever have a form field that depends on another?
You can find a [Demo with LiveComponent on Symfony UX](https://ux.symfony.com/demos/live-component/dependent-form-fields).
* Show a field only if another field is set to a specific value;
* Change the options of a field based on the value of another field;
* Have multiple-level dependencies (e.g. field A depends on field B
which depends on field C).
```php
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder = new DynamicFormBuilder($builder);
$builder->add('meal', ChoiceType::class, [
'choices' => [
'Breakfast' => 'breakfast',
'Lunch' => 'lunch',
'Dinner' => 'dinner',
],
]);
$builder->addDependent('mainFood', ['meal'], function(DependentField $field, string $meal) {
// dynamically add choices based on the meal!
$choices = ['...'];
$field->add(ChoiceType::class, [
'placeholder' => null === $meal ? 'Select a meal first' : sprintf('What is for %s?', $meal->getReadable()),
'choices' => $choices,
'disabled' => null === $meal,
]);
});
```
## Installation
Install the package with:
```bash
composer require symfonycasts/dynamic-forms
```
Done - you're ready to build dynamic forms!
## Usage
Setting up a dependent field is two parts:
1. [Usage in PHP](#usage-in-php) - set up your Symfony form to handle
the dynamic fields;
2. [Updating the Frontend](#updating-the-frontend) - adding code to your
frontend so that when one field changes, part of the form is re-rendered.
## Usage in PHP
Start by wrapping your `FormBuilderInterface` with a `DynamicFormBuilder`:
```php
use Symfonycasts\DynamicForms\DynamicFormBuilder;
// ...
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder = new DynamicFormBuilder($builder);
// ...
}
```
`DynamicFormBuilder` has all the same methods as `FormBuilderInterface` plus
one extra: `addDependent()`. If a field depends on another, use this method
instead of `add()`
```php
// src/Form/FeedbackForm.php
// ...
use Symfonycasts\DynamicForms\DependentField;
use Symfonycasts\DynamicForms\DynamicFormBuilder;
class FeedbackForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder = new DynamicFormBuilder($builder);
$builder->add('rating', ChoiceType::class, [
'choices' => [
'Select a rating' => null,
'Great' => 5,
'Good' => 4,
'Okay' => 3,
'Bad' => 2,
'Terrible' => 1
],
]);
$builder->addDependent('badRatingNotes', 'rating', function(DependentField $field, ?int $rating) {
if (null === $rating || $rating >= 3) {
return; // field not needed
}
$field->add(TextareaType::class, [
'label' => 'What went wrong?',
'attr' => ['rows' => 3],
'help' => sprintf('Because you gave a %d rating, we\'d love to know what went wrong.', $rating),
]);
});
}
}
```
The `addDependent()` method takes 3 arguments:
1. The name of the field to add;
2. The name (or names) of the field that this field depends on;
3. A callback that will be called when the form is submitted. This callback
receives a `DependentField` object as the first argument then the
value of each dependent field as the next arguments.
Behind the scenes, this works by registering several form event listeners.
The callback be executed when the form is first created (using the initial
data) and then again when the form is submitted. This means that the callback
may be called multiple times.
Rendering the field is the same - just be sure to make sure the field exists
if it's conditionally added:
```twig
{{ form_start(form) }}
{{ form_row(form.rating) }}
{% if form.badRatingNotes is defined %}
{{ form_row(form.badRatingNotes) }}
{% endif %}
<button>Send Feedback</button>
{{ form_end(form) }}
```
## Updating the Frontend
In the previous example, when the `rating` field changes, the form (or part of
the form) needs to be re-rendered so the `badRatingNotes` field can be added.
This library doesn't handle this for you, but here are the 2 main options:
### A) Use [Live Components](https://symfony.com/bundles/ux-live-component/current/index.html)
This is the easiest method: by rendering your form inside a live component,
it will automatically re-render when the form changes.
### B) Use [Symfony UX Turbo](https://symfony.com/bundles/ux-turbo/current/index.html#decomposing-complex-pages-with-turbo-frames)
If you are already using Symfony UX Turbo on your website, you can have a dynamic form running quickly without any JavaScript.
Or you may want to install Symfony UX Turbo, [check out the documentation](https://symfony.com/bundles/ux-turbo/current/index.html#installation).
> [!NOTE]
> You only need to have Turbo Frame, you can disable Turbo Drive if you do not use it, or do not want to use it.
> ie: `Turbo.session.drive = false;`
Simply add a `<turbo-frame>` around your form:
```twig
<turbo-frame id="rating-form">
{{ form(form) }}
</turbo-frame>
```
From here you need two small changes:
First, in your form type:
- You need to add an attribute on the choice field, so it auto-submits the form when changed (may need to be adapted to your own form if more complex)
- Add a submit button, so in the controller you can differenciate from an auto-submit versus a user action
```diff
// src/Form/FeedbackForm.php
// ...
class FeedbackForm extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder = new DynamicFormBuilder($builder);
$builder->add('rating', ChoiceType::class, [
'choices' => [
'Select a rating' => null,
'Great' => 5,
'Good' => 4,
'Okay' => 3,
'Bad' => 2,
'Terrible' => 1
],
+ // This will allow the form to auto-submit on value change
+ 'attr' => ['onchange' => 'this.form.requestSubmit()'],
]);
+ // This will allow to differenciate between a user submition and an auto-submit
+ $builder->add('submit', SubmitType::class, [
+ 'attr' => ['value' => 'submit'], // Needed for Turbo
+ ]);
$builder->addDependent('badRatingNotes', 'rating', function(DependentField $field, ?int $rating) {
if (null === $rating || $rating >= 3) {
return; // field not needed
}
$field->add(TextareaType::class, [
'label' => 'What went wrong?',
'attr' => ['rows' => 3],
'help' => sprintf('Because you gave a %d rating, we\'d love to know what went wrong.', $rating),
]);
});
}
}
```
Second, in your controller:
- Specify the action on your form, [this is needed for Turbo Frame](https://symfony.com/bundles/ux-turbo/current/index.html#3-form-response-code-changes)
- Handle the auto-submit by checking if the button has been clicked
```diff
// src/Controller/FeedbackController.php
#[Route('/feedback', name: 'feedback')]
public function feedback(Request $request): Response
{
//...
- $feedbackForm = $this->createForm(FeedbackForm::class);
+ $feedbackForm = $this->createForm(FeedbackForm::class, options: [
+ // This is needed by Turbo Frame, it is not specific to Dependent Symfony Form Fields
+ 'action' => $this->generateUrl('feedback'),
+ ]);
$feedbackForm->handleRequest($request);
if ($feedbackForm->isSubmitted() && $feedbackForm->isValid()) {
+ /** @var SubmitButton $submitButton */
+ $submitButton = $feedbackForm->get('submit');
+ if (!$submitButton->isClicked()) {
+ return $this->render('feedback.html.twig', ['feedbackForm' => $feedbackForm]);
+ }
// Your code here
// ...
return $this->redirectToRoute('home');
}
return $this->render('feedback.html.twig', ['feedbackForm' => $feedbackForm]);
}
```
### C) Write custom JavaScript
If you're not using Live Components, nor Turbo Frames, you'll need to write some custom
JavaScript to listen to the `change` event on the `rating` field and then
make an AJAX call to re-render the form. The AJAX call should submit the
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.
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!

View File

@@ -1,51 +0,0 @@
{
"name": "symfonycasts/dynamic-forms",
"description": "Add dynamic/dependent fields to Symfony forms",
"license": "MIT",
"type": "library",
"keywords": ["symfony", "forms"],
"authors": [
{
"name": "Ryan Weaver",
"homepage": "https://symfonycasts.com"
}
],
"require": {
"php": ">=8.1",
"symfony/form": "^5.4|^6.3|^7.0"
},
"require-dev": {
"symfony/framework-bundle": "^6.3|^7.0",
"symfony/phpunit-bridge": "^5.4.32|^6.3.9|^7.0",
"zenstruck/browser": "^1.4",
"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"
},
"minimum-stability": "dev",
"autoload": {
"psr-4": {
"Symfonycasts\\DynamicForms\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Symfonycasts\\DynamicForms\\Tests\\": "tests/"
}
},
"scripts": {
"tools:upgrade": [
"@tools:upgrade:php-cs-fixer",
"@tools:upgrade:phpstan"
],
"tools:upgrade:php-cs-fixer": "composer upgrade -W -d tools/php-cs-fixer",
"tools:upgrade:phpstan": "composer upgrade -W -d tools/phpstan",
"tools:run": [
"@tools:run:php-cs-fixer",
"@tools:run:phpstan"
],
"tools:run:php-cs-fixer": "tools/php-cs-fixer/vendor/bin/php-cs-fixer fix",
"tools:run:phpstan": "tools/phpstan/vendor/bin/phpstan --memory-limit=1G"
}
}

View File

@@ -1,46 +0,0 @@
<?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;
/**
* Used to configure a dependent/dynamic field.
*
* If ->add() is not called, the field won't be included.
*/
class DependentField
{
private ?string $type = null;
private array $options = [];
private bool $shouldBeAdded = false;
public function add(?string $type = null, array $options = []): static
{
$this->type = $type;
$this->options = $options;
$this->shouldBeAdded = true;
return $this;
}
public function getType(): ?string
{
return $this->type;
}
public function getOptions(): array
{
return $this->options;
}
public function shouldBeAdded(): bool
{
return $this->shouldBeAdded;
}
}

View File

@@ -1,60 +0,0 @@
<?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;
use Symfony\Component\Form\FormEvents;
/**
* Holds the configuration for a dynamic field & what listeners have been executed.
*/
class DependentFieldConfig
{
public array $callbackExecuted = [
FormEvents::PRE_SET_DATA => false,
FormEvents::POST_SUBMIT => false,
];
public function __construct(
public string $name,
public array $dependencies,
public \Closure $callback,
) {
}
public function isReady(array $availableDependencyData, string $eventName): bool
{
if (!\array_key_exists($eventName, $this->callbackExecuted)) {
throw new \InvalidArgumentException(\sprintf('Invalid event name "%s"', $eventName));
}
if ($this->callbackExecuted[$eventName]) {
return false;
}
foreach ($this->dependencies as $dependency) {
if (!\array_key_exists($dependency, $availableDependencyData)) {
return false;
}
}
return true;
}
public function execute(array $availableDependencyData, string $eventName): DependentField
{
$configurableFormBuilder = new DependentField();
$this->callbackExecuted[$eventName] = true;
$dependencyData = array_map(fn (string $dependency) => $availableDependencyData[$dependency], $this->dependencies);
$this->callback->__invoke($configurableFormBuilder, ...$dependencyData);
return $configurableFormBuilder;
}
}

View File

@@ -1,587 +0,0 @@
<?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;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\Form\ClearableErrorsInterface;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormConfigInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\RequestHandlerInterface;
use Symfony\Component\Form\ResolvedFormTypeInterface;
use Symfony\Component\PropertyAccess\PropertyPathInterface;
/**
* Wraps the normal form builder & to add addDynamic() to it.
*
* @author Ryan Weaver
*/
class DynamicFormBuilder implements FormBuilderInterface, \IteratorAggregate
{
/**
* @var DependentFieldConfig[]
*/
private array $dependentFieldConfigs = [];
/**
* The actual form that this builder is turned into.
*/
private FormInterface $form;
private array $preSetDataDependencyData = [];
private array $postSubmitDependencyData = [];
public function __construct(private FormBuilderInterface $builder)
{
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event) {
$this->form = $event->getForm();
$this->preSetDataDependencyData = [];
$this->initializeListeners();
// A fake hidden field where we can "store" an error if a dependent form
// field is suddenly invalid because its previous data was invalid
// and a field it depends on just changed (e.g. user selected "Michigan"
// as a state, then the user changed "Country" from "USA" to "Mexico"
// and so now "Michigan" is invalid). In this case, we clear the error
// on the actual field, but store a "fake" error here, which won't be
// rendered, but will prevent the form from being valid.
if (!$this->form->has('__dynamic_error')) {
$this->form->add('__dynamic_error', HiddenType::class, [
'mapped' => false,
'error_bubbling' => false,
]);
}
}, 100);
$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);
}, -1);
}
public function addDependent(string $name, string|array $dependencies, callable $callback): self
{
$dependencies = (array) $dependencies;
$this->dependentFieldConfigs[] = new DependentFieldConfig($name, $dependencies, $callback);
return $this;
}
public function storePreSetDataDependencyData(FormEvent $event): void
{
$dependency = $event->getForm()->getName();
$this->preSetDataDependencyData[$dependency] = $event->getData();
$this->executeReadyCallbacks($this->preSetDataDependencyData, FormEvents::PRE_SET_DATA);
}
public function storePostSubmitDependencyData(FormEvent $event): void
{
$dependency = $event->getForm()->getName();
$this->postSubmitDependencyData[$dependency] = $event->getForm()->getData();
$this->executeReadyCallbacks($this->postSubmitDependencyData, FormEvents::POST_SUBMIT);
}
public function clearDataOnTransformationError(FormEvent $event): void
{
$form = $event->getForm();
$transformationErrorsCleared = false;
foreach ($this->dependentFieldConfigs as $dependentFieldConfig) {
if (!$form->has($dependentFieldConfig->name)) {
continue;
}
$subForm = $form->get($dependentFieldConfig->name);
if ($subForm->getTransformationFailure() && $subForm instanceof ClearableErrorsInterface) {
$subForm->clearErrors();
$transformationErrorsCleared = true;
}
}
if ($transformationErrorsCleared) {
// We've cleared the error, but the bad data remains on the field.
// 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.'));
}
}
}
private function executeReadyCallbacks(array $availableDependencyData, string $eventName): void
{
foreach ($this->dependentFieldConfigs as $dependentFieldConfig) {
if ($dependentFieldConfig->isReady($availableDependencyData, $eventName)) {
$dynamicField = $dependentFieldConfig->execute($availableDependencyData, $eventName);
$name = $dependentFieldConfig->name;
if (!$dynamicField->shouldBeAdded()) {
$this->form->remove($name);
continue;
}
$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);
}
}
}
private function initializeListeners(?array $fieldsToConsider = null): void
{
$registeredFields = [];
foreach ($this->dependentFieldConfigs as $dynamicField) {
foreach ($dynamicField->dependencies as $dependency) {
if ($fieldsToConsider && !\in_array($dependency, $fieldsToConsider)) {
continue;
}
// skip dependencies that are possibly not *yet* part of the form
if (!$this->builder->has($dependency)) {
continue;
}
if (\in_array($dependency, $registeredFields)) {
continue;
}
$registeredFields[] = $dependency;
$this->builder->get($dependency)->addEventListener(FormEvents::PRE_SET_DATA, [$this, 'storePreSetDataDependencyData']);
$this->builder->get($dependency)->addEventListener(FormEvents::POST_SUBMIT, [$this, 'storePostSubmitDependencyData']);
}
}
}
/*
* ----------------------------------------
*
* Pure decoration methods below.
*
* ----------------------------------------
*/
public function count(): int
{
return $this->builder->count();
}
/**
* @param string|FormBuilderInterface $child
*/
public function add($child, ?string $type = null, array $options = []): static
{
$this->builder->add($child, $type, $options);
return $this;
}
public function create(string $name, ?string $type = null, array $options = []): FormBuilderInterface
{
return $this->builder->create($name, $type, $options);
}
public function get(string $name): FormBuilderInterface
{
return $this->builder->get($name);
}
public function remove(string $name): static
{
$this->builder->remove($name);
return $this;
}
public function has(string $name): bool
{
return $this->builder->has($name);
}
public function all(): array
{
return $this->builder->all();
}
public function getForm(): FormInterface
{
return $this->builder->getForm();
}
public function addEventListener(string $eventName, callable $listener, int $priority = 0): static
{
$this->builder->addEventListener($eventName, $listener, $priority);
return $this;
}
public function addEventSubscriber(EventSubscriberInterface $subscriber): static
{
$this->builder->addEventSubscriber($subscriber);
return $this;
}
public function addViewTransformer(DataTransformerInterface $viewTransformer, bool $forcePrepend = false): static
{
$this->builder->addViewTransformer($viewTransformer, $forcePrepend);
return $this;
}
public function resetViewTransformers(): static
{
$this->builder->resetViewTransformers();
return $this;
}
public function addModelTransformer(DataTransformerInterface $modelTransformer, bool $forceAppend = false): static
{
$this->builder->addModelTransformer($modelTransformer, $forceAppend);
return $this;
}
public function resetModelTransformers(): static
{
$this->builder->resetModelTransformers();
return $this;
}
public function setAttribute(string $name, mixed $value): static
{
$this->builder->setAttribute($name, $value);
return $this;
}
public function setAttributes(array $attributes): static
{
$this->builder->setAttributes($attributes);
return $this;
}
public function setDataMapper(?DataMapperInterface $dataMapper = null): static
{
$this->builder->setDataMapper($dataMapper);
return $this;
}
public function setDisabled(bool $disabled): static
{
$this->builder->setDisabled($disabled);
return $this;
}
public function setEmptyData(mixed $emptyData): static
{
$this->builder->setEmptyData($emptyData);
return $this;
}
public function setErrorBubbling(bool $errorBubbling): static
{
$this->builder->setErrorBubbling($errorBubbling);
return $this;
}
public function setInheritData(bool $inheritData): static
{
$this->builder->setInheritData($inheritData);
return $this;
}
public function setMapped(bool $mapped): static
{
$this->builder->setMapped($mapped);
return $this;
}
public function setMethod(string $method): static
{
$this->builder->setMethod($method);
return $this;
}
/**
* @param string|PropertyPathInterface|null $propertyPath
*/
public function setPropertyPath($propertyPath): static
{
$this->builder->setPropertyPath($propertyPath);
return $this;
}
public function setRequired(bool $required): static
{
$this->builder->setRequired($required);
return $this;
}
public function setAction(?string $action): static
{
$this->builder->setAction($action);
return $this;
}
public function setCompound(bool $compound): static
{
$this->builder->setCompound($compound);
return $this;
}
public function setDataLocked(bool $locked): static
{
$this->builder->setDataLocked($locked);
return $this;
}
public function setFormFactory(FormFactoryInterface $formFactory): static
{
$this->builder->setFormFactory($formFactory);
return $this;
}
public function setType(?ResolvedFormTypeInterface $type): static
{
$this->builder->setType($type);
return $this;
}
public function setRequestHandler(?RequestHandlerInterface $requestHandler): static
{
$this->builder->setRequestHandler($requestHandler);
return $this;
}
public function getAttribute(string $name, mixed $default = null): mixed
{
return $this->builder->getAttribute($name, $default);
}
public function hasAttribute(string $name): bool
{
return $this->builder->hasAttribute($name);
}
public function getAttributes(): array
{
return $this->builder->getAttributes();
}
public function getDataMapper(): ?DataMapperInterface
{
return $this->builder->getDataMapper();
}
public function getEventDispatcher(): EventDispatcherInterface
{
return $this->builder->getEventDispatcher();
}
public function getName(): string
{
return $this->builder->getName();
}
public function getPropertyPath(): ?PropertyPathInterface
{
return $this->builder->getPropertyPath();
}
public function getRequestHandler(): RequestHandlerInterface
{
return $this->builder->getRequestHandler();
}
public function getType(): ResolvedFormTypeInterface
{
return $this->builder->getType();
}
public function setByReference(bool $byReference): static
{
$this->builder->setByReference($byReference);
return $this;
}
public function setData(mixed $data): static
{
$this->builder->setData($data);
return $this;
}
public function setAutoInitialize(bool $initialize): static
{
$this->builder->setAutoInitialize($initialize);
return $this;
}
public function getFormConfig(): FormConfigInterface
{
return $this->builder->getFormConfig();
}
public function setIsEmptyCallback(?callable $isEmptyCallback): static
{
$this->builder->setIsEmptyCallback($isEmptyCallback);
return $this;
}
public function getMapped(): bool
{
return $this->builder->getMapped();
}
public function getByReference(): bool
{
return $this->builder->getByReference();
}
public function getInheritData(): bool
{
return $this->builder->getInheritData();
}
public function getCompound(): bool
{
return $this->builder->getCompound();
}
public function getViewTransformers(): array
{
return $this->builder->getViewTransformers();
}
public function getModelTransformers(): array
{
return $this->builder->getModelTransformers();
}
public function getRequired(): bool
{
return $this->builder->getRequired();
}
public function getDisabled(): bool
{
return $this->builder->getDisabled();
}
public function getErrorBubbling(): bool
{
return $this->builder->getErrorBubbling();
}
public function getEmptyData(): mixed
{
return $this->builder->getEmptyData();
}
public function getData(): mixed
{
return $this->builder->getData();
}
public function getDataClass(): ?string
{
return $this->builder->getDataClass();
}
public function getDataLocked(): bool
{
return $this->builder->getDataLocked();
}
public function getFormFactory(): FormFactoryInterface
{
return $this->builder->getFormFactory();
}
public function getAction(): string
{
return $this->builder->getAction();
}
public function getMethod(): string
{
return $this->builder->getMethod();
}
public function getAutoInitialize(): bool
{
return $this->builder->getAutoInitialize();
}
public function getOptions(): array
{
return $this->builder->getOptions();
}
public function hasOption(string $name): bool
{
return $this->builder->hasOption($name);
}
public function getOption(string $name, mixed $default = null): mixed
{
return $this->builder->getOption($name, $default);
}
public function getIsEmptyCallback(): ?callable
{
return $this->builder->getIsEmptyCallback();
}
public function getIterator(): \Traversable
{
return $this->builder;
}
}