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 rows. This can lead to a poor user experience, as users are forced to perform actions on each row individually, leading to slow and tedious interactions.
The need for a table that supports multiple actions by the user is becoming increasingly important, especially in today's fast-paced digital environment. Users expect to be able to perform actions on multiple rows in a single click, providing a more efficient and user-friendly experience. By implementing a table that supports multiple actions by the user, developers can improve their applications' overall usability and satisfaction, making it a necessary feature for modern web development.
In this article, we will discuss how to implement a table that supports bulk actions by the user using the combination of the django-tables2
, htmx
, and AlpineJS
libraries. The django-tables2
package is a powerful library that allows developers to create and manage tables in Django projects with minimal effort. It provides a simple and flexible way to define the columns, data, and behavior of the table. The htmx
and Alpine.js
packages are lightweight JavaScript libraries that allow developers to perform rich interactions and dynamic updates on web pages without the need for extensive JavasScript code.
Together, these libraries provide a powerful solution for building tables without compromising performance, scalability, and maintainability.
The Requirements
Before we start, let’s write down what we want to build. The following are the rough specification
The table shall support pagination and sorting when the user clicks on the column header.
The table shall have a search query form that allows the user to filter the table. The pagination and sort shall be ignored when the user performs a search.
The table shall allow the user to select the row(s) and perform a specific action on those selected row(s).
Provide a checkbox column. The user shall be able to select individual rows by clicking on the checkbox of each row.
When the user clicks on the checkbox column on the table header, all the checkboxes in the rows on this page shall be selected.
The user shall be able to select multiple rows using the combination of the shift key and a mouse click on each row.
The table shall be rendered in a way that provides a seamless interaction.
No page refresh on any table actions
A progress bar to indicate the process of fetching data on all activities that requires access to the server (pagination, sorting, filtering, etc.)
Provide a highlight for rows that are recently updated.
The Packages Used For This Project
To build this table, we will need to use the following packages:
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
First, let's define a model to work with.
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)
class Meta:
ordering = ("pk",)
def __str__(self):
return self.name
The Table
Next, based on our model above, we are going to define the table. For this project, we need to:
Add a checkbox column. We do this by using
tables.CheckBoxColumn(accessor="pk", orderable=False)
.To make the checkbox column appear as the first column, we will utilize the
sequence
parameter on the table meta's class.We also need to add a class to our rows to indicate when rows are updated. We use this class to give some animation when a user updates rows. To achieve this, we will use the
row_attrs
parameter on the table meta's class. To get the information of the updated rows, we will add a new parameter for the table__init__
method namedselection
.Also, we will allow a user to select multiple rows using Shift Key and a mouse click. To achieve this, we are going to define an attribute
attrs={td__input:{ "@click": "checkRange"}}
. TheAlpineJS
package will handle the rest.
import django_tables2 as tables
from .models import Product
def rows_higlighter(**kwargs):
# Add highlight class to rows
# when the product is recently updated.
# Recently updated rows are in the table
# selection parameter.
selected_rows = kwargs["table"].selected_rows
if selected_rows and kwargs["record"].pk in selected_rows:
return "highlight-me"
return ""
class ProductHTMxBulkActionTable(tables.Table):
# Add a checkbox column to the table.
selection = tables.CheckBoxColumn(accessor="pk", orderable=False,
attrs={
"td__input": {
"@click": "checkRange"
}
})
# Status is not orderable
status = tables.Column(accessor="status", orderable=False)
class Meta:
model = Product
template_name = "tables/bootstrap_htmx_bulkaction.html"
show_header = False
# This will put the checkbox column first.
sequence = ("selection", "...")
# This will add the highlight class to the rows
# when the product is recently updated.
row_attrs = {
"class": rows_higlighter
}
# Additional class for easier styling.
attrs = {"class": "table checkcolumn-table"}
def __init__(self, selected_rows=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# The selection parameter is a list of product ids
# that are recently updated.
self.selected_rows = selected_rows
return
The Filter
The table supports a single query form so that the user can filter the table. We define the filter below.
from decimal import Decimal
from django.db.models import Q
from django.forms import TextInput
import django_filters
from .models import Product
# Custom widget that uses search input type
class SearchInput(TextInput):
input_type = "search"
class ProductUniversalFilter(django_filters.FilterSet):
query = django_filters.CharFilter(
method="universal_search",
label="",
widget=SearchInput(attrs={"placeholder": "Search..."}),
)
class Meta:
model = Product
fields = ["query"]
def universal_search(self, queryset, name, value):
if value.replace(".", "", 1).isdigit():
value = Decimal(value)
return Product.objects.filter(Q(price=value) | Q(cost=value))
return Product.objects.filter(
Q(name__icontains=value) | Q(category__icontains=value)
)
I made a simple custom widget to change the input_type
to search
. The main basic differences come in the way browsers handle them. The first thing to note is that some browsers show a cross icon that can be clicked on to remove the search term instantly if desired.
The Views
Now that we have our model, table, and filter, we can start implementing our view. First, we need to implement a view that renders the table.
from django_tables2 import SingleTableMixin
from django_filters.views import FilterView
from .models import Product
from .tables import ProductHTMxBulkActionTable
from .filters import ProductUniversalFilter
class ProductHTMxBulkActionView(SingleTableMixin, FilterView):
table_class = ProductHTMxBulkActionTable
queryset = Product.objects.all()
filterset_class = ProductUniversalFilter
paginate_by = 10
def get_template_names(self):
if self.request.htmx:
template_name = "products/product_table_partial.html"
else:
template_name = "products/product_table_bulkaction.html"
return template_name
def get_table_kwargs(self):
# Get the list of recently updated products.
# Pass the list to the table kwargs.
kwargs = super().get_table_kwargs()
selected_rows = self.request.GET.get("selection", None)
if selected_rows:
selected_rows = [int(_) for _ in selected_rows.split(",")]
kwargs["selected_rows"] = selected_rows
return kwargs
In the get_template_names
, we render different templates based on whether or not the request is made via htmx
. Also, since we need to pass the selected rows to our table constructor, we need to implement the get_table_kwargs
.
Next, we need a view to handle the bulk update request.
from django.http import HttpResponseRedirect
from django.utils.http import urlencode
from django.urls import reverse_lazy
def reverse_querystring(view, urlconf=None, args=None, kwargs=None,
current_app=None, query_kwargs=None):
'''Custom reverse to handle query strings.
Usage:
reverse('app.views.my_view', kwargs={'pk': 123},
query_kwargs={'search': 'Bob'})
'''
base_url = reverse_lazy(view, urlconf=urlconf, args=args,
kwargs=kwargs, current_app=current_app)
if query_kwargs:
return '{}?{}'.format(base_url, urlencode(query_kwargs))
return base_url
def response_updateview(request):
if request.method == "POST" and request.htmx:
# Get the selected products
selected_products = request.POST.getlist("selection")
# Check if the activate/deactivate button is pressed
if request.htmx.trigger_name == "activate":
# Activate the selected products
Product.objects
.filter(pk__in=selected_products)
.update(status=Product.Status.ACTIVE)
elif request.htmx.trigger_name == "deactivate":
# Deactivate the selected products
Product.objects
.filter(pk__in=selected_products)
.update(status=Product.Status.INACTIVE)
# Get the page number
page = request.POST.get("page", 1)
page = int(page)
# Get the sort by column
sort_by = request.POST.get("sort", None)
# Get the query
query = request.POST.get("query", "")
# Get selection
selection = ",".join(selected_products)
# Redirect to table view
return HttpResponseRedirect(
reverse_querystring("products:products_htmx_bulkaction",
query_kwargs={"page": page, "sort": sort_by,
"query": query,
"selection": selection}))
In this view, we first check what action is triggered (activate/deactivate) and update our data. Finally, we redirect to our table view, including all the needed parameters like pagination, sort, query/filter, and the selected row.
The Templates
There are three templates in play here:
A template to render the whole page. Where I generate the page with an 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 query form on this page. This template has a lot of functionality.
Bulk activate or de-active products. To achieve this, we need to include all information like pagination, sorting, and filtering. We accomplish this using
hx-include="#bulk-actions, #id_query"
.For searching, we included a simple search form. There are two ways to trigger the search functionality. The first is when the user types something in the search box. We delay the trigger for 500ms, and the trigger only happens when the content is changed. The second trigger is when the user clicks on the cross icon on the search box. We achieve this by using
hx-trigger="keyup changed delay:500ms, search"
. Also, when we search, we will remove the pagination and sorting information. To achieve this, we first need to definex-data
and trigger the customclear-pagination-and-sort
event before every request. To accomplish this functionality, we usex-on:htmx:before-request="$dispatch('clear-pagination-and-sort')
. The custom event is defined later via the@clear-pagination-and-sort.window
functionality.Pagination and sorting. I define two extra hidden
input
fields. Thesort
andpage
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.We also added functionality in the check box column, where the whole rows in that page got selected upon clicking. We achieved this using
@click="toggleSelection()"
. Inside thetoggleSelection
we simply checked all rows on that page.To support multiple rows selection when the user uses Shift Key and mouse click, I added the
checkRange
function at the bottom.
{% extends "products/base.html" %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% load django_tables2 %}
{% block bulkaction_table %}active{% endblock %}
{% block table_main %}
<h1>Product Table</h1>
<div class="table-top-container">
{# Bulk actions and search bar #}
<div class="row justify-content-between">
<div class="col-4">
<div>
<button id="activate" type="submit" name="activate"
class="btn btn-secondary"
hx-post="{% url 'tables:products_htmx_bulkaction_update' %}"
hx-target=".table-container"
hx-indicator=".progress"
hx-include="#bulk-actions, #id_query">
Activate
</button>
<button id="deactivate" type="submit" name="deactivate"
class="btn btn-secondary"
hx-post="{% url 'tables:products_htmx_bulkaction_update' %}"
hx-target=".table-container"
hx-indicator=".progress"
hx-include="#bulk-actions, #id_query">
Deactivate
</button>
</div>
</div>
<div class="col-4">
<div class="form-inline">
<div class="d-flex justify-content-end">
<input type="search" name="query" placeholder="Search..."
class="searchinput form-control" id="id_query"
hx-trigger="keyup changed delay:500ms, search"
hx-get="{% url 'tables:products_htmx_bulkaction' %}"
hx-indicator=".progress"
hx-target=".table-container"
x-data
x-on:htmx:before-request="$dispatch('clear-pagination-and-sort')">
</div>
</div>
</div>
</div>
{# Table header #}
<form id="bulk-actions"
hx-get="{% url 'tables:products_htmx_bulkaction' %}"
hx-target=".table-container"
hx-trigger="sort-initiated, pagination-initiated"
hx-swap="outerHTML"
hx-include="#id_query"
hx-indicator=".progress"
x-data="{ sort_by: '',
page_by: 1,
select_all: false,
last_checked: false }"
@clear-pagination-and-sort.window="page_by = 1; sort_by = ''"
x-on:htmx:after-swap="select_all = false">
{% csrf_token %}
{# Hidden input to store pagination page and column to sort by #}
<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 class="table checkcolumn-table header">
<thead {{ table.attrs.thead.as_html }}>
<tr>
{% for column in table.columns %}
{% if column.name == 'selection' %}
<th {{ column.attrs.th.as_html }}
x-data="{ toggleSelection(event) {
select_all = !select_all;
let checkboxes = document.getElementsByName('selection');
[...checkboxes].map((el) => {
el.checked = select_all;
})
}
}"
@click="toggleSelection()"
style="cursor: pointer;">
<input type="checkbox" x-model="select_all">
</th>
{% else %}
{% if column.orderable %}
<th {{ column.attrs.th.as_html }}
x-data="{ col_name: '{{ column.order_by_alias }}',
toggleSort(event) {
this.col_name = this.col_name.startsWith('-') ? this.col_name.substring(1) : ('-' + this.col_name);
sort_by = this.col_name;
}
}"
@click="toggleSort()"
:class="sort_by !== '' ? (sort_by === col_name ? (sort_by.startsWith('-') ? 'desc' : 'asc') : '') : ''"
style="cursor: pointer;">
{{ column.header }}
</th>
{% else %}
<th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
{% endif %}
{% endif %}
{% endfor %}
</tr>
</thead>
</table>
{# Progress indicator #}
<div class="progress">
<div class="indeterminate"></div>
</div>
{# Content table #}
{% render_table table %}
</form>
</div>
{% endblock %}
{% block footer %}
<script>
// Set the checkbox to be checked from the start
// to end when the user presses the shift key.
function checkRange(event) {
let checkboxes = document.getElementsByName('selection');
let inBetween = false;
if( event.shiftKey && event.target.checked ) {
checkboxes.forEach( checkbox => {
if( checkbox === event.target || checkbox === last_checked ) {
inBetween = !inBetween;
}
if( inBetween ) {
checkbox.checked = true;
}
});
}
last_checked = event.target;
}
</script>
{% endblock %}
Template to Render Table
Here we only render the table content, no HTML header, and 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 extends the default table from django-tables2
. It seamlessly updates the page_by
variable in x-data
when a user triggers any pagination action. This, in turn, updates the value of the page
input, which is linked via the x-model
. And it starts a custom event called pagination-initiated, which tells htmx to send the complete form back to the server, including all sorting, pagination, and filtering information. It's like magic!
{% extends "django_tables2/bootstrap4.html" %}
{% load django_tables2 %}
{% load i18n %}
{% 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 %}
{% 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 %}
{% 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 %}
Style
Column Width
/* Column Width */
.checkcolumn-table td:nth-child(1),
.checkcolumn-table th:nth-child(1) {
width: 1%;
}
.checkcolumn-table td:nth-child(2),
.checkcolumn-table th:nth-child(2) {
width: 8%;
}
.checkcolumn-table td:nth-child(3),
.checkcolumn-table th:nth-child(3) {
width: 34%;
}
.checkcolumn-table td:nth-child(4),
.checkcolumn-table th:nth-child(4) {
width: 32%;
}
.checkcolumn-table td:nth-child(5),
.checkcolumn-table td:nth-child(6),
.checkcolumn-table td:nth-child(7),
.checkcolumn-table th:nth-child(5),
.checkcolumn-table th:nth-child(6),
.checkcolumn-table th:nth-child(7) {
width: 8%;
}
Pagination
/* Pagination */
ul.pagination {
justify-content: end !important;
}
Sorting
/* Sorting */
th.asc:after {
content: '\0000a0\0025b2';
float: right;
width: 10%;
}
th.desc:after {
content: '\0000a0\0025bc';
float: right;
width: 10%;
}
Highlight Rows on Update
/* Rows highlight */
.highlight-me {
background-color: white;
animation-name: blink;
animation-duration: 2s;
transition-timing-function: ease-in;
transition: 0.2s;
}
@keyframes blink {
0% { background-color: orange; color: white;}
50% { background-color: orange; color: white; }
51% { background-color: white; }
100% { background-color: white; }
}
Progress Bar
/* Progress bar */
.checkcolumn-table.header {
margin-bottom: 0;
}
.progress {
height: 4px;
width: 100%;
border-radius: 2px;
background-clip: padding-box;
overflow: hidden;
position: relative;
opacity: 0;
}
.htmx-request .progress,
.htmx-request.progress {
opacity: 1;
}
.progress .indeterminate {
background-color: blue;
}
.progress .indeterminate:after,
.progress .indeterminate:before {
content: '';
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
}
.progress .indeterminate:before {
animation: indeterminate 2.1s cubic-bezier(0.65, 0.815, 0.735, 0.395) infinite;
}
.progress .indeterminate:after {
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.84, 0.44, 1) infinite;
}
@keyframes indeterminate {
0% { left: -35%; right: 100%; }
60% { left: 100%; right: -90%; }
100% { left: 100%; right: -90%; }
}
@keyframes indeterminate-short {
0% { left: -200%; right: 100%; }
60% { left: 107%; right: -8%; }
100% { left: 107%; right: -8%; }
}
Summary
In this article, we have covered the process of building a table that supports bulk actions by the user using the combination of django-tables2
, htmx
, and AlpineJS
. We have gone through the implementation of pagination and sorting, the search query form, and the checkbox column. We also covered how to enhance the user experience by improving the table's design and layout, adding responsive design, and providing feedback to the user.
We have seen how django-tables2
is a powerful library that allows developers to create and manage tables in Django projects with minimal effort. The htmx
allows developers to perform rich interactions and dynamic updates on web pages without the need for extensive JavaScript code, and Alpine.js
will enable developers to add interactive functionality to their web pages with minimal code. Together, these libraries provide a powerful solution for building tables that support bulk actions by the user.