Flask with multiple forms on a page posted using AJAX and returning a rendered form result

22 February 2020 Updated 22 February 2020 by Peter

Returning a rendered form to the client means the client does not need to know much about the form and reduces client side coding.

post main image
https://unsplash.com/@emilydafinchy

While working on comments system for the first time I bounced into the problem of having multiple WTForms forms on a single page. On every page there also is the search form but this is not a POST form. It is doing a GET. The comments system uses two forms:

  • Comment form
  • Comment reply form

The comment form is immediately below the content item, blog post or page, the comment reply form is initially hidden and inserted and shown when the visitor clicks the Reply button. The forms are almost identical, the main difference is that the comment reply form has one extra parameter, the parent_id. When the comment reply form is shown, the parent_id is copied into the form. Other differences are more textual. Both forms could be sqeezed into a single form but that would cause other difficulties like adding extra code to handle form switching and copying form data. We could post the forms without AJAX but in the end we want to use AJAX here.

Using a form in a template with HTML

The traditional way to do this is to send the form and receive a (JSON) response that consists of status only. You have to decode the status and in case of errors, set the proper error messages somewhere on the page.

The solution I want to use is different and aims at minimizing client-side code, I already used something like this for the contact form. The idea is that the page knows nothing about the form and its response. For a form I use a Jinja template that also contains the error messages and other messages. I use (Bootstrap) macro to render all forms on the site, and this includes rendering the error messages. Another plus of this approach is that all forms and error messages look the same, no duplication in the client.

The title is not contained in this template as I consider this part of the comments page. On the server we use the standard WTForms processing and render the form template. Then the rendered template is send back to the client. I think you can see the advantage here. The client-side logic is different because instead of poking alert messages and, in case of an error, error messages, at the proper positions (ids), we now replace a part of the HTML on the page.

Two WTForms forms on a page

My first attempt was just to put both forms on the page. I told you in a previous blog post that I put forms on the page using a macro:

	{{ bootstrap_form(comment_form, id='comment_form', form_action=url_for('pages.content_item_comment_new')) }}
    ...
	{{ bootstrap_form(comment_reply_form, id='comment_reply_form', form_action=url_for('pages.content_item_comment_new')) }}

I set the id attribute of the forms so I can access the forms in jQuery. Then the red console messages started coming, CSRF token field id was duplicate, submit field id was duplicate. This is not allowed in HTML5! In HTML5 an id must be unique in the document.

Searching the internet and reading the WTForms documentation it appeared that the form class comes with a 'prefix' parameter. The description is:

prefix – If provided, all fields will have their name prefixed with the value.

To use this is very easy. I have the following forms:

class ContentItemCommentForm(FlaskForm):

    message = TextAreaWithCounterField(_l('Your message'), 
        validators=[ InputRequired(), Length(min=6, max=1000) ] )

    submit = SubmitField(_l('Add'))


class ContentItemCommentReplyForm(FlaskForm):

    parent_uid = HiddenField('parent uid')

    message = TextAreaWithCounterField(_l('Your message'), 
        validators=[ InputRequired(), Length(min=6, max=1000) ] )

    submit = SubmitField(_l('Add'))

In the view function we simply add the prefixes:

    ...
    comment_form_prefix = 'comment_form'
    comment_form = ContentItemCommentForm(prefix=comment_form_prefix)

    comment_reply_form_prefix = 'comment_reply_form'
    comment_reply_form = ContentItemCommentReplyForm(prefix=comment_reply_form_prefix)

    if request.method == 'POST':

        if comment_form.submit.data and comment_form.validate_on_submit():
            ...
            # message
            message = comment_form.message.data
            ...

        elif comment_reply_form.submit.data and comment_reply_form.validate_on_submit():
            ...
            # parent_id
            parent_id = comment_reply_form.parent_id.data

            # message
            message = comment_reply_form.message.data
            ...

    return render_template(
        ...
        )

To put the forms on the page we do the same as above, no changes here. Accessing form elements from jQuery is also easy knowing the form prefixes. For example, to set the parent_id hidden field of the comment reply form we can do something like:

    $('#comment_reply_form-parent_id').val(parent_comment_id);

All elements have unique ids and it is easy to code and to read. So far so good.

Posting the forms using AJAX

The jQuery code to post a form is not really difficult. Many examples can be found on the internet. As explained above, on a submit the server processes the form. In case of an error the server simply returns the rendered form again including alert messages and error messages. The client logic replaces part of the HTML on the page by the HTML returned. If there are no errors, the server returns an anchor id of the comment that was added. The client logic then does a redirect to the same page using the page url with this anchor. This can be optimized later.

The jQuery serialize() function does not include the submit button

I use the jQuery serialize() function to get the data of the form. Unfortunately jQuery serialize() does not include the submit button because there can be multiple submit buttons on the form. Because I want to use the mechanism described above (determine which was form was submitted by checking the submit button) I append the submit button to the serialized form data in another way, not really difficult. After calling the jQuery replace() function we must re-attach the events. 

In the $(document).ready function two events are attached to a form:

  • The event to post the form
  • The event to show the number of remaining characters

After replacing the HTML these bindings get lost and must be attached again. Again not really difficult. Maybe we also can use event delegation but I did not research this.

The form templates

Below is the template of the comment form. The comment reply template is almost identical. The server can add alert messages and error messages and the client does not know anything about them. In case of an error, the element with id comment_form-post-container is replaced by the rendered form HTML returned by the AJAX call.

<div id="comment_form-post-container">

    {% if comment_alert %}
    <div class="alert alert-info alert-dismissible fade show" role="alert">
        {{ comment_alert_message }}
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
            <span aria-hidden="true">&times;</span>
        </button>

    </div>
    {% endif %}

    {% if comment_error and comment_error_type == 2 %}
    <div class="alert alert-danger alert-dismissible fade show" role="alert">
        {{ comment_error_message }}
        <button type="button" class="close" data-dismiss="alert" aria-label="Close">
            <span aria-hidden="true">&times;</span>
        </button>

    </div>
    {% endif %}

    {{ bootstrap_form(comment_form, id='comment_form', form_action=url_for('pages.content_item_comment_new')) }}

</div>

The comment form is inserted in the page by including it:

    <div class="row mt-2">
        <div class="col-12 p-0">

            <h2>{{ _('Add comment') }}</h2>

        </div>
    </div>

    <div class="row mb-2">
        <div class="col-12 p-0">

            {%- include "pages/comment_form.html" -%}

        </div>
    </div>

The client jQuery code:

$(document).ready(function(){
    ...
    // comment form: post
    $('#comment_form').on('submit', function(event){
        event.preventDefault();
        post_comment_form('comment_form');
        return false;
    });

    // comment form: character count
    update_character_count('comment_form-message');
    $('#comment_form-message').on('change input paste', function(event){
        update_character_count('comment_form-message');
    });
    ...
});

And the code to submit the form:

function post_comment_form(form_id){

    var form = $('#' + form_id)

    // submit button must be added to the form data
    var submit_button = $('input[type=submit]', form);

    // get url 
    var page_url =  window.location.href.replace(/#.*/, '');
    page_url = page_url.replace(/\?.*/,"");

    $.ajax({
        data: $('#' + form_id).serialize() + '&' + encodeURI( $(submit_button).attr('name') ) + '=' + encodeURI( $(submit_button).val() ),
        type: 'POST',
        url: url_comment_new,
        contentType: 'application/x-www-form-urlencoded; charset=UTF-8', 
        dataType: 'json'
    })
    .done(function(data){
        if(data.error){

            // replace
            $('#' + form_id + '-post-container').replaceWith(data.rendered_form);

            // attach submit event again 
            $('#' + form_id).on('submit', function(event){
                event.preventDefault();
                post_comment_form(form_id);
                return false;
            });

            // attach character count event again 
            update_character_count(form_id + '-message');
            $('#' + form_id + '-message').on('change input paste', function(event){
                update_character_count(form_id + '-message');
            });

        }else{

            var new_url = page_url + '?t=' + (+new Date()) + '#' + data.comment_anchor;
            window.location.href = new_url;

        }
    });
}

Jumping to an inserted comment using an anchor

After a comment has been inserted into the database by the server we must show the new tree of comments. We can do this by staying on the using client code but for the moment I choose to return an anchor to the AJAX call. The page is then called, refreshed, using the page url and the anchor. I add a timestamp to prevent problems with browser caching. In HTML5 the id is used as an anchor. Because I use Bootstrap with a sticky navbar we also must compensate for the extra navbar pixels:

.anchor5 {
    display: block;
    position: relative;
    top: -100px;
    visibility: hidden;
}

Summary

The above shows a possible implementation of Flask, WTforms and AJAX. I tried to reduce client side code by returning a rendered form including alert messages and error messages instead of just error messages. CSRF protection also works because we use FlaskForm. From the Flask_WTF extension documentation: Any view using FlaskForm to process the request is already getting CSRF protection. There is much more work to do but I thought to share this with you.

Links / credits

Combining multiple forms in Flask-WTForms but validating independently
https://dev.to/sampart/combining-multiple-forms-in-flask-wtforms-but-validating-independently-cbm

flask-bootstrap with two forms in one page
https://stackoverflow.com/questions/39738069/flask-bootstrap-with-two-forms-in-one-page/39739863#39739863

Forms
https://wtforms.readthedocs.io/en/stable/forms.html

Multiple forms in a single page using flask and WTForms
https://stackoverflow.com/questions/18290142/multiple-forms-in-a-single-page-using-flask-and-wtforms