'Unobtrusive' asynchronous Form in ASP.NET MVC 2
Some time ago I have written a post about creating asynchronous form with client-side validation in ASP.NET MVC. Since then few things have been changed. Now we have ASP.NET MVC 2 with built in client-side validation. So it's time to rewrite that sample (but we are still going to use jQuery Validation plugin).
First let's remove all the javascript from our view, and leave only those script references:
We now should adjust the view markup to use our user control:
Now it's time for key changes. We will remove IDataErrorInfo interface from ViewModel class and add DataAnnotations. The problem is, that by default we have only four rules: Required, Range, StringLength and RegularExpression. In our sample we can only use Required (I will also use DisplayName attribute for labels) and for the rest of rules we have to write our own attributes.
Let's go step by step through implementing a DataAnnotation attribute for minlength rule and making it work on client-side. First, we need to create an attribute delivered from ValidationAttribute:
You should take special interest in EqualToAttribute. ASP.NET MVC 2 has been built for .Net Framework 3.5, because of that there is no access to context in ValidatorAttribute. This is why cross properties check can't be done on property level, but has to be done on class level. On the other hand, class level attributes doesn't generate client-side validation. This is why this attribute must be placed on class and property to make it work on client and server side (nasty workaround but it is necessary).
When all the DataAnnotations attributes are finished, we can finally change our ViewModel:
First let's remove all the javascript from our view, and leave only those script references:
<script src="<%= Url.Content("~/Scripts/jquery-1.4.1.min.js") %>" type="text/javascript"></script>Now we will move our form to user control and make some changes in it:
<script src="<%= Url.Content("~/Scripts/jquery.validate.min.js") %>" type="text/javascript"></script>
<script src="<%= Url.Content("~/Scripts/MicrosoftAjax.js") %>" type="text/javascript"></script>
<script src="<%= Url.Content("~/Scripts/MicrosoftMvcAjax.js") %>" type="text/javascript"></script>
<script src="<%= Url.Content("~/Scripts/MicrosoftMvcJQueryValidation.js") %>" type="text/javascript"></script>
<%@ Control Language="C#" Inherits="System.Web.Mvc.ViewUserControl<...>" %>As you can see I have added Html.EnableClientValidation(); to the markup - this will enable native client-side validation. I have also changed Html.BeginForm into Ajax.BeginForm - we can use it now without any problems. Another important change you should notice is usage of new strongly typed helpers.
<% Html.EnableClientValidation(); %>
<% if (Model.UserRegistered) { %>
User successfully registered.
<% } else { %>
<% using (Ajax.BeginForm("AsynchronousForm", "Home", null, new AjaxOptions() { UpdateTargetId = "dvAccountInformationFormContainer" }, new { id = "frmRegister" })) { %>
<%= Html.AntiForgeryToken() %>
<div>
<fieldset>
<legend>Account Information</legend>
<p>
<%= Html.LabelFor(value => Model.UserName) %>
<%= Html.TextBoxFor(value => Model.UserName) %>
<%= Html.ValidationMessageFor(value => Model.UserName) %>
</p>
<p>
<%= Html.LabelFor(value => Model.Email) %>
<%= Html.TextBoxFor(value => Model.Email) %>
<%= Html.ValidationMessageFor(value => Model.Email) %>
</p>
<p>
<%= Html.LabelFor(value => Model.Password) %>
<%= Html.TextBoxFor(value => Model.Password) %>
<%= Html.ValidationMessageFor(value => Model.Password) %>
</p>
<p>
<%= Html.LabelFor(value => Model.ConfirmPassword) %>
<%= Html.TextBoxFor(value => Model.ConfirmPassword) %>
<%= Html.ValidationMessageFor(value => Model.ConfirmPassword) %>
</p>
<p>
<input type="submit" value="Register" />
</p>
</fieldset>
</div>
<% } %>
</div>
We now should adjust the view markup to use our user control:
<div id="dvAccountInformationFormContainer">Next thing is to modify our controller actions:
<% Html.RenderPartial("~/Views/Home/AccountInformationForm.ascx", Model); %>
</div>
/// <summary>Most of changes are in action responsible for handling Post request - it should now return markup which will be injected in our page using script generated by Ajax.BeginForm. We also had to add JsonRequestBehavior.AllowGet to JsonResult because of changes in ASP.NET MVC 2 (by default JsonResult is allowed only for Post request).
/// GET Home/AsynchronousForm
/// </summary>
/// <returns>AsynchronousForm view</returns>
public ViewResult AsynchronousForm()
{
return View(new AsynchronousFormViewModel());
}
/// <summary>
/// POST Home/AsynchronousForm
/// </summary>
/// <param name="viewModel">The form post data</param>
/// <param name="isAjaxRequest">Value indicating if it is an AJAX request</param>
/// <returns>AsynchronousForm view or json result</returns>
[AcceptVerbs(HttpVerbs.Post)]
[ValidateAntiForgeryToken]
[IsAjaxRequest("isAjaxRequest")]
public ActionResult AsynchronousForm(AsynchronousFormViewModel viewModel, bool isAjaxRequest)
{
if (ModelState.IsValid)
{
try
{
_usersRepository.RegisterUser(viewModel.UserName, viewModel.Email, viewModel.Password);
viewModel = new AsynchronousFormViewModel() { UserRegistered = true };
}
catch (Exception ex)
{
ModelState.AddModelError("_FORM", ex.Message);
}
}
if (isAjaxRequest)
return PartialView("AccountInformationForm", viewModel);
else
return View(viewModel);
}
/// <summary>
/// Validates username
/// </summary>
/// <param name="UserName">The username</param>
/// <returns>true or false</returns>
public JsonResult ValidateUserName(string UserName)
{
return Json(_usersRepository.ValidateUserNameUnique(UserName), JsonRequestBehavior.AllowGet);
}
/// <summary>
/// Validates password
/// </summary>
/// <param name="Password">The password</param>
/// <returns>true or error message</returns>
public JsonResult ValidatePassword(string Password)
{
string passwordInvalidMessage = _usersRepository.ValidatePassword(Password);
if (String.IsNullOrEmpty(passwordInvalidMessage))
return Json(true, JsonRequestBehavior.AllowGet);
else
return Json(passwordInvalidMessage, JsonRequestBehavior.AllowGet);
}
Now it's time for key changes. We will remove IDataErrorInfo interface from ViewModel class and add DataAnnotations. The problem is, that by default we have only four rules: Required, Range, StringLength and RegularExpression. In our sample we can only use Required (I will also use DisplayName attribute for labels) and for the rest of rules we have to write our own attributes.
Let's go step by step through implementing a DataAnnotation attribute for minlength rule and making it work on client-side. First, we need to create an attribute delivered from ValidationAttribute:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]The implementation is pretty straightforward and doesn't require any sophisticated comments. This is all we need to make it work on server-side, now let's make it work on client-side. We need to prepare a validator class (in this case it will be class delivered from DataAnnotationsModelValidator<TAttribute>):
public class MinLengthAttribute : ValidationAttribute
{
#region Properties
public int MinLength { get; set; }
#endregion
#region Constructor
public MinLengthAttribute(int minLength)
{
MinLength = minLength;
}
#endregion
#region Methods
public override bool IsValid(object value)
{
if (value == null)
return true;
if ((value as String).Length < MinLength)
return false;
return true;
}
#endregion
}
public class MinLengthValidator : DataAnnotationsModelValidator<MinLengthAttribute>The most important method here is GetCleintValidationRules, it will be used for generating metadata which will be injected into the client script. Now, we have to register the validator with ASP.NET MVC 2 engine (preferably in Global.asax:
{
#region Fields
int _minLength;
string _errorMessage;
#endregion
#region Constructor
public MinLengthValidator(ModelMetadata metadata, ControllerContext context, MinLengthAttribute attribute)
: base(metadata, context, attribute)
{
_minLength = attribute.MinLength;
_errorMessage = attribute.ErrorMessage;
}
#endregion
#region Methods
public override IEnumerable<ModelClientValidationRule> GetClientValidationRules()
{
var validatioRule = new ModelClientValidationRule
{
ErrorMessage = _errorMessage,
ValidationType = "minlength"
};
validatioRule.ValidationParameters.Add("length", _minLength);
return new[] { validatioRule };
}
#endregion
}
DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(MinLengthAttribute), typeof(MinLengthValidator));We also have to modify MicrosoftMvcJQueryValidation.js in two places. First, we should add following function:
function __MVC_ApplyValidator_MinLength(object, length) {Then modify __MVC_CreateRulesForField function by extending switch statement inside of it:
object["minlength"] = length;
}
switch (thisRule.ValidationType) {Following those steps we can implement attributes and validators for all needed rules. You can find here final source code of those used in sample.
...
case "minlength":
__MVC_ApplyValidator_MinLength(rulesObj,
thisRule.ValidationParameters["length"]);
break;
...
}
You should take special interest in EqualToAttribute. ASP.NET MVC 2 has been built for .Net Framework 3.5, because of that there is no access to context in ValidatorAttribute. This is why cross properties check can't be done on property level, but has to be done on class level. On the other hand, class level attributes doesn't generate client-side validation. This is why this attribute must be placed on class and property to make it work on client and server side (nasty workaround but it is necessary).
When all the DataAnnotations attributes are finished, we can finally change our ViewModel:
[EqualTo("Password", "ConfirmPassword", typeof(AsynchronousFormViewModel), ErrorMessage = "Confirm password does not match password")]The sample is finished. We have achieved the same functionality, but the code fallows DRY principle now. You can download complete source code from Codeplex.
public class AsynchronousFormViewModel
{
#region Properties
[DisplayName("Username:")]
[Required(ErrorMessage = "Please enter username")]
[MinLength(5, ErrorMessage = "Please enter username with at least 5 characters")]
[Remote("ValidateUserName", "Home", typeof(DummyUsersRepository), "ValidateUserNameUnique", ErrorMessage = "Username is already in use")]
public string UserName { get; set; }
[DisplayName("Email:")]
[Required(ErrorMessage = "Please enter email address")]
[Email(ErrorMessage = "Please enter valid email address")]
public string Email { get; set; }
[DisplayName("Password:")]
[Required(ErrorMessage = "Please enter password")]
[Remote("ValidatePassword", "Home", typeof(DummyUsersRepository), "ValidatePassword", ErrorMessage = null)]
public string Password { get; set; }
[DisplayName("Confirm password:")]
[Required(ErrorMessage = "Please repeat password")]
[EqualTo("Password", "ConfirmPassword", typeof(AsynchronousFormViewModel), ErrorMessage = "Confirm password does not match password")]
public string ConfirmPassword { get; set; }
public bool UserRegistered { get; set; }
#endregion
}