Редактирование древовидных структур — довольно частая задача в веб-разработке. Это очень удобно пользователю, поскольку дает ему возможность создавать любые иерархии у себя на сайте. Естественно, что после перехода на Symfony2, одной из первых задач стало создание такого иерархического списка страниц и написание админки к нему. А так как в качестве админки я использую SonataAdminBundle, то задача сводилась к настройке его для редактирования деревьев.
Казалось что задача распространенная, востребованная и я ожидал получить готовое решение «из коробки». Однако этого не произошло. Мало того, разработчики из 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 ShtumiPravBundleAdmin;
use SonataAdminBundleAdminAdmin;
use SonataAdminBundleFormFormMapper;
use SonataAdminBundleDatagridListMapper;
use SonataDoctrineORMAdminBundleDatagridProxyQuery;
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('ShtumiPravBundleEntityPage');
$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 ShtumiPravBundleController;
use SymfonyBundleFrameworkBundleControllerController;
use SensioBundleFrameworkExtraBundleConfigurationRoute;
use SensioBundleFrameworkExtraBundleConfigurationTemplate;
use JMSSecurityExtraBundleAnnotationSecure;
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
{
...
/**
* @ORMOneToMany(targetEntity="Service", mappedBy="page", cascade={"all"}, orphanRemoval=true)
* @ORMOrderBy({"position"="ASC"})
*/
protected $services;
...
}
Удаление же потомков Nested Tree производит автоматически. Тут ничего настраивать не пришлось.
Заключение
Казалось бы ничего сложного в описанном мною решении нет, однако из-за иногда не совсем прозрачного поведения Nested Tree, осложненного особенностями создания админок в SonataAdminBundle, пришлось некоторое время повозиться над этим решением. Надеюсь, что это поможет сохранить время вам, дорогой читатель, при реализации аналогичной задачи.
Чего не хватает данному решению. Первое, что приходит на ум — это сокрытие поддеревьев. Т. е. «плюсики» возле каждого элемента, позволяющие отобразить его потомков. Такое решение будет актуально для очень больших деревьев. Вторая идея доработок вытекает из первой — хотелось бы, чтобы по нажатию на «плюсик» админка запоминала этот родительский элемент и при создании новой статьи выбирала его в поле «родитель» автоматически.
Решение обеих проблем не сложное. Необходимо создать еще один шаблон для «плюсика» и дальше в контроллере сохранять в сессию, какие элементы нужно отображать, а какие скрывать. Ну а в методе createQuery обрабатывать данные из этой сессии.
Автор: shtumi