Although Drupal provides a built-in comment system, I use Disqus Comments on this site for commenting. Disqus is a good-enough choice if you need a comment system with moderation and spam detection while you don’t want to store personal information; but sadly it has some performance issues. To keep the site as fast as possible, I decided that I will show the comments only if the user clicks a “Show comments” button.

In this article I will use the example of the Disqus formatter used on this page to show you how to update the page content with Drupal’s Ajax API while the content remains accessible even when the user’s browser doesn’t have (or does not use) JavaScript interpretation.

The concept

We will create a new field formatter for the field provided by the Disqus module that renders an Ajax-capable link. If the user clicks on this link, we will update the current page with the Disqus-comments by returning an Ajax response. When the user does not have Javascript or the link is opened in a new browser tab, we will provide a fallback route where the comments are shown without any additional interaction.

We need:

  • New route for every entity that has Disqus comment fields
  • A controller for the new route that returns Ajax response for Ajax requests and a renderable array for non-js requests
  • A field formatter that outputs only the ajax-capable link to the new route
  • And of course, for these, we need a module as well. I will be using the name field_tools.

The implementation

Route subscriber

For providing the needed route for entities with Disqus comment fields, we will create a route subscriber service. In this route subscriber we will use the entity_type.manager and the disqus.manager services for getting the Disqus comment fields of each entity type, so we will have to list them as arguments.

Put this into the field_tools/field_tools.services.yml file:

1
2
3
4
5
6
7
services:
  field_tools.subscriber:
    class: Drupal\field_tools\Routing\RouteSubscriber
    arguments: 
      - '@entity_type.manager'
      - '@disqus.manager'
    tags: [{ name: event_subscriber }]

Try to be generic: we want to cover as many cases as possible, so we will provide routes for any fieldable entity and not just for nodes. Keep in mind that an entity may have an unlimited number of Disqus fields. Because of these, we will use this path pattern: /[entity_type_id]/[entity_id]/[field_name]/[nojs|ajax]. For example, for node 123, this will be turned into /node/123/discus_comments/nojs if the Discus field’s machine name of that node type is discus_comments.

Let’s create the class of the route subscriber in field_tools/src/Routing/RouteSubscriber.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
<?php

namespace Drupal\field_tools\Routing;

use Drupal\Core\Entity\ContentEntityTypeInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Routing\RouteSubscriberBase;
use Drupal\Core\Routing\RoutingEvents;
use Drupal\disqus\DisqusCommentManagerInterface;
use Symfony\Component\Routing\Route;
use Symfony\Component\Routing\RouteCollection;

/**
 * Subscriber for entity routes.
 *
 * Provides route for Disqus comment fields of every fieldable entity.
 */
class RouteSubscriber extends RouteSubscriberBase {

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * The Disqus comment manager.
   *
   * @var \Drupal\disqus\DisqusCommentManagerInterface
   */
  protected $disqusCommentManager;

  /**
   * Constructs an SftRouteSubscriber object.
   *
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   * @param \Drupal\disqus\DisqusCommentManagerInterface $disqus_comment_manager
   *   The Disqus comment manager.
   */
  public function __construct(EntityTypeManagerInterface $entity_type_manager, DisqusCommentManagerInterface $disqus_comment_manager) {
    $this->entityTypeManager = $entity_type_manager;
    $this->disqusCommentManager = $disqus_comment_manager;
  }

  /**
   * {@inheritdoc}
   */
  protected function alterRoutes(RouteCollection $collection) {
    $entity_type_definitions = $this->entityTypeManager->getDefinitions();

    // Collecting content entity types which have canonical link template.
    $content_entity_type_filter = function (EntityTypeInterface $entity_type_definition) {
      return
        ($entity_type_definition instanceof ContentEntityTypeInterface) &&
        $entity_type_definition->get('field_ui_base_route');
    };
    $valid_content_entity_types = array_filter($entity_type_definitions, $content_entity_type_filter);

    foreach ($valid_content_entity_types as $entity_type_id => $entity_type) {
      foreach (array_keys($this->disqusCommentManager->getFields($entity_type_id)) as $field_name) {
        $route = new Route(
          "/$entity_type_id/{entity}/$field_name/{js}",
          [
            '_controller' => '\Drupal\field_tools\Controller\DisqusCommentsController::entityWithComments',
            '_title' => 'Comments',
            'entity_type' => $entity_type_id,
            'field_name' => $field_name,
          ],
          [
            '_permission' => 'view disqus comments',
            '_entity_access' => 'entity.view',
            '_custom_access' => '\Drupal\field_tools\Controller\DisqusCommentsController::commentAccess',
            'js' => 'nojs|ajax',
          ],
          [
            'parameters' => [
              'entity' => [
                'type' => 'entity:' . $entity_type_id,
              ],
            ],
          ]
        );
        $route_name = "entity.$entity_type_id.$field_name";
        $collection->add($route_name, $route);
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public static function getSubscribedEvents() {
    $events = parent::getSubscribedEvents();
    // Ensure to run after the entity resolver subscriber. Therefore priority -176.
    // @see \Drupal\Core\EventSubscriber\EntityRouteAlterSubscriber
    $events[RoutingEvents::ALTER] = ['onAlterRoutes', -176];
    return $events;
  }

}

Here we add a new route for every entity type that implements ContentEntityTypeInterface and has a Disqus comment field. Our event subscriber should be called after entity routes are built, so we will need a bit lower priority than EntityRouteAlterSubscriber has.

Controller

Now we will create our new route’s controller field_tools/src/Controller/DisqusCommentsController.php. We will use the entityDisqusComment method for rendering both the Ajax and the no-JS response.

Because of the previous route definition we will have four parameters on this method:

  • The $entity_type (from route defaults) that is the entity type ID (e.g. node).
  • The $entity (route slug) that will be resolved to a content entity object.
  • The $js (route slug) that will be 'ajax' on Ajax requests or 'nojs' otherwise.
  • The $field_name (from route defaults) that will be the name of a Disqus comment field.
  • You can see that the order of these parameters is irrelevant: only the name of the variable and its type hinting has to match.

In the controller we will check that the field exists on the entity and has the expected type, then we will return the renderable array of the field (based on the request type):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<?php

namespace Drupal\field_tools\Controller;

use Drupal\Component\Utility\Html;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

/**
 * Controller routines for disqus comment routes.
 */
class DisqusCommentsController extends ControllerBase {

  /**
   * Provides the disqus comments markup.
   *
   * This controller assumes that it is only invoked for users with the needed
   * permission.
   *
   * @param string $entity_type
   *   The entity type.
   * @param \Drupal\Core\Entity\EntityInterface $entity
   *   The entity object.
   * @param string $js
   *   If this is an Ajax request, it will be the string 'ajax'. Otherwise, it will
   *   be 'nojs'. This determines the response type.
   * @param string $field_name
   *   The name of the disqus comment field.
   *
   * @return array|\Drupal\Core\Ajax\AjaxResponse
   *   The field content as renderable array or as Ajax response.
   *
   * @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
   */
  public function entityWithComments($entity_type, EntityInterface $entity, $js = 'nojs', $field_name = NULL) {
    $build = [];

    if (!$entity->isNew() && !empty($field_name)) {
      $build = $entity->$field_name->view([
        'type' => 'disqus_comment',
        'label' => 'hidden',
        'settings' => [],
      ]);
    }

    switch ($js) {
      case 'ajax':
        $response = new AjaxResponse();
        $unique_id = Html::getClass(implode('--', [
          $entity_type,
          $entity->id(),
          $field_name ?? 'no-field',
        ]));
        $response->addCommand(new ReplaceCommand('[data-field-tools-disqus-id="' . $unique_id . '"]', $build));
        return $response;

      default:
        if (empty($build)) {
          throw new NotFoundHttpException();
        }
        $build['#title'] = $this->t('Comments on %title', [
          '%title' => $entity->label(),
        ]);
        return $build;
    }
  }

}

Note that the Ajax ReplaceCommand will replace the element that has the data-field-tools-disqus-id attribute with the matching value. So for node 123 with discus_comments field, the replaced element should be:

1
<element data-field-tools-disqus-id="node--123--discus-comments" />

Later on, in the field formatter, we will have to add this to the renderable array.

Besides this we will also define a custom access callback for the route also. We won’t be able to rely on the entity’s access results because the Disqus comment field can be disabled while the entity itself can be accessed or the user does not have the 'view disqus comments' permission. So we will have to check the field value (it is a boolean) and deny access if any of the next condition matches:

  • The entity cannot be viewed by the user.
  • The Disqus comment field’s value is boolean FALSE.
  • User does not have the 'view disqus comments' permission.

The first one is already verified by the _entity_access service on the route: we only have to cover the next two cases:

  • ::commentFieldAccessible will check the field value and return with boolean TRUE or FALSE.
  • The ::commentAccess method will do the same but return the equivalent AccessResultInterface object.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
 * Determines whether comments are enabled and can be accessed.
 *
 * @param \Drupal\Core\Entity\EntityInterface $entity
 *   The entity object.
 * @param string $field_name
 *   The name of the disqus comment field.
 *
 * @return bool
 *   TRUE if access is allowed, FALSE if not.
 */
protected function commentFieldAccessible(EntityInterface $entity, $field_name = NULL) {
  // Access if the field exists on the entity, the field is a disqus_comment
  // field and it's enabled.
  return !empty($field_name) &&
    $entity->hasField($field_name) &&
    $entity->$field_name->getFieldDefinition()->getType() === 'disqus_comment' &&
    !empty($entity->$field_name->status);
}

/**
 * Access check.
 *
 *
 * @param string $entity_type
 *   The entity type.
 * @param \Drupal\Core\Entity\EntityInterface $entity
 *   The entity object.
 * @param string $js
 *   If this is an Ajax request, it will be the string 'ajax'. Otherwise, it will
 *   be 'nojs'.
 * @param string $field_name
 *   The name of the Disqus comment field.
 *
 * @return \Drupal\Core\Access\AccessResultInterface
 *   An access result.
 */
public function commentAccess($entity_type, EntityInterface $entity, $js = 'nojs', $field_name = NULL) {
  $access = AccessResult::allowedIf($this->commentFieldAccessible($entity, $field_name));
  // Cache decision until entity changes.
  $access->addCacheableDependency($entity)->cachePerPermissions();

  return $access;
}

Now that all logical dependency is available, we can create our field formatter.

Field formatter

The situation is easy: we only have to create an Ajax-capable link with the data attribute that’s expected by the controller’s Ajax ReplaceCommand. We will also will check the access to the generated route: if it is denied, the formatter will return with an empty array.

field_tools/src/Plugin/Field/FieldFormatter/DisqusAjaxLinkFormatter.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<?php

namespace Drupal\field_tools\Plugin\Field\FieldFormatter;

use Drupal\Component\Utility\Html;
use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Link;
use Drupal\Core\Url;
use Drupal\disqus\Plugin\Field\FieldFormatter\DisqusFormatter;

/**
 * Provides an enhanced Disqus comment field formatter.
 *
 * @FieldFormatter(
 *   id = "disqus_comment_link",
 *   label = @Translation("Disqus Ajax link"),
 *   field_types = {
 *     "disqus_comment"
 *   }
 * )
 */
class DisqusAjaxLinkFormatter extends DisqusFormatter {

  /**
   * {@inheritdoc}
   */
  public function viewElements(FieldItemListInterface $items, $langcode) {
    $elements = [];
    $entity = $items->getEntity();
    $field_name = $items->getName();
    $unique_id = Html::getClass(implode('--', [
      $entity->getEntityTypeId(),
      $entity->id(),
      $field_name,
    ]));
    $url = Url::fromRoute("entity.{$entity->getEntityTypeId()}.$field_name", [
      'entity' => $entity->id(),
      'field_name' => $field_name,
      'js' => 'nojs',
    ], [
      'attributes' => ['class' => ['ajax-disqus-comments', 'use-ajax', 'button']],
    ]);

    // We'll have only one item, but that should be fixed in the Disqus contrib sometime...
    if ($url->access()) {
      foreach ($items as $delta => $item) {
        if (!empty($item->status)) {
          $elements[$delta] = Link::fromTextAndUrl($this->t('View comments'), $url)->toRenderable();
          $value = $items[$delta]->getValue();
          $value['_attributes']['data-field-tools-disqus-id'] = $unique_id;
          $items[$delta]->setValue($value, FALSE);
        }
      }

      if (!empty($elements)) {
        $elements['#attached']['library'][] = 'core/drupal.ajax';
      }
    }

    return $elements;
  }

}

We are done!

Optional service parameters

I usually group these kind of formatters in the same module. This way the formatters can be easily reused in other projects without repeating the code.

But in the current case we may have a problem: because we use its disqus.manager service in our route subscriber, we cannot ignore that our custom module depends on Disqus module. Fortunately, we can make this service optional: we only have to remove the service from our constructor and define a setter instead:

1
2
3
4
5
6
services:
  field_tools.subscriber:
    class: Drupal\field_tools\Routing\RouteSubscriber
    arguments: ['@entity_type.manager']
    calls:
      - [setDisqusManager, ['@?disqus.manager']]

Now implement the setter method in the route subscriber:

1
2
3
4
5
6
7
8
9
/**
 * Sets Disqus comment manager.
 *
 * @param \Drupal\disqus\DisqusCommentManagerInterface $disqus_comment_manager
 *   The Disqus comment manager.
 */
public function setDisqusManager(DisqusCommentManagerInterface $disqus_comment_manager) {
  $this->disqusCommentManager = $disqus_comment_manager;
}

Since we don’t have to create routes for our formatter if the Disqus module is unavailable, we are able to easily handle the case when the optional disqus.manager service is missing: the route subscriber won’t do anything.

We have to add this to the beginning of the RouteSubscriber::alterRoutes() method:

1
2
3
if (!$this->disqusCommentManager) {
  return;
}

Sources: