CNK's Blog

Wagtail: Dynamically Adding Admin Menu Items

I am in the process of upgrading to Wagtail 2.16. One of the new features is a slim admin menu which I am sure many of my laptop users will really like - or would really like - if I had not just added a chunk of code that violates the last item in the exceptions list: MenuItem can no longer be sub-classed to customise its HTML output or load additional JavaScript

I had had an item that was restricted to be “one page of this type per site” and so it was easy to construct a menu item to display all the subpages that could be under that page - I just need to find the PersonIndexPage2 for the current site, and then create a url for the page explorer for that page.

  class PeoplePages2MenuItems(MenuItem):
       def __init__(self):
           super().__init__(
               label="People Pages",
               url=None,
               classnames="icon icon-user",
               order=300,
           )

       def is_shown(self, request):
           """
           The PeoplePages2MenuItem is only shown if there is a PersonPage2Template in the site.
           """
           return PersonPage2Template.objects.in_site(Site.find_for_request(request)).exists()

      def get_context(self, request):
          """
          Constructs the url for listing PersonPage2 pages
          """
          page = PersonIndexPage2.objects.descendant_of(Site.find_for_request(request).root_page).first()
          self.url = reverse('wagtailadmin_explore', args=[page.id]) + "?ordering=title&people_pages_only=True"
          return super().get_context(request)


  @hooks.register('register_people_admin_menu_item')
  def register_people_pages_v2_template_menu_item():
      return PeoplePages2TemplateMenuItem()

But then someone asked me if they could add more than one PersonIndexPage2 per site. So we will need more than one menu item for “People Pages” - and we’ll need more than one link per site. So I had a look at the MenuItem class and there is the render code, just begging me to hijack it. so I removed the get_context method above and did all the dirty work in the render_html method.

      def render_html(self, request):
          pages = PersonIndexPage2.objects.descendant_of(Site.find_for_request(request).root_page).all()
          items = []
          for page in pages:
              context = self.get_context(request)
              context['url'] = reverse('wagtailadmin_explore', args=[page.id]) + "?ordering=title&people_pages_only=True"
              context['label'] = page.title
              items.append(render_to_string(self.template, context, request=request))
          return (' ').join(items)

That was great - for about 2 weeks. Then I started my Wagtail 2.16 upgrade and suddenly my “People Pages” links go to /admin/null.

So I went poking around in the Wagtail source code and found what I probably should have been using all the time. The Menu class has a method menu_items_for_request. This is where the is_shown rules are enforced - but more important for my current issue is the section where it executes any hooks registered by a menu’s construct_hook_name. I have lots of code that uses hooks configured with register_hook_name but it hadn’t occurred to me to look for a request-time equivalent.

So, first I need to define a construct hook:

  class PeopleAdminMenu(Menu):
      def __init__(self):
          super().__init__(
              register_hook_name='register_people_admin_menu_item',
              construct_hook_name='construct_people_admin_menu_item',
          )

Then I replaced my PeoplePages2MenuItems class and the register_people_admin_menu_item hook that added it to the correct top level menu item with a method to add the menu items.

  @hooks.register('construct_people_admin_menu_item')
  def add_people_pages2_menu_items(request, items):
      site = Site.find_for_request(request)
      if PersonPage2Template.objects.in_site(site).exists():
          for page in PersonIndexPage2.objects.descendant_of(site.root_page).all():
              pp2_menu_item = MenuItem(
                  page.title,
                  reverse('wagtailadmin_explore', args=[page.id]) + "?ordering=title&people_pages_only=True",
                  icon_name='icon icon-user',
                  order=300,
              )
              items.append(pp2_menu_item)

This contains all the same logic as the previous class. The if clause contains the logic from the is_shown method and the class’s init parameters are combined with the dynamic url and label items from the render_html method to instantiate a MenuItem. So much cleaner! I should have been doing it like this all along.