Given the following viewmodel:
public class SomeViewModel
{
public bool IsA { get; set; }
public bool IsB { get; set; }
public bool IsC { get; set; }
//... other properties
}
I wish to create a custom attribute that validates at least one of the available properties are true. I envision being able to attach an attribute to a property and assign a group name like so:
public class SomeViewModel
{
[RequireAtLeastOneOfGroup("Group1")]
public bool IsA { get; set; }
[RequireAtLeastOneOfGroup("Group1")]
public bool IsB { get; set; }
[RequireAtLeastOneOfGroup("Group1")]
public bool IsC { get; set; }
//... other properties
[RequireAtLeastOneOfGroup("Group2")]
public bool IsY { get; set; }
[RequireAtLeastOneOfGroup("Group2")]
public bool IsZ { get; set; }
}
I would like to validate on the client-side prior to form submission as values in the form change which is why I prefer to avoid a class-level attribute if possible.
This would require both the server-side and client-side validation to locate all properties having identical group name values passed in as the parameter for the custom attribute. Is this possible? Any guidance is much appreciated.
Here's one way to proceed (there are other ways, I am just illustrating one that would match your view model as is):
[AttributeUsage(AttributeTargets.Property)]
public class RequireAtLeastOneOfGroupAttribute: ValidationAttribute, IClientValidatable
{
public RequireAtLeastOneOfGroupAttribute(string groupName)
{
ErrorMessage = string.Format("You must select at least one value from group \"{0}\"", groupName);
GroupName = groupName;
}
public string GroupName { get; private set; }
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
foreach (var property in GetGroupProperties(validationContext.ObjectType))
{
var propertyValue = (bool)property.GetValue(validationContext.ObjectInstance, null);
if (propertyValue)
{
// at least one property is true in this group => the model is valid
return null;
}
}
return new ValidationResult(FormatErrorMessage(validationContext.DisplayName));
}
private IEnumerable<PropertyInfo> GetGroupProperties(Type type)
{
return
from property in type.GetProperties()
where property.PropertyType == typeof(bool)
let attributes = property.GetCustomAttributes(typeof(RequireAtLeastOneOfGroupAttribute), false).OfType<RequireAtLeastOneOfGroupAttribute>()
where attributes.Count() > 0
from attribute in attributes
where attribute.GroupName == GroupName
select property;
}
public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
{
var groupProperties = GetGroupProperties(metadata.ContainerType).Select(p => p.Name);
var rule = new ModelClientValidationRule
{
ErrorMessage = this.ErrorMessage
};
rule.ValidationType = string.Format("group", GroupName.ToLower());
rule.ValidationParameters["propertynames"] = string.Join(",", groupProperties);
yield return rule;
}
}
Now, let's define a controller:
public class HomeController : Controller
{
public ActionResult Index()
{
var model = new SomeViewModel();
return View(model);
}
[HttpPost]
public ActionResult Index(SomeViewModel model)
{
return View(model);
}
}
and a view:
@model SomeViewModel
<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>
@using (Html.BeginForm())
{
@Html.EditorFor(x => x.IsA)
@Html.ValidationMessageFor(x => x.IsA)
<br/>
@Html.EditorFor(x => x.IsB)<br/>
@Html.EditorFor(x => x.IsC)<br/>
@Html.EditorFor(x => x.IsY)
@Html.ValidationMessageFor(x => x.IsY)
<br/>
@Html.EditorFor(x => x.IsZ)<br/>
<input type="submit" value="OK" />
}
The last part that's left would be to register adapters for the client side validation:
jQuery.validator.unobtrusive.adapters.add(
'group',
[ 'propertynames' ],
function (options) {
options.rules['group'] = options.params;
options.messages['group'] = options.message;
}
);
jQuery.validator.addMethod('group', function (value, element, params) {
var properties = params.propertynames.split(',');
var isValid = false;
for (var i = 0; i < properties.length; i++) {
var property = properties[i];
if ($('#' + property).is(':checked')) {
isValid = true;
break;
}
}
return isValid;
}, '');
Based on your specific requirements the code might be adapted.
require_from_group
from jquery-validation team:jQuery-validation project has a sub-folder in src folder called additional. You can check it here.
In that folder we have a lot of additional validation methods that are not common that is why they're not added by default.
As you see in that folder it exists so many methods that you need to choose by picking which validation method you actually need.
Based on your question, the validation method you need is named require_from_group
from additional folder.
Just download this associated file which is located here and put it into your Scripts
application folder.
The documentation of this method explains this:
Lets you say "at least X inputs that match selector Y must be filled."
The end result is that neither of these inputs:
...will validate unless at least one of them is filled.
partnumber: {require_from_group: [1,".productinfo"]}, description: {require_from_group: [1,".productinfo"]}
options[0]: number of fields that must be filled in the group options2: CSS selector that defines the group of conditionally required fields
This validation method is generic and works for every input
(text, checkbox, radio etc), textarea
and select
. This method also let you specify the minimum number of required inputs that need to be filled e.g
partnumber: {require_from_group: [2,".productinfo"]},
category: {require_from_group: [2,".productinfo"]},
description: {require_from_group: [2,".productinfo"]}
I created two classes RequireFromGroupAttribute
and RequireFromGroupFieldAttribute
that will help you on both server-side and client-side validations
RequireFromGroupAttribute
class definitionRequireFromGroupAttribute
only derives from Attribute
. The class is use just for configuration e.g. setting the number of fields that need to be filled for the validation. You need to provide to this class the CSS selector class that will be used by the validation method to get all elements on the same group. Because the default number of required fields is 1 then this attribute is only used to decorate your model if the minimum requirement in the spcefied group is greater than the default number.
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
public class RequireFromGroupAttribute : Attribute
{
public const short DefaultNumber = 1;
public string Selector { get; set; }
public short Number { get; set; }
public RequireFromGroupAttribute(string selector)
{
this.Selector = selector;
this.Number = DefaultNumber;
}
public static short GetNumberOfRequiredFields(Type type, string selector)
{
var requiredFromGroupAttribute = type.GetCustomAttributes<RequireFromGroupAttribute>().SingleOrDefault(a => a.Selector == selector);
return requiredFromGroupAttribute?.Number ?? DefaultNumber;
}
}
RequireFromGroupFieldAttribute
class definitionRequireFromGroupFieldAttribute
which derives from ValidationAttribute
and implements IClientValidatable
. You need to use this class on each property in your model that participates to your group validation. You must pass the css selector class.
[AttributeUsage(AttributeTargets.Property)]
public class RequireFromGroupFieldAttribute : ValidationAttribute, IClientValidatable
{
public string Selector { get; }
public bool IncludeOthersFieldName { get; set; }
public RequireFromGroupFieldAttribute(string selector)
: base("Please fill at least {0} of these fields")
{
this.Selector = selector;
this.IncludeOthersFieldName = true;
}
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
var properties = this.GetInvolvedProperties(validationContext.ObjectType); ;
var numberOfRequiredFields = RequireFromGroupAttribute.GetNumberOfRequiredFields(validationContext.ObjectType, this.Selector);
var values = new List<object> { value };
var otherPropertiesValues = properties.Where(p => p.Key.Name != validationContext.MemberName)
.Select(p => p.Key.GetValue(validationContext.ObjectInstance));
values.AddRange(otherPropertiesValues);
if (values.Count(s => !string.IsNullOrWhiteSpace(Convert.ToString(s))) >= numberOfRequiredFields)
{
return ValidationResult.Success;
}
return new ValidationResult(this.GetErrorMessage(numberOfRequiredFields, properties.Values), new List<string> { validationContext.MemberName });
}
public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
{
var properties = this.GetInvolvedProperties(metadata.ContainerType);
var numberOfRequiredFields = RequireFromGroupAttribute.GetNumberOfRequiredFields(metadata.ContainerType, this.Selector);
var rule = new ModelClientValidationRule
{
ValidationType = "requirefromgroup",
ErrorMessage = this.GetErrorMessage(numberOfRequiredFields, properties.Values)
};
rule.ValidationParameters.Add("number", numberOfRequiredFields);
rule.ValidationParameters.Add("selector", this.Selector);
yield return rule;
}
private Dictionary<PropertyInfo, string> GetInvolvedProperties(Type type)
{
return type.GetProperties()
.Where(p => p.IsDefined(typeof(RequireFromGroupFieldAttribute)) &&
p.GetCustomAttribute<RequireFromGroupFieldAttribute>().Selector == this.Selector)
.ToDictionary(p => p, p => p.IsDefined(typeof(DisplayAttribute)) ? p.GetCustomAttribute<DisplayAttribute>().Name : p.Name);
}
private string GetErrorMessage(int numberOfRequiredFields, IEnumerable<string> properties)
{
var errorMessage = string.Format(this.ErrorMessageString, numberOfRequiredFields);
if (this.IncludeOthersFieldName)
{
errorMessage += ": " + string.Join(", ", properties);
}
return errorMessage;
}
}
In your model here is how to use it :
public class SomeViewModel
{
internal const string GroupOne = "Group1";
internal const string GroupTwo = "Group2";
[RequireFromGroupField(GroupOne)]
public bool IsA { get; set; }
[RequireFromGroupField(GroupOne)]
public bool IsB { get; set; }
[RequireFromGroupField(GroupOne)]
public bool IsC { get; set; }
//... other properties
[RequireFromGroupField(GroupTwo)]
public bool IsY { get; set; }
[RequireFromGroupField(GroupTwo)]
public bool IsZ { get; set; }
}
By default you don't need to decorate your model with RequireFromGroupAttribute
because the default number of required fields is 1. But if you want a number of required fields to be different to 1 you can do the following :
[RequireFromGroup(GroupOne, Number = 2)]
public class SomeViewModel
{
//...
}
@model SomeViewModel
<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 src="@Url.Content("~/Scripts/require_from_group.js")" type="text/javascript"></script>
@using (Html.BeginForm())
{
@Html.CheckBoxFor(x => x.IsA, new { @class="Group1"})<span>A</span>
@Html.ValidationMessageFor(x => x.IsA)
<br />
@Html.CheckBoxFor(x => x.IsB, new { @class = "Group1" }) <span>B</span><br />
@Html.CheckBoxFor(x => x.IsC, new { @class = "Group1" }) <span>C</span><br />
@Html.CheckBoxFor(x => x.IsY, new { @class = "Group2" }) <span>Y</span>
@Html.ValidationMessageFor(x => x.IsY)
<br />
@Html.CheckBoxFor(x => x.IsZ, new { @class = "Group2" })<span>Z</span><br />
<input type="submit" value="OK" />
}
Notice the group selector you specified when using RequireFromGroupField
attribute is use in your view by specifing it as a class in each input involved in your groups.
That is all for the server side validation.
If you check the GetClientValidationRules
implementation in RequireFromGroupFieldAttribute
class you will see I'm using the string requirefromgroup
and not require_from_group
as the name of method for the ValidationType
property. That is because ASP.Net MVC only allows the name of the validation type to contain alphanumeric char and must not start with a number. So you need to add the following javascript :
$.validator.unobtrusive.adapters.add("requirefromgroup", ["number", "selector"], function (options) {
options.rules["require_from_group"] = [options.params.number, options.params.selector];
options.messages["require_from_group"] = options.message;
});
The javascript part is really simple because in the implementation of the adaptater function we just delegate the validation to the correct require_from_group
method.
Because it works with every type of input
, textarea
and select
elements, I may think this way is more generic.
Hope that helps!
I implemented Darin's awesome answer into my application, except I added it for strings and not boolean values. This was for stuff like name/company, or phone/email. I loved it except for one minor nitpick.
I tried to submit my form without a work phone, mobile phone, home phone, or email. I got four separate validation errors client side. This is fine by me because it lets the users know exactly what field(s) can be filled in to make the error go away.
I typed in an email address. Now the single validation under email went away, but the three remained under the phone numbers. These are also no longer errors anymore.
So, I reassigned the jQuery method that checks validation to account for this. Code below. Hope it helps someone.
jQuery.validator.prototype.check = function (element) {
var elements = [];
elements.push(element);
var names;
while (elements.length > 0) {
element = elements.pop();
element = this.validationTargetFor(this.clean(element));
var rules = $(element).rules();
if ((rules.group) && (rules.group.propertynames) && (!names)) {
names = rules.group.propertynames.split(",");
names.splice($.inArray(element.name, names), 1);
var name;
while (name = names.pop()) {
elements.push($("#" + name));
}
}
var dependencyMismatch = false;
var val = this.elementValue(element);
var result;
for (var method in rules) {
var rule = { method: method, parameters: rules[method] };
try {
result = $.validator.methods[method].call(this, val, element, rule.parameters);
// if a method indicates that the field is optional and therefore valid,
// don't mark it as valid when there are no other rules
if (result === "dependency-mismatch") {
dependencyMismatch = true;
continue;
}
dependencyMismatch = false;
if (result === "pending") {
this.toHide = this.toHide.not(this.errorsFor(element));
return;
}
if (!result) {
this.formatAndAdd(element, rule);
return false;
}
} catch (e) {
if (this.settings.debug && window.console) {
console.log("Exception occurred when checking element " + element.id + ", check the '" + rule.method + "' method.", e);
}
throw e;
}
}
if (dependencyMismatch) {
return;
}
if (this.objectLength(rules)) {
this.successList.push(element);
}
}
return true;
};
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With