It took me some time of digging through disparate docs and blog posts to piece together how to use
django-tables2, htmx, django-template-partials,
and django-filter, all at the same time in an optimal way for highly interactive data tables in django.
At present I am not using Alpine.js, another popular addition to this stack, due to my preference
for writing plain ole javascript instead of having to learn another syntax, but that's really
outside of this scope anyway, this is meant to be a how-to on the Python tools + HTMX.
Credit to the following blog posts and stackoverflow replies that were helpful in piecing this stuff together as a first time user of most of these Django packages:
https://taskbadger.net/blog/tables.html#bonus-2-filtering
https://enzircle.hashnode.dev/responsive-table-with-django-and-htmx
Thanks to the following git repo for the 10,000 dummy data contacts for the demo...
https://github.com/datablist/sample-csv-files
The below are some sample code from my current project, and should be easy enough to abstract away to any sort of model if you also need searchable, interactive, user-friendly tables with server-side filtering and pagination.
I find it easiest to separate each database table that will need an interactive UI table into a separate app in Django, in this way you can
combine filter, table, list, and view classes all together into a common views.py
file.
from django.conf import settings
from django.db.models import Q
from django.shortcuts import render
from django.utils.html import format_html
from django_filters import FilterSet, CharFilter
from django_filters.views import FilterView
from django_tables2 import SingleTableView, Table, Column
from django_tables2.views import SingleTableMixin
from phonenumber_field.phonenumber import PhoneNumber
from accounts.models import Account
# the class that defines the properties of the rendered table
class AccountTable(Table):
id = Column(
# definining a column footer that gives me a count of rows in the current table view,
# this will render for example "10,003 Accounts" as a table footer
verbose_name="ID",
footer=lambda table: f'{"{:,}".format(len(table.data))} Accounts'
)
class Meta:
model = Account
# the fields that will be visible on the table
fields = ('id', 'first_name', 'last_name', 'email1', 'organization_name', 'mobile_phone', 'date_created')
order_by = ('-date_created', '-id')
# controlling the rendering of particular fields, ex: a field named 'home_phone' in the model,
# render it as a tel: clickable link
def render_home_phone(self, value):
if value.is_valid():
return format_html('<a href="'+value.as_rfc3966+'">'+value.as_international+'</a>')
return value
def render_mobile_phone(self, value):
if value.is_valid():
return format_html('<a href="'+value.as_rfc3966+'">'+value.as_international+'</a>')
return value
def render_work_phone(self, value):
if value.is_valid():
return format_html('<a href="'+value.as_rfc3966+'">'+value.as_international+'</a>')
return value
def render_fax_number(self, value):
if value.is_valid():
return format_html('<a href="'+value.as_rfc3966+'">'+value.as_international+'</a>')
return value
# the class that defines the view, SingleTableView is synonymous with the Django generic list
# view, FYI (inherits from it, so the same generic ListView methods are here)
class AccountListView(SingleTableView):
model = Account
template_name = 'account_list.html'
paginate_by = 100
table_class = AccountTable
def get(self, request, *args, **kwargs):
# a 'global' (for this app) check on GET requests to see if they're HTMX, and if so,
# don't render the whole page
response = super().get(self, request, *args, **kwargs)
context = response.context_data
is_htmx = request.headers.get('HX-Request') == 'true'
if is_htmx:
# with django-template-partials you target template + HTML partial element (like
# referencing an element by ID in JS) to render a partial
return render(request, self.template_name + '#account_table', context)
return response
# the class that defines a search filter, in this example selectively searching different fields
# depending on what the user types (a site header "quick search")
class AccountQuickSearchFilter(FilterSet):
# a custom query definition for this filter, "method" points to a class method function in
# this case, to do some checking on the user's input data to determine which field(s) to search
query = CharFilter(method='account_quick_search', label='Quick Search')
class Meta:
model = Account
# when using a custom queryset, call the variable that defines it for the 'fields' definition
fields = ['query']
# a function to do some plain ole python try / except and if / else to build out a
# 'quick search' which supports a single top level 'search' box (ex: in a site header)
# which can find what the user typed in multiple different database fields.
#
# The logic here works like:
#
# * if the user types a number less than 7 digits, look for an exact account ID match
#
# * if the user types a number greater or equal to 7 digits, look for either an exact
# account ID match OR a mobile number partial match
#
# * if the user types any non-integer characters, look for partial matches on account
# name, or exact matches on email address
def account_quick_search(self, queryset, name, value):
query = self.request.GET.get('query')
try:
q = int(query)
except:
q = None
if query:
if q and isinstance(q, int):
if len(str(q)) > 7:
return queryset.filter(
Q(id=query) | Q(mobile_phone__icontains=query)
)
else:
return queryset.filter(
Q(id=query)
)
else:
return queryset.filter(
Q(full_name__icontains=query) |
Q(organization_name__icontains=query) |
Q(email1=query) |
Q(email2=query) |
Q(email3=query)
).order_by('-date_created', '-id')
# the class that defines the list view for the 'quick search' search results
class AccountQuickSearchListView(SingleTableMixin, FilterView):
table_class = AccountTable
model = Account
paginate_by = 100
# when you need to reference a template without 'self' (ex: in a class definition without
# an instance of the class to work with), you just ram the template name together with the
# partial / ID definition and it works the same way
template_name = 'account_list.html#account_table'
filterset_class = AccountQuickSearchFilter
The main table list page's container, which includes the django-template-partials partial template definition.
(I am using the stock bootstrap table view included with django-tables2, not copied and pasted here)
Why django-template-partials? Because you'll soon find with HTMX that you need another layer of abstraction
for HTMX within the scope of a single template file, and you can't use blocks and includes as the
built-in Django template abstraction provides. If you do try to use blocks and includes you'll run
into problems where things are rendering multiple times on top of each other, as Django doesn't know
how to not-do-so when there's an HTMX request. django-template-partials lets you define and use a block
all within the scope of a single file, in short, so that you don't have to worry about your {% block %}
and
{% include %}
templates inadvertently rendering more than once.
{% include 'base_pre_content.html' %}
{% load django_tables2 %}
<!-- beginning of partial content -->
{% startpartial account_table %}
<div id="account_table">
{% render_table table %}
</div>
{% endpartial %}
<!-- end of partial content -->
<div id="content" class="app-content vw-90">
<div class="container">
<div class="row">
<div class="col-xl-12">
<div class="card">
<div class="card-body">
<!-- basically you want every page load to be an HTMX page load, if
you're starting with some sort of HTML template, replace all of the
links with HTMX page loads like the below
1. on load, don't really load... instead let HTMX render the partial
2. put a container div and let HTMX replace all the outer HTML, updates
to re-render the table only will replace inner HTML instead
3. hx-push-url to push the URL of the page to the browser's address bar
for back / forward / bookmark ability
4. after the opening div, define your partial, which is atop this file
after the includes -->
<div hx-get="{% url 'accounts:accounts' %}"
hx-trigger="load"
hx-swap="outerHTML"
hx-push-url="true"
class="col-xs-4 col-md-12">
{% partial account_table %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
This is the code I'm using which defines the 'quick search' mentioned in the view. In essence, it's using HTMX's input detection to determine when the user stops typing, and then running the search on whatever they've typed when they stop.
<!-- some notes:
1. use GET, that puts the query in the browser's address bar so the user doesn't lose back / forward
2. use namespaces on your URLs in Django, more on this in the url.py files to follow...
3. as mentioned previously, here we are replacing inner HTML on the table so that the container does not re-render
4. always use hx-push-url="true" if you want the user to have the URL
5. hx-on::after-request and hx-on::before-request are useful to use when you need to do something with another
element before or after the ajax request happens (ex: call a javascript function to change another element) -->
<form id="searchForm" class="menu-search" method="GET" name="quick_search_form" label="Quick Search">
<div class="menu-search-container">
<div class="menu-search-icon"><i class="bi bi-search"></i></div>
<div class="menu-search-input">
<input id="searchField"
type="text"
class="form-control form-control-lg"
placeholder="Search for accounts by ID, mobile phone, full name, organization name, or email"
name="query"
hx-get="{% url 'accounts:search' %}"
hx-trigger="keyup delay:500ms"
hx-target="#account_table"
hx-swap="innerHTML"
hx-push-url="true"
hx-on::after-request="paceAnimation()">
</div>
<div class="menu-search-icon">
<!-- you can call namespaced URLs across apps in Django like this, "{% url 'appname:url_name' %}".
Namespacing is very useful with HTMX presuming you will need to reuse templates across
multiple Django apps within a project, each with models that have slightly different table
view and filter settings per-app
Additionally, if you need to use HTMX on an '<a' link as I'm doing here, just leave the href
in there with the same URL. HTMX will intercept the click and stop the default action attached
to the href from happening anyway, and doing it this way lets your link styling CSS keep working
without any hack'ish modifications that would be required if the href is gone -->
<a href="{% url 'accounts:accounts' %}"
hx-get="{% url 'accounts:accounts' %}"
hx-trigger="click"
hx-target="#account_table"
hx-swap="innerHTML"
hx-push-url="true"
hx-on::before-request="closeSearch()">
<i class="bi bi-x-lg"></i></a>
</div>
</div>
</form>
<!-- some simple javascript to handle the transitions mentioned above. The first one
restarts my template's loading animation. The second one resets the search form and
closes the search form's modal when the user X's out of the search form. -->
<script>
function paceAnimation() {
Pace.restart()
};
</script>
<script>
function closeSearch() {
const element = document.querySelector('#app')
const searchField = document.querySelector("#searchForm")
searchField.reset()
element.classList.remove('app-header-menu-search-toggled')
};
</script>
Again, I think namespacing URLs is a good idea with Django and HTMX for easy template refactoring. This is an example of my core / base app project urls.py.
Bonus: intelligently load django-debug-toolbar URLs or not depending on whether you're in debug mode or not.
Including login requirements here or as a decorator on views is a personal preference, but if you opt for in urls.py like this, the example below is what you need to replace the built-in Django admin login page with your own template for a login page.
If that's what you want, after setting up URLs like this all you have to do is override the Django admin page's login template with your own HTML template and you're good to go.
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.urls import include, path, re_path
from django.views.generic import TemplateView
from accounts.views import AccountListView
from accounts import urls as account_urls
if 'debug_toolbar' in settings.INSTALLED_APPS and settings.DEBUG:
import debug_toolbar
from django.conf.urls.static import static
urlpatterns = [
path('__debug__/', include(debug_toolbar.urls)),
] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
else:
urlpatterns = []
urlpatterns += [
path('', login_required(TemplateView.as_view(template_name='index.html'), login_url='adminlogin/'), name='home'),
path('admin', admin.site.urls),
path('accounts', include('accounts.urls', namespace='accounts')),
]
Since we're using namespaces Django will auto-append the name of the namespaced URL automatically, as such if you
use namespaces for URLs you don't have to define the base URL in each Django 'app', but rather leave it blank and
let Django do that for you. In this example because I have an accounts
app and I am namespacing accounts
in the base app's URLs, when I include the accounts
app's urls.py a http://thisurl.tld/accounts
base URL
for the included app will be auto-handled by Django. Additional URL endpoints in the app should therefore
start with a slash in this case, for example my search endpoint below will really be http://thisurl.tld/accounts/search
Unfortunately Django really presumes that you are using its default slash-on-both-ends URL naming mechanism, and if you override it like this there are warnings in the console about how you should not have leading URL slashes. Cheers to whoever can tell me how to suppress that warning in the comments to this gist!
from django.contrib.auth.decorators import login_required
from django.urls import path, re_path
from accounts.views import AccountListView, AccountQuickSearchListView
app_name = 'accounts'
urlpatterns = [
path('', login_required(AccountListView.as_view(), login_url='adminlogin/'), name='accounts'),
path('/search', login_required(AccountQuickSearchListView.as_view(), login_url='adminlogin/'), name='search'),
]
Some useful settings for django-tables2 can be specified globally. Like: DJANGO_TABLES2_PAGE_RANGE
for the max number of pagination buttons to render, the table template, and the table template components' CSS classes.
DJANGO_TABLES2_PAGE_RANGE = 7
DJANGO_TABLES2_TEMPLATE = 'tables/bootstrap5-responsive.html'
DJANGO_TABLES2_TABLE_ATTRS = {
'class': 'table table-hover',
'thead': {
'class': 'table-dark',
},
'th': {
'class': 'sticky-header',
'scope': 'col',
}
}
Some CSS to make a sticky header row, if you so choose...
table > thead > tr > th.sticky-header {
position: sticky;
top: 50px;
z-index: 10;
}
What all of this looks like: