3

Using the post request below the model returns null for both the collections yet it correctly returns the boolean attribute. My expectation was that the collections loaded into the model during the get request would persist to the post request. What am I missing?

EDIT: Essentially I am trying to update the list of invoices based on the users selection of a selectlist and a checkbox.

Controller:

    [HttpGet]
    [AllowAnonymous]
    public async Task<ActionResult> Index(bool displayFalse = true)
    {
        InvoiceViewModel invoiceView = new InvoiceViewModel();
        var companies = new SelectList(await DbContext.Company.ToListAsync(), "CompanyID", "Name").ToList();
        var invoices = await DbContext.Invoice.Where(s => s.Paid.Equals(displayFalse)).ToListAsync();

        return View(new InvoiceViewModel { Companies = companies,Invoices = invoices, SelectedCompanyID = 0, DisplayPaid = displayFalse});
    }

    [HttpPost]
    [AllowAnonymous]
    public async Task<IActionResult> Index(InvoiceViewModel model)
    {
        model.Invoices = await DbContext.Invoice.Where(s => s.CompanyID.Equals(model.SelectedCompanyID) && s.Paid.Equals(model.DisplayPaid)).ToListAsync();

        return View(model);         
    }

Model:

public class InvoiceViewModel

{

    public int SelectedCompanyID { get; set; }

    public bool DisplayPaid { get; set; }

    public ICollection<SelectListItem> Companies { get; set; }

    public ICollection<Invoice> Invoices{ get; set; }

}

View:

@model InvoiceIT.Models.InvoiceViewModel
<form asp-controller="Billing" asp-action="Index" method="post" class="form-horizontal" role="form">
<label for="companyFilter">Filter Company</label>
<select asp-for="SelectedCompanyID"  asp-items="Model.Companies"  name="companyFilter"  class="form-control"></select>
<div class="checkbox">
    <label>
        <input type="checkbox" asp-for="DisplayPaid" />Display Paid  
        <input type="submit" value="Filter" class="btn btn-default" />     
    </label>
</div>
<br />
</form>
<table class="table">
<tr>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().InvoiceID)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().CompanyID)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().Description)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().InvoiceDate)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().DueDate)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().Paid)
    </th>
    <th></th>
</tr>

@foreach (var item in Model.Invoices)
{
    <tr>
        <td>
            @Html.DisplayFor(modelItem => item.InvoiceID)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.CompanyID)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Description)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.InvoiceDate)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.DueDate)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Paid)
        </td>
        <td>
            @Html.ActionLink("Edit", "Edit", new { id = item.InvoiceID }) |
            @Html.ActionLink("Details", "Index", "InvoiceItem", new { id = item.InvoiceID }) |
            @Html.ActionLink("Delete", "Delete", new { id = item.InvoiceID })
        </td>
    </tr>
}
</table>
Reafidy
  • 8,240
  • 5
  • 51
  • 83
  • Are you expecting `Companies` to be populated when you post? - You don't (and should not) generate any form controls for each `SelectListItem` in the collection so their values wont post back. –  Aug 06 '15 at 05:12
  • @Stephen yes in the postback I expected the companies and invoices to be persisted in the model. However they are both null. Sorry I don't understand your second statement. – Reafidy Aug 06 '15 at 05:15
  • A form only posts back the name/pair values of its form controls (input, textarea, select). The only 2 form controls your generating are for properties `SelectedCompanyID` and `DisplayPaid`. Your not generating any form controls for the properties of each `SelectListItem` in `Companies` (nor should you) or for each `Invoice` in `Invoices` so their values will not be posted. –  Aug 06 '15 at 05:19
  • I added the rest of the view code which shows the invoices being populated. In the post I am trying to filter the results based on the selected company. Unfortunately I cant because invoices is now null in the model. – Reafidy Aug 06 '15 at 05:22
  • You cant use a `foreach` loop to generate form controls in a collection. You need to us a `for` loop (or an `EditorTemplate` for typeof `Invoice`). The `foreach` loop is generating duplicate `name` attributes (without indexers and with a name that does not match you model properties). Its also generating duplicate `id` attributes which is invalid html –  Aug 06 '15 at 05:26
  • In any case, `@Html.DisplayFor()` does not generate a form control (just text). You would have needed to use textboxes or hidden inputs if you wanted to post back the collection –  Aug 06 '15 at 05:31
  • I'm not sure why I need form controls. This is simply a page to display a grid of all the invoices. And then filter them with a drop-down. It is not for editing anything. Sorry I think I must still be missing something. – Reafidy Aug 06 '15 at 05:35
  • So are you wanting to filter you collection of invoices based on the value of `SelectedCompanyID`? If so the best approach is to use ajax (handle the `.change()` event of your dropdown, pass its value to another method that returns a partial view of the results and update the DOM. –  Aug 06 '15 at 05:37
  • Yes that's correct but crikey that sounds complicated. I will have to do some research. – Reafidy Aug 06 '15 at 05:39
  • Not complicated at all :). Alternatively in your POST method, get the value of `SelectedCompanyID` and pass it to the GET method (`return RedirectToAction("Index", new { companyID = model.SelectedCompanyID })` and add a `int companyID` parameter to the GET method so you can filter the collection –  Aug 06 '15 at 05:42
  • Could you perhaps post an answer, what you have been saying seems right. – Reafidy Aug 06 '15 at 06:05
  • You will first need to update the question to explain what your actually wanting to do (i.e. update the table of invoices based on the selected company and the checkbox). Give me about an hour and I'll add an answer using ajax (which gives far better performance than posting and regenerating the whole view anyway) –  Aug 06 '15 at 06:12
  • Sure will do, thanks for your help. – Reafidy Aug 06 '15 at 06:13

4 Answers4

4

A form only posts back the name/value pairs of its controls (input, textarea, select). Since the only 2 controls you generate are for the SelectedCompanyID and DisplayPaid properties of your model, then only those properties will be bound when post.

From your comments, what your really wanting to do is to update the table of invoices based on the values of the selected company and the checkbox.

From a performance point of view, the approach is to use ajax to update just the table of invoices based on the value of your controls.

Create a new controller method that return a partial view of the table rows

public PartialViewResult Invoices(int CompanyID, bool DisplayPaid)
{
  // Get the filtered collection
  IEnumerable<Invoice> model = DbContext.Invoice.Where(....
  return PartialView("_Invoices", model);
}

Note you may want to make the CompanyID parameter nullable and adjust the query if your wanting to initially display unfiltered results

And a partial view _Invoices.cshtml

@model IEnumerable<yourAssembly.Invoice>
@foreach(var item in Model)
{
  <tr>
    <td>@Html.DisplayFor(m => item.InvoiceID)</td>
    .... other table cells
  </tr>
}

In the main view

@model yourAssembly.InvoiceViewModel
@Html.BeginForm()) // form may not be necessary if you don't have validation attributes
{
  @Html.DropDownListFor(m => m.SelectedCompanyID, Model.Companies)
  @Html.CheckboxFor(m => m.DisplayPaid)
  <button id="filter" type="button">Filter results</button>
}
<table>
  <thead>
    ....
  </thead>
  <tbody id="invoices">
    // If you want to initially display some rows
    @Html.Action("Invoices", new { CompanyID = someValue, DisplayPaid = someValue })
  </tbody>
</table>

<script>
  var url = '@Url.Action("Invoices")';
  var table = $('#invoices');
  $('#filter').click(function() {
    var companyID = $('#SelectedCompanyID').val();
    var isDisplayPaid = $('#DisplayPaid').is(':checked');
    $.get(url, { CompanyID: companyID, DisplayPaid: isDisplayPaid }, function (html) {
      table.append(html);
    });
  });
</script>

The alternative would be to post the form as your are, but rather than returning the view, use

return RedirectToAction("Invoice", new { companyID = model.SelectedCompanyID, DisplayPaid = model.DisplayPaid });

and modify the GET method to accept the additional parameter.

Side note: Your using the TagHelpers to generate

select asp-for="SelectedCompanyID"  asp-items="Model.Companies"  name="companyFilter"  class="form-control"></select>

I'm not familiar enough with them to be certain, but if name="companyFilter" works (and overrides the default name which would be name="SelectedCompanyID"), then you generating a name attribute which does not match your model property and as a result SelectedCompanyID would be 0 (the default for int) in the POST method.

  • This looks good, but do you know what to use instead of `@Html.Action("Invoices", new { CompanyID = someValue, DisplayPaid = someValue })` as Action is not available in asp.net 5 – Reafidy Aug 06 '15 at 08:25
  • Didn't know that :) [This answer](http://stackoverflow.com/questions/26916664/html-action-in-asp-net-5-mvc6) might help –  Aug 06 '15 at 08:29
  • OK I have the view component sorted, should there still be an httppost in the controller? – Reafidy Aug 06 '15 at 09:18
  • Does not need to be `[HttpPost]` (and you not changing data so its really a `[HttpGet]` anyway). But if you did change it to a POST, then you would also need to change `$.get()` to `$.post()` –  Aug 06 '15 at 09:31
  • Instead of @URL, the script needs to invoke the view component like this: `@Component.Invoke("Invoice", CompanyID, DisplayPaid)`, I'm not sure how to modify it, are you able to help? – Reafidy Aug 06 '15 at 09:39
  • Not at the moment, but its a good excuse for me to start learning out the new features in asp.net-5 so hopefully can let you know tomorrow. –  Aug 06 '15 at 11:19
0

Appending ToList() to the statement that populates companies is converting the SelectList into a List<T>, which the form will not recognize as a SelectList. Also, by using the dynamic var keyword, you are masking this problem. Try this instead:

SelectList companies = new SelectList(await DbContext.Company.ToListAsync(), "CompanyID", "Name");

In general, try to avoid use of var unless the type is truly dynamic (unknown until runtime).

Peter Gluck
  • 8,168
  • 1
  • 38
  • 37
  • Now I get a compile error: Cannot implicitly convert type 'Microsoft.AspNet.Mvc.Rendering.SelectList' to 'System.Collections.Generic.ICollection'. An explicit conversion exists. On this line `return View(new InvoiceViewModel { Companies = companies,Invoices = invoices, SelectedCompanyID = 0, DisplayPaid = displayFalse});` – Reafidy Aug 06 '15 at 05:27
  • Right, so you need to change your model to declare `Companies` as a `SelectList` rather than an `ICollection`. See the [documentation for `SelectList`](https://msdn.microsoft.com/en-us/library/system.web.mvc.selectlist(v=vs.118).aspx) and note that it does _not_ inherit from `ICollection`. – Peter Gluck Aug 06 '15 at 06:06
  • Thanks so I made the changes and it compiles. However it made no difference to the original problem. – Reafidy Aug 06 '15 at 06:11
  • Have you defined your view to be strongly-typed so it recognizes the model type? See this article: http://stackoverflow.com/questions/2896544/what-is-strongly-typed-view-in-asp-net-mvc – Peter Gluck Aug 06 '15 at 06:27
  • I posted the code for my view in my original post. I believe it is strongly typed. – Reafidy Aug 06 '15 at 06:35
  • I am specifying the model type with `@model InvoiceIT.Models.InvoiceViewModel` – Reafidy Aug 06 '15 at 06:50
  • Yes, that should work, assuming the namespace is correct and in the search path for the view. I'm not sure the form elements as you have defined them will work. You should use the Razor HTML helpers, e.g., `@Html.DropDownListFor(m => m.Companies)` – Peter Gluck Aug 06 '15 at 06:55
  • Thanks but I'm trying to use TagHelpers, as I'm using asp.net 5. – Reafidy Aug 06 '15 at 06:57
0

You put your model data out of form, so it would not submited!

    <form asp-controller="Billing" asp-action="Index" method="post" class="form-horizontal" role="form">
<label for="companyFilter">Filter Company</label>
<select asp-for="SelectedCompanyID"  asp-items="Model.Companies"  name="companyFilter"  class="form-control"></select>
<div class="checkbox">
    <label>
        <input type="checkbox" asp-for="DisplayPaid" />Display Paid  
        <input type="submit" value="Filter" class="btn btn-default" />     
    </label>
</div>
<br />
<table class="table">
<tr>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().InvoiceID)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().CompanyID)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().Description)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().InvoiceDate)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().DueDate)
    </th>
    <th>
        @Html.DisplayNameFor(model => model.Invoices.FirstOrDefault().Paid)
    </th>
    <th></th>
</tr>

@foreach (var item in Model.Invoices)
{
    <tr>
        <td>
            @Html.DisplayFor(modelItem => item.InvoiceID)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.CompanyID)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Description)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.InvoiceDate)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.DueDate)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.Paid)
        </td>
        <td>
            @Html.ActionLink("Edit", "Edit", new { id = item.InvoiceID }) |
            @Html.ActionLink("Details", "Index", "InvoiceItem", new { id = item.InvoiceID }) |
            @Html.ActionLink("Delete", "Delete", new { id = item.InvoiceID })
        </td>
    </tr>
}
</table>
</form>
Bashir Mahmoudi
  • 176
  • 1
  • 15
-1

Using a for loop to create the with the companies will make it possible to map back and persist the company values

for(c = 0 ; c < Model.Companies.Count(); c++)
{
    <input type='hidden' name='@Html.NameFor(Model.Companies[c].Propery1)'       id='@Html.IdFor(Model.Comapnies[c].Propery1)' value='somevalue'>someText />
 <input type='hidden' name='@Html.NameFor(Model.Companies[c].Propery2)'                id='@Html.IdFor(Model.Comapnies[c].Propery2)' value='somevalue'>someText />
}

this ensures that the list is mapped back as the default model binder expects list to be in ListProperty[index] format

mahlatse
  • 11
  • 3
  • Nonsense. A ` –  Aug 06 '15 at 05:29
  • What I wanted to explain was the format that the list would expect, this could also be done as a hidden, etc, the format is what im trying to explain – mahlatse Aug 06 '15 at 05:34
  • Then it would be `for(int i = 0l i < Model.Invoices.Count; i++) { @Html.HiddenFor(m => m.Invoices[i].someProperty) .... }` –  Aug 06 '15 at 05:39
  • Hence the edit i made, before you say something is Nonsense, try to understand it first before you jump the gun. – mahlatse Aug 06 '15 at 05:41
  • My "nonsense" comment was in relation to your original answer which was totally wrong. But you edit is still awful code. –  Aug 06 '15 at 05:44