Ajouter une navigation modifiable personnalisée à un site Wagtail

Un bon menu de navigation est crucial. Dans cet article, on va créer un menu flexible qui utilise la structure de page de Wagtail, est modifiable et répond à la connexion et à la déconnexion de l'utilisateur.

9 Juillet 2020 19:18
Thèmes: Navigation

La création d'un menu de navigation de base est facile avec des outils tels que Bootstrap. Cependant, lorsque votre site devient un peu plus compliqué, votre menu de navigation en fait de même. Dans ce didacticiel, on va créer un menu de navigation avec les exigences suivantes:

  • peut inclure des liens vers des pages Wagtail ainsi que des pages en dehors de l'arborescence Wagtail (telles que la connexion / l'inscription)
  • configurable dans l'éditeur Wagtail
  • peut avoir des sous-menus
  • peut avoir des icônes au lieu de texte
  • change en fonction de l'utilisateur connecté ou non (par exemple, n'affiche pas l'entrée de menu 'connexion' lorsque l'utilisateur est déjà connecté)
  • est multilingue

Comme on le verra, cela nécessitera quelques compromis sur l'endroit où mettre quelle fonctionnalité.

Tout d'abord, il faut dire qu'un package génial et facile à créer des menus dans Wagtail est wagtailmenus. Malheureusement, il ne semble pas y avoir de moyen de faire répondre le menu aux utilisateurs connectés. Alors peut-être que c'est un de ces moments où on ne peut pas utiliser la pléthore de packages et d'options que Django et Wagtail nous offrent et on se met à écrire du code Python soi-même. Heureusement, il existe un exemple sur lequel on peut s'appuyer.

Notre système de navigation comprendra quatre éléments: un modèle de menu, un modèle d'élément de menu, une balise de modèle qui nous permettra d'insérer le menu dans nos modèles et un modèle qui affiche le menu. Le menu est sous-classé à partir de ClusterableModel. Il s'agit d'un modèle du package django-modelcluster qui est utilisé par Wagtail (et installé avec Wagtail), a.o. pour permettre aux modèles 'parent' et 'enfant' (comme un menu et ses éléments de menu) d'être traités comme une seule unité. Le modèle menu n'a que deux champs, un title et un 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

Comme dans l'exemple, on a utilisé le package django-extensions, qui a un modèle AutoSlugField qui remplit automatiquement le champ slug avec une valeur dérivée du titre. Dans Wagtail admin, le champ slug se remplit automatiquement lors de la saisie du titre; il convient de noter que cela ne fonctionne que lorsque le champ titre est appelé title et le champ slug appelé slug. Installez le package avec:

pip3 install django-extensions

Ajoutez-le à requirements.txt et ajoutez django_extensions à INSTALLED_APPS.

Le champ menu_items est le related_name de la relation avec le modèle MenuItem qu'on va créer maintenant. De cette façon, on peut créer les éléments de menu lors de la modification de notre menu. Notre modèle d'élément de menu doit comporter les éléments suivants:

  • une relation de clé étrangère (foreign key) avec un menu
  • un titre; on aimerait pouvoir nommer l'élément de menu indépendamment du lien vers lequel il pointe
  • soit une URL 'externe', c'est-à-dire une URL qui ne fait pas partie de notre arbre Wagtail,
  • ou une relation de clé étrangère vers une page Wagtail,
  • une référence à un sous-menu, s'il y en a un
  • un champ d'icône, nous permettant d'afficher une icône au lieu du texte dans le menu
  • un commutateur qui nous permettra de choisir si on veut toujours afficher l'élément, l'afficher uniquement lorsque l'utilisateur est connecté ou l'afficher uniquement lorsque l'utilisateur n'est pas connecté

Le modèle découle assez logiquement de tout cela. On ne l'enregistrera pas en tant que bloc (snippet) (par opposition à Menu), car on pourra ajouter et modifier des éléments de menu via le bloc Menu dans 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

L'utilisation de la classe Orderable nous permet de donner un ordre aux éléments de menu, ce qu'on veut évidemment. Comme mentionné ci-dessus, on a le nom associé menu_items dont on a besoin dans notre modèle Menu. Le modèle ParentalKey est similaire à une ForeignKey et est également importé du package django-modelcluster. Le champ link_page pointe vers une TranslatablePage dans notre arbre Wagtail. Le champ name_of_submenu est le nom du sous-menu, s'il y en a un. Le champ icon nous permet de télécharger une image. Enfin, le champ show_when utilise l'option choices pour répertorier un certain nombre d'options quand afficher l'élément de menu.

Tous les champs de Menu et MenuItem peuvent être transmis via une balise de modèle à un modèle html. Cependant, on doit encore faire trois choses:

  • traduire link_url / link_page dans la bonne langue (dans le cas d'un site multilingue)
  • décider quels éléments de menu doivent être affichés selon que l'utilisateur est connecté ou non
  • savoir si un sous-menu est défini, sinon savoir si link_page a des enfants, et en fonction de ce résultat, créer le sous-menu

Une petite remarque sur le design. Les points 1 et 2 peuvent être considérés comme des aspects du modèle MenuItem et doivent donc faire partie de la définition du modèle. Le point 3 est plus difficile: on peut (et va) avoir une situation où un menu a un élément qui pointe vers une page Wagtail avec des enfants qu'on voudra dans un sous-menu. La construction de ce sous-menu n'utilise pas le modèle Menu. Il est possible de résoudre ce problème dans le modèle html: vérifiez s'il existe un sous-menu, sinon créez un bloc de code séparé pour les enfants de link_page. Cependant,la logique doit être exclue du modèle html. Notre choix est donc de résoudre ce problème dans la balise de modèle. Veuillez donner votre avis dans un commentaire ci-dessous si vous pensez qu'il existe une meilleure solution. Commençons par traduire link_page. Si vous n'utilisez qu'une seule langue, vous pouvez ignorer cette étape. On utilise le concept de pages canoniques de wagtailtrans: une page canonique d'une page donnée est cette page dans la langue par défaut. Vous pouvez voir la page canonique dans Wagtail admin pour chaque page, sous l'onglet Paramètres. Chaque page traduisible a des champs language et canonical_page; si le champ canonical_page n'est pas défini, alors la page elle-même est la page canonique. On obtient donc la version traduite de la link_page (s'il y en a une) comme suit:

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

Obtenir une URL traduite dérivée de link_url si elle est définie, ou bien de link_page, est alors relativement facile (cela suppose qu'on utilise des préfixes de langue pour nos URL):

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

On veut également traduire le titre du sous-menu dans un slug, qui est l'identifiant de menu qu'on utilise (car il est moins sujet aux erreurs qu'un titre):

@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

Enfin, on peut décider si un élément de menu doit être affiché: uniquement si le champ show_when correspond au statut d'authentification de l'utilisateur.

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

Passons maintenant à notre balise de modèle. Un élément de menu à afficher dans un modèle nécessite les éléments suivants:

  • un titre à afficher
  • une URL vers laquelle il pointe
  • soit un slug identifiant un sous-menu ou une page avec des enfants à partir de laquelle on peut créer un sous-menu
  • une icône, si celle-ci doit être utilisée à la place du texte

S'il y a un slug identifiant un sous-menu, on pourra passer ce menu à notre modèle html. Mais si on doit construire notre sous-menu à partir des enfants d'une page, il n'y a pas d'instance Menu. Au lieu de cela, on construit une liste d'éléments de menu qui couvrira les deux options. Pour cela, on utilise une simple balise. Créez un fichier templatetags/cms_tags.py. La base est (on importe déjà certaines choses dont on aura besoin):

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

Donc, comme arguments, on prend:

  • un slug qui identifie notre menu,
  • une page pour construire un menu avec s'il n'y a pas de slug,
  • un paramètre logged_in indiquant si notre utilisateur est connecté ou non.

La sortie de get_menu sera une liste d'éléments de menu, chacun avec les éléments suivants: un titre, une url, un slug d'un sous-menu possible, une page pour un sous-menu possible, et éventuellement une icône. Avec cela, on va construire notre menu dans le modèle html.

Tout d'abord le cas où un menu avec le slug donné existe. On récupère la langue courante via la fonction get_language de Django. Avec les méthodes et les propriétés de nos modèles, la liste est alors facile à construire.

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

Si aucun menu personnalisé n'est défini, on construit la même liste en utilisant les enfants de la page donnée:

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

La seule vérification qu'on fait est de savoir si la page enfant a l'indicateur show_in_menus défini sur True. Cet indicateur est Wagtail standard et peut être défini dans l'onglet Promotion dans l'éditeur. S'il n'y a aucun enfant à afficher, la liste sera vide. Si la page n'est pas valide, on renvoie None et on n'a pas de menu.

Cela complète notre balise. Créez un modèle main_menu.html et utilisez la balise avec les lignes:

{% load cms_tags wagtailimages_tags %}

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

On fait entrer wagtailimages_tags également, car on utilisera la balise image de Wagtail pour afficher les icônes. On appellera notre menu principal main, c'est le nom qu'on devra également utiliser dans notre éditeur. Le deuxième argument None correspond à l'argument page de notre balise de modèle. Puisqu'on définira un menu principal, on n'en aura pas besoin, alors on peut donc utiliser None. Enfin request.user.is_authenticated nous indique si l'utilisateur est connecté ou non. Pour construire le menu, on utilisera le composant Bootstrap standard navbar. La partie intéressante du modèle est la suivante:

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

Pour chaque élément de notre menu navigation, on essaye d'abord de récupérer un sous-menu. S'il y en a un, on utilise une bascule déroulante, sinon une info-bulle (les deux Bootstrap standard). Le lien est simplement item.url. S'il y a une icône, on l'utilise, sinon on utilise item.title. L'ajout de certaines classes permet un style spécifique si on le veut. L'affichage du sous-menu est fondamentalement le même, sauf qu'on n'assume pas de troisième niveau dans notre menu. Notre fonction get_menu permettrait parfaitement un troisième niveau ou autant de niveaux qu'on veut, mais ici on s'arrête à deux. Ensuite, le code pour afficher le sous-menu est simplement:

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

Le style peut être minimal. Si on a une icône dans notre menu, on peut l'agrandir légèrement lorsque l'utilisateur la survole:

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

Le code complet de main_menu.html et tous les autres fichiers se trouvent dans le référentiel. On a maintenant seulement besoin de mettre la ligne suivante dans notre modèle base.html, au début de notre corps:

{% include "main_menu.html" %}

C'est tout. On peut migrer la base de données pour intégrer nos nouveaux modèles, puis aller dans notre éditeur, dans Blocs (Snippets), créer le menu main, ajouter des icônes et voir à quoi il ressemble. Dans un didacticiel précédent, on a configuré l'authentification avec allauth, alors on peut créer un menu account avec un sous-menu avec des liens vers /accounts/login, /accounts/logout, etc. De cette façon, on combine notre application d'authentification et notre application de gestion de contenu dans un menu de navigation. La vidéo ci-jointe en montre plus.

Il y a encore un défaut: nos éléments de menu auto-construits ne sont pas encore traduits. On va résoudre ce problème dans un autre tutoriel. Lisez la suite si vous souhaitez également ajouter un menu de pied de page avec une déclaration de cookie et une politique de confidentialité à votre site, ainsi qu'un favicon et un logo d'entreprise.

Commentez cet article (connectez-vous d'abord ou confirmez par nom et email ci-dessous)