Search code examples
javaspringspring-mvcspring-bootthymeleaf

How to bind an object list with thymeleaf?


I am having a lot of difficulty with POSTing back a form to the controller, which should contain simply an arraylist of objects that the user may edit.

The form loads up correctly, but when it's posted, it never seems to actually post anything.

Here is my form:

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

Above works fine, it loads up the list correctly. However, when I POST, it returns a empty object (of size 0). I believe this is due to the lack of th:field, but anyway here is controller POST method:

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

I have tried adding a th:field but regardless of what I do, it causes an exception.

I've tried:

...
<tr th:each="currentClient, stat : ${clientList}">   
     <td><input type="checkbox" th:checked="${currentClient.selected}"  th:field="*{}" /></td>

    <td th th:field="*{currentClient.selected}" ></td>
...

I cannot access currentClient (compile error), I can't even select clientList, it gives me options like get(), add(), clearAll() etc, so it things it should have an array, however, I cannot pass in an array.

I've also tried using something like th:field=${}, this causes runtime exception

I've tried

th:field = "*{clientList[__currentClient.clientID__]}" 

but also compile error.

Any ideas?


UPDATE 1:

Tobias suggested that I need to wrap my list in a wraapper. So that's what I did:

ClientWithSelectionWrapper:

public class ClientWithSelectionListWrapper {

private ArrayList<ClientWithSelection> clientList;

public List<ClientWithSelection> getClientList(){
    return clientList;
}

public void setClientList(ArrayList<ClientWithSelection> clients){
    this.clientList = clients;
}
}

My page:

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

Above loads fine: enter image description here

Then my controller:

@RequestMapping(value="/submitQuery", method = RequestMethod.POST)
public String processQuery(@ModelAttribute ClientWithSelectionListWrapper wrapper, Model model){
... 
}

The page loads correctly, the data is displayed as expected. If I post the form without any selection I get this:

org.springframework.expression.spel.SpelEvaluationException: EL1007E:(pos 0): Property or field 'clientList' cannot be found on null

Not sure why it's complaining

(In the GET Method it has: model.addAttribute("wrapper", wrapper);)

enter image description here

If I then make a selection, i.e. tick the first entry:

There was an unexpected error (type=Bad Request, status=400).
Validation failed for object='clientWithSelectionListWrapper'. Error count: 1

I'm guessing my POST controller is not getting the clientWithSelectionListWrapper. Not sure why, since I have set the wrapper object to be posted back via the th:object="wrapper" in the FORM header.


UPDATE 2:

I've made some progress! Finally the submitted form is being picked up by the POST method in controller. However, all the properties appear to be null, except for whether the item has been ticked or not. I've made various changes, this is how it is looking:

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

I also added a default param-less constructor to my wrapper class and added a bindingResult param to POST method (not sure if needed).

public String processQuery(@ModelAttribute ClientWithSelectionListWrapper wrapper, BindingResult bindingResult, Model model)

So when an object is being posted, this is how it is looking: enter image description here

Of course, the systemInfo is supposed to be null (at this stage), but the clientID is always 0, and ipAddress/Description always null. The selected boolean is correct though for all properties. I'm sure I've made a mistake on one of the properties somewhere. Back to investigation.


UPDATE 3:

Ok I've managed to fill up all the values correctly! But I had to change my td to include an <input /> which is not what I wanted... Nonetheless, the values are populating correctly, suggesting spring looks for an input tag perhaps for data mapping?

Here is an example of how I changed the clientID table data:

<td>
 <input type="text" readonly="readonly"                                                          
     th:name="|clientList[${stat.index}]|"
     th:value="${currentClient.getClientID()}"
     th:field="*{clientList[__${stat.index}__].clientID}"
  />
</td>

Now I need to figure out how to display it as plain data, ideally without any presence of an input box...


Solution

  • You need a wrapper object to hold the submited data, like this one:

    public class ClientForm {
        private ArrayList<String> clientList;
    
        public ArrayList<String> getClientList() {
            return clientList;
        }
    
        public void setClientList(ArrayList<String> clientList) {
            this.clientList = clientList;
        }
    }
    

    and use it as the @ModelAttribute in your processQuery method:

    @RequestMapping(value="/submitQuery", method = RequestMethod.POST)
    public String processQuery(@ModelAttribute ClientForm form, Model model){
        System.out.println(form.getClientList());
    }
    

    Moreover, the input element needs a name and a value. If you directly build the html, then take into account that the name must be clientList[i], where i is the position of the item in the list:

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

    Note that clientList can contain null at intermediate positions. Per example, if posted data is:

    clientList[1] = 'B'
    clientList[3] = 'D'
    

    the resulting ArrayList will be: [null, B, null, D]

    UPDATE 1:

    In my exmple above, ClientForm is a wrapper for List<String>. But in your case ClientWithSelectionListWrapper contains ArrayList<ClientWithSelection>. Therefor clientList[1] should be clientList[1].clientID and so on with the other properties you want to sent back:

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

    I've built a little demo, so you can test it:

    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>
    

    UPDATE 1.B:

    Below is the same example using th:field and sending back all other attributes as hidden values.

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