Search code examples
c#data-bindingasp.net-core-mvcasp.net-core-5.0

ASP.NET Core MVC - editing a list of entities


I'm trying to dip my toe into frontend development with ASP.NET Core 5 MVC - but I'm not really having much success so far... I'm more of a backend developer - give me a SQL Server database, and a ASP.NET Core Web API, and I'm happy - but this frontend wizardry is not quite my strong suit ...

OK, so I'm trying something extremely simple - a Razor page to edit a list of something, add a numerical value to each of those somethings, and then storing them.

I have a model/DTO class like:

public class Customer
{
    public int CustomerId { get; set; }
    public string Name { get; set; }
    public string City { get; set; }
}

Of course, in reality, it has many more properties - but those aren't relevant here. What is: name and city are given, and I need to add the CustomerId to it (in our case, the CustomerId is handled by SAP - so we need to manually add those after the customer has been created there).

I have a really simple Razor page which basically shows the list of customers, allows me to enter the CustomerId in the last column, and then I have a POST method on the same Razor page that gets hit when the user clicks on the Update (submit) button.

Here's my view:

@page
@model IndexModel

<h3 class="display-4">Customers</h3>

@if (!string.IsNullOrWhiteSpace(@Model.Message))
{
    <div class="container">
        <span class="@Model.MessageClass mt-4 mb-5">@Model.Message</span>
    </div>
}

<div class="container">
    <form method="post">
        <table class="table table-striped table-bordered table-hover table-sm">
            <thead class="thead-dark">
                <tr>
                    <th scope="col-md-2">Name</th>
                    <th scope="col-md-2">City</th>
                    <th scope="col-md-1">CustomerId</th>
                </tr>
            </thead>
            <tbody>

                @for (int index = 0; index < Model.Customers.Count; index++)
                {
                    <tr>
                        <td>@Model.Customers[index].Name</td>
                        <td>@Model.Customers[index].City</td>
                        <td>
                            <input type="number" asp-for="Customers[index].CustomerId" id="Customers[@index]" />
                        </td>
                    </tr>
                }
            </tbody>
        </table>

        <input type="submit" value="Update" class="btn btn-primary" />
    </form>
</div>

and this is my "code-behind" for this Razor page:

public class IndexModel : PageModel
{
    public string Message { get; set; }
    public string MessageClass { get; set; }
    public List<Customer> Customers { get; set; }

    public async Task OnGetAsync()
    {
        Customers = CustomerProvider.GetCustomers();
    }

    public async Task OnPostAsync()
    {
        Customers = CustomerProvider.GetCustomers();

        if (await TryUpdateModelAsync(Customers))
        {
            Message = "Customers successfully updated";
            MessageClass = "alert-success";
        }
        else
        {
            Message = "Customer update did not work";
            MessageClass = "alert-danger";
        }
    }
}

Nothing too fancy here - I basically get a list of customers from somewhere (a database, in reality), I display that list, I can enter customer id values into the grid, and I was expecting to get back the updated Customers in my OnPostAsync method.

Now the code runs fine, shows the list of customers, I can enter values into the CustomerId column, and I can click on "Update". The OnPostAsync method gets called, I fetch the customers again, and I was expecting the await TryUpdateModelAsync to update my customers with the CustomerId values that I have entered.

But it does not do that - after my GetCustomers call, I have my four test customers - as expected - but after the TryUpdateModelAsync, that list is empty... The call to TryUpdateModelAsync works - it returns true - but the list of customers isn't updated with the information entered on screen - quite the contrary, the list is wiped out ...

I also tried to use

public async Task OnPostAsync([FromForm] List<Customer> updatedCustomers)
    

hoping that the MVC data binding would return back the list of updated customers - but this updatedCustomers is null and doesn't send back the entered data...

But when I look at HttpContext.Request.Form - I do see the values that were entered:

enter image description here

but somehow, those aren't handled properly and not applied to the Customers list...

Any ideas? I must be missing something really silly somewhere - but I just cannot find it...


Solution

  • You can bind the values in two ways:

    1. Using [BindProperty]

    Add a property to your model and annotate it with [BindProperty]. When you build inputs with asp-for="@Model.MyList[i].AProp" it will be bound to form values when submitted.

    Note: You still need to render read-only properties in HTML with hidden inputs (<input type="hidden" />) for those values to be available when the form is submitted, otherwise you'll get sentinel/null values.

    Assuming you have a Razor template as follows:

    <form method="POST">
        @Html.AntiForgeryToken()
    
        <table>
            <thead>
            <tr>
                <th>Customer Id</th>
                <th>Name</th>
            </tr>
            </thead>
            <tbody>
            @for (var i = 0; i < Model.Customers.Count; i++)
            {
                <tr>
                    <td>
                        <input type="text" 
                               readonly 
                               value="@Model.Customers[i].CustomerId" 
                               asp-for="@Model.Customers[i].CustomerId"/> <!-- asp-for attributes are indexed for each item -->
                    </td>
                    <td>
                        <input type="text" 
                               value="@Model.Customers[i].Name"
                               asp-for="@Model.Customers[i].Name"/> 
                    </td>
                </tr>
            }
            </tbody>
        </table>
    
        <button type="submit">Save</button>
    </form>
    

    you need a model with a bound property:

    public record Customer(string CustomerId, string Name);
    public class HomeModel : PageModel
    {
        [BindProperty]
        public List<Customer> Customers { get; set; }
        // ...
    }
    

    When rendered, you should have inputs with indexed name attributes:

    <input type="text" value="Bob" id="Customers_1__Name" name="Customers[1].Name"/>
    

    When you submit the form, Model.Customers will be populated with the values.

    2. Using [FromForm] attribute

    You can also accept a parameter of the same name as input names. This means if the input names are like Customers[1].Name, the parameter name must be customers (case insensitive) (not updatedCustomers like you have). Or you can specify a different name using [FromForm(Name = "customers")] updatedCustomers.

    // this works
    public ActionResult OnPost([FromForm]List<Customer> customers)
    {
        return Page();
    }
    
    // this also works
    public ActionResult OnPost([FromForm(Name = "customers")]List<Customer> updatedCustomers)
    {
        return Page();
    }
    

    If the model has a bound property ([BindProperty]), it will also be populated with the form values in addition to the parameter.

    Further info