Răsfoiți Sursa

Move wagtail.admin.edit_handlers to wagtail.admin.panels

Matt Westcott 3 ani în urmă
părinte
comite
b189ab8382
40 a modificat fișierele cu 1149 adăugiri și 1153 ștergeri
  1. 3 3
      docs/advanced_topics/customisation/page_editing_interface.rst
  2. 5 5
      docs/getting_started/tutorial.md
  3. 7 7
      docs/reference/contrib/forms/customisation.md
  4. 1 1
      docs/reference/contrib/forms/index.md
  5. 1 1
      docs/reference/contrib/modeladmin/create_edit_delete_views.rst
  6. 1 1
      docs/reference/contrib/modeladmin/index.rst
  7. 1 1
      docs/reference/contrib/settings.rst
  8. 6 6
      docs/reference/pages/panels.rst
  9. 1 1
      docs/releases/2.17.md
  10. 7 7
      docs/topics/pages.md
  11. 1 1
      docs/topics/snippets.rst
  12. 1 1
      docs/topics/streamfield.rst
  13. 1 1
      wagtail/admin/checks.py
  14. 1 1080
      wagtail/admin/edit_handlers.py
  15. 1 1
      wagtail/admin/forms/workflows.py
  16. 2 2
      wagtail/admin/models.py
  17. 1080 0
      wagtail/admin/panels.py
  18. 1 1
      wagtail/admin/tests/pages/test_preview.py
  19. 2 2
      wagtail/admin/tests/test_edit_handlers.py
  20. 1 0
      wagtail/bin/wagtail.py
  21. 1 1
      wagtail/contrib/forms/edit_handlers.py
  22. 1 1
      wagtail/contrib/forms/models.py
  23. 1 1
      wagtail/contrib/forms/tests/test_views.py
  24. 1 4
      wagtail/contrib/modeladmin/options.py
  25. 1 1
      wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py
  26. 1 1
      wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py
  27. 1 1
      wagtail/contrib/settings/tests/test_admin.py
  28. 1 1
      wagtail/contrib/settings/views.py
  29. 2 2
      wagtail/documents/edit_handlers.py
  30. 2 2
      wagtail/images/edit_handlers.py
  31. 1 1
      wagtail/models/__init__.py
  32. 2 2
      wagtail/snippets/edit_handlers.py
  33. 1 1
      wagtail/snippets/tests.py
  34. 1 4
      wagtail/snippets/views/snippets.py
  35. 1 1
      wagtail/test/customuser/models.py
  36. 1 1
      wagtail/test/demosite/models.py
  37. 1 1
      wagtail/test/modeladmintest/models.py
  38. 1 1
      wagtail/test/modeladmintest/wagtail_hooks.py
  39. 1 1
      wagtail/test/snippets/models.py
  40. 3 3
      wagtail/test/testapp/models.py

+ 3 - 3
docs/advanced_topics/customisation/page_editing_interface.rst

@@ -10,7 +10,7 @@ As standard, Wagtail organises panels for pages into three tabs: 'Content', 'Pro
 
 .. code-block:: python
 
-    from wagtail.admin.edit_handlers import TabbedInterface, ObjectList
+    from wagtail.admin.panels import TabbedInterface, ObjectList
 
     class BlogPage(Page):
         # field definitions omitted
@@ -43,7 +43,7 @@ Wagtail provides a general-purpose WYSIWYG editor for creating rich text content
 .. code-block:: python
 
     from wagtail.fields import RichTextField
-    from wagtail.admin.edit_handlers import FieldPanel
+    from wagtail.admin.panels import FieldPanel
 
 
     class BookPage(Page):
@@ -160,7 +160,7 @@ or to add custom validation logic for your models:
     from django import forms
     from django.db import models
     import geocoder  # not in Wagtail, for example only - https://geocoder.readthedocs.io/
-    from wagtail.admin.edit_handlers import FieldPanel
+    from wagtail.admin.panels import FieldPanel
     from wagtail.admin.forms import WagtailAdminPageForm
     from wagtail.models import Page
 

+ 5 - 5
docs/getting_started/tutorial.md

@@ -132,7 +132,7 @@ from django.db import models
 
 from wagtail.models import Page
 from wagtail.fields import RichTextField
-from wagtail.admin.edit_handlers import FieldPanel
+from wagtail.admin.panels import FieldPanel
 
 
 class HomePage(Page):
@@ -224,7 +224,7 @@ Lets start with a simple index page for our blog. In `blog/models.py`:
 ```python
 from wagtail.models import Page
 from wagtail.fields import RichTextField
-from wagtail.admin.edit_handlers import FieldPanel
+from wagtail.admin.panels import FieldPanel
 
 
 class BlogIndexPage(Page):
@@ -278,7 +278,7 @@ from django.db import models
 
 from wagtail.models import Page
 from wagtail.fields import RichTextField
-from wagtail.admin.edit_handlers import FieldPanel
+from wagtail.admin.panels import FieldPanel
 from wagtail.search import index
 
 
@@ -466,7 +466,7 @@ from modelcluster.fields import ParentalKey
 
 from wagtail.models import Page, Orderable
 from wagtail.fields import RichTextField
-from wagtail.admin.edit_handlers import FieldPanel, InlinePanel
+from wagtail.admin.panels import FieldPanel, InlinePanel
 from wagtail.search import index
 
 
@@ -621,7 +621,7 @@ from taggit.models import TaggedItemBase
 
 from wagtail.models import Page, Orderable
 from wagtail.fields import RichTextField
-from wagtail.admin.edit_handlers import FieldPanel, InlinePanel, MultiFieldPanel
+from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
 from wagtail.search import index
 
 

+ 7 - 7
docs/reference/contrib/forms/customisation.md

@@ -11,7 +11,7 @@ You can do this as shown below.
 
 ```python
 from modelcluster.fields import ParentalKey
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     FieldPanel, FieldRowPanel,
     InlinePanel, MultiFieldPanel
 )
@@ -61,7 +61,7 @@ from django.conf import settings
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
 from modelcluster.fields import ParentalKey
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     FieldPanel, FieldRowPanel,
     InlinePanel, MultiFieldPanel
 )
@@ -121,7 +121,7 @@ from django.conf import settings
 from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
 from modelcluster.fields import ParentalKey
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     FieldPanel, FieldRowPanel,
     InlinePanel, MultiFieldPanel
 )
@@ -195,7 +195,7 @@ from django.core.serializers.json import DjangoJSONEncoder
 from django.db import models
 from django.shortcuts import render
 from modelcluster.fields import ParentalKey
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     FieldPanel, FieldRowPanel,
     InlinePanel, MultiFieldPanel
 )
@@ -288,7 +288,7 @@ The following example shows how to create a multi-step form.
 from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
 from django.shortcuts import render
 from modelcluster.fields import ParentalKey
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     FieldPanel, FieldRowPanel,
     InlinePanel, MultiFieldPanel
 )
@@ -430,7 +430,7 @@ First, you need to collect results as shown below:
 
 ```python
 from modelcluster.fields import ParentalKey
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     FieldPanel, FieldRowPanel,
     InlinePanel, MultiFieldPanel
 )
@@ -544,7 +544,7 @@ Finally, we add a URL param of `id` based on the `form_submission` if it exists.
 
 ```python
 from django.shortcuts import redirect
-from wagtail.admin.edit_handlers import FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel
+from wagtail.admin.panels import FieldPanel, FieldRowPanel, InlinePanel, MultiFieldPanel
 from wagtail.contrib.forms.models import AbstractEmailForm
 
 class FormPage(AbstractEmailForm):

+ 1 - 1
docs/reference/contrib/forms/index.md

@@ -26,7 +26,7 @@ Within the `models.py` of one of your apps, create a model that extends `wagtail
 ```python
 from django.db import models
 from modelcluster.fields import ParentalKey
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     FieldPanel, FieldRowPanel,
     InlinePanel, MultiFieldPanel
 )

+ 1 - 1
docs/reference/contrib/modeladmin/create_edit_delete_views.rst

@@ -231,7 +231,7 @@ and ManyToManyField fields.
 ``ModelAdmin.get_edit_handler()``
 -----------------------------------
 
-**Must return**: An instance of ``wagtail.admin.edit_handlers.ObjectList``
+**Must return**: An instance of ``wagtail.admin.panels.ObjectList``
 
 Returns the appropriate ``edit_handler`` for the modeladmin class.
 ``edit_handlers`` can be defined either on the model itself or on the

+ 1 - 1
docs/reference/contrib/modeladmin/index.rst

@@ -82,7 +82,7 @@ to create, view, and edit ``Book`` entries.
 .. code-block:: python
 
     from django.db import models
-    from wagtail.admin.edit_handlers import FieldPanel
+    from wagtail.admin.panels import FieldPanel
 
     class Book(models.Model):
         title = models.CharField(max_length=255)

+ 1 - 1
docs/reference/contrib/settings.rst

@@ -65,7 +65,7 @@ with a custom ``edit_handler`` attribute:
 
 .. code-block:: python
 
-    from wagtail.admin.edit_handlers import TabbedInterface, ObjectList
+    from wagtail.admin.panels import TabbedInterface, ObjectList
 
     @register_setting
     class MySettings(BaseSetting):

+ 6 - 6
docs/reference/pages/panels.rst

@@ -6,11 +6,11 @@ Panel types
 Built-in Fields and Choosers
 ----------------------------
 
-Django's field types are automatically recognised and provided with an appropriate widget for input. Just define that field the normal Django way and pass the field name into :class:`~wagtail.admin.edit_handlers.FieldPanel` when defining your panels. Wagtail will take care of the rest.
+Django's field types are automatically recognised and provided with an appropriate widget for input. Just define that field the normal Django way and pass the field name into :class:`~wagtail.admin.panels.FieldPanel` when defining your panels. Wagtail will take care of the rest.
 
 Here are some Wagtail-specific types that you might include as fields in your models.
 
-.. module:: wagtail.admin.edit_handlers
+.. module:: wagtail.admin.panels
 
 FieldPanel
 ~~~~~~~~~~
@@ -65,7 +65,7 @@ MultiFieldPanel
 
 .. class:: MultiFieldPanel(children, heading="", classname=None)
 
-    This panel condenses several :class:`~wagtail.admin.edit_handlers.FieldPanel` s or choosers, from a ``list`` or ``tuple``, under a single ``heading`` string.
+    This panel condenses several :class:`~wagtail.admin.panels.FieldPanel` s or choosers, from a ``list`` or ``tuple``, under a single ``heading`` string.
 
     .. attribute:: MultiFieldPanel.children
 
@@ -142,7 +142,7 @@ PageChooserPanel
     .. code-block:: python
 
         from wagtail.models import Page
-        from wagtail.admin.edit_handlers import PageChooserPanel
+        from wagtail.admin.panels import PageChooserPanel
 
 
         class BookPage(Page):
@@ -242,7 +242,7 @@ By adding CSS classes to your panel definitions or adding extra parameters to yo
 Full-Width Input
 ~~~~~~~~~~~~~~~~
 
-Use ``classname="full"`` to make a field (input element) stretch the full width of the Wagtail page editor. This will not work if the field is encapsulated in a :class:`~wagtail.admin.edit_handlers.MultiFieldPanel`, which places its child fields into a formset.
+Use ``classname="full"`` to make a field (input element) stretch the full width of the Wagtail page editor. This will not work if the field is encapsulated in a :class:`~wagtail.admin.panels.MultiFieldPanel`, which places its child fields into a formset.
 
 
 Titles
@@ -366,7 +366,7 @@ Let's look at the example of adding related links to a :class:`~wagtail.models.P
       InlinePanel('related_links', label="Related Links"),
     ]
 
-The ``RelatedLink`` class is a vanilla Django abstract model. The ``BookPageRelatedLinks`` model extends it with capability for being ordered in the Wagtail interface via the ``Orderable`` class as well as adding a ``page`` property which links the model to the ``BookPage`` model we're adding the related links objects to. Finally, in the panel definitions for ``BookPage``, we'll add an :class:`~wagtail.admin.edit_handlers.InlinePanel` to provide an interface for it all. Let's look again at the parameters that :class:`~wagtail.admin.edit_handlers.InlinePanel` accepts:
+The ``RelatedLink`` class is a vanilla Django abstract model. The ``BookPageRelatedLinks`` model extends it with capability for being ordered in the Wagtail interface via the ``Orderable`` class as well as adding a ``page`` property which links the model to the ``BookPage`` model we're adding the related links objects to. Finally, in the panel definitions for ``BookPage``, we'll add an :class:`~wagtail.admin.panels.InlinePanel` to provide an interface for it all. Let's look again at the parameters that :class:`~wagtail.admin.panels.InlinePanel` accepts:
 
 .. code-block:: python
 

+ 1 - 1
docs/releases/2.17.md

@@ -25,7 +25,7 @@ The panel types `StreamFieldPanel`, `RichTextFieldPanel`, `ImageChooserPanel`, `
 
 ### Permission-dependent FieldPanels
 
-[`FieldPanel`](wagtail.admin.edit_handlers.FieldPanel) now accepts a `permission` keyword argument to specify that the field should only be available to users with a given permission level. This feature was developed by Matt Westcott and sponsored by Google as part of Wagtail's page editor redevelopment.
+[`FieldPanel`](wagtail.admin.panels.FieldPanel) now accepts a `permission` keyword argument to specify that the field should only be available to users with a given permission level. This feature was developed by Matt Westcott and sponsored by Google as part of Wagtail's page editor redevelopment.
 
 
 ### Other features

+ 7 - 7
docs/topics/pages.md

@@ -22,7 +22,7 @@ from modelcluster.fields import ParentalKey
 
 from wagtail.models import Page, Orderable
 from wagtail.fields import RichTextField
-from wagtail.admin.edit_handlers import FieldPanel, MultiFieldPanel, InlinePanel
+from wagtail.admin.panels import FieldPanel, MultiFieldPanel, InlinePanel
 from wagtail.search import index
 
 
@@ -129,8 +129,8 @@ Here's a summary of the `EditHandler` classes that Wagtail provides out of the b
 
 These allow editing of model fields. The `FieldPanel` class will choose the correct widget based on the type of the field, such as a rich text editor for `RichTextField`, or an image chooser for a `ForeignKey` to an image model. `FieldPanel` also provides a page chooser interface for `ForeignKey`s to page models, but for more fine-grained control over which page types can be chosen, `PageChooserPanel` provides additional configuration options.
 
-- {class}`~wagtail.admin.edit_handlers.FieldPanel`
-- {class}`~wagtail.admin.edit_handlers.PageChooserPanel`
+- {class}`~wagtail.admin.panels.FieldPanel`
+- {class}`~wagtail.admin.panels.PageChooserPanel`
 
 ```{versionchanged} 2.17
 Previously, certain field types required special-purpose panels: `StreamFieldPanel`, `ImageChooserPanel`, `DocumentChooserPanel` and `SnippetChooserPanel`. These are now all handled by `FieldPanel`.
@@ -140,9 +140,9 @@ Previously, certain field types required special-purpose panels: `StreamFieldPan
 
 These are used for structuring fields in the interface.
 
-- {class}`~wagtail.admin.edit_handlers.MultiFieldPanel`
-- {class}`~wagtail.admin.edit_handlers.InlinePanel`
-- {class}`~wagtail.admin.edit_handlers.FieldRowPanel`
+- {class}`~wagtail.admin.panels.MultiFieldPanel`
+- {class}`~wagtail.admin.panels.InlinePanel`
+- {class}`~wagtail.admin.panels.FieldRowPanel`
 
 
 #### Customising the page editor interface
@@ -356,7 +356,7 @@ class BlogPageRelatedLink(Orderable):
     ]
 ```
 
-To add this to the admin interface, use the {class}`~wagtail.admin.edit_handlers.InlinePanel` edit panel class:
+To add this to the admin interface, use the {class}`~wagtail.admin.panels.InlinePanel` edit panel class:
 
 ```python
 content_panels = [

+ 1 - 1
docs/topics/snippets.rst

@@ -17,7 +17,7 @@ Here's an example snippet model:
 
   from django.db import models
 
-  from wagtail.admin.edit_handlers import FieldPanel
+  from wagtail.admin.panels import FieldPanel
   from wagtail.snippets.models import register_snippet
 
   ...

+ 1 - 1
docs/topics/streamfield.rst

@@ -22,7 +22,7 @@ Using StreamField
     from wagtail.models import Page
     from wagtail.fields import StreamField
     from wagtail import blocks
-    from wagtail.admin.edit_handlers import FieldPanel, StreamFieldPanel
+    from wagtail.admin.panels import FieldPanel, StreamFieldPanel
     from wagtail.images.blocks import ImageChooserBlock
 
     class BlogPage(Page):

+ 1 - 1
wagtail/admin/checks.py

@@ -104,7 +104,7 @@ def inline_panel_model_panels_check(app_configs, **kwargs):
 
 def check_panels_in_model(cls, context="model"):
     """Check panels configuration uses `panels` when `edit_handler` not in use."""
-    from wagtail.admin.edit_handlers import BaseCompositeEditHandler, InlinePanel
+    from wagtail.admin.panels import BaseCompositeEditHandler, InlinePanel
     from wagtail.models import Page
 
     errors = []

+ 1 - 1080
wagtail/admin/edit_handlers.py

@@ -1,1080 +1 @@
-import functools
-import re
-from warnings import warn
-
-from django import forms
-from django.apps import apps
-from django.conf import settings
-from django.contrib.auth import get_user_model
-from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
-from django.core.signals import setting_changed
-from django.dispatch import receiver
-from django.forms.formsets import DELETION_FIELD_NAME, ORDERING_FIELD_NAME
-from django.forms.models import fields_for_model
-from django.template.loader import render_to_string
-from django.utils.functional import cached_property
-from django.utils.safestring import mark_safe
-from django.utils.translation import gettext_lazy
-from modelcluster.models import get_serializable_data_for_fields
-
-from wagtail.admin import compare, widgets
-from wagtail.admin.forms.comments import CommentForm, CommentReplyForm
-from wagtail.admin.templatetags.wagtailadmin_tags import avatar_url, user_display_name
-from wagtail.blocks import BlockField
-from wagtail.coreutils import camelcase_to_underscore
-from wagtail.models import COMMENTS_RELATION_NAME, Page
-from wagtail.utils.decorators import cached_classmethod
-from wagtail.utils.deprecation import RemovedInWagtail219Warning
-
-# DIRECT_FORM_FIELD_OVERRIDES, FORM_FIELD_OVERRIDES are imported for backwards
-# compatibility, as people are likely importing them from here and then
-# appending their own overrides
-from .forms.models import (  # NOQA
-    DIRECT_FORM_FIELD_OVERRIDES,
-    FORM_FIELD_OVERRIDES,
-    WagtailAdminModelForm,
-    formfield_for_dbfield,
-)
-from .forms.pages import WagtailAdminPageForm
-
-
-def widget_with_script(widget, script):
-    return mark_safe("{0}<script>{1}</script>".format(widget, script))
-
-
-def get_form_for_model(
-    model,
-    form_class=WagtailAdminModelForm,
-    fields=None,
-    exclude=None,
-    formsets=None,
-    exclude_formsets=None,
-    widgets=None,
-    field_permissions=None,
-):
-
-    # django's modelform_factory with a bit of custom behaviour
-    attrs = {"model": model}
-    if fields is not None:
-        attrs["fields"] = fields
-    if exclude is not None:
-        attrs["exclude"] = exclude
-    if widgets is not None:
-        attrs["widgets"] = widgets
-    if formsets is not None:
-        attrs["formsets"] = formsets
-    if exclude_formsets is not None:
-        attrs["exclude_formsets"] = exclude_formsets
-    if field_permissions is not None:
-        attrs["field_permissions"] = field_permissions
-
-    # Give this new form class a reasonable name.
-    class_name = model.__name__ + str("Form")
-    bases = (object,)
-    if hasattr(form_class, "Meta"):
-        bases = (form_class.Meta,) + bases
-
-    form_class_attrs = {"Meta": type(str("Meta"), bases, attrs)}
-
-    metaclass = type(form_class)
-
-    return metaclass(class_name, (form_class,), form_class_attrs)
-
-
-def extract_panel_definitions_from_model_class(model, exclude=None):
-    if hasattr(model, "panels"):
-        return model.panels
-
-    panels = []
-
-    _exclude = []
-    if exclude:
-        _exclude.extend(exclude)
-
-    fields = fields_for_model(
-        model, exclude=_exclude, formfield_callback=formfield_for_dbfield
-    )
-
-    for field_name, field in fields.items():
-        try:
-            panel_class = field.widget.get_panel()
-        except AttributeError:
-            panel_class = FieldPanel
-
-        panel = panel_class(field_name)
-        panels.append(panel)
-
-    return panels
-
-
-class EditHandler:
-    """
-    Abstract class providing sensible default behaviours for objects implementing
-    the EditHandler API
-    """
-
-    def __init__(self, heading="", classname="", help_text=""):
-        self.heading = heading
-        self.classname = classname
-        self.help_text = help_text
-        self.model = None
-        self.instance = None
-        self.request = None
-        self.form = None
-
-    def clone(self):
-        return self.__class__(**self.clone_kwargs())
-
-    def clone_kwargs(self):
-        return {
-            "heading": self.heading,
-            "classname": self.classname,
-            "help_text": self.help_text,
-        }
-
-    # return list of widget overrides that this EditHandler wants to be in place
-    # on the form it receives
-    def widget_overrides(self):
-        return {}
-
-    # return list of fields that this EditHandler expects to find on the form
-    def required_fields(self):
-        return []
-
-    # return a dict of formsets that this EditHandler requires to be present
-    # as children of the ClusterForm; the dict is a mapping from relation name
-    # to parameters to be passed as part of get_form_for_model's 'formsets' kwarg
-    def required_formsets(self):
-        return {}
-
-    # return a dict mapping field name to the permission codename that a user must have for that
-    # field to be included in the form
-    def field_permissions(self):
-        return {}
-
-    # return any HTML that needs to be output on the edit page once per edit handler definition.
-    # Typically this will be used to define snippets of HTML within <script type="text/x-template"></script> blocks
-    # for JavaScript code to work with.
-    def html_declarations(self):
-        return ""
-
-    def is_shown(self):
-        return True
-
-    def bind_to(self, model=None, instance=None, request=None, form=None):
-        if model is None and instance is not None and self.model is None:
-            model = instance._meta.model
-
-        new = self.clone()
-        new.model = self.model if model is None else model
-        new.instance = self.instance if instance is None else instance
-        new.request = self.request if request is None else request
-        new.form = self.form if form is None else form
-
-        if new.model is not None:
-            new.on_model_bound()
-
-        if new.instance is not None:
-            new.on_instance_bound()
-
-        if new.request is not None:
-            new.on_request_bound()
-
-        if new.form is not None:
-            new.on_form_bound()
-
-        return new
-
-    def on_model_bound(self):
-        pass
-
-    def on_instance_bound(self):
-        pass
-
-    def on_request_bound(self):
-        pass
-
-    def on_form_bound(self):
-        pass
-
-    def __repr__(self):
-        return "<%s with model=%s instance=%s request=%s form=%s>" % (
-            self.__class__.__name__,
-            self.model,
-            self.instance,
-            self.request,
-            self.form.__class__.__name__,
-        )
-
-    def classes(self):
-        """
-        Additional CSS classnames to add to whatever kind of object this is at output.
-        Subclasses of EditHandler should override this, invoking super().classes() to
-        append more classes specific to the situation.
-        """
-        if self.classname:
-            return [self.classname]
-        return []
-
-    def field_type(self):
-        """
-        The kind of field it is e.g boolean_field. Useful for better semantic markup of field display based on type
-        """
-        return ""
-
-    def id_for_label(self):
-        """
-        The ID to be used as the 'for' attribute of any <label> elements that refer
-        to this object but are rendered outside of it. Leave blank if this object does not render
-        as a single input field.
-        """
-        return ""
-
-    def render_as_object(self):
-        """
-        Render this object as it should appear within an ObjectList. Should not
-        include the <h2> heading or help text - ObjectList will supply those
-        """
-        # by default, assume that the subclass provides a catch-all render() method
-        return self.render()
-
-    def render_as_field(self):
-        """
-        Render this object as it should appear within a <ul class="fields"> list item
-        """
-        # by default, assume that the subclass provides a catch-all render() method
-        return self.render()
-
-    def render_missing_fields(self):
-        """
-        Helper function: render all of the fields that are defined on the form but not "claimed" by
-        any panels via required_fields. These fields are most likely to be hidden fields introduced
-        by the forms framework itself, such as ORDER / DELETE fields on formset members.
-
-        (If they aren't actually hidden fields, then they will appear as ugly unstyled / label-less fields
-        outside of the panel furniture. But there's not much we can do about that.)
-        """
-        rendered_fields = self.required_fields()
-        missing_fields_html = [
-            str(self.form[field_name])
-            for field_name in self.form.fields
-            if field_name not in rendered_fields
-        ]
-
-        return mark_safe("".join(missing_fields_html))
-
-    def render_form_content(self):
-        """
-        Render this as an 'object', ensuring that all fields necessary for a valid form
-        submission are included
-        """
-        return mark_safe(self.render_as_object() + self.render_missing_fields())
-
-    def get_comparison(self):
-        return []
-
-
-class BaseCompositeEditHandler(EditHandler):
-    """
-    Abstract class for EditHandlers that manage a set of sub-EditHandlers.
-    Concrete subclasses must attach a 'children' property
-    """
-
-    def __init__(self, children=(), *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.children = children
-
-    def clone_kwargs(self):
-        kwargs = super().clone_kwargs()
-        kwargs["children"] = self.children
-        return kwargs
-
-    def widget_overrides(self):
-        # build a collated version of all its children's widget lists
-        widgets = {}
-        for handler_class in self.children:
-            widgets.update(handler_class.widget_overrides())
-        widget_overrides = widgets
-
-        return widget_overrides
-
-    def required_fields(self):
-        fields = []
-        for handler in self.children:
-            fields.extend(handler.required_fields())
-        return fields
-
-    def required_formsets(self):
-        formsets = {}
-        for handler_class in self.children:
-            formsets.update(handler_class.required_formsets())
-        return formsets
-
-    def field_permissions(self):
-        field_permissions = {}
-        for handler_class in self.children:
-            field_permissions.update(handler_class.field_permissions())
-        return field_permissions
-
-    @property
-    def visible_children(self):
-        return [child for child in self.children if child.is_shown()]
-
-    def is_shown(self):
-        return any(child.is_shown() for child in self.children)
-
-    def html_declarations(self):
-        return mark_safe("".join([c.html_declarations() for c in self.children]))
-
-    def on_model_bound(self):
-        self.children = [child.bind_to(model=self.model) for child in self.children]
-
-    def on_instance_bound(self):
-        self.children = [
-            child.bind_to(instance=self.instance) for child in self.children
-        ]
-
-    def on_request_bound(self):
-        self.children = [child.bind_to(request=self.request) for child in self.children]
-
-    def on_form_bound(self):
-        children = []
-        for child in self.children:
-            if isinstance(child, FieldPanel):
-                if self.form._meta.exclude:
-                    if child.field_name in self.form._meta.exclude:
-                        continue
-                if self.form._meta.fields:
-                    if child.field_name not in self.form._meta.fields:
-                        continue
-            children.append(child.bind_to(form=self.form))
-        self.children = children
-
-    def render(self):
-        return mark_safe(render_to_string(self.template, {"self": self}))
-
-    def get_comparison(self):
-        comparators = []
-
-        for child in self.children:
-            comparators.extend(child.get_comparison())
-
-        return comparators
-
-
-class BaseFormEditHandler(BaseCompositeEditHandler):
-    """
-    Base class for edit handlers that can construct a form class for all their
-    child edit handlers.
-    """
-
-    # The form class used as the base for constructing specific forms for this
-    # edit handler.  Subclasses can override this attribute to provide a form
-    # with custom validation, for example.  Custom forms must subclass
-    # WagtailAdminModelForm
-    base_form_class = None
-
-    def get_form_class(self):
-        """
-        Construct a form class that has all the fields and formsets named in
-        the children of this edit handler.
-        """
-        if self.model is None:
-            raise AttributeError(
-                "%s is not bound to a model yet. Use `.bind_to(model=model)` "
-                "before using this method." % self.__class__.__name__
-            )
-        # If a custom form class was passed to the EditHandler, use it.
-        # Otherwise, use the base_form_class from the model.
-        # If that is not defined, use WagtailAdminModelForm.
-        model_form_class = getattr(self.model, "base_form_class", WagtailAdminModelForm)
-        base_form_class = self.base_form_class or model_form_class
-
-        return get_form_for_model(
-            self.model,
-            form_class=base_form_class,
-            fields=self.required_fields(),
-            formsets=self.required_formsets(),
-            widgets=self.widget_overrides(),
-            field_permissions=self.field_permissions(),
-        )
-
-
-class TabbedInterface(BaseFormEditHandler):
-    template = "wagtailadmin/edit_handlers/tabbed_interface.html"
-
-    def __init__(self, *args, show_comments_toggle=None, **kwargs):
-        self.base_form_class = kwargs.pop("base_form_class", None)
-        super().__init__(*args, **kwargs)
-        if show_comments_toggle is not None:
-            self.show_comments_toggle = show_comments_toggle
-        else:
-            self.show_comments_toggle = (
-                "comment_notifications" in self.required_fields()
-            )
-
-    def get_form_class(self):
-        form_class = super().get_form_class()
-
-        # Set show_comments_toggle attribute on form class
-        return type(
-            form_class.__name__,
-            (form_class,),
-            {"show_comments_toggle": self.show_comments_toggle},
-        )
-
-    def clone_kwargs(self):
-        kwargs = super().clone_kwargs()
-        kwargs["base_form_class"] = self.base_form_class
-        kwargs["show_comments_toggle"] = self.show_comments_toggle
-        return kwargs
-
-
-class ObjectList(TabbedInterface):
-    template = "wagtailadmin/edit_handlers/object_list.html"
-
-
-class FieldRowPanel(BaseCompositeEditHandler):
-    template = "wagtailadmin/edit_handlers/field_row_panel.html"
-
-    def on_instance_bound(self):
-        super().on_instance_bound()
-
-        col_count = " col%s" % (12 // len(self.children))
-        # If child panel doesn't have a col# class then append default based on
-        # number of columns
-        for child in self.children:
-            if not re.search(r"\bcol\d+\b", child.classname):
-                child.classname += col_count
-
-
-class MultiFieldPanel(BaseCompositeEditHandler):
-    template = "wagtailadmin/edit_handlers/multi_field_panel.html"
-
-    def classes(self):
-        classes = super().classes()
-        classes.append("multi-field")
-        return classes
-
-
-class HelpPanel(EditHandler):
-    def __init__(
-        self,
-        content="",
-        template="wagtailadmin/edit_handlers/help_panel.html",
-        heading="",
-        classname="",
-    ):
-        super().__init__(heading=heading, classname=classname)
-        self.content = content
-        self.template = template
-
-    def clone_kwargs(self):
-        kwargs = super().clone_kwargs()
-        del kwargs["help_text"]
-        kwargs.update(
-            content=self.content,
-            template=self.template,
-        )
-        return kwargs
-
-    def render(self):
-        return mark_safe(render_to_string(self.template, {"self": self}))
-
-
-class FieldPanel(EditHandler):
-    TEMPLATE_VAR = "field_panel"
-
-    def __init__(self, field_name, *args, **kwargs):
-        widget = kwargs.pop("widget", None)
-        if widget is not None:
-            self.widget = widget
-        self.permission = kwargs.pop("permission", None)
-        self.disable_comments = kwargs.pop("disable_comments", None)
-        super().__init__(*args, **kwargs)
-        self.field_name = field_name
-
-    def clone_kwargs(self):
-        kwargs = super().clone_kwargs()
-        kwargs.update(
-            field_name=self.field_name,
-            widget=self.widget if hasattr(self, "widget") else None,
-            permission=self.permission,
-        )
-        return kwargs
-
-    def widget_overrides(self):
-        """check if a specific widget has been defined for this field"""
-        if hasattr(self, "widget"):
-            return {self.field_name: self.widget}
-        return {}
-
-    def field_permissions(self):
-        if self.permission:
-            return {self.field_name: self.permission}
-        else:
-            return {}
-
-    def is_shown(self):
-        if (
-            self.permission
-            and self.request
-            and not self.request.user.has_perm(self.permission)
-        ):
-            return False
-
-        return True
-
-    def classes(self):
-        classes = super().classes()
-
-        if self.bound_field.field.required:
-            classes.append("required")
-
-        # If field has any errors, add the classname 'error' to enable error styling
-        # (e.g. red background), unless the widget has its own mechanism for rendering errors
-        # via the render_with_errors mechanism (as StreamField does).
-        if self.bound_field.errors and not hasattr(
-            self.bound_field.field.widget, "render_with_errors"
-        ):
-            classes.append("error")
-
-        classes.append(self.field_type())
-
-        return classes
-
-    def field_type(self):
-        return camelcase_to_underscore(self.bound_field.field.__class__.__name__)
-
-    def id_for_label(self):
-        return self.bound_field.id_for_label
-
-    @property
-    def comments_enabled(self):
-        if self.disable_comments is None:
-            # by default, enable comments on all fields except StreamField (which has its own comment handling)
-            return not isinstance(self.bound_field.field, BlockField)
-        else:
-            return not self.disable_comments
-
-    object_template = "wagtailadmin/edit_handlers/single_field_panel.html"
-
-    def render_as_object(self):
-        return mark_safe(
-            render_to_string(
-                self.object_template,
-                {
-                    "self": self,
-                    self.TEMPLATE_VAR: self,
-                    "field": self.bound_field,
-                    "show_add_comment_button": self.comments_enabled
-                    and getattr(
-                        self.bound_field.field.widget, "show_add_comment_button", True
-                    ),
-                },
-            )
-        )
-
-    field_template = "wagtailadmin/edit_handlers/field_panel_field.html"
-
-    def render_as_field(self):
-        return mark_safe(
-            render_to_string(
-                self.field_template,
-                {
-                    "field": self.bound_field,
-                    "field_type": self.field_type(),
-                    "show_add_comment_button": self.comments_enabled
-                    and getattr(
-                        self.bound_field.field.widget, "show_add_comment_button", True
-                    ),
-                },
-            )
-        )
-
-    def required_fields(self):
-        return [self.field_name]
-
-    def get_comparison_class(self):
-        # Hide fields with hidden widget
-        widget_override = self.widget_overrides().get(self.field_name, None)
-        if widget_override and widget_override.is_hidden:
-            return
-
-        try:
-            field = self.db_field
-
-            if field.choices:
-                return compare.ChoiceFieldComparison
-
-            comparison_class = compare.comparison_class_registry.get(field)
-            if comparison_class:
-                return comparison_class
-
-            if field.is_relation:
-                if field.many_to_many:
-                    return compare.M2MFieldComparison
-
-                return compare.ForeignObjectComparison
-
-        except FieldDoesNotExist:
-            pass
-
-        return compare.FieldComparison
-
-    def get_comparison(self):
-        comparator_class = self.get_comparison_class()
-
-        if comparator_class and self.is_shown():
-            try:
-                return [functools.partial(comparator_class, self.db_field)]
-            except FieldDoesNotExist:
-                return []
-        return []
-
-    @cached_property
-    def db_field(self):
-        try:
-            model = self.model
-        except AttributeError:
-            raise ImproperlyConfigured(
-                "%r must be bound to a model before calling db_field" % self
-            )
-
-        return model._meta.get_field(self.field_name)
-
-    def on_form_bound(self):
-        try:
-            self.bound_field = self.form[self.field_name]
-        except KeyError:
-            return
-
-        if self.heading:
-            self.bound_field.label = self.heading
-        else:
-            self.heading = self.bound_field.label
-        self.help_text = self.bound_field.help_text
-
-    def __repr__(self):
-        return "<%s '%s' with model=%s instance=%s request=%s form=%s>" % (
-            self.__class__.__name__,
-            self.field_name,
-            self.model,
-            self.instance,
-            self.request,
-            self.form.__class__.__name__,
-        )
-
-
-class RichTextFieldPanel(FieldPanel):
-    def __init__(self, *args, **kwargs):
-        warn(
-            "wagtail.admin.edit_handlers.RichTextFieldPanel is obsolete and should be replaced by FieldPanel",
-            category=RemovedInWagtail219Warning,
-            stacklevel=2,
-        )
-        super().__init__(*args, **kwargs)
-
-
-class BaseChooserPanel(FieldPanel):
-    def __init__(self, *args, **kwargs):
-        warn(
-            "wagtail.admin.edit_handlers.BaseChooserPanel is obsolete and should be replaced by FieldPanel",
-            category=RemovedInWagtail219Warning,
-            stacklevel=2,
-        )
-        super().__init__(*args, **kwargs)
-
-
-class PageChooserPanel(FieldPanel):
-    def __init__(self, field_name, page_type=None, can_choose_root=False):
-        super().__init__(field_name=field_name)
-
-        self.page_type = page_type
-        self.can_choose_root = can_choose_root
-
-    def clone_kwargs(self):
-        return {
-            "field_name": self.field_name,
-            "page_type": self.page_type,
-            "can_choose_root": self.can_choose_root,
-        }
-
-    def widget_overrides(self):
-        if self.page_type or self.can_choose_root:
-            return {
-                self.field_name: widgets.AdminPageChooser(
-                    target_models=self.page_type, can_choose_root=self.can_choose_root
-                )
-            }
-        else:
-            return {}
-
-
-class InlinePanel(EditHandler):
-    def __init__(
-        self,
-        relation_name,
-        panels=None,
-        heading="",
-        label="",
-        min_num=None,
-        max_num=None,
-        *args,
-        **kwargs,
-    ):
-        super().__init__(*args, **kwargs)
-        self.relation_name = relation_name
-        self.panels = panels
-        self.heading = heading or label
-        self.label = label
-        self.min_num = min_num
-        self.max_num = max_num
-
-    def clone_kwargs(self):
-        kwargs = super().clone_kwargs()
-        kwargs.update(
-            relation_name=self.relation_name,
-            panels=self.panels,
-            label=self.label,
-            min_num=self.min_num,
-            max_num=self.max_num,
-        )
-        return kwargs
-
-    def get_panel_definitions(self):
-        # Look for a panels definition in the InlinePanel declaration
-        if self.panels is not None:
-            return self.panels
-        # Failing that, get it from the model
-        return extract_panel_definitions_from_model_class(
-            self.db_field.related_model, exclude=[self.db_field.field.name]
-        )
-
-    def get_child_edit_handler(self):
-        panels = self.get_panel_definitions()
-        child_edit_handler = MultiFieldPanel(panels, heading=self.heading)
-        return child_edit_handler.bind_to(model=self.db_field.related_model)
-
-    def required_formsets(self):
-        child_edit_handler = self.get_child_edit_handler()
-        return {
-            self.relation_name: {
-                "fields": child_edit_handler.required_fields(),
-                "widgets": child_edit_handler.widget_overrides(),
-                "min_num": self.min_num,
-                "validate_min": self.min_num is not None,
-                "max_num": self.max_num,
-                "validate_max": self.max_num is not None,
-                "formsets": child_edit_handler.required_formsets(),
-            }
-        }
-
-    def html_declarations(self):
-        return self.get_child_edit_handler().html_declarations()
-
-    def get_comparison(self):
-        field_comparisons = []
-
-        for panel in self.get_panel_definitions():
-            field_comparisons.extend(
-                panel.bind_to(model=self.db_field.related_model).get_comparison()
-            )
-
-        return [
-            functools.partial(
-                compare.ChildRelationComparison, self.db_field, field_comparisons
-            )
-        ]
-
-    def on_model_bound(self):
-        manager = getattr(self.model, self.relation_name)
-        self.db_field = manager.rel
-
-    def on_form_bound(self):
-        self.formset = self.form.formsets[self.relation_name]
-
-        self.children = []
-        for subform in self.formset.forms:
-            # override the DELETE field to have a hidden input
-            subform.fields[DELETION_FIELD_NAME].widget = forms.HiddenInput()
-
-            # ditto for the ORDER field, if present
-            if self.formset.can_order:
-                subform.fields[ORDERING_FIELD_NAME].widget = forms.HiddenInput()
-
-            child_edit_handler = self.get_child_edit_handler()
-            self.children.append(
-                child_edit_handler.bind_to(
-                    instance=subform.instance, request=self.request, form=subform
-                )
-            )
-
-        # if this formset is valid, it may have been re-ordered; respect that
-        # in case the parent form errored and we need to re-render
-        if self.formset.can_order and self.formset.is_valid():
-            self.children.sort(
-                key=lambda child: child.form.cleaned_data[ORDERING_FIELD_NAME] or 1
-            )
-
-        empty_form = self.formset.empty_form
-        empty_form.fields[DELETION_FIELD_NAME].widget = forms.HiddenInput()
-        if self.formset.can_order:
-            empty_form.fields[ORDERING_FIELD_NAME].widget = forms.HiddenInput()
-
-        self.empty_child = self.get_child_edit_handler()
-        self.empty_child = self.empty_child.bind_to(
-            instance=empty_form.instance, request=self.request, form=empty_form
-        )
-
-    template = "wagtailadmin/edit_handlers/inline_panel.html"
-
-    def render(self):
-        formset = render_to_string(
-            self.template,
-            {
-                "self": self,
-                "can_order": self.formset.can_order,
-            },
-        )
-        js = self.render_js_init()
-        return widget_with_script(formset, js)
-
-    js_template = "wagtailadmin/edit_handlers/inline_panel.js"
-
-    def render_js_init(self):
-        return mark_safe(
-            render_to_string(
-                self.js_template,
-                {
-                    "self": self,
-                    "can_order": self.formset.can_order,
-                },
-            )
-        )
-
-
-# This allows users to include the publishing panel in their own per-model override
-# without having to write these fields out by hand, potentially losing 'classname'
-# and therefore the associated styling of the publishing panel
-class PublishingPanel(MultiFieldPanel):
-    def __init__(self, **kwargs):
-        updated_kwargs = {
-            "children": [
-                FieldRowPanel(
-                    [
-                        FieldPanel("go_live_at"),
-                        FieldPanel("expire_at"),
-                    ],
-                    classname="label-above",
-                ),
-            ],
-            "heading": gettext_lazy("Scheduled publishing"),
-            "classname": "publishing",
-        }
-        updated_kwargs.update(kwargs)
-        super().__init__(**updated_kwargs)
-
-
-class PrivacyModalPanel(EditHandler):
-    def __init__(self, **kwargs):
-        updated_kwargs = {"heading": gettext_lazy("Privacy"), "classname": "privacy"}
-        updated_kwargs.update(kwargs)
-        super().__init__(**updated_kwargs)
-
-    def render(self):
-        content = render_to_string(
-            "wagtailadmin/pages/privacy_switch_panel.html",
-            {"self": self, "page": self.instance, "request": self.request},
-        )
-
-        from wagtail.admin.staticfiles import versioned_static
-
-        return mark_safe(
-            '{0}<script type="text/javascript" src="{1}"></script>'.format(
-                content, versioned_static("wagtailadmin/js/privacy-switch.js")
-            )
-        )
-
-
-class CommentPanel(EditHandler):
-    def required_fields(self):
-        # Adds the comment notifications field to the form.
-        # Note, this field is defined directly on WagtailAdminPageForm.
-        return ["comment_notifications"]
-
-    def required_formsets(self):
-        # add the comments formset
-        # we need to pass in the current user for validation on the formset
-        # this could alternatively be done on the page form itself if we added the
-        # comments formset there, but we typically only add fields via edit handlers
-        current_user = getattr(self.request, "user", None)
-
-        class CommentReplyFormWithRequest(CommentReplyForm):
-            user = current_user
-
-        class CommentFormWithRequest(CommentForm):
-            user = current_user
-
-            class Meta:
-                formsets = {"replies": {"form": CommentReplyFormWithRequest}}
-
-        return {
-            COMMENTS_RELATION_NAME: {
-                "form": CommentFormWithRequest,
-                "fields": ["text", "contentpath", "position"],
-                "formset_name": "comments",
-            }
-        }
-
-    template = "wagtailadmin/edit_handlers/comments/comment_panel.html"
-    declarations_template = (
-        "wagtailadmin/edit_handlers/comments/comment_declarations.html"
-    )
-
-    def html_declarations(self):
-        return render_to_string(self.declarations_template)
-
-    def get_context(self):
-        def user_data(user):
-            return {"name": user_display_name(user), "avatar_url": avatar_url(user)}
-
-        user = getattr(self.request, "user", None)
-        user_pks = {user.pk}
-        serialized_comments = []
-        bound = self.form.is_bound
-        comment_formset = self.form.formsets.get("comments")
-        comment_forms = comment_formset.forms if comment_formset else []
-        for form in comment_forms:
-            # iterate over comments to retrieve users (to get display names) and serialized versions
-            replies = []
-            for reply_form in form.formsets["replies"].forms:
-                user_pks.add(reply_form.instance.user_id)
-                reply_data = get_serializable_data_for_fields(reply_form.instance)
-                reply_data["deleted"] = (
-                    reply_form.cleaned_data.get("DELETE", False) if bound else False
-                )
-                replies.append(reply_data)
-            user_pks.add(form.instance.user_id)
-            data = get_serializable_data_for_fields(form.instance)
-            data["deleted"] = form.cleaned_data.get("DELETE", False) if bound else False
-            data["resolved"] = (
-                form.cleaned_data.get("resolved", False)
-                if bound
-                else form.instance.resolved_at is not None
-            )
-            data["replies"] = replies
-            serialized_comments.append(data)
-
-        authors = {
-            str(user.pk): user_data(user)
-            for user in get_user_model()
-            .objects.filter(pk__in=user_pks)
-            .select_related("wagtail_userprofile")
-        }
-
-        comments_data = {
-            "comments": serialized_comments,
-            "user": user.pk,
-            "authors": authors,
-        }
-
-        return {
-            "comments_data": comments_data,
-        }
-
-    def render(self):
-        panel = render_to_string(self.template, self.get_context())
-        return panel
-
-
-# Now that we've defined EditHandlers, we can set up wagtailcore.Page to have some.
-def set_default_page_edit_handlers(cls):
-    cls.content_panels = [
-        FieldPanel("title", classname="full title"),
-    ]
-
-    cls.promote_panels = [
-        MultiFieldPanel(
-            [
-                FieldPanel("slug"),
-                FieldPanel("seo_title"),
-                FieldPanel("search_description"),
-            ],
-            gettext_lazy("For search engines"),
-        ),
-        MultiFieldPanel(
-            [
-                FieldPanel("show_in_menus"),
-            ],
-            gettext_lazy("For site menus"),
-        ),
-    ]
-
-    cls.settings_panels = [
-        PublishingPanel(),
-        PrivacyModalPanel(),
-    ]
-
-    if getattr(settings, "WAGTAILADMIN_COMMENTS_ENABLED", True):
-        cls.settings_panels.append(CommentPanel())
-
-    cls.base_form_class = WagtailAdminPageForm
-
-
-set_default_page_edit_handlers(Page)
-
-
-@cached_classmethod
-def get_edit_handler(cls):
-    """
-    Get the EditHandler to use in the Wagtail admin when editing this page type.
-    """
-    if hasattr(cls, "edit_handler"):
-        edit_handler = cls.edit_handler
-    else:
-        # construct a TabbedInterface made up of content_panels, promote_panels
-        # and settings_panels, skipping any which are empty
-        tabs = []
-
-        if cls.content_panels:
-            tabs.append(ObjectList(cls.content_panels, heading=gettext_lazy("Content")))
-        if cls.promote_panels:
-            tabs.append(ObjectList(cls.promote_panels, heading=gettext_lazy("Promote")))
-        if cls.settings_panels:
-            tabs.append(
-                ObjectList(
-                    cls.settings_panels,
-                    heading=gettext_lazy("Settings"),
-                    classname="settings",
-                )
-            )
-
-        edit_handler = TabbedInterface(tabs, base_form_class=cls.base_form_class)
-
-    return edit_handler.bind_to(model=cls)
-
-
-Page.get_edit_handler = get_edit_handler
-
-
-@receiver(setting_changed)
-def reset_page_edit_handler_cache(**kwargs):
-    """
-    Clear page edit handler cache when global WAGTAILADMIN_COMMENTS_ENABLED settings are changed
-    """
-    if kwargs["setting"] == "WAGTAILADMIN_COMMENTS_ENABLED":
-        set_default_page_edit_handlers(Page)
-        for model in apps.get_models():
-            if issubclass(model, Page):
-                model.get_edit_handler.cache_clear()
-
-
-class StreamFieldPanel(FieldPanel):
-    def __init__(self, *args, **kwargs):
-        warn(
-            "wagtail.admin.edit_handlers.StreamFieldPanel is obsolete and should be replaced by FieldPanel",
-            category=RemovedInWagtail219Warning,
-            stacklevel=2,
-        )
-        super().__init__(*args, **kwargs)
+from wagtail.admin.panels import *  # noqa

+ 1 - 1
wagtail/admin/forms/workflows.py

@@ -5,8 +5,8 @@ from django.utils.translation import gettext as _
 from django.utils.translation import gettext_lazy as __
 
 from wagtail.admin import widgets
-from wagtail.admin.edit_handlers import FieldPanel, InlinePanel, ObjectList
 from wagtail.admin.forms import WagtailAdminModelForm
+from wagtail.admin.panels import FieldPanel, InlinePanel, ObjectList
 from wagtail.admin.widgets.workflows import AdminTaskChooser
 from wagtail.coreutils import get_model_string
 from wagtail.models import Page, Task, Workflow, WorkflowPage

+ 2 - 2
wagtail/admin/models.py

@@ -3,11 +3,11 @@ from django.db.models import Count, Model
 from modelcluster.fields import ParentalKey
 from taggit.models import Tag
 
-# The edit_handlers module extends Page with some additional attributes required by
+# The panels module extends Page with some additional attributes required by
 # wagtail admin (namely, base_form_class and get_edit_handler). Importing this within
 # wagtail.admin.models ensures that this happens in advance of running wagtail.admin's
 # system checks.
-from wagtail.admin import edit_handlers  # NOQA
+from wagtail.admin import panels  # NOQA
 from wagtail.models import Page
 
 

+ 1080 - 0
wagtail/admin/panels.py

@@ -0,0 +1,1080 @@
+import functools
+import re
+from warnings import warn
+
+from django import forms
+from django.apps import apps
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
+from django.core.signals import setting_changed
+from django.dispatch import receiver
+from django.forms.formsets import DELETION_FIELD_NAME, ORDERING_FIELD_NAME
+from django.forms.models import fields_for_model
+from django.template.loader import render_to_string
+from django.utils.functional import cached_property
+from django.utils.safestring import mark_safe
+from django.utils.translation import gettext_lazy
+from modelcluster.models import get_serializable_data_for_fields
+
+from wagtail.admin import compare, widgets
+from wagtail.admin.forms.comments import CommentForm, CommentReplyForm
+from wagtail.admin.templatetags.wagtailadmin_tags import avatar_url, user_display_name
+from wagtail.blocks import BlockField
+from wagtail.coreutils import camelcase_to_underscore
+from wagtail.models import COMMENTS_RELATION_NAME, Page
+from wagtail.utils.decorators import cached_classmethod
+from wagtail.utils.deprecation import RemovedInWagtail219Warning
+
+# DIRECT_FORM_FIELD_OVERRIDES, FORM_FIELD_OVERRIDES are imported for backwards
+# compatibility, as people are likely importing them from here and then
+# appending their own overrides
+from .forms.models import (  # NOQA
+    DIRECT_FORM_FIELD_OVERRIDES,
+    FORM_FIELD_OVERRIDES,
+    WagtailAdminModelForm,
+    formfield_for_dbfield,
+)
+from .forms.pages import WagtailAdminPageForm
+
+
+def widget_with_script(widget, script):
+    return mark_safe("{0}<script>{1}</script>".format(widget, script))
+
+
+def get_form_for_model(
+    model,
+    form_class=WagtailAdminModelForm,
+    fields=None,
+    exclude=None,
+    formsets=None,
+    exclude_formsets=None,
+    widgets=None,
+    field_permissions=None,
+):
+
+    # django's modelform_factory with a bit of custom behaviour
+    attrs = {"model": model}
+    if fields is not None:
+        attrs["fields"] = fields
+    if exclude is not None:
+        attrs["exclude"] = exclude
+    if widgets is not None:
+        attrs["widgets"] = widgets
+    if formsets is not None:
+        attrs["formsets"] = formsets
+    if exclude_formsets is not None:
+        attrs["exclude_formsets"] = exclude_formsets
+    if field_permissions is not None:
+        attrs["field_permissions"] = field_permissions
+
+    # Give this new form class a reasonable name.
+    class_name = model.__name__ + str("Form")
+    bases = (object,)
+    if hasattr(form_class, "Meta"):
+        bases = (form_class.Meta,) + bases
+
+    form_class_attrs = {"Meta": type(str("Meta"), bases, attrs)}
+
+    metaclass = type(form_class)
+
+    return metaclass(class_name, (form_class,), form_class_attrs)
+
+
+def extract_panel_definitions_from_model_class(model, exclude=None):
+    if hasattr(model, "panels"):
+        return model.panels
+
+    panels = []
+
+    _exclude = []
+    if exclude:
+        _exclude.extend(exclude)
+
+    fields = fields_for_model(
+        model, exclude=_exclude, formfield_callback=formfield_for_dbfield
+    )
+
+    for field_name, field in fields.items():
+        try:
+            panel_class = field.widget.get_panel()
+        except AttributeError:
+            panel_class = FieldPanel
+
+        panel = panel_class(field_name)
+        panels.append(panel)
+
+    return panels
+
+
+class EditHandler:
+    """
+    Abstract class providing sensible default behaviours for objects implementing
+    the EditHandler API
+    """
+
+    def __init__(self, heading="", classname="", help_text=""):
+        self.heading = heading
+        self.classname = classname
+        self.help_text = help_text
+        self.model = None
+        self.instance = None
+        self.request = None
+        self.form = None
+
+    def clone(self):
+        return self.__class__(**self.clone_kwargs())
+
+    def clone_kwargs(self):
+        return {
+            "heading": self.heading,
+            "classname": self.classname,
+            "help_text": self.help_text,
+        }
+
+    # return list of widget overrides that this EditHandler wants to be in place
+    # on the form it receives
+    def widget_overrides(self):
+        return {}
+
+    # return list of fields that this EditHandler expects to find on the form
+    def required_fields(self):
+        return []
+
+    # return a dict of formsets that this EditHandler requires to be present
+    # as children of the ClusterForm; the dict is a mapping from relation name
+    # to parameters to be passed as part of get_form_for_model's 'formsets' kwarg
+    def required_formsets(self):
+        return {}
+
+    # return a dict mapping field name to the permission codename that a user must have for that
+    # field to be included in the form
+    def field_permissions(self):
+        return {}
+
+    # return any HTML that needs to be output on the edit page once per edit handler definition.
+    # Typically this will be used to define snippets of HTML within <script type="text/x-template"></script> blocks
+    # for JavaScript code to work with.
+    def html_declarations(self):
+        return ""
+
+    def is_shown(self):
+        return True
+
+    def bind_to(self, model=None, instance=None, request=None, form=None):
+        if model is None and instance is not None and self.model is None:
+            model = instance._meta.model
+
+        new = self.clone()
+        new.model = self.model if model is None else model
+        new.instance = self.instance if instance is None else instance
+        new.request = self.request if request is None else request
+        new.form = self.form if form is None else form
+
+        if new.model is not None:
+            new.on_model_bound()
+
+        if new.instance is not None:
+            new.on_instance_bound()
+
+        if new.request is not None:
+            new.on_request_bound()
+
+        if new.form is not None:
+            new.on_form_bound()
+
+        return new
+
+    def on_model_bound(self):
+        pass
+
+    def on_instance_bound(self):
+        pass
+
+    def on_request_bound(self):
+        pass
+
+    def on_form_bound(self):
+        pass
+
+    def __repr__(self):
+        return "<%s with model=%s instance=%s request=%s form=%s>" % (
+            self.__class__.__name__,
+            self.model,
+            self.instance,
+            self.request,
+            self.form.__class__.__name__,
+        )
+
+    def classes(self):
+        """
+        Additional CSS classnames to add to whatever kind of object this is at output.
+        Subclasses of EditHandler should override this, invoking super().classes() to
+        append more classes specific to the situation.
+        """
+        if self.classname:
+            return [self.classname]
+        return []
+
+    def field_type(self):
+        """
+        The kind of field it is e.g boolean_field. Useful for better semantic markup of field display based on type
+        """
+        return ""
+
+    def id_for_label(self):
+        """
+        The ID to be used as the 'for' attribute of any <label> elements that refer
+        to this object but are rendered outside of it. Leave blank if this object does not render
+        as a single input field.
+        """
+        return ""
+
+    def render_as_object(self):
+        """
+        Render this object as it should appear within an ObjectList. Should not
+        include the <h2> heading or help text - ObjectList will supply those
+        """
+        # by default, assume that the subclass provides a catch-all render() method
+        return self.render()
+
+    def render_as_field(self):
+        """
+        Render this object as it should appear within a <ul class="fields"> list item
+        """
+        # by default, assume that the subclass provides a catch-all render() method
+        return self.render()
+
+    def render_missing_fields(self):
+        """
+        Helper function: render all of the fields that are defined on the form but not "claimed" by
+        any panels via required_fields. These fields are most likely to be hidden fields introduced
+        by the forms framework itself, such as ORDER / DELETE fields on formset members.
+
+        (If they aren't actually hidden fields, then they will appear as ugly unstyled / label-less fields
+        outside of the panel furniture. But there's not much we can do about that.)
+        """
+        rendered_fields = self.required_fields()
+        missing_fields_html = [
+            str(self.form[field_name])
+            for field_name in self.form.fields
+            if field_name not in rendered_fields
+        ]
+
+        return mark_safe("".join(missing_fields_html))
+
+    def render_form_content(self):
+        """
+        Render this as an 'object', ensuring that all fields necessary for a valid form
+        submission are included
+        """
+        return mark_safe(self.render_as_object() + self.render_missing_fields())
+
+    def get_comparison(self):
+        return []
+
+
+class BaseCompositeEditHandler(EditHandler):
+    """
+    Abstract class for EditHandlers that manage a set of sub-EditHandlers.
+    Concrete subclasses must attach a 'children' property
+    """
+
+    def __init__(self, children=(), *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.children = children
+
+    def clone_kwargs(self):
+        kwargs = super().clone_kwargs()
+        kwargs["children"] = self.children
+        return kwargs
+
+    def widget_overrides(self):
+        # build a collated version of all its children's widget lists
+        widgets = {}
+        for handler_class in self.children:
+            widgets.update(handler_class.widget_overrides())
+        widget_overrides = widgets
+
+        return widget_overrides
+
+    def required_fields(self):
+        fields = []
+        for handler in self.children:
+            fields.extend(handler.required_fields())
+        return fields
+
+    def required_formsets(self):
+        formsets = {}
+        for handler_class in self.children:
+            formsets.update(handler_class.required_formsets())
+        return formsets
+
+    def field_permissions(self):
+        field_permissions = {}
+        for handler_class in self.children:
+            field_permissions.update(handler_class.field_permissions())
+        return field_permissions
+
+    @property
+    def visible_children(self):
+        return [child for child in self.children if child.is_shown()]
+
+    def is_shown(self):
+        return any(child.is_shown() for child in self.children)
+
+    def html_declarations(self):
+        return mark_safe("".join([c.html_declarations() for c in self.children]))
+
+    def on_model_bound(self):
+        self.children = [child.bind_to(model=self.model) for child in self.children]
+
+    def on_instance_bound(self):
+        self.children = [
+            child.bind_to(instance=self.instance) for child in self.children
+        ]
+
+    def on_request_bound(self):
+        self.children = [child.bind_to(request=self.request) for child in self.children]
+
+    def on_form_bound(self):
+        children = []
+        for child in self.children:
+            if isinstance(child, FieldPanel):
+                if self.form._meta.exclude:
+                    if child.field_name in self.form._meta.exclude:
+                        continue
+                if self.form._meta.fields:
+                    if child.field_name not in self.form._meta.fields:
+                        continue
+            children.append(child.bind_to(form=self.form))
+        self.children = children
+
+    def render(self):
+        return mark_safe(render_to_string(self.template, {"self": self}))
+
+    def get_comparison(self):
+        comparators = []
+
+        for child in self.children:
+            comparators.extend(child.get_comparison())
+
+        return comparators
+
+
+class BaseFormEditHandler(BaseCompositeEditHandler):
+    """
+    Base class for edit handlers that can construct a form class for all their
+    child edit handlers.
+    """
+
+    # The form class used as the base for constructing specific forms for this
+    # edit handler.  Subclasses can override this attribute to provide a form
+    # with custom validation, for example.  Custom forms must subclass
+    # WagtailAdminModelForm
+    base_form_class = None
+
+    def get_form_class(self):
+        """
+        Construct a form class that has all the fields and formsets named in
+        the children of this edit handler.
+        """
+        if self.model is None:
+            raise AttributeError(
+                "%s is not bound to a model yet. Use `.bind_to(model=model)` "
+                "before using this method." % self.__class__.__name__
+            )
+        # If a custom form class was passed to the EditHandler, use it.
+        # Otherwise, use the base_form_class from the model.
+        # If that is not defined, use WagtailAdminModelForm.
+        model_form_class = getattr(self.model, "base_form_class", WagtailAdminModelForm)
+        base_form_class = self.base_form_class or model_form_class
+
+        return get_form_for_model(
+            self.model,
+            form_class=base_form_class,
+            fields=self.required_fields(),
+            formsets=self.required_formsets(),
+            widgets=self.widget_overrides(),
+            field_permissions=self.field_permissions(),
+        )
+
+
+class TabbedInterface(BaseFormEditHandler):
+    template = "wagtailadmin/edit_handlers/tabbed_interface.html"
+
+    def __init__(self, *args, show_comments_toggle=None, **kwargs):
+        self.base_form_class = kwargs.pop("base_form_class", None)
+        super().__init__(*args, **kwargs)
+        if show_comments_toggle is not None:
+            self.show_comments_toggle = show_comments_toggle
+        else:
+            self.show_comments_toggle = (
+                "comment_notifications" in self.required_fields()
+            )
+
+    def get_form_class(self):
+        form_class = super().get_form_class()
+
+        # Set show_comments_toggle attribute on form class
+        return type(
+            form_class.__name__,
+            (form_class,),
+            {"show_comments_toggle": self.show_comments_toggle},
+        )
+
+    def clone_kwargs(self):
+        kwargs = super().clone_kwargs()
+        kwargs["base_form_class"] = self.base_form_class
+        kwargs["show_comments_toggle"] = self.show_comments_toggle
+        return kwargs
+
+
+class ObjectList(TabbedInterface):
+    template = "wagtailadmin/edit_handlers/object_list.html"
+
+
+class FieldRowPanel(BaseCompositeEditHandler):
+    template = "wagtailadmin/edit_handlers/field_row_panel.html"
+
+    def on_instance_bound(self):
+        super().on_instance_bound()
+
+        col_count = " col%s" % (12 // len(self.children))
+        # If child panel doesn't have a col# class then append default based on
+        # number of columns
+        for child in self.children:
+            if not re.search(r"\bcol\d+\b", child.classname):
+                child.classname += col_count
+
+
+class MultiFieldPanel(BaseCompositeEditHandler):
+    template = "wagtailadmin/edit_handlers/multi_field_panel.html"
+
+    def classes(self):
+        classes = super().classes()
+        classes.append("multi-field")
+        return classes
+
+
+class HelpPanel(EditHandler):
+    def __init__(
+        self,
+        content="",
+        template="wagtailadmin/edit_handlers/help_panel.html",
+        heading="",
+        classname="",
+    ):
+        super().__init__(heading=heading, classname=classname)
+        self.content = content
+        self.template = template
+
+    def clone_kwargs(self):
+        kwargs = super().clone_kwargs()
+        del kwargs["help_text"]
+        kwargs.update(
+            content=self.content,
+            template=self.template,
+        )
+        return kwargs
+
+    def render(self):
+        return mark_safe(render_to_string(self.template, {"self": self}))
+
+
+class FieldPanel(EditHandler):
+    TEMPLATE_VAR = "field_panel"
+
+    def __init__(self, field_name, *args, **kwargs):
+        widget = kwargs.pop("widget", None)
+        if widget is not None:
+            self.widget = widget
+        self.permission = kwargs.pop("permission", None)
+        self.disable_comments = kwargs.pop("disable_comments", None)
+        super().__init__(*args, **kwargs)
+        self.field_name = field_name
+
+    def clone_kwargs(self):
+        kwargs = super().clone_kwargs()
+        kwargs.update(
+            field_name=self.field_name,
+            widget=self.widget if hasattr(self, "widget") else None,
+            permission=self.permission,
+        )
+        return kwargs
+
+    def widget_overrides(self):
+        """check if a specific widget has been defined for this field"""
+        if hasattr(self, "widget"):
+            return {self.field_name: self.widget}
+        return {}
+
+    def field_permissions(self):
+        if self.permission:
+            return {self.field_name: self.permission}
+        else:
+            return {}
+
+    def is_shown(self):
+        if (
+            self.permission
+            and self.request
+            and not self.request.user.has_perm(self.permission)
+        ):
+            return False
+
+        return True
+
+    def classes(self):
+        classes = super().classes()
+
+        if self.bound_field.field.required:
+            classes.append("required")
+
+        # If field has any errors, add the classname 'error' to enable error styling
+        # (e.g. red background), unless the widget has its own mechanism for rendering errors
+        # via the render_with_errors mechanism (as StreamField does).
+        if self.bound_field.errors and not hasattr(
+            self.bound_field.field.widget, "render_with_errors"
+        ):
+            classes.append("error")
+
+        classes.append(self.field_type())
+
+        return classes
+
+    def field_type(self):
+        return camelcase_to_underscore(self.bound_field.field.__class__.__name__)
+
+    def id_for_label(self):
+        return self.bound_field.id_for_label
+
+    @property
+    def comments_enabled(self):
+        if self.disable_comments is None:
+            # by default, enable comments on all fields except StreamField (which has its own comment handling)
+            return not isinstance(self.bound_field.field, BlockField)
+        else:
+            return not self.disable_comments
+
+    object_template = "wagtailadmin/edit_handlers/single_field_panel.html"
+
+    def render_as_object(self):
+        return mark_safe(
+            render_to_string(
+                self.object_template,
+                {
+                    "self": self,
+                    self.TEMPLATE_VAR: self,
+                    "field": self.bound_field,
+                    "show_add_comment_button": self.comments_enabled
+                    and getattr(
+                        self.bound_field.field.widget, "show_add_comment_button", True
+                    ),
+                },
+            )
+        )
+
+    field_template = "wagtailadmin/edit_handlers/field_panel_field.html"
+
+    def render_as_field(self):
+        return mark_safe(
+            render_to_string(
+                self.field_template,
+                {
+                    "field": self.bound_field,
+                    "field_type": self.field_type(),
+                    "show_add_comment_button": self.comments_enabled
+                    and getattr(
+                        self.bound_field.field.widget, "show_add_comment_button", True
+                    ),
+                },
+            )
+        )
+
+    def required_fields(self):
+        return [self.field_name]
+
+    def get_comparison_class(self):
+        # Hide fields with hidden widget
+        widget_override = self.widget_overrides().get(self.field_name, None)
+        if widget_override and widget_override.is_hidden:
+            return
+
+        try:
+            field = self.db_field
+
+            if field.choices:
+                return compare.ChoiceFieldComparison
+
+            comparison_class = compare.comparison_class_registry.get(field)
+            if comparison_class:
+                return comparison_class
+
+            if field.is_relation:
+                if field.many_to_many:
+                    return compare.M2MFieldComparison
+
+                return compare.ForeignObjectComparison
+
+        except FieldDoesNotExist:
+            pass
+
+        return compare.FieldComparison
+
+    def get_comparison(self):
+        comparator_class = self.get_comparison_class()
+
+        if comparator_class and self.is_shown():
+            try:
+                return [functools.partial(comparator_class, self.db_field)]
+            except FieldDoesNotExist:
+                return []
+        return []
+
+    @cached_property
+    def db_field(self):
+        try:
+            model = self.model
+        except AttributeError:
+            raise ImproperlyConfigured(
+                "%r must be bound to a model before calling db_field" % self
+            )
+
+        return model._meta.get_field(self.field_name)
+
+    def on_form_bound(self):
+        try:
+            self.bound_field = self.form[self.field_name]
+        except KeyError:
+            return
+
+        if self.heading:
+            self.bound_field.label = self.heading
+        else:
+            self.heading = self.bound_field.label
+        self.help_text = self.bound_field.help_text
+
+    def __repr__(self):
+        return "<%s '%s' with model=%s instance=%s request=%s form=%s>" % (
+            self.__class__.__name__,
+            self.field_name,
+            self.model,
+            self.instance,
+            self.request,
+            self.form.__class__.__name__,
+        )
+
+
+class RichTextFieldPanel(FieldPanel):
+    def __init__(self, *args, **kwargs):
+        warn(
+            "wagtail.admin.edit_handlers.RichTextFieldPanel is obsolete and should be replaced by wagtail.admin.panels.FieldPanel",
+            category=RemovedInWagtail219Warning,
+            stacklevel=2,
+        )
+        super().__init__(*args, **kwargs)
+
+
+class BaseChooserPanel(FieldPanel):
+    def __init__(self, *args, **kwargs):
+        warn(
+            "wagtail.admin.edit_handlers.BaseChooserPanel is obsolete and should be replaced by wagtail.admin.panels.FieldPanel",
+            category=RemovedInWagtail219Warning,
+            stacklevel=2,
+        )
+        super().__init__(*args, **kwargs)
+
+
+class PageChooserPanel(FieldPanel):
+    def __init__(self, field_name, page_type=None, can_choose_root=False):
+        super().__init__(field_name=field_name)
+
+        self.page_type = page_type
+        self.can_choose_root = can_choose_root
+
+    def clone_kwargs(self):
+        return {
+            "field_name": self.field_name,
+            "page_type": self.page_type,
+            "can_choose_root": self.can_choose_root,
+        }
+
+    def widget_overrides(self):
+        if self.page_type or self.can_choose_root:
+            return {
+                self.field_name: widgets.AdminPageChooser(
+                    target_models=self.page_type, can_choose_root=self.can_choose_root
+                )
+            }
+        else:
+            return {}
+
+
+class InlinePanel(EditHandler):
+    def __init__(
+        self,
+        relation_name,
+        panels=None,
+        heading="",
+        label="",
+        min_num=None,
+        max_num=None,
+        *args,
+        **kwargs,
+    ):
+        super().__init__(*args, **kwargs)
+        self.relation_name = relation_name
+        self.panels = panels
+        self.heading = heading or label
+        self.label = label
+        self.min_num = min_num
+        self.max_num = max_num
+
+    def clone_kwargs(self):
+        kwargs = super().clone_kwargs()
+        kwargs.update(
+            relation_name=self.relation_name,
+            panels=self.panels,
+            label=self.label,
+            min_num=self.min_num,
+            max_num=self.max_num,
+        )
+        return kwargs
+
+    def get_panel_definitions(self):
+        # Look for a panels definition in the InlinePanel declaration
+        if self.panels is not None:
+            return self.panels
+        # Failing that, get it from the model
+        return extract_panel_definitions_from_model_class(
+            self.db_field.related_model, exclude=[self.db_field.field.name]
+        )
+
+    def get_child_edit_handler(self):
+        panels = self.get_panel_definitions()
+        child_edit_handler = MultiFieldPanel(panels, heading=self.heading)
+        return child_edit_handler.bind_to(model=self.db_field.related_model)
+
+    def required_formsets(self):
+        child_edit_handler = self.get_child_edit_handler()
+        return {
+            self.relation_name: {
+                "fields": child_edit_handler.required_fields(),
+                "widgets": child_edit_handler.widget_overrides(),
+                "min_num": self.min_num,
+                "validate_min": self.min_num is not None,
+                "max_num": self.max_num,
+                "validate_max": self.max_num is not None,
+                "formsets": child_edit_handler.required_formsets(),
+            }
+        }
+
+    def html_declarations(self):
+        return self.get_child_edit_handler().html_declarations()
+
+    def get_comparison(self):
+        field_comparisons = []
+
+        for panel in self.get_panel_definitions():
+            field_comparisons.extend(
+                panel.bind_to(model=self.db_field.related_model).get_comparison()
+            )
+
+        return [
+            functools.partial(
+                compare.ChildRelationComparison, self.db_field, field_comparisons
+            )
+        ]
+
+    def on_model_bound(self):
+        manager = getattr(self.model, self.relation_name)
+        self.db_field = manager.rel
+
+    def on_form_bound(self):
+        self.formset = self.form.formsets[self.relation_name]
+
+        self.children = []
+        for subform in self.formset.forms:
+            # override the DELETE field to have a hidden input
+            subform.fields[DELETION_FIELD_NAME].widget = forms.HiddenInput()
+
+            # ditto for the ORDER field, if present
+            if self.formset.can_order:
+                subform.fields[ORDERING_FIELD_NAME].widget = forms.HiddenInput()
+
+            child_edit_handler = self.get_child_edit_handler()
+            self.children.append(
+                child_edit_handler.bind_to(
+                    instance=subform.instance, request=self.request, form=subform
+                )
+            )
+
+        # if this formset is valid, it may have been re-ordered; respect that
+        # in case the parent form errored and we need to re-render
+        if self.formset.can_order and self.formset.is_valid():
+            self.children.sort(
+                key=lambda child: child.form.cleaned_data[ORDERING_FIELD_NAME] or 1
+            )
+
+        empty_form = self.formset.empty_form
+        empty_form.fields[DELETION_FIELD_NAME].widget = forms.HiddenInput()
+        if self.formset.can_order:
+            empty_form.fields[ORDERING_FIELD_NAME].widget = forms.HiddenInput()
+
+        self.empty_child = self.get_child_edit_handler()
+        self.empty_child = self.empty_child.bind_to(
+            instance=empty_form.instance, request=self.request, form=empty_form
+        )
+
+    template = "wagtailadmin/edit_handlers/inline_panel.html"
+
+    def render(self):
+        formset = render_to_string(
+            self.template,
+            {
+                "self": self,
+                "can_order": self.formset.can_order,
+            },
+        )
+        js = self.render_js_init()
+        return widget_with_script(formset, js)
+
+    js_template = "wagtailadmin/edit_handlers/inline_panel.js"
+
+    def render_js_init(self):
+        return mark_safe(
+            render_to_string(
+                self.js_template,
+                {
+                    "self": self,
+                    "can_order": self.formset.can_order,
+                },
+            )
+        )
+
+
+# This allows users to include the publishing panel in their own per-model override
+# without having to write these fields out by hand, potentially losing 'classname'
+# and therefore the associated styling of the publishing panel
+class PublishingPanel(MultiFieldPanel):
+    def __init__(self, **kwargs):
+        updated_kwargs = {
+            "children": [
+                FieldRowPanel(
+                    [
+                        FieldPanel("go_live_at"),
+                        FieldPanel("expire_at"),
+                    ],
+                    classname="label-above",
+                ),
+            ],
+            "heading": gettext_lazy("Scheduled publishing"),
+            "classname": "publishing",
+        }
+        updated_kwargs.update(kwargs)
+        super().__init__(**updated_kwargs)
+
+
+class PrivacyModalPanel(EditHandler):
+    def __init__(self, **kwargs):
+        updated_kwargs = {"heading": gettext_lazy("Privacy"), "classname": "privacy"}
+        updated_kwargs.update(kwargs)
+        super().__init__(**updated_kwargs)
+
+    def render(self):
+        content = render_to_string(
+            "wagtailadmin/pages/privacy_switch_panel.html",
+            {"self": self, "page": self.instance, "request": self.request},
+        )
+
+        from wagtail.admin.staticfiles import versioned_static
+
+        return mark_safe(
+            '{0}<script type="text/javascript" src="{1}"></script>'.format(
+                content, versioned_static("wagtailadmin/js/privacy-switch.js")
+            )
+        )
+
+
+class CommentPanel(EditHandler):
+    def required_fields(self):
+        # Adds the comment notifications field to the form.
+        # Note, this field is defined directly on WagtailAdminPageForm.
+        return ["comment_notifications"]
+
+    def required_formsets(self):
+        # add the comments formset
+        # we need to pass in the current user for validation on the formset
+        # this could alternatively be done on the page form itself if we added the
+        # comments formset there, but we typically only add fields via edit handlers
+        current_user = getattr(self.request, "user", None)
+
+        class CommentReplyFormWithRequest(CommentReplyForm):
+            user = current_user
+
+        class CommentFormWithRequest(CommentForm):
+            user = current_user
+
+            class Meta:
+                formsets = {"replies": {"form": CommentReplyFormWithRequest}}
+
+        return {
+            COMMENTS_RELATION_NAME: {
+                "form": CommentFormWithRequest,
+                "fields": ["text", "contentpath", "position"],
+                "formset_name": "comments",
+            }
+        }
+
+    template = "wagtailadmin/edit_handlers/comments/comment_panel.html"
+    declarations_template = (
+        "wagtailadmin/edit_handlers/comments/comment_declarations.html"
+    )
+
+    def html_declarations(self):
+        return render_to_string(self.declarations_template)
+
+    def get_context(self):
+        def user_data(user):
+            return {"name": user_display_name(user), "avatar_url": avatar_url(user)}
+
+        user = getattr(self.request, "user", None)
+        user_pks = {user.pk}
+        serialized_comments = []
+        bound = self.form.is_bound
+        comment_formset = self.form.formsets.get("comments")
+        comment_forms = comment_formset.forms if comment_formset else []
+        for form in comment_forms:
+            # iterate over comments to retrieve users (to get display names) and serialized versions
+            replies = []
+            for reply_form in form.formsets["replies"].forms:
+                user_pks.add(reply_form.instance.user_id)
+                reply_data = get_serializable_data_for_fields(reply_form.instance)
+                reply_data["deleted"] = (
+                    reply_form.cleaned_data.get("DELETE", False) if bound else False
+                )
+                replies.append(reply_data)
+            user_pks.add(form.instance.user_id)
+            data = get_serializable_data_for_fields(form.instance)
+            data["deleted"] = form.cleaned_data.get("DELETE", False) if bound else False
+            data["resolved"] = (
+                form.cleaned_data.get("resolved", False)
+                if bound
+                else form.instance.resolved_at is not None
+            )
+            data["replies"] = replies
+            serialized_comments.append(data)
+
+        authors = {
+            str(user.pk): user_data(user)
+            for user in get_user_model()
+            .objects.filter(pk__in=user_pks)
+            .select_related("wagtail_userprofile")
+        }
+
+        comments_data = {
+            "comments": serialized_comments,
+            "user": user.pk,
+            "authors": authors,
+        }
+
+        return {
+            "comments_data": comments_data,
+        }
+
+    def render(self):
+        panel = render_to_string(self.template, self.get_context())
+        return panel
+
+
+# Now that we've defined EditHandlers, we can set up wagtailcore.Page to have some.
+def set_default_page_edit_handlers(cls):
+    cls.content_panels = [
+        FieldPanel("title", classname="full title"),
+    ]
+
+    cls.promote_panels = [
+        MultiFieldPanel(
+            [
+                FieldPanel("slug"),
+                FieldPanel("seo_title"),
+                FieldPanel("search_description"),
+            ],
+            gettext_lazy("For search engines"),
+        ),
+        MultiFieldPanel(
+            [
+                FieldPanel("show_in_menus"),
+            ],
+            gettext_lazy("For site menus"),
+        ),
+    ]
+
+    cls.settings_panels = [
+        PublishingPanel(),
+        PrivacyModalPanel(),
+    ]
+
+    if getattr(settings, "WAGTAILADMIN_COMMENTS_ENABLED", True):
+        cls.settings_panels.append(CommentPanel())
+
+    cls.base_form_class = WagtailAdminPageForm
+
+
+set_default_page_edit_handlers(Page)
+
+
+@cached_classmethod
+def get_edit_handler(cls):
+    """
+    Get the EditHandler to use in the Wagtail admin when editing this page type.
+    """
+    if hasattr(cls, "edit_handler"):
+        edit_handler = cls.edit_handler
+    else:
+        # construct a TabbedInterface made up of content_panels, promote_panels
+        # and settings_panels, skipping any which are empty
+        tabs = []
+
+        if cls.content_panels:
+            tabs.append(ObjectList(cls.content_panels, heading=gettext_lazy("Content")))
+        if cls.promote_panels:
+            tabs.append(ObjectList(cls.promote_panels, heading=gettext_lazy("Promote")))
+        if cls.settings_panels:
+            tabs.append(
+                ObjectList(
+                    cls.settings_panels,
+                    heading=gettext_lazy("Settings"),
+                    classname="settings",
+                )
+            )
+
+        edit_handler = TabbedInterface(tabs, base_form_class=cls.base_form_class)
+
+    return edit_handler.bind_to(model=cls)
+
+
+Page.get_edit_handler = get_edit_handler
+
+
+@receiver(setting_changed)
+def reset_page_edit_handler_cache(**kwargs):
+    """
+    Clear page edit handler cache when global WAGTAILADMIN_COMMENTS_ENABLED settings are changed
+    """
+    if kwargs["setting"] == "WAGTAILADMIN_COMMENTS_ENABLED":
+        set_default_page_edit_handlers(Page)
+        for model in apps.get_models():
+            if issubclass(model, Page):
+                model.get_edit_handler.cache_clear()
+
+
+class StreamFieldPanel(FieldPanel):
+    def __init__(self, *args, **kwargs):
+        warn(
+            "wagtail.admin.edit_handlers.StreamFieldPanel is obsolete and should be replaced by wagtail.admin.panels.FieldPanel",
+            category=RemovedInWagtail219Warning,
+            stacklevel=2,
+        )
+        super().__init__(*args, **kwargs)

+ 1 - 1
wagtail/admin/tests/pages/test_preview.py

@@ -7,7 +7,7 @@ from django.urls import reverse
 from django.utils import timezone
 from freezegun import freeze_time
 
-from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface
+from wagtail.admin.panels import FieldPanel, ObjectList, TabbedInterface
 from wagtail.admin.views.pages.preview import PreviewOnEdit
 from wagtail.models import Page
 from wagtail.test.testapp.models import EventCategory, EventPage, SimplePage, StreamPage

+ 2 - 2
wagtail/admin/tests/test_edit_handlers.py

@@ -13,7 +13,8 @@ from django.utils.html import json_script
 from freezegun import freeze_time
 from pytz import utc
 
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.forms import WagtailAdminModelForm, WagtailAdminPageForm
+from wagtail.admin.panels import (
     CommentPanel,
     FieldPanel,
     FieldRowPanel,
@@ -25,7 +26,6 @@ from wagtail.admin.edit_handlers import (
     extract_panel_definitions_from_model_class,
     get_form_for_model,
 )
-from wagtail.admin.forms import WagtailAdminModelForm, WagtailAdminPageForm
 from wagtail.admin.rich_text import DraftailRichTextArea
 from wagtail.admin.widgets import (
     AdminAutoHeightTextInput,

+ 1 - 0
wagtail/bin/wagtail.py

@@ -153,6 +153,7 @@ class UpdateModulePaths(Command):
         # Added in Wagtail 3.0
         (re.compile(r"\bwagtail\.tests\b"), "wagtail.test"),
         (re.compile(r"\bwagtail\.core\b"), "wagtail"),
+        (re.compile(r"\bwagtail\.admin\.edit_handlers\b"), "wagtail.admin.panels"),
     ]
 
     def add_arguments(self, parser):

+ 1 - 1
wagtail/contrib/forms/edit_handlers.py

@@ -2,7 +2,7 @@ from django.template.loader import render_to_string
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext as _
 
-from wagtail.admin.edit_handlers import EditHandler
+from wagtail.admin.panels import EditHandler
 
 
 class FormSubmissionsPanel(EditHandler):

+ 1 - 1
wagtail/contrib/forms/models.py

@@ -12,8 +12,8 @@ from django.utils.formats import date_format
 from django.utils.text import slugify
 from django.utils.translation import gettext_lazy as _
 
-from wagtail.admin.edit_handlers import FieldPanel
 from wagtail.admin.mail import send_mail
+from wagtail.admin.panels import FieldPanel
 from wagtail.contrib.forms.utils import get_field_clean_name
 from wagtail.models import Orderable, Page
 

+ 1 - 1
wagtail/contrib/forms/tests/test_views.py

@@ -10,8 +10,8 @@ from django.test import RequestFactory, TestCase, override_settings
 from django.urls import reverse
 from openpyxl import load_workbook
 
-from wagtail.admin.edit_handlers import get_form_for_model
 from wagtail.admin.forms import WagtailAdminPageForm
+from wagtail.admin.panels import get_form_for_model
 from wagtail.contrib.forms.edit_handlers import FormSubmissionsPanel
 from wagtail.contrib.forms.models import FormSubmission
 from wagtail.contrib.forms.tests.utils import (

+ 1 - 4
wagtail/contrib/modeladmin/options.py

@@ -9,10 +9,7 @@ from django.utils.safestring import mark_safe
 from wagtail import hooks
 from wagtail.admin.admin_url_finder import register_admin_url_finder
 from wagtail.admin.checks import check_panels_in_model
-from wagtail.admin.edit_handlers import (
-    ObjectList,
-    extract_panel_definitions_from_model_class,
-)
+from wagtail.admin.panels import ObjectList, extract_panel_definitions_from_model_class
 from wagtail.models import Page
 
 from .helpers import (

+ 1 - 1
wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py

@@ -2,7 +2,7 @@ from unittest import mock
 
 from django.test import RequestFactory, TestCase
 
-from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface
+from wagtail.admin.panels import FieldPanel, ObjectList, TabbedInterface
 from wagtail.contrib.modeladmin.views import CreateView
 from wagtail.test.modeladmintest.models import Person
 from wagtail.test.modeladmintest.wagtail_hooks import PersonAdmin

+ 1 - 1
wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py

@@ -10,7 +10,7 @@ from django.utils.timezone import make_aware
 from openpyxl import load_workbook
 
 from wagtail.admin.admin_url_finder import AdminURLFinder
-from wagtail.admin.edit_handlers import FieldPanel, TabbedInterface
+from wagtail.admin.panels import FieldPanel, TabbedInterface
 from wagtail.contrib.modeladmin.helpers.search import DjangoORMSearchHandler
 from wagtail.images.models import Image
 from wagtail.images.tests.utils import get_test_image_file

+ 1 - 1
wagtail/contrib/settings/tests/test_admin.py

@@ -5,7 +5,7 @@ from django.utils.text import capfirst
 
 from wagtail import hooks
 from wagtail.admin.admin_url_finder import AdminURLFinder
-from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface
+from wagtail.admin.panels import FieldPanel, ObjectList, TabbedInterface
 from wagtail.contrib.settings.registry import SettingMenuItem
 from wagtail.contrib.settings.views import get_setting_edit_handler
 from wagtail.models import Page, Site

+ 1 - 1
wagtail/contrib/settings/views.py

@@ -9,7 +9,7 @@ from django.utils.text import capfirst
 from django.utils.translation import gettext as _
 
 from wagtail.admin import messages
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     ObjectList,
     TabbedInterface,
     extract_panel_definitions_from_model_class,

+ 2 - 2
wagtail/documents/edit_handlers.py

@@ -1,13 +1,13 @@
 from warnings import warn
 
-from wagtail.admin.edit_handlers import FieldPanel
+from wagtail.admin.panels import FieldPanel
 from wagtail.utils.deprecation import RemovedInWagtail219Warning
 
 
 class DocumentChooserPanel(FieldPanel):
     def __init__(self, *args, **kwargs):
         warn(
-            "wagtail.documents.edit_handlers.DocumentChooserPanel is obsolete and should be replaced by wagtail.admin.edit_handlers.FieldPanel",
+            "wagtail.documents.edit_handlers.DocumentChooserPanel is obsolete and should be replaced by wagtail.admin.panels.FieldPanel",
             category=RemovedInWagtail219Warning,
             stacklevel=2,
         )

+ 2 - 2
wagtail/images/edit_handlers.py

@@ -3,14 +3,14 @@ from warnings import warn
 from django.template.loader import render_to_string
 
 from wagtail.admin.compare import ForeignObjectComparison
-from wagtail.admin.edit_handlers import FieldPanel
+from wagtail.admin.panels import FieldPanel
 from wagtail.utils.deprecation import RemovedInWagtail219Warning
 
 
 class ImageChooserPanel(FieldPanel):
     def __init__(self, *args, **kwargs):
         warn(
-            "wagtail.images.edit_handlers.ImageChooserPanel is obsolete and should be replaced by wagtail.admin.edit_handlers.FieldPanel",
+            "wagtail.images.edit_handlers.ImageChooserPanel is obsolete and should be replaced by wagtail.admin.panels.FieldPanel",
             category=RemovedInWagtail219Warning,
             stacklevel=2,
         )

+ 1 - 1
wagtail/models/__init__.py

@@ -379,7 +379,7 @@ class Page(AbstractPage, index.Indexed, ClusterableModel, metaclass=PageBase):
     ]
 
     # Define these attributes early to avoid masking errors. (Issue #3078)
-    # The canonical definition is in wagtailadmin.edit_handlers.
+    # The canonical definition is in wagtailadmin.panels.
     content_panels = []
     promote_panels = []
     settings_panels = []

+ 2 - 2
wagtail/snippets/edit_handlers.py

@@ -1,13 +1,13 @@
 from warnings import warn
 
-from wagtail.admin.edit_handlers import FieldPanel
+from wagtail.admin.panels import FieldPanel
 from wagtail.utils.deprecation import RemovedInWagtail219Warning
 
 
 class SnippetChooserPanel(FieldPanel):
     def __init__(self, *args, **kwargs):
         warn(
-            "wagtail.snippets.edit_handlers.SnippetChooserPanel is obsolete and should be replaced by wagtail.admin.edit_handlers.FieldPanel",
+            "wagtail.snippets.edit_handlers.SnippetChooserPanel is obsolete and should be replaced by wagtail.admin.panels.FieldPanel",
             category=RemovedInWagtail219Warning,
             stacklevel=2,
         )

+ 1 - 1
wagtail/snippets/tests.py

@@ -19,8 +19,8 @@ from taggit.models import Tag
 
 from wagtail import hooks
 from wagtail.admin.admin_url_finder import AdminURLFinder
-from wagtail.admin.edit_handlers import FieldPanel, ObjectList
 from wagtail.admin.forms import WagtailAdminModelForm
+from wagtail.admin.panels import FieldPanel, ObjectList
 from wagtail.blocks.field_block import FieldBlockAdapter
 from wagtail.models import Locale, ModelLogEntry, Page
 from wagtail.snippets.action_menu import (

+ 1 - 4
wagtail/snippets/views/snippets.py

@@ -17,11 +17,8 @@ from django.views.generic import TemplateView
 
 from wagtail import hooks
 from wagtail.admin import messages
-from wagtail.admin.edit_handlers import (
-    ObjectList,
-    extract_panel_definitions_from_model_class,
-)
 from wagtail.admin.forms.search import SearchForm
+from wagtail.admin.panels import ObjectList, extract_panel_definitions_from_model_class
 from wagtail.admin.ui.tables import Column, DateColumn, UserColumn
 from wagtail.admin.views.generic.models import IndexView
 from wagtail.log_actions import log

+ 1 - 1
wagtail/test/customuser/models.py

@@ -9,7 +9,7 @@ from django.db import models
 # (which is easily done because it's dealing with django.contrib.auth views which depend
 # on the user model)
 from wagtail.admin.auth import permission_denied  # noqa
-from wagtail.admin.edit_handlers import FieldPanel
+from wagtail.admin.panels import FieldPanel
 
 from .fields import ConvertedValueField
 

+ 1 - 1
wagtail/test/demosite/models.py

@@ -7,7 +7,7 @@ from modelcluster.contrib.taggit import ClusterTaggableManager
 from modelcluster.fields import ParentalKey
 from taggit.models import TaggedItemBase
 
-from wagtail.admin.edit_handlers import FieldPanel, InlinePanel, MultiFieldPanel
+from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel
 from wagtail.api import APIField
 from wagtail.fields import RichTextField
 from wagtail.images.api.fields import ImageRenditionField

+ 1 - 1
wagtail/test/modeladmintest/models.py

@@ -1,6 +1,6 @@
 from django.db import models
 
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.panels import (
     FieldPanel,
     MultiFieldPanel,
     ObjectList,

+ 1 - 1
wagtail/test/modeladmintest/wagtail_hooks.py

@@ -1,4 +1,4 @@
-from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface
+from wagtail.admin.panels import FieldPanel, ObjectList, TabbedInterface
 from wagtail.contrib.modeladmin.helpers import WagtailBackendSearchHandler
 from wagtail.contrib.modeladmin.options import (
     ModelAdmin,

+ 1 - 1
wagtail/test/snippets/models.py

@@ -2,7 +2,7 @@ from django.db import models
 from modelcluster.fields import ParentalKey
 from modelcluster.models import ClusterableModel
 
-from wagtail.admin.edit_handlers import FieldPanel, InlinePanel
+from wagtail.admin.panels import FieldPanel, InlinePanel
 from wagtail.fields import RichTextField
 from wagtail.models import TranslatableMixin
 from wagtail.search import index

+ 3 - 3
wagtail/test/testapp/models.py

@@ -19,15 +19,15 @@ from modelcluster.models import ClusterableModel
 from taggit.managers import TaggableManager
 from taggit.models import ItemBase, TagBase, TaggedItemBase
 
-from wagtail.admin.edit_handlers import (
+from wagtail.admin.forms import WagtailAdminPageForm
+from wagtail.admin.mail import send_mail
+from wagtail.admin.panels import (
     FieldPanel,
     InlinePanel,
     MultiFieldPanel,
     ObjectList,
     TabbedInterface,
 )
-from wagtail.admin.forms import WagtailAdminPageForm
-from wagtail.admin.mail import send_mail
 from wagtail.blocks import (
     CharBlock,
     FieldBlock,