How do I write a custom validator in MVC3 to validate the postal code in the United States or Canada, depending on the country selected?

advertisements

I need to apply a custom validator to billing address fields. The view displays several address fields, including a Country drop-down list (with U.S. and Canada options), and a BillingPostalCode textbox. Originally I applied a regular expression to the message contract that allowed either U.S. or Canada zip codes, like so:

[MessageBodyMember]
[Display(Name = "Billing Postal Code")]
[Required]
[StringLength(10, MinimumLength = 5)]
[RegularExpression("(^\\d{5}(-\\d{4})?$)|(^[ABCEGHJKLMNPRSTVXY]{1}\\d{1}[A-Z]{1} *\\d{1}[A-Z]{1}\\d{1}$)", ErrorMessage = "Zip code is invalid.")] // US or Canada
public string BillingPostalCode
{
   get { return _billingPostalCode; }
   set { _billingPostalCode = value; }
}

The above will allow either U.S. or Canada zip codes. But, the business owner wants the form to allow a U.S. or Canadian zip code only when United States or Canada is respectively selected in the BillingCountry drop-down list. In his test case, he selected Canada and entered a U.S. zip code. That scenario should be disallowed.

My initial stab at doing this was to put this in the view, though I'm not happy with creating 2 textbox fields. I should only need 1 field.

<div style="float: left; width: 35%;">
   Zip Code<br />
   <span id="spanBillingZipUS">
      @Html.TextBoxFor(m => m.BillingPostalCode, new { @class = "customer_input", @id = "BillingPostalCode" })
   </span>
   <span id="spanBillingZipCanada">
      @Html.TextBoxFor(m => m.BillingPostalCode, new { @class = "customer_input", @id = "BillingPostalCodeCanada" })
   </span>
   @Html.ValidationMessageFor(m => m.BillingPostalCode)
   <br />
</div>

My thought process being that I'll use jQuery to show or hide the appropriate span when the Country drop-down list is toggled. That piece is easy.

But I'm stuck with the problem that both text boxes have that single validator applied to them, which maps to the MessageBodyMember pasted above. I know how to write the validation code in jQuery, but would prefer to also have the validation applied to the server side as well.

I'm fairly new to MVC, having come from web forms. The "old school" web forms custom validation was simple to implement. Examples I've found online for custom validation in MVC are pretty complex. At first, this seemed to be a very basic request. The code needs to evaluate one variable (the selected Country) and apply the appropriate regular expression for that country to the BillingPostalCode field.

How do I meet this requirement in a straightforward fashion with MVC3? Thanks.


Well I implemented what this guy made and it works as a chram with Data Annotations. You do have to work a little in order to change the check for a dropdown value, but this is the more elegant way I found to implement validation with Data Annotations and Unobtrusive.


Here an example:

Model

...
        [RequiredIf("IsUKResident", true, ErrorMessage = "You must specify the City if UK resident")]
        public string City { get; set; }
...

Custom Attribute

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

namespace Mvc3ConditionalValidation.Validation
{
    public class RequiredIfAttribute : ValidationAttribute, IClientValidatable
    {
        private RequiredAttribute _innerAttribute = new RequiredAttribute();

        public string DependentProperty { get; set; }
        public object TargetValue { get; set; }

        public RequiredIfAttribute(string dependentProperty, object targetValue)
        {
            this.DependentProperty = dependentProperty;
            this.TargetValue = targetValue;
        }

        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
        {
            // get a reference to the property this validation depends upon
            var containerType = validationContext.ObjectInstance.GetType();
            var field = containerType.GetProperty(this.DependentProperty);

            if (field != null)
            {
                // get the value of the dependent property
                var dependentvalue = field.GetValue(validationContext.ObjectInstance, null);

                // compare the value against the target value
                if ((dependentvalue == null && this.TargetValue == null) ||
                    (dependentvalue != null && dependentvalue.Equals(this.TargetValue)))
                {
                    // match => means we should try validating this field
                    if (!_innerAttribute.IsValid(value))
                        // validation failed - return an error
                        return new ValidationResult(this.ErrorMessage, new[] { validationContext.MemberName });
                }
            }

            return ValidationResult.Success;
        }

        public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
        {
            var rule = new ModelClientValidationRule()
            {
                ErrorMessage = FormatErrorMessage(metadata.GetDisplayName()),
                ValidationType = "requiredif",
            };

            string depProp = BuildDependentPropertyId(metadata, context as ViewContext);

            // find the value on the control we depend on;
            // if it's a bool, format it javascript style
            // (the default is True or False!)
            string targetValue = (this.TargetValue ?? "").ToString();
            if (this.TargetValue.GetType() == typeof(bool))
                targetValue = targetValue.ToLower();

            rule.ValidationParameters.Add("dependentproperty", depProp);
            rule.ValidationParameters.Add("targetvalue", targetValue);

            yield return rule;
        }

        private string BuildDependentPropertyId(ModelMetadata metadata, ViewContext viewContext)
        {
            // build the ID of the property
            string depProp = viewContext.ViewData.TemplateInfo.GetFullHtmlFieldId(this.DependentProperty);
            // unfortunately this will have the name of the current field appended to the beginning,
            // because the TemplateInfo's context has had this fieldname appended to it. Instead, we
            // want to get the context as though it was one level higher (i.e. outside the current property,
            // which is the containing object (our Person), and hence the same level as the dependent property.
            var thisField = metadata.PropertyName + "_";
            if (depProp.StartsWith(thisField))
                // strip it off again
                depProp = depProp.Substring(thisField.Length);
            return depProp;
        }
    }
}

Client Side

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

<script>
    $.validator.addMethod('requiredif',
        function (value, element, parameters) {
            var id = '#' + parameters['dependentproperty'];

            // get the target value (as a string,
            // as that's what actual value will be)
            var targetvalue = parameters['targetvalue'];
            targetvalue =
              (targetvalue == null ? '' : targetvalue).toString();

            // get the actual value of the target control
            // note - this probably needs to cater for more
            // control types, e.g. radios
            var control = $(id);
            var controltype = control.attr('type');
            var actualvalue =
                controltype === 'checkbox' ?
                control.attr('checked').toString() :
                control.val();

            // if the condition is true, reuse the existing
            // required field validator functionality
            if (targetvalue === actualvalue)
                return $.validator.methods.required.call(
                  this, value, element, parameters);

            return true;
        }
    );

    $.validator.unobtrusive.adapters.add(
        'requiredif',
        ['dependentproperty', 'targetvalue'],
        function (options) {
            options.rules['requiredif'] = {
                dependentproperty: options.params['dependentproperty'],
                targetvalue: options.params['targetvalue']
            };
            options.messages['requiredif'] = options.message;
        });

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

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