Repository: wagtail/wagtail

Issue:
API for registering a custom page edit handler
For some page types I'd like to define custom admin interfaces with a different set of tabs, like "Main Content // Sidebar Content // Promote" instead of the default "Content // Promote."

I see that I can already do this by adding an entry to the `wagtailadmin.views.pages.PAGE_EDIT_HANDLERS` dictionary in my `models.py` at import time.

This PR proposes a `wagtailadmin.views.pages.register_page_edit_handler(page_class, handler)` function to formalize that approach.  It would be used in a `models.py` like

```
MyPage.main_content_panels = [
  FieldPanel("title"),
  FieldPanel("main_section_content")
  ]
MyPage.promote_panels = MultiFieldPanel(COMMON_PANELS, "Common page configuration"),
MyPage.sidebar_panels = [
  ImageChooserPanel("sidebar_image"),
  InlinePanel(MyPage, "related_links", label="Related pages"),
]

from wagtail.wagtailadmin.views.pages import (register_page_edit_handler,
                                          TabbedInterface,
                                          ObjectList)
register_page_edit_handler(TabbedInterface([
       ObjectList(MyPage.main_content_panels, heading="Main Content"),
       ObjectList(MyPage.sidebar_panels, heading="Sidebar Content"),
       ObjectList(MyPage.promote_panels, heading="Promote")])
```



Discussion:
Thanks @ejucovy, this is an interesting idea, and something we are currently investigating ourselves. Assigning this to @gasman for review.

@ejucovy Thanks! This looks like a good idea to me
I'd rather this was called set_page_edit_handler. (register_ suggests that we are appending an extra edit handler to the class when set_ suggests that we are replacing the current one).

Also, I'm not sure what the assertion is protecting against

Thanks,

Karl

Hi @gasman, please could you review/update this pull request.

Thanks!

> Also, I'm not sure what the assertion is protecting against

The assertion should probably be removed.  I put it in because I wasn't sure what should happen in case two callers separately register different `TabbedInterfaces`  for the same model class.

Since the whole thing relies on app import order and import-time side effects (assuming the `set_page_edit_handler` function is called in `models.py`) there's no great way for package authors or integrators to resolve conflicts.  But I think that's beyond the scope of this PR. 

And anyway having the assertion doesn't help; it makes duplicate registrations loud, but also makes them impossible for integrators to resolve at all.  Maybe a `logger.warning` would be appropriate.

There are a few places where there is a pattern to go through the `INSTALLED_APPS` list in order for this type of registration. Since the order of important isn't controllable in Django, this would at least make it consistent based on app ordering in settings. As is, I think if multiple callers register different `TabbedInterfaces`, the one that gets used could actually be random.

Sorry it's taken forever for me to get round to reviewing this!

I think it would be neater to have the get_page_edit_handler function in wagtailadmin/views/pages.py check for the presence of an `edit_handler` property on the page class and use that if available, falling back to the default TabbedInterface setup otherwise. Having a `set_page_edit_handler` function is probably the lowest friction thing to implement within the current codebase, but it's not particularly natural for the end user...

(In principle we could make edit_handler a property of the base Page class, and ditch the get_page_edit_handler function entirely - but that would be mingling wagtailadmin and wagtailcore code more than I'd like.)

I need to refactor some of this mechanism for #823 (so that edit handlers have proper access to the model's properties, not just during form rendering), so probably best to hold off on this change until I've completed that.

Now that edit handlers have had the necessary lick of paint, here's my plan for addressing this, #485 and #972 in one go...
- `get_page_edit_handler` checks for the presence of an `edit_handler` property. If found, it will use that value instead of constructing a TabbedInterface of its own. This allows page models to totally customise the tab set (e.g. adding "Sidebar content" as described here) or - potentially - replace TabbedInterface entirely with their own EditHander.
- Failing that, it will construct a TabbedInterface out of `content_panels`, `promote_panels` and `settings_panels` as it does now - but _omitting any of them that are empty lists_.


Resolving patch:
diff --git a/wagtail/wagtailadmin/views/pages.py b/wagtail/wagtailadmin/views/pages.py
--- a/wagtail/wagtailadmin/views/pages.py
+++ b/wagtail/wagtailadmin/views/pages.py
@@ -707,11 +707,24 @@ def copy(request, page_id):
 
 def get_page_edit_handler(page_class):
     if page_class not in PAGE_EDIT_HANDLERS:
-        PAGE_EDIT_HANDLERS[page_class] = TabbedInterface([
-            ObjectList(page_class.content_panels, heading='Content'),
-            ObjectList(page_class.promote_panels, heading='Promote'),
-            ObjectList(page_class.settings_panels, heading='Settings', classname="settings")
-        ]).bind_to_model(page_class)
+        if hasattr(page_class, 'edit_handler'):
+            # use the edit handler specified on the page class
+            edit_handler = page_class.edit_handler
+        else:
+            # construct a TabbedInterface made up of content_panels, promote_panels
+            # and settings_panels, skipping any which are empty
+            tabs = []
+
+            if page_class.content_panels:
+                tabs.append(ObjectList(page_class.content_panels, heading='Content'))
+            if page_class.promote_panels:
+                tabs.append(ObjectList(page_class.promote_panels, heading='Promote'))
+            if page_class.settings_panels:
+                tabs.append(ObjectList(page_class.settings_panels, heading='Settings', classname="settings"))
+
+            edit_handler = TabbedInterface(tabs)
+
+        PAGE_EDIT_HANDLERS[page_class] = edit_handler.bind_to_model(page_class)
 
     return PAGE_EDIT_HANDLERS[page_class]
 


Test patch:
diff --git a/wagtail/tests/models.py b/wagtail/tests/models.py
--- a/wagtail/tests/models.py
+++ b/wagtail/tests/models.py
@@ -12,7 +12,7 @@
 
 from wagtail.wagtailcore.models import Page, Orderable
 from wagtail.wagtailcore.fields import RichTextField
-from wagtail.wagtailadmin.edit_handlers import FieldPanel, MultiFieldPanel, InlinePanel, PageChooserPanel
+from wagtail.wagtailadmin.edit_handlers import FieldPanel, MultiFieldPanel, InlinePanel, PageChooserPanel, TabbedInterface, ObjectList
 from wagtail.wagtailimages.edit_handlers import ImageChooserPanel
 from wagtail.wagtaildocs.edit_handlers import DocumentChooserPanel
 from wagtail.wagtailforms.models import AbstractEmailForm, AbstractFormField
@@ -390,15 +390,27 @@ class StandardIndex(Page):
     parent_page_types = []
 
 
+# A custom panel setup where all Promote fields are placed in the Content tab instead;
+# we use this to test that the 'promote' tab is left out of the output when empty
 StandardIndex.content_panels = [
     FieldPanel('title', classname="full title"),
+    FieldPanel('seo_title'),
+    FieldPanel('slug'),
     InlinePanel('advert_placements', label="Adverts"),
 ]
+StandardIndex.promote_panels = []
 
 
 class StandardChild(Page):
     pass
 
+# Test overriding edit_handler with a custom one
+StandardChild.edit_handler = TabbedInterface([
+    ObjectList(StandardChild.content_panels, heading='Content'),
+    ObjectList(StandardChild.promote_panels, heading='Promote'),
+    ObjectList(StandardChild.settings_panels, heading='Settings', classname='settings'),
+    ObjectList([], heading='Dinosaurs'),
+])
 
 class BusinessIndex(Page):
     """ Can be placed anywhere, can only have Business children """
diff --git a/wagtail/wagtailadmin/tests/test_pages_views.py b/wagtail/wagtailadmin/tests/test_pages_views.py
--- a/wagtail/wagtailadmin/tests/test_pages_views.py
+++ b/wagtail/wagtailadmin/tests/test_pages_views.py
@@ -158,6 +158,27 @@ def test_add_subpage_nonexistantparent(self):
     def test_create_simplepage(self):
         response = self.client.get(reverse('wagtailadmin_pages_create', args=('tests', 'simplepage', self.root_page.id)))
         self.assertEqual(response.status_code, 200)
+        self.assertContains(response, '<a href="#content" class="active">Content</a>')
+        self.assertContains(response, '<a href="#promote" class="">Promote</a>')
+
+    def test_create_page_without_promote_tab(self):
+        """
+        Test that the Promote tab is not rendered for page classes that define it as empty
+        """
+        response = self.client.get(reverse('wagtailadmin_pages_create', args=('tests', 'standardindex', self.root_page.id)))
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, '<a href="#content" class="active">Content</a>')
+        self.assertNotContains(response, '<a href="#promote" class="">Promote</a>')
+
+    def test_create_page_with_custom_tabs(self):
+        """
+        Test that custom edit handlers are rendered
+        """
+        response = self.client.get(reverse('wagtailadmin_pages_create', args=('tests', 'standardchild', self.root_page.id)))
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, '<a href="#content" class="active">Content</a>')
+        self.assertContains(response, '<a href="#promote" class="">Promote</a>')
+        self.assertContains(response, '<a href="#dinosaurs" class="">Dinosaurs</a>')
 
     def test_create_simplepage_bad_permissions(self):
         # Remove privileges from user