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

Вроде бы простая, на первый взгляд, задача превратилась в большую кучу самостоятельных и неочевидных решений. На момент решения задачи в нашем проекте уже использовался 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) %}

{{ block('children') }}

{% else %} {{ block('children') }}

{% 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 %}
{# потребности верстки - разделитель #}

  • {%- 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') }}

    {% 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

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