Note: When I say ASP.NET MVC, this post only applies to version 2 and above, which at the time of writing includes MVC3 Preview 1.
There is a bit of client-side magic that happens on some forms these days. You’ve probably seen it; you type in a username or an email address into a registration form and before you have finished entering the next field, the form is already telling you whether or not the username/email address has already been used. Or, maybe you’ve typed in a gift code that immediately validates (or not).
This magic, contrary to the belief of some posse’s of some Faygo wielding clowns, is not magic but a technique called “remote validation”. Remote Validation is providing validation on the client (using something like JavaScript, Flash or Silverlight) that can utilise server side logic by way of an Ajax request. In most cases, validating the input against your data store.
At this point it should be noted that I will be focusing on an implementation that relies on the jQuery Validation plug-in. If you would prefer to use the MSAJAX library, I will now point you to Brad Wilson’s excellent post on the subject. My implementation is based on Brad’s, with a few differences, that I’ll mention on the way.
Okay to the implementation. As with a lot of things, jQuery makes our lives a lot easier. I expect John Resig should at least take some credit for my steady marital status, I’m not getting complacent. jQuery offers a plug-in, one of not very many officially supported plug-ins, called jQuery Validate. I am sure you are already acquainted. The validation plug-in offers remote validation out of the box, the implementation is both incredibly simple and efficient. Below is an example borrowed (I’m not giving it back) from the jQuery documentation.
$("#myform").validate({
rules: {
email: {
required: true,
email: true,
remote: "check-email.php"
}
}
});
The above example says three things about the field called email.
- It’s required.
- It’s and email address.
- As an extra check, the browser should pass the email address to an endpoint called “check-email.php”.
The remote endpoint has but one job, to return true or false (as a JSON object). It is up to you how that decision is arrived at. By default, this check is made either when the field loses focus or before the form is submitted.
That is fantastic if you can be bothered to write all of a long sentence each time, yeah I have better things to do like try and learn Japanese and the Ukulele simultaneously. Yeah, that’s how I roll, yo.
What we need to do is bake in some ASP.NET MVC wizardry. I have loved data annotations, since I first saw them in that scaffolding thing, umm Dynamic Data. Just awesome, one day I’d like to build a website with just a long string of attributes.
Okay, I’m going to assume that you are already familiar with how Html.EnableClientValidation() works for jQuery for the rest of this post. If there is any call for it, I focus on this on another post.
That assumed, we need to create a new Attribute to flag our property with. Our use case scenario will be a basic registration form (very basic) that requires a username, that has not already been used. Our model is below:
using System.ComponentModel.DataAnnotations;
public class Reg {
[HiddenInput(DisplayValue = false)]
public int ID { get; set; }
[StringLength(50), Required]
public string Name { get; set; }
[StringLength(250), Required]
public string Email { get; set; }
}
So our form is going to require a name and an email with maximum string lengths. Lets create an Attribute that says check me against something remote:
using System;
using System.ComponentModel.DataAnnotations;
using System.Configuration;
[AttributeUsage(AttributeTargets.Property)]
public class RemoteAttribute : ValidationAttribute {
#region Properties
public string Key { get; set; }
public string[] AdditionalProperties { get; set; }
#endregion
#region Methods
public virtual string GetUrl() {
return ConfigurationManager.AppSettings[Key];
}
public override bool IsValid(object value) {
return true;
}
#endregion
}
The key difference between mine and Brad’s Remote Attribute, is the way the remote URL is retrieved. Brad’s URL is baked straight into the Attribute, whereas mine is takes the URL from a Web Config AppSetting. I made this change because I tend to reuse models between projects and didn’t want my routing to be dictated to by my model. In the future I would like to have the option to set the URL in the Global.asax, so I can make use of T4MVC’s strongly typed ActionResults.
I have also added a string array called AdditionalProperties, this will contain a list of additional properties that I also want to pass to the remote endpoint. This is useful if you don’t want to invalidate an email address on a registration, because it already exists on the registration you’re currently editing. I’m going to infer my parameter name from the property.
If you haven’t read Brad’s post, I should mention that we’re returning true in IsValid() because this Attribute will not be performing traditional server-side validation. This is not because I’m lazy, but rather because we don’t know what that validation will be until it is implemented. I am quite lazy.
We’re also going to need an Adapter. An Adapter tells the compiler how to pass the Attribute to the client. Here it is:
using System.Collections.Generic;
using System.Web.Mvc;
public class RemoteAttributeAdapter : DataAnnotationsModelValidator<RemoteAttribute> {
public RemoteAttributeAdapter(ModelMetadata metadata, ControllerContext context, RemoteAttribute attribute) : base(metadata, context, attribute) { }
public override IEnumerable<ModelClientValidationRule> GetClientValidationRules() {
var rule = new ModelClientValidationRule {
ErrorMessage = ErrorMessage,
ValidationType = "remote"
};
rule.ValidationParameters["url"] = Attribute.GetUrl();
rule.ValidationParameters["type"] = "post";
rule.ValidationParameters["additionalProperties"] = Attribute.AdditionalProperties; ;
return new[] { rule };
}
}
For the most part this Adapter is just pairing up my attribute’s properties with the parameter’s of the jQuery validation plug-in. The only property that isn’t like for like at this stage is the additionalParameters parameter, this will map to a jQuery parameter called data.
Note: I’ve also hardcoded that the request be a POST. Bad practice, possibly, but I can’t see a call of this nature legitimately being a GET. I am posting information for comparison, not to specifically get information. You may have your own take on this, in which case you are free to adjust your implementation.
Which brings to the second example provided by the jQuery documentation:
$("#myform").validate({
rules: {
email: {
required: true,
email: true,
remote: {
url: "check-email.php",
type: "post",
data: {
username: function() {
return $("#username").val();
}
}
}
}
}
});
In the example, additional parameters are sent to the endpoint to add context to the validation method. I will replicate this in my client-side validation. My client-side validation will not be reinventing the wheel, I will be building on the file called MicrosoftMvcJQueryValidation.js. This file was originally provided in some of the preview releases of ASP.NET MVC2, and I can now never bloody find, so will include the script in it’s entirety at the bottom of this post. For now though, lets look at the additions I have made:
function __MVC_ApplyValidator_Remote(object, validationParameters, fieldName) {
var obj = object["remote"] = {};
var props = validationParameters.additionalProperties;
obj["url"] = validationParameters.url;
obj["type"] = validationParameters.type;
obj["beforeSend"] = function () {
var elementName = fieldName + "_validationMessage";
$("#" + elementName).addClass("remote");
};
obj["complete"] = function () {
var elementName = fieldName + "_validationMessage";
$("#" + elementName).removeClass("remote");
};
if (props) {
var data = {};
for (var i = 0, l = props.length; i < l; ++i) {
var param = props[i];
data[props[i]] = function () {
return $("#" + param).val();
}
}
obj["data"] = data;
}
}
The above block of code is added at around line 32, and maps the ASP.NET MVC generated JS to a jQuery Validate object. In additional to what you might have been expecting, I have also bound to two event handlers. This allows me to provide field by field loading indications the user “Hey user, I’m doing something clever to this field.” I do this in addition to ajaxStart and ajaxStop.
If you focus in on the for statement, you’ll see that I’m iterating through the additionalParameters array to produce a data parameter exactly the same in function as in the second jQuery example.
Let’s look at the second addition I’ve made:
// Line 110
function __MVC_CreateRulesForField(validationField, fieldName) {
// Line 137
case "remote":
__MVC_ApplyValidator_Remote(rulesObj, thisRule.ValidationParameters, fieldName);
break;
// Line 156
rulesObj[fieldName] = __MVC_CreateRulesForField(validationField, fieldName);
In the addition above I am simply adding an extra case into the switch that determines which adapter method to fire. This was necessary because I require the field name to be passed to the adapter.
Right, we’re almost there. Just four more steps:
DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(RemoteAttribute), typeof(RemoteAttributeAdapter));
Step One: Add the above line of code to the Application_Start method of your Global.asax.
using System.ComponentModel.DataAnnotations;
public class Reg {
[HiddenInput(DisplayValue = false)]
public int ID { get; set; }
[StringLength(50), Required, Remote(Key = "name-test", AdditionalProperties = new[] { "ID" })]
public string Name { get; set; }
[StringLength(250), Required]
public string Email { get; set; }
}
Step Two: Adjust our model to include the new RemoteAttribute. I’m telling the attribute to look for an AppSetting of “name-test” in web.config.
<add key="name-test" value="/Home/NameTest"/>
Step Three: Add an AppSetting called “name-test” to your web.config.
public virtual ActionResult NameTest() {
return Json(true);
}
Step Four: If you have not already done so, create your end point.
Discussing the creation of the form is beyond the scope of this post, mainly because it is dead easy and would sit better in a general post about jQuery validation with ASP.NET MVC. I will clarify that you need to reference jQuery and jQuery Validate. You will also need to add Html.EnableClientValidation() to the top of your form.
So what have we done here. We have created an Attribute and an AttributeAdapter pair that will allow you to describe a remote interaction between the server and your model. We have registered this Attribute/Adapter pair in our Global.asax file, so ASP.NET knows to look out for it. We have then transformed the interaction into a state that jQuery can the use using our modified MicrosoftMvcJQueryValidation.js file.
The complete MicrosoftMvcJQueryValidation.js source as promised:
/// <reference path="jquery-1.3.2.js" />
/// <reference path="jquery.validate.js" />
// register custom jQuery methods
jQuery.validator.addMethod("regex", function (value, element, params) {
if (this.optional(element)) {
return true;
}
var match = new RegExp(params).exec(value);
return (match && (match.index == 0) && (match[0].length == value.length));
});
// glue
function __MVC_ApplyValidator_Range(object, min, max) {
object["range"] = [min, max];
}
function __MVC_ApplyValidator_RegularExpression(object, pattern) {
object["regex"] = pattern;
}
function __MVC_ApplyValidator_Required(object) {
object["required"] = true;
}
function __MVC_ApplyValidator_StringLength(object, maxLength) {
object["maxlength"] = maxLength;
}
function __MVC_ApplyValidator_Remote(object, validationParameters, fieldName) {
var obj = object["remote"] = {};
var props = validationParameters.additionalProperties;
obj["url"] = validationParameters.url;
obj["type"] = validationParameters.type;
obj["beforeSend"] = function () {
var elementName = fieldName + "_validationMessage";
$("#" + elementName).addClass("remote");
};
obj["complete"] = function () {
var elementName = fieldName + "_validationMessage";
$("#" + elementName).removeClass("remote");
};
if (props) {
var data = {};
for (var i = 0, l = props.length; i < l; ++i) {
var param = props[i];
data[props[i]] = function () {
return $("#" + param).val();
}
}
obj["data"] = data;
}
}
function __MVC_ApplyValidator_Unknown(object, validationType, validationParameters) {
object[validationType] = validationParameters;
}
function __MVC_CreateFieldToValidationMessageMapping(validationFields) {
var mapping = {};
for (var i = 0; i < validationFields.length; i++) {
var thisField = validationFields[i];
mapping[thisField.FieldName] = "#" + thisField.ValidationMessageId;
}
return mapping;
}
function __MVC_CreateErrorMessagesObject(validationFields) {
var messagesObj = {};
for (var i = 0; i < validationFields.length; i++) {
var thisField = validationFields[i];
var thisFieldMessages = {};
messagesObj[thisField.FieldName] = thisFieldMessages;
var validationRules = thisField.ValidationRules;
for (var j = 0; j < validationRules.length; j++) {
var thisRule = validationRules[j];
if (thisRule.ErrorMessage) {
var jQueryValidationType = thisRule.ValidationType;
switch (thisRule.ValidationType) {
case "regularExpression":
jQueryValidationType = "regex";
break;
case "stringLength":
jQueryValidationType = "maxlength";
break;
}
thisFieldMessages[jQueryValidationType] = thisRule.ErrorMessage;
}
}
}
return messagesObj;
}
function __MVC_CreateRulesForField(validationField, fieldName) {
var validationRules = validationField.ValidationRules;
// hook each rule into jquery
var rulesObj = {};
for (var i = 0; i < validationRules.length; i++) {
var thisRule = validationRules[i];
switch (thisRule.ValidationType) {
case "range":
__MVC_ApplyValidator_Range(rulesObj,
thisRule.ValidationParameters["minimum"], thisRule.ValidationParameters["maximum"]);
break;
case "regularExpression":
__MVC_ApplyValidator_RegularExpression(rulesObj,
thisRule.ValidationParameters["pattern"]);
break;
case "required":
__MVC_ApplyValidator_Required(rulesObj);
break;
case "stringLength":
__MVC_ApplyValidator_StringLength(rulesObj,
thisRule.ValidationParameters["maximumLength"]);
break;
case "remote":
__MVC_ApplyValidator_Remote(rulesObj, thisRule.ValidationParameters, fieldName);
break;
default:
__MVC_ApplyValidator_Unknown(rulesObj,
thisRule.ValidationType, thisRule.ValidationParameters);
break;
}
}
return rulesObj;
}
function __MVC_CreateValidationOptions(validationFields) {
var rulesObj = {};
for (var i = 0; i < validationFields.length; i++) {
var validationField = validationFields[i];
var fieldName = validationField.FieldName;
rulesObj[fieldName] = __MVC_CreateRulesForField(validationField, fieldName);
}
return rulesObj;
}
function __MVC_EnableClientValidation(validationContext) {
// this represents the form containing elements to be validated
var theForm = $("#" + validationContext.FormId);
var fields = validationContext.Fields;
var rulesObj = __MVC_CreateValidationOptions(fields);
var fieldToMessageMappings = __MVC_CreateFieldToValidationMessageMapping(fields);
var errorMessagesObj = __MVC_CreateErrorMessagesObject(fields);
var options = {
errorClass: "input-validation-error",
errorElement: "span",
errorPlacement: function (error, element) {
var messageSpan = fieldToMessageMappings[element.attr("name")];
$(messageSpan).empty();
$(messageSpan).removeClass("field-validation-valid field-validation-isvalid");
$(messageSpan).addClass("field-validation-error");
error.removeClass("input-validation-error");
error.attr("_for_validation_message", messageSpan);
error.appendTo(messageSpan);
},
messages: errorMessagesObj,
rules: rulesObj,
success: function (label) {
var messageSpan = $(label.attr("_for_validation_message"));
$(messageSpan).empty();
$(messageSpan).addClass("field-validation-valid field-validation-isvalid");
$(messageSpan).removeClass("field-validation-error");
}
};
// register callbacks with our AJAX system
var formElement = document.getElementById(validationContext.FormId);
var registeredValidatorCallbacks = formElement.validationCallbacks;
if (!registeredValidatorCallbacks) {
registeredValidatorCallbacks = [];
formElement.validationCallbacks = registeredValidatorCallbacks;
}
registeredValidatorCallbacks.push(function () {
theForm.validate();
return theForm.valid();
});
theForm.validate(options);
}
// need to wait for the document to signal that it is ready
$(document).ready(function () {
var allFormOptions = window.mvcClientValidationMetadata;
if (allFormOptions) {
while (allFormOptions.length > 0) {
var thisFormOptions = allFormOptions.pop();
__MVC_EnableClientValidation(thisFormOptions);
}
}
});