MVC does not really model bind checkboxes. The problem is html based; input checkboxes do not post in the form unless actually checked. MVC gets around this with their helper by creating a hidden for every checkbox, and I assume unobstrusively sets the hidden input whenever the associated checkbox is toggled.
This means that we cannot validate the checkbox as required, which isn't an easy case anyway since a bool defaults to false. It’s not a nullable type. And we can't readily access the hidden input to validate that. People have created several roll-your-own extensions and helpers out there… but I couldn’t find one that fit my needs exactly.
My main requirement was a list of boxes (a grouping so to speak) requiring at least one to be checked. With client validation (most examples on the web missed this one). SO I found myself a little library project.
- - [In your Model]: add your list of checkboxes like this: IEnumerable<MH.Mvc.Checkbox> { new {Name="Bank"} new {Name="Stocks", IsChecked?="true"}}
- decorate your checkboxlist with [MH.Attributes.CheckBoxListServerValidator], and you will get server validation that at least one box is checked
- In your View, use the HtmlHelper.CheckBoxListFor(m=>m.checkboxlist) which gives you the proper model binding
- - [In your Model]: add a bool named something like this: CheckboxClientValidator
- decorate your bool with [MH.Attributes.CheckBoxListClientValidator(listname)], and
- add the MH/Content/scripts/custom-validators js to your page and you will get client side validation that at least one box is checked
Here is what my Model looks like:
[MH.Web.Mvc3.Attributes.CheckBoxListClientValidator("LiquidAssetSourceList")]
public bool LiquidAssetClientValidator { get; set; }
[Display(Name = "Where do you have your liquid assets? (Check all that apply.)*")]
[MH.Web.Mvc3.Attributes.CheckBoxListServerValidator]
public IEnumerable<MH.Web.Mvc3.Models.CheckBox> LiquidAssetSourceList { get; set; }
In my Model constructor (isn't that where all lists should get instantiated?) I create the actual checkboxes
LiquidAssetSourceList = new List<MH.Web.Mvc3.Models.CheckBox>
{ new MH.Web.Mvc3.Models.CheckBox{ Name = "Bank" },
new MH.Web.Mvc3.Models.CheckBox{ Name = "Stocks" },
... and Here is what my View looks like:
@Html.LabelFor(model => model.LiquidAssetSourceList)
@Html.HiddenFor(m=>m.LiquidAssetClientValidator)
@Html.ValidationMessageFor(m=>m.LiquidAssetClientValidator)
@Html.CheckboxListFor(m=>m.LiquidAssetSourceList)
Here's the simple checkbox object:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace MH.Models
{
public class CheckBox
{
public int ID { get; set; }
public string Name { get; set; }
public bool IsChecked { get; set; }
}
}
And the HtmlExtension:
public static System.Web.Mvc.MvcHtmlString CheckboxListFor<TModel, TProperty>(this HtmlHelper<TModel> html,
Expression<Func<TModel, TProperty>> checkboxlist, object htmlattributes = null)
where TProperty : IEnumerable<Models.CheckBox>
{
MemberExpression body = checkboxlist.Body as MemberExpression;
string checkboxlistname = body.Member.Name;
//Get currently selected values from the ViewData model
IEnumerable<Models.CheckBox> cbs = checkboxlist.Compile().Invoke(html.ViewData.Model);
StringBuilder sb = new StringBuilder();
int idx = 0;
foreach (Models.CheckBox cb in cbs)
{
string id = String.Format("{0}_{1}__IsChecked", checkboxlistname, idx);
string name = String.Format("{0}[{1}].IsChecked", checkboxlistname, idx);
TagBuilder itemdiv = new TagBuilder("div");
itemdiv.AddCssClass("checkboxlist-itemdiv");
TagBuilder label = new TagBuilder("label");
label.AddCssClass("checkboxlist-label");
label.MergeAttribute("for", id);
label.InnerHtml = cb.Name;
itemdiv.InnerHtml += label.ToString(TagRenderMode.Normal);
TagBuilder inputcheck = new TagBuilder("input");
if (cb.IsChecked) inputcheck.MergeAttribute("checked", "checked");
inputcheck.AddCssClass("checkboxlist-checkbox");
inputcheck.MergeAttribute("type", "checkbox");
inputcheck.MergeAttribute("id", id);
inputcheck.MergeAttribute("name", name);
inputcheck.MergeAttribute("value", "true");
itemdiv.InnerHtml += inputcheck.ToString(TagRenderMode.Normal);
TagBuilder hiddencheck = new TagBuilder("input");
hiddencheck.MergeAttribute("type", "hidden");
hiddencheck.MergeAttribute("name", name);
hiddencheck.MergeAttribute("value", "false"); // input checkboxes only post if checked, so we default to hidden with same id... mvc only takes first input of each id
itemdiv.InnerHtml += hiddencheck.ToString(TagRenderMode.Normal);
TagBuilder hiddenname = new TagBuilder("input");
hiddenname.MergeAttribute("type", "hidden");
hiddenname.MergeAttribute("name", String.Format("{0}[{1}].Name", checkboxlistname, idx));
hiddenname.MergeAttribute("value", cb.Name);
itemdiv.InnerHtml += hiddenname.ToString(TagRenderMode.Normal);
TagBuilder hiddenid = new TagBuilder("input");
hiddenid.MergeAttribute("type", "hidden");
hiddenid.MergeAttribute("name", String.Format("{0}[{1}].ID", checkboxlistname, idx));
hiddenid.MergeAttribute("value", cb.ID.ToString());
itemdiv.InnerHtml += hiddenid.ToString(TagRenderMode.Normal);
sb.Append(itemdiv.ToString(TagRenderMode.Normal));
idx++;
}
TagBuilder div = new TagBuilder("div");
div.AddCssClass("checkboxlist-paneldiv");
div.MergeAttribute("id", checkboxlistname);
div.MergeAttribute("name", checkboxlistname);
div.MergeAttributes(new System.Web.Routing.RouteValueDictionary(htmlattributes));
div.InnerHtml = sb.ToString();
return new System.Web.Mvc.MvcHtmlString(div.ToString(TagRenderMode.Normal));
}
The attribute for the validator:
Namespace Attributes{
[AttributeUsage(AttributeTargets.Property)]
public class CheckBoxListServerValidator : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (value != null)
{
if ((value as IEnumerable<Euro.Web.Mvc3.Models.CheckBox>).Any(l => l.IsChecked)) return ValidationResult.Success;
}
return new ValidationResult("Please select an option.");
}
}
[AttributeUsage(AttributeTargets.Property)] // validon bool? i dont think it matters
public class CheckBoxListClientValidator : ValidationAttribute, IClientValidatable
{
public string ListName { get; set; }
public CheckBoxListClientValidator(string listname)
{
ListName = listname;
}
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
return ValidationResult.Success;
}
public IEnumerable<ModelClientValidationRule> GetClientValidationRules(ModelMetadata metadata, ControllerContext context)
{
ModelClientValidationRule rule = new ModelClientValidationRule()
{
ValidationType = "requiredcheckbox", // "requiredcheckbox" used in jquery
ErrorMessage = "Please select at least one"
};
rule.ValidationParameters.Add("listname", ListName);
return new[] { rule };
}
}
and the js for the validator:
In custom-validators.js
/// (do not put validators inside document.ready)
/// these two are for MH.Attributes.CheckBoxListClientValidator
$.validator.addMethod("requiredcheckbox",
function (value, element, parameters) {
return $("#" + parameters.listname + " input:checked").length > 0;
});
$.validator.unobtrusive.adapters.add("requiredcheckbox", ["listname"], function (options) {
options.rules["requiredcheckbox"] = options.params;
options.messages["requiredcheckbox"] = options.message;
});