MVC post a list of complex objects
Using Razor you can implement the for loop using a dictionary as follows without making changes to your object:
@foreach (var x in Model.Questions.Select((value,i)=>new { i, value }))
{
if (Model.Questions[x.i].QuestionType == "Single")
{
@Html.EditorFor(modelItem => (modelItem.Questions[x.i] as OpenDataPortal.ViewModels.SingleQuestionViewModel).AnswerText)
}
...
}
The collection needs to be either a List or Array for this to work.
After much research I've found two solutions:
- One is to write HTML that has hardcoded Id's and Names
- Two is to convert your ICollection/IEnumerable to an Array or List (i.e IList something with an 'index'), and have an Array object in your BindingModel in your Controller POST Action.
Thanks to Phil Haack's (@haacked) 2008 blog post http://haacked.com/archive/2008/10/23/model-binding-to-a-list.aspx/ Which is still relevant to how the default ModelBinder works today for MVC. (NB: the links in Phil's article to sample porject and extension methods are broken)
HTML snippet that inspired me:
<form method="post" action="/Home/Create">
<input type="hidden" name="products.Index" value="cold" />
<input type="text" name="products[cold].Name" value="Beer" />
<input type="text" name="products[cold].Price" value="7.32" />
<input type="hidden" name="products.Index" value="123" />
<input type="text" name="products[123].Name" value="Chips" />
<input type="text" name="products[123].Price" value="2.23" />
<input type="submit" />
</form>
Post array looks a bit like:
products.Index=cold&products[cold].Name=Beer&products[cold].Price=7.32&products.Index=123&products[123].Name=Chips&products[123].Price=2.23
Model:
public class CreditorViewModel
{
public CreditorViewModel()
{
this.Claims = new HashSet<CreditorClaimViewModel>();
}
[Key]
public int CreditorId { get; set; }
public string Comments { get; set; }
public ICollection<CreditorClaimViewModel> Claims { get; set; }
public CreditorClaimViewModel[] ClaimsArray {
get { return Claims.ToArray(); }
}
}
public class CreditorClaimViewModel
{
[Key]
public int CreditorClaimId { get; set; }
public string CreditorClaimType { get; set; }
[DisplayFormat(ApplyFormatInEditMode = true, DataFormatString = "{0:N2}")]
public Decimal ClaimedTotalAmount { get; set; }
}
Controller GET:
public async Task<ActionResult> Edit(int id)
{
var testmodel = new CreditorViewModel
{
CreditorId = 1,
Comments = "test",
Claims = new HashSet<CreditorClaimViewModel>{
new CreditorClaimViewModel{ CreditorClaimId=1, CreditorClaimType="1", ClaimedTotalAmount=0.00M},
new CreditorClaimViewModel{ CreditorClaimId=2, CreditorClaimType="2", ClaimedTotalAmount=0.00M},
}
};
return View(model);
}
Edit.cshtml:
@Html.DisplayNameFor(m => m.Comments)
@Html.EditorFor(m => m.Comments)
<table class="table">
<tr>
<th>
@Html.DisplayNameFor(m => Model.Claims.FirstOrDefault().CreditorClaimType)
</th>
<th>
@Html.DisplayNameFor(m => Model.Claims.FirstOrDefault().ClaimedTotalAmount)
</th>
</tr>
<!--Option One-->
@foreach (var item in Model.Claims)
{
var fieldPrefix = string.Format("{0}[{1}].", "Claims", item.CreditorClaimId);
<tr>
<td>
@Html.DisplayFor(m => item.CreditorClaimType)
</td>
<td>
@Html.TextBox(fieldPrefix + "ClaimedTotalAmount", item.ClaimedTotalAmount.ToString("F"),
new
{
@class = "text-box single-line",
data_val = "true",
data_val_number = "The field ClaimedTotalAmount must be a number.",
data_val_required = "The ClaimedTotalAmount field is required."
})
@Html.Hidden(name: "Claims.index", value: item.CreditorClaimId, htmlAttributes: null)
@Html.Hidden(name: fieldPrefix + "CreditorClaimId", value: item.CreditorClaimId, htmlAttributes: null)
</td>
</tr>
}
</table>
<!--Option Two-->
@for (var itemCnt = 0; itemCnt < Model.ClaimsArray.Count(); itemCnt++)
{
<tr>
<td></td>
<td>
@Html.TextBoxFor(m => Model.ClaimsArray[itemCnt].ClaimedTotalAmount)
@Html.HiddenFor(m => Model.ClaimsArray[itemCnt].CreditorClaimId)
</td></tr>
}
Form is processed in the Controller:
Post Model:
public class CreditorPostViewModel
{
public int CreditorId { get; set; }
public string Comments { get; set; }
public ICollection<CreditorClaimPostViewModel> Claims { get; set; }
public CreditorClaimPostViewModel[] ClaimsArray { get; set; }
}
public class CreditorClaimPostViewModel
{
public int CreditorClaimId { get; set; }
public Decimal ClaimedTotalAmount { get; set; }
}
Controller:
[HttpPost]
public ActionResult Edit(int id, CreditorPostViewModel creditorVm)
{
//...
Make sure you are rendering your view in order so that Model.Questions[i]
renders in order.
For example, Model.Questions[0], Model.Questions[1], Model.Questions[2]
.
I noticed that if the order is not correct mvc model binder will only bind the first element.
Thanks for pointing me in the right direction with this post. I was struggling to get the syntax right for binding a non-sequential IDictionary<string, bool>
object. Not sure this is 100% correct, but this Razor code worked for me:
<input type="hidden" name="MyDictionary.Index" value="ABC" />
<input type="hidden" name="MyDictionary[ABC].Key" value="ABC" />
@Html.CheckBox(name: "MyDictionary[ABC].Value", isChecked: Model.MyDictionary["ABC"], htmlAttributes: null)
If you need a checkbox, be sure to use Html.CheckBox instead of a standard HTML checkbox. The model will blow up if a value is not provided, and Html.CheckBox generates a hidden field to ensure a value is present when the checkbox is not checked.