Customizable Navbars in Wagtail

How to use Snippets and StreamFields to create a navigation bar builder.


Wagtail, the excellent CMS Framework developed by Torchbox, gives developers a large amount of freedom in how their projects are built. One of our projects required us to have a navigation bar creator. We decided to implement this using Wagtail's StreamFields and Snippets.

Here's what our navigation bar creator needed to do:

  • Build any number of navigation bars
  • Have each page choose which navigation bar they wanted to use
  • Allow the items in the navigation bar to be customizable

In order to accomplish this, we create a new 'Navbar' model that holds information on the navigation bars and register it as a snippet. In that model we have a StreamField with custom blocks that holds the possible menu items. We then add a ForeignKey field to our Page Models for our Navbar class.

Due to a limitation with Wagtail, blocks aren't able to reference themselves in a StreamField. So for every level of children we want in our Navbar, we need to make a separate Block.

blocks.py
from wagtail.wagtailcore import blocks

class BaseLinkBlock(blocks.StructBlock):
    """
    Base StructBlock class used to prevent DRY code.
    """
    display_text = blocks.CharBlock()


class ExternalLinkBlock(BaseLinkBlock):
    """
    Block that holds a link to any URL.
    """
    link = blocks.URLBlock()

    class Meta:
        template = 'home/menu/external_link_block.html'

class PageLinkBlock(BaseLinkBlock):
    """
    Block that holds a page.
    """
    page = blocks.PageChooserBlock()

    class Meta:
        template = 'home/menu/page_link_block.html'

class LinkChildrenBlock(blocks.StructBlock):
    """
    Base childblock for second level children.
    """
    children = blocks.StreamBlock(
            [
                ('external_link', ExternalLinkBlock()),
                ('page_link', PageLinkBlock()),
            ]
        )

class ExternalLinkWithChildrenBlock(LinkChildrenBlock, ExternalLinkBlock):
    """
    Uses LinkChildrenBlock as a mixin to create an ExternalLinkBlock that supports Children.
    """
    pass

class PageLinkWithChildrenBlock(LinkChildrenBlock, PageLinkBlock):
    """
    Uses LinkChildrenBlock as a mixin to create a PageLinkBlock that supports Children.
    """
    pass

Now that the blocks are made, we need to define the Navbar model and create a Page that supports it.

models.py
from django.db import models
from wagtail.wagtailadmin.edit_handlers import FieldPanel, StreamFieldPanel
from wagtail.wagtailcore.fields import StreamField
from wagtail.wagtailcore.models import Page
from wagtail.wagtailsnippets.models import register_snippet
from .blocks import ExternalLinkWithChildrenBlock, PageLinkWithChildrenBlock

class Navbar(models.Model):
    """
    Model that represents website navigation bars.  Can be modified through the
    snippets UI. 
    """
    name = models.CharField(max_length=255)
    menu_items = StreamField([
        ('external_link', ExternalLinkWithChildrenBlock()),
        ('page_link', PageLinkWithChildrenBlock()),
        ],)

    panels = [
        FieldPanel('name'),
        StreamFieldPanel('menu_items')
    ]

    def __str__(self):
        return self.name

register_snippet(Navbar)


class PageWithNavbar(Page):
    navbar = models.ForeignKey(
        Navbar,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+',
    )

    content_panels=[
        FieldPanel('title'),
        SnippetChooserPanel('navbar')
    ]

The wagtail backend will look like this for your editors.

After filling out some content, it would look similar to this.


Here is how we'll render the Navbar. The markup assumes that we're using Bootstrap for the style.

navbar.html
{% load wagtailcore_tags %}

<nav class="navbar navbar-inverse">
    <div class="container-fluid">
      <div class="navbar-header">
        <button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar">
          <span class="sr-only">Toggle navigation</span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
          <span class="icon-bar"></span>
        </button>
      </div>
      <div id="navbar" class="navbar-collapse collapse">
        <ul class="nav navbar-nav">
            {% for item in page.navbar.menu_items %}
                {% include_block item %}
            {% endfor %}
        </ul>
      </div><!--/.nav-collapse -->
    </div><!--/.container-fluid -->
</nav>

To render the blocks themselves, I have them inheriting from a base template.

base_link_block.html
{% block menu_item %}
{% load wagtailcore_tags %}

<li>
    <a href="{% block url %}#{% endblock %}" class="{% if value.children %}dropwdown-toggle{% endif %}" {% value.children %}data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" {% endif %}>
        <div class="menu-item-display">
            {% block display_text %}{{value.display_text}}{% if value.children %}<span class="caret"></span>{% endif %}{% endblock %}
        </div>
    </a>
    {% if value.children %}
    <ul class="dropdown-menu">
    {% for child in value.children %}
        {% include_block child %}
    {% endfor %}
    </ul>
    {% endif %}
</li>

{% endblock %}


external_link_block.html
{% extends 'home/menu/base_link_block.html' %}

{% block url %}
    {{value.link}}
{% endblock %}

page_link_block.html
{% extends 'home/menu/base_link_block.html' %}
{% load wagtailcore_tags %}

{% block url %}
    {% pageurl value.page %}
{% endblock %}

The last step is adding the navbar.html file to your base page.  This bit will actually render the navbar on the page.

page_with_navbar.html
<head>
...
</head>
<body>
    {% include 'navbar.html' %}
</body>

After that you should be good to go. When you open the wagtail admin, you should be able to make new Navbars through the Snippets UI and add them to pages that have a Navbar field.

Here is what it will look like on the front end: