Lee RowlandsSenior Developer
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.
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.