LH7
LH7

Reputation: 1423

List<T> within the model not reaching controller when form is submitted

Why when I submit the form, the controller does not receive any data from Model.Items? I am using asp.net core mvc 2.0

This is my View-Model

public class SectionVM
{
    public long Id { get; set; }

    [Required]
    public string Name { get; set; }

    public string Description { get; set; }

    public ICollection<SectionItemCollector> Items { get; set; }
}

public class SectionItemCollector
{
    public bool IsSelected { get; set; }

    public long ItemId { get; set; }

    public string ItemName { get; set; }
}

Here I create a new view-model, populate it with data and send it to the view. The view receives the view model and I am currently able of accessing all the data in the view-model without any problems.

// Controller Actions
// GET: MenuSections/Create
public async Task<IActionResult> Create()
{
    var viewModel = new SectionVM();
    var allItems = await _context.MenuItem.ToListAsync();
    var vmItems = new List<SectionItemCollector>();
    foreach (var i in allItems)
    {
        vmItems.Add(new SectionItemCollector() { IsSelected = false, ItemId = i.Id, ItemName = i.Name });
    }
    viewModel.Items = vmItems;
    return View(viewModel);
}

My view shows the data contained in the view model and user can choose to select some Items

// View
@model ROO_App.Models.SectionVM
...
<form asp-action="Create">
    <div asp-validation-summary="ModelOnly" class="text-danger"></div>
    <div class="form-group">
        <label asp-for="Name" class="control-label"></label>
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name" class="text-danger"></span>
    </div>
    <div class="form-group">
        <label asp-for="Description" class="control-label"></label>
        <input asp-for="Description" class="form-control" />
        <span asp-validation-for="Description" class="text-danger"></span>
    </div>
    <div class="form-group">
    @{
        foreach (var item in Model.Items)
        {
             <div class="row">
                 <div class="col-sm-1">  
                     <input asp-for="@item.IsSelected" type="checkbox" class="form-check-input" id="@item.ItemId"/> 
                 </div>
                 <div class="col-sm-4">@item.ItemName</div>    
             </div>
        }
    }

    </div>
    <div class="form-group">
        <input type="submit" value="Create" class="btn btn-default" />
    </div>
</form>

when the form is submitted Items = null

// POST: MenuSections/Create
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Id,Name,Description, Items")] SectionVM menuSection)
{
    ...
}

Upvotes: 1

Views: 3689

Answers (2)

mathieu mauron
mathieu mauron

Reputation: 11

it's my first post, so sorry in advance (especially for my English)

You can use tag helper for collection and get directly model when post form.

this is a little example but i think its the best solution to have a view and controller code clean and standard :

<div class="row">

    @{
        foreach (var element in Model.Elements)
        {
            int index = Model.Elements.IndexOf(element);

            <input type="text" asp-for="Elements[index].Text" />
            <input type="checkbox" asp-for="Elements[index].IsSelected" />
        }
    }
</div>

so just use the asp-for like another property and in controller your collection have directly binded elements after post

Upvotes: 1

Dan Sorensen
Dan Sorensen

Reputation: 11753

Cause:

No variable named "Items" is being sent to the server since the form does not include an input named "Items". Instead an array named "item.IsSelected" is being sent by the checkbox inputs.

In your view the following line: (showing the important part)

<input asp-for="@item.IsSelected" 

is scaffolding a HTML input field that looks like this: (showing the important part)

<input type="checkbox" name="item.IsSelected" value="true">

With Google Chrome, press F12 before you submit the form. Look at the Network tab > Headers > Form data, you can confirm that it submitted the following field for each checked item.

item.IsSelected:true

So the controller is not receiving an input named "Items". it is receiving a string array called "item.IsSelected" with the default value of "true".

Working towards a solution:

You can override the default name by providing the optional 'name' parameter to the input tag.

<input asp-for="@item.IsSelected" name="Items" 

You are also setting the Id attribute. In this context, the Id is used for HTML and is not submitted with the form. Only the value of the checkbox is submitted.

id="@item.ItemId" /> 

If you want to submit the checked IDs back as an array, set it as the value, not the id.

value="@item.ItemId" />            

If you prefer to use the ItemName as the value:

value="@item.ItemName" />

However the HTML checkbox input will not allow you to pass both ItemName and Id vales as it only passes a single value for selected items.

The MVC controller will not be able to map the string[] called "Items" to your SectionItemCollector class as the types do not match. It may detect the count of Items in the array, but the values will remain null.

With that limitation, it may be best to serialize the form data into a JSON object and post via AJAX.

Solving it with a Form Post

To solve with a Form Post, you will need to make a few changes to bind and process the form data:

Add the following properties to your class SectionVM:

public string[] CheckedItems { get; set; }

public string[] CheckedValues { get; set; }

Update Create.cshtml to pass the isChecked and ID as part of the checkbox value. Add a hidden input to pass the ItemName.

<div class="form-group">
    @{
        foreach (var item in Model.Items)
        {
            <div class="row">
                <div class="col-sm-1">
                    @* If checked, the value will be posted as the CheckedItems string[] *@
                    <input asp-for="@item.IsSelected" name="CheckedItems" type="checkbox" class="form-check-input" value="@item.ItemId" />

                    @* posting a hidden value as the CheckedValues string[] *@
                    <input name="CheckedValues" type="hidden" value="@item.ItemName" />
                </div>
                <div class="col-sm-4">@item.ItemName</div>
            </div>
        }
    }

</div>

Finally, update your controller action to collect the checkbox and hidden values and re-combine them into the expected object:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create([Bind("Id,Name,Description, Items, CheckedItems, CheckedValues")] SectionVM menuSection)
{
    // convert CheckedItems and CheckedValues into List<SectionItemCollector>
    var selectedItems = new List<SectionItemCollector>();
    for (int i = 0; i < menuSection.CheckedItems.Length; i++)
    {
        // if the checked ID is number, find the related ItemName.
        long id;
        if (long.TryParse(menuSection.CheckedItems[i], out id))
        {
            var item = new SectionItemCollector
            {
                ItemId = id,
                // inferred as HTML only posts checkboxes that are selected.
                IsSelected = true,                         
                // get the ItemName with the same index.
                ItemName = menuSection.CheckedValues[i]
            };
            selectedItems.Add(item);
        }
    }
    var viewmodel = new SectionVM()
    {
        Items = selectedItems
    };

    // TODO: Set the breakpoint to the next line and inspect viewmodel to see your result.
    return View(viewmodel);
}

Maybe Asp.Net has another way to abstract away the complexity of submitting a checkbox with multiple properties. If so, I hope someone submits that answer too.

Upvotes: 4

Related Questions