Symfony2 – Génération dynamique de formulaire en fonction de l’entitée liée

La génération dynamique de formulaires est assez peu documentée sur le site de symfony2.
Une entrée dans le cookbook nous permet quand même de se mettre sur la bonne voie. Je vous invite à la lire à cette adresse.

Attention, la toute prochaine version 2.1 de symfony introduit des changements notables dans le framework de formulaire. Une rétrocompatibilité temporaire est assurée avec la version 2.0 mais plusieurs méthodes sont aujourd’hui marquées deprecated. Par exemple, la méthode getDefaultsOptions est désormais obsolète et on doit surcharger setDefaultOptions qui prend en paramètre un objet de type OptionsResolverInterface.

J’utilise personnellement la version 2.1 du master et les extraits de codes ne sont pas tous exploitables tel quel sur une version 2.0 stable.

Le besoin

L’objectif est de pouvoir construire un formulaire et d’y ajouter ou non un champ en fonction de l’entitée qui est passée au moment de l’instanciation du formulaire.

Par exemple, si on construit un formulaire d’édition d’une entité catégorie, on veut pouvoir ajouter un champ entity pour nous permettant de lister les catégories parentes sélectionnables pour la catégorie en cours d’édition. Par contre, cette liste ne doit pas être affichée si notre entitée n’a pas de catégorie parente (si elle est à la racine de notre arbo par exemple).

On veut également pouvoir filtrer les catégories qui s’affichent dans notre liste, de manière à n’afficher que les catégories qui ont un niveau hiérarchiques supérieur à la catégorie en cours d’édition, histoire de ne pas affecter à notre catégorie un parent lui même enfant de notre catégorie.

Implémentation

La solution à ce type de problématique est de passer par l’ajout d’un event listener au sein de la méthode buildForm(...). C’est ce listener qui va nous permettre de récupérer l’entitée injectée dans notre formulaire via le controller.

// src/Acme/AcmeBundle/Form/Type/CategoryType.php
$builder->addEventListener(FormEvents::PRE_SET_DATA, function(FormEvent $event) use ($builder) {
  /** @var Category $category */
  $category = $event->getData();
  // Si on n'a pas accès à l'entitée liée ou si notre entitée n'a pas de catégorie parente, on n'ajoute pas d'autre champ.
  if (!$category instanceof Category || !$category->getParent()) {
      return;
  }
  // On ajoute un nouveau champ de type entity pour sélectionner la catégorie parente.
  $event->getForm()->add($builder->getFormFactory()->createNamed('parent', 'entity', null, array(
    'class' => 'AcmeAcmeBundleBundle:Category',
    'required' => false,
    'query_builder' => function(EntityRepository $er) use ($category) {
      return $er->createQueryBuilder('c')
        ->where('c.level <= :level') // On ne veut récupérer que les catégories qui sont au dessus de celle en cours d'édition.
        ->setParameter('level', $category->getParent()->getLevel());
    }
  )));
});

Ci-dessous, le code source complet de ma classe de gestion du formulaire d’édition de catégorie :

// src/Acme/AcmeBundle/Form/Type/CategoryType.php
namespace Acme\AcmeBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormBuilderInterface;
use Doctrine\ORM\EntityRepository;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

use Acme\AcmeBundle\Entity\Category;

class CategoryType extends AbstractType
{
  /**
   * @param \Symfony\Component\OptionsResolver\OptionsResolverInterface $resolver
   */
  public function setDefaultOptions(OptionsResolverInterface $resolver)
  {
    $resolver->setDefaults(array(
      'data_class' => 'Acme\AcmeBundle\Entity\Category',
    ));
  }

  /**
   * @param \Symfony\Component\Form\FormBuilderInterface $builder
   * @param array $options
   */
  public function buildForm(FormBuilderInterface $builder, array $options)
  {
    $builder->add('title', 'text');
    $builder->addEventListener(FormEvents::PRE_SET_DATA, function(FormEvent $event) use ($builder) {
      /** @var Category $category */
      $category = $event->getData();
      // Si on n'a pas accès à l'entitée liée ou si notre entitée n'a pas de catégorie parente, on n'ajoute pas d'autre champ.
      if (!$category instanceof Category || !$category->getParent()) {
          return;
      }
      // On ajoute un nouveau champ de type entity pour sélectionner la catégorie parente.
      $event->getForm()->add($builder->getFormFactory()->createNamed('parent', 'entity', null, array(
        'class' => 'AcmeAcmeBundleBundle:Category',
        'required' => false,
        'query_builder' => function(EntityRepository $er) use ($category) {
          return $er->createQueryBuilder('c')
            ->where('c.level <= :level') // On ne veut récupérer que les catégories qui sont au dessus de celle en cours d'édition.
            ->setParameter('level', $category->getParent()->getLevel());
        }
      )));
    });
  }

  /**
   * @return string
   */
  public function getName()
  {
    return 'category';
  }
}

Attention, la fonction anonyme utilisée au sein de la méthode addEventListener prend en entrée un FormEvent et non pas un DataEvent comme dans la version 2.0 de Symfony.

En espérant que cela puisse être utile à certains…

Publié par Olivier Balais

Jeune ingénieur logiciel basé à Lyon (@overnetcity) passionné par les NTIC et le développement Web, je suis actuellement salarié chez Reputation VIP et effectue en parallèle des missions ponctuelles en temps que Freelance. Passionné depuis toujours par l'informatique et le développement, suite à une formation solide à l'INSA de Lyon, je me suis spécialisé dans la réalisation de bout en bout de projets web complexes.