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

Search for a command to run...

Hi,
Thanks for source. I have a problem with css in my project. When i change the .table-container td:nth-child(x), table width body change but not header and filter in the template "_table_col_filter.html" .
thanks
Hello, same issue here. The rows with data in them aren't aligning with the headers. Any suggestions?
Sam Hausmann hi, for me,the problem came from the version of boostrap that I had used. boostrap 5 instead of 4
Hello Dear,
i am really impressed by this article of you and i was thinking about you sharing the source code of this article. and how to add another custom column with action buttons like edit delete and so on.
Thank You Dear Joash Xu
When it comes to displaying large amounts of data in a tabular format, tables are often the go-to choice for developers. However, traditional tables can be limiting in their functionality, especially when it comes to performing actions on multiple ro...
When building a web application, we often face the challenge of managing a diverse set of icons. Icons are integral to user interface design. However, managing these icons can quickly become cumbersome, especially when aiming for consistency and effi...

Facebook Marketing API allows developers and marketers to create and manage their Facebook ad campaigns more efficiently through a programmatic interface. This beginner's guide will introduce you to the basics of using the Facebook Marketing API with...

The article you are about to read was originally published on GlassFlow Blog. GlassFlow provides serverless and production-ready streaming data pipelines setup for the data team. Venturing into the world of data pipelines may feel daunting, especial...

Socket programming involves the transmission of data over a network using sockets. One type of data that can be transmitted over a network is binary data. Handling binary data in socket programming requires special attention, as different systems use...

In TCP Socket, handling message boundaries can be a huge problem. This is because TCP sockets are stream-oriented, meaning that data is transmitted as a continuous stream of bytes without any inherent message boundaries. Sending and receiving message...

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:

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.
htmx gives access to using AJAX directly in HTML, using attributes. It is simple and, for a package this small, quite powerful.ModelForm and works well with django-tables2.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.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 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 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 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 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:
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.
There are three templates in play here:
Alpine-js attributes for the pagination to work.
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:
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.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.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 %}
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 %}
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">«</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">»</span>
</div>
</li>
{% endblock pagination.next %}
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock pagination %}
</div>
{% endblock table-wrapper %}
This is what it looks like in the end.
Sorting ![]() | Pagination ![]() |
Filtering ![]() | Progress Bar ![]() |
All actions ![]() |
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.