Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Custom Validation Attribute Multiple Times on same field

How can I use Same Custom Validation Attribute Multiple Times on Same Field or simply enable AllowMultiple=true, for both server side and client side validation??

I have a following Custom Validation Attribute:

[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, 
        AllowMultiple = true, Inherited = true)]
public class RequiredIfAttribute : ValidationAttribute,IClientValidatable
{
    public RequiredIfAttribute(string dependentProperties, 
     string dependentValues = "",
     string requiredValue = "val")
     {
     }
}

Where in dependentProperties I can specify multiple dependant properties seperated by comma, in dependentValues I can specify for which values of dependant properties validation should process and finally in requiredValue I can specify expected value for the field to be validated.

In my model there are two properties LandMark, PinCode and I want to use validation as follows:

public string LandMark { get; set; }
[RequiredIf("LandMark","XYZ","500500")]
[RequiredIf("LandMark", "ABC", "500505")]
public string PinCode { get; set; }

The values here are just for example, as per it seems I can add the attribute multiple times and don't get any compile error, I have implemented TypeID in attribute and it works well from serverside if I remove client validation from it. But when I am implementing IClientValidatable on the attribute, it gives me an error:

"Validation type names in unobtrusive client validation rules must be unique."

Any help how can I solve it??

like image 575
Satish Avatar asked Aug 02 '11 14:08

Satish


1 Answers

The Problem

Validation Attributes have two environments they can validate against:

  1. Server
  2. Client

Server Validation - Multiple Attributes Easy

If you have any attribute with:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class RequiredIfAttribute : ValidationAttribute

And have put it on your class property like this:

public class Client
{
    public short ResidesWithCd { get; set; };

    [RequiredIf(nameof(ResidesWithCd), new[] { 99 }, "Resides with other is required.")]
    public string ResidesWithOther { get; set; }
}

Then anytime the Server goes to validate an object (ex. ModelState.IsValid), it will check every ValidationAttribute on each property and call .IsValid() to determine validity. This will work fine, even if AttributeUsage.AllowMultiple is set to true.

Client Validation - HTML Attribute Bottleneck

If you enable client side by implementing IClientValidatable like this:

public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)    
{
    var modelClientValidationRule = new ModelClientValidationRule
    {
        ValidationType = "requiredif",
        ErrorMessage = ErrorMessageString
    };
    modelClientValidationRule.ValidationParameters.Add("target", prop.PropName);
    modelClientValidationRule.ValidationParameters.Add("values", prop.CompValues);

    return new List<ModelClientValidationRule> { modelClientValidationRule };
}

Then ASP.NET will emit the following HTML when generated:

(As long as ClientValidationEnabled & UnobtrusiveJavaScriptEnabled are enabled)

<input class="form-control" type="text" value=""
       id="Client_CommunicationModificationDescription" 
       name="Client.CommunicationModificationDescription" 
       data-val="true"
       data-val-requiredif="Communication Modification Description is required."
       data-val-requiredif-target="CommunicationModificationCd"
       data-val-requiredif-values="99" >

Data Attributes are the only vehicle we have for dumping rules into the client side validation engine which will search for any attributes on the page via a built in or custom adapter. And once part of the set of client side rules, it'll be able to determine the validity of each parsed rule with a built in or custom method.

So we can call jQuery Validate Unobtrusive to look for and parse these attributes by adding a custom adapter which will add a validation rule to the engine:

// hook up to client side validation
$.validator.unobtrusive.adapters.add('requiredif', ['target', 'values'], function (options) {
    options.rules["requiredif"] = {
        id: '#' + options.params.target,
        values: JSON.parse(options.params.values)
    };
    options.messages['requiredif'] = options.message;
});

We can then tell that rule how function and determine validity by adding a custom method like this which will add a custom way to evaluate requiredif rules (as opposed to date rules or regex rules) which will rely on the parameters we loaded earlier through the adapter:

// test validity
$.validator.addMethod('requiredif', function (value, element, params) {
    var targetHasCondValue = targetElHasValue(params.id, params.value);
    var requiredAndNoValue = targetHasCondValue && !value; // true -> :(
    var passesValidation = !requiredAndNoValue;            // true -> :)
    return passesValidation;
}, '');

Which all operates like this:

IClientValidatable

Solution

So, what have we learned? Well, if we want the same rule to appear multiple times on the same element, the adapter would have to see the exact set of rules multiple times per element, with no way to differentiate between each instance within multiple sets. Further, ASP.NET won't render the same attribute name multiple times since it's not valid html.

So, we either need to:

  1. Collapse all the client side rules into a single mega attribute with all the info
  2. Rename attributes with each instance number and then find a way to parse them in sets.

I'll explore Option One (emitting a single client side attribute), which you could do a couple ways:

  1. Create a single Attribute that takes in multiple elements to validate on the server client
  2. Keep multiple distinct server side attributes and then merge all attributes via reflection before emitting to the client

In either case you will have to re-write the client side logic (adapter/method) to take an array of values, instead of a single value at a time.

To we'll build/transmit a JSON serialized object that looks like this:

var props = [
  {
    PropName: "RoleCd",
    CompValues: ["2","3","4","5"]
  },
  {
    PropName: "IsPatient",
    CompValues: ["true"]
  }
]

Scripts/ValidateRequiredIfAny.js

Here's how we'll handle that in client side adapter / method:

// hook up to client side validation
$.validator.unobtrusive.adapters.add("requiredifany", ["props"], function (options) {
    options.rules["requiredifany"] = { props: options.params.props };
    options.messages["requiredifany"] = options.message;
});

// test validity
$.validator.addMethod("requiredifany", function (value, element, params) {
    var reqIfProps = JSON.parse(params.props);
    var anytargetHasValue = false;

    $.each(reqIfProps, function (index, item) {
        var targetSel = "#" + buildTargetId(element, item.PropName);
        var $targetEl = $(targetSel);
        var targetHasValue = elHasValue($targetEl, item.CompValues);
       
        if (targetHasValue) {
            anytargetHasValue = true;
            return ;
        }
    });

    var valueRequired = anytargetHasValue;
    var requiredAndNoValue = valueRequired && !value; // true -> :(
    var passesValidation = !requiredAndNoValue;       // true -> :)
    return passesValidation;

}, "");

// UTILITY METHODS

function buildTargetId(currentElement, targetPropName) {
    // https://stackoverflow.com/a/39725539/1366033
    // we are only provided the name of the target property
    // we need to build it's ID in the DOM based on a couple assumptions
    // derive the stacking context and depth based on the current element's ID/name
    // append the target property's name to that context

                                                // currentElement.name i.e. Details[0].DosesRequested
    var curId = currentElement.id;              // get full id         i.e. Details_0__DosesRequested
    var context = curId.replace(/[^_]+$/, "");  // remove last prop    i.e. Details_0__
    var targetId = context + targetPropName;    // build target ID     i.e. Details_0__OrderIncrement

    // fail noisily
    if ($("#" + targetId).length === 0)
        console.error(
            "Could not find id '" + targetId +
            "' when looking for '" + targetPropName +
            "'  on originating element '" + curId + "'");

    return targetId;
}

function elHasValue($el, values) {
    var isCheckBox = $el.is(":checkbox,:radio");
    var isChecked = $el.is(":checked");
    var inputValue = $el.val();
    var valueInArray = $.inArray(String(inputValue), values) > -1;

    var hasValue = (!isCheckBox || isChecked) && valueInArray;

    return hasValue;
};

Models/RequiredIfAttribute.cs

On the server side, we'll validate attributes like normal, but when we got to build the client side attributes, we'll look for all attributes and build one mega attribute

using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Reflection;
using System.Web.Helpers;
using System.Web.Mvc;

[AttributeUsage(AttributeTargets.Property, AllowMultiple = true)]
public class RequiredIfAttribute : ValidationAttribute, IClientValidatable
{

    public PropertyNameValues TargetProp { get; set; }

    public RequiredIfAttribute(string compPropName, string[] compPropValues, string msg) : base(msg)
    {
        this.TargetProp = new PropertyNameValues()
        {
            PropName = compPropName,
            CompValues = compPropValues
        };
    }
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        PropertyInfo compareProp = validationContext.ObjectType.GetProperty(TargetProp.PropName);
        var compPropVal = compareProp.GetValue(validationContext.ObjectInstance, null);
        string compPropValAsString = compPropVal?.ToString().ToLower() ?? "";
        var matches = TargetProp.CompValues.Where(v => v == compPropValAsString);
        bool needsValue = matches.Any();

        if (needsValue)
        {
            if (value == null || value.ToString() == "" || value.ToString() == "0")
            {
                return new ValidationResult(FormatErrorMessage(null));
            }
        }

        return ValidationResult.Success;
    }


    public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
    {
        // at this point, who cares that we're on this particular instance - find all instances
        PropertyInfo curProp = metadata.ContainerType.GetProperty(metadata.PropertyName);
        RequiredIfAttribute[] allReqIfAttr = curProp.GetCustomAttributes<RequiredIfAttribute>().ToArray();

        // emit validation attributes from all simultaneously, otherwise each will overwrite the last
        PropertyNameValues[] allReqIfInfo = allReqIfAttr.Select(x => x.TargetProp).ToArray();
        string allReqJson = Json.Encode(allReqIfInfo);

        var modelClientValidationRule = new ModelClientValidationRule
        {
            ValidationType = "requiredifany",
            ErrorMessage = ErrorMessageString
        };

        // add name for jQuery parameters for the adapter, must be LOWERCASE!
        modelClientValidationRule.ValidationParameters.Add("props", allReqJson);

        return new List<ModelClientValidationRule> { modelClientValidationRule };
    }
}

public class PropertyNameValues
{
    public string PropName { get; set; }
    public string[] CompValues { get; set; }
}

Then we can bind that to our model by applying multiple attributes simultaneously:

[RequiredIf(nameof(RelationshipCd), new[] { 1,2,3,4,5 }, "Mailing Address is required.")]
[RequiredIf(nameof(IsPatient), new[] { "true" },"Mailing Address is required.")]
public string MailingAddressLine1 { get; set; }

Further Reading

  • ASP.NET MVC custom multiple fields validation by Stephen Muecke
  • Unobtrusive Client Validation in ASP.NET MVC 3 by Brad Wilson
like image 130
KyleMit Avatar answered Nov 10 '22 00:11

KyleMit