Spring Boot 2 - JPA - Page - Thymeleaf

Descarga el proyecto de GitHub.

Introducción

Partiendo del proyecto anterior, vamos a realizar un autocompletado, una paginación, lo vamos a internacionalizar y por último realizaremos algunos test para las clases nuevas que vayan surgiendo.

Esta es la página principal (obviamente con mi base de datos) del proyecto:

app run

El Proyecto

La estructura del proyecto

Desglosemos el proyecto en partes.

El modelo
La vista
Los test
application.properties

Ver el proyecto anterior.

Las dependencias

Ver el proyecto anterior.

La Configuración

Para permitir al usuario escoger el idioma en que quiere ver la página debemos cambiar el LocaleResolver de la aplicación y convertirlo en un SessionLocaleResolver. Para ello implementamos la clase MessageConfiguration

MessageConfiguration.java

package com.wanchopi.config;

import java.util.Locale;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
import org.springframework.web.servlet.i18n.SessionLocaleResolver;

@Configuration
public class MessageConfiguration implements WebMvcConfigurer {

  @Bean
    public LocaleResolver localeResolver() {
      SessionLocaleResolver localeResolver = new SessionLocaleResolver();
      localeResolver.setDefaultLocale(Locale.getDefault());
      return localeResolver;
    }

    @Bean
    public LocaleChangeInterceptor localeChangeInterceptor() {
      LocaleChangeInterceptor localeInterceptor = new LocaleChangeInterceptor();
      localeInterceptor.setIgnoreInvalidLocale(true);
      localeInterceptor.setParamName("idioma");
      return localeInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
      registry.addInterceptor(localeChangeInterceptor());
    }
    
}
            
El modelo

Ver el proyecto anterior.

El Controlador

AppController.java

package com.wanchopi.controller;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import com.wanchopi.model.Player;
import com.wanchopi.model.Team;
import com.wanchopi.service.PlayerServiceAPI;
import com.wanchopi.service.TeamServiceAPI;

/**
 * 
 * @author Wanchopi
 *
 */
@Controller
public class AppController {
  
  @Autowired
  private PlayerServiceAPI playerService;
  @Autowired
  private TeamServiceAPI teamService;
  
  @InitBinder
    public void initBinder(WebDataBinder dataBinder) {
        StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
        dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
    }
  
  @RequestMapping(value = "/")
  public String home(@RequestParam Map param, Model model) {
    int page = param.get("page") != null ? (Integer.valueOf(param.get("page").toString()) -1) : 0;
    PageRequest pageRequest = PageRequest.of(page, 5);
    Page pagePlayer = playerService.findAll(pageRequest);
    int totalPage = pagePlayer.getTotalPages();
    if(totalPage > 0) {
      List pages = IntStream.rangeClosed(1, totalPage)
          .boxed()
          .collect(Collectors.toList());
      model.addAttribute("pages", pages);
    }
    model.addAttribute("players", pagePlayer.getContent());
    model.addAttribute("current", page + 1);
    model.addAttribute("next", page + 2);
    model.addAttribute("prev", page);
    model.addAttribute("last", totalPage);
    return "index";
  }
  
  @RequestMapping(value = "/players/{id}")
  public String findByTeam(@PathVariable("id") long id, Model model) {
    Team team = teamService.get(id);
    List players = playerService.findPlayersByTeam(team);
    model.addAttribute("players", players);
    return "/players-team";
  }
  
  @RequestMapping(value = "/search", method = RequestMethod.GET)
  @ResponseBody 
  public List search(HttpServletRequest request) {
    return playerService.findNamePlayer(request.getParameter("term"));
  }
  
  @RequestMapping(value = "/search")
  public String searchPlayerByName(@RequestParam String keyword, Model model) {
    List players = playerService.findByName(keyword);
    model.addAttribute("players", players);
    return "/player";
  }
  
  @RequestMapping(value = "/players/search")
  public String searchPlayerByNameFromPlayers(@RequestParam String keyword, Model model) {
    List players = playerService.findByName(keyword);
    model.addAttribute("players", players);
    return "/player";
  }
  
  @RequestMapping(value = "/edit/search")
  public String searchPlayerByNameReRe(@RequestParam String keyword, Model model) {
    return "redirect:/?q= " + keyword;
  }
  
  
  @GetMapping(value = "/create")
  public String showForm(ModelMap mp) {
    mp.put("player", new Player());
    mp.put("teams", teamService.getAll());
    return "/player-form";
  }
  
  @PostMapping(value = "/save")
  public String save(@Valid Player player, BindingResult br, ModelMap mp) {
    if (br.hasErrors()) {
      mp.put("teams", teamService.getAll());
      return "/player-form";
    } else {
      playerService.save(player);
      return "redirect:/";
    } 
  }
  
  @GetMapping(value = "/edit/{id}")
  public String showEditForm(@PathVariable("id") long id, ModelMap mp) {
    Player player = playerService.get(id);
    mp.put("player", player);
    mp.put("teams", teamService.getAll());
    return "/update-form";
  }
  
  @PostMapping(value = "/update")
  public String update(@Valid Player player, BindingResult br) {
    if (br.hasErrors()) {
      return "/update-form";
    } else {
      playerService.save(player);
      return "redirect:/";
    }
  }
  
  @GetMapping(value = "/delete/{id}")
  public String delete(@PathVariable("id") long id) {
    playerService.delete(id);
    return "redirect:/";
  }

}
            

Atención al método home(), pues es el de la paginación.

El Repositorio
PlayerRepository.java

package com.wanchopi.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.wanchopi.model.Player;
import com.wanchopi.model.Team;

/**
 * Player repository
 * 
 * @author Wanchopi
 *
 */
@Repository
public interface PlayerRepository extends JpaRepository<Player, Long>{
  
  @Query("SELECT p FROM Player p WHERE p.name like %:keyword%")
  public List<Player> searchPlayers(@Param("keyword") String keyword);
  
  @Query("SELECT name FROM Player WHERE name like %:keyword%")
  public List<String> search(@Param("keyword") String keyword);
  
  @Query("SELECT p FROM Player p WHERE p.team like :team")
  public List<Player> findByTeam(Team team);
  
}
            

TeamRepository.java

package com.wanchopi.repository;

import java.util.List;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import com.wanchopi.model.Team;

/**
 * Team repository
 * 
 * @author Wanchopi
 *
 */
@Repository
public interface TeamRepository extends JpaRepository<Team, Long>{
  
  @Query("SELECT name FROM Team where name like %:keyword%")
  public List<String> search(@Param("keyword") String keyword);

}
            

Aparte de los métodos que heredamos de JpaRepository necesitamos implementar nuestros propios métodos para el autocompletado.

El Service

Vamos a utilizar el concepto Java Generic Service muy utilizado cuando trabajamos con tecnologías de persistencia.

GenericServiceAPI.java

package com.wanchopi.service;

import java.io.Serializable;
import java.util.List;

/**
 * GenericServiceAPI, interface where the basic methods are declared.
 * 
 * @author Wanchopi
 *
 * @param <T>
 * @param <ID>
 */
public interface GenericServiceAPI<T, ID extends Serializable> {
  
  T save(T entity);
  void delete(ID id);
  T get(ID id);
  List<T> getAll();

}
            
GenericServiceImpl.java

package com.wanchopi.service;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;

/**
 * GenericServiceImpl, implements the GenericServiceAPI interface
 * 
 * @author Wanchopi
 *
 * @param <T>
 * @param <ID>
 */
@Service
public abstract class GenericServiceImpl<T, ID extends Serializable> implements GenericServiceAPI<T, ID> {

  @Override
  public T save(T entity) {
    return getRepository().save(entity);
  }

  @Override
  public void delete(ID id) {
    getRepository().deleteById(id);
  }

  @Override
  public T get(ID id) {
    Optional<T> obj = getRepository().findById(id);
    if(obj.isPresent()) return obj.get();
    return null;
  }

  @Override
  public List<T> getAll() {
    List<T> list = new ArrayList<>();
    getRepository().findAll().forEach(obj -> list.add(obj));
    return list;
  }
  
  public abstract JpaRepository<T, ID> getRepository();

}              
            
PlayerServiceAPI.java

package com.wanchopi.service;

import java.util.List;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;

import com.wanchopi.model.Player;
import com.wanchopi.model.Team;

/**
 * PlayerServiceImpl, Player model's own interface
 * 
 * @author Wanchopi
 *
 */
public interface PlayerServiceAPI extends GenericServiceAPI<Player, Long>{
  
  public List<Player> findPlayersByTeam(Team team);
  
  public List<String> findNamePlayer(String keyword);
  
  public List<Player> findByName(String keyword);
  
  public Page<Player> findAll(PageRequest pageRequest);

}              
            
PlayerServiceImpl.java

package com.wanchopi.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;

import com.wanchopi.model.Player;
import com.wanchopi.model.Team;
import com.wanchopi.repository.PlayerRepository;

/**
 * PlayerServiceImpl, implements the PLayerServiceAPI interface
 * 
 * @author Wanchopi
 *
 */
@Service
public class PlayerServiceImpl extends GenericServiceImpl<Player, Long> implements PlayerServiceAPI {
  
  @Autowired
  private PlayerRepository playerRepository;
  
  @Override
  public JpaRepository<Player, Long> getRepository() {
    return playerRepository;
  }

  @Override
  public Page<Player> findAll(PageRequest pageable) { 
    return playerRepository.findAll(pageable);
  }

  @Override
  public List<String> findNamePlayer(String keyword) {
    List<String> players = playerRepository.search(keyword);
    return players;
  }

  @Override
  public List<Player> findByName(String keyword) {
    List<Player> players = playerRepository.searchPlayers(keyword);
    return players;
  }

  @Override
  public List<Player> findPlayersByTeam(Team team) {
    List<Player> players = playerRepository.findByTeam(team);
    return players;
  }

}              
            
TeamServiceAPI.java

package com.wanchopi.service;

import java.util.List;

import com.wanchopi.model.Team;

/**
 * TeamServiceAPI, Team model's own interface
 * 
 * @author Wanchopi
 *
 */
public interface TeamServiceAPI extends GenericServiceAPI<Team, Long>{
  
  public List<String> findNameTeams(String keyword);

}              
            
TeamServiceImpl.java

package com.wanchopi.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Service;

import com.wanchopi.model.Team;
import com.wanchopi.repository.TeamRepository;

/**
 * TeamServiceImpl, implements the TeamServiceAPI interface
 * 
 * @author Wanchopi
 *
 */
@Service
public class TeamServiceImpl extends GenericServiceImpl<Team, Long> implements TeamServiceAPI {
  
  @Autowired
  private TeamRepository teamRepository;

  @Override
  public List<String> findNameTeams(String keyword) {
    List<String> teams = teamRepository.search(keyword);
    return teams;
  }

  @Override
  public JpaRepository<Team, Long> getRepository() {
    return teamRepository;
  }

}              
            

Testing

PlayerRepositoryTest.java

package com.wanchopi.repository;

import static org.junit.Assert.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;

import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.junit4.SpringRunner;

import com.wanchopi.model.Player;
import com.wanchopi.model.Team;

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
public class PlayerRepositoryTest {
  
  @Autowired
  private PlayerRepository playerRepository;
  
  @Autowired
  private TeamRepository teamRespository;
    
  @Test
  public void testSearchPlayers() {
    String keyword = "gasol";
    List<Player> players = playerRepository.searchPlayers(keyword);
    // not null
    assertNotNull(players);
    // two players
    assertEquals(2, players.size());
    keyword = "lll";
    List<Player> players2 = playerRepository.searchPlayers(keyword);
    // not null
    assertNotNull(players2);
    // zero players
    assertEquals(0, players2.size());
  }
  
  @Test
  public void testFindByTeam() {
    List<Team> teams = teamRespository.findAll();
    Team team = teams.get(0); 
    List<Player> players = playerRepository.findByTeam(team);
    // three players
    assertEquals(3, players.size());
  }

}
            
PlayerServiceTest.java

package com.wanchopi.service;

import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.when;

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

import org.junit.Before;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.boot.test.context.SpringBootTest;

import com.wanchopi.model.Player;
import com.wanchopi.model.Team;
import com.wanchopi.repository.PlayerRepository;

@SpringBootTest
public class PlayerServiceTest {
  
  @InjectMocks
  private PlayerServiceImpl playerService;
  
  @Mock
  private PlayerRepository playerRepository;
  
  @Before
    public void init() {
        MockitoAnnotations.initMocks(this);
    }
  
  @Test
  public void testFindNamePlayer() {
    List<Player> players = new ArrayList<Player>();
    Player player1 = new Player(1L, "player1", "player1@gmail.com", new Team());
    Player player2 = new Player(2L, "player2", "player2@gmail.com", new Team());
    players.add(player1);
    players.add(player2);

    when(playerRepository.searchPlayers("gasol")).thenReturn(players);
    assertEquals(players.size(), playerService.findByName("gasol").size());
  }
  
}
            
AppControllerTest.java

package com.wanchopi.controllers;

import static org.assertj.core.api.Assertions.assertThat;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;

import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

import com.wanchopi.repositories.PlayerRepository;
import com.wanchopi.repositories.TeamRepository;


@RunWith(SpringRunner.class)
@WebMvcTest(AppController.class)
@AutoConfigureMockMvc
public class AppControllerTest {
  
  @Autowired
  private MockMvc mockMvc;
  
  @Autowired
  private AppController controller;
  
  @MockBean
  private TeamRepository teamRepository;
  
  @MockBean
  private PlayerRepository playerRepository;
  
  @Test
  public void contexLoads() throws Exception {
    assertThat(controller).isNotNull();
  }
  
  @Test
  public void testHome() throws Exception {
    this.mockMvc.perform(get("/"))
      .andExpect(status().isOk())
      .andDo(print())
      .andExpect(view().name("index"));
  }
  
}
            
Las vistas
header.html

<header th:fragment="header" xmlns:th="http://www.w3.org/1999/xhtml">
  <!-- Static navbar -->
  <nav class="navbar navbar-light bg-light">
    <a th:href="@{/}"><img alt="logo" th:src="@{/assets/images/mini-logo.png}"></a>
    <form class="form-inline my-2 my-lg-0" action="#" method="post">
        <input class="form-control mr-sm-2" type="text" name="keyword" placeholder="Team search" 
        aria-label="Search" id="teamName">
        <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
      </form>
  </nav>
  <!-- header -->
  <div class="container">
    <div class="row">
      <div class="col-lg-12" align="center">
        <img alt="logo" th:src="@{/assets/images/main-logo.png}">
      </div>
    </div>
  </div>
</header>
            
footer.html

<div th:fragment="footer" class="footer" xmlns:th="http://www.w3.org/1999/xhtml">
    <div class="container">
        <p><span th:text="#{text.footer}" class="text-muted">App footer</span></p>
    </div>
</div>
            
header-error.html

<header th:fragment="header" xmlns:th="http://www.w3.org/1999/xhtml">
  <!-- header -->
  <div class="container">
    <div class="row">
      <div class="col-lg-12" align="center">
        <img alt="logo" th:src="@{/assets/images/main-logo.png}">
      </div>
    </div>
  </div>
</header>
            
layout.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <title layout:title-pattern="$DECORATOR_TITLE - $CONTENT_TITLE">SpringBootThymeleaf</title>
  <meta
    content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
    name="viewport" />
  <link rel="stylesheet" type="text/css" href="webjars/bootstrap/4.1.0/css/bootstrap.min.css" />
  <link rel="stylesheet" type="text/css" href="/webjars/jquery/3.4.1/jquery.min.css" />
  <link rel="stylesheet" type="text/css" href="webjars/jquery-ui/1.12.1/jquery-ui.min.css" />
  <link rel="stylesheet" type="text/css" href="webjars/jquery-ui-themes/1.12.1/blitzer/theme.css" /> 
  <link rel="stylesheet" type="text/css" href="webjars/font-awesome/5.11.2/css/all.min.css" /> 
  <link rel="stylesheet" type="text/css" th:href="@{/assets/css/main.css}"/>
</head>

<body>
  <!--header.html  that's here  -->
  <header>
    <div th:replace="fragments/header :: header"></div>
  </header>
  
  <div layout:fragment="content">
    <!-- Your Page Content Here -->
  </div>

  <!--footer.html  that's here  -->
  <footer>
    <div th:replace="fragments/footer :: footer"></div>
  </footer>

  <!-- JS -->
  <script type="text/javascript" src="/webjars/bootstrap/4.1.0/js/bootstrap.min.js"></script>
  <script type="text/javascript" src="/webjars/jquery/3.4.1/jquery.min.js"></script>
    <script type="text/javascript" src="/webjars/jquery-ui/1.12.1/jquery-ui.min.js"></script>
    <script type="text/javascript">
    $(document).ready(function () {
      $('.fa-times').css('color', '#ff0000');
      $('.fa-edit').css('color', '#0000ff');
    });
  </script>
  <!-- blinking effect -->
  <script>
  $(document).ready(function blink() {
    $('#a-save').fadeIn(750).delay(250).fadeOut(1000, blink);
  });
  </script>
  <script>
  $(document).ready(function() {
    $('#name').focus();
  });
  </script>
</body>
</html>
            
layout-error.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org"
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
  <meta charset="utf-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <title layout:title-pattern="$DECORATOR_TITLE - $CONTENT_TITLE">SpringBootThymeleaf</title>
  <meta
    content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
    name="viewport" />
  <link rel="stylesheet" type="text/css" href="webjars/bootstrap/4.1.0/css/bootstrap.min.css" />
  <link rel="stylesheet" type="text/css" th:href="@{/assets/css/main.css}"/>
</head>
<body>

  <!--header.html  that's here  -->
  <header>
    <div th:replace="fragments/header-error :: header"></div>
  </header>

  <div layout:fragment="content">
    <!-- Your Page Content Here -->
  </div>
  
  <!--footer.html  that's here  -->
  <footer>
    <div th:replace="fragments/footer :: footer"></div>
  </footer>

</body>
</html>
            
index.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org" 
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layouts/layout">
<head>
  <title>index :: n.b.a.</title>
</head>
<body>
  <!-- body -->
  <div layout:fragment="content">
    <div class="container-fluid" align="center">  
      <div class="col-lg-9" style="margin-top: 50px;">
        <div th:if="${!players.isEmpty()}">
          <table class="table">
            <thead class="thead-dark">
              <tr align="center">
                <th scope="col">ID</th>
                <th scope="col">NAME</th>
                <th scope="col">E-MAIL</th>
                <th scope="col">TEAM</th>
                <th scope="col">EDIT</th>
                <th scope="col">DELETE</th>
              </tr>
            </thead>
            <tbody>
              <tr align="center" th:each="player: ${players}">
                <td th:text="${player.id}"></td>
                <td th:text="${player.name}"></td>
                <td th:text="${player.email}"></td>
                <td th:text="${player.team.name}"></td>
                <td><a th:href="@{/edit/{id}(id=${player.id})}"><i class="fa fa-edit"></i></a></td>
                <td><a th:href="@{/delete/{id}(id=${player.id})}"><i class="fa fa-times"></i></a></td>
              </tr>
            </tbody>
          </table>
        </div>
        <div th:unless="${!players.isEmpty()}">
          <p>no items to show</p> 
        </div>
        <a class="a-save" id="a-save" th:href="@{/create}">save a new Player</a>
      </div>
    </div>
  </div> 

</body>
</html>
            
player-form.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org" 
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layouts/layout">
<head>
  <!-- fonts googleapis-->
  <link href="https://fonts.googleapis.com/css?family=Raleway&display=swap" rel="stylesheet">
  <title>form :: springboot</title>
</head>
<body>
  <!-- body -->
  <div layout:fragment="content">
    <div class="container-fluid" align="center">  <!-- main container -->
      <div class="col-lg-9 player-form">
        <form th:action="@{save}" th:object="${player}" method="post" id="player-form"> 
          <!-- name -->
          <div class="form-group">
            <label for="name" class="col-lg-4 control-label font-weight-bold">Name</label>
            <div class="col-lg-4">
              <input type="text" th:field="*{name}" class="form-control" id="name" placeholder="Name" required="required"/>
              <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="alert text-danger"></span>
            </div>
          </div>
          <!-- email -->
          <div class="form-group">
            <label for="email" class="col-lg-4 control-label font-weight-bold">Email</label>
            <div class="col-lg-4">
              <input type="text" th:field="*{email}" class="form-control" id="email" placeholder="Email" required="required"/>
              <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="alert text-danger"></span> 
            </div>
          </div>
          <!-- team -->
            <div class="form-group">
              <label for="team" class="col-lg-4 control-label font-weight-bold">Team</label>
              <div class="col-lg-4" align="left">
                <select th:field="*{team}" class="form-control">
                  <option value=""> Select team </option>
                  <option th:each="team : ${teams}"
                    th:value="${team.id}"
                    th:text="${team.name}"
                    th:selected="false"
                  />
                </select>
                <span th:if="${#fields.hasErrors('team')}" th:errors="*{team}" class="alert text-danger"></span> 
            </div>
          </div>
          <!-- Button -->
          <div class="form-group">
            <div class="col-lg-4" align="left">
              <input type="submit" class="btn" value="Save"> 
            </div>
          </div>
        </form>
      </div>
    </div> <!-- /main container -->
  </div> <!-- /content -->
</body>
</html>
            
update-form.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org" 
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layouts/layout">
<head>
  <!-- fonts googleapis-->
  <link href="https://fonts.googleapis.com/css?family=Raleway&display=swap" rel="stylesheet"> 
  <title>form :: springboot</title>
</head>
<body>
  <!-- body -->
  <div layout:fragment="content">
    <div class="container-fluid" align="center">  <!-- main container -->
      <div class="col-lg-9 player-form">
        <form th:action="@{/update}" th:object="${player}" method="post" id="player-form">
          <!-- id -->
          <div class="form-group">
            <label for="id" class="col-md-4 control-label font-weight-bold">ID</label>
            <div class="col-md-4">
              <input type="text" th:field="*{id}" class="form-control" id="id" placeholder="" readonly="readonly"/>
              <span th:if="${#fields.hasErrors('id')}" th:errors="*{id}" class="alert text-danger"></span>
            </div>
          </div>
          <!-- name -->
          <div class="form-group">
            <label for="name" class="col-md-4 control-label font-weight-bold">Name</label>
            <div class="col-md-4">
              <input type="text" th:field="*{name}" class="form-control" id="name" placeholder="" required="required"/>
              <span th:if="${#fields.hasErrors('name')}" th:errors="*{name}" class="alert text-danger"></span>
            </div>
          </div>
          <!-- email -->
          <div class="form-group">
            <label for="email" class="col-md-4 control-label font-weight-bold">Email</label>
            <div class="col-md-4">
              <input type="text" th:field="*{email}" class="form-control" id="email" placeholder="" required="required"/>
              <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}" class="alert text-danger"></span> 
            </div>
          </div>
          <!-- team -->
            <div class="form-group">
              <label for="team" class="col-md-4 control-label font-weight-bold">Team</label>
              <div class="col-lg-4" align="left">
                <select th:field="*{team}" class="form-control">
                  <option value=""></option>
                  <option th:each="team : ${teams}"
                    th:value="${team.id}"
                    th:text="${team.name}"
                    th:selected="false"
                  />
                </select>
                <span th:if="${#fields.hasErrors('team')}" th:errors="*{team}" class="alert text-danger"></span>  
            </div>
          </div>
          <!-- Button -->
          <div class="form-group">
            <div class="col-md-4" align="left">
              <input type="submit" class="btn" value="Update"> 
            </div>
          </div>
        </form>
      </div>
    </div> <!-- /main container -->
  </div>
</body>
</html>
            
error.html

<!DOCTYPE html>
<html 
  xmlns="http://www.w3.org/1999/xhtml"
  xmlns:th="http://www.thymeleaf.org" 
  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
  layout:decorator="layouts/layout-error">
<head>
  <title>error :: n.b.a.</title>
</head>
<body>
  <div layout:fragment="content">
    <div class="container-fluid" align="center">
      <h1>Custom error page</h1>
      
      <div class="messages-error"> 
        <ul>
          <li><span>Status : </span><span th:text="${status}">status</span></li>
              <li><span>Error : </span><span th:text="${error}">error</span></li>
              <li><span>Message : </span><span th:text="${message}">message</span></li>
              <li><span>Path : </span><span th:text="${path}">path</span></li>
              <!-- <li><span>Trace : </span><span th:text="${trace}">trace</span></li> -->
        </ul>
      </div>  
      
      <a id="a-error" th:href="@{/}"> - Return main page - </a>
    </div>
  </div>
</body>
</html>
            

De esta manera tenemos una aplicación totalmente funcional.

La página principal imprime hasta un máximo de cinco jugadores, permite borrar jugadores, editar jugadores, posee un autocompletado totalmente funcional, una paginación y permite al usuario escoger entre dos idiomas.


Herramientas y Software

  • Eclipse Version: 2019-03 (4.11.0)
  • Java SE-1.8 (jdk1.8.0)
  • Spring Boot 2.2.4
  • Spring MVC 5.2.3
  • thymeleaf 3.0.11
  • thymeleaf-layout-dialect 2.4.1
  • mysql 8.0.19
  • bootstrap 4.1.0
  • jquery 3.4.1
  • jquery-ui 1.12.1
  • jquery-ui-themes 1.12.1
anterior

Spring Boot 2 - JPA

siguiente

Spring Boot 2 - Web Service