Symfony: Form Events y Select2

En el artículo de hoy quiero mostraros la forma en que podemos crear un formulario sencillo que emplee un SelectType con opciones dinámicas las cuales son obtenidas desde un servicio externo.

Para ello, emplearemos la librería select2 de Javascript/jQuery, la cual es bastante sencilla de integrar y nos da prácticamente todo hecho en cuanto al front se refiere:

En lo que respecta al backend, éste tiene algo más de miga, ya que tendremos que familiarizarnos con los FormEvent de Symfony. Esto se debe a que inicialmente el SelectType que definiremos en nuestro formulario no tendrá ninguna opción (recordad que las extraeremos de un servicio externo) lo cual provocará un error de validación cuando enviemos el formulario (pues Symfony no reconocerá la opción que hayamos escogido en el select mediante el autocompletado). Pero veámoslo paso a paso.

0. Cosas a tener en cuenta

Lo primero de todo y por simplificar un poco el artículo no emplearé entidades, aunque una vez entendido el concepto de este artículo, ese caso de uso lo podremos resolver fácilmente.

Además, emplearé la libreríawebpack encore para gestionar los assets y daré por hecho la existencia de un servicio externo / API que nos devuelve las opciones del select de nuestro formulario.

Dicho esto, ¡vamos a lo interesante!

1. Integrando Select2

En primer lugar creamos un AbstractType en Symfony que cuyo padre será el tipo ChoiceType de modo que podamos usar nuestro nuevo componente en cualquier formulario como si de un Choice se tratase.

El código de este componente es el siguiente:

Sencillo, ¿verdad? Tal y como comentaba, el método getParent devuelve la clase ChoiceType para informar a Symfony de dónde hereda este nuevo tipo. Además, definiremos dos opciones extra para él en forma de atributo:

  • data-autocomplete-url , el cual nos servirá para que podamos acceder desde el front a la url de la API y así podérsela pasar a la librería Select2
  • Y estableceremos la clase que el componente llevará por defento de mofo que posteriormente podamos referenciarla mediante javascript y así emplear la librería select2 sobre el mismo.

Finalmente, en el método buildView añadiremos estos dos atributos para que cuando rendereemos el componente en Twig aparezcan dentro de la etiqueta select :

<select id="form_job" name="form[job]" data-autocomplete-url="/jobs" class="select2 form-control"></select>

Por otra parte, crearemos un archivo Javascript con el siguiente contenido:

Nada complicado como podéis ver. En la línea 4 mediante jQuery (sí, por desgracia es una dependencia de la librería) referenciamos nuestro select (que llevará la clase select2 ) y de él extraremos el atributo autocomplete-url para pasárselo a la librería Select2.

Nota. Si os fijáis, en la línea 10 estoy pasando una función para la opción processResults ya que mi librería devuelve un json con el siguiente aspecto:

[
  {
    id: 1,

name: 'name'
  },
  { 
   ...
  }
]

pero Select2 nos pide que las opciones las pasemos con el siguiente formato:

{id: id, text: 'text'}

La opción processResults nos permite llevar a cabo ese cambio sobre los resultados obtenidos.

2. Usando el type Select2Type

Una vez que hemos creado nuestro tipo Select2Type definiremos un formulario sencillo que lo emplee:

En este gist podéis ver cómo empleo nuestro tipo Select2Type pasándole la url de la API de donde obtendremos los resultados para el autocompletado. Es por eso que estoy inyectando en el formulario el servicio Router .

Además, estoy usando una clase básica llamada Person con una única propiedad llamada job que es la que queremos autocompletar con la información del servidor. Por lo demás el resto del código creo que es bastante sencillo.

Este formulario lo podremos crear en cualquier controlador de la forma habitual y renderearlo en twig mediante {{ form(form) }} . Lo que sí es importante recordar es que en el archivo twig donde vayáis a usar el formulario os acordéis de incluir el archivo Javascript del punto anterior para que el autocompletado funcione correctamente.

Si todo ha ido bien tendréis el autocompletado funcionando sin mayor problema… Hasta el momento en que pulséis sobre Enviar y el formulario no valide…

3. FormEvents al rescate

¿Y cómo es que no valida? Bien, Symfony es bastante exigente en lo que a los ChoiceType se refiere. Esto quiere decir que, si durante el proceso de validación detecta que hemos enviado una opción que no estaba previamente en el atributo choices (línea 24) saltará un error. Por suerte hay una solución para ésto y es aquí donde entran los FormEvents , algo que yo creo que es bastante desconocido cuando trabajamos con formularios pero que sin embargo nos permiten resolver casos como el de este artículo.

Lo que vamos a hacer contado en palabras

Sabiendo la causa del problema, nuestro yo programador nos dice que debería haber alguna manera de pasar al formulario PersonFormType la opción procedente del autocompletado en el momento de validarlo, para que así Symfony la reconozca como válida.

Justo eso es lo que nos permiten los FormEvents . Para nuestro caso concreto, querremos emplear el evento FormEvents:PRE_SUBMIT el cual se ejecuta justo antes de normalizar los datos enviados por el usuario (el proceso de normalización es el que nos permite por ejemplo, en un campo de tipo EntityType convertir un id en una entidad) y que nos permite modificar los campos del formulario para, por ejemplo, añadirles opciones:

Wowwwwwww, estaréis pensando si nunca habéis visto nada similar trabajando con formularios. Por pasos:

  • En la línea 22 obtenemos el valor del formulario (el cual será un objeto del tipo Person ) por lo que podremos rellenar el atributo choices de nuestro Select2Type en el caso de que estemos editando un objeto Person .
  • En la linea 35 estoy definiendo un callable al más puro estilo Javascript (sí, podemos asignar funciones a variables en PHP). En breves momentos entenderéis lo que hace.
  • En la línea 46 le digo al $builder del formulario que vamos a escuchar el evento PRE_SUBMIT de modo que al recibirlo podamos ejecutar nuestro callable anterior (por cierto, necesitamos emplear la instrucción use para poderle pasar nuestro callable $formModidier . Ahora viene lo importante.
  • Cuando el evento PRE_SUBMIT tiene lugar (que es durante la ejecución del método handleSubmit que invocamos en nuestro controlador después de crear el formulario) ya disponemos de los datos que ha enviado el usuario. Así que Symfony nos los envía por medio del argumento FormEvent event y su método getData en la función invocada al recibir este evento.
  • Es por ello que, en la línea 50 accedo a la propiedad job del array $data para obtener dicho valor.
Vamos a hacer una parada técnica para entenderlo todo bien y seguimos. Suponed que hemos pintado nuestro formulario y el usuario ha usado el autocompletar para obtener el objeto { id: 1, name: 'desarrollador'} y a continuación ha pulsado enviar. La libreríaSelect2 habrá ya entonces rellenado el valor del <select name="form_job" ... con el valor 1 y lo recibiremos en nuestro controlador.
Cuando llegamos a nuestro controlador, el formulario se vuelve a crear, se ejecuta el método $form->handleRequest($request) y se invoca el evento PRE_SUBMIT . Como nos habíamos suscrito a él, el argumento $event nos permite acceder al valor de la propiedad job , es decir, obtendremos el 1 correspondiente al id del trabajo desarrollador . Por tanto, invocamos nuestro callable $formModifier con el propio formulario y con el valor 1 como segundo argumento.

Sigamos entonces.

  • A nuestro callable $formModifier (el cual puede tener la forma que queramos) le pasaremos como primer argumento el propio formulario que nos ocupa (el cual abstraemos mediante la clase FormInterface ) y como segundo el jobId .
  • Dentro de él, definimos nuestro campo job como un Select2Type pero dado que ahora tenemos el $jobId (recordad que estamos en el evento PRE_SUBMIT cuando ya el usuario nos ha enviado los datos) se lo podemos pasar como valor para el atributo choices de modo que el formulario valide correctamente.

Con ello, ya tendremos resuelto el problema de la validación y podremos rellenar nuestro formulario normalmente.

4. Bola extra

En el caso de que estéis componiendo formularios existe un problema con el código anterior.

Es decir, si estáis empleando el formulario anterior para editar la propiedad person de otro objeto:

$builder->add('person', PersonFormType::class, ['label' => 'Person']);

el método $builder->getData() dentro de nuestra clase PersonFormType devolverá null por la forma en que Symfony gestiona ese tipo de formularios:

Para estos casos, es necesario recurrir al evento PRE_SET_DATA de modo que podamos definir el formulario correctamente con los valores por defecto:

Ahora el $builder no añade ningún campo al inicio sino que espera al evento PRE_SET_DATA para definir el campo job como un Select2Type . En el caso de que estemos editando un objeto Person (y por tanto, ya tenga un valor para su propiedad job ), en dicho evento podremos obtener el valor de esa propiedad y pásarselo al callable $formModifier para que establezca como choices un array con dicho valor y que así aparezca seleccionado del modo en que lo hacía en el caso anterior.

Esto realmente es algo farragoso pero no parece existir otra alternativa según la documentación por lo que conviene tenerlo en cuenta para evitar algún que otro quebradero de cabeza.

Finalmente os dejo un par de imágenes donde se explica el orden de los eventos y la forma en que Symfony trabaja con los valores de los formularios. La verdad es que son muy ilustrativas: