Search code examples
asp.net-mvcasp.net-mvc-5html-helpercheckboxlist

How can I persist a check box list in MVC


I'm trying to build an html helper for creating a list of checkboxes, which will have the check state persisted using sessions. It works for the most part, remembering check box states when you check and uncheck various boxes and click submit. However, if you have boxes checked and submitted, and you go back and clear the checkboxes and resubmit (when they are ALL cleared) - it seems to want to remember the last selections. Here is what I've written...

[HomeController]

public ActionResult Index()
{
    TestViewModel tvm = new TestViewModel();
    return View(tvm);
}

[HttpPost]
public ActionResult Index(TestViewModel viewModel)
{
    viewModel.SessionCommit();
    return View(viewModel);
}

[Index View]

@model TestApp.Models.TestViewModel

@{
    ViewBag.Title = "Index";
}

@using (Html.BeginForm())
{
    <p>Checkboxes:</p>
    @Html.CheckedListFor(x => x.SelectedItems, Model.CheckItems, Model.SelectedItems)

    <input type="submit" name="Submit form" />
}

[TestViewModel]

// Simulate the checklist data source
public Dictionary<int, string> CheckItems
{
    get
    {
        return new Dictionary<int, string>()
        {
            {1, "Item 1"},
            {2, "Item 2"},
            {3, "Item 3"},
            {4, "Item 4"}
        };
    }
}

// Holds the checked list selections
public int[] SelectedItems { get; set; }


// Contructor
public TestViewModel()
{
    SelectedItems = GetSessionIntArray("seld", new int[0] );
}


// Save selections to session
public void SessionCommit()
{
    System.Web.HttpContext.Current.Session["seld"] = SelectedItems;
}


// Helper to get an int array from session
int[] GetSessionIntArray(string sessionVar, int[] defaultValue)
{
    if (System.Web.HttpContext.Current.Session == null || System.Web.HttpContext.Current.Session[sessionVar] == null)
        return defaultValue;

    return (int[])System.Web.HttpContext.Current.Session[sessionVar];
}

[The HTML helper]

public static MvcHtmlString CheckedList(this HtmlHelper htmlHelper, string PropertyName, Dictionary<int, string> ListItems, int[] SelectedItemArray)
{
    StringBuilder result = new StringBuilder();
    foreach(var item in ListItems)
    {
        result.Append(@"<label>");
        var builder = new TagBuilder("input");
        builder.Attributes["type"] = "checkbox";
        builder.Attributes["name"] = PropertyName;
        builder.Attributes["id"] = PropertyName;
        builder.Attributes["value"] = item.Key.ToString();
        builder.Attributes["data-val"] = item.Key.ToString();
        if (SelectedItemArray.Contains(item.Key))
            builder.Attributes["checked"] = "checked";

        result.Append(builder.ToString(TagRenderMode.SelfClosing));
        result.AppendLine(string.Format(" {0}</label>", item.Value));
    }
    return MvcHtmlString.Create(result.ToString());
}
public static MvcHtmlString CheckedListFor<TModel, TProperty>(this HtmlHelper<TModel> htmlHelper, Expression<Func<TModel, TProperty>> expression, Dictionary<int, string> ListItems, int[] SelectedItemArray)
{
    var name = ExpressionHelper.GetExpressionText(expression);
    var metadata = ModelMetadata.FromLambdaExpression(expression, htmlHelper.ViewData);
    return CheckedList(htmlHelper, name, ListItems, SelectedItemArray);
}

I've read this SO question and I think this may be to do with the model binder not knowing when there are no checkboxes checked, but even though I've gone through that and various other posts - I'm no further forward.

In one post, I saw that a hidden field is often used in combination with the checkbox to pass the 'false' state of the checkbox, but I couldn't get it working with multiple checkboxes posting back to a single property.

Can anyone shed light on this?

EDITED : to include the demonstration project I've highlighted in this post. Hopefully this will help someone to help me!


Solution

  • Your main issue, and the reason why the previous selections are being 'remembered' when you un-check all items is that you have a constructor in your model that calls GetSessionIntArray() which gets the values you stored last time you submitted the form. The DefaultModelBinder works by first initializing your model (including calling its default constructor) and then setting the values of its properties based the form values. In the following scenario

    Step 1: Navigate to the Index() method

    • Assuming its the first call and no items have been added to Session, then the value of SelectedItems returned by GetSessionIntArray() is int[0], which does not match any values in CheckItems, so no checkboxes are checked.

    Step 2: Check the first 2 checkboxes and submit.

    • The DefaultModelBinder initializes a new instance of TestViewModel and calls the constructor. The value of SelectedItems is again int[0] (nothing has been added to Session yet). The form values are then read and the value of SelectedItems is now int[1, 2] (the values of the checked checkboxes). The code inside the method is called and int[1, 2] is added to Session before returning the view.

    Step 3: Un-check all checkboxes and submit again.

    • Your model is again initialized, but this time the constructor reads the values from Session and the value of SelectedItems is int[1,2]. The DefaultModelBinder reads the form values for SelectedItems, but there are none (un-checked checkboxes do not submit a value) so there is nothing to set and the value of SelectedItems remains int[1,2]. You then return the view and your helper checks the first 2 checkboxes based on the value of SelectedItems

    You could solve this by removing the constructor from the model and modifying the code in the extension method to test for null

    if (SelectedItemArray != null && SelectedItemArray.Contains(item.Key))
    {
        ....
    

    However there are other issues with you implementation, including

    1. Your generating duplicate id attributes for each checkbox (your use of builder.Attributes["id"] = PropertyName;) which is invalid html.

    2. builder.Attributes["data-val"] = item.Key.ToString(); makes no sense (it generates data-val="1", data-val="1" etc). Assuming you want attributes for unobtrusive client side validation, then the attributes would be data-val="true" data-val-required="The SelectedItems field is required.". But then you would need a associated placeholder for the error message (as generated by @Html.ValidationMessageFor() and the name attribute of each checkbox would need to be distinct (i.e. using indexers - name="[0].SelectedItems" etc).

    3. Your using the value of the property for binding, but the correct approach (as all the built in extension method use) is to first get the value from ModelState, then from the ViewDataDictionary and finally if no values are found, then the actual model property.

    4. You never use the value of var metadata = ModelMetadata..... although you should be (so that you can remove the last parameter (int[] SelectedItemArray) from the method, which is in effect just repeating the value of expression.

    Side note: The use of a hidden field is not applicable in your case. The CheckboxFor() method generates the additional hidden input because the method binds to a bool property, and it ensures a value is always submitted.

    My recommendation would be to use a package such as MvcCheckBoxList (I have not tried that one myself as I have my own extension method), at least until you spend some time studying the MVC source code to better understand how to create HtmlHelper extension methods (apologies if that sounds harsh).