Django Tables and htmx: a match well-made

Tables are one of the central constructs of HTML and virtually every system will have some tables, often many. But creating tables, while straightforward, can be burdensome, particularly when adding more advanced features like pagination, sorting, filtering, and table actions.

There’s also the matter of styling tables and keeping them consistent throughout the user interface which can be a burden if not done right from the start.

While Django provides a solid foundation for building applications, it does not provide much support for HTML tables. Django has first class tools for HTML forms that make it easy to construct forms and process their data but the best it has to offer for tables are the basic template tags and filters.

Let's take one of the tables in Task Badger as an example:

final.gif

We won't fully re-create this table but by the end you'll see how it can be done, and hopefully you'll be inspired to try it yourself.

Django Tables2

First things first - we don't want to have to write HTML for our tables. We want to define them in Python and have Django render them for us.

This is where django-tables2 enters the picture. It provides excellent classes and utilities for defining tables in Python in a similar fashion to Django forms.

Let's have a look at a quick example. Here's our model:

# tasks/models.py

from django.db import models


class TaskStatus(models.TextChoices):
    pending = "pending", _("Pending")
    processing = "processing", _("Processing")
    success = "success", _("Success")
    error = "error", _("Error")


class Task(models.Model):
    project = models.ForeignKey("projects.Project", on_delete=models.CASCADE)
    name = models.CharField(max_length=255)
    status = models.IntegerField(choices=TaskStatus.choices, default=TaskStatus.pending)
    value = models.PositiveBigIntegerField(null=True, help_text=_("Current progress value."))

    def get_absolute_url(self):
        """This is used by the table to generate a link to the task detail page."""
        return reverse("tasks:task_detail", kwargs={"pk": self.pk})

With django-tables2 we can create a class which represents the table configuration and will be used to render the table in the template along with pagination and sorting controls:

# tasks/tables.py

from django_tables2 import tables
from .models import Task


class TaskTable(tables.Table):
    project = tables.Column(linkify=True)

    class Meta:
        model = Task
        fields = ("project", "name", "status", "value")

In the view we need to instantiate the table and pass it to the template:

# tasks/views.py
from django_tables2 import RequestConfig
from .models import Task
from .tables import TaskTable


def task_list(request):
    table = TaskTable(Task.objects.all())
    RequestConfig(request, paginate={"per_page": 5}).configure(table)
    return render(request, "tasks/task_list.html", {"table": table})

And the template is as simple as this:

# templates/tasks/task_list.html

{% extends "base.html" %}
{% load render_table from django_tables2 %}
{% block content %}
    <h1>Tasks</h1>
    {% render_table table %}
{% endblock %}

It's that simple. We now have a fully rendered table with pagination and sorting without having to write single line of HTML.

basic_table.png

As cools as that is, it's not the end of the story. We can do a lot more with tables but for now we're going to focus on making them load dynamically.

Enter htmx

htmx is a small JavaScript library that allows you to add dynamic behavior to your HTML pages without writing any JavaScript. It does this by adding a few attributes to your HTML elements that tell htmx what to do when an event occurs. For example, if you want to load some HTML from the server and replace the contents of a div element with it, you can do this:


<div hx-get="/some/url">This will be replaced.</div>

When the page loads, htmx will make a GET request to /some/url and replace the contents of the div with the response. It's that simple.

Now let's see how we can use htmx to make our tables load dynamically. There are a few steps involved:

  1. Add htmx to the page.
  2. Create a separate view for the table.
  3. Update the main template to load the table dynamically.

To keep this simple we'll just use the pre-build htmx library from unpkg.com:


<script src="https://unpkg.com/htmx.org@x.x.x"></script>

A table with a view

Instead of using a function based view as before we're going to use one of the generic views provided by django-tables2 just to have some variety:

# tasks/views.py

from django_tables2 import SingleTableView
from .models import Task
from .tables import TaskTable


class TaskTable(SingleTableView):
    model = Task
    paginate_by = 5
    table_class = TaskTable
    template_name = "single_table.html"

Since we only want to render the table we're going to create a generic template which we can reuse for all our tables in the future:

# templates/single_table.html

{% load render_table from django_tables2 %}
{% render_table table %}

Dynamic load

Now we have our view and htmx loaded on the page, so we can update our main table view template:

# templates/task_list.html

{% extends "base.html" %}
{% load django_tables2 %}
{% block content %}
<h1>Tasks</h1>
<div hx-get="{% url 'tasks:task_table' %}" hx-trigger="load" hx-swap="outerHTML">
  Loading...
</div>
{% endblock %}

As soon as the page loads, htmx will make a request to the URL and replace the contents of the div with the response, which in this case is our table.

And we're done! Well, not quite, what about the pagination and sorting? Let's fix that now.

htmx sorting pagination

The pagination controls are rendered by django-tables2 as a separate element and credit to the authors, they've placed it in a separate template block which makes it easy to extend.

Let's create a new table template that extends the one provided:

# templates/htmx_table.html

{% extends 'django_tables2/bootstrap5.html' %}
{% load i18n %}
{% load django_tables2 %}

{% block table.thead %}
{% if table.show_header %}
<thead {{ table.attrs.thead.as_html }}>
<tr>
  {% for column in table.columns %}
  <th {{ column.attrs.th.as_html }} scope="col"
      {% if column.orderable %}
      hx-get="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}"
      hx-trigger="click"
      hx-target="div.table-container"
      hx-swap="outerHTML"
      style="cursor: pointer;"
      {% endif %}>
    {{ column.header }}
  </th>
  {% endfor %}
</tr>
</thead>
{% endif %}
{% endblock table.thead %}

{% block pagination %}
<div class="pagination">
  {% if table.page and table.paginator.num_pages > 1 %}
  <div class="join">
    <a class="join-item btn"
       {% if table.page.has_previous %}
       hx-get="{{ request.path_info }}{% querystring table.prefixed_page_field=table.page.previous_page_number %}"
       hx-trigger="click"
       hx-target="div.table-container"
       hx-swap="outerHTML"
       {% else %}disabled{% endif %}>
      <span aria-hidden="true">&laquo;</span>
    </a>
    <a class="join-item btn">
      {% with current_position=table.page.end_index total=table.page.paginator.count %}
      {% blocktranslate %}
      {{ current_position }} of {{ total }}
      {% endblocktranslate %}
      {% endwith %}
    </a>
    <a class="join-item btn"
       {% if table.page.has_next %}
       hx-get="{{ request.path_info }}{% querystring table.prefixed_page_field=table.page.next_page_number %}"
       hx-trigger="click"
       hx-target="div.table-container"
       hx-swap="outerHTML"
       {% else %}disabled{% endif %}>
      <span aria-hidden="true">&raquo;</span>
    </a>
  </div>
</div>
{% endif %}
{% endblock pagination %}

You may notice I've also changed the pagination controls a bit to make them look how I want them to. And now our table is fully dynamic!

Bonus 1: Custom Columns

As I mentioned before, there's a lot more you can do with django-tables2. One of the things I commonly end up doing is providing a custom template for a specific column. For example, in Task Badger we have a column that shows the current progress of a task. It's a simple integer field but we want to show it as a progress bar. Here's how we can do that:

# tasks/tables.py

class TaskTable(tables.Table):
    status = columns.TemplateColumn(template_name="components/task_status_column.html")

    class Meta:
        model = Task
        fields = ("project", "name", "status")

Now that template will be rendered for each row in the table and the contents will be used for that column. When django-tables2 renders the template it will pass the current object as record to the template (along with some other context).

Here's what a template to render the progress bar looks like:

# templates/components/task_status_column.html

<div>
  <progress class="progress progress-{{ record.status_enum.css_suffix }}"
            value="{{ record.value }}"
            max="{{ record.value_max }}"></progress>
</div>

We're using the task status to set the color and the task value to set the progress.

Bonus 2: Filtering

Another common feature is to filter or search the table. django-tables2 doesn't provide any built-in support for this but it's easy to add. We could use django-filters which works well with django-tables2 but for this example we're going to do the filtering ourselves.

We do however want to use htmx to avoid loading the page when the user submits the form. Here's how we can do that:

{# Search form #}

<input class="input input-bordered join-item" type="search"
       name="search" placeholder="Search..."
       hx-get="{% url 'table_htmx' %}"
       hx-trigger="keyup delay:500ms, changed, search"
       hx-target="div.table-container"
       hx-swap="outerHTML"
       hx-indicator=".htmx-indicator">
<i class="mx-2 htmx-indicator fa fa-spinner fa-pulse"></i>

We're using the hx-trigger attribute to tell htmx to make a request to the URL when the user types in the search box. We're also using the hx-indicator attribute to tell htmx to show a spinner while the request is being made.

We're also using hx-target again to tell htmx where to put the contents it gets back from the request.

Voilà! We now have a fully dynamic table with sorting, pagination, AND filtering

Summary

In this post I've demonstrated some of the features of django-tables2 and how to use it with htmx to create dynamic tables. I've also shown how to customize the pagination controls and how to use custom templates for columns.

Notice how little JavaScript we had to write to make this work and how little HTML we had to write to create the table.

Subscribe for Updates

Sign up to get notified when we publish new articles.

We don't spam and you can unsubscribe anytime.