Thursday, July 21, 2011

IValidatableObject client-side validation

We cannot really bring IValidatableObject validation to client-side, unless someone could transpile C# code to javascript :p Another hurdle is IValidatableObject can access resources from server, and that cannot be transpiled to javascript :-) What the following article will show you is how to bring those IValidatableObject validation results back to client-side via ajax, so no page round-trip would occur. As IValidatableObject results are also placed in ModelState, we can nicely retrieve not just the IValidatableObject results, but also those errors that get past property-level validations and model-level(IValidatableObject) validations, e.g. database constraints exceptions, concurrent update exception, deleted record exception, server errors, disk out of space error :-) etc.

First, let's design the API. Line #49 and #83

@model NhConcurrencyOnAspNetMvc.Models.Song

@{
 ViewBag.Title = "Input";
}

<h2>Input</h2>


@DateTime.Now

<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>
<script src="/Scripts/IEnableMuch/ienablemuch.js" type="text/javascript"></script>

@{ string controllerName = (string) ViewContext.RouteData.Values["Controller"]; }

@using (Html.BeginForm("Save", controllerName) )
{

 @Html.HiddenFor(x => x.SongId)
 @Html.HiddenFor(x => x.Version)
 <fieldset>
  <legend>Song</legend>

  <div class="editor-label">
   @Html.LabelFor(model => model.SongName)
  </div>
  <div class="editor-field">
   @Html.EditorFor(model => model.SongName)
   @Html.ValidationMessageFor(model => model.SongName)
  </div>

  <div class="editor-label">
   @Html.LabelFor(model => model.AlbumName)
  </div>
  <div class="editor-field">
   @Html.EditorFor(model => model.AlbumName)
   @Html.ValidationMessageFor(model => model.AlbumName)
  </div>

  <p>
   <input type="submit" value="Save" />
  </p>
 </fieldset>
 
 
 
 @Html.JsAccessibleValidationSummary(true)
 
   
}


<div>
 @Html.ActionLink("Back to List", "Index")
</div>



@ViewBag.Message



<script type="text/javascript">

 $(function () {

  $('input[type=submit]').click(function (e) {            
   e.preventDefault();

   if ($('form').valid()) {

    //alert('test');
    $.ajax({
     url: '/Song/SaveViaAjax',
     type: 'POST',
     data: $('form').serialize(),
     dataType: 'json',
     success: function (data) {


      var isOk = $('form').modelValidation(data);


      if (isOk) {
       $('input[name=SongId]').val(data.SongId);
       $('input[name=Version]').val(data.Version);

       alert('Saved.');
      }

     },
     error: function (xhr, err) {
      alert("readyState: " + xhr.readyState + "\nstatus: " + xhr.status);
      alert("responseText: " + xhr.responseText);
     }
    }); //ajax

   } //end if valid

  }); // submit


 });   // ready
</script>

On Controller line #36, return ModelState's valid status and its errors (model-level errors and property-level errors), we segregate those two errors so it's easier on jQuery's side to process those.

[HttpPost]
public JsonResult SaveViaAjax(Song song)
{
 SaveCommon(song);

 
 // you can use the following commented code
 /* 
 var v = from m in ModelState.AsEnumerable()
   from e in m.Value.Errors
   select new { m.Key, e.ErrorMessage };
 
 
 return Json(
  new 
  { 
   ModelState = 
    new 
    { 
     IsValid = ModelState.IsValid, 
     PropertyErrors = v.Where(x => !string.IsNullOrEmpty(x.Key)),
     ModelErrors = v.Where(x => string.IsNullOrEmpty(x.Key))
    },
   SongId = song.SongId,
   Version = Convert.ToBase64String(song.Version ?? new byte[]{} )
  });*/


 
 // alternatively, you can use an extension method, less error-prone,
 // pattern it after the code above

 return Json(
  new 
  { 
   ModelState = ModelState.ToJsonValidation(),
   SongId = song.SongId,
   Version = Convert.ToBase64String(song.Version ?? new byte[]{} )
  });


}





We refactor common code, line #9, so in case the user turned off the javascript, our app can degrade gracefully:

[HttpPost]
public ActionResult Save(Song song)
{
 SaveCommon(song);

 return View("Input", song);
}

private void SaveCommon(Song song)
{
 using (var s = Mapper.GetSessionFactory().OpenSession())
 using (var tx = s.BeginTransaction())
 {
  try
  {
   if (ModelState.IsValid)
   {
    s.SaveOrUpdate(song);

    tx.Commit();

    ModelState.Remove("SongId");
    // no need to issue ModelState.Remove on Version property; with byte array, ASP.NET MVC always get its value from the model, not from ModelState
    // ModelState.Remove("Version") 
   }

  }
  catch (StaleObjectStateException)
  {                    
   s.Evict(song);
   var dbValues = s.Get<Song>(song.SongId);
   var userValues = song;


   if (dbValues == null)
   {
    ModelState.AddModelError("", "This record you are attempting to save is already deleted by other user");
    return;
   }

   if (dbValues.SongName != userValues.SongName)
    ModelState.AddModelError("SongName", "Current value made by other user: " + dbValues.SongName);


   if (dbValues.AlbumName != userValues.AlbumName)
    ModelState.AddModelError("AlbumName", "Current value made by other user: " + dbValues.AlbumName);

   ModelState.AddModelError("",
    "The record you attempted to edit was already modified by another user since you last loaded it. Open the latest changes on this record");

  }
  catch (Exception ex)
  {
   ModelState.AddModelError("", ex.Message);
  }

 }
}


public ActionResult Delete(int id, byte[] Version)
{
 using (var s = Mapper.GetSessionFactory().OpenSession())
 using (var tx = s.BeginTransaction())
 {

  
  if (Request.HttpMethod == "POST")
  {
   var versionedDelete = new Song { SongId = id, Version = Version };
   try
   {
    s.Delete(versionedDelete);
    tx.Commit();

    return RedirectToAction("Index");
   }
   catch (StaleObjectStateException)
   {
    s.Evict(versionedDelete);

    var songToDelete = s.Get<Song>(id);
    if (songToDelete == null)
     return RedirectToAction("Index");

    ModelState.AddModelError("", "The record you are attempting to delete was modified by other user first. Do you still want to delete this record?");
    return View(songToDelete);
   }
  }
  else
  {
   var songToDelete = s.Get<Song>(id);
   return View(songToDelete);
  }
  
 }
}

The model and its server-level validations. These validations can be instantly seen on client-side(no page refresh) without doing a page roundtrip, use the jQuery library before the screenshots guide.


using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.ComponentModel.DataAnnotations;

namespace NhConcurrencyOnAspNetMvc.Models
{
 public class Song : IValidatableObject
 {
  public virtual int SongId { get; set; }
  [Required] public virtual string SongName { get; set; }
  [Required] public virtual string AlbumName { get; set; }

  public virtual byte[] Version { get; set; }

  public virtual IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
  {

   // model-level validation
   if (SongName == "blah" && AlbumName == "meh")
   {
    yield return new ValidationResult("Combined song name and album name cannot be blah and meh");
    yield return new ValidationResult("*", new[] { "SongName", "AlbumName" });                
   }
   else
   {

    // model-level validation
    if (SongName[0] != AlbumName[0])
    {
     yield return new ValidationResult("Song name and album name must start with same letter");
     yield return new ValidationResult("*", new[] { "AlbumName", "SongName" });                    
    }

    // property, inline style
    if (!SongName.ToUpper().Contains("LOVE"))
     yield return new ValidationResult("Song name must have a word love", new[] { "SongName" });

    // property, but using asterisk style
    if (!AlbumName.ToUpper().Contains("GREAT"))
    {
     yield return new ValidationResult("Album name must have a word great");
     yield return new ValidationResult("*", new[] { "AlbumName" });                    
    }
   }
   
  }
 }
}



There's a bug on Html.ValidationSummary when excludePropertyErrors is true, there's no <div class="validation-summary-valid"> tag emitted, i.e. it doesn't output anything, it's exactly the same as if not placing Html.ValidationSummary in code at all. As it is, there's no way for javascript to output validations on page. To rectify that problem, we still output validation-summary-valid on page even the excludePropertyErrors is true, with one caveat, we set data-valmsg-summary to false (line #83); this won't have any effect on existing client-side validation, jquery validation library just look for data-valmsg-summary=true attribute.


Below is the corresponding HtmlHelper, API-wise, it behaves exactly the same as its eight Html.ValidationSummary cousin. In fact, it's safe to keep Html.JsAccessibleValidationSummary in your code even you will not use IValidatableObject client-side validation. Loving extension methods :-) Click the Expand Source.


public static object ToJsonValidation(this ModelStateDictionary modelState)
{
 var v = from m in modelState.AsEnumerable()
   from e in m.Value.Errors
   select new { m.Key, e.ErrorMessage };

 return new
 {
  IsValid = modelState.IsValid,
  PropertyErrors = v.Where(x => !string.IsNullOrEmpty(x.Key)),
  ModelErrors = v.Where(x => string.IsNullOrEmpty(x.Key))
 };
}



public static MvcHtmlString JsAccessibleValidationSummary(this HtmlHelper htmlHelper)
{
 return htmlHelper.ValidationSummary();
}


public static MvcHtmlString JsAccessibleValidationSummary(this HtmlHelper htmlHelper, bool excludePropertyErrors)
{
 if (!htmlHelper.ViewData.ModelState.IsValid || !excludePropertyErrors)
  return htmlHelper.ValidationSummary(excludePropertyErrors);
 else
 {
  return htmlHelper.BuildValidationSummary(null, null);
 }
}

public static MvcHtmlString JsAccessibleValidationSummary(this HtmlHelper htmlHelper, string message)
{
 return htmlHelper.ValidationSummary(message);
}

public static MvcHtmlString JsAccessibleValidationSummary(this HtmlHelper htmlHelper, bool excludePropertyErrors, string message)
{
 if (!htmlHelper.ViewData.ModelState.IsValid || !excludePropertyErrors)
  return htmlHelper.ValidationSummary(excludePropertyErrors, message);
 else
  return htmlHelper.BuildValidationSummary(message, (IDictionary<string, object>)null);

}


public static MvcHtmlString JsAccessibleValidationSummary(this HtmlHelper htmlHelper, string message, object htmlAttributes)
{
 return htmlHelper.ValidationSummary(message, htmlAttributes);
}


public static MvcHtmlString JsAccessibleValidationSummary(this HtmlHelper htmlHelper, bool excludePropertyErrors, string message, object htmlAttributes)
{
 if (!htmlHelper.ViewData.ModelState.IsValid || !excludePropertyErrors)
  return htmlHelper.ValidationSummary(excludePropertyErrors, message, htmlAttributes);
 else
  return htmlHelper.BuildValidationSummary(message, HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));
}

public static MvcHtmlString JsAccessibleValidationSummary(this HtmlHelper htmlHelper, string message, IDictionary<string, object> htmlAttributes)
{
 return htmlHelper.ValidationSummary(message, htmlAttributes);
}

public static MvcHtmlString JsAccessibleValidationSummary(this HtmlHelper htmlHelper, bool excludePropertyErrors, string message, IDictionary<string, object> htmlAttributes)
{
 if (!htmlHelper.ViewData.ModelState.IsValid || !excludePropertyErrors)
  return htmlHelper.ValidationSummary(excludePropertyErrors, message, htmlAttributes);
 else
  return htmlHelper.BuildValidationSummary(message, htmlAttributes);
}


private static MvcHtmlString BuildValidationSummary(this HtmlHelper htmlHelper, string message, IDictionary<string, object> htmlAttributes)
{
 // excludePropertyErrors is always true here

 string messageSpan;
 if (!String.IsNullOrEmpty(message))
 {
  TagBuilder spanTag = new TagBuilder("span");
  spanTag.SetInnerText(message);
  messageSpan = spanTag.ToString(TagRenderMode.Normal) + Environment.NewLine;
 }
 else
 {
  messageSpan = null;
 }

 TagBuilder divBuilder = new TagBuilder("div");

 divBuilder.AddCssClass(HtmlHelper.ValidationSummaryValidCssClassName);
 divBuilder.InnerHtml = messageSpan + "<ul></ul>";


 // We use false, so jQuery won't output property validation on summary
 divBuilder.MergeAttribute("data-valmsg-summary", "false");

 divBuilder.MergeAttributes(htmlAttributes);


 return divBuilder.ToMvcHtmlString(TagRenderMode.Normal);
}

// copied from ASP.NET MVC. so many internals in ASP.NET MVC :-)
private static MvcHtmlString ToMvcHtmlString(this TagBuilder tagBuilder, TagRenderMode renderMode)
{
 System.Diagnostics.Debug.Assert(tagBuilder != null);
 return new MvcHtmlString(tagBuilder.ToString(renderMode));
}


Here's the modelValidation code, it's very lean:
(function ($) {

 $.fn.modelValidation = function (data) {

  var form = $(this);

  if (form.size() > 1) {
   alert('There are more than one form in this page. Contact the dev to indicate the specific form that needed be validated');
   return;
  }



  if (!data.ModelState.IsValid) {
   showInvalid(data, form);
  }
  else
   clearInvalid(form);

  return data.ModelState.IsValid;

 }



 function clearInvalid(form) {
  var valSum = $('div[data-valmsg-summary]', form)
  valSum.removeClass().addClass('validation-summary-valid');
  var errorList = $('> ul', valSum);
  errorList.html('');
 }

 function showInvalid(data, form) {

  valSum = $('div[data-valmsg-summary]', form);


  if (valSum.attr('data-valmsg-summary') == undefined)
   valSum = null;



  var errorList = null;
  if (valSum) {
   valSum.removeClass().addClass('validation-summary-errors');
   errorList = $('> ul', valSum);
   errorList.html('');
  }


  $.each(data.ModelState.PropertyErrors, function () {

   if (valSum && valSum.attr('data-valmsg-summary') == 'true') {
    errorList.append($('<li />').text(this.ErrorMessage));
   }


   var propVal = $('span[data-valmsg-for=' + this.Key + ']', form);
   propVal.removeClass().addClass('field-validation-error').text(this.ErrorMessage);

   var prop = $('input[name=' + this.Key + ']', form);
   prop.addClass('input-validation-error');
  });



  if (valSum) {
   $.each(data.ModelState.ModelErrors, function () {
    errorList.append($('<li />').text(this.ErrorMessage));
   });
  }

 }//showInvalid


})(jQuery);


Complete code here: http://code.google.com/p/nh-concurrency-handling-on-aspnet-mvc-demo/downloads/list


Code simulation output for fetching ModelState errors(of which IValidatableObject also places its errors) messages from server using ajax, time remain constant. You can also try to simulate sans ajax, the output is same, with different time of course :-)

Session 1 ( 12:44:20 PM ). Save button not yet clicked

Session 1 ( 12:44:22 PM ). Save button not yet clicked

Session 3. Delete not yet clicked

Session 1 ( 12:44:20 PM ). Save button clicked. Shows the server business validations without refreshing the whole page. No page refresh, note the time

Session 1 ( 12:44:20 PM ). Valid input done on Song Name. Song name and album name must start with same letter. Validation from server is invoked. No page refresh, note the time

Session 1 ( 12:44:20 PM ). Valid input done on Song Name, yet the combined validation of Song Name and Album Name from business validation is still not valid. No page refresh, note the time

Session 1 ( 12:44:20 PM ). Combined validation of Song Name and Album Name passed. Album name is still not valid. No page refresh, note the time

Session 1 ( 12:44:20 PM ). Every business validations passed. Saved successfully

Session 2( 12:44:22 PM ). After session 1 finished. Session 2 user tried to save his/her edit concurrently with Session 1. The database error is shown back, there's no page refresh, note the time


Session 3. It detected changes made on other sessions

Session 3. Delete successful

Session 1 ( 12:44:20 PM ). Tried to save again. It shows the concurrent delete by other session. The server from server is  shown back. No page refresh, note the time


UPDATE 2012-09-04


C# can be transpiled to Javascript: http://www.saltarelle-compiler.com/documentation/supported-c-language-features

2 comments:

  1. do you know of a way to set data-valmsg-summary to false? client side

    ReplyDelete
    Replies
    1. use jQuery attr to set data-valmsg-summary to false

      http://jsfiddle.net/J228j/

      Delete