Validación en tiempo real y campos de formulario dependientes
Keep on Learning!
If you liked what you've learned so far, dive in! Subscribe to get access to this tutorial plus video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribePara el día 28, quiero mostrarte una de las formas más comunes en que la gente utiliza los Componentes Vivos: los formularios. Dado que los Componentes Vivos tienen la capacidad de recargarse a medida que escribes, nos ofrecen interesantes posibilidades con los formularios, ¡como la validación en tiempo real! Así que éste es el objetivo de hoy: ¡convertir el formulario Voyage en un Componente Vivo y ver por nosotros mismos una genial validación en tiempo real!
Ya tenemos un controlador que se encarga de crear el formulario Voyage y gestiona el envío. Lo que vamos a hacer es envolver la parte frontal del formulario dentro de un Componente LIVE para que, a medida que escribimos, se vuelva a renderizar. Pero al final, cuando guardemos, se guardará como siempre a través del controlador.
Trasladar el formulario a un componente Twig
Para el primer paso, olvídate de los Componentes Vivos: simplemente convirtamos la renderización del formulario en un Componente Twig. En este caso, sé que vamos a necesitar una clase PHP, así que crea una nueva llamada VoyageForm y conviértela en un Componente Twig con#[AsTwigComponent]:
| // ... lines 1 - 2 | |
| namespace App\Twig\Components; | |
| // ... lines 4 - 5 | |
| use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; | |
| class VoyageForm | |
| { | |
| // ... line 11 | |
| } |
¡Perfecto! El formulario en sí vive en templates/voyage/_form.html.twig y utiliza una variable form, que tendremos que pasar al componente Twig.
En la clase VoyageForm, añade una propiedad pública para esto: public FormView $form, porque FormView es el tipo de objeto de la variable form:
| // ... lines 1 - 4 | |
| use Symfony\Component\Form\FormView; | |
| // ... lines 6 - 7 | |
| class VoyageForm | |
| { | |
| public FormView $form; | |
| } |
A continuación, en templates/components/, crea la plantilla del componente: VoyageForm.html.twig. Copia todo el formulario y pégalo aquí:
| {{ form_start(form) }} | |
| {{ form_widget(form) }} | |
| <twig:Button | |
| formnovalidate | |
| variant="success" | |
| class="hover:animate-wiggle" | |
| > | |
| {{ button_label|default('Save') }} | |
| </twig:Button> | |
| {{ form_end(form) }} |
Y luego en _form.html.twig, es sencillo: <twig:VoyageForm />:
| <twig:VoyageForm :form="form" /> |
Y en el navegador... ¡bah! Obtenemos:
La variable
formno existe.
Pensemos en esto. Tenemos una propiedad pública en la clase componente llamadaform... por lo que deberíamos tener una variable local con ese nombre. Pero, la propiedad no está inicializada porque olvidé pasarle ese valor. ¡Culpa mía! Pasa:form="form" - utilizando : para que el valor - form - sea código Twig: ésa es la variableform:
| <twig:VoyageForm :form="form" /> |
Y ahora... ¡ya lo tengo! Antes de continuar, dentro de la plantilla, recuerda renderizar la variable attributes. Lo más fácil es envolverla en div y decir{{ attributes }}. Pondré la etiqueta de cierre... y luego sangraré todo el formulario:
| <div {{ attributes }}> | |
| {{ form_start(form) }} | |
| // ... lines 3 - 11 | |
| {{ form_end(form) }} | |
| </div> |
Así que la representación del formulario es ahora un componente Twig. Pero para darle comportamiento, necesitamos un Componente Live.
LiveComponent y formularios Symfony
Pensemos. Después de cambiar cualquier campo, quiero que un Live Component recoja el valor de cada campo y los envíe al sistema Live Component mediante una llamada Ajax. A continuación, el Componente Live enviará estos valores al objeto formulario y volverá a renderizar la plantilla.
Utilizar formularios Symfony con Live Components es un caso de uso un poco más complejo que el caso normal de Live Components: en el que creamos algunas propiedades públicas y las hacemos escribibles.
Afortunadamente, Live Component incluye un trait para ayudarte. En VoyageForm, primero, conviértelo en un Live Component diciendo #[AsLiveComponent] y luego utilizando el rasgoDefaultActionTrait:
| // ... lines 1 - 9 | |
| use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; | |
| // ... line 11 | |
| use Symfony\UX\LiveComponent\DefaultActionTrait; | |
| class VoyageForm extends AbstractController | |
| { | |
| use DefaultActionTrait; | |
| // ... lines 18 - 27 | |
| } |
A continuación, como queremos vincular este componente a un objeto formulario, utilizaComponentWithFormTrait. Cuando hagamos eso, ya no necesitaremos esta propiedad pública formporque vive dentro del rasgo:
| // ... lines 1 - 10 | |
| use Symfony\UX\LiveComponent\ComponentWithFormTrait; | |
| // ... lines 12 - 13 | |
| class VoyageForm extends AbstractController | |
| { | |
| use DefaultActionTrait; | |
| use ComponentWithFormTrait; | |
| // ... lines 20 - 27 | |
| } |
Sin embargo, este rasgo requiere un nuevo método. Ve a "Código"->"Generar" -oCmd+N en un Mac- e implementa el que necesitamos: instantiateForm():
| // ... lines 1 - 7 | |
| use Symfony\Component\Form\FormInterface; | |
| // ... lines 9 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 17 - 19 | |
| protected function instantiateForm(): FormInterface | |
| { | |
| // ... lines 22 - 26 | |
| } | |
| } |
Esto puede parecer extraño al principio. Pero recuerda que, a medida que cambiemos los campos de nuestro formulario, los valores del formulario se enviarán vía Ajax de vuelta a nuestro componente Live... que luego necesita enviarlos al objeto formulario para que pueda volver a renderizarse. Esto significa que, durante la llamada Ajax, nuestro componente Live necesita ser capaz de crear nuestro objeto formulario. Para ello, llama a este método.
Para obtener la lógica de esto, en VoyageController, abajo del todo, copia las tripas de createVoyageForm()... y pégalas aquí. Pulsa OK para añadir las dos sentencias use:
| // ... lines 1 - 4 | |
| use App\Entity\Voyage; | |
| use App\Form\VoyageType; | |
| // ... lines 7 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 17 - 19 | |
| protected function instantiateForm(): FormInterface | |
| { | |
| $voyage = $voyage ?? new Voyage(); | |
| return $this->createForm(VoyageType::class, $voyage, [ | |
| 'action' => $voyage->getId() ? $this->generateUrl('app_voyage_edit', ['id' => $voyage->getId()]) : $this->generateUrl('app_voyage_new'), | |
| ]); | |
| } | |
| } |
Hay... sólo un problema: ¡los métodos createForm() y generateUrl() no existen aquí! Pero no te he hablado de una cosa loca y genial: ¡Los Componentes Live son controladores Symfony disfrazados! Y esto significa que podemos extenderAbstractController:
| // ... lines 1 - 6 | |
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | |
| // ... lines 8 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 17 - 27 | |
| } |
Eso está totalmente permitido y nos da acceso a todos los atajos que conocemos y amamos.
Vale, ¡hora del espectáculo! Muévete. Cuando escribo, no pasa nada. En este caso, Live Components espera a que el campo cambie... así que espera a que nos movamos del campo. En cuanto lo hagamos, veremos dispararse una petición Ajax aquí abajo. Observa. ¡Bum! ¿Lo ves? Ha devuelto los datos, ha enviado el formulario y lo ha vuelto a renderizar.
Para comprobarlo, borra el campo y pulsa tabulador. ¡Un error de validación! ¡Eso viene de Symfony y de la renderización normal de validación del formulario! Vuelve a escribir algo, pulsa tabulador y desaparecerá. ¿Lo mejor? El campo planeta de aquí abajo también es obligatorio gracias a las restricciones de validación de Symfony. Pero el sistema Live Component es inteligente: sabe que el usuario aún no ha cambiado este campo, por lo que no debería mostrar el error de validación. Pero si seleccionamos un planeta... y luego borramos, cuando se vuelve a renderizar, muestra el error.
Pasar los datos iniciales del formulario
Esto también funciona bien para el formulario de edición. Pulsa editar y borra un campo.
Aunque, echa un vistazo a instantiateForm(). Hmm, siempre estamos instanciando un nuevo objeto Voyage: nunca hay una variable $voyage. Cambiamos un campo, Live Components envía una petición Ajax y, cuando crea el formulario, lo hace utilizando un objeto Voyage totalmente nuevo, no el objeto Voyage existente en la base de datos.
Y... eso probablemente esté bien... porque envía todos los datos en él, y se renderiza correctamente.
Sin embargo, una cosa que puedes hacer con los componentes Live es enviar el formulario directamente al objeto Componente y manejar allí la lógica de guardado. No vamos a hacer eso, pero si lo hiciéramos, el objeto Voyage vinculado al formulario sería siempre un objeto nuevo... y siempre insertaría una nueva fila en la base de datos.
Pasar los datos iniciales del formulario
Aunque esto funciona, es un poco raro.
Para ajustarlo, podemos almacenar el objeto Voyage existente en el componente y utilizarlo durante la creación del formulario. Añade una propiedad pública ?Voyage $initialFormData . Sobre ella, para que el sistema del componente recuerde este valor a través de todas sus peticiones Ajax, añade #[LiveProp]:
| // ... lines 1 - 10 | |
| use Symfony\UX\LiveComponent\Attribute\LiveProp; | |
| // ... lines 12 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 18 - 20 | |
| public ?Voyage $initialFormData = null; | |
| // ... lines 23 - 31 | |
| } |
Esta es ahora una propiedad no escribible de la que nuestro componente hará un seguimiento. Y sí, es no escribible: el usuario cambia directamente los datos del formulario, no esta propiedad. Esto sólo está aquí para ayudarnos a crear el objeto formulario en cada llamada Ajax.
A continuación, cambia esto a $voyage igual a $this->initialFormData, si no new Voyage():
| // ... lines 1 - 14 | |
| class VoyageForm extends AbstractController | |
| { | |
| // ... lines 18 - 20 | |
| public ?Voyage $initialFormData = null; | |
| protected function instantiateForm(): FormInterface | |
| { | |
| $voyage = $this->initialFormData ?? new Voyage(); | |
| // ... lines 27 - 30 | |
| } | |
| } |
Por último, pasa initialFormData diciendo :initialFormData="voyage", que es una variable Twig que ya tenemos:
| <twig:VoyageForm :form="form" :initialFormData="voyage" /> |
Así que no notaremos ninguna diferencia, pero cuando pulsemos editar y cambiemos un campo, esa petición Ajax creará ahora un objeto Formulario vinculado a este objeto Voyage existente.
Esto es un poco técnico, pero ampliémoslo. Al renderizar nuestro formulario a través de un Componente Vivo, ¡obtenemos validación en tiempo real gratis! Es genial.
Campos de formulario dependientes
Casi no nos queda tiempo, pero creo que hoy podemos abordar otro problema de formularios. De hecho, quizá el problema de formularios más doloroso de todo Symfony.
En este formulario, si el planeta no está en nuestro sistema solar, quiero mostrar un nuevo desplegable para una mejora opcional del agujero de gusano. Este es el clásico problema de campo de formulario dependiente. En Symfony, es difícil porque necesitamos aprovechar los eventos del formulario. En el frontend también es difícil Históricamente, necesitábamos escribir JavaScript para activar una llamada Ajax para volver a renderizar el formulario.
Pero... ¡ahora ya no es necesario! Live Components es genial para volver a mostrar el formulario cuando cambian los campos. ¿Y la primera parte? Sí, ¡hay una nueva biblioteca que también lo hace fácil!
Se llama symfonycasts/dynamic-forms... creada por nosotros porque este problema me volvía absolutamente loco. Gracias a Ben Davies, desarrollador de Symfony, que ha descifrado el código.
Copia la línea "composer require", dale la vuelta y ejecútalo:
composer require symfonycasts/dynamic-forms
Usar esto es realmente agradable. Busca la clase de formulario: src/Form/VoyageType.php. La biblioteca utiliza decoración. En la parte superior, di que $builder es igual anew DynamicFormBuilder() y pasa a $builder:
| // ... lines 1 - 12 | |
| use Symfonycasts\DynamicForms\DynamicFormBuilder; | |
| class VoyageType extends AbstractType | |
| { | |
| public function buildForm(FormBuilderInterface $builder, array $options): void | |
| { | |
| $builder = new DynamicFormBuilder($builder); | |
| // ... lines 20 - 52 |
Este DynamicFormBuilder tiene los mismos métodos que el original, pero uno extra:addDependent(). Pero antes de utilizarla, comenta el'autocomplete' => true:
| // ... lines 1 - 12 | |
| use Symfonycasts\DynamicForms\DynamicFormBuilder; | |
| class VoyageType extends AbstractType | |
| { | |
| public function buildForm(FormBuilderInterface $builder, array $options): void | |
| { | |
| $builder = new DynamicFormBuilder($builder); | |
| $builder | |
| // ... lines 21 - 24 | |
| ->add('planet', null, [ | |
| // ... lines 26 - 27 | |
| //'autocomplete' => true, | |
| ]) | |
| // ... lines 30 - 41 | |
| ; | |
| } | |
| // ... lines 44 - 50 | |
| } |
Hay un error con el sistema de autocompletar y Live Components. Debería solucionarse pronto, pero no quiero que estorbe.
De todos modos, el método addDependent() recibe tres argumentos. El primero es el nombre del nuevo campo: wormholeUpgrade. El segundo es una matriz de campos de los que depende este campo. En este caso, sólo es planet. El último argumento es una función de devolución de llamada y su primer argumento siempre será un objetoDependentField. Veremos cómo se utiliza en un minuto. Entonces, ésta recibirá el valor de cada campo del que dependa. Como sólo dependemos de planet, la llamada de retorno lo recibirá como argumento: ?Planet $planet :
| // ... lines 1 - 12 | |
| use Symfonycasts\DynamicForms\DynamicFormBuilder; | |
| class VoyageType extends AbstractType | |
| { | |
| public function buildForm(FormBuilderInterface $builder, array $options): void | |
| { | |
| $builder = new DynamicFormBuilder($builder); | |
| $builder | |
| // ... lines 21 - 24 | |
| ->add('planet', null, [ | |
| // ... lines 26 - 27 | |
| //'autocomplete' => true, | |
| ]) | |
| ->addDependent('wormholeUpgrade', ['planet'], function (DependentField $field, ?Planet $planet) { | |
| // ... lines 31 - 40 | |
| }) | |
| ; | |
| } | |
| // ... lines 44 - 50 | |
| } |
Dentro, si no tenemos un planeta -porque el usuario aún no lo ha seleccionado o el planeta está en la Vía Láctea-, simplemente devuelve. Y sí, me he equivocado con la ciencia espacial: Quería que fuera isInOurSolarSystem() - no la vía láctea ¡Perdóname Data!
De todos modos, como estamos regresando, no habrá ningún campo wormholeUpgrade. Si no, añade uno con $field->add(). Este método es idéntico al método normal add(), salvo que no necesitamos pasar el nombre del campo... porque ya lo pasamos antes. Así que pasa directamente aChoiceType::class... y luego a las opciones con choices configurado como una matriz de "Sí" para verdadero, y "No" para falso:
| // ... lines 1 - 7 | |
| use Symfony\Component\Form\Extension\Core\Type\ChoiceType; | |
| // ... lines 9 - 14 | |
| class VoyageType extends AbstractType | |
| { | |
| public function buildForm(FormBuilderInterface $builder, array $options): void | |
| { | |
| // ... line 19 | |
| $builder | |
| // ... lines 21 - 29 | |
| ->addDependent('wormholeUpgrade', ['planet'], function (DependentField $field, ?Planet $planet) { | |
| if (!$planet || $planet->isInMilkyWay()) { | |
| return; | |
| } | |
| $field->add(ChoiceType::class, [ | |
| 'choices' => [ | |
| 'Yes' => true, | |
| 'No' => false, | |
| ], | |
| ]); | |
| }) | |
| ; | |
| } | |
| // ... lines 44 - 50 | |
| } |
¡Listo! Comprueba el resultado. Actualiza, edita y cambia a un planeta que no esté en nuestro sistema. ¡Ahí lo tienes! ¡El campo ha aparecido! Si volvemos a un planeta que esté en nuestro sistema solar... ¡desapareció! Y... el campo se guarda perfectamente. Cuando editamos el viaje, el formulario se inicia con él. ¡Funciona!
Vale, ¡ya casi hemos llegado al final de nuestro viaje de 30 días! Mañana toca hablar de cómo podemos probar nuestras nuevas y bonitas funciones del frontend.
15 Comments
Has the bug mentioned at 9:25 been resolved? That use case is exactly what I'm currently trying to achieve and could have used some guidance.
Hi @Seriousedy
Sorry for the late answer, as I see from git updates, that should be already fixed in
symfony/ux-autocompletepackage, however, we are not applying such fixed to course code. So it's up to you to test it 😉Cheers1
Hey Ryan. Thanks again for another great tutorial. I just updated an app with a lot of form events, Ajax calls, and custom stimulus controllers, because of many dependent form fields and many kind of complex CollectionType fields.
Now I got rid of a lot of boilerplate code, and I am very happy to use LiveComponents more in the future.
For me, as a more backend-focused full-stack developer, it is a very nice technology. And now we even got to choose between multiple technologies that do in this direction (live-components, turbo, htmx as the most prominent ones).
I am just left with one question.
I am using live-components in combination with "SymfonyCasts/dynamic-forms" and it works perfect. But with my old custom setup, I knew exactly when I dynamically added dependent form fields or added/removed new items of a CollectionType, and in these cases manually added css classes to animate the newly added items/rows, so that it is smoother from a ux perspective.
How can I react to new dependent rows/fields of my form that get added or removed, or the same for items of my LiveCollection?
I know (from the awesome docs) there are hooks like "render:finished" which I am already using to get the Trix editor working with live components, but I am not sure what is the best ans most easy way to detect newly added or removed items and to add my css classes to those. Any ideas?
Hey @TristanoMilano
Happy to hear about the stack you are using. That is a proof that we are working in the right direction! About your question, I think this section of docs should help you https://symfony.com/bundles/ux-live-component/current/index.html#javascript-component-hooks. espesially
loading.state:hooks, give a try =)Cheers!
Hi everyone, a great tool for dependent form fields - I'm thrilled :)
I have the problem that dependent fields are no longer recognized as mandatory fields. I work with Turbo and LiveComponents. Displaying and hiding the dependent fields works wonderfully. But the "new" dependent fields are not recognized as mandatory fields if they are displayed.
Is there a solution?
thank you so much! cheers :)
Hey @creativx007!
Sorry for the slow reply: the team kept this message for me and I've been missing!
Hmm. This "should" just come down to validation. I'd use the
Callbackconstraint: https://symfony.com/doc/current/reference/constraints/Callback.html. This would allow you to mark certain fields asNotBlankIF some other field has a certain value.Way late, but I hope this helps you or someone else.
Cheers!
Hello people form SC! I have a question on Real Time validation with Live Components. I have a form and validation is going ok. But as soon as i add a field with the DateType::class i get an error. The error occurs when i fill in an other field. But I expect that the DateTime field doesn't throw an error because i didn't changed it yet.
I tried to change the DateType field to the default values but the error keeps popping. It doesn't happen on the other required fields (nullable=false in the entity class).
The formbuilder:
The fields in the entity class:
The error:
Hey @lexhartman!
I think you're close to the solution :). In this case, the error isn't really a validation error: it's a PHP error. When the
executedAtfield is submitted empty, it's converted tonulland the form system tries to call$yourObject->setExecutedAt(null). This happens even before validation is executed. I can see that your$executedAtproperty does allownull. But is it possible that yoursetExecutedAt()method does not allownull? It should look like this:Let me know if this was the issue :).
Cheers!
Yes! That was it 😅
Hi Ryan, I have a quick question on Real Time validation.
My form has a field which is a LiveCollectionType. I copied your example from the symfony ux demo page, and it works awesomely. However I'm having one weird issue with real time validation.
The validation on the embedded form type works perfectly, exactly how I would expect. However on the actual collection type, I have a constraint with a
new Count(min: 1). Now, if I remove the last embedded object from the collection (or don't add any in the first place), I don't get any real time validation on this field. I can live with that, because when I post the form I will get the error displayed then. When I add to the collection, the error goes away in real time. Perfect!But here's my issue. When this form has been posted (with zero in the collection causing the error), and now I press the 'add to collection' button, as I said the error will go away, however the embedded form is validating straight away, and since the field inside the collection is currently empty, I am getting the validation error. I would expect to NOT see a validation error until the user types something, deletes it, then tabs away. Every new embedded form added to the collection will have this issue, until the entire page is reloaded.
Did I miss something obvious?
Yo @Scott-D!
Sorry my slow reply! First, I don't use
LiveCollectionTypepersonally, so my knowledge is a bit limited. I really need to play with it more just so I can speak about it and, possibly, improve it if needed.Does this problem only happen if the entire form has been posted with zero items? Or does it happen after you submit the form in general (i.e. even if you have 2 items in the collection, after submitting, you will see the problem when you add a 3rd item)? Are you submitting via a
LiveAction?A key to making the auto-validation work is that LiveComponents keeps track of which fields have been modified by the user. Then, when you submit, it's careful to remove any errors from fields that have not yet been touched by the user. Otherwise, EVERY field would be validated the first time the live component re-renders, even though the user has only completed one field. There is even a
LivePropthat tracks which fields have been modified - https://github.com/symfony/ux/blob/2.x/src/LiveComponent/src/ComponentWithFormTrait.php#L63However, when you submit the form, now you want EVERY field to be validated. In this case, we stop keeping track of
validatedFieldsand instead mark the entire form as needing validation. You can see that here - https://github.com/symfony/ux/blob/2.x/src/LiveComponent/src/ComponentWithFormTrait.php#L88-L91 and here https://github.com/symfony/ux/blob/2.x/src/LiveComponent/src/ComponentWithFormTrait.php#L158-L163 (depending on if you're submitting your form via a normal controller vs a LiveAction).So I think that's what's happening. You're submitting your form, so
isValidatedbecomes true and every field going forward is validated. I don't have an exact solution for you, but given this info, there may be a workaround you can do (and perhaps that workaround should be in the core library itself). For example, you may, before rendering (e.g. via a PreRender hook), grab your form object and manually clear the errors for any collection fields that look blank to you (see https://github.com/symfony/ux/blob/2.x/src/LiveComponent/src/ComponentWithFormTrait.php#L271-L280 for some info on how you can clear an error).Let me know if that helps!
Cheers!
Hey Ryan, Thanks for such a detailed response!
Only in the first case. The 2nd case works perfectly, exactly as I would expect. If I edit an existing resource that has two items, then go to add a 3rd, there will be no immediate validation on that new embedded form.
So to reproduce:
As soon as I type into the required fields and tab away, those errors will go away in real time.
No, I'm not. I pretty much followed your example here to a T. In fact, I didn't even know about
LiveAction. I will definitely take a closer look at that.Thanks Ryan, I'll give this a shot!
Ryan,
I have these fields:
All works really well on a new form, but when I render the form for an 'edit', these three fields don't have their initial data. All my other form fields do, but not these three. So one would have to re-select these fields on an edit, even if they don't want to change them. I can not figure out why, any suggestions?
Yo @Brandon!
Hmm, interesting. This video also shows an edit form: and both of the fields DO have their value. What's interesting to me is this:
The key parts is: three fields. If truly all 3 fields are missing their data, that's significant because the first field -
selectedCategory- is not a dependent field: this is a plain, boring, normal field. So if this is empty on edit, double-check that the underlying data is, in fact set. This may not be a problem with the dynamic forms at all.Cheers!
Ryan,
I made an oversight, I wasn't saving the selectedCategory in the database, and I had no code to reverse back to it from the selectedGroup. Thank you for all your help, I wish you all the best. You have helped me so much in my journey.
"Houston: no signs of life"
Start the conversation!