Пользователь
0,0
рейтинг
13 ноября 2013 в 23:19

Разработка → KnpMenuBundle + Sonata. Делаем меню из базы

Всем приятного времени суток уважаемые хабровчане. Я люблю Symfony. Она мне нравится и я ее обожаю. Еще мне нравится SonataAdminBundle. Думаю многим из вас тоже. Итак, в данной статье я хочу рассмотреть процесс создания меню для сайта при участие в этом процессе KNPMenuBundle + SonataAdminBundle. По сути процесс создания меню достаточно прост и подробно описан на github’e самого бандла, но что если нам необходимо, что бы меню было управляемо из админки? Заинтересовались? Тогда прошу под кат.

Сразу хочу извинить за то, что будет изложенно ниже, но внятного пояснения как сделать задуманное я не нашел. Если кто их вас видел подобное, поделитесь ссылкой. Может некоторым моя статья пригодится и будет полезной. Итак приступаем. Изначально я предполагаю, что у вас уже установлена Sonata и она работает. Итак, первым делом начнем с генерации бандла для нашего меню.

Открываем консоль, переходим в папку с проектом и пишем:
#php app/console generate:bundle

Имя для бандла вы вольны выбрать сами, я же назвал его просто MenuBundle.
После необходимо создать 2 сущности. Если у вас не оказалось в папке с бандлом папки Entity — то создайте ее. Итак, файл номер раз — Menu.php. Файл номер два — MenuType.php. Для чего нужен второй файл, я поясню позже.

Привожу исходный код файла под номером раз:

namespace MyFolder\MenuBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
/**
 * @ORM\Entity
 * @ORM\Table(name="menu")
 * @ORM\Entity(repositoryClass="MyFolder\MenuBundle\Entity\MenuRepository")
 */
class Menu{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string", length=100)
     */
    protected $title;

    /**
     * @ORM\Column(type="string", length=100)
     */
    protected $route;

    /**
     * @ORM\Column(type="string", nullable=true)
     */
    protected $alias;

    /**
     * @ORM\Column(type="boolean")
     */
    protected $static;

    /**
     * @ORM\ManyToOne(targetEntity="MyFolder\MenuBundle\Entity\MenuType", inversedBy="menuTypeId")
     * @ORM\JoinColumn(name="menuTypeId", referencedColumnName="id")
     */
    protected $menuTypeId;

}


Посмотрим на файл под номером два:
namespace MyFolder\MenuBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
/**
 * @ORM\Entity
 * @ORM\Table(name="menu_type")
 * @ORM\Entity(repositoryClass="MyFolder\MenuBundle\Entity\MenuTypeRepository")
 */
class MenuType {

    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string", length=100)
     */
    protected $title;

    /**
     * @ORM\OneToMany(targetEntity="Menu", mappedBy="menuTypeId")
     */
    private $typeId;
}


Итак, у нас написаны 2 модели, давайте сгенерируем геттеры и сеттеры для них?!
# php app/console doctrine:generate:entities MyFolder/MenuBundle/Entity/Menu
и
# php app/console doctrine:generate:entities MyFolder/MenuBundle/Entity/MenuType

Если все прошло удачно ваши классы должны преобразиться и получить в свое распоряжение свои геттеры и сеттеры.

После необходимо создать сами таблицы в БД.
# php app/console doctrine:schema:update --force

Итак у нас 2 таблицы, связанные друг с другом связью ManyToOne. То есть, по сути, таблица РАЗ может иметь множество связей с таблице ДВА.

Маленькое отступление. Давай те проговорим связи в моделях, для тех кто не знает.

Ниже строки из файла РАЗ.
/**
  * @ORM\ManyToOne(targetEntity="MyFolder\MenuBundle\Entity\MenuType", inversedBy="menuTypeId")
  * @ORM\JoinColumn(name="menuTypeId", referencedColumnName="id")
  */
protected $menuTypeId;


Говорят нам, что много строк из файла MyFolder\MenuBundle\Entity\Menu могут относиться только к одной строке из файла MyFolder\MenuBundle\Entity\MenuType о чем нам любезно сообщает аннотация из файла ДВА
/**
   * @ORM\OneToMany(targetEntity="Menu", mappedBy="menuTypeId")
   */
private $typeId;


Таким образом, это один из способов установки связей между сущностями в Symfony.

Возвращаемся к коду. Итак, сущности мы подготовили, БД создали. Переходим к админ части.

Для того, что бы отработала наша админ панель, мы сделаем следующее. В папке с бандлом создаем папку Admin а в ней 2 файла. Файл раз — MenuAdmin, файл два — MenuTypeAdmin. Код из файла РАЗ:
namespace MyFolder\MenuBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;

class MenuAdmin extends Admin{
    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->add('title', null, array())
            ->add('route', null, array())
            ->add('alias', null, array())
            ->add('static', null, array('required' => false))
            ->add('menuTypeId', 'sonata_type_model', array(
                    'class'=>'MenuBundle:MenuType',
                    'property'=>'title',
                    'required' => false
                )
            )
        ;
    }

    protected function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('title', null, array())
            ->add('id', null, array())
            ->add('route', null, array())
        ;
    }

    public function configureShowField(ShowMapper $showMapper){
        $showMapper
            ->add('title', null, array())
            ->add('id', null, array())
            ->add('route', null, array())
        ;
    }

    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('title', null, array())
            ->add('route', null, array())
            ->add('id', null, array())
            ->add('menuTypeId', 'entity', array(
                    'class'=>'MenuBundle:MenuType',
                    'property'=>'title'
                )
            )
        ;
    }
}


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

Код из файла ДВА:
namespace MyFolder\MenuBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Form\FormMapper;
use Sonata\AdminBundle\Show\ShowMapper;

class MenuTypeAdmin extends Admin{
    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->add('title', null, array())
        ;
    }

    protected function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('title', null, array())
            ->add('id', null, array())
        ;
    }

    public function configureShowField(ShowMapper $showMapper){
        $showMapper
            ->add('title', null, array())
            ->add('id', null, array())
        ;
    }

    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('title', null, array())
            ->add('id', null, array())
        ;
    }
} 


Далее необходимо сказать сонате, что она должна увидеть нашу папку и наши 2 файла. Для этого необходимо прописать сервис. Открываем файл MyFolder/MenuBundle/Resources/config/services.yml и вносим изменения. Я приведу код всего файла, что бы не возникло несоответствий:
parameters:

services:
    admin.menu:
        class: MyFolder\MenuBundle\Admin\MenuAdmin
        tags:
        - { name:  sonata.admin, manager_type: orm, group: Меню, label: Меню}
        arguments: [null, MyFolder\MenuBundle\Entity\Menu, SonataAdminBundle:CRUD]
     
     admin.menu_type:
          class: MyFolder\MenuBundle\Admin\MenuTypeAdmin
          tags:
          - { name:  sonata.admin, manager_type: orm, group: Меню Тип, label: Меню Тип}
          arguments: [null, MyFolder\MenuBundle\Entity\MenuType, SonataAdminBundle:CRUD]


Итак, если вы все сделали правильно, то в админ части вашего сайта должно были появиться 2 пункта, и выглядили бы они приблизительно так:
image

Если же так не получилось, вы можете написать мне (sin666m4a1fox@gmail.com), охотно отвечу на ваши письма.

Итак, если вы все сделаи правильно, то теперь у вас появилась возможность добавлять, изменять, удалять ваши пункты меню.

Для чего такая сложность вы спросите? Наверно с этого стоило бы начать, но тем не менее не просто ж так мы тут с вами такой путь проделали. Собственно сам процесс создания такого меню направлен на простоту его последующего изменения и добавления новых пунктов, вывод общего шаблона страницы и разного его содержимого… и много много так далее.

Продолжим. Если вы все сделали правильно, то смею вам предложить создать первый тип меню. Для этого переходим во вкладку “Меню Тип” и жмем кнопку с плюсиком. Так все просто должно быть. Одно поле — title (Заголовок). Таким образом мы можем создать Типы Меню к которым, позже сможем привязать Пункты меню. Я сделал два Типа Меню (“Главное Меню” и “Меню в подвале”). После переходим в само Меню и добавляем новое. Вот тут поинтересней.
image

Собственно Title это понятно зачем, Route — это ссылка которая пойдем в KNPMenuBundle. Alias — это лично мое предпочтение, вы можете не делать такой пункт. Static чекбокс предназначен для того, что бы сказать системе что странице будет кастомной и ей не нужен будет Action метод в контроллере. Menu Type Id собственно там и появятся те пункты меню что вы создали выше. Это та сама привязка, которая после поможет системе понять, какой пункт меню вы все таки ходите выбрать в том или ином случае.

Один момент. В необходимость создания кастомного маршрута пришлось применить такой JS код.
$(document).ready(function () {
    $('input[id$="_static"]').click(function(){
        var $_thisRoute = $('input[id$="_route"]'),
            defaultValues = $_thisRoute.val().split('/');
        if($(this).is(':checked')) {
            $_thisRoute.val('/custom/'+defaultValues[defaultValues.length -1]);
        } else {
            $_thisRoute.val('/'+defaultValues[defaultValues.length -1]);
        }
    })
});


То есть при клике на пункт Static маршрут превращается, если вы написали например about-us то он становится /custom/about-us.

Как добавить свой js в админ часть сонаты не буду расписывать, это выходит за пределы рассматриваемой области, если есть необходимость, подскажу, только спросите :)

Я создал 7 пунктов меню

image

Как видете Route почти у всех одинаков кроме последнего пункта. Привязано все это только к Главному Меню. Собственно с этой частью мы закончили. Переходит к KNPMenuBundle.

У меня установлена версия бандла 1.1 не смотря на то, что уже есть 2.2 тем не менее подружить 2.2 с Сонатой у меня не получилось, да и в Сонате в requirements стоит версия KNPMenuBundle 1.1 поэтому мы ничего не нарушаем.

Продолжаем. В папке с нашим бандлом создаем папку Menu в ней файл Builder.php. Вот его код:
namespace MyFolder\MenuBundle\Menu;

use Knp\Menu\FactoryInterface;
use Knp\Menu\ItemInterface;
use Symfony\Component\DependencyInjection\ContainerAware;

class Builder extends ContainerAware
{

    public function mainMenu(FactoryInterface $factory, array $options)
    {
        $menuItems = $this->container->get('menu')->getMainMenu();
        $menu = $factory->createItem('root');

        $this->setCurrentItem($menu);

        $menu->setChildrenAttribute('class', 'nav');
        $menu->setExtra('currentElement', 'active');

        foreach($menuItems as $item) {
            $menu->addChild($item->getTitle(), array('uri' => $item->getRoute()));
        }

        return $menu;
    }

    protected function setCurrentItem(ItemInterface $menu)
    {
        $menu->setCurrentUri($this->container->get('request')->getPathInfo());
    }
}


Здесь пару моментов. Так как сам Builder наследует ContainerAware то у нас явно есть возможность использовать $this->container->get(), а если так, то мы можем быстро написать сервис на выборку необходимых пунктов меню. Сказано — сделано.

В папке бандла создаем папку Service а в ней один файл MenuService.php. Перед тем как начать писать в него код, давай те сделаем сервис доступным, то есть, отредактируем файл MyFolder/MenuBundle/Resources/config/services.yml таким образом, что бы у нас получилось нижеследующее:
parameters:

services:
  menu:
        class: MyFolder\MenuBundle\Service\MenuService
        arguments: [@service_container]

  admin.menu:
        class: MyFolder\MenuBundle\Admin\MenuAdmin
        tags:
        - { name:  sonata.admin, manager_type: orm, group: Меню, label: Меню}
        arguments: [null, MyFolder\MenuBundle\Entity\Menu, SonataAdminBundle:CRUD]

  admin.menu_type:
          class: MyFolder\MenuBundle\Admin\MenuTypeAdmin
          tags:
          - { name:  sonata.admin, manager_type: orm, group: Меню Тип, label: Меню Тип}
          arguments: [null, MyFolder\MenuBundle\Entity\MenuType, SonataAdminBundle:CRUD]


Собственно теперь код файла MyFolder/MenuBundle/Service/MenuService
namespace MyFolder\MenuBundle\Service;

use Symfony\Component\DependencyInjection\Container;

class MenuService
{
    private $doctrine;
    private $container;
    private $menuRepository;

    public function __construct(Container $container)
    {
        $this->container = $container;
        $this->doctrine = $this->container->get('doctrine');
        $this->menuRepository = $this->doctrine->getRepository('MenuBundle:Menu');
    }

    public function getMainMenu()
    {
        return $this->menuRepository->getMainMenu();
    }
}


Напомню одну строчку из Menu.php сущности
ORM\Entity(repositoryClass=“MyFolder\MenuBundle\Entity\MenuRepository")
Это означает, что в папке с сущностью создайте файл MenuRepository.php и код в нем выглядит вот так:
namespace MyFolder\MenuBundle\Entity;

use Doctrine\ORM\EntityRepository;

use Doctrine\ORM\Query\ResultSetMapping;

class MenuRepository extends EntityRepository
{
   public function getMainMenu()
    {
        return $this->findBy(array('menuTypeId' => 1));
    }
}


собственно это и есть та самая выборка, которая вернет нам все пункты меню, которые относятся только к “Главному Меню” типу.

Заканчиваем: теперь для отображания нашего меню, в шаблонизаторе twig достаточно прописать такую строчку:
{{ knp_menu_render('MenuBundle:Builder:mainMenu', { 'currentClass': 'active'}) }}


CSS класс active будет у значения которое в данный момент активно. Если вы делали меню как я, то вы можете сделать в одном из ваших контролеров такой метод
/**
     * @Template()
     * @Route("/custom/{link}", name="_custom_page",  defaults={"link" = "/"})
     */
    public function customAction($link)
    {
        return $this->render('CommonBundle:Default:commonPage.html.twig', array('page' => $link));
    }


Этот код не панацея, просто как пример. А вот для последнего пункта меню, вам необходимо будет создать свой собсвенный метод.

Если вам показалось, что статья все только усложняет, и есть способ как написать это проще или быстрее, прошу поделиться со мной, ибо сначала изучения любого фреймверка, мы любим все усложнять.

P.S. Если что не правильно пояснил или что не пояснил, пишите на почту, охотно всем отвечу. Спасибо за внимание. До встречи.
Максим @m4a1fox
карма
2,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (6)

  • 0
    Сейчас затестим
  • 0
    Чем мне не нравится Symfony (Хотя она мне, конечно, нравися), так это тем, что для, вроде бы, простых действий вроде меню, нужно писать столько кода. Сейчас пишу на Silex, все в разы быстрее получается.
    • 0
      Понимаете, тут даже не в кол-ве кода дело. Мне кажется, что используя такой код (его правда не много, просто в статье так кажется) мы действительно получаем очень гибкую систему. Возможно мои писательские способности меня тут подвели и статья должна была быть гораздо мень чем есть, тем не менее я постарался как можно более подробно рассмотреть вопрос, оставив без внимания некие моменты.
      • 0
        Ну я писал на симфони, там хватает лишних телодвижений, хотя в плане готовых модулей она лучше, но от этого и менее гибкая, вообще, конечно, от задачи зависит, где-то будет лучше, где-то хуже.
  • 0
    Поигрался с меню, сделал пару телодвижений, а именно:

    1) Добавил в сущность «Menu» следующее:

    src/Common/MenuBundle/Entity/Menu.php
    /**
    	 * @ORM\ManyToOne(targetEntity="Menu", inversedBy="children")
    	 * @ORM\JoinColumn(name="parent_id", referencedColumnName="id", onDelete="CASCADE")
    	 */
    	private $parent;
    
    	/**
    	 * @ORM\OneToMany(targetEntity="Menu", mappedBy="parent")
    	 */
    	private $children;
    
        /**
         * Constructor
         */
        public function __construct()
        {
            $this->children = new \Doctrine\Common\Collections\ArrayCollection();
        }
        
        /**
         * Set parent
         *
         * @param \Common\MenuBundle\Entity\Menu $parent
         * @return Menu
         */
        public function setParent(\Common\MenuBundle\Entity\Menu $parent = null)
        {
            $this->parent = $parent;
        
            return $this;
        }
    
        /**
         * Get parent
         *
         * @return \Common\MenuBundle\Entity\Menu 
         */
        public function getParent()
        {
            return $this->parent;
        }
    
        /**
         * Add children
         *
         * @param \Common\MenuBundle\Entity\Menu $children
         * @return Menu
         */
        public function addChildren(\Common\MenuBundle\Entity\Menu $children)
        {
            $this->children[] = $children;
        
            return $this;
        }
    
        /**
         * Remove children
         *
         * @param \Common\MenuBundle\Entity\Menu $children
         */
        public function removeChildren(\Common\MenuBundle\Entity\Menu $children)
        {
            $this->children->removeElement($children);
        }
    
        /**
         * Get children
         *
         * @return \Doctrine\Common\Collections\Collection 
         */
        public function getChildren()
        {
            return $this->children;
        }
    



    2) Изменил Builder:

    src/Common/MenuBundle/Menu/Builder.php
    public function mainMenu(FactoryInterface $factory, array $options)
    	{
    		$menuItems = $this->container->get('menu')->getMainMenu();
    		$menu = $factory->createItem('root');
    
    		$this->setCurrentItem($menu);
    
    		$menu->setChildrenAttribute('class', 'nav nav-justified');
    		$menu->setExtra('currentElement', 'active');
    
    		foreach($menuItems as $item) {
    			if ($item->getParent()) {
    				$em = $this->container->get('doctrine')->getEntityManager();
    				$parent = $em->getRepository('CommonMenuBundle:menu')->find($item->getParent());
    
    				$menu[$parent->getTitle()]
    					->setChildrenAttribute('class', 'dropdown-menu dropdown-menu-large row')
    					->setAttribute("class", "dropdown dropdown-large")
    					->addChild($item->getTitle(), array('uri' => $item->getRoute()))
    					->setAttribute("class", "col-sm-6")
    				;
    			} else {
    				$menu->addChild($item->getTitle(), array('uri' => $item->getRoute()));
    			}
    		}
    		return $menu;
    	}
    



    В результате получилось вложенное меню:



    А на фронтенде в моем случае отображается так:

  • 0
    За статью однозначно спасибо. Побольше бы про Симфони…

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