rmed.blog

Dynamic fields with Flask-WTF

02 Mar 2019

Lately I've been working on a side project that requires dynamically adding or removing fields to a form: ideally, I should be able to add any number of fields and the server should receive the data correctly.

While a combination of FieldList and FormField works great when adding fields in the backend, I wanted to dynamically add and remove fields in the client (through Javascript) without having send requests to the server until finally submitting the form with all the dynamic fields. Here's how that went.

TL;DR: Example code is available at https://gist.github.com/rmed/def5069419134e9da0713797ccc2cb29.

Scenario

Let's assume that we have a simple web application used to log the lap times of one or more runners: every time a runner completes a lap, we want to log the amount of time (in minutes) it took them to complete that lap. Once all runners have completed all laps, we will submit the results to the server.

For this scenario, we could use the following two forms:

  • MainForm: this would be the form submitted at the end of all laps and contains entries for each of the laps completed
  • LapForm: this sub-form would contain the name of the runner and the time it took them to complete the lap

In this case, LapForms will be added and removed dinamycally from the frontend.

Implementation

This example application will have two files: app.py and templates/index.html.

app.py

The app.py file will contain form definitions and the index (/) endpoint that manages the form submission and template rendering. Starting with the forms:

from flask_wtf import FlaskForm
from wtforms import Form, FieldList, FormField, IntegerField, StringField, \
        SubmitField

class LapForm(Form):
    """Subform.

    CSRF is disabled for this subform (using `Form` as parent class) because
    it is never used by itself.
    """
    runner_name = StringField('Runner name')
    lap_time = IntegerField('Lap time')


class MainForm(FlaskForm):
    """Parent form."""
    laps = FieldList(
        FormField(LapForm),
        min_entries=1,
        max_entries=20
    )

The previous lines implement the forms we defined earlier. Note that there will be at least 1 LapForm and a maximum of 20. This means that when accessing the page for the first time, we are guaranteed at least one lap subform and that we can only add a maximum of 20 lap subforms.

Now, let's implement the application and the main endpoint:

from flask import Flask, render_template

app = Flask(__name__)
app.config['SECRET_KEY'] = 'sosecret'

@app.route('/', methods=['GET', 'POST'])
def index():
    form = MainForm()

    if form.validate_on_submit():
        # Display form data
        return render_template(
            'index.html',
            form=form,
            data=form.data
        )

    return render_template(
        'index.html',
        form=form
    )

if __name__ == '__main__':
    app.run()

Putting it all together:

# -*- coding: utf-8 -*-
# app.py

from flask import Flask, render_template
from flask_wtf import FlaskForm
from wtforms import Form, FieldList, FormField, IntegerField, StringField, \
        SubmitField

class LapForm(Form):
    """Subform.

    CSRF is disabled for this subform (using `Form` as parent class) because
    it is never used by itself.
    """
    runner_name = StringField('Runner name')
    lap_time = IntegerField('Lap time')


class MainForm(FlaskForm):
    """Parent form."""
    laps = FieldList(
        FormField(LapForm),
        min_entries=1,
        max_entries=20
    )


app = Flask(__name__)
app.config['SECRET_KEY'] = 'sosecret'

@app.route('/', methods=['GET', 'POST'])
def index():
    form = MainForm()

    if form.validate_on_submit():
        # Display form data
        return render_template(
            'index.html',
            form=form,
            data=form.data
        )

    return render_template(
        'index.html',
        form=form
    )

if __name__ == '__main__':
    app.run()

templates/index.html

The template of the application has two main purposes:

  • To display current fields in the form (when submitting data)
  • To allow the user to add/remove fields as needed

First, let's take a look at the first point:

<html>
    <head>
        <title>Lap logging</title>

        <style>
            .is-hidden {
                display: none;
            }
        </style>
    </head>

    <body>
        <a id="add" href="#">Add Lap</a>

        {# Show all subforms #}
        <form id="lap-form" action="" method="POST" role="form">
            {{ form.hidden_tag() }}

            <div id="subforms-container">
                {% for subform in form.laps %}
                    <div id="lap-{{ loop.index0 }}-form" class="subform" data-index="{{ loop.index0 }}">
                        {{ subform.runner_name.label }}
                        {{ subform.runner_name }}

                        {{ subform.lap_time.label }}
                        {{ subform.lap_time}}

                        <a class="remove" href="#">Remove</a>
                    </div>
                {% endfor %}
            </div>

            <button type="submit">Send</button>
        </form>

        {% if form.errors %}
            {{ form.errors }}
        {% endif %}

        {# Show submitted data #}
        {% if data is defined %}
            <p>
                Received data: 
                {{ data }}
            </p>
        {% endif %}
    </body>
</html>

We end up with something like this:

Overview

If we tried to submit the form we would be sending a single lap subform (the only one present) and we would see the data received by the server.

However, how do we add new forms so that WTForms recognizes the new data? If we take a look at the HTML generated by Flask when rendering the template, we will see that the fields looke like the following:

<div id="lap-0-form" class="subform" data-index="0">
    <label for="laps-0-runner_name">Runner name</label>
    <input id="laps-0-runner_name" name="laps-0-runner_name" type="text" value="">

    <label for="laps-0-lap_time">Lap time</label>
    <input id="laps-0-lap_time" name="laps-0-lap-time" type="text" value="">

    <a class="remove" href="#">Remove</a>
</div>

Apparently, WTForms recognizes inputs laps-0-runner_name and laps-0-lap_time as the first LapForm so if we submitted a form which also included laps-1-runner_name and laps-1-lap_time, then WTForms would get both LapForm objects in order.

Given that the structure is not going to change anytime soon, why not create a template for subforms and then copy it whenever we wanted to add new fields dynamically? For instance, we could have:

<div id="lap-_-form" class="is-hidden" data-index="_">
    <label for="laps-_-runner_name">Runner name</label>
    <input id="laps-_-runner_name" name="laps-_-runner_name" type="text" value="">

    <label for="laps-_-lap_time">Lap time</label>
    <input id="laps-_-lap_time" name="laps-_-lap_time" type="text">

    <a class="remove" href="#">Remove</a>
</div>

Note that I replaced the index with a _ to uniquely identify the form. This character would be replaced with the corresponding index when inserting the new field.

Now that we have the template, we need to have the capability of adding and removing fields using the Add and Remove links defined in the template. For this, I will use the following Javascript functions (assuming JQuery is imported):

/**
 * Adjust the indices of form fields when removing items.
 */
function adjustIndices(removedIndex) {
    var $forms = $('.subform');

    $forms.each(function(i) {
        var $form = $(this);
        var index = parseInt($form.data('index'));
        var newIndex = index - 1;

        if (index < removedIndex) {
            // Skip
            return true;
        }

        // Change ID in form itself
        $form.attr('id', $form.attr('id').replace(index, newIndex));
        $form.data('index', newIndex);

        // Change IDs in form inputs
        $form.find('input').each(function(j) {
            var $item = $(this);
            $item.attr('id', $item.attr('id').replace(index, newIndex));
            $item.attr('name', $item.attr('name').replace(index, newIndex));
        });
    });
}

/**
 * Remove a subform.
 */
function removeForm() {
    var $removedForm = $(this).closest('.subform');
    var removedIndex = parseInt($removedForm.data('index'));

    $removedForm.remove();

    // Update indices
    adjustIndices(removedIndex);
}

/**
 * Add a new subform.
 */
function addForm() {
    var $templateForm = $('#lap-_-form');

    if (!$templateForm) {
        console.log('[ERROR] Cannot find template');
        return;
    }

    // Get Last index
    var $lastForm = $('.subform').last();

    var newIndex = 0;

    if ($lastForm.length > 0) {
        newIndex = parseInt($lastForm.data('index')) + 1;
    }

    // Maximum of 20 subforms
    if (newIndex > 20) {
        console.log('[WARNING] Reached maximum number of elements');
        return;
    }

    // Add elements
    var $newForm = $templateForm.clone();

    $newForm.attr('id', $newForm.attr('id').replace('_', newIndex));
    $newForm.data('index', newIndex);

    $newForm.find('input').each(function(idx) {
        var $item = $(this);

        $item.attr('id', $item.attr('id').replace('_', newIndex));
        $item.attr('name', $item.attr('name').replace('_', newIndex));
    });

    // Append
    $('#subforms-container').append($newForm);
    $newForm.addClass('subform');
    $newForm.removeClass('is-hidden');

    $newForm.find('.remove').click(removeForm);
}


$(document).ready(function() {
    $('#add').click(addForm);
    $('.remove').click(removeForm);
});

The following functions work as follows:

  • adjustIndices: When removing a subform, adjusts the indices of the next subforms so that there is no gap in the numbering
  • removeForm: Removes a subform
  • addForm: Copies the template and assigns it the index corresponding to the last subform + 1

The complete templates/index.html file looks as follows:

<html>
    <head>
        <title>Lap logging</title>

        {# Import JQuery #}
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>

        <script>
            /**
             * Adjust the indices of form fields when removing items.
             */
            function adjustIndices(removedIndex) {
                var $forms = $('.subform');

                $forms.each(function(i) {
                    var $form = $(this);
                    var index = parseInt($form.data('index'));
                    var newIndex = index - 1;

                    if (index < removedIndex) {
                        // Skip
                        return true;
                    }

                    // Change ID in form itself
                    $form.attr('id', $form.attr('id').replace(index, newIndex));
                    $form.data('index', newIndex);

                    // Change IDs in form inputs
                    $form.find('input').each(function(j) {
                        var $item = $(this);
                        $item.attr('id', $item.attr('id').replace(index, newIndex));
                        $item.attr('name', $item.attr('name').replace(index, newIndex));
                    });
                });
            }

            /**
             * Remove a form.
             */
            function removeForm() {
                var $removedForm = $(this).closest('.subform');
                var removedIndex = parseInt($removedForm.data('index'));

                $removedForm.remove();

                // Update indices
                adjustIndices(removedIndex);
            }

            /**
             * Add a new form.
             */
            function addForm() {
                var $templateForm = $('#lap-_-form');

                if (!$templateForm) {
                    console.log('[ERROR] Cannot find template');
                    return;
                }

                // Get Last index
                var $lastForm = $('.subform').last();

                var newIndex = 0;

                if ($lastForm.length > 0) {
                    newIndex = parseInt($lastForm.data('index')) + 1;
                }

                // Maximum of 20 subforms
                if (newIndex > 20) {
                    console.log('[WARNING] Reached maximum number of elements');
                    return;
                }

                // Add elements
                var $newForm = $templateForm.clone();

                $newForm.attr('id', $newForm.attr('id').replace('_', newIndex));
                $newForm.data('index', newIndex);

                $newForm.find('input').each(function(idx) {
                    var $item = $(this);

                    $item.attr('id', $item.attr('id').replace('_', newIndex));
                    $item.attr('name', $item.attr('name').replace('_', newIndex));
                });

                // Append
                $('#subforms-container').append($newForm);
                $newForm.addClass('subform');
                $newForm.removeClass('is-hidden');

                $newForm.find('.remove').click(removeForm);
            }


            $(document).ready(function() {
                $('#add').click(addForm);
                $('.remove').click(removeForm);
            });
        </script>

        <style>
            .is-hidden {
                display: none;
            }
        </style>
    </head>

    <body>
        <a id="add" href="#">Add Lap</a>

        {# Show all subforms #}
        <form id="lap-form" action="" method="POST" role="form">
            {{ form.hidden_tag() }}

            <div id="subforms-container">
                {% for subform in form.laps %}
                    <div id="lap-{{ loop.index0 }}-form" class="subform" data-index="{{ loop.index0 }}">
                        {{ subform.runner_name.label }}
                        {{ subform.runner_name }}

                        {{ subform.lap_time.label }}
                        {{ subform.lap_time}}

                        <a class="remove" href="#">Remove</a>
                    </div>
                {% endfor %}
            </div>

            <button type="submit">Send</button>
        </form>

        {% if form.errors %}
            {{ form.errors }}
        {% endif %}

        {# Form template #}
        <div id="lap-_-form" class="is-hidden" data-index="_">
            <label for="laps-_-runner_name">Runner name</label>
            <input id="laps-_-runner_name" name="laps-_-runner_name" type="text" value="">

            <label for="laps-_-lap_time">Lap time</label>
            <input id="laps-_-lap_time" name="laps-_-lap_time" type="text">

            <a class="remove" href="#">Remove</a>
        </div>


        {# Show submitted data #}
        {% if data is defined %}
            <p>
                Received data: 
                {{ data }}
            </p>
        {% endif %}

    </body>
</html>

Testing

In order to test the application, simply run python3 app.py and try adding and removing fields. The end result should look like this:

Dynamic fields and data

Tags: flask wtforms dynamic fields javascript

rmed

My name is Rafael Medina, and I like code.

More about me.