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.
The Packages Used For This Project
- htmx.
htmx
gives access to using AJAX directly in HTML, using attributes. It is simple and, for a package this small, quite powerful. - 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.
- django-filter. I use this package for the filtering functionality. It has APIs similar to Django's
ModelForm
and works well withdjango-tables2
. - django-htmx. For
htmx
to work, Django view needs to tell which request is made usinghtmx
and which is not. It has a middleware that addshtmx
attribute to a request object. - 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 whereAlpine-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:
- A user selects a valid page 8
- The user then uses the filter
- 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:
- A template to render the whole page. Where I generate the page with HTML header and body and the table.
- A template to render only the table. When we perform some action like sorting or filtering, we only need to render the partial page.
- 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.
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:
- When a user sorts a table by clicking on a column header, the value of the
sort_by
that I define in thex-data
gets updated viatoggle
function. This also changes thesort
input value because this is linked through the use ofx-model
. To toggle between the ascending or descending arrow, I use:class
in the column header. - I use
x-init
to watch if the input value forsort
has changed or not. When the value changes, I send out a custom event calledsort-initiated
. - 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">«</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 %}
The Result
This is what it looks like in the end.
Sorting | Pagination |
Filtering | Progress Bar |
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.