ASP.NET MVC: Adding client-side validation to PropertiesMustMatchAttribute
This is the third post in what is becoming a mini-series:
- ASP.NET MVC: Adding client-side validation to ValidatePasswordLengthAttribute
- ASP.NET MVC: Adding client-side validation to ValidatePasswordLengthAttribute in ASP.NET MVC 3 Preview 1
- ASP.NET MVC: Adding client-side validation to PropertiesMustMatchAttribute (this post!)
- ASP.NET MVC: Adding client-side validation to PropertiesMustMatchAttribute in ASP.NET MVC 3 Preview 1
- ASP.NET MVC: ValidatePasswordLength and PropertiesMustMatch in ASP.NET MVC 3 RC2
In the last two posts (here and here) we added client-side validation support for the ValidatePasswordLengthAttribute that is part of the default ASP.NET MVC project template. In this post we will look at another of the validators in the template: PropertiesMustMatchAttribute. This attribute is used on RegisterModel and ChangePasswordModel to ensure that the user has entered the same password for both the Password and ConfirmPassword fields.
If you apply the steps from the previous post to the PropertiesMustMatchAttribute you may be surprised to discover that the client-side validator doesn’t get wired up. If you take a look at the source for ChangePasswordModel below, you will notice that ValidatePasswordLength is applied to the NewPassword property whereas PropertiesMustMatch is applied to the ChangePasswordModel itself. In other words, the ValidatePasswordLength is a proprety validator but PropertiesMustMatch is a type validator. This difference is significant as ASP.NET MVC only supports client-side validation for property validators.
[PropertiesMustMatch("NewPassword", "ConfirmPassword", ErrorMessage = "The new password and confirmation password do not match.")]
public class ChangePasswordModel
{
[Required]
[DataType(DataType.Password)]
[DisplayName("Current password")]
public string OldPassword { get; set; }
[Required]
[ValidatePasswordLength]
[DataType(DataType.Password)]
[DisplayName("New password")]
public string NewPassword { get; set; }
[Required]
[DataType(DataType.Password)]
[DisplayName("Confirm new password")]
public string ConfirmPassword { get; set; }
}
In the remainder of this post we will create a MustMatchAttribute. This will be applied to a property (as shown below) to specify that its value should match another property, and will support client-side validation:
[Required]
[DataType(DataType.Password)]
[DisplayName("Confirm password")]
[MustMatch("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
Here we are applying the MustMatch validator to ConfirmPassword and specifying that the property’s value must must the Password property. As in the previous posts, we will start by creating the client-side validation code:
Sys.Mvc.ValidatorRegistry.validators.mustMatch = function (rule) { var propertyIdToMatch = rule.ValidationParameters.propertyIdToMatch; var message = rule.ErrorMessage; return function (value, context) { var thisField = context.fieldContext.elements[0]; var otherField = $get(propertyIdToMatch, thisField.form); if (otherField.value != value) { return false; } return true; }; };
This code is similar to the code from the previous post. The only real points of note are the use of the context property in the validation function to access the HTML form and the use of the jQuery $get function to find the property that we’re matching against.
With the client-side code taken care of the next step is to create the Validation attribute. Previously we derived from ValidationAttribute and supplied an override for the IsValid method. IsValid has the following signature:
public override bool IsValid(object value)
The value parameter is the value for the item currently being validated. In the case of the type-level validator (PropertiesMustMatch) this is the instance of the ChangePasswordModel, which allows reflection to be used to access the properties to validate. However, in the case of a property-level validator such as the one we’re creating the value is the value of the property that the validator was applied to. This causes a problem as we need to be able to access other properties on the model. We hooked up an adapter class previously to provide the link to client-side validation – the adapter also exposes a validate method which gives us more context about the validation.
We’ll create a basic validation attribute:
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public sealed class MustMatchAttribute : ValidationAttribute
{
private const string DefaultErrorMessage = "Must match {0}";
private readonly object _typeId = new object();
public MustMatchAttribute(string propertyToMatch)
: base(DefaultErrorMessage)
{
PropertyToMatch = propertyToMatch;
}
public string PropertyToMatch { get; private set; }
public override object TypeId
{
get
{
return _typeId;
}
}
public override string FormatErrorMessage(string name)
{
return String.Format(CultureInfo.CurrentUICulture, ErrorMessageString, PropertyToMatch);
}
public override bool IsValid(object value)
{
// we don't have enough information here to be able to validate against another field
// we need the DataAnnotationsMustMatchValidator adapter to be registered
throw new Exception("MustMatchAttribute requires the DataAnnotationsMustMatchValidator adapter to be registered"); // TODO – make this a typed exception :-)
}
}
Note that we’re not expecting the IsValid method to be called as we’re going to intercept this in the adapter class. Just to be sure, we’re raising an exception to catch the situation where the adapter hasn’t been registered. Our adapter class will look as shown below:
public class DataAnnotationsMustMatchValidator : DataAnnotationsModelValidator<MustMatchAttribute>
{
public DataAnnotationsMustMatchValidator(ModelMetadata metadata, ControllerContext context, MustMatchAttribute attribute)
: base(metadata, context, attribute)
{
}
public override System.Collections.Generic.IEnumerable<ModelValidationResult> Validate(object container)
{
var propertyToMatch = Metadata.ContainerType.GetProperty(Attribute.PropertyToMatch);
if (propertyToMatch != null)
{
var valueToMatch = propertyToMatch.GetValue(container, null);
var value = Metadata.Model;
bool valid = Equals(value, valueToMatch);
if (!valid)
{
yield return new ModelValidationResult {Message = ErrorMessage};
}
}
// we're not calling base.Validate here so that the attribute IsValid method doesn't get called
}
}
There are a few points of note in this code
- the container parameter a reference to the object that contains the item being validated – in our example the instance of ChangePasswordModel
- the Metadata property on DataAnnotationsModelValidator is an instance of ModelMetadata and gives us extra contextual information for the validation. Here we are using the ContainerType property to access the properties of the container and the Model property to access the value of the property that the attribute was applied to (this saves a property look-up via reflection)
- The return type from Validate is an IEnumerable<ModelValidationResult> in contrast to the bool from ValidationAttribute.IsValid. Thanks to iterator support in C# we can use the yield return keyword to make a simple conversion to our code
Now that we have our adapter class we can register if with the framework:
DataAnnotationsModelValidatorProvider.RegisterAdapter(typeof(MustMatchAttribute), typeof(DataAnnotationsMustMatchValidator));
With this in place we can apply the MustMatch attribute to our model as we saw earlier (make sure you comment out the PropertiesMustMatch attribute on the class):
[Required]
[DataType(DataType.Password)]
[DisplayName("Confirm password")]
[MustMatch("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
So far we’ve created a replacement for the PropertiesMustMatch attribute that is applied to properties instead of classes. However, this currently only gives us server-side validation and the whole reason for this exercise was to add client-side validation! Fortunately we have got nearly all the pieces in place. Firstly we need a ModelClientValidationRule:
public class ModelClientMustMatchValidationRule : ModelClientValidationRule
{
public ModelClientMustMatchValidationRule(string errorMessage, string propertyIdToMatch)
{
ErrorMessage = errorMessage;
ValidationType = "mustMatch";
ValidationParameters.Add("propertyIdToMatch", propertyIdToMatch);
}
}
And then we can add the following code to the adapter class to hook up the client-side validation code we saw earlier:
public override System.Collections.Generic.IEnumerable<ModelClientValidationRule> GetClientValidationRules()
{
string propertyIdToMatch = GetFullHtmlFieldId(Attribute.PropertyToMatch);
yield return new ModelClientMustMatchValidationRule(ErrorMessage, propertyIdToMatch);
}
private string GetFullHtmlFieldId(string partialFieldName)
{
ViewContext viewContext = (ViewContext)ControllerContext;
return viewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(partialFieldName);
}
The GetFullHtmlFieldId takes the server-side property name and uses the ViewContext to get the qualified client-side id for the input field. This is then used by the client-side code to get the value that we’re comparing to.
With those last code modifications we can now use the MustMatch validator to ensure that our Password and ConfirmPassword fields contain the same value and have the validation applied on the client side.
In summary:
- validation attributes can be applied to a class or property, but ASP.NET MVC only supports client-side validation for property-level validators
- Property-level validators only have access to the property value through ValidationAttribute.IsValid but the adapter class contains a Validate method that give us greater context to work with
If this feels a little more complicated than you had hoped then stay tuned for the next post where we’ll see how this has been improved in ASP.NET MVC 3 Preview 1!
Comments
Anonymous
August 23, 2010
Thanks for a most fascinating post Stuart, I have really learned a lot here. I have one problem though, the MustMatch attribute is not working client-side. I suspect this is because of where I have placed the "Sys.Mvc.ValidatorRegistry" code. It is in the master page, after all the markup, but before the jQuery "$(function () {...}" startup code. Do you think this could be the problem? What can I do to diagnose this?Anonymous
September 02, 2010
Hi Brady, First of I'd check that your registration script is included, and also check that the rendered page that uses it includes the javascript to call through to your validator. After that I'd probably use a script debugger (the Developer Tools included in Internet Explorer 8 or Visual Studio work well) to check that the registration code fires and see whether your validator fires). It's often helpful at this point to temporarily include the debug versions of Microsoft.Ajax. Hope that helps! StuartAnonymous
November 12, 2010
Well that explains most of it... I was wondering why the validation wasn't working correctly. One question, though: where is the best place to put the "Sys.Mvc.ValidatorRegistry.validators.mustMatch" function?Anonymous
November 24, 2010
Hi dcolumbus, I'd put the function in a separate script file to make it easy to work with from a development perspective. It would then be worth looking at script minifiers/combiners to incorporate into the built process to improve the download performance. StuartAnonymous
December 30, 2010
I appreciate this blog post and have learned a good amount from it thus far, but i have to admit i'm not sure what goes where. is there a down loadable example to help me understand this? Thank You KimAnonymous
December 30, 2010
(i posted that last from the wrong account) I appreciate this blog post and have learned a good amount from it thus far, but i have to admit i'm not sure what goes where. is there a down loadable example to help me understand this? Thanks Kim (again)Anonymous
February 08, 2011
Have any simple project with MustMatch Attribute??? Give plz url. Thanks.Anonymous
May 10, 2011
Thanks stuartle. I'm desperately looking for such functionality. Hats Off.Anonymous
January 19, 2012
Nice Article Stuartle, just what i'm looking for