Je vais ici vous expliquer comment utiliser le nestedset pour créer un système de catégorie gérant les parents et enfants sur une infinité de niveaux.
La première étape est de comprendre le principe de gestion des arbres par représentation intervallaire.
Bon, vous avez pris le temps de lire l’article présenté ci-dessus on passe maintenant à la pratique avec symfony et doctrine.
L’idée ici est de créer un système de catégories et de sous catégories sans limitation de niveaux
Création du schéma de base de données
Category: actAs: NestedSet: hasManyRoots: true rootColumnName: root_id columns: name: string(255)
Voila avec ce code vous allez demander à doctrine de créer une table capable de gérer votre arbre.
hasManyRoots: true spécifie qu’il peut y avoir des parents sur plusieurs niveaux (c’est à dire qu’une catégorie enfant peut avoir elle même d’autres enfants).
rootColumnName: root_id est nécessaire si justement hasManyRoots est placé à true.
Le fichier fixture
category: cat1: nom: categorie_1 children: [] cat2: nom: categorie_2 children: cat2a: nom: categorie_2_a children: cat2a1: nom: categorie_2_1 children: [] cat2a2: nom: categorie_2_a_2 children: [] cat2a3: nom: categorie_2_a_3 children: [] cat2a4: nom: categorie_2_a_4 children: [] cat2b: nom: categorie_2_b children: cat2b1: nom: categorie_2_b_1 children: [] cat2b2: nom: categorie_2_b_2 children: [] cat2b3: nom: categorie_2_b_3 children: [] cat2b4: nom: categorie_2_b_4 children: []
La représentation d’un arbre en yml est très clair donc pas besoin de beaucoup d’explications. On peut juste préciser que children: [] signifie que la catégorie ne possède aucun enfant.
Il faut maintenant lancer la création de votre table et des classes des modèles en utilisant la formule magique:
symfony doctrine:build --all --and-load --no-confirmation
Configuration du formulaire
La première étape consiste à modifier le formulaire de base des catégories pour qu’il soit plus agréable et plus fonctionnel.
Pour cela modifiez le fichier CategoryForm.class.php comme ceci:
class CategoryForm extends BaseCategoryForm { public function configure() { // cree un widget pour representer les categories parentes $this->setWidget('parent', new sfWidgetFormDoctrineChoiceNestedSet(array( 'model' => 'Category', 'add_empty' => true, ))); // si la categorie a un parent on en fait le choix par defaut if ($this->getObject()->getNode()->hasParent()) { $this->setDefault('parent', $this->getObject()->getNode()->getParent()->getId()); } // permet à l'utilisateur de ne changer que le nom et les parents d'une categorie $this->useFields(array( 'name', 'parent', )); // les labels des champs $this->widgetSchema->setLabels(array( 'name' => 'Category', 'parent' => 'Parent category', )); // cree un validator qui evite qu'un enfant d'une categorie soit specifie comme etant son propre parent $this->setValidator('parent', new sfValidatorDoctrineChoiceNestedSet(array( 'required' => false, 'model' => 'Category', 'node' => $this->getObject(), ))); $this->getValidator('parent')->setMessage('node', 'Une catégorie ne peut être le descendant d'elle même'); } public function doSave($con = null) { // sauvegarde l'enregistrement parent::doSave($con); // si un parent a ete specifie, ajoute/déplace ce noeud pour etre l'enfant de ce parent if ($this->getValue('parent')) { $parent = Doctrine::getTable('Category')->findOneById($this->getValue('parent')); if ($this->isNew()) { $this->getObject()->getNode()->insertAsLastChildOf($parent); } else { $this->getObject()->getNode()->moveAsLastChildOf($parent); } } // si aucun parent n'est specifie, ajoute/deplace ce noeud pour etre un nouveau noeud racine else { $categoryTree = Doctrine::getTable('Category')->getTree(); if ($this->isNew()) { $categoryTree->createRoot($this->getObject()); } else { $this->getObject()->getNode()->makeRoot($this->getObject()->getId()); } } } }
Création du widget sfWidgetFormDoctrineChoiceNestedSet
Allez dans le dossier lib/widget (créez le s’il n’existe pas) et créez le fichier sfWidgetFormDoctrineChoiceNestedSet.class.php
Ajoutez-y le code suivant:
class sfWidgetFormDoctrineChoiceNestedSet extends sfWidgetFormDoctrineChoice { public function getChoices() { $choices = array(); if (false !== $this->getOption('add_empty')) { $choices[''] = true === $this->getOption('add_empty') ? '' : $this->getOption('add_empty'); } if (null === $this->getOption('table_method')) { $query = null === $this->getOption('query') ? Doctrine_Core::getTable($this->getOption('model'))->createQuery() : $this->getOption('query'); $query->addOrderBy('root_id asc') ->addOrderBy('lft asc'); $objects = $query->execute(); } else { $tableMethod = $this->getOption('table_method'); $results = Doctrine_Core::getTable($this->getOption('model'))->$tableMethod(); if ($results instanceof Doctrine_Query) { $objects = $results->execute(); } else if ($results instanceof Doctrine_Collection) { $objects = $results; } else if ($results instanceof Doctrine_Record) { $objects = new Doctrine_Collection($this->getOption('model')); $objects[] = $results; } else { $objects = array(); } } $method = $this->getOption('method'); $keyMethod = $this->getOption('key_method'); foreach ($objects as $object) { $choices[$object->$keyMethod()] = str_repeat(' ', ($object['level'] * 4)) . $object->$method(); } return $choices; } }
Création du validator sfValidatorDoctrineChoiceNestedSet
Allez dans le dossier lib/validator (créez le s’il n’existe pas) et créez le fichier sfValidatorDoctrineChoiceNestedSet.class.php
Ajoutez-y le code suivant:
class sfValidatorDoctrineChoiceNestedSet extends sfValidatorBase { /** * Configures the validator. * Available options: * model: The model class (required) * node: The node being moved (required) * * @see sfValidatorBase */ protected function configure($options = array(), $messages = array()) { $this->addRequiredOption('model'); $this->addRequiredOption('node'); $this->addMessage('node', 'A node cannot be set as a child of itself.'); } protected function doClean($value) { if (isset($value) && !$value) { unset($value); } else { $targetNode = Doctrine::getTable($this->getOption('model'))->find($value)->getNode(); if ($targetNode->isDescendantOfOrEqualTo($this->getOption('node'))) { throw new sfValidatorError($this, 'node', array('value' => $value)); } return $value; } } }
Voila maintenant lorsque vous tentez d’ajouter ou modifier une catégorie vous aurez une jolie selectBox avec l’indentation qui va bien.
Surcharger la fonction delete
Il faut surcharger la fonction delete créée initialement par doctrine car, au lieu de simplement détruire le noeud demandé, elle doit également mettre à jour l’arbre.
Ouvrez le fichier apps/backend/modules/category/actions/actions.class.php et ajoutez-y le code suivant:
// /apps/frontend/modules/category/actions/actions.class.php class CategoryActions extends autoCategoryActions { public function executeDelete(sfWebRequest $request) { $request->checkCSRFProtection(); $this->dispatcher->notify(new sfEvent($this, 'admin.delete_object', array('object' => $this->getRoute()->getObject()))); if ($this->getRoute()->getObject()->getNode()->delete()) { $this->getUser()->setFlash('notice', 'The item was deleted successfully.'); } $this->redirect('@category'); } protected function executeBatchDelete(sfWebRequest $request) { $ids = $request->getParameter('ids'); $records = Doctrine_Query::create() ->from('ChangeType') ->whereIn('id', $ids) ->execute(); foreach ($records as $record) { $record->getNode()->delete(); } $this->getUser()->setFlash('notice', 'The selected items have been deleted successfully.'); $this->redirect('@change_type'); } }
On surcharge également la fonction executeBatchDelete afin que lorsqu’on supprime plusieurs catégories d’un coup l’arbre soit, là aussi, mis à jour.
Appliquer notre propre tri
Afin d’afficher nos catégories dans l’ordre nous avons besoin de trier nos résultats selon deux critères: root_id et lft. Malheureusement, le fichier generator.yml ne permet pas de trier sur deux colonnes. Il faut donc écraser la fonction de tri d’origine.
Pour cela ajoutez le code suivant au fichier actions.class.php
protected function addSortQuery($query) { $query->addOrderBy('root_id asc'); $query->addOrderBy('lft asc'); }
Le fichier generator.yml
On va maintenant définir les champs que l’on veut voir apparaître dans la liste. Ici nous ne voulons voir que le champ name
# /apps/frontend/modules/category/config/generator.yml generator: class: sfDoctrineGenerator param: model_class: Category theme: admin non_verbose_templates: true with_show: false singular: ~ plural: ~ route_prefix: category with_doctrine_route: true actions_base_class: sfActions config: actions: ~ fields: ~ list: display: [=name] filter: ~ form: ~ edit: ~ new: ~
Modifier le template de l’admin
Il faut maintenant modifier le template de base proposé par l’admin generator.
Pour cela, créez le fichier called _list_td_tabular.php et placez-le dans le dossier apps/backend/modules/templates.
Ajoutez-y le code suivant:
<?php use_stylesheet('nested-set.css') ?> <td class="sf_admin_text sf_admin_list_td_name nested_set"> <span class="<?php echo $category->getNode()->isLeaf() ? 'leaf' : 'node' ?>" style="<?php echo 'margin-left: ' . ($category['level'] * 1.3) . 'em' ?>"> <?php echo link_to($category->getName(), 'category_edit', $category) ?> </span> </td>
La fonction isLeaf est implantée par doctrine et permet de déterminer si le noeud en question possède des enfants ou pas. On applique donc la classe node ou leaf en conséquence.
le css
Via la fonction use_stylesheet on a appelé le fichier nested-set.css.
Il faut donc le créer et le placer dans le dossier web/css/
.nested_set span { background-position: center left; background-repeat: no-repeat; padding: .2em 0 .2em 1.5em; } .nested_set span.leaf { background-image: url('/images/icons/tree-leaf.png'); } .nested_set span.node { background-image: url('/images/icons/tree-node.png'); }
Il ne vous reste plus qu’à trouver deux images, une pour les catégories avec enfants tree-node.png l’autre pour celles sans enfant tree-leaf.png
Si vous voulez plus de détails cet article est (grandement) inspiré de celui-ci (en anglais)
A lire
gestion des arbres par représentation intervallaire
Manipuler les données hiérarchisées avec Doctrine