Редактирование древовидных структур с SonataAdminBundle в Symfony2

в 12:45, , рубрики: sonataadmin, symfony, symfony 2, symfony2, метки: , ,

Редактирование древовидных структур — довольно частая задача в веб-разработке. Это очень удобно пользователю, поскольку дает ему возможность создавать любые иерархии у себя на сайте. Естественно, что после перехода на Symfony2, одной из первых задач стало создание такого иерархического списка страниц и написание админки к нему. А так как в качестве админки я использую SonataAdminBundle, то задача сводилась к настройке его для редактирования деревьев.

Казалось что задача распространенная, востребованная и я ожидал получить готовое решение «из коробки». Однако этого не произошло. Мало того, разработчики из Sonata похоже вообще никогда не думали о том, что кому-то придет в голову «админить» деревья через их бандл.

Начнем с самого дерева. С самого моего «программистского детства» меня учили никогда не изобретать велосипед. И хоть я иногда «брыкался» и парировал, что заново изобретенный велосипед будет легче и ехать вперед, а не боком: всегда получал по рукам и… приходилось исользовать готовые решения. Для древовидной структуры страниц решено было использовать Nested tree из Doctrine Extensions.

Создание модели дерева с использованием Doctrine Extensions Tree не представляет сложности и описано в мануале. Хочу отметить, что для удобного использования расширений Доктрины внутри Symfony2, необходимо подключить StofDoctrineExtensionsBundle, установка и настройка которого, опять же хорошо описана в мануале. Ну а если вдруг у кого-то возникнут с этим проблемы, я с удовольствием помогу в комментариях.

Итак, у меня получилась модель ShtumiPravBundle:Page, полный код которой я не буду приводить в этой статье за ненадобностью.

Теперь хочу сказать несколько слов о нехороших особенностях Nested Tree, из-за которых мне пару раз приходилось все менять.

  1. Для хранения структуры дерева, Doctrine Extensions использует не только поле parent, но и поля root, lft, rgt, lvl, которые тоже хранятся в БД. Назначение полей понятно: они определяют порядок следования детей в дереве, а также позволяют создавать более простые SQL запросы для получения элементлв дерева в «правильном» порядке. Вычисляются и сохраняются в БД эти поля автоматически. Однако понять алгоритм вычисления значения поля lft и rgt я так и не смог (правда не сильно и пытался). Так вот. Стоит одному значению этих полей в любом элементе дерева стать неправильным — это приведет к поломке всего дерева. Поломку, которую исправить практически невозможно, учитывая сложность рассчета вышеуказанных полей, помноженную на количество элементов дерева.
  2. В Doctrine Extensions Tree невозможно стандартными методами (moveUp, moveDown) менять местами корневые элементы. При попытке это сделать «вылазит» исключение с соответствующим сообщением. Поведение, право сказать, странное и неожиданное, но приходиься мириться.
  3. В п. 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.

Согласен, что решение не самое лучшее, но другого здесь не дано.

Редактирование древовидных структур с SonataAdminBundle в Symfony2

Вспомним п. 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 должно быть обязательным.

Редактирование древовидных структур с SonataAdminBundle в Symfony2

Что же касается методов 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


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js