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.

  1. On a valid POST, the server should create the record, respond with the new row for the table, and inject it2.
  2. 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

  1. I mention Bootstrap here because our frontend implementation is tailored for integrating with it. 

  2. We decided that any new row should be visible in the table, regardless of filtering, sorting, and pagination. 

  3. The table-container class is applied via django-tables2’s template. 

  4. Yes, it should probably find the specific modal, but I’m lazy. 

  5. A festbier if you’re wondering.