Click here to Skip to main content
15,868,141 members
Articles / Web Development / HTML
Tip/Trick

Editing Tree Structures in Symfony2

Rate me:
Please Sign up or sign in to vote.
0.00/5 (No votes)
9 Jul 2012CPOL7 min read 39.4K   1   6
Editing tree structures is a common problem in web development

Introduction

Editing tree structures is a common problem in web development. It is very convenient to the user, because it gives him the opportunity to create any hierarchy on his site. Naturally, after the transition to Symfony2, one of the first tasks was to create a hierarchical list of pages and the admin panel for it. And since, as I use the admin panel SonataAdminBundle, the task was to configure it for editing trees.

It seems that the problem is widespread, as well as in high demand and I expect to receive a turnkey solution "out of the box". However, this did not happen. Moreover, the developers of Sonata seem to have never thought that someone would think of "admining" trees through their bundle.

Let's start with the tree. Since being a "baby-programmer", I was taught never to reinvent the wheel. And though I sometimes was trying to retort and thought that to reinvent the wheel will be easier and go forward, but always was a failure ... and had to use ready-made solutions. For the tree structure of pages, it was decided to use the Nested tree of the Doctrine Extensions.

Creating a model tree using Doctrine Extensions Tree is not complicated and it is described in the manual. I note that for ease of use of extensions within the Doctrine Symfony2, you must connect StofDoctrineExtensionsBundle, installation and configuration of which, again, is well described in the manual.

So, I made a model ShtumiPravBundle: Page, complete code that I will not give in this tip as it is unnecessary.

Now I want to say a few words about the bad features of Nested Tree, from which I had a couple of times to change everything.

To store the tree structure, Doctrine Extensions use not only the field of parent, but the fields root, lft, rgt, lvl, which are also stored in the database. Field assignment is clear: they determine the order of the children in the tree, as well as allow you to create a simple SQL queries to retrieve elementlv tree in the "correct" order. These fields are automatically computed and stored in the database. However, I could not understand the algorithm for calculating the value of the field lft and. If any value the same of these fields in any part of the tree is wrong - it will damage the whole tree. Breakdown, which is almost impossible to fix, given the complexity of the calculation of the above fields, multiplied by the number of elements in the tree.

In the Doctrine Extensions Tree, it’s impossible to swap root elements using standard methods (moveUp, moveDown). If you try to do it, then the exception with the appropriate message "climbs" out.

In part 1, I talked about the fields root, lft, rgt, a failure in the values of which brings damage to the tree. Now, let us hem some fuel to the fire. Such situations occur in the event of a failure when deleting items in the tree because of foreign keys. In my case, there were the additional items, that are "fasten" to each article. The problem was revealed in all its glory after the filling of the site with content and restoration of the tree required a lot of nerves and labor.

Derivation of the Tree Structure in the Admin Panel

One of the first issues that needed to be resolved - to display pages in the admin panel as a tree, i.e., to add on the left side before the title of the article some number of blanks corresponding to the level of nesting. The same problem was with the “select” drop-down lists. The solution was very simple - to add to the model the methods __ toString and getLaveledTitle:

C#
class Page
{
    ...    
    public function __toString()
    {
        $prefix = "";
        for ($i=2; $i<= $this->lvl; $i++){
            $prefix .= "& nbsp;& nbsp;& nbsp;& nbsp;";
        }
        return $prefix . $this->title;
    }

    public function getLaveledTitle()
    {
        return (string)$this;
    }
    ...
}

Now in the settings list, it became possible to use the generated "on the wing" field laveled_title.

I agree that the decision is not the best, but the other is not given.

Image 1

Let’s go back to the problems of paragraph 2 about which I wrote above. The easiest way to get around this problem - to create a root element, and either not use it at all, or to use as the text of the main page.

I decided to give it the name "== root element ==" and not to use it anywhere else. That is, the ban its editing / removing in the admin. All other articles should be either direct descendants of the root element, or the descendants of descendants. The root element has been created in the DB by hands, and to ensure that it was not available for editing, the class has been added to the method PageAdmin createQuery.

Here I show the complete code for the class PageAdmin, and below I will describe what methods were used and for what purpose.

PHP
 'ASC',
        '_sort_by'    => 'p.root, p.lft'
    );

   public function createQuery($context = 'list')
    {
        $em = $this->modelManager->getEntityManager('Shtumi\PravBundle\Entity\Page');

        $queryBuilder = $em
            ->createQueryBuilder('p')
            ->select('p')
            ->from('ShtumiPravBundle:Page', 'p')
            ->where('p.parent IS NOT NULL');

        $query = new ProxyQuery($queryBuilder);
        return $query;
    }

   protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->add('up', 'text', array('template' => 
            'ShtumiPravBundle:admin:field_tree_up.html.twig', 'label'=>' '))
            ->add('down', 'text', array('template' => 
            'ShtumiPravBundle:admin:field_tree_down.html.twig', 'label'=>' '))
            ->add('id', null, array('sortable'=>false))
            ->addIdentifier('laveled_title', null, 
            array('sortable'=>false, 'label'=>'???????? ????????'))
            ->add('_action', 'actions', array(
                    'actions' => array(
                        'edit' => array(),
                        'delete' => array()
                    ), 'label'=> '????????'
                ))
        ;
    }

   protected function configureFormFields(FormMapper $form)
    {
        $subject = $this->getSubject();
        $id = $subject->getId();

        $form
            ->with('?????')
                ->add('parent', null, array('label' => '????????'
                                          , 'required'=>true
                                          , 'query_builder' => function($er) use ($id) {
                                                $qb = $er->createQueryBuilder('p');
                                                if ($id){
                                                    $qb
                                                        ->where('p.id <> :id')
                                                        ->setParameter('id', $id);
                                                }
                                                $qb
                                                    ->orderBy('p.root, p.lft', 'ASC');
                                                return $qb;
                                            }
                    ))
                ->add('title', null, array('label' => '????????'))
                ->add('text', null, array('label' => '????? ????????'))
            ->end()
        ;
    }

   public function preRemove($object)
    {
        $em = $this->modelManager->getEntityManager($object);
        $repo = $em->getRepository("ShtumiPravBundle:Page");
        $subtree = $repo->childrenHierarchy($object);
        foreach ($subtree AS $el){
            $menus = $em->getRepository('ShtumiPravBundle:AdditionalMenu')
                        ->findBy(array('page'=> $el['id']));
            foreach ($menus AS $m){
                $em->remove($m);
            }
            $services = $em->getRepository('ShtumiPravBundle:Service')
                           ->findBy(array('page'=> $el['id']));
            foreach ($services AS $s){
                $em->remove($s);
            }
            $em->flush();
        }

        $repo->verify();
        $repo->recover();
        $em->flush();
    }

   public function postPersist($object)
    {
        $em = $this->modelManager->getEntityManager($object);
        $repo = $em->getRepository("ShtumiPravBundle:Page");
        $repo->verify();
        $repo->recover();
        $em->flush();
    }

   public function postUpdate($object)
    {
        $em = $this->modelManager->getEntityManager($object);
        $repo = $em->getRepository("ShtumiPravBundle:Page");
        $repo->verify();
        $repo->recover();
        $em->flush();
    }
}

There is a feature in construction of the tree in the Nested tree. For the correct sequence of going through the tree from left to right, it is necessary to sort the items first by the field, root, and then by the field lft. To do this, there was added property $ datagridValues.

When editing a tree, pagination is not necessary in most cases. So I increased the number of elements on a single page from the default 30 to 2500.

Adding / Editing Items

The main problem was the conclusion of the hierarchical drop-down list of parents in the form of editing of the article. This problem was solved by adding query_builder with the closure of the field in the entity parent. Because of the fact that in our database there is the root element "== == root element", the parent field should be mandatory.

Image 2

As for the methods and postPersist postUpdate, they were added in order to call methods verify and recover of the repository to get sure, that after these steps, the structure of the tree will not be damaged.

Also, it was necessary to make the buttons, with which the user could move the paper up / down relative to its neighbors. SonataAdminBundle allows you to use your templates in the fields list of records. So you need to create two templates: for the up and down, respectively:

ShtumiPravBundle:admin:field_tree_up.html.twig

PHP
{% extends 'SonataAdminBundle:CRUD:base_list_field.html.twig' %}

{% block field %}

    {% spaceless %}
        {% if object.parent.children[0].id != object.id %}
            <a href="{{ path('page_tree_up', {'page_id': object.id}) }}">

            </a>
        {% endif %}
    {% endspaceless %}
{% endblock %}

ShtumiPravBundle:admin:field_tree_down.html.twig

PHP
{% extends 'SonataAdminBundle:CRUD:base_list_field.html.twig' %}

{% block field %}

    {% spaceless %}
        {% if object.parent.children[object.parent.children|length - 1].id != object.id %}
            <a href="{{ path('page_tree_down', {'page_id': object.id}) }}">
            </a>
        {% endif %}
    {% endspaceless %}
{% endblock %} 

These patterns are connected in the method of the class configureListFields PageAdmin.

Into routing.yml file, two paths must be added: for the up and down, respectively:

PHP
page_tree_up:
    pattern: /admin/page_tree_up/{page_id}
    defaults:  { _controller: ShtumiPravBundle:PageTreeSort:up }

page_tree_down:
    pattern: /admin/page_tree_down/{page_id}
    defaults:  { _controller: ShtumiPravBundle:PageTreeSort:down }

And of course, you need to create a controller PageTreeSortController which will perform the movement of the article:

PHP
getDoctrine()->getEntityManager();
        $repo = $em->getRepository('ShtumiPravBundle:Page');
        $page = $repo->findOneById($page_id);
        if ($page->getParent()){
            $repo->moveUp($page);
        }
        return $this->redirect($this->getRequest()->headers->get('referer'));
    }

    /**
    * @Secure(roles="ROLE_SUPER_ADMIN")
    */
    public function downAction($page_id)
    {
        $em = $this->getDoctrine()->getEntityManager();
        $repo = $em->getRepository('ShtumiPravBundle:Page');
        $page = $repo->findOneById($page_id);
        if ($page->getParent()){
            $repo->moveDown($page);
        }
        return $this->redirect($this->getRequest()->headers->get('referer'));
    }
}

Access to this controller can only have an administrator, so you need to limit the role of ROLE_SUPER_ADMIN.

Removing Items

The main difficulty of the elements of the tree removal is that we need to make sure that there was no conflict because of the foreign key there was no failure in the tree. This is what I said in paragraph 3.

I deliberately did not remove the method from the class preRemove PageAdmin, to show that before removing the article, you should take care to remove all the associated records from the other models. In my case, it was a model AdditionalMenu and Service.

I would also like to note that the installation in a model of cascade deletion does not work in this case. The fact is that the Doctrine Extensions Tree uses its own methods for descendants removal, which do not pay attention to the cascade. However, for greater certainty, I still installed and cascading deletes:

PHP
class Page 
{
    ...
    /**
     * @ORM\OneToMany(targetEntity="Service", mappedBy="
     page", cascade={"all"}, orphanRemoval=true)
     * @ORM\OrderBy({"position"="ASC"})
     */
    protected $services;
    ...
}

Removal of the descendants Nested Tree produces automatically. There's nothing to configure.

Conclusion

It would seem that it isn’t a big deal in the above solution of mine, but sometimes due to the not transparent behavior of Nested Tree, complicated with the features of the admin panel in SonataAdminBundle, it took some time to generate this solution. I hope this will help you to save time when implementing a similar problem.

What's missing about this solution. The first thing that comes to mind is the concealment of subtrees. That is, the "plus signs" next to each item, allowing to display his descendants. Such a decision would be actual for very large trees. The second idea of completions follows from the first – it would be preferable for the admin panel to remember the parent element, and to choose it in the "parent" automatically when a new article is created by clicking on the "plus sign".

The solution to both problems is not difficult. You need to create one template for the "plus sign" and then in the controller to maintain a session which items you want to display and which to hide. And in the method createQuery data from this session should be processed.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Web Developer SymfonyDriven
Ukraine Ukraine
This member doesn't quite have enough reputation to be able to display their biography and homepage.
This is a Organisation (No members)


Comments and Discussions

 
QuestionFull article Pin
Member 1276443628-Sep-16 2:38
Member 1276443628-Sep-16 2:38 
QuestionWhole code Pin
ayat00023-Dec-13 5:11
ayat00023-Dec-13 5:11 
QuestionCode missing? Pin
Dracoo8730-Oct-12 2:08
Dracoo8730-Oct-12 2:08 
QuestionCode missing Pin
Vincent Wasteels27-Sep-12 11:02
Vincent Wasteels27-Sep-12 11:02 
QuestionMiss moveUp, moveDown function in your Repository class Pin
princedominh25-Aug-12 18:09
princedominh25-Aug-12 18:09 
I saw that your code is miss moveUp and moveDown. And this is my code (for my entity Area):
#namespace DMD\Bundle\OutletBundle\Repository;
#class AreaRepository extends EntityRepository


First: the moveUp function:
PHP
public function moveUp($area)
{
    //get the area upper
    $em = $this->_em;
    $repositor = $em->getRepository('DMDOutletBundle:Area'); //change here for your Entity
    $area_upper = $repositor->findOneBy(array('rgt'=>($area->getLft()-1)));
    if ($area_upper) {
        $del_1 = $area->getRgt() - $area->getLft();
        $del_2 = $area_upper->getRgt() - $area_upper->getLft();

        //calculate new lft, rgt of 2 node and swap
        $area->setLft($area_upper->getLft());
        $area->setRgt($area->getLft() + $del_1);
        $area_upper->setLft($area->getRgt()+1);
        $area_upper->setRgt($area_upper->getLft() + $del_2);
        $end = 0;
        //save new order
        $repositor->postOrderTraversal($area_upper, $area_upper->getLft() , $end, $em);
        $repositor->postOrderTraversal($area, $area->getLft() , $end, $em);

        $em->persist($area);
        $em->persist($area_upper);
        $em->flush();
    }
}


Second: the moveDown function:
PHP
public function moveDown($area)
{
    //get the area under
    $em = $this->_em;
    $repositor = $em->getRepository('DMDOutletBundle:Area');
    $area_under = $repositor->findOneBy(array('lft'=>($area->getRgt()+1)));
    if ($area_under) {
        $del_1 = $area->getRgt() - $area->getLft();
        $del_2 = $area_under->getRgt() - $area_under->getLft();

        //calculate new lft, rgt of 2 node and swap
        $area_under->setLft($area->getLft());
        $area_under->setRgt($area->getLft() + $del_2);
        $area->setLft($area_under->getRgt() + 1);
        $area->setRgt($area->getLft() + $del_1);
        $end = 0;
        //save new order
        $repositor->postOrderTraversal($area_under, $area_under->getLft() , $end, $em);
        $repositor->postOrderTraversal($area, $area->getLft() , $end, $em);

        $em->persist($area);
        $em->persist($area_under);
        $em->flush();
    }
}


Two function above use postOrderTraversal function (in AreaRepository too) to re compute the left and right value:
PHP
/**
 * postOrderTraversal
 */
public function postOrderTraversal($tree, $begin, &$end, $em)
{
    //get $tree childrens
    $children = $em->getRepository('DMDOutletBundle:Area')
                    ->getChildren($tree->getId());
    $tree->setLft($begin);
    $end = ++$begin ;
    //Travesal the tree
    foreach ($children as $child)
    {
        $repositor = $em->getRepository('DMDOutletBundle:Area');
        $repositor->postOrderTraversal($child, $begin , $end, $em);
        $begin = ++$end;
    }
    $tree->setRgt($end);

}


And insert this function in AreaRepositoty class
C#
public function getChildren($parent_id)
{
    return $this->getEntityManager()
            ->getRepository('DMDOutletBundle:Area')
            ->createQueryBuilder('a')
            ->where('a.parent = :parentId')
            ->andWhere('a.parent != a.id')
            ->setParameter('parentId', $parent_id)
            ->orderBy('a.lft', 'ASC')
            ->getQuery()
            ->getResult();
}



Hope these functions are helpful for you

modified 26-Aug-12 3:32am.

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.