Spring – Adding Spring MVC – part 2
So let’s start with org.timesheet.web.TaskController. First create a class and this time we will be accessing richer domain, so we’ll need to autowire three DAOS – for tasks, employees and managers.
@Controller @RequestMapping('/tasks') public class TaskController { private TaskDao taskDao; private EmployeeDao employeeDao; private ManagerDao managerDao; @Autowired public void setTaskDao(TaskDao taskDao) { this.taskDao = taskDao; } @Autowired public void setEmployeeDao(EmployeeDao employeeDao) { this.employeeDao = employeeDao; } @Autowired public void setManagerDao(ManagerDao managerDao) { this.managerDao = managerDao; } public EmployeeDao getEmployeeDao() { return employeeDao; } public TaskDao getTaskDao() { return taskDao; } public ManagerDao getManagerDao() { return managerDao; } }
Let’s handle GET request on /tasks:
/** * Retrieves tasks, puts them in the model and returns corresponding view * @param model Model to put tasks to * @return tasks/list */ @RequestMapping(method = RequestMethod.GET) public String showTasks(Model model) { model.addAttribute('tasks', taskDao.list()); return 'tasks/list'; }
We will place JSPs in tasks subfolder. First is list.jsp for showing all tasks. It does not only iterate through all tasks, but on each task it iterates through employees:
<%@ page contentType='text/html;charset=UTF-8' language='java' %> <%@ taglib prefix='fmt' uri='http://java.sun.com/jsp/jstl/fmt' %> <%@ taglib prefix='spring' uri='http://www.springframework.org/tags' %> <%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core'%> <%@ taglib prefix='sf' uri='http://www.springframework.org/tags/form'%> <!-- resolve variables --> <%--@elvariable id='tasks' type='java.util.List<org.timesheet.domain.Task>'--%> <html> <head> <title>Tasks</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h1>List of tasks</h1> <a href='tasks?new'>Add new task</a> <table cellspacing='5' class='main-table wide'> <tr> <th style='width: 35%;'>Description</th> <th>Manager</th> <th>Employees</th> <th>Completed</th> <th style='width: 20%;'>Details</th> <th>Delete</th> </tr> <c:forEach items='${tasks}' var='task'> <tr> <td>${task.description}</td> <td> <a href='managers/${task.manager.id}'>${task.manager.name}</a> </td> <td> <c:forEach items='${task.assignedEmployees}' var='emp'> <a href='employees/${emp.id}'>${emp.name}</a> </c:forEach> </td> <td> <div class='delete'> <c:choose> <c:when test='${task.completed}'> Done </c:when> <c:when test='${!task.completed}'> In progress </c:when> </c:choose> </div> </td> <td> <a href='tasks/${task.id}'>Go to page</a> </td> <td> <sf:form action='tasks/${task.id}' method='delete' cssClass='delete'> <input type='submit' value='' class='delete-button' /> </sf:form> </td> </tr> </c:forEach> </table> <br /> <a href='welcome'>Go back</a> </body> </html>
Deleting task as usual:
/** * Deletes task with specified ID * @param id Task's ID * @return redirects to tasks if everything was ok * @throws TaskDeleteException When task cannot be deleted */ @RequestMapping(value = '/{id}', method = RequestMethod.DELETE) public String deleteTask(@PathVariable('id') long id) throws TaskDeleteException { Task toDelete = taskDao.find(id); boolean wasDeleted = taskDao.removeTask(toDelete); if (!wasDeleted) { throw new TaskDeleteException(toDelete); } // everything OK, see remaining tasks return 'redirect:/tasks'; }
TaskDeleteException:
package org.timesheet.web.exceptions; import org.timesheet.domain.Task; /** * When task cannot be deleted. */ public class TaskDeleteException extends Exception { private Task task; public TaskDeleteException(Task task) { this.task = task; } public Task getTask() { return task; } }
Method for handling this exception:
/** * Handles TaskDeleteException * @param e Thrown exception with task that couldn't be deleted * @return binds task to model and returns tasks/delete-error */ @ExceptionHandler(TaskDeleteException.class) public ModelAndView handleDeleteException(TaskDeleteException e) { ModelMap model = new ModelMap(); model.put('task', e.getTask()); return new ModelAndView('tasks/delete-error', model); }
JSP page jsp/tasks/delete-error.jsp for showing deletion error:
<%--@elvariable id='task' type='org.timesheet.domain.Task'--%> <html> <head> <title>Cannot delete task</title> </head> <body> Oops! Resource <a href='${task.id}'>${task.description}</a> can not be deleted. <p> Make sure there are no timesheets assigned on task. </p> <br /><br /><br /> <a href='../welcome'>Back to main page.</a> </body> </html>
Showing task’s detail will be accessed with URI /tasks/{id}. We’ll put in the model both task and unassigned employees that can be added to the task. It’ll be handled like so:
/** * Returns task with specified ID * @param id Tasks's ID * @param model Model to put task to * @return tasks/view */ @RequestMapping(value = '/{id}', method = RequestMethod.GET) public String getTask(@PathVariable('id') long id, Model model) { Task task = taskDao.find(id); model.addAttribute('task', task); // add all remaining employees List<Employee> employees = employeeDao.list(); Set<Employee> unassignedEmployees = new HashSet<Employee>(); for (Employee employee : employees) { if (!task.getAssignedEmployees().contains(employee)) { unassignedEmployees.add(employee); } } model.addAttribute('unassigned', unassignedEmployees); return 'tasks/view'; }
Now something slightly more complicated. We would like to show user detail page of the task. On this task we’d like to add/remove employees assigned on it.
First, let’s think of URL. Tasks have assigned employees, so our URL for accessing employee on task will be like this:
/tasks/{id}/employees/{employeeId}
To remove employee, we will simply access this resource with DELETE method, so let’s add method to controller:
/** * Removes assigned employee from task * @param taskId Task's ID * @param employeeId Assigned employee's ID */ @RequestMapping(value = '/{id}/employees/{employeeId}', method = RequestMethod.DELETE) @ResponseStatus(HttpStatus.NO_CONTENT) public void removeEmployee( @PathVariable('id') long taskId, @PathVariable('employeeId') long employeeId) { Employee employee = employeeDao.find(employeeId); Task task = taskDao.find(taskId); task.removeEmployee(employee); taskDao.update(task); }
On the view page (we’ll see that just in moment), we will simply alter DOM model using jQuery and remove assigned employee from list.
Let’s pretend that nothing can go wrong (we have NO_CONTENT response) so employee will always be successfully removed from DB. So we can simply alter that DOM model.
For adding employee, we will have selection list (or combo box) of unassigned employees. When employee is removed we will append this to selection of available employees (he is available again). When employee will be added, we will alter Task with DAO and redirect back to same task (everything will be updated). Here’s code for assigning employee to task:
/** * Assigns employee to tak * @param taskId Task's ID * @param employeeId Employee's ID (to assign) * @return redirects back to altered task: tasks/taskId */ @RequestMapping(value = '/{id}/employees/{employeeId}', method = RequestMethod.PUT) public String addEmployee( @PathVariable('id') long taskId, @PathVariable('employeeId') long employeeId) { Employee employee = employeeDao.find(employeeId); Task task = taskDao.find(taskId); task.addEmployee(employee); taskDao.update(task); return 'redirect:/tasks/' + taskId; }
And finally, tasks/view.jsp for details of Task. As I mentioned, there is lot of DOM altering so this code might seem little more difficult than usually.
<%@ page contentType='text/html;charset=UTF-8' language='java' %> <%@ taglib prefix='sf' uri='http://www.springframework.org/tags/form'%> <%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core'%> <%--@elvariable id='task' type='org.timesheet.domain.Task'--%> <%--@elvariable id='unassigned' type='java.util.List<org.timesheet.domain.Employee>'--%> <html> <head> <title>Task page</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h2>Task info</h2> <div id='list'> <ul> <li> <label for='description'>Description:</label> <input name='description' id='description' value='${task.description}' disabled='${task.completed ? 'disabled' : ''}' /> </li> <li> <label for='manager'>Manager:</label> <input name='manager' id='manager' value='${task.manager.name}' disabled='true' /> </li> <li> <label for='employees'>Employees:</label> <table id='employees' class='task-table'> <c:forEach items='${task.assignedEmployees}' var='emp'> <tr> <sf:form action='${task.id}/employees/${emp.id}' method='delete'> <td> <a href='../employees/${emp.id}' id='href-${emp.id}'>${emp.name}</a> </td> <td> <input type='submit' value='Remove' id='remove-${emp.id}' /> <script src='/timesheet-app/resources/jquery-1.7.1.js'></script> <script type='text/javascript'> $('#remove-${emp.id}').on('click', function() { $('#remove-${emp.id}').addClass('hidden'); $('#href-${emp.id}').remove(); // add to list of unassigned var opt = document.createElement('option'); opt.setAttribute('value', '${emp.id}'); opt.textContent = '${emp.name}'; $('#selected-emp').append(opt); }); </script> </td> </sf:form> </tr> </c:forEach> </table> </li> <li> <label for='unassigned'>Unassgined:</label> <table id='unassigned' class='task-table'> <tr> <sf:form method='put' id='add-form'> <td> <select id='selected-emp'> <c:forEach items='${unassigned}' var='uemp'> <option value='${uemp.id}'> ${uemp.name} </option> </c:forEach> </select> </td> <td> <input type='submit' value='Add' id='add-employee' /> <script src='/timesheet-app/resources/jquery-1.7.1.js'></script> <script type='text/javascript'> $('#add-employee').on('click', function() { $('#selected-emp').selected().remove(); }); </script> </td> </sf:form> </tr> </table> </li> </ul> </div> <br /><br /> <a href='../tasks'>Go Back</a> <script src='/timesheet-app/resources/jquery-1.7.1.js'></script> <script type='text/javascript'> (function() { // prepare default form action setAddAction(); // handler for changing action $('#selected-emp').on('change', function() { setAddAction(); }); function setAddAction() { var id = $('#selected-emp').val(); $('#add-form').attr('action', '${task.id}/employees/' + id); } })(); </script> </body> </html>
As you can observe from the code, we’re again using only HTML + JavaScript. Only thing that is JSP specific is bringing data from model to the page.
OK, now we must be able to create new Task. Let’s prepare our controller for serving form for adding task that will be accessed from /tasks?new:
/** * Creates form for new task. * @param model Model to bind to HTML form * @return tasks/new */ @RequestMapping(params = 'new', method = RequestMethod.GET) public String createTaskForm(Model model) { model.addAttribute('task', new Task()); // list of managers to choose from List<Manager> managers = managerDao.list(); model.addAttribute('managers', managers); return 'tasks/new'; }
Task consists of name, manager and assigned employees. For scope of this tutorial, I decided not to implement the last one. We will simply generate some employees. If you will ever want to be able to pick employees from some sort of selection list and assign them to task, then please note, that this should be done asynchronously. For that purposes you can map special methods to controller and do AJAX posts for example with jQuery with $.post. I think that would be little too much for this tutorial, but if you’re interested how to use AJAX with Spring, check out this blog post on ajax simplifications in Spring 3.
When we were creating Employees and Managers, we only used primitive types for properties. Now we would like to assign actual Manager instance to task. So we will have to tell Spring how it should convert value from select list (manager’s id) to actual instance. For this we will use custom PropertyEditorSupport facility. Add new org.timesheet.web.editors package and create new class ManagerEditor with following code:
public class ManagerEditor extends PropertyEditorSupport { private ManagerDao managerDao; public ManagerEditor(ManagerDao managerDao) { this.managerDao = managerDao; } @Override public void setAsText(String text) throws IllegalArgumentException { long id = Long.parseLong(text); Manager manager = managerDao.find(id); setValue(manager); } }
ManagerEditor will have passed DAO in it’s constructor. It will lookup actual manager by it’s ID and call parent’s setValue.
Spring should now know there is such an editor, so we must register it in our controller. We only need method that has WebDataBinder as parameter and we need to annotate it with @InitBinder annotation like so:
@InitBinder protected void initBinder(WebDataBinder binder) { binder.registerCustomEditor(Manager.class, new ManagerEditor(managerDao)); }
And that’s it, Spring now knows how to assign manager to our task directly from form.
Finally code for saving Task. As I said earlier, we will generate some employees to task just before saving it:
/** * Saves new task to the database * @param task Task to save * @return redirects to tasks */ @RequestMapping(method = RequestMethod.POST) public String addTask(Task task) { // generate employees List<Employee> employees = reduce(employeeDao.list()); task.setAssignedEmployees(employees); taskDao.add(task); return 'redirect:/tasks'; }
There is reduce method, which is simple helper method for reducing employees in memory. This is not terribly effective, we could do that rather with more sophisticated query, but for now it’ll do just fine. Also feel free to roll your own reduce logic if you want:
/** * Reduces list of employees to some smaller amount. * Simulates user interaction. * @param employees Employees to reduced * @return New list of some employees from original employees list */ private List<Employee> reduce(List<Employee> employees) { List<Employee> reduced = new ArrayList<Employee>(); Random random = new Random(); int amount = random.nextInt(employees.size()) + 1; // max. five employees amount = amount > 5 ? 5 : amount; for (int i = 0; i < amount; i++) { int randomIdx = random.nextInt(employees.size()); Employee employee = employees.get(randomIdx); reduced.add(employee); employees.remove(employee); } return reduced; }
Let’s see tasks/new.jsp page now:
<%@ page contentType='text/html;charset=UTF-8' language='java' %> <%@ taglib prefix='sf' uri='http://www.springframework.org/tags/form'%> <%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core'%> <%--@elvariable id='task' type='org.timesheet.domain.Task'--%> <%--@elvariable id='managers' type='java.util.List<org.timesheet.domain.Manager'--%> <html> <head> <title>Add new task</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h2>Add new Task</h2> <div id='list'> <sf:form method='post' action='tasks' commandName='task'> <ul> <li> <label for='description'>Description:</label> <input name='description' id='description' value='${task.description}' /> </li> <li> <label for='manager-select'>Manager:</label> <sf:select path='manager' id='manager-select'> <sf:options items='${managers}' itemLabel='name' itemValue='id' /> </sf:select> </li> <li> Employees will be generated ... </li> <li> <input type='submit' value='Save'> </li> </ul> </sf:form> </div> <br /><br /> <a href='tasks'>Go Back</a> </body> </html>
And of course test for controller:
package org.timesheet.web; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.ui.ExtendedModelMap; import org.springframework.ui.Model; import org.springframework.web.servlet.ModelAndView; import org.timesheet.DomainAwareBase; import org.timesheet.domain.Employee; import org.timesheet.domain.Manager; import org.timesheet.domain.Task; import org.timesheet.service.dao.EmployeeDao; import org.timesheet.service.dao.ManagerDao; import org.timesheet.service.dao.TaskDao; import org.timesheet.web.exceptions.TaskDeleteException; import java.util.Collection; import java.util.List; import static org.junit.Assert.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ContextConfiguration(locations = {'/persistence-beans.xml', '/controllers.xml'}) public class TaskControllerTest extends DomainAwareBase { private Model model; // used for controller @Autowired private TaskDao taskDao; @Autowired private ManagerDao managerDao; @Autowired private EmployeeDao employeeDao; @Autowired private TaskController controller; @Before public void setUp() { model = new ExtendedModelMap(); } @After public void cleanUp() { List<Task> tasks = taskDao.list(); for (Task task : tasks) { taskDao.remove(task); } } @Test public void testShowTasks() { // prepare some data Task task = sampleTask(); // use controller String view = controller.showTasks(model); assertEquals('tasks/list', view); List<Task> listFromDao = taskDao.list(); Collection<?> listFromModel = (Collection<?>) model.asMap ().get('tasks'); assertTrue(listFromModel.contains(task)); assertTrue(listFromDao.containsAll(listFromModel)); } @Test public void testDeleteTaskOk() throws TaskDeleteException { Task task = sampleTask(); long id = task.getId(); // delete & assert String view = controller.deleteTask(id); assertEquals('redirect:/tasks', view); assertNull(taskDao.find(id)); } @Test(expected = TaskDeleteException.class) public void testDeleteTaskThrowsException() throws TaskDeleteException { Task task = sampleTask(); long id = task.getId(); // mock DAO for this call TaskDao mockedDao = mock(TaskDao.class); when(mockedDao.removeTask(task)).thenReturn(false); TaskDao originalDao = controller.getTaskDao(); try { // delete & expect exception controller.setTaskDao(mockedDao); controller.deleteTask(id); } finally { controller.setTaskDao(originalDao); } } @Test public void testHandleDeleteException() { Task task = sampleTask(); TaskDeleteException e = new TaskDeleteException(task); ModelAndView modelAndView = controller.handleDeleteException(e); assertEquals('tasks/delete-error', modelAndView.getViewName()); assertTrue(modelAndView.getModelMap().containsValue(task)); } @Test public void testGetTask() { Task task = sampleTask(); long id = task.getId(); // get & assert String view = controller.getTask(id, model); assertEquals('tasks/view', view); assertEquals(task, model.asMap().get('task')); } @Test public void testRemoveEmployee() { Task task = sampleTask(); long id = task.getAssignedEmployees().get(0).getId(); controller.removeEmployee(task.getId(), id); // task was updated inside controller in other transaction -> refresh task = taskDao.find(task.getId()); // get employee & assert Employee employee = employeeDao.find(id); assertFalse(task.getAssignedEmployees().contains(employee)); } @Test public void testAddEmployee() { Task task = sampleTask(); Employee cassidy = new Employee('Butch Cassidy', 'Cowboys'); employeeDao.add(cassidy); controller.addEmployee(task.getId(), cassidy.getId()); // task was updated inside controller in other transaction -> refresh task = taskDao.find(task.getId()); // get employee & assert Employee employee = employeeDao.find(cassidy.getId()); assertTrue(task.getAssignedEmployees().contains(employee)); } @Test public void testAddTask() { Task task = sampleTask(); // save via controller String view = controller.addTask(task); assertEquals('redirect:/tasks', view); // task is in DB assertEquals(task, taskDao.find(task.getId())); } private Task sampleTask() { Manager manager = new Manager('Jesse James'); managerDao.add(manager); Employee terrence = new Employee('Terrence', 'Cowboys'); Employee kid = new Employee('Sundance Kid', 'Cowboys'); employeeDao.add(terrence); employeeDao.add(kid); Task task = new Task('Wild West', manager, terrence, kid); taskDao.add(task); return task; } }
That’s it for Tasks. Now let’s create controllers for timesheets. Add basic boilerplate for controller and autowired DAOs that we’ll require:
@Controller @RequestMapping('/timesheets') public class TimesheetController { private TimesheetDao timesheetDao; private TaskDao taskDao; private EmployeeDao employeeDao; @Autowired public void setTimesheetDao(TimesheetDao timesheetDao) { this.timesheetDao = timesheetDao; } @Autowired public void setTaskDao(TaskDao taskDao) { this.taskDao = taskDao; } @Autowired public void setEmployeeDao(EmployeeDao employeeDao) { this.employeeDao = employeeDao; } public TimesheetDao getTimesheetDao() { return timesheetDao; } public TaskDao getTaskDao() { return taskDao; } public EmployeeDao getEmployeeDao() { return employeeDao; } }
Method for handling GET request on timesheets:
/** * Retrieves timesheets, puts them in the model and returns corresponding view * @param model Model to put timesheets to * @return timesheets/list */ @RequestMapping(method = RequestMethod.GET) public String showTimesheets(Model model) { List<Timesheet> timesheets = timesheetDao.list(); model.addAttribute('timesheets', timesheets); return 'timesheets/list'; }
JSPs will be placed in timesheets subfolder. Add list.jsp page, that will basically iterate through Timesheet’s properties and roll deleting form:
<%@ page contentType='text/html;charset=UTF-8' language='java' %> <%@ taglib prefix='fmt' uri='http://java.sun.com/jsp/jstl/fmt' %> <%@ taglib prefix='spring' uri='http://www.springframework.org/tags' %> <%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core'%> <%@ taglib prefix='sf' uri='http://www.springframework.org/tags/form'%> <!-- resolve variables --> <%--@elvariable id='timesheets' type='java.util.List<org.timesheet.domain.Timesheet>'--%> <html> <head> <title>Timesheets</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h1>List of timesheets</h1> <a href='timesheets?new'>Add new timesheet</a> <table cellspacing='5' class='main-table wide'> <tr> <th style='width: 30%'>Employee</th> <th style='width: 50%'>Task</th> <th>Hours</th> <th>Details</th> <th>Delete</th> </tr> <c:forEach items='${timesheets}' var='ts'> <tr> <td> <a href='employees/${ts.who.id}'>${ts.who.name}</a> </td> <td> <a href='tasks/${ts.task.id}'>${ts.task.description}</a> </td> <td>${ts.hours}</td> <td> <a href='timesheets/${ts.id}'>Go to page</a> </td> <td> <sf:form action='timesheets/${ts.id}' method='delete' cssClass='delete'> <input type='submit' class='delete-button'> </sf:form> </td> </tr> </c:forEach> </table> <br /> <a href='welcome'>Go back</a> </body> </html>
Deleting Timesheet is easier than deleting task, because we won’t break any constraint in database, so we can simply use default remove method on DAO:
/** * Deletes timeshet with specified ID * @param id Timesheet's ID * @return redirects to timesheets */ @RequestMapping(value = '/{id}', method = RequestMethod.DELETE) public String deleteTimesheet(@PathVariable('id') long id) { Timesheet toDelete = timesheetDao.find(id); timesheetDao.remove(toDelete); return 'redirect:/timesheets'; }
We will access individual Timesheet resource by adding it’s ID to URI as usual, so we’ll handle /timesheets/{id}. But there are objects assigned to timesheet – Task instance and Employee instance. We don’t want form to null them out. Therefore we will introduce lightweight command backing object for form. We will update only hours and then set those new hours on real Timesheet instance:
/** * Returns timesheet with specified ID * @param id Timesheet's ID * @param model Model to put timesheet to * @return timesheets/view */ @RequestMapping(value = '/{id}', method = RequestMethod.GET) public String getTimesheet(@PathVariable('id') long id, Model model) { Timesheet timesheet = timesheetDao.find(id); TimesheetCommand tsCommand = new TimesheetCommand(timesheet); model.addAttribute('tsCommand', tsCommand); return 'timesheets/view'; }
And here’s code for TimesheetCommand which is now under new package org.timesheet.web.commands:
package org.timesheet.web.commands; import org.hibernate.validator.constraints.Range; import org.timesheet.domain.Timesheet; import javax.validation.constraints.NotNull; public class TimesheetCommand { @NotNull @Range(min = 1, message = 'Hours must be 1 or greater') private Integer hours; private Timesheet timesheet; // default c-tor for bean instantiation public TimesheetCommand() {} public TimesheetCommand(Timesheet timesheet) { hours = timesheet.getHours(); this.timesheet = timesheet; } public Integer getHours() { return hours; } public void setHours(Integer hours) { this.hours = hours; } public Timesheet getTimesheet() { return timesheet; } public void setTimesheet(Timesheet timesheet) { this.timesheet = timesheet; } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } TimesheetCommand that = (TimesheetCommand) o; if (hours != null ? !hours.equals(that.hours) : that.hours != null) { return false; } if (timesheet != null ? !timesheet.equals(that.timesheet) : that.timesheet != null) { return false; } return true; } @Override public int hashCode() { int result = hours != null ? hours.hashCode() : 0; result = 31 * result + (timesheet != null ? timesheet.hashCode() : 0); return result; } }
Pretty straightforward, but what are those @NotNull and @Range annotations? Well, we certailny don’t want user to enter negative or zero number for amount of hours, so we will use this neat JSR 303 Bean Validation API. To make it work, simply add dependency to hibernate validator to your pom.xml:
<dependency> <groupId>org.hibernate</groupId> <artifactId>hibernate-validator</artifactId> <version>4.2.0.Final</version> </dependency>
Once Hibernate Validator is in our classpath, default validator will automatically be picked. To make it work though, we must enable annotation driven MVC, so add following line to timesheet-servlet.xml bean config file:
<mvc:annotation-driven />
We’ll see usage of a valid model a few lines later.
Under timesheets folder we’ll now create view.jsp page that’ll contain info about single timesheet:
<%@ page contentType='text/html;charset=UTF-8' language='java' %> <%@ taglib prefix='sf' uri='http://www.springframework.org/tags/form'%> <%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core'%> <%--@elvariable id='tsCommand' type='org.timesheet.web.commands.TimesheetCommand'--%> <html> <head> <title>Timesheet page</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h2>Timesheet info</h2> <div id='list'> <sf:form method='post' modelAttribute='tsCommand'> <sf:errors path='*' cssClass='errors' element='div' /> <ul> <li> <label for='employeeName'>Assigned employee:</label> <a id='employee' href='../employees/${tsCommand.timesheet.who.id}'> ${tsCommand.timesheet.who.name} </a> </li> <li> <label for='task'>Task:</label> <a id='task' href='../tasks/${tsCommand.timesheet.task.id}'> ${tsCommand.timesheet.task.description} </a> </li> <li> <label for='hours'>Hours:</label> <input name='hours' id='hours' value='${tsCommand.hours}' /> </li> <li> <input type='submit' value='Save' /> </li> </ul> </sf:form> </div> <br /><br /> <a href='../timesheets'>Go Back</a> </body> </html>
In this view page we have submit button that’ll trigger POST request on /timesheets/{id} and pass updated model (TimesheetCommand instance in that). So let’s handle this. We will use @Valid annotation, which is part of JSR 303 Bean Validation API which marks object to validate. Also note, that TimesheetCommand must be annotated with @ModelAttribute annotation, because this command is bound to web view. Validation errors are stored in BindingResult object:
/** * Updates timesheet with given ID * @param id ID of timesheet to lookup from DB * @param tsCommand Lightweight command object with changed hours * @return redirects to timesheets */ @RequestMapping(value = '/{id}', method = RequestMethod.POST) public String updateTimesheet(@PathVariable('id') long id, @Valid @ModelAttribute('tsCommand') TimesheetCommand tsCommand, BindingResult result) { Timesheet timesheet = timesheetDao.find(id); if (result.hasErrors()) { tsCommand.setTimesheet(timesheet); return 'timesheets/view'; } // no errors, update timesheet timesheet.setHours(tsCommand.getHours()); timesheetDao.update(timesheet); return 'redirect:/timesheets'; }
For adding, we will have to pick from select menus of existing Tasks and Employees, so we’ll pass list of those when serving new form:
/** * Creates form for new timesheet * @param model Model to bind to HTML form * @return timesheets/new */ @RequestMapping(params = 'new', method = RequestMethod.GET) public String createTimesheetForm(Model model) { model.addAttribute('timesheet', new Timesheet()); model.addAttribute('tasks', taskDao.list()); model.addAttribute('employees', employeeDao.list()); return 'timesheets/new'; }
For showing select lists of Employees and Tasks we again need to create editors for them. We saw this approach earlier, so as before let’s add 2 new editors to our project that use corresponding DAOs:
package org.timesheet.web.editors; import org.timesheet.domain.Employee; import org.timesheet.service.dao.EmployeeDao; import java.beans.PropertyEditorSupport; /** * Will convert ID from combobox to employee's instance. */ public class EmployeeEditor extends PropertyEditorSupport { private EmployeeDao employeeDao; public EmployeeEditor(EmployeeDao employeeDao) { this.employeeDao = employeeDao; } @Override public void setAsText(String text) throws IllegalArgumentException { long id = Long.parseLong(text); Employee employee = employeeDao.find(id); setValue(employee); } }
package org.timesheet.web.editors; import org.timesheet.domain.Task; import org.timesheet.service.dao.TaskDao; import java.beans.PropertyEditorSupport; public class TaskEditor extends PropertyEditorSupport { private TaskDao taskDao; public TaskEditor(TaskDao taskDao) { this.taskDao = taskDao; } @Override public void setAsText(String text) throws IllegalArgumentException { long id = Long.parseLong(text); Task task = taskDao.find(id); setValue(task); } }
We’ll register these editors in TimesheetController initBinder method:
@InitBinder protected void initBinder(WebDataBinder binder) { binder.registerCustomEditor(Employee.class, new EmployeeEditor(employeeDao)); binder.registerCustomEditor(Task.class, new TaskEditor(taskDao)); }
Now we can safely add new.jsp under timesheets folder, because select lists will correctly be populated with data passed in the model:
<%@ taglib prefix='sf' uri='http://www.springframework.org/tags/form' %> <%@ page contentType='text/html;charset=UTF-8' language='java' %> <%--@elvariable id='employees' type='java.util.List<org.timesheet.domain.Employee'--%> <%--@elvariable id='tasks' type='java.util.List<org.timesheet.domain.Task'--%> <html> <head> <title>Add new timesheet</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h2>Add new Timesheet</h2> <div id='list'> <sf:form method='post' action='timesheets' commandName='timesheet'> <ul> <li> <label for='employees'>Pick employee:</label> <sf:select path='who' id='employees'> <sf:options items='${employees}' itemLabel='name' itemValue='id' /> </sf:select> </li> <li> <label for='tasks'>Pick task:</label> <sf:select path='task' id='tasks'> <sf:options items='${tasks}' itemLabel='description' itemValue='id' /> </sf:select> </li> <li> <label for='hours'>Hours:</label> <sf:input path='hours' /> </li> <li> <input type='submit' value='Save' /> </li> </ul> </sf:form> </div> <br /><br /> <a href='timesheets'>Go Back</a> </body> </html>
Submit button submits POST request on /timesheets path, so we’ll handle this with pretty straightforward controller method:
/** * Saves new Timesheet to the database * @param timesheet Timesheet to save * @return redirects to timesheets */ @RequestMapping(method = RequestMethod.POST) public String addTimesheet(Timesheet timesheet) { timesheetDao.add(timesheet); return 'redirect:/timesheets'; }
So all timesheet functionalities should be working now, just make sure by using application for a while. Of course, we will also write unit test for TimesheetController now. In test methods testUpdateTimesheetValid and testUpdateTimesheetInValid we are not validating object manually, but we’re mocking validator instead:
package org.timesheet.web; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.ui.ExtendedModelMap; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; import org.timesheet.DomainAwareBase; import org.timesheet.domain.Employee; import org.timesheet.domain.Manager; import org.timesheet.domain.Task; import org.timesheet.domain.Timesheet; import org.timesheet.service.dao.EmployeeDao; import org.timesheet.service.dao.ManagerDao; import org.timesheet.service.dao.TaskDao; import org.timesheet.service.dao.TimesheetDao; import org.timesheet.web.commands.TimesheetCommand; import java.util.Collection; import java.util.List; import static org.junit.Assert.*; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @ContextConfiguration(locations = {'/persistence-beans.xml', '/controllers.xml'}) public class TimesheetControllerTest extends DomainAwareBase { @Autowired private TimesheetDao timesheetDao; @Autowired private EmployeeDao employeeDao; @Autowired private ManagerDao managerDao; @Autowired private TaskDao taskDao; @Autowired private TimesheetController controller; private Model model; // used for controller @Before public void setUp() { model = new ExtendedModelMap(); } @Test public void testShowTimesheets() { // prepare some data Timesheet timesheet = sampleTimesheet(); // use controller String view = controller.showTimesheets(model); assertEquals('timesheets/list', view); List<Timesheet> listFromDao = timesheetDao.list(); Collection<?> listFromModel = (Collection<?>) model.asMap().get('timesheets'); assertTrue(listFromModel.contains(timesheet)); assertTrue(listFromDao.containsAll(listFromModel)); } @Test public void testDeleteTimesheet() { // prepare ID to delete Timesheet timesheet = sampleTimesheet(); timesheetDao.add(timesheet); long id = timesheet.getId(); // delete & assert String view = controller.deleteTimesheet(id); assertEquals('redirect:/timesheets', view); assertNull(timesheetDao.find(id)); } @Test public void testGetTimesheet() { // prepare timesheet Timesheet timesheet = sampleTimesheet(); timesheetDao.add(timesheet); long id = timesheet.getId(); TimesheetCommand tsCommand = new TimesheetCommand(timesheet); // get & assert String view = controller.getTimesheet(id, model); assertEquals('timesheets/view', view); assertEquals(tsCommand, model.asMap().get('tsCommand')); } @Test public void testUpdateTimesheetValid() { // prepare ID to delete Timesheet timesheet = sampleTimesheet(); timesheetDao.add(timesheet); long id = timesheet.getId(); TimesheetCommand tsCommand = new TimesheetCommand(timesheet); // user alters Timesheet hours in HTML form with valid value tsCommand.setHours(1337); BindingResult result = mock(BindingResult.class); when(result.hasErrors()).thenReturn(false); // update & assert String view = controller.updateTimesheet(id, tsCommand, result); assertEquals('redirect:/timesheets', view); assertTrue(1337 == timesheetDao.find(id).getHours()); } @Test public void testUpdateTimesheetInValid() { // prepare ID to delete Timesheet timesheet = sampleTimesheet(); timesheetDao.add(timesheet); long id = timesheet.getId(); TimesheetCommand tsCommand = new TimesheetCommand(timesheet); Integer originalHours = tsCommand.getHours(); // user alters Timesheet hours in HTML form with valid value tsCommand.setHours(-1); BindingResult result = mock(BindingResult.class); when(result.hasErrors()).thenReturn(true); // update & assert String view = controller.updateTimesheet(id, tsCommand, result); assertEquals('timesheets/view', view); assertEquals(originalHours, timesheetDao.find(id).getHours()); } @Test public void testAddTimesheet() { // prepare timesheet Timesheet timesheet = sampleTimesheet(); // save but via controller String view = controller.addTimesheet(timesheet); assertEquals('redirect:/timesheets', view); // timesheet is stored in DB assertEquals(timesheet, timesheetDao.find(timesheet.getId())); } private Timesheet sampleTimesheet() { Employee marty = new Employee('Martin Brodeur', 'NHL'); employeeDao.add(marty); Manager jeremy = new Manager('Jeremy'); managerDao.add(jeremy); Task winStanleyCup = new Task('NHL finals', jeremy, marty); taskDao.add(winStanleyCup); Timesheet stanelyCupSheet = new Timesheet(marty, winStanleyCup, 100); timesheetDao.add(stanelyCupSheet); return stanelyCupSheet; } }
Last controller we have to do is for our special business service – TimesheetService. We already have implemented and tested it’s logic. Controller will simply merge this functionalities to one menu page and we’ll handle each with controller. So let’s add some boilerplate controller definition and DAO wiring at first:
@Controller @RequestMapping('/timesheet-service') public class TimesheetServiceController { private TimesheetService service; private EmployeeDao employeeDao; private ManagerDao managerDao; @Autowired public void setService(TimesheetService service) { this.service = service; } @Autowired public void setEmployeeDao(EmployeeDao employeeDao) { this.employeeDao = employeeDao; } @Autowired public void setManagerDao(ManagerDao managerDao) { this.managerDao = managerDao; } }
When user enters /timesheet-service with GET request, we will server him menu with populated data:
/** * Shows menu of timesheet service: * that contains busiest task and employees and managers to * look for their assigned tasks. * @param model Model to put data to * @return timesheet-service/list */ @RequestMapping(method = RequestMethod.GET) public String showMenu(Model model) { model.addAttribute('busiestTask', service.busiestTask()); model.addAttribute('employees', employeeDao.list()); model.addAttribute('managers', managerDao.list()); return 'timesheet-service/menu'; }
Again, to make stuff work from select lists we will register editors (we’ll just reuse editors that we created recently):
@InitBinder protected void initBinder(WebDataBinder binder) { binder.registerCustomEditor(Employee.class, new EmployeeEditor(employeeDao)); binder.registerCustomEditor(Manager.class, new ManagerEditor(managerDao)); }
Now we will be providing a service. We will have RESTful URLs once again, but actual resources won’t be directly mapped to domain models as before, but results of some internal service. So getting tasks for manager with id 123 will result to GET request timesheets/manager-tasks/123. Same for tasks for employee. We will form actual URLs with jQuery using listeners for select lists. Add timesheet-service folder and add there menu.jsp page with following content:
<%@ taglib prefix='sf' uri='http://www.springframework.org/tags/form' %> <%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core' %> <%@ page contentType='text/html;charset=UTF-8' language='java' %> <%--@elvariable id='busiestTask' type='org.timesheet.domain.Task'--%> <%--@elvariable id='managers' type='java.util.List<org.timesheet.domain.Manager>'--%> <%--@elvariable id='employees' type='java.util.List<org.timesheet.domain.Employee>'--%> <html> <head> <title>Timesheet Service</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h1>Timesheet services</h1> <div id='list'> <h3>Busiest task</h3> <ul> <li> <a href='/timesheet-app/tasks/${busiestTask.id}' id='busiest-task'>${busiestTask.description}</a> </li> </ul> <h3>Tasks for manager</h3> <sf:form method='get' id='manager-form'> <ul> <li> <select id='select-managers'> <c:forEach items='${managers}' var='man'> <option value='${man.id}'>${man.name}</option> </c:forEach> </select> </li> <li> <input type='submit' value='Search' /> </li> </ul> </sf:form> <h3>Tasks for employee</h3> <sf:form method='get' id='employee-form'> <ul> <li> <select id='select-employees'> <c:forEach items='${employees}' var='emp'> <option value='${emp.id}'>${emp.name}</option> </c:forEach> </select> </li> <li> <input type='submit' value='Search'> </li> </ul> </sf:form> </div> <br /><br /> <a href='/timesheet-app/welcome'>Go Back</a> <script src='/timesheet-app/resources/jquery-1.7.1.js'></script> <script type='text/javascript'> (function() { // set default actions setAddAction('#select-managers', '#manager-form', 'manager-tasks'); setAddAction('#select-employees', '#employee-form', 'employee-tasks'); // handler for chaning action $('#select-managers').on('change', function() { setAddAction('#select-managers', '#manager-form', 'manager-tasks'); }); $('#select-employees').on('change', function() { setAddAction('#select-employees', '#employee-form', 'employee-tasks'); }); function setAddAction(selectName, formName, action) { var id = $(selectName).val(); $(formName).attr('action', '/timesheet-app/timesheet-service/' + action + '/' + id); } })(); </script> </body> </html>
Getting tasks for given manager:
/** * Returns tasks for given manager * @param id ID of manager * @param model Model to put tasks and manager * @return timesheet-service/manager-tasks */ @RequestMapping(value = '/manager-tasks/{id}', method = RequestMethod.GET) public String showManagerTasks(@PathVariable('id') long id, Model model) { Manager manager = managerDao.find(id); List<Task> tasks = service.tasksForManager(manager); model.addAttribute('manager', manager); model.addAttribute('tasks', tasks); return 'timesheet-service/manager-tasks'; }
And as a result page timesheet-service/manager-tasks.jsp will be rendered:
<%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core' %> <%@ page contentType='text/html;charset=UTF-8' language='java' %> <%--@elvariable id='manager' type='org.timesheet.domain.Manager'--%> <%--@elvariable id='tasks' type='java.util.List<org.timesheet.domain.Task>'--%> <html> <head> <title>Tasks for manager</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h3> Current manager: <a href='/timesheet-app/managers/${manager.id}'>${manager.name}</a> </h3> <div id='list'> <c:forEach items='${tasks}' var='task'> <li> <a href='/timesheet-app/tasks/${task.id}'>${task.description}</a> </li> </c:forEach> </div> <br /><br /> <a href='../'>Go Back</a> </body> </html>
We’ll do pretty much the same for employee:
/** * Returns tasks for given employee * @param id ID of employee * @param model Model to put tasks and employee * @return timesheet-service/employee-tasks */ @RequestMapping(value = '/employee-tasks/{id}', method = RequestMethod.GET) public String showEmployeeTasks(@PathVariable('id') long id, Model model) { Employee employee = employeeDao.find(id); List<Task> tasks = service.tasksForEmployee(employee); model.addAttribute('employee', employee); model.addAttribute('tasks', tasks); return 'timesheet-service/employee-tasks'; }
And jsp view employee-tasks.jsp:
<%@ taglib prefix='c' uri='http://java.sun.com/jsp/jstl/core' %> <%@ page contentType='text/html;charset=UTF-8' language='java' %> <%--@elvariable id='employee' type='org.timesheet.domain.Employee'--%> <%--@elvariable id='tasks' type='java.util.List<org.timesheet.domain.Task>'--%> <html> <head> <title>Tasks for employee</title> <link rel='stylesheet' href='/timesheet-app/resources/style.css' type='text/css'> </head> <body> <h3> Current employee: <a href='/timesheet-app/employees/${employee.id}'>${employee.name}</a> </h3> <div id='list'> <c:forEach items='${tasks}' var='task'> <li> <a href='/timesheet-app/tasks/${task.id}'>${task.description}</a> </li> </c:forEach> </div> <br /><br /> <a href='../'>Go Back</a> </body> </html>
So lets make sure everything is well integrated and add unit test for this new contoller:
package org.timesheet.web; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.FileSystemResource; import org.springframework.jdbc.core.simple.SimpleJdbcTemplate; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.jdbc.SimpleJdbcTestUtils; import org.springframework.ui.ExtendedModelMap; import org.springframework.ui.Model; import org.timesheet.DomainAwareBase; import org.timesheet.domain.Employee; import org.timesheet.domain.Manager; import org.timesheet.service.TimesheetService; import org.timesheet.service.dao.EmployeeDao; import org.timesheet.service.dao.ManagerDao; import static org.junit.Assert.assertEquals; /** * This test relies on fact that DAOs and Services are tested individually. * Only compares, if controller returns the same as individual services. */ @ContextConfiguration(locations = {'/persistence-beans.xml', '/controllers.xml'}) public class TimesheetServiceControllerTest extends DomainAwareBase { @Autowired private TimesheetServiceController controller; @Autowired private TimesheetService timesheetService; @Autowired private EmployeeDao employeeDao; @Autowired private ManagerDao managerDao; @Autowired private SimpleJdbcTemplate jdbcTemplate; private Model model; private final String createScript = 'src/main/resources/sql/create-data.sql'; @Before public void setUp() { model = new ExtendedModelMap(); SimpleJdbcTestUtils.executeSqlScript(jdbcTemplate, new FileSystemResource(createScript), false); } @Test public void testShowMenu() { String view = controller.showMenu(model); assertEquals('timesheet-service/menu', view); assertEquals(timesheetService.busiestTask(), model.asMap().get('busiestTask')); // this should be done only on small data sample // might cause serious performance cost for complete assertEquals(employeeDao.list(), model.asMap().get('employees')); assertEquals(managerDao.list(), model.asMap().get('managers')); } @Test public void testShowManagerTasks() { // prepare some ID Manager manager = managerDao.list().get(0); long id = manager.getId(); String view = controller.showManagerTasks(id, model); assertEquals('timesheet-service/manager-tasks', view); assertEquals(manager, model.asMap().get('manager')); assertEquals(timesheetService.tasksForManager(manager), model.asMap().get('tasks')); } @Test public void testShowEmployeeTasks() { // prepare some ID Employee employee = employeeDao.list().get(0); long id = employee.getId(); String view = controller.showEmployeeTasks(id, model); assertEquals('timesheet-service/employee-tasks', view); assertEquals(employee, model.asMap().get('employee')); assertEquals(timesheetService.tasksForEmployee(employee), model.asMap().get('tasks')); } }
Project structure after this part (all new stuff is visible):
Final request mappings:
Reference: Part 5 – Adding Spring MVC part 2 from our JCG partner Michal Vrtiak at the vrtoonjava blog.