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
- 2. Étape 1 : Gestion d’associations 1-N dans une application réaliste
- 2.1. TODO Étape 1-a : Ajout de l’entité
Project
- 2.2. Étape 1-b : Gestion du lien entre projet et tâches
- 2.2.1. TODO Ajout du lien vers le projet dans le formulaire de modification des tâches
- 2.2.2. TODO Ajout du projet dans la consultation d’une tâche
- 2.2.3. TODO Ajout de la liste des tâches dans la consultation des projets
- 2.2.4. TODO Ajout du projet dans la liste des tâches
- 2.2.5. TODO Ajout des éléments de navigation entre pages
- 2.3. Étape 1-c : Formulaire d’ajout d’une tâche dans un projet
- 2.3.1. TODO Ajout du lien d’appel à un nouveau formulaire
- 2.3.2. TODO Ajout du formulaire de création d’une tâche dans le contexte d’un projet
- 2.3.3. Amélioration du formulaire d’ajout d’une nouvelle tâche à un projet (optionnelle)
- 2.3.4. Conclusion : Importance de la contextualisation pour aller au-delà de ce que propose le
make:crud
- 2.1. TODO Étape 1-a : Ajout de l’entité
- 3. Étape 2 : Ajout d’une fonction de mise en ligne d’images
- 4. Évaluation
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 :
Ajoutez l’entité
Project
qui sera liée aux entités des tâchesTodo
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.- 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
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/
Vérifiez la présence des routes correspondantes dans l’application :
symfony console debug:router
->app_project_index GET ANY ANY /project/
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' # ...
Nettoyez le cache (Symfony se comporte souvent bizarement après l’exécution du maker de CRUD…) :
symfony console cache:clear
- 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.
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 champproject
.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.Modifiez en conséquence la classe de gestion du formulaire de modification des tâches (
TodoType
, danssrc/Form/
), pour ajouter un champproject
dans le formbuilder :$builder ->add('project');
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.
Figure 1 : Champs du formulaire de création de nouvelle tâche
- Ajoutez maintenant un nouveau projet dans la base de données via le formulaire de création de nouveau projet.
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.- Vérifiez que la création ou modification des tâches fonctionne enfin, permettant d’y définir le projet d’une tâche.
- 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 deProject
.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.
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é.
Modifiez
templates/todo/show.html.twig
pour ajouter l’affichage de l’attributtodo.project
. Vous pouvez spécialiser l’affichage, au cas où il n’y a pas de projet pour une tâche, avec un blocif
:{% if todo.project %} Projet : {{ todo.project }} {% endif %}
- Comparez l’affichage entre tâches d’un projet ou tâches isolées. La section optionnelle devrait s’afficher comme souhaité.
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’attributtodo.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 %}
- Testez l’affichage de la page pour une tâche ayant un projet.
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 ▶} } }
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.- 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 appelerProject::_toString()
:SELECT t0.id AS id_1, t0.title AS title_2, t0.description AS description_3 FROM project t0 WHERE t0.id = 2;
- 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 :
- créez un nouveau gabarit nommé
todo/newinproject.html.twig
, sur le modèle detodo/new.html.twig
; ajoutez une méthode
TodoController::newInProject()
reprenant certains éléments deTodoController::new()
, mais ajoutant une todo à un projet existantSon 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 classeTodoController
qui gère les tâches (on aurait pu faire le choix de le placer dansProjectController
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éroid
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
à sonProject
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’associationOneToMany
.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 nouvelleTodo
. 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 dansnew()
. 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 routeapp_project_show
, qui attend un argument. On donc on doit appelergetId()
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 gabaritnew.html.twig
;
- le chemin de cette nouvelle route,
- 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 routeapp_todo_newinproject
). - 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.
- vous pouvez ajouter des
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.
Modifiez le code de la méthode
TodoController::newInProject()
pour gérer le passage de l’optiondisplay_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’optiondisplay_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, //... ] );
- Modifiez le code du gestionnaire de formulaire
TodoType
pour n’afficher le champ de formulaire que pour les tâches modifiablesAjoutez-lui une option, via la méthode
configureOptions()
, nomméedisplay_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'); }
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 dedisplay_project
:public function buildForm(FormBuilderInterface $builder, array $options): void { ... if($options['display_project'] ) { $builder->add('project'); } $builder->add(...); ...
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.
- Testez la création d’une tâche sans projet, en repassant par
/todo/new
, pour vérifier que la modification deTodoType
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
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
Lier l’entité
Paste
avec le module de téléversement. Pour ce faire, annoter la classePaste
(danssrc/Entity/Paste.php
) comme étant gérée par le module (annotationVich\Uploadable
) ://... use Symfony\Component\HttpFoundation\File\File; use Vich\UploaderBundle\Mapping\Annotation as Vich; #[ORM\Entity(repositoryClass: PasteRepository::class)] #[Vich\Uploadable] class Paste
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 dansconfig/packages/vich_uploader.yaml
(appelépastes
, ici) - l’attribut
imageUpdatedAt
qui sera utilisé en interne pour le bon fonctionnement du module
- l’attribut
- 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; }
- Ajouter les attributs permettant le stockage et la gestion des
fichiers d’images téléversés :
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 :
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]) ;
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>
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); } //...
Mettez à jour le schéma de la base de données :
symfony console doctrine:schema:update --force
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 dephp.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.
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 configurationupload_max_filesize
fixée à 2Mo dans sa configuration (php.ini
), qui est facilement débordée si on télé-verse de grosses photos.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.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-champfile
.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. »
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