Ce cours inclut : HTML, CSS, Thymeleaf, et un bonus JavaScript/HTMX
Quand on construit une application web avec Spring Boot, il faut produire des pages HTML à envoyer au navigateur. Il existe deux grandes approches :
Thymeleaf est le moteur de template officiel de Spring Boot pour faire du SSR. Il permet de créer des pages HTML dynamiques côté serveur, en injectant des données Java directement dans le HTML.
Analogie : Imaginez Thymeleaf comme un document Word avec des champs de fusion. Vous préparez le template avec des espaces réservés (Cher ), et au moment de l’impression, le système remplace chaque espace par les vraies valeurs. Thymeleaf fait exactement ça avec des pages HTML.
Cher
Navigateur Spring Boot Base de données │ │ │ │── GET /livres ──────────→ │ │ │ │── findAll() ─────────→ │ │ │←── List<Livre> ──────── │ │ │ │ │ [Thymeleaf prend le template │ │ livres.html et injecte │ │ la List<Livre> dedans] │ │ │ │ │←── Page HTML complète ─── │ │ │ avec les vrais livres │
Le navigateur reçoit du HTML pur — il n’a pas besoin de JavaScript pour afficher la page.
.html
th:
<% %>
Avant d’utiliser Thymeleaf, il faut comprendre HTML. Cette section couvre l’essentiel.
HTML (HyperText Markup Language) est le langage qui structure le contenu d’une page web. Un fichier HTML est un simple fichier texte que le navigateur interprète pour afficher une page.
**Analogie ** : HTML est le squelette de la page (la structure). CSS est la décoration (les couleurs, les tailles). JavaScript est le comportement (les interactions).
<!DOCTYPE html> <html lang="fr"> <head> <!-- La section head est invisible pour l'utilisateur --> <!-- Elle contient les métadonnées de la page --> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Ma première page</title> <link rel="stylesheet" href="style.css"> <!-- Liaison CSS --> </head> <body> <!-- La section body est ce que l'utilisateur voit --> <h1>Bonjour le monde !</h1> <p>Ceci est un paragraphe.</p> </body> </html>
Explication ligne par ligne : <!DOCTYPE html> : dit au navigateur que c’est du HTML5 (la version moderne) <html lang="fr"> : la balise racine, lang="fr" indique la langue pour l’accessibilité <head> : contient les informations sur la page (invisible à l’écran) <meta charset="UTF-8"> : encode les caractères spéciaux (é, à, ü…) <title> : le texte affiché dans l’onglet du navigateur <body> : tout ce que l’utilisateur voit
Explication ligne par ligne :
<!DOCTYPE html>
<html lang="fr">
lang="fr"
<head>
<meta charset="UTF-8">
<title>
<body>
Une balise HTML est composée d’une ouverture <balise> et d’une fermeture </balise>. Certaines balises sont auto-fermantes <img />.
<balise>
</balise>
<img />
Titres et textes
<h1>Titre principal</h1> <!-- Le plus grand (un seul par page) --> <h2>Titre de section</h2> <!-- Sous-titre --> <h3>Titre de sous-section</h3> <p>Un paragraphe de texte.</p> <strong>Texte en gras</strong> <em>Texte en italique</em> <br> <!-- Saut de ligne (auto-fermante) --> <hr> <!-- Ligne horizontale (auto-fermante) -->
Listes
<!-- Liste non ordonnée (à puces) --> <ul> <li>Premier élément</li> <li>Deuxième élément</li> <li>Troisième élément</li> </ul> <!-- Liste ordonnée (numérotée) --> <ol> <li>Premier</li> <li>Deuxième</li> </ol>
Liens et images
<!-- Lien hypertexte --> <a href="https://www.google.fr">Aller sur Google</a> <a href="/livres">Voir les livres</a> <!-- Lien interne --> <a href="/livres/1">Livre numéro 1</a> <!-- Image --> <img src="/images/livre.jpg" alt="Photo du livre Clean Code"> <!-- alt = texte alternatif si l'image ne charge pas (accessibilité) -->
Tableaux
<table> <thead> <!-- En-tête du tableau --> <tr> <!-- tr = table row (ligne) --> <th>Titre</th> <!-- th = table header (en-tête de colonne) --> <th>Auteur</th> <th>Prix</th> </tr> </thead> <tbody> <!-- Corps du tableau --> <tr> <td>Clean Code</td> <!-- td = table data (cellule) --> <td>Robert Martin</td> <td>35,90 €</td> </tr> </tbody> </table>
Formulaires
<form action="/livres/ajouter" method="post"> <!-- action = où envoyer les données --> <!-- method = GET (récupérer) ou POST (envoyer des données) --> <label for="titre">Titre :</label> <input type="text" id="titre" name="titre" placeholder="Ex: Clean Code"> <label for="prix">Prix :</label> <input type="number" id="prix" name="prix" min="0" step="0.01"> <label for="description">Description :</label> <textarea id="description" name="description" rows="4"></textarea> <select id="categorie" name="categorie"> <option value="">-- Choisir --</option> <option value="TECH">Technologie</option> <option value="FICTION">Fiction</option> </select> <button type="submit">Enregistrer</button> <button type="reset">Réinitialiser</button> </form>
Règle importante : L’attribut name de chaque champ est ce qui est envoyé au serveur. Le id sert à lier le label au champ (accessibilité).
name
id
label
Conteneurs génériques
<div> <!-- Bloc (prend toute la largeur) --> <p>Je suis dans un div</p> </div> <span>Texte inline</span> <!-- En ligne (ne crée pas de saut de ligne) --> <!-- Balises sémantiques HTML5 (plus expressives que les div) --> <header>En-tête de la page</header> <nav>Menu de navigation</nav> <main>Contenu principal</main> <section>Une section de contenu</section> <article>Un article indépendant</article> <aside>Contenu secondaire (barre latérale)</aside> <footer>Pied de page</footer>
Les attributs donnent des informations supplémentaires aux balises :
<balise attribut="valeur">Contenu</balise> <!-- Exemples --> <a href="/livres" class="btn btn-primary" id="lien-livres">Voir les livres</a> <!-- ↑ destination ↑ classes CSS ↑ identifiant unique --> <input type="text" name="titre" required disabled placeholder="Saisir un titre"> <!-- ↑ obligatoire ↑ désactivé ↑ texte d'aide -->
Les attributs les plus importants :
class
href
<a>
src
<img>
type
<input>
Le CSS (Cascading Style Sheets) permet de styliser les éléments HTML : couleurs, tailles, espacements, alignements…
<!-- 1. CSS inline (à éviter sauf cas exceptionnel) --> <p style="color: red; font-size: 18px;">Texte rouge</p> <!-- 2. CSS interne (dans le head) --> <style> p { color: blue; } </style> <!-- 3. CSS externe (recommandé) — dans un fichier séparé style.css --> <link rel="stylesheet" href="/css/style.css">
/* Sélecteur de balise — s'applique à tous les <p> */ p { color: #333333; font-size: 16px; } /* Sélecteur de classe — s'applique à tous les éléments class="carte" */ .carte { border: 1px solid #ddd; border-radius: 8px; padding: 16px; } /* Sélecteur d'id — s'applique à l'élément id="menu-principal" */ #menu-principal { background-color: #2c3e50; } /* Sélecteur combiné — les <a> à l'intérieur de .nav */ .nav a { color: white; text-decoration: none; } /* Pseudo-classe — quand la souris est dessus */ .btn:hover { background-color: #2980b9; }
.exemple { /* Texte */ color: #333; /* Couleur du texte */ font-size: 16px; /* Taille de la police */ font-weight: bold; /* Gras */ font-family: Arial, sans-serif; text-align: center; /* Alignement : left, center, right */ text-decoration: none; /* Enlever le soulignement des liens */ /* Fond */ background-color: #f5f5f5; background-image: url('/images/fond.jpg'); /* Dimensions */ width: 300px; height: 200px; max-width: 100%; /* Jamais plus large que son parent */ /* Espacement */ margin: 16px; /* Espace AUTOUR de l'élément */ margin-top: 8px; margin: 8px 16px; /* Raccourci : haut/bas gauche/droite */ padding: 16px; /* Espace INTÉRIEUR de l'élément */ /* Bordure */ border: 1px solid #ccc; border-radius: 8px; /* Coins arrondis */ /* Affichage */ display: block; /* Prend toute la largeur */ display: inline; /* En ligne */ display: flex; /* Flexbox (alignement avancé) */ display: none; /* Cache l'élément */ }
Chaque élément HTML est une boîte rectangulaire avec 4 zones :
┌─────────────────────────────────┐ │ margin (espace ext.) │ │ ┌───────────────────────────┐ │ │ │ border │ │ │ │ ┌─────────────────────┐ │ │ │ │ │ padding │ │ │ │ │ │ ┌───────────────┐ │ │ │ │ │ │ │ CONTENU │ │ │ │ │ │ │ └───────────────┘ │ │ │ │ │ └─────────────────────┘ │ │ │ └───────────────────────────┘ │ └─────────────────────────────────┘
.boite { width: 200px; /* Largeur du contenu */ padding: 20px; /* Espace intérieur (fait grossir la boîte) */ border: 2px solid; /* Bordure */ margin: 10px; /* Espace extérieur */ /* Largeur totale visible = 200 + 20*2 + 2*2 = 244px */ /* Astuce : box-sizing évite les calculs */ box-sizing: border-box; /* width inclut le padding et la bordure */ }
Flexbox est le système d’alignement moderne en CSS :
.container { display: flex; justify-content: space-between; /* Répartit les éléments horizontalement */ align-items: center; /* Centre verticalement */ gap: 16px; /* Espace entre les éléments */ flex-wrap: wrap; /* Passe à la ligne si nécessaire */ } /* Les cartes dans le container */ .carte { flex: 1; /* Chaque carte prend la même largeur disponible */ min-width: 250px; /* Largeur minimale avant de passer à la ligne */ }
Pour ne pas repartir de zéro, on utilise souvent Bootstrap, un framework CSS avec des classes prêtes à l’emploi.
<!-- Intégration Bootstrap via CDN (dans le <head>) --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> <!-- Utilisation des classes Bootstrap --> <div class="container"> <!-- Centre le contenu avec des marges --> <div class="row"> <!-- Une ligne de la grille --> <div class="col-md-4"> <!-- Colonne de 4/12 sur écran moyen --> <div class="card"> <!-- Carte Bootstrap --> <div class="card-body"> <h5 class="card-title">Clean Code</h5> <p class="card-text">Robert Martin</p> <a href="/livres/1" class="btn btn-primary">Voir</a> </div> </div> </div> </div> </div>
Classes Bootstrap essentielles à connaître :
container
container-fluid
row
col-*
btn btn-primary
btn-danger
btn-success
card
alert alert-danger
table table-striped
form-control
mb-3
mt-2
p-4
Créez un projet Spring Boot avec ces dépendances dans pom.xml :
pom.xml
<dependencies> <!-- Spring MVC — gère les routes HTTP --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- Thymeleaf — moteur de templates --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- Spring Data JPA + H2 (base de données en mémoire pour le TP) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <!-- Validation des formulaires --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- Lombok — évite les getters/setters verbeux --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <!-- DevTools — rechargement automatique en développement --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> </dependencies>
# Base de données H2 spring.datasource.url=jdbc:h2:mem:formation spring.datasource.driver-class-name=org.h2.Driver spring.jpa.hibernate.ddl-auto=create-drop spring.h2.console.enabled=true # Thymeleaf — désactiver le cache en développement spring.thymeleaf.cache=false spring.thymeleaf.prefix=classpath:/templates/ spring.thymeleaf.suffix=.html spring.thymeleaf.encoding=UTF-8
spring: datasource: url: jdbc:h2:mem:formation driver-class-name: org.h2.Driver jpa: hibernate: ddl-auto: create-drop h2: console: enabled: true thymeleaf: cache: false prefix: classpath:/templates/ suffix: .html encoding: UTF-8 ```yaml ### 4.3 Structure des fichiers ```bash └── main/ ├── java/com/formation/ │ ├── FormationApplication.java │ ├── controller/ │ │ └── LivreController.java │ ├── service/ │ │ └── LivreService.java │ ├── repository/ │ │ └── LivreRepository.java │ └── domain/ │ └── Livre.java └── resources/ ├── templates/ ← Vos fichiers HTML Thymeleaf │ ├── fragments/ │ │ ├── header.html │ │ └── footer.html │ ├── livres/ │ │ ├── liste.html │ │ ├── detail.html │ │ └── formulaire.html │ └── index.html ├── static/ ← Fichiers servis tels quels │ ├── css/ │ │ └── style.css │ ├── js/ │ │ └── app.js │ └── images/ └── application.properties
Point clé : Les templates Thymeleaf sont dans src/main/resources/templates/. Les fichiers CSS, JS et images sont dans src/main/resources/static/. Spring Boot sert automatiquement les fichiers du dossier static/.
src/main/resources/templates/
src/main/resources/static/
static/
@Controller // Pas @RestController ! @Controller retourne des noms de vues public class LivreController { @GetMapping("/") public String accueil(Model model) { // Model = le "sac" dans lequel on met les données pour le template model.addAttribute("message", "Bienvenue sur la librairie !"); model.addAttribute("nombreLivres", 42); return "index"; // → src/main/resources/templates/index.html } }
Différence cruciale : @RestController retourne du JSON → pour les APIs REST @Controller retourne un nom de vue (template Thymeleaf) → pour les pages web
Différence cruciale :
@RestController
@Controller
<!-- src/main/resources/templates/index.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <!-- ↑ Cette déclaration est OBLIGATOIRE pour activer Thymeleaf --> <head> <meta charset="UTF-8"> <title>Librairie</title> <link rel="stylesheet" href="/css/style.css"> </head> <body> <h1 th:text="${message}">Message par défaut</h1> <!-- ↑ th:text remplace le contenu de la balise par la valeur de message --> <!-- "Message par défaut" est le contenu vu si on ouvre le fichier directement --> <p>La librairie contient <span th:text="${nombreLivres}">0</span> livres.</p> </body> </html>
TP — Étape 1 : Créez ce projet, lancez-le et vérifiez que la page s’affiche sur http://localhost:8080.
http://localhost:8080
Les expressions sont le cœur de Thymeleaf. Elles permettent d’accéder aux données passées par le Controller.
${...}
C’est l’expression la plus utilisée. Elle accède aux attributs du Model.
Model
// Controller model.addAttribute("livre", livre); // Un objet model.addAttribute("livres", listeLivres); // Une liste model.addAttribute("utilisateur", "Alice"); // Une String model.addAttribute("prix", 35.90); // Un nombre
<!-- Template --> <!-- Accès à une String directe --> <p th:text="${utilisateur}">Nom</p> <!-- Accès à une propriété d'un objet --> <h1 th:text="${livre.titre}">Titre</h1> <p th:text="${livre.auteur}">Auteur</p> <!-- Appel de méthode --> <p th:text="${livre.getDescription()}">Description</p> <!-- Accès à un élément de liste par index --> <p th:text="${livres[0].titre}">Premier livre</p> <!-- Formatage d'un nombre --> <p th:text="${#numbers.formatDecimal(prix, 1, 2)}">0.00</p> <!-- Affiche : 35.90 -->
@{...}
Pour construire des URLs dynamiques (liens et attributs action des formulaires).
action
<!-- URL simple --> <a th:href="@{/livres}">Tous les livres</a> <!-- Rendu : <a href="/livres"> --> <!-- URL avec paramètre de chemin (path variable) --> <a th:href="@{/livres/{id}(id=${livre.id})}">Voir le livre</a> <!-- Si livre.id = 5 → <a href="/livres/5"> --> <!-- URL avec paramètre de requête (query parameter) --> <a th:href="@{/livres(page=2, taille=10)}">Page 2</a> <!-- Rendu : <a href="/livres?page=2&taille=10"> --> <!-- Combinaison path variable + query parameter --> <a th:href="@{/livres/{id}/commentaires(id=${livre.id}, page=1)}"> Commentaires </a> <!-- Rendu : <a href="/livres/5/commentaires?page=1"> --> <!-- Dans un formulaire --> <form th:action="@{/livres/sauvegarder}" method="post">
Règle d’or : Utilisez TOUJOURS @{...} pour les URLs dans Thymeleaf. N’écrivez jamais de lien en dur comme href="/livres" — @{/livres} gère automatiquement les contextes d’application.
href="/livres"
@{/livres}
#{...}
Pour l’internationalisation (i18n) — nous y reviendrons au chapitre 9.
<h1 th:text="#{page.accueil.titre}">Titre de la page</h1> <!-- Cherche la clé "page.accueil.titre" dans messages.properties -->
*{...}
Utilisée à l’intérieur d’un bloc th:object pour éviter de répéter le nom de l’objet.
th:object
<!-- Sans *{} — répétition de "livre" --> <div> <p th:text="${livre.titre}">Titre</p> <p th:text="${livre.auteur}">Auteur</p> <p th:text="${livre.prix}">Prix</p> </div> <!-- Avec th:object + *{} — plus lisible --> <div th:object="${livre}"> <p th:text="*{titre}">Titre</p> <p th:text="*{auteur}">Auteur</p> <p th:text="*{prix}">Prix</p> </div>
Thymeleaf fournit des objets utilitaires accessibles dans les expressions :
<!-- #strings — manipulation de chaînes --> <p th:text="${#strings.toUpperCase(livre.titre)}">TITRE</p> <p th:if="${#strings.isEmpty(livre.description)}">Pas de description</p> <p th:text="${#strings.abbreviate(livre.description, 100)}">...</p> <!-- #numbers — formatage de nombres --> <p th:text="${#numbers.formatDecimal(livre.prix, 1, 'COMMA', 2, 'POINT')}"> 35,90 </p> <!-- #dates — formatage de dates (avec java.util.Date) --> <p th:text="${#dates.format(livre.datePublication, 'dd/MM/yyyy')}"> 01/01/2024 </p> <!-- #temporals — formatage de dates (avec java.time.LocalDate) --> <p th:text="${#temporals.format(livre.datePublication, 'dd/MM/yyyy')}"> 01/01/2024 </p> <!-- #lists — opérations sur les listes --> <p th:text="${#lists.size(livres)}">0</p> <p th:if="${#lists.isEmpty(livres)}">Aucun livre</p> <!-- Ternaire — comme Java mais dans le template --> <p th:text="${livre.disponible ? 'En stock' : 'Rupture'}">Disponible</p> <!-- Valeur par défaut avec ?: (Elvis operator) --> <p th:text="${livre.description ?: 'Pas de description'}">Description</p>
th:text
th:utext
th:text injecte du texte en échappant le HTML (sécurité contre les injections XSS). th:utext injecte du texte sans échapper (à utiliser avec des données de confiance uniquement).
<!-- livre.description = "<b>Excellent</b> livre !" --> <!-- th:text : le HTML est échappé --> <p th:text="${livre.description}">Description</p> <!-- Rendu : <p><b>Excellent</b> livre !</p> --> <!-- Affiché : <b>Excellent</b> livre ! (le texte <b>, pas du gras) --> <!-- th:utext : le HTML est interprété --> <p th:utext="${livre.description}">Description</p> <!-- Rendu : <p><b>Excellent</b> livre !</p> --> <!-- Affiché : Excellent livre ! (en gras) -->
N’utilisez th:utext qu’avec des données que vous contrôlez totalement.
th:if
th:unless
Pour l’affichage conditionnel.
<!-- th:if : affiche si la condition est vraie --> <div th:if="${livre.disponible}"> <span class="badge bg-success">En stock</span> </div> <!-- th:unless : affiche si la condition est FAUSSE (inverse de th:if) --> <div th:unless="${livre.disponible}"> <span class="badge bg-danger">Rupture de stock</span> </div> <!-- Conditions complexes --> <p th:if="${livre.prix > 20 and livre.disponible}"> Livre premium disponible </p> <p th:if="${livre.auteur == 'Robert Martin' or livre.auteur == 'Martin Fowler'}"> Auteur reconnu </p> <!-- Vérification null --> <p th:if="${livre.description != null and not #strings.isEmpty(livre.description)}" th:text="${livre.description}"> Description </p>
Important : Si la condition est fausse, l’élément n’est pas du tout rendu dans le HTML final (il n’existe même pas dans le DOM).
th:each
C’est l’équivalent du for Java dans les templates.
for
<!-- Syntaxe de base --> <tr th:each="livre : ${livres}"> <td th:text="${livre.titre}">Titre</td> <td th:text="${livre.auteur}">Auteur</td> <td th:text="${livre.prix}">Prix</td> </tr> <!-- Avec la variable de statut (optionnel) --> <tr th:each="livre, stat : ${livres}" th:class="${stat.odd ? 'table-light' : ''}"> <!-- stat.index = index à partir de 0 --> <!-- stat.count = compteur à partir de 1 --> <!-- stat.size = taille totale de la liste --> <!-- stat.first = true si premier élément --> <!-- stat.last = true si dernier élément --> <!-- stat.odd/even = true si position impaire/paire --> <td th:text="${stat.count}">1</td> <td th:text="${livre.titre}">Titre</td> </tr> <!-- Tableau complet avec th:each --> <table class="table table-striped"> <thead> <tr> <th>#</th> <th>Titre</th> <th>Auteur</th> <th>Prix</th> <th>Actions</th> </tr> </thead> <tbody> <tr th:each="livre, stat : ${livres}"> <td th:text="${stat.count}">1</td> <td th:text="${livre.titre}">Titre</td> <td th:text="${livre.auteur}">Auteur</td> <td th:text="${#numbers.formatDecimal(livre.prix, 1, 2) + ' €'}">0 €</td> <td> <a th:href="@{/livres/{id}(id=${livre.id})}" class="btn btn-sm btn-info">Voir</a> <a th:href="@{/livres/{id}/modifier(id=${livre.id})}" class="btn btn-sm btn-warning">Modifier</a> </td> </tr> <!-- Ligne affichée si la liste est vide --> <tr th:if="${#lists.isEmpty(livres)}"> <td colspan="5" class="text-center">Aucun livre trouvé</td> </tr> </tbody> </table>
th:class
th:classappend
Pour modifier dynamiquement les classes CSS.
<!-- th:class remplace TOUTES les classes --> <div th:class="${livre.disponible ? 'card border-success' : 'card border-danger'}"> </div> <!-- th:classappend AJOUTE une classe aux classes existantes --> <div class="card" th:classappend="${livre.disponible ? 'border-success' : 'border-danger'}"> </div> <!-- Rendu si disponible : <div class="card border-success"> --> <!-- Exemple avec alternance de couleurs --> <tr class="table-row" th:classappend="${stat.odd ? 'table-light' : 'table-white'}" th:each="livre, stat : ${livres}"> </tr>
th:attr
Pour modifier n’importe quel attribut HTML.
<!-- th:attr — modifier un attribut quelconque --> <img th:attr="src=@{/images/{img}(img=${livre.imageCouverture})}, alt=${livre.titre}"> <!-- Raccourcis directs (plus lisibles) --> <img th:src="@{/images/{img}(img=${livre.imageCouverture})}" th:alt="${livre.titre}"> <!-- th:value — pour les champs de formulaire --> <input type="text" th:value="${livre.titre}"> <!-- th:placeholder --> <input type="text" th:placeholder="#{form.titre.placeholder}"> <!-- th:disabled --> <button th:disabled="${not livre.disponible}">Commander</button> <!-- th:checked — pour les cases à cocher --> <input type="checkbox" th:checked="${livre.disponible}"> <!-- th:selected — pour les listes déroulantes --> <option th:each="cat : ${categories}" th:value="${cat}" th:text="${cat.libelle}" th:selected="${cat == livre.categorie}"> Catégorie </option>
th:switch
th:case
Équivalent du switch Java.
switch
<div th:switch="${livre.categorie}"> <p th:case="'FICTION'"> Roman et fiction</p> <p th:case="'SCIENCE'"> Sciences</p> <p th:case="'TECHNOLOGIE'"> Technologie</p> <p th:case="*"> Autre catégorie</p> <!-- th:case="*" = le cas par défaut --> </div>
th:inline
Pour utiliser des expressions Thymeleaf dans des balises <script> JavaScript.
<script>
<script th:inline="javascript"> // Passer des données Java vers JavaScript const livreId = [[${livre.id}]]; const livreTitre = [[${livre.titre}]]; const livresJson = [[${livresJson}]]; // Un objet JSON sérialisé console.log('Livre chargé:', livreTitre); </script>
TP — Étape 2 : Créez la liste des livres avec th:each, affichez les données dans un tableau Bootstrap. Ajoutez des liens “Voir” qui pointent vers /livres/{id}.
/livres/{id}
Sans fragments, chaque page HTML répète le même header, footer et navigation. Si vous avez 10 pages et changez le logo, vous devez modifier 10 fichiers !
Un fragment est une portion de HTML réutilisable, définie avec th:fragment.
th:fragment
<!-- src/main/resources/templates/fragments/header.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <body> <!-- Définition du fragment "navbar" --> <nav th:fragment="navbar" class="navbar navbar-expand-lg navbar-dark bg-dark"> <div class="container"> <a class="navbar-brand" th:href="@{/}"> Ma Librairie</a> <div class="navbar-nav ms-auto"> <a class="nav-link" th:href="@{/livres}">Livres</a> <a class="nav-link" th:href="@{/livres/nouveau}">Ajouter</a> </div> </div> </nav> <!-- Définition du fragment "head" --> <head th:fragment="head(titreComplet)"> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title th:text="${titreComplet + ' | Ma Librairie'}">Ma Librairie</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> <link rel="stylesheet" th:href="@{/css/style.css}"> </head> </body> </html>
<!-- src/main/resources/templates/fragments/footer.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <body> <footer th:fragment="footer" class="bg-dark text-white text-center py-3 mt-5"> <p class="mb-0">© 2024 Ma Librairie — Formation Thymeleaf</p> </footer> </body> </html>
Il existe trois façons d’inclure un fragment :
<!-- 1. th:insert — insère le fragment DANS la balise hôte --> <div th:insert="~{fragments/header :: navbar}"></div> <!-- Résultat : <div><nav class="navbar...">...</nav></div> --> <!-- 2. th:replace — REMPLACE la balise hôte par le fragment --> <nav th:replace="~{fragments/header :: navbar}"></nav> <!-- Résultat : <nav class="navbar...">...</nav> --> <!-- (La balise <nav> hôte disparaît, remplacée par le fragment) --> <!-- 3. th:include — insère le CONTENU du fragment dans la balise hôte --> <div th:include="~{fragments/header :: navbar}"></div> <!-- Résultat : <div>contenu interne du nav...</div> -->
Quelle méthode choisir ? th:replace est la plus utilisée car elle ne crée pas de balise supplémentaire.
th:replace
Syntaxe complète de la référence de fragment :
~{chemin/du/fichier :: nomDuFragment} ~{fragments/header :: navbar} ↑ chemin depuis templates/ ↑ nom du th:fragment
<!-- Définition du fragment avec paramètres --> <div th:fragment="alerte(type, message)" class="alert" th:classappend="'alert-' + ${type}"> <strong th:if="${type == 'danger'}">Erreur !</strong> <strong th:if="${type == 'success'}">Succès !</strong> <span th:text="${message}">Message</span> </div> <!-- Utilisation avec paramètres --> <div th:replace="~{fragments/alertes :: alerte('success', 'Livre ajouté avec succès !')}"></div> <div th:replace="~{fragments/alertes :: alerte('danger', 'Erreur lors de la sauvegarde')}"></div>
Le pattern le plus puissant : définir un layout général et le “remplir” dans chaque page.
<!-- src/main/resources/templates/fragments/layout.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" lang="fr"> <head> <meta charset="UTF-8"> <title>Ma Librairie</title> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"> <link rel="stylesheet" th:href="@{/css/style.css}"> <!-- Zone personnalisable pour les CSS supplémentaires --> <th:block th:replace="${styles ?: ~{}}"></th:block> </head> <body> <!-- Navigation commune --> <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> <div class="container"> <a class="navbar-brand" th:href="@{/}"> Ma Librairie</a> </div> </nav> <!-- Message flash (succès/erreur) --> <div class="container mt-3"> <div th:if="${successMessage}" class="alert alert-success" th:text="${successMessage}"></div> <div th:if="${errorMessage}" class="alert alert-danger" th:text="${errorMessage}"></div> </div> <!-- Zone de contenu — chaque page remplace ce bloc --> <main class="container mt-4"> <th:block th:replace="${content}"> Contenu de la page ici </th:block> </main> <!-- Footer commun --> <footer class="bg-dark text-white text-center py-3 mt-5"> <p class="mb-0">© 2024 Ma Librairie</p> </footer> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> </body> </html>
Utilisation dans une page avec th:replace et fragments nommés :
<!-- src/main/resources/templates/livres/liste.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <head> <title>Liste des livres</title> </head> <body> <!-- Fragment qui sera injecté dans le layout --> <th:block th:fragment="content"> <h1 class="mb-4"> Catalogue des livres</h1> <a th:href="@{/livres/nouveau}" class="btn btn-success mb-3"> ➕ Nouveau livre </a> <table class="table table-striped table-hover"> <thead class="table-dark"> <tr> <th>Titre</th> <th>Auteur</th> <th>Prix</th> <th>Actions</th> </tr> </thead> <tbody> <tr th:each="livre : ${livres}"> <td th:text="${livre.titre}">Titre</td> <td th:text="${livre.auteur}">Auteur</td> <td th:text="${#numbers.formatDecimal(livre.prix, 1, 2) + ' €'}">0 €</td> <td> <a th:href="@{/livres/{id}(id=${livre.id})}" class="btn btn-sm btn-info">Voir</a> </td> </tr> <tr th:if="${#lists.isEmpty(livres)}"> <td colspan="4" class="text-center text-muted py-4"> Aucun livre dans le catalogue </td> </tr> </tbody> </table> </th:block> </body> </html>
Puis dans le controller on utilise la résolution de fragment :
// Controller @GetMapping("/livres") public String liste(Model model) { model.addAttribute("livres", livreService.findAll()); // On retourne le chemin vers le template contenant le fragment return "livres/liste :: content"; // Ou simplement : return "livres/liste"; // Thymeleaf charge le fichier entier }
Alternative populaire : Thymeleaf Layout Dialect. Pour les projets complexes, ajoutez la dépendance nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect qui propose un système de layout encore plus puissant.
nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect
TP — Étape 3 : Créez un layout.html avec navbar et footer. Transformez vos pages liste et détail pour utiliser des fragments.
layout.html
Thymeleaf peut lier automatiquement les champs d’un formulaire aux propriétés d’un objet Java. C’est ce qu’on appelle le Command Object ou Form Backing Object.
Côté Java :
// L'objet qui représente le formulaire @Getter @Setter public class LivreForm { private Long id; @NotBlank(message = "Le titre est obligatoire") @Size(max = 255) private String titre; @NotBlank(message = "L'auteur est obligatoire") private String auteur; @NotNull(message = "Le prix est obligatoire") @DecimalMin("0.01") private Double prix; private String description; private Categorie categorie; }
@Controller @RequestMapping("/livres") public class LivreController { // GET — Afficher le formulaire de création @GetMapping("/nouveau") public String formulaireCreation(Model model) { // IMPORTANT : on passe un objet vide pour lier le formulaire model.addAttribute("livreForm", new LivreForm()); model.addAttribute("categories", Categorie.values()); model.addAttribute("titreFormulaire", "Nouveau livre"); return "livres/formulaire"; } // POST — Traiter la soumission du formulaire @PostMapping("/sauvegarder") public String sauvegarder(@Valid @ModelAttribute("livreForm") LivreForm form, BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) { // @Valid déclenche la validation Bean Validation // BindingResult contient les erreurs de validation if (bindingResult.hasErrors()) { // S'il y a des erreurs, on réaffiche le formulaire model.addAttribute("categories", Categorie.values()); return "livres/formulaire"; // Pas de redirect ! } livreService.sauvegarder(form); // RedirectAttributes — passer un message après redirect redirectAttributes.addFlashAttribute("successMessage", "Le livre a été ajouté avec succès !"); return "redirect:/livres"; // Redirection après succès } // GET — Formulaire de modification @GetMapping("/{id}/modifier") public String formulaireModification(@PathVariable Long id, Model model) { Livre livre = livreService.findById(id); LivreForm form = livreService.toLivreForm(livre); model.addAttribute("livreForm", form); model.addAttribute("categories", Categorie.values()); model.addAttribute("titreFormulaire", "Modifier le livre"); return "livres/formulaire"; } }
<!-- src/main/resources/templates/livres/formulaire.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <head><title>Formulaire livre</title></head> <body> <div class="container mt-4"> <h1 th:text="${titreFormulaire}">Formulaire</h1> <!-- th:action → URL de soumission (utilise @{} !) th:object → lie le formulaire à l'objet "livreForm" du Model method → POST pour l'envoi de données --> <form th:action="@{/livres/sauvegarder}" th:object="${livreForm}" method="post"> <!-- Champ caché pour l'id (en mode modification) --> <input type="hidden" th:field="*{id}"> <!-- Champ Titre --> <div class="mb-3"> <label for="titre" class="form-label">Titre *</label> <input type="text" class="form-control" id="titre" th:field="*{titre}" th:errorclass="is-invalid" placeholder="Ex: Clean Code"> <!-- th:field="*{titre}" fait THREE choses automatiquement : 1. name="titre" → le nom envoyé au serveur 2. id="titre" → l'id HTML 3. value="${livreForm.titre}" → pré-remplit avec la valeur actuelle th:errorclass → ajoute la classe CSS si ce champ a une erreur --> <!-- Message d'erreur pour ce champ --> <div class="invalid-feedback" th:if="${#fields.hasErrors('titre')}" th:errors="*{titre}"> Erreur sur le titre </div> </div> <!-- Champ Auteur --> <div class="mb-3"> <label for="auteur" class="form-label">Auteur *</label> <input type="text" class="form-control" th:field="*{auteur}" th:errorclass="is-invalid"> <div class="invalid-feedback" th:errors="*{auteur}">Erreur</div> </div> <!-- Champ Prix --> <div class="mb-3"> <label for="prix" class="form-label">Prix *</label> <div class="input-group"> <input type="number" class="form-control" th:field="*{prix}" th:errorclass="is-invalid" step="0.01" min="0"> <span class="input-group-text">€</span> </div> <div class="invalid-feedback" th:errors="*{prix}">Erreur</div> </div> <!-- Liste déroulante Catégorie --> <div class="mb-3"> <label for="categorie" class="form-label">Catégorie</label> <select class="form-select" th:field="*{categorie}"> <option value="">-- Choisir une catégorie --</option> <option th:each="cat : ${categories}" th:value="${cat}" th:text="${cat.libelle}"> Catégorie </option> </select> <!-- th:field sur un <select> gère automatiquement l'option sélectionnée (th:selected) selon la valeur actuelle --> </div> <!-- Zone de texte Description --> <div class="mb-3"> <label for="description" class="form-label">Description</label> <textarea class="form-control" th:field="*{description}" rows="4" placeholder="Description du livre..."></textarea> </div> <!-- Case à cocher Disponible --> <div class="mb-3 form-check"> <input type="checkbox" class="form-check-input" th:field="*{disponible}" id="disponible"> <label class="form-check-label" for="disponible">Disponible à la vente</label> </div> <!-- Boutons --> <div class="d-flex gap-2"> <button type="submit" class="btn btn-primary"> Enregistrer </button> <a th:href="@{/livres}" class="btn btn-secondary"> Annuler </a> </div> </form> </div> </body> </html>
Les RedirectAttributes.addFlashAttribute() sont disponibles dans le template après une redirection :
RedirectAttributes.addFlashAttribute()
<!-- Dans le layout ou en haut de chaque page --> <div class="container mt-3"> <div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show"> <i class="bi bi-check-circle"></i> <span th:text="${successMessage}">Succès</span> <button type="button" class="btn-close" data-bs-dismiss="alert"></button> </div> <div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show"> <span th:text="${errorMessage}">Erreur</span> <button type="button" class="btn-close" data-bs-dismiss="alert"></button> </div> </div>
La suppression pose un défi : les liens <a> font des requêtes GET, mais une suppression devrait utiliser DELETE ou POST. Voici le pattern classique :
<!-- Formulaire de suppression (dans le tableau) --> <form th:action="@{/livres/{id}/supprimer(id=${livre.id})}" method="post" onsubmit="return confirm('Êtes-vous sûr de vouloir supprimer ce livre ?')"> <!-- Token CSRF de Spring Security (automatique avec Thymeleaf + Security) --> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <button type="submit" class="btn btn-sm btn-danger">Supprimer</button> </form>
@PostMapping("/{id}/supprimer") public String supprimer(@PathVariable Long id, RedirectAttributes redirectAttributes) { livreService.supprimer(id); redirectAttributes.addFlashAttribute("successMessage", "Livre supprimé."); return "redirect:/livres"; }
TP — Étape 4 : Créez le formulaire d’ajout/modification d’un livre. Testez la validation (soumettez un formulaire vide et vérifiez les messages d’erreur).
L’internationalisation permet d’afficher votre application dans plusieurs langues sans modifier les templates.
@Configuration public class I18nConfig implements WebMvcConfigurer { @Bean public LocaleResolver localeResolver() { SessionLocaleResolver resolver = new SessionLocaleResolver(); resolver.setDefaultLocale(Locale.FRENCH); return resolver; } @Bean public LocaleChangeInterceptor localeChangeInterceptor() { LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor(); interceptor.setParamName("lang"); // ?lang=fr ou ?lang=en return interceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(localeChangeInterceptor()); } }
# src/main/resources/messages.properties (défaut, français) page.accueil.titre=Bienvenue sur Ma Librairie page.livres.titre=Catalogue des livres livre.titre=Titre livre.auteur=Auteur livre.prix=Prix bouton.ajouter=Ajouter un livre bouton.modifier=Modifier bouton.supprimer=Supprimer bouton.enregistrer=Enregistrer message.succes.ajout=Le livre a été ajouté avec succès ! message.erreur.validation=Veuillez corriger les erreurs ci-dessous.
# src/main/resources/messages_en.properties (anglais) page.accueil.titre=Welcome to My Library page.livres.titre=Book Catalogue livre.titre=Title livre.auteur=Author livre.prix=Price bouton.ajouter=Add a book bouton.modifier=Edit bouton.supprimer=Delete bouton.enregistrer=Save message.succes.ajout=Book added successfully! message.erreur.validation=Please correct the errors below.
<!-- Texte traduit --> <h1 th:text="#{page.livres.titre}">Catalogue</h1> <!-- Dans un attribut --> <button th:text="#{bouton.enregistrer}" class="btn btn-primary">Enregistrer</button> <!-- Avec paramètres --> <!-- messages.properties : bienvenue=Bonjour {0}, vous avez {1} livre(s) --> <p th:text="#{bienvenue(${utilisateur.nom}, ${nombreLivres})}">Bienvenue</p> <!-- Bouton de changement de langue --> <div class="d-flex gap-2"> <a th:href="@{''(lang=fr)}" class="btn btn-sm btn-outline-secondary">🇫🇷 Français</a> <a th:href="@{''(lang=en)}" class="btn btn-sm btn-outline-secondary">🇬🇧 English</a> </div>
Spring Boot cherche automatiquement des templates dans templates/error/ pour les erreurs HTTP :
templates/error/
<!-- templates/error/404.html — Page non trouvée --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <head><title>Page non trouvée</title></head> <body> <div class="container text-center mt-5"> <h1 class="display-1 text-muted">404</h1> <h2>Page non trouvée</h2> <p class="text-muted">La page que vous cherchez n'existe pas ou a été déplacée.</p> <a th:href="@{/}" class="btn btn-primary">Retour à l'accueil</a> </div> </body> </html>
<!-- templates/error/500.html — Erreur serveur --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <head><title>Erreur serveur</title></head> <body> <div class="container text-center mt-5"> <h1 class="display-1 text-danger">500</h1> <h2>Erreur interne du serveur</h2> <p>Une erreur inattendue s'est produite. Veuillez réessayer plus tard.</p> <p th:text="${message}" class="text-muted small">Détail technique</p> </div> </body> </html>
<!-- Affichage global de toutes les erreurs --> <div th:if="${#fields.hasAnyErrors()}" class="alert alert-danger"> <strong th:text="#{message.erreur.validation}">Des erreurs sont présentes.</strong> <ul class="mb-0 mt-2"> <li th:each="err : ${#fields.allErrors()}" th:text="${err}">Erreur</li> </ul> </div> <!-- Erreur par champ (inline) --> <div class="mb-3"> <label th:for="titre">Titre</label> <input type="text" th:field="*{titre}" th:errorclass="is-invalid" class="form-control"> <!-- th:errors affiche tous les messages d'erreur pour ce champ --> <div class="invalid-feedback" th:errors="*{titre}">Erreur titre</div> </div> <!-- Vérifier si un champ spécifique a une erreur --> <span th:if="${#fields.hasErrors('email')}" class="text-danger"> <th:block th:errors="*{email}">Erreur email</th:block> </span>
@ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(LivreNotFoundException.class) public String handleNotFound(LivreNotFoundException ex, Model model) { model.addAttribute("message", ex.getMessage()); model.addAttribute("titre", "Livre introuvable"); return "error/404"; // → templates/error/404.html } @ExceptionHandler(Exception.class) public String handleGeneral(Exception ex, Model model) { model.addAttribute("message", ex.getMessage()); return "error/500"; } }
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.thymeleaf.extras</groupId> <artifactId>thymeleaf-extras-springsecurity6</artifactId> </dependency>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" lang="fr">
<!-- Afficher selon le rôle --> <div sec:authorize="hasRole('ADMIN')"> <a href="/admin" class="btn btn-danger">Administration</a> </div> <div sec:authorize="isAuthenticated()"> <p>Connecté en tant que : <span sec:authentication="name">Utilisateur</span></p> </div> <div sec:authorize="isAnonymous()"> <a href="/login" class="btn btn-primary">Se connecter</a> </div> <!-- Afficher le nom et le rôle de l'utilisateur --> <span sec:authentication="name">Utilisateur</span> <span sec:authentication="principal.authorities">Rôles</span> <!-- Menu adapté selon le rôle --> <ul class="navbar-nav"> <li sec:authorize="isAuthenticated()"> <a class="nav-link" th:href="@{/mon-compte}">Mon compte</a> </li> <li sec:authorize="hasRole('ADMIN')"> <a class="nav-link" th:href="@{/admin/livres}">Gestion</a> </li> <li sec:authorize="isAnonymous()"> <a class="nav-link" th:href="@{/login}">Connexion</a> </li> <li sec:authorize="isAuthenticated()"> <form th:action="@{/logout}" method="post"> <button type="submit" class="btn btn-link nav-link">Déconnexion</button> </form> </li> </ul>
<!-- templates/login.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org" lang="fr"> <head><title>Connexion</title></head> <body> <div class="container mt-5"> <div class="row justify-content-center"> <div class="col-md-4"> <div class="card shadow"> <div class="card-header text-center bg-dark text-white"> <h4>Connexion</h4> </div> <div class="card-body"> <!-- Message d'erreur si mauvais identifiants --> <div th:if="${param.error}" class="alert alert-danger"> Identifiants incorrects. Veuillez réessayer. </div> <!-- Message après déconnexion --> <div th:if="${param.logout}" class="alert alert-info"> Vous avez été déconnecté. </div> <form th:action="@{/login}" method="post"> <div class="mb-3"> <label for="username">Email</label> <input type="email" class="form-control" id="username" name="username" required> </div> <div class="mb-3"> <label for="password">Mot de passe</label> <input type="password" class="form-control" id="password" name="password" required> </div> <button type="submit" class="btn btn-primary w-100"> Se connecter </button> </form> </div> </div> </div> </div> </div> </body> </html>
JavaScript permet d’ajouter de l’interactivité à vos pages sans rechargement complet.
<!-- Passer des données Java vers JavaScript --> <script th:inline="javascript"> // [[${variable}]] est la syntaxe pour les expressions dans les scripts const livreId = [[${livre.id}]]; const livreTitre = [[${livre.titre}]]; console.log(`Livre ${livreId}: ${livreTitre}`); </script>
<button onclick="confirmerSuppression(this)" th:data-id="${livre.id}" th:data-titre="${livre.titre}" class="btn btn-sm btn-danger"> 🗑️ Supprimer </button> <script> function confirmerSuppression(btn) { const titre = btn.getAttribute('data-titre'); if (confirm(`Supprimer "${titre}" ? Cette action est irréversible.`)) { const id = btn.getAttribute('data-id'); // Créer et soumettre un formulaire dynamiquement const form = document.createElement('form'); form.method = 'POST'; form.action = `/livres/${id}/supprimer`; // CSRF token (si Spring Security est activé) const csrf = document.querySelector('meta[name="_csrf"]'); if (csrf) { const input = document.createElement('input'); input.type = 'hidden'; input.name = document.querySelector('meta[name="_csrf_header"]').content; input.value = csrf.content; form.appendChild(input); } document.body.appendChild(form); form.submit(); } } </script> <!-- Meta tags CSRF dans le <head> pour utilisation en JS --> <meta name="_csrf" th:content="${_csrf.token}"> <meta name="_csrf_header" th:content="${_csrf.headerName}">
<!-- Champ de recherche --> <input type="text" id="recherche" class="form-control" placeholder="Rechercher un livre..." oninput="rechercherLivres(this.value)"> <!-- Zone où les résultats s'affichent --> <div id="resultats-recherche"> <!-- Les résultats apparaissent ici dynamiquement --> </div> <script> let rechercheTimeout; function rechercherLivres(terme) { // Debounce : on attend que l'utilisateur arrête de taper (300ms) clearTimeout(rechercheTimeout); if (terme.length < 2) { document.getElementById('resultats-recherche').innerHTML = ''; return; } rechercheTimeout = setTimeout(async () => { try { // Appel à l'API Spring Boot const response = await fetch(`/api/livres/recherche?q=${encodeURIComponent(terme)}`); const livres = await response.json(); // Construire le HTML des résultats const html = livres.map(livre => ` <div class="card mb-2"> <div class="card-body py-2"> <strong>${livre.titre}</strong> — ${livre.auteur} <a href="/livres/${livre.id}" class="btn btn-sm btn-info float-end"> Voir </a> </div> </div> `).join(''); document.getElementById('resultats-recherche').innerHTML = livres.length > 0 ? html : '<p class="text-muted">Aucun résultat</p>'; } catch (error) { console.error('Erreur de recherche:', error); } }, 300); } </script>
Endpoint API correspondant :
// Controller REST pour la recherche (retourne du JSON) @RestController @RequestMapping("/api") public class ApiLivreController { @GetMapping("/livres/recherche") public List<LivreDTO> rechercher(@RequestParam String q) { return livreService.rechercher(q).stream() .map(LivreDTO::from) .toList(); } }
HTMX est une bibliothèque JavaScript légère qui permet de faire des requêtes AJAX directement depuis des attributs HTML, sans écrire de JavaScript !
<!-- Intégrer HTMX --> <script src="https://unpkg.com/htmx.org@1.9.10"></script> <!-- Exemple 1 : Charger une liste sans JS --> <button hx-get="/livres/fragment" hx-target="#liste-livres" hx-swap="innerHTML" class="btn btn-primary"> Actualiser la liste </button> <div id="liste-livres"><!-- Les livres apparaissent ici --></div> <!-- Exemple 2 : Recherche en temps réel sans JS --> <input type="text" name="q" placeholder="Rechercher..." hx-get="/livres/rechercher" hx-target="#resultats" hx-trigger="keyup changed delay:300ms" class="form-control"> <div id="resultats"></div> <!-- Exemple 3 : Supprimer une ligne de tableau sans rechargement --> <button hx-delete="/livres/5" hx-target="#ligne-livre-5" hx-swap="outerHTML" hx-confirm="Supprimer ce livre ?" class="btn btn-sm btn-danger"> Supprimer </button>
Controller qui retourne un fragment pour HTMX :
@GetMapping("/livres/fragment") public String listeFragment(Model model) { model.addAttribute("livres", livreService.findAll()); return "livres/liste :: tableauLivres"; // Retourne seulement le fragment }
<!-- Template avec le fragment ciblé --> <table th:fragment="tableauLivres" class="table"> <tr th:each="livre : ${livres}"> <td th:text="${livre.titre}">Titre</td> </tr> </table>
L’énoncé complet est disponible dans le fichier tp-enonce-thymeleaf
th:text="${livre.titre}"
th:field="*{titre}"
th:text="#{bouton.sauvegarder}"
th:href="@{/livres/{id}(id=${livre.id})}"
~{...}
th:replace="~{fragments/header :: nav}"
th:href
th:src
th:field
*{}
th:errors
th:errorclass
th:insert
th:inline="javascript"