Author image
Senior Developer

Replacing a vocabulary listing

In a recent Drupal 8 project, one of the site's vocabularies had several thousand terms in it (representing airports), which caused its admin listing page to run out of memory before it could render. I wanted to solve this without affecting any other vocabularies on the site, and improve the listing itself along the way to be more useful, with filters to make searching it much easier. The original listing is not a view, and loads much more than it needs to.

For comparison, here's an example of how core's original vocabulary listing can look:

Example of the original core vocabulary listing

...and then here's the customised page for airports, with the filter and an extra column (corresponding to the term names and descriptions) that I wanted to head towards, below. You can download an export of the view configuration that I ended up using (for importing on a D8 site at /admin/config/development/configuration/single/import, though you will need an 'airports' vocabulary to exist).

Customised airports view

I thought of a few approaches:

Just limit the number of terms listed

Change the terms_per_page_admin setting in the taxonomy.settings config, which defaults to 100 (there is no UI for this):

\Drupal::configFactory()->getEditable('taxonomy.settings')->set('terms_per_page_admin', '25')->save();

But this would have affected all vocabularies, and couldn't allow improvements like added filters.

Override the controller for the existing route

Use my own controller on the existing entity.taxonomy_vocabulary.overview_form route (probably via a RouteSubscriber). So the page would no longer directly use the form that is specified in the taxonomy.routing.yml file, but instead a custom controller that would call through to that form for all other vocabularies and then do something else (probably embed a view) for my airports taxonomy.

But that would mean changing the very definition of the existing route, which I want to remain in place for other vocabularies really. That might have been okay, but the route I went down was as follows...

Use a new view just for the vocabulary

Create a new page with views to be used on the specific path for airports (i.e. '/admin/structure/taxonomy/manage/airports/overview'). Behind the scenes, that means a new route would be set up, so there would be two routes valid for one path, but the more generic one from core would use a wildcard, whilst mine would specify the vocabulary in its path so it gets used ahead of core's listing, just for this vocabulary. The definition of the existing route from core could then remain untouched.

But of course it's never quite as easy as you think...

Once I'd set up this view, with the nice filters I wanted to add, and visited the taxonomy listing, I got a cryptic error message:

Symfony\Component\Routing\Exception\MissingMandatoryParametersException: Some mandatory parameters are missing ("taxonomy_vocabulary") to generate a URL for route "entity.taxonomy_vocabulary.devel_load". in Drupal\Core\Routing\UrlGenerator->doGenerate() (line 171 of core/lib/Drupal/Core/Routing/UrlGenerator.php).

This turns out to be caused by the view having a specific path, that does not use the vocabulary wildcard that one of the tabs on the page expects. This is the sort of thing I thought I'd avoid by leaving the original route intact. Disabling the Devel module, which provided this particular tab, does solve that issue, but the view now has no tabs, because the tabs are attached using the original route name, not mine.

No tabs on vocabulary listing

So, where to now? I thought of three ideas:

  1. Force my view to use the same route name as the original route. But then I would lose the original route, which I don't want to do for the reasons outlined above already.

  2. Loop over all tabs that are attached to the original route, in a hook_local_tasks_alter(), to attach them to my route instead. But then all the other vocabularies would lose their tabs too! I could work around that with some more wrapping code, but this idea became less attractive as I thought about it.

  3. Attach my view as a tab to the original vocabulary listing. This would create a duplicate 'List' tab, but I figured that would be the easiest problem to solve.

Duplicated 'List' tab

That last option needed a few hooks to be implemented, and to remove the duplicate tab that you can see in the above screenshot...

hook_local_tasks_alter:

/**
* Implements hook_local_tasks_alter().
*/
function MYMODULE_local_tasks_alter(&$local_tasks) {
  // Views places the airports view below the vocab edit form route, but that
  // then stops the fields UI tabs showing up.
  if (isset($local_tasks['views_view:view.airports.page_1'])) {
    $local_tasks['views_view:view.airports.page_1']['base_route'] = 'entity.taxonomy_vocabulary.overview_form';
  }
}

hook_module_implements_alter():

/**
* Implements hook_module_implements_alter().
*/
function MYMODULE_module_implements_alter(&$implementations, $hook) {
  if ($hook === 'local_tasks_alter') {
    // Move MYMODLE_local_tasks_alter() to run after
    // views_local_tasks_alter() as that sets up the base routes.
    $group = $implementations['MYMODLE'];
    unset($implementations['MYMODLE']);
    $implementations['MYMODLE'] = $group;
  }
}

hook_menu_local_tasks_alter():

This one ensures only one of the routes that is valid for the 'List' tab actually shows. Otherwise both tabs will show, despite them both linking to the same URL!

/**
* Implements hook_menu_local_tasks_alter().
*/
function MYMODULE_menu_local_tasks_alter(&$data, $route_name, $cacheability) {
  if (isset($data['tabs'][0]['views_view:view.airports.page_1'])) {
    /** @var \Drupal\Core\Url $url */
    if (isset($data['tabs'][0]['entity.taxonomy_vocabulary.overview_form']['#link']['url'])) {
      $url = $data['tabs'][0]['entity.taxonomy_vocabulary.overview_form']['#link']['url'];
      $params = $url->getRouteParameters();
      if (isset($params['taxonomy_vocabulary']) && $params['taxonomy_vocabulary'] === 'airports') {
        unset($data['tabs'][0]['entity.taxonomy_vocabulary.overview_form']);
      }
      else {
        unset($data['tabs'][0]['views_view:view.airports.page_1']);
      }
    }
    else {
      unset($data['tabs'][0]['views_view:view.airports.page_1']);
    }
  }
}

This now allows the new custom filterable view and all the tabs to show up correctly for my airports vocabulary, and leave all other taxonomies as they were. But to have it working with the dynamically-generated tabs that the Devel module adds, I still needed a little more secret sauce. Potentially other modules could be doing similar (and I wanted to have Devel enabled!), so I had to go further down the rabbit hole...

Injecting a raw variable into a route

The route provided by core's taxonomy module uses this definition:

entity.taxonomy_vocabulary.overview_form:
  path: '/admin/structure/taxonomy/manage/{taxonomy_vocabulary}/overview'

See the {taxonomy_vocabulary} part? That's a wildcard that matches the particular vocab to be listed, and Devel module used it to generate its tabs. I needed that wildcard 'variable' to be defined on the route for my new views page, and for it to have the value 'airports'.

This meant altering the definition of the route with a RouteSubscriber but also then injecting the variable's value at run time, or more specifically, at the point of reacting to the request. RouteSubscribers are just event subscribers, and event subscribers can react to multiple events so I added a method to listen to the KernelEvents::REQUEST event, since that's when a route would be matched on visiting any page on a Drupal 8 site. Here's the service definition, which goes in MYMODULE.services.yml and then the whole code of the class file (which is also listed at the end of this article for download):

services:
  MYMODULE.route_subscriber:
    class: Drupal\MYMODULE\EventSubscriber\RouteSubscriber
    tags:
      - { name: 'event_subscriber' }
<?php

namespace Drupal\MYMODULE\EventSubscriber;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Cmf\Component\Routing\RouteObjectInterface;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouteCollection;

/**
* Adds the unused parameters of the regular taxonomy overview to the current
* route match object when it is the airports view so that other routes
* (specifically, for the Devel load local task) can still find them.
*/
class RouteSubscriber extends RouteSubscriberBase {

  /**
   * Adds default to the airport view to match the original vocabulary overview.
   *
   * @param \Symfony\Component\Routing\RouteCollection $collection
   *   The route collection for adding routes.
   */
  protected function alterRoutes(RouteCollection $collection) {
    if ($route = $collection->get('view.airports.page_1')) {
      $route->setDefault('taxonomy_vocabulary', 'airports');
    }
  }

  /**
   * Adds variables to the current route match object if it is the airport view.
   *
   * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
   *   The response event, which contains the current request.
   */
  public function onKernelRequest(GetResponseEvent $event) {
    $request = $event->getRequest();
    // Deliberately avoid calling \Drupal::routeMatch()->getRouteName() because
    // that will instantiate a protected route match object that will not have
    // the raw variable we want to add.
    if ($request->attributes->get(RouteObjectInterface::ROUTE_NAME) === 'view.airports.page_1') {
      if ($raw = $request->attributes->get('_raw_variables', array())) {
        $raw->add(array('taxonomy_vocabulary' => 'airports'));
        $request->attributes->set('_raw_variables', $raw);
      }
    }
  }

  /**
   * [email protected]}
   */
  public static function getSubscribedEvents() {
    $events = parent::getSubscribedEvents();

    // The route object attribute will have been set in
    // router_listener::onKernelRequest(), which has a priority of 32.
    $events[KernelEvents::REQUEST][] = array('onKernelRequest', 31);

    return $events;
  }

}

The class file should be placed at MYMODULE/src/EventSubscriber/RouteSubscriber.php.

The alterRoutes() method is the normal one for a RouteSubscriber. That's where I alter the definition of the route (if it exists, since it won't until the view is actually created!). Then I've added a onKernelRequest() method, with a priority that means it runs after the request has been matched to a route. This sets the 'taxonomy_vocabulary' raw variable on the incoming request.

Admittedly, that last part feels quite wrong! I'd love to know if there's a better way of doing all this. Perhaps I should have gone down the idea of swapping out the controller for the original route after all? But should you ever need to replace the listing for a specific vocabulary, at least you can now follow on from this. Debugging through the Drupal & Symfony internals where requests are matched to routes is not for the faint-hearted! I've learnt a lot along the way around it, which hopefully means I'll be ready for my next obscure D8 challenge!