KnpMenuBundle + Sonata. Делаем меню из базы

в 19:19, , рубрики: menu, php, SonataAdminBundle, symfony, symfony2, метки: , , ,

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

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

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

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

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

namespace MyFolderMenuBundleEntity;

use DoctrineORMMapping as ORM;
/**
 * @ORMEntity
 * @ORMTable(name="menu")
 * @ORMEntity(repositoryClass="MyFolderMenuBundleEntityMenuRepository")
 */
class Menu{
    /**
     * @ORMId
     * @ORMColumn(type="integer")
     * @ORMGeneratedValue(strategy="AUTO")
     */
    protected $id;

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

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

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

    /**
     * @ORMColumn(type="boolean")
     */
    protected $static;

    /**
     * @ORMManyToOne(targetEntity="CafeMenuBundleEntityMenuType", inversedBy="menuTypeId")
     * @ORMJoinColumn(name="menuTypeId", referencedColumnName="id")
     */
    protected $menuTypeId;

}

Посмотрим на файл под номером два:

namespace MyFolderMenuBundleEntity;

use DoctrineORMMapping as ORM;
/**
 * @ORMEntity
 * @ORMTable(name="menu_type")
 * @ORMEntity(repositoryClass="MyFolderMenuBundleEntityMenuTypeRepository")
 */
class MenuType {

    /**
     * @ORMId
     * @ORMColumn(type="integer")
     * @ORMGeneratedValue(strategy="AUTO")
     */
    protected $id;

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

    /**
     * @ORMOneToMany(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. То есть, по сути, таблица РАЗ может иметь множество связей с таблице ДВА.

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

Ниже строки из файла РАЗ.

/**
  * @ORMManyToOne(targetEntity="MyFolderMenuBundleEntityMenuType", inversedBy="menuTypeId")
  * @ORMJoinColumn(name="menuTypeId", referencedColumnName="id")
  */
protected $menuTypeId;

Говорят нам, что много строк из файла MyFolderMenuBundleEntityMenu могут относиться только к одной строке из файла MyFolderMenuBundleEntityMenuType о чем нам любезно сообщает аннотация из файла ДВА

/**
   * @ORMOneToMany(targetEntity="Menu", mappedBy="menuTypeId")
   */
private $typeId;

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

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

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

namespace MyFolderMenuBundleAdmin;

use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleFormFormMapper;
use SonataAdminBundleShowShowMapper;

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 MyFolderMenuBundleAdmin;

use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleFormFormMapper;
use SonataAdminBundleShowShowMapper;

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: MyFolderMenuBundleAdminMenuAdmin
        tags:
        - { name:  sonata.admin, manager_type: orm, group: Меню, label: Меню}
        arguments: [null, MyFolderMenuBundleEntityMenu, SonataAdminBundle:CRUD]
     
     admin.menu_type:
          class: MyFolderMenuBundleAdminMenuTypeAdmin
          tags:
          - { name:  sonata.admin, manager_type: orm, group: Меню Тип, label: Меню Тип}
          arguments: [null, MyFolderMenuBundleEntityMenuType, 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 MyFolderMenuBundleMenu;

use KnpMenuFactoryInterface;
use KnpMenuItemInterface;
use SymfonyComponentDependencyInjectionContainerAware;

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: MyFolderMenuBundleServiceMenuService
        arguments: [@service_container]

  admin.menu:
        class: MyFolderMenuBundleAdminMenuAdmin
        tags:
        - { name:  sonata.admin, manager_type: orm, group: Меню, label: Меню}
        arguments: [null, MyFolderMenuBundleEntityMenu, SonataAdminBundle:CRUD]

  admin.menu_type:
          class: MyFolderMenuBundleAdminMenuTypeAdmin
          tags:
          - { name:  sonata.admin, manager_type: orm, group: Меню Тип, label: Меню Тип}
          arguments: [null, MyFolderMenuBundleEntityMenuType, SonataAdminBundle:CRUD]

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

namespace MyFolderMenuBundleService;

use SymfonyComponentDependencyInjectionContainer;

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 сущности
* ORMEntity(repositoryClass=“MyFolderMenuBundleEntityMenuRepository")
Это означает, что в папке с сущностью создайте файл MenuRepository.php и код в нем выглядит вот так:

namespace MyFolderMenuBundleEntity;

use DoctrineORMEntityRepository;

use DoctrineORMQueryResultSetMapping;

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

Источник


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


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