Nous allons explorer les formulaires sur Symfony à travers la création d'un système de champs imbriqués.

Base de données de test

Afin de tester notre système dans un cas réel nous allons créer 3 <select> permettant de sélectionner une région, puis un département, puis une ville. Si vous souhaitez avoir des données pour essayer le système vous pouvez utiliser data.gouv.fr (les données datent de 2012) et créer une commande Symfony pour importer le CSV au niveau de votre base de données. Pour des données plus actuelles vous pouvez utiliser le site de l'INSEE mais il vous faudra combiner plusieurs fichiers.

<?php

namespace AppBundle\Command;

use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use AppBundle\Entity\Region;
use AppBundle\Entity\Departement;
use AppBundle\Entity\Ville;

class AppCitiesCommand extends ContainerAwareCommand
{
    protected function configure()
    {
        $this
            ->setName('app:cities')
            ->setDescription('Importer les villes de france depuis un CSV')
            ->addArgument('argument', InputArgument::OPTIONAL, 'Argument description')
            ->addOption('option', null, InputOption::VALUE_NONE, 'Option description')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        /* @var $em EntityManager */
        $em = $this->getContainer()->get('doctrine')->getManager();

        // yolo
        ini_set("memory_limit", "-1");

        // On vide les 3 tables
        $connection = $em->getConnection();
        $platform   = $connection->getDatabasePlatform();
        $connection->executeUpdate($platform->getTruncateTableSQL('ville', true /* whether to cascade */));
        $connection->executeUpdate($platform->getTruncateTableSQL('region', true /* whether to cascade */));
        $connection->executeUpdate($platform->getTruncateTableSQL('departement', true /* whether to cascade */));

        $csv = dirname($this->getContainer()->get('kernel')->getRootDir()) . DIRECTORY_SEPARATOR . 'var' . DIRECTORY_SEPARATOR . 'villes.csv';
        $lines = explode("\n", file_get_contents($csv));
        $regions = [];
        $departements = [];
        $villes = [];

        foreach ($lines as $k => $line) {
            $line = explode(';', $line);
            if (count($line) > 10 && $k > 0) {
                // On sauvegarde la region
                if (!key_exists($line[1], $regions)) {
                    $region = new Region();
                    $region->setCode($line[1]);
                    $region->setName($line[2]);
                    $regions[$line[1]] = $region;
                    $em->persist($region);
                } else {
                    $region = $regions[$line[1]];
                }

                // On sauvegarde le departement
                if (!key_exists($line[4], $departements)) {
                    $departement = new Departement();
                    $departement->setName($line[5]);
                    $departement->setCode($line[4]);
                    $departement->setRegion($region);
                    $departements[$line[4]] = $departement;
                    $em->persist($departement);
                } else {
                    $departement = $departements[$line[4]];
                }

                // On sauvegarde la ville
                $ville = new Ville();
                $ville->setName($line[8]);
                $ville->setCode($line[9]);
                $ville->setDepartement($departement);
                $villes[] = $line[8];
                $em->persist($ville);
            }
        }
        $em->flush();
        $output->writeln(count($villes) . ' villes importées');
    }

}

Ajout des champs successifs

Pour commencer notre système nous allons demander à l'utilisateur de renseigner sa région. Pour cela nous allons utiliser un simple champs de type EntityType qu'il faudra désigné comme non mappé (en effet le champs n'existe pas réellement sur l'entité envoyé au formulaire).

$builder
  ->add('name')
  ->add('region', EntityType::class, [
    'class'       => 'AppBundle\Entity\Region',
    'placeholder' => 'Sélectionnez votre région',
    'mapped'      => false,
    'required'    => false
  ]);

Lorsque ce champs est remplit par l'utilisateur on doit ajouter le champs suivant pour sélectionner le département. Nous allons donc utiliser l'évènement POST_SUBMIT sur le sous-formulaire region

$builder->get('region')->addEventListener(
  FormEvents::POST_SUBMIT,
  function (FormEvent $event) {
    $form = $event->getForm();
    $this->addDepartementField($form->getParent(), $form->getData());
  }
);

Il nous faut maintenant créer la méthode addDepartementField() qui, comme son nom l'indique, va rajouter le champs département et ajouter l'évènement POST_SUBMIT sur ce nouveau champs.

private function addDepartementField(FormInterface $form, ?Region $region)
{
  $builder = $form->getConfig()->getFormFactory()->createNamedBuilder(
    'departement',
    EntityType::class,
    null,
    [
      'class'           => 'AppBundle\Entity\Departement',
      'placeholder'     => $region ? 'Sélectionnez votre département' : 'Sélectionnez votre région',
      'mapped'          => false,
      'required'        => false,
      'auto_initialize' => false,
      'choices'         => $region ? $region->getDepartements() : []
    ]
  );
  $builder->addEventListener(
    FormEvents::POST_SUBMIT,
    function (FormEvent $event) {
      $form = $event->getForm();
      $this->addVilleField($form->getParent(), $form->getData());
    }
  );
  $form->add($builder->getForm());
}

Enfin, la méthode addVilleField() va se contenter d'ajouter le champs ville au formulaire.

private function addVilleField(FormInterface $form, ?Departement $departement)
{
  $form->add('ville', EntityType::class, [
    'class'       => 'AppBundle\Entity\Ville',
    'placeholder' => $departement ? 'Sélectionnez votre ville' : 'Sélectionnez votre département',
    'choices'     => $departement ? $departement->getVilles() : []
  ]);
}

Ce système permet donc à chaque soumission du formulaire d'ajouter le champs suivant, jusqu'à avoir le champs ville rempli par l'utilisateur.

Formulaire d'édition

En revanche, ce système ne couvre pas l'édition d'une entité. Dans ce cas là nous allons utiliser l'évènement POST_SET_DATA afin de rajouter nos données departement et ville et les 2 <select> associés.

$builder->addEventListener(
  FormEvents::POST_SET_DATA,
  function (FormEvent $event) {
    $data = $event->getData();
    /* @var $ville Ville */
    $ville = $data->getVille();
    $form = $event->getForm();
    if ($ville) {
      // On récupère le département et la région
      $departement = $ville->getDepartement();
      $region = $departement->getRegion();
      // On crée les 2 champs supplémentaires
      $this->addDepartementField($form, $region);
      $this->addVilleField($form, $departement);
      // On set les données
      $form->get('region')->setData($region);
      $form->get('departement')->setData($departement);
    } else {
      // On crée les 2 champs en les laissant vide (champs utilisé pour le JavaScript)
      $this->addDepartementField($form, null);
      $this->addVilleField($form, null);
    }
  }
);

Un peu de JavaScript

Devoir soumettre le formulaire afin de dévoiler de nouveaux champs, ce n'est pas très "user-friendly". Nous allons donc rajouter une couche de JavaScript par dessus notre système afin d'automatiser le processus.

Lorsque l'on sélectionnera une région, on soumettra notre formulaire partiel en Ajax, et on récupèrera le nouveau select que l'on l'injectera dans notre formulaire actuel.

$(document).on('change', '#appbundle_medecin_region, #appbundle_medecin_departement', function () {
  let $field = $(this)
  let $regionField = $('#appbundle_medecin_region')
  let $form = $field.closest('form')
  let target = '#' + $field.attr('id').replace('departement', 'ville').replace('region', 'departement')
  // Les données à envoyer en Ajax
  let data = {}
  data[$regionField.attr('name')] = $regionField.val()
  data[$field.attr('name')] = $field.val()
  // On soumet les données
  $.post($form.attr('action'), data).then(function (data) {
    // On récupère le nouveau <select>
    let $input = $(data).find(target)
    // On remplace notre <select> actuel
    $(target).replaceWith($input)
  })
})

Conclusion

Les évènements permettent donc de créer des formulaires complexes qui se déroulent au fur et à mesure des choix de l'utilisateur.

Pour le cas présenté ici en revanche, il pourrait être intéréssant de se tourner vers une approche plus JavaScript afin d'éviter les rechargements inutils de Symfony en se contentant de renvoyer la liste des départements et ville en JSON mais cela imposerait mais il faudra alors écrire plus de JavaScript.