Свой велосипед на хлебных крошках

Игорь Адров — Jul 25, 2012    development, symfony2

«Хлебные крошки» — элемент навигации по сайту, представляющий собой путь по сайту от его «корня» до текущей страницы, на которой находится пользователь.

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

Файл App/DefaultBundle/Resources/views/breadcrumb.html.twig:


{% extends 'knp_menu.html.twig' %}

Выводим только родительские элементы, без их детей:


{% block list %}
{% import 'knp_menu.html.twig' as macros %}
{% if item.hasChildren and options.depth is not sameas(0) and item.displayChildren %}
    {% if item.level is sameas(0) %}
    <ul{{ macros.attributes(listAttributes) }}>
        <li class="breadcrumbs-guide">Вы здесь:</li>
        {{ block('children') }}
    </ul>
    {% else %}
        <li{{ macros.attributes(listAttributes) }}>
            {{ block('children') }}
        </li>
    {% endif %}
{% endif %}
{% endblock %}

Выводим или текущий элемент, или если он имеет текущего родителя:


{% block item %}
{% import 'knp_menu.html.twig' as macros %}
{% if item.displayed %}
    {# building the class of the item #}
    {%- set classes = item.attribute('class') is not empty ? [item.attribute('class')] : [] %}
    {%- if item.current %}
        {%- set classes = classes|merge([options.currentClass]) %}
    {%- elseif item.currentAncestor %}
        {%- set classes = classes|merge([options.ancestorClass]) %}
    {%- endif %}
    {%- if item.actsLikeFirst %}
        {%- set classes = classes|merge([options.firstClass]) %}
    {%- endif %}
    {%- if item.actsLikeLast %}
        {%- set classes = classes|merge([options.lastClass]) %}
    {%- endif %}
    {%- set attributes = item.attributes %}
    {%- if classes is not empty %}
        {%- set attributes = attributes|merge({'class': classes|join(' ')}) %}
    {%- endif %}
    {# displaying the item #}
    {# сама проверка #}
    {%- if item.current or item.currentAncestor %}
    {# потребности верстки - разделитель #}
    <li class="sep"></li>
    <li{{ macros.attributes(attributes) }}>
        {%- if item.uri is not empty and (not item.current or options.currentAsLink) %}
        {{ block('linkElement') }}
        {%- else %}
        {{ block('spanElement') }}
        {%- endif %}
        {# render the list of children#}
        {%- set childrenClasses = item.childrenAttribute('class') is not empty ? [item.childrenAttribute('class')] : [] %}
        {%- set childrenClasses = childrenClasses|merge(['menu_level_' ~ item.level]) %}
        {%- set listAttributes = item.childrenAttributes|merge({'class': childrenClasses|join(' ') }) %}
        {{ block('list') }}
    </li>
    {% endif %}
{% endif %}
{% endblock %}

Вывести в шаблоне это можно так:


{{ knp_menu_render('AppDefaultBundle:Builder:breadcrumb', {template: 'AppDefaultBundle::breadcrumb.html.twig'}) }}

В принципе, все готово и работает. Но хотелось бы, чтобы крошки можно было задавать в одном месте и в удобном формате. Тут на помощь приходит встроенный компонент Yaml и куча новых проблем в комплекте, но обо всем по порядку.

Наш новый метод вывода крошек в Builder:

public function breadcrumb(FactoryInterface $factory, array $options)
{
    $menu = $factory->createItem('root');
    $menu->setCurrentUri($this->container->get('request')->getRequestUri());

    $main = $menu->addChild('Главная', array('route' => '_index'));

    $route = $this->container->get('request')->get('_route');

    $loader = Yaml::parse($this->container->get('kernel')->getRootDir().'/config/breadcrumb.yml');
    $this->parseMenu($main, $loader);

    return $menu;
}

И самое главное - метод для перевода многомерного массива в меню:

protected function parseMenu($item, array $menu)
{
    $translator = $this->container->get('translator');
    $currentRoute = $this->container->get('request')->get('_route');
    foreach ($menu as $child) {
        $title = $translator->trans($child['title']);
        $rp = isset($child['routeParameters']) ? $child['routeParameters'] : array();

        $current = $item->addChild($title, array(
            'route' => $child['route'],
            'routeParameters' => $rp
        ));

        if (isset($child['items']) && is_array($child['items'])) {
            $this->parseMenu($current, $child['items']);
        }
    }
}

Отлично, все работает, и крошки хранятся в Yaml. Но возникло несколько проблем:

  1. Надо сделать возможность задавать последнюю динамическую крошку. Например, при просмотре статьи “Hello world” мы должны видеть “Главная -> Статьи -> Hello world”.
  2. Если в URL есть какие-то параметры, например методом GET передается форма меню не засчитывается за текущее.
  3. Почему то заголовок должен быть уникальным, иначе навигация выводится только на какой-то одной странице.

Пойдем с конца. Как всегда помогают комментарии в исходном коде. Обращаемся к файлу ItemInterface.php и видим:

/**
 * Returns the label that will be used to render this menu item
 *
 * Defaults to the name of no label was specified
 *
 * @return string
 */
function getLabel();

Рядом мы также видим методы setName, setLabel. С этой проблемой справились просто - ключ и заголовок задаются отдельно, но по желанию. Изменим наш код:

protected function parseMenu($item, array $menu)
{
    $translator = $this->container->get('translator');
    $currentRoute = $this->container->get('request')->get('_route');
    // теперь нам пригодиться ключ массива, он же должен быть уникальным
    foreach ($menu as $key => $child) {
        $title = $translator->trans($child['title']);
        $rp = isset($child['routeParameters']) ? $child['routeParameters'] : array();

        // добавляем меню по ключу
        $current = $item->addChild($key, array(
            'route' => $child['route'],
            'routeParameters' => $rp
        ));
        // и ставим нужный нам заголовок
        $current->setLabel($title);

        if (isset($child['items']) && is_array($child['items'])) {
            $this->parseMenu($current, $child['items']);
        }
    }
}

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

protected function parseMenu($item, array $menu)
{
    $translator = $this->container->get('translator');
    $currentRoute = $this->container->get('request')->get('_route');
    // теперь нам пригодится ключ массива, он же должен быть уникальным
    foreach ($menu as $key => $child) {
        $title = $translator->trans($child['title']);
        $rp = isset($child['routeParameters']) ? $child['routeParameters'] : array();

        // добавляем меню по ключу
        $current = $item->addChild($key, array(
            'route' => $child['route'],
            'routeParameters' => $rp
        ));
        // и ставим нужный нам заголовок
        $current->setLabel($title);

        if ($currentRoute == $child['route']) {
            if ($rp) {
                $match = true;
                foreach ($rp as $param => $value) {
                    $currentValue = $this->container->get('request')->get($param);
                    if ($currentValue != $value) {
                        $match = false;
                        break;
                    }
                }
                if ($match) {
                   $current->setCurrent(true);
                }
            } else {
                $current->setCurrent(true);
            }
        }

        if (isset($child['items']) && is_array($child['items'])) {
            $this->parseMenu($current, $child['items']);
        }
    }
}

Изначально хотелось решить первую проблему, задавая последний пункт меню в контроллере. Но не тут-то было, наши крошки отображаются из layout, а следовательно раньше контроллера. Что ж, добавляем еще одно свойство для пункта меню - entity. Им мы воспользуемся, только если данный пункт меню текущий, чтобы не делать лишних запросов. Финальный код:

protected function parseMenu($item, array $menu)
{
    $translator = $this->container->get('translator');
    $currentRoute = $this->container->get('request')->get('_route');
    foreach ($menu as $key => $child) {
        $rp = isset($child['routeParameters']) ? $child['routeParameters'] : array();

        if (!isset($child['entity'])) {
            $current = $item->addChild($key, array(
                'route' => $child['route'],
                'routeParameters' => $rp
            ));
        } else {
            $current = $item->addChild($key);
        }

        if ($currentRoute == $child['route']) {
            if ($rp) {
                $match = true;
                foreach ($rp as $param => $value) {
                    $currentValue = $this->container->get('request')->get($param);
                    if ($currentValue != $value) {
                        $match = false;
                        break;
                    }
                }
                if ($match) {
                   $current->setCurrent(true);
                }
            } else {
                $current->setCurrent(true);
            }
        }

        if (isset($child['title'])) {
            $title = $translator->trans($child['title']);
            $current->setLabel($title);
        }

        if ($current->isCurrent() && isset($child['entity'])) {
            $id = $this->container->get('request')->get('id');
            $em = $this->container->get('doctrine')->getEntityManager();
            $entity = $em->getRepository($child['entity'])->find($id);
            $current->setLabel($entity);
        }

        if (isset($child['items']) && is_array($child['items'])) {
            $this->parseMenu($current, $child['items']);
        }
    }
}

А вот так может выглядеть файл breadcrumb.yml:

news_region:
    title: Новости
    route: news
    routeParameters:
        geoType: region
    items:
        all:
            title: Все категории
            route: news
            routeParameters:
                geoType: region
                rubric: ~
        news:
            title: Новости
            route: news
            routeParameters:
                geoType: region
                rubric: news
        article:
            title: Объявления
            route: news
            routeParameters:
                geoType: region
                rubric: article
        show:
            route: news_show
            entity: AppNewsBundle:News
page_contacts:
    title: Контакты
    route: page_show
    routeParameters:
        slug: contacts
page_about:
    title: О проекте
    route: page_show
    routeParameters:
        slug: about

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

Evercode Lab

Close