Rendre la page de recherche de Wagtail dynamique avec htmx
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.
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 }}&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 }}&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>
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êteGET
) et la cible (la pagesearch_page
, c'est-à-dire la vueblog: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 unediv #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 valeurouterHTML
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 classeis-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 }}&page={{ search_results.previous_page_number }}" hx-get="{% url 'blog:blog_search' %}?query={{ search_query|urlencode }}&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 }}&page={{ search_results.next_page_number }}" hx-get="{% url 'blog:blog_search' %}?query={{ search_query|urlencode }}&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)