Search code examples
javahibernatespring-mvcone-to-manydynamicform

Spring 3 MVC: one-to-many within a dynamic form (add/remove on create/update)


I'm looking for a solution to manage a one-to-many relation within an HTML form using jQuery. I'm developing with Spring, Spring MVC and Hibernate. I found many tracks on the web, but not any working full-example.

The background

I've three JPA entities:

Consult.java (1)

@Entity
@Table(name = "consult")
public class Consult

    private Integer id;
    private String label;
    private Set<ConsultTechno> consultTechnos;

    /* getters & setters */

}

ConsultTechno.java (2)

@Entity
@Table(name = "consult_techno")
public class ConsultTechno {

    private Integer id;
    private Techno techno;
    private Consult consult;
    private String level;

    /* getters & setters */

}

Techno.java (3)

@Entity
@Table(name="techno")
public class Techno {

    private Integer id;
    private String label;
    private Set<ConsultTechno> consultTechnos;

    /* getters & setters */

}

As shown, a Consult (1) contains n ConsultTechnos (2), which are caracterized by a level and a Techno (3).

The needs

Using an HTML form, I would like to have a Add a techno button which dynamically adds two fields in the DOM:

<input type="text" name="consult.consultTechnos[].techno.id" />
<input type="text" name="consult.consultTechnos[].level" />

Of course, each time the user clicks on the button, those two fields should be re-added, etc. I chose input type="text" for the example, but at the end, the fields will be two select.

Four kinds of operation should be covered:

  1. Add a child entity when creating a new master entity
  2. Remove a child entity when creating a new master entity
  3. Add a child entity when updating a new master entity
  4. Remove a child entity when updating a new master entity

The problem

That layout part already works, but when posting the form, I can't manage to bind the dynamically added fields to my @ModelAttribute consult.

Do you have any idea of how to do that kind of jobs? I hope I've been clear enough...

Thanks in advance :)


Solution

  • This point is still quite confusing and unclear on the web, so here is the way I solved my problem. This solution is probably not the most optimized one, but it works when creating and updating a master entity.

    Theory

    1. Use a List instead of a Set for your one-to-many relations which should be dynamically managed.

    2. Initialize your List as an AutoPopulatingList. It's a lazy list which allows to add dynamically elements.

    3. Add an attribute remove of int to your child entity. This will play the part of a boolean flag and will be usefull when removing dynamically an element.

    4. When posting the form, persist only the elements that have the flag remove on 0 (i.e. false).

    Practice

    A working full-example: an employer has many employees, an employee has one employer.

    Entities:

    Employer.java

    @Entity
    @Table(name = "employer")
    public class Employer
    
        private Integer id;
    
        private String firstname;
        private String lastname;
        private String company;
    
        private List<Employee> employees; // one-to-many
    
        /* getters & setters */
    
    }
    

    Employee.java

    @Entity
    @Table(name = "employee")
    public class Employee {
    
        private Integer id;
    
        @Transient // means "not a DB field"
        private Integer remove; // boolean flag
    
        private String firstname;
        private String lastname;
    
        private Employer employer; // many-to-one
    
        /* getters & setters */
    
    }
    

    Controller:

    EmployerController.java

    @Controller
    @RequestMapping("employer")
    public class EmployerController {
    
        // Manage dynamically added or removed employees
        private List<Employee> manageEmployees(Employer employer) {
            // Store the employees which shouldn't be persisted
            List<Employee> employees2remove = new ArrayList<Employee>();
            if (employer.getEmployees() != null) {
                for (Iterator<Employee> i = employer.getEmployees().iterator(); i.hasNext();) {
                    Employee employee = i.next();
                    // If the remove flag is true, remove the employee from the list
                    if (employee.getRemove() == 1) {
                        employees2remove.add(employee);
                        i.remove();
                    // Otherwise, perform the links
                    } else {
                        employee.setEmployer(employer);
                    }
                }
            }
            return employees2remove;
        }
    
        // -- Creating a new employer ----------
    
        @RequestMapping(value = "create", method = RequestMethod.GET)
        public String create(@ModelAttribute Employer employer, Model model) {
            // Should init the AutoPopulatingList
            return create(employer, model, true);
        }
    
        private String create(Employer employer, Model model, boolean init) {
            if (init) {
                // Init the AutoPopulatingList
                employer.setEmployees(new AutoPopulatingList<Employee>(Employee.class));
            }
            model.addAttribute("type", "create");
            return "employer/edit";
        }
    
        @RequestMapping(value = "create", method = RequestMethod.POST)
        public String create(@Valid @ModelAttribute Employer employer, BindingResult bindingResult, Model model) {
            if (bindingResult.hasErrors()) {
                // Should not re-init the AutoPopulatingList
                return create(employer, model, false);
            }
            // Call the private method
            manageEmployees(employer);
            // Persist the employer
            employerService.save(employer);
            return "redirect:employer/show/" + employer.getId();
        }
    
        // -- Updating an existing employer ----------
    
        @RequestMapping(value = "update/{pk}", method = RequestMethod.GET)
        public String update(@PathVariable Integer pk, @ModelAttribute Employer employer, Model model) {
            // Add your own getEmployerById(pk)
            model.addAttribute("type", "update");
            return "employer/edit";
        }
    
        @RequestMapping(value = "update/{pk}", method = RequestMethod.POST)
        public String update(@PathVariable Integer pk, @Valid @ModelAttribute Employer employer, BindingResult bindingResult, Model model) {
            // Add your own getEmployerById(pk)
            if (bindingResult.hasErrors()) {
                return update(pk, employer, model);
            }
            List<Employee> employees2remove = manageEmployees(employer);
            // First, save the employer
            employerService.update(employer);
            // Then, delete the previously linked employees which should be now removed
            for (Employee employee : employees2remove) {
                if (employee.getId() != null) {
                    employeeService.delete(employee);
                }
            }
            return "redirect:employer/show/" + employer.getId();
        }
    
        // -- Show an existing employer ----------
    
        @RequestMapping(value = "show/{pk}", method = RequestMethod.GET)
        public String show(@PathVariable Integer pk, @ModelAttribute Employer employer) {
            // Add your own getEmployerById(pk)
            return "employer/show";
        }
    
    }
    

    View:

    employer/edit.jsp

    <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"
    %><%@ taglib prefix="form" uri="http://www.springframework.org/tags/form"
    %><%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions"
    %><%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"
    %><!DOCTYPE HTML>
    <html>
    <head>
    
        <title>Edit</title>
        <style type="text/css">.hidden {display: none;}</style>
        <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.2/jquery.min.js"></script>
        <script type="text/javascript">
        $(function() {
    
            // Start indexing at the size of the current list
            var index = ${fn:length(employer.employees)};
    
            // Add a new Employee
            $("#add").off("click").on("click", function() {
                $(this).before(function() {
                    var html = '<div id="employees' + index + '.wrapper" class="hidden">';                    
                    html += '<input type="text" id="employees' + index + '.firstname" name="employees[' + index + '].firstname" />';
                    html += '<input type="text" id="employees' + index + '.lastname" name="employees[' + index + '].lastname" />';
                    html += '<input type="hidden" id="employees' + index + '.remove" name="employees[' + index + '].remove" value="0" />';
                    html += '<a href="#" class="employees.remove" data-index="' + index + '">remove</a>';                    
                    html += "</div>";
                    return html;
                });
                $("#employees" + index + "\\.wrapper").show();
                index++;
                return false;
            });
    
            // Remove an Employee
            $("a.employees\\.remove").off("click").on("click", function() {
                var index2remove = $(this).data("index");
                $("#employees" + index2remove + "\\.wrapper").hide();
                $("#employees" + index2remove + "\\.remove").val("1");
                return false;
            });
    
        });
        </script>
    
    </head>
    <body>
    
        <c:choose>
            <c:when test="${type eq 'create'}"><c:set var="actionUrl" value="employer/create" /></c:when>
            <c:otherwise><c:set var="actionUrl" value="employer/update/${employer.id}" /></c:otherwise>
        </c:choose>
    
        <form:form action="${actionUrl}" modelAttribute="employer" method="POST" name="employer">
            <form:hidden path="id" />
            <table>
                <tr>
                    <td><form:label path="firstname">Firstname</form:label></td>
                    <td><form:input path="firstname" /><form:errors path="firstname" /></td>
                </tr>
                <tr>
                    <td><form:label path="lastname">Lastname</form:label></td>
                    <td><form:input path="lastname" /><form:errors path="lastname" /></td>
                </tr>
                <tr>
                    <td><form:label path="company">company</form:label></td>
                    <td><form:input path="company" /><form:errors path="company" /></td>
                </tr>
                <tr>
                    <td>Employees</td>
                    <td>
                        <c:forEach items="${employer.employees}" varStatus="loop">
                            <!-- Add a wrapping div -->
                            <c:choose>
                                <c:when test="${employer.employees[loop.index].remove eq 1}">
                                    <div id="employees${loop.index}.wrapper" class="hidden">
                                </c:when>
                                <c:otherwise>
                                    <div id="employees${loop.index}.wrapper">
                                </c:otherwise>
                            </c:choose>
                                <!-- Generate the fields -->
                                <form:input path="employees[${loop.index}].firstname" />
                                <form:input path="employees[${loop.index}].lastname" />
                                <!-- Add the remove flag -->
                                <c:choose>
                                    <c:when test="${employees[loop.index].remove eq 1}"><c:set var="hiddenValue" value="1" /></c:when>
                                    <c:otherwise><c:set var="hiddenValue" value="0" /></c:otherwise>
                                </c:choose>
                                <form:hidden path="employees[${loop.index}].remove" value="${hiddenValue}" />
                                <!-- Add a link to remove the Employee -->
                                <a href="#" class="employees.remove" data-index="${loop.index}">remove</a>
                            </div>
                        </c:forEach>
                        <button id="add" type="button">add</button>
                    </td>
                </tr>
            </table>
            <button type="submit">OK</button>
        </form:form>
    
    </body>
    </html>
    

    Hope that could help :)