La gestion d’arbres en SQL se fait traditionnellement par une auto-jointure, avec le champ classique parent_id. Cette méthode est cependant très coûteuse quand il s’agit de faire des recherches dans cet arbre car il faut alors utiliser la récursivité.
Une autre méthode beaucoup plus puissante existe : la gestion intervallaire. Vous pouvez trouver une explication détaillée de cette méthode ici :
http://sqlpro.developpez.com/cours/arborescence/.
Nous allons voir dans cet article comment implémenter celle-ci dans un projet symfony 1.2 avec doctrine.
Pré-requis
- Un backend symfony 1.2, doctrine
- Comment créer un projet symfony 1.2 Doctrine :
http://www.symfony-project.org/jobeet/1_2/Doctrine/en/03 - Comment créer une application backend :
http://www.symfony-project.org/jobeet/1_2/Doctrine/en/12 - Tout ce post est basé sur le travail des gens de ce lien (en anglais) :
http://redotheoffice.com/
Schéma
/config/doctrine/schema.yml
Tree:
actAs:
NestedSet:
hasManyRoots: true
rootColumnName: root_id
columns:
name:
type: string(255) |
Module
Créez le module :
symfony doctrine:generate-admin backend Tree --module=tree |
Formulaire
Modifiez la classe de formulaire :
/lib/form/doctrine/TreeForm.class.php
widgetSchema['parent_id'] = new sfWidgetFormDoctrineChoice(array( 'model' => 'tree', 'add_empty' => '~ (object is at root level)', 'order_by' => array('root_id, lft',''), 'method' => 'getIndentedName' )); $this->validatorSchema['parent_id'] = new sfValidatorDoctrineChoice(array( 'required' => false, 'model' => 'tree' )); $this->setDefault('parent_id', $this->object->getParentId()); $this->widgetSchema->setLabel('parent_id', 'Child of'); } public function updateParentIdColumn($parentId) { $this->parentId = $parentId; // further action is handled in the save() method } protected function doSave($con = null) { parent::doSave($con); $node = $this->object->getNode(); if ($this->parentId != $this->object->getParentId() || !$node->isValidNode()) { if (empty($this->parentId)) { //save as a root if ($node->isValidNode()) { $node->makeRoot($this->object['id']); $this->object->save($con); } else { $this->object->getTable()->getTree()->createRoot($this->object); //calls $this->object->save internally } } else { //form validation ensures an existing ID for $this->parentId $parent = $this->object->getTable()->find($this->parentId); $method = ($node->isValidNode() ? 'move' : 'insert') . 'AsFirstChildOf'; $node->$method($parent); //calls $this->object->save internally } } } } |
Modèle
Modifiez la classe du modèle :
/lib/model/doctrine/Tree.class.php
getIndentedName()); } public function getParentId() { if (!$this->getNode()->isValidNode() || $this->getNode()->isRoot()) { return null; } $parent = $this->getNode()->getParent(); return $parent['id']; } public function getIndentedName() { return str_repeat('- ',$this['level']).$this['name']; } } |
Actions
Modifier la classe action du module :
/apps/backend/modules/tree/actions
addOrderBy('root_id, lft'); } public function executeBatch(sfWebRequest $request) { if ("batchOrder" == $request->getParameter('batch_action')) { return $this->executeBatchOrder($request); } parent::executeBatch($request); } public function executeBatchOrder(sfWebRequest $request) { $newparent = $request->getParameter('newparent'); //manually validate newparent parameter //make list of all ids $ids = array(); foreach ($newparent as $key => $val) { $ids[$key] = true; if (!empty($val)) $ids[$val] = true; } $ids = array_keys($ids); //validate if all id's exist $validator = new sfValidatorDoctrineChoiceMany(array('model' => 'Tree')); try { // validate ids $ids = $validator->clean($ids); // the id's validate, now update the tree $count = 0; $flash = ""; foreach ($newparent as $id => $parentId) { if (!empty($parentId)) { $node = Doctrine::getTable('Tree')->find($id); $parent = Doctrine::getTable('Tree')->find($parentId); if (!$parent->getNode()->isDescendantOfOrEqualTo($node)) { $node->getNode()->moveAsFirstChildOf($parent); $node->save(); $count++; $flash .= " Moved '".$node['name']."' under '".$parent['name']."'."; } } } if ($count > 0) { $this->getUser()->setFlash('notice', sprintf("Tree order updated, moved %s item%s:".$flash, $count, ($count > 1 ? 's' : ''))); } else { $this->getUser()->setFlash('error', "You must at least move one item to update the tree order"); } } catch (sfValidatorError $e) { $this->getUser()->setFlash('error', 'Cannot update the tree order, maybe some item are deleted, try again'); } $this->redirect('@tree'); } public function executeDelete(sfWebRequest $request) { $request->checkCSRFProtection(); $this->dispatcher->notify(new sfEvent($this, 'admin.delete_object', array('object' => $this->getRoute()->getObject()))); $object = $this->getRoute()->getObject(); if ($object->getNode()->isValidNode()) { $object->getNode()->delete(); } else { $object->delete(); } $this->getUser()->setFlash('notice', 'The item was deleted successfully.'); $this->redirect('@tree'); } public function executeListNew(sfWebRequest $request) { $this->executeNew($request); $this->form->setDefault('parent_id', $request->getParameter('id')); $this->setTemplate('edit'); } protected function processForm(sfWebRequest $request, sfForm $form) { $form->bind($request->getParameter($form->getName()), $request->getFiles($form->getName())); if ($form->isValid()) { $this->getUser()->setFlash('notice', $form->getObject()->isNew() ? 'The item was created successfully.' : 'The item was updated successfully.'); $tree = $form->save(); $this->dispatcher->notify(new sfEvent($this, 'admin.save_object', array('object' => $tree))); if ($request->hasParameter('_save_and_add')) { $this->getUser()->setFlash('notice', $this->getUser()->getFlash('notice').' You can add another one below.'); //$this->redirect('@tree_new'); } else { //$this->redirect('@tree_edit?id='.$tree['id']); } } else { $this->getUser()->setFlash('error', 'The item has not been saved due to some errors.'); } } } |
Generator
Modifiez le fichier :
/apps/backend/modules/tree/config/generator.yml
generator:
class: sfDoctrineGenerator
param:
model_class: Tree
theme: admin
non_verbose_templates: true
with_show: false
singular: ~
plural: ~
route_prefix: tree_tree
with_doctrine_route: 1
config:
actions: ~
fields: ~
list:
title: Gestions des catégories
max_per_page: 999999
batch_actions:
order:
label: Update tree order
_delete: ~
object_actions:
new:
label: Add Child
_edit: ~
_delete: ~
actions:
_new:
label: Add Root
filter: ~
form: ~
edit:
title: Editing Categorie "%%name%%"
new: ~ |
Templates
- Créez les fichiers suivant :
- _list.php
/apps/backend/modules/tree/templates_list.php
<div class="sf_admin_list"> getNbResults()): ?> <table id="main_list" border="0" cellspacing="0"> <thead> <tr> <th id="sf_admin_list_batch_actions"> <input id="sf_admin_list_batch_checkbox" onclick="checkAll();" type="checkbox" /></th> $sort)) ?> <th id="sf_admin_list_th_actions"></th> </tr> </thead> <tfoot> <tr> <th colspan="8"> haveToPaginate()): ?> $pager)) ?> $pager->getNbResults()), $pager->getNbResults(), 'sf_admin') ?> haveToPaginate()): ?> $pager->getPage(), '%%nb_pages%%' => $pager->getLastPage()), 'sf_admin') ?></th> </tr> </tfoot> <tbody> getResults() as $i => $tree): $odd = fmod(++$i, 2) ? 'odd' : 'even' ?> <tr id="node-<?php echo $tree['id'] ?>" class="sf_admin_row <?php echo $odd ?><?php // insert hierarchical info $node = $tree->getNode(); if ($node->isValidNode() && $node->hasParent()) { echo">getParent()->getId(); } ?>"> $tree, 'helper' => $helper)) ?> $tree)) ?> $tree, 'helper' => $helper)) ?></tr> </tbody></table> </div> <script type="text/javascript"><!--mce:0--></script>
- _list_footer.php
/apps/backend/modules/tree/templates_list_footer.php
<em>After changing the order of the tree, the new order should be saved. This is currently implemented as a batch action, so please choose <strong>Update tree order</strong> from the 'Choose an action' dropdown and click on 'Go' to save the new order.</em>
- _list_td_batch_actions.php
/apps/backend/modules/tree/templates_list_td_batch_actions.php
<input class="sf_admin_batch_checkbox" name="ids[]" type="checkbox" value="<?php echo $tree->getPrimaryKey() ?>" /> <input id="select_node-<?php echo $tree->getPrimaryKey() ?>" name="newparent[<?php echo $tree->getPrimaryKey() ?>]" type="hidden" />
- _list_td_tabular.php
/apps/backend/modules/tree/templates/_list_td_tabular.php
<span class="<?php echo $tree->getNode()->isLeaf() ? 'file' : 'folder' ?>"> </span>
CSS/JS
- CSSCréez ce fichier :
/web/css/jQuery.treeTable.css
/* jQuery TreeTable Core 2.0 stylesheet * * This file contains styles that are used to display the tree table. Each tree * table is assigned the +treeTable+ class. * ========================================================================= */ /* jquery.treeTable.collapsible * ------------------------------------------------------------------------- */ .treeTable tr td .expander { background-position: left center; background-repeat: no-repeat; cursor: pointer; padding: 0; zoom: 1; /* IE7 Hack */ } .treeTable tr.collapsed td .expander { background-image: url(../images/toggle-expand-dark.png); } .treeTable tr.expanded td .expander { background-image: url(../images/toggle-collapse-dark.png); } /* jquery.treeTable.sortable * ------------------------------------------------------------------------- */ .treeTable tr.selected, .treeTable tr.accept { background-color: #ccccff !important; color: #fff !important; } .treeTable tr.collapsed.selected td .expander, .treeTable tr.collapsed.accept td .expander { background-image: url(../images/toggle-expand-light.png); } .treeTable tr.expanded.selected td .expander, .treeTable tr.expanded.accept td .expander { background-image: url(../images/toggle-collapse-light.png); } .treeTable .ui-draggable-dragging { color: #000; z-index: 1; }
Créez ce fichier :
/web/css/main.css
table span { background-position: center left; background-repeat: no-repeat; padding: .2em 0 .2em 1.5em; } table span.file { background-image: url(../images/page_white_text.png); } table span.folder { background-image: url(../images/folder.png); }
- JavascriptCréez ce fichier :
/web/js/jQuery.treeTable.js
/* jQuery treeTable Plugin 2.2 - http://ludo.cubicphuse.nl/jquery-plugins/treeTable/ */ (function($) { // Helps to make options available to all functions // TODO: This gives problems when there are both expandable and non-expandable // trees on a page. The options shouldn't be global to all these instances! var options; $.fn.treeTable = function(opts) { options = $.extend({}, $.fn.treeTable.defaults, opts); return this.each(function() { $(this).addClass("treeTable").find("tbody tr").each(function() { // Initialize root nodes only whenever possible if(!options.expandable || $(this)[0].className.search("child-of-") == -1) { initialize($(this)); } }); }); }; $.fn.treeTable.defaults = { childPrefix: "child-of-", expandable: true, indent: 19, initialState: "collapsed", treeColumn: 0 }; // Recursively hide all node's children in a tree $.fn.collapse = function() { $(this).addClass("collapsed"); childrenOf($(this)).each(function() { initialize($(this)); if(!$(this).hasClass("collapsed")) { $(this).collapse(); } $(this).hide(); }); return this; }; // Recursively show all node's children in a tree $.fn.expand = function() { $(this).removeClass("collapsed").addClass("expanded"); childrenOf($(this)).each(function() { initialize($(this)); if($(this).is(".expanded.parent")) { $(this).expand(); } $(this).show(); }); return this; }; // Add an entire branch to +destination+ $.fn.appendBranchTo = function(destination) { var node = $(this); var parent = parentOf(node); var ancestorNames = $.map(ancestorsOf($(destination)), function(a) { return a.id; }); // Conditions: // 1: +node+ should not be inserted in a location in a branch if this would // result in +node+ being an ancestor of itself. // 2: +node+ should not have a parent OR the destination should not be the // same as +node+'s current parent (this last condition prevents +node+ // from being moved to the same location where it already is). // 3: +node+ should not be inserted as a child of +node+ itself. if($.inArray(node[0].id, ancestorNames) == -1 && (!parent || (destination.id != parent[0].id)) && destination.id != node[0].id) { indent(node, ancestorsOf(node).length * options.indent * -1); // Remove indentation if(parent) { node.removeClass(options.childPrefix + parent[0].id); } node.addClass(options.childPrefix + destination.id); move(node, destination); // Recursively move nodes to new location indent(node, ancestorsOf(node).length * options.indent); } return this; }; // Add reverse() function from JS Arrays $.fn.reverse = function() { return this.pushStack(this.get().reverse(), arguments); }; // Toggle an entire branch $.fn.toggleBranch = function() { if($(this).hasClass("collapsed")) { $(this).expand(); } else { $(this).removeClass("expanded").collapse(); } return this; }; // === Private functions function ancestorsOf(node) { var ancestors = []; while(node = parentOf(node)) { ancestors[ancestors.length] = node[0]; } return ancestors; }; function childrenOf(node) { return $("table.treeTable tbody tr." + options.childPrefix + node[0].id); }; function indent(node, value) { var cell = $(node.children("td")[options.treeColumn]); var padding = parseInt(cell.css("padding-left"), 10) + value; cell.css("padding-left", + padding + "px"); childrenOf(node).each(function() { indent($(this), value); }); }; function initialize(node) { if(!node.hasClass("initialized")) { node.addClass("initialized"); var childNodes = childrenOf(node); if(!node.hasClass("parent") && childNodes.length > 0) { node.addClass("parent"); } if(node.hasClass("parent")) { var cell = $(node.children("td")[options.treeColumn]); var padding = parseInt(cell.css("padding-left"), 10) + options.indent; childNodes.each(function() { $($(this).children("td")[options.treeColumn]).css("padding-left", padding + "px"); }); if(options.expandable) { cell.prepend(''); $(cell[0].firstChild).click(function() { node.toggleBranch(); }); // Check for a class set explicitly by the user, otherwise set the default class if(!(node.hasClass("expanded") || node.hasClass("collapsed"))) { node.addClass(options.initialState); } if(node.hasClass("collapsed")) { node.collapse(); } else if (node.hasClass("expanded")) { node.expand(); } } } } }; function move(node, destination) { node.insertAfter(destination); childrenOf(node).reverse().each(function() { move($(this), node[0]); }); }; function parentOf(node) { var classNames = node[0].className.split(' '); for(key in classNames) { if(classNames[key].match("child-of-")) { return $("#" + classNames[key].substring(9)); } } }; })(jQuery);
view.yml
Modifiez ce fichier :
/apps/backend/config/view.yml
default:
http_metas:
content-type: text/html
metas:
#title: symfony project
#description: symfony project
#keywords: symfony, project
#language: en
#robots: index, follow
stylesheets: [main.css, jQuery.treeTable.css]
javascripts:
- http://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js
- http://ajax.googleapis.com/ajax/libs/jqueryui/1.5.3/jquery-ui.min.js
- jquery.treeTable.js
has_layout: on
layout: layout |
Routing
Il faut rajouter la règle de routage « tree » !!
/apps/backend/config/routing.yml
tree_tree:
class: sfDoctrineRouteCollection
options:
model: Tree
module: tree
prefix_path: tree
column: id
with_wildcard_routes: true
tree:
class: sfDoctrineRouteCollection
options:
model: Tree
module: tree
prefix_path: tree
column: id
with_wildcard_routes: true
# default rules
homepage:
url: /
param: { module: default, action: index }
default_index:
url: /:module
param: { action: index }
default:
url: /:module/:action/* |
Clear Cache
On vide le cache :
symfony cc |
Fixtures
Ajouter ce fichier :
/data/fixtures/data.yml
Tree:
Tree_1:
name: Couleurs
root_id: '1'
lft: '1'
rgt: '8'
level: '0'
Tree_2:
name: Bleu
root_id: '1'
lft: '6'
rgt: '7'
level: '1'
Tree_3:
name: Rouge
root_id: '1'
lft: '2'
rgt: '3'
level: '1'
Tree_4:
name: Vert
root_id: '1'
lft: '4'
rgt: '5'
level: '1'
Tree_5:
name: Langages
root_id: '5'
lft: '1'
rgt: '14'
level: '0'
Tree_6:
name: Serveur
root_id: '5'
lft: '8'
rgt: '13'
level: '1'
Tree_7:
name: Client
root_id: '5'
lft: '2'
rgt: '7'
level: '1'
Tree_8:
name: Javascript
root_id: '5'
lft: '5'
rgt: '6'
level: '2'
Tree_9:
name: ActionScript
root_id: '5'
lft: '3'
rgt: '4'
level: '2'
Tree_10:
name: PHP
root_id: '5'
lft: '11'
rgt: '12'
level: '2'
Tree_11:
name: ASP
root_id: '5'
lft: '9'
rgt: '10'
level: '2' |
build-all-reload
symfony doctrine:build-all-reload |
Les images :
A copier dans web/images
![]() |
page_white_text.png | |
![]() |
folder.png | |
![]() |
toggle_collapse_dark.png | |
![]() |
toggle_collapse_light.png | |
![]() |
toggle_expand_dark.png | |
![]() |
toggle_expand_light.png |
Aperçu
Vous devriez avoir ça !

Je rajoute un lien pratique: un petit outil que nous avons fait pour pouvoir générer automatiquement tous les fichiers, donc plus besoin de suivre le post à la lettre, complétez le formulaire et télécharger vos fichiers créés:
http://www.lexik.fr/nested/index.php
Sources :
- http://sqlpro.developpez.com/cours/arborescence/
- http://www.symfony-project.org/jobeet/
- http://redotheoffice.com/







Excellent article !
Je l’ai bookmarké depuis un bout de temps mais je n’avais toujours pas eu le temps de venir déposer un commentaire.
Ce post complète très bien celui de Redo the Office.
voici un petit outil que je viens de créer pour faciliter la mise en place du nestedset et de jquery. Effectivement suivant le nom de son module et de son modele, il faut mettre le nez dans le code et modifier toutes les occurences. Plus la peine, nous générons les fichiers avec le nom de votre modèle!
http://www.lexik.fr/nested/index.php
Salut,
Félicitation pour ce travail.
Je cherche à faire la même chose avec Symfony/Propel et prototype/scriptaculous mais je n\’y parviens pas.
Savez-vous où je peux trouver de l\’aide ?
Très bon article !
Ma question concerne l’outil de Thomas, pourquoi le champs affiché ne doit pas être un champs I18N ?
D’avance merci
@Manu
Bonjour et merci!
le champs ne doit pas être I18N car dans le backend dans le listing nous avons rencontré des erreurs en listant un champs I18N. Donc pour simplifier et corriger facilement, on ne liste pas un champs I18N.
Cependant avec les mises a jours symfony ce problème ne ressort peut être plus, à tester!
Super tuto !
J’ai bien aimé ton générateur de fichiers car il manque quelques lignes de codes dans le post
@BugsByte pour avec propel j’avais combiné le tree de chez dhmlgoodies
Hello,
It will be cool :
- to optimize the number of requests ($tree->getNode() makes a request per line),
- to add sortable behavior (like the menu management in Drupal),
- and to update more than one change at the same time.
Thanks for your webiste.
Toujours aussi utile ce site !!!
Je me suis ajouté 2 objects actions pour déplacer les enfants de même parent si ça peut être utile …
http://404past-it.fr/721
Par contre, je ne comprends pas l\’action Order ….
Encore merci!
Seb
Ouf ! Sacré travail. Qui m\’a fait gagner pas mal de temps, merci beaucoup.
À quand un plugin ?