#! code: Drupal 11: Building A “Load More” Feature For Paginating Nodes Using HTMX

drupal_htmx_examples_add_more_nodes:
path: '/drupal-htmx-examples/add-more-nodes'
defaults:
_title: 'HTMX Add More Nodes'
_controller: 'Drupaldrupal_htmx_examplesControllerHtmxAddMoreNodesController::action'
requirements:
_permission: 'access content'

Now we will flesh out the action bit by bit to introduce the functionality needed.The following query uses a fairly advanced technique where we extend the query object to use a PagerSelectExtender class as the query engine. This will automatically work out all of the information like the current page (pulled from the page query parameter in the current request), what the offsets are based on this page number, and how many items are available in the list as a whole. We are asking the database for a list of the article pages, ordered by date created in descending order.With the action method in place we can use the $this->isHtmxRequest() method to see if the current request is from a HTMX callback. The first thing we do in the action is to detect if this is an HTMX request and grab a page query parameter from the current request, which we will use to store the current page of items being requested from the controller action.Next, we need to set up some variables. This includes the number of items per page (currently set to 2), the node storage object, the cache tags needed for this list of nodes, and the initialisation of the render array.The route we create here just links the path requested with the controller class. As we are only using a single action in this example we don’t need to provide a second route for the HTMX request.

The Route

When we load the page the controller action will render the following onto the page (simplified to remove some of the markup in the article tags).All of the code contained in this article can be found in the Drupal HTMX examples project on GitHub, but here we will go through what the code does and what actions it performs to generate content.   The final step here is to add the cache tags and context values to the render array before we return it.

The Controller

To create a controller that can understand that a HTMX request has been made we need to use the DrupalCoreHtmxHtmxRequestInfoTrait trait. In order to make use of this trait we need to inject the request_stack service and create a method called gerRequest(), which will return the current request from the request stack. We need to include this trait here as we are using a single route for this controller, if we had a separate route for the HTMX requests we could do away with the trait (assuming that we didn’t need anything else that the trait provides).The HTMX element will act like a “load more” button, which will load more and more content as long as there is content to load.<article>... Article 3...</article>
<article>... Article 4...</article>
<a href="/drupal-htmx-examples/add-more-nodes" data-hx-get="/drupal-htmx-examples/add-more-nodes?page=2&amp;_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">Load more...</a>

First, let’s create the route to the controller.

The Controller Action

foreach ($results as $id => $result) {
// Load the current node.
$node = $nodeStorage->load($result->nid);

// Render the node using the teaser view mode.
$entityType = 'node';
$viewMode = 'teaser';
$viewBuilder = $this->entityTypeManager()->getViewBuilder($entityType);
$output['node-' . $node->id()] = $viewBuilder->view($node, $viewMode);

// Merge this node's cache tags with the list for this page.
$cacheTags = Cache::mergeTags($cacheTags, $node->getCacheTags());

if ($id == $pageLimit - 1 && $page * $pageLimit < $totalItems) {
// This is the last item in the list (but not the last item overall)
// so we create a link that will act as our load more element.
$output['add_more_nodes'] = [
'#type' => 'link',
'#url' => Url::fromRoute('<current>'),
'#title' => 'Load more...',
];

// Apply HTMX attributes to the link.
$htmx = new Htmx();
$htmx->get(Url::fromRoute(route_name: 'drupal_htmx_examples_add_more_nodes', options: [
'query' => [
'page' => ++$page,
'_wrapper_format' => 'drupal_htmx',
],
]))
// Setting the swap value to outerHTML means that we replace the link
// with the result of the HTMX request.
->swap('outerHTML')
->trigger('click once')
->applyTo($output['add_more_nodes']);
}
}

The following controller class is a typical template for a controller that can detect HTMX requests and will form the basis of the rest of the code.<?php

namespace Drupaldrupal_htmx_examplesController;

use DrupalCoreCacheCache;
use DrupalCoreControllerControllerBase;
use DrupalCoreDatabaseConnection;
use DrupalCoreDatabaseQueryPagerSelectExtender;
use DrupalCoreHtmxHtmx;
use DrupalCoreHtmxHtmxRequestInfoTrait;
use DrupalCoreUrl;
use SymfonyComponentHttpFoundationRequestStack;

/**
* Class to show infinite scrolling with nodes.
*/
class HtmxAddMoreNodesController extends ControllerBase {

use HtmxRequestInfoTrait;

public function __construct(
protected RequestStack $requestStack,
protected Connection $database,
) {}

/**
* {@inheritdoc}
*/
protected function getRequest() {
return $this->requestStack->getCurrentRequest();
}

public function action() {
// Controller action.
}
}

If this isn’t a HTMX request then it is the normal page request so we set the page number to 0 (ie. the first page).I thought a good place to start this off would be to look at using HTMX in a simple controller. By creating a route to a controller we can render content and then inject HTMX attributes to perform actions with the same controller.By setting up the cache in this way we can ensure that if any of the items in the list changes then we page can be regenerated without having to flush the caches of the site. This makes the page more dynamic and easier to manage. <article>... Article 1...</article>
<article>... Article 2...</article>
<article>... Article 3...</article>
<article>... Article 4...</article>
<a href="/drupal-htmx-examples/add-more-nodes" data-hx-get="/drupal-htmx-examples/add-more-nodes?page=2&amp;_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">Load more...</a>

<article>... Article 1...</article>
<article>... Article 2...</article>
<a href="/drupal-htmx-examples/add-more-nodes" data-hx-get="/drupal-htmx-examples/add-more-nodes?page=1&amp;_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">Load more...</a>

Let’s build the controller that this route points to.The response of this request will contain the following HTML.

  • data-hx-get="/drupal-htmx-examples/add-more-nodes?page=1&_wrapper_format=drupal_htmx" – This is the path that we will send a GET request to when the trigger is run. We set the page number of the next page we want, and set the wrapper format to be drupal_htmx. By setting the wrapper format we tell Drupal that the response needs to be HTMX friendly in that it will just be a plain HTML response containing the elements we are interested in.
  • data-hx-swap="outerHTML" – This attribute means that we will swap the returned HTML (remember that HTMX works using plain HTML) with this element. In other words, we remove this element and replace it with the data coming from the responce.
  • data-hx-trigger="click once" – This means that when the user clicks the link the GET request is run. Link elements get the click event “for free” since they already have a click event, but we are augmenting this with a once flag to prevent the button from being clicked twice and throwing the pagination off.

In this article I will put together a controller action to load some pages of content to display them as a list. An element containing HTMX attributes will be used to make a request back to the same controller action and generate more items in the list. These new items will be appended to the existing list along with another element containing HTMX attributes that we can use to request more items.Following on from my last article, an introduction to HTMX in Drupal, I wanted to start looking at examples of HTMX being used to power interactivity in Drupal in different ways.Once we have generated the list of items we check to make sure we are not at the end of the list and then create a link element that we will use as the HTMX class to inject the needed attributes.

The HTMX Workflow

What we end up with here is a total count of the number of items as a whole, and a list of the node IDs in the current page. // Query the database using a PagerSelectExtender query. This type of pager
// will automatically look for the query string "page" being passed to the
// response and will use this as the current pager for the query.
$query = $this->database->select('node', 'n')
->fields('n', ['nid']);
$query->join('node_field_data', 'nfd', '[nfd].[nid] = [n].[nid] AND [nfd].[langcode] = [n].[langcode]');
$query->orderBy('nfd.created', 'desc');
$query->where('n.type = :type', [':type' => 'article']);

// Add the pager to the query.
$query = $query->extend(PagerSelectExtender::class);
$query->limit($pageLimit);

// Set the count query and execute.
$query->setCountQuery($query->countQuery());
$queryResult = $query->execute();

$results = $queryResult->fetchAll();
$totalItems = $query->getCountQuery()->execute()->fetchField();

// The page limit variable is the number of items to show per page.
$pageLimit = 2;

// Load the node storage.
$nodeStorage = $this->entityTypeManager()->getStorage('node');

// Include the node_list and node_view cache tags for this list.
$cacheTags = ['node_list', 'node_view'];

// Set up our render array.
$output = [];

// Set up the cache for this request.
$output['#cache'] = [
'contexts' => [
'url:path',
'url:path.query',
],
'tags' => $cacheTags,
];

return $output;

As we set the swap style for the element to be outerHTML it means that the response will replace the link that was clicked, which will result in the following markup being rendered onto the page. if ($this->isHtmxRequest()) {
// If this is a HTMX request, so grab the page variable from the query.
$page = $this->getRequest()->query->get('page');
}
else {
// Default to the first page.
$page = 0;
}

The page variable is only used in the HTMX element to tell this endpoint what the current page of results is. It isn’t actually used in the query to find the current page (well, not directly, but we’ll come onto that).

Similar Posts