Django formsets tutorial series: Dynamic

Version info

For this tutorial, I used Python version 3.8 and Django version 3.1.

Prerequisites

You'll want to do the first part of this series before proceeding.

This part involves a lot of JavaScript, since that's what's used to change the content of a web page without any page refreshes (dynamically). If you really want to learn JavaScript properly, there's a plethora of online resources for getting started. freeCodeCamp has some great stuff, including this beginner-friendly video tutorial series. That said, I only assume a basic understanding of JavaScript and how it interacts with the Document Object Model, and I'll try to make the steps as clear as possible.

Since we need to use JavaScript we also have to configure Django for using static files. I assume a basic understanding of how this works, so if this is new to you then have a look at the Django docs on static files and/or try listening to this Django Riffs podcast episode.

Aim

Learning the very basics of adding and removing forms dynamically for Django formsets

About the code quality

The JavaScript code here won't be polished or efficient - it's all vanilla JavaScript, and it will often be 'clumsy' on purpose to avoid using more advanced syntax (and to provide you with a learning exercise at the end). You wouldn't want to use the JavaScript code in a real project - this is just to help you understand the building blocks and to get you started exploring how you can make Django/formsets and JavaScript interact.

If you want a more ready-made solution instead, or if you want to explore it after going through this tutorial, you might be interested in the django-dynamic-formset package. Nicole Harris has written a tutorial about using formsets with it, though the tutorial is quite old and might be partly outdated. I haven't read this particular post by Harris, but other material from her that I have read has been really good.

Recap and introduction

After finishing the first tutorial, we had a 'treasures' app with a TreasureForm, a HTML template for inserting generated HTML forms into, and a view treasureform_view for linking the two together. If you haven't saved the project directory from the first tutorial, clone (or download as a .zip) the series' github repo and use the 'part1_explorers_project' directory as a starting point for this tutorial. If you do that, remember to run pipenv install if using pipenv, or activating the virtual environment and running pip install -r requirements.txt if using venv, in a terminal after cd:ing into the 'part1_explorers_project' directory, in order to install the necessary packages.

Currently our view always generates the HTML for three treasure forms and expects three sets of treasure input to be submitted with POST requests. But what if the explorer has found more than three treasures and wants to submit them all in one go? Having to go back and send multiple requests isn't user-friendly. Or what if an explorer has just one treasure to report? It's confusing and might cause problems if empty forms have to be submitted in this case. To make the form submission process a bit more interactive, we'll use JavaScript.

(this tutorial also has a corresponding directory in the github repo - this might come in handy if you're having trouble with eg directory structure or want to see all the JavaScript in one file)

Slightly improving the HTML

Since we didn't do much with the HTML in the first part we were a bit sloppy about it. Now that we need to use JavaScript, and for good web design in general really, we should add identifiers for the important elements.

Open up the 'root_directory/part1_explorers_project/treasures/templates/treasures/treasure_submit.html' file. It should look like this:

<!doctype html>

<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Treasure submission</title>
</head>

<body>
    <form method='POST'>{% csrf_token %}
      {{ treasure_formset.management_form.as_p }}
      {% for treasure_form in treasure_formset.forms %}
        <h2>Form number {{ forloop.counter0 }}</h2>
        {{ treasure_form.as_p }}
        <hr>
      {% endfor %}
      <input type='submit' value="Submit treasure">
    </form>
</body>
</html>

Note that we haven't used id= attributes for any of the elements. This is bad design in general, and also means that we don't have easy-access 'hooks' for JavaScript to use. Let's change this by adding id's for the <form> and submit <input> elements.

<!-->...<-->
    <form id="treasure-form" method='POST'>{% csrf_token %}
<!-->...<-->
        <h2 id="form-{{ forloop.counter0 }}-h2">Form number {{ forloop.counter0 }}</h2>
<!-->...<-->
      <input id="submit-button" type='submit' value="Submit treasure">
<!-->...<-->

Let's go ahead and also add simple 'add/remove form' buttons, which we will make use of later.

<!doctype html>

<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Treasure submission</title>
</head>

<body>
    <button id="add-form-btn">Add form</button> <!-- new -->
    <button id="remove-form-btn">Remove form</button> <!-- new -->
    <form id="treasure-form" method='POST'>{% csrf_token %}
      {{ treasure_formset.management_form.as_p }}
      {% for treasure_form in treasure_formset.forms %}
        <h2 id="form-{{ forloop.counter0 }}-h2">Form number {{ forloop.counter0 }}</h2>
        {{ treasure_form.as_p }}
        <hr>
      {% endfor %}
      <input id="submit-button" type='submit' value="Submit treasure">
    </form>
</body>
</html>

Adding a static directory

JavaScript, .js, files are a kind of what Django calls 'static files'. In order to use static files a bit of configuration is necessary.

Start by adding two folders to your root directory, 'static' and 'static_root'. Your root directory should now look something like this:

.
├── Pipfile
├── Pipfile.lock
├── db.sqlite3
├── explorer_project
│   └── ...
├── manage.py
├── requirements.txt
├── static
├── static_root
└── treasures
    └── ...

Now go to the project's 'settings.py' file and add this at the bottom:

# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/

STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'static_root'
STATICFILES_DIRS = [BASE_DIR / 'static']

(you probably already have the STATIC_URL line - in that case, just copy the last two lines)

This tells Django

  • STATICFILES_DIRS: Directories where Django should go looking for static files to collect, and/or to serve during development/debugging.
  • STATIC_ROOT: "The absolute path to the directory where collectstatic will collect static files for deployment." (Django docs)
  • STATIC_URL: What URL/path that static files should be accessible at when making requests.

What this means is that you should put whatever files you want Django to use inside of the root directory's (BASEDIR's) subdirectory 'static', since we put STATICFILES_DIRS = [BASE_DIR / 'static']. If you then run python manage.py collecstatic in the terminal, Django will copy all of your static files to the 'staticroot' directory, since we specified STATIC_ROOT = BASE_DIR / 'static_root'. Then, when Django is running and serving your site, the static files will be made available at 'www.example.com/static/...', because of STATIC_URL = '/static/'. If this is confusing, try the resources I suggested at the beginning of this and the first tutorial.

The python manage.py collectstatic step can be skipped during development, when you have DEBUG set to True. During this tutorial we won't be running collectstatic since we're learning, but do remember that you will have to use collectstatic in production, unless you are using an entirely different solution for serving static files.

An additional note is that another alternative for storing static files would be to put the files inside of a 'static' subdirectory of the 'treasures' app itself, as described in the Django docs. To keep things simple I'll stick to gathering all static files in one directory (specified with STATICFILES_DIRS).

JavaScript 'Hello World'

Creating the file

We're now ready to create our .js file. Since the code will be highly specific and only relevant to our 'treasures' app, let's put it in a subdirectory within the 'static directory' (again, an alternative here would have been to use a 'static' subdirectory of the app itself). In fact, let's go one level further, creating a 'js' directory within the app's static files subdirectory. This is line with the web development convention of putting JavaScript files in a dedicated 'js' directory. Confusing? This should make things clear:

...
├── manage.py
├── requirements.txt
├── static
│   └── treasures
│       └── js
│           └── form.js
├── static_root
...

From the terminal, after cd'ing to the root directory you can add the new subdirectories and the file using these commands:

mkdir static/treasures
mkdir static/treasures/js
touch static/treasures/js/form.js

Open up your 'form.js' file and add the following:

// static/treasures/js/form.js
alert("Hello JavaScript!");

Save your file (remember to do this after every change you make during the tutorial).

Accessing the JavaScript file directly

Because we set STATIC_URL = '/static/', from any client's (e. g. browser's) point of view, our JavaScript file will be available at the url 'localhost:8000/static/treasures/js/form.js' once we run the server. Let's try it out!

In the terminal, run python manage.py runserver, and go to 'localhost:8000/static/treasures/js/form.js'. You should see the raw text contents of the JavaScript file itself. Normally, of course, clients won't access the JavaScript files directly like this. The reason we did was just to check that static files are being served as expected. If you had any issues, please see if you get any helpful error messages, and double check your settings.py file as well as the directory/file names you've used. It's easy to miss some small detail here.

If you want, you can also try running python manage.py collecstatic in the terminal now. You should get a message akin to "133 static files copied to '/Users/datalowe/.../root_directory/static_root'". The reason that there are so many static files, much more than our single JavaScript file, is that Django's included admin interface uses a bunch of them, so it's nothing to worry about. Note that running this command doesn't have any actual effect while DEBUG is set to True, and so you don't really need it for the tutorial - this is only if you want to try the command out.

Linking to our JavaScript file from our HTML file

In order to have a client's browser actually run the JavaScript we've written, we need to include instructions for this in our page's HTML file. There are different ways to do this, and recommendations have varied over time. This is not a tutorial for JavaScript best practices however, so we'll use a simple method that gets us going.

  1. Add the Django template tag {% load static %} for loading static files to the top of your HTML.
  2. Add <script src="{% static 'treasures/js/form.js' %}"></script> just before your closing </body> tag.

Your HTML file should now basically look like this:

{% load static %}
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Treasure submission</title>
</head>

<body>
    <!-- ...omitted... -->
    <script src="{% static 'treasures/js/form.js' %}"></script>
</body>
</html>

The {% load static %} tag tells Django that you want to use its static files serving capabilities. Without it, you can't use the {% static %} at all. This is very easy to forget, so whenever you run into issues with static files/JavaScript, always check to make sure you've included {% load static %}.

{% static 'treasures/js/form.js' %} tells Django that you want to get the client-facing path for accessing the static file. In this case, that means '/static/treasures/js/form.js'.

The <script> HTML tag is used for inserting JavaScript. You could insert JavaScript right there in the HTML file, but it's always better to keep JavaScript in dedicated .js files, in order to keep your HTML and JavaScript separate. The src attribute points to the address/path from which the client's browser should retrieve the JavaScript file. What this means is that when the user visits your page, their browser will make an initial request, get HTML back, load the HTML and render the page, and then make an additional request to your server, this time for the JavaScript file. Once the browser gets the JavaScript code back, it runs the code. If you're not familiar with it already, this is a very important concept. It means that any .js files we make accessible as static files won't be run by our server - it's all run in the client's browser.

Now, try reloading the page in your browser. You should get a pop-up window saying 'Hello JavaScript!'. If you don't, then double check what you've written in the HTML file, try shutting down and re-opening your browser, and if possible test another browser to see if that makes any difference.

Grabbing elements and retrieving data

Grabbing the <form> element

Now that we know the JavaScript file is loaded correctly, remove the 'Hello JavaScript!' line.

If we're going to change the page dynamically, we need to first access objects/elements in the Document Object Model (DOM), 'nodes' that often (though not necessarily) correspond to HTML tags in your HTML file. We can do this by using the JavaScript document.getElementById function, since we deftly added those id= attributes to our HTML.

Which element do we want to access? We at least want the <form> element, as this is what we will be adding and removing 'child nodes' to/from. This element 'wraps around' (is the parent node of) all of the p elements which are generated by Django for representing components of 'forms' as seen from Django's perspective. In our example, the <form> element wraps around three pairs of <p> elements (so 6 <p>s total), with each pair corresponding to one 'Django form'. This can be really confusing, but hopefully it makes sense based on what you learned in the previous tutorial, or it will soon enough.

// static/treasures/js/form.js
const wrapFormEl = document.getElementById("treasure-form");
console.log(wrapFormEl);

Now try visiting/reloading your form page again. This time, you won't get an alert, since we're now using console.log instead of the alert function (relying on alerts for debugging gets old real fast). Instead, what you want to do is open up your browser's web console/terminal. If you don't know how to do this, you can find instructions for Firefox here - if you use some other browser, search and you will find.

Once you've opened up the web console, you should see something like <form id="treasure-form" method="POST">. This means that your JavaScript code successfully retrieved a reference to the form element, and has printed some info about it.

As we go, I will assume that you remove the console.log commands/lines after you've ensured they produce the expected output, while leaving all other code intact.

Grabbing the submit button

When adding extra forms (<p> elements wrapping <input> elements), we want to add them just before the submit button. So let's grab that as well.

const submitBtn = document.getElementById("submit-button");
console.log(submitBtn);

Retrieving the total number of forms currently on the page

In order to be able to add/remove forms, we need to know how many are currently on the page (in the DOM). Do you remember from part 1 how Django includes a hidden 'managementform' for formsets? This corresponds to a set of <input> HTML tags which Django generates for us. In particular, we want the input element that has an id attribute of "idform-TOTAL_FORMS", since this serves as a counter of the total number of 'Django forms' on the page. The element keeps count in its value attribute, and hence you can access the number of forms by adding this to your JavaScript file:

const totalFormsInput = document.getElementById("id_form-TOTAL_FORMS");
const totalFormsValue = totalFormsInput.value;
console.log(totalFormsValue);

Now refresh the page in your browser and check the console - you should see an output of 3 there.

Adding elements

Adding a single heading

We now have the information we need to start adding more DOM objects/elements. First, let's create a brand new <h2> element with document.createElement.

const newFormH2 = document.createElement("h2");

This element is like a node floating freely in space right now - we haven't attached it to the DOM, so it's as abstract an object as regular variables are. It does have some properties which relate to actual HTML content/attributes however. Using one of these properties, let's define what HTML it should contain.

newFormH2.innerHTML = `
    Totally cool new form
`;

We also need an id for the element, since we updated the django template above to assign each <h2> element an id based on form number. Hence, we need to use the value we retrieved for the current number of forms count. Remember that Django uses 0-indexing when naming forms, so if there are already 3 forms (ID'd using 0, 1, 2), the new one should simply use 3 for generating name/id values.

newFormH2.id = "form-" + totalFormsValue + "-h2";
newFormH2.innerHTML = `
    Totally cool new form
`;

Great! Now we just need to stick the element somewhere in the DOM. We use the objects already in the DOM, which we retrieved references for, to specify exactly where the new element should go. The insertBefore method of DOM nodes comes in handy here.

wrapFormEl.insertBefore(newFormH2, submitBtn);

The syntax is pretty self-explanatory; "insert the newFormH2 element as a child node of wrapFormEl (the <form> element), just before where the submitBtn element is located".

Refresh the page, and you should see that 'Totally cool new form' has been added just above the submit button.

What to add?

Let's think about what elements we need in order to create a representation of a django form. As you might recall from the first tutorial, the .as_p() method of Django formsets generates output/HTML like this

<p><label for="id_form-0-name">Name:</label> <input type="text" name="form-0-name" value="Toyota AE86" maxlength="100" id="id_form-0-name"></p>
<p><label for="id_form-0-estimated_price">Estimated price:</label> <input type="number" name="form-0-estimated_price" value="3000" id="id_form-0-estimated_price"></p>

So, we can see that in our case, each 'form' consists of two <p> elements, as I mentioned earlier. Each <p> in turn holds a <label> and an <input> element. The <label> elements need to have for attributes with values that are identical to the id attribute values of their corresponding inputs. The names and id's follow a predictable pattern of 'form-<formid>-<name|estimatedpriced>' and the same thing again but prepended with 'id_'. We also need to specify the type attribute for each input, include the label text itself inside of (ie as 'innerHTML') the <label> elements, and set the maxlength attribute for the name input to avoid invalid input.

Add name input

We've figured out what we need, which is really the hard part if you already know JavaScript, so let's create the first <p> element and its child nodes:

const newNameP = document.createElement("p");
const newNameLabel = document.createElement("label");
const newNameInput = document.createElement("input");

Let's now go on to specifying DOM object properties (which here again correspond to HTML attributes or HTML content) of the <label> and <input> elements. In the process we need to generate the name and id values, again using the value we retrieved for the current number of forms count.

// Specify the content and attributes of the <label> element
newNameLabel.innerHTML = "Name: ";
newNameLabel.for = "id_form-" + totalFormsValue + "-name";
// Specify the content and attributes of the <input> element
newNameInput.name = "form-" + totalFormsValue + "-name";
newNameInput.id = "id_form-" + totalFormsValue + "-name";
newNameInput.maxlength = 100;
newNameInput.type = "text";

Alright, with the objects created and their properties set, we can add the input and label as child nodes of the <p> element, then append that in turn to the DOM, much like we did with the heading.

// Add the <label>/<input> elements as children of the wrapping <p> element
newNameP.appendChild(newNameLabel);
newNameP.appendChild(newNameInput);

// Add the new <p> element as a child node of the HTML <form> element, 
// inserting it right before the submit button <input> element
wrapFormEl.insertBefore(newNameP, submitBtn);

Refresh the page in your browser and see if there's a 'Name:' input under 'Totally cool new form', like we'd expect.

Add price input

This is more or less a repetition of what we did above, so I'll just paste the code.

// Create the inputs, with corresponding label elements, and
// wrapping <p> element for treasure "estimated price" input
const newPriceP = document.createElement("p");
const newPriceLabel = document.createElement("label");
const newPriceInput = document.createElement("input");
// Specify the content and attributes of the <label> element
newPriceLabel.innerHTML = "Estimated price: ";
newPriceLabel.for = "id_form-" + totalFormsValue + "-estimated_price";
// Specify the content and attributes of the <input> element
newPriceInput.id = "id_form-" + totalFormsValue + "-estimated_price";
newPriceInput.name = "form-" + totalFormsValue + "-estimated_price";
// Add the <label>/<input> elements as children of the wrapping p element
newPriceP.appendChild(newPriceLabel);
newPriceP.appendChild(newPriceInput);

Refresh the page to check the results again.

Add an <hr> tag

Since we used an <hr> element in our template to add a horizontal line (a dirty trick, as HTML experts will tell you, but fine for our purposes) after every form, let's do that for this new form too:

const newHr = document.createElement("hr");
wrapFormEl.insertBefore(newHr, submitBtn);

Don't forget about the meta

Everything looks fine now, right? Yes it does! But it still isn't. If you try to submit with all four 'Django forms' (pairs of input) filled out, Django won't understand that there is an 'extra' form in the formset. The reason is that we haven't explicitly told Django about it. We need to update the hidden inputs related to the management form. Specifically, we need to explain to Django that there are now four forms.

Luckily, we already created a handy reference to the input whose value describes the total number of forms in use, and so we just need to increment its value property (and thereby the corresponding HTML attribute).

totalFormsInput.value++;

That's it! You'll note however that the message Django sends back to us doesn't say anything about the fourth set of inputs. This is just an artifact of how we defined the view back in the first tutorial.

Show me what you got

Open up 'treasures/views.py'. The way the response message is generated upon form submission, it only ever uses three forms, regardless of how many are available. Let's change this so that the code can handle any number of additional or removed forms.

# treasures/views.py
def treasureform_view(request):
# ...
        if treasure_formset.is_valid() and treasure_formset.has_changed():
            message = ''
            for treasure_data in treasure_formset.cleaned_data:
                treasure_name = treasure_data['name']
                treasure_price = treasure_data['estimated_price']
                message += (
                    f'Thanks for submitting {treasure_name}, with an '
                    f'estimated price of {treasure_price}!\n'
                )
            if not message:
                message = "I'm so sorry you didn't find any treasure."
            return HttpResponse(message)
# ...

Stay awhile and listen

What we just did means that a form is, technically speaking, dynamically added to the page. That is, the DOM/page is changed by the browser running some JavaScript, after the initial HTML has been loaded and the page has been rendered. From a user's perspective however, this wouldn't seem very 'dynamic' in layman terms. After all, the change is instantaneous enough that the user wouldn't even notice that the fourth set of inputs wasn't there to begin with.

To make the website more interactive and 'obviously' dynamic, let's allow for the user to add forms at will. The way we do this is with an event listener - a 'function that waits for an event to occur'. The event in question, for us, is the user clicking or tapping (if on a touch device) on a button. This is why we added the 'add-form-btn' earlier.

As usual, let's grab the element we want to do something with.

const addFormBtn = document.getElementById('add-form-btn');

We also need a function that is to be called as soon as the user clicks/taps the button. What do we want to happen when the user clicks the 'add form' button? Why, all the things that we already wrote. So what are we gonna do? I mentioned at the outset that we wouldn't be doing any pretty JavaScript in this tutorial. Let's chuck all of our code inside of a function. Well, almost - we'll skip the parts related to grabbing references to DOM objects, and we'll make sure that the form headings are slightly more descriptive.

function addForm() {
    const totalFormsValue = totalFormsInput.value;

    const newFormH2 = document.createElement("h2");
    // Slightly updated 
    newFormH2.innerHTML = "Cool new form no. " + totalFormsValue;
    newFormH2.id = "form-" + totalFormsValue + "-h2";
    wrapFormEl.insertBefore(newFormH2, submitBtn);


    const newNameP = document.createElement("p");
    const newNameLabel = document.createElement("label");
    const newNameInput = document.createElement("input");
    newNameLabel.innerHTML = "Name: ";
    newNameLabel.for = "id_form-" + totalFormsValue + "-name";
    newNameInput.name = "form-" + totalFormsValue + "-name";
    newNameInput.id = "id_form-" + totalFormsValue + "-name";
    newNameInput.maxlength = 100;
    newNameInput.type = "text";
    newNameP.appendChild(newNameLabel);
    newNameP.appendChild(newNameInput);
    wrapFormEl.insertBefore(newNameP, submitBtn);


    const newPriceP = document.createElement("p");
    const newPriceLabel = document.createElement("label");
    const newPriceInput = document.createElement("input");
    newPriceLabel.innerHTML = "Estimated price: ";
    newPriceLabel.for = "id_form-" + totalFormsValue + "-estimated_price";
    newPriceInput.id = "id_form-" + totalFormsValue + "-estimated_price";
    newPriceInput.name = "form-" + totalFormsValue + "-estimated_price";
    newPriceInput.type = "number";
    newPriceP.appendChild(newPriceLabel);
    newPriceP.appendChild(newPriceInput);
    wrapFormEl.insertBefore(newPriceP, submitBtn);

    const newHr = document.createElement("hr");
    wrapFormEl.insertBefore(newHr, submitBtn);

    totalFormsInput.value++;
}

With this, we have a reference to the object corresponding to the button that the user is to click, and a function that should be triggered when a click happens. All we need to do is link them up with addEventListener.

addFormBtn.addEventListener('click', addForm);

Refresh the page and try clicking the 'Add form' button. Hopefully you'll see new forms pop up.

Removing elements

Removing forms involves a very similar procedure to what we did for adding them, only in reverse. The most difficult part is being able to specify exactly which objects you want to have removed. Something that would have helped here is having each 'django form' along with its heading and 'horizontal line' (<hr> element) wrapped inside of an element which has a unique id (see the 'Suggested exercises' section at the end). But things aren't too bad, all we need to do is traverse ('go up/down or across') the DOM a bit.

Remember, we already have a way of figuring out what the highest-numbered form is.

// since Django uses 0-indexing for form numbering, we need to decrease
// the count by 1
const highestFormNumber = totalFormsInput.value - 1;

Now we want to get a reference to some element that we know is part of, or directly related to, the 'django form' that is to be removed. There are a few choices for this, but this should work:

const removeH2 = document.getElementById("form-" + highestFormNumber + "-h2");

Only removing this node from the DOM wouldn't be enough - that would mean removing the heading, but the 'django form' components and the 'horizontal line' would hang around. To get references to these, which we know always come directly after the heading, we use the nextSibling method. First, we'll get the <p> element which contains components related to the 'name' input.

const removeP1 = removeH2.nextElementSibling;

Now how do we get to the other <p> element, for 'estimated price' input? We know that it comes after the first <p>, so we just make one more jump. Then we make another, to get ahold of the <hr> element:

const removeP2 = removeP1.nextElementSibling;
const removeHr = removeP2.nextElementSibling;

We also need a reference to the elements' parent node, which is the <form> element. We could use any of the elements' parentNode property for this, but we actually already have a reference to the <form>, by way of the variable wrapFormEl.

Let's finally get rid of these nodes, by using removeChild.

wrapFormEl.removeChild(removeH2);
wrapFormEl.removeChild(removeP);
wrapFormEl.removeChild(removeHr);

And let's not forget to decrease the total form count.

totalFormsInput.value--;

Saving the file and refreshing the page in your browser, you should find that the 'extra' form that we added dynamically is now gone. What happens is that the browser, after rendering the page and requesting the JavaScript code, adds the 'extra' form and then instantly removes it again (as it is the 'highest-numbered' form).

Listening, again

You've probably already guessed what's next. We wrap all of the things we did with a function, then make it trigger upon user clicks on the 'remove-form-btn'. We need an if-conditional though. We should also consider an issue we run into. See the comments in the code.

const removeFormBtn = document.getElementById('remove-form-btn');

// get hidden input that counts the number of 'initial value' forms
const initialFormsInput = document.getElementById("id_form-INITIAL_FORMS");

function removeForm() {
    const highestFormNumber = totalFormsInput.value - 1;
    // is there at least one form left to remove?
    if (totalFormsInput.value > 0) {
        console.log("form-" + highestFormNumber + "-h2");
        const removeH2 = document.getElementById("form-" + highestFormNumber + "-h2");
        const removeP1 = removeH2.nextElementSibling;
        const removeP2 = removeP1.nextElementSibling;
        const removeHr = removeP2.nextElementSibling;
        wrapFormEl.removeChild(removeH2);
        wrapFormEl.removeChild(removeP1);
        wrapFormEl.removeChild(removeP2);
        wrapFormEl.removeChild(removeHr);
        totalFormsInput.value--;
    }
}

removeFormBtn.addEventListener('click', removeForm);

Hopefully you can mostly understand what's going on here. Otherwise, please compare this code to what we've done so far in the tutorial.

Something you might be wondering is why we retrieve a reference to the 'initial value forms counter' input, even though we don't do anything with it. The thing is that there's a bug in the code right now. If the user removes the first two 'non-extra forms', which have initial values, the 'initial value forms counter' needs to be decreased to reflect this. How could you rewrite the function to make sure that the counter is correctly decreased?

Summary

Well done! We've discussed:

  • HTML generated by Django formsets
  • Using static files (and static template tags) and setting up related configurations
  • Accessing and manipulating nodes through the DOM
  • 'Dynamic' as a technical term and as it relates to interactivity
  • Event listeners
  • Updating values submitted to the back-end

For me, combining different technologies and seeing their interplay is one of the most illuminating things there are when it comes to computer science. I hope this tutorial helped you discover something new and that you found the excursion worthwhile.

Suggested exercises

Practice makes perfect, and the code written here is far from perfect! Here are a few suggestions for what you could do to further explore the concepts we've gone through. You could also try to figure out entirely different ways to apply what you've learned, preferrably in some project of your own.

  1. Fix the bug related to the 'initial forms counter' mentioned in the 'Removing elements' section.
  2. Reorganize and tidy up the JavaScript. A good start is to remove the redundant code which instantly adds/removes an 'extra' form.
  3. Set a minimum number of forms, by use of JavaScript, and display a message if the user tries to remove even more forms.
  4. See if you can change the HTML/DOM structure to simplify adding/removing elements.
  5. Dynamically add more forms with initial data (see part 1), remembering to update the value of the hidden input with id 'idform-INITIALFORMS'.
  6. (if you know how CSS works) Add a simple CSS styling sheet static file and link to it from the template.
  7. (if you already know more advanced JavaScript) Rewrite the JavaScript code, making it more eloquent by using eg arrays/array methods.
  8. (if you already know more advanced JavaScript) Rewrite the JavaScript code so that instead of putting all of the variables etc in the global namespace, you make use of the singleton pattern.
  9. (if you already know more advanced JavaScript) Allow the user to submit treasure data without leaving the page, and show a response in a new HTML element/DOM object.

Next time

We still have left:

  • using formsets to save data to the database
  • changing what "blueprint" the formset_factory function is to use for building formset classes

I'll see when I can find the time for writing about this. Until then, thanks for reading!

Please feel free to contact me at datalouvre@gmail.com if you have any feedback or questions. You can also commit PR's or raise issues on the series' github repo.

Addendum: I've received some very nice feedback in a Django forum thread, including descriptions of a few better practices and alternative methods to what's described here.