TA n°7-8 - Implémentation de formulaires pour entités liées
Importance de la contextualisation pour une application réelle

Table des matières

1. Introduction

Vous allez approfondir votre connaissance des Contrôleurs et formulaires Symfony, pour mettre en œuvre la gestion des entités liées dans le Modèle de données de l’application, de façon proche de ce qu’on attend dans une application réelle.

Dans une première partie, on verra le cœur du sujet avec une mise en œuvre allant plus loin que ce que fait le code généré par les makers.

Dans une seconde partie on verra comment gérer le télé-versement (upload) d’images dans l’application.

2. Étape 1 : Gestion d’associations 1-N dans une application réaliste

L’objectif de cette étape est de comprendre précisément comment mieux gérer les pages et formulaires de gestion de données liées par une association 1-N.

On va voir comment on doit dépasser les contrôleurs « CRUD basiques », pour s’approcher d’une expérience utilisateur réaliste, notamment quand on a des données liées entre-elles dans le modèle de données.

L’ajout de nouvelles fonctionnalités à une application nécessite l’extension du Modèle des données de l’application, et l’ajout du code de gestion des pages correspondantes.

On va examiner en particulier le cas des associations 1-N, qui sont très fréquentes en pratique.

On a déjà vu que des outils de génération de code permettent de faciliter le prototypage des pages et des formulaires. Plutôt que d’écrire du code « à la main » pour ajouter ce type de fonctions à l’application, on peut utiliser des outils fournis par Symfony pour générer du code, dans le maker bundle.

En particulier, on apprécie d’utiliser l’assistant make:crud qui génère pour nous des Contrôleurs et leurs gabarits. Ceci diminue le risque d’erreurs et d’oublis, et accélère le développement, au moins pour un prototype de la fonction voulue. Le code généré est du code PHP pour Symfony complètement « normal » qui peut (et doit) être retouché ensuite à loisir.

Cependant, pris tel-quel, ce code n’offre qu’un prototype, des mécanismes de CRUD très basiques, proches de ce dont on aurait besoin dans un backoffice destiné à des adminisrateurs.

Nous allons maintenant essayer d’aller plus loin que le prototypage, pour nous rapprocher de ce qu’on attend d’une application Web « normale ».

Nous allons voir l’importance de la finalisation qui doit être réalisée par les développeurs, pour gérer en particulier la contextualisation nécessaire dans la navigation entre pages et formulaires, dans une application destinée aux utilisateurs finaux.

2.1. TODO Étape 1-a : Ajout de l’entité Project

L’objectif de cette étape est d’ajouter l’entité Projet au modèle des données de l’application.

Dans cette nouvelle évolution de l’application fil-rouge ToDo, on ajoute une gestion de « projets » dans lesquels on peut ranger des tâches.

Il s’agit d’une simple association, transitoire, et non d’une composition. Un projet peut contenir de 0 à N tâches et une tâche peut être associée ou non à 1 projet.

Procédez aux modifications suivantes :

  1. Ajoutez l’entité Project qui sera liée aux entités des tâches Todo via une association 1-N, en utilisant le générateur Symfony pour Doctrine.

    symfony console make:entity
    
    Class name of the entity to create or update (e.g. DeliciousPopsicle):
    > Project
    
    created: src/Entity/Project.php
    created: src/Repository/ProjectRepository.php
    
    Entity generated! Now let's add some fields!
    You can always add more fields later manually or by re-running this command.
    
    New property name (press <return> to stop adding fields):
    > title
    
    Field type (enter ? to see all types) [string]:
    > 
    
    Field length [255]:
    > 
    
    Can this field be null in the database (nullable) (yes/no) [no]:
    > 
    
    updated: src/Entity/Project.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > description
    
    Field type (enter ? to see all types) [string]:
    > text
    
    Can this field be null in the database (nullable) (yes/no) [no]:
    > yes
    
    updated: src/Entity/Project.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > todos
    
    Field type (enter ? to see all types) [string]:
    > ?
    
    Main types
     * string
     * text
     * boolean
     * integer (or smallint, bigint)
     * float
    
    Relationships / Associations
     * relation (a wizard 🧙 will help you build the relation)
     * ManyToOne
     * OneToMany
     * ManyToMany
     * OneToOne
    
    Array/Object Types
     * array (or simple_array)
     * json
     * object
     * binary
     * blob
    
    Date/Time Types
     * datetime (or datetime_immutable)
     * datetimetz (or datetimetz_immutable)
     * date (or date_immutable)
     * time (or time_immutable)
     * dateinterval
    
    Other Types
     * ascii_string
     * decimal
     * guid
     * uuid
     * ulid
    
    
    Field type (enter ? to see all types) [string]:
    > OneToMany
    
    What class should this entity be related to?:
    > Todo
    
    A new property will also be added to the Todo class so that you can access and set the related Project object from it.
    
    New field name inside Todo [project]:
    > 
    
    Is the Todo.project property allowed to be null (nullable)? (yes/no) [yes]:
    > 
    
    updated: src/Entity/Project.php
    updated: src/Entity/Todo.php
    
    Add another property? Enter the property name (or press <return> to stop adding fields):
    > 
    
    
    
     Success! 
    
    
    Next: When you're ready, create a migration with symfony console make:migration
    

    Notez que le champ project ajouté à Todo ci-dessus, peut être mis à null, ce qui signifie qu’une tâche peut n’avoir aucun projet : association simple, et non composition.

  2. Ensuite, recréez la base de données :
    • symfony console doctrine:database:drop --force
    • symfony console doctrine:database:create
    • symfony console doctrine:schema:create
    • symfony console doctrine:fixtures:load -n
  3. Puis générez les classes gérant un contrôleur CRUD pour Project

    symfony console make:crud
    
    The class name of the entity to create CRUD (e.g. VictoriousPizza):
    > Project
    
    Choose a name for your controller class (e.g. ProjectController) [ProjectController]:
    > 
    
    Do you want to generate tests for the controller?. [Experimental] (yes/no) [no]:
    > 
    
    created: src/Controller/ProjectController.php
    created: src/Form/ProjectType.php
    created: templates/project/_delete_form.html.twig
    created: templates/project/_form.html.twig
    created: templates/project/edit.html.twig
    created: templates/project/index.html.twig
    created: templates/project/new.html.twig
    created: templates/project/show.html.twig
    
    
     Success! 
    
    
    Next: Check your new CRUD by going to /project/
    
  4. Vérifiez la présence des routes correspondantes dans l’application :

    symfony console debug:router
    
    
    ->app_project_index                  GET        ANY      ANY    /project/       
    
    
  5. Ajoutez enfin une entrée du menu Bootstrap, dans config/packages/bootstrap_menu.yaml :

    bootstrap_menu:
      menus:
        main:
          items:
            projects:
               label: 'Projects list'
               route: 'app_project_index'
    # ...
    
  6. Nettoyez le cache (Symfony se comporte souvent bizarement après l’exécution du maker de CRUD…) :

    symfony console cache:clear
    
  7. Testez que l’application fonctionne bien, et que le CRUD des projets fonctionne.

2.2. Étape 1-b : Gestion du lien entre projet et tâches

L’objectif de cette étape est de modifier le reste de l’application pour permettre la navigation entre projets et tâches

2.2.1. TODO Ajout du lien vers le projet dans le formulaire de modification des tâches

On souhaite tout d’abord pouvoir sauvegarder dans la base de données le fait qu’une tâche peut faire partie d’un projet.

Les formulaires actuels permettent de créer une tâche, mais pas de l’affecter à un projet à la création.

De la même façon on voudra pouvoir modifier les tâches existantes pour les ranger dans un projet.

Cela correspondra à gérer la saisie ou la mise à jour de la valeur de l’attribut Todo::project qu’on a jouté à l’étape précédente, dans les formulaires.

  1. Chargez la page d’ajout d’une nouvelle tâche /todo/new. Déroulez l’ajout et observez dans le Profiler Symfony le détail de la gestion du formulaire (Form) dans la requête POST de soumission des données.

    On voit que le Form ne contient que les anciens champs, mais pas le nouveau champ project.

    Par contre, le Normalized Format qui génère les données Doctrine sous forme de Todo (App\Entity\Todo) nous montre les valeurs pour l’entité actuelle :

    App\Entity\Todo {#525 ▼
      -id: 5
      -title: "whatever"
      -completed: false
      -created: DateTime @1724858066 {#526 ▶}
      -updated: DateTime @1724858066 {#527 ▶}
      -tags: Doctrine\ORM\PersistentCollection {#766 …}
      -project: null
    }
    

    En mémoire, la tâche a un champ project nul, puisqu’il n’est pas renseignable dans le formulaire.

  2. Modifiez en conséquence la classe de gestion du formulaire de modification des tâches (TodoType, dans src/Form/), pour ajouter un champ project dans le formbuilder :

    $builder
         ->add('project');
    
  3. Testez dans l’application pour ouvrir le formulaire de modification de tâches d’une tâche créée juste auparavant.

    Normalement, à ce stade, le formulaire fonctionne, proposant une liste de projets vide : normal, nous n’avons pas encore saisi de projets dans la base.

    form-todo-project-1.png

    Figure 1 : Champs du formulaire de création de nouvelle tâche

  4. Ajoutez maintenant un nouveau projet dans la base de données via le formulaire de création de nouveau projet.
  5. Retournez créer une tâche.

    Normalement, vous devriez constater une erreur à l’ouverture du formulaire d’édition de la tâche : « Object of class App\Entity\Project could not be converted to string ».

    La construction du formulaire, très basique, doit en effet afficher la liste des projets dans la liste de saisie vue plus haut. Pour cela, elle doit les afficher sous forme de chaîne de caractères dans cette liste…

    Modifiez en conséquence la classe Project pour ajouter une méthode __toString(). Vous pouvez vous inspirer d’une autre méthode __toString() déjà présente dans une autre classe du modèle des données.

  6. Vérifiez que la création ou modification des tâches fonctionne enfin, permettant d’y définir le projet d’une tâche.
  7. Utilisez la barre d’outils Symfony pour examiner dans le Profiler le comportement de la requête POST de soumission des données modifiées.
    • Dans l’outil Forms vous devriez voir que le champ project est bien renseigné et que les données Doctrine sont correctement modifiées en mémoire :

      App\Entity\Todo {#525 ▼
        -id: 7
        -title: "Ajouter une association 1-N"
        -completed: false
        -created: DateTime @1724859017 {#526 ▶}
        -updated: DateTime @1724859017 {#527 ▶}
        -tags: Doctrine\ORM\PersistentCollection {#840 …}
        -project: App\Entity\Project {#937 ▶}
      }
      

      si vous dépliez le contenu de la propriété project (« #937 », dans cet exemple), vous verrez la valeur du projet référencé :

      App\Entity\Todo {#525 ▼
        -id: 7
        -title: "Ajouter une association 1-N"
        -completed: false
        -created: DateTime @1724859017 {#526 ▶}
        -updated: DateTime @1724859017 {#527 ▶}
        -tags: Doctrine\ORM\PersistentCollection {#840 …}
        -project: App\Entity\Project {#937 ▼
          -id: 2
          -title: "Beau projet"
          -description: "Car c'est notre projet !"
          -todos: Doctrine\ORM\PersistentCollection {#931 …}
        }
      }
      

      On a bien une entité Doctrine Todo qui fait référence à son projet, une instance de Project.

    • Dans l’outil Doctrine, vous devriez voir la requête INSERT.

      En version « /runnable » :

      INSERT INTO todo
             (title, completed, created, updated, project_id)
      VALUES
              ('Ajouter une association 1-N', 0, '2024-08-28 15:30:17',
               '2024-08-28 15:30:17', 2);
      

      La clé étrangère du projet (project_id) est bien renseignée à la valeur de l’id du projet (ici « 2 ») : le nouveau champ fonctionne.

  8. Vous pouvez maintenant vérifier aussi avec un dump() que les liens vers les projets des tâches sont bien définis, même si on ne les affiche pas encore.

    Modifiez par exemple templates/todo/index.html.twig pour ajouter un {% dump todo %} à l’intérieur du {% for todo in todos %}.

    L’affichage du dump doit ressembler à ceci, pour les tâches qu’on a affectée à un projet :

    App\Entity\Todo {#640 ▼
      -id: 7
      -title: "Ajouter une association 1-N"
      -completed: false
      -created: DateTime @1724859017 {#638 ▶}
      -updated: DateTime @1724859017 {#639 ▶}
      -tags: Doctrine\ORM\PersistentCollection {#641 ▶}
      -project: Proxies\__CG__\App\Entity\Project {#634 ▼
        -id: 2
        -title: ? ?string
        -description: ? ?string
        -todos: ? Doctrine\Common\Collections\Collection
        -lazyObjectState: Symfony\Component\VarExporter\Internal\LazyObjectState {#635 ▶}
      }
    }
    

    Remarquez que tant qu’on n’a pas accédé aux attributs d’un projet, l’attribut interne todo:project référence une entité « proxy », qui n’est pas initialisée (__isInitialized__), mais porte bien l’identifiant du projet concerné (id).

    C’est le mécanisme de chargement paraisseux dont nous parlerons en cours. Tant qu’on n’accède pas aux attributs du Project, inutile de charger ses données depuis la base de données.

2.2.2. TODO Ajout du projet dans la consultation d’une tâche

Modifiez le gabarit d’affichage d’une tâche pour afficher son projet, s’il est renseigné.

  1. Modifiez templates/todo/show.html.twig pour ajouter l’affichage de l’attribut todo.project. Vous pouvez spécialiser l’affichage, au cas où il n’y a pas de projet pour une tâche, avec un bloc if :

    {%  if todo.project %}
       Projet : {{ todo.project }}
    {% endif %}
    
  2. Comparez l’affichage entre tâches d’un projet ou tâches isolées. La section optionnelle devrait s’afficher comme souhaité.
  3. Modifiez le gabarit pour ajouter des dump() pour afficher l’état de la tâche, et de la référence à son projet en mémoire, à différents endroits au début du gabarit : à la fois avant, et après l’affichage de l’attribut todo.project :

    {% dump todo %}
    
    {# todo.project avant consultation de ses attributs #}
    {% dump todo.project %}
    
    {% if todo.project %}
    
       {# affichage dans le gabarit #}
       Projet : {{ todo.project }}
    
       {% dump todo %}
    
    {% endif %}
    
  4. Testez l’affichage de la page pour une tâche ayant un projet.
    1. Regardez les deux premiers dumps faits avant l’accès au champ todo.project pour son affichage effectif dans la page, avec {{ todo.project }} : normalement, il s’agit, comme dans l’étape précédente, d’une entité Proxy non initialisée (« #535 »).

      App\Entity\Todo {#493 ▼
        -id: 7
        -title: "Ajouter une association 1-N"
        -completed: false
        -created: DateTime @1724859017 {#487 ▶}
        -updated: DateTime @1724859017 {#478 ▶}
        -tags: Doctrine\ORM\PersistentCollection {#508 ▶}
        -project: Proxies\__CG__\App\Entity\Project {#535 ▼
          -id: 2
          -title: ? ?string
          -description: ? ?string
          -todos: ? Doctrine\Common\Collections\Collection
          -lazyObjectState: Symfony\Component\VarExporter\Internal\LazyObjectState {#536 ▶}
        }
      }
      
    2. Regardez maintenant le 3ème dump. Cette fois, comme l’objet a dû être affiché, dans le contenu de la page, Twig a provoqué l’accès réel à l’objet depuis la base de données.

      App\Entity\Todo {#493 ▼
        -id: 7
        -title: "Ajouter une association 1-N"
        -completed: false
        -created: DateTime @1724859017 {#487 ▶}
        -updated: DateTime @1724859017 {#478 ▶}
        -tags: Doctrine\ORM\PersistentCollection {#508 ▶}
        -project: Proxies\__CG__\App\Entity\Project {#535 ▼
          -id: 2
          -title: "Beau projet"
          -description: "Car c'est notre projet !"
          -todos: Doctrine\ORM\PersistentCollection {#1194 ▶}
          -lazyObjectState: Symfony\Component\VarExporter\Internal\LazyObjectState {#536 ▶}
        }
      }
      

      Cette fois, la classe Proxy est initialisée, et les valeurs des attributs du Project autres que l’id sont bien renseignés à leurs valeurs.

    3. Regardez, dans l’outil Doctrine de la barre Symfony, les requêtes qui sont faites en base : pour cette même tâche ayant un projet, il y a 2 requêtes :
      • pour charger la tâche (au déclenchement de la méthode show()) :

        SELECT
          t0.id AS id_1,
          t0.title AS title_2,
          t0.completed AS completed_3,
          t0.created AS created_4,
          t0.updated AS updated_5,
          t0.project_id AS project_id_6
        FROM
          todo t0
        WHERE
          t0.id = '7';
        

        C’est cet état qu’on voit dans les premiers dumps. On n’a connaissance que de la propriété project_id

      • et la deuxième, plus tardive, pour initialiser l’instance de Project, puisqu’il s’agit de l’afficher dans le gabarit, c’est à dire appeler Project::_toString() :

        SELECT
          t0.id AS id_1,
          t0.title AS title_2,
          t0.description AS description_3
        FROM
          project t0
        WHERE
          t0.id = 2;
        
    4. Comparez avec l’affichage d’une tâche n’ayant pas de projet. En toute logique, on voit bien qu’on ne fait qu’une requête.

Vous voyez clairement à l’œuvre le mécanisme de Lazy loading, avec le patron de conception Proxy utilisé par Doctrine.

2.2.3. TODO Ajout de la liste des tâches dans la consultation des projets

Maintenant que vous pouvez renseigner en base de données des liens entre tâches et projets, vous pouvez ajouter l’affichage de la liste des tâches d’un projet, dans le gabarit de la fiche de consultation d’un projet (templates/project/show.html.twig) :

{% dump(project) %}

{# FIXME: display the project todos
{% for todo in ... %}
...
   <a href="{{ path('app_todo_show', {'id' : todo.id}) }}">{{ todo.title }}</a>
...
{% else %}
   <em>no tasks found</em>
{% endfor %}
/FIXME #}

2.2.4. TODO Ajout du projet dans la liste des tâches

Ajoutez dans la page de consultation de toutes les tâches, une colonne permettant l’affichage du projet, s’il est défini (gabarit templates/todo/index.html.twig).

2.2.5. TODO Ajout des éléments de navigation entre pages

Vous pouvez maintenant compléter les pages existantes pour permettre de passer de la consultation d’une tâche à son projet (s’il existe), et compléter ainsi la navigation dans le modèle de données.

Modifiez templates/todo/show.html.twig pour compléter l’affichage de todo.project sous forme de lien (en vous inspirant de ce qui vient d’être fait pour le lien inverse de projet à tâche, avec path()).

Attention : l’application doit continuer de fonctionner aussi bien pour des tâches ayant un projet, que pour des tâches sans projet.

2.3. Étape 1-c : Formulaire d’ajout d’une tâche dans un projet

On souhaite maintenant améliorer les pages de notre application pour gérer un formulaire moins basique que ceux générés par les assistants de génération de CRUD.

Vous allez ajouter un nouveau formulaire qui permet, une fois connu un projet, d’ajouter une tâche dans le contexte de ce projet, directement.

On souhaite que l’utilisateur qui consulte la fiche d’un projet puisse ajouter une tâche directement depuis cette page de l’application, plutôt que de devoir revenir à la liste des tâches, et en créer une, qui serait ensuite rattachée au projet.

2.3.1. TODO Ajout du lien d’appel à un nouveau formulaire

Modifiez le gabarit d’affichage d’un projet, pour ajouter, en dessous de la liste des tâches du projet, un lien vers un formulaire (que nous allons ajouter juste après).

Modifiez templates/project/show.html.twig pour ajouter un lien du type :

<tr>
        <th>Tâches</th>
        <td>
                <ul>
                  {% for todo in project.todos %}
                  ...
                  {% else %}
                  ...
                  {% endfor %}
                </ul>
                  {# CHANGEZ le nom de route une fois le formulaire de création de tâche ajouté #}
                  <a href="{{ path('app_project_show', {'id' : project.id}) }}">add new task</a>
        </td>
</tr>

Pour l’instant, le lien pointe sur la même page. On le modifiera plus tard, une fois ajouté la gestion du formulaire de nouvelle tâche.

2.3.2. TODO Ajout du formulaire de création d’une tâche dans le contexte d’un projet

Comme on connaît le projet en cours de consultation, on va directement invoquer l’ajout d’une tâche à ce projet, en utilisant un nouveau formulaire d’ajout de tâche ayant connaissance du projet courant.

Ajoutez différents éléments au code de l’application :

  1. créez un nouveau gabarit nommé todo/newinproject.html.twig, sur le modèle de todo/new.html.twig;
  2. ajoutez une méthode TodoController::newInProject() reprenant certains éléments de TodoController::new(), mais ajoutant une todo à un projet existant

    Son code correspond à ce qui suit :

    //...
    use App\Entity\Project;
    
         //...
    
         #[Route('/newinproject/{id}', name: 'app_todo_newinproject', methods: ['GET', 'POST'])]
         public function newInProject(Request $request, EntityManagerInterface $entityManager, Project $project): Response
         {
                 $todo = new Todo();
                 // already set a project, so as to not need add that field in the form (in TodoType)
                 $todo->setProject($project);
    
                 $form = $this->createForm(...);
                 //...
                 if ($form->isSubmitted() && $form->isValid()) {
                         //...
                         return $this->redirectToRoute('app_project_show',
                                                       ['id' => $project->getId()],
                                                       Response::HTTP_SEE_OTHER);
                 }
    
                 return $this->render('todo/newinproject.html.twig', [
                         'project' => $project,
                         'todo' => $todo,
                         'form' => $form,
                 ]);
         }
    

    Remarquons quelques faits marquants sur ce code :

    • le chemin de cette nouvelle route, /todo/newinproject/{id}, est dérivé de /todo, le préfixe de la classe TodoController qui gère les tâches (on aurait pu faire le choix de le placer dans ProjectController mais ça aurait été peu judicieux car tirant trop de liens avec l’entité Todo)
    • on effectue un passage d’argument de l’instance du projet dans l’appel (un objet $project de type Project), qui est généré « automagiquement » en chargeant un projet ayant pour identifiant le numéro id extrait du chemin de la route (/todo/newinproject/{id}) via la fonctionnalité d’Entity Value Resolver de Symfony.

      Ce mécanisme est essentiel pour écrire du code très compact. C’est le même qui est utilisé pour app_project_show :

      L’Entity Value Resolver effectue le chargement des données depuis la base via Doctrine automatiquement, sans avoir besoin de faire un ->find($id).

    • ajout de la Todo à son Project si les données soumises sont correctes :

      $todo->setProject($project);
      

      On utilise ici la méthode du modèle doctrine générée par make:entity en cas d’association OneToMany.

    • comme pour new(), on effectue un appel à persist() (qui sert à « taguer » une entité du modèle comme devant être sauvée en base) uniquement sur la nouvelle Todo. Oui, et alors ?

      Mieux vaut vérifier : ici, on a défini un projet pour la tâche. Qui doit être sauvegardé en base ? Le projet doit-il être modifié suite à l’ajout de cette tâche ?

      Dans cette configuration, c’est la tâche qui contient la référence vers son projet, mais l’inverse n’est pas vrai : l’entité projet n’est pas modifiée. En base de données, cette association OneToMany s’est traduite par la clé étrangère de Project migrée dans Todo.

      On ne doit donc bien faire qu’un persist() sur cette nouvelle Todo, comme dans new(). On en est sûrs, cette fois.

    • Une fois ajouté la nouvelle Todo dans la base de données, on doit rediriger vers le point de départ (en principe) de l’accès à ce nouveau dialogue. C’est différent du code de new() on doit aller vers la page de consultation du projet, donc la route app_project_show, qui attend un argument. On donc on doit appeler getId() sur $project pour construire la route correcte;
    • Notez qu’on passe le projet directement au gabarit todo/newinproject.html.twig, ce qui permet de le customiser par rapport au précédent gabarit new.html.twig;
  3. Modifiez enfin le gabarit templates/project/show.html.twig pour appeler la bonne route dans le lien d’ajout de la tâche qu’on avait ajouté un peu plus tôt (on veut la nouvelle route app_todo_newinproject).
  4. Vérifiez que ça fonctionne. En cas de soucis, vous connaissez maintenant les outils facilitant la mise-au-point :
    • vous pouvez ajouter des dump(), dans le code PHP, ou dans les gabarits
    • vous pouvez aussi vérifier les requêtes transmises via le Profiler dans les outils du développeur Symfony. Vous y trouverez aussi la gestion des formulaires, et les requêtes transmises à la base de données par Doctrine.
  5. Quand ça fonctionne, modifiez enfin le gabarit de nouvelle tâche pour contextualiser, par exemple avec :

    <h1>Add a Todo to {{ project }}</h1>
    

    En passant, le sous-formulaire de todo/_form.html.twig n’a pas eu besoin d’être modifié : on peut toujours créer des todos sans projets (autorisé dans le modèle de données) si on passe par /todo/new.

Nous venons de voir comment faire fonctionner un élément clé pour la gestion des entités liées par une association 1-N (OneToMany), la contextualisation de l’ajout d’une nouvelle entité fille de l’association.

Dans une véritable application Web, ce type de contextualisation sera très courant.

On dépasse le simple emballage des entités individuelles par un CRUD basique, et on se rapproche d’une logique métier dans les enchainements de pages (principe HATEOS vu en cours).

2.3.3. Amélioration du formulaire d’ajout d’une nouvelle tâche à un projet (optionnelle)

Vous pouvez raffiner un peu la gestion de ce formulaire d’ajout d’une nouvelle tâche à un projet, afin de ne pas afficher le champ de modification du projet dans le formulaire HTML, puisque, dans ce cas, le projet est fixé.

Cette amélioration n’est pas cruciale, donc vous pouvez passer à la suite, si vous êtes pressés par le temps restant dans la séance de TP.

Actuellement l’ajout du champ est réalisé dans TodoType::buildForm() :

$builder
 ->add('title')
 ->add('project');
 ...

On peut modificer ce comportement, mais attention, car la génération du formulaire s’applique aussi bien à la création (via new() ou newInProject()) qu’à la modification…

Une solution consiste à dupliquer la classe TodoType pour réaliser une classe TodoAddType ayant un comportement différent, spécifique à cette méthode newInProject().

Une autre solution consiste à garder une seule classe TodoType, et à utiliser le mécanisme des options, qu’on a déjà décrit dans une partie optionnelle de la séquence précédente : on l’a utilisé pour mettre en place des règles de gestion. C’est cette solution que nous allons expérimenter ici.

  1. Modifiez le code de la méthode TodoController::newInProject() pour gérer le passage de l’option display_project.

    Modifiez le code dans la méthode new(), qui appelle la génération du formulaire, pour passer en argument un tableau d’options, qui contient la valeur de l’option display_project, qui doit alors être fausse :

    public function newInProject(Request $request, EntityManagerInterface $entityManager, Project $project): Response
    {
       $todo = new Todo();
       $todo->setProject($project);
       //...
    
       $form = $this->createForm(TodoType::class, $todo,
               [
                 'task_is_new' => true,
                 'display_project' => false,
                 //...
               ]
               );
    
  2. Modifiez le code du gestionnaire de formulaire TodoType pour n’afficher le champ de formulaire que pour les tâches modifiables
    1. Ajoutez-lui une option, via la méthode configureOptions(), nommée display_project de type booléen, dont la valeur par défaut est fausse :

      public function configureOptions(OptionsResolver $resolver)
      {
        $resolver->setDefaults([
                'data_class' => Todo::class,
            'task_is_new' => false,
                'display_project' => true
        ]);
        $resolver->setAllowedTypes('task_is_new', 'bool');
        $resolver->setAllowedTypes('display_project', 'bool');
      }
      
    2. Modifiez le comportement de la création du formulaire, dans buildForm() pour ajouter ou non certains champs, selon qu’ils doivent être inclus dans le formulaire, en fonction de la valeur de display_project :

      public function buildForm(FormBuilderInterface $builder, array $options): void
      {
         ...
         if($options['display_project'] ) 
         {
             $builder->add('project');
         }
         $builder->add(...);
      
         ...
      
  3. Testez que le champ de sélection du projet a bien disparu quand on crée la tâche dans le contexte du projet.

    Vérifiez néanmoins que même si le champ n’est plus affiché, il est bien sauvegardé en base de données.

  4. Testez la création d’une tâche sans projet, en repassant par /todo/new, pour vérifier que la modification de TodoType n’a pas cassé le mécanisme d’ajout de tâches isolées.

2.3.4. Conclusion : Importance de la contextualisation pour aller au-delà de ce que propose le make:crud

Nous avons terminé la gestion de la contextualisation dans le cas d’une association OneToMany, pour aller plus loin que ce que proposent les formulaires par défaut créés par l’assistant make:crud.

Il est particulièrement important de gérer ce type de contextualisation quand une telle association est « structurante », avec une relation de composition.

Par exemple, dans une application réelle, où cette fois, les Projets seraient composés de tâches, et où toute tâche devrait appartenir au projet, on ne pourrait plus créer des tâches sans avoir de projet comme point de départ.

On ferait bien-sûr évoluer le modèle de données pour que le champ correspondant à l’association vers le projet d’une tâche ne puisse être nul. Cela garantirait la cohérence des données.

Mais au niveau utilisabilité des interfaces Web, il serait crucial de passer par les projets pour accéder aux tâches et de pré-renseigner le projet de la tâche à sa création comme on vient de le faire. En particulier s’il y a des milliers de projets et qu’il devient impossible de retrouver un projet dans une champ de saisie comportant des milliers d’éléments.

Il est tout aussi indispensable de gérer de telles relations structurantes quand on est sur une application multi-utilisateurs, pour ne pas mélanger les données de différents utilisateurs.

On réalisera donc de nombreuses adaptations similaires à cette contextualisation :

  • au niveau du chargement des données : données du bon utilisateur, ou dans le bon sous-contexte passé en paramètres de la route;
  • et au niveau de la définition des valeurs des attributs pour les nouvelles entités : encore une fois en fonction du contexte implicite (utilisateur courant connecté), ou explicite, en fonction des arguments passés aux routes.

Vous allez mettre en œuvre ce genre de contextualisation dans votre projet de façon assez similaire à ce qu’on vient de faire ici.

3. Étape 2 : Ajout d’une fonction de mise en ligne d’images

Ajouter une fonctionnalité permettant le télé-versement (upload) d’images vers le site, pour associer des images aux entités Pastes de l’application fil-rouge.

On va utiliser le module VichUploader pour Symfony (cf. https://github.com/dustin10/VichUploaderBundle).

3.1. TODO Installation du paquetage

Installez le bundle :

symfony composer require vich/uploader-bundle

Confirmer l’exécution de la recette « Do you want to execute this recipe? »

3.2. TODO Configuration de l’entité Paste

Procédez aux opérations suivantes

  1. Configurer un mapping de téléversement dans le fichier config/packages/vich_uploader.yaml qui a été installé par composer/flex (rafraîchir le contenu du projet dans Eclipse) :

    vich_uploader:
        db_driver: orm
    
        mappings:
            pastes:
                uri_prefix: /images/pastes
                upload_destination: '%kernel.project_dir%/public/images/pastes'
                namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
    
  2. Lier l’entité Paste avec le module de téléversement. Pour ce faire, annoter la classe Paste (dans src/Entity/Paste.php) comme étant gérée par le module (annotation Vich\Uploadable) :

    //...
    use Symfony\Component\HttpFoundation\File\File;
    use Vich\UploaderBundle\Mapping\Annotation as Vich;
    
    #[ORM\Entity(repositoryClass: PasteRepository::class)]
    #[Vich\Uploadable]
    class Paste
    
  3. Il faut ensuite :

    • Ajouter les attributs permettant le stockage et la gestion des fichiers d’images téléversés :
      • l’attribut imageName sera persisté dans la base de données via Doctrine
      • l’attribut imageFile sera utilisé par le module de télé-versement, et sera associé au mapping défini précédamment dans config/packages/vich_uploader.yaml (appelé pastes, ici)
      • l’attribut imageUpdatedAt qui sera utilisé en interne pour le bon fonctionnement du module
    • Ajouter aussi les getters et setters correspondants en suivant les instructions de la documentation du module, ce qui revient à ajouter ceci :
    use Symfony\Component\HttpFoundation\File\File;
    
    //...
    
        #[Vich\UploadableField(mapping: 'pastes', fileNameProperty: 'imageName', size: 'imageSize')]
        private ?File $imageFile = null;
    
        #[ORM\Column(nullable: true)]
        private ?string $imageName = null;
    
        #[ORM\Column(nullable: true)]
        private ?int $imageSize = null;
    
        #[ORM\Column(nullable: true)]
        private ?\DateTimeImmutable $updatedAt = null;
    
    
        //...
        /**
         * If manually uploading a file (i.e. not using Symfony Form) ensure an instance
         * of 'UploadedFile' is injected into this setter to trigger the update. If this
         * bundle's configuration parameter 'inject_on_load' is set to 'true' this setter
         * must be able to accept an instance of 'File' as the bundle will inject one here
         * during Doctrine hydration.
         *
         * @param File|\Symfony\Component\HttpFoundation\File\UploadedFile|null $imageFile
         */
        public function setImageFile(?File $imageFile = null): void
        {
            $this->imageFile = $imageFile;
    
            if (null !== $imageFile) {
                // It is required that at least one field changes if you are using doctrine
                // otherwise the event listeners won't be called and the file is lost
                $this->updatedAt = new \DateTimeImmutable();
            }
        }
    
        public function getImageFile(): ?File
        {
            return $this->imageFile;
        }
    
        public function setImageName(?string $imageName): void
        {
            $this->imageName = $imageName;
        }
    
        public function getImageName(): ?string
        {
            return $this->imageName;
        }
    
        public function setImageSize(?int $imageSize): void
        {
            $this->imageSize = $imageSize;
        }
    
        public function getImageSize(): ?int
        {
            return $this->imageSize;
        }
    

Attention aux contraintes de déploiement, et à la sécurité, quand on utilise un tel module, qui manipule les fichers stockés dans un des répertoires du projet.

Il est probablement assez risqué d’utiliser un tel module en production, car le dépôt d’image modifie des fichiers à l’intérieur du sous-répertoire public/ du projet… de public/ à ../src/Controller/, il n’y a pas très loin, et un bug ou une faille de sécurité pourrait bien amener à compromettre facilement le code de l’application.

C’est pour éviter cela que certaines solutions de déploiement des applications PHP vont restreindre la possibilité à l’application de modifier le stockage où elle est déployée… Le répertoire var/ sera typiquement accessible en modification, mais peut-être pas public/. Ou bien, on utilisera un autre emplacement que le sous-répertoire var/ pour les fichiers temporaires générés.

3.3. TODO Ajout du formulaire de téléversement à la création d’une Paste

Procédez aux opérations suivantes :

  1. Modifier le formulaire d’ajout / édition de Pastes src/Form/PasteType.php :

    //...
    use Symfony\Component\Form\Extension\Core\Type\TextType;
    use Vich\UploaderBundle\Form\Type\VichImageType;
    
    //...
    
     class PasteType extends AbstractType
     {
    
         //...
         $builder
             //...
    
                ->add('imageName', TextType::class,  ['disabled' => true])
                ->add('imageFile', VichImageType::class, ['required' => false])
             ;
    
  2. Modifier le gabarit d’affichage des pastes templates/paste/show.html.twig :

    <tr>
        <th>Image</th>
        <td><img src="{{ vich_uploader_asset(paste, 'imageFile') }}"/></td>
    </tr>
    
  3. Modifier le content-type en fonction du type d’image, si une image est présente, dans src/Controller/PasteController.php :

    public function new(Request $request, PasteRepository $pasteRepository): Response
    {
         // ...
         if ($form->isSubmitted() && $form->isValid()) {
    
                 // Change content-type according to image's
                 $imagefile = $paste->getImageFile();
                 if($imagefile) {
                         $mimetype = $imagefile->getMimeType();
                         $paste->setContentType($mimetype);
                 }
    
                 //...
    
  4. Mettez à jour le schéma de la base de données :

    symfony console doctrine:schema:update --force
    
    
  5. Vous pouvez tester le fonctionnement de la mise en ligne d’images dans les Pastes.

    Vérifiez dans le moniteur réseau du navigateur (ou dans les logs du serveur Web) les requêtes HTTP.

    Comprenez-vous d’où proviennent les images. Identifiez-vous leur répertoire de stockage sur le disque dur ?

    Vous pouvez consulter les noms d’images sauvegardés en base de données, avec symfony console dbal:run-sql "select * from paste;".

3.4. TODO Amélioration de la gestion des problèmes de téléversement

En cas de problèmes, il peut y avoir différentes sources de résolution

  • il y a une certaine dépendance au contexte de fonctionnement

    Le fonctionnement de ce module requiert la présence du module fileinfo dans PHP. Il convient éventuellement d’activer ce module dans la configuration de php.ini, en cas de problèmes.

  • il se peut que certains fichiers passent et d’autres non. Cela peut être dû à leur taille, si elle est supérieure à celle acceptée pour les téléversements par le serveur HTTP de PHP utilisé pour les tests.

En cas d’essai de mise en ligne d’un tel fichier trop volumineux, vous obtiendrez une erreur « Error 422 Unprocessable Content » et le formulaire sera raffiché en rouge avec un message sybyllin « No file selected. »…

Pour un utilisateur final de l’application, l’erreur « No file selected.«  sera étrange… il faudrait qu’il y ait un message plus parlant, puisqu’il s’agit là d’un problème de fichier trop volumineux.

  1. Testez le téléversement d’une grosse photo ou d’un autre gros fichier.

    Normalement vous devriez avoir cette erreur ou la Paste ne peut être créée, avec le champ de formulaire qui s’affiche en rouge.

    Ici, on suppose que votre serveur PHP (lancé par symfony server:... pour l’environnement de développement) a une configuration upload_max_filesize fixée à 2Mo dans sa configuration (php.ini), qui est facilement débordée si on télé-verse de grosses photos.

  2. Pour vérifier la configuration de upload_max_filesize vous pouvez accéder à la configuration du server PHP interne depuis le Profiler (sur http://localhost:8000/_profiler/phpinfo ).

    Si besoin, pour ce test, diminuez la taille pour définir un réglage spécifique, en créant un fichier php.ini à la racine du projet, contenant :

    upload_max_filesize = 2M
    

    Arrêtez et relancez le serveur symfony server:stop / symfony server:start, et vérifiez si le réglage est bien pris en compte, avec l’erreur qui apparaît bien cette fois.

  3. Quand l’erreur 422 apparaît dans le formulaire l’affichage se produit au traîtement de la requête POST, vérifiez les icones dans la barre d’outils Symfony dans la page d’erreur du formulaire soumis.

    Vous verrez l’affichage d’un indicateur d’erreur dans un formulaire. Vous pouvez ainsi aller voir dans le Profiler qu’il y a une erreur dans le champ imageFile, dans son sous-champ file.

    C’est là que vous, développeur, vous pouvez identifier le message d’erreur réel : « The file is too large. Allowed maximum size is 2 MiB. »

  4. Modifions le comportement du code de soumission pour vérifier ce genre d’erreur, et pour les traiter dans le code.

    Vous pouvez ainsi modifier le code de la méthode pour essayer de voir d’où vient l’erreur. C’est dans la soumission des données du formulaire, dans la méthode new() qu’il faut intervenir, au moment de la véfification de la validité des données soumises.

    Modifiez PasteController::new() ainsi :

    use Symfony\Component\Form\FileUploadError;
    
    // ...
    
    #[Route('/new', name: 'app_paste_new', methods: ['GET', 'POST'])]
    public function new(Request $request, EntityManagerInterface $entityManager): Response
    {
        // ...
    
        // explode the submitted check and the valid check
        $valid=false;
        $submitted=$form->isSubmitted();
        dump($submitted);
        if($submitted) {
            $valid=$form->isValid();
            dump($valid);
        }
    
        if ($submitted && $valid) {
    
            // ...
            return $this->redirectToRoute('app_paste_index', [], Response::HTTP_SEE_OTHER);
        }
    
        // now, we know invalid data was submitted
        if(! $valid) {
            // check all errors from all of the form's fields (in depth)
            $errors = $form->getErrors(true);
    
            foreach($errors as $error) {
                dump($error);
    
                // now, if we find a problem of file upload, propagate the error into the top-level form
                if($error instanceof FileUploadError) {
                    $form->addError($error);
                }
            }
        }
    
        // in that re-rendering, the main error message should be displayed by the form
        return $this->render('paste/new.html.twig', [
            'paste' => $paste,
            'form' => $form,
        ]);
    }
    

    En cas de problème de téléversement du fichier (Symfony\Component\Form\FileUploadError), on « injecte » l’erreur au niveau du formulaire global. Ainsi, l’erreur s’affiche en haut de formulaire, et pas juste dans le champ en rouge.

On vient de voir ainsi en détail la différence entre soumission et validation dans l’algorithme de gestion des formulaires.

Vous commencez à mieux entrevoir les capacités des contrôleurs et comment la customization d’une application réelle peut s’effectuer.

4. Évaluation

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

  • Comment est gérée une association 1-N dans Doctrine
  • Comment on contextualise les routes dans l’enchainement des pages
  • Comment on doit dépasser les prototypes de CRUD basiques des générateurs de code, pour aller vers une gestion plus conforme aux associations entre entités
  • La gestion du télé-versement de fichiers dans les formulaires, en tant que données « comme les autres ».
  • Le rôle de la validation des données dans la gestion des soumissions dans les contrôleurs

Author: Olivier Berger (TSP)

Date: 2024-09-06 Fri 13:21

Emacs (Org mode)