How to asynchronously refresh elements in the Django admin with HTMX
Recently updated on
The client wanted a dropdown select box that, when changed, would trigger the repopulation of a separate multiselect box. In the mock-up below, the contents of the “Tags” picker changes depending on the value of the “Foo” dropdown. The element is refreshed via AJAX, so we avoid reloading the entire page.
Looking online for answers to the question “how do I dynamically refresh content in the Django admin?” turns up roll-your-own JavaScript solutions, possibly with the help of jQuery. Those solutions were great back in grandad’s day, but nowadays all the cool kids are using HTMX!
And HTMX is a great option for this problem. We can get the functionality we want without a single line of… well, let’s say with just a tiny bit of JavaScript. But HTMX will do all the heavy AJAX lifting for us. Let’s see how we can make it happen.
By the way, this post assumes some slight familiarity with HTMX. For example, I will not go into the function of each of the HTMX attributes we use below. However, we only need to understand the basics of HTMX to accomplish this task.
The post also assumes that you have a working knowledge of Django and that you have access to a Django project that you can make changes to. If you have the former but not the latter, create and activate a virtual environment, use “pip” to install a recent version of Django into it, and finally use $ django-admin startproject <my-project>
to start a new Django project. Some other management commands you may or may not want to follow up with include:
$ ./manage.py migrate
to create the initial database tables (we’re assuming the file-based “sqlite3” database)
$ ./manage.py createsuperuser
to create a superuser for yourself, if you don’t have one already,
$ ./manage.py startapp foos $ ./manage.py startapp bars $ ./manage.py startapp tags
to create the three apps we’ll use in this demonstration. Make sure all three are included in the INSTALLED_APPS
setting of your project. While you’re in there, you should probably also set the ALLOWED_HOSTS
setting to ["*"]
to allow your project to run on your local machine.
Once you’ve got this basic framework in place, continue reading below.
The Models
Ok, so assume for the sake of this blog post that “Foo” and “Bar” are the names of our base models. A “Bar” instance has a foreign key to a “Foo” instance. Finally, we have a third model named “Tag” that can be related to either Foo or Bar. All models are defined in the “models.py” modules of their respectively named apps, like so:
foos/models.py
from django.db import models from tags.models import Tag class Foo(models.Model): name = models.CharField(max_length=255) tags = models.ManyToManyField(Tag) def __str__(self): return self.name
bars/models.py
from django.db import models from foos.models import Foo from tags.models import Tag class Bar(models.Model): name = models.CharField(max_length=255) foo = models.ForeignKey(Foo, on_delete=models.CASCADE) tags = models.ManyToManyField(Tag, blank=True) def __str__(self): return self.name
tags/models.py
from django.db import models class Tag(models.Model): name = models.CharField(max_length=255) def __str__(self): return self.name
You can now run
$ ./manage.py makemigrations
and
$ ./manage.py migrate
to create the database tables for your new models.
The Admin
Our admins for the “foos” and “tags” apps can be simple too:
foos/admin.py
from django.contrib import admin from .models import Foo admin.site.register(Foo)
tags/admin.py
from django.contrib import admin from .models import Tag admin.site.register(Tag)
It’s the admin for the “bars” app where things start to get more involved. At a minimum, we know that our admin change view will need to load the HTMX library. To make that happen, we override the base template for the Django admin’s change view. Let’s create some template directories for the “bars” app and put the override there:
bars/templates/admin/base.html
{% extends "admin/base.html" %} {% block extrahead %} <script src="https://unpkg.com/htmx.org@1.9.9"></script> {% endblock %}
That lone <script>
tag is all we need for HTMX to work. Magic!
In your settings file, make sure that the APP_DIRS
flag is set (“True”) in your TEMPLATES
setting, and that your app (“bars” in our case) is listed before 'django.contrib.admin'
in your INSTALLED_APPS
setting. Without these details, Django won’t find your override template.
Now that our change view knows what HTMX is, we need to attach some HTMX attributes to the “select” dropdown for the “foo” field in the admin. That’s going to require a custom change form, so let’s start there. We’ll define it in the “admin.py” module of the “bars” app:
bars/admin.py
from django import forms from django.contrib import admin from django.urls import reverse_lazy from .models import Bar class BarAdminForm(forms.ModelForm): class Meta: htmx_attrs = { "hx-get": reverse_lazy("bars:get_tags_for_foo"), "hx-swap": "innerHTML", "hx-trigger": "change", "hx-target": "#id_tags", } model = Bar fields = "__all__" widgets = { "foo": forms.Select(attrs=htmx_attrs), } class BarAdmin(admin.ModelAdmin): form = BarAdminForm admin.site.register(Bar, BarAdmin)
A View and Another Template
Notice that our “hx-get” attribute above references a view in the “bars” app (“get_tags_for_foo”). That view will return the tags related to the given “Foo” instance. We can then populate our multiselect widget with those values. Here’s the view:
bars/views.py
from django.views.generic import ListView from foos.models import Foo, Tag class GetTagsForFoo(ListView): model = Tag template_name = "bars/tag_choices.html" def get_queryset(self): if foo_id := self.request.GET.get("foo"): try: foo = Foo.objects.get(pk=foo_id) except Foo.DoesNotExist: pass else: return foo.tags.all() return Tag.objects.none()
The template for this view is just:
bars/templates/bars/tag_choices.html
{% for tag in object_list %} <option title="{{ tag.name }}" value="{{ tag.id }}">{{ tag.name }}</option> {% endfor %}
(The generic “ListView” will populate “object_list” for us.)
Wiring Up the URLs
As Django users will know, you must also connect this view to a URL in “bars/urls.py” and connect *that* module to the “urls.py” of your main project.
bars/urls.py
from django.urls import path from . import views app_name = "bars" # url namespace urlpatterns = [ path("tags-for-foo/", views.GetTagsForFoo.as_view(), name="get_tags_for_foo"), ]
urls.py (main project)
from django.conf.urls import include from django.contrib import admin from django.urls import path urlpatterns = [ path("bars/", include("bars.urls")), path('admin/', admin.site.urls), ]
Go Time
Now with everything in place, you should be able to start runserver
on port 8000. To see the dynamic loading in action ala the screenshots at the beginning of this post, go to
http://localhost:8000/admin/
and log in as your superuser. Then:
- Create multiple “Tag” instances at http://localhost:8000/admin/tags/tag/add/
- Create a “Foo” instance at http://localhost:8000/admin/foos/foo/add/ and associate it with some tags.
- Create another “Foo” instance and associate it with different tags.
- Create a “Bar” instance at http://localhost:8000/admin/bars/bar/add/ and watch as your “tag” choices magically repopulate when you change the value of the “Foo” dropdown.
Fine Tuning
Hopefully, HTMX is working its magic on the “Foo” dropdown, but our form could use some tweaking. For example, the initial load of the form offers all “Tag” instances as choices, even though it should only offer those tags related to the chosen “Foo” instance. We might also like to prevent the user from changing the selected “Foo” if tags have already been chosen. This will help to ensure that any tags related to a given “Bar” instance are also related to the chosen “Foo” instance.
To add these nice features, you can override the __init__()
method of your custom form and the `get_readonly_fields()` method of your custom admin. Now the admin module will look like this:
bars/admin.py
from django import forms from django.contrib import admin from django.urls import reverse_lazy from foos.models import Foo from .models import Bar class BarAdminForm(forms.ModelForm): class Meta: htmx_attrs = { "hx-get": reverse_lazy("bars:get_tags_for_foo"), "hx-swap": "innerHTML", "hx-trigger": "change", "hx-target": "#id_tags", } model = Bar fields = "__all__" widgets = { "foo": forms.Select(attrs=htmx_attrs), } def __init__(self, *args, **kwargs): foo = None super().__init__(*args, **kwargs) # If this is a POST request (i.e. 'self.data' is populated), then set the queryset # for the 'tags' field to the tags associated with the Foo that was selected. This # allows the form to validate. If this is a GET request, then set the queryset to # the tags associated with the Bar instance that is being edited, if any. This # allows the initial state of the field to be set correctly. foo_id = self.data.get("foo") or self.initial.get("foo") if foo_id: try: foo = Foo.objects.get(pk=foo_id) except Foo.DoesNotExist: pass elif self.instance.id: foo = self.instance.foo if foo: self.fields["tags"].queryset = foo.tags.all() else: self.fields["tags"].queryset = Tag.objects.none() class BarAdmin(admin.ModelAdmin): form = BarAdminForm def get_readonly_fields(self, request, obj=None): # The user can only change the Foo instance if this Bar instance has no tags. if obj and obj.tags.count() > 0: return ["foo"] return [] admin.site.register(Bar, BarAdmin)
Final Words
Of course there are other tweaks you can make to make your custom form even nicer. A big one would be to use the Django admin’s “filter_horizontal” widget for the tag selector, as this is much more intuitive than the simple multi-select widget in our examples here. That widget comes with its own complications though, as changing its contents dynamically causes lots of problems with its own javascript-based functionality. Still, it can be done! I’ll leave it as an exercise for the reader to figure out how.
Happy coding!
Need Upgrade Assistance?
Django 3.2 reaches its end of life in April 2024. If your site is on this or an earlier version, please fill out this form and we will reach out and provide a cost and time estimate for your consideration. No obligation.