Submit WTform with dynamically generated fields
Asked Answered
O

1

5

I have a form where users can dynamically add fields to it. While submitting this form, backend only sees the fields which backend generated

#forms.py

class ExpensesForm(FlaskForm):
    expense_name = StringField('Expense_Item', validators=[DataRequired()])
    cost = FloatField('Cost', validators=[DataRequired()])
    due_date = DateField('Due Date', format='%Y-%m-%d', validators=[DataRequired()], default=datetime.datetime.today().date())
    type = SelectField('Role', choices=[('mutual', 'Mutual'),
                                        ('personal#1', 'Personal #1'),
                                        ('personal#2', 'Personal #2')
                                        ])

I'm passing this form with return render_template('index.html', form=form, ...) from the main.py to index.html

All 4 fields are generated via;

<form class="form-horizontal" id="main-form" enctype=multipart/form-data role="form" method="post" action="/">
        <input type="hidden" name="count" value="1"/>
        {{ form.csrf_token }}

        {{ form.expense_name(placeholder="Expense Name", id="expense_1", value="") }}
        {{ form.cost(placeholder="Cost", id="cost_1", class="cost", value="") }}
        {{ form.due_date(id="due_date_1") }}
        {{ form.type(placeholder="Type", id="type_1") }}
        <button id="b1" class="btn btn-info add-more" type="button">+</button>
        <small>Press + to add another set of fields.</small>
        <br>
        <hr>
        <button class="btn btn-sm btn-success" type="submit">Post Expense</button>
    </form>

A JQuery snippet generates the same fields with different(unique) id's after each button press as a row of new fields after the last #type_ id.

When I hit the submit button, the backend only receives the first row not the generated ones.

What am I missing here?

UPDATE:

# main.py
@main_blueprint.route('/', methods=['GET', 'POST'])
def index():
    dates = []
    form = ExpensesForm(request.form)
    if request.method == 'POST':
        print(form.data)
        # prints the following even when the browser sends more than 1 set of data:
        # {'due_date': None, 'csrf_token': long_hash, 'expense_name': 'Electric', 'cost': 13.0, 'type': 'mutual'}
        if form.validate_on_submit():
            for n in range(len(form.expense_name.data)):
                if form.expense_name.raw_data[n] != '':
                    data = Expenses(form.expense_name.raw_data[n].title(),
                                    form.cost.raw_data[n],
                                    datetime.datetime.strptime(form.due_date.raw_data[n], '%Y-%m-%d').date(),
                                    form.type.raw_data[n].title(),
                                    )
                    print(data)
                    db.session.add(data)
                    db.session.commit()
        return redirect(url_for('main.index'))
    expenses = db.session.query(Expenses).all()
    # expenses_schema = ExpensesSchema()
    # output = expenses_schema.dump(expenses).data

    output = []
    for i in expenses:
        output.append(i.__dict__)
    return render_template('index.html', form=form, expenses=output)

UPDATE 2

Since form.data is a dict I can't have the names matching with the new fields. But even if I give unique names to the added fields the backend only displays the initial form fields with print(form.data) but if I do;

    for k, v in request.form.items():
        print(k, v)

I get all the fields. Doesn't seem the right way to me. Any thoughts?

Overestimate answered 6/1, 2019 at 8:2 Comment(3)
are you doing multi select by adding a new type ?Profusion
@snakecharmerb Browser sends all the data. but the backend is not getting them.Overestimate
@VillageMonkey Not sure what you mean by "multi select by adding new type"Overestimate
J
4

You can only have a single form result per form submission. To be able to submit an arbitrary and unknown number of inputs, you need to restructure your form with the help of WTForm's field enclosures.

forms.py

from flask_wtf import FlaskForm
from wtforms import (
    FieldList, FormField, DateField FloatField, StringField, SelectField)
from wtforms import Form as NoCsrfForm


class ExpenseItem(NoCsrfForm):
    expense_name = StringField('Expense_Item', validators=[DataRequired()])
    cost = FloatField('Cost', validators=[DataRequired()])
    due_date = DateField('Due Date', format='%Y-%m-%d',
                                 validators=[DataRequired()],
                                 default=datetime.datetime.today().date())
    type = SelectField('Role', choices=[
        ('mutual', 'Mutual'),
        ('personal#1', 'Personal #1'),
        ('personal#2', 'Personal #2'),
    ])

class ExpensesForm(FlaskForm):
    """A collection of expense items."""
    items = FieldList(FormField(ExpenseItem), min_entries=1)

I'd strongly recommend that you preface all your field names with expense, not just expense_name for sanity's sake.

index.html

<form class="form-horizontal" id="main-form" enctype=multipart/form-data role="form" method="post" action="/">
    <input type="hidden" name="count" value="1"/>
    {{ form.hidden_tag() }}
    {% for expense_item in form.items %}
        {{ form.expense_name(placeholder="Expense Name", value="") }}
        {{ form.cost(placeholder="Cost", class="cost", value="") }}
        {{ form.due_date() }}
        {{ form.type(placeholder="Type") }}
    {% endfor %}

    <button id="b1" class="btn btn-info add-more" type="button">+</button>
    <small>Press + to add another set of fields.</small>
    <br>
    <hr>
    <button class="btn btn-sm btn-success" type="submit">Post Expense</button>
</form>

Note that the id attribute of the HTML input fields must follow a particular pattern. So for every new expense item field which you add by clicking on the + button, you need to re-number the id attribute of its input fields.

something.js

Everything else was comparatively easy. You now need to write a piece of .js which will re-index the id attributes of all the input fields every time a new expense item is added. I accomplished this using the Zepto library for Javascript. It wasn't fun, and my .js is terrible. The best I can do here is just paste the whole thing and hope it'll be of service to you. I know it's confusing, but I was added multiple classes to a course. For you, you'll want expense_item/expense_request or whatever you go with:

// append class-box when new class link clicked
$("#new-class").click(function(event) {
    appendClassBox('#classes', {{ newclass|tojson|safe }});
    reindexNames('.class-box');
    return false;
})

// remove class box when its "remove" link is clicked
$(document).on('click', '#remove-class', function(){
    var $toremove = $(this).closest('.class-box');
    $toremove.remove();
    reindexNames('.class-box');
    return false;
})

// add a new class-box
function appendClassBox(container, content) {
    $(container).append(content);
    // raise last and hence newest class box
    raiseClassBox($(container).children().last())
    return false;
}

function isNormalInteger(str) {
    var n = ~~Number(str);
    return String(n) === str && n >= 0;
}

// re-index class-box names
function reindexNames(class_name) {
    var $oboxen = $(class_name);
    $oboxen.each(function(index) {
        // Get all the input fields in the class-box.
        var $labels = $oboxen.eq(index).find('label')
        var $inputs = $oboxen.eq(index).find(
            'input, select, textarea')
        // Update the index contained in the name attribute.
        $inputs.each(function(idx) {
            var $name = $inputs.eq(idx).attr('name').split('-');
            // If number in name, grab from number onwards.
            var $has_num = false
            for (var part in $name) {
                if (isNormalInteger($name[part])) {
                    $has_num = true
                    $name = $name.slice(part)
                    $name[0] = index
                    break
                }
            }
            // Re-index.
            if ($has_num == false) {
                $name.unshift(index)
            }
            var $prefix = 'questions'
            if (class_name == '.class-box') {
                $prefix = 'classes'
            }
            $name.unshift($prefix)
            if (idx > 0) {
                $labels.eq(idx - 1).attr('for', $name.join('-'));
            }
            $inputs.eq(idx).attr('id', $name.join('-'));
            $inputs.eq(idx).attr('name', $name.join('-'));
        })
    })
}

views.py

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

    # Iterate over a collection of new expense items.
    if form.validate_on_submit():
        for item in form.items.data:
            print(item['expense_name'])
            print(item['cost'])
            print(item['due_date'])
            print(item['type'])
Janessa answered 8/1, 2019 at 22:24 Comment(3)
I'll try this as soon as possible. Looks like I'm going to need the '''class ExpensesForm(FlaskForm): """A collection of expense items.""" items = FieldList(FormField(ExpenseItem), min_entries=1)''' part. and I think I'm going to have to add the other fields into this new class. I know I didn't paste the .js but I can assure you that all new fields gets a new/incremented id which follows a pattern. This looks promising. Thank you. note: I suck at naming things.Overestimate
nwm, you passed the entire class into the new one not the fields.Overestimate
Please accept the answer if you feel it helps - otherwise feel free to let me know if I can provide additional pointers :)Janessa

© 2022 - 2024 — McMap. All rights reserved.