Adding custom editable navigation to a Wagtail site

A good navigation menu is crucial. In this article we will create a flexible menu that uses Wagtail's page structure, is editable and responds to user's login and logout.

July 9, 2020, 7:18 p.m.
Themes: Navigation

Building a basic navigation menu is easy with tools such as Bootstrap. However, when your site becomes a bit more complicated, so does your navigation menu. In this tutorial we are going to build a navigation menu with the following requirements:

  • can include links to Wagtail pages as well as pages outside the Wagtail tree (such as login/signup)
  • configurable in the Wagtail editor
  • can have submenus
  • can have icons instead of text
  • changes depending on the user being logged in or not (e.g. does not show 'login' menu entry when user is already logged in)
  • is multilingual

As we will see, this will require some trade-offs about where to put which functionality.

First it should be said that a great and easy package to build menus in Wagtail is wagtailmenus. Unfortunately there does not seem to be a way to make the menu respond to users being logged in. So maybe this is one of those moments where we cannot use the plethora of packages and options that Django and Wagtail offer us and we get down to writing some Python code ourselves. Luckily there is an example on which we can build.

Our navigation system will consist of four elements: a menu model, a menu-item model, a template tag that will allow us to insert the menu in our templates, and a template which displays the menu. The menu is subclassed from ClusterableModel. This is a model from the package django-modelcluster which is used by Wagtail (and installed together with Wagtail), a.o. to allow 'parent' and 'child' models (such as a menu and its menu-items) to be treated as a single unit. The Menu model has just two fields, a title and a slug.

from django.db import models
from django_extensions.db.fields import AutoSlugField
from modelcluster.models import ClusterableModel
from wagtail.admin.edit_handlers import FieldPanel, InlinePanel, MultiFieldPanel
from wagtail.snippets.models import register_snippet

@register_snippet
class Menu(ClusterableModel):

    title = models.CharField(max_length=50)
    slug = AutoSlugField(populate_from='title', editable=True, help_text="Unique identifier of menu. Will be populated automatically from title of menu. Change only if needed.")

    panels = [
        MultiFieldPanel([
            FieldPanel('title'),
            FieldPanel('slug'),
        ], heading=_("Menu")),
        InlinePanel('menu_items', label=_("Menu Item"))
    ]

    def __str__(self):
        return self.title

As in the example we have used the package django-extensions, which has a model AutoSlugField that auto-populates the slug field with a value that is derived from the title. In Wagtail admin the slug field dynamically auto-populates while typing the title; it should be remarked that this only works when the title field is called title and the slug field called slug. Install the package with:

pip3 install django-extensions

Add it to requirements.txt and add django_extensions to INSTALLED_APPS.

The field menu_items is the related_name of the relationship with the MenuItem model that we are going to create now. In this way we can create the menu items when editing our menu. Our menu item model should have the following elements:

  • a foreign key relationship to a menu
  • a title; we would like to be able to name the menu item independent of the link it is pointing to
  • either an 'external' url, i.e. a url that is not part of our Wagtail tree,
  • or a foreign key relationship to a Wagtail page,
  • a reference to a submenu, if there is one
  • an icon field, allowing us to show an icon instead of text in the menu
  • a switch that will allow us to choose whether we want to always show the item, only show it when the user is logged in, or only show it when the user is not logged in

The model follows quite logically from all this. We will not register it as a snippet (as opposed to Menu), because we will be able to add and edit menu items via the Menu snippet in admin.

from modelcluster.fields import ParentalKey
from wagtail.admin.edit_handlers import PageChooserPanel
from wagtail.core.models import Orderable
from wagtail.images.edit_handlers import ImageChooserPanel

class MenuItem(Orderable):
    menu = ParentalKey('Menu', related_name='menu_items', help_text=_("Menu to which this item belongs"))
    title = models.CharField(max_length=50, help_text=_("Title of menu item that will be displayed"))
    link_url = models.CharField(max_length=500, blank=True, null=True, help_text=_("URL to link to, e.g. /accounts/signup (no language prefix, LEAVE BLANK if you want to link to a page instead of a URL)"))
    link_page = models.ForeignKey(
        TranslatablePage, blank=True, null=True, related_name='+', on_delete=models.CASCADE, help_text=_("Page to link to (LEAVE BLANK if you want to link to a URL instead)"),
    )
    title_of_submenu = models.CharField(
        blank=True, null=True, max_length=50, help_text=_("Title of submenu (LEAVE BLANK if there is no custom submenu)")
    )
    icon = models.ForeignKey(
        'wagtailimages.Image', blank=True, null=True, on_delete=models.SET_NULL, related_name='+',
    )
    show_when = models.CharField(
        max_length=15,
        choices=[('always', _("Always")), ('logged_in', _("When logged in")), ('not_logged_in', _("When not logged in"))],
        default='always',
    )

    panels = [
        FieldPanel('title'),
        FieldPanel('link_url'),
        PageChooserPanel('link_page'),
        FieldPanel('title_of_submenu'),
        ImageChooserPanel('icon'),
        FieldPanel('show_when'),
    ]

    def __str__(self):
        return self.title

Using the class Orderable allows us to give an order to the menu items, which we obviously want. As mentioned above, we have the related name menu_items we need in our Menu model. The ParentalKey model is similar to a ForeignKey and is also imported from the package django-modelcluster. The link_page field points to a TranslatablePage within our Wagtail tree. The name_of_submenu field is the name of the submenu, if there is one. The icon field allows us to upload an image. Finally the show_when field uses the choices option to list a number of options when to show the menu item.

All of the fields of Menu and MenuItem can be passed via a template tag to a template. However we still need to do a couple of things:

  1. translate link_url / link_page to the right language (in case of a multilingual site)
  2. decide which menu items should be shown depending on whether the user is logged in or not
  3. find out if there is a submenu defined, if not find out if link_page has children, and depending on this outcome create the submenu

A short remark on design. Points 1 and 2 can be considered aspects of the MenuItem model, so should be part of the model definition. Point 3 is more difficult: we can (and will) have a situation where a menu has an item that points to a Wagtail page with children that we would want in a submenu. Constructing this submenu does not use the Menu model. It is possible to solve this in the template: check if a submenu exists, if not create a separate block of code for the children of link_page. However, business logic should be kept out of the template. So our choice is to solve this in the template tag. Please give your opinion in a comment below if you feel there is a better solution. Let's start with translating link_page. If you are just using one language, you can skip this step. We use the concept of canonical pages of wagtailtrans: a canonical page of a given page is that page in the default language. You can view the canonical page in Wagtail admin for every page, under the tab Settings. Every translatable page has fields language and canonical_page; if the field canonical_page is not defined, then the page itself is the canonical page. So we get the translated version of the link_page (if there is one) as follows:

from django.conf import settings
from wagtailtrans.models import Language

def trans_page(self, language_code):
    if self.link_page:
        can_page = self.link_page.canonical_page if self.link_page.canonical_page else self.link_page
        if language_code == settings.LANGUAGE_CODE: # requested language is the canonical language
            return can_page
        try:
            language = Language.objects.get(code=language_code)
        except Language.DoesNotExist: # no language found, return original page
            return self.link_page
        return TranslatablePage.objects.get(language=language, canonical_page=can_page)
    return None

Getting a translated url that is derived from link_url if that is defined, or else from link_page, is then relatively easy (this assumes that we are using language prefixes for our urls):

def trans_url(self, language_code):
    if self.link_url:
        return '/' + language_code + self.link_url
    elif self.link_page:
        return self.trans_page(language_code).url
    return None

We also want to translate the title of the submenu in a slug, which is the menu identifier that we use (because it is less error prone than a title):

@property
def slug_of_submenu(self):
    # becomes slug of submenu if there is one, otherwise None
    if self.title_of_submenu:
        return slugify(self.title_of_submenu)
    return None

Finally we can decide whether a menu item should be shown: only if the field show_when corresponds with the authentication status of the user.

def show(self, authenticated):
    return ((self.show_when == 'always')
            or (self.show_when == 'logged_in' and authenticated)
            or (self.show_when == 'not_logged_in' and not authenticated))

Now to our template tag. A menu item to be displayed in a template needs the following elements:

  • a title to be displayed
  • a url to which it points
  • either a slug identifying a submenu or a page with children from which we can create a submenu
  • an icon, if this should be used instead of text

If there is a slug identifying a submenu, we could pass that menu to our template. But if we need to construct our submenu from children of a page there is no Menu instance. So instead we construct a list of menu items that will cover both options. We use a simple tag for this. Create a file templatetags/cms_tags.py. The basics are (we already import some things we are going to need):

from cms.models import Menu
from django import template
from django.utils import translation

register = template.Library()

@register.simple_tag()
def get_menu(slug, page, logged_in):
    # returns a list of dicts with title, url, slug, page and icon of all items in the menu of the given slug or page
    CODE FOLLOWS

So as arguments we take:

  • a slug which identifies our menu,
  • a page to construct a menu with if there is no slug,
  • a parameter logged_in indicating whether our user is logged in or not.

The output of get_menu will be a list of menu items, each with the following elements: a title, a url, a slug of a possible submenu, a page for a possible submenu, and possibly an icon. With this we will construct our menu in the template.

First the case when a menu with the given slug exists. We retrieve the current language via Django's function get_language. With the methods and property in our models the list is then easy to construct.

try:
    # see if there is a custom menu defined for the slug of the item
    candidates = Menu.objects.get(slug=slug).menu_items.all()
    language_code = translation.get_language()

    # create a list of all items that should be shown in the menu depending on logged_in
    menu_items = []
    for candidate in candidates:
        if candidate.show(logged_in):
            menu_items.append({'title': candidate.title, 'url': candidate.trans_url(language_code),
                               'slug': candidate.slug_of_submenu, 'page': candidate.trans_page(language_code), 'icon': candidate.icon})
    return menu_items
except Menu.DoesNotExist:
    pass

If there is no custom menu defined then we construct the same list using the children of the given page:

try:
    # if there is no custom menu, then there should be a valid page argument; see if it has children
    candidates = page.get_children()

    # if so, create a list of all items that have show_in_menus == True
    menu_items = []
    for candidate in candidates:
        if candidate.show_in_menus:
            menu_items.append({'title': candidate.title, 'url': candidate.url,
                               'slug': None, 'page': candidate, 'icon': None})
    return menu_items
except AttributeError:
    # neither custom menu nor valid page argument; return None
    return None

The only check we do is whether the child page has the show_in_menus flag set to True. This flag is standard Wagtail and can be set in the tab Promote in the editor. If there are no children to be shown, the list will be empty. If the page is invalid, then we return None and we have no menu.

That completes our tag. Create a template main_menu.html and use the tag with the lines:

{% load cms_tags wagtailimages_tags %}

{% get_menu "main" None request.user.is_authenticated as navigation %}

We already load wagtailimages_tags as well, because we will use Wagtail's image tag to display icons. We'll call our main menu main, that's the name we'll have to use in our editor as well. The second argument None corresponds to the page argument of our template tag. Since we will define a menu main, we will not need this, so we can use None. Finally request.user.is_authenticated tells us whether the user is logged in or not. To construct the menu we'll use the standard Bootstrap navbar component. The interesting part of the template is the following:

{% for item in navigation %}
    {% get_menu item.slug item.page request.user.is_authenticated as submenu %}
    <li class="{% if submenu %}dropdown {% endif %}p-2">
        <div class="dropdown show">
            <a href="{{ item.url }}"
                    {% if submenu %} class="menuitem dropdown-toggle {% if item.icon %}menuicon{% endif %}" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"
                    {% else %} data-toggle="tooltip" title="{{ item.title }}" class="menuitem"
                    {% endif %}
            >
                {% if item.icon %}
                    {% image item.icon fill-30x30 class="image-menu" %}
                {% else %}
                    {{ item.title }}
                {% endif %}
            </a>
            {% if submenu %}

                DISPLAY THE SUBMENU

            {% endif %}
        </div>
    </li>
{% endfor %}

For every item in our current menu navigation we first try to fetch a submenu. If there is one, we use a dropdown toggle, otherwise a tooltip (both standard Bootstrap). The link is simply item.url. If there is an icon, we use it, otherwise we use item.title. Adding some classes allows specific styling if we want it. Displaying the submenu is basically the same, except that we assume no third level in our menu. Our function get_menu would perfectly allow a third level or as many levels we want, but here we stop at two. Then the code for displaying the submenu is simply:

{% for subitem in submenu %}
    <a href="{{ subitem.url }}" class="dropdown-item menuitem p-2 {% if subitem.icon %}menuicon{% endif %}">
        {% if subitem.icon %}
            {% image subitem.icon fill-30x30 class="image-menu" %}
        {% else %}
            {{ subitem.title }}
        {% endif %}
    </a>
{% endfor %}

The styling can be minimal. If we have an icon in our menu, we can enlarge it slightly when the user hovers over it:

/* slightly enlarge the image on hover */
a.menuitem .image-menu {
    transition: transform 300ms;
}
a.menuitem:hover .image-menu {
    transform: scale(1.1);
}

The full code of main_menu.html and all other files can be found in the repository. We now only need to put the following line in our base.html template, at the beginning of our body:

{% include "main_menu.html" %}

That's it. We can migrate the database to incorporate our new models, then go to our editor, to Snippets, create the menu main, add icons and see how it looks like. In a previous tutorial we have set up authentication with allauth, so we can construct a menu account with a submenu with links to /accounts/login, /accounts/logout etc. In this way we combine our authentication app and our content management app in one navigation menu. The accompanying video shows more of this.

There's still one flaw: our self-constructed menu items are not translated yet. We'll solve this in another tutorial. Read on if you also want to add a footer menu with a cookie statement and privacy policy to your site, along with a favicon and a company logo.

Comment on this article (sign in first or confirm by name and email below)