MVC3 Validation - Require One From Group

Shawn picture Shawn · Aug 30, 2011 · Viewed 14.5k times · Source

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.

Answer

Darin Dimitrov picture Darin Dimitrov · Aug 30, 2011

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.