Редактирование древовидных структур с SonataAdminBundle в 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.

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



    Вспомним п. 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 обрабатывать данные из этой сессии.
    • +7
    • 12,5k
    • 9
    Поделиться публикацией

    Комментарии 9

      +2
      В Doctrine Extensions Tree невозможно стандартными методами (moveUp, moveDown) менять местами корневые элементы. При попытке это сделать «вылазит» исключение с соответствующим сообщением. Поведение, право сказать, странное и неожиданное, но приходиься мириться.

      Изменять порядок следования корневых узлов деревьев (когда у нас не дерево, а «лес») архитектурно невозможно (все они представлены как отдельные деревья с lft = 1). Согласен, что сообщение об ошибке должно быть гораздо более user-friendly, сегодня или завтра поправлю
      Такие ситуации происходят в случае сбоя при удалении элементов дерева из-за наличия foreign ключей
      

      Спасибо за баг-репорт, если получится написать вменяемый тест-кейс — за сутки поправлю
      Стоит одному значению этих полей в любом элементе дерева стать неправильным — это приведет к поломке всего дерева. Поломку, которую исправить практически невозможно, учитывая сложность рассчета вышеуказанных полей, помноженную на количество элементов дерева.
      

      Эх, надо все-таки реализовать в Nested Sets метод repair. «на всякий случай»

        +2
        Вернее не repair а recover:
            public function recover()
            {
                if ($this->verify() === true) {
                    return;
                }
                // not yet implemented
            }
        

        :(
          0
          Привожу пример таска для diem cms (symfony1.4) который достаточно хорошо чинит nested set дерево:

          Тоже мучаюсь поисками готового решения для администрирования Nested Set и ничего не могу найти, в sf1.4 меня выручил в свое время sfDoctrineTreePlugin

          <?php
          
          class fixMediaFolderTreeTask extends dmContextTask
          {
          
            protected function configure()
            {
              parent::configure();
          
              $this->namespace = 'fix';
              $this->name = 'media-folder-tree';
            }
          
            public function execute( $arguments = array(), $options = array() )
            {
              $this->withDatabase();
              $conn = Doctrine_Manager::getInstance()->getConnection( 'doctrine' );
          
              $res = myDoctrineQuery::create()
                ->select( 'COUNT(f.id) AS num' )
                ->from( 'DmMediaFolder f' )
                ->fetchOne();
          
              $lft = 1;
              $rgt = $res['num'] * 2;
          
              $root = DmMediaFolder::getById(1);
              $root->lft = $lft;
              $root->rgt = $rgt;
              $root->save();
          
              self::fixChildren( $root, $lft, $rgt );
            }
          
            private static function fixChildren( DmMediaFolder $f, $lft )
            {
              $children = self::getChildren( $f );
          
              foreach ( $children as $child )
              {
                $child->lft = ++$lft;
          
                $lft += 2 * self::fixChildren( $child, $child->lft );
          
                $child->rgt = ++$lft;
          
                echo( "rel_path: {$child->rel_path}\t lft: {$child->lft}\t rgt: {$child->rgt}\n" );
          
                $child->save();
              }
          
              return sizeof( $children );
            }
          
            private static function getChildren( DmMediaFolder $f )
            {
              return myDoctrineQuery::create()
                ->from( 'DmMediaFolder f' )
                ->where( 'f.level = ? AND f.rel_path LIKE ?', array( ( $f->level +1 ), $f->rel_path . '%' ) )
                ->execute();
            }
          
          }
          
          
            0
            единственно здесь нужно реализовать свою стратегию getChildren, которая работает самым достоверным способом.
          0
          Согласен, что сообщение об ошибке должно быть гораздо более user-friendly, сегодня или завтра поправлю

          Ну с моей точки зрения сообщение вполне себе дружелюбное…
          А вот по поводу того, что деревья в лесу менять местами нельзя — тут несколько нелогично. Т. е. логично было бы, если бы оно не давало создавать лес. Ну или позволяло деревья менять местами…

          Спасибо за баг-репорт, если получится написать вменяемый тест-кейс

          Спасибо. Постараюсь написать.
          0
          Я думаю что маршруты можно легко объединить в один при помощи дополнительной переменной и добавить ограничение, так будет понятнее.
            0
            Может я ошибаюсь, но список и дерево немного разные вещи. Вы пытаетесь вывести дерево через список. Не лучше ли сделать бандл, расширяющий функционал SonataAdminBundle, в котором описать TreeMapper и расширить классы Admin и CRUDController. Ну или попроще — просто в PageAdminController (или как там у вас) сделать экшн treeAction и в нем уже отобразить что хочется и как хочется.
              0
              ну искалось самое простое решение…
              а делать бандл расширяющий фукнционал SonataAdminBundle — точно не самое простое…
              да и вывод деревьев в списках не такая уж и редкая задача.
              И реализуют это очень многие решения, которые предназначены для выводи списков/таблиц. Например, тот же jqGrid

              Хотя идея сделать отдельный бандл для деревьев и добавить туда «плюсики» мне нравится.
              0
              Извините. Уровнем ошибся.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое