Editing tree structures is a fairly frequent task in web development. This is very convenient for the user, as it allows him 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 to it. And since I use
SonataAdminBundle as admin
panel , the task was to configure it for editing trees.
It seemed that the problem was widespread, in demand, and I expected to get a ready-made solution out of the box. However, this did not happen. Moreover, the developers of Sonata never seemed to think that it would occur to someone to "admin" the trees through their bundle.
Let's start with the tree itself. From my very "programmer childhood" I was taught to never reinvent the wheel. And even though I sometimes “kicked” and retorted that the reinvented bike would be easier and go forward, not sideways: I always got my hands and ... 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 easy and is described in the manual. I want to note that for convenient use of the Doctrine extensions inside Symfony2, you need to connect
StofDoctrineExtensionsBundle , the installation and configuration of which, again, is well described in the
manual . Well, if someone has any problems with this, I will be happy to help in the comments.
')
So, I have a ShtumiPravBundle model: Page, 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.
- 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 the children in the tree, and also allow you to create simpler SQL queries to get the elements in the "correct" order. These fields are calculated and stored in the database automatically. However, I could not understand the algorithm for calculating the value of the lft and rgt field (though I didn’t try hard). So here. It is worth the value of these fields in any element of the tree to become wrong - it will break the entire tree. A breakdown that is practically impossible to fix, given the difficulty of calculating the above fields, multiplied by the number of tree elements.
- In the Doctrine Extensions Tree it is not possible to swap root elements using standard methods (moveUp, moveDown). When you try to do this, "climbs" an exception with the appropriate message. The behavior, the right to say, is strange and unexpected, but you have to put up.
- In Section 1, I talked about the root, lft, rgt fields, a failure in the values ​​of which leads to the breakdown of the entire tree. Now pour oil on the fire. Such situations occur in case of failure when deleting tree elements due to the presence of foreign keys. In my case, these were additional elements, “bolted” to each article. The problem was revealed in all its glory after filling the site with content, and the restoration of the tree required a lot of nerves and labor costs.
Output tree structure in admin panel
One of the first problems that had 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 in select dropdown lists. The solution was found 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 became possible to use the laveled_title field generated on the fly.
I agree that the solution is not the best, but there is no other here.

Let us recall p. 2 of the problems about which 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 a name "== Root element ==" and not to use it anywhere else. That is, to forbid its editing / deleting in the admin panel. All other articles must be either direct descendants of this root element, or descendants of descendants. The root element was created by hand in the database, and in order for it not to be 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 for.
<? namespace Shtumi\PravBundle\Admin; use Sonata\AdminBundle\Admin\Admin; use Sonata\AdminBundle\Form\FormMapper; use Sonata\AdminBundle\Datagrid\ListMapper; use Sonata\DoctrineORMAdminBundle\Datagrid\ProxyQuery; class PageAdmin extends Admin{ protected $maxPerPage = 2500; protected $maxPageLinks = 2500; protected $datagridValues = array( '_sort_order' => '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(); } }
In building a tree in Nested tree there is one feature. In order to go around the entire tree from left to right in the correct sequence, it is necessary 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.
Add / Edit Items
Here the main problem was the output of a hierarchical drop-down list of parents in the form of editing the article. This problem was solved by adding query_builder with closing the entity field parent. Since we have a root element in the database "== Root element ==", the parent field must be mandatory.

As for the postPersist and postUpdate methods, they were added to call the verify and recover repository methods 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 the help of which the user could move the articles up / down relative to their neighbors. SonataAdminBundle allows you to use your templates in the list fields of entries. Therefore, it is necessary to 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 %} <a href="{{ path('page_tree_up', {'page_id': object.id}) }}"> <img src="{{ asset('bundles/shtumiprav/images/admin/arrow_up.png') }}" alt="{% trans %}{% endtrans %}" /> </a> {% 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 %} <a href="{{ path('page_tree_down', {'page_id': object.id}) }}"> <img src="{{ asset('bundles/shtumiprav/images/admin/arrow_down.png') }}" alt="{% trans %}{% endtrans %}" /> </a> {% endif %} {% endspaceless %} {% endblock %}
These templates are included 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 }
And of course, you need to create a PageTreeSortController controller that will perform the movement of the article:
<?php namespace Shtumi\PravBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template; use JMS\SecurityExtraBundle\Annotation\Secure; class PageTreeSortController extends Controller { public function upAction($page_id) { $em = $this->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')); } 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 an administrator can access this controller, therefore a ROLE_SUPER_ADMIN role restriction is necessary.
Deleting items
The main subtlety of the removal of tree elements is that care must be taken to avoid conflicts due to the foreign key and failures in the tree. I already spoke about this in paragraph 3 of the problems of the Nested tree.
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 delete all entries related to it from other models. In my case, these were the AdditionalMenu and Service models.
Separately, I want to note that the installation in the cascade delete model does not work in this case. The fact is that the Doctrine Extensions Tree to remove descendants uses its own methods that do not pay attention to cascading. True, for greater certainty, I still installed cascading deletion:
class Page { ... protected $services; ... }
Removing the descendants of the Nested Tree automatically. There was nothing to adjust.
Conclusion
It would seem that there is nothing difficult in the solution I described, but because of the sometimes not very transparent behavior of the Nested Tree, complicated by the features of creating admins in SonataAdminBundle, I had to tinker with this decision for some time. I hope that this will help you save time, dear reader, in carrying out a similar task.
What is missing this decision. The first thing that comes to mind is hiding subtrees. That is, "pluses" near each element, allowing 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 panel to remember this parent element by clicking on the “plus sign” and automatically select it in the “parent” field when creating a new article.
The solution to both problems is not difficult. It is necessary to create another template for the “plus sign” and then save in the controller to the session which elements need to be displayed and which to hide. Well, in the createQuery method, process data from this session.