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.
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:
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:
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:
link_url
/ link_page
to the right language (in case of a multilingual site)link_page
has children, and depending on this outcome create the submenuA 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:
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:
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)