4

I am currently struggling to wrap my head around how the menu tree and menu link services work for outputting part of a menu with some custom requirements. Admittedly I don't have to do this very often, but it seems like there must be a way.

I tried with Menu Block, but that does not seem to be able to do what I need to do.

First, when I am on level 3 and a certain content type, (or the parent route is nolink), I need to print the current level of the menu like this:

enter image description here

The menu links represent what is under the current page.

Second, if it is level 2+ (and not a certain content type), the section parent needs to sit on the top as a blue link, in a different treatment:

enter image description here

Third, if the current page in the menu is active, no siblings are printed, only this page menu link and its children:

enter image description here

I started here, in a menu block:

/** @var \Drupal\Core\Menu\MenuLinkManager $menu_link_manager */
$menu_link_manager = \Drupal::service('plugin.manager.menu.link');
$route_params = array('node' => $node->id());

$menu_links = $menu_link_manager->loadLinksByRoute('entity.node.canonical', $route_params, 'main');

// Attach the top parent, if we are at level 3 (for a project) or level 2 (for a basic page)
// top parent cannot go above level 3.... but also level 2?
// show the current node menu tree and its siblings
// show the siblings if the current page has no children

$parent = reset($menu_links)->getParent();
$uuid = str_replace('menu_link_content:', '', $parent);
$parent_menu_content = current(\Drupal::entityTypeManager()
  ->getStorage('menu_link_content')
  ->loadByProperties(array('uuid' => $uuid)));

$params = new MenuTreeParameters();
$params->setRoot($parent);
$params->setActiveTrail([$parent, reset($menu_links)->getPluginId()]);

$tree = $this->menuTree->load($parent, $params);
$built_tree = $this->menuTree->build($tree);

$link = end($parent_menu_content->link->getValue())['uri'];

$element = $this->menuTree->build($tree);
$build['#items'] = $element['#items'][$parent]['below'];

if ($link != 'route:<nolink>') {
  $build['#top_parent'] = $element['#items'][$parent];
}

if (empty($build['#cache']['contexts'])) {
  $build['#cache']['contexts'] = ['user.permissions'];
  $build['#cache']['tags'] = ['config:system.menu.main'];
}

return $build;

But the children of a link (in below) don't render correctly in Twig, its blank, and I am not sure if there is some easier way to do this.

top_parent is a new theme variable I have added to menu so I can render it separately in Twig.

Edit: below now renders right - I forgot to {% import _self as menus %} in my second macro.

  • Have you had a look at this already drupal.stackexchange.com/a/259505/15055? Seems related if I understand your issue right. – leymannx Nov 7 at 15:31
  • Thats pretty close, I made some modifications. Now I think I am almost there, except in the case where I don't need to show the blue link at the top, the starting level needs to be offset by 1 I think... but is not quite working. – Kevin Nov 7 at 16:32
  • I think I got it, had to sort of cheat. Will post answer in a bit. – Kevin Nov 7 at 17:40
  • Nice! I'm looking forward to your answer. And I think I should also add your caching strategy in my answers as well. – leymannx Nov 7 at 22:12
  • It's not glamorous, but I am up against a deadline, so... – Kevin Nov 8 at 0:34
3

Found a way to satisfy all the cases without having to create multiple blocks (visibility rules would be odd) or create different menus. I could not achieve this by assuming a content type like in the question, because you could have the same content type nested until multiple areas of the menu, and not really know if the current depth you were at for it qualified as the parent (where the menu block should "begin" - not necessarily the parent of the page).

I decided to cheat a bit and use the URL and the alias pattern. If the current path matched a pattern, and the URL consisted of a certain amount of segments, I could tell I was in a specific part of the menu where the behavior should be different than others. In this case, it should use the current page and its children, instead of the parent of the current page and its siblings:

class MenuBlockEnhanced extends MenuBlock {

  /**
   * {@inheritdoc}
   */
  public function build() {
    $node = \Drupal::routeMatch()->getParameter('node');

    // If we are not on a node route, get out.
    if (!$node) {
      return parent::build();
    }

    // If the menu being rendered is not specifically the sub nav, get out.
    if ($this->configuration["suggestion"] != 'subnav') {
      return parent::build();
    }

    $current_path = \Drupal::request()->getRequestUri();
    $parts = explode('/', $current_path);
    $build = parent::build();

    /** @var \Drupal\Core\Menu\MenuLinkManager $menu_link_manager */
    $menu_link_manager = \Drupal::service('plugin.manager.menu.link');
    $route_params = array('node' => $node->id());
    $menu_links = $menu_link_manager->loadLinksByRoute('entity.node.canonical', $route_params, 'main');
    $set_parent = TRUE;

    $params = new MenuTreeParameters();

    // I don't really know of another way to 'tell' we are on a project, since
    // a project can have projects under it. We can only rely on the path and
    // how many 'parts' are in the path - anything over 4 parts would be anything
    // nested in this section.
    if (preg_match('/programs-projects/i', $current_path) && count($parts) == 4) {
      $parent = reset($menu_links)->getPluginId();
      $set_parent = FALSE;
    } else {
      $parent = reset($menu_links)->getParent();
    }

    $uuid = str_replace('menu_link_content:', '', $parent);
    $parent_menu_content = current(\Drupal::entityTypeManager()
     ->getStorage('menu_link_content')
     ->loadByProperties(array('uuid' => $uuid)));

    $params->setRoot($parent);
    $params->setActiveTrail([$parent, reset($menu_links)->getPluginId()]);
    $tree = $this->menuTree->load($parent, $params);

    // Transform the tree using the manipulators you want.
    $manipulators = array(
      // Only show links that are accessible for the current user.
      array('callable' => 'menu.default_tree_manipulators:checkAccess'),
      // Use the default sorting of menu links.
      array('callable' => 'menu.default_tree_manipulators:generateIndexAndSort'),
    );
    $tree = $this->menuTree->transform($tree, $manipulators);

    $link = end($parent_menu_content->link->getValue())['uri'];

    $element = $this->menuTree->build($tree);
    $build['#items'] = $element['#items'][$parent]['below'];

    if ($link != 'route:<nolink>' && $set_parent) {
      $build['#top_parent'] = $element['#items'][$parent];
    }

    if (empty($build['#cache']['contexts'])) {
      $build['#cache']['contexts'] = ['user.permissions'];
      $build['#cache']['tags'] = ['config:system.menu.main'];
    }

    return $build;
  }

}

The caching strategy comes right from what would normally be there, frankly I probably do not even need to specify it since I am already calling parent::build() and just reduce that code.

In the template, I resolve the "do I print just siblings, or this page and its children?" by using the in_active_path check, and faking a control break in Twig macro (see: break = true):

{% import _self as menus %}

{{ menus.menu_links(items, attributes, 0, top_parent) }}

{% macro menu_links(items, attributes, menu_level, top_parent) %}
  {% import _self as menus %}

  {% if items %}
    {% if top_parent %}
      <ul class="secondary-nav__list">
        {%
          set parent_list_classes =
          [
            'secondary-nav__item',
            'secondary-nav__item--parent'
          ]
        %}

        {% set parent_link_classes =
          [
            'link',
            'link--left-arrow',
          ]
        %}

        <li{{ top_parent.attributes.addClass(parent_list_classes) }}>
          {{ link(top_parent.title, top_parent.url, { 'class': parent_link_classes }) }}
          {{ menus.menu_child_links(items, attributes, 0) }}
        </li>
      </ul>
    {% else %}
      {{ menus.menu_child_links(items, attributes, 0) }}
    {% endif %}
  {% endif %}
{% endmacro %}

{% macro menu_child_links(items, attributes, menu_level) %}
  {% import _self as menus %}

  <ul class="secondary-nav__list">
    {% set break = false %}
    {% for item in items if not break %}
      {%
        set list_classes =
        [
          'secondary-nav__item',
          item.in_active_trail ? 'is-active'
        ]
      %}

      <li{{ item.attributes.addClass(list_classes) }}>
        {{ link(item.title, item.url) }}

        {% if item.below and item.in_active_trail %}
          {% set break = true %}
          {{ menus.menu_child_links(item.below, attributes, menu_level + 1) }}
        {% endif %}
      </li>
    {% endfor %}
  </ul>
{% endmacro %}

The menu works exactly as asked for all cases, and will go all the way down to its maximum depth and back up the chain (with the parent blue link appearing up top).

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.