Gestion de la paternité (Symfony 1.2, Doctrine)

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 :
https://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 :
    https://symfony.com/legacy
  • Comment créer une application backend :
    https://symfony.com/legacy
  • Tout ce post est basé sur le travail des gens de ce lien (en anglais) :
    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

    getNbResults()): ?>

    $sort)) ?>

    getResults() as $i => $tree): $odd = fmod(++$i, 2) ? ‘odd’ : ‘even’ ?>getParent()->getId(); } ?>”> $tree, ‘helper’ => $helper)) ?> $tree)) ?> $tree, ‘helper’ => $helper)) ?>

    haveToPaginate()): ?> $pager)) ?> $pager->getNbResults()), $pager->getNbResults(), ‘sf_admin’) ?> haveToPaginate()): ?> $pager->getPage(), ‘%%nb_pages%%’ => $pager->getLastPage()), ‘sf_admin’) ?>

     

  • _list_footer.php

    /apps/backend/modules/tree/templates_list_footer.php

    After changing the order of the tree, the new order should be saved. This is currently implemented
    as a batch action, so please choose Update tree order from the 'Choose an action' dropdown
    and click on 'Go' to save the new order.
  • _list_td_batch_actions.php

    /apps/backend/modules/tree/templates_list_td_batch_actions.php

    
    
  • _list_td_tabular.php

    /apps/backend/modules/tree/templates/_list_td_tabular.php

      
    
    

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 - https://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:
    - https://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js
    - https://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

pagewhitetext.png page_white_text.png
pagewhitetext.png folder.png
toggle_collapse_dark.png toggle_collapse_dark.png
toggle_collapse_light.png toggle_collapse_light.png
toggle_expand_dark.png toggle_expand_dark.png
toggle_expand_light.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:
https://www.lexik.fr/nested/index.php


Sources :

 

Voir l’étude de cas
Lire l’article
Voir le témoignage
Fermer