Configureerbare navigatie toevoegen aan een Wagtail-site

Een goed navigatiemenu is cruciaal. In dit artikel zullen we een flexibel menu maken dat de paginastructuur van Wagtail gebruikt, bewerkbaar is en reageert op het inloggen en uitloggen van de gebruiker.

9 juli 2020 19:18
Thema's: Navigatie

Het bouwen van een navigatiemenu is eenvoudig met tools zoals Bootstrap. Wanneer de site echter een beetje ingewikkelder wordt, wordt het navigatiemenu dat ook. In deze tutorial gaan we een navigatiemenu bouwen met de volgende vereisten:

  • kan links naar Wagtail-pagina's bevatten, maar ook pagina's buiten de Wagtail-boom (zoals inloggen / aanmelden)
  • configureerbaar in de Wagtail-editor
  • kan submenu's hebben
  • kan pictogrammen hebben in plaats van tekst
  • verandert afhankelijk van het feit of de gebruiker is ingelogd of niet (bijv. toont geen 'login' menu-item wanneer de gebruiker al is ingelogd)
  • is meertalig

Zoals we zullen zien, vereist dit een aantal afwegingen over waar welke functionaliteit te plaatsen.

Allereerst moet gezegd worden dat er een geweldig en eenvoudig pakket is om menu's te bouwen in Wagtail: wagtailmenus. Helaas lijkt er geen manier te zijn om het menu te laten reageren op gebruikers die zijn ingelogd. Dus misschien is dit een van die momenten waarop we de overvloed aan pakketten en opties die Django en Wagtail ons bieden niet kunnen gebruiken en we zelf Python-code gaan schrijven. Gelukkig is er een voorbeeld waarop we kunnen voortbouwen.

Ons navigatiesysteem zal uit vier elementen bestaan: een menumodel, een menu-itemmodel, een templatetag waarmee we het menu in onze templates kunnen invoegen en een template die het menu weergeeft. Het menu is afgeleid van ClusterableModel. Dit is een model uit het pakket django-modelcluster dat wordt gebruikt door Wagtail (en samen met Wagtail wordt geïnstalleerd), o.a. om 'ouder'- en' kind'-modellen (zoals een menu en de menu-items) als één geheel te behandelen. Het menumodel heeft slechts twee velden, een title en een 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

Net als in het voorbeeld hebben we het pakket django-extensions gebruikt, dat een model AutoSlugField heeft dat het veld slug automatisch vult met een waarde die is afgeleid van de titel. In Wagtail admin vult het slug-veld zich automatisch tijdens het typen van de titel; opgemerkt moet worden dat dit alleen werkt als het titelveld title heet en het slug-veld slug wordt genoemd. Installeer het pakket met:

pip3 install django-extensions

Voeg het toe aan requirements.txt en voeg django_extensions toe aan INSTALLED_APPS.

Het veld menu_items is de related_name van de relatie met het MenuItem-model dat we nu gaan maken. Op deze manier kunnen we de menu-items maken bij het bewerken van ons menu. Ons menu-itemmodel moet de volgende elementen bevatten:

  • een foreign key relatie tot een menu
  • een titel; we willen het menu-item een ​​naam kunnen geven, onafhankelijk van de link waarnaar het verwijst
  • ofwel een 'externe' url, d.w.z. een url die geen deel uitmaakt van onze Wagtail-boom,
  • of een foreign key-relatie met een Wagtail-pagina,
  • een verwijzing naar een submenu, als dat er is
  • een pictogramveld, zodat we een pictogram kunnen weergeven in plaats van tekst in het menu
  • een schakelaar waarmee we kunnen kiezen of we het item altijd willen laten zien, het alleen willen tonen als de gebruiker is ingelogd, of het alleen tonen als de gebruiker niet is ingelogd

Het model volgt daar logisch uit. We zullen het niet registreren als een snippet (in tegenstelling tot Menu), omdat we menu-items kunnen toevoegen en bewerken via de 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

Door de klasse Orderable te gebruiken, kunnen we een volgorde aan de menu-items geven, wat we uiteraard willen. Zoals hierboven vermeld, hebben we de gerelateerde naam menu_items die we nodig hebben in ons menumodel. Het ParentalKey-model is vergelijkbaar met een ForeignKey en wordt ook geïmporteerd uit het pakket django-modelcluster. Het veld link_page verwijst naar een TranslatablePage binnen onze Wagtail-boom. Het veld name_of_submenu is de naam van het submenu, als dat er is. Via het veld icon kunnen we een afbeelding uploaden. Ten slotte gebruikt het veld show_when de parameter choices om een ​​aantal opties weer te geven wanneer het menu-item moet worden weergegeven.

Alle velden van Menu en MenuItem kunnen via een template tag aan een template worden doorgegeven. We moeten echter nog een aantal dingen doen:

  • link_url / link_page vertalen naar de juiste taal (in het geval van een meertalige site)
  • bepalen welke menu-items moeten worden weergegeven, afhankelijk van of de gebruiker is ingelogd of niet
  • uitzoeken of er een submenu is gedefinieerd, zo niet, uitzoeken of link_page kinderen heeft en, afhankelijk van deze uitkomst, het submenu maken

Een opmerking over design. Punten 1 en 2 kunnen worden beschouwd als aspecten van het MenuItem-model, en moeten daarom deel uitmaken van de modeldefinitie. Punt 3 is moeilijker: we kunnen (en zullen) een situatie hebben waarin een menu een item heeft dat verwijst naar een Wagtail-pagina met kinderen die we in een submenu zouden willen. Het construeren van dit submenu maakt geen gebruik van het menumodel. Het is mogelijk om dit op te lossen in de template: controleer of er een submenu bestaat, maak anders een apart codeblok voor de kinderen van link_page. Business logic moet echter buiten de template worden gehouden. Onze keuze is dus om dit op te lossen in de template tag. Geef je mening in een opmerking hieronder als je denkt dat er een betere oplossing is. Laten we beginnen met het vertalen van link_page. Als je slechts één taal gebruikt, kun je deze stap overslaan. We gebruiken het concept van canonieke pagina's van wagtailtrans: een canonieke pagina van een bepaalde pagina is die pagina in de standaardtaal. Je kunt de canonieke pagina in Wagtail admin voor elke pagina bekijken, onder het tabblad Instellingen. Elke vertaalbare pagina heeft de velden language en canonical_page; als het veld canonical_page niet is gedefinieerd, dan is de pagina zelf de canonical page. Dus we krijgen de vertaalde versie van de link_page (als die er is) als volgt:

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

Een vertaalde url verkrijgen die is afgeleid van link_url als die is gedefinieerd, of anders van link_page, is dan relatief eenvoudig (dit veronderstelt dat we taalvoorvoegsels gebruiken voor onze 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 willen ook de titel van het submenu vertalen in een slug, wat de menu-identifier is die we gebruiken (omdat het minder foutgevoelig is dan een titel):

@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

Tenslotte kunnen we beslissen of een menu-item getoond moet worden: alleen als het veld show_when overeenkomt met de authenticatiestatus van de gebruiker.

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))

Nu onze templatetag. Een menu-item dat in een template moet worden weergegeven, heeft de volgende elementen nodig:

  • een titel die moet worden weergegeven
  • een url waarnaar het verwijst
  • ofwel een slug die een submenu identificeert of een pagina met kinderen waaruit we een submenu kunnen maken
  • een pictogram, als dit moet worden gebruikt in plaats van tekst

Als er een slug is die een submenu identificeert, kunnen we dat menu doorgeven aan onze template. Maar als we ons submenu moeten samenstellen uit kinderen van een pagina, is er geen menu-instantie. Dus in plaats daarvan stellen we een lijst samen met menu-items die beide opties dekken. Hiervoor gebruiken we een simple tag. Maak een bestand templatetags/cms_tags.py. De basis is (we importeren al enkele dingen die we nodig hebben):

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

Dus als argumenten nemen we:

  • een slug die ons menu identificeert,
  • een pagina om een menu op te bouwen als er geen slug is,
  • een parameter logged_in die aangeeft of onze gebruiker al dan niet is ingelogd.

De uitvoer van get_menu zal een lijst met menu-items zijn, elk met de volgende elementen: een titel, een url, een slug van een mogelijk submenu, een pagina voor een mogelijk submenu en mogelijk een pictogram. Hiermee bouwen we ons menu op in de template.

Eerst het geval wanneer er een menu met de gegeven slug bestaat. We halen de huidige taal op via Django's functie get_language. Met de methoden en eigenschappen in onze modellen is de lijst dan eenvoudig op te bouwen.

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

Als er geen aangepast menu is gedefinieerd, stellen we dezelfde lijst samen met de onderliggende items van de gegeven pagina:

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

De enige controle die we doen is of op de onderliggende pagina de vlag show_in_menus is ingesteld op True. Deze vlag is standaard Wagtail en kan worden ingesteld in het tabblad Promotie in de editor. Als er geen kinderen worden getoond, is de lijst leeg. Als de pagina ongeldig is, retourneren we None en hebben we geen menu.

Dat maakt onze tag compleet. Maak een template main_menu.html en gebruik de tag met de regels:

{% load cms_tags wagtailimages_tags %}

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

We laden ook al wagtailimages_tags, omdat we Wagtail's image-tag zullen gebruiken om pictogrammen weer te geven. We noemen ons hoofdmenu main, dat is de naam die we ook in onze editor moeten gebruiken. Het tweede argument None komt overeen met het pagina-argument van onze templatetag. Aangezien we een menu main zullen definiëren, hebben we dit niet nodig, dus we kunnen None gebruiken. Tot slot vertelt request.user.is_authenticated ons of de gebruiker is ingelogd of niet. Om het menu samen te stellen gebruiken we de standaard Bootstrap navbar-component. Het interessante deel van de template is het volgende:

{% 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 %}

Voor elk item in onze huidige menu navigation proberen we eerst een submenu op te halen. Als er een is, gebruiken we een dropdown toggle, anders een tooltip (beide standaard Bootstrap). De link is gewoon item.url. Als er een pictogram is, gebruiken we het, anders gebruiken we item.title. Het toevoegen van enkele klassen maakt specifieke styling mogelijk als we dat willen. Het submenu weergeven is in principe hetzelfde, behalve dat we in ons menu geen derde niveau veronderstellen. Onze functie get_menu zou prima een derde niveau of zoveel niveaus toestaan als we willen, maar we houden het hier bij twee. Dan is de code voor het weergeven van het submenu eenvoudigweg:

{% 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 %}

De styling kan minimaal zijn. Als we een pictogram in ons menu hebben, kunnen we het iets vergroten wanneer de gebruiker de muis erboven beweegt:

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

De volledige code van main_menu.html en alle andere bestanden zijn te vinden in de repository. We hoeven nu alleen de volgende regel in onze base.html-template te plaatsen, aan het begin van de body:

{% include "main_menu.html" %}

Dat is het. We kunnen de database migreren om onze nieuwe modellen op te nemen, dan naar onze editor gaan, naar Snippets, het menu main maken, iconen toevoegen en zien hoe het eruit ziet. In een eerdere tutorial hebben we authenticatie met allauth opgezet, zodat we een menu account kunnen definiëren met een submenu met links naar /accounts/login, /accounts/logout etc. Op deze manier combineren we onze authenticatie-app en onze content management app in één navigatiemenu. De bijbehorende video laat hier meer van zien.

Er is nog één minpunt: onze zelfgemaakte menu-items zijn nog niet vertaald. We lossen dit op in een andere tutorial.. Lees verder als je ook een footer-menu met een cookieverklaring en privacybeleid aan de site wilt toevoegen, samen met een favicon en een bedrijfslogo.

Reageer op dit artikel (log eerst in of bevestig hieronder met naam en email)