TP n°9 - Interface dynamique en Javascript, API, AJAX

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 Javascript, avec JQuery.

On va dans un premier temps travailler sur une fonction très simple permettant d’interroger une API, en lecture, avec du code AJAX s’exécutant sur le navigateur.

Dans un second temps on s’attaquera aux formulaires Symfony dynamiques.

Vous allez modifier la version de ToDo, supposée être dans l’état final obtenu à la fin de la séance 8 portant sur les formulaires des entités liées.

1.1. TODO Préparation du répertoire de travail

Vous allez dupliquer l’arborescence du projet PHP obtenu à la fin de la séance précédente, pour travailler sur une nouvelle version.

Vous pourrez ainsi faire évoluer le code, et revenir en arrière si besoin, en comparant l’état à la fin de la séance prédente, avec l’état courant.

  1. Effectuez les opérations suivantes pour dupliquer le dossier :

    cd "$HOME/CSC4101"
    cp -r tp-07 tp-09
    cd tp-09
    cd todo-app
    
  2. Ensuite, dans ce nouveau projet, on va réinitialiser les rouages du cadriciel Symfony avec Composer :

    cd "$HOME/CSC4101/tp-09/todo-app"
    rm -fr composer.lock symfony.lock var/cache/ vendor/ .project
    symfony composer install
    

    Confirmez la génération de fichiers pour Docker (on ne s’en servira pas tout de suite, mais pourquoi ne pas les avoir… le rôle de Docker ne sera pas évoqué en cours, mais vous pouvez en parler à vos encadrants de TP).

Cette étape est nécessaire car Symfony s’appuie sur une mécanique sophistiquée de mise en cache du code de l’application (dans var/cache/), pour assurer des performances maximales. Malheureusement, si on duplique un projet, une partie de ce cache a tendance à devenir incohérente (présence de chemins d’accès « en dur », etc.).

Il est donc préférable de réinitialiser le projet radicalement : à un état le plus « propre » possible. On fait ensuite réinstaller par Composer les bibliothèques du cadriciel Symfony (installées dans vendor/), et reconfigurer certains fichiers de configuration associés.

1.2. TODO Chargement du nouveau projet dans l’IDE

Vous allez travailler sur un nouveau projet dans le workspace dans l’IDE Eclipse, importé depuis ce nouveau répertoire de travail.

  1. Pour les utilisateurs de l’IDE Eclipse, supprimez les anciennes infos du projet Eclipse :

    cd "$HOME/CSC4101/tp-09/todo-app"
    rm -fr .project .settings/
    
  2. Importez dans votre IDE cette nouvelle version du projet Symfony Todo sur laquelle vous allez maintenant travailler.

    Dans Eclipse vous pouvez importer ce nouveau projet dans le même workspace que le précédent, mais avec un nom distinctif (par exemple « todo-tp-9 »).

    Si besoin, vous pourrez comparer le code des deux projets « todo-tp-7 » et « todo-tp-9 ». Mais pour l’instant, et pour éviter de vous mélanger, nous vous conseillons de fermer le projet de la séance précédente (menu Project / Close …).

2. Étape 1 : Ajout d’une API REST

On va découvrir comment décorer nos ressources via une API REST avec le composant APIPlatform pour Symfony

2.1. TODO Étape 1-a : Ajout d’une API avec api-platform

Vous allez ajouter un module d’API à l’application ToDo, qui permet à des programmes d’accéder via HTTP à des fonctionnalités CRUD.
Le fonctionnement de cette interface HTTP sera compatible avec les meilleures pratiques de l’approche REST, en s’appuyant sur le module api-platform pour Symfony.

Une API (Application Programming Interface) est une interface permettant à des programmes d’interagir avec une application.

Typiquement, un ensemble de routes de l’application qui peuvent être utilisées pour effectuer des opérations de type CRUD directement, via un client HTTP, en manipulant uniquement des données, et non du HTML.

Procédez aux étapes suivantes, pour ajouter une API à votre application ToDo :

  1. Ajout du composant api-platform dans le projet Symfony :

    symfony composer require api
    
    api-platform/core  instructions:
    
    * Your API is almost ready:
      1. Create your first API resource in src/ApiResource;
      2. Go to /api to browse your API
    
    * Using MakerBundle? Try php bin/console make:entity --api-resource 
    
    * To enable the GraphQL support, run composer require webonyx/graphql-php,
      then browse /api/graphql.
    
    * Read the documentation at https://api-platform.com/docs/
    
  2. Modification du code pour déclarer que l’entité Paste est exposée via une API REST

    Ajoutez l’annotation ApiResource à la classe Paste :

    use ApiPlatform\Metadata\ApiResource;
    
    //...
    
    #[ORM\Entity(repositoryClass: PasteRepository::class)]
    #[ApiResource]
    #[Vich\Uploadable]
    class Paste
    {
    
  3. Nettoyez le cache de l’application

    symfony console cache:clear   
    
  4. Lancez l’application avec :

    symfony server:start
    
  5. Testez l’URL de l’application http://localhost:8000/api/

    Vous devriez constater que l’affichage pose problème. Certaines ressources ne sont pas corectement chargées. C’est la façon dont nous avions configuré les assets pour Bootstrap qui n’était pas idéale.

  6. Modifiez la configuration des assets dans config/packages/framework.yaml pour régler celà :

    framework:
        ...
        assets:
            packages:
                bootstrap:
                    base_path: '/startbootstrap-bare-gh-pages'
    
  7. Vérifiez que l’affichage de l’API est réparé
  8. Modifiez à son tour le gabarit de base de l’application (base.html.twig) pour changer les appels à asset() pour ajouter en argument le nom du paquetage qu’on vient d’ajouter dans la configuration ci-dessus (’bootstrap’), pour utiliser par exemple : asset('css/styles.css', 'bootstrap').

    Faites de même pour le JavaScript que pour le CSS.

  9. Testez que l’affichage des pages de l’application est à nouveau correct.

À partir de ce moment, l’application dispose d’une API REST utilisable par un client HTTP. Cette API est conforme aux spécifications OpenAPI.

2.2. Principe d’utilisation de l’API par un client HTTP

On peut utiliser tout client HTTP pour interagir avec l’API, pas uniquement un navigateur Web. Le client pourra manipuler des ressources « pastes », grâce aux routes CRUD des Pastes fournies par api-platform.

2.2.1. TODO Routes CRUD de l’API

Consultez les routes mises en œuvre par api-platform, avec symfony console debug:router.

Vous voyez apparaître différentes routes derrière le chemin _api_/pastes/ correspondant aux opérations CRUD supportées par notre API :

  • « get »
  • « get collection »
  • « post »
  • « put »
  • « patch »
  • « delete »

2.2.2. Structure des données dans l’API

On va s’intéresser à la consultation de la liste des Pastes de l’application.

Dans l’API de l’application, la collection des entités Paste est disponible sur le chemin d’accès à la ressource /api/pastes via une requête GET.

On peut ainsi utiliser le client curl en ligne de commande, par exemple, pour y accéder sous forme de représentation JSON-LD :

curl -X GET "http://localhost:8000/api/pastes" -H  "accept: application/ld+json"
{
  "@context": "/api/contexts/Paste",
  "@id": "/api/pastes",
  "@type": "hydra:Collection",
  "hydra:totalItems": 2,
  "hydra:member": [
    {
      "@id": "/api/pastes/5",
      "@type": "Paste",
      "id": 5,
      "content": "I am the blue US president",
      "created": "2018-01-01T00:00:00+00:00"
    },
    {
      "@id": "/api/pastes/6",
      "@type": "Paste",
      "id": 6,
      "content": "I am the red US president",
      "created": "2018-01-01T00:00:00+00:00"
    }
  ]
}

JSON-LD est un format d’encodage de données sémantiques basé sur JSON. Il permet d’embarquer des méta-informations permettant d’exploiter les données de façon plus fine qu’un format JSON basique. C’est l’un des formats standards de l’approche Web des données / Web Sémantique, supportant le modèle RDF.

Le format JSON-LD (JSON for Linking Data) est documenté sur https://json-ld.org/.

2.2.3. Méthodes CRUD

On peut donc aller bien au-delà de la simple consultation, par exemple pour générer des requêtes CRUD de création de nouveaux Pastes via un POST. Encore un exemple avec le client curl en ligne de commande :

curl -v -s -X 'POST' \
  'http://localhost:8000/api/pastes' \
  -H 'accept: application/ld+json' \
  -H 'Content-Type: application/ld+json' \
  -d '{
  "content": "I am the US president",
  "created": "2023-10-23T14:29:33.383Z"
}'

Le code de réponse HTTP sera 201 Created (ce code est spécifiquement fait pour confirmer cette création, et faisant partie des codes dans la gamme 2xx il confirme que ça c’est bien passé).

Par convention, il sera complété par l’en-tête location qui pointera sur la nouvelle ressource créée : Location: /api/pastes/13

La réponse transmise par le serveur affichera, au format JSON LD (comme demandé via l’en-tête accept de la requête) :

{
  "@context":"/api/contexts\Paste",
  "@id":"/api/pastes/13",
  "@type":"Paste",
  "id":13,
  "content":"I am the US president",
  "created":"2023-10-23T14:29:33+00:00"
}

2.2.4. TODO Génération de requêtes HTTP REST

On peut utiliser cURL en ligne de commande, pour tester l’API, comme dans les exemples de cette section (l’utilitaire jq est aussi bien pratique, pour mettre en forme le JSON, par exemple avec curl -X GET "http://localhost:8000/api/pastes" -H "accept: application/ld+json" -s | jq).

Si on ne dispose pas de cURL, on pourrait aussi utiliser une extension du navigateur générant des requêtes REST.

Mais de façon beaucoup plus pratique, api-platform intègre déjà tout ce qu’il nous faut pour tester.

C’est une interface Web dynamique permettant d’expérimenter avec l’API, basée sur Swagger/OpenAPI. Il s’agit d’un client HTTP codé en JavaScript, directement accessible depuis la page de documentation intégrée dans l’application : http://localhost:8000/api/

L’appui sur le bouton « Try it out », pour une des méthodes permet de tester. Par exemple, ici, pour POST des Pastes :

apt-swagger-post-1.png

Figure 1 : Saisie des données de création pour tester le POST dans Swagger

L’outil pré-remplit la structure de données JSON avec les bons champs, et sur ce modèle, on peut définir nos propres valeurs (et ignorer certaines propriétés, comme ci-dessus).

L’appui sur « Execute » nous montre la commande d’invocation de cURL qu’on aurait pu utiliser, une fois hors du navigateur :

apt-swagger-post-2.png

Figure 2 : Ligne de commande pour tester hors de l’outil

Enfin, il nous affiche le résultat de la requête POST :

apt-swagger-post-3.png

Figure 3 : Résultat de la requête

La nouvelle tâche a bien été créée par cet outil de test d’API en JavaScript.

2.3. TODO Compréhension de l’exécution côté serveur

Le client JavaScript qui nous a permis de tester l’API interagit via des requêtes HTTP (REST) dirigées vers notre application Symfony.

Sous le capot, c’est donc les routes de ApiPlatform qui vont gérer ces requêtes et faire le nécessaire dans le modèle de données.

On peut visualiser l’exécution de ces appels via le Profiler Symfony.

Lorsqu’on clique sur « Try it out », puis « Execute », on voit apparaître l’icône des requêtes AJAX apparaître, dans la barre d’outils Symfony.

Si on consulte le popup qui s’y affiche, on peut aller consulter les détails de l’exécution de la requête POST traitée par l’application.

ajax-popup.png

Figure 4 : Popup d’accès aux requêtes AJAX dans le Profiler

Si on clique sur le numéro « Profile » (ici 7e2a8a), on accède au Profiler classique pour les requêtes POST.

On pourra par exemple y voir la requête exécutée en base par Doctrine : INSERT INTO paste (content, created, content_type, image_name, image_size, updated_at) VALUES ('I am the blue US president'...

APIPlatform fonctionne à peu près comme nos contrôleurs Symfony, en s’appuyant sur notre modèle de données via Doctrine.

2.4. TODO Étape 1-b : Ajout de Todo et Project dans l’API

On souhaite maintenant pouvoir utiliser l’API pour interagir en CRUD REST avec les ressources Todo et Project de l’application.

De la même façon que pour les Pastes, il suffit d’annoter les classes Todo et Project avec l’attribut PHP 8 ApiResource comme vous l’avez fait ci-dessus pour Paste.

Testez le fonctionnement de l’API avec l’interface intégrée via OpenAPI, pour tester que ces nouvelles ressources sont bien disponibles via http://localhost:8000/api/.

Il est possible que vous obteniez une erreur, en cours de tests, signalant un problème de référence circulaire (du type « A circular reference has been detected when serializing the object of class… »).

Cela signifie a priori qu’une des associations pointe vers une entité inconnue d’API-Platform. Il suffit a priori d’ajouter l’attribut ApiResource sur le reste des entités concernées pour régler le problème (Tag, …).

3. Étape 2 : Interface dynamique en Javascript pour la liste des tâches en page d’accueil

Application des concepts présentés dans le cours magistral qui précède la séance, en étudiant la mise en œuvre avec Javascript d’une fonction d’interface utilisateur dynamique, s’exécutant du côté du navigateur Web.

3.1. Principe de fonctionnement de l’interface Javascript

On va expérimenter une architecture légèrement différente de celle proposée jusqu’alors, pour la réalisation des interfaces de l’application.

Dans l’architecture mise en œuvre jusqu’ici pour construire les interfaces avec les gabarits Twig du côté serveur, le navigateur est « stupide » et se contente d’afficher une représentation des ressources du serveur, que le serveur a construite pour lui sous forme de page HTML. Le serveur doit mettre en œuvre la couche de présentation, pour implémenter l’affichage destiné au final à un utilisateur humain, dans une fenêtre graphique du navigateur.

Dans cette étape, on va expérimenter une architecture alternative, où le client va être plus intelligent, et le serveur déchargé, en conséquence, de cette couche de présentation.

Cette fois, la représentation pour l’utilisateur humain, sous forme interface HTML s’exécutera uniquement du côté du client, codée en Javascript. Le serveur se contentera alors de transmettre une représentation de la ressource demandée qui sera beaucoup plus dépouillée et plus générique.

Le serveur se contentera de fournir une représentation unique, quelque soit le type de client : que ce soit une couche de présentation d’une application destinée à un utilisateur final humain, ou un autre programme consommant des données structurées (client d’API).

3.2. TODO Étape 2-a : Ajout du code Javascript d’interrogation la collection des tâches

On va enfin intégrer une liste des tâches dans la page d’accueil de l’application, construite en Javascript, via une requête faite après de l’API.

Procédez aux étapes suivantes :

  1. Modifiez le gabarit de base templates/base.html.twig pour y ajouter la possibilité pour une page de définir des ressources JavaScript supplémentaires.

    Ajoutez un nouveau bloc custompage_script (vide, pour l’instant) au fond du bloc javascripts existant.

    Vous obtiendrez le fragment suivant pour le bloc de base javascripts :

    {% block javascripts %}
      <!-- Bootstrap core JS-->
      <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
      <!-- Core theme JS-->
      <script src="{{ asset('js/scripts.js', 'bootstrap') }}"></script>
    
      {% block custompage_script %}
      {% endblock %} {# custompage_script #}
    {% endblock %} {# javascripts #}
    
  2. Modifiez le gabarit de la page d’accueil de l’application (présente sur le chemin /todo via le gabarit templates/index.html.twig), pour :

    1. ajouter un bloc Twig custompage_script contenant le chargement de JQuery depuis le CDN.
    2. ajouter dans le bloc Twig main :
      • un réceptacle prévu pour réceptionner la représentation de la collection qui sera calculée (<div class="divtasks"></div>)
      • un bouton cliquable par l’utilisateur pour déclencher le chargement (<button id="todosbtn">...)

    Vous devriez obtenir le résultat suivant :

    {% block main %}
    
      <h1>Welcome</h1>
    
      <p>{{ welcome }}</p>
    
      <p><button id="todosbtn">Cliquer ici pour charger la liste des tâches dynamiquement</button></p>
    
      <!-- réceptacle pour la liste des tâches chargée dynamiquement -->
      <div class="divtasks"></div>
    
    {% endblock %} {# main #}
    
    {% block custompage_script %}
    <!-- JQuery from CDN -->
    <script
      src="https://code.jquery.com/jquery-3.7.1.js"
      integrity="sha256-eKhayi8LEQwp4NKxN+CfCh+3qOVUtJn3QNZ0TciWLP4="
      crossorigin="anonymous"></script>
    {% endblock %} {# custompage_script #}
    
    
    
    
  3. Ajoutez le programme Javascript (écrit avec JQuery) permettant de cabler un réflexe derrière l’appui sur le bouton.

    On ajoute le programme suivant en fin de bloc custompage_script du gabarit templates/index.html.twig, après le chargement de JQuery :

    <script>
    // Chargement de la liste des tâches en AJAX avec JQuery
    $(document).ready(function(){
         // enregistrer un gestionnaire de clics sur le bouton d'id 'todosbtn'
         $("#todosbtn").click(function(){
                 // récupération AJAX de la liste des tâches au format JSON
                 $.get("/api/todos", function(getresult){
                         // insertion d'une liste dans la cible prévue dans le DOM
                         $(".divtasks").append('<ul>');
                         // Gestion des résultats de la liste récupérés
                         $(getresult['hydra:member']).each( function(index, item) {
                                 console.log(index, item);
                                 // ajout d'un élément dans la liste 
                                 $(".divtasks ul").append(
                                         $(document.createElement('li')).text(
                                             item.id + ' ' + ( item.title ? item.title : "pas de titre"))
                                 );
                         });
                 },
                 "json");
         });
    });
    </script>
    
    

3.3. Explications du fonctionnement du client AJAX

Ce programme JQuery est un client AJAX, qui fonctionne de la façon suivante :

  1. un réflexe est positionné pour gérer le clic sur le bouton d’identifiant todosbtn :

    $(document).ready(function(){
           // enregistrer un gestionnaire de clics sur le bouton d'id 'todosbtn'
           $("#todosbtn").click(function(){
          ... code de la fonction réflexe ...
       }
    
  2. Cette fonction réflexe réalise les opérations suivantes :
    1. récupération de la collection au format JSON (requête AJAX), et exécution d’une fonction traitant les résultats (getresult), si la requête GET est réussie :

      $.get("/api/todos", function(getresult){
         ...
      },
      "json");
      
    2. Le contenu récupéré. C’est une collection d’éléments en JSON-LD, présente dans la propriété hydra:member, qu’on avait aperçue plus tôt dans les tests sur OpenAPI.

      La collection est ensuite traité pour générer les éléments d’une liste <ul> qui sera ajoutée dans l’arbre DOM de la page, au niveau du réceptacle prévu : divtasks.

      Une boucle « for-each » applique une dernière fonction à chacun des élements de la collection :

      $(".divtasks").append('<ul>');
      
      $(getresult['hydra:member']).each( function(index, item) {
         ...
      } 
      
      

      Cette fonction ajoute un élément <li> dans la liste ul, représentant l’élément courant de la collection (item).

      Chaque morceau de la liste peut alors être construit à partir des propriétés de la représentation JSON d’une instance de Todo (id, title, …) :

      $(".divtasks ul").append(
                  $(document.createElement('li')).text(...)
          );
      

3.4. TODO Tests du client AJAX dans les outils du développeur dans le navigateur

Testez l’appui sur le bouton.

  1. Si tout va bien, le code du client AJAX se déclenche et envoie une requête de chargement des tâches au serveur, notre application Symfony.

    Vous pouvez voir cette foi encore la requête apparaître dans l’icône AJAX dans la barre d’outils Symfony.

    Avec le popup, vous pouvez accéder aux paramètres de la requête dans le profiler Symfony et voir comment APIPlatform a chargé la collection des tâches.

  2. Si tout va bien, vous pouvez aussi observer l’exécution du client JavaScript… et c’est aussi utile si par malheur, ça ne marche pas du premier coup.

Vous pouvez alors utiliser le débogueur Javascript dans les outils du développeur du navigateur.

Il vous permet d’inspecter le code HTML, pour identifier le réflexe installé sur le bouton, et faire tourner ce programme en observant son exécution dans le debogueur javascript :

  1. Vérifiez que le code source HTML de la page d’accueil contient bien le code JavaScript ci-dessus, et le réflexe déclenchant le code

    button-trigger.png

    Figure 5 : Accès au débogueur depuis le réflexe du bouton

    Accédez au code du réflexe en cliquant sur le bouton « Event » dans le DOM, et accédez au code dans le débogueur.

  2. Observez ensuite le traitement suite à l’invocation requête AJAX, avec le débogueur pas-à-pas :

    js-debugger.png

    Figure 6 : Observation pas-à-pas du code de gestion des résultats

    vous pouvez mettre un point d’arrêt sur les instructions à l’intérieur de la fonction qui traîte les résultats du get(), afficher le contenu de la variable getresult comme figuré ici

    En plus du débogueur, vous pouvez aussi ajouter des éléments de traces comme avec l’appel à console.log(), ou en en ajoutant d’autres dans le code JavaScript.

Si tout va bien, vous arrivez à mieux comprendre le fonctionnement des ajouts dans le DOM pour faire apparaître la liste à puce <ul>, <li> contenant les infos sur les tâches.

Bravo pour votre premier programme AJAX réussi.

Après ce premier programme Javascript qui illustre le fonctionnement d’AJAX, passons à une gestion un peu plus avancée des formulaires Symfony, utilisant également du code Javascript.

4. Étape 3 : Ajout d’un formulaire dynamique de création de projet avec ses tâches

L’objectif de cette deuxième partie est de passer à l’ajout d’un formulaire dynamique pour la création d’un projet avec un nombre de tâches associées variable.

On cherche à ce que les formulaires de l’application soient plus évolués. Ainsi, 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 une séquence précédente)

À la place, on 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 ci-dessous 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.

4.1. Étape 3-a : Modification du formulaire de nouveau projet

Cette première étape consiste à mettre en place le moyen de tester les modifications sur l’application ToDo.

On va ajouter dans la page qui affiche le formulaire, un script JQuery, qui sera capable de modifier le contenu de la page Web. Il y 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é, la requête HTTP POST sera envoyée, et là, Symfony interviendra à nouveau côté serveur. 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.

Si le code client JQuery a bien fait le job, ces données seront formattées comme il faut pour Symfony

Avant que le code client JavaScript puisse fonctionner, il faut donc mettre en place le nécessaire dans l’application Symfony, qui s’exécute côté serveur.

4.1.1. TODO Ajout de la création de tâches de test dans le Contrôleur

Commençons par la mise en place, dans le modèle des données, 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: 'app_project_new', methods: ['GET', 'POST'])]
        public function new(Request $request, EntityManagerInterface $entityManager): 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);
            $form->handleRequest($request);
    
            if ($form->isSubmitted() && $form->isValid()) {
                $entityManager->persist($project);
                $entityManager->flush();
    
                return $this->redirectToRoute('app_project_index', [], Response::HTTP_SEE_OTHER);
            }
    
            return $this->render('project/new.html.twig', [
                'project' => $project,
                'form' => $form,
            ]);
        }
    

    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.

    Vous devrez peut-être adapter le code ci-dessous :

    use Symfony\Component\Form\Extension\Core\Type\CollectionType;
    //...
        public function buildForm(FormBuilderInterface $builder, array $options)
        {
            $builder
                ->add('title')
                ->add('description')
                ->add('todos', CollectionType::class, [
                    'entry_type' => TodoType::class,
                    'entry_options' => [
                        'label' => false,
                        //'task_is_new' => true,
                        'display_project' => 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).

    Dans entry_options, on passe les options nécessaires à TodoType, dont notre option display_project qui signale que le champ du projet ne doit pas être affiché dans TodoType::buildForm. Selon vos choix d’implémentation des séances précédentes, vous devrez peut-être adapter les valeurs du tableau entry_options (ajout d’une valeur par défaut pour task_is_new, par exemple).

  3. Vérifiez que le formulaire de création de nouveau projet s’affiche.

    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.

    project-new-1.png

    Figure 7 : Formulaire de création de nouveau projet incluant des tâches « bidon »

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

4.1.2. TODO Modification du gabarit de création d’un nouveau projet

Il faut maintenant 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-formulaires 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 :

    {# Removed: extends 'base.html.twig' #}
    {% 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 'base.html.twig' %}
    
    {% block custompage_script %}
       <!-- JQuery from CDN -->
       <script
        src="https://code.jquery.com/jquery-3.7.1.js"
        integrity="sha256-eKhayi8LEQwp4NKxN+CfCh+3qOVUtJn3QNZ0TciWLP4="
        crossorigin="anonymous"></script>
       <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 base.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 dans quelques instants.

  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_errors(form) }}
    
        <div class="row">
            <div class="col">
                {{ form_row(form.title) }}
                {{ form_row(form.description) }}
            </div>
            <div class="col">
    
                {% dump form.todos %}
    
                <h3>Tasks</h3>
                <ul class="todos">
                     {# form_row(form.todos) #}
                     {# passer en revue chaque todo existante pour afficher ses champs #}
                     {% for todo in form.todos %}
                            <li>{{ form_row(todo) }}</li>
                     {% endfor %}
                 </ul>
    
            </div>
        </div>
    
        <button class="btn btn-primary">{{ 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 toujours quand on invoque la page de création d’un nouveau projet. La mise en page des champs a juste changé un peu.

    project-new-2.png

    Figure 8 : Formulaire de création de nouveau projet toujours en cours de refonte

  6. Observez la structure du formulaire Symfony dans le Profiler. Remarquez qu’il est composé de sous-formulaires associés aux objets de la Collection gérant les sous-tâches instances de Todo.

    form-details-1.png

    Figure 9 : Structure interne des sous-formulaires Symfony

  7. Observez, avec l’inspecteur des outils de développement du navigateur, la structure des sous-formulaires dans l’arbre DOM.

    form-details-dom-1.png

    Figure 10 : Structure interne des sous-formulaires HTML

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.1.3. TODO Mise en place d’un prototype de formulaire de nouvelle tâche

On va maintenant ajouter au formulaire de nouveau projet, ce qui permet de le rendre dynamique via un code Javascript.

Procédez aux étapes suivantes :

Si besoin de plus de d’explications, 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.

  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, [
                'entry_type' => TodoType::class,
                'entry_options' => [
                    'label' => false,
                    //'task_is_new' => true,
                    'display_project' => 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’objectif 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">
        {# form_row(form.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') }}">
        {# form_row(form.todos) #}
        {# 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 de l’affichage du formulaire de création de nouveau projet, et vérifiez que 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, completed, etc. des sous-tâches. En voici une version indentée, donc plus lisible :

    <div id="project_todos___name__">
      <div class="mb-3">
        <label for="project_todos___name___title" class="form-label">Title</label>
        <input type="text" id="project_todos___name___title"
               name="project[todos][__name__][title]" maxlength="255"
               class="form-control" />
      </div>
    </div>
    

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

  4. Ajoutez enfin un bouton qui servira à déclencher l’ajout d’une nouvelle tâche, juste après la liste à puces <ul>...</ul> :

      {% for todo in form.todos %}
        <li>{{ form_row(todo) }}</li>
      {% endfor %}
    </ul>
    <button type="button" class="add_item_link" data-collection-holder-class="todos">Add a task</button>
    

C’est tout bon du côté de Twig et du gestionnaire de formulaires Symfony.

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

4.2. TODO Étape 3-b : Ajout du code Javascript pour rendre le formulaire dynamique

Dans cette étape, on va rendre le formulaire réellement dynamique, en programmant en JQuery.

Cette section bascule sur des outils différents : autre langage (JavaScript avec JQuery), et autres outils de mise au point : les outils du développeur Web dans le navigateur.

On peut oublier Symfony quelques temps.

4.2.1. TODO 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;
    
    jQuery(document).ready(function() {
     // Get the ul that holds the collection of todos
     $collectionHolder = $('ul.todos');
    
     // count the current items in the ul/li we have (e.g. 2), use that as the new
     // index when inserting a new item (e.g. 2)
     $collectionHolder.data('index', $collectionHolder.find('li').length);
    
     $('button.add_item_link').on('click', function(e) {
         // add a new todo form (see next code block)
         addTodoForm($collectionHolder);
     });
    });
    
    function addTodoForm($collectionHolder) {
        // 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 todos field in ProjectType
        // 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);
        $collectionHolder.append($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 »).

    En cas de soucis, aidez-vous de la « console web », de l’inspecteur et du débogueur Javascript pour voir si tout est opérationnel :

    • le bouton doit avoir un réflexe qui apparaît dans l’inspecteur DOM, et pointe vers le code d’appel à addTodoForm()
    • vous pouvez mettre des points d’arrêt dans addTodoForm() dans le débogueur Javascript pour voir ce qui se passe, pas à pas.

    debugging-addtodoform.png

    Figure 11 : Exemple de déboguage de la construction de la liste des sous-formulaires dans le DOM

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.

4.2.2. TODO Gestion de la suppression des sous-formulaires des tâches

On doit maintenant 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, grâce à une nouvelle fonction addTodoFormDeleteLink( ) :
    1. insérez le code correspondant qui modifie tous les <li> des tâches, dans le formulaire existant :

      var $collectionHolder;
      
      jQuery(document).ready(function() {
          // Get the ul that holds the collection of todos
          $collectionHolder = $('ul.todos');
      
          // NEW here :
          //add a delete link to all of the existing task form li elements
          $collectionHolder.find('li').each(function() {
              addTodoFormDeleteLink($(this));
          });
          // END NEW
      
          //...
      
    2. puis ajoutez, presque à la fin de la fonction addTodoForm(), l’appel nouvelle fonction :

      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);
      
          // NEW here :
          // add a delete link to the new form
          addTodoFormDeleteLink($newFormLi);
          // END NEW
      
          $collectionHolder.append($newFormLi);
      }
      
    3. et enfin, ajoutez la fonction addTodoFormDeleteLink( ) :

      // NEW here :
      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();
          });
      }
      // END NEW
      
      
  3. Testez le fonctionnement des liens de suppression et leur impact sur le DOM de la page.

4.3. TODO Étape 3-c : Finalisation du fonctionnement du formulaire

Cette étape va nous permettre de terminer le fonctionnement de l’ajout via ce formulaire dynamique, et tester que les créations de projets et de leurs tâches fonctionnent bien.

Maintenant que le JavaScript est en place, on peut revenir côté application Symfony, dans les contrôleurs, pour finaliser le tout. Retour à PHP, Doctrine et au Profiler pour la mise-au-point.

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’occupe 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/6.4/reference/forms/types/collection.html#by-reference) :

    public function buildForm(FormBuilderInterface $builder, array $options)
        {
            $builder
                ->add('todos', CollectionType::class, [
                    ...
                    '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.

4.3.1. TODO 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 propriétés du projet soient sauvées, mais aussi les ajouts, modifications ou suppressions des données de ses 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, lors des requêtes POST.

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

Normalement, vous erreur 500 « Multiple non-persisted new entities were found through the given association graph:… ». On va voir plus loin comment la régler.

Mais intéressons-nous déjà au fonctionnement du formulaire HTML et à la gestion à la réception de la requête HTTP POST à la soumission des données, avant que l’erreur soit survenue.

  1. dans le Profiler de la barre d’outils Symfony, vous allez examiner les détails de la requête POST sur http://localhost:8000/project/new.

    Au cas où il n’y aurait pas eu d’erreur, remontez dans l’historique des requêtes antérieures, avant la redirection.

  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 :
      • 0
        • title
        • created
        • updated
      • 1
        • title
        • created
        • updated

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.

Si vous n’avez pu accéder au menu Doctrine, car l’erreur 500 est déclarée avant, voyons ce qui la provoque.

4.3.2. TODO 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 se produit dans l’appel à $entityManager->flush();, soit au niveau de la tentative de génération des requêtes à envoyer à la base de données, avec 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. Examinons la signification de ce message d’erreur complet, et qui propose une solution pour remédier au problème :

    To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={« persist »}).

    L’explication du problème qu’on vient de résoudre est donnée dans la documentation, dans l’encadré « Doctrine: Cascading Relations and saving the « Inverse » side » de https://symfony.com/doc/6.4/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 (à cause du « by_reference » mis à « false »).

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

    if (!$this->todos->contains($todo)) {
        $this->todos->add($todo);
        $todo->setProject($this);
    }
    
    • on ajoute le nouveau todo dans la collection des todos de ce projet,
    • puis on met à jour le pointeur inverse, du Todo vers son Project.

    C’est correct. De nouvelles entités sont donc censées être présentes en mémoire dans App\Entity\Project#todos. Et c’est bien le cas, comme nous l’indique Doctrine dans le message d’erreur : « A new entity was found through the relationship ’App\Entity\Project#todos’… ». OK, jusqu’ici.

    Examinons maintenant le code traitant les données du formulaire dans notre contrôleur ProjectController :

    if ($form->isSubmitted() && $form->isValid()) {
        $entityManager->persist($project);
        $entityManager->flush();
    
        //...
    

    C’est bien l’endroit où se produit l’erreur, sur $entityManager->flush();.

    Vous pouvez intercaler un dump($project) entre l’appel à persist() et l’appel flush().

    On voit notre structure de données dans le Profiler dans l’affichage du dump(). Remarquez que les identifiant sont tous nuls, puisque ce sont des données uniquement présentes en mémoire et pas encore sauvegardées (c’est le SGBD qui attribue les identifiants Doctrine).

    Toute la question est maintenant de vérifier quelles sont les données qui doivent être sauvegardées, pour éviter l’erreur.

    Ce sont celles qui sont « marquées » comme telles avec l’appel à $entityManager->persist($project);. A priori, seul le projet est marqué ainsi.

    Comme indiqué dans la proposition de correction du message d’erreur :

    • soit on devrait écrire une boucle pour appeler persist( ) explicitement pour chaque élément de $project->getTodos(),
    • 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"}.

    Choisissons l’annotation qui semble beaucoup plus pratique, dans ce cas. Consultez la documentation doctrine pour plus de détails.

  3. Procédez aux modifications suggérées en ajoutant cascade: ['persist'] dans l’attribut Doctrine ORM\OneToMany de Project:todos dans src/Entity/Project.php :

    #[ORM\OneToMany(targetEntity: Todo::class, mappedBy: 'project', cascade: ['persist'])]
    private Collection $todos;
    
    

Vérifiez que cela fonctionne correctement maintenant.

En revenant dans le Profiler sur l’examen du traitement de la requête POST, examinez les requêtes Doctrine.

Y a-t-il les INSERT INTO todos attendus ? Cela doit maintenant fonctionner : d’abord l’INSERT pour le projet lui-même, puis les INSERT des tâches des sous-formulaires dynamiques.

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

4.4. Étape 3-d : Correction de la suppression dynamique des tâches d’un projet

Cette dernière étape va nous permettre de finaliser le fonctionnement du formulaire en modification, et de tester que les retraits des tâches supprimées du projet fonctionne bien

Le code du formulaire dynamique fonctionne pour l’ajout de tâches à un nouveau projet.

Mais à la modification d’un projet existant ayant déjà des tâches, le comportement n’est pas exactement conforme à ce qu’on souhaite.

4.4.1. TODO 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;
  6. Dans le Profiler, remontez aux requêtes précédentes, jusqu’à la requête POST de soumission du formulaire. Dans l’outil Doctrine vous devriez voir que les requêtes SQL ne contiennent pas de DELETE, mais un UPDATE todo SET project_id = NULL WHERE id =...

Dans notre application, disons que ce n’est pas le comportement souhaité.

Pour les instances de Todo créées comme sous-tâches dans le contexte d’un projet, si on les supprime depuis la modification de leur projet, on souhaiterait qu’au lieu d’être détachées du projet, comme c’est actuellement le cas, elles soient supprimées complètement.

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 recherche pour l’instant.

4.4.2. TODO 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 à nouveau 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 du Profiler Symfony.
  3. Examinez les requêtes transmises par Doctrine à la base de données : des requêtes du type UPDATE todo 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 (remise à zéro de la valeur optionnelle de la clé étrangère), mais pas à la suppression de la Todo (on devrait voir une requête DELETE). 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 grâce à la clé étrangère de la table project migrée dans la table todo (dans project_id).

Examinons le code de Project:removeTodo() qui est appelé par la suppression :

if ($this->todos->removeElement($todo)) {
    // set the owning side to null (unless already changed)
    if ($todo->getProject() === $this) {
        $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 Project (toujours en mémoire).

Examinons la traduction que Doctrine effectue lors du $entityManager->persist($project); dans le contrôleur.

Cette instruction n’aurait pas nécessairement d’impact sur les données en base de données sur les Todo, si l’instance de Project est la seule à être marquée comme devant être sauvegardée par le persist(). Mais on a défini un « cascade: persists » un peu plus haut.

Doctrine va donc balayer les tâches du projet supprimées, en cascade, suite à ce persist(). Mais avec quel impact sur chacune de ces tâches ?

Dans la configuration de notre modèle des données, qui est définie via les annotations Doctrine, on n’a établi qu’un lien de type association entre Project et Todo, mais pas une composition (forte). En effet, on n’a en principe pas défini l’annotation orphanRemoval: true (si vous avez suivi scrupuleusement les indications à la création de l’association via les questions posées par le générateur de code make:entity).

Cela signifie qu’une tâche sans projet peut donc exister seule, mais une tâche peut aussi être associée à un projet. Et Doctrine n’a pas à gérer la suppression sans qu’on le lui demande.

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, pour sauvegarder dans la base cette rupture de l’association.

Ça pourrait être exactement le comportement souhaité dans d’autres applications. Doctrine ne peut pas deviner tout… et les questions de l’assistant générateur de code étaient un peu floues au départ, en ce qui nous concerne…

4.4.3. TODO Correction : ajout explicite de la suppression

On va appliquer la suggestion mentionnée dans la documentation dans l’encadré « Doctrine: Ensuring the database persistence » en fin de page de https://symfony.com/doc/6.4/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érations de suppression des sous-tâches, avant le flush().

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 celles qui ont été supprimées ne sont plus présentes;
  3. on compare les deux et on marque explicitement celles qui doivent être supprimées.

Voici un exemple code réalisant cette fonctionnalité :

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 (false === $project->getTodos()->contains($todo)) {
                    // la Todo n'est plus présente dans le tableau. Il faut donc la supprimer de la base.
                    $project->getTodos()->removeElement($todo);
                    $entityManager->persist($todo);
                    $entityManager->remove($todo);

                }

            }

            $entityManager->flush();

            return $this->redirectToRoute('app_project_index', [], Response::HTTP_SEE_OTHER);
        }

        return $this->render('project/edit.html.twig', [
            'project' => $project,
            'form' => $form,
        ]);
    }

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.

5. É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 on peut implémenter une API par desssus notre modèle de données Doctrine, avec ApiPlatform
  • comment fonctionne un code JavaScript client AJAX qui fait des requêtes sur l’API de notre application Symfony
  • 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.)
  • que les formulaires Symfony sont très sophistiqués, mais qu’on n’en a utilisé que les fonctionnalités les plus basiques
  • où trouver les outils de mise au point à la fois dans le Profiler Symfony, que dans les outils du développeur dans le navigateur.

Author: Olivier Berger (TSP)

Date: 2024-11-13 Wed 14:11

Emacs (Org mode)