CustomMenu

Monday, February 24, 2014

Spring MVC 4 - Bootstrap and Validation

In this post we build on the example from the prior posts.  Here we add input validation by updating the strategy controller, strategy list/add page, strategy update/edit page, and the strategy entity.

After a bit of research, I've decided to remove the DTO layer for the CRUD operations.  It seems there is quite a bit of disagreement around DTOs, especially around the use of DTOs for the view layer, that exacly duplicate the fields and methods of an entity/model object. Here is the link to one of the articles about DTO/VO and their use relative to Domain/@Entity objects:

Controller vs Service vs private method on command object.

1. The first step in this example is to update the entity/model layer.  We will add some constraint and column annotations to the entity, and also implement the toString(), equals(), and hashCode() methods.  In addition we will move the id attribute to a common entity base class.  The two classes are shown below.
package com.dtr.oas.model;

import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;

import com.google.common.base.Objects;

@MappedSuperclass()
public abstract class BaseEntity {

 @Id
 @GeneratedValue
 private Integer id;

 public Integer getId() {
  return id;
 }

 public void setId(Integer id) {
  this.id = id;
 }

 @Override
 public String toString() {
  return String.format("%s(id=%d)", this.getClass().getSimpleName(), this.getId());
 }

 @Override
 public boolean equals(Object o) {
  if (this == o)
   return true;
  if (o == null)
   return false;

  if (o instanceof BaseEntity) {
   final BaseEntity other = (BaseEntity) o;
   return Objects.equal(getId(), other.getId());
  }
  return false;
 }

 @Override
 public int hashCode() {
  return Objects.hashCode(getId());
 }

}
And now for the Strategy entity/model class:
package com.dtr.oas.model;

import java.io.Serializable;

import com.google.common.base.Objects;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import org.hibernate.validator.constraints.NotEmpty;

@Entity
@Table(name = "STRATEGY")
public class Strategy extends BaseEntity implements Serializable {

 private static final long serialVersionUID = 96285180113476324L;

 @NotNull(message = "{error.strategy.type.null}")
 @NotEmpty(message = "{error.strategy.type.empty}")
 @Size(max = 20, message = "{error.strategy.type.max}")
 @Column(name = "TYPE", length = 20)
 private String type;

 @NotNull(message = "{error.strategy.name.null}")
 @NotEmpty(message = "{error.strategy.name.empty}")
 @Size(max = 20, message = "{error.strategy.name.max}")
 @Column(name = "NAME", length = 20)
 private String name;

 public String getType() {
  return type;
 }

 public void setType(String type) {
  this.type = type;
 }

 public String getName() {
  return name;
 }

 public void setName(String name) {
  this.name = name;
 }

 @Override
 public String toString() {
  return String.format("%s(id=%d, type='%s', name=%s)", 
    this.getClass().getSimpleName(), this.getId(), this.getType(), this.getName());
 }

 @Override
 public boolean equals(Object o) {
  if (this == o) return true;
  if (o == null) return false;

  if (o instanceof Strategy) {
   final Strategy other = (Strategy) o;
   return Objects.equal(getId(), other.getId()) && 
       Objects.equal(getType(), other.getType()) && 
       Objects.equal(getName(), other.getName());
  }
  return false;
 }

 @Override
 public int hashCode() {
  return Objects.hashCode(getId(), getType(), getName());
 }
}
In the Strategy class, you will notice that there are annotation tags added for each instance variable.  The @NotNull and @Size annotations are part of javax.validation, and the @NotEmpty annotation is part of the hibernate.validator.  With these annotations in place, we can call @Valid in our controller to run all of these validations against this Strategy entity.  I have also added the @Column annotation more as a reminder about the size of each column and to make sure the @Size values match the respective @Column length values.  BTW, here is an interesting comparison of approaches for overriding toString(), equals(), and hashCode():

http://www.halyph.com/2013/05/equals-hashcode-and-tostring-in-java.html


2. Next we will use the built in method for externalizing the validation error messages.  We will create a ValidationMessages.properties file in the main/resources directory in Eclipse.  This file contains simple key/value pairs.  The message keys defined in the Strategy class map to message keys in the properties file, which are then resolved to values (messages for display).  A good article on Spring message validation can be found here:

http://www.silverbaytech.com/2013/04/16/custom-messages-in-spring-validation/
error.strategy.type.null=Strategy type required
error.strategy.type.empty=Strategy type required
error.strategy.type.max=Type must be less than 20 characters

error.strategy.name.null=Strategy name required
error.strategy.name.empty=Strategy name required
error.strategy.name.max=Name must be less than 20 characters

3. The controller is next.  The controller will be updated to perform validation and pass error messages back to the Thymeleaf pages.  In addition we will add logging to the controller and update the controller methods to return Strings rather than ModelAndView objects.
package com.dtr.oas.controller;

import java.util.List;
import java.util.Locale;

import javax.validation.Valid;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
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.servlet.mvc.support.RedirectAttributes;

import com.dtr.oas.model.Strategy;
import com.dtr.oas.service.StrategyService;

@Controller
@RequestMapping(value="/strategy")
public class StrategyController {
    static Logger logger = LoggerFactory.getLogger(StrategyController.class);

    @Autowired
    private StrategyService strategyService;
    
    @Autowired  
    private MessageSource messageSource;

    @RequestMapping(value="/list",  method=RequestMethod.GET)
    public String listOfStrategies(Model model) {
        logger.info("IN: Strategy/list-GET");

        List<Strategy> strategies = strategyService.getStrategies();
        model.addAttribute("strategies", strategies);

        if(! model.containsAttribute("strategy")) {
            logger.info("Adding Strategy object to model");
            Strategy strategy = new Strategy();
            model.addAttribute("strategy", strategy);
        }
        return "strategy-list";
    }
    
    @RequestMapping(value="/add", method=RequestMethod.POST)
    public String addingStrategy(@Valid @ModelAttribute Strategy strategy, 
                                 BindingResult result,  
                                 Model model, 
                                 RedirectAttributes redirectAttrs) {
        
        logger.info("IN: Strategy/add-POST");

        if (result.hasErrors()) {
            logger.info("Strategy-add error: " + result.toString());
            redirectAttrs.addFlashAttribute("org.springframework.validation.BindingResult.strategy", result);
            redirectAttrs.addFlashAttribute("strategy", strategy);
            return "redirect:/strategy/list";
        } else {
            strategyService.addStrategy(strategy);
            String message = "Strategy " + strategy.getId() + " was successfully added";
            model.addAttribute("message", message);
            return "redirect:/strategy/list";
        }
    }
        
    @RequestMapping(value="/edit", method=RequestMethod.GET)
    public String editStrategyPage(@RequestParam(value="id", required=true) Integer id, Model model) {
        logger.info("IN: Strategy/edit-GET:  ID to query = " + id);

        if(! model.containsAttribute("strategy")) {
            logger.info("Adding Strategy object to model");
            Strategy strategy = strategyService.getStrategy(id);
            logger.info("Strategy/edit-GET:  " + strategy.toString());
            model.addAttribute("strategy", strategy);
        }
        
        return "strategy-edit";
    }
        
    @RequestMapping(value="/edit", method=RequestMethod.POST)
    public String editingStrategy(@Valid @ModelAttribute Strategy strategy, 
                                        BindingResult result, 
                                        Model model, 
                                        RedirectAttributes redirectAttrs,
                                        @RequestParam(value="action", required=true) String action) {

        logger.info("IN: Strategy/edit-POST: " + action);

        if (action.equals( messageSource.getMessage("button.action.cancel", null, Locale.US) )) {
            String message = "Strategy " + strategy.getId() + " edit cancelled";
            model.addAttribute("message", message);
        } else if (result.hasErrors()) {
            logger.info("Strategy-edit error: " + result.toString());
            redirectAttrs.addFlashAttribute("org.springframework.validation.BindingResult.strategy", result);
            redirectAttrs.addFlashAttribute("strategy", strategy);
            return "redirect:/strategy/edit?id=" + strategy.getId();
        } else if (action.equals( messageSource.getMessage("button.action.save", null, Locale.US) )) {
            logger.info("Strategy/edit-POST:  " + strategy.toString());
            strategyService.updateStrategy(strategy);
            String message = "Strategy " + strategy.getId() + " was successfully edited";
            model.addAttribute("message", message);
        } 
        
        return "redirect:/strategy/list";
    }

    @RequestMapping(value="/delete", method=RequestMethod.GET)
    public String deleteStrategyPage(@RequestParam(value="id", required=true) Integer id, 
            @RequestParam(value="phase", required=true) String phase, Model model) {
        
        Strategy strategy = strategyService.getStrategy(id);
        logger.info("IN: Strategy/delete-GET | id = " + id + " | phase = " + phase + " | " + strategy.toString());

        if (phase.equals( messageSource.getMessage("button.action.cancel", null, Locale.US) )) {
            String message = "Strategy delete was cancelled.";
            model.addAttribute("message", message);
            return "redirect:/strategy/list";
        } else if (phase.equals( messageSource.getMessage("button.action.stage", null, Locale.US) )) {
            String message = "Strategy " + strategy.getId() + " queued for display.";
            model.addAttribute("strategy",strategy);
            model.addAttribute("message", message);
            return "strategy-delete";
        } else if (phase.equals( messageSource.getMessage("button.action.delete", null, Locale.US) )) {
            strategyService.deleteStrategy(id);
            String message = "Strategy " + strategy.getId() + " was successfully deleted";
            model.addAttribute("message", message);
            return "redirect:/strategy/list";
        }
        
        return "redirect:/strategy/list";
    }
}
At the top of the controller file you can see the slf4j logger definition.  In the list-GET method you will notice that this method now takes a Model parameter.  This is needed for displaying error messages from the add-POST method.  We have a conditional to make sure we do not add a new empty Strategy object to the model...in the situation where there is already a Strategy object present containing an error from the add-POST method.  Also, all of the methods in the controller now return a String rather than a ModelAndView object.

In the add-POST method there are now new method parameters and an @Valid annotation on the strategy to be added.  The BindingResult must immediately follow the validated object in the parameter sequence.  The Model must then follow the Binding Result, followed by the RedirectAttributes.  The BindingResult is checked for errors from the @Valid calls on the Strategy to be added.  If there are errors, the errors are added to the RedirectAttributes and a redirect back to list-GET occurs.  If there are no errors, then the Strategy is added and the user is redirected back to list-GET.

Edit-GET looks similar to list-GET, in that a Model object is now in the method parameter list.  If the Edit-GET method invocation occurs prior to the submit of an edit/update, then a new empty Strategy object is added to the Model, otherwise the Strategy object containing errors is left in place.

The edit-POST method is similar to the add-POST method.  The same method attributes are in place, but there is also an action request parameter that indicates whether there is a "cancel" or "save".  There is also an error check, with redirection back to edit-GET if there are errors.  Notice also, that the "cancel" check comes before the error check.

The delete-GET method follows a similar structure to the edit-POST, but a form is not used in the Thymeleaf page.  There are three actions/phases in this method: "cancel", "confirm", and "stage".  The "stage" phase displays the initial delete page, while the other two phases provide functionality after the page is displayed.


4. The updated messaged_en.properties file is next.  The HTML pages contain a number of static references, that point back to the messages_en.properties file.  This contains static text that was updated during this exercise.  The contents of this file is shown below.
admin.page.title=Admin Home Page

button.label.update=Update
button.label.add.strategy=Add Strategy
button.label.save=Save
button.label.delete=Delete
button.label.cancel=Cancel

button.action.stage=stage
button.action.save=save
button.action.delete=delete
button.action.cancel=cancel

strategy.list.page.title=Strategy List
strategy.list.head.title=Strategy List
strategy.list.body.title=Strategy List
strategy.list.table.title=Strategy List
strategy.list.id.label=Id
strategy.list.type.label=Strategy Type
strategy.list.name.label=Strategy Name
strategy.list.actions.label=Actions
strategy.list.add.title=Add Strategy

strategy.delete.page.title=Delete Strategy
strategy.delete.head.title=Delete Strategy
strategy.delete.body.title=Delete Strategy
strategy.delete.form.title=Delete Strategy

strategy.edit.page.title=Edit Strategy
strategy.edit.head.title=Edit Strategy
strategy.edit.body.title=Edit Strategy
strategy.edit.form.title=Edit Strategy

5. The Thymeleaf HTML pages are updated next.  Additionally, the pages have been updated to use the Thymeleaf "data-th" tags rather than the "th:" tags.  The former being HTML5 compliant.  The first page to be updated is the list page.
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
   xmlns:th="http://www.thymeleaf.org">

<head data-th-fragment="header">
 <meta charset="utf-8" />
 <meta http-equiv="X-UA-Compatible" content="IE=edge" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <title data-th-text="#{strategy.list.page.title}">Title</title>
 
 <!-- Bootstrap -->
 <link type="text/css" rel="stylesheet" href="../../resources/css/bootstrap-3.1.1.min.css" data-th-href="@{/resources/css/bootstrap-3.1.1.min.css}" />
 <link type="text/css" rel="stylesheet" href="../../resources/css/dashboard.css" data-th-href="@{/resources/css/dashboard.css}" />
 
 <style>
  .no-border-on-me>thead>tr>th,
  .no-border-on-me>tbody>tr>th,
  .no-border-on-me>tfoot>tr>th,
  .no-border-on-me>thead>tr>td,
  .no-border-on-me>tbody>tr>td,
  .no-border-on-me>tfoot>tr>td
  {
   border-top-style: none;
   border-bottom-style: none;
  }
 </style>
 <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
 <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
 <!--[if lt IE 9]>
       <script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
       <script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
     <![endif]-->
</head>
<body>
 <div class="container-fluid">
  <div class="row">
   <div class="main">
    <h3 data-th-text="#{strategy.list.table.title}">Configured Strategies</h3>
    <div class="table responsive">
    <table class="table table-striped table-bordered table-hover">
     <thead>
      <tr>
       <th class="col-sm-1" data-th-text="#{strategy.list.id.label}">Id</th>
       <th class="col-sm-4" data-th-text="#{strategy.list.type.label}">Strategy Type</th>
       <th class="col-sm-4" data-th-text="#{strategy.list.name.label}">Strategy Name</th>
       <th class="col-sm-2" data-th-text="#{strategy.list.actions.label}">Action</th>
      </tr>
     </thead>
     <tbody>
      <tr data-th-each="strategy : ${strategies}">
       <td data-th-text="${strategy.id}">1</td>
       <td data-th-text="${strategy.type}">Iron Butterfly</td>
       <td data-th-text="${strategy.name}">Triple Butter</td>
       <td style="text-align: center;">
        <a href="#" data-th-href="@{/strategy/edit(id=${strategy.id})}">
         <button type="button" class="btn btn-default btn-xs">
          <span class="glyphicon glyphicon-pencil"></span>&nbsp;&nbsp;Edit
         </button></a> &nbsp; 
        <a href="#" data-th-href="@{/strategy/delete(id=${strategy.id},phase=stage)}">
         <button type="button" class="btn btn-default btn-xs">
          <span class="glyphicon glyphicon-trash"></span>&nbsp;&nbsp;Delete
         </button></a>
       </td>
      </tr>
      <tr data-th-remove="all">
       <td>2</td>
       <td>Iron Condor</td>
       <td>High Prob Hedged</td>
       <td style="text-align: center;">
        <a href="#">
         <button type="button" class="btn btn-default btn-xs">
          <span class="glyphicon glyphicon-pencil"></span>&nbsp;&nbsp;Edit
         </button></a>&nbsp; 
        <a href="#">
         <button type="button" class="btn btn-default btn-xs">
          <span class="glyphicon glyphicon-trash"></span>&nbsp;&nbsp;Delete
         </button></a>
       </td>
      </tr>
     </tbody>
    </table>
    </div>
    
    <br />
    
    <form class="form" action="#" data-th-action="@{/strategy/add}" data-th-object="${strategy}" method="post">
    <div class="table responsive">
     <table class="no-border-on-me table ">
      <thead>
       <tr>
        <th class="col-sm-1"></th>
        <th class="col-sm-4" data-th-text="#{strategy.list.type.label}">Strategy Type</th>
        <th class="col-sm-4" data-th-text="#{strategy.list.name.label}">Strategy Name</th>
        <th class="col-sm-2" data-th-text="#{strategy.list.actions.label}">Action</th>
       </tr>
      </thead>
      <tbody>
       <tr>
        <td><input type="text" hidden="hidden" data-th-field="*{id}"></input></td>
        <td><input class="form-control" type="text" data-th-field="*{type}" placeholder="Type"></input></td>
        <td><input class="form-control" type="text" data-th-field="*{name}" placeholder="Name"></input></td>
        <td>
         <button type="submit" class="btn btn-primary" data-th-text="#{button.label.add.strategy}">Add Strategy</button>
        </td>
       </tr>
       <tr>
        <td>  </td>
        <td class="text-danger" data-th-if="${#fields.hasErrors('type')}" data-th-errors="*{type}">type error</td>
        <td class="text-danger" data-th-if="${#fields.hasErrors('name')}" data-th-errors="*{name}">name error</td>
        <td>  </td>
       </tr>
      </tbody>
     </table>
    </div>
    </form>
    
   </div>  <!-- END MAIN TAG -->
  </div>  <!-- END ROW TAG -->
 </div> <!-- END CONTAINER TAG -->

 <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
 <script type="text/css" src="../../resources/js/jquery-1.11.0.min.js" data-th-href="@{/resources/js/jquery-1.11.0.min.js}"></script>
 <!-- Include all compiled plugins (below), or include individual files as needed -->
 <script type="text/css" src="../../resources/js/bootstrap-3.3.3.min.js" data-th-href="@{/resources/js/bootstrap-3.3.3.min.js}"></script>
</body>
</html>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
   xmlns:th="http://www.thymeleaf.org">

<head data-th-fragment="header">
 <meta charset="utf-8" />
 <meta http-equiv="X-UA-Compatible" content="IE=edge" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <title data-th-text="#{strategy.list.page.title}">Title</title>
 
 <!-- Bootstrap -->
 <link type="text/css" rel="stylesheet" href="../../resources/css/bootstrap-3.1.1.min.css" data-th-href="@{/resources/css/bootstrap-3.1.1.min.css}" />
 <link type="text/css" rel="stylesheet" href="../../resources/css/dashboard.css" data-th-href="@{/resources/css/dashboard.css}" />
 
 <style>
  .no-border-on-me>thead>tr>th,
  .no-border-on-me>tbody>tr>th,
  .no-border-on-me>tfoot>tr>th,
  .no-border-on-me>thead>tr>td,
  .no-border-on-me>tbody>tr>td,
  .no-border-on-me>tfoot>tr>td
  {
   border-top-style: none;
   border-bottom-style: none;
  }
 </style>
 <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
 <!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
 <!--[if lt IE 9]>
       <script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
       <script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
     <![endif]-->
</head>
<body>
 <div class="container-fluid">
  <div class="row">
   <div class="main">
   
    <div class="col-sm-3"></div>
    <div class="col-sm-6">
     <h3 data-th-text="#{strategy.edit.head.title}">Edit Strategy</h3><br />
     <form class="form-horizontal" action="#" data-th-action="@{/strategy/edit}" data-th-object="${strategy}" method="post">
      <div class="form-group">
       <label class="col-sm-5 control-label" data-th-text="#{strategy.list.type.label}">Strategy Type</label>
       <div class="col-sm-7">
        <input type="text" hidden="hidden" data-th-value="*{id}" data-th-field="*{id}" ></input>
        <input type="text" class="form-control" data-th-value="*{type}" data-th-field="*{type}" ></input>
       </div>
      </div>
      <div class="form-group">
       <label class="col-sm-5 control-label" data-th-text="#{strategy.list.name.label}">Strategy Name</label>
       <div class="col-sm-7">
        <input type="text" class="form-control" data-th-value="*{name}" data-th-field="*{name}" ></input>
       </div>
      </div>
      <div class="form-group">
       <div class="col-sm-offset-5 col-sm-7" >
        <button type="submit" class="btn btn-primary"        name="action" data-th-value="#{button.action.save}"   data-th-text="#{button.label.save}"  >Save</button>
        <button type="submit" class="btn btn-default active" name="action" data-th-value="#{button.action.cancel}" data-th-text="#{button.label.cancel}">Cancel</button>
       </div>
      </div>
      <div class="form-group">
       <div class="col-sm-offset-5 col-sm-7" >
        <p class="text-danger" data-th-if="${#fields.hasErrors('type')}" data-th-errors="*{type}">type error</p> 
        <p class="text-danger" data-th-if="${#fields.hasErrors('name')}" data-th-errors="*{name}">name error</p> 
       </div>
      </div>
     </form>
    </div>
    <div class="col-sm-3"></div>
    
   </div>  <!-- END MAIN TAG -->
  </div>  <!-- END ROW TAG -->
 </div> <!-- END CONTAINER TAG -->

 <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
 <script type="text/css" src="../../resources/js/jquery-1.11.0.min.js" data-th-href="@{/resources/js/jquery-1.11.0.min.js}"></script>
 <!-- Include all compiled plugins (below), or include individual files as needed -->
 <script type="text/css" src="../../resources/js/bootstrap-3.3.3.min.js" data-th-href="@{/resources/js/bootstrap-3.3.3.min.js}"></script>
</body>
</html>
The "fields.hasErrors" lines in both of the above HTML files are used to display error messages produced from the @Valid call in the controller.


6.  At this point the application is ready to test.  If you run the application and navigate to the list and edit pages you can verify the validation.  Screenshots are below.








In future posts we will look at modal dialogues, navigation/themes/Thymeleaf templates, integrating Spring Security, JPA/connection pooling, and unit testing.  After these steps are complete, we should be ready to start building out the trading system core.

Code at GitHub: https://github.com/dtr-trading/spring-ex06-validation

6 comments:

Unknown said...

Hi,
Would you please the need of BaseEntity and why implement the toString(), equals(), and hashCode() methods. I am new to this. I have gone through few references but would like to hear your explanation as it will be very usefull for me.

Dave R. said...

BaseEntity is the parent class of the entity objects in my code. All of the entity objects are Hibernate / JPA entities (you'll notice the @Entity tag in the child classes). Hibernate requires that their entity objects implement equals() and hashCode(), and I added toString() for completeness.

Take a look at the following link also:
http://docs.jboss.org/hibernate/orm/4.3/manual/en-US/html/ch04.html#persistent-classes-equalshashcode

Thanks,
Dave

Unknown said...

Hi Dave,
Thanks for the explanation of the topic, it was very helpful to me. One more thing do you conduct online class for spring framework via pluralsight. If so please forward me the link for the same.

Thanks
Kalyan

Dave R. said...

Hi Kalyan,

Thank you for the kind words. Unfortunately, I do not offer any online classes or training. I wish I could provided you a reference.

Thanks,
Dave

Unknown said...
This comment has been removed by the author.
Unknown said...

Hi Dave,
I have posted my problem here http://stackoverflow.com/questions/32702804/in-springframework-validation-error-doesnt-display-rather-getting-http-status.

Could u plz suggest where have i gone wrong, as i am getting error instead of validation message.

Please help

Post a Comment