Skip to main content

Making Drupal 8's menu active trail consider query arguments

On a recent Drupal 8 client project our client was building listing pages using views exposed filters and adding these to the menu.

This resulted in several menu URLs pointing to the same base path, but with the query arguments determining the difference.

However Drupal 8's default menu-trail calculation was resulting in the menu highlighting all instances when one of them was viewed.

Luckily the active trail calculation is done in a service and it was simple to modify the default behaviour.

Read on to see how we did it.

by Lee Rowlands /

The problem

So the site included a view that displayed all of the different Venues the client managed, with exposed filters that allowed filtering the listing into groups.

The client used the URL generated by the filters to add different menu entries. For example there was a list of 'Community centres' in one section of the menu, linking to a pre-filtered view. In another section of the menu there was a link to 'Outdoor art spaces', also a link to a pre-filtered view.

However Drupal 8's default menu active trail calculation uses the \Drupal\Core\Menu\MenuLinkManager::loadLinksByRoute() method to calculate the active trail. As indicated by the name, this only loads matches based on the route name and parameters, but doesn't consider query arguments such as those used by Views exposed filters.

The solution

Luckily, the menu active trail calculation is handled in a service. This means we can override the definition and inject an alternate implementation or arguments.

Now there are two points we could override here, we could inject a new menu link manager definition into the menu active trail service, and change the way that loadLinksByRoute works to also consider query arguments - however the active trail service is heavily cached, and this would result in the first one to be cached and any subsequent ones to not work.

Instead we need to run our code after the values are fetched from the cache, so the logical point is to override Drupal\Core\Menu\MenuActiveTrail::getActiveTrailIds() method to filter out matches and their parents that don't match the current query arguments.

So to do this we need an implementation of \Drupal\Core\DependencyInjection\ServiceModifierInterface. Ours looks something like this:

<?php

namespace Drupal\my_module;

use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\DependencyInjection\ServiceModifierInterface;
use Symfony\Component\DependencyInjection\Reference;

class MyModuleServiceProvider implements ServiceModifierInterface {

  /**
   * {@inheritdoc}
   */
  public function alter(ContainerBuilder $container) {
    // Get the service we want to modify.
    $definition = $container->getDefinition('menu.active_trail');
    // Inject an additional service, the request stack.
    $definition->addArgument(new Reference('request_stack'));
    // Make the active trail use our service.
    $definition->setClass(MyModuleMenuActiveTrail::class);
  }
}

For more information, see our previous blog post on overriding Drupal 8 service definitions.

Filtering on query parameters

Now we have our new active trail service, we need to filter out the links that match on route, but not on query arguments.

To do this, we need to get the query arguments from the current request. In our service alter above you'll note we injected an additional service into our active trail class, the request stack.

This allows us to get the current request and therefore the query arguments.

So first we need a constructor to handle the new argument, and a class property to store it in.

<?php

namespace Drupal\my_module;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Lock\LockBackendInterface;
use Drupal\Core\Menu\MenuActiveTrail;
use Drupal\Core\Menu\MenuLinkManagerInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Symfony\Component\HttpFoundation\RequestStack;

/**
 * Defines a class for menu active trail that considers query parameters.
 */
class MyModuleMenuActiveTrail extends MenuActiveTrail {

  /**
   * Current request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * {@inheritdoc}
   */
  public function __construct(MenuLinkManagerInterface $menu_link_manager, RouteMatchInterface $route_match, CacheBackendInterface $cache, LockBackendInterface $lock, RequestStack $request_stack) {
    parent::__construct($menu_link_manager, $route_match, $cache, $lock);
    $this->requestStack = $request_stack;
  }

}

Now we have the pieces in place, we just need to add the code to filter out the links and their parents that don't match on query parameters.

/**
 * {@inheritdoc}
 */
public function getActiveTrailIds($menu_name) {
  // Get the existing trail IDs from the core implementation.
  $matching_ids = parent::getActiveTrailIds($menu_name);
  // If we don't have any query parameters, there's nothing to do here.
  if (($request = $this->requestStack->getCurrentRequest()) && $request->query->count()) {
    // Start with the top-level item.
    $new_match = ['' => ''];
    // Get all the query parameters.
    $query = $request->query->all();
    // Get the route name.
    $route_name = $this->routeMatch->getRouteName();
    if ($route_name) {
      $route_parameters = $this->routeMatch->getRawParameters()->all();

      // Load all links matching this route in this menu.
      $links = $this->menuLinkManager->loadLinksByRoute($route_name, $route_parameters, $menu_name);
      // Loop through them.
      foreach ($links as $active_link) {
        $match_options = $active_link->getOptions();
        if (!isset($match_options['query'])) {
          // This link has no query parameters, so cannot match, ignore it.
          continue;
        }
        if ($match_options['query'] == $query) {
          // This one matches - so we add its parent trail to our new match.
          if ($parents = $this->menuLinkManager->getParentIds($active_link->getPluginId())) {
            $new_match += $parents;
          }
        }
      }
    }
    // Replace the existing trail with the new trail.
    $matching_ids = $new_match;
  }
  return $matching_ids;
}

Wrapping up

Drupal 8's service based architecture gives us new levels of flexibility, personally I'm really enjoying building client projects with Drupal 8. I hope you are too.

Posted by Lee Rowlands
Senior Drupal Developer

Dated

Comments

Comment by Jesse

Dated

Hi,

Thanks for the tutorial. Could you please share your services.yml file?

Comment by Lee Rowlands

Dated

With a service provider, you don't need a services.yml. You need a class named \Drupal\your_module\YourModuleServiceProvider

Comment by Jaime Rodríguez

Dated

Hi! thank you very much for your tutorial, I'm new with drupal 8 and i have a lot of questions about how to implement this solution, right now i'm having the exact same problem and I would highly appreciate if you could give me a lil more insights on filenames and file structure needed to get this solution to work.

Thanks in advance

Comment by Jaime Rodríguez

Dated

Hi, first of all thank you very much for sharing this knowledge,

I'm new to drupal 8 development and I'm trying to implement your solution considering I'm having the exact same problem as the one described here, I would highly appreciate if you can give me a little insight on details like file names and file structure for implementing this on mi case.

I don't understand how should I put every chunk of code in a module structure like file names and folders, i would also know if I should write a .module file, module.install and .info.yml?

Thank you very much in advance for all your help.

Comment by Denis

Dated

Thank you very much!
It is very useful to me.

Pagination

Add new comment

Restricted HTML

  • Allowed HTML tags: <a> <em> <strong> <cite> <blockquote> <code> <ul> <ol> <li> <dl> <dt> <dd> <h2> <h3> <h4> <h5> <h6>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Not sure where to start? Try typing "hello" or "help" if you get stuck.