Kihagyás

2. gyakorlat

Ezen a gyakorlaton folytatjuk a Contacts alkalmazásunk megvalósítását. Összegezzük, hogy hol is tartunk:

  • Van egy Contact modell osztályunk
  • Egy home.html és a hozzá tartozó controller (csak egy egyszerű képmegjelenítés)
  • /contact/create: a létrehozáshoz szükséges form (contact-create.html), illetve a hozzá tartozó controller, mely egyszerűen logolja a hozzáadni kívánt kontaktot.
  • A create-contact template-ben használjuk a bootstrap CSS könyvtárat

A továbbiakban szeretnénk az összes CRUD műveletet támogatni, így ezeken fogunk végigmenni. Előtte azonban csinálunk egy kis refactoring-ot, mivel több template-ben is szeretnénk majd a Bootstrap-et használni, illetve a különböző oldalakon ugyanazt a menüt szeretnénk használni.

Thymeleaf fragment-ek

A Thymeleaf kínál megoldást erre a problémára, melyet úgy hívnak, hogy fragment. Egy fragment olyan template részlet, melyet más template-ek használhatnak (include-olhatnak), így azokat egyszerű lesz más oldalakon beágyazni.

Szervezzük ki a <head> részben megadott Bootstrap specifikus CSS és JS includokat!

Ehhez először hozzunk létre egy mappát a resources/templates alá fragments néven! Ide hozzunk létre egy header.html állományt, mely a header fragment kódját fogja tartalmazni! A contact-create.html template-ből emeljük át a <head> kódját a fragment állományba!

Ekkor a fragment tartalma a következő lesz:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<head>
    <meta charset="UTF-8">
    <meta name="viewport"
          content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
    <script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js" integrity="sha384-9/reFTGAW83EW2RDu2S0VKaIzap3H66lZH81PoYlFhbGU+6BZp6G7niu735Sk7lN" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js" integrity="sha384-B4gt1jrGC7Jh4AgTPSdUtOBvfO8shuf57BaghqFfPlYxofvL8/KUEfYiJOMMV+rV" crossorigin="anonymous"></script>
    <title>Create Contact</title>
</head>

Ez így még nem használható, mivel a fragment-eknek nevet kell adnunk! Ehhez a <head> elemre a következő attribútumot kell elhelyeznünk (a Thymeleaf-es XML namespace megadás mellett):

1
<head th:fragment="header" xmlns:th="http://www.thymeleaf.org">

A th:fragment="header" adja meg a fragment nevét. Fontos, hogy egy ilyen állományban több fragment is definiálható, akár egymásba ágyazva is.

Miután megvan a beemelendő fragment, hivatkozzuk is azt a contact-create.html-ból! Ehhez a <body> előtt a következőt kell megadnunk!

1
2
3
<div th:replace="fragments/header :: header"></div>
<body>
...

A fenti kód hatására a az üres <div>-et lecseréljük a fragment/header állományban található header nevű fragment-re (a :: előtti rész a fragment forrását, az az utáni pedig a nevét adja meg), mármint annak tartalmára.

A Thymeleaf többféle fragment include-ot is támogat, melyből a replace csak egy. A teljes lista:

  • replace – lecseréli az aktuális tag-et a fragment tag-re
  • insert – ugyanaz, mint a replace, de nem lecseréli a tag-et, hanem a belsejébe helyezi el a fragment kódját
  • include – ez már deprecated, szóval ne használjuk

Miután ezzel megvagyunk, egy dolgot még meg kell tennünk. A header-ben a következőt adtuk meg:

1
<title>Create Contact</title>

Nyilván nem szeretnénk, ha az összes template-ben a 'Create Contact' szöveg jelenne meg. Ennek megoldására használhatjuk a paraméterezett fragment-eket. Ilyen esetben, mintha egy függvényt hívnánk, adjuk át a paramétereket a következő módon a contact-create.html-ben:

1
<div th:replace="fragments/header :: header (title='Create Contact')"></div>

A fragment-et ebben az esetben a következőképpen módosítjuk:

1
2
3
4
<head th:fragment="header (title)" xmlns:th="http://www.thymeleaf.org">
    ...
    <title th:text="${title}">Default Title</title>
</head>

Fontos, hogy a th:fragment-ben is adjuk meg a paramétert, így az IDE is tud nekünk segíteni a highlight-al.

Készítsük el az alkalmazás menüjét!

Hasonlóképpen szeretnénk behúzni a menüt, azokon az oldalakon ahol szükség lehet rá, így készítsünk neki egy fragment-et a resources/templates/fragments alá menu.html néven!

A tartalma legyen a következő (induljunk ki egy bootstrap-es példából, majd alakítsuk azt a projektünknek megfelelően):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<nav class="navbar navbar-expand-lg navbar-light bg-light" th:fragment="menu" xmlns:th="http://www.thymeleaf.org">
    <a class="navbar-brand" href="#"><img class="logo" th:src="@{/images/contacts_logo.png}"></a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNavAltMarkup">
        <div class="navbar-nav">
            <a class="nav-item nav-link active" th:href="@{/contact/create}">Create</a>
            <a class="nav-item nav-link" th:href="@{/contact}">List</a>
        </div>
    </div>
</nav>

A navigációban két link szerepel szerepel majd, az egyik a create-re mutat, melyet már elkészítettünk, a másik pedig a /contact-ra, mely a listázásért lesz felelős (később készítjük el).

Ezután a menu fragment-et a contact-create.html-ben a következőképpen használhatjuk!

1
<div th:replace="fragments/menu :: menu"></div>

Egy dolog azonban még hiányozhat, mégpedig a nav-on belüli most a 'Create' tagjére van elhelyezve az active class, mely segítségével a Bootstrap más formázást ad az éppen aktív oldalnak.

A menu fragment fogadjon egy current nevű paramétert, melyet felhasználunk a fragment-ben és csak a paraméterül kapott menüelemre aggatjuk rá az active class-ot!

A megoldásban segítségünkre jön, ha ismerjük a következőket:

  • th:class
  • th:classappend

Ezen attribútumok segítségével feltételtől függően határozhatjuk meg egy elem class attribútumát (feltétel nélkül is használhatjuk, de arra ott a sima class attribútum). Például:

1
<div th:class="${param} == 'Valami' ? 'class-ha-igaz' : 'class-ha-hamis' ">...</div>

A th:class a teljes class attribútumot átírja, viszont a th:classappend hozzáfűzi az eddigiekhez a megadott értéket.

Nézzük is a módosított menu fragment-et:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<nav class="navbar navbar-expand-lg navbar-light bg-light" th:fragment="menu(current)" xmlns:th="http://www.thymeleaf.org">
    <a class="navbar-brand" href="#"><img class="logo" th:src="@{/images/contacts_logo.png}"></a>
    <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNavAltMarkup" aria-controls="navbarNavAltMarkup" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="navbarNavAltMarkup">
        <div class="navbar-nav">
            <a class="nav-item nav-link" th:classappend="${current} == 'Create' ? 'active' : ''" th:href="@{/contact/create}">Create</a>
            <a class="nav-item nav-link" th:classappend="${current} == 'List' ? 'active' : ''" th:href="@{/contact}">List</a>
        </div>
    </div>
</nav>

Az első sorban a fragment kap egy current paramétert, mely alapján a 8. és a 9. sorban eldöntjük, hogy melyik menüelem legyen kiemelve.

Miután ez megvan a contact-create.html-ben szereplő felhasználást is módosítsuk:

1
<div th:replace="fragments/menu :: menu (current='Create')"></div>

Létrehozás finomítása

Jelen esetben, ha hozzáadunk egy új Contact-ot az alkalmazáshoz, akkor azt szimplán logoljuk. Ennél többre lesz szükségünk a normál működéshez.

Készítsük el a Repository rétegünket, mely az adatok tárolásáért felelős.

Ehhez először hozzuk létre a hu.suaf.contacts.repository package-t, majd helyezzük el bele a következőket:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package hu.suaf.contacts.repository;

import hu.suaf.contacts.model.Contact;
import org.springframework.stereotype.Repository;

import java.util.ArrayList;
import java.util.List;

@Repository
public class ContactRepository {

    private static long currentId = 0L;
    private List<Contact> contacts = new ArrayList<>();

    public void addContact(Contact c){
        c.setId(currentId++);
        contacts.add(c);
    }

}

A ContactRepository-n helyezzük el a @Repository annotációt, mellyel jelezzük, hogy az adatok tárolásáért felel ez a komponens! Egyszerűen a memóriában tároljuk jelenleg a Contact-ok egy listáját.

Ennek függvényében át kell alakítanunk a ContactController-t is, viszont jobb ha már most egy további réteget is behozunk a rendszerbe, melyet szokás service rétegnek nevezni. A service rétegben adjuk meg majd az összes üzleti logikát, ami egy telefonkönyv alkalmazásnál nem féltétlen a legkomplexebb, de magát a koncepciót jól be tudjuk mutatni rajta. Készítsük el a hu.suaf.contacts.service package-t, melyen belül a ContactService a következőképpen néz ki:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package hu.suaf.contacts.service;

import hu.suaf.contacts.model.Contact;
import hu.suaf.contacts.repository.ContactRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@Slf4j
public class ContactService {

    private ContactRepository contactRepository;

    @Autowired
    public void setContactRepository(ContactRepository contactRepository) {
        this.contactRepository = contactRepository;
    }

    public void addContact(Contact c){
        contactRepository.addContact(c);
        log.info("Contact added: " + c.toString());
    }

}

Jelen pillanatban, annyit csinálunk, hogy a contactRepository-t injektáljuk, illetve az addContact-ban továbbítjuk a kérést a repository-nak.

Ezután a ContactController createContact metódusát a következőképpen módosítjuk:

1
2
3
4
5
@PostMapping("/create")
public String createContact(Contact contact){
    contactService.addContact(contact);
    return "redirect:/contact";
}

természetesen immár a contactService-t be kell injektálnunk ebbe az osztályba (mondjuk egy setter injektálással):

1
2
3
4
5
6
private ContactService contactService;

@Autowired
public void setContactService(ContactService contactService) {
    this.contactService = contactService;
}

Kontaktok listázása

Nézzük mire van szükségünk ahhoz, hogy a kontaktokat kilistázzuk egy újabb oldalon! Elsőként szükségünk van a kontaktok listájának lekérésére a ContactRepostiry-ban:

1
2
3
public List<Contact> getContacts(){
    return contacts;
}

A fenti kód egyszerűen visszaadja a memóriában tárolt Contact objektumok listáját.

Hasonlóan a ContactService csak továbbítja a kérést a repository felé:

1
2
3
public List<Contact> getContacts(){
    return contactRepository.getContacts();
}

Ezek után nézzük, hogy a ContactController mit is kell, hogy csináljon:

1
2
3
4
5
@GetMapping
public String listOfContacts(Model model){
    model.addAttribute("contacts", contactService.getContacts());
    return "home";
}

Amikor a /contact URL-re érkezik kérés (az osztályon a @RequestMapping("/contact") annotáció szerepel), akkor lekérjük a kontaktok listáját melyet a model számára átadunk mint attribútum (contacts néven), melyet így aztán a view-ban fel tudunk használni. Miután ezt a beállítást megtettük átadjuk a vezérlést a home.html template-nek, hogy megjeleníthesse a generált HTML tartalmat a böngészőben.

A home.html eddig csak egy képet tartalmazott, melyet most teljesen lecserélünk, hiszen szeretnénk behúzni a header és a menu fragment tartalmakat is. illetve magukat a kontaktokat megjeleníteni.

Ennek tükrében a home.html tartalma a következőképpen alakulhat:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<html lang="en" xmlns:th="http://www.thymeleaf.org">

<div th:replace="fragments/header :: header (title='List Contacts')"></div>
<body>

<div th:replace="fragments/menu :: menu (current='List')"></div>

<div class="container">
    <table class="table">
        <thead>
        <tr>
            <th scope="col">Name</th>
            <th scope="col">Phone</th>
            <th scope="col">Email</th>
            <th scope="col">Address</th>
            <th scope="col">Birth Date</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="contact: ${contacts}">
            <td th:text="${contact.name}"></td>
            <td th:text="${contact.phone}"></td>
            <td th:text="${contact.email}"></td>
            <td th:text="${contact.address}"></td>
            <td th:text="${contact.birthDate}"></td>
        </tr>
        </tbody>
    </table>
</div>

</body>
</html>

A kódban az újdonság a th:each alkalmazása, mellyel a Thymeleaf-ben iterálhatunk végig egy-egy kollekción, pontosabban a következőkön lehet alkalmazni a th:each-et:

  • objektumok, amik implementálják a java.util.Iterable interfészt
  • objektumok, amik implementálják a java.util.Map interfészt
  • tömbök

A ContactController-ben átadott modell attribútumot a ${contacts}-el érhetjük el, mely tartalmát rendre felveszi a contact változó.

Az iterálás során használhatunk egy status változót is, mely mindenféle hasznos infot ad az iteráció során:

  • index: az aktuális index (0-tól kezdődően)
  • count: az eddig feldolgozott elemek száma
  • size: az iterált objektumban lévő elemek száma
  • even/odd: az iteráció indexe páros/páratlan
  • first: ellenőrzésre szolgál, hogy az aktuális iteráció az első-e
  • last: ugyanaz, mint az előző csak az utolsó elemre

Példa a használatára:

1
<tr th:each="contact, status: ${contacts}" th:style="${status.odd} ? 'font-weight: bold;' " >

Ennek eredményeképpen minden páratlan indexű sor félkövérrel lesz formázva. Figyeljük meg, hogy a th:each-en belül vesszővel elválasztva deklaráljuk a status változót. Amennyiben a each-n belül vesszővel megadunk egy további változót is, akkor az lesz a status változónk. Amennyiben nem definiáljuk külön, hogy milyen néven szeretnénk elérni az iteráció státuszát, akkor is létrejön egy implicit változó, melyet a rendszer a következő néven hoz létre: iterációs változó (contact) neve + Stat postfix. Jelen esetben tehát contactStat lett volna a neve ennek a státusz változónak.

Ezek után próbáljuk is ki a funkciókat!

Törlés

Miután tudunk létrehozni és listázni, jól jön, ha egy-egy kontaktot tudunk törölni is. A ContactRepository képességeinek bővítéséhez a következőket tehetjük:

1
2
3
public void deleteContact(long id){
    contacts.removeIf(contact -> contact.getId() == id);
}

A ContactService ebben az esetben is csak továbbadja a kérésünket:

1
2
3
4
public void deleteContact(long id){
    contactRepository.deleteContact(id);
    log.info("Contact deleted with id: " + Long.toString(id));
}

A törlést id alapján valósítottuk meg. A törlésnek nem kell külön lapot készítenünk, azt a listázás közben egy további oszlopban jelenítjük majd meg. Először nézzük meg magát a ContactController-t, hogy hogyan kezeli a törlésre kapott kérést:

1
2
3
4
5
@GetMapping("/delete/{id}")
public String deleteContact(@PathVariable long id){
    contactService.deleteContact(id);
    return "redirect:/contact";
}

Az első érdekes dolog a @PathVariable és a hozzá tartozó id paraméter az URL-ben (/delete/{id}). Az URL-ben a paramétereket mindig a {...} jelek közé írjuk és amit a kapcsos zárójelek közé írunk az lesz a paraméter neve. Ez azért fontos, mert név alapján történik a megfeleltetés, azaz a deleteContact(@PathVariable long id)-ban megadott id név párt alkot az URL-ben megadott {id}-val. Amennyiben nem használjuk ezt a lehetőséget, akkor a @PathVariable-ben kell megadjuk az URL-ben szereplő paraméter nevét.

Például:

1
public String deleteContact(@PathVariable("param_name_in_url") long id){...}

A deleteContact többi része nem jelenthet problémát, hiszen az csak továbbítja a kérést a contactService felé.

Ezután alakítsuk ki a template-et úgy, hogy az utolsó oszlopban, legyen egy kis szemetes kuka, melyre kattintva a /contact/delete/{id}-ra küldjünk egy kérést.

Megjegyzés

Mivel nem tudunk DELETE kérést küldeni, így muszáj vagyunk egy GET kéréssel elrendezni ezt a dolgot, mely jelen esetben egy egyszerű linkre való kattintással történik.

A home.html-t bővítsük úgy, hogy annak fejlécébe bekerüljön egy extra oszlop, melynek neve Actions!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<thead>
    <tr>
        <th scope="col">Name</th>
        <th scope="col">Phone</th>
        <th scope="col">Email</th>
        <th scope="col">Address</th>
        <th scope="col">Birth Date</th>
        <th scope="col">Actions</th>
    </tr>
</thead>

A táblázat body részében pedig adjuk meg a kuka ikont egy linken belül, melyet ha megnyom a felhasználó, akkor a delete URL-re megyünk:

1
2
3
4
5
6
7
<td>
    <a th:href="@{/contact/delete/{id}(id=${contact.id})}"><svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-trash" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
    <path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
    <path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4L4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
    </svg>
    </a>
</td>

Az svg ikont a Bootstrap Icons oldalról szereztem be. Természetesen használható a <i class="bi bi-trash"></i> forma is csak ekkor a header fragment-be be kell illeszteni a megfelelő CSS-t, illetve más ikon forrás is alkalmazható, pl. Font Awesome.

Amit érdemes megfigyelni, hogy az alkalmazás context root-hoz megadott útvonalban, hogyan használhatok paramétereket: th:href="@{/contact/delete/{id}(id=${contact.id})}. Itt az eddig megszokott módon megy minden, aztán következik a {id} az URL-ben, ami igencsak emlékeztet a controller-ben használt paraméter megadáshoz. Ezután a fragment-eknél is látott paramétermegadási módot használhatjuk, azaz kerek zárójelek között megadjuk az összes paramétert név=érték felsorolásban.

Figyelem

A th:href="@{/contact/delete/${contact.id}}" nem fog működni!!!

Módosítás

A módosításnál néhány trükk elő fog még jönni. Első körben nézzük megint csak a ContactRepository-t, melyet a következő metódusokkal bővítettünk:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public Contact getContactById(long id){
    Optional<Contact> c = contacts.stream().filter(contact -> contact.getId() == id).findAny();
    return c.orElse(null);
}

public void saveContact(Contact c) {
    Optional<Contact> existing = contacts.stream().filter(contact -> contact.getId().equals(c.getId())).findAny();
    if (existing.isPresent()){
        existing.get().setAddress(c.getAddress());
        existing.get().setBirthDate(c.getBirthDate());
        existing.get().setEmail(c.getEmail());
        existing.get().setName(c.getName());
        existing.get().setPhone(c.getPhone());
    }
}

Először is biztosítunk egy lekérdezést, ami id alapján megkeres egy Contact-ot. A második a létező kontakt módosítása/updatelése a kapott adatokkal. Ebben az esetben az id-t használjuk fel, mint biztos pont.

A ContactService továbbra is nagyon egyszerűen csak továbbítja a kéréseinket:

1
2
3
4
5
6
7
public Contact getContactById(long id) {
    return contactRepository.getContactById(id);
}

public void saveContact(Contact c) {
    contactRepository.saveContact(c);
}

Itt kezdenek a dolgok egy kicsit érdekesebbé válni. A ContactController új metódusai:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@GetMapping("/edit/{id}")
public String editContactForm(@PathVariable long id, Model model){
    Contact c = contactService.getContactById(id);
    model.addAttribute("contact", c);
    return "contact-create";
}

@PostMapping("/edit/{id}")
public String editContact(Contact c){
    contactService.saveContact(c);
    return "redirect:/contact";
}

A kontaktok listájában a delete mellé egy kis szerkesztő ikont is elhelyezünk, melyre kattintva bejön a kontakt szerkesztő oldal, aminek nyilván biztosítani kell majd a meglévő adatokat, hiszen ezeket szeretné szerkeszteni a felhasználó. Ezeket el is helyezzük a model objektumban mint attribútum. Ezután viszont nem egy új oldalra kalandozunk, hanem magára a létrehozó oldalra megyünk. Mivel a kontaktot beállítottuk attribútumként és a template pontosan egy contact nevű attribútummal dolgozik, így a meglévő értékeket ki is tudja olvasni a rendereléskor (azaz inicializálja, feltölti a HTML inputokat ezekkel az értékekkel).

POST kéréskor egyszerűen csak meghívjuk a service réteget, majd átirányítjuk a felhasználót a listázó oldalra.

Végül lássuk a home.html ide vonatkozó részét!

1
2
3
4
5
<a th:href="@{/contact/edit/{id}(id=${contact.id})}">
    <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-pencil" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
        <path fill-rule="evenodd" d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5L13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175l-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/>
    </svg>
</a>

Az ikon megint egy svg, de lényeg ismét a th:href-ben található, ahol a törléshez hasonló módon adtuk meg a paraméterezést.

Van azonban még egy probléma: a create és az edit is a contact-create.html-re visz, de akkor ott honnan tudjuk, hogy hova küldjünk kérést (új létrehozása, vagy létező mentése)?

Nézzük a trükköt:

1
<form action="#" th:action="${contact.id} == null ? @{/contact/create} : @{/contact/edit/{id}(id=${contact.id})}" th:object="${contact}" method="post">

Itt megvizsgáljuk, hogy az id-ja adott-e a contact objektumnak. Amennyiben az null, akkor a /contact/create-re megyünk, a másik esetben viszont a /contact/edit/{id} URL-re küldjük a POST kérést.

Egyéb dolgokat is tehetünk ettől függővé. Például a form submit gombjának szövegét:

1
<button type="submit" class="btn btn-primary" th:text="${contact.id != null ? 'Save' : 'Submit'}">Submit</button>

Form Validation

Jelenleg új kontakt létrehozása esetén nincs semmilyen jellegű input validáció. A felhasználó, ha úgy tartja kedve akkor üresen hagyhatja a Name mezőt, vagy helytelen emailt ad meg, stb. Több opciónk is van az inputok validálására.

Az egyik, hogy a POST kéréshez tartozó controller metódusban validálunk és rengeteg if-el mindent megvizsgálunk. Ez érezhető, hogy nem túl célravezető. Kliens oldalon próbálkozhatunk JavaScript használatával, de az csak kliens oldalon validálna (pl Postman-el bárki küldhetne érvénytelen form-data-t), ami megint csak nem túl jó megoldás (persze kombinálhatjuk szerver oldali validációval).

A Spring szerencsére támogatja a Java Bean Validation API-t. Ez az API, mint látni fogjuk igen kényelmessé teszi a validációt, mivel a szükséges feltételeket, vagy szabályokat közvetlenül a model-jeink field-jeire adhatjuk meg annotáció segítségével. A Spring spring-boot-starter-web dependency automatikusan behúzza azokat a dependency-ket amik ehhez kellenek, így nincs semmi függőség, amivel foglalkoznunk kellene.

Tipp

Ha mégis olyan verzióba botlunk, ahol a szükséges függőségek nem lettek automatikusan behúzva, akkor adjuk hozzá a pom.xml megfelelő részéhez a következőt:

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Így az alkalmazásunkban mindösszesen a következőket kell tennünk:

  • Alakítsuk át a Contact osztályunkat úgy, hogy a megfelelő validációkkal legyenek ellátva a szükséges adatok.
  • Meg kell adnunk azt a pontot ahol a validációt el szeretnénk végezni.
  • Módosítanunk kell a view-t, hogy a validációs üzeneteket megjeleníthessük a felhasználó számára.

Contact osztály validációja

Kezdjük is a kóddal:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Data
public class Contact {

    private Long id;

    @NotEmpty(message = "Name cannot be empty")
    @Size(min = 3, message = "Name must be at least 3 characters long")
    private String name;

    private String phone;

    @Email(message = "Must be a well-formed email")
    private String email;
    private String address;

    @Past(message = "Must be a past date")
    private Date birthDate;

    @PastOrPresent
    private Date createdAt;

}

A 6. sorban megadjuk, hogy a name nem lehet null, továbbá a 7. sor azt is kiköti, hogy legalább 3 hosszúnak kell lennie a névnek. A message részt a view-n fogjuk felhasználni, ugyanis ez fog megjelenni hibaüzenetként, amennyiben az előírást sérti a megadott érték.

Jelen pillanatban a telefonszámra semmilyen megkötést nem teszünk, viszont az email-re ráaggattuk a @Email annotációt, mely valid email felépítést vár el (pl. legyen benne kukac, előtte és utána is legalább egy karakter, stb.). A két dátum típusú adattagra a @Past és a @PastOrPresent annotációkat helyeztük el, melyek rendre azt ellenőrzik majd, hogy a megadott dátum múltbéli, illetve múltbeli vagy éppen a jelen.

Miután ezzel megvagyunk, valahol meg is kell hívnunk magát a validálást. Ezt a Controller-ben érdemes megtennünk, mégpedig a createContact és az editContact metódusokban:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@PostMapping("/edit/{id}")
public String editContact(@Valid Contact c, BindingResult result){
    if (result.hasErrors()) {
        return "contact-create";
    }

    contactService.saveContact(c);
    return "redirect:/contact";
}

@PostMapping("/create")
public String createContact(@Valid Contact contact, BindingResult result){
    if (result.hasErrors()) {
        return "contact-create";
    }

    contactService.addContact(contact);
    return "redirect:/contact";
}

A két metódus egészen hasonló, de más service metódust hívnak. Lényeges a @Valid annotációt észrevenni, hiszen mindössze ennyi szükséges ahhoz, hogy a validáció megtörténjen. A BindingResult paramétert a rendszer automatikusan biztosítja a számunkra. Ezen keresztül elkérhetjük, hogy volt-e hiba a validáció során. Mivel a HTML oldalon szeretnénk kiíratni a hibaüzeneteket a megfelelő helyeken, így az a következőképpen módosul:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<form action="#" th:action="${contact.id} == null ? @{/contact/create} : @{/contact/edit/{id}(id=${contact.id})}" th:object="${contact}" method="post" novalidate>
    <div class="form-group">
        <label for="name">Name</label>
        <input type="text" class="form-control" id="name" th:field="*{name}">
        <span class="validationError" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></span>
    </div>
    <div class="form-group">
        <label for="email">Email</label>
        <input type="email" class="form-control" id="email" th:field="*{email}">
        <span class="validationError" th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
    </div>
    <div class="form-group">
        <label for="phone">Phone</label>
        <input type="text" class="form-control" id="phone" th:field="*{phone}">
    </div>
    <div class="form-group">
        <label for="address">Address</label>
        <input type="text" class="form-control" id="address" th:field="*{address}">
    </div>
    <div class="form-group">
        <label for="birthDate">Birth Date</label>
        <input type="date" class="form-control" id="birthDate" th:field="*{birthDate}">
        <span class="validationError" th:if="${#fields.hasErrors('birthDate')}" th:errors="*{birthDate}"></span>
    </div>
    <button type="submit" class="btn btn-primary" th:text="${contact.id != null ? 'Save' : 'Submit'}">Submit</button>
</form>

Ami érdekes ebben az a th:if="${#fields.hasErrors('email')}" rész. Itt a #fields-et a rendszer automatikusan adja a számunkra, melytől megkérdezhetjük, hogy az aktuális field-en lépett-e fel hiba. Amennyiben igen, akkor a th:errors="*{email}" résszel a span belsejébe bele is írjuk magát a hibaüzenetet, vagy üzeneteket.

A formázásokhoz létrehozhatunk saját CSS-eket, például a resources/static/css/style.css állományt, melynek tartalma:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.logo {
    width: 2rem;
    height: 2rem;
}

nav{
    margin-bottom: 2rem;
}

.validationError{
    color: #ED4337;
}

Ezt pedig a header fragment-en belül tudjuk elhelyezni a megszokott módon:

1
<link rel="stylesheet" th:href="@{/css/style.css}">

Feladat

Próbáljuk ki, hogy az egyes validációs annotációk, illetve azok kombinációi milyen feltételeket szabnak meg - milyen adatokat fogad el a form-unk!

További anyagok

A gyakorlat anyagáról készült videó:

SUAF_02_gyak


Utolsó frissítés: 2021-09-29 18:43:10
Back to top