Hypermedia Systems - Using Django - Adding htmx (pt 2)
I’m working through Hypermedia Sytstems using django and htmx in public. This post continues to work through Chapter 5: HTMX Patterns. In the last post I finished the data validation parts of the chapter. If you haven’t read the prior posts, they’re linked from the introductory one. The code can be found on sr.ht. To pick up where this post does, start from the tag email_validation
. The rest of the chapter deals with paging and infinite scroll. I’ll build that out in this post.
First - Fake Some Data
For paging/infinite scroll to be interesting, there needs to be more data in the app. Since this is all contact information, the python faker library is very easy to use to generate a bunch. So I’ll add that to the virtual environment:
pip install faker
pip freeze >requirements.txt
Then I’ll create a scripts
package at the top level of my project, and create a small script that dumps 100 fake contacts into the database each time it’s run.
from faker import Factory
from contacts.models import Contact
def run():
f = Factory.create()
for _ in range(100):
fn = f.first_name()
ln = f.last_name()
tel = f.phone_number()
email = f.email()
contact, __ = Contact.objects.get_or_create(email=email)
contact.first_name = fn
contact.last_name = ln
contact.phone_number = tel
contact.save()
That can be run with the runscript
command included in django_extensions
:
python3 manage.py runscript populate_fake_contacts
Running it a handful of times gives the database enough mass for pagination to be interesting.
Paging the Traditional Way
The django documentation shows how to use the framework’s paginator in a view function. It’s easy to modify the one in contacts/views.py
to follow that model:
def contacts(request):
contacts_list = Contact.objects.all()
search_form = SearchForm()
query = request.GET.get("query", None)
if query:
search_form = SearchForm(initial={"query": query})
contacts_list = contacts_list.filter(
Q(first_name__icontains=query)
| Q(last_name__icontains=query)
| Q(email__icontains=query)
)
paginator = Paginator(contacts_list, 25)
page_obj = paginator.get_page(request.GET.get("page", None))
ctx = {
"page_obj": page_obj,
"search_form": search_form,
}
return render(request, "contacts/contact_list.html", ctx)
then change the contacts_list.html
template to iterate through page_obj
instead of a queryset and add some page controls:
{% extends "base.html" %}
{% block content %}
<form method="get" action="{% url 'contacts:contacts' %}">
{{ search_form.as_div }}
<input type="submit" value="Search">
</form>
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">« first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last »</a>
{% endif %}
</span>
</div>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Phone Number</th>
<th>Email Address</th>
<th></th>
</tr>
</thead>
<tbody>
{% for contact in page_obj %}
<tr>
<td>{{contact.first_name}}</td>
<td>{{contact.last_name}}</td>
<td>{{contact.phone_number}}</td>
<td>{{contact.email}}</td>
<td>
<a href="{% url 'contacts:edit_contact' contact.id %}">Edit</a>
<a href="{% url 'contacts:contact' contact.id %}">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?page=1">« first</a>
<a href="?page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}">next</a>
<a href="?page={{ page_obj.paginator.num_pages }}">last »</a>
{% endif %}
</span>
</div>
<div>
<a href="{% url 'contacts:add_contact' %}">Add new contact</a>
</div>
{% endblock content %}
That pagination has one bug: it doesn’t account for a filtered view of the contacts list. If you search for “jones” and have more than one page worth, the basic paginator controls there take you to an unfiltered view as soon as you click the next page link. To fix that, I need to add the query string to the template context to make it easily accessible when constructing those links:
def contacts(request):
contacts_list = Contact.objects.all()
search_form = SearchForm()
query = request.GET.get("query", "")
if query:
search_form = SearchForm(initial={"query": query})
contacts_list = contacts_list.filter(
Q(first_name__icontains=query)
| Q(last_name__icontains=query)
| Q(email__icontains=query)
)
paginator = Paginator(contacts_list, 25)
page_obj = paginator.get_page(request.GET.get("page", None))
ctx = {
"page_obj": page_obj,
"search_form": search_form,
"query": query,
}
return render(request, "contacts/contact_list.html", ctx)
Then add that to each of the pagination links in the template:
<div class="pagination">
<span class="step-links">
{% if page_obj.has_previous %}
<a href="?query={{ query }}&page=1">« first</a>
<a href="?query={{ query }}&page={{ page_obj.previous_page_number }}">previous</a>
{% endif %}
<span class="current">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}.
</span>
{% if page_obj.has_next %}
<a href="?query={{ query }}&page={{ page_obj.next_page_number }}">next</a>
<a href="?query={{ query }}&page={{ page_obj.paginator.num_pages }}">last »</a>
{% endif %}
</span>
</div>
Click to Load
With basic pagination working (and, as the book points out, working quite nicely thanks to the free ajax from hx-boost
on the body tag) it’s time to convert that to a more modern-looking “Load More” button. This only requires a minor template change to contacts_list.html
:
{% extends "base.html" %}
{% block content %}
<form method="get" action="{% url 'contacts:contacts' %}">
{{ search_form.as_div }}
<input type="submit" value="Search">
</form>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Phone Number</th>
<th>Email Address</th>
<th></th>
</tr>
</thead>
<tbody>
{% for contact in page_obj %}
<tr>
<td>{{contact.first_name}}</td>
<td>{{contact.last_name}}</td>
<td>{{contact.phone_number}}</td>
<td>{{contact.email}}</td>
<td>
<a href="{% url 'contacts:edit_contact' contact.id %}">Edit</a>
<a href="{% url 'contacts:contact' contact.id %}">View</a>
</td>
</tr>
{% endfor %}
{% if page_obj.has_next %}
<tr>
<td colspan="5" style="text-align: center">
<button hx-target="closest tr"
hx-swap="outerHTML"
hx-select="tbody > tr"
hx-get="?query={{ query }}&page={{ page_obj.next_page_number }}">
Load More
</button>
</td>
</tr>
{% endif %}
</tbody>
</table>
<div>
<a href="{% url 'contacts:add_contact' %}">Add new contact</a>
</div>
{% endblock content %}
That gets a more modern-feeling “click to load” button that, like the next and previous links, preserves any search filter.
The end of the section in the book sums it up perfectly:
Somewhat surprisingly, no server-side changes are necessary for this new functionality. This is because of the flexibility that htmx gives you with respect to how it processes server responses.
So, four attributes, and we now have a sophisticated “Click To Load” UX, via htmx.
Infinite Scroll
While I agree with the authors that infinite scroll feels a little weird on a table like this, it’s just a small change to the contact_list.html
template:
{% extends "base.html" %}
{% block content %}
<form method="get" action="{% url 'contacts:contacts' %}">
{{ search_form.as_div }}
<input type="submit" value="Search">
</form>
<table>
<thead>
<tr>
<th>First Name</th>
<th>Last Name</th>
<th>Phone Number</th>
<th>Email Address</th>
<th></th>
</tr>
</thead>
<tbody>
{% for contact in page_obj %}
<tr>
<td>{{contact.first_name}}</td>
<td>{{contact.last_name}}</td>
<td>{{contact.phone_number}}</td>
<td>{{contact.email}}</td>
<td>
<a href="{% url 'contacts:edit_contact' contact.id %}">Edit</a>
<a href="{% url 'contacts:contact' contact.id %}">View</a>
</td>
</tr>
{% endfor %}
{% if page_obj.has_next %}
<tr>
<td colspan="5" style="text-align: center">
<span hx-target="closest tr"
hx-swap="outerHTML"
hx-select="tbody > tr"
hx-trigger="revealed"
hx-get="?query={{ query }}&page={{ page_obj.next_page_number }}">
Loading more contacts...
</span>
</td>
</tr>
{% endif %}
</tbody>
</table>
<div>
<a href="{% url 'contacts:add_contact' %}">Add new contact</a>
</div>
{% endblock content %}
The changes in this part of the chapter were very satisfying. Just a minor move away from the older pagination style made the view feel very different.
The next post will start with Chapter 6.