Dans ce TP, vous allez compléter une mini-application Spring Boot permettant de gérer des demandes de congés associées à un loisir principal.
Objectif : comprendre concrètement le trajet des données…
L’application utilise Flowbite et TailwindCSS via CDN pour obtenir rapidement une interface propre sans perdre une demi-journée à se battre avec les pixels. Les pixels ont toujours tort, mais ils sont nombreux.
Vous allez compléter 2 pages Thymeleaf :
liste.html
formulaire.html
Le back-end est déjà prêt : entités, DTO, validation, repositories, service, contrôleur, données de démo bd H2.
Vous devrez principalement compléter les expressions Thymeleaf :
th:each
th:text
th:href
th:action
th:object
th:field
th:errors
L’application contient 2 entités.
Loisir
Un loisir représente l’activité principale prévue pendant le congé.
Exemples :
DemandeConge
Une demande de congé contient :
Relation : une demande de congé est associée à un seul loisir.
Loisir 1 ----- * DemandeConge
Dans ce TP, les pages Thymeleaf ne manipulent pas directement les entités JPA.
On utilise :
DemandeCongeDto
DemandeCongeFormDto
LoisirDto
C’est plus propre, plus sécurisé et plus pro. Une entité JPA représente la table en base. Un DTO représente ce qu’on veut envoyer ou recevoir dans une page.
Autrement dit :
Base de données ↓ Entité JPA ↓ Service ↓ DTO ↓ Contrôleur ↓ Page Thymeleaf
Pour un formulaire :
Page Thymeleaf ↓ DTO de formulaire ↓ Validation ↓ Service ↓ Entité JPA ↓ Base de données
conges-loisirs-starter/ ├── pom.xml ├── src/main/java/fr/formation/congesloisirs/ │ ├── CongesLoisirsApplication.java │ ├── config/ │ │ └── DataInitializer.java │ ├── controller/ │ │ ├── AccueilController.java │ │ └── DemandeCongeController.java │ ├── dto/ │ │ ├── DemandeCongeDto.java │ │ ├── DemandeCongeFormDto.java │ │ └── LoisirDto.java │ ├── mapper/ │ │ └── ApplicationMapper.java │ ├── model/ │ │ ├── DemandeConge.java │ │ ├── Loisir.java │ │ └── StatutDemande.java │ ├── repository/ │ │ ├── DemandeCongeRepository.java │ │ └── LoisirRepository.java │ └── service/ │ └── DemandeCongeService.java └── src/main/resources/ ├── application.properties └── templates/demandes/ ├── liste.html └── formulaire.html
Récupérer le projet depuis Gitlab en faisant un “git clone” en https depuis le lien : lien vers le projet Gitlab
Vous pouvez modifier le port si vous avez des conflits.
Depuis le dossier du projet que vous avez cloné :
mvn spring-boot:run
Puis ouvrir :
http://localhost:8080/demandes
Console H2 :
http://localhost:8080/h2-console
Paramètres H2 :
JDBC URL : jdbc:h2:mem:congesloisirsdb User : sa Password : laisser vide
@Entity public class Loisir { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, unique = true, length = 80) private String nom; @Column(length = 120) private String description; // constructeurs, getters, setters }
@Entity public class DemandeConge { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(nullable = false, length = 80) private String collaborateur; @Column(nullable = false) private LocalDate dateDebut; @Column(nullable = false) private LocalDate dateFin; @Column(length = 250) private String commentaire; @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private StatutDemande statut = StatutDemande.BROUILLON; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "loisir_id", nullable = false) private Loisir loisir; // getters, setters }
public record DemandeCongeDto( Long id, String collaborateur, LocalDate dateDebut, LocalDate dateFin, String commentaire, StatutDemande statut, Long loisirId, String loisirNom ) { }
Ce DTO est utilisé pour la table HTML. On y trouve directement loisirNom, ce qui évite d’écrire dans la vue :
loisirNom
${demande.loisir.nom}
On préfère écrire :
${demande.loisirNom}
La vue reste simple.
public class DemandeCongeFormDto { private Long id; @NotBlank(message = "Le nom du collaborateur est obligatoire.") @Size(max = 80, message = "Le nom ne doit pas dépasser 80 caractères.") private String collaborateur; @NotNull(message = "La date de début est obligatoire.") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate dateDebut; @NotNull(message = "La date de fin est obligatoire.") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate dateFin; @Size(max = 250, message = "Le commentaire ne doit pas dépasser 250 caractères.") private String commentaire; @NotNull(message = "Le statut est obligatoire.") private StatutDemande statut = StatutDemande.BROUILLON; @NotNull(message = "Le loisir est obligatoire.") private Long loisirId; @AssertTrue(message = "La date de fin doit être égale ou postérieure à la date de début.") public boolean isPeriodeValide() { if (dateDebut == null || dateFin == null) { return true; } return !dateFin.isBefore(dateDebut); } }
Point important : le formulaire reçoit seulement loisirId, pas un objet Loisir complet. Dans un formulaire HTML, une liste déroulante envoie une valeur simple :
loisirId
<option value="1">Randonnée</option>
Le service se charge ensuite de retrouver l’entité Loisir correspondante.
@Controller @RequestMapping("/demandes") public class DemandeCongeController { private final DemandeCongeService demandeCongeService; public DemandeCongeController(DemandeCongeService demandeCongeService) { this.demandeCongeService = demandeCongeService; } @GetMapping public String lister(Model model) { model.addAttribute("demandes", demandeCongeService.listerDemandes()); return "demandes/liste"; } @GetMapping("/nouvelle") public String afficherFormulaireCreation(Model model) { model.addAttribute("demandeForm", demandeCongeService.preparerCreation()); ajouterDonneesFormulaire(model, "Nouvelle demande de congé"); return "demandes/formulaire"; } @PostMapping public String creer( @Valid @ModelAttribute("demandeForm") DemandeCongeFormDto demandeForm, BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { ajouterDonneesFormulaire(model, "Nouvelle demande de congé"); return "demandes/formulaire"; } demandeCongeService.creer(demandeForm); redirectAttributes.addFlashAttribute("message", "Demande ajoutée avec succès."); return "redirect:/demandes"; } @GetMapping("/{id}/modifier") public String afficherFormulaireModification(@PathVariable Long id, Model model) { model.addAttribute("demandeForm", demandeCongeService.preparerModification(id)); ajouterDonneesFormulaire(model, "Modifier une demande de congé"); return "demandes/formulaire"; } @PostMapping("/{id}/modifier") public String modifier( @PathVariable Long id, @Valid @ModelAttribute("demandeForm") DemandeCongeFormDto demandeForm, BindingResult bindingResult, Model model, RedirectAttributes redirectAttributes) { if (bindingResult.hasErrors()) { ajouterDonneesFormulaire(model, "Modifier une demande de congé"); return "demandes/formulaire"; } demandeCongeService.modifier(id, demandeForm); redirectAttributes.addFlashAttribute("message", "Demande modifiée avec succès."); return "redirect:/demandes"; } }
Pour afficher une page, le contrôleur utilise Model :
Model
model.addAttribute("demandes", demandeCongeService.listerDemandes());
Dans Thymeleaf, on récupère cette liste avec :
${demandes}
Pour recevoir un formulaire, le contrôleur utilise :
@Valid @ModelAttribute("demandeForm") DemandeCongeFormDto demandeForm
Le DTO reçoit les valeurs envoyées par les champs HTML.
Fichier à compléter :
src/main/resources/templates/demandes/liste.html
Dans la barre de navigation, ajouter un lien vers la page de création :
<a th:href="@{/demandes/nouvelle}" class="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded-lg text-sm px-5 py-2.5"> Nouvelle demande </a>
Après une création ou une modification, le contrôleur ajoute un message :
redirectAttributes.addFlashAttribute("message", "Demande ajoutée avec succès.");
Dans Thymeleaf :
<div th:if="${message}" class="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50" role="alert"> <span th:text="${message}">Message de confirmation</span> </div>
À compléter dans le <tbody> :
<tbody>
<tr th:each="demande : ${demandes}" class="bg-white border-b hover:bg-gray-50">
Cela signifie : Pour chaque élément de la liste demandes, crée une variable locale appelée demande et génère une ligne de tableau HTML.
Exemple :
<td th:text="${demande.collaborateur}">Anaïs Desmoutiers</td>
Pour formater une date :
<td th:text="${#temporals.format(demande.dateDebut, 'dd/MM/yyyy')}">01/07/2026</td>
<a th:href="@{/demandes/{id}/modifier(id=${demande.id})}" class="font-medium text-blue-600 hover:underline">Modifier</a>
<form th:action="@{/demandes/{id}/supprimer(id=${demande.id})}" method="post" onsubmit="return confirm('Supprimer cette demande ?');"> <button type="submit" class="font-medium text-red-600 hover:underline">Supprimer</button> </form>
Même si un lien peut techniquement déclencher une suppression, on évite. Une suppression modifie les données, donc on utilise plutôt une requête POST.
POST
src/main/resources/templates/demandes/formulaire.html
Le contrôleur fournit un objet :
model.addAttribute("demandeForm", demandeCongeService.preparerCreation());
Dans le formulaire :
<form th:object="${demandeForm}" method="post">
Ensuite, chaque champ peut utiliser th:field.
Le même fichier formulaire.html sert à créer et à modifier.
Si demandeForm.id == null, on crée une nouvelle demande.
demandeForm.id == null
Sinon, on modifie une demande existante.
<form th:object="${demandeForm}" th:action="${demandeForm.id == null} ? @{/demandes} : @{/demandes/{id}/modifier(id=${demandeForm.id})}" method="post">
<input type="text" id="collaborateur" th:field="*{collaborateur}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5" placeholder="Exemple : Alice Martin"> <p th:if="${#fields.hasErrors('collaborateur')}" th:errors="*{collaborateur}" class="mt-2 text-sm text-red-600">Erreur collaborateur</p>
th:field="*{collaborateur}" génère automatiquement :
th:field="*{collaborateur}"
name="collaborateur"
id="collaborateur"
value="..."
<input type="date" id="dateDebut" th:field="*{dateDebut}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5">
<input type="date" id="dateFin" th:field="*{dateFin}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5">
Le contrôleur ajoute la liste :
model.addAttribute("loisirs", demandeCongeService.listerLoisirs());
Dans la vue :
<select id="loisirId" th:field="*{loisirId}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5"> <option value="">-- Choisir un loisir --</option> <option th:each="loisir : ${loisirs}" th:value="${loisir.id}" th:text="${loisir.nom}">Randonnée</option> </select>
Le formulaire envoie seulement l’identifiant du loisir.
loisirId=2
Le service récupère ensuite l’entité complète :
Loisir loisir = loisirRepository.findById(form.getLoisirId()) .orElseThrow(() -> new EntityNotFoundException("Loisir introuvable"));
<select id="statut" th:field="*{statut}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5"> <option th:each="statut : ${statuts}" th:value="${statut}" th:text="${statut}">BROUILLON</option> </select>
<textarea id="commentaire" th:field="*{commentaire}" rows="4" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300" placeholder="Petite précision éventuelle"></textarea>
<!DOCTYPE html> <html lang="fr" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Liste des demandes</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.5.2/flowbite.min.css" rel="stylesheet" /> </head> <body class="bg-gray-50 text-gray-900"> <nav class="bg-white border-gray-200 shadow-sm"> <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> <a th:href="@{/demandes}" class="text-2xl font-semibold">Congés & Loisirs</a> <a th:href="@{/demandes/nouvelle}" class="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded-lg text-sm px-5 py-2.5"> Nouvelle demande </a> </div> </nav> <main class="max-w-screen-xl mx-auto p-6"> <div th:if="${message}" class="p-4 mb-4 text-sm text-green-800 rounded-lg bg-green-50" role="alert"> <span th:text="${message}">Message de confirmation</span> </div> <div class="flex items-center justify-between mb-6"> <div> <h1 class="text-3xl font-bold">Demandes de congés</h1> <p class="mt-2 text-gray-600">Données récupérées depuis le back Spring Boot puis affichées avec Thymeleaf.</p> </div> </div> <div class="relative overflow-x-auto shadow-md sm:rounded-lg bg-white"> <table class="w-full text-sm text-left rtl:text-right text-gray-500"> <thead class="text-xs text-gray-700 uppercase bg-gray-100"> <tr> <th scope="col" class="px-6 py-3">Collaborateur</th> <th scope="col" class="px-6 py-3">Début</th> <th scope="col" class="px-6 py-3">Fin</th> <th scope="col" class="px-6 py-3">Loisir</th> <th scope="col" class="px-6 py-3">Statut</th> <th scope="col" class="px-6 py-3">Commentaire</th> <th scope="col" class="px-6 py-3 text-right">Actions</th> </tr> </thead> <tbody> <tr th:each="demande : ${demandes}" class="bg-white border-b hover:bg-gray-50"> <th scope="row" class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap" th:text="${demande.collaborateur}">Alice Martin</th> <td class="px-6 py-4" th:text="${#temporals.format(demande.dateDebut, 'dd/MM/yyyy')}">01/07/2026</td> <td class="px-6 py-4" th:text="${#temporals.format(demande.dateFin, 'dd/MM/yyyy')}">05/07/2026</td> <td class="px-6 py-4" th:text="${demande.loisirNom}">Randonnée</td> <td class="px-6 py-4"> <span class="bg-blue-100 text-blue-800 text-xs font-medium me-2 px-2.5 py-0.5 rounded" th:text="${demande.statut}">ENVOYEE</span> </td> <td class="px-6 py-4" th:text="${demande.commentaire}">Commentaire</td> <td class="px-6 py-4 text-right flex justify-end gap-2"> <a th:href="@{/demandes/{id}/modifier(id=${demande.id})}" class="font-medium text-blue-600 hover:underline">Modifier</a> <form th:action="@{/demandes/{id}/supprimer(id=${demande.id})}" method="post" onsubmit="return confirm('Supprimer cette demande ?');"> <button type="submit" class="font-medium text-red-600 hover:underline">Supprimer</button> </form> </td> </tr> <tr th:if="${#lists.isEmpty(demandes)}"> <td colspan="7" class="px-6 py-8 text-center text-gray-500">Aucune demande pour le moment.</td> </tr> </tbody> </table> </div> </main> <script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.5.2/flowbite.min.js"></script> </body> </html>
<!DOCTYPE html> <html lang="fr" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title th:text="${titrePage}">Formulaire</title> <script src="https://cdn.tailwindcss.com"></script> <link href="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.5.2/flowbite.min.css" rel="stylesheet" /> </head> <body class="bg-gray-50 text-gray-900"> <nav class="bg-white border-gray-200 shadow-sm"> <div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4"> <a th:href="@{/demandes}" class="text-2xl font-semibold">Congés & Loisirs</a> <a th:href="@{/demandes}" class="text-blue-700 hover:underline">Retour à la liste</a> </div> </nav> <main class="max-w-3xl mx-auto p-6"> <div class="bg-white shadow-md rounded-lg p-6"> <h1 class="text-3xl font-bold mb-2" th:text="${titrePage}">Nouvelle demande</h1> <p class="text-gray-600 mb-6">Formulaire envoyé au contrôleur Spring Boot via une requête POST.</p> <form th:object="${demandeForm}" th:action="${demandeForm.id == null} ? @{/demandes} : @{/demandes/{id}/modifier(id=${demandeForm.id})}" method="post" class="space-y-5"> <div th:if="${#fields.hasErrors('*')}" class="p-4 mb-4 text-sm text-red-800 rounded-lg bg-red-50" role="alert"> <p class="font-medium">Le formulaire contient des erreurs.</p> </div> <input type="hidden" th:field="*{id}"> <div> <label for="collaborateur" class="block mb-2 text-sm font-medium text-gray-900">Collaborateur</label> <input type="text" id="collaborateur" th:field="*{collaborateur}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5" placeholder="Exemple : Alice Martin"> <p th:if="${#fields.hasErrors('collaborateur')}" th:errors="*{collaborateur}" class="mt-2 text-sm text-red-600">Erreur collaborateur</p> </div> <div class="grid gap-5 md:grid-cols-2"> <div> <label for="dateDebut" class="block mb-2 text-sm font-medium text-gray-900">Date de début</label> <input type="date" id="dateDebut" th:field="*{dateDebut}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5"> <p th:if="${#fields.hasErrors('dateDebut')}" th:errors="*{dateDebut}" class="mt-2 text-sm text-red-600">Erreur date début</p> </div> <div> <label for="dateFin" class="block mb-2 text-sm font-medium text-gray-900">Date de fin</label> <input type="date" id="dateFin" th:field="*{dateFin}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5"> <p th:if="${#fields.hasErrors('dateFin')}" th:errors="*{dateFin}" class="mt-2 text-sm text-red-600">Erreur date fin</p> </div> </div> <p th:if="${#fields.hasErrors('periodeValide')}" th:errors="*{periodeValide}" class="mt-2 text-sm text-red-600">Erreur période</p> <div class="grid gap-5 md:grid-cols-2"> <div> <label for="loisirId" class="block mb-2 text-sm font-medium text-gray-900">Loisir principal</label> <select id="loisirId" th:field="*{loisirId}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5"> <option value="">-- Choisir un loisir --</option> <option th:each="loisir : ${loisirs}" th:value="${loisir.id}" th:text="${loisir.nom}">Randonnée</option> </select> <p th:if="${#fields.hasErrors('loisirId')}" th:errors="*{loisirId}" class="mt-2 text-sm text-red-600">Erreur loisir</p> </div> <div> <label for="statut" class="block mb-2 text-sm font-medium text-gray-900">Statut</label> <select id="statut" th:field="*{statut}" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg block w-full p-2.5"> <option th:each="statut : ${statuts}" th:value="${statut}" th:text="${statut}">BROUILLON</option> </select> <p th:if="${#fields.hasErrors('statut')}" th:errors="*{statut}" class="mt-2 text-sm text-red-600">Erreur statut</p> </div> </div> <div> <label for="commentaire" class="block mb-2 text-sm font-medium text-gray-900">Commentaire</label> <textarea id="commentaire" th:field="*{commentaire}" rows="4" class="block p-2.5 w-full text-sm text-gray-900 bg-gray-50 rounded-lg border border-gray-300" placeholder="Petite précision éventuelle"></textarea> <p th:if="${#fields.hasErrors('commentaire')}" th:errors="*{commentaire}" class="mt-2 text-sm text-red-600">Erreur commentaire</p> </div> <div class="flex justify-end gap-3"> <a th:href="@{/demandes}" class="px-5 py-2.5 text-sm font-medium text-gray-900 bg-white border border-gray-300 rounded-lg hover:bg-gray-100"> Annuler </a> <button type="submit" class="text-white bg-blue-700 hover:bg-blue-800 font-medium rounded-lg text-sm px-5 py-2.5"> Enregistrer </button> </div> </form> </div> </main> <script src="https://cdnjs.cloudflare.com/ajax/libs/flowbite/2.5.2/flowbite.min.js"></script> </body> </html>
Lancez l’application et vérifiez que la table affiche les 3 demandes créées dans le DataInitializer.
DataInitializer
Ajoutez une nouvelle demande :
Nina Simone
Plage
ENVOYEE
Vérifiez que la demande apparaît bien dans la table après validation.
Testez une erreur de validation :
La page doit afficher les messages d’erreur.
Modifiez une demande existante.
Vérifiez que le formulaire est prérempli.
Supprimez une demande.
Vérifiez qu’elle disparaît de la table.
Côté contrôleur :
Côté Thymeleaf :
<tr th:each="demande : ${demandes}"> <td th:text="${demande.collaborateur}"></td> </tr>
<form th:object="${demandeForm}" th:action="@{/demandes}" method="post"> <input th:field="*{collaborateur}"> </form>
@PostMapping public String creer(@Valid @ModelAttribute("demandeForm") DemandeCongeFormDto demandeForm, BindingResult bindingResult) { if (bindingResult.hasErrors()) { return "demandes/formulaire"; } demandeCongeService.creer(demandeForm); return "redirect:/demandes"; }
BindingResult
Spring associe le résultat de validation au paramètre qui le précède immédiatement.
Correct :
public String creer(@Valid @ModelAttribute("demandeForm") DemandeCongeFormDto demandeForm, BindingResult bindingResult)
À éviter :
public String creer(@Valid @ModelAttribute("demandeForm") DemandeCongeFormDto demandeForm, Model model, BindingResult bindingResult)
Ici, BindingResult est trop loin du DTO. Spring n’aime pas. Spring est rigide, mais au moins il prévient.
Améliorations possibles :
Collaborateur
MockMvc