Aller au contenu

Formation complète : Thymeleaf avec Spring Boot

Ce cours inclut : HTML, CSS, Thymeleaf, et un bonus JavaScript/HTMX


Table des matières

  1. Introduction — Qu’est-ce que Thymeleaf ?
  2. Les bases du HTML — Ce qu’il faut savoir
  3. Les bases du CSS — Mettre en forme ses pages
  4. Mise en place du projet Spring Boot
  5. Les expressions Thymeleaf
  6. Les attributs Thymeleaf essentiels
  7. Les fragments et layouts — Éviter la duplication
  8. Les formulaires avec Thymeleaf
  9. Internationalisation (i18n)
  10. Gestion des erreurs et validation
  11. Sécurité avec Spring Security
  12. BONUS — JavaScript et requêtes asynchrones
  13. TP Final — Médiathèque en ligne

1. Introduction

1.1 Pourquoi Thymeleaf ?

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.

1.2 Comment ça fonctionne ?

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.

1.3 Les avantages de Thymeleaf

1.4 Thymeleaf vs JSP

Critère Thymeleaf JSP
Syntaxe HTML valide Balises <% %> qui cassent l’HTML
Aperçu sans serveur Oui Non
Support Spring Boot Excellent Possible mais déprécié
Lisibilité Bonne Mauvaise avec beaucoup de logique

2. Les bases du HTML

Avant d’utiliser Thymeleaf, il faut comprendre HTML. Cette section couvre l’essentiel.

2.1 Qu’est-ce que le HTML ?

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

2.2 Structure d’une page HTML

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

2.3 Les balises HTML essentielles

Une balise HTML est composée d’une ouverture <balise> et d’une fermeture </balise>. Certaines balises sont auto-fermantes <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é).

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>

2.4 Les attributs HTML

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 :


3. Les bases du CSS

Le CSS (Cascading Style Sheets) permet de styliser les éléments HTML : couleurs, tailles, espacements, alignements…

3.1 Les trois façons d’écrire du CSS

<!-- 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">

3.2 Les sélecteurs 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;
}

3.3 Les propriétés CSS essentielles

.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 */
}

3.4 Le modèle de boîte (Box Model)

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 */
}

3.5 Flexbox — Aligner facilement

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 */
}

3.6 Bootstrap — Un framework CSS prêt à l’emploi

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 :


4. Mise en place du projet

4.1 Dépendances Maven

Créez un projet Spring Boot avec ces dépendances dans 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>

4.2 Configuration application.properties

# 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

4.2.1 Version yaml

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/.

4.4 Le premier Controller

@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

4.5 Le premier template

<!-- 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.


5. Les expressions Thymeleaf

Les expressions sont le cœur de Thymeleaf. Elles permettent d’accéder aux données passées par le Controller.

5.1 Expression variable : ${...}

C’est l’expression la plus utilisée. Elle accède aux attributs du 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 -->

5.2 Expression URL : @{...}

Pour construire des URLs dynamiques (liens et attributs action des formulaires).

<!-- 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.

5.3 Expression de message : #{...}

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

5.4 Expression de sélection : *{...}

Utilisée à l’intérieur d’un bloc th:object pour éviter de répéter le nom de l’objet.

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

5.5 Les utilitaires Thymeleaf

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>

6. Les attributs Thymeleaf essentiels

6.1 th:text et 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>&lt;b&gt;Excellent&lt;/b&gt; 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.

6.2 th:if et 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).

6.3 th:each — Itérer sur une liste

C’est l’équivalent du for Java dans les templates.

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

6.4 th:class et 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>

6.5 th:attr et attributs directs

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>

6.6 th:switch et th:case

Équivalent du switch Java.

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

6.7 th:inline

Pour utiliser des expressions Thymeleaf dans des balises <script> JavaScript.

<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}.


7. Les fragments et layouts

7.1 Le problème sans fragments

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 !

7.2 Créer un fragment

Un fragment est une portion de HTML réutilisable, définie avec 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>

7.3 Inclure un fragment

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.

Syntaxe complète de la référence de fragment :

~{chemin/du/fichier :: nomDuFragment}
~{fragments/header :: navbar}
   ↑ chemin depuis templates/    ↑ nom du th:fragment

7.4 Fragments avec paramètres

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

7.5 Le pattern Layout — Template de base

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.

TP — Étape 3 : Créez un layout.html avec navbar et footer. Transformez vos pages liste et détail pour utiliser des fragments.


8. Les formulaires avec Thymeleaf

8.1 Lier un formulaire à un objet Java

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";
    }
}

8.2 Le template du 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>

8.3 Afficher les messages flash

Les RedirectAttributes.addFlashAttribute() sont disponibles dans le template après une redirection :

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

8.4 La suppression avec confirmation

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


9. Internationalisation (i18n)

L’internationalisation permet d’afficher votre application dans plusieurs langues sans modifier les templates.

9.1 Configuration

@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());
    }
}

9.2 Fichiers de messages

# 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.

9.3 Utilisation dans les templates

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

10. Gestion des erreurs et validation

10.1 Pages d’erreur personnalisées

Spring Boot cherche automatiquement des templates dans templates/error/ pour les erreurs HTTP :

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

10.2 Affichage des erreurs de validation

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

10.3 ExceptionHandler avec Thymeleaf

@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";
    }
}

11. Sécurité avec Spring Security

11.1 Dépendance

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

11.2 Déclaration du namespace

<html xmlns:th="http://www.thymeleaf.org"
      xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
      lang="fr">

11.3 Les expressions de sécurité

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

11.4 Page de login personnalisée

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

12. BONUS — JavaScript et requêtes asynchrones

12.1 JavaScript de base avec Thymeleaf

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>

12.2 Confirmation avant suppression (JavaScript pur)

<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}">

12.3 Recherche en temps réel avec fetch()

<!-- 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();
    }
}

12.4 HTMX — Le HTML de l’avenir (Bonus avancé)

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>

13. TP complet — Médiathèque en ligne

L’énoncé complet est disponible dans le fichier tp-enonce-thymeleaf


Récapitulatif des expressions et attributs Thymeleaf

Expression Usage Exemple
${...} Variable du Model th:text="${livre.titre}"
*{...} Propriété de l’objet courant (th:object) th:field="*{titre}"
#{...} Message i18n th:text="#{bouton.sauvegarder}"
@{...} URL th:href="@{/livres/{id}(id=${livre.id})}"
~{...} Fragment th:replace="~{fragments/header :: nav}"
Attribut Usage
th:text Injecte du texte (HTML échappé)
th:utext Injecte du HTML non échappé
th:if / th:unless Affichage conditionnel
th:each Itération sur une liste
th:href / th:src URLs dynamiques
th:field Liaison formulaire ↔ objet
th:object Définit l’objet courant pour *{}
th:errors Affiche les erreurs de validation
th:errorclass Ajoute une classe si erreur
th:replace Remplace la balise par un fragment
th:insert Insère un fragment dans la balise
th:class / th:classappend Classes CSS dynamiques
th:fragment Définit un fragment réutilisable
th:switch / th:case Switch conditionnel
th:inline="javascript" Expressions dans les scripts JS