TP n°9 - Formulaires dynamiques complexes
Formulaires Symfony dynamiques - Doctrine et JQuery en détails

Table des matières

1. Introduction

Cette séance vise à mettre en œuvre, dans l’application fil-rouge « ToDo », la gestion de formulaires dynamiques Symfony, en intégrant les mécanismes codés en PHP et JQuery, et à ajouter une gestion des erreurs.

On cherche à ce que les formulaires de l’application soient plus riches.

Auparavant, on avait :

  1. un formulaire « statique » de création d’un nouveau projet
  2. un formulaire d’ajout d’une nouvelle tâche à un projet déjà créé (vous avez travaillé dessus dans la séance précédente)

À la place, ou souhaiterait désormais disposer d’un seul formulaire, dynamique, qui intègre l’ajout d’un nouveau Projet et d’un nombre variable de tâches qui le constituent (Collection de Todos, dans le cadre d’une relation de type association 1-N).

Cela nécessite que le formulaire puisse contenir un nombre variable de champs de saisie, pour les tâches du projet, dont on ne connaît pas le nombre a priori. On utilisera Javascript pour modifier dynamiquement les champs de saisie présents dans le formulaire HTML, pour chaque nouvelle tâche du projet à créer, au fur-et-à-mesure des besoins, sans que l’ensemble du formulaire soit rechargé.

Ce type de fonctionnalité sera très classique dans les applications, dès lors qu’on souhaitera ajouter une nouvelle entité dans un CRUD, et que cette entité est composée d’autres sous-entités liées par une relation « OneToMany ».

On va suivre les indications de la documentation Symfony How to Embed a Collection of Forms pour intégrer l’ajout ou la suppression des tâches du projet, directement dans le formulaire de création d’un nouveau projet.

2. Étape 1 : Premières modifications de l’application « Todo »

L’objectif de cette étape est de mettre en place le moyen de tester les modifications sur l’application ToDo.

2.1. Étape 1-a : Ajout de la création de tâches de test dans le Contrôleur du formulaire de nouveau projet

Vous allez modifier la version de ToDo, supposée être dans l’état final obtenu à la fin de la séance 8.

  • Si vous aviez terminé la séance précédente, nous vous recommendons de travailler dans une copie du répertoire :

    cd $HOME/CSC4101/
    mkdir tp-09
    cp -r tp-08/todo-app tp-09/
    cd tp-09/todo-app/
    rm -fr .project
    symfony console cache:clear
    

    Pensez éventuellement à supprimer aussi les répertoires des vieilles séances (tp-N-2) pour libérer un peu d’espace disque.

    Recréez un nouveau projet dans Eclipse pour la nouvelle version de ToDo de la présente séance.

  • En cas de besoin, si vous n’aviez pas pu effectuer les modifications complètement dans la séance précédente, et aviez un état instable dans le répertoire de travail de la séance 8, vous pouvez récupérer le code correspondant au travail terminé de la séance 8 :

    mkdir -p $HOME/CSC4101/tp-09
    cd $HOME/CSC4101/tp-09/
    symfony composer create-project oberger/tspcsc4101-todo-skeleton todo-app "v8.*"
    

    Confirmez l’application de la recette Flex pour vich/uploader-bundle en répondant ’y’ (yes) au prompt de composer.

    Puis importez le projet dans Eclipse.

    Profitez-en pour faire éventuellement du ménage dans les anciennes versions (TP n-2 et antérieures)

2.2. Étape 1-b : Modification du formulaire de nouveau projet

Vous allez mettre en place, dans le modèle des données, une gestion de l’ajout de tâches dès la création d’un nouveau projet, pour pouvoir effectuer des tests dans la suite des étapes.

Procédez aux étapes suivantes :

  1. dans src/Controller/ProjectController.php, modifiez la méthode new() existante, pour ajouter le code bidon suivant.

    Attention à bien placer les différents blocs dans le bon ordre, comme ci-dessous

    use App\Entity\Todo;
    
    //...
    
     /**
      * @Route("/new", name="project_new", methods="GET|POST")
      */
     public function new(Request $request): Response
     {
         // 1) création d'un projet en mémoire
         $project = new Project();
    
         // 2) ajout de sous-entités
         // code bidon - c'est juste fait pour qu'un Project ait des todos
         // sinon, ça ne sert pas à grand chose
         $todo1 = new Todo();
         $todo1->setTitle('todo1');
         $project->getTodos()->add($todo1);
         $todo2 = new Todo();
         $todo2->setTitle('todo2');
         $project->getTodos()->add($todo2);
         // fin du code bidon
    
         // 3) création d'un formulaire d'affichage du projet
         //    et de ses sous-tâches
         $form = $this->createForm(ProjectType::class, $project);
    
         ...
     }
    

    Dès qu’un projet est créé, en support de l’affichage du formulaire de création, il comportera deux sous-tâches de test.

  2. modifiez la méthode ProjectType::buildForm (dans src/Form/ProjectType.php) pour gérer l’intégration dans le formulaire de modification d’un projet, de sous-formulaires pour ses sous-tâches :

    use Symfony\Component\Form\Extension\Core\Type\CollectionType;
    //...
        public function buildForm(FormBuilderInterface $builder, array $options)
        {
            $builder
                ->add('title')
                ->add('description')
                ->add('todos', CollectionType::class, array(
                    'entry_type' => TodoType::class,
                    'entry_options' => array('label' => false),
                ));
            ;
        }
    

    Vous voyez ici comment on intègre un nouvel élément au formulaire, pour gérer la collection des sous-tâches de l’attribut todos des Project.

    Au lieu d’une gestion d’un champ de saisie mono-valuée, comme pour le titre du projet (title), il s’agit ici d’un objet Symfony gérant une collection (CollectionType). Chaque élément de la collection sera géré par un sous-formulaire de type TodoType (il existe déjà, et est utilisé pour la création des tâches). Ignorez le rôle d’entry_options qui est un détail d’implémentation.

  3. Vérifiez que le formulaire de création de nouveau projet s’affiche, et permet d’y voir les deux sous-tâches « bidon ».

Il faut maintenant adapter le code du gabarit associé, pour intégrer la dimension dynamique du formulaire HTML.

3. Étape 2 : Modification du gabarit de création d’un nouveau projet

L’objectif de cette étape est de modifier le gabarit qui génère le formulaire HTML afin qu’il intègre la capacité à être modifié dynamiquement en Javascript, côté client (dans le navigateur).

Dans le gabarit templates/project/_form.html.twig existant, on va modifier le formulaire de création de nouveau projet pour embarquer des sous-formulaire pour chacune des tâches.

Procédez aux étapes suivantes :

  1. Modifiez templates/project/new.html.twig pour ajouter un gabarit de base intermédiaire, qui sera spécifique aux formulaires de création ou de modification des projets, qui pourra intégrer le code Javascript :

    {% extends 'project/project_form_base.html.twig' %}
    
    {% block title %}New Project{% endblock %}
    ...
    
  2. Modifiez de même templates/project/edit.html.twig
  3. Ajoutez le nouveau gabarit templates/project/project_form_base.html.twig :

    {% extends "baselayout.html.twig" %}
    
    {% block custompage_script %}
    <script>
    
      /* Ici se trouvera le code JQuery nécessaire aux formulaires dynamiques */
    
    </script>
    {% endblock %} {# custompage_script #}
    
    

    Le bloc custompage_script existait dans le gabarit de base baselayout.html.twig, prêt à être surchargé pour l’ajout de code javascript dans les pages de l’application. C’est ce qu’on va faire ici.

  4. Modifiez le gabarit de formulaire project/_form.html.twig qui est inclus dans les deux gabarits new et edit, afin d’y lister explicitement tous les attributs, du projet, et de ses sous-tâches.

    {{ form_start(form) }}
    
        {{ form_row(form.title) }}
        {{ form_row(form.description) }}
    
        <h3>Tasks</h3>
        <ul class="todos">
    
            {# passer en revue chaque todo existante pour afficher ses champs #}
            {% for todo in form.todos %}
                <li>{{ form_row(todo.title) }}<br/>{{ form_row(todo.completed) }}</li>
            {% endfor %}
        </ul>
    
        <button class="btn">{{ button_label|default('Save') }}</button>
    {{ form_end(form) }}
    
    

    Cela suppose que le formulaire qui sera transmis à ce gabarit ne contiendra pas uniquement des données du modèle pour le projet, mais aussi pour ses sous-tâches. Cela devrait fonctionner puisqu’on a ajouté des données « bidon » ci-dessus, quand on a modifié ProjectController::new.

  5. Testez que l’affichage du formulaire fonctionne bien quand on invoque la page de création d’un nouveau projet. Le formulaire doit afficher les données « bidon » qu’on a ajouté dans la création du formulaire, pour des tâches « todo1 » et « todo2 », via deux jeux de champs de saisie pour ces tâches.
  6. Observez dans la barre d’outils Symfony la structure des formulaires transmis, le format des objets Collection gérant les sous-tâches instances de Todo.
  7. Observez, avec l’inspecteur des outils de développement du navigateur, la structure des sous-formulaires dans l’arbre DOM.

Il reste maintenant à mettre en place le fonctionnement dynamique du formulaire pour supporter la saisie d’un nombre quelconque de tâches, et non deux sous-tâches « bidons ».

4. Étape 3 : Mise en place d’un prototype de formulaire de nouvelle tâche

L’objectif de cette étape est de préparer le formulaire de nouveau projet, pour qu’on puisse le rendre dynamique via un code Javascript.

Lisez la section Allowing « new » Tags with the « Prototype » de la documentation Symfony, qui explique le principe de fonctionnement du formulaire dynamique qu’on va mettre en œuvre.

Procédez aux étapes suivantes, en parallèle de votre lecture :

  1. Modifiez la méthode ProjectType::buildForm (dans src/Form/ProjectType.php) pour autoriser l’ajout de nouveaux sous-formulaires pour la Collection todos si elle doit s’agrandir dynamiquement. Ajoutez l’ option allow_add, pour obtenir ce qui suit :

    public function buildForm(FormBuilderInterface $builder, array $options)
        {
            $builder
                ->add('title')
                ->add('description')
                ->add('todos', CollectionType::class, array(
                    'entry_type' => TodoType::class,
                    'entry_options' => array('label' => false),
                    'allow_add' => true,
                    // 'by_reference' => false,
                    // 'allow_delete' => true,
                ))
            ;
        }
    
  2. Modifiez en parallèle le gabarit project/_form.html.twig pour y ajouter un « prototype » de nouveau formulaire, dont l’ojectif sera d’être prêt à être utilisé par du code Javascript.

    Modifiez la balise <ul> de la liste des sous-formulaires.

    Le code Twig doit passer de :

    <h3>Tasks</h3>
    <ul class="todos">
    
        {# passer en revue chaque todo existante pour afficher ses champs #}
        {% for todo in form.todos %}
    

    à :

    <h3>Tasks</h3>
    <ul class="todos" data-prototype="{{ form_widget(form.todos.vars.prototype)|e('html_attr') }}">
    
        {# passer en revue chaque todo existante pour afficher ses champs #}
        {% for todo in form.todos %}
    

    On insère ainsi, dans un attribut data-prototype, qu’on place ainsi dans l’en-tête de la liste dans le <ul>, un prototype de formulaire généré par Symfony (form.todos.vars.prototype) du fait de l’option allow_add. On l’encode sous forme d’attribut d’une balise HTML, pour ne pas casser le HTML, avec le filtre Twig e('html_attr').

  3. Testez le fonctionnement du formulaire de création de nouveau projet, et vérifiez que le le <ul> contenant les tâches contient bien une valeur (assez longue) générée pour l’attribut data-prototype, quand on examine le code HTML dans l’inspecteur des outils du développeur Web.

    Cette valeur est un peu absconse à première vue.

    En fait, elle correspond à une version « prête à l’emploi » d’un code HTML de sous-formulaire, qui contient deux couples champs de saisie et labels, pour les attributs title et completed des sous-tâches. En voici une version indentée, donc plus lisible :

    <div id="project_todos___name__">
      <div class="form-group">
        <label for="project_todos___name___title">Title</label>
        <textarea id="project_todos___name___title"
                  name="project[todos][__name__][title]"
                  class="form-control"></textarea>
      </div>
      <div class="form-group">
        <div class="form-check">
          <input type="checkbox"
                 id="project_todos___name___completed"
                 name="project[todos][__name__][completed]"
                 class="form-check-input"
                 value="1" />
            <label class="form-check-label"
                   for="project_todos___name___completed">Completed</label>
        </div>
      </div>
    </div>
    

    Inutile d’en comprendre tous les détails. Le code Javascript fourni ci-dessous saura quoi en faire.

Maintenant, il ne reste plus qu’à coder le Javascript correspondant.

5. Étape 4 : Ajout du code Javascript pour rendre le formulaire dynamique

L’objectif de cette étape est de rendre le formulaire réellement dynamique, en programmant en JQuery.

On va ajouter dans le formulaire des liens qui déclencheront l’exécution de code JQuery qui ajoutera ou supprimera des sous-formulaires permettant d’ajouter ou supprimer des tâches.

Ce code n’aura pas d’incidence sur l’exécution côté serveur : l’ajout des champs de saisie se fera côté client dans le navigateur via l’exécution Javascript. On se contente de modifier le DOM de la page HTML contenant le formulaire de création de projets, sans interaction HTTP avec le serveur PHP. Une fois que le formulaire sera validé, le gestionnaire de formulaires Symfony recevra les donnés du formulaire, et découvrira les données correspondantes aux sous-entités ajoutées dynamiquement par ce biais.

5.1. Étape 4-a : Permettre l’ajout de nouveaux champs de saisie pour ajouter une tâche

L’objectif de cette sous-étape est de coder ce qui permet l’ajout de liens pour l’ajout d’un sous-formulaire de création de tâche, dans le formulaire maître de création d’un nouveau projet.

Procédez aux étapes suivantes :

  1. On va modifier le gabarit de base project/project_form_base.html.twig. Dans le bloc custompage_script Twig, dans les balises <script>, on ajoute le code JQuery suivant :

    var $collectionHolder;
    
    //setup an "add a todo" link
    var $addTodoButton = $('<button type="button" class="add_todo_link">Add a task</button>');
    var $newLinkLi = $('<li></li>').append($addTodoButton);
    
    jQuery(document).ready(function() {
     // Get the ul that holds the collection of todos
     $collectionHolder = $('ul.todos');
    
     // add the "add a todo" anchor and li to the todos ul
     $collectionHolder.append($newLinkLi);
    
     // count the current form inputs we have (e.g. 2), use that as the new
     // index when inserting a new item (e.g. 2)
     $collectionHolder.data('index', $collectionHolder.find(':input').length);
    
     $addTodoButton.on('click', function(e) {
         // add a new todo form (see next code block)
         addTodoForm($collectionHolder, $newLinkLi);
     });
    });
    
    function addTodoForm($collectionHolder, $newLinkLi) {
        // Get the data-prototype explained earlier
        var prototype = $collectionHolder.data('prototype');
    
        // get the new index
        var index = $collectionHolder.data('index');
    
        var newForm = prototype;
        // You need this only if you didn't set 'label' => false in your taskss field in TaskType
        // Replace '__name__label__' in the prototype's HTML to
        // instead be a number based on how many items we have
        // newForm = newForm.replace(/__name__label__/g, index);
    
        // Replace '__name__' in the prototype's HTML to
        // instead be a number based on how many items we have
        newForm = newForm.replace(/__name__/g, index);
    
        // increase the index with one for the next item
        $collectionHolder.data('index', index + 1);
    
        // Display the form in the page in an li, before the "Add a task" link li
        var $newFormLi = $('<li></li>').append(newForm);
        $newLinkLi.before($newFormLi);
    }
    

    Ce code peut sembler un peu compliqué, mais il est en fait relativement simple, pourvu qu’on ait compris le principe de la phase précédente où on a embarqué dans le formulaire HTML l’attribut data-prototype, tel que généré par Symfony.

    addTodoForm() va convertir ce prototype en code HTML, qui sera ajouté dans le DOM du formulaire, pour faire apparaître au bon endroit les sous-champs d’ajout d’une tâche, en remplaçant les identifiants des différentes balises des formulaires, là où le prototype contenait __name__.

    Référez-vous si besoin à la documentation Symfony qui explique bien les détails de ce code.

  2. Testez le fonctionnement du bouton « Ajouter une tâche », pour vérifier que l’ajout d’un sous-formulaire est opérationnel. Vérifiez dans le DOM, via l’inspecteur des outils de développement du navigateur, que les sous-formulaires sont ajoutés dynamiquement comme on le souhaite (de la même façon que lorsqu’ils sont déjà présents pour les tâches « bidon »).

Vous avez ici un exemple de la façon dont un réflexe Javascript sait modifier dynamiquement le contenu d’une page, pour changer à la demande le contenu d’un formulaire. La structure du formulaire, préparée à l’avance dans le prototype, généré par le serveur, n’est pas complètement construite par le code Javascript. Le formulaire reste sous un contrôle assez important du code Symfony.

Le formulaire complet ne fonctionne pas encore complètement. On va voir un peu plus loin comment faire en sorte que la sauvegarde en base de données soit complète.

5.2. Étape 4-b : Gestion de la suppression des sous-formulaires des tâches

L’objectif de cette sous-étape est de coder le pendant du code précédent, pour pouvoir supprimer dynamiquement des formulaires de création de sous-tâches.

Procédez aux modifications suivantes :

  1. Ajoutez dans la classe de formulaire des Projets, une option supplémentaire (allow_delete) permettant d’autoriser la suppression des sous-formulaires :

    public function buildForm(FormBuilderInterface $builder, array $options)
        {
            $builder
                ->add('todos', CollectionType::class, array(
                    ...
                    'allow_delete' => true,
                ));
            ;
        }
    
  2. Modifiez le code JQuery correspondant pour ajouter des liens de suppression de sous-formulaires de saisie de tâches. Insérez le code correspondant qui modifiee tous les <li> des tâches, puis le code de addTodoForm() pour invoquer une nouvelle fonction addTodoFormDeleteLink( ), pour obtenir du code similaire à ce qui suit :

    var $collectionHolder;
    
    //setup an "add a task" link
    var $addTodoButton = $('<button type="button" class="add_todo_link">Add a task</button>');
    var $newLinkLi = $('<li></li>').append($addTodoButton);
    
    jQuery(document).ready(function() {
     // Get the ul that holds the collection of tasks
     $collectionHolder = $('ul.todos');
    
    //add a delete link to all of the existing task form li elements
     $collectionHolder.find('li').each(function() {
         addTodoFormDeleteLink($(this));
     });
    
     // add the "add a task" anchor and li to the tasks ul
     $collectionHolder.append($newLinkLi);
    
        ...
    });
    
    function addTodoForm($collectionHolder, $newLinkLi) {
        ...
        // Display the form in the page in an li, before the "Add a task" link li
        var $newFormLi = $('<li></li>').append(newForm);
    
        // add a delete link to the new form
        addTodoFormDeleteLink($newFormLi);
    
        $newLinkLi.before($newFormLi);
    }
    
    function addTodoFormDeleteLink($todoFormLi) {
        var $removeFormButton = $('<button type="button">Delete this task</button>');
        $todoFormLi.append($removeFormButton);
    
        $removeFormButton.on('click', function(e) {
            // remove the li for the todo form
            $todoFormLi.remove();
        });
    }
    
    
  3. Testez le fonctionnement des liens de suppression et leur impact sur le DOM de la page.

6. Étape 5 : Finalisation du fonctionnement du formulaire

L’objectif de cette dernière étape est de finaliser le fonctionnement du formulaire et de tester que les créations de projets et de leurs tâches fonctionnent bien.

Procédez aux étapes suivantes :

  1. Dans la classe de formulaire des Projets, ajoutez une option supplémentaire (by_reference définie à false) pour que la soumission des formulaires de création des projets s’occupent bien de vérifier les tâches soumises via le formulaire, et appelle en conséquence les méthodes addTodo() et removeTodo() de l’entité Project (cf. https://symfony.com/doc/current/reference/forms/types/collection.html#by-reference) :

    public function buildForm(FormBuilderInterface $builder, array $options)
        {
            $builder
                ->add('todos', CollectionType::class, array(
                    ...
                    'by_reference' => false,
                ));
            ;
        }
    
  2. Modifiez le code de la méthode new() du contrôleur ProjectController pour supprimer l’ajout des données de tâches « bidon » dans les projets, qu’on avait ajouté en début de séance.

Les principaux éléments nécessaires au fonctionnement des formulaires dynamiques sont désormais présents.

Il reste à vérifier que les ajouts ou suppressions des tâches depuis les formulaires des projets sont bien fonctionnels, et ce que les données sont bien modifiées en conséquence via Doctrine.

6.1. Étape 5-a : Utilisation des outils de mise au point pour vérifier le fonctionnement des formulaires

Lors des mises à jour des données, à la validation des formulaires de modification des projets, on s’attend à ce que les modifications sur les données des projets soient sauvées, mais aussi les ajouts, modifications ou suppressions des données de leurs tâches, venant des sous-formulaires Todo embarqués dynamiquement dans le formulaire.

Si les formulaires fonctionnent correctement, les méthodes new et edit du contrôleur ProjectController reçoivent les bonnes données.

Contrôlez que les données transmises dans les formulaires sont cohérentes, après des ajouts ou suppressions de sous-formulaires de Todos :

  1. dans le Profiler de la barre d’outils Symfony, vous allez examiner les requêtes POST (antérieures à la redirection, en cas de succès) en remontant dans l’historique des requêtes.
  2. les données soumises dans une requête POST sont visibles dans le menu Forms : il doit y avoir le bon nombre de sous formulaires des todos dans la hiérarchie :
    • project
    • title
    • description
    • todos, qui contient bien une collection de tâches :
      • 1
      • 2
  3. si le formulaire a été accepté sans problème, vérifiez les requêtes transmises à la base de données par Doctrine, suite aux traitements du formulaire (valide), dans le menu « Doctrine ».

Si le formulaire fonctionnait correctement, des requêtes INSERT, UPDATE ou DELETE devraient être faites dans la base de données, dans la table todos, pour gérer l’ajout, la modification ou la suppression des sous-tâches des projets.

Or normalement, à ce stade, les données ne sont pas sauvegardées correctement. Seule la table des projets est toujours gérée.

On va corriger le code pour s’assurer de la bonne sauvegarde.

6.2. Étape 5-b : Correction de l’absence de sauvegarde des sous-tâches ajoutées

L’ajout d’une sous-tâche devrait afficher une erreur 500 du type « A new entity was found through the relationship ’App\Entity\Project#todos’ that was not configured to cascade persist operations for entity »

Notez que l’erreur de produit dans l’appel à $em->flush();, soit au niveau de la tentative de génération des requêtes à envoyer à la base de données, dans Doctrine.

  1. Procédez à l’examen des données des formulaires, comme indiqué ci-dessus. Il doit bien y avoir le bon nombre de sous-tâches dans les sous-formulaires todos.
  2. Examinez le message d’erreur complet, qui propose une solution pour remédier au problème.
  3. Procédez aux modifications suggérées en ajoutant cascade={"persist"} dans l’annotation Doctrine @ORM\OneToMany de Project:todos dans src/Entity/Project.php.
  4. Vérifiez que cela fonctionne correctement maintenant, en revenant dans le Profiler sur la requête POST, et en examinant les requêtes.

Y a-t-il les INSERT INTO todos attendus ? Cela doit maintenant fonctionner.

L’explication du problème qu’on vient de résoudre est donnée dans la documentation, dans la « bloc » « Doctrine: Cascading Relations and saving the « Inverse » side » de https://symfony.com/doc/current/form/form_collections.html#allowing-new-tags-with-the-prototype.

Dans la gestion de la soumission du formulaire, le code gérant l’ajout des données des formulaires dans les entités en mémoire est invoqué dans $form->isValid() : cela invoque Project:addTodo() pour chaque sous-formulaire des todos.

Examinez Project:addTodo(), et remarquez le code :

$this->todos[] = $todo;
$todo->setProject($this);
  • la première ligne ajoute le nouveau todo dans la collection des todos de ce projet,
  • puis elle met à jour le pointeur inverse, du Todo vers son Project.

De nouvelles entités sont donc présentes en mémoire dans App\Entity\Project#todos, comme nous l’indique Doctrine dans le message d’erreur.

Mais si on examine le code traitant les données du formulaire dans le contrôleur (entre $form->isValid() et l’endoit où se produit l’erreur : $em->flush();), on peut vérifier quelles sont les données qui doivent être sauvegardées, puisqu’elles sont « marquées » comme telles avec l’appel à $em->persist($project);. Seul le projet est marqué.

Comme indiqué dans la documentation, soit ce persist() doit procéder « en cascade », en parcourant l’arbre des données liées depuis l’instance de $project, ce qui sera fait à condition d’avoir déclaré l’attribut todos de Project avec cascade={"persist"}.

Ou bien, on devrait écrire une boucle pour appeler persist( ) explicitement pour chaque élément de $project->getTodos(). L’annotation est beaucoup plus pratique, dans ce cas. Consultez la documentation doctrine pour plus de détails.

Maintenant que l’ajout fonctionne, vérifions la suppression.

6.3. Étape 5-c : Correction de la suppression des tâches d’un projet

6.3.1. Mise en évidence du dysfonctionnement

  1. Modifiez un projet qui a déjà des sous-tâches;
  2. Dans le formulaire de modification, supprimez certaines de ses sous-tâches en supprimant les sous-formulaires dynamiquement;
  3. Validez la mise-à-jour du projet : aucune erreur n’est signalée. Tout va bien en apparence;
  4. Examinez à nouveau le projet : il semble ne plus avoir la sous-tâche qu’on a supprimée;
  5. Examinez maintenant la liste de toutes les tâches de l’application : la sous-tâche du projet qu’on a tenté de supprimer est toujours présente, mais sans qu’elle soit rattachée à aucun projet;

Ce n’est pas le comportement souhaité : les instances de Todo créées comme sous-tâches, dans le contexte d’un projet, doivent être supprimées complètement, si on les supprime depuis la modification de leur projet, au lieu d’être détachées du projet. On pourrait ajouter à l’application une fonctionnalité permettant de « détacher » une tâche de son projet, mais ce n’est pas l’objectif qu’on recherchait pour l’instant.

6.3.2. Compréhension du problème

La correction de ce problème nécessite d’appliquer un algorithme qui gère explicitement les données dans le contrôleur. Cette fois, Doctrine ne nous offre pas d’annotation « magique » nous évitant d’écrire du code.

Examinons tout d’abord la cause du dysfonctionnement.

  1. Recommencez la suppression d’une sous-tâche d’un projet.
  2. Une fois la validation du formulaire transmise, examinez le contenu de la requête POST dans les outils de la barre Symfony.
  3. Examinez les requêtes transmises par Doctrine à la base de données : des requêtes du type UPDATE todos SET project_id = ? WHERE tid = ? sont transmises avec une mise à NULL de project_id.

Cela correspond en effet à la suppression de la référence d’une Todo à son Project, pas à la suppression de la Todo (on devrait voir une requête DELETE).

Examinons le code de Project:removeTodo() :

$this->todos->removeElement($todo);
...
$todo->setProject(null);

Examinons ces instructions :

  1. on supprime l’instance de Todo de la liste des todos du Project en mémoire.
  2. puis on met à nul la référence de la Todo vers son Projet (toujours en mémoire).

Examinons la traduction que Doctrine effectue lors du $entityManager->persist($project); dans le contrôleur. Ces instructions n’ont pas nécessairement d’impact sur les données en base de données, si l’instance de Project est la seule à être marquée comme devant être sauvegardée par le persist(). En fait, la liste des todos d’un Project, qu’on a déclaré comme une association OneToMany dans Doctrine, est en fait construite dans la base la clé étrangère de la table project migrée dans la table todo (dans project_id).

Or dans cette configuration du modèle des données via les annotations Doctrine, on n’a établi qu’un lien de type association entre Project et Todo, pas une composition (forte).

En effet, on n’a pas défini l’annotation orphanRemoval=true comme c’est le cas par contre dans l’application Agence de Voyages, pour Circuit et Etape, par exemple, qui sont fortement liés.

Une tâche sans projet peut donc exister seule, mais une tâche peut aussi être associée à un projet.

Doctrine n’a donc pas à gérer la suppression sans qu’on le lui demande, dans ce cas. Il ne génère donc pas les requêtes DELETE automatiquement lorsque le pointeur project vers l’entité mère est mis à nul $todo->setProject(null);. Le cascade={"persist"} prend juste en compte cette modification de l’attribut project et génère donc seulement les requêtes UPDATE.

6.3.3. Correction : ajout explicite de la suppression

On va appliquer la suggestion mentionnée dans la documentation dans le bloc « Doctrine: Ensuring the database persistence » en fin de page de https://symfony.com/doc/current/form/form_collections.html.

On va modifier le code du contrôleur qui traite la soumission d’un formulaire valide, pour gérer explicitement les opétations de suppression des sous-tâches supprimées.

Le principe est le suivant :

  1. on garde la trace de la liste des sous-tâches du projet avant la soumission du formulaire
  2. on récupère les données valides soumises par le formulaire, et la nouvelle liste de sous-tâches dans laquelle ne sont plus présentes celles qui ont été supprimées
  3. on compare les deux et on marque explicitement celle qui doivent être supprimées.

Voici un exemple code réalisant cette fonctionnalité :

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Common\Collections\ArrayCollection;

//...

public function edit(Request $request, Project $project, EntityManagerInterface $entityManager): Response
{
    $originalTodos = new ArrayCollection();

    // On crée un tableau ArrayCollection mémorisant les objets Todo associés avant la soumission
    foreach ($project->getTodos() as $todo) {
        $originalTodos->add($todo);
    }

    $form = $this->createForm(ProjectType::class, $project);
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {

        // une fois que le formulaire est valide, les nouvelles données sont celles où des sous-tâches ont pu être supprimées

        // on parcourt l'ancien tableau et on vérifie l'impact
        foreach ($originalTodos as $todo) {

            if (! $project->getTodos()->contains($todo)) {
                // la Todo n'est plus présente dans le tableau. Il faut donc la supprimer de la base.       
                $entityManager->remove($todo);
            }
        }

        $entityManager->flush();

        return $this->redirectToRoute('project_edit', ['id' => $project->getId()]);
    }
    ...

Modifiez le code du contrôleur, puis testez que l’application fonctionne désormais de la façon souhaitée. Vérifiez que les requêtes DELETE sont bien transmises à la base.

7. Évaluation

À l’issue de cette séance, les éléments ci-dessous doivent vous paraître clairs :

  • comment sont gérées les données des relations de type composition OneToMany par Doctrine via une Collection
  • comment sont « câblés » des réflexes entre les formulaires des Contrôleurs et le Modèle au niveau des ajouts/suppressions « automatiques » dans les données soumises aux formulaires
  • comment le code Javascript (JQuery) permet relativement simplement de rendre des formulaires dynamiques pour gérer des sous-formulaires en nombre quelconque.
  • qu’il est important de comprendre la mise en œuvre des associations dans le modèle de données, pour s’assurer que les requêtes en base dont correctes, et à défaut, modifier les annotations nécessaires (cascade, etc.)

Author: Olivier Berger (TSP)

Date: 2024-09-06 Fri 13:21

Emacs (Org mode)