Editing Tree Structures with SonataAdminBundle in Symfony2

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

    It seemed that the task was widespread, in demand and I expected to get a ready-made solution “out of the box”. However, this did not happen. Not only that, the developers at Sonata never seemed to think about the idea that someone would “administer” trees through their bundle.

    Let's start with the tree itself. From my very “childhood as a programmer” I was taught never to reinvent the wheel. And although I sometimes “kicked” and retorted, that the newly invented bicycle would be easier to ride forward, and not sideways: I always got on my hands and ... I had to use ready-made solutions. For the tree structure of the pages, it was decided to use the Nested tree from Doctrine Extensions.

    Creating a tree model using the Doctrine Extensions Tree is straightforward and is described in the manual. I want to note that for the convenient use of the Doctrine extensions inside Symfony2, you must connect the StofDoctrineExtensionsBundle , the installation and configuration of which, again, is well described in the manual. Well, if someone suddenly has a problem with this, I will be happy to help in the comments.

    So, I got the ShtumiPravBundle: Page model, the full code of which I will not give in this article as unnecessary.

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

    1. To store the tree structure, Doctrine Extensions uses not only the parent field, but also the root, lft, rgt, lvl fields, which are also stored in the database. The purpose of the fields is clear: they determine the order of children in the tree, and also allow you to create simpler SQL queries to receive the tree items in the "correct" order. These fields are calculated and stored in the database automatically. However, I still could not understand the algorithm for calculating the values ​​of the field lft and rgt (although I did not try hard). So here. Should one value of these fields in any element of the tree become incorrect - this will lead to the breakdown of the whole tree. A breakdown that is almost impossible to fix, given the complexity of calculating the above fields, multiplied by the number of tree elements.
    2. In the Doctrine Extensions Tree, it is impossible to interchange the root elements with standard methods (moveUp, moveDown). When you try to do this, an “exception” with the corresponding message will be thrown out. The behavior, the right to say, is strange and unexpected, but you have to put up with it.
    3. In step 1, I talked about the root, lft, rgt fields, a failure in the values ​​of which leads to the breakdown of the whole tree. Now add fuel to the fire. Such situations occur in the event of a failure when deleting tree elements due to the presence of foreign keys. In my case, these were additional elements “screwed” to each article. The problem was revealed in all its glory after filling the site with content, and restoring the tree required a lot of nerves and labor.

    The conclusion of the tree structure in the admin panel

    One of the first problems that needed to be solved was the output of pages in the admin panel in the form of a tree, that is, add the number of spaces corresponding to the nesting level on the left before the article title. The same problem was with select dropdowns. The solution was found to be very simple - add the __toString and getLaveledTitle methods to the model:

    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 list settings it has become possible to use the laveled_title field generated on the fly.

    I agree that the solution is not the best, but no other is given here.



    Recall paragraph 2 of the problems that I wrote above. The easiest way to get around this problem is to create one root element and either not use it at all, or use it as the text of the main page.
    I decided to give it the name "== Root Element ==" and not use it anywhere else. That is, to prohibit in the admin panel its editing / deletion. All other articles should be either direct descendants of this root element, or descendants of descendants. The root element was created in the database by hand, and to prevent it from being editable, the createQuery method was added to the PageAdmin class.

    Here I will give the full code of the PageAdmin class, and below I will describe what methods and what were used.

     '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 one peculiarity in building a tree in Nested tree. In order to go around the whole tree in the correct sequence from left to right, you need to sort its elements first by the root field, and then by the lft field. For this, the $ datagridValues ​​property has been added.

    When editing a tree, pagination is not needed in most cases. Therefore, I increased the number of elements by one page from the standard 30 to 2500.

    Adding / Editing Elements

    Here the main problem was the output of the hierarchical drop-down list of parents in the form of editing the article. This problem was resolved by adding query_builder with a closure in the entity parent field. Since we have a root element "== Root element ==" in the database, the parent field must be required.



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

    Sort items relative to their neighbors

    It was also necessary to make buttons with which the user could move articles up / down relative to their neighbors. SonataAdminBundle allows you to use your templates in the fields of the list of records. Therefore, you must create two templates: for the up and down buttons, respectively:

    ShtumiPravBundle: admin: field_tree_up.html.twig

    {% extends 'SonataAdminBundle:CRUD:base_list_field.html.twig' %}
    {% block field %}
        {% spaceless %}
            {% if object.parent.children[0].id != object.id %}
                {% trans %}Вверх{% endtrans %}
            {% endif %}
        {% endspaceless %}
    {% endblock %}
    


    ShtumiPravBundle: admin: field_tree_down.html.twig

    {% extends 'SonataAdminBundle:CRUD:base_list_field.html.twig' %}
    {% block field %}
        {% spaceless %}
            {% if object.parent.children[object.parent.children|length - 1].id != object.id %}
                {% trans %}Вниз{% endtrans %}
            {% endif %}
        {% endspaceless %}
    {% endblock %}
    

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

    Two paths must be added to the routing.yml file: for the up and down buttons, respectively:

    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 }
    

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

    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'));
        }
    }
    

    Only the administrator can access this controller, therefore, a restriction on the role ROLE_SUPER_ADMIN is required.

    Delete items

    The main subtlety of deleting tree elements is that you need to take care that there are no conflicts due to foreign key and there are no failures in the tree. I already spoke about this in Section 3 of the Nested tree problems.

    I deliberately did not remove the preRemove method from the PageAdmin class to show that before deleting an article, you need to take care and remove all entries related to it from other models. In my case, these were AdditionalMenu and Service models.

    I would also like to note that the installation in the cascade delete model does not work in this case. The fact is that Doctrine Extensions Tree uses its own methods to remove descendants, which do not pay attention to cascading. True, to be sure, I still installed cascading deletion:

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

    Nested Tree automatically removes descendants. There was nothing to configure.

    Conclusion

    It would seem that there is nothing complicated in the solution I described, however, because of the sometimes not completely transparent behavior of the Nested Tree, complicated by the features of creating admins in the SonataAdminBundle, I had to tinker with this solution for some time. I hope that this will help save time for you, dear reader, when implementing a similar task.

    What is missing from this solution. The first thing that comes to mind is hiding the subtrees. That is, the "pluses" next to each element, allowing you to display its descendants. Such a solution will be relevant for very large trees. The second idea of ​​improvements follows from the first - I would like the admin to remember this parent element by clicking on the plus sign and when creating a new article, it will automatically select it in the “parent” field.

    The solution to both problems is not complicated. It is necessary to create another template for the “plus sign” and then in the controller save to the session which elements should be displayed and which should be hidden. Well, in the createQuery method, process data from this session.

    Also popular now: