0

This question is similar to other questions asked before, but my usage (and MVC knowledge) is different enough so I can't figure out how to adapt the other answers to fit my needs.

I have a form where a user requests a price for a product. That product has a number of optional modules, which affect the overall price. The controller should then send me an email with the chosen module(s)' DisplayName included.

I can render out these module titles okay in the request form, but cannot read them back when the form is submitted. Debugging shows !ModelState.IsValid, and within the model state there is a conversion exception:

The parameter conversion from type 'System.String' to type 'MyNamspace.Models.MyProductModule' failed because no type converter can convert between these types.

My entire approach may be wrong, but I've defined the modules in the product model (simplified example for SO), working from this tutorial:

public class MyProductModule
{
    public string ModuleName { get; set; }
    public bool Checked { get; set; }
}

public class ProductRequest
{

    public ProductRequest()
    {
        Modules = LoadModules();
    }

    private List<MyProductModule> LoadModules()
    {
        return new List<MyProductModule>()
        {
            new MyProductModule() { ModuleName = "Module One", Checked = false },
            new MyProductModule() { ModuleName = "Module Two", Checked = false },
            new MyProductModule() { ModuleName = "Module Three", Checked = false }
        };
    }

    [DisplayName("MyProduct Modules")]
    public List<MyProductModule> Modules { get; set; }
}

Here's the code I use to render the check box list:

@model MyNamespace.Models.ProductRequest

@foreach (var item in Model.Modules)
{
    <label>
        <input type="checkbox" name="Modules" value="@item.ModuleName" checked="@item.Checked" />
        @item.ModuleName
    </label>
}

Here's how I am trying to collect the posted form data:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult ProcessRequest(ProductRequest qd)
{
    if (!ModelState.IsValid)
    {
        return View("Index", qd); // Code exits here with ModelState error
    }
    else
    {
        StringBuilder sb = new StringBuilder();
        // null checks removed for brevity...
        sb.Append("<ol>");
        var selectedModules = qd.Modules.Where(x => x.Checked).Select(x => x).ToList();
        foreach (MyProductModule sm in selectedModules)
        {
            sb.AppendFormat("<li>{0}</li>", sm.ModuleName);
        }
        sb.Append("</ol>");
    }   
    // ....
}

Any help or advice would be much appreciated. As a long-term webforms developer, I am finding the MVC learning curve relentless!

Solution

See posted answer.

EvilDr
  • 8,943
  • 14
  • 73
  • 133
  • 1
    Your creating `name` attrbutes that have no relationship at all to your model (it would bind to `string[] Modules` - but refer [this answer](http://stackoverflow.com/questions/29542107/pass-list-of-checkboxes-into-view-and-pull-out-ienumerable/29554416#29554416) for how to implement it correctly –  Dec 19 '16 at 08:43
  • above answer should help you. Also have a look into Non-Sequential indices approach as well - http://haacked.com/archive/2008/10/23/model-binding-to-a-list.aspx/ – Developer Dec 19 '16 at 08:50
  • 1
    @EvilDr, There is nothing your can change your `name` attribute to to make this bind correctly. You need to use a view model to make this work as per the link I gave you. With your current implementation, all you can do id change the parameter from `ProductRequest qd` to `string[] modules` –  Dec 19 '16 at 09:11
  • 1
    Except for the awful use if `CheckBox()` instead of `CheckBoxFor()`, the answer by Bob Dust is the correct approach –  Dec 19 '16 at 09:24
  • LOL on "awful". It must be "awful" being such a seasoned pro and seeing us meagre fools struggle on...(!) – EvilDr Dec 19 '16 at 09:29
  • Cue the next stumbling block... http://stackoverflow.com/questions/41223489/checkbox-list-label-value-lost-if-modelstate-isvalid – EvilDr Dec 19 '16 at 13:19

2 Answers2

2

You'll probably want to render the view like this:

for (var i = 0; i < Model.Modules.Count; i++ )
{
    var item = Model.Modules[i];
    <label>
        @Html.CheckBox(string.Format("Modules[{0}].Checked", i), item.Checked)
        @item.ModuleName
    </label>
    @Html.Hidden(string.Format("Modules[{0}].ModuleName", i), item.ModuleName)
}
Bob Dust
  • 2,370
  • 1
  • 17
  • 13
2

With thanks to Stephen Muecke...

As described in the links suggested, it is possible to refer to the data model without using an instance of the data model. Therefore, this is the correct approach to render the checkboxes:

<div>
    @for (int i = 0; i < Model.Modules.Count; i++)
    {   
        @Html.CheckBoxFor(m => m.Modules[i].Checked)
        @Html.LabelFor(m => m.Modules[i].Checked, Model.Modules[i].ModuleName)                  
        <br />
    }
</div>

This ensures that all the modules are listed, even if user doesn't actually select them all, and validation fails.

The model binding is then done automatically when the form is posted so the controller code for processing the form did not need amending need amending slightly. The code to get all the available modules should be static. That way all the modules can be "read"/made available conveniently. Then that static list is compared against the user-submitted list (which may only contains some or none of the available modules):

List<MyProductModule> allModules = ProductRequest.LoadModules();
foreach(MyProductModule sm in qd.Modules)
{
    if(sm.Checked)
    {
        sb.AppendFormat("<li>{0}</li>", allModules[qd.Modules.IndexOf(sm)].ModuleName);
    }
}
Community
  • 1
  • 1
EvilDr
  • 8,943
  • 14
  • 73
  • 133