java - example - ¿Cómo enlazar una lista de objetos con thymeleaf?
thymeleaf spring boot (2)
Tengo muchas dificultades para enviar un formulario al controlador, que debe contener simplemente una lista de objetos que el usuario puede editar.
El formulario se carga correctamente, pero cuando se publica, nunca parece publicar nada.
Aquí está mi forma:
<form action="#" th:action="@{/query/submitQuery}" th:object="${clientList}" method="post">
<table class="table table-bordered table-hover table-striped">
<thead>
<tr>
<th>Select</th>
<th>Client ID</th>
<th>IP Addresss</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr th:each="currentClient, stat : ${clientList}">
<td><input type="checkbox" th:checked="${currentClient.selected}" /></td>
<td th:text="${currentClient.getClientID()}" ></td>
<td th:text="${currentClient.getIpAddress()}"></td>
<td th:text="${currentClient.getDescription()}" ></td>
</tr>
</tbody>
</table>
<button type="submit" value="submit" class="btn btn-success">Submit</button>
</form>
Lo anterior funciona bien, carga la lista correctamente.
Sin embargo, cuando POST, devuelve un objeto vacío (de tamaño 0).
Creo que esto se debe a la falta de
th:field
, pero de todos modos aquí está el método POST del controlador:
...
private List<ClientWithSelection> allClientsWithSelection = new ArrayList<ClientWithSelection>();
//GET method
...
model.addAttribute("clientList", allClientsWithSelection)
....
//POST method
@RequestMapping(value="/submitQuery", method = RequestMethod.POST)
public String processQuery(@ModelAttribute(value="clientList") ArrayList clientList, Model model){
//clientList== 0 in size
...
}
Intenté agregar un
th:field
pero independientemente de lo que hago, causa una excepción.
He intentado:
...
<tr th:each="currentClient, stat : ${clientList}">
<td><input type="checkbox" th:checked="${currentClient.selected}" th:field="*{}" /></td>
<td th th:field="*{currentClient.selected}" ></td>
...
No puedo acceder a currentClient (error de compilación), ni siquiera puedo seleccionar clientList, me da opciones como
get()
,
add()
,
clearAll()
, etc., por lo que debería tener una matriz, sin embargo, no puedo pasar una matriz.
También he intentado usar algo como
th:field=${}
, esto causa una excepción en tiempo de ejecución
He intentado
th:field = "*{clientList[__currentClient.clientID__]}"
pero también compila error.
¿Algunas ideas?
ACTUALIZACIÓN 1:
Tobias sugirió que necesito envolver mi lista en un wraapper. Entonces eso fue lo que hice:
ClientWithSelectionWrapper:
public class ClientWithSelectionListWrapper {
private ArrayList<ClientWithSelection> clientList;
public List<ClientWithSelection> getClientList(){
return clientList;
}
public void setClientList(ArrayList<ClientWithSelection> clients){
this.clientList = clients;
}
}
Mi página:
<form action="#" th:action="@{/query/submitQuery}" th:object="${wrapper}" method="post">
....
<tr th:each="currentClient, stat : ${wrapper.clientList}">
<td th:text="${stat}"></td>
<td>
<input type="checkbox"
th:name="|clientList[${stat.index}]|"
th:value="${currentClient.getClientID()}"
th:checked="${currentClient.selected}" />
</td>
<td th:text="${currentClient.getClientID()}" ></td>
<td th:text="${currentClient.getIpAddress()}"></td>
<td th:text="${currentClient.getDescription()}" ></td>
</tr>
Por encima de las cargas bien:
Entonces mi controlador:
@RequestMapping(value="/submitQuery", method = RequestMethod.POST)
public String processQuery(@ModelAttribute ClientWithSelectionListWrapper wrapper, Model model){
...
}
La página se carga correctamente, los datos se muestran como se esperaba. Si publico el formulario sin ninguna selección, obtengo esto:
org.springframework.expression.spel.SpelEvaluationException: EL1007E:(pos 0): Property or field ''clientList'' cannot be found on null
No estoy seguro de por qué se queja
(En el método GET tiene:
model.addAttribute("wrapper", wrapper);
)
Si luego hago una selección, es decir, marque la primera entrada:
There was an unexpected error (type=Bad Request, status=400).
Validation failed for object=''clientWithSelectionListWrapper''. Error count: 1
Supongo que mi controlador POST no está obteniendo el clientWithSelectionListWrapper.
No estoy seguro de por qué, ya que he configurado el objeto contenedor para que se publique de nuevo a través de
th:object="wrapper"
en el encabezado FORM.
ACTUALIZACIÓN 2:
¡He hecho algunos progresos! Finalmente, el formulario enviado está siendo recogido por el método POST en el controlador. Sin embargo, todas las propiedades parecen ser nulas, excepto si el elemento ha sido marcado o no. He realizado varios cambios, así es como se ve:
<form action="#" th:action="@{/query/submitQuery}" th:object="${wrapper}" method="post">
....
<tr th:each="currentClient, stat : ${clientList}">
<td th:text="${stat}"></td>
<td>
<input type="checkbox"
th:name="|clientList[${stat.index}]|"
th:value="${currentClient.getClientID()}"
th:checked="${currentClient.selected}"
th:field="*{clientList[__${stat.index}__].selected}">
</td>
<td th:text="${currentClient.getClientID()}"
th:field="*{clientList[__${stat.index}__].clientID}"
th:value="${currentClient.getClientID()}"
></td>
<td th:text="${currentClient.getIpAddress()}"
th:field="*{clientList[__${stat.index}__].ipAddress}"
th:value="${currentClient.getIpAddress()}"
></td>
<td th:text="${currentClient.getDescription()}"
th:field="*{clientList[__${stat.index}__].description}"
th:value="${currentClient.getDescription()}"
></td>
</tr>
También agregué un constructor predeterminado sin parámetros a mi clase de contenedor y agregué un
bindingResult
al método POST (no estoy seguro si es necesario).
public String processQuery(@ModelAttribute ClientWithSelectionListWrapper wrapper, BindingResult bindingResult, Model model)
Entonces, cuando se publica un objeto, así es como se ve:
Por supuesto, se supone que systemInfo es nulo (en esta etapa), pero el ID de cliente siempre es 0 y ipAddress / Descripción siempre es nulo. Sin embargo, el booleano seleccionado es correcto para todas las propiedades. Estoy seguro de que he cometido un error en una de las propiedades en alguna parte. De vuelta a la investigación.
ACTUALIZACIÓN 3:
Ok, ¡he logrado completar todos los valores correctamente!
Pero tuve que cambiar mi
td
para incluir un
<input />
que no es lo que quería ... Sin embargo, los valores se están rellenando correctamente, lo que sugiere que Spring busque una etiqueta de entrada para el mapeo de datos.
Aquí hay un ejemplo de cómo cambié los datos de la tabla clientID:
<td>
<input type="text" readonly="readonly"
th:name="|clientList[${stat.index}]|"
th:value="${currentClient.getClientID()}"
th:field="*{clientList[__${stat.index}__].clientID}"
/>
</td>
Ahora necesito descubrir cómo mostrarlo como datos simples, idealmente sin la presencia de un cuadro de entrada ...
Cuando desee seleccionar objetos en la hoja de tomillo, en realidad no necesita crear un contenedor con el fin de almacenar un campo de selección
boolean
.
El uso de
dynamic fields
según la guía de la hoja de tomillo con sintaxis
th:field="*{rows[__${rowStat.index}__].variety}"
es bueno para cuando desea acceder a un conjunto de objetos ya existente en una colección.
No está realmente diseñado para hacer selecciones mediante el uso de objetos de contenedor IMO, ya que crea un código repetitivo innecesario y es una especie de truco.
Considere este ejemplo simple, una
Person
puede seleccionar las
Drinks
que le gustan.
Nota: Los constructores, captadores y definidores se omiten para mayor claridad.
Además, estos objetos normalmente se almacenan en una base de datos, pero estoy usando en matrices de memoria para explicar el concepto.
public class Person {
private Long id;
private List<Drink> drinks;
}
public class Drink {
private Long id;
private String name;
}
Controladores de primavera
Lo principal aquí es que estamos almacenando la
Person
en el
Model
para poder vincularla al formulario dentro de
th:object
.
En segundo lugar, las bebidas
selectableDrinks
son las bebidas que una persona puede seleccionar en la interfaz de usuario.
@GetMapping("/drinks")
public String getDrinks(Model model) {
Person person = new Person(30L);
// ud normally get these from the database.
List<Drink> selectableDrinks = Arrays.asList(
new Drink(1L, "coke"),
new Drink(2L, "fanta"),
new Drink(3L, "sprite")
);
model.addAttribute("person", person);
model.addAttribute("selectableDrinks", selectableDrinks);
return "templates/drinks";
}
@PostMapping("/drinks")
public String postDrinks(@ModelAttribute("person") Person person) {
// person.drinks will contain only the selected drinks
System.out.println(person);
return "templates/drinks";
}
Código de plantilla
Presta mucha atención al ciclo
li
y a cómo se puede
selectableDrinks
para obtener todas las bebidas posibles que se pueden seleccionar.
La casilla de verificación
th:field
realmente se expande a
person.drinks
ya que
th:object
está vinculado a
Person
y
*{drinks}
simplemente es el atajo para referirse a una propiedad en el objeto
Person
.
Puede pensar en esto como simplemente decirle a spring / thymeleaf que cualquier bebida seleccionada se colocará en la lista de
ArrayList
en la ubicación
person.drinks
.
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" >
<body>
<div class="ui top attached segment">
<div class="ui top attached label">Drink demo</div>
<form class="ui form" th:action="@{/drinks}" method="post" th:object="${person}">
<ul>
<li th:each="drink : ${selectableDrinks}">
<div class="ui checkbox">
<input type="checkbox" th:field="*{drinks}" th:value="${drink.id}">
<label th:text="${drink.name}"></label>
</div>
</li>
</ul>
<div class="field">
<button class="ui button" type="submit">Submit</button>
</div>
</form>
</div>
</body>
</html>
De cualquier manera ... la salsa secreta está usando
th:value=${drinks.id}
.
Esto se basa en convertidores de resorte.
Cuando se publique el formulario, spring intentará recrear una
Person
y para hacerlo necesita saber cómo convertir las cadenas de
drink.id
seleccionadas en el tipo de
Drink
real.
Nota: Si hiciste
th:value${drinks}
la clave de
value
en la casilla de verificación html sería la representación
toString()
de una
Drink
que no es lo que deseas, por lo tanto, ¡debes usar la identificación !.
Si está siguiendo, todo lo que necesita hacer es crear su propio convertidor si aún no lo ha hecho.
Sin un convertidor, recibirá un error como
Failed to convert property value of type ''java.lang.String'' to required type ''java.util.List'' for property ''drinks''
Puede activar el inicio de sesión en
application.properties
para ver los errores en detalle.
logging.level.org.springframework.web=TRACE
Esto solo significa que spring no sabe cómo convertir una identificación de cadena que representa un
drink.id
en una
Drink
.
El siguiente es un ejemplo de un
Converter
que soluciona este problema.
Normalmente inyectaría un repositorio para obtener acceso a la base de datos.
@Component
public class DrinkConverter implements Converter<String, Drink> {
@Override
public Drink convert(String id) {
System.out.println("Trying to convert id=" + id + " into a drink");
int parsedId = Integer.parseInt(id);
List<Drink> selectableDrinks = Arrays.asList(
new Drink(1L, "coke"),
new Drink(2L, "fanta"),
new Drink(3L, "sprite")
);
int index = parsedId - 1;
return selectableDrinks.get(index);
}
}
Si una entidad tiene un repositorio de datos de Spring correspondiente, Spring crea automáticamente los convertidores y manejará la recuperación de la entidad cuando se proporciona una identificación (la identificación de la cadena también parece estar bien, por lo que Spring hace algunas conversiones adicionales allí por las miradas). Esto es realmente genial, pero puede ser confuso de entender al principio.
Necesita un objeto contenedor para contener los datos enviados, como este:
public class ClientForm {
private ArrayList<String> clientList;
public ArrayList<String> getClientList() {
return clientList;
}
public void setClientList(ArrayList<String> clientList) {
this.clientList = clientList;
}
}
y
@ModelAttribute
como
@ModelAttribute
en su método
@ModelAttribute
:
@RequestMapping(value="/submitQuery", method = RequestMethod.POST)
public String processQuery(@ModelAttribute ClientForm form, Model model){
System.out.println(form.getClientList());
}
Además, el elemento de
input
necesita un
name
y un
value
.
Si
clientList[i]
directamente el html, tenga en cuenta que el nombre debe ser
clientList[i]
, donde
i
es la posición del elemento en la lista:
<tr th:each="currentClient, stat : ${clientList}">
<td><input type="checkbox"
th:name="|clientList[${stat.index}]|"
th:value="${currentClient.getClientID()}"
th:checked="${currentClient.selected}" />
</td>
<td th:text="${currentClient.getClientID()}" ></td>
<td th:text="${currentClient.getIpAddress()}"></td>
<td th:text="${currentClient.getDescription()}" ></td>
</tr>
Tenga en cuenta que
clientList
puede contener
null
en posiciones intermedias.
Por ejemplo, si los datos publicados son:
clientList[1] = ''B''
clientList[3] = ''D''
la
ArrayList
resultante será:
[null, B, null, D]
ACTUALIZACIÓN 1:
En mi ejemplo anterior,
ClientForm
es un contenedor para
List<String>
.
Pero en su caso,
ClientWithSelectionListWrapper
contiene
ArrayList<ClientWithSelection>
.
Por
clientList[1]
tanto,
clientList[1]
debe ser
clientList[1].clientID
y así sucesivamente con las otras propiedades que desea devolver:
<tr th:each="currentClient, stat : ${wrapper.clientList}">
<td><input type="checkbox" th:name="|clientList[${stat.index}].clientID|"
th:value="${currentClient.getClientID()}" th:checked="${currentClient.selected}" /></td>
<td th:text="${currentClient.getClientID()}"></td>
<td th:text="${currentClient.getIpAddress()}"></td>
<td th:text="${currentClient.getDescription()}"></td>
</tr>
He creado una pequeña demostración, para que pueda probarla:
Application.java
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
ClientWithSelection.java
public class ClientWithSelection {
private Boolean selected;
private String clientID;
private String ipAddress;
private String description;
public ClientWithSelection() {
}
public ClientWithSelection(Boolean selected, String clientID, String ipAddress, String description) {
super();
this.selected = selected;
this.clientID = clientID;
this.ipAddress = ipAddress;
this.description = description;
}
/* Getters and setters ... */
}
ClientWithSelectionListWrapper.java
public class ClientWithSelectionListWrapper {
private ArrayList<ClientWithSelection> clientList;
public ArrayList<ClientWithSelection> getClientList() {
return clientList;
}
public void setClientList(ArrayList<ClientWithSelection> clients) {
this.clientList = clients;
}
}
TestController.java
@Controller
class TestController {
private ArrayList<ClientWithSelection> allClientsWithSelection = new ArrayList<ClientWithSelection>();
public TestController() {
/* Dummy data */
allClientsWithSelection.add(new ClientWithSelection(false, "1", "192.168.0.10", "Client A"));
allClientsWithSelection.add(new ClientWithSelection(false, "2", "192.168.0.11", "Client B"));
allClientsWithSelection.add(new ClientWithSelection(false, "3", "192.168.0.12", "Client C"));
allClientsWithSelection.add(new ClientWithSelection(false, "4", "192.168.0.13", "Client D"));
}
@RequestMapping("/")
String index(Model model) {
ClientWithSelectionListWrapper wrapper = new ClientWithSelectionListWrapper();
wrapper.setClientList(allClientsWithSelection);
model.addAttribute("wrapper", wrapper);
return "test";
}
@RequestMapping(value = "/query/submitQuery", method = RequestMethod.POST)
public String processQuery(@ModelAttribute ClientWithSelectionListWrapper wrapper, Model model) {
System.out.println(wrapper.getClientList() != null ? wrapper.getClientList().size() : "null list");
System.out.println("--");
model.addAttribute("wrapper", wrapper);
return "test";
}
}
test.html
<!DOCTYPE html>
<html>
<head></head>
<body>
<form action="#" th:action="@{/query/submitQuery}" th:object="${wrapper}" method="post">
<table class="table table-bordered table-hover table-striped">
<thead>
<tr>
<th>Select</th>
<th>Client ID</th>
<th>IP Addresss</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr th:each="currentClient, stat : ${wrapper.clientList}">
<td><input type="checkbox" th:name="|clientList[${stat.index}].clientID|"
th:value="${currentClient.getClientID()}" th:checked="${currentClient.selected}" /></td>
<td th:text="${currentClient.getClientID()}"></td>
<td th:text="${currentClient.getIpAddress()}"></td>
<td th:text="${currentClient.getDescription()}"></td>
</tr>
</tbody>
</table>
<button type="submit" value="submit" class="btn btn-success">Submit</button>
</form>
</body>
</html>
ACTUALIZACIÓN 1.B:
A continuación se muestra el mismo ejemplo usando
th:field
y enviando todos los demás atributos como valores ocultos.
<tbody>
<tr th:each="currentClient, stat : *{clientList}">
<td>
<input type="checkbox" th:field="*{clientList[__${stat.index}__].selected}" />
<input type="hidden" th:field="*{clientList[__${stat.index}__].clientID}" />
<input type="hidden" th:field="*{clientList[__${stat.index}__].ipAddress}" />
<input type="hidden" th:field="*{clientList[__${stat.index}__].description}" />
</td>
<td th:text="${currentClient.getClientID()}"></td>
<td th:text="${currentClient.getIpAddress()}"></td>
<td th:text="${currentClient.getDescription()}"></td>
</tr>
</tbody>