ASP.NET Core MVC - editing a list of entities

Viewed 609

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...

2 Answers

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

Ah if I understand this case right then you are missing a very little part.

So you want to send a list/array type parameter to your POST method, but your POST method does not know about the object which is coming as a parameter. So you should define as a name attribute onto your input.

<input type="number" name="customerIds" asp-for="Customers[index].CustomerId" id="Customers[@index]" />

and your POST method should meet the parameter as an int[] type.

public async Task OnPostAsync(int[] customerIds)
{
      //Now you have edited customerIds as an integer array type and you can manuplate it.
}
Related