diff --git a/wagtail/api/v2/tests/test_pages.py b/wagtail/api/v2/tests/test_pages.py
index e9436db366..9ca9f8a313 100644
--- a/wagtail/api/v2/tests/test_pages.py
+++ b/wagtail/api/v2/tests/test_pages.py
@@ -1554,6 +1554,60 @@ class TestPageDetail(TestCase):
self.assertEqual(response.status_code, 400)
self.assertEqual(content, {"message": "'title' does not support nested fields"})
+ def test_form_fields_on_form_page(self):
+ """
+ Check that adding form_fields will correctly return then in the API response when declared
+ """
+
+ home_page = Page.objects.get(slug="home-page")
+ form_page = home_page.add_child(instance=models.FormPage(title="Contact us"))
+
+ field_1 = models.FormField.objects.create(
+ page=form_page,
+ sort_order=1,
+ label="email",
+ field_type="email",
+ )
+ field_2 = models.FormField.objects.create(
+ page=form_page,
+ sort_order=2,
+ label="message",
+ field_type="multiline",
+ required=True,
+ help_text="please be polite",
+ )
+
+ response = self.get_response(form_page.pk, fields="form_fields")
+ content = json.loads(response.content.decode("UTF-8"))
+
+ self.assertEqual(
+ content["form_fields"],
+ [
+ {
+ "id": field_1.pk,
+ "clean_name": "email",
+ "meta": {"type": "demosite.FormField"},
+ "label": "email",
+ "help_text": "",
+ "required": True,
+ "field_type": "email",
+ "choices": "",
+ "default_value": "",
+ },
+ {
+ "id": field_2.pk,
+ "clean_name": "message",
+ "meta": {"type": "demosite.FormField"},
+ "label": "message",
+ "help_text": "please be polite",
+ "required": True,
+ "field_type": "multiline",
+ "choices": "",
+ "default_value": "",
+ },
+ ],
+ )
+
class TestPageFind(TestCase):
fixtures = ["demosite.json"]
diff --git a/wagtail/test/demosite/migrations/0001_initial.py b/wagtail/test/demosite/migrations/0001_initial.py
index 7e58e57fab..def1985497 100644
--- a/wagtail/test/demosite/migrations/0001_initial.py
+++ b/wagtail/test/demosite/migrations/0001_initial.py
@@ -1204,4 +1204,120 @@ class Migration(migrations.Migration):
),
preserve_default=True,
),
+ migrations.CreateModel(
+ name="FormPage",
+ fields=[
+ (
+ "page_ptr",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ parent_link=True,
+ primary_key=True,
+ related_name="+",
+ serialize=False,
+ to="wagtailcore.page",
+ ),
+ ),
+ ],
+ options={
+ "abstract": False,
+ },
+ bases=("wagtailcore.page",),
+ ),
+ migrations.CreateModel(
+ name="FormField",
+ fields=[
+ (
+ "id",
+ models.AutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "sort_order",
+ models.IntegerField(blank=True, editable=False, null=True),
+ ),
+ (
+ "clean_name",
+ models.CharField(
+ blank=True,
+ default="",
+ help_text="Safe name of the form field, the label converted to ascii_snake_case",
+ max_length=255,
+ verbose_name="name",
+ ),
+ ),
+ (
+ "label",
+ models.CharField(
+ help_text="The label of the form field",
+ max_length=255,
+ verbose_name="label",
+ ),
+ ),
+ (
+ "field_type",
+ models.CharField(
+ choices=[
+ ("singleline", "Single line text"),
+ ("multiline", "Multi-line text"),
+ ("email", "Email"),
+ ("number", "Number"),
+ ("url", "URL"),
+ ("checkbox", "Checkbox"),
+ ("checkboxes", "Checkboxes"),
+ ("dropdown", "Drop down"),
+ ("multiselect", "Multiple select"),
+ ("radio", "Radio buttons"),
+ ("date", "Date"),
+ ("datetime", "Date/time"),
+ ("hidden", "Hidden field"),
+ ],
+ max_length=16,
+ verbose_name="field type",
+ ),
+ ),
+ (
+ "required",
+ models.BooleanField(default=True, verbose_name="required"),
+ ),
+ (
+ "choices",
+ models.TextField(
+ blank=True,
+ help_text="Comma or new line separated list of choices. Only applicable in checkboxes, radio and dropdown.",
+ verbose_name="choices",
+ ),
+ ),
+ (
+ "default_value",
+ models.TextField(
+ blank=True,
+ help_text="Default value. Comma or new line separated values supported for checkboxes.",
+ verbose_name="default value",
+ ),
+ ),
+ (
+ "help_text",
+ models.CharField(
+ blank=True, max_length=255, verbose_name="help text"
+ ),
+ ),
+ (
+ "page",
+ modelcluster.fields.ParentalKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="form_fields",
+ to="demosite.formpage",
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["sort_order"],
+ "abstract": False,
+ },
+ ),
]
diff --git a/wagtail/test/demosite/models.py b/wagtail/test/demosite/models.py
index c923174c3f..3e590a0e02 100644
--- a/wagtail/test/demosite/models.py
+++ b/wagtail/test/demosite/models.py
@@ -9,6 +9,7 @@ from taggit.models import TaggedItemBase
from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.api import APIField
+from wagtail.contrib.forms.models import AbstractForm, AbstractFormField
from wagtail.fields import RichTextField
from wagtail.images.api.fields import ImageRenditionField
from wagtail.models import Orderable, Page
@@ -677,3 +678,18 @@ ContactPage.promote_panels = [
MultiFieldPanel(Page.promote_panels, "Common page configuration"),
FieldPanel("feed_image"),
]
+
+
+class FormField(AbstractFormField):
+ page = ParentalKey("FormPage", related_name="form_fields", on_delete=models.CASCADE)
+
+
+class FormPage(AbstractForm):
+
+ page_ptr = models.OneToOneField(
+ Page, parent_link=True, related_name="+", on_delete=models.CASCADE
+ )
+ api_fields = [APIField("form_fields")]
+ content_panels = AbstractForm.content_panels + [
+ InlinePanel("form_fields", label="Form fields")
+ ]