Search code examples
c#formslinqasp.net-core-mvchtml.hiddenfor

Rendering a List<Model> in form and posting to controller


I'm rendering a recursive tree-structure using a ViewComponent, and I'm struggeling with the use of Html.HiddenFor and Html.CheckBoxFor in the form.

This is the ViewModel:

public class ViewModelProductCategory
{
    public int Id { get; set; }
    public int? ParentId { get; set; }
    public string Title { get; set; }
    public int SortOrder { get; set; }
    public bool Checked { get; set; }
    public ViewModelProductCategory ParentCategory { get; set; }
    public IEnumerable<ViewModelProductCategory> Children { get; set; }
    public IEnumerable<ViewModelProduct> Products { get; set; }
}

The ViewComponent is being invoked from the main View-page like this:

@await Component.InvokeAsync("SelectCategories",
new
{
    parentId = 0,
    productId = Model.Id // This is the current product Id from the main View
})

... and this is the ViewComponent's Invoke-method:

public async Task<IViewComponentResult> InvokeAsync(int? parentId, int productId)
{
    List<ViewModelProductCategory> VM = new List<ViewModelProductCategory>();
    if (parentId == 0)
    {
        VM = _mapper.Map<List<ViewModelProductCategory>>
            (await _context.ProductCategories.Include(c => c.Children)
            .Where(x => x.ParentId == null).OrderBy(o => o.SortOrder).ToListAsync());
    }
    else
    {
        VM = _mapper.Map<List<ViewModelProductCategory>>
            (await _context.ProductCategories.Include(c => c.Children)
            .Where(x => x.ParentId == parentId).OrderBy(o => o.SortOrder).ToListAsync());
    }
    foreach (var item in VM)
    {
        // The Checked-value is not stored in the database, but is set here based
        // on the occurances of ProductId in the navigation property Products.Id
        // I'm not entirely confident that this statement checks the correct checkboxes...
        item.Checked = item.Products.Any(c => c.Id == productId);
    }
    ViewData["productId"] = productId;
    return View(VM);
}

This is the Default.cshtml of the ViewComponent:

@model List<MyStore.Models.ViewModels.ViewModelProductCategory>
<ul style="list-style:none;padding-left:0px;">
    @if (Model != null)
    {
        int ProductId = (ViewData["productId"] != null)
            ? int.Parse(ViewData["productId"].ToString())
            : 0;
        @for (int i = 0; i < Model.Count(); i++)
        {
            <li style="margin-top:4px;padding:0px;">
                @if (Model[i].Children.Count() == 0)// Prevent products from being placed in a category with child categories
                                                    // by not rendering a checkbox next to it
                {
                    @Html.HiddenFor(model => Model[i].Id)
                    @Html.CheckBoxFor(model => Model[i].Checked)
                }
                @Html.LabelFor(model => Model[i].Id, Model[i].Title)
                <ul>
                    @*Let's recurse!*@
                    @await Component.InvokeAsync("SelectCategories",
                    new
                    {
                        parentId = Model[i].Id,
                        productId = ProductId
                    })
                </ul>
            </li>
        }
    }
</ul>

Which outputs this HTML (this is a portion of it):

<input id="z0__Id" name="[0].Id" type="hidden" value="1003" />
<input checked="checked" id="z0__Checked" name="[0].Checked" type="checkbox" value="true" />
<label for="z0__Id">Up to 20&quot;</label>

<input data-val="true" data-val-required="The Id field is required." id="z1__Id" name="[1].Id" type="hidden" value="1004" />
<input checked="checked" data-val="true" data-val-required="The Checked field is required." id="z1__Checked" name="[1].Checked" type="checkbox" value="true" />
<label for="z1__Id">21&quot; - 40&quot;</label>

<input data-val="true" data-val-required="The Id field is required." id="z2__Id" name="[2].Id" type="hidden" value="1005" />
<input checked="checked" data-val="true" data-val-required="The Checked field is required." id="z2__Checked" name="[2].Checked" type="checkbox" value="true" />
<label for="z2__Id">41&quot; - 55&quot;</label>

<!-- ...and so on ... -->

I'm faced with (at least) two challenges here:

  1. The statement item.Products.Any(c => c.Id == productId); should evaluate to true if the value of the local variable productId is found in the navigation property Products, meaning that this particular product is linked to that particular category (via item.Products), but now it is checking all sibling categories in the parent-category.

  2. I think the HTML-output is not correct, at least for the name-property: <input checked="checked" id="z0__Checked" name="[0].Checked" type="checkbox" value="true" />. Could it work being named [0].Checked?

I haven't written the controller's POST-edit method yet, so I'm not including it in this question. In it I have to do some converting from one model to another, and some other stuff. If I can just get the form to render correctly, I can bind it to the controller.


Solution

  • Yes, I expect the rendered html names are probably invalid for model binding. You should add a parent model to contain a list of this class:

    public class ViewModelX
    {
        public List<ViewModelProductCategory> Categories = new List<ViewModelProductCategory>();
    }
    

    Then use it in your view and controller:

    @model ViewModelX
    
    @Html.HiddenFor(model => model.Categories[i].Id)
    @Html.CheckBoxFor(model => model.Categories[i].Checked)
    // etc
    
    _mapper.Map<ViewModelX> ...
    

    This will then render in the html:

    name="Categories[0].Id"