Introduction
In this article, I will describe implementing custom DataAnnotation validation for ASP.NET MVC3 applications with support for AllowMultiple = true
on both server side and client side. The article explains problems and solutions when you enable AllowMultiple = true
on a custom defined validation attribute. The server side solution for validation is very simple but getting unobtrusive client side validation to work is difficult and a workaround is used.
The Problem
Custom validation attributes can be defined to meet specific validation requirements. E.g., Dependent Property validation or RequiredIf validation. There are many places where one would require using the same validation attribute multiple times. Enable AllowMultiple
on the attribute like:
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property,
AllowMultiple = true, Inherited = true)]
public class RequiredIfAttribute : ValidationAttribute,IClientValidatable
{
protected override ValidationResult IsValid(object value,
ValidationContext validationContext)
{
}
public IEnumerable<ModelClientValidationRule> GetClientValidationRules(
ModelMetadata metadata, ControllerContext context)
{
}
}
Now there is certainly a requirement and I want to use it multiple times on the following model:
public class UserInfo
{
[Required(ErrorMessage="Name is required")]
public string Name { get; set; }
[Required(ErrorMessage="Address is required")]
public string Address { get; set; }
public string Area { get; set; }
public string AreaDetails { get; set; }
[RequiredIf("Area,AreaDetails")]
public string LandMark { get; set; }
[RequiredIf("Area,AreaDetails", "val,Yes")]
[RequiredIf("LandMark","Rocks","413402")]
[RequiredIf("LandMark", "Silicon", "500500")]
public string PinCode { get; set; }
}
Firstly, it doesn't work very well for two reasons. Let me explain this for a specific field or property, PinCode
:
TypeID
It actually does not add the attribute three times, only the last one [RequiredIf("LandMark", "Silicon", "500500")]
gets added for the field and validation executes for that only.
This can be solved by overriding TypeID
; take a look at the article on TypeID
here.
- Client side calidation
Once you override the TypeID
, validation gets added for each instance of RequiredIfAttribute
on the same filed, so eventually it tries to get the ClientValidationRule
s for each instance. If we have the implementation of GetClientValidation
rule like this:
public IEnumerable<ModelClientValidationRule> GetClientValidationRules(
ModelMetadata metadata, ControllerContext context)
{
yield return new RequiredIfValidationRule(ErrorMessageString,
requiredFieldValue, Props, Vals);
}
public class RequiredIfValidationRule : ModelClientValidationRule
{
public RequiredIfValidationRule(string errorMessage,string reqVal,
string otherProperties,string otherValues)
{
ErrorMessage = errorMessage;
ValidationType = "requiredif";
ValidationParameters.Add("reqval", reqVal);
ValidationParameters.Add("others", otherProperties);
ValidationParameters.Add("values", otherValues);
}
}
As we are adding the custom validation attribute multiple times on the same field or property (e.g., PinCode
), this ends up with an error message.
"Validation type names in unobtrusive client validation rules must be unique."
This is because we are assigning the requiredif
client side ValidationType
to the same field multiple times.
The Solution
The solution to force a custom validation attribute on server side is simple; just override TypeID
which creates a distinct instance for Attribute. But to solve the issue with client side validation, there is a workaround required. What we are going to do is:
- Use a static field which will keep track of how many attributes per field or property there are, and as per the count, appends letters a, b, c... in the
ValidationType
of each next rule produced for the field or property. - Provide a custom HTML Helper to render the editor for the field; the HTML Helper will then parse all "HTML-5 data-val" attributes on the field and will convert them to a
requiredifmultiple
rule (client side rule which doesn't change anything on the server side code) for the field. - Provide two adaptors and validation functions for the client side validation of this custom validation attribute, one if there is only one instance of the Attribute on the field (i.e.,
RequiredIf
), another when there are multiple instances of the Attribute on the field (i.e., RequiredIfMultiple
).
The Code
Following is the code for Attribute, Rule, Helper, jQuery and View:
- Attribute
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property,
AllowMultiple = true, Inherited = true)]
public class RequiredIfAttribute : ValidationAttribute,IClientValidatable
{
private string DefaultErrorMessageFormatString = "The {0} is required";
public List<string> DependentProperties { get; private set; }
public List<string> DependentValues { get; private set; }
public string Props { get; private set; }
public string Vals { get; private set; }
public string requiredFieldValue { get; private set; }
public static Dictionary<string, int> countPerField = null;
private object _typeId = new object();
public override object TypeId
{
get { return _typeId; }
}
public RequiredIfAttribute(string dependentProperties,
string dependentValues = "", string requiredValue = "val")
{
if (string.IsNullOrWhiteSpace(dependentProperties))
{
throw new ArgumentNullException("dependentProperties");
}
string[] props = dependentProperties.Trim().Split(new char[] { ',' });
if (props != null && props.Length == 0)
{
throw new ArgumentException("Prameter Invalid:DependentProperties");
}
if (props.Contains("") || props.Contains(null))
{
throw new ArgumentException("Prameter Invalid:DependentProperties," +
"One of the Property Name is Empty");
}
string[] vals = null;
if (!string.IsNullOrWhiteSpace(dependentValues))
vals = dependentValues.Trim().Split(new char[] { ',' });
if (vals != null && vals.Length != props.Length)
{
throw new ArgumentException("Different Number " +
"Of DependentProperties And DependentValues");
}
DependentProperties = new List<string>();
DependentProperties.AddRange(props);
Props = dependentProperties.Trim();
if (vals != null)
{
DependentValues = new List<string>();
DependentValues.AddRange(vals);
Vals = dependentValues.Trim();
}
if (requiredValue == "val")
requiredFieldValue = "val";
else if (string.IsNullOrWhiteSpace(requiredValue))
{
requiredFieldValue = string.Empty;
DefaultErrorMessageFormatString = "The {0} should not be given";
}
else
{
requiredFieldValue = requiredValue;
DefaultErrorMessageFormatString =
"The {0} should be:" + requiredFieldValue;
}
if (props.Length == 1)
{
if (vals != null)
{
ErrorMessage = DefaultErrorMessageFormatString +
", When " + props[0] + " is ";
if (vals[0] == "val")
ErrorMessage += " given";
else if (vals[0] == "")
ErrorMessage += " not given";
else
ErrorMessage += vals[0];
}
else
ErrorMessage = DefaultErrorMessageFormatString +
", When " + props[0] + " is given";
}
else
{
if (vals != null)
{
ErrorMessage = DefaultErrorMessageFormatString +
", When " + dependentProperties + " are: ";
foreach (string val in vals)
{
if (val == "val")
ErrorMessage += "AnyValue,";
else if (val == "")
ErrorMessage += "Empty,";
else
ErrorMessage += val + ",";
}
ErrorMessage = ErrorMessage.Remove(ErrorMessage.Length - 1);
}
else
ErrorMessage = DefaultErrorMessageFormatString + ", When " +
dependentProperties + " are given";
}
}
protected override ValidationResult IsValid(object value,
ValidationContext validationContext)
{
for (int i = 0; i < DependentProperties.Count; i++)
{
var contextProp =
validationContext.ObjectInstance.GetType().
GetProperty(DependentProperties[i]);
var contextPropVal = Convert.ToString(contextProp.GetValue(
validationContext.ObjectInstance, null));
var requiredPropVal = "val";
if (DependentValues != null)
requiredPropVal = DependentValues[i];
if (requiredPropVal ==
"val" && string.IsNullOrWhiteSpace(contextPropVal))
return ValidationResult.Success;
else if (requiredPropVal == string.Empty &&
!string.IsNullOrWhiteSpace(contextPropVal))
return ValidationResult.Success;
else if (requiredPropVal != string.Empty && requiredPropVal !=
"val" && requiredPropVal != contextPropVal)
return ValidationResult.Success;
}
string fieldVal = (value != null ? value.ToString() : string.Empty);
if (requiredFieldValue == "val" && fieldVal.Length == 0)
return new ValidationResult(string.Format(
ErrorMessageString, validationContext.DisplayName));
else if (requiredFieldValue == string.Empty && fieldVal.Length != 0)
return new ValidationResult(string.Format(
ErrorMessageString, validationContext.DisplayName));
else if (requiredFieldValue != string.Empty && requiredFieldValue
!= "val" && requiredFieldValue != fieldVal)
return new ValidationResult(string.Format(ErrorMessageString,
validationContext.DisplayName));
return ValidationResult.Success;
}
public IEnumerable<ModelClientValidationRule> GetClientValidationRules(
ModelMetadata metadata, ControllerContext context)
{
int count = 0;
string Key = metadata.ContainerType.FullName + "." + metadata.GetDisplayName();
if(countPerField==null)
countPerField = new Dictionary<string, int>();
if (countPerField.ContainsKey(Key))
{
count = ++countPerField[Key];
}
else
countPerField.Add(Key, count);
yield return new RequiredIfValidationRule(string.Format(ErrorMessageString,
metadata.GetDisplayName()), requiredFieldValue, Props, Vals, count);
}
}
Look at the lines in bold. The first bold line is for the static Dictionary(FieldName,Count)
which will keep track of the number of times we are adding the attribute on the same field. Actually it does so in the GetClientValidationRule
method, so for each incremented count, a or b or c is appended to the client side validation rule (ValidationType
). Don't worry about the static field and the storage, in the custom helper, we will empty the dictionary each time so none of the useful memory is wasted. The next blue line is about TypeID
. Here we are overriding TypeID
so that server side validation works for each instance of the attribute on the same field.
The third bold line is inside GetClientValidationRules
where we are generating the unique key for adding to the Dictionary. The key represents the "Full Assembly Name" and field name itself. The next line is all about storing, incrementing, and retrieving the count of rules for that "KEY". Now let's look at the implementation of Rule.
- Validation Rule
public class RequiredIfValidationRule : ModelClientValidationRule
{
public RequiredIfValidationRule(string errorMessage,string reqVal,
string otherProperties,string otherValues,int count)
{
string tmp = count == 0 ? "" : Char.ConvertFromUtf32(96 + count);
ErrorMessage = errorMessage;
ValidationType = "requiredif"+tmp;
ValidationParameters.Add("reqval", reqVal);
ValidationParameters.Add("others", otherProperties);
ValidationParameters.Add("values", otherValues);
}
}
Here the first bold line gets the character (a, b, c..) to be appended to produce the unique rule name and the second bold line appends the character to the rule. Now let's look at the code for the HTML Helper.
- HTML Helper
public static class RequiredIfHelpers
{
public static MvcHtmlString EditorForRequiredIf<TModel, TValue>(
this HtmlHelper<TModel> html, Expression<Func<TModel, TValue>> expression,
string templateName=null, string htmlFieldName=null,
object additionalViewData=null)
{
string mvcHtml=html.EditorFor(expression, templateName,
htmlFieldName, additionalViewData).ToString();
string element = html.ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldName(
ExpressionHelper.GetExpressionText(expression));
string Key = html.ViewData.Model.ToString() + "." + element;
RequiredIfAttribute.countPerField.Remove(Key);
if (RequiredIfAttribute.countPerField.Count == 0)
RequiredIfAttribute.countPerField = null;
string pattern = @"data\-val\-requiredif[a-z]+";
if (Regex.IsMatch(mvcHtml, pattern))
{
return MergeClientValidationRules(mvcHtml);
}
return MvcHtmlString.Create(mvcHtml);
}
public static MvcHtmlString MergeClientValidationRules(string str)
{
const string searchStr="data-val-requiredif";
const string val1Str="others";
const string val2Str="reqval";
const string val3Str="values";
List<XmlAttribute> mainAttribs = new List<XmlAttribute>();
List<XmlAttribute> val1Attribs = new List<XmlAttribute>();
List<XmlAttribute> val2Attribs = new List<XmlAttribute>();
List<XmlAttribute> val3Attribs = new List<XmlAttribute>();
XmlDocument doc = new XmlDocument();
doc.LoadXml(str);
XmlNode node = doc.DocumentElement;
foreach (XmlAttribute attrib in node.Attributes)
{
if (attrib.Name.StartsWith(searchStr))
{
if (attrib.Name.EndsWith("-" + val1Str))
val1Attribs.Add(attrib);
else if (attrib.Name.EndsWith("-" + val2Str))
val2Attribs.Add(attrib);
else if (attrib.Name.EndsWith("-" + val3Str))
val3Attribs.Add(attrib);
else
mainAttribs.Add(attrib);
}
}
var mainAttrib=doc.CreateAttribute(searchStr+"multiple");
var val1Attrib = doc.CreateAttribute(searchStr + "multiple-"+val1Str);
var val2Attrib = doc.CreateAttribute(searchStr + "multiple-"+val2Str);
var val3Attrib = doc.CreateAttribute(searchStr + "multiple-"+val3Str);
mainAttribs.ForEach(new Action<XmlAttribute>(delegate(XmlAttribute attrib)
{
mainAttrib.Value += attrib.Value + "!";
node.Attributes.Remove(attrib);
}
));
val1Attribs.ForEach(new Action<XmlAttribute>(delegate(XmlAttribute attrib)
{
val1Attrib.Value += attrib.Value + "!";
node.Attributes.Remove(attrib);
}
));
val2Attribs.ForEach(new Action<XmlAttribute>(delegate(XmlAttribute attrib)
{
val2Attrib.Value += attrib.Value + "!";
node.Attributes.Remove(attrib);
}
));
val3Attribs.ForEach(new Action<XmlAttribute>(delegate(XmlAttribute attrib)
{
val3Attrib.Value += attrib.Value + "!";
node.Attributes.Remove(attrib);
}
));
mainAttrib.Value=mainAttrib.Value.TrimEnd('!');
val1Attrib.Value=val1Attrib.Value.TrimEnd('!');
val2Attrib.Value=val2Attrib.Value.TrimEnd('!');
val3Attrib.Value = val3Attrib.Value.TrimEnd('!');
node.Attributes.Append(mainAttrib);
node.Attributes.Append(val1Attrib);
node.Attributes.Append(val2Attrib);
node.Attributes.Append(val3Attrib);
return MvcHtmlString.Create(node.OuterXml);
}
}
Here the important things are, get HTML5 using the in-built EditorFor
; however, you can implement your own logic here for text box or check box or whatever. The next is "KEY". Since in the attribute class we used a static field, it is directly accessible to us, so clear the memory used by it, and if there is no other data in the Dictionary, make the Dictionary itself null.
The next important thing is the RegEx pattern: it is to find where the given field has any requiredif'a' or 'b' or 'c' ... rules. Note that if the count doesn't grow, we have only one rule, i.e., requiredif
on the field, so if we have any rule ending with a or b or c, then we will convert it to a requiredifmultiple
rule and that is the next logic.
This method is where I used the XML logic to parse and combine the rules. The next is the client side jQuery for these rules (requiredif
or requiredifmultiple
).
- jQuery
(function ($) {
var reqIfValidator = function (value, element, params) {
var values = null;
var others = params.others.split(',');
var reqVal = params.reqval + "";
var currentVal = value + "";
if (params.values + "" != "")
values = params.values.split(',')
var retVal = false;
$.each(others, function (index, value) {
var $other = $('#' + value);
var currentOtherVal = ($other.attr('type').toUpperCase() == "CHECKBOX") ?
($other.attr("checked") ? "true" : "false") :
$other.val();
var requiredOtherVal = "val";
if (values != null)
requiredOtherVal = values[index];
if (requiredOtherVal == "val" && currentOtherVal == "")
retVal = true;
else if (requiredOtherVal == "" && currentOtherVal != "")
retVal = true;
else if (requiredOtherVal != "" && requiredOtherVal != "val" &&
requiredOtherVal != currentOtherVal) {
retVal = true;
}
if (retVal == true) {
return false;
}
});
if (retVal == true)
return true;
if (reqVal == "val" && currentVal == "")
return false;
else if (reqVal == "" && currentVal != "")
return false;
else if (reqVal != "" && reqVal != "val" && reqVal != currentVal)
return false;
return true;
}
var reqIfMultipleValidator = function (value, element, params) {
var others = params.others.split('!');
var reqVals = params.reqval.split('!');
var msgs = params.errorMsgs.split('!');
var errMsg = "";
var values = null;
if (params.values + "" != "")
values = params.values.split('!')
var retVal = true;
$.each(others, function (index, val) {
var myParams = { "others": val, "reqval": reqVals[index],
"values": values[index] };
retVal = reqIfValidator(value, element, myParams);
if (retVal == false) {
errMsg = msgs[index];
return false;
}
});
if (retVal == false) {
var evalStr = "this.settings.messages." + $(element).attr("name") +
".requiredifmultiple='" + errMsg + "'";
eval(evalStr);
}
return retVal;
}
$.validator.addMethod("requiredif", reqIfValidator);
$.validator.addMethod("requiredifmultiple", reqIfMultipleValidator);
$.validator.unobtrusive.adapters.add("requiredif", ["reqval", "others", "values"],
function (options) {
options.rules['requiredif'] = {
reqval: options.params.reqval,
others: options.params.others,
values: options.params.values
};
options.messages['requiredif'] = options.message;
});
$.validator.unobtrusive.adapters.add(
"requiredifmultiple", ["reqval", "others", "values"],
function (options) {
options.rules['requiredifmultiple'] = {
reqval: options.params.reqval,
others: options.params.others,
values: options.params.values,
errorMsgs: options.message
};
options.messages['requiredifmultiple'] = "";
});
} (jQuery));
jQuery has two functions RequiredIf
and RequiredIfMultiple
, and two adaptors are registered for both rules. If the field has a requiredif
rule, it uses first function for client side validation; if it has a requiredifmultiple
rule, the second function is called which splits the values and calls the first function for validation. It was a very difficult task in the entire session to make these functions work. The very important point here is how to change the error message, because while sending from server, we are sending all the error messages combined together, and it was displaying the combined message on the error. I looked thousands of pages on Google to find how to change the error message dynamically, and got nothing /p>
So I have highlighted only one line in the entire jQuery code which was the bottom line of the article. I placed a break point in jQuery and observed the values using Firebug, and finally got the solution. The final thing is how to use it in View. Please note that I have given the Model code in the second snippet. UserInfo
is my Model here.
- View
@model MVC_FirstApp.Models.UserInfo
@using MVC_FirstApp.CustomValidations.RequiredIf;
@{
ViewBag.Title = "Create";
}
<h2>Create</h2>
@section JavaScript
{
<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="../../CustomValidations/RequiredIf/RequiredIf.js"
type="text/javascript"></script>
}
@using (Html.BeginForm("Create", "UserInfo", FormMethod.Post,
new { id = "frmCreateUserInfo" }))
{
@Html.ValidationSummary(true)
<fieldset>
<legend>UserInfo</legend>
<div class="editor-label">
@Html.LabelFor(model => model.Name)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Name)
@Html.ValidationMessageFor(model => model.Name)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.Address)
</div>
<div class="editor-field">
@Html.EditorFor(model => model.Address)
@Html.ValidationMessageFor(model => model.Address)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.Area)
</div>
<div class="editor-field">
@Html.DropDownList("Area",new SelectList(
new List<string>{"One","Two","Three"},null))
@Html.ValidationMessageFor(model => model.Area)
@Html.DropDownList("AreaDetails",
new SelectList(new List<string>{"Yes","No"},null))
@Html.ValidationMessageFor(model => model.AreaDetails)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.LandMark)
</div>
<div class="editor-field">
@Html.EditorForRequiredIf(model => model.LandMark)
@Html.ValidationMessageFor(model => model.LandMark)
</div>
<div class="editor-label">
@Html.LabelFor(model => model.PinCode)
</div>
<div class="editor-field">
@Html.EditorForRequiredIf(model => model.PinCode)
@Html.ValidationMessageFor(model => model.PinCode)
</div>
<p>
<input type="submit" value="Create" />
</p>
</fieldset>
}
<div>
@Html.ActionLink("Back to List", "Index")
</div>
History
First version.