Sortable Sonata Type Model – suite

Cet article est la suite de Sortable Sonata Type Model.

On s’y remet

À la fin du dernier article, nous avions résolu le besoin principal : afficher dans une page d’admin Sonata, une entité jointe plutôt qu’une entité abstraite représentant la liaison avec celle-ci.
Il restait à s’occuper des autres besoins :

  • pouvoir ajouter un item à cette entité jointe directement depuis la page d’admin
  • pouvoir assigner une valeur à une propriété de l’entité abstraite, en l’occurrence un ordre d’affichage

Un petit rappel sur le contexte : on a une entité projet. Dans ce projet, des lignes (c’est notre deuxième entité). Ces lignes doivent pouvoir être ordonnées de façon arbitraire : on a donc introduit une troisième entité projet-ligne, dotée d’une propriété numérique indiquant l’ordre d’affichage.

Nous nous trouvons ici dans la page d’admin de l’entité « Projet ». Et nous cherchons à personnaliser le champ du formulaire correspondant à la relation one-to-many : projet / liaisons projet-lignes. Le but est de rendre transparente la manipulation des liaisons projet-lignes, au profit des lignes elles-mêmes, pour plus de simplicité.

Besoin n°1 : Ajouter un item

Le plan d’attaque

Pour s’attaquer à ce travail il faut encore décomposer en petits objectifs :

  • affichage du bouton d’ajout dans le champ
  • ouverture du formulaire de création en ajax

La méthode

Pour remplir nos objectifs, on ne va bien sûr ni réinventer la roue, ni invoquer les dieux du code pour voir la solution en rêve. Tout simplement, on va observer par quelle mécanique Sonata procède d’habitude, pour qu’un bouton « ajouter » soit affiché dans la page, qu’un formulaire s’ouvre en popup quand on clique dessus, et qu’un nouvel item soit coché quand on soumet le formulaire.

A) Afficher le bouton

La première des choses est déjà d’analyser le bouton en lui-même dans le DOM. Il porte un attribut onclick, qui retourne une fonction préfixée « start_field_dialog_form_add_ ». C’est une bonne piste : cherchons dans le répertoire de Sonata, quel(s) fichier(s) mentionne(nt) ce préfixe.

On en trouve plusieurs occurrences, réparties dans différents templates Twig. Un de ces templates comporte la définition de la fonction préfixée « start_field_dialog_form_add_ », réservons-le pour plus tard. Dans les autres templates, nous retrouvons précisément le code html du bouton d’ajout qu’il nous faut (il s’agit en fait d’un lien <a>). On observe au passage qu’une condition existe pour l’affichage du bouton, liée au paramétrage de l’admin : {% if sonata_admin.field_description.associationadmin.hasroute('create') and sonata_admin.field_description.associationadmin.isGranted('CREATE') %}. Il va falloir en tenir compte.

L’autre aspect important à observer est l’attribut href de notre lien-bouton : {{ sonata_admin.field_description.associationadmin.generateUrl('create', sonata_admin.field_description.getOption('link_parameters', {})) }}. Là encore, il est généré à partir de la variable sonata_admin. Cette variable est en fait un tableau regroupant, pour chaque champ du formulaire d’administration, toutes les propriétés utiles à Sonata. Les valeurs par défaut stockées dans ce tableau ne nous sont pas utiles, puisque justement nous cherchons à complètement contourner le rendu par défaut du champ.

Il nous faut donc à présent reproduire ce fragment de code html pour notre champ, en appliquant la même vérification pour l’affichage, et en générant dynamiquement l’url du bouton, pour qu’il pointe vers le formulaire de création de notre entité « Ligne » et non « Projet-Ligne ». Et pour que notre champ de formulaire puisse utiliser du code html personnalisé, nous allons créer un type de champ, enfant du type natif « entity » qui nous convenait parfaitement. Nous y associerons notre propre template pour le rendu. Créer un type de champ va également nous donner toute latitude pour introduire nos propres attributs au champ de formulaire.
En l’occurrence, nous avons besoin de deux informations, comme dans le modèle observé :

  • les infos de paramétrage de la route vers la page de création de notre entité « Ligne » (pour les conditions d’affichage du bouton)
  • et l’url de cette route (pour l’attribut href du lien-bouton)

J’ai choisi de récupérer ces valeurs directement dans la classe de mon type de champ, et de les injecter dans mon template par des variables. Pour cela, j’ai observé le template modèle de Sonata. Trois méthodes ont été utilisées : hasroute, isGranted et generateUrl. Elles appartiennent à l’objet associationadmin, contenu dans sonata_admin.field_description. Un dump dans le template modèle ({{ dump(sonata_admin.field_description.associationadmin) }}) me permet de constater qu’il s’agit de la classe d’Admin de l’entité jointe. Les classes d’Admin que l’on crée dans Sonata, étendent en effet la classe AbstractAdmin, et si on l’étudie, on voit bien que les trois méthodes existent.
Dans notre cas, nous devons appliquer ces méthodes sur l’instance d’objet LigneAdmin, pour connaître les infos de paramétrage et l’url de sa route. Il nous faut donc un moyen d’injecter cette instance dans notre champ personnalisé : nous allons pour cela utiliser un attribut de champ. Et comment récupérer l’instance ? Euréka, elle est déclarée comme un service. Une rapide recherche sur Google, et je sais comment récupérer le Container depuis une classe d’Admin ; il n’y a plus qu’à en extraire le service souhaité, et l’affecter à un attribut de champ que j’ai nommé « admin_service ». La définition du champ au final ressemble à cela :

Dans notre classe de type de champ JointType, on constitue ensuite la variable btn_add_link, utile au template pour afficher le bouton :


Dernière étape, on crée le template du type de champ dans un fichier Twig, qu’on déclare dans la configuration globale du site (comme indiqué dans la doc de Symfony). Pour afficher les options du champ, on peut s’appuyer sur un block par défaut « choice_widget », pour afficher (enfin !) notre bouton d’ajout, on utilise le fragment de code du modèle, en remplaçant les variables adéquates :

Le résultat : Notre champ affiche à présent le bouton d’ajout, qui pointe bien vers le formulaire de création d’une ligne. Mais on n’est pas encore au bout de nos peines : pour l’instant, un clic sur le bouton nous fait sortir de la page d’administration du projet. Ce n’est pas ce que nous voulons : il faut maintenant permettre l’ouverture de ce formulaire en ajax, sous forme de popup.

B) Ouvrir le formulaire dans un popup

On retourne à l’observation d’un champ classique d’une relation one-to-many. Au niveau du DOM, lorsqu’on clique sur le bouton d’ajout, le formulaire de création s’ouvre dans une div portant la classe css modal. Le template modèle de Sonata comportait justement une inclusion d’un template appelé edit_modal.html.twig : on l’ouvre. Le code html ressemble exactement à celui du popup. Pas besoin de tergiverser, on rajoute ce code à la suite de notre template.

Au niveau du javascript à présent. On se rappelle que le bouton d’ajout comporte un attribut onclick, le comportement lors du clic est donc défini dans la fonction vue précédemment, préfixée « start_field_dialog_form_add_ ». On avait trouvé la définition de cette fonction dans un template de Sonata : edit_orm_many_association_script.html.twig. Et si l’on fait maintenant une recherche sur le nom de ce template dans le répertoire de Sontata, on s’aperçoit qu’il est inclus dans le template utilisé par défaut pour le rendu des champs des relations one-to-many.
Parfait, faisons pareil dans le notre, en rajoutant cette ligne : {% include 'SonataDoctrineORMAdminBundle:CRUD:edit_orm_many_association_script.html.twig' %}.

Le résultat : Nous avons maintenant un bouton d’ajout, qui ouvre un popup, qui contient bien un formulaire de création d’une ligne. Lors de la création d’une ligne par le popup, à la soumission du formulaire, la nouvelle ligne apparaît bien dans le champ, et, magie, elle est même cochée !

La nouvelle ligne apparaît, et la case est cochée, grâce à des commandes javascript déclenchées dans la séquence des actions de la fonction préfixée « start_field_dialog_form_add_ ». Comme on est curieux on va essayer de comprendre exactement ce qui se passe :

  • la fonction appelle l’instruction préfixée « initialize_popup_ », qui se charge de préparer le conteneur du popup,
  • puis celle préfixée « field_dialog_ », qui se charge de le remplir, et au passage d’attacher un comportement préfixé « field_dialog_ » au submit du formulaire inclus. Ce comportement déclenche, à la fermeture du popup, un nouvel appel ajax vers la route « sonata_admin_retrieve_form_element » : cette route, liée à l’action de controller retrieveFormFieldElementAction, est celle qui renvoie à javascript un nouveau rendu tout neuf au champ de notre formulaire, mis à jour avec la nouvelle ligne créée. La case est cochée par javascript.

Notre champ est à présent fonctionnel ; rappelons toutefois qu’un data transformer a au préalable été créé pour prendre en charge la transformation de données « lignes » en données « liaison projet-ligne » lors de l’enregistrement du projet (voir la première partie de l’article).

Besoin n°2 : Ordonner les lignes

Bon, tout ça c’est bien, mais on n’a pas encore géré ce pour quoi on s’embête depuis le départ avec une entité de liaison projet-ligne : on souhaite pouvoir ordonner les lignes du projet.
Là encore, on peut décomposer le travail en sous-tâches :

  • pouvoir régler l’ordre des lignes dans le formulaire d’admin du projet (gérer la vue)
  • enregistrer l’ordre à la soumission de ce formulaire (gérer le modèle)
  • et tant qu’à faire, passer au template les lignes dans le bon ordre pour l’affichage (gérer le controller)

A) Gérer la vue

Spontanément, on a envie de pouvoir régler l’ordre des lignes en drag and drop.

Pour cela, trouvons déjà une librairie js sympa et simple à mettre en œuvre (parce que quand même on commence à avoir hâte d’arriver au bout…). Emballez c’est pesé (voilà que je parle comme au XIXè siècle, il est vraiment temps de finir…), pour moi ce sera Sortable. Rajoutons-le aux dépendances de notre bundle, et incluons-le grâce au template du type de champ que nous avons créé :

Enfin, créons le code javascript pour initialiser le drag and drop sur notre champ :

Et hop, après un petit dump assetic, notre drag and drop fonctionne.

B) Gérer le modèle

En suivant à peu près les instructions de Sonata, nous avions implémenté dans la première partie de l’article un data transformer, dont une des fonctions était de gérer la propriété de position des entités de liaison projet-lignes. Pour cela, il parcourait dans un foreach toutes les lignes envoyées lors de l’enregistrement du projet. En incrémentant une variable à chaque boucle, il attribuait la valeur de position à la liaison.

Bad news les amis… Si vous êtes encore avec moi à ce stade avancé de l’article, je dois vous le dire : encore une fois, la doc de Sonata nous a amenés sur une piste où, me semble-t-il, on ne fait que s’embourber. En effet, malgré l’annotation @ORM\OrderBy qui va bien dans mon entité, le data transformer ne recevait jamais les lignes dans l’ordre où elles étaient rangées dans mon formulaire après les drag and drop que je m’étais fatiguée à faire. Donc à chaque test mes lignes restaient obstinément dans l’ordre par défaut, celui de leur ID.

On ne se décourage pas, on n’est pas arrivés jusque là pour rien ! On prend une grande respiration, et on constate en observant les paramètres envoyés en POST lors de l’enregistrement, que les lignes sont bien envoyées dans l’ordre qu’on a choisi. Une lueur d’espoir ! Il nous faut donc un moyen de traiter cette information à la soumission du formulaire.

Et comment fait-on cela ? En attachant au formulaire d’admin du projet un nouvel élément : un Event Subscriber, qui écoute l’événement PRE_SUBMIT (et auquel on injecte notre instance de projet, et le ModelManager de Sonata)

De là, le reverseTransform du data transformer n’a plus besoin que de retourner la collection telle qu’il la trouve avec un simple get sur l’instance du projet.

C) Gérer le controller

Toute dernière étape, une cerise luisante sur notre gros gâteau : faire en sorte qu’à l’affichage du champ, les lignes soient rangées dans l’ordre où elles sont liées au projet ; en d’autres termes, qu’on utilise la valeur de la propriété « position » de la liaison projet-ligne, pour classer les lignes dans la page d’admin du projet.

C’est notre classe d’admin du projet qui joue le rôle de controller puisqu’elle se charge de récupérer les lignes et de les affecter à l’attribut de champ choices. Nous allons simplement enrichir cette méthode de récupération des lignes.

Voici la requête initiale, qui se contente de rechercher toutes les lignes existantes :

Gardons-la. Mais rajoutons une étape : celle de parcourir toute la collection de liaisons projet-ligne du projet, en constituant un tableau des lignes concernées, indexé par la valeur de la propriété « position ». Ce tableau peut ensuite être ordonné par ses index.

Fabriquons ensuite la liste des lignes dans l’ordre de ce tableau. Toutes les lignes restantes (celles qui n’ont pas été récupérées via les liaisons projet-ligne existantes), sont ensuite ajoutées à la liste, dans l’ordre par défaut.

On peut maintenant aller manger le gros gâteau, a’que la cerise : on l’a bien mérité 😉