vendredi 11 janvier 2013

Tableaux complexes avec AngularJS : ng-repeat et tbody

[Ajout du 12/01/2013] J'ai modifié la solution proposée à la fin de l'article pour utiliser une directive ng-switch plutôt que ng-show, ce qui est beaucoup plus pertinent.

On m'a demandé plusieurs fois ce qui peut être compliqué à faire avec AngularJS. Il y a une chose qui parfois s'avère contraignante, surtout pour présenter des tableaux complexes, c'est le fait que les templates d'AngularJS doivent être du HTML, sinon forcément valide, du moins compris par le navigateur.

AngularJS compile le template à partir du DOM chargé par le navigateur. Il ne travaille pas sur le fichier source HTML, mais sur le DOM construit par le navigateur à partir du source, et ça fait une grosse différence. Ce fonctionnement est un des principaux atouts d'AngularJS, mais dans quelques rares cas, il peut s'avérer contraignant.



C'est le cas avec les tableaux HTML. Quand on a un tableau simple, avec un modèle de ligne répété en fonction des éléments d'une liste, il n'y a pas de difficulté. Mais imaginons un tableau un peu plus compliqué comme celui-ci, qui présente les navigateurs web utilisés par les visiteurs sur les premières semaines d'existence de FrAngular :

Browser %
Chrome 68.83 %
Chrome 23.0.1271.97 46.68 %
Chrome 18.0.1025.166 10.97 %
Chrome 23.0.1271.101 10.66 %
Firefox 17.87 %
Firefox 17.0 70.24 %
Firefox 16.0 12.50 %
Safari 4.36 %
Safari 536.26.17 36.59 %
Safari 8536.25 36.59 %
Safari 7534.48.3 19.51 %

On a dans ce tableau deux niveaux de répétition :

  • un premier niveau sur le navigateur : Chrome, Firefox, Safari (je n'ai mis que les premiers de la liste, et IE était à 0.34%)
  • un second niveau sur les versions de ces navigateurs

La seconde répétition, sur la version, porte sur une seule ligne du tableau, il suffit de mettre un attribut ng-repeat sur le <tr> servant de template. Mais la première répétition, sur le navigateur, doit porter sur deux <tr> : le template de ligne concernant le navigateur, et le template de ligne indiquant la version qui est lui-même répété.

On pourrait avoir même avec un seul niveau de répétition des cas où l'on veut répéter plusieurs lignes ensemble. L'attribut ng-repeat doit être positionné sur un élément HTML, donc impossible de le mettre sur un <tr> dans ce cas, car une seule ligne serait répétée. On ne peut pas non plus intercaler un <div> dans la balise <table> pour regrouper les deux <tr>, ce n'est pas valide en HTML, et un tel <div> intermédiaire est ignoré par les navigateurs, ce qui fait qu'il n'existe pas dans le DOM du template que compile AngularJS. C'est là qu'on voit la différence entre un traitement fait sur le source du template, et un traitement fait sur le DOM que le navigateur a construit à partir du code source.

<tbody> à la rescousse

Heureusement il existe une balise HTML permettant de regrouper des <tr>, c'est la balise <tbody>, et par chance on a le droit de mettre plusieurs <tbody> dans une même table. Du coup c'est ce qu'on va utiliser avec AngularJS pour répéter plusieurs <tr> ensemble : on les regroupe dans un <tbody>, et on met l'attribut  ng-repeat sur le <tbody>.

Vous avez un exemple ici sur jsFiddle, qui construit ce tableau des navigateurs, avec un premier niveau de répétition sur le <tbody>, et un second niveau de répétition sur un <tr> à l'intérieur de ce <tbody>.

<tbody ng-repeat="browser in browsers">
  <tr class="total">
    <td class="browser">{{browser.name}}</td>
    <td class="pct">{{browser.total}} %</td>
  </tr>
  <tr ng-repeat="version in browser.versions" class="detail">
    <td class="browser">{{browser.name}} {{version.name}}</td>
    <td class="pct">{{version.value}} %</td>
  </tr>
<tbody>

Cette solution simple fonctionne très bien si l'on a un seul niveau de répétition. Elle convient encore avec deux niveaux de répétition, à condition que le second niveau porte sur une seule ligne. Pour les cas rarissimes avec trois niveaux de répétitions ou plus, ou seulement deux niveaux mais où il faut répéter plusieurs lignes au second niveau, ça ne convient plus. Il n'est pas possible d'imbriquer des balises <tbody>, c'est interdit en HTML et ignoré par le navigateur, et donc un second <tbody> imbriqué dans notre fichier source n'existerait pas dans le DOM compilé par AngularJS.

Transformer les données

Il y a une solution plus générique, qui fonctionne dans tous les cas. C'est de transformer les données, pour en faire une liste correspondant exactement au tableau à afficher, avec un élément pour chaque <tr>.

En général on fait le data-binding d'une vue sur les données telles qu'elles ont été reçues du serveur. Mais dans des cas complexes où la structure des données n'est pas bien adaptée, il peut être plus pratique de transformer ces données dans le code du contrôleur (ou dans une fonction du scope si ce sont des données qui peuvent être modifiées), pour en faire un modèle plus simple à afficher.

Dans notre cas d'un tableau particulièrement exotique, il suffit de mettre à plat les différents niveaux de répétition, pour n'en avoir plus qu'un seul correspondant aux <tr> de la table HTML. La répétition se fait sur un unique <tbody> contenant plusieurs templates de <tr>.

[Ajout du 12/01/2013] J'avais proposé initialement d'utiliser des attributs ng-show pour conditionner l'affichage du template adéquat pour chaque occurrence (voir le jsFiddle ici). Mais l'attribut ng-show, tout comme son pendant ng-hide, masque simplement l'élément s'il ne doit pas être visible, via une propriété css display: none. L'élément est tout de même présent, même s'il est caché, ce qui signifie que chaque occurrence de <tbody> contient tous les templates de <tr>. Merci à ceux qui ont fait remarquer ce problème dans les commentaires. Si ça n'est pas prohibitif pour un petit tableau, avec un gros volume de données ça peut être un vrai problème, car du coup le DOM HTML est plus volumineux, et surtout ça multiplie le nombre de bindings à vérifier pour AngularJS.

Je pensais qu'il n'y avait pas de directive standard dans AngularJS permettant d'avoir réellement qu'un <tr> présent. Mais la nuit porte conseil, et ça m'a conduit à une solution bien meilleure, à laquelle je n'avais pas pensé sur le moment : il suffit d'utiliser les directives ng-switch et ng-switch-when, qui finalement sont faites exactement pour ça.


Dont voici la bonne solution (jsFiddle ici), où les templates de <tr> sont conditionnés selon la valeur de la propriété line.type. Avec un ng-switch sur l'élément <tbody> et un ng-switch-when sur chacun des <tr>, on obtient bien le résultat voulu, et sans <tr> cachés. Seul le <tr> correspondant à la valeur du ng-switch-when est présent dans chaque <tbody> :


<tbody ng-repeat="line in lines" ng-switch="line.type">
  <tr ng-switch-when="total" class="total">
    <td class="browser">{{line.name}}</td>
    <td class="pct">{{line.value}} %</td>
  </tr>
  <tr ng-switch-when="detail" class="detail">
    <td class="browser">{{line.name}}</td>
    <td class="pct">{{line.value}} %</td>
  </tr>
<tbody>

Sur ce principe on peut afficher n'importe quel tableau, aussi complexe soit-il.

Donc ce qu'il faut retenir, c'est que la balise <tbody> est très pratique pour regrouper des <tr> qui doivent être répétés ensemble, mais que dans les cas les plus complexes il ne faut pas hésiter à transformer les données pour se simplifier la vie.


12 commentaires:

  1. Actually you can do even shorter

    http://jsfiddle.net/nk3ta/

    Sorry for my French ;)
    Alessandro

    RépondreSupprimer
  2. Thanks Alessandro. You're right, in my simple example the row templates had same columns with same binding, so a unique template with a conditional class is enough.

    But in a more general way, different row templates may be needed.

    RépondreSupprimer
    Réponses
    1. I understand Thierry.
      Between the 2 approaches in your post, I would recommend the 1st since with ng-show you add elements to the DOM even if they are not shown.
      If you have a table with 100 rows, in the 2nd approach angular would add 200 rows to the HTML and make only 100 visible. This could add up and deteriorate performance.

      Sorry if you already wrote this in the article, I can't read French

      Supprimer
    2. It was not clear in the article. And tonight I had a better idea for the second solution : using ng-switch, only one tr is present in each tbody (new exemple here).

      I modified the article to explain that, thanks for your remarks.

      Of course when the first approach can work, it's the easiest and the best one, and in fact the only one I had to use in an actual application.

      Supprimer
    3. That's cool!
      I didn't know that ng-switch worked that way, I thought it had the same behavior of ng-show.
      I learnt something new and useful, thanks!

      Supprimer
  3. Merci pour l'astuce.
    Y'a tout de même un truc qui me gène dans la seconde technique, on double tout de même les tr dans le dom, puisqu'il me semble que la directive ng-show ne fait que changer la propriété css display.
    Donc attention aux manipulation du dom ensuite :)
    De plus dans ton exemple, le tbody est répété à chaque fois. Au final, la structure ne me semble pas très propre.

    RépondreSupprimer
  4. Oui le tbody est répété à chaque fois, mais la norme HTML précise bien qu'une table peut en contenir plusieurs, ça ne pose pas de problème.

    Par contre c'est vrai que tous les templates sont présents dans chaque répétition, même si un seul est visible. S'il y a beaucoup d'occurrences, ça peut conduire à un nombre important de bindings.

    Dans ce cas il vaut mieux utiliser une directive qui conditionne carrément la présence de l'élément, et pas seulement sa visibilité. Elle n'existe pas en standard dans AngularJS, mais c'est ce que fait la directive If d'Angular-UI.

    RépondreSupprimer
  5. Suites aux remarques sur les inconvénients bien réels du ng-show, ça m'a fait penser à une bien meilleure solution pour la seconde approche. Il s'agit d'utiliser la directive ng-switch, avec laquelle il n'y aura cette fois plus qu'un seul élément tr présent pour chaque occurrence.

    Même si cette seconde approche consistant à mettre à plat les données est rarement nécessaire (je n'en ai pas encore rencontré de cas réel), c'est bien d'avoir une solution optimale.

    J'ai modifié l'article en conséquence, merci pour ces commentaires.

    RépondreSupprimer
  6. La directive ng-if ( idem a ng-show mais ne cree pas l'element dans le DOM) est disponible dans angular depuis la 1.1.5 il me semble

    RépondreSupprimer
    Réponses
    1. Oui, je confirme, elle est intégrée au framework depuis la version 1.1.5.

      Supprimer
  7. Bonne astuce!

    Je propose également une idée basée sur la directive ng-class (http://jsfiddle.net/minouch/j7pwh728/). J'aimerai bien avoir des feedback sur la perf de cette solution par rapport la solution utilisant ng-switch.

    Merci!

    RépondreSupprimer