ASP.Net Core: How do I update (change/add/remove) nested item objects (One-to-Many relationship)?

120

Question: ASP.Net Core: How do I update (change/add/remove) nested item objects (One-to-Many relationship)?

I have an .Net 5.x project with "MSCustomers" and "MSLocations". c# error There's a many-to-one of MSLocations to MSCustomers.

My "Edit" page correctly displays an "MSCustomer" record and the corresponding c# error "MSLocations" fields.

PROBLEM:

"Edit" should allow me to modify or "remove" c# error any MSLocation. But when I save the record, none of the MSLocations are changed.

MSCustomer.cs:

public class MSCustomer {     public int ID { get; set; }     public string CustomerName { get; set; }     public string EngagementType { get; set; }     public string MSProjectNumber { get; set; }     // EF Navigation     public virtual ICollection<MSLocation> MSLocations { get; set; }  } 

MSLocation.cs

public class MSLocation {     public int ID { get; set; }     public string Address { get; set; }     public string City { get; set; }     public string State { get; set; }     public string Zip { get; set; }     public int MSCustomerId { get; set; }  // FK     // EF Navigation     public MSCustomer MSCustomer { get; set; } } 

Edit.cshtml.cs:

public class EditModel : PageModel {     [BindProperty]     public MSCustomer MSCustomer { get; set; }     ...     public IActionResult OnGet(int? id)     {                   if (id == null)             return NotFound();          MSCustomer = ctx.MSCustomer             .Include(location => location.MSLocations)             .FirstOrDefault(f => f.ID == id);          return Page();  // This all works...     }      public async Task<IActionResult> OnPostAsync(string? submitButton)     {         ctx.Attach(MSCustomer).State = EntityState.Modified;         await ctx.SaveChangesAsync();          return RedirectToPage("Index"); // Saves MSCustomer updates, but not MSLocations... 

Edit.cshtml.cs

@page @model HelloNestedFields.Pages.MSFRD.EditModel @using HelloNestedFields.Models ... <form method="POST">     <input type="hidden" asp-for="MSCustomer.ID" />     ...     <table>         <thead>             <tr>                 <th style="min-width:140px">Address</th>                 <th style="min-width:140px">City</th>                 <th style="min-width:140px">State</th>                 <th style="min-width:140px">Zip</th>             </tr>         </thead>         <tbody>             @foreach (MSLocation loc in @Model.MSCustomer.MSLocations)             {                 <tr id="[email protected]">                     <td><input asp-for="@loc.Address" /></td>                     <td><input asp-for="@loc.City" /></td>                     <td><input asp-for="@loc.State" /></td>                     <td><input asp-for="@loc.Zip" /></td>                     <td><button onclick="removeField(@loc.ID);">Remove</button></td>                 </tr>             }             <tr>                 <td></td>                 <td></td>                 <td></td>                 <td></td>                 <td><button id="add_location_btn">Add Location</button></td>             </tr>         </tbody>     </table>     ... @section Scripts { <script type="text/javascript">     function removeField(element_id) {         try {           let row_id = "row_" + element_id;           console.log("removeField, element_id=" + element_id + ", row_id=" + row_id + "...");           let tr = document.getElementById(row_id);           console.log("tr:", tr);           tr.parentNode.removeChild(tr);         } catch (e) {           console.error(e);         }         debugger;     }; </script>  } 

HelloNestedContext.cs

public class HelloNestedContext : DbContext {     public HelloNestedContext(DbContextOptions<HelloNestedContext> options)         : base(options)     {     }      public DbSet<HelloNestedFields.Models.MSCustomer> MSCustomers { get; set; }     public DbSet<HelloNestedFields.Models.MSLocation> MSLocations { get; set; }      protected override void OnModelCreating(ModelBuilder modelBuilder)     {         modelBuilder.Entity<MSCustomer>()             .HasMany(d => d.MSLocations)             .WithOne(c => c.MSCustomer)             .HasForeignKey(d => d.MSCustomerId);     } } 

Q: What am I missing?

Q: What do I need to do so that MSCustomers.MSLocations updates are passed from the browser back to OnPostAsync(), and saved correctly?

I'm sure it's POSSIBLE. But I haven't been able to find any documentation or sample code anywhere for modifying "nested item objects" in a "record object".

Any suggestions would be very welcome!


Update:

Razor pages don't seem to support binding to a "complex" object c# error (with nested lists within a record).

So I tried Okan Karadag's excellent suggestion below - I split "MSLocations" into its own binding, then added it back to "MSCustomer" in the "POST" handler. This got me CLOSER - at least now I'm now able to update nested fields. But I'm still not able to add or remove MSLocations in my "Edit" page.

New Edit.cshtml.cs

[BindProperty] public MSCustomer MSCustomer { get; set; } [BindProperty] public List<MSLocation> MSLocations { get; set; } ...  public IActionResult OnGet(int? id) {               MSCustomer = ctx.MSCustomer         .Include(location => location.MSLocations)         .FirstOrDefault(f => f.ID == id);     MSLocations = new List<MSLocation>(MSCustomer.MSLocations);    ...  public async Task<IActionResult> OnPostAsync(string? submitButton) {     MSCustomer.MSLocations = new List<MSLocation>(MSLocations);  // Update record with new     ctx.Update(MSCustomer);     await ctx.SaveChangesAsync();    ... 

New Edit.cshtml

<div class="row">     ...     <table>         ...         <tbody id="mslocations_tbody">             @for (int i=0; i < Model.MSLocations.Count(); i++)             {                 <tr id="[email protected][i].ID">                           <td><input asp-for="@Model.MSLocations[i].Address" /></td>                     <td><input asp-for="@Model.MSLocations[i].City" /></td>                     <td><input asp-for="@Model.MSLocations[i].State" /></td>                     <td><input asp-for="@Model.MSLocations[i].Zip" /></td>                     <td>                         <input type="hidden" asp-for="@Model.MSLocations[i].ID" />                         <input type="hidden" asp-for="@Model.MSLocations[i].MSCustomerId" />                         <button onclick="removeLocation([email protected][i].ID);">Remove</button>                     </td>                 </tr>             }         </tbody>     </table>     <button onclick="addLocation();">Add Location</button> </div> 

Current Status:

  • I can update top-level "MSCustomer" data fields OK.
  • I can update existing "MSlocation" fields OK.
  • I CANNOT add new or remove current MSLocation items.
  • The "blocker" seems to be Razor bindings: communicating the "updated" MSLocations list from the Razor "Edit" page back to the C# "POST" action handler.

My next step will be to try this:

How to dynamically add items from different entities to lists in ASP.NET Core MVC

It would be great if it worked. It would be even BETTER if there were a simpler alternative that didn't involve Ajax calls..

Total Answers: 2

47

Answers 1: of ASP.Net Core: How do I update (change/add/remove) nested item objects (One-to-Many relationship)?

When sending data, it should be customer.Locations[0].City : "foo" customer.Locations[1].City : "bar", You should post as Locations[index]. you can look passed data in network tab at browser.

Solution 1 (with for)

@for (var i = 0; i < Model.MSCustomer.Locations.Count(); i++) {     <tr id="[email protected]">         <td><input asp-for="@Model.MSCustomer.Locations[i].Address" /></td>         <td><input asp-for="@Model.MSCustomer.Locations[i].City" /></td>         <td><input asp-for="@Model.MSCustomer.Locations[i].State" /></td>         <td><input asp-for="@Model.MSCustomer.Locations[i].Zip" /></td>         <td><button onclick="removeField(@loc.ID);">Remove</button></td>      </tr> } 

solution 2 (with foreach)

@foreach (MSLocation loc in @Model.MSCustomer.MSLocations) {     <tr id="[email protected]">         <td><input asp-for="@Model.MSCustomer.MSLocations[loc.Id].Address" /></td>         <td><input asp-for="@Model.MSCustomer.MSLocations[loc.Id].City" /></td>         <td><input asp-for="@Model.MSCustomer.MSLocations[loc.Id].State" /></td>         <td><input asp-for="@Model.MSCustomer.MSLocations[loc.Id].Zip" /></td>         <td><button onclick="removeField(@loc.ID);">Remove</button></td>     </tr> } 
58

Answers 2: of ASP.Net Core: How do I update (change/add/remove) nested item objects (One-to-Many relationship)?

OK: my basic challenge was figuring out how to create a simple web form c# error - in ASP.Net Core MVC - with a "master record" having "nested fields".

One "Razor page" where I could create and edit a schema like this:

* Customers      int ID      string Name      List<Location> Locations  * Locations:     int ID     string Address     string City     string State     string Zip     int CustomerID 
  1. I started out creating my models:

    Models\Customer.cs

     public class Customer  {      public int ID { get; set; }      public string CustomerName { get; set; }      public string EngagementType { get; set; }      public string MSProjectNumber { get; set; }      // EF Navigation      public virtual ICollection<MSLocation> MSLocations { get; set; } 

    Models\MSLocation.cs

     public class MSLocation {      public int ID { get; set; }      public string Address { get; set; }      public string City { get; set; }      public string State { get; set; }      public string Zip { get; set; }      public int CustomerId { get; set; }  // FK      // EF Navigation      public Customer Customer { get; set; }        <= Both Customer, MSLocation configured for EF navigation 
  2. Next, I configured my DBContext to support navigation:

    Data\HelloNestedContext.cs

     public class HelloNestedContext : DbContext {      ...      public DbSet<HelloNestedFields.Models.Customer> Customers { get; set; }      public DbSet<HelloNestedFields.Models.MSLocation> MSLocations { get; set; }       protected override void OnModelCreating(ModelBuilder modelBuilder) {          base.OnModelCreating(modelBuilder);          ...          modelBuilder.Entity<Customer>()              .HasMany(d => d.MSLocations)              .WithOne(c => c.Customer)              .HasForeignKey(d => d.CustomerId);        <= Configure "One::Many " relationship in DbContext 
  3. It's easy and efficient to use query master record and nested fields all-at-once:

    Pages\MSFRD\Edit.cshtml.cs > OnGet()

     Customer = ctx.Customers      .Include(location => location.MSLocations)      .FirstOrDefault(f => f.ID == id);        <= So far, so good: this all worked fine in the original "Departments/Courses/Course Auditors" example... 
  4. CHALLENGES:

    • PROBLEM #1: Razor pages don't support binding to nested c# error subfields (e.g. "Customer.MSLocations")

    • SOLUTION: Declare the subfield(s) as its own model variable (e.g. "List c# error MSLocations"), and bind separately from "master record"

    • PROBLEM #2: Merge updates to separate "MSLocations" c# error back in to "Customer.MSLocations" when update submitted

    • SOLUTION:

      Pages\MSFRD\Edit.cshtml.cs > OnPostAsync ()

      ... Customer.MSLocations = new List<MSLocation>(MSLocations); ctx.Update(Customer); await ctx.SaveChangesAsync();   <= No problem: Fairly straightforward... 
    • PROBLEM #3a: Add new subfields to "Customer.MSLocations"

    • SOLUTION: Use JS to create new HTML elements; follow same id/attribute c# error element attribute naming conventions as Razor uses

      EXAMPLE:

      <td><input type="text" id="MSLocations_3__Address" name="MSLocations[3].Address" /> <td><input type="text" id="MSLocations_3__City" name="MSLocations[3].City" /> ... 

      Pages\MSFRD\Edit.cshtml

      function addLocation() {     console.log("addLocation(), maxRow=" + maxRow + "...");     //debugger;     try {       let markup =        `<tr id="row_${maxRow}">            <td><input type="text" id="MSLocations_${maxRow}__Address" name="MSLocations[${maxRow}].Address" /></td>            <td><input type="text" id="MSLocations_${maxRow}__City" name="MSLocations[${maxRow}].City" /></td>            <td><input type="text" id="MSLocations_${maxRow}__State" name="MSLocations[${maxRow}].State" /></td>            <td><input type="text" id="MSLocations_${maxRow}__Zip" name="MSLocations[${maxRow}].Zip" /></td>            <td>                <input type="hidden" id="MSLocations_${maxRow}__ID" name="MSLocations[${maxRow}].ID"  value="0" />                <input type="hidden" id="MSLocations_${maxRow}__CustomerId" name="MSLocations[${maxRow}].CustomerId" value="@Model.Customer.ID" />                <button onclick="return removeLocation(row_${maxRow}, ${maxRow});">Remove</button>            </td>        </tr>`;        //console.log("markup:", markup);        let tbody = $("#mslocations_tbody");        tbody.append(markup);        ++maxRow; 
    • PROBLEM #3b: Tracking current row#

    • SOLUTION: <input type="hidden" id="MaxRow" value="@Model.MaxRow" />

    • PROBLEM #4: Delete subfields

      • Simply updating the DB with "MSLocations" is NOT sufficient
      • To "delete" an entity, you MUST explicitly call "context.Remove(Item)"
    • SOLUTION:

      • Declare and bind model variable "public string DeletedLocations { get; set;}"
      • Initialize it to an empty JSON array ("[]})
      • Use JS to remove all "MSLocations...." elements for that item from the HTML
      • The same JS function also saves the item being deleted to the "DeletedLocations" JSON array
      • Upon "Submit", deserialize "DeletedLocations" and call ""context.Remove()" on each item:

      Pages\MSFRD\Edit.cshtml.cs > OnPostAsync ()

      ... JArray deletedLocations = JArray.Parse(DeletedLocations); foreach (var jobject in deletedLocations) {     MSLocation MSLocation = jobject.ToObject<MSLocation>();     ctx.Remove(MSLocation); } 
      • Following the "ctx.Remove()" loop, update "Customer" with c# error all adds/modifies, and call "ctx.SaveChangesAsync()":

        // Remove any deleted locations from DB JArray deletedLocations = JArray.Parse(DeletedLocations); foreach (var jobject in deletedLocations) {     MSLocation MSLocation = jobject.ToObject<MSLocation>();     ctx.Remove(MSLocation); }  // Associate MSLocations modifications/updates with Customer record Customer.MSLocations = new List<MSLocation>(MSLocations); ctx.Update(Customer);  // Update DB await ctx.SaveChangesAsync(); 
  • Key benefits:

    a. MINIMIZE the #/round trips to the DB

    b. MINIMIZE the amount of SQL-level data for each round trip