«Хлебные крошки» — элемент навигации по сайту, представляющий собой путь по сайту от его «корня» до текущей страницы, на которой находится пользователь.
Вроде бы простая, на первый взгляд, задача превратилась в большую кучу самостоятельных и неочевидных решений. На момент решения задачи в нашем проекте уже использовался KnpMenu, так что, переписав немного шаблон для меню, мы получаем наши хлебные крошки.
Файл App/DefaultBundle/Resources/views/breadcrumb.html.twig:
{% extends 'knp_menu.html.twig' %}
Выводим только родительские элементы, без их детей:
{{ block('children') }}
{% else %}
{% endif %}{% 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) %}
{% 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 %}
{# потребности верстки - разделитель #}
{{ 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. Но возникло несколько проблем:
- Надо сделать возможность задавать последнюю динамическую крошку. Например, при просмотре статьи “Hello world” мы должны видеть “Главная -> Статьи -> Hello world”.
- Если в URL есть какие-то параметры, например методом GET передается форма меню не засчитывается за текущее.
- Почему то заголовок должен быть уникальным, иначе навигация выводится только на какой-то одной странице.
Пойдем с конца. Как всегда помогают комментарии в исходном коде. Обращаемся к файлу 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
Стоит напомнить, что существует множество готовых решений, но большинство из них примитивны. Так, например, они не могут отображать заголовок «динамических» крошек.