Drupal 8: Getting the node/add page to list content types alphabetically

Drupal, much like PHP itself, is rife with the internal inconsistencies that come from people solving similar problems in slightly different ways. These inconsistencies aren't always at the level of a "bug" or a "regression", but sometimes you wish you could change it. This is one of those times.

The issue

In Drupal 8, the list of node bundles (known as "content types") is ordered alphabetically by the bundle's machine name on the create content (node/add) page, and is ordered alphabetically by the bundle's label on the manage content types (admin/structure/types) page.

Side-by-side comparison

In Drupal 7, both of these lists are ordered by the human-readable label, not the machine name. This is the functionalty that I want to add back to my Drupal 8 site.

The approach

The "quick and dirty" way to change this would be to find the code that manages the listing in D8 core, and change it. DO NOT DO THIS. Nothing good can come of it. The best result is that you will accidently overwrite your customizations when the code is updated (you do keep your codebase updated, right?), never mind the inadvertent side effects and security holes that can also result.

The "correct" way to change this would be to search through the Drupal project issue queue, to see if anyone else has reported this. I tried a few searches, with mixed results: 3 pages of issues to plow through (I looked at the titles), or one issue that isn't what I'm looking for. So at this point, I should really make a new issue describing what I feel the problem is.

However, there are a few drawbacks to going down this official path. Core maintainers might not view this as an issue important enough to fix (or indeed, that it's even an issue at all). Or, the issue could get lost in the list of all the other little things that the community wants to fix in the 8.0.x branch, and the impending freeze on the 8.1.x feature branch. This process also takes some time, and I want to implement my fix now.

Diving into the code

Thanks to using Drupal Console to create custom entities, I've learned that all entity types extend ControllerBase for their {entity_type}/add pages, and ConfigEntityListBuilder for their admin/structure/{types} pages. Drupal Console's generated custom entity code also follows core's class naming pattern, so I can deduce that the classes I want to investigate are NodeController and NodeTypeListBuilder.

From following the chain of class inheritance, I find that ConfigEntityBase::sort is used for sorting the list on admin/structure/types. Unfortunately, NodeController::addPage doesn't call a sorting function after loading the list of content types.

Next I set about writing the change if I were to "fix" it in core. There's probably some way to get ConfigEntityBase loaded so I can use its sort function, but I don't know enough OOP to do that (which I determined after a half-hour of krumo()- and kint()-ing in addPage()). So then I started doing some inelegant looping before the access-checking foreach... only to discover that had I actually read the code, I would only need to change two lines:

diff --git a/core/modules/node/src/Controller/NodeController.php b/core/modules/node/src/Controller/NodeController.php
index 3c534f2..1bf5cd9 100644
--- a/core/modules/node/src/Controller/NodeController.php
+++ b/core/modules/node/src/Controller/NodeController.php
@@ -85,10 +85,11 @@ public function addPage() {
     foreach ($this->entityManager()->getStorage('node_type')->loadMultiple() as $type) {
       $access = $this->entityManager()->getAccessControlHandler('node')->createAccess($type->id(), NULL, [], TRUE);
       if ($access->isAllowed()) {
-        $content[$type->id()] = $type;
+        $content[$type->label()] = $type;
       }
       $this->renderer->addCacheableDependency($build, $access);
     }
+    ksort($content);

     // Bypass the node/add listing if only one content type is available.
     if (count($content) == 1) {

Now, I can hear many of you thinking, "Now wait a minute! You just told us not to hack core!" -- and that's true. If was to leave this change in core's NodeController, that would be wrong. But it's taken me a long time to learn that looking through the code, poking at it and changing it, and seeing what it does with a few tweaks, is not wrong -- with the caveat that whatever the change needs to be, you proceed with the next proper step of implementing that change as an override.

A quick override, the correct way

Based on the investigations described above, I can quickly build out my custom module example_controlleroverride's new class to use in place of NodeController, creating the following file as {drupal8_root}/modules/example_controlleroverride/src/Controller/ExampleControlleroverrideNodeController.php:

/**
 * @file
 * Contains \Drupal\example_controlleroverride\Controller\ExampleControlleroverrideNodeController.
 */

namespace Drupal\example_controlleroverride\Controller;

use \Drupal\node\Controller\NodeController;

/**
 * Returns my customizations for Node routes.
 */
class ExampleControlleroverrideNodeController extends NodeController {

  /**
   * {@inheritdoc}
   */
  public function addPage() {
    $build = [
      '#theme' => 'node_add_list',
      '#cache' => [
        'tags' => $this->entityManager()->getDefinition('node_type')->getListCacheTags(),
      ],
    ];

    $content = array();

    // Only use node types the user has access to.
    foreach ($this->entityManager()->getStorage('node_type')->loadMultiple() as $type) {
      $access = $this->entityManager()->getAccessControlHandler('node')->createAccess($type->id(), NULL, [], TRUE);
      if ($access->isAllowed()) {
        $content[$type->label()] = $type;
      }
      ksort($content);
      $this->renderer->addCacheableDependency($build, $access);
    }

    // Bypass the node/add listing if only one content type is available.
    if (count($content) == 1) {
      $type = array_shift($content);
      return $this->redirect('node.add', array('node_type' => $type->id()));
    }

    $build['#content'] = $content;

    return $build;
  }

}

Note: If needed, take a look at this section of an earlier post on the steps you'll need to take to set up a custom module.

I've got my class set up, now what? Well, now I need to tell Drupal about it. To the intarwebs search engines!

First, searching within the Drupal 8 codebase for either "node/add" and "NodeController" shows me that they are associated in node.routing.yml. This leads to the question, how do I alter a route?

Thanks to an awesome docs page, we have Altering existing routes and adding new routes based on dynamic ones (altering existing routes is the part we're looking for). Following the guidelines there, I've set up a route subscriber, then registered it in example_controlleroverride.services.yml:

services:
  example_controlleroverride.route_subscriber:
    class: Drupal\example_controlleroverride\Routing\RouteSubscriber
    tags:
      - { name: event_subscriber }

Here's where I initially hit a snag. I know my subscriber works, because I'm able to change the path alias of the destination as provided in the handbook example. But what method should I use in place of $route->setPath() in order to the controller? I dug into the documented code of Symfony\Component\Routing\RouteCollection (see that use statement up top?), and eventually figured out what I need to use with $route->setDefaults() in order to get it all working:

/**
 * @file
 * Contains \Drupal\example_controlleroverride\Routing\RouteSubscriber.
 */

namespace Drupal\example_controlleroverride\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Symfony\Component\Routing\RouteCollection;

/**
 * Listens to the dynamic route events.
 */
class RouteSubscriber extends RouteSubscriberBase {

  /**
   * {@inheritdoc}
   */
  public function alterRoutes(RouteCollection $collection) {
    // Change controller class for "node/add".
    if ($route = $collection->get('node.add_page')) {
      $route->setDefaults(array(
        '_title' => 'Add content',
        '_controller' => '\Drupal\example_controlleroverride\Controller\ExampleControlovverideNodeController::addPage'
      ));
    }
  }

}

Final result The node types are listed alphabetically by their human-readable name.

Comments

Are you really sure, that the inconsistency you found doesn't qualify as a bug or regression? Because I'm pretty sure it is one. It's nowhere a critical bug, but it surely seems like a major UX WTF. The thing is, you can actually fix this. That's how open source works. Just file an issue on Drupal.org.

But it's even better than that. While you were trying to learn the new D8 code and documented your journey, you actually wrote most of the patch to fix that issue. That patch above,  the one with those couple of lines you temporarily changed/added in /core/modules/node/src/Controller/NodeController.php, seems exactly like the patch that could fix this issue. It might be an initial patch only, I can't tell, but it's definitely a good starts.

Oh, before I forget, also thanks for the interesting and instructive read, I bookmarked your blog :)