2011/06/19

ASP.Net MVC 3 partial form validation on server and client (unobtrusive) sides.

If your view has more than one submit button and each button must validate different fields of the same form then this guide is the solution.
You will learn how to:
- Choose different actions to each submit button
- Choose which model properties doesn't have to be validated on actions
- Skip client side validation when a submit button is clicked.

When you click a submit button, its name is sent to server.

<input type="submit" name="announce" value="Announce!" />

In the case above, we know the submit button announce was clicked because Request["announce"] is not null. It's value is "Announce!".

So let's create an ActionMethodSelector that will allow us to instruct our action method to answer only when the submit button is clicked.

public class WhenRequestContainsKeyAttribute : ActionMethodSelectorAttribute
{
    public string Key { get; set; }

    public WhenRequestContainsKeyAttribute(string key)
    {
        this.Key = key;
    }

    public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo)
    {
        return controllerContext.HttpContext.Request[this.Key] != null;
    }
}

And now we use that ActionMethodSelector on our controller action method:

[HttpPost]
[WhenRequestContainsKey("announce")]
public ViewResult Add(MyModel m)
{
    //do whatever
}

Ok. The first stage is complete. Now what if we want an action doesn't validate some model properties?
Once again we create an attribute:

public class IgnoreValidationAttribute : Attribute
{
    public string[] IgnoredProperties { get; set; }

    public IgnoreValidationAttribute(params string[] ignoredProperties)
    {
        this.IgnoredProperties = ignoredProperties;
    }
}

And decorate our controller action method:

[HttpPost]
[ActionName("Add")]
[WhenRequestContainsKey("savedraft")]
[IgnoreValidation("Description")]
public ViewResult SaveDraft(MyModel m)
{
    //do whatever
}

In this case we don't want Description to be validated. Observe that action name is Add although method name is SaveDraft. That's because we can't have two Add methods with the same signature.
But wait. IgnoreValidationAttribute is just a simple Attribute. MVC doesn't know what to do with it.
The class responsible for the validation process is DataAnnotationsModelValidatorProvider. It instantiates lots of ModelValidators. Each validator validates each model property. If Description is decorated with [Required] then a validator is created to require its value. We need to disable that validator. So we'll replace DataAnnotationsModelValidatorProvider with our CustomDataAnnotationsModelValidatorProvider:

public class CustomDataAnnotationsModelValidatorProvider : DataAnnotationsModelValidatorProvider
{
    protected override IEnumerable<ModelValidator> GetValidators(ModelMetadata metadata, ControllerContext context, IEnumerable<Attribute> attributes)
    {

        ReflectedControllerDescriptor controllerDescriptor = new ReflectedControllerDescriptor(context.Controller.GetType());
        ReflectedActionDescriptor actionDescriptor = (ReflectedActionDescriptor)controllerDescriptor.FindAction(context, context.RouteData.GetRequiredString("action"));
        object[] actionIgnoredAttributes = actionDescriptor.GetCustomAttributes(typeof(IgnoreValidationAttribute), true);

        List<string> ignoredProperties = new List<string>();

        foreach (Attribute attribute in actionIgnoredAttributes)
        {
            foreach (string ignoredProperty in ((IgnoreValidationAttribute) attribute).IgnoredProperties)
            {
                ignoredProperties.Add(ignoredProperty);
            }
        }

        bool performServerValidation = !ignoredProperties.Contains(metadata.PropertyName);
        var validators = base.GetValidators(metadata, context, attributes);

        foreach (var validator in validators)
            yield return new ModelValidatorInterceptor(validator, performServerValidation, metadata, context);
    }
}

It will replace the ordinary validators with our ModelValidatorInterceptor. This new validator knows whether server validation must be performed or not:

public class ModelValidatorInterceptor : ModelValidator
{
    ModelValidator Validator;
    bool PerformServerValidation;

    public ModelValidatorInterceptor(ModelValidator validator, bool performServerValidation, ModelMetadata metadata, ControllerContext controllerContext)
        : base(metadata, controllerContext)
    {
        this.Validator = validator;
        this.PerformServerValidation = performServerValidation;
    }

    public override IEnumerable<ModelValidationResult> Validate(object container)
    {
        if (this.PerformServerValidation)
        {
            foreach (var result in this.Validator.Validate(container))
                yield return result;
        }
        else
        {
            yield break;
        }
    }

    public override bool IsRequired
    {            
        get 
        {
            if (this.PerformServerValidation)
            {
                return this.Validator.IsRequired;
            }
            else
            {
                return false;
            }
        }
    }

    public override IEnumerable<ModelClientValidationRule> GetClientValidationRules()
    {
        foreach (var rule in Validator.GetClientValidationRules())
        {
            yield return rule;
        }
    }
}
Our CustomDataAnnotationsModelValidatorProvider is not working yet. We need to replace the DataAnnotationsModelValidatorProvider. Put this code in your Global.asax:

protected void Application_Start()
{
    AreaRegistration.RegisterAllAreas();

    RegisterGlobalFilters(GlobalFilters.Filters);
    RegisterRoutes(RouteTable.Routes);
    ReplaceDataAnnotationsModelValidatorProvider();
}

private void ReplaceDataAnnotationsModelValidatorProvider()
{
    var existingProvider = ModelValidatorProviders.Providers
    .Single(x => x is DataAnnotationsModelValidatorProvider);
    ModelValidatorProviders.Providers.Remove(existingProvider);
    ModelValidatorProviders.Providers.Add(new CustomDataAnnotationsModelValidatorProvider());
}

We are almost done. Client validation is missing. To instruct the field shouldn't be validated by a submit button we add an HTML property to it:

...
@using (Html.BeginForm()) {
    @Html.ValidationSummary(false, "Errors found. Please correct them.")
...
    <div class="editor-label">
        @Html.LabelFor(m => m.Description)
    </div>
    <div class="editor-field">
        @Html.TextBoxFor(m => m.Description, new { data_val_dontvalidatesubmitsnamed = "savedraft"})
        @Html.ValidationMessageFor(m => m.Description)
    </div>
...    
    <input type="submit" name="announce" value="Announce!" />
    <input type="submit" name="savedraft" value="Save Draft" />

In this case we don't want Description field to be validated when a submit button with name=savedraft is clicked. You can use as many names as you want. Separate them with spaces.
It's not working yet. We need to insert a java script that reads those attributes and skips client validation.

$(function () {
    $("input[type=submit]").click(function () {
        var inputElement = $(this);
        var formElement = $(inputElement.parents("form")[0]);
        var restore = false;

        //Get all fields those validation should be ignored when this submit element is clicked
        //Remove their validation rules
        $("*[data-val-dontvalidatesubmitsnamed~=" + inputElement.attr("name") + "]").each(function () {
            var rules = $(this).rules("remove");
            $(this).data("rulesBackup", rules);
            $(formElement).find("[data-valmsg-for='" + $(this).attr("name") + "']").addClass("field-validation-valid");
            $(this).removeClass("input-validation-error");
            restore = true;
        });
        //Restore rules after submit
        if (restore) {
            formElement.one('submit', function (eventObject) {
                $("*[data-val-dontvalidatesubmitsnamed~=" + inputElement.attr("name") + "]").each(function () {
                    $(this).rules("add", $(this).data("rulesBackup"));
                });
            });
        }
    });
});


Well. That's all. I hope Microsoft solves that limitation as soon as possible.

2011/06/16

How ASP.NET MVC 3 custom validation works

Here I show how validation works. There are many useless parameters in this example. They were created with the aim of watching where information comes from and where they go to.

Create a ValidationAttribute for server side validation and for generating client instructions

public class MyValidationAttribute : ValidationAttribute, IClientValidatable
{
    public override bool IsValid(object value)
    {
        return value.ToString() == "I AM VALID";
    }

    public IEnumerable GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        yield return new ModelClientValidationRule
        {
            ErrorMessage = this.ErrorMessage,
            ValidationType = "myparameterizedvalidationtype",
            ValidationParameters = { { "firstparam", 10 }, { "secondparam", new { Amount = 108, Message = "Hello" } } }
        };
        yield return new ModelClientValidationRule
        {
            ErrorMessage = this.ErrorMessage,
            ValidationType = "myunparameterizedvalidationtype"
        };
    }
}


Use that attribute in model
public class MyViewModel
{
    [MyValidation(ErrorMessage = "This is the error message")]
    public string Name { get; set; }
}

This simple Razor view creates a form with validation rules

@model MyWebSite.MyViewModel
<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="@Url.Content("~/Scripts/myvalidation.js")" type="text/javascript"></script>


@using (Html.BeginForm()) {
    @Html.ValidationSummary(false, "Errors found. Correct them.")
    @Html.EditorForModel()
    <input type="submit" value="Register" />
}

This is how the model property is rendered



myvalidation.js - Check comments

// we add a custom jquery validation method
jQuery.validator.addMethod('testIamValid', function (value, element, params) {
    /*
        element: HTMLInputElement
        params: Object
            anything: "aaa"
            anythingelse: "bbb"
        value: "Value typed on text input"
    */
    return value == "I AM VALID";
}, '');

// and an unobtrusive adapter
jQuery.validator.unobtrusive.adapters.add('myparameterizedvalidationtype', ["firstparam", "secondparam"], function (options) {
    /*
        options: Object
            element: HTMLInputElement
            form: HTMLFormElement
            message: "This is the error message"
            messages: Object
            params: Object
                firstparam: "10"
                secondparam: "{ Amount = 108, Message = Hello }"
            rules: Object
    */
    //This causes testIamValid method to be called.
    options.rules['testIamValid'] = { anything: "aaa", anythingelse: "bbb" };
    //Message that will be shown if testIamValid method return false
    options.messages['testIamValid'] = options.message;
});