Repository: wtforms/wtforms (python)

Issue:
Changing FormField seperator parameter to non-default breaks iteration over enclosing FieldList
I have a use case for generating a FieldList of a variable number of simple forms each enclosed in a FormField. I have attempted setting the separator parameter of the FormField. Changing FormField separator parameter to non-default (aka from "-" to "_" breaks iteration over enclosing FieldList. Only one entry is returned regardless of how many are in the list. 

### Actual Behavior
Vastly simplified code below. Eveything works as intended if you leave separator set as default "-" but breaks if changed to "_"

```python
# Form for individual person's test score
class OneScoreForm(Form):
    Score = DecimalRangeField('Score',default = 1)

# List of forms for entering man people's test scores
class ScoringForm(Form):

    Scores = FieldList(FormField(OneScoreForm,  separator='_'), min_entries=1)
    submit = SubmitField('Submit Your Scores')

form = ScoringForm()

# Build Form
for person in people:
    form.Scores.append_entry()
    form.Scores[-1]['Person'].data = person.Person

# Render some HTML
# Get form data back with imputted scores

# Try to iterate over people and write to database
for score in form.Scores:
    # Put form data back into database
```
This returns only the first entry regardless of how many Score forms are appended to the list.


### Expected Behavior

Iterates over the whole list of submitted forms regardless of setting separator parameter.


### Additional Info

I do have a fix in my code that works by setting the hard coded use of the hyphens in FieldList._add_entry and FieldList._extract_indices to instead use the specified separator. It does pass all the package tests but I still don't feel confident it will break something else. 

The reason setting the separator matters to me is that I would like to eliminate the hyphens from he field names so I can use a bit of java on them to display the value of the range slider in real time. Hyphens are forbidden. 

### Environment

* Python version: 3.8.5
* wtforms version: 2.3.3


Pull request:
Add `separator` to FieldList
As of right now there is no way of creating fully custom names and ids for nested forms that contain a field list since the `FieldList` contains a hardcoded separator. This PR adds the ability to set a custom separator for a `FieldList` similar to `FormField`. 

This PR also fixes #681 and adds the relevant tests to verify the fix works.

For mixed separators (see test `test_enclosed_subform_list_separator` and `test_enclosed_subform_mixed_separators`) to work properly, the separator of the unbound field (in case it is a form field or another list) needs to be determined. For this we look into the `kwargs` used to construct the unbound field or default to `-`.

<!--
Commit checklist:

* add tests that fail without the patch
* ensure all tests pass with ``pytest``
* add documentation to the relevant docstrings or pages
* add ``versionadded`` or ``versionchanged`` directives to relevant docstrings
* add a changelog entry if this patch changes code
-->


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 6723a9a..79ce1bb 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -14,6 +14,7 @@ Unreleased
 -   Support for optgroups in :class:`~fields.SelectField` and
     :class:`~fields.SelectMultipleField`. :issue:`656` :pr:`667`
 -   Minor documentation fix. :issue:`701`
+-   Custom separators for :class:`~fields.FieldList`. :issue:`681` :pr:`694`
 
 Version 3.0.0a1
 ---------------
diff --git a/docs/fields.rst b/docs/fields.rst
index 66b7ed3..1452492 100644
--- a/docs/fields.rst
+++ b/docs/fields.rst
@@ -414,7 +414,7 @@ complex data structures such as lists and nested objects can be represented.
     :attr:`~wtforms.form.Form.data` dict of the enclosed form. Similarly, the
     `errors` property encapsulate the forms' errors.
 
-.. autoclass:: FieldList(unbound_field, default field arguments, min_entries=0, max_entries=None)
+.. autoclass:: FieldList(unbound_field, default field arguments, min_entries=0, max_entries=None, separator='-')
 
     **Note**: Due to a limitation in how HTML sends values, FieldList cannot enclose
     :class:`BooleanField` or :class:`SubmitField` instances.
diff --git a/src/wtforms/fields/core.py b/src/wtforms/fields/core.py
index 2b04972..71608d3 100644
--- a/src/wtforms/fields/core.py
+++ b/src/wtforms/fields/core.py
@@ -1105,6 +1105,9 @@ class FieldList(Field):
     :param max_entries:
         accept no more than this many entries as input, even if more exist in
         formdata.
+    :param separator:
+        A string which will be suffixed to this field's name to create the
+        prefix to enclosed list entries. The default is fine for most uses.
     """
 
     widget = widgets.ListWidget()
@@ -1116,6 +1119,7 @@ class FieldList(Field):
         validators=None,
         min_entries=0,
         max_entries=None,
+        separator="-",
         default=(),
         **kwargs,
     ):
@@ -1133,6 +1137,8 @@ class FieldList(Field):
         self.max_entries = max_entries
         self.last_index = -1
         self._prefix = kwargs.get("_prefix", "")
+        self._separator = separator
+        self._field_separator = unbound_field.kwargs.get("separator", "-")
 
     def process(self, formdata, data=unset_value, extra_filters=None):
         if extra_filters:
@@ -1175,12 +1181,12 @@ class FieldList(Field):
 
         formdata must be an object which will produce keys when iterated.  For
         example, if field 'foo' contains keys 'foo-0-bar', 'foo-1-baz', then
-        the numbers 0 and 1 will be yielded, but not neccesarily in order.
+        the numbers 0 and 1 will be yielded, but not necessarily in order.
         """
         offset = len(prefix) + 1
         for k in formdata:
             if k.startswith(prefix):
-                k = k[offset:].split("-", 1)[0]
+                k = k[offset:].split(self._field_separator, 1)[0]
                 if k.isdigit():
                     yield int(k)
 
@@ -1232,8 +1238,8 @@ class FieldList(Field):
         if index is None:
             index = self.last_index + 1
         self.last_index = index
-        name = "%s-%d" % (self.short_name, index)
-        id = "%s-%d" % (self.id, index)
+        name = f"{self.short_name}{self._separator}{index}"
+        id = f"{self.id}{self._separator}{index}"
         field = self.unbound_field.bind(
             form=None,
             name=name,


Test patch:
diff --git a/tests/fields/test_list.py b/tests/fields/test_list.py
index 4df373e..d04a5d8 100644
--- a/tests/fields/test_list.py
+++ b/tests/fields/test_list.py
@@ -103,6 +103,68 @@ def test_enclosed_subform_custom_name():
     assert o.subforms[0].foo.data == "default"
 
 
+def test_custom_separator():
+    F = make_form(a=FieldList(t, separator="_"))
+
+    pdata = DummyPostData({"a_0": "0_a", "a_1": "1_a"})
+    f = F(pdata)
+    assert f.a[0].data == "0_a"
+    assert f.a[1].data == "1_a"
+
+
+def test_enclosed_subform_list_separator():
+    class Inside(Form):
+        foo = StringField(default="default")
+
+    class Outside(Form):
+        subforms = FieldList(FormField(Inside), min_entries=1, separator="_")
+
+    o = Outside()
+    assert o.subforms[0].foo.data == "default"
+    assert o.subforms[0].foo.name == "subforms_0-foo"
+
+    pdata = DummyPostData({"subforms_0-foo": "0-foo", "subforms_1-foo": "1-foo"})
+    o = Outside(pdata)
+    assert o.subforms[0].foo.data == "0-foo"
+    assert o.subforms[1].foo.data == "1-foo"
+
+
+def test_enclosed_subform_uniform_separators():
+    class Inside(Form):
+        foo = StringField(default="default")
+
+    class Outside(Form):
+        subforms = FieldList(
+            FormField(Inside, separator="_"), min_entries=1, separator="_"
+        )
+
+    o = Outside()
+    assert o.subforms[0].foo.data == "default"
+    assert o.subforms[0].foo.name == "subforms_0_foo"
+
+    pdata = DummyPostData({"subforms_0_foo": "0_foo", "subforms_1_foo": "1_foo"})
+    o = Outside(pdata)
+    assert o.subforms[0].foo.data == "0_foo"
+    assert o.subforms[1].foo.data == "1_foo"
+
+
+def test_enclosed_subform_mixed_separators():
+    class Inside(Form):
+        foo = StringField(default="default")
+
+    class Outside(Form):
+        subforms = FieldList(FormField(Inside, separator="_"), min_entries=1)
+
+    o = Outside()
+    assert o.subforms[0].foo.data == "default"
+    assert o.subforms[0].foo.name == "subforms-0_foo"
+
+    pdata = DummyPostData({"subforms-0_foo": "0_foo", "subforms-1_foo": "1_foo"})
+    o = Outside(pdata)
+    assert o.subforms[0].foo.data == "0_foo"
+    assert o.subforms[1].foo.data == "1_foo"
+
+
 def test_entry_management():
     F = make_form(a=FieldList(t))
     a = F(a=["hello", "bye"]).a