IAN WALDRON IAN WALDRON
Add Form To Django Formset Dynamically With JavaScript

Add Form To Django Formset Dynamically With JavaScript

Use JavaScript to dynamically add forms to a Django Formset on the client side by copying an existing form, updating the necessary values and attributes, and appending the copied form to the formset. Additionally, this article will explore basic error handling for a couple of pitfalls we may encounter as well as how we can extend the functionality with a callback function allowing us to reuse the abstraction throughout our project.
July 13, 2024

Background

Django Formsets are a power tool used to display multiple forms of a common type on one single page. To construct a Formset (henceforth 'formset'), we use the function formset_factory which, after specifying the base form and formset, allows us to control how many forms are rendered initially, how many extras are provided, and both the minimum and maximum quantities of forms allowed to be submitted.

However, these configurations are set on the server side. What do we do if we want to be able to add forms to the formset from the browser? Fortunately, with a bit of JavaScript we can easily clone forms on the spot and dynamically adjust our formset.

Considerations

Broadly speaking, we're going to select a pre-existing form component and make a copy using the cloneNode() JavaScript method. But there's a bit more to deal with under the surface. With formsets in Django, a Management Form ('management form') is provided along with the formset's individual forms.

The management form contains two required fields: "<form>-TOTAL_FORMS" and "<form>-INITIAL_FORMS" where <form> is the form prefix. Django uses the values of these inputs to validate the form submission. So, we need to be sure to increment their respective values appropriately:

[The Management Form] is used to keep track of how many form instances are being displayed. If you are adding new forms via JavaScript, you should increment the count fields in this form as well. (docs)

Additionally, we have two more fields "<form>-MIN_NUM_FORMS" and "<form>-MAX_NUM_FORMS." However, these fields don't affect server-side validation. Instead, they're used for client-side validation. We'll use "<form>-MAX_NUM_FORMS" to make sure we don't allow the clone function to add one too many forms beyond what our server will accept. This article isn't going to deal with form deletion so "<form>-MIN_NUM_FORMS" isn't relevant here, just know it exists and you should consider its value when making dynamic deletions.

Setup

For this article we'll have a formset containing instances of the form "FavoriteColorForm" with two fields "name" and "color" that we'll use to store a given person's favorite color. I wrote the JavaScript example using the prefix "example" so I'm going to make sure my form is prefixed with this identifier. Normally this value would be a bit more semantic.

class FavoriteColorForm(forms.Form):
    name = forms.CharField(max_length=50)
    color = forms.CharField(max_length=25)

    prefix = 'example'
    
    
FavoriteColorFormset = forms.formset_factory(
    FavoriteColorForm,
    min_num=1,
    extra=0
)

Our formset will display by default a single form. We'll then use this form as a template to clone others.

JavaScript

After setting up the function, we'll start by selecting a form to copy. Then, we'll clone it and loop through its inputs and labels correcting form references in the "name," "id," and "for" attributes. We'll reset values already present in the original form so those aren't inherited. We'll append the form to the bottom the set. And finally, we'll add a bit of error handling so our function runs gracefully.

Function Setup

The function we'll use to clone forms will be named cloneForm and accept the arguments "selector," "prefix," "removed_checked," and "additionalFn."

function cloneForm(selector, prefix, remove_checked = true, additionalFn) {}

The argument "selector" will be the css selector chosen to identify and keep separate the individual forms of the formset in the actual HTML. Assuming there's only one formset on the page and name collision isn't going to occur, I tend to use something generic like a class ".form-group." When rendering the template and looping through the formset's forms, I'll drop each form inside of a container element with this class.

Next, "prefix" is the form prefix. This is used with a regular expression to find and replace attributes of the target input fields. 

Next, I have the argument "remove_checked" with a default value of "true." If the form contains a checkbox input, the "checked" attribute will be inherited from the original form unless you explicitly remove it. By default, I want any "checked" input checkboxes to be unchecked when they're cloned. By setting this argument to "false," you can allow the cloned form to inherit the "checked" status of its ancestor.

Last, I have an argument for additionalFn. If you deploy formsets enough then you'll likely encounter a situation where you'll want to "extend" its functionality. This is a callback function that is passed to it an objected containing certain relevant attributes and is used to drop in further functionality like setting default values, etc.

Find & Clone

We start by selecting an existing form whithin the formset to be cloned. The position of this element is relevant because I want to later append my new form just after the ancestor. Because of this, I want to target the last element in the chain of forms. To do this, I'll use a combination of querySelectorAll() and the array method pop() to grab all form groups with my target selector and return the one in the final position.

The method pop() isn't available to the output of querySelectorAll() because querySelectorAll() returns a NodeList whereas pop() is a method of the object Array. Therefore, we need to first convert the NodeList to an Array by either using the "spread operator" or the Array.from() static method.

  const ancestor = [...document.querySelectorAll(selector)].pop()
  // or convert nodelist to array this way
  // const ancestor = Array.from(document.querySelectorAll(selector)).pop()

With our target form identified, we can now clone the object using the cloneNode() method.

const newForm = ancestor.cloneNode(true)

As you can see, this method accepts as a parameter the boolean value "deep." This isn't deep in the traditional sense of memory and relationship in making copies. Rather, it indicates whether you want to bring along the subtree with the copy or just keep the parent node. We'll use the value "true" because we want everything included.

Our newForm is an exact clone of its ancestor. Therefore, "id" attributes will be duplicated. Simply appending this object to the formset will result in invalid HTML.

Next, we need to loop through the inputs and their corresponding labels to update attributes for the correct form number. Remember, each input and label have a full prefix containing the form prefix as well as the form number. To fix this, we'll use a regular expression to locate the parts needing changing and update attribute values using the replace() method.

const regex = RegExp(`${ prefix }-(\\d+)-`)
const totalElement = document.querySelector('input[name$="-TOTAL_FORMS"]')
let total = totalElement.value

// grab all commonly encountered input types 
// exludes the less common types like 'progress,' etc.
newForm.querySelectorAll('input, select, textarea').forEach((input) => {
  const name = input.getAttribute('name').replace(regex, `${ prefix }-${ total}-`)
  const id = `id_${ name }`
  input.setAttribute('name', name)
  input.setAttribute('id', id)
  // clear any values already entered in ancestor
  input.value = ''
  // otherwise, closed form will inherit checked state of ancestor
  if (remove_checked) {
    input.removeAttribute('checked')
  }
})

newForm.querySelectorAll('label').forEach((label) => {
  const newFor = label.getAttribute('for').replace(regex, `${ prefix }-${ total}-`)
  label.setAttribute('for', newFor)
})

First, we build our "regex" (regular expression) using the RegExp object and passing a combination of a template literal and the expression pattern "\\d+" which will match any digit, or in our case, any form number.

You also need to provide a leading backslash to "\d" to escape the expression because it's nested within a string. Also, the "+" is necessary to match digits greater than 9. Without the "+," you'll just match forms 0 - 9. Ten forms on a screen is already a lot so maybe that's not a problem, but we might as well be cover the edge cases.

Next, we grab the current "FORM_TOTAL" to use as our next form's index number. You might think we need to increment this number first, but that's not the case here. While our "FORM_TOTAL" is a positive integer value representing our form count beginning at 1, our form index numbers, on the other hand, are indexed at 0. Therefore, using the "FORM_TOTAL" value does represent the next form index because the last form's index will be equal to "FORM_TOTAL" - 1. We'll increment this value at the end to update "FORM_TOTAL."

With those values in place, we loop through our inputs and labels using our regex to find and replace the attribute values specific to the form's index. At each step, we clear any values inherited from the ancestor. Last, we update the "for" attributes of our labels.

Update FORM_TOTAL & Append

With our new form cloned and initialized in the prior step, we're ready to update the "FORM_TOTAL" and append the new form to the bottom of the formset.

total++;
totalElement.value = total;
ancestor.after(newForm)

First we increment our total variable to reflect our additional form. Next, we set our totalElement, which maps to our hidden input for "FORM_TOTAL," to the incremented total value. And last, we append our new form after the ancestor using the after() method.

Additional Functionality (Callback Fn)

I want the ability to extend this function with additional functionality. For example, I might have a field that I need to initialize in a particular way or perform some other operation to the "newForm" element we just created. To accomplish this, I pass a function to the "cloneForm" function as an argument, and then pass it relevant resources grouped as an object. 

// additional functionality
if (additionalFn) {
  // allow the exception if not a function
  additionalFn({
    'newForm': newForm,
    'ancestor': ancestor,
    'total': total
  })
}

The three things I plan on needing are the "newForm," the "ancestor" element, and the "total" count. Add anything else you may need as an attribute to the object. Then, I can use this function arrangement like so:

cloneForm('.form-group', 'example', false, function(obj) {
  // callback function passed to cloneForm
  // do something else with 'newForm,' 'ancestor,' or 'total'
})

Together

When combined, the above implementation will look like:

function cloneForm(selector, prefix, remove_checked = true, additionalFn) {
  // grab the last item using pop() so we can append after/bottom
  // 'pop' method isn't avaialble to nodelist so we need to convert to array
  const ancestor = [...document.querySelectorAll(selector)].pop()
  // or convert nodelist to array this way
  // const ancestor = Array.from(document.querySelectorAll(selector)).pop()
  const newForm = ancestor.cloneNode(true)
  // use regex so me don't need to match ancestor num for replace to work
  const regex = RegExp(`${ prefix }-(\\d+)-`)
  const totalElement = document.querySelector('input[name$="-TOTAL_FORMS"]')
  let total = totalElement.value

  // grab all commonly encountered input types 
  // exludes the less common types like 'progress,' etc.
  newForm.querySelectorAll('input, select, textarea').forEach((input) => {
    const name = input.getAttribute('name').replace(regex, `${ prefix }-${ total}-`)
    const id = `id_${ name }`
    input.setAttribute('name', name)
    input.setAttribute('id', id)
    // clear any values already entered in ancestor
    input.value = ''
    // otherwise, closed form will inherit checked state of ancestor
    if (remove_checked) {
      input.removeAttribute('checked')
    }
  })

  // update references for label
  newForm.querySelectorAll('label').forEach((label) => {
    const newFor = label.getAttribute('for').replace(regex, `${ prefix }-${ total}-`)
    label.setAttribute('for', newFor)
  })

  total++;
  totalElement.value = total;

  // additional functionality
  if (additionalFn) {
    // allow the exception if not a function
    additionalFn({
      'newForm': newForm,
      'ancestor': ancestor,
      'total': total
    })
  }
  ancestor.after(newForm)
}

Error Handling

With the above implementation, we now have a workable solution to dynamically add formsets. However, we can improve this a bit by handling a few issues likely to occur. More specifically, I want to handle circumstances where (1) there isn't a form available or discovered that we can clone, and (2) we're trying to add one too many forms beyond what "MAX_NUM_FORMS" will allow.

I'll create separate Error() classes for each. When I call "cloneForm," I'll add the appropriate try/catch blocks to handle these errors.

class FormNotFoundError extends Error {
  constructor(message) {
    super(message)
    this.name = 'FormNotFoundError'
  }
}

class TooManyFormsError extends Error {
  constructor(message) {
    super(message)
    this.name = 'TooManyFormsError'
  }
}

Then in the function body, we'll check certain conditions and raise/throw these errors accordingly.

function cloneForm(selector, prefix, remove_checked = true, additionalFn) {
  const ancestor = [...document.querySelectorAll(selector)].pop()
  if (ancestor) {
    // at least one element matching the selector exists
    // continue
    const maxForms = document.querySelector('input[name$="-MAX_NUM_FORMS"]')
    const totalElement = document.querySelector('input[name$="-TOTAL_FORMS"]')
    if (maxForms.value === totalElement.value) {
      // we're already at the limit
      throw new TooManyFormsError('Unable to add additional form. ' +
                                  'Max Forms threshold has been met')
    } else {
      // continue
    }
  } else {
    // couldn't find an element to clone
    throw new FormNotFoundError('Unable to retrive existing form to clone. ' + 
                                'Check your selector is correc and at least one form exists.')
  }
}

If I don't have an ancestor, I throw the "FormNotFoundError." Then, I check if our total forms count already equals our maxForms value. If true, then I throw the "TooManyFormsError" stopping the flow and preventing an additional form from being added that ultimately wouldn't be able to validate on the server side.

Final Code

With a bit of error handling, we now have a complete solution.

// clone form

class FormNotFoundError extends Error {
  constructor(message) {
    super(message)
    this.name = 'FormNotFoundError'
  }
}

class TooManyFormsError extends Error {
  constructor(message) {
    super(message)
    this.name = 'TooManyFormsError'
  }
}

function cloneForm(selector, prefix, remove_checked = true, additionalFn) {
  // grab the last item using pop() so we can append after/bottom
  // 'pop' method isn't avaialble to nodelist so we need to convert to array
  const ancestor = [...document.querySelectorAll(selector)].pop()
  // or convert nodelist to array this way
  // const ancestor = Array.from(document.querySelectorAll(selector)).pop()
  if (ancestor) {
    // at least one element matching the selector exists
    const maxForms = document.querySelector('input[name$="-MAX_NUM_FORMS"]')
    const totalElement = document.querySelector('input[name$="-TOTAL_FORMS"]')
    if (maxForms.value === totalElement.value) {
      // we're already at the limit
      throw new TooManyFormsError('Unable to add additional form. ' +
                                  'Max Forms threshold has been met')
    } else {

      const newForm = ancestor.cloneNode(true)
      // use regex so me don't need to match ancestor num for replace to work
      const regex = RegExp(`${ prefix }-(\\d+)-`)
      let total = totalElement.value

      // grab all commonly encountered input types 
      // exludes the less common types like 'progress,' etc.
      newForm.querySelectorAll('input, select, textarea').forEach((input) => {
        const name = input.getAttribute('name').replace(regex, `${ prefix }-${ total}-`)
        const id = `id_${ name }`
        input.setAttribute('name', name)
        input.setAttribute('id', id)
        // clear any values already entered in ancestor
        input.value = ''
        // otherwise, closed form will inherit checked state of ancestor
        if (remove_checked) {
          input.removeAttribute('checked')
        }
      })

      // update references for label
      newForm.querySelectorAll('label').forEach((label) => {
        const newFor = label.getAttribute('for').replace(regex, `${ prefix }-${ total}-`)
        label.setAttribute('for', newFor)
      })

      total++;
      totalElement.value = total;
 
      // additional functionality
      if (additionalFn) {
        // allow the exception if not a function
        additionalFn({
          'newForm': newForm,
          'ancestor': ancestor,
          'total': total
        })
      }
      ancestor.after(newForm)
    }
  } else {
    // couldn't find an element to clone
    throw new FormNotFoundError('Unable to retrive existing form to clone. ' + 
                                'Check your selector is correc and at least one form exists.')
  }
}

Github

We can now hook into a button "click" event, call the function, and handle the errors with something like:

const cloneButton = document.querySelector('.control-group button[type="button"]')

cloneButton.addEventListener('click', () => {
  try {
    cloneForm('.form-group', 'example', false, function(obj) {
      // additional functionality
    })
  }
  catch (error) {
    if (error instanceof FormNotFoundError) {
      alert('We need a form to clone!')
    } else if (error instanceof TooManyFormsError) {
      alert('Too many forms!')
    }
  }
})

Preview

Here's a codepen demonstrating a working example of cloning forms!

See the Pen dynamic-django-formsets by Ian Waldron (@Ian-Waldron) on CodePen.

Final Thoughts

Using JavaScript is a great way to bring your Django formsets to life and improve the user experience. When I'm using formsets, the use case often involves an unknown quantity of data and I've found the above solutions handles these situations gracefully. But its not the only way. If you wanted to simplify the process, you could display more forms using the form_helper and discard the empties upon submission.

Enjoy!

Codepen Github