MVC post a list of complex objects
Asked Answered
S

5

21

I have a FeedbackViewModel that contains a list of questions:

public class FeedbackViewModel
{
    public List<QuestionViewModel> Questions { get; set; }
}

This QuestionViewModel is an object that can be inherited by 5 different types of questions

public class QuestionViewModel
{
    public string QuestionText { get; set; }
    public string QuestionType { get; set; }
}

An example of one of the inheriting question types:

public class SingleQuestionViewModel : QuestionViewModel
{
    public string AnswerText { get; set; }
}

In the HttpGet of the Index action in the controller I get the questions from the database and add the correct question type in list of question in the FeedbackViewModel Then I render this model in the view:

@using (Html.BeginForm())
{
    //foreach (var item in Model.Questions)
    for (int i = 0; i < Model.Questions.Count; i++)
    {
        <div class="form-group">
            @Html.DisplayFor(modelItem => Model.Questions[i].QuestionText, new { @class = "control-label col-md-4" })
            <div class="col-md-6">
                @if (Model.Questions[i].QuestionType == "Single")
                {
                    @Html.EditorFor(modelItem => (Model.Questions[i] as OpenDataPortal.ViewModels.SingleQuestionViewModel).AnswerText)
                }
                else if (Model.Questions[i].QuestionType == "Multiple")
                {
                    @Html.TextAreaFor(modelItem => (Model.Questions[i] as OpenDataPortal.ViewModels.SingleQuestionViewModel).AnswerText)
                }
                else if (Model.Questions[i].QuestionType == "SingleSelection")
                {
                    @Html.RadioButtonForSelectList(modelItem => (Model.Questions[i] as OpenDataPortal.ViewModels.SingleSelectionQuestionViewModel).SelectedAnswer,
                                                                (Model.Questions[i] as OpenDataPortal.ViewModels.SingleSelectionQuestionViewModel).SelectionAnswers)
                }
                else if (Model.Questions[i].QuestionType == "MultipleSelection")
                {
                    @Html.CustomCheckBoxList((Model.Questions[i] as OpenDataPortal.ViewModels.MultipleSelectionQuestionViewModel).AvailableAnswers)
                }
                else if (Model.Questions[i].QuestionType == "UrlReferrer")
                {
                    @Html.EditorFor(modelItem => (Model.Questions[i] as OpenDataPortal.ViewModels.SingleQuestionViewModel).AnswerText)
                }
            </div>
        </div>
        <br />
    }

    <br />
    <button type="submit">Submit</button>
}

enter image description here


Now, I simply can't get it to post the list of questions in the model. Is it even possible to post a list of different object types?


Edit: Following is the list of data within the post that I discovered using Fiddler:

enter image description here

Standin answered 2/10, 2014 at 6:43 Comment(6)
I don't see any reason why it shouldn't work. As long as the names of the inputs are correctly set, you should receive the data. however, your problem might coming from the default model binder. Your complex type might be too complex for it's skills. You should first check if the names are correctly set, then check the data that gets posted to your server and write your own model binder.Narda
@AndreiV - I've checked the names and values that are posted from the page using fiddler and added it to the question above. The values seem to be there. Do you reckon I need to write a model binder? (never done that before)Standin
Without seeing the exact POST array, I'm just guessing, but: if you're trying to model-bind a collection of any sort, the indexes cannot skip a number, or the model binder skips everything after. So, if your POSTed values were something like Questions[1].SelectedAnswer, etc., you'll have issues with the default model binder.Pegu
I was actually referring to names set on the HTML elements (with corresponding indices). You should first check if the HTML gets correctly generated and only after that bother with a custom model binder. My answer to this question might be of some help with that.Narda
If your POST method parameter is FeedbackViewModel then you will only get List<QuestionViewModel> Questions (the base type) not instances of SingleQuestionViewModel or MultipleSelectionQuestionViewModel. The DefaultModelBinder has no way of knowing if you want an inherited type. This article might gives some clues for creating a custom ModelBinder although it might be easier to create 5 collection properties for the 5 different typesRooke
In the end, I opted to put all the different possible question types in one class/model and got rid of the inheritance completely.Standin
B
36

After much research I've found two solutions:

  1. One is to write HTML that has hardcoded Id's and Names
  2. 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)
    {
        //...
Bellebelleek answered 5/12, 2014 at 2:59 Comment(0)
M
4

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.

Mercuric answered 30/3, 2015 at 15:41 Comment(0)
A
4

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.

Amur answered 27/12, 2016 at 1:52 Comment(1)
This was posted as an answer, but it does not attempt to answer the question. It should possibly be an edit, a comment, another question, or deleted altogether.Sprue
D
1

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.

Dewitt answered 15/7, 2016 at 13:43 Comment(0)
S
-1

I use this code maybe its can help

<input type="hidden" name="OffersCampaignDale[@(item.ID)].ID" value="@(item.ID)" />

@Html.Raw(Html.EditorFor(modelItem => item.NameDale, new { htmlAttributes = new { @class = "form-control" } })
.ToString().Replace("item.NameDale", "OffersCampaignDale[" + item.ID+ "].NameDale").Replace("item_NameDale", "NameDale-" + item.ID))
@Html.ValidationMessageFor(modelItem => item.NameDale, "", new { @class = "text-danger" })
Savoy answered 22/2, 2017 at 9:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.