Drupal 8/9 - Views with tab only on certain node types

I recently came across a situation with Drupal 8 that took me literal hours to figure out.  I thought I would share it here, for anyone else who might have the same problem, as the solution at first seemed to have no relation to the problem at all.  This works in Drupal 8.8.x, and will probably work in 9+ as well.

The Problem

I wanted to create a view which would show up as a tab ("outcomes") when you visit a "event" node.  For example, at path:  /node/%node/outcomes.

However, after creating it, the view showed up on ALL nodes.  This should have been something I could do with Contextual Filters, but no matter what I did, it wouldn't work.  The solution, it turns out, was to create a custom Views Access Plugin.

 

Step One:  Create Your View

Look at the following screenshot of my created view:

creating a view in drupal 8/9 with a menu tab

(1) We can see that I defined the path as /node/%node/outcomes.  The %node is a wildcard.  This makes it so that when visiting a node (ex: /node/1) this will be able to appear.

(2) Notice that I have it defined as a Tab with the title "outcomes"

(3) For now, I am going to leave the Access set to "permission", and the catch-all "view published content" permission.

 

The Result

Now, when you load a node, you will see the tab appears alongside the other tabs:

Drupal 8/9 - new tab appears

This is what we want.  However-- it also appears on EVERY OTHER node as well!  We only want it to appear on our "event" nodes.  If we went to a "page" or "article" node, this tab would appear there as well.  Which we do not want:

 

Step Two:  Creating A Custom Views Access Plugin

The solution to our problem is to actually tell our View that we have a special access function.  So, instead of being based on a permission (like "administer content", etc), we will need to point it to a function we will write.  This function will look at the path (called the route in Drupal 8), and figure out what node we are displaying.  If it is not an "event", then we will say that access is denied, which causes the menu tab to not display.  This will work for all users, even admin.

Let's start by creating a basic module called "mymodule".  We will need to create a mymodule.services.yml file.

Ex:

#mymodule.services.yml
services:
  mymodule_event_outcomes:
    class: '\Drupal\mymodule\Plugin\views\access\MyModuleEventOutcomesAccess'
    parent: default_plugin_manager

This is declaring that we are creating a new "service" which Drupal will be able to see once we flush our cache.  It is also stating that we intend to create a new plugin .php file.

So now, let's create our MyModuleEventOutcomesAccess.php file.  It needs to be located under mymodule/src/Plugin/views/access.  Here is the full text of that file:

<?php
/**
 * These declarations are REQUIRED in this comment...
 *
 * @ingroup views_access_plugins
 *
 * @ViewsAccess(
 *   id = "event_outcomes.access_handler",
 *   title = @Translation("MyModule Event Outcomes Access"),
  * )
 */

namespace Drupal\mymodule\Plugin\views\access;

use Drupal\views\Plugin\views\access\AccessPluginBase;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\Routing\Route;

class MyModuleEventOutcomesAccess extends AccessPluginBase {

  public function summaryTitle() {
    return $this->t('MyModule Event Outcomes Access');
  }
  
  public function access(AccountInterface $account) {
    // If we have made it here, I am OK with proceeding.
    return TRUE;
  }
  
  
  /**
   * We want to ask a custom function, which will examine the route (and the node we are looking at) to
   * decide if this should proceed.
   * @param Route $route
   */
  public function alterRouteDefinition(Route $route) {    
    $route->setRequirement('_custom_access', '\Drupal\mymodule\Controller\MyModuleController:isAllowedAccessToEventOutcomes');
    
  } 
  
  /**
   * Do not cache.
   * @return number
   */
  public function getCacheMaxAge() {
    return 0;
  }
  
  
} // class

There's a lot here, so let's step through it slowly.

  • The "summaryTitle()" method returns the title which Views will display later, when we select to use this custom plugin.
  • The "access()" function may be used to determine if the user has the correct permission to view the tab.  For example, this is where you might check if they have the "view published content" permission, and return TRUE or FALSE.  For now, let's return TRUE, meaning it will be available to everyone (for testing)
  • The "alterRouteDefinition()" method is the heart of our logic.  Here, we are defining a new _custom_access function to add to the route.  In this case, we are pointing to a controller file (see below) where we will test if the node is of type "event" or not.  Technically this could instead point to a function in your .module file, but let's still with a controller file for now.
  • The "getCacheMaxAge()" method tells Drupal not to cache our results.  If we don't do this, then we will see the same behavior for all users, for all nodes, which would completely negate our efforts here.

 

Step Three:  Create a Controller

As we discussed in step four, in the "alterRouteDefinition()" method, we described a controller as well as a method to call.  We now need to create that file and method.

Create the file:  mymodule/src/Controller/MyModuleController.php, and add this:

<?php

namespace Drupal\mymodule\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Access\AccessResult;

class MyModuleController extends ControllerBase {

  
  public function isAllowedAccessToEventOutcomes() {
    
    $bundle = FALSE;
    $route = \Drupal::routeMatch();
    $routeName = $route->getRouteName();
    $node = $route->getParameter('node');
    if ($node instanceof \Drupal\node\NodeInterface) {
      $bundle = $node->bundle();
    }    
    // the first part ensures the local task (tab) is shown on node pages of type 'event'
    // the second part makes sure the tab also displays on the view itself  
    //      (the view route is view.VIEW_MACHINE_NAME.DISPLAY_MACHINE_NAME)
    return AccessResult::allowedIf($bundle === 'event' || $routeName === 'view.outcome_view_name.page_1');
  }
  
  
  
  /**
   * Do not cache
   * @return number
   */
  public function getCacheMaxAge() {
    return 0;
  }
  
} // class
  • Remember to change the string to the name of your view, in the return statement

What this does is (finally) ask the question: "Is this node of the type 'event' ?"  In the || or condition, we also see that we are asking if the route is actually the one defined by our view.  This is important, or else the tab will disappear on the view itself.

 

Step Four:  Tell the View to Use Our New Views Access Plugin

Okay, we're almost done.  We need to clear cache, then tell our view to use our new access plugin we have created.  We do this by clicking on the "PAGE SETTINGS -> Access" option, and selecting our new plugin:

Drupal 8/9 changing to views access plugin

When you click to change the Access, you now see your new plugin is available.  Select it, then save everything.

 

The Results (At Long Last!)

If everything has gone according to plan, you will now see your tab correctly on your "event" node (or whatever node type you wanted):

drupal 8/9 showing tab

... but not on OTHER node types:

drupal 8/9 the tab is not on other node types

 

Conclusions

I know this was a lot of steps for what seems like a tiny result.  Unfortunately, this is the way things work now in Drupal 8 & 9, so we have to start getting used to it.  Luckily, once you have done it once, it's easy to set up and duplicate again for other situations and views moving forward!