Редактирование древовидных структур — довольно частая задача в веб-разработке. Это очень удобно пользователю, поскольку дает ему возможность создавать любые иерархии у себя на сайте. Естественно, что после перехода на Symfony2, одной из первых задач стало создание такого иерархического списка страниц и написание админки к нему. А так как в качестве админки я использую SonataAdminBundle, то задача сводилась к настройке его для редактирования деревьев.
Казалось что задача распространенная, востребованная и я ожидал получить готовое решение «из коробки». Однако этого не произошло. Мало того, разработчики из Sonata похоже вообще никогда не думали о том, что кому-то придет в голову «админить» деревья через их бандл.
Начнем с самого дерева. С самого моего «программистского детства» меня учили никогда не изобретать велосипед. И хоть я иногда «брыкался» и парировал, что заново изобретенный велосипед будет легче и ехать вперед, а не боком: всегда получал по рукам и… приходилось исользовать готовые решения. Для древовидной структуры страниц решено было использовать Nested tree из Doctrine Extensions.
Создание модели дерева с использованием Doctrine Extensions Tree не представляет сложности и описано в мануале. Хочу отметить, что для удобного использования расширений Доктрины внутри Symfony2, необходимо подключить StofDoctrineExtensionsBundle, установка и настройка которого, опять же хорошо описана в мануале. Ну а если вдруг у кого-то возникнут с этим проблемы, я с удовольствием помогу в комментариях.
Итак, у меня получилась модель ShtumiPravBundle:Page, полный код которой я не буду приводить в этой статье за ненадобностью.
Теперь хочу сказать несколько слов о нехороших особенностях Nested Tree, из-за которых мне пару раз приходилось все менять.
Одна из первых проблем, которые необходимо было решить — это вывод страниц в админке в виде дерева, т. е. добавить слева перед названием статьи соответствующее уровню вложенности количество пробелов. Та же проблема была и в выпадающих списках select. Решение было найдено очень простое — добавить в модель методы __toString и getLaveledTitle:
Теперь в настройках списка стало возможным использование генерируемого «на лету» поля laveled_title.
Согласен, что решение не самое лучшее, но другого здесь не дано.

Вспомним п. 2 проблем, о которых я писал выше. Наиболее простой способ обойти эту проблему — создать один корневой элемент и либо не использовать его вообще, либо использовать как текст главной страницы.
Я решил дать ему имя "== Корневой элемент ==" и не использовать вообще нигде. Т. е. запретить в админке его редактирование/удаление. Все остальные статьи должны быть либо непосредственными потомками этого корневого элемента, либо потомками потомков. Корневой элемент был создан в БД руками, а для того, чтобы он не был доступен для редактирования, в класс PageAdmin был добавлен метод createQuery.
Здесь я приведу полный код класса PageAdmin, а ниже опишу какие методы и для чего использовались.
В построении дерева в Nested tree есть одна особенность. Для того, чтобы в правильной последовательности обойти все дерево слева направо необходимо отсортировать его элементы сначала по полю root, а затем по полю lft. Для этого было добавлено свойство $datagridValues.
При редактировании дерева пагинация не нужна в большинстве случаев. Поэтому я увеличил количество элементов на одну страницу со стандартных 30-ти до 2500.
Тут основную проблему составлял вывод иерархического выпадающего списка родителей в форме редактирования статьи. Эта проблема была решена добавлением query_builder с замыканием в entity поле parent. Т. к. у нас в БД имеется корневой элемент "== Корневой элемент ==", то поле parent должно быть обязательным.

Что же касается методов postPersist и postUpdate, то они были добавлены с целью вызвать методы verify и recover репозитория для пущей уверенности, что после этих действий структура дерева не будет повреждена.
Также нужно было сделать кнопки, с помощью которых пользователь мог бы перемещать статьи вверх/вниз относительно своих соседей. SonataAdminBundle позволяет использовать свои шаблоны в полях списка записей. Поэтому необходимо создать два шаблона: для кнопок вверх и вниз соответственно:
ShtumiPravBundle:admin:field_tree_up.html.twig
ShtumiPravBundle:admin:field_tree_down.html.twig
Подключаются эти шаблоны в методе configureListFields класса PageAdmin.
В файл routing.yml необходимо добавить два пути: для кнопок вверх и вниз соответственно:
Ну и естественно, необходимо создать контроллер PageTreeSortController, который и будет выполнять перемещение статьи:
Доступ к данному контроллеру может иметь только администратор, поэтому необходимо ограничение по роли ROLE_SUPER_ADMIN.
Основная тонкость удаления элементов дерева заключается в том, что нужно позаботиться, чтобы не возникло конфликтов из-за foreign key и не произошло сбоев в дереве. Об этом я уже говорил в п. 3 проблем Nested tree.
Я специально не стал удалять метод preRemove из класса PageAdmin, чтобы показать, что перед удалением статьи необходимо позаботиться и удалить все связанные с нею записи из других моделей. В моем случае это были модели AdditionalMenu и Service.
Отдельно хочу отметить, что установка в модели каскадного удаления не работает в данном случае. Дело в том, что Doctrine Extensions Tree для удаления потомков пользуется своими методами, которые не обращают внимания на каскадность. Правда для пущей уверенности я все же установил и каскадное удаление:
Удаление же потомков Nested Tree производит автоматически. Тут ничего настраивать не пришлось.
Казалось бы ничего сложного в описанном мною решении нет, однако из-за иногда не совсем прозрачного поведения Nested Tree, осложненного особенностями создания админок в SonataAdminBundle, пришлось некоторое время повозиться над этим решением. Надеюсь, что это поможет сохранить время вам, дорогой читатель, при реализации аналогичной задачи.
Чего не хватает данному решению. Первое, что приходит на ум — это сокрытие поддеревьев. Т. е. «плюсики» возле каждого элемента, позволяющие отобразить его потомков. Такое решение будет актуально для очень больших деревьев. Вторая идея доработок вытекает из первой — хотелось бы, чтобы по нажатию на «плюсик» админка запоминала этот родительский элемент и при создании новой статьи выбирала его в поле «родитель» автоматически.
Решение обеих проблем не сложное. Необхо��имо создать еще один шаблон для «плюсика» и дальше в контроллере сохранять в сессию, какие элементы нужно отображать, а какие скрывать. Ну а в методе createQuery обрабатывать данные из этой сессии.
Казалось что задача распространенная, востребованная и я ожидал получить готовое решение «из коробки». Однако этого не произошло. Мало того, разработчики из Sonata похоже вообще никогда не думали о том, что кому-то придет в голову «админить» деревья через их бандл.
Начнем с самого дерева. С самого моего «программистского детства» меня учили никогда не изобретать велосипед. И хоть я иногда «брыкался» и парировал, что заново изобретенный велосипед будет легче и ехать вперед, а не боком: всегда получал по рукам и… приходилось исользовать готовые решения. Для древовидной структуры страниц решено было использовать Nested tree из Doctrine Extensions.
Создание модели дерева с использованием Doctrine Extensions Tree не представляет сложности и описано в мануале. Хочу отметить, что для удобного использования расширений Доктрины внутри Symfony2, необходимо подключить StofDoctrineExtensionsBundle, установка и настройка которого, опять же хорошо описана в мануале. Ну а если вдруг у кого-то возникнут с этим проблемы, я с удовольствием помогу в комментариях.
Итак, у меня получилась модель ShtumiPravBundle:Page, полный код которой я не буду приводить в этой статье за ненадобностью.
Теперь хочу сказать несколько слов о нехороших особенностях Nested Tree, из-за которых мне пару раз приходилось все менять.
- Для хранения структуры дерева, Doctrine Extensions использует не только поле parent, но и поля root, lft, rgt, lvl, которые тоже хранятся в БД. Назначение полей понятно: они определяют порядок следования детей в дереве, а также позволяют создавать более простые SQL запросы для получения элементлв дерева в «правильном» порядке. Вычисляются и сохраняются в БД эти поля автоматически. Однако понять алгоритм вычисления значения поля lft и rgt я так и не смог (правда не сильно и пытался). Так вот. Стоит одному значению этих полей в любом элементе дерева стать неправильным — это приведет к поломке всего дерева. Поломку, которую исправить практически невозможно, учитывая сложность рассчета вышеуказанных полей, помноженную на количество элементов дерева.
- В Doctrine Extensions Tree невозможно стандартными методами (moveUp, moveDown) менять местами корневые элементы. При попытке это сделать «вылазит» исключение с соответствующим сообщением. Поведение, право сказать, странное и неожиданное, но приходиься мириться.
- В п. 1 я рассказывал о полях root, lft, rgt, сбой в значениях которых привожит к поломке всего дерева. Теперь подолью масла в огонь. Такие ситуации происходят в случае сбоя при удалении элементов дерева из-за наличия foreign ключей. В моем случае это были дополнительные элементы, «прикручиваемые» к каждой статье. Проблема обнаружилась во всей красе уже после заполнения сайта контентом, и восстановление дерева потребовало немало нервов и трудозатрат.
Вывод древовидной структуры в админке
Одна из первых проблем, которые необходимо было решить — это вывод страниц в админке в виде дерева, т. е. добавить слева перед названием статьи соответствующее уровню вложенности количество пробелов. Та же проблема была и в выпадающих списках select. Решение было найдено очень простое — добавить в модель методы __toString и getLaveledTitle:
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; } ... }
Теперь в настройках списка стало возможным использование генерируемого «на лету» поля laveled_title.
Согласен, что решение не самое лучшее, но другого здесь не дано.

Вспомним п. 2 проблем, о которых я писал выше. Наиболее простой способ обойти эту проблему — создать один корневой элемент и либо не использовать его вообще, либо использовать как текст главной страницы.
Я решил дать ему имя "== Корневой элемент ==" и не использовать вообще нигде. Т. е. запретить в админке его редактирование/удаление. Все остальные статьи должны быть либо непосредственными потомками этого корневого элемента, либо потомками потомков. Корневой элемент был создан в БД руками, а для того, чтобы он не был доступен для редактирования, в класс PageAdmin был добавлен метод createQuery.
Здесь я приведу полный код класса PageAdmin, а ниже опишу какие методы и для чего использовались.
<? 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(); } }
В построении дерева в Nested tree есть одна особенность. Для того, чтобы в правильной последовательности обойти все дерево слева направо необходимо отсортировать его элементы сначала по полю root, а затем по полю lft. Для этого было добавлено свойство $datagridValues.
При редактировании дерева пагинация не нужна в большинстве случаев. Поэтому я увеличил количество элементов на одну страницу со стандартных 30-ти до 2500.
Добавление/редактирование элементов
Тут основную проблему составлял вывод иерархического выпадающего списка родителей в форме редактирования статьи. Эта проблема была решена добавлением query_builder с замыканием в entity поле parent. Т. к. у нас в БД имеется корневой элемент "== Корневой элемент ==", то поле parent должно быть обязательным.

Что же касается методов postPersist и postUpdate, то они были добавлены с целью вызвать методы verify и recover репозитория для пущей уверенности, что после этих действий структура дерева не будет повреждена.
Сортировка элементов относительно своих соседей
Также нужно было сделать кнопки, с помощью которых пользователь мог бы перемещать статьи вверх/вниз относительно своих соседей. SonataAdminBundle позволяет использовать свои шаблоны в полях списка записей. Поэтому необходимо создать два шаблона: для кнопок вверх и вниз соответственно:
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 %}
Подключаются эти шаблоны в методе configureListFields класса PageAdmin.
В файл routing.yml необходимо добавить два пути: для кнопок вверх и вниз соответственно:
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 }
Ну и естественно, необходимо создать контроллер PageTreeSortController, который и будет выполнять перемещение статьи:
<?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 { /** * @Secure(roles="ROLE_SUPER_ADMIN") */ 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')); } /** * @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')); } }
Доступ к данному контроллеру может иметь только администратор, поэтому необходимо ограничение по роли ROLE_SUPER_ADMIN.
Удаление элементов
Основная тонкость удаления элементов дерева заключается в том, что нужно позаботиться, чтобы не возникло конфликтов из-за foreign key и не произошло сбоев в дереве. Об этом я уже говорил в п. 3 проблем Nested tree.
Я специально не стал удалять метод preRemove из класса PageAdmin, чтобы показать, что перед удалением статьи необходимо позаботиться и удалить все связанные с нею записи из других моделей. В моем случае это были модели AdditionalMenu и Service.
Отдельно хочу отметить, что установка в модели каскадного удаления не работает в данном случае. Дело в том, что Doctrine Extensions Tree для удаления потомков пользуется своими методами, которые не обращают внимания на каскадность. Правда для пущей уверенности я все же установил и каскадное удаление:
class Page { ... /** * @ORM\OneToMany(targetEntity="Service", mappedBy="page", cascade={"all"}, orphanRemoval=true) * @ORM\OrderBy({"position"="ASC"}) */ protected $services; ... }
Удаление же потомков Nested Tree производит автоматически. Тут ничего настраивать не пришлось.
Заключение
Казалось бы ничего сложного в описанном мною решении нет, однако из-за иногда не совсем прозрачного поведения Nested Tree, осложненного особенностями создания админок в SonataAdminBundle, пришлось некоторое время повозиться над этим решением. Надеюсь, что это поможет сохранить время вам, дорогой читатель, при реализации аналогичной задачи.
Чего не хватает данному решению. Первое, что приходит на ум — это сокрытие поддеревьев. Т. е. «плюсики» возле каждого элемента, позволяющие отобразить его потомков. Такое решение будет актуально для очень больших деревьев. Вторая идея доработок вытекает из первой — хотелось бы, чтобы по нажатию на «плюсик» админка запоминала этот родительский элемент и при создании новой статьи выбирала его в поле «родитель» автоматически.
Решение обеих проблем не сложное. Необхо��имо создать еще один шаблон для «плюсика» и дальше в контроллере сохранять в сессию, какие элементы нужно отображать, а какие скрывать. Ну а в методе createQuery обрабатывать данные из этой сессии.
