Bulk Actions in Table with Django, htmx, and AlpineJS

Search for a command to run...

hey amazing post! I'm learning a lot about htmx. But one question, I think there is an issue with some values. For instance, page = request.POST.get("page", 1), it can be empty quotes, and it will throw an error in the int. I just fixed adding an if, but I wanted to share with you the issue.
great stuff do you have some animations like in the other blog entries in the series? also a github link for the code would be nice to try it out
As a developer, you will need to create tables in your web applications. To create a table in Django, you typically implement a set of APIs on the server-side that transfer data to the client and use a Javascript table library on the client-side. But...
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...

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.
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.

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 with django-tables2.
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.
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.
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
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 named selection.
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"}}. The AlpineJS 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 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.
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.
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.

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 define x-data and trigger the custom clear-pagination-and-sort event before every request. To accomplish this functionality, we use x-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. 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.
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 the toggleSelection 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 %}
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 %}
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 %}
/* 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 */
ul.pagination {
justify-content: end !important;
}
/* Sorting */
th.asc:after {
content: '\0000a0\0025b2';
float: right;
width: 10%;
}
th.desc:after {
content: '\0000a0\0025bc';
float: right;
width: 10%;
}
/* 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 */
.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%; }
}
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.