Search code examples
asp.net-mvcasp.net-mvc-3jquery-validateunobtrusive-validation

MVC3 Validation - Require One From Group


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.


Solution

  • 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.