pythonflaskflask-wtformswtforms

Populating a list using FieldList, FormField, and populate_obj in WTForms, with adding items client-side


I have an application I am building using Python and Flask. I have a form I am creating using WTForms which will allow the user to edit customer contact details, including a dynamic number of phone numbers. When the form is submitted, I want to save the data from the form back into the Customer object using the form's populate_obj function.

The code for the forms is as follows:

class PhoneNumberFormPart(FlaskForm):
    class Meta:
        csrf = False # Disable CSRF protection, it will confilct with protection on the parent form
    number = StringField("Phone Number", widget=Input('tel'))
    label = SelectField('Label', choices=(("Cell", "Cell"), ("Home", "Home"), ("Work", "Work")))
    preferred = BooleanField('Preferred', default=False)

class CustomerEditForm(FlaskForm):
    name = StringField('Name', validators=[DataRequired()])
    name2 = StringField('Contact Person Name')
    isBusiness = BooleanField('This is a business client', default=False)
    phones = FieldList(FormField(PhoneNumberFormPart), min_entries=1)
    address = StringField("Address")
    city = StringField("City")
    state = StringField("State")
    zip = StringField("Zip Code")
    email = StringField("Email Address", widget=Input('email'), validators=[Email()])
    submit = SubmitField('Save Customer Details')

I have the following javascript to add additional phone number fields on the client side:

/// Called in forms using a FieldList to duplicate the last FieldList-item
function addFieldListItem(fieldList){
    let lastField = fieldList.lastElementChild.previousElementSibling; 
    // Last child is the "add phone" button added in the template, so the last field is the 2nd-to-last item
    let newField = lastField.cloneNode(true);

    let newInputs = newField.getElementsByTagName('input');
    Array.prototype.forEach.call(newInputs, function(input){
        // Increment the number that flask assigns to each field name
        input.name = input.name.replace(/(\d+)/, function(n){return ++n});
        input.id = input.id.replace(/(\d+)/, function(n){return ++n});
        // Clear the input values
        input.value = null;
        input.checked = false;
    });

    let newSelects = newField.getElementsByTagName('select');
    Array.prototype.forEach.call(newSelects, function(select){
        // Increment the number that flask assigns to each field name
        select.name = select.name.replace(/(\d+)/, function(n){return ++n});
        select.id = select.id.replace(/(\d+)/, function(n){return ++n});
    });

    let newLabels = newField.getElementsByTagName('label');
    Array.prototype.forEach.call(newLabels, function(label){
        // Increment the number that flask assigns to each field name
        label.htmlFor = label.htmlFor.replace(/(\d+)/, function(n){return ++n});
    });

    fieldList.insertBefore(newField, fieldList.lastElementChild);
}

Everything seems to work as I expect as long as I don't add an additional phone number client-side. However, if I do add another number client-side, when I call populate_obj I get the following exception:

Traceback (most recent call last):
  File "c:\python\lib\site-packages\flask\app.py", line 2463, in __call__
    return self.wsgi_app(environ, start_response)
  File "c:\python\lib\site-packages\flask\app.py", line 2449, in wsgi_app
    response = self.handle_exception(e)
  File "c:\python\lib\site-packages\flask\app.py", line 1866, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "c:\python\lib\site-packages\flask\_compat.py", line 39, in reraise
    raise value
  File "c:\python\lib\site-packages\flask\app.py", line 2446, in wsgi_app
    response = self.full_dispatch_request()
  File "c:\python\lib\site-packages\flask\app.py", line 1951, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "c:\python\lib\site-packages\flask\app.py", line 1820, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "c:\python\lib\site-packages\flask\_compat.py", line 39, in reraise
    raise value
  File "c:\python\lib\site-packages\flask\app.py", line 1949, in full_dispatch_request
    rv = self.dispatch_request()
  File "c:\python\lib\site-packages\flask\app.py", line 1935, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "c:\python\lib\site-packages\flask_login\utils.py", line 261, in decorated_view
    return func(*args, **kwargs)
  File "C:\Users\Techris Design\Project Spiderman\spiderman\spiderman\views\customers.py", line 40, in customer_landing
    form.populate_obj(customer)
  File "c:\python\lib\site-packages\wtforms\form.py", line 96, in populate_obj
    field.populate_obj(obj, name)
  File "c:\python\lib\site-packages\wtforms\fields\core.py", line 962, in populate_obj
    field.populate_obj(fake_obj, 'data')
  File "c:\python\lib\site-packages\wtforms\fields\core.py", line 829, in populate_obj
    raise TypeError('populate_obj: cannot find a value to populate from the provided obj or input data/defaults')
TypeError: populate_obj: cannot find a value to populate from the provided obj or input data/defaults

I'm pretty sure it's because the phones property (which is a list of PhoneNumber objects) in the corresponding Customer object doesn't have enough items in it to contain all the form data into the list.

I've looked over the WTForms documentation to see if there is a way to assign a factory function or class to the FormField so that it could create additional PhoneNumber objects to add to customer.phones when I call populate_obj if there are more items in the form data than in the target object. However, so far as I can tell from the documentation there is no such option.

Anyone know the best way to do this?


Solution

  • Okay, I spend some time studying the WTForms source code on Github and figured it out. Pass a class or factory function to the default parameter of the FormField instance, as follows:

    phones = FieldList(FormField(PhoneNumberFormPart, default=PhoneNumber), min_entries=1)
    

    It's actually pretty easy, although calling the parameter default made it a little hard for me to find. Never would have even thought to look there...