Aprende a implementar una selección dependiente en un formulario de Symfony 3.

Trabajar con Symfony Forms es bastante fácil, asombroso y funcional. Ya resuelven muchos problemas que teóricamente puedes ignorar porque solo necesitas ocuparte de construirlos. Para las personas perezosas como yo, hay algo incluso asombroso, la generación de formularios CRUD (crear, actualizar y eliminar) a partir de una entidad. Esto crea un módulo completo con un controlador y visualiza correctamente que está listo para usar. Aunque la compilación automática resuelve muchos problemas, lamentablemente no existe una función de selección dependiente automática por defecto en Symfony, lo que significa que tendrás que implementarla tú mismo.

De acuerdo con el diseño de una base de datos, necesitará una selección dependiente en su formulario para mostrar al usuario solo las filas que están relacionadas con otra selección en el mismo formulario. El ejemplo más típico en la vida real (y más fácil de entender) es el Person,  Cityy la relación Neighborhood. Considere las siguientes entidades, la primera es Person.php:

Nota

Omitiremos los captadores y definidores en la clase para hacer el artículo más corto, sin embargo, obviamente deben existir.

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Person
 *
 * @ORM\Table(name="person", indexes={@ORM\Index(name="city_id", columns={"city_id"}), @ORM\Index(name="neighborhood_id", columns={"neighborhood_id"})})
 * @ORM\Entity
 */
class Person
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="bigint")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255, nullable=false)
     */
    private $name;

    /**
     * @var string
     *
     * @ORM\Column(name="last_name", type="string", length=255, nullable=false)
     */
    private $lastName;

    /**
     * @var \AppBundle\Entity\City
     *
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\City")
     * @ORM\JoinColumns({
     *   @ORM\JoinColumn(name="city_id", referencedColumnName="id")
     * })
     */
    private $city;

    /**
     * @var \AppBundle\Entity\Neighborhood
     *
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\Neighborhood")
     * @ORM\JoinColumns({
     *   @ORM\JoinColumn(name="neighborhood_id", referencedColumnName="id")
     * })
     */
    private $neighborhood;
}

Toda persona que pueda registrarse en el sistema necesita vivir en una Ciudad de nuestra base de datos y también vivir en un Barrio específico de nuestra base de datos, por lo que la entidad City.php:

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * City
 *
 * @ORM\Table(name="city")
 * @ORM\Entity
 */
class City
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="bigint")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255, nullable=false)
     */
    private $name;
}

Y la entidad Neighborhood.php:

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Neighborhood
 *
 * @ORM\Table(name="neighborhood", indexes={@ORM\Index(name="city_id", columns={"city_id"})})
 * @ORM\Entity
 */
class Neighborhood
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="bigint")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    private $id;

    /**
     * @var string
     *
     * @ORM\Column(name="name", type="string", length=255, nullable=false)
     */
    private $name;

    /**
     * @var \AppBundle\Entity\City
     *
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\City")
     * @ORM\JoinColumns({
     *   @ORM\JoinColumn(name="city_id", referencedColumnName="id")
     * })
     */
    private $city;
}

Las Entidades son totalmente válidas y no necesitamos modificarlas en absoluto durante este tutorial , sin embargo son útiles para entender lo que vamos a hacer. Si genera un Formulario automáticamente a partir de esas entidades, en el campo Vecindario del Formulario de Persona, encontrará todos los barrios sin filtrar solo los Barrios que pertenecen a la Ciudad seleccionada:

Dependent Select Implementation in Symfony 3

Es por eso que necesitamos implementar una selección dependiente, por lo que cuando el usuario selecciona, por ejemplo, San Francisco como su ciudad, en la selección de vecindario debe encontrar solo los 2 vecindarios que pertenecen a San Francisco (Treasure Island y Presidio de San Francisco). Filtrar la consulta en FormType es fácil, sin embargo, esto también debe hacerse dinámicamente con JavaScript, por lo que esto se puede lograr fácilmente siguiendo estos pasos:

1. Configure FormType correctamente

La lógica para crear una selección dependiente es la siguiente, inicialmente, la selección de Barrio (dependiente) estará vacía, hasta que el usuario seleccione una Ciudad, utilizando el ID de la Ciudad seleccionada debe cargar las nuevas opciones en la selección de barrio. Sin embargo, si está editando el formulario de Persona, el vecindario seleccionado debería aparecer seleccionado automáticamente sin necesidad de JavaScript en la vista de edición. Es por eso que necesita modificar el FormType de su formulario, en este caso el PersonType. Para comenzar, debe adjuntar 2 detectores de eventos al formulario que se ejecutan cuando se activan los eventos PRE_SET_DATAy  PRE_SUBMITel formulario. Dentro de los eventos verificará si hay una Ciudad seleccionada en el formulario o no, si la hay, la enviará como argumento al método addElements.

El método addElements espera como segundo argumento que la entidad City (o nula) decida qué datos se van a representar en la selección de Barrio:

Nota

FormType necesita recibir el Entity Manager en el constructor, ya que deberá realizar algunas consultas en el interior. Dependiendo de tus versiones de Symfony, esto lo hace automáticamente Autowiring , si no se hace automáticamente, es posible que debas pasarlo como argumento en el constructor en los controladores donde se transmite la clase.

<?php

namespace AppBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

// 1. Incluir espacios de nombres obligatorios
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Doctrine\ORM\EntityManagerInterface;

// Tu entidad
use AppBundle\Entity\City;

class PersonType extends AbstractType
{
    private $em;
    
    /**
     * El tipo requiere EntityManager como argumento en el constructor. Es autowired
     * en Symfony 3.
     * 
     * @param EntityManagerInterface $em
     */
    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }
    
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // 2. Elimine la selección dependiente del buildForm original, ya que será
        // agregado dinámicamente más tarde y el activador también
        $builder->add('name')
                ->add('lastName');
        
        // 3. Agregue 2 detectores de eventos para el formulario
        $builder->addEventListener(FormEvents::PRE_SET_DATA, array($this, 'onPreSetData'));
        $builder->addEventListener(FormEvents::PRE_SUBMIT, array($this, 'onPreSubmit'));
    }
    
    protected function addElements(FormInterface $form, City $city = null) {
        // 4. Agregar el elemento de provincia
        $form->add('city', EntityType::class, array(
            'required' => true,
            'data' => $city,
            'placeholder' => 'Select a City...',
            'class' => 'AppBundle:City'
        ));
        
        // Barrios vacíos, a menos que haya una ciudad seleccionada (Editar vista)
        $neighborhoods = array();
        
        // Si hay una ciudad almacenada en la entidad Persona, cargue los vecindarios de la misma
        if ($city) {
            // Obtener Vecindarios de la ciudad si hay una ciudad seleccionada
            $repoNeighborhood = $this->em->getRepository('AppBundle:Neighborhood');
            
            $neighborhoods = $repoNeighborhood->createQueryBuilder("q")
                ->where("q.city = :cityid")
                ->setParameter("cityid", $city->getId())
                ->getQuery()
                ->getResult();
        }
        
        // Agregue el campo Vecindarios con los datos adecuados
        $form->add('neighborhood', EntityType::class, array(
            'required' => true,
            'placeholder' => 'Select a City first ...',
            'class' => 'AppBundle:Neighborhood',
            'choices' => $neighborhoods
        ));
    }
    
    function onPreSubmit(FormEvent $event) {
        $form = $event->getForm();
        $data = $event->getData();
        
        // Busque la ciudad seleccionada y conviértala en una entidad
        $city = $this->em->getRepository('AppBundle:City')->find($data['city']);
        
        $this->addElements($form, $city);
    }

    function onPreSetData(FormEvent $event) {
        $person = $event->getData();
        $form = $event->getForm();

        // Cuando creas una nueva persona, la ciudad siempre está vacía
        $city = $person->getCity() ? $person->getCity() : null;
        
        $this->addElements($form, $city);
    }
    
    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'AppBundle\Entity\Person'
        ));
    }

    /**
     * {@inheritdoc}
     */
    public function getBlockPrefix()
    {
        return 'appbundle_person';
    }
}

Con solo configurar correctamente el FormType, si intenta crear una nueva Persona, el campo Vecindario estará vacío y no podrá guardar nada todavía y si intenta editar una persona, verá que solo el campo Vecindario carga los barrios relacionados con la ciudad seleccionada.

2. Cree un punto final para mostrar los barrios de una ciudad de forma dinámica.

Como siguiente paso, debe crear un punto final accesible ajax que devuelva los vecindarios de una ciudad (la identificación de la ciudad se envía a través de un parámetro de obtención, a saber, cityid), por lo que puede crearlo donde quiera y como quiera , en este ejemplo decidimos escribirlo en el mismo controlador Person:

<?php

namespace AppBundle\Controller;

use AppBundle\Entity\Person;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;

// Incluir respuesta JSON
use Symfony\Component\HttpFoundation\JsonResponse;

/**
 * Controlador de Person .
 *
 */
class PersonController extends Controller
{
    // Resto de su controlador original


    /**
     * Devuelve una cadena JSON con los vecindarios de la ciudad con la identificación proporcionada.
     * 
     * @param Request $request
     * @return JsonResponse
     */
    public function listNeighborhoodsOfCityAction(Request $request)
    {
        // Obtener administrador de entidades y repositorio
        $em = $this->getDoctrine()->getManager();
        $neighborhoodsRepository = $em->getRepository("AppBundle:Neighborhood");
        
        // Busque los barrios que pertenecen a la ciudad con el ID dado como parámetro GET "cityid"
        $neighborhoods = $neighborhoodsRepository->createQueryBuilder("q")
            ->where("q.city = :cityid")
            ->setParameter("cityid", $request->query->get("cityid"))
            ->getQuery()
            ->getResult();
        
        // Serializar en una matriz los datos que necesitamos, en este caso solo el nombre y la identificación
        // Nota: también puede usar un serializador, para fines explicativos, lo haremos manualmente
        $responseArray = array();
        foreach($neighborhoods as $neighborhood){
            $responseArray[] = array(
                "id" => $neighborhood->getId(),
                "name" => $neighborhood->getName()
            );
        }
        
        // Matriz de retorno con estructura de los barrios de la identificación de ciudad proporcionada
        return new JsonResponse($responseArray);

        // e.g
        // [{"id":"3","name":"Treasure Island"},{"id":"4","name":"Presidio of San Francisco"}]
    }
}

En este proyecto, nuestras rutas se definen mediante un archivo yml (routing.yml) y la ruta se verá así:

# AppBundle/Resources/config/routing/person.yml
person_list_neighborhoods:
    path:     /get-neighborhoods-from-city
    defaults: { _controller: "AppBundle:Person:listNeighborhoodsOfCity" }
    methods:  GET

Una vez que el punto final está disponible, puede probarlo manualmente accediendo a la ruta. Lo importante es que el controlador necesita devolver una respuesta JSON con la matriz que contiene los barrios que pertenecen a la ciudad deseada.

3. Escriba JavaScript para gestionar el cambio de ciudad

Como último paso, debemos hacer que cuando el usuario cambie de Ciudad, los barrios se actualicen con los datos del controlador creado anteriormente. Para ello, deberá escribir su propio JavaScript y realizar una solicitud AJAX al punto final creado anteriormente. Esta parte depende totalmente de los frameworks JS que use o de la forma en que le gusta trabajar con JavaScript. Para que nuestro ejemplo sea universal, usaremos jQuery.

La lógica debe colocarse en ambas vistas del formulario (nuevo y editar), por ejemplo, en nuestro, new.html.twig el código será:

{# views/new.html.twig #}
{% extends 'base.html.twig' %}

{% block body %}
    <h1>Person creation</h1>

    {{ form_start(form) }}
        {{ form_widget(form) }}
        <input type="submit" value="Create" />
    {{ form_end(form) }}

    <ul>
        <li>
            <a href="{{ path('person_index') }}">Back to the list</a>
        </li>
    </ul>
            
{% endblock %}

{% block javascripts %}
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
    <script>
        $('#appbundle_person_city').change(function () {
            var citySelector = $(this);
            
            // Solicite los barrios de la ciudad seleccionada.
            $.ajax({
                url: "{{ path('person_list_neighborhoods') }}",
                type: "GET",
                dataType: "JSON",
                data: {
                    cityid: citySelector.val()
                },
                success: function (neighborhoods) {
                    var neighborhoodSelect = $("#appbundle_person_neighborhood");

                    // Eliminar opciones actuales
                    neighborhoodSelect.html('');
                    
                    // Valor vacío...
                    neighborhoodSelect.append('<option value> Select a neighborhood of ' + citySelector.find("option:selected").text() + ' ...</option>');
                    
                    
                    $.each(neighborhoods, function (key, neighborhood) {
                        neighborhoodSelect.append('<option value="' + neighborhood.id + '">' + neighborhood.name + '</option>');
                    });
                },
                error: function (err) {
                    alert("An error ocurred while loading data ...");
                }
            });
        });
    </script>
{% endblock %}

Si todo se implementó correctamente, cuando el usuario intente crear un nuevo registro con el formulario, se cargarán los barrios de la ciudad seleccionada cuando cambie la selección. Además, gracias a los Eventos de formulario de Symfony, los vecindarios se cargarán automáticamente en el campo del lado del servidor cuando el usuario edite el formulario:

Dependent Select Symfony 3

Que te diviertas ❤️!


Interesado en la programación desde los 14 años, Carlos es un programador autodidacta, fundador y autor de la mayoría de los artículos de Our Code World.

Conviertete en un programador más sociable

Patrocinadores