Creating rows for django-tables2 with HTMX
I’m a bit late to the HTMX party, but we all move at different speeds. At AspirEDU, we’ve recently started switching our React applications over to HTMX. The areas we started with were on the administration side. They are purely CRUD views, with maybe some CSV uploads or downloads. The main element of each page is a table that supports sorting and filtering. We eventually landed on (after being recommended) django-tables2, HTMX, Alpine and django-widget-tweaks. We were constrained by using Bootstrap 5 for the UI of the application1.
As you can imagine, using HTMX to load a table and manage sorting/filtering is pretty straightforward. Well, it is with django-filters. The trouble was when we wanted to create a record. This is because you need to have two different responses for the POST
.
- On a valid
POST
, the server should create the record, respond with the new row for the table, and inject it2. - On an invalid
POST
, the server should re-render the form with the validation errors and swap it in on the client-side for the user to correct.
Solution
view.html
{% load render_table from django_tables2 %}
<style>
{# Fade in transition for htmx tables #}
.fade-in.htmx-added {
opacity: 0;
background-color: #fcbd08;
}
.fade-in {
opacity: 1;
background-color: unset;
transition: opacity 0.4s ease-out, background-color 0.4s linear;
}
</style>
{# Render the table, doesn't support sorting/filtering in this example #}
{% render_table %}
<div class="modal fade"
id="myModal"
tabindex="-1"
aria-hidden="true"
hx-trigger="show.bs.modal from:#myModal"
hx-swap="innerHTML"
hx-target="find .modal-body"
hx-get="{% url 'form' %}"
>
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form
hx-post="{% url 'form' %}"
hx-swap="afterbegin"
hx-target=".table-container tbody"
{# Tell the server to tell the client (in the response header) to raise an event to hide the modal #}
hx-on:htmx:config-request="event.detail.parameters.success_event = 'created'"
@created.window="bootstrap.Modal.getInstance('.modal.show').hide()"
>
{% lti_token %}
{% csrf_token %}
<div class="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="submit" class="btn btn-primary">Create</button>
</div>
</form>
</div>
</div>
form.html
{% load widget_tweaks %}
<div
id="form-{{ form.prefix }}"
{% if form.errors %}
hx-swap-oob="true"
{% endif %}
>
{% if form.non_field_errors %}
<div class="row text-danger">
<div class="col-md-2"></div>
<div class="col-12 col-md-10">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
{% for field in form.hidden_fields %}{{ field }}{% endfor %}
{% for field in form.visible_fields %}
<div class="row mb-3">
<label class="col-md-2 col-form-label">
{{ field.label }}
</label>
<div class="col-12 col-md-10">
{% if field|widget_type == "select" %}
{% render_field field|add_error_class:'is-invalid' class+='form-select' %}
{% else %}
{% render_field field|add_error_class:'is-invalid' class+='form-control' %}
{% endif %}
{% if field.help_text %}
<div class="form-text">{{ field.help_text|linebreaksbr }}</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
row.html
{% load l10n %}
{% comment %}
Copied from site-packages/django_tables2/templates/django_tables2/bootstrap5.html
{% endcomment %}
{% for row in table.paginated_rows %}
<tr {{ row.attrs.as_html }} x-show="true" x-transition>
{% for column, cell in row.items %}
<td {{ column.attrs.td.as_html }}>
{% if column.localize == None %}
{{ cell }}
{% else %}
{% if column.localize %}
{{ cell|localize }}
{% else %}
{{ cell|unlocalize }}
{% endif %}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
models.py
from django.db import models
from django.contrib.auth.models import User
class MyModel(models.Model):
created = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=256, unique=True)
views.py
from django import forms
from django.shortcuts import render
from django.views.generic.base import View
from django.views.generic.edit import FormMixin
from django_tables2 import Column, SingleTableMixin, SingleTableView, Table
from .models import MyModel
class MyModelTable(Table):
class Meta:
model = MyModel
template_name = "table.html"
fields = (
"name",
"created",
"created_by",
)
order_by = ("-created_by", "name")
# Add some bootstrap styling
attrs = {"class": "table table-striped table-bordered"}
# Specify some custom classes to include nice transitions
row_attrs = {"class": "fade-in"}
class TableView(SingleTableView):
template_name = "view.html"
model = MyModel
table_class = MyModelTable
paginate_by = 100
class CreateTableRowMixin(SingleTableMixin, FormMixin):
"""
Define a post method that supports rendering different responses
based on whether the form submission is valid or not.
When valid, the response should be a new row for the table.
When invalid, the response should be the form with the validation
errors.
"""
template_name_table_row = None
template_name_form = None
def post(self, request, *args, **kwargs):
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def form_valid(self, form):
"""If the form is valid, render the table row."""
# Move the object into the table data so it renders as the only row
self.table_data = [form.save()]
return render(
self.request,
self.template_name_table_row,
context=self.get_context_data(form=form),
)
def form_invalid(self, form):
"""If the form is invalid, render the invalid form."""
# Setting the table_data to an empty list avoids extra DB hits
self.table_data = []
response = render(
self.request,
self.template_name_form,
context=self.get_context_data(form=form),
)
return response
class RelayTriggerAfterSwapMixin:
"""
Set the HX-Trigger-After-Swap response header if the request needs it.
The purpose here is to avoid the server telling the client which specific
event should be raised. Instead, this allows the client to declaratively
specify the event that should be raised when the swap is complete.
Example template:
hx-on:htmx:config-request="event.detail.parameters.success_event = 'event_name'"
@event_name.window="bootstrap.Modal.getInstance('#Modal-ID').hide()"
"""
def form_valid(self, *args, **kwargs):
response = super().form_valid(*args, **kwargs)
if value := self.request.POST.get("success_event"):
response.headers["HX-Trigger-After-Swap"] = value
return response
class MyModelModelForm(forms.ModelForm):
class Meta:
model = MyModel
fields = ['name']
class MyModelFormView(
RelayTriggerAfterSwapMixin, CreateTableRowMixin, View
):
model = MyModel
template_name_table_row = "row.html"
template_name_form = "form.html"
form_class = MyModelModelForm
table_class = MyModelTable
prefix = "my-model"
def get(self, request, *args, **kwargs):
# Setting the table_data to an empty list avoids extra DB hits
self.table_data = []
form = self.get_form()
return render(
request,
"form.html",
context=self.get_context_data(form=form),
)
def get_form(self, form_class=None):
form = super().get_form(form_class=form_class)
form.instance.created_by = self.request.user
return form
urls.py
from django.urls import path
from . import views
urlpatterns = [
path("view/", views.MyModelTableView.as_view(), name="view"),
path("form/", views.MyModelFormView.as_view(), name="form"),
]
Alright, there’s a lot going on there. I’ll do my best to break it down.
When the initial page loads (/view/
), it renders the table and the skeleton Bootstrap modal. It does not contain the actual form yet. When the modal opens, it will make a request to the server to load the form. This also acts as a form reset when the modal is dismissed because the form is always loaded in its initial state.
When you click the create button, the hx-post="{% url 'form' %}"
attribute on the form tells HTMX to post the form. The MyModelFormView
is what processes it. Based on whether the data submitted is valid, it will either return a row or the form with the validation errors.
Starting with the valid flow, the server response is generated with the row.html
template. This is swapped into the table by the form’s hx-target=".table-container tbody"
attribute3. At this point, the modal will still be visible, and the table will have a new row at the top (with a nice yellow flash animation). To dismiss the modal, we need to run:
bootstrap.Modal.getInstance('.modal.show').hide()
This is where Alpine comes in. The following attributes on the form tell the server to include a HTMX header (HX-Trigger-After-Swap
) that will raise an event on the client. When that event is raised, Alpine captures it and runs code to dismiss any visible modal4.
<form
...
hx-on:htmx:config-request="event.detail.parameters.success_event = 'created'"
@created.window="bootstrap.Modal.getInstance('.modal.show').hide()"
>
The dismissal of the modal is managed on the server-side by the RelayTriggerAfterSwapMixin
class. This looks for the POST
data to contain an element named success_event
. If that exists and there’s a valid form submission, it will include the value in the header HX-Trigger-After-Swap
. Ideally, HTMX would support an event handler for a specific after-swap
event, but it doesn’t. So we need to tell the server to tell the client what to do. I like this approach of having it all on the form
because we can have the event trigger (hx-on:htmx:config-request
) and the event listener (@created.window
) defined in the same place.
But how did we generate the table row? The CreateTableRowMixin.form_valid
method specifically sets self.table_data = [form.save()]
. This means that when we render the table from the inherited SingleTableMixin
, it will only have a single row. The next trick is to have the template only render that <tr>
element. That’s where row.html
comes in, which is a copy and paste from the django-tables2 template.
Cool, that wasn’t so bad.
Now let’s look at the case of an invalid form submission. In this case, we want to swap the form’s content with the validation errors. To avoid manipulating the DOM on the client-side (Bootstrap is very verbose), we generate it on the server-side and swap it in. However, since our <form>
already has a swap defined, we need to use an Out Of Band Swap.
This works by having an id
attribute on the form and then only setting hx-swap-oob="true"
as an attribute when we’re handling the invalid form submission flow. When that response is processed by HTMX, it will swap this element with the element in the DOM that has that same id
.
Boom! I think that’s everything.
I’m going to wrap this up now since I’m brewing a beer5 and it’s lunchtime.
If you have questions or comments, feel free to reach out! You can find me on the Fediverse, Django Discord server or reach me via email
-
I mention Bootstrap here because our frontend implementation is tailored for integrating with it. ↩
-
We decided that any new row should be visible in the table, regardless of filtering, sorting, and pagination. ↩
-
The
table-container
class is applied via django-tables2’s template. ↩ -
Yes, it should probably find the specific modal, but I’m lazy. ↩
-
A festbier if you’re wondering. ↩