Column Filter Table with htmx, Alpine-js, and Django

·

9 min read

Have you ever needed to create a table where you need to filter between multiple columns? Maybe you've tried using a standard table with a single search/filter input field, but it didn't quite work the way you wanted.

This article will show how I build a table with a multi-column filter. The table I am going to implement looks like this:

Image by Author

As a side note, this article builds on the previous article, where I create a simple table using Django and htmx; I won't revisit how htmx work. Now, let's get started.

The Packages Used For This Project

  1. htmx. htmx gives access to using AJAX directly in HTML, using attributes. It is simple and, for a package this small, quite powerful.
  2. django-tables2. This Django app lets you define tables like you define Django models. It can automatically generate a table based on a Django model. It supports pagination, column-based table sorting, custom column functionality via subclassing, and many other features.
  3. django-filter. I use this package for the filtering functionality. It has APIs similar to Django's ModelForm and works well with django-tables2.
  4. django-htmx. For htmx to work, Django view needs to tell which request is made using htmx and which is not. It has a middleware that adds htmx attribute to a request object.
  5. Alpine-js. This project requires a more complex behavior, htmx alone will not be enough. Specifically, the table needs to make sorting, pagination, and filtering work together nicely. This is where Alpine-js comes in. This small javascript package allows me to store data and trigger action based on changes happening to a variable.

The Model

The model I use for this project is as follows:

# products/models.py
from django.db import models

class Product(models.Model):
    class Status(models.IntegerChoices):
        ACTIVE = 1, "Active"
        INACTIVE = 2, "Inactive"
        ARCHIVED = 3, "Archived"

    name = models.CharField(max_length=255)
    category = models.CharField(max_length=255)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    cost = models.DecimalField(max_digits=10, decimal_places=2)
    status = models.PositiveSmallIntegerField(choices=Status.choices)

    def __str__(self):
        return self.name

The Table

The table is straightforward to set up. The show_header is set to False because I put the table header, which includes columns name, in a separate template. I will discuss this further in the template section below.

# products/tables.py
import django_tables2 as tables
from products.models import Product

class ProductHTMxMultiColumnTable(tables.Table):
    class Meta:
        model = Product
        show_header = False
        template_name = "tables/bootstrap_col_filter.html"

The Filter

The filter I use is as follows:

# products/filters.py
from decimal import Decimal

from django.db.models import Q
from django.forms import TextInput
import django_filters

from products.models import Product


class ProductFilter(django_filters.FilterSet):
    id = django_filters.NumberFilter(label="")
    name = django_filters.CharFilter(label="", lookup_expr="istartswith")
    category = django_filters.CharFilter(label="", lookup_expr="istartswith")
    price = django_filters.NumberFilter(label="", method="filter_decimal")
    cost = django_filters.NumberFilter(label="", method="filter_decimal")
    status = django_filters.ChoiceFilter(label="", choices=Product.Status.choices)

    class Meta:
        model = Product
        fields = ["id", "name", "category", "price", "cost", "status"]

    def filter_decimal(self, queryset, name, value):
        # For price and cost, filter based on
        # the following property:
        # value <= result < floor(value) + 1

        lower_bound = "__".join([name, "gte"])
        upper_bound = "__".join([name, "lt"])

        upper_value = math.floor(value) + Decimal(1)

        return queryset.filter(**{lower_bound: value,
                                  upper_bound: upper_value})

The View

The view will send out a full page when the request is not made by htmx and send partial results when the request is made by htmx.

# products/views.py
from django.core.paginator import Paginator, EmptyPage

from django_tables2 import SingleTableMixin
from django_filters.views import FilterView

from products.models import Product
from products.tables import ProductHTMxMultiColumnTable
from products.filters import ProductFilter


class CustomPaginator(Paginator):
    def validate_number(self, number):
        try:
            return super().validate_number(number)
        except EmptyPage:
            if int(number) > 1:
                # return the last page
                return self.num_pages
            elif int(number) < 1:
                # return the first page
                return 1
            else:
                raise


class ProductHTMxMultiColumTableView(SingleTableMixin, FilterView):
    table_class = ProductHTMxMultiColumnTable
    queryset = Product.objects.all()
    filterset_class = ProductFilter
    paginate_by = 10
    paginator_class = CustomPaginator

    def get_template_names(self):
        if self.request.htmx:
            template_name = "products/product_table_partial.html"
        else:
            template_name = "products/product_table_col_filter.html"

        return template_name

I also add a CustomPaginator to handle an edge case in pagination. Specifically, this is to handle a case where for instance:

  1. A user selects a valid page 8
  2. The user then uses the filter
  3. Because the result of the filter is less than 8 pages long, Django then raises 404.

Here I can choose to use a different template when Django raises 404. But I am not sure if it is desirable. The template will not have any pagination information. I think this will confuse users since they would not know that the empty page results from an invalid page and filtering, rather than just filtering.

So, when a user filter and the page is invalid, I simply return either the last page or the first page of the filtering result.

The Template

There are three templates in play here:

  1. A template to render the whole page. Where I generate the page with HTML header and body and the table.
  2. A template to render only the table. When we perform some action like sorting or filtering, we only need to render the partial page.
  3. Custom table template to override the default. I need to remove the table definition. And add some Alpine-js attributes for the pagination to work.

Image by Author

Template to Render the Entire Page

For this project, I put the table header and filter form on this page. Here I define two extra hidden input fields. The sort and page input. When a user performs an action, be it sorting, jumping between pages, or filtering, I will submit all three pieces of information back to the server.

To make this happens, I need to use Alpine-js. Here is the summary of how it works:

  1. When a user sorts a table by clicking on a column header, the value of the sort_by that I define in the x-data gets updated via toggle function. This also changes the sort input value because this is linked through the use of x-model. To toggle between the ascending or descending arrow, I use :class in the column header.
  2. I use x-init to watch if the input value for sort has changed or not. When the value changes, I send out a custom event called sort-initiated.
  3. Since I have specified that custom event in the hx-trigger attribute, when this custom event is made, htmx will submit the whole form, which contains information on the filter, the sorting, and the pagination.
{# templates/products/product_table_col_filter #}
{% extends "base.html" %}

{% load render_table from django_tables2 %}
{% load i18n %}
{% load django_tables2 %}
{% load crispy_forms_tags %}

{% block multi_htmx_table %}active{% endblock %}

{% block main %}
<h1>Product table</h1>

<div id="table-container" class="table-container">

    <form class="form-inline"
          hx-get="{% url 'tables:products_htmx_multicol' %}"
          hx-target=".table-body-container"
          hx-trigger="input, select, sort-initiated, pagination-initiated"
          hx-swap="outerHTML"
          hx-indicator=".progress"
          x-data="{ sort_by: '', page_by: 1 }">

        <input type="hidden" name="sort" x-ref="sort_input" x-model="sort_by"
                x-init="$watch('sort_by',
                        () => $refs.sort_input.dispatchEvent(
                                new Event('sort-initiated', { bubbles: true })))">

        <input type="hidden" name="page" x-ref="paginate_input" x-model="page_by"
               x-init="$watch('page_by',
                        () => $refs.paginate_input.dispatchEvent(
                                new Event('pagination-initiated', { bubbles: true })))" >

        <table {% render_attrs table.attrs class="table multi-col-header" %}>
            <thead {{ table.attrs.thead.as_html }}>
                <tr>
                    {% for column in table.columns %}
                    <th {{ column.attrs.th.as_html }}
                        x-data="{ col_name: '{{ column.order_by_alias }}',
                                  toggle(event) {
                                    this.col_name = this.col_name.startsWith('-') ? this.col_name.substring(1) : ('-' + this.col_name);
                                    sort_by = this.col_name;}}"
                        @click="toggle()"
                        :class="sort_by !== '' ? (sort_by === col_name ? (sort_by.startsWith('-') ? 'desc' : 'asc') : '') : ''"
                        style="cursor: pointer;">
                        {{ column.header }}
                    </th>
                    {% endfor %}
                </tr>
                <tr>
                    {% for field in filter.form %}
                    <td>{{ field|as_crispy_field }}</td>
                    {% endfor %}
                </tr>
            </thead>
        </table>

        <div class="progress">
            <div class="indeterminate"></div>
        </div>

        {% render_table table %}
    </form>
</div>
{% endblock %}

Template to Render Table

Here we only render the table content, no HTML header, no HTML body. Just the content.

{# templates/products/product_table_partial.html #}
{% load render_table from django_tables2 %}

{% render_table table %}

Template to Override the Default Table

This template overrides the default table from django-tables2. On this template, I render the table body and pagination information. The pagination works like sorting. When a user triggers any pagination action, the page_by variable in x-data gets updated, which changes the value of page input because it is linked via x-model. Which creates a custom event pagination-initiated. Which then tells htmx to send the complete form with all sorting, pagination, and filtering information back to the server.

{# templates/tables/bootstrap_col_filter.html #}
{% load django_tables2 %}
{% load i18n %}

{% block table-wrapper %}
    <div class="table-body-container">
        {% block table %}
            <table {% render_attrs table.attrs class="table" %}>
                {% block table.thead %}{% endblock table.thead %}

                {% block table.tbody %}
                    <tbody id="body-target" {{ table.attrs.tbody.as_html }}>
                    {% for row in table.paginated_rows %}
                        {% block table.tbody.row %}
                            <tr {{ row.attrs.as_html }}>
                                {% 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>
                        {% endblock table.tbody.row %}
                    {% empty %}
                        {% if table.empty_text %}
                            {% block table.tbody.empty_text %}
                                <tr><td colspan="{{ table.columns|length }}">{{ table.empty_text }}</td></tr>
                            {% endblock table.tbody.empty_text %}
                        {% endif %}
                    {% endfor %}
                    </tbody>
                {% endblock table.tbody %}
                {% block table.tfoot %}
                    {% if table.has_footer %}
                        <tfoot {{ table.attrs.tfoot.as_html }}>
                        <tr>
                            {% for column in table.columns %}
                                <td {{ column.attrs.tf.as_html }}>{{ column.footer }}</td>
                            {% endfor %}
                        </tr>
                        </tfoot>
                    {% endif %}
                {% endblock table.tfoot %}
            </table>
        {% endblock table %}

        {% block pagination %}
            {% if table.page and table.paginator.num_pages > 1 %}
                <nav aria-label="Table navigation">
            <ul class="pagination justify-content-end">
                {% if table.page.has_previous %}
                {% block pagination.previous %}
                <li class="previous page-item" role="button">
                    <div @click="page_by = {{table.page.previous_page_number}}"
                         class="page-link">
                        <span aria-hidden="true">&laquo;</span>
                        {% trans 'previous' %}
                    </div>
                </li>
                {% endblock pagination.previous %}
                {% endif %}
                {% if table.page.has_previous or table.page.has_next %}
                {% block pagination.range %}
                {% for p in table.page|table_page_range:table.paginator %}
                <li class="page-item{% if table.page.number == p %} active{% endif %}" role="button">
                    <div class="page-link" {% if p != '...' %}@click="page_by={{p}}"{% endif %}>
                        {{ p }}
                    </div>
                </li>
                {% endfor %}
                {% endblock pagination.range %}
                {% endif %}
                {% if table.page.has_next %}
                {% block pagination.next %}
                <li class="next page-item user-select" role="button">
                    <div @click="page_by = {{table.page.next_page_number}}" class="page-link">
                        {% trans 'next' %}
                        <span aria-hidden="true">&raquo;</span>
                    </div>
                </li>
                {% endblock pagination.next %}
                {% endif %}
            </ul>
        </nav>
            {% endif %}
        {% endblock pagination %}
    </div>
{% endblock table-wrapper %}

The Result

This is what it looks like in the end.

Sorting SortingPagination Pagination
Filtering FilterProgress Bar Progress
All actions All actions

Summary

In this article, I explore how to use Django, htmx to build a slightly more complex table. For a more complex behavior, htmx alone is not enough. This is where I use Alpine-js. With Alpine-js I built a table with a multi-column filtering feature. It is pretty straightforward to make. I hope you find this article helpful.

In the future, I will explore how to build an even more complex table with features like complex filters, column selection, and saved filters.