Repository: wagtail/wagtail

Issue:
Allow subclasses to override how AbstractFormField.clean_name is set
### Is your proposal related to a problem?

In Wagtail 2.10,  ``AbstractFormField.clean_name`` became a proper model field (introduced in #6093) and the ``save()`` method handles setting the value based on ``self.label``. 

We have heavily customized subclasses of ``AbstractFormField`` which are used for questionnaires. The questions can be quite long so we overrode ``label = models.TextField()``.  We in turn overrode ``self.clean_data`` to be a property that simply returned ``str(self.sort_order)`` as a quick hack to create a unique value since using the label would be ridiculous in our scenario. The customized property unfortunately masked the upgrade incompatibilities since the new model field was not detected by ``makemigrations`` and ``AbstractFormField._migrate_legacy_clean_name()`` didn't catch this since it queried against a non-existent field. We only uncovered this issue because someone tried to add a new question which raised ``AttributeError: can't set attribute``. 

I realize that heavy customizations almost always get their revenge and they are our responsibility, but there aren't any obvious and acceptable options forward for us. The least ugly workaround was to create an intermediate abstract class to override ``AbstractFormField.save()`` so we can set ``self.clean_name`` but this means that instead of calling ``super()``, we have to bypass that in the MRO and call ``Orderable.save(self, *args, **kwargs)`` instead. I am unhappy with this workaround since we'll now have to closely track any updates to ``AbstractFormField.save()`` but this solved the immediate issue (we also wrote our own data migration to populate existing rows since ``_migrate_legacy_clean_name`` will not work in our case).

### Describe the solution you'd like

A simple change to how ``clean_name`` is set would allow us to more cleanly override this specific bit while still being able to allow the normal ``super().save()``.

Instead of:

```python
def save(self, *args, **kwargs):
    """
    When new fields are created, generate a template safe ascii name to use as the
    JSON storage reference for this field. Previously created fields will be updated
    to use the legacy unidecode method via checks & _migrate_legacy_clean_name.
    """

    is_new = self.pk is None
    if is_new:
        clean_name = get_field_clean_name(self.label)
        self.clean_name = clean_name

    super().save(*args, **kwargs)
```

What about moving this out so subclasses can decide how to best generate ``clean_name``?

```python
def get_field_clean_name():
    return get_field_clean_name(self.label)

def save(self, *args, **kwargs):
    """
    When new fields are created, generate a template safe ascii name to use as the
    JSON storage reference for this field. Previously created fields will be updated
    to use the legacy unidecode method via checks & _migrate_legacy_clean_name.
    """

    is_new = self.pk is None
    if is_new:
        clean_name = self.get_field_clean_name()
        self.clean_name = clean_name

    super().save(*args, **kwargs)
```

This would let us simply override the single ``self.get_field_clean_name()`` in our implementation and everything else will work as usual.

I can write up a PR if this would be an acceptable change. Thanks!


Discussion:
Hello this is a nice approach; I also have problems with the way this works currently because if the label of a field is in greek it results to a unicode mess (check my faq https://github.com/spapas/wagtail-faq#whats-that-unicode-related-mess-i-see-in-my-field-names-when-i-use-non-english-characters-as-labels-in-my-wagtail-forms). 

To solve this I also rely on using the grand-parent save trickery but I'd be happy to use something better :)

Resolving patch:
diff --git a/wagtail/contrib/forms/forms.py b/wagtail/contrib/forms/forms.py
--- a/wagtail/contrib/forms/forms.py
+++ b/wagtail/contrib/forms/forms.py
@@ -6,7 +6,6 @@
 from django.utils.translation import gettext_lazy as _
 
 from wagtail.admin.forms import WagtailAdminPageForm
-from wagtail.contrib.forms.utils import get_field_clean_name
 
 
 class BaseForm(django.forms.Form):
@@ -142,7 +141,7 @@ def formfields(self):
             # If the field hasn't been saved to the database yet (e.g. we are previewing
             # a FormPage with unsaved changes) it won't have a clean_name as this is
             # set in FormField.save.
-            clean_name = field.clean_name or get_field_clean_name(field.label)
+            clean_name = field.clean_name or field.get_field_clean_name()
             formfields[clean_name] = create_field(field, options)
 
         return formfields
@@ -187,12 +186,10 @@ def clean(self):
             for i, form in enumerate(_forms):
                 if "label" in form.changed_data:
                     label = form.cleaned_data.get("label")
-                    clean_name = get_field_clean_name(label)
+                    clean_name = form.instance.get_field_clean_name()
                     for idx, ff in enumerate(_forms):
                         # Exclude self
-                        ff_clean_name = get_field_clean_name(
-                            ff.cleaned_data.get("label")
-                        )
+                        ff_clean_name = ff.instance.get_field_clean_name()
                         if idx != i and clean_name == ff_clean_name:
                             form.add_error(
                                 "label",
diff --git a/wagtail/contrib/forms/models.py b/wagtail/contrib/forms/models.py
--- a/wagtail/contrib/forms/models.py
+++ b/wagtail/contrib/forms/models.py
@@ -121,6 +121,16 @@ class AbstractFormField(Orderable):
         FieldPanel("default_value", classname="formbuilder-default"),
     ]
 
+    def get_field_clean_name(self):
+        """
+        Prepare an ascii safe lower_snake_case variant of the field name to use as the field key.
+        This key is used to reference the field responses in the JSON store and as the field name in forms.
+        Called for new field creation, validation of duplicate labels and form previews.
+        When called, does not have access to the Page, nor its own id as the record is not yet created.
+        """
+
+        return get_field_clean_name(self.label)
+
     def save(self, *args, **kwargs):
         """
         When new fields are created, generate a template safe ascii name to use as the
@@ -132,7 +142,7 @@ def save(self, *args, **kwargs):
 
         is_new = self.pk is None
         if is_new:
-            clean_name = get_field_clean_name(self.label)
+            clean_name = self.get_field_clean_name()
             self.clean_name = clean_name
 
         super().save(*args, **kwargs)
diff --git a/wagtail/contrib/forms/utils.py b/wagtail/contrib/forms/utils.py
--- a/wagtail/contrib/forms/utils.py
+++ b/wagtail/contrib/forms/utils.py
@@ -9,8 +9,8 @@
 
 def get_field_clean_name(label):
     """
-    Converts a user entered field label to a template and JSON safe ascii value to be used
-    as the internal key (clean name) for the field.
+    Converts a user entered field label to a string that is safe to use for both a
+    HTML attribute (field's name) and a JSON key used internally to store the responses.
     """
     return safe_snake_case(label)
 


Test patch:
diff --git a/wagtail/contrib/forms/tests/test_forms.py b/wagtail/contrib/forms/tests/test_forms.py
--- a/wagtail/contrib/forms/tests/test_forms.py
+++ b/wagtail/contrib/forms/tests/test_forms.py
@@ -352,3 +352,34 @@ def test_adding_custom_field(self):
         self.assertIsInstance(
             form.base_fields["device_ip_address"], forms.GenericIPAddressField
         )
+
+    def test_unsaved_fields_in_form_builder_formfields_with_clean_name_override(self):
+        """
+        Ensure unsaved FormField instances are added to FormBuilder.formfields dict
+        with a clean_name that uses the `get_field_clean_name` method that can be overridden.
+        """
+
+        unsaved_field_1 = ExtendedFormField(
+            page=self.form_page,
+            sort_order=14,
+            label="Unsaved field 1",
+            field_type="number",
+            required=True,
+        )
+        self.form_page.form_fields.add(unsaved_field_1)
+
+        unsaved_field_2 = ExtendedFormField(
+            page=self.form_page,
+            sort_order=15,
+            label="duplicate (suffix removed)",
+            field_type="singleline",
+            required=True,
+        )
+        self.form_page.form_fields.add(unsaved_field_2)
+
+        form_class = self.form_page.get_form_class()
+        form = form_class()
+
+        # See ExtendedFormField get_field_clean_name method
+        self.assertIn("number_field--unsaved_field_1", form.base_fields)
+        self.assertIn("test duplicate", form.base_fields)
diff --git a/wagtail/contrib/forms/tests/test_models.py b/wagtail/contrib/forms/tests/test_models.py
--- a/wagtail/contrib/forms/tests/test_models.py
+++ b/wagtail/contrib/forms/tests/test_models.py
@@ -18,6 +18,7 @@
     CustomFormPageSubmission,
     ExtendedFormField,
     FormField,
+    FormFieldWithCustomSubmission,
     FormPageWithCustomFormBuilder,
     JadeFormPage,
 )
@@ -808,3 +809,56 @@ def test_non_html_extension(self):
         self.assertEqual(
             form_page.landing_page_template, "tests/form_page_landing.jade"
         )
+
+
+class TestFormFieldCleanNameCreation(TestCase, WagtailTestUtils):
+    fixtures = ["test.json"]
+
+    def setUp(self):
+        self.login(username="siteeditor", password="password")
+        self.form_page = Page.objects.get(
+            url_path="/home/contact-us-one-more-time/"
+        ).specific
+
+    def test_form_field_clean_name_creation(self):
+        """creating a new field should use clean_name format (anyascii snake_case)"""
+
+        field = FormFieldWithCustomSubmission.objects.create(
+            page=self.form_page,
+            label="Telefón-nummer",
+            field_type="number",
+        )
+
+        self.assertEqual(field.clean_name, "telefon_nummer")
+
+
+class TestFormFieldCleanNameCreationOverride(TestCase, WagtailTestUtils):
+    def setUp(self):
+        # Create a form page
+        home_page = Page.objects.get(url_path="/home/")
+
+        self.form_page = home_page.add_child(
+            instance=FormPageWithCustomFormBuilder(
+                title="Richiesta Gelato",
+                slug="ice-cream-request",
+                to_address="scoops@pro-eis.co.it",
+                from_address="scoops@pro-eis.co.it",
+                subject="Gelato in arrivo",
+            )
+        )
+
+    def test_form_field_clean_name_override(self):
+        """
+        Creating a new field should use the overridden method
+        See ExtendedFormField get_field_clean_name method
+        """
+
+        field = ExtendedFormField.objects.create(
+            page=self.form_page,
+            sort_order=1,
+            label="quanti ge·là·to?",
+            field_type="number",  # only number fields will add the ID as a prefix to the clean_name
+            required=True,
+        )
+
+        self.assertEqual(field.clean_name, "number_field--quanti_gelato")
diff --git a/wagtail/contrib/forms/tests/test_views.py b/wagtail/contrib/forms/tests/test_views.py
--- a/wagtail/contrib/forms/tests/test_views.py
+++ b/wagtail/contrib/forms/tests/test_views.py
@@ -1695,3 +1695,42 @@ def test_adding_duplicate_form_labels_as_cleaned_name(self):
             response,
             text="There is another field with the label LOW EARTH ORBIT, please change one of them.",
         )
+
+    def test_adding_duplicate_form_labels_using_override_clean_name(self):
+        """
+        Ensure form submission fails when attempting to create labels that will resolve
+        to the same clean_name that already exists when using a custom `get_field_clean_name` method
+        """
+
+        post_data = {
+            "title": "Form page!",
+            "content": "Some content",
+            "slug": "contact-us",
+            "form_fields-TOTAL_FORMS": "3",
+            "form_fields-INITIAL_FORMS": "3",
+            "form_fields-MIN_NUM_FORMS": "0",
+            "form_fields-MAX_NUM_FORMS": "1000",
+            "form_fields-0-id": "",
+            "form_fields-0-label": "duplicate 1",
+            "form_fields-0-field_type": "singleline",
+            "form_fields-1-id": "",
+            "form_fields-1-label": "duplicate 2",
+            "form_fields-1-field_type": "singleline",
+            "form_fields-2-id": "",
+            "form_fields-2-label": "bar",
+            "form_fields-2-field_type": "singleline",
+        }
+        response = self.client.post(
+            reverse(
+                "wagtailadmin_pages:add",
+                args=("tests", "formpagewithcustomformbuilder", self.root_page.id),
+            ),
+            post_data,
+        )
+
+        self.assertEqual(response.status_code, 200)
+
+        self.assertContains(
+            response,
+            text="There is another field with the label duplicate 1, please change one of them.",
+        )
diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py
--- a/wagtail/test/testapp/models.py
+++ b/wagtail/test/testapp/models.py
@@ -773,7 +773,10 @@ def get_data_fields(self):
 
 
 class ExtendedFormField(AbstractFormField):
-    """Override the field_type field with extended choices."""
+    """
+    Override the field_type field with extended choices
+    and a custom clean_name override.
+    """
 
     page = ParentalKey(
         "FormPageWithCustomFormBuilder",
@@ -784,6 +787,19 @@ class ExtendedFormField(AbstractFormField):
         verbose_name="field type", max_length=16, choices=EXTENDED_CHOICES
     )
 
+    def get_field_clean_name(self):
+        clean_name = super().get_field_clean_name()
+
+        # scoping to field type to easily test behaviour in isolation
+        if self.field_type == "number":
+            return f"number_field--{clean_name}"
+
+        # scoping to field label to easily test duplicate behaviour in isolation
+        if "duplicate" in self.label:
+            return "test duplicate"
+
+        return clean_name
+
 
 class CustomFormBuilder(FormBuilder):
     """