How-to Symfony: Gestion d’un arbre en Propel via les NestedSet – Part 1

accessories-text-editor Les arbres en informatique c’est un peu le sujet qui fait rêver mais qui embête souvent, moi le premier. Car qui dit arbre, dit récursivité et là en général on commence à se prendre la tête dans les mains. Bah oui gérer quelque chose dont on ne connait pas la fin, ca fait toujours un peu peur.

Ici, le principe va être justement de gérer un arbre de catégories, un cas qui peut revenir assez régulièrement. A noter que le concept reprend la version doctrine de redotheoffice avec une modification quant au plugin jquery utilisé. En effet, on essaiera ici de gérer l’ordre des catégories également par drag and drop.

En général, quand on part la tête dans le guidon, on crée une table categories, et pour créer la notion de parent, on applique une clé étrangère sur cette même table. Oui mais voilà, autant pour les opérations de suppression et d’ajout, voir même de déplacement dans l’arbre cette démarche est très performante, autant quand il s’agit d’afficher l’arbre, la galère commence.
Et pourtant, c’est bien cette deuxième qu’on fait à chaque affichage de page!

Il existe pourtant une alternative à cette clé étrangère: les Nested Set. En français, les représentations intervallaires. Le but n’étant pas d’expliquer dans le détail ce que c’est, je vous invite à faire un tour sur développez.com où on retrouve un très bon article illustré à ce sujet.

Brièvement, cette méthode consiste à rajouter des champs en base, left, right et scope permettant de retrouver ses petits. Le diagramme de developpez.com résume bien:
Nested set

Ok, c’est bien, mais appliquer tout ça à symfony ca doit être lourd! Et bien nan, Propel dans sa dernière version (1.3) intègre déjà cet algorithme en tant que behavior. (Cela sous entend qu’il faut utiliser symfony 1.2)

Pour se faire, on va donc créer un project vierge en 1.2 histoire d’éviter tout parasitage qu’on mènera en 2 parties. La première pour la partie visuel, la deuxième pour la partie code admin generator.

Voici le résultat attendu à la fin de cette première partie:
image-301

On se lance donc, notre beau projet est créé, on ouvre le schema.yml que l’on modifie comme ceci:

propel:
  categories:
    _attributes: { phpName: Categories, treeMode: NestedSet }
    id: ~
    titre: { type: VARCHAR, size: '50', required: true }
    tree_left: { type: INTEGER, size: '11', required: true, nestedSetLeftKey: true }
    tree_right: { type: INTEGER, size: '11', required: true, nestedSetRightKey: true }
    tree_parent: { type: INTEGER, size: '11', required: true }

Vous noterez donc l’ajout du treeMode et la définition des nesetSetLeftKey et nestedSetRightKey, le tree_parent, n’est là que pour un certain confort à l’affichage, mais pas indispensable.

Votre schema ainsi terminé (vous pouvez y rajouter d’autres tables, même pas peur), on construit la base correspondante (en ayant au préalable configuré notre database.yml évidemment:

php symfony propel:build-all

Et là on constate dans notre dossier lib, deux nouvelles classes que vous n’avez sans doute jamais vu:
image-25
- BaseCategoriesNestedSet
- BaseCategoriesNestedSetPeer
qui viennent se placer entre entre les classes classiques et les classes « Base »

Afin de jouer rapidement, on va ajouter quelques valeurs test dans data/fixtures/fixtures.yml comme suit:

Categories:
  cat_1:
    titre: root
    tree_left: 1
    tree_right: 10
  cat_2:
    titre: Hi-Tech
    tree_left: 2
    tree_right: 7
  cat_3:
    titre: Developpement
    tree_left: 3
    tree_right: 4
  cat_4:
    titre: Internet
    tree_left: 5
    tree_right: 6
  cat_5:
    titre: Culture
    tree_left: 8
    tree_right: 9

que l’on va charger dès à présent:

php symfony propel:data-load

A noter que la notion de root est ici très importante. En effet, j’ai choisi délibérément de travailler avec un seul scope (une seule racine) pour la simple et bonne raison que les manipulations entre scope sont très limitées et l’intérêt et de pouvoir y faire tout et n’importe quoi. Il nous faut donc un élément parent de tous, que l’on ne modifiera jamais, que l’on n’affichera jamais.

Maintenant, les choses sérieuses commencent. On va commencer par générer un module admin generator:

php symfony generate:app main
php symfony propel:generate-admin main Categories

Et vous devriez arriver via http://monlocal/categories sur quelque chose d’assez classique:
image-26

L’idée est maintenant d’inclure un plugin jquery qui va matérialiser notre arbre, propel ne s’occupant évidemment que de la logique métier.
J’ai choisi le très bon NestedSortable qui répond très bien en terme de performance et très flexible à configurer. Vous pouvez donc télécharger la dernière version. Seul bémol, il nécessite Interface, un équivalent à jquery UI, que l’on va donc télécharger également.
Pour jQuery, on laissera google faire.

On a donc nos js comme ceci:
image-281

et on les intègre à notre projet via notre view.yml (la notation en ligne étant plus claire à mon goût):

  javascripts: 
    - http://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js
    - interface.js
    - inestedsortable-1.0.1.pack.js

On va devoir maintenant modifier un peu quelques partials car le plugin jquery nécessite une imbrication de tag html, ici on utilisera ce qui semble être le plus apte sémantiquement parlant, des couples ul/li.

L’idée est donc d’afficher tout l’arbre quelque soit le nombre d’élément et de n’afficher que l’info qui nous interesse, en l’occurrence le titre. Première chose, voici la modification sur le generator.yml:

      list:
        peer_method: retrieveTree
        max_per_page: 99999
        display: [titre]

Vous n’avez maintenant plus qu’un seul élément affiché dans votre tableau, l’élément root et c’est normal.

La suite se passe dans le partial _list.php. Rapide brief pour ceux qui n’ont jamais modifier un partial de l’admin generator. On récupère la version qui est dans le dossier cache/[APP]/[ENV]/modules/auto[MODULE]/templates et on la copie dans notre dossier templates de notre module généré.

Une fois cette opération effectuée, rien n’a du changer, mais maintenant on a la possibilité de modifier le partial, et voici un extrait de ce qu’il faut modifier dans la balise tbody:

     <tbody>
        <tr>
            <td class="container_main_list" colspan="3">
              <?php foreach ($pager->getResults() as $i => $item): ?>
                <?php include_partial('categories/tree',array('tree' => $item, 'i' => $i, 'helper' => $helper)) ?>
              <?php endforeach; ?>
            </td>
        </tr>
      </tbody>

Puis dans la partie script tout en base (vous pouvez retirer l’ancienne fonction checkAll, on ne pourra plus s’en servir:

<script type="text/javascript">
/* <![CDATA[ */
$(document).ready(function()  {
  $('#main_list').NestedSortable(
    {
      accept: 'item_list',
      opacity: 0.6,
      autoScroll: true,
      revert: true,
      nestingPxSpace: '15',
      handle: '.sort-handle',
      currentNestingClass: 'current-nesting',
      noNestingClass: 'sf_admin_td_actions',
      onChange: function(serialized) {
	$('#hashValue').val(serialized[0].hash);
      } 
    }
  );
});
/* ]]> */
</script>

Bon si vous avez suivi, vous vous doutez que si vous actualisez, symfony vous dira gentiment qu’il manque le partial _tree.php. Et bien créons le! C’est la base même du tuto, car c’est un partial récursif, qui va donc s’auto inclure:

<ul <?php echo (!empty($i)?'id="main_list" ':'')?>class="page-list">
  <?php foreach($tree->getChildren() as $node): ?>
    <li class="item_list clear-element" id="ele-<?php echo $node->getPrimaryKey() ?>">
      <div class="sort-handle">
        <?php echo $node->getTitre() ?>
			</div>
      <?php if ($node->hasChildren()): ?>
            <?php include_partial('categories/tree',array('tree' => $node, 'helper' => $helper)) ?>
      <?php endif; ?>
    </li>
  <?php endforeach; ?>
</ul>

Et là, un petit coup de F5 et la magie comment à opérer. Vous pouvez maintenant déplacer les éléments de branche en branche tel un hibou… bref, ca fonctionne!

Un petit coup de css s’impose pour que ca se rapproche le plus possible des autres modules classiques:

#sf_admin_container table tr:hover {
	background-color: #FFF;
}
 
ul#menu li{
	list-style: none;
	display: inline;
	padding: 0 10px 0 0 ;
}
 
#admin_content ul {
	margin: 0;
	list-style-position: inside;
}
 
#admin_content ul#list-container {
	margin-left: 1em;
	background-color: #FFF ;
	padding: 10px ;
}
 
#admin_content ul li {
	list-style-position: inside;
}
 
li img {
	vertical-align: text-bottom ;
}
 
#list-container li span {
	cursor: pointer;
}
 
#sortHelper {
	background: #FFE;
}
 
#dragHelper {
	background-color: #EEE;
}
 
div.wrap {
	border:1px solid #BBBBBB;
	padding: 1em 1em 1em 1em;
}
#main_list,#contenu_list {
	width: 700px;
}
 
#main_list li {
	list-style-type: none;
	list-style-position: none;
}
 
.page-list {
	list-style: none;
	margin: 0;
	padding: 0;
	display: block;
}
 
.clear-element {
	clear: both;
	position: relative;
}
 
.sort-handle {
	cursor:move;
	margin: 0.25em 0 0 0;
	padding: 2px 0 2px 18px;
	background: #f3F3F3 left center url(../images/toggleexpanddark.png) no-repeat;
}
 
div.item-title {
	background-color: #DDD;
	margin: 0.25em 0 0 0;
	padding: 2px 0 2px 18px;
}
 
.list-action {
	/*background-color: #f3F3F3;*/
	position: absolute;
	right: 0; top:0;
	width: 350px;
}

Et l’image qui va bien (récupéré de redotheoffice):
toggleexpanddark

Et voilà, la première étape s’achève ici, on peut s’amuser à déplacer ces éléments. La suite très prochainement, pour remettre les actions et ajouter une action pour sauvegarder l’arbre.

A noter que je ne détaille pas forcément toutes les étapes de création d’un projet symfony, Jobeet le fais si bien.

Évidemment, toutes remarques, questions, propositions, meilleurs solutions sont les bienvenues! Je suis aussi là pour apprendre : )

Tags: , , , , , ,

A propos de l'auteur

Tim

Développeur web spécialisé Symfony, il est avant tout passionné de web tout simplement. Il aime les défis et farfouiller dans le code de Symfony ou Doctrine. Fondateur du blog, il exerce chez Autrement.

Vous avez aimé ce billet? Faites le savoir!

  • Delicious
  • Twitter
  • Technorati Favorites
  • FriendFeed
  • Google Bookmarks
  • Share/Bookmark

10 Réponses

  1. tenshu 13 mai 2009 à 11 h 08 min #

    Super j’attends la suite avec impatience!
    Ça faisait un bout de temps que j’avais pas lu un tuto à la fois intéressant technique, succinct mais clair et détaillé.

    Keep up the good work comme un dit chez les étasuniens!

  2. Tim 13 mai 2009 à 12 h 22 min #

    Merci ca fait toujours plaisir ;)

    La suite a déjà été dev, donc c’est juste le temps de rédaction, j’espère mardi prochain vu que sans le vouloir le mardi est un peu devenu mon jour de blogging :p

  3. Jf 13 mai 2009 à 17 h 39 min #

    Super article !
    au temps de symfony 1.0 j’avais essayé de mettre en place un tel système mais j’avais finis par abandonné :-(

    merci beaucoup pour ce tuto :-)

  4. goulwen 14 mai 2009 à 18 h 27 min #

    Nous utilisons sous sf1.0 le plugin sfNestedSetAsPropelBehaviorPlugin.

    Ca marche relativement bien, même si les opérations de suppressions et déplacement dans les arbres produisent parfois des soucis. Il faudrait que l’on porte le tout sous sf1.2/Propel1.3, donc ce tuto est le bienvenu !

  5. netounet 21 mai 2009 à 13 h 28 min #

    Tuto très intéressant et très ludique.

    Par contre je n’ai pas pu allé plus loin que la personnalisation du module.

    peer_method: retrieveTree

    La méthode n’existe pas pour CategoriePeer (par contre elle existe bien pour CategorieNestedSetPeer)

    Manque-t-il une étape ?

    Merci d’avance

  6. netounet 21 mai 2009 à 13 h 33 min #

    Problème résolu en fait j’avais effectuer un premier build-model avant d’appliquer les nestedSeter et du coup les classes présentes dans lib/model étendaient BaseCategoriePeer au lieu de BasecategorieNestedSetPeer.

    Comme elles ne sont pas mises à jour une fois qu’elles existent, cela posait donc problème ;)

    Encore merci

  7. Tim 21 mai 2009 à 15 h 20 min #

    Effectivement, j’avais eu le même souci lors mes tatonages. Je vais rajouter le point car on sera pas les seuls à avoir le souci je pense ^^

  8. nico 20 novembre 2009 à 12 h 21 min #

    En ayant suivi plusieurs fois ce tuto avec attention, je parviens toujours à un résultat un peu différent:
    - j’ai toujours une ligne supplémentaire au bas de mon tableau, concernant l’entrée ‘root’
    - sur la partie droite de la page wen, j’ai un second tableau qui semble être un filtre concernant l’affichage
    - les liens sur les en-têtes des colonnes générent une erreur: Fatal error: Cannot redeclare class BaseCategoriesPeer in /home/[...]/symfony/lib/model/om/BaseCategoriesPeer.php on line 14

    A quel endroit pourrais-je m’être trompé?
    D’avance merci.

  9. nico 20 novembre 2009 à 12 h 43 min #

    Pour la ligne ‘root’ en trop, j’ai fini par trouver:
    le fichier _list.php contenait encore l’ancien code entre les balises :]]

  10. Tim 20 novembre 2009 à 14 h 34 min #

    Hello,

    Ravi que tu sois arrivé à t’en sortir.

    Pour info, j’ai toujours projet de mettre à disposition des versions complètes de ce code à télécharger (pour les 2 ORM)

    Je manque juste de beaucoup de temps en ce moment mais ca viendra un jour : )


Laisser un message