Pagination
Most UIs that display a list of user-created data will grow to the point that they should paginate their data.
On this page
Overview
With the introduction of HTMX and Alpine as preferred front-end libraries in late 2024,
and phasing out Knockout beginning in 2025, newer pages using paginated data should
rely on HTMX
+ django-tables2
for handling pagination moving forward.
HQ has previously relied on a custom pagination component that uses Knockout Components. See pagination.js for full documentation. We will cover that example here, as it's still relevant to understand older code using this component.
Pagination with HTMX and Django Tables
As covered in the HTMX + Alpine.JS guide,
HTMX works by sending partial HTML responses to asynchronous requests triggered by special hx-
attributes added to buttons, forms, links, and other elements.
To assist with rendering this partial HTML template, we use django-tables2 to paginate, sort, and format tabular data.
An Example
Here is an example of what this solution looks like. We will go over its components in the sections below. Please make sure to also review the comments within the code.
Table Definition
First, we begin with the Table Definition—ExampleFakeDataTable
in this example.
It subclasses BaseHtmxTable
, which inherits from django-tables2
's Table
.
This object defines the table's visual style, template, and sets up the column structure and typing.
The template and default styling are already taken care of by BaseHtmxTable.Meta
,
so most use cases just need to specify the columns when starting from BaseHtmxTable
.
from django.utils.translation import gettext_lazy from django_tables2 import columns from corehq.apps.hqwebapp.tables.htmx import BaseHtmxTable class ExampleFakeDataTable(BaseHtmxTable): """ This defines the columns for the table rendered by `ExamplePaginatedTableView`. The variable names for each column match the keys available in `generate_example_pagination_data` below, and the `verbose_name` specifies the name shown to the user. We are using the `BaseHtmxTable` parent class and defining its Meta class below based on `BaseHtmxTable.Meta`, as it provides some shortcuts and default styling for our use of django-tables2 with HTMX. """ class Meta(BaseHtmxTable.Meta): pass name = columns.Column( verbose_name=gettext_lazy("Name"), ) color = columns.Column( verbose_name=gettext_lazy("Color"), ) big_cat = columns.Column( verbose_name=gettext_lazy("Big Cats"), ) dob = columns.Column( verbose_name=gettext_lazy("Date of Birth"), ) app = columns.Column( verbose_name=gettext_lazy("Application"), ) date_opened = columns.Column( verbose_name=gettext_lazy("Opened On"), ) owner = columns.Column( verbose_name=gettext_lazy("Owner"), ) status = columns.Column( verbose_name=gettext_lazy("Status"), )
Table View
Next we have the Table View, ExamplePaginatedTableView
. This view renders a page of the
ExampleFakeDataTable
based on GET
parameters and a queryset
.
Since we are using HTMX, ExamplePaginatedTableView
only returns a partial template response
that is just the table itself, page navigation, and page size selection—nothing else.
The SelectablePaginatedTableView
parent class handles page size selection and saving that choice
in a cookie. It inherits from django-tables2
's classes and mixins, which handle pagination
within a given queryset.
from corehq.apps.hqwebapp.tables.pagination import SelectablePaginatedTableView from corehq.apps.styleguide.examples.bootstrap5.htmx_pagination_data import generate_example_pagination_data from corehq.apps.styleguide.examples.bootstrap5.htmx_pagination_table import ExampleFakeDataTable class ExamplePaginatedTableView(SelectablePaginatedTableView): """ This view returns a partial template of a table, along with its page controls and page size selection. Its parent classes handle pagination of a given queryset based on GET parameters in the request. This view will be fetched by the "host" `HtmxPaginationView` via an HTMX GET request. """ urlname = "styleguide_b5_paginated_table_view" table_class = ExampleFakeDataTable def get_queryset(self): return generate_example_pagination_data(100)
The queryset in this example is just an in-memory list of dicts for simplicity (seen below).
However, django-tables2
also has support for Django QuerySets. We will be adding
support for elasticsearch
queries soon.
from corehq.apps.prototype.utils import fake_data from corehq.util.quickcache import quickcache @quickcache(['num_entries']) def generate_example_pagination_data(num_entries): """ This function just generates some fake data made to look somewhat like a CommCare Case. """ rows = [] for row in range(0, num_entries): rows.append({ "name": f"{fake_data.get_first_name()} {fake_data.get_last_name()}", "color": fake_data.get_color(), "big_cat": fake_data.get_big_cat(), "dob": fake_data.get_past_date(), "app": fake_data.get_fake_app(), "status": fake_data.get_status(), "owner": fake_data.get_owner(), "date_opened": fake_data.get_past_date(months_away=[0, 1, 2, 3]), }) return rows
Host View
Lastly, we have the host view, HtmxPaginationView
. This view "hosts" the partial template
HTML returned from HTMX requests to ExamplePaginatedTableView
.
from django.utils.decorators import method_decorator from django.urls import reverse from corehq.apps.hqwebapp.decorators import use_bootstrap5 from corehq.apps.hqwebapp.views import BasePageView @method_decorator(use_bootstrap5, name='dispatch') class HtmxPaginationView(BasePageView): """ This view hosts the Django Tables `ExamplePaginatedTableView`. """ urlname = "styleguide_b5_htmx_pagination_view" template_name = "styleguide/bootstrap5/examples/htmx_pagination.html" @property def page_url(self): return reverse(self.urlname)
Its template (seen below) sets up the JavaScript context and inherits from the appropriate
CommCare HQ base template. A div
makes the initial hx-get
request to
ExamplePaginatedTableView
on page load (hx-trigger="load"
). Subsequent
requests are controlled by hx-
attributes within the table's template.
{% extends "hqwebapp/bootstrap5/base_navigation.html" %} {% load hq_shared_tags %} {% load django_tables2 %} {% load i18n %} {# This is the basic entry point for pages using HTMX and Alpine that do not need additional JavaScript: #} {% js_entry "hqwebapp/js/htmx_and_alpine" %} {% block content %} <div class="container p-5"> <h1 class="py-3 m-0"> {% trans "Simple Pagination: HTMX + Django Tables2" %} </h1> <p> {% blocktrans %} This button will trigger a refresh on the table once its HTMX request is complete: {% endblocktrans %} <br /> <button class="btn btn-primary" type="button" hx-get="{% url 'styleguide_a_hanging_view' %}" {# the attribute below triggers an `hqRefresh` event on `ExampleFakeDataTable` after the button's request is complete #} hq-hx-refresh-after="#ExampleFakeDataTable" {# we use none for `hx-swap` because the URL from `hx-get` above returns nothing in this example #} hx-swap="none" > <i class="fa fa-refresh"></i> {% trans "Refresh Table After Request" %} </button> </p> <div hx-trigger="load" {# `hx-get` asynchronously loads the table from HtmxPaginationView on page load, triggered by `hx-trigger` above #} {# The `{% querystring %}` template tag below is from django-tables2. It retrieves the GET parameters from the URL to properly initialize the table when they are present. #} hx-get="{% url "styleguide_b5_paginated_table_view" %}{% querystring %}" > <div class="htmx-indicator"> <i class="fa-solid fa-spinner fa-spin"></i> {% trans "Loading..." %} </div> </div> </div> {% endblock %} {% block modals %} {# you can either include this template or include an extension of this template to show HTMX errors to the user #} {% include "hqwebapp/htmx/error_modal.html" %} {{ block.super }} {% endblock modals %}
The host view can also interact with the table from outside the original div
that contains the partial HTML responses from ExamplePaginatedTableView
. This example demonstrates
sending a refresh event to the table using the hq-hx-refresh-after
attribute placed
on a button
. After this button's own HTMX request completes, the table is reloaded.
This example is very simple, but you can imagine chaining a refresh event to a form
(perhaps a column filter form) that triggers table refresh after submission.
Using Knockout Pagination
The best way to understand the different ways of using pagination is to see its use in HQ directly. The best sources for this are the Web Users and Mobile Workers pages.
For the Web Users page, the key points are:
- the HTML widget initializing the pagination
- the goToPage javascript function that retrieves data for a given page
- the django view that returns the pagination information
An Example
Here is a quick example simulating the pagination that should otherwise be done asynchronously as in the Web Users example above. This is just so you have a visual reference of the pagination widget.
inlinePageListOnly: true
to the pagination element's params. You can see an example of this on the
HQ Dashboard page.
<div id="js-pagination-example"> <ul class="list-group" data-bind="foreach: items" > <li class="list-group-item" data-bind="text: $data" ></li> </ul> <pagination data-apply-bindings="false" params=" goToPage: goToPage, slug: 'style-guide', perPage: perPage, onLoad: onPaginationLoad, totalItems: totalItems " ></pagination> </div>
import $ from 'jquery'; import ko from 'knockout'; import _ from 'underscore'; import 'hqwebapp/js/components/pagination'; $(function () { let paginationExample = function () { let self = {}; self.items = ko.observableArray(); self.perPage = ko.observable(); // Most of the time the widget will only deal with a page of items at a time // and goToPage will be an ajax call that will fetch some items and possibly a totalItems value self.allItems = _.map(_.range(23), function (i) { return "Item #" + (i + 1); }); self.totalItems = ko.observable(self.allItems.length); self.goToPage = function (page) { self.items(self.allItems.slice(self.perPage() * (page - 1), self.perPage() * page)); }; // Initialize with first page of data self.onPaginationLoad = function () { self.goToPage(1); }; return self; }; $("#js-pagination-example").koApplyBindings(paginationExample()); });