Rendre la page de recherche de Wagtail dynamique avec htmx

Ash_Crow Logiciels , Vie du site

En faisant la mise à jour de ce site pour utiliser Wagtail 5.0, j'ai fini par en profiter pour faire pas mal de changements. Parmi ceux-ci, j’ai enfin pris le temps de m’occuper de la page de recherche, qui en avait bien besoin.

La page de recherche avant le début des mises à jour

Tant que j'y étais à le mettre à jour, j'ai mis en place htmx sur ce formulaire pour rendre la page dynamique.

Htmx est un micro-framework JavaScript qui permet d’étendre HTML pour rendre des parties de la page dynamiques directement en hypertexte, en demandant au serveur de renvoyer directement le html à afficher.

C'est redoutablement puissant pour très peu de lignes de code, d'autant que cela permet de réutiliser beaucoup du code existant plutôt qu'avoir à le dupliquer.

Ce billet est plus une série de notes pour moi-même qu'un tutorial en bonne et due forme, en espérant que cela ne soit pas trop dur à suivre. Au cas où, le code complet du site est sur https://framagit.org/Ash_Crow/ash-bzh.

Base

La première étape a été de transformer la vue et les templates pour avoir une page plaisante, et séparée entre le blog et la galerie[1].

On se retrouve donc avec la vue suivante :

# blog/views.py
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.shortcuts import render

from wagtail.contrib.search_promotions.models import Query
from wagtail.models import TemplateResponse

from blog.models import BlogPage

# [...]

def blog_search(request):
    search_query = request.GET.get("query", None)
    page = request.GET.get("page", 1)

    # Search
    if search_query:
        search_results = BlogPage.objects.live().search(search_query)
        query = Query.get(search_query)

        # Record hit
        query.add_hit()
    else:
        search_results = BlogPage.objects.none()

    # Pagination
    paginator = Paginator(search_results, 10)
    try:
        search_results = paginator.page(page)
    except PageNotAnInteger:
        search_results = paginator.page(1)
    except EmptyPage:
        search_results = paginator.page(paginator.num_pages)

    return TemplateResponse(request, "blog/search.html", {
        "search_query": search_query,
        "search_results": search_results,
    })

Et côté urls.py :

# blog/urls.py
from django.urls.conf import path
from blog import views


app_name = "blog"

urlpatterns = [
    # [...]
    path("search/", views.blog_search, name="blog_search"),
]

Le template principal est relativement simple.

<!-- blog/templates/blog/search.html -->
{% extends "base.html" %}
{% load i18n static wagtailcore_tags sri %}

{% block body_class %}template-searchresults{% endblock %}

{% block title %}{% translate 'Search in blog' %}{% endblock %}

{% block content %}
    <div class="container">
        <div class="content mt-6">
            <h1>{% translate 'Search in blog' %}</h1>
            {% include "blocks/search_form.html" with search_page='blog:blog_search' %}
        
            {% if search_results %}
                <h2>{% translate "Results" %}</h2>
                <ul>
                    {% for result in search_results %}
                        <li>
                            <h3 class="mt-4"><a href="{% pageurl result %}">{{ result }}</a></h3>
                            {% if result.date %}
                                <p class="block has-text-grey is-size-7">
                                    {{ result.date }}
                                </p>
                            {% endif %}
                            {% if result.search_description %}
                                <p class="block">
                                    {{ result.search_description }}
                                </p>
                            {% endif %}
                        </li>
                    {% endfor %}
                </ul>
        
                {% if search_results.has_previous or search_results.has_next %}
                    <nav class="pagination mt-6" role="navigation" aria-label="pagination">
                        {% if search_results.has_previous %}
                            <a class="pagination-previous has-background-primary" href="{% url 'blog:blog_search' %}?query={{ search_query|urlencode }}&amp;page={{ search_results.previous_page_number }}">
                                <span class="icon">
                                    <i class="ri-arrow-left-line ri-lg" aria-hidden="true"></i>
                                </span>
                                <span>
                                    {% translate 'Previous' %}
                                </span>
                            </a>
                        {% endif %}
                        {% if search_results.has_next %}
                            <a class="pagination-next has-background-primary" href="{% url 'blog:blog_search' %}?query={{ search_query|urlencode }}&amp;page={{ search_results.next_page_number }}">
                                <span class="icon">
                                    <i class="ri-arrow-right-line ri-lg" aria-hidden="true"></i>
                                </span>
                                <span>
                                    {% translate 'Next' %}
                                </span>
                            </a>
                        {% endif %}
                    </nav>
                {% endif%}
            {% elif search_query %}
                {% translate 'No results found' %}
            {% endif %}    
        </div>
    </div>
{% endblock %}

{% block extra_footer %}
  {% include 'blog/blocks/blog_footer.html' %}
{% endblock %}

C'est un peu verbeux, notamment notamment à cause de Bulma, utilisé pour gérer la mise en page, mais il y a grosso-modo 3 parties :

  • La barre de recherche, qui dans un composant à part pour pouvoir la réutiliser dans l'en-tête du site, mais aussi dans la galerie (d’où le paramètre search_page), cf. ci-dessous.
  • Une boucle sur les résultats, s’il y en a (et sinon un simple message « Aucun résultat trouvé »)
  • Les boutons pour aller à la page précédente/suivante, s'il y a plusieurs pages de résultats. Finalement la partie la plus imposante, juste pour deux boutons en bas de page 😅.

Enfin, le template pour la barre de recherche, un simple formulaire avec un champ de saisie et un bouton :

<!-- config/templates/blocks/search_form.html -->
{% load i18n %}
<form action="{% url search_page %}" method="get">
    <div class="field has-addons">
        <div class="control">
            <label>
                <span class="is-sr-only">
                    {% translate 'Search' %}
                </span>
                <input type="text" class="input" name="query"{% if search_query %} value="{{ search_query }}"{% endif %}>
            </label>
        </div>
        <div class="control">
            <input type="submit" value="{% translate 'Search' %}" class="button is-primary">
        </div>
    </div>
</form>

La même page que plus haut. Ça ressemble maintenant à quelque chose.

On voit qu'il y a plus de résultats, car j'ai amélioré l'indexation dans le modèle au passage :

# blog/models.py
from wagtail.models import Page
from wagtail.search import index
from .abstract import BlogPageAbstract

# [...]

class BlogPage(BlogPageAbstract):
    # [...]

    search_fields = Page.search_fields + [  # Inherit search_fields from Page
        index.SearchField("body_stream"),
        index.SearchField("header_image_caption"),
        index.FilterField("date"),
    ]

# [...]

Ajout de htmx

La première étape est l'ajout de la librairie elle-même. Il y a plusieurs solutions, en utilisant un CDN, via npm ou via webpack, mais j'ai tout simplement téléchargé le fichier minifié dans mes statiques et l'ai appelé sur la page concernée.

Comme mon template base.html contient déjà un block extra_js, j'ai juste eu à rajouter trois lignes à la fin de search.html :

<!-- blog/templates/blog/search.html -->
<!-- [...] -->

{% block extra_js %}
   {% sri_static "lib/htmx/htmx.1.9.4.min.js" %}
{% endblock extra_js %}

Mise à jour des templates

J'ai ensuite ajouté les contrôles dans le champ de recherche. Comme je veux pouvoir utiliser le template lui-même en haut de page sans faire appel à htmx, j'ai décidé d'utiliser une variable pour décider quand je veux intégrer les paramètres htmx ou non.

La requête est inspirée de l'exemple « Active search » de la documentation de htmx.

<!-- config/templates/blocks/search_form.html -->
<!-- [Avant] -->
<input type="text" class="input" name="query"{% if search_query %} value="{{ search_query }}"{% endif %}>
<!-- [Après] -->
<input
    type="text"
    class="input"
    name="query"
    {% if search_query %} value="{{ search_query }}"{% endif %}
    {% if htmx_on %}
        hx-get="{% url search_page %}"
        hx-trigger="keyup changed delay:300ms, search"
        hx-target="#search-results"
        hx-swap="outerHTML"
        hx-push-url="true"
        hx-indicator=".htmx-indicator"
    {% endif %}
>
<!-- [...] -->

Les paramètres sont les suivants :

  • hx-get détermine l'action effectuée (une requête GET) et la cible (la page search_page, c'est-à-dire la vue blog:blog_search. Je y vais revenir.
  • hx-trigger détermine quand l'action est effectuée (en l'occurrence sur pression du clavier, avec un délai de 300ms pour limiter le nombre de requêtes, ou immédiatement si la touche « entrée » est pressée.
  • hx-target détermine la partie de la page qui va être mise à jour, en l'occurrence une div #search-results qui va venir encadrer la section des résultats de recherche.
  • hx-swap détermine la façon dont le remplacement se fait. La valeur outerHTML signifie que la balise div en elle-même va être remplacée, au lieu de seulement son contenu comme c'est le cas par défaut.
  • hx-push-url force la mise à jour de l'URL quand une recherche est effectuée.
  • hx-indicator indique la classe ou l'id de l'indicateur de recherche. Idéalement, j'aurais préféré pouvoir le faire à la façon native de Bulma (en ajoutant la classe is-loading au bouton de recherche pendant qu'elle s'exécute), mais cela nécessiterait l'ajout d'une extension, ce qui me semblait excessif. J'ai un peu hésité sur la façon de faire, j'ai fini par ajouter un bouton caché supplémentaire :
<!-- config/templates/blocks/search_form.html -->
<!-- [Avant] -->
<div class="control">
    <button type="submit" class="button is-primary">
        {% translate 'Search' %}
    </button>
</div>

<!-- [Après] -->
<div class="control">
    <button type="submit" class="button is-primary">
        {% translate 'Search' %}
    </button>
    {% if htmx_on %}
        <span class="button is-white htmx-indicator">
            <span class="icon"><img src="{% static '/lib/htmx/bars.svg' %}" alt="" /></span>
            <span>{% translate 'Searching…' %}</span>
        </span>
    {% endif %}
</div>

Ce qui donne le résultat final suivant pour ce formulaire :

<!-- config/templates/blocks/search_form.html -->
{% load i18n static %}
<form action="{% url search_page %}" method="get">
    <div class="field has-addons">
        <div class="control">
            <label>
                <span class="is-sr-only">
                    {% translate 'Search' %}
                </span>
                <input
                    type="text"
                    class="input"
                    name="query"
                    {% if search_query %} value="{{ search_query }}"{% endif %}
                    {% if htmx_on %}
                        hx-get="{% url search_page %}"
                        hx-trigger="keyup changed delay:300ms, search"
                        hx-target="#search-results"
                        hx-swap="outerHTML"
                        hx-push-url="true"
                        hx-indicator=".htmx-indicator"
                    {% endif %}
                >
            </label>
        </div>
        <div class="control">
            <button type="submit" class="button is-primary">
                {% translate 'Search' %}
            </button>
            {% if htmx_on %}
                <span class="button is-white htmx-indicator">
                    <span class="icon"><img src="{% static '/lib/htmx/bars.svg' %}" alt="" /></span>
                    <span>{% translate 'Searching…' %}</span>
                </span>
            {% endif %}
        </div>
    </div>
</form>

Comme dit plus haut, j'ai déplacé la partie des résultats dans un nouveau template, pour pouvoir l'appeler séparément.

J'en ai profité pour ajouter la prise en charge de htmx sur les boutons de pagination, avec grosso-modo les mêmes paramètres :

<!-- blog/templates/blog/blocks/search_results.html -->
{% load i18n static wagtailcore_tags %}
<div id="search-results" class="mt-4">
    {% if search_results %}
        <h2>{% translate "Results" %}</h2>
        <ul>
            {% for result in search_results %}
                <li>
                    <h3 class="mt-4"><a href="{% pageurl result %}">{{ result }}</a></h3>
                    {% if result.date %}
                        <p class="block has-text-grey is-size-7">
                            {{ result.date }}
                        </p>
                    {% endif %}
                    {% if result.search_description %}
                        <p class="block">
                            {{ result.search_description }}
                        </p>
                    {% endif %}
                </li>
            {% endfor %}
        </ul>

        {% if search_results.has_previous or search_results.has_next %}
            <nav class="pagination mt-6" role="navigation" aria-label="pagination">
                {% if search_results.has_previous %}
                    <a
                        class="pagination-previous has-background-primary"
                        href="{% url 'blog:blog_search' %}?query={{ search_query|urlencode }}&amp;page={{ search_results.previous_page_number }}"
                        hx-get="{% url 'blog:blog_search' %}?query={{ search_query|urlencode }}&amp;page={{ search_results.previous_page_number }}"
                        hx-target="#search-results"
                        hx-swap="outerHTML show:#main:top"
                        hx-push-url="true"
                        hx-indicator=".htmx-indicator"
                    >
                        <span class="icon">
                            <i class="ri-arrow-left-line ri-lg" aria-hidden="true"></i>
                        </span>
                        <span>
                            {% translate 'Previous' %}
                        </span>
                    </a>
                {% endif %}
                {% if search_results.has_next %}
                    <a
                        class="pagination-next has-background-primary"
                        href="{% url 'blog:blog_search' %}?query={{ search_query|urlencode }}&amp;page={{ search_results.next_page_number }}"
                        hx-get="{% url 'blog:blog_search' %}?query={{ search_query|urlencode }}&amp;page={{ search_results.next_page_number }}"
                        hx-target="#search-results"
                        hx-swap="outerHTML show:#main:top"
                        hx-push-url="true"
                        hx-indicator=".htmx-indicator"
                    >
                        <span class="icon">
                            <i class="ri-arrow-right-line ri-lg" aria-hidden="true"></i>
                        </span>
                        <span>
                            {% translate 'Next' %}
                        </span>
                    </a>
                {% endif %}
            </nav>
        {% endif%}
    {% elif search_query %}
        {% translate 'No results found' %}
    {% endif %}
</div>

On voit que le {% if search_results %} est maintenant dans une div #search-results, comme dit plus haut.

On peut aussi voir que la variable hx-swap a une valeur un peu différente : "outerHTML show:#main:top". Le show:top fait remonter l'affichage au début de la section #main : sinon, on resterait en bas de page alors que tout le contenu change.

Une fois tous ces transferts faits, le template de recherche ressemble à ça :

<!-- blog/templates/blog/search.html -->
{% extends "base.html" %}
{% load i18n static wagtailcore_tags sri %}

{% block body_class %}template-searchresults{% endblock %}

{% block title %}{% translate 'Search in blog' %}{% endblock %}

{% block content %}
    <div class="container">
        <div class="content mt-6">
            <h1>{% translate 'Search in blog' %}</h1>
            {% include "blocks/search_form.html" with search_page='blog:blog_search' htmx_on=1 %}
        
            {% include "blog/blocks/search_results.html" with search_results=search_results search_query=search_query %}
        </div>
    </div>
{% endblock %}

{% block extra_footer %}
  {% include 'blog/blocks/blog_footer.html' %}
{% endblock %}

{% block extra_js %}
  {% sri_static "lib/htmx/htmx.1.9.4.min.js" %}
{% endblock extra_js %}

Mise à jour de la logique

Il ne reste plus qu'à ajuster la logique côté serveur pour renvoyer le HTML modifié.

Comme dit plus haut, je le fais directement au niveau de la vue blog:blog_search elle-même. Elle va donc devoir renvoyer soit la page complète comme actuellement, soit uniquement le fragment concerné si on fait la requête en htmx. Cela se fait très simplement en remplaçant le return à la fin de la vue :

# blog/views.py

# [Avant]
    return TemplateResponse(request, "blog/search.html", {
        "search_query": search_query,
        "search_results": search_results,
    })
# [Après]
    payload = {
        "search_query": search_query,
        "search_results": search_results,
    }

    if request.META.get("HTTP_HX_REQUEST") == "true":
        return render(request, "blog/blocks/search_results.html", payload)
    else:
        return TemplateResponse(request, "blog/search.html", payload)

En mettant le contexte renvoyé dans une variable payload au passage pour éviter la répétition.

Et c'est tout.

Outre les changements apportés aux templates, rentre la page dynamique nécessite donc littéralement une condition if en plus sur la vue. C'est redoutablement efficace.

Image d’en-tête:

Portrait du pape Léon X par Raphaël (domaine public)

Mots-clefs :

0 commentaire publié.

Notes de bas de page

  1. la recherche telle qu’elle était au début ne faisait pas de distinction entre les types de pages.

Commentaires

Les commentaires sont fermés.