Răsfoiți Sursa

Telepath set up for new sidebar

* Add ability to register multiple hooks with register_temporarily

It's not possible to add multiple items in a ``with`` block in multiple
lines. So to register multiple hooks, you either need to put them all on
one line or add many nested ``with`` blocks.

This commit adds the ability to pass in a list of hooks into one call.
This works around the syntax error, but there's still no way to
represent this in a way that flake8 is happy with so I've added
``#noqa`` in a few places.

* Telepath set up for new sidebar

* unindent hooks.register_temporarily with blocks

Co-authored-by: Matt Westcott <matt@west.co.tt>

* Update wagtail/core/telepath.py

Co-authored-by: Matt Westcott <matt@west.co.tt>

Co-authored-by: Matt Westcott <matt@west.co.tt>
Karl Hobley 3 ani în urmă
părinte
comite
8e25960972

+ 18 - 0
docs/reference/hooks.rst

@@ -78,6 +78,24 @@ And here's an example of registering a hook function for a single block of code:
 
   # Hook is unregistered here
 
+If you need to register multiple hooks in a ``with`` block, you can pass the hooks in as a list of tuples:
+
+.. code-block:: python
+
+    def my_hook(...):
+        pass
+
+    def my_other_hook(...):
+        pass
+
+    with hooks.register_temporarily([
+        ('hook_name', my_hook),
+        ('hook_name', my_other_hook),
+    ]):
+        # All hooks are registered here
+        ..
+
+    # All hooks are unregistered here
 
 The available hooks are listed below.
 

+ 23 - 7
wagtail/admin/menu.py

@@ -4,6 +4,8 @@ from django.template.loader import render_to_string
 from django.utils.safestring import mark_safe
 from django.utils.text import slugify
 
+from wagtail.admin.ui.sidebar import LinkMenuItem as LinkMenuItemComponent
+from wagtail.admin.ui.sidebar import SubMenuItem as SubMenuItemComponent
 from wagtail.core import hooks
 
 
@@ -49,6 +51,9 @@ class MenuItem(metaclass=MediaDefiningClass):
         context = self.get_context(request)
         return render_to_string(self.template, context, request=request)
 
+    def render_component(self, request):
+        return LinkMenuItemComponent(self.name, self.label, self.url, icon_name=self.icon_name, classnames=self.classnames)
+
 
 class Menu:
     def __init__(self, register_hook_name, construct_hook_name=None):
@@ -67,7 +72,14 @@ class Menu:
         return self._registered_menu_items
 
     def menu_items_for_request(self, request):
-        return [item for item in self.registered_menu_items if item.is_shown(request)]
+        items = [item for item in self.registered_menu_items if item.is_shown(request)]
+
+        # provide a hook for modifying the menu, if construct_hook_name has been set
+        if self.construct_hook_name:
+            for fn in hooks.get_hooks(self.construct_hook_name):
+                fn(request, items)
+
+        return items
 
     def active_menu_items(self, request):
         return [item for item in self.menu_items_for_request(request) if item.is_active(request)]
@@ -81,17 +93,18 @@ class Menu:
 
     def render_html(self, request):
         menu_items = self.menu_items_for_request(request)
-
-        # provide a hook for modifying the menu, if construct_hook_name has been set
-        if self.construct_hook_name:
-            for fn in hooks.get_hooks(self.construct_hook_name):
-                fn(request, menu_items)
-
         rendered_menu_items = []
         for item in sorted(menu_items, key=lambda i: i.order):
             rendered_menu_items.append(item.render_html(request))
         return mark_safe(''.join(rendered_menu_items))
 
+    def render_component(self, request):
+        menu_items = self.menu_items_for_request(request)
+        rendered_menu_items = []
+        for item in sorted(menu_items, key=lambda i: i.order):
+            rendered_menu_items.append(item.render_component(request))
+        return rendered_menu_items
+
 
 class SubmenuMenuItem(MenuItem):
     template = 'wagtailadmin/shared/menu_submenu_item.html'
@@ -114,6 +127,9 @@ class SubmenuMenuItem(MenuItem):
         context['request'] = request
         return context
 
+    def render_component(self, request):
+        return SubMenuItemComponent(self.name, self.label, self.menu.render_component(request), icon_name=self.icon_name, classnames=self.classnames)
+
 
 class AdminOnlyMenuItem(MenuItem):
     """A MenuItem which is only shown to superusers"""

+ 30 - 0
wagtail/admin/templatetags/wagtailadmin_tags.py

@@ -8,10 +8,12 @@ from django.conf import settings
 from django.contrib.admin.utils import quote
 from django.contrib.humanize.templatetags.humanize import intcomma
 from django.contrib.messages.constants import DEFAULT_TAGS as MESSAGE_TAGS
+from django.core.serializers.json import DjangoJSONEncoder
 from django.db.models import Min, QuerySet
 from django.template.defaultfilters import stringfilter
 from django.template.loader import render_to_string
 from django.templatetags.static import static
+from django.urls import reverse
 from django.utils import timezone
 from django.utils.encoding import force_str
 from django.utils.html import avoid_wrapping, format_html, format_html_join
@@ -24,10 +26,12 @@ from wagtail.admin.menu import admin_menu
 from wagtail.admin.navigation import get_explorable_root_page
 from wagtail.admin.search import admin_search_areas
 from wagtail.admin.staticfiles import versioned_static as versioned_static_func
+from wagtail.admin.ui import sidebar
 from wagtail.core import hooks
 from wagtail.core.models import (
     Collection, CollectionViewRestriction, Locale, Page, PageViewRestriction,
     UserPagePermissionsProxy)
+from wagtail.core.telepath import JSContext
 from wagtail.core.utils import camelcase_to_underscore
 from wagtail.core.utils import cautious_slugify as _cautious_slugify
 from wagtail.core.utils import escape_script
@@ -637,3 +641,29 @@ def locales():
         }
         for locale in Locale.objects.all()
     ])
+
+
+@register.simple_tag(takes_context=True)
+def menu_props(context):
+    request = context['request']
+    search_areas = admin_search_areas.search_items_for_request(request)
+    if search_areas:
+        search_area = search_areas[0]
+    else:
+        search_area = None
+
+    account_menu = [
+        sidebar.LinkMenuItem('account', _("Account"), reverse('wagtailadmin_account'), icon_name='user'),
+        sidebar.LinkMenuItem('logout', _("Logout"), reverse('wagtailadmin_logout'), icon_name='logout'),
+    ]
+
+    modules = [
+        sidebar.WagtailBrandingModule(),
+        sidebar.SearchModule(search_area) if search_area else None,
+        sidebar.MainMenuModule(admin_menu.render_component(request), account_menu, request.user),
+    ]
+    modules = [module for module in modules if module is not None]
+
+    return json.dumps({
+        'modules': JSContext().pack(modules),
+    }, cls=DjangoJSONEncoder)

+ 95 - 0
wagtail/admin/tests/test_menu.py

@@ -0,0 +1,95 @@
+from django.test import RequestFactory, TestCase
+
+from wagtail.admin.menu import AdminOnlyMenuItem, Menu, MenuItem, SubmenuMenuItem
+from wagtail.admin.ui import sidebar
+from wagtail.core import hooks
+from wagtail.tests.utils import WagtailTestUtils
+
+
+def menu_item_hook(*args, cls=MenuItem, **kwargs):
+    def hook_fn():
+        return cls(*args, **kwargs)
+
+    return hook_fn
+
+
+class TestMenuRendering(TestCase, WagtailTestUtils):
+    def setUp(self):
+        self.request = RequestFactory().get('/admin')
+        self.request.user = self.create_superuser(username='admin')
+
+    def test_simple_menu(self):
+        # Note: initialise the menu before registering hooks as this is what happens in reality.
+        # (the real menus are initialised at the module level in admin/menu.py)
+        menu = Menu(register_hook_name='register_menu_item')
+
+        with hooks.register_temporarily([
+            ('register_menu_item', menu_item_hook("Pages", '/pages/')),
+            ('register_menu_item', menu_item_hook("Images", '/images/')),
+        ]):
+            rendered = menu.render_component(self.request)
+
+        self.assertIsInstance(rendered, list)
+        self.assertListEqual(rendered, [
+            sidebar.LinkMenuItem('pages', "Pages", '/pages/'),
+            sidebar.LinkMenuItem('images', "Images", '/images/'),
+        ])
+
+    def test_menu_with_construct_hook(self):
+        menu = Menu(register_hook_name='register_menu_item', construct_hook_name='construct_menu')
+
+        def remove_images(request, items):
+            items[:] = [item for item in items if not item.name == 'images']
+
+        with hooks.register_temporarily([
+            ('register_menu_item', menu_item_hook("Pages", '/pages/')),
+            ('register_menu_item', menu_item_hook("Images", '/images/')),
+            ('construct_menu', remove_images),
+        ]):
+            rendered = menu.render_component(self.request)
+
+        self.assertEqual(
+            rendered,
+            [
+                sidebar.LinkMenuItem('pages', "Pages", '/pages/'),
+            ]
+        )
+
+    def test_submenu(self):
+        menu = Menu(register_hook_name='register_menu_item')
+        submenu = Menu(register_hook_name='register_submenu_item')
+
+        with hooks.register_temporarily([
+            ('register_menu_item', menu_item_hook("My lovely submenu", submenu, cls=SubmenuMenuItem)),
+            ('register_submenu_item', menu_item_hook("Pages", '/pages/')),
+        ]):
+            rendered = menu.render_component(self.request)
+
+        self.assertIsInstance(rendered, list)
+        self.assertEqual(len(rendered), 1)
+        self.assertIsInstance(rendered[0], sidebar.SubMenuItem)
+        self.assertEqual(rendered[0].name, "my-lovely-submenu")
+        self.assertEqual(rendered[0].label, "My lovely submenu")
+        self.assertListEqual(rendered[0].menu_items, [
+            sidebar.LinkMenuItem('pages', "Pages", '/pages/'),
+        ])
+
+    def test_admin_only_menuitem(self):
+        menu = Menu(register_hook_name='register_menu_item')
+
+        with hooks.register_temporarily([
+            ('register_menu_item', menu_item_hook("Pages", '/pages/')),
+            ('register_menu_item', menu_item_hook("Secret pages", '/pages/secret/', cls=AdminOnlyMenuItem)),
+        ]):
+            rendered = menu.render_component(self.request)
+            self.request.user = self.create_user(username='non-admin')
+            rendered_non_admin = menu.render_component(self.request)
+
+        self.assertListEqual(rendered, [
+            sidebar.LinkMenuItem('pages', "Pages", '/pages/'),
+            sidebar.LinkMenuItem('secret-pages', "Secret pages", '/pages/secret/'),
+        ])
+
+        self.assertListEqual(rendered_non_admin, [
+            sidebar.LinkMenuItem('pages', "Pages", '/pages/'),
+        ])

+ 0 - 0
wagtail/admin/tests/ui/__init__.py


+ 232 - 0
wagtail/admin/tests/ui/test_sidebar.py

@@ -0,0 +1,232 @@
+from unittest import TestCase
+
+from django.test import TestCase as DjangoTestCase
+from django.urls import reverse
+
+from wagtail.admin.search import SearchArea
+from wagtail.admin.ui.sidebar import (
+    CustomBrandingModule, LinkMenuItem, MainMenuModule, PageExplorerMenuItem, SearchModule,
+    SettingsMenuItem, SubMenuItem, WagtailBrandingModule)
+from wagtail.core.telepath import JSContext
+from wagtail.tests.utils import WagtailTestUtils
+
+
+class TestAdaptLinkMenuItem(TestCase):
+    def test_adapt(self):
+        packed = JSContext().pack(LinkMenuItem('link', "Link", '/link/'))
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.LinkMenuItem',
+            '_args': [
+                {
+                    'classnames': '',
+                    'icon_name': '',
+                    'label': 'Link',
+                    'name': 'link',
+                    'url': '/link/'
+                }
+            ]
+        })
+
+    def test_adapt_with_classnames_and_icon(self):
+        packed = JSContext().pack(LinkMenuItem('link', "Link", '/link/', icon_name='link-icon', classnames='some classes'))
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.LinkMenuItem',
+            '_args': [
+                {
+                    'classnames': 'some classes',
+                    'icon_name': 'link-icon',
+                    'label': 'Link',
+                    'name': 'link',
+                    'url': '/link/'
+                }
+            ]
+        })
+
+
+class TestAdaptSubMenuItem(TestCase):
+    def test_adapt(self):
+        packed = JSContext().pack(
+            SubMenuItem('sub-menu', "Sub menu", [
+                LinkMenuItem('link', "Link", '/link/', icon_name='link-icon'),
+            ])
+        )
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.SubMenuItem',
+            '_args': [
+                {
+                    'name': 'sub-menu',
+                    'label': 'Sub menu',
+                    'icon_name': '',
+                    'classnames': ''
+                },
+                [
+                    {
+                        '_type': 'wagtail.sidebar.LinkMenuItem',
+                        '_args': [
+                            {
+                                'name': 'link',
+                                'label': 'Link',
+                                'icon_name': 'link-icon',
+                                'classnames': '',
+                                'url': '/link/'
+                            }
+                        ]
+                    }
+                ]
+            ]
+        })
+
+
+class TestAdaptPageExplorerMenuItem(TestCase):
+    def test_adapt(self):
+        packed = JSContext().pack(PageExplorerMenuItem('pages', "Pages", '/pages/', 1))
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.PageExplorerMenuItem',
+            '_args': [
+                {
+                    'classnames': '',
+                    'icon_name': '',
+                    'label': 'Pages',
+                    'name': 'pages',
+                    'url': '/pages/'
+                },
+                1
+            ]
+        })
+
+
+class TestAdaptSettingsMenuItem(TestCase):
+    def test_adapt(self):
+        packed = JSContext().pack(
+            SettingsMenuItem('settings', "Settings", [
+                LinkMenuItem('groups', "Groups", '/groups/', icon_name='people'),
+            ])
+        )
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.SettingsMenuItem',
+            '_args': [
+                {
+                    'name': 'settings',
+                    'label': 'Settings',
+                    'icon_name': '',
+                    'classnames': ''
+                },
+                [
+                    {
+                        '_type': 'wagtail.sidebar.LinkMenuItem',
+                        '_args': [
+                            {
+                                'name': 'groups',
+                                'label': 'Groups',
+                                'icon_name': 'people',
+                                'classnames': '',
+                                'url': '/groups/'
+                            }
+                        ]
+                    }
+                ]
+            ]
+        })
+
+
+class TestAdaptWagtailBrandingModule(TestCase):
+    def test_adapt(self):
+        packed = JSContext().pack(WagtailBrandingModule())
+
+        self.assertEqual(packed['_type'], 'wagtail.sidebar.WagtailBrandingModule')
+        self.assertEqual(len(packed['_args']), 2)
+        self.assertEqual(packed['_args'][0], reverse('wagtailadmin_home'))
+        self.assertEqual(packed['_args'][1].keys(), {
+            'desktopLogoBody',
+            'desktopLogoEyeClosed',
+            'desktopLogoEyeOpen',
+            'desktopLogoTail',
+            'mobileLogo'
+        })
+
+
+class TestAdaptCustomBrandingModule(TestCase):
+    def test_adapt(self):
+        packed = JSContext().pack(CustomBrandingModule('<h1>My custom branding</h1>'))
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.CustomBrandingModule',
+            '_args': [
+                '<h1>My custom branding</h1>',
+                False
+            ]
+        })
+
+    def test_collapsible(self):
+        packed = JSContext().pack(CustomBrandingModule('<h1>My custom branding</h1>', collapsible=True))
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.CustomBrandingModule',
+            '_args': [
+                '<h1>My custom branding</h1>',
+                True
+            ]
+        })
+
+
+class TestAdaptSearchModule(TestCase):
+    def test_adapt(self):
+        packed = JSContext().pack(SearchModule(SearchArea("Search", '/search/')))
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.SearchModule',
+            '_args': [
+                '/search/'
+            ]
+        })
+
+
+class TestAdaptMainMenuModule(DjangoTestCase, WagtailTestUtils):
+    def test_adapt(self):
+        main_menu = [
+            LinkMenuItem('pages', "Pages", '/pages/'),
+        ]
+        account_menu = [
+            LinkMenuItem('account', "Account", reverse('wagtailadmin_account'), icon_name='user'),
+            LinkMenuItem('logout', "Logout", reverse('wagtailadmin_logout'), icon_name='logout'),
+        ]
+        user = self.create_user(username='admin')
+
+        packed = JSContext().pack(MainMenuModule(main_menu, account_menu, user))
+
+        self.assertEqual(packed, {
+            '_type': 'wagtail.sidebar.MainMenuModule',
+            '_args': [
+                [
+                    {
+                        '_type': 'wagtail.sidebar.LinkMenuItem',
+                        '_args': [
+                            {'name': 'pages', 'label': 'Pages', 'icon_name': '', 'classnames': '', 'url': '/pages/'}
+                        ]
+                    }
+                ],
+                [
+                    {
+                        '_type': 'wagtail.sidebar.LinkMenuItem',
+                        '_args': [
+                            {'name': 'account', 'label': 'Account', 'icon_name': 'user', 'classnames': '', 'url': reverse('wagtailadmin_account')}
+                        ]
+                    },
+                    {
+                        '_type': 'wagtail.sidebar.LinkMenuItem',
+                        '_args': [
+                            {'name': 'logout', 'label': 'Logout', 'icon_name': 'logout', 'classnames': '', 'url': reverse('wagtailadmin_logout')}
+                        ]
+                    }
+                ],
+                {
+                    'name': user.first_name or user.get_username(),
+                    'avatarUrl': '//www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=100&d=mm'
+                }
+            ]
+        })

+ 0 - 0
wagtail/admin/ui/__init__.py


+ 169 - 0
wagtail/admin/ui/sidebar.py

@@ -0,0 +1,169 @@
+from typing import List
+
+from django import forms
+from django.urls import reverse
+from django.utils.functional import cached_property
+
+from wagtail.admin.staticfiles import versioned_static
+from wagtail.core.telepath import Adapter, adapter
+
+
+class BaseSidebarAdapter(Adapter):
+    @cached_property
+    def media(self):
+        return forms.Media(js=[
+            versioned_static('wagtailadmin/js/telepath/sidebar.js'),
+        ])
+
+
+# Main menu
+
+class MenuItem:
+    def __init__(self, name: str, label: str, icon_name: str = '', classnames: str = ''):
+        self.name = name
+        self.label = label
+        self.icon_name = icon_name
+        self.classnames = classnames
+
+    def js_args(self):
+        return [
+            {
+                'name': self.name,
+                'label': self.label,
+                'icon_name': self.icon_name,
+                'classnames': self.classnames,
+            }
+        ]
+
+
+@adapter('wagtail.sidebar.LinkMenuItem', base=BaseSidebarAdapter)
+class LinkMenuItem(MenuItem):
+    def __init__(self, name: str, label: str, url: str, icon_name: str = '', classnames: str = ''):
+        super().__init__(name, label, icon_name=icon_name, classnames=classnames)
+        self.url = url
+
+    def js_args(self):
+        args = super().js_args()
+        args[0]['url'] = self.url
+        return args
+
+    def __eq__(self, other):
+        return (
+            self.__class__ == other.__class__
+            and self.name == other.name
+            and self.label == other.label
+            and self.url == other.url
+            and self.icon_name == other.icon_name
+            and self.classnames == other.classnames
+        )
+
+
+@adapter('wagtail.sidebar.SubMenuItem', base=BaseSidebarAdapter)
+class SubMenuItem(MenuItem):
+    def __init__(self, name: str, label: str, menu_items: List[MenuItem], icon_name: str = '', classnames: str = ''):
+        super().__init__(name, label, icon_name=icon_name, classnames=classnames)
+        self.menu_items = menu_items
+
+    def js_args(self):
+        args = super().js_args()
+        args.append(self.menu_items)
+        return args
+
+    def __eq__(self, other):
+        return (
+            self.__class__ == other.__class__
+            and self.name == other.name
+            and self.label == other.label
+            and self.menu_items == other.menu_items
+            and self.icon_name == other.icon_name
+            and self.classnames == other.classnames
+        )
+
+
+@adapter('wagtail.sidebar.PageExplorerMenuItem', base=BaseSidebarAdapter)
+class PageExplorerMenuItem(LinkMenuItem):
+    def __init__(self, name: str, label: str, url: str, start_page_id: int, icon_name: str = '', classnames: str = ''):
+        super().__init__(name, label, url, icon_name=icon_name, classnames=classnames)
+        self.start_page_id = start_page_id
+
+    def js_args(self):
+        args = super().js_args()
+        args.append(self.start_page_id)
+        return args
+
+    def __eq__(self, other):
+        return (
+            self.__class__ == other.__class__
+            and self.name == other.name
+            and self.label == other.label
+            and self.url == other.url
+            and self.start_page_id == other.start_page_id
+            and self.icon_name == other.icon_name
+            and self.classnames == other.classnames
+        )
+
+
+@adapter('wagtail.sidebar.SettingsMenuItem', base=BaseSidebarAdapter)
+class SettingsMenuItem(SubMenuItem):
+    pass
+
+
+# Modules
+
+@adapter('wagtail.sidebar.WagtailBrandingModule', base=BaseSidebarAdapter)
+class WagtailBrandingModule:
+    def js_args(self):
+        return [
+            reverse('wagtailadmin_home'),
+            {
+                'mobileLogo': versioned_static('wagtailadmin/images/wagtail-logo.svg'),
+                'desktopLogoBody': versioned_static('wagtailadmin/images/logo-body.svg'),
+                'desktopLogoTail': versioned_static('wagtailadmin/images/logo-tail.svg'),
+                'desktopLogoEyeOpen': versioned_static('wagtailadmin/images/logo-eyeopen.svg'),
+                'desktopLogoEyeClosed': versioned_static('wagtailadmin/images/logo-eyeclosed.svg'),
+            }
+        ]
+
+
+@adapter('wagtail.sidebar.CustomBrandingModule', base=BaseSidebarAdapter)
+class CustomBrandingModule:
+    def __init__(self, html, collapsible=False):
+        self.html = html
+        self.collapsible = collapsible
+
+    def js_args(self):
+        return [
+            self.html,
+            self.collapsible,
+        ]
+
+
+@adapter('wagtail.sidebar.SearchModule', base=BaseSidebarAdapter)
+class SearchModule:
+    def __init__(self, search_area):
+        self.search_area = search_area
+
+    def js_args(self):
+        return [
+            self.search_area.url
+        ]
+
+
+@adapter('wagtail.sidebar.MainMenuModule', base=BaseSidebarAdapter)
+class MainMenuModule:
+    def __init__(self, menu_items: List[MenuItem], account_menu_items: List[MenuItem], user):
+        self.menu_items = menu_items
+        self.account_menu_items = account_menu_items
+        self.user = user
+
+    def js_args(self):
+        from wagtail.admin.templatetags.wagtailadmin_tags import avatar_url
+
+        return [
+            self.menu_items,
+            self.account_menu_items,
+            {
+                'name': self.user.first_name or self.user.get_username(),
+                'avatarUrl': avatar_url(self.user, size=50),
+            }
+        ]

+ 13 - 0
wagtail/admin/wagtail_hooks.py

@@ -20,6 +20,8 @@ from wagtail.admin.rich_text.converters.html_to_contentstate import (
     InlineStyleElementHandler, ListElementHandler, ListItemElementHandler, PageLinkElementHandler)
 from wagtail.admin.search import SearchArea
 from wagtail.admin.site_summary import PagesSummaryItem
+from wagtail.admin.ui.sidebar import PageExplorerMenuItem as PageExplorerMenuItemComponent
+from wagtail.admin.ui.sidebar import SettingsMenuItem as SettingsMenuItemComponent
 from wagtail.admin.viewsets import viewsets
 from wagtail.admin.widgets import Button, ButtonWithDropdownFromHook, PageListingButton
 from wagtail.core import hooks
@@ -44,6 +46,14 @@ class ExplorerMenuItem(MenuItem):
 
         return context
 
+    def render_component(self, request):
+        start_page = get_explorable_root_page(request.user)
+
+        if start_page:
+            return PageExplorerMenuItemComponent(self.name, self.label, self.url, start_page.id, icon_name=self.icon_name, classnames=self.classnames)
+        else:
+            return super().render_component(request)
+
 
 @hooks.register('register_admin_menu_item')
 def register_explorer_menu_item():
@@ -57,6 +67,9 @@ def register_explorer_menu_item():
 class SettingsMenuItem(SubmenuMenuItem):
     template = 'wagtailadmin/shared/menu_settings_menu_item.html'
 
+    def render_component(self, request):
+        return SettingsMenuItemComponent(self.name, self.label, self.menu.render_component(request), icon_name=self.icon_name, classnames=self.classnames)
+
 
 @hooks.register('register_admin_menu_item')
 def register_settings_menu():

+ 29 - 9
wagtail/core/hooks.py

@@ -35,21 +35,22 @@ def register(hook_name, fn=None, order=0):
 
 
 class TemporaryHook(ContextDecorator):
-    def __init__(self, hook_name, fn, order):
-        self.hook_name = hook_name
-        self.fn = fn
+    def __init__(self, hooks, order):
+        self.hooks = hooks
         self.order = order
 
     def __enter__(self):
-        if self.hook_name not in _hooks:
-            _hooks[self.hook_name] = []
-        _hooks[self.hook_name].append((self.fn, self.order))
+        for hook_name, fn in self.hooks:
+            if hook_name not in _hooks:
+                _hooks[hook_name] = []
+            _hooks[hook_name].append((fn, self.order))
 
     def __exit__(self, exc_type, exc_value, traceback):
-        _hooks[self.hook_name].remove((self.fn, self.order))
+        for hook_name, fn in self.hooks:
+            _hooks[hook_name].remove((fn, self.order))
 
 
-def register_temporarily(hook_name, fn, order=0):
+def register_temporarily(hook_name_or_hooks, fn=None, *, order=0):
     """
     Register hook for ``hook_name`` temporarily. This is useful for testing hooks.
 
@@ -72,8 +73,27 @@ def register_temporarily(hook_name, fn, order=0):
             # Hook is registered here
 
         # Hook is unregistered here
+
+    To register multiple hooks at the same time, pass in a list of 2-tuples:
+
+        def my_hook(...):
+            pass
+
+        def my_other_hook(...):
+            pass
+
+        with hooks.register_temporarily([
+                ('hook_name', my_hook),
+                ('hook_name', my_other_hook),
+            ]):
+            # Hooks are registered here
     """
-    return TemporaryHook(hook_name, fn, order)
+    if not isinstance(hook_name_or_hooks, list) and fn is not None:
+        hooks = [(hook_name_or_hooks, fn)]
+    else:
+        hooks = hook_name_or_hooks
+
+    return TemporaryHook(hooks, order)
 
 
 _searched_for_hooks = False

+ 43 - 0
wagtail/core/telepath.py

@@ -22,3 +22,46 @@ JSContext = registry.js_context_class
 
 def register(adapter, cls):
     registry.register(adapter, cls)
+
+
+def adapter(js_constructor, base=Adapter):
+    """
+    Allows a class to implement its adapting logic with a `js_args()` method on the class itself.
+    This just helps reduce the amount of code you have to write.
+
+    For example:
+
+        @adapter('wagtail.mywidget')
+        class MyWidget():
+            ...
+
+            def js_args(self):
+                return [
+                    self.foo,
+                ]
+
+    Is equivalent to:
+
+        class MyWidget():
+            ...
+
+
+        class MyWidgetAdapter(Adapter):
+            js_constructor = 'wagtail.mywidget'
+
+            def js_args(self, obj):
+                return [
+                    self.foo,
+                ]
+    """
+    def _wrapper(cls):
+        ClassAdapter = type(cls.__name__ + 'Adapter', (base, ), {
+            'js_constructor': js_constructor,
+            'js_args': lambda self, obj: obj.js_args(),
+        })
+
+        register(ClassAdapter(), cls)
+
+        return cls
+
+    return _wrapper