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.
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.
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.
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.
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.
First, let's create the route to the controller.
The Route
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.
drupal_htmx_examples_add_more_nodes:
path: '/drupal-htmx-examples/add-more-nodes'
defaults:
_title: 'HTMX Add More Nodes'
_controller: '\Drupal\drupal_htmx_examples\Controller\HtmxAddMoreNodesController::action'
requirements:
_permission: 'access content'
When the user (assuming they have the access content permission) visits the path /drupal-htmx-examples/add-more-nodes then they will trigger the action() method in the controller.
Let's build the controller that this route points to.
The Controller
To create a controller that can understand that a HTMX request has been made we need to use the Drupal\Core\Htmx\HtmxRequestInfoTrait trait. In order to make use of this trait we need to inject the request_stack service and create a method called getRequest(), 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 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 Drupal\drupal_htmx_examples\Controller;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Database\Connection;
use Drupal\Core\Database\Query\PagerSelectExtender;
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Htmx\HtmxRequestInfoTrait;
use Drupal\Core\Url;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* 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.
}
}
Note that not all of the use statements in the header of this class are being used right now, but they will be once we have finished adding code to the action() method. I include them here for completeness and so I don't need to add in random use statements throughout the rest of the article.
Now we will flesh out the action bit by bit to introduce the functionality needed.
The Controller Action
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.
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).
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).
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 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 = [];
We are now ready to query the database for our content items.
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.
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();
We now need to loop through those results and generate our page output. This is facilitated by the entity type manager service, which we use to get the content item ready to render in the teaser view mode.
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.
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']);
}
}
As you can see here, the code that adds the HTMX attributes to this page is quite minimal. We just create a link element and then pass this to the applyTo() method of the Htmx object to add the following attributes to the element:
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 response.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.
The final step here is to add the cache tags and context values to the render array before we return it.
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.
// Set up the cache for this request.
$output['#cache'] = [
'contexts' => [
'url:path',
'url:path.query',
],
'tags' => $cacheTags,
];
return $output;
We are now ready to load the page and start requesting pages of data. Let's look into how this works.
The HTMX Workflow
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).
<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&_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">Load more...</a>
When the user clicks on the link the HTMX library will make a request to the controller, passing the page number of 1 and setting the wrapper format to drupal_htmx.
The response of this request will contain the following HTML.
<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&_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">Load more...</a>
Drupal will send back the response as a full page of HTML, with a <head> element containing any styles or JavaScript needed to render the page. The htmx-assets.js script (which is part of the Drupal HTMX implementation) will grab any assets from the page and add them to our current page, meaning that we can render our pages without any problems. In this case we don't actually have any new styles to add as they are already part of the main page, but that process is useful to know about.
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.
<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&_wrapper_format=drupal_htmx" data-hx-swap="outerHTML ignoreTitle:true" data-hx-trigger="click once">Load more...</a>
The user can now click the link at the bottom of the page and request page 2 from our pagination controller. They can keep doing this until the site runs out of nodes, or the user runs out of patience.
Add new comment