Django and HTMX Forms - with django-forms-dynamic

In this post, we'll look at a new Django Form library called django-forms-dynamic. This library "resolves form field arguments dynamically when a form is instantiated, not when it's declared" - allowing dynamic forms to be created, where the values of one field can be dependent on another. This makes the library suitable for use with tools such as HTMX and Unpoly.

We are going to convert our example from this post, and instead make use of Django Forms, along with the django-forms-dynamic library.

The library provides a DynamicField form field that takes the normal field type as its first argument - for example, forms.ModelChoiceField or forms.CharField. Any additional arguments are passed to the normal, wrapped field, but importantly can be callables which are called when the form is being instantiated. The callable form of the field arguments takes the form instance itself as an argument, allowing for dynamic behaviour such as making a field's values be contingent on the value of another form field.

The associated video for this post can be found below.


Objectives

In this post, we will:

  • Use Django's Forms to build a dynamic, dependent dropdown form
  • Learn how to use the django-forms-dynamic library

Project Setup

This tutorial will build off the code from this Github repository - specifically, the final branch. To get this code, run the following commands:

git clone https://github.com/bugbytes-io/django-htmx-chained-dropdown
git checkout -b final
git pull origin final
cd django-htmx-chained-dropdown

Ensure to then install the requirements in a virtual environment, with pip install -r requirements.txt, and then create the database tables with python manage.py migrate.

Once the database tables are created, you should run the custom management script to populate this database: python manage.py load_data. Check the previous post for more information on this command - in a nutshell, though, it'll create Course and Module rows in the database, linking the modules to their parent course.

We are going to make use of two new libraries in this post - django-forms-dynamic and django-widget-tweaks, which we will use to attach HTMX attributes to the form instance's fields in the template. After the above setup is complete, run the following command:

pip install django-forms-dynamic django-widget-tweaks

After this command is completed, you should now have the project and its environment setup for development. Let's start by defining a Form that will define our dropdown lists of courses and modules!

Creating a Django Form

As a reminder, the form we built in the previous post had two <select> fields, with the first list of options defining the courses available in the database, and the second defining the modules linked to the chosen course (i.e. the modules are dependent on the chosen course). These correspond to the forms.ModelChoiceField field that exists in Django's forms module.

Let's start by defining a simple form, with a choice field for the courses. Create a file called forms.py and add the following code:

from django import forms
from .models import Course, Module

class UniversityForm(forms.Form):
    course = forms.ModelChoiceField(
        queryset=Course.objects.all(),
        initial=Course.objects.first()
    )

This form has a single field, for the course choices, and sets the queryset and initial arguments appropriately. We can now instantiate this form in our view, and pass it to the template. Within views.py, modify the courses view and add the following code.

from .forms import UniversityForm

def courses(request):
    form = UniversityForm()
    context = {'form': form}
    return render(request, 'university.html', context)

We now have access to the form instance within the template, using the form context variable. Amend the university.html template to add the following code:

{% extends 'base.html' %} 
{% load widget_tweaks %}

{% block content %}

{{ form.course.label_tag }}
{% render_field form.course class='custom-select mb-4' autocomplete="off" %}

{% endblock %}

We are using django-widget-tweaks to render the form's course field, and we add the Bootstrap class custom-select to style the field, with some additional margin-bottom via the mb-4 class.

Before running the server and inspecting the results, let's add a __str__() method to both our Course and Module models, to give better, more-readable names when showing the instances in the form fields. The models.py file should look like the following:

from django.db import models

class Course(models.Model):
    name = models.CharField(max_length=128)

    def __str__(self):
        return self.name

class Module(models.Model):
    name = models.CharField(max_length=128)
    course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='modules')

    def __str__(self):
        return self.name

If you now run Django's development server and navigate to the page, you should see a single dropdown field with options for each course.

With the first field sorted, let's now add our dependent module field. This will also be a <select> field, with options for the modules that are linked to the chosen course. However, we are going to use some tools from django-forms-dynamic to add this field to our Form class.

Amend the forms.py file with the following code:

from django import forms
from dynamic_forms import DynamicField, DynamicFormMixin
from .models import Course, Module


class UniversityForm(DynamicFormMixin, forms.Form):

    def module_choices(form):
        course = form['course'].value()
        return Module.objects.filter(course=course)

    def initial_module(form):
        course = form['course'].value()
        return Module.objects.filter(course=course).first()     
    
    # course field
    course = forms.ModelChoiceField(
        queryset=Course.objects.all(),
        initial=Course.objects.first()
    )

    # module field
    modules = DynamicField(
        forms.ModelChoiceField,
        queryset=module_choices,
        initial=initial_module
    )

The first change is to add the DynamicFormMixin to the form class, on line 6. Secondly, we add a new field called modules on lines 23-27, which is a DynamicField - this field class comes from django-forms-dynamic. With the first argument, we specify that this field should be a ModelChoiceField; the second argument defines a callable that determines the queryset for the modules dropdown; and the third argument defines another callable to get the initial value of the modules dropdown.

The callable returning the modules queryset is defined on lines 8-10. As mentioned in the introduction, the callable gets the form instance itself as an argument, which means we have dynamic access to the values of other form fields. Thus, we can filter the Module objects on line 10 down to only those associated with the course that has been selected. On line 9, we extract the value of the chosen course from the form, and use this to filter the modules.

The callable defining the initial value, on lines 12-14, is identical to the other callable - however, this returns a single module, rather than a queryset, using the .first() function on line 14.

Now, let's add this new form field to our template. Amend the university.html template with the following code.

{% extends 'base.html' %} 
{% load widget_tweaks %}

{% block content %}

{{ form.course.label_tag }}
{% render_field form.course class='custom-select mb-4' autocomplete="off" %}


{{ form.modules.label_tag }}
{% render_field form.modules class='custom-select mb-4' %}

{% endblock %}

On lines 10-11, we render the modules field that we have just defined. We should now see this second dropdown on the front-end, however the modules will not dynamically update at the moment - this is where we bring in HTMX!

Adding HTMX Attributes

We need to send an AJAX request to a Django view whenever the course has been changed, so we can update the modules. Thus, we need to let the backend know which course has been selected, to ensure that we filter the modules appropriately.

We're going to add HTMX attributes to the course form field. In the university.html template, change that field's code to the following:

{% render_field form.course class='custom-select mb-4' autocomplete="off" hx-get="/modules/" hx-target="#id_modules" %}

We specify a GET request to the modules view whenever the <select> element changes, and swap the HTML response into the element with an ID of id_modules (this is the default ID of the modules form field rendered by Django).

All that's left to do is to amend the view that handles this HTMX request. From the previous post, we had the following:

def modules(request):
    course = request.GET.get('course')
    modules = Module.objects.filter(course=course)
    context = {'modules': modules}
    return render(request, 'partials/modules.html', context)

We are now going to change this to use the form class. Amend this function as follows:

def modules(request):
    form = UniversityForm(request.GET)
    return HttpResponse(form['modules'])

We pass the GET parameters (which contain the selected course) to the form, and return a string containing the HTML code for the modules field. This code is then swapped into the target, replacing the modules with the new list.

form['modules'] contains the HTML for the modules field, i.e. a <select> element with the correct options for the modules.

This should be all that's required to get our form working again, but with the beauty of having both removed much of the template code, as well as consolidating the form logic within that Form class rather than in the view. You should now have something similar to the following on your page:

Using django-forms-dynamic can make this type of dynamic behaviour accessible, and whilst this is a very new library, it might well prove very useful for Django developers who are not using SPA frameworks.

Summary

In this post, we've refactored the code from this post to now use Django's forms machinery, and also included the django-forms-dynamic library to easily include HTMX-based dynamic choices in our form.

If you enjoyed this post, please subscribe to our YouTube channel and follow us on Twitter to keep up with our new content!

Please also consider buying us a coffee, to encourage us to create more posts and videos!

;