Hypermedia Systems - Using Django - Adding htmx (pt 1)
I’m working through Hypermedia Sytstems using django and htmx in public. This post picks up with Chapter 5: HTMX Patterns. 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 contacts-app-web1.0
.
Even though it’s possible (and reasonable!) to integrate htmx without it, I’m going to add django-htmx to my environment now, then integrate it into my django project. I’m doing this primarily because past experience tells me I like it.
First, I need to install the package into my activated venv:
pip install django-htmx
pip freeze >requirements.txt
Then add it to INSTALLED_APPS
and add the HtmxMiddleware
to MIDDLEWARE
in config/settings.py
:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_extensions",
"django_htmx",
"contacts",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django_htmx.middleware.HtmxMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
After that, I vendor htmx into my static files because I don’t want my little project to depend on a CDN:
curl -o static/htmx.min.js https://unpkg.com/htmx.org@1.9.6/dist/htmx.min.js
and I update my base template to include htmx in its <head>
tag:
<script src="{% static 'htmx.min.js' %}" defer></script>
and I’m ready to start working through Chapter 5 and adding htmx to the contacts app.
hx-boost
To start, first we change the body tag so that htmx will take charge of loading links and forms on the site by adding the hx-boost
attribute.
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
...
<script src="{% static 'htmx.min.js' %}" defer></script>
</head>
<body hx-boost="true">
...
</body>
</html>
For a local application that’s doing so little, it’s hard to spot the difference just by observing browser behavior. You can confirm in the browser’s network inspector that links are being handled via XHR now, though.
hx-delete
The next step is to make contact deletion use the HTTP DELETE
verb instead of POST
. Before starting this, I’ll need to refactor a little bit to dispatch based on HTTP verbs.
First, I change the contact details view to take a Contact
object instead of an integer key and define a new view function that receives any request against a specific contact resource:
@require_safe
def contact_detail(request, contact):
return render(request, "contacts/contact_detail.html", {"contact": contact})
def contact_handler(request, pk):
contact = get_object_or_404(Contact, pk=pk)
match request.method:
case "GET" | "HEAD":
return contact_detail(request, contact)
raise BadRequest()
I then edit contacts/urls.py
:
urlpatterns = [
path("", views.index, name="index"),
path("contacts", views.contacts, name="contacts"),
path("contacts/new", views.add_contact, name="add_contact"),
path("contacts/<int:pk>", views.contact_handler, name="contact"),
path("contacts/<int:pk>/edit", views.edit_contact, name="edit_contact"),
path("contacts/<int:pk>/delete", views.delete_contact, name="delete_contact"),
]
Update my list template to reference the new name:
...
<tbody>
{% for contact in contacts %}
<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 %}
...
And finally view the contact’s details in the browser to make sure I didn’t break the details view.
With that established, I change the delete button on the edit page from a form control to just a bare button, and use htmx attributes to cause it to send a DELETE
request to the contact’s URL:
{% extends 'base.html' %}
{% block content %}
<div>
<form method="post" action="{% url 'contacts:edit_contact' contact.id %}">
{% csrf_token %}
{{ form.as_div }}
<input type="submit" value="Save">
</form>
<div>
<button hx-delete="{% url 'contacts:contact' contact.id %}">Delete Contact</button>
<div>
<a href="{% url 'contacts:contacts' %}">Back</a>
</div>
</div>
</div>
{% endblock content %}
And attempt a deletion that shouldn’t work yet from the browser. Django blocks it before it even gets to my view function due to the missing CSRF token:
Forbidden (CSRF token missing.): /contacts/2
[12/Oct/2023 13:29:52] "DELETE /contacts/2 HTTP/1.1" 403 2506
To fix that, I add hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
to the <body>
tag on my base template. Once the CSRF token is in place, I get the expected BadRequest
error because I haven’t yet written anything to handle the DELETE
method:
django.core.exceptions.BadRequest
[12/Oct/2023 13:32:22] "DELETE /contacts/2 HTTP/1.1" 400 61999
So now I change my old delete_contact
view to take a contact object instead of a primary key and add a match for the DELETE
method:
@require_http_methods(["DELETE"])
def delete_contact(request, contact):
contact.delete()
messages.info(request, f"Contact {contact} deleted.")
return redirect("contacts:contacts")
def contact_handler(request, pk):
contact = get_object_or_404(Contact, pk=pk)
match request.method:
case "GET" | "HEAD":
return contact_detail(request, contact)
case "DELETE":
return delete_contact(request, contact)
raise BadRequest()
Now a browser test is successful, but the redirect to the contact list doesn’t yet render the way it needs to.
That is partly due to the redirect response and partly due to not having targeted the correct element on the page for replacement.
The Hypermedia Systems book says HTMX expects the server to return a response code 303
for the redirect. That means I can’t currently use the shortcut function, so the redirect will become slightly more verbose. For the time being, I decided to introduce a new trivial class in contacts/views.py
which should possibly move elsewhere if this were a larger application:
class HttpResponseSeeOther(HttpResponseRedirectBase):
status_code = 303
and use that instead:
@require_http_methods(["DELETE"])
def delete_contact(request, contact):
contact.delete()
messages.info(request, f"Contact {contact} deleted.")
return HttpResponseSeeOther(resolve_url("contacts:contacts"))
With that in place, adding hx-target="body"
to the delete button fixes the rendering, as confirmed by a quick browser test. The URL still shows http://localhost:8000/contacts/12/edit
, though. The fix for that is telling the delete button that it needs to update the window location with the hx-push-url
attribute:
<button
hx-delete="{% url 'contacts:contact' contact.id %}"
hx-target="body"
hx-push-url="true">
Delete Contact
</button>
Hypermedia Systems points out that htmx offers the hx-confirm attribute that can be used to gate requests behind a confirmation dialog. It’s easy, and works as expected:
<button
hx-delete="{% url 'contacts:contact' contact.id %}"
hx-target="body"
hx-push-url="true"
hx-confirm="Are you sure you want to delete {{ contact }}?">
Delete Contact
</button>
I’ve added it here, but really want to revisit this. Putting that kind of question above “OK” and “Cancel” is a little bit counter-intuitive, and since HTMX is using the built-in browser dialog, the text on those buttons can’t be changed. It’d be easy to make a clearer dialog at the expense of an extra round trip or at the expense of a bit of client side scripting. It’s such a common need that it seems worth finding a nice approach and sticking to it.
The other piece that’s likely worth revisiting is a point that’s well-made in the book: this is not a progressive enhancement. It won’t work for clients without javascript. Given that htmx so drastically reduces the amount of javascript, that feels less urgent for most applications that would use this than it would for, say, react.
Diversion: Server-side Data Validation
When I was building out the django application in the prior post, I did not notice that the samples in the book expected the server to validate that contacts' emails are unique. Additionally, other fields are not required. So before proceeding with chapter 5, it’s time to modify the Contact
model so that email must be unique and other fields are optional:
class Contact(models.Model):
first_name = models.CharField(max_length=255, blank=True)
last_name = models.CharField(max_length=255, blank=True)
phone_number = models.CharField(max_length=32, blank=True)
email = models.EmailField(unique=True)
def __str__(self):
return f"{self.first_name} {self.last_name} <{self.email}>"
After that, makemigrations
and migrate
bring this app’s current server-side validations into line with the book’s expectations.
While I did this, I also took a few seconds to change the message display with minimal CSS by making them show up in boxes and lining up the message level tags with missing.css’s class names.
Finally, the form fields should be re-entered to match the order described in the book:
class ContactForm(forms.ModelForm):
class Meta:
model = Contact
fields = ["email", "first_name", "last_name", "phone_number"]
Better Contact Email Validation
The book goes over changing the field type, which came for free in this case because I used an EmailField in Django’s ORM. Even with that, though, enabling additional client side validation will make the app nicer to use. Before I can get there, I neeed to change how the form is being rendered. Up to this point, I’ve been using the simplest approach to rendering forms: {{ form }} in the template. This doesn’t afford much control over individual fields, though.
While there are nice built-in mechanisms (which are getting nicer as of Django 4.2), I’m used to django-widget-tweaks, mostly because it’s an easy way to get tailwind working, so I’ll use it here. Crispy would work just as well, but requires a little more ceremony IMO, which doesn’t really pay off for this toy project.
Install into the venv:
pip install django-widget-tweaks
pip freeze >requirements.txt
Update INSTALLED_APPS
in config/settings.py
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_extensions",
"django_htmx",
"widget_tweaks",
"contacts",
]
Since using widget-tweaks makes the form more verbose, it feels worth refactoring so that a single template can be used for both creating and editing a contact. Since we put the contact into the template context, it’s easy to make the parts of the form that should differ between the two (just the post location, button text, and the delete button so far) conditional based on whether an existing contact came down.
Here’s the new form:
{% extends 'base.html' %}
{% load widget_tweaks %}
{% block content %}
<div>
<form method="post"
{% if contact %}
action="{% url 'contacts:edit_contact' contact.id %}"
{% else %}
action="{% url 'contacts:add_contact' %}"
{% endif %}
class="table crowded"
>
{% csrf_token %}
<div
{% if form.email.errors %}
class="row color bg bad"
{% else %}
class="row"
{% endif %}>
{{ form.email.label_tag }}
{{ form.email }}
{% for error in form.email.errors %}
<div class="bad color">
{{ error }}
</div>
{% endfor %}
</div>
<div
{% if form.first_name.errors %}
class="row color bg bad"
{% else %}
class="row"
{% endif %}>
{{ form.first_name.label_tag }}
{{ form.first_name }}
{% for error in form.first_name.errors %}
<div class="bad color">
{{ error }}
</div>
{% endfor %}
</div>
<div
{% if form.last_name.errors %}
class="row color bg bad"
{% else %}
class="row"
{% endif %}>
{{ form.last_name.label_tag }}
{{ form.last_name }}
{% for error in form.last_name.errors %}
<div class="bad color">
{{ error }}
</div>
{% endfor %}
</div>
<div
{% if form.phone_number.errors %}
class="row color bg bad"
{% else %}
class="row"
{% endif %}>
{{ form.phone_number.label_tag }}
{{ form.phone_number }}
{% for error in form.phone_number.errors %}
<div class="bad color">
{{ error }}
</div>
{% endfor %}
</div>
<button class="info color border bg" type="submit">
{% if contact %}
Save
{% else %}
Create
{% endif %}
</button>
</form>
{% if contact %}
<div>
<button class="bad color border bg" hx-delete="{% url 'contacts:contact' contact.id %}" hx-target="body" hx-push-url="true" hx-confirm="Are you sure you want to delete {{ contact }}?">Delete Contact</button>
<div>
<a href="{% url 'contacts:contacts' %}">Back</a>
</div>
</div>
{% endif %}
</div>
{% endblock content %}
Now I need a new view function on the server which can see if an email is valid for a new or an existing contact:
def check_email(request, pk=None):
if pk:
contact = get_object_or_404(Contact, pk=pk)
contact_form = ContactForm(request.GET, instance=contact)
else:
contact_form = ContactForm(request.GET)
if contact_form.is_valid() or "email" not in contact_form.errors:
return render(request, "error_messages.html", {"messages": []})
return render(
request, "error_messages.html", {"messages": contact_form.errors["email"]}
)
That requires a very simple template that returns an HTML fragment:
<div class="errors">
{% for message in messages %}
<div class="bad color">{{ message }}</div>
{% endfor %}
</div>
I also replace my error display in the form template for each field with:
{% include 'error_messages.html' with messages=form.email.errors %}
The view gets two different URL mappings in contacts/urls.py
:
urlpatterns = [
path("", views.index, name="index"),
path("contacts", views.contacts, name="contacts"),
path("contacts/new", views.add_contact, name="add_contact"),
path("contacts/<int:pk>", views.contact_handler, name="contact"),
path("contacts/<int:pk>/edit", views.edit_contact, name="edit_contact"),
path("contacts/<int:pk>/delete", views.delete_contact, name="delete_contact"),
path("contacts/<int:pk>/email", views.check_email, name="check_contact_email"),
path("contacts/email", views.check_email, name="check_new_email"),
]
And the email field rendering changes to:
<div
{% if form.email.errors %}
class="row color bg bad"
{% else %}
class="row"
{% endif %}>
{{ form.email.label_tag }}
{% if contact %}
{% url 'contacts:check_contact_email' contact.id as validation_url %}
{% else %}
{% url 'contacts:check_new_email' as validation_url %}
{% endif %}
{% render_field form.email hx-get=validation_url hx-trigger="change, keyup delay:200ms" hx-target="next .errors" hx-swap="outerHTML transition:true"%}
{% include 'error_messages.html' with messages=form.email.errors %}
</div>
That causes the server to validate the email as it’s typed on either the new or edit form.
Although I had initially planned for this post to cover all of Chapter 5, it’s already quite long. The next one will cover paging and infinite scroll.