Fun with Formsets

Every once in a while I like to experiment with new technologies. One of those technologies I've seen mentioned here and there was Hyperscript, which is another brainchild of HTMX-creator Carson Gross. It's built upon old ideas, but applies them to Javascript where it actually innovates on many concepts like transparent asynchronous behaviour or built-in DOM literals.

Up until now, I didn't really have a chance to try it out in practice. Fortunately, when working with Django's formsets, I needed some client-side behavior and decided to give Hyperscript a try.

Formsets in Django

Formsets in Django are handy whenever you want to edit multiple instances of something. While they might be a bit daunting at first, they actually pack quite a punch in terms of functionality that you don't necessarily think about when first approaching the topic (e.g. deleting or sorting instances).

Let's take a simplistic example:

 1from django import forms
 2from django.template.response import TemplateResponse
 3
 4class InvoiceLineForm(forms.Form):
 5    description = forms.CharField()
 6    amount = forms.DecimalField()
 7
 8InvoiceLineFormSet = forms.formset_factory(InvoiceLineForm, extra=1)
 9
10def view(request):
11	context = {"line_formset": InvoiceLineFormset()}
12	return TemplateResponse("template.html", context)

And the relevant parts of template.html:

1<form method="post">
2	{{ line_formset.management_form }}
3	
4	{% for form in line_formset %}
5	  {{ form }}
6	{% endfor %}
7</form>

This will work, but the user experience feels a bit dated. For one, you can only ever add the prescribed number of extra lines. Additionally, to delete a line you actually need to check a checkbox and submit the form.

To keep this post somewhat short, I'll focus on the first part, where I had quite some fun experimenting with Hyperscript.

Adding lines on the client-side

As you might know from Django's very own admin, formsets are perfectly capable of handling a dynamic amount of extra forms. To achieve this, you just need a bit of Javascript, since we'll be manipulating the DOM.

The basic idea is the following: formsets have an empty_form property, which just renders an empty form (who'd have thought). In general, to distinguish the individual forms of a formset, the forms inputs have a name that includes the forms index. In our example, the description of the fifth form might have the name forms-5-description:

1<input type="text" name="forms-5-description">

The special empty_form is just like a regular form, but the index is replaced with the string __prefix__:

1<input type="text" name="forms-__prefix__-description">

To add a new form on the client side, all you have to do is copy the empty_form HTML, replace __prefix__ with an appropriate index and then insert the modified HTML into the DOM at the appropriate location. How do we now the appropriate index? That's one place, where the management_form from above comes in. Besides other information, it holds the total number of forms of a formset. When we dynamically add forms to the formset, we need to ensure that this number is kept up-to-date.

So, without further ado, here's all you need to do, to make all that happen using Hyperscript (using the _ attribute btw.):

 1<form method="post" id="form">
 2	{{ line_formset.management_form }}
 3	
 4	{% for form in line_formset %}
 5	  {{ form }}
 6	{% endfor %}
 7</form>
 8
 9<template id="empty-line">
10  {{ line_formset.empty_form }}
11</template>
12
13<button
14    _="
15        on click 
16        put #empty-line.content.cloneNode(true) at the end of #form
17        then set #form.innerHTML to #form.innerHTML.replaceAll('__prefix__', #id_form-TOTAL_FORMS.value)
18        then increment #forms-TOTAL_FORMS.value
19    "		
20>
21  Add line
22</button>

Isn't this lovely? Granted, it's also a bit crazy, but there's a certain elegance to it.

But even if you've never written any Hyperscript, this should make sense to you. Now consider all the noise you'd have to write to make this happen in normal Javacsript:

  • placing a script somewhere in your project and making it available to call here (of course you can also use an inline script, like surreal.js encourages)
  • registering an event handler
  • instantiating the template and copying the contents to the correct location
  • parsing and incrementing the management forms TOTAL_FORMS value

I'm totally aware that this approach is not the "right" solution for everything. But to me, it was a genuinely joyful experience to write this. Who knows, maybe Hyperscript will have a similar effect on you?

21