Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save RNCTX/872f7d09a0c0177d5f4d59653998f780 to your computer and use it in GitHub Desktop.
Save RNCTX/872f7d09a0c0177d5f4d59653998f780 to your computer and use it in GitHub Desktop.
How to tie HTMX + django-tables2 + django-filters + django-template-partials together

Reasoning for this

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://stackoverflow.com/questions/57270470/django-filter-how-to-make-multiple-fields-search-with-django-filter

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.

views.py

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

account_list.html (the django-tables2 template)

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>

Quick search html form

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>

base app urls.py

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')),
]

included app's urls.py

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'),
]

settings.py

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',
    }
}

css

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:

ezgif-861bef54e99547

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment