Append entry to FieldList with Flask-WTForms using

2020-02-11 05:22发布

问题:

I made a simple form in Flask using Flask-WTForms where a parent can register itself and his children. The parent can register as many children as he wants, by clicking on the button 'Add child'. WTForms makes this pretty easy to implement by using the FieldList feature.

However, after clicking on the button 'Add child' the page refreshes itself because it makes a request to the server. I want to use an AJAX request to add a child form, so the page doesn't refreshes itself.

I know how to do an AJAX-request, send a response back and add this response to the html page. However, I do not know how to append an entry to the form object and return the page itself (somehow) with the updated form-object. Is that even possible?

My forms:

class ChildForm(FlaskForm):

    name = StringField(label='Name child')
    age = IntegerField(label='Age child')

    class Meta:
        # No need for csrf token in this child form
        csrf = False

class ParentForm(FlaskForm):

    name = StringField(label='Name parent')
    children = FieldList(FormField(ChildForm), label='Children')
    add_child = SubmitField(label='Add child')

    submit = SubmitField()

My routes:

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

    if form.add_child.data:
        form.children.append_entry()
        return render_template('register.html', form=form)

    if form.validate_on_submit():
        # do something with data

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

register.html:

<form action="{{ url_for('register') }}" method="post" id="parentForm">
    {{ form.hidden_tag() }}
    {{ form.name.label }} {{ form.name }}

    {{ form.add_child }}

    {% for childform in form.children %}

        {% for field in childform %}
            {{ field.label }} {{ field }}   
        {% endfor %}

    {% endfor %}

    {{ form.submit }}
</form>

回答1:

I think this should work. Barring any typos.

views.py

########
# SET UP YOUR FLASK APP HERE
########

from flask_wtf import FlaskForm
from wtforms_alchemy import model_form_factory

class Family(db.Model):
    id = db.Column('id', db.Integer, primary_key=True)
    name = db.Column(db.String(500), info={'label': 'Familyname', 'validators': DataRequired()})

class Member(db.Model):
    id = db.Column('id', db.Integer, primary_key=True)
    name = db.Column(db.String(500), info={'label': 'Member', 'validators': DataRequired()})


BaseModelForm = model_form_factory(FlaskForm)

class ModelForm(BaseModelForm):
    @classmethod
    def get_session(self):
        return db.session

class MemberForm(ModelForm):    

    class Meta:
        model = Machine


class MemberEditForm(MemberForm):
    pass

class MainForm(ModelForm):

    add_member = SubmitField('+ Member')
    members = ModelFieldList(FormField(MemberForm))
    class Meta:
        model = Family



@app.route('/form')
def main_form():

    family = Family()
    form = MainForm(obj=family)

    if form.add_member.data:
        getattr(form,'members').append_entry()
        return render_template('form.html', form=form)

    if form.validate_on_submit():
        form.populate_obj(family)
        db.session.add(order)
        db.session.commit()

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


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

    getattr(form,'members').append_entry()

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

form.html

<script type="text/javascript">

    $("#add-member").on('click', function(event){
        $.ajax({
            url: "{{ url_for('add_member') }}",
            type : "POST",
            //dataType : 'json', // data type
            data : $("#main-form").serialize(),
            success : function(result) {
                console.log(result);
                $("#members").html(result);
            },
            error: function(xhr, resp, text) {
                console.log(xhr, resp, text);
            }
        });
        event.preventDefault();

        });
</script>


<form method="post" action="{{ url_for('main_form') }}" id="main-form">
{{ form.hidden_tag() }}
{{ form.name.label }} {{ form.name }}


<fieldset class="form-group border p-2">
    <span id="members">{% include 'members.html' %}</span>

    <div class="form-row">
        {{ form.add_member(id="add-member") }}
    </div>
</fieldset>

</form>

members.html

<span class="h3">Members</span>
{% if form.members%}
    <div class="form-row">
        <div class="col-1 form-group">Name</div>
    </div>
{% endif %}

{% for member in form.members %}    
    <div class="form-row">
        {% for field in member %}
            <div class="col-1 form-group">{{ field(class="form-control") }}</div>
        {% endfor %}
    </div>
{% endfor %}


回答2:

In the below setup a user has a input which specifies how many text areas he will submit on the form (they dynamically appear and disappear) and then can submit it with AJAX.

Python backend

from flask import jsonify
from webargs import flaskparser, fields, validate

USER_ARGS_POST = {
    'list_of_items': fields.List(fields.Str(required=True)),
    'num_items': fields.Int(required=True)
}

@api.route('/json_form', methods=['POST']
def json_form_post():
    parsed_args = flaskparser.parser.parse(USER_ARGS_POST, request)
    for data_item in parsed_args['list_of_data']:
        # do something with each data item, e.g. create db tuple and add
        db.session.add(new_data_item_tuple)
    db.session.commit()
    return jsonify({'result': 'SUCCESS', 'desc': 'All items appended to DB'})

@web.route('/index', methods=['GET'])
def index():
    return render_template('index.html')

JS frontend and HTML (uses Vue.js and Jquery)

<div id="vue-container">

    <h2>Form Input List</h2>
    Number of items:<br>
    <input type="number" min="1" max="10" v-model="form_data.num_items" placeholder="Number of items for form.."><br>

    <template v-for="n in Number(form_data.num_items)">
        <textarea v-model="form_data.list_of_data[n-1]" v-bind:placeholder="'Items ' + n"></textarea><br>
    </template>

    <button type="button" v-on:click="submit_('/json_form', form_data)">Submit Form</button>

    {({ form_data })} <br>
    {({ ajax_message })} <br>

</div>

<script>
    'use strict';

    var vm = new Vue({
        el: '#vue-container',
        delimiters: ['{({', '})}'], // separate vue from jinja2 globally
        data: {
            ajax_message: '',
            form_data: {
                num_items: 1,
                list_of_data: [null],
            },
        },
        methods: {
            ajax_: function (url, action, form_data, cb) {
                /*
                Wrapper to send a JSON version of form data to a specified url and execute
                callback on success, or register error message to Vue Instance data variable

                Args:
                    url: ajax API request url
                    action: 'post', 'put', 'delete', 'get', based on API
                    form_data: dict of kwargs consistent with API end route
                    cb: execute on success, should be callable with one argument: 'data'.

                Returns:
                    None: executes asyncronously the supplied callback.
                 */

                self = this;

                $.ajax({
                    url: url,
                    dataType: "json",
                    contentType: "application/json;charset=utf-8",
                    type: action,
                    data: JSON.stringify(form_data),
                    success: function (data) {
                        cb.bind(self)(data);
                    },
                    error: function (xhr, status, error) {
                        var res = JSON.parse(xhr.responseText);
                        console.log("Ajax error:", res.description);
                        self.ajax_message = res.description;
                    }
                });
            },
            submit_: function (route, form_data) {
                var cb = function (data) {
                    this.ajax_message = data.desc;
                    alert('Submitted');
                };
                this.ajax_($API_SCRIPT_ROOT + route, 'post', form_data, cb);
            },
         }
    })
</script>

===== edited for comment.

Templating

There is a difference between server side templating, i.e. :

{% for childform in form.children %}

    {% for field in childform %}
        {{ field.label }} {{ field }}   
    {% endfor %}

{% endfor %}

and client side templating, i.e. :

<template v-for="n in Number(form_data.num_items)">
    <textarea v-model="form_data.list_of_data[n-1]" v-bind:placeholder="'Items ' + n"></textarea><br>
</template>

The OP requested:

I want to use an AJAX request to add a child form, so the page doesn't refreshes itself.

In that case you need to update your page dynamically using Javascript. Only on a page refresh could the server update its template, whilst the client side Javascript can fetch the data with the AJAX query and then update dynamically. In this case when form_data.num_items changes the number of textareas increase.

I have used Vue in this example since it is a fairly user friendly JS library. But you can also use React or Angular or use plain old JS, but you need to choose one.

Database

The OP made reference to a database:

if form.validate_on_submit():
    # do something with data

the only equivelent reference I make to a database is here:

for data_item in parsed_args['list_of_data']:
    # do something with each data item, e.g. create db tuple and add
    db.session.add(new_data_item_tuple)
db.session.commit()

but of course you are free to do with the form data what you please and not necessarily commit anything to the database. In my example based on the number of items that are passed to the list, there are items created.

Summary

If you tolerate page refreshes, then you can add items to the form on the server side each time the user clicks an add button. This is simpler to code, but less efficient since data is repeatedly transferred between client and server and is probably more difficult to maintain.

If you want a smoother, more efficient user experience and more maintainable solution then my example is not the only combination of libraries of course, but they will all operate in a fairly similar way:

i.e:

  • Load a Basic Page from basic server side code
  • Use Javascript to manipulate that page based on adding/manipulating form input, (with possible AJAX queries for data lookup)
  • Either submit that form to an API for a JSON response (as in my case) or submit it as a web route with redirect or web page response.


回答3:

For people who want to use the form in the more rudimentary non-RESTful way there is still the challenge of how to persist the form data correctly server side. If you don't do it right updating existing children in the form will append new children in the db rather than update existing ones. Below I provide two flask views, one for registering, and one for updating the registration. My view for updating the registration works but is a little kludgy. If anyone knows how to write it more elegantly I'd be psyched for some feedback:

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

    if form.add_child.data:
        form.children.append_entry()
        return render_template('register.html', form=form)

    if form.validate_on_submit():
        parentObj = Parent(name=form.name.data)
        for child in form.children.data:
            childObj = Child(name=child['name'],age=child['age'])
            parentObj.children.append(childObj)
        db.session.add(parentObj)
        db.session.commit()
        flash('Parent Added!!')
        return redirect(url_for('home_page'))

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

@app.route('/update_registration', methods=['GET', 'POST'])
def update_registration():
    parentObj = Parent.query.filter(Parent.id == 1).first()
    form = ParentForm(id=parentObj.id, name=parentObj.name, children=parentObj.children)

    if form.add_child.data:
        form.children.append_entry()
        return render_template('update_registration.html', form=form)
    if form.validate_on_submit():
        parentObj.name=form.name.data
        # There should be a way to update the existing children objects rather than deleting and readding them
        # But in the below we delete and re-add.  Otherwise updated children simply append to existing children list
        for i in range(len(parentObj.children)):
            db.session.delete(parentObj.children[i])
        for child in form.children.data:
            childObj = Child(name=child['name'],age=child['age'])
            parentObj.children.append(childObj)
        db.session.add(parentObj)
        db.session.commit()
        flash('Parent [and children] Updated!!')
        return redirect(url_for('home_page'))
    return render_template('update_registration.html', form=form)