tags | |
---|---|
|
Django comes with builtin utilities for creating formsets (see Formsets Django documentation). The disadvantage is that django templates are server side rendered, therefore there is no possibility for the user to add any extra forms interactively. Therefor we need to use Javascript.
Django admin for example also takes care of this interactivity with jQuery. We could obviously also write plain Javascript to accomplish that. But the locality of behaviour principle can be accomplished very nicely with Alpine.js.
See the Pen Formset by ViggieSmalls (@viggiesmalls) on CodePen.
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>Let's start with a simple formset, that is rendered with django templates. We will use the ArticleFormSet example from the django documentation. Here is how the formset would be built in python code:
from django import forms
from django.forms import formset_factory
class ArticleForm(forms.Form):
title = forms.CharField()
pub_date = forms.DateField()
ArticleFormSet = formset_factory(ArticleForm)
And here is how the formset would be rendered in a django template, without any interaction:
<form>
{{ formset.management_form }}
{% for form in formset %}
{{ form }}
{% endfor %}
<button>Submit</button>
</form>
In the following steps we will implement an addForm
and deleteForm
method and adjust the html to be able to make the formset interactive.
Let's initialize the Alpine.js component with the x-data
attribute on the <form>
element and add a button that triggers the addForm
method on click.
<form x-data="formset">
<button type="button" @click="addForm()">Add form</button>
...
</form>
Formsets created with the formset_factory
have an attribute empty_form
.
This can be used as a template for new forms, since names and ids of the fields of the form contain the __prefix__
instead of a number, so we can easily replace it with an incremental number.
So the empty form can be packed inside a template
tag and when copied we replace __prefix__
with the number of forms present in the forms
div, plus one.
We also need a button that triggers the addForm
method on click.
Afterward we just need to append it inside the forms
container.
The HTML would look something like this:
<form x-data="formset">
<button type="button" @click="addForm()">Add form</button>
<template>
{{ formset.empty_form }}
</template>
{{ formset.management_form }}
<div class="forms">
{% for form in formset %}
{{ form }}
{% endfor %}
</div>
</form>
And the addForm
Javascript code would look like this:
const template = this.$root.querySelector('template')
const formsContainer = this.$root.querySelector('.forms')
const newForm = this.template.content.cloneNode(true)
// replace __prefix__ with the correct index
for (let el of newForm.querySelectorAll('input, select, textarea')) {
if (el.name.includes('__prefix__')) {
el.name = el.name.replace('__prefix__', formsContainer.children.length)
}
if (el.id.includes('__prefix__')) {
el.id = el.id.replace('__prefix__', formsContainer.children.length)
}
}
const labels = newForm.querySelectorAll('label')
for (let el of labels) {
if (el.htmlFor.includes('__prefix__')) {
el.htmlFor = el.htmlFor.replace('__prefix__', formsContainer.children.length)
}
}
// append the new form to the dom
formsContainer.appendChild(newForm)
We also need to replace the number of total and initial forms in the management form
const totalFormsInput = this.$root.querySelector(`input[name$='-TOTAL_FORMS']`)
const initialFormsInput = this.$root.querySelector('input[name$="-INITIAL_FORMS"]')
totalFormsInput.value = parseInt(totalFormsInput.value) + 1
initialFormsInput.value = parseInt(initialFormsInput.value) + 1
Removing a form has some implications.
We need to add a delete button for each form that is rendered within the "forms" div
.
We also need to encompass every form with a "form" div
, so we are able to select it with the closest() method and remove it from the DOM.
<form x-data="formset">
<template>
<fieldset>
{{ formset.empty_form }}
<button type="button" @click="deleteForm()">Delete form</button>
</fieldset>
</template>
{{ formset.management_form }}
<div class="forms">
{% for form in formset %}
<fieldset>
{{ form }}
<button type="button" @click="deleteForm()">Delete form</button>
</fieldset>
{% endfor %}
</div>
</form>
We could separate the HTML for the form and place it inside a template partial:
<form x-data="formset">
<template>
{% include "form_template.html" with form=formset.empty_form %}
</template>
{{ formset.management_form }}
<div class="forms">
{% for form in formset %}
{% include "form_template.html" %}
{% endfor %}
</div>
</form>
After removing a form from the DOM, we need to adjust the IDs of the remaining inputs, so they remain consecutively numbered.
This is required, because the construct_forms
method expects the form inputs to be numbered consecutively.
And don't forget to adjust the management form.
Here is the corresponding Javascript code:
this.$el.closest('fieldset').remove()
// adjust the ids of the remaining inputs
for (let i = 0; i < this.formsContainer.children.length; i++) {
const form = this.formsContainer.children[i]
const inputs = form.querySelectorAll('input, select, textarea')
for (let el of inputs) {
el.name = el.name.replace(/\d+/, i)
el.id = el.id.replace(/\d+/, i)
}
const labels = form.querySelectorAll('label')
for (let el of labels) {
el.htmlFor = el.htmlFor.replace(/\d+/, i)
}
}
// adjust the management form inputs
totalFormsInput.value = Math.max(0, parseInt(this.totalFormsInput.value) - 1)
initialFormsInput.value = Math.max(0, parseInt(this.initialFormsInput.value) - 1)
So now we have all we need for a dynamic formset, and I believe it is really portable and reusable for many cases.
I have also learned that the usage of x-ref
should be restricted very carefully.
We could have also worked with x-ref
attributes like x-ref="forms"
or x-ref="managementForm"
instead of querySelector.
But I had a discussion on the Alpine.js Discord chanel, and I was told that x-ref
is probably a correct choice only in 1/100 cases, and that only for external libraries that rely on a React-style reference.
It's obvious that for our use case it is not necessary.