Wednesday, March 10, 2021

ASP.NET MVC - Data Binding for Hierarchical Views or Parent Child Relation C#.NET Core and .NET Standard

Introduction

Many developers know that they can create forms on web pages with a minimum of code using ASP.NET model binding. Visual Studio's default MVC view templates will even create a standard list, create, edit, and delete views without any additional programming.


But the power of default model binding extends beyond the flat data model of a simple input form or list of records. Using a few straightforward coding techniques, developers can use ASP.NET to create forms and collect data for hierarchical entity relationships. In many applications, this can make the difference between leveraging the rapid development capabilities of ASP.NET MVC and strapping on the additional infrastructure and complexity of a client-side framework like Angular or React.


Using ASP.NET MVC model binding to present and collect hierarchical form data in a hierarchical structure.

Scope

Using ASP.NET MVC model binding to present and collect hierarchical form data in a hierarchical structure.

The code presented in this guide are based on the .NET Framework. Implementation details for .NET Core and .NET Standard will be covered in a different guide.

Structure

  1. We'll begin with an overview of the case study entities and the principal views of the example solution used to prepare this guide.

  2. Then, we'll see how to create a view model incorporating member fields of various primitive types and incorporating a field that is a collection of an object type.

  3. Next, we'll look at the code required to present information to the end user.

  4. We'll conclude with a close look at how to ensure that Razor code creates the correct HTML and we'll take a look how to use HtmlHelpers and CSS to apply formatting to the form fields created by the view.

 

Entities and Relationships

Note that the many-to-many relationship between Orders and Items is implemented through the use of a merge table with payload: in addition to maintaining the relationship between Orders and Items, the OrderItems table also contains information about the items included in an order, the price at which they were sold and the quantity which were sold.

Obviously, this isn't a complete order processing system; it's just meant to provide an example of hierarchical relationships in a familiar form.

The database is created and maintained using Entity Framework code-first design. Let's look at the Customer and Order entities:

Customer.cs

Namespace Entities.Customers {
        public class Customer {
            
            public Customer() {
                Orders = new HashSet<Order>();
            }
            [Key]
            [Column(Order = 0)]
            [DatabaseGenerated(DatabaseGeneratedOption.None)]
            public Guid CustomerID { get; set; }
            [Required]
            [MaxLength(128)]
            public string CustomerName { get; set; }
            [Required]
            [MaxLength(3)]
            public string CountryIso3 { get; set; }
            [MaxLength(3)]
            public string RegionCode { get; set; }
            public virtual Country Country { get; set; }
            public virtual Region Region { get; set; }
            public virtual ICollection < Order > Orders { get; set; }
    }
}


Note the following characteristics:
  • ComponentModel DataAnnotations are used to identify the key field for the database and to specify the size and other options.

  • The one-to-many relationship between Customers and Orders is created by the virtual member field comprised of a collection of Order entities.

  • The required relationship between Customers and Countries in the database is reflected in the field to hold the value CountryIso3 and the field to identify the relationship, Country, which is of type Country.

Order.cs

The Order entity is defined in a similar way:


namespace Entities.Orders
{
 public class Order
    {
        public Order()
        {
            Items = new HashSet<Item>();
        }
        [Key]
                                    [DatabaseGenerated(DatabaseGeneratedOption.None)]
        public Guid OrderID { get; set; }
        [Required]
        public Guid CustomerID { get; set; }
        [Required]
        public DateTime OrderDate { get; set; }
        [Required]
        [MaxLength(128)]
        public string Description { get; set; }
        public virtual ICollection<Item> Items { get; set; }
        public virtual Customer Customer { get; set; }
    }
}


Note that:

  • The Order can only belong to one Customer, as reflected in the field for a single CustomerID and the navigation property Customer.

  • The one-to-many relationship between Orders and Items is implemented through the virtual property for the collection of Item entities.

Presenting Data

For the purposes of this guide, there are two notable views in the case study, one to display a list of customers and another to display a list of orders for each customer.

Customer/Index View

The view for the list of customers is a simple table displaying some basic information about the customer and an Html.ActionLink helper method to navigate to the list of orders for the customer:


Customer/Index view

Views\Customer\Index.cshtml

In the code for the view above, note that the list of customers is created with a foreach loop that iterates through the collection of CustomerDisplayViewModel entities. This is a standard way of presenting a list with a variable number of records:


@foreach(var item in Model)
{
    <tr>
        <td>
            @Html.DisplayFor(modelItem => item.CustomerID)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.CustomerName)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.CountryName)
        </td>
        <td>
            @Html.DisplayFor(modelItem => item.RegionName)
        </td>
        <td>
            @Html.ActionLink("Orders", "Index", "Order",  new { customerid = item.CustomerID }, null)
        </td>
    </tr>
}


In the list of customers, the view model has a flat structure, it's just an enumerable list of objects that contain the customer information. Here's the @model directive from the beginning of Index.cshtml:


@model IEnumerable<Entities.Customers.ViewModels.CustomerDisplayViewModel>


Now let's take a closer look at that view model.

Customers.ViewModels\CustomerDisplayViewModel.cs

namespace Entities.Customers.ViewModels
{
    public class CustomerDisplayViewModel
    {
        [Display(Name = "Customer Number")]
        public Guid CustomerID { get; set; }

        [Display(Name = "Customer Name")]
        public string CustomerName { get; set; }

        [Display(Name = "Country")]
        public string CountryName { get; set; }

        [Display(Name = "State / Province / Region")]
        public string RegionName { get; set; }
    }
}

When the repository method populates this model it combines data from the Customers table with CountryNameEnglish from the Country table and RegionName from the Region table. In this way, the view model can present information that is more helpful to the user than the index values for country and region from the Customers table.

Order/Index View

The simple list of orders for a customer shows the customer information and the order number and date as read-only fields, and the purchase order/description as an editable field. By changing values in the editable field and saving we can see how model binding works when doing HttpPost actions.



Order/Index view

In this view, we're presenting information in a hierarchical structure. At the top level is the customer information. Underneath that is the list of orders for the customer.

Let's see how the data is presented in code.

Views\Order\Index.cshtml

For the top-tier data, pertaining to the customer, the fields are composed in a very standard way:
   <div class="form-group">
            @Html.LabelFor(model => model.CustomerName, new { @class = "control-label col-md-2" })
            <div class="col-md-10">
                @Html.EditorFor(model => model.CustomerName, new { htmlAttributes = new { @class = "form-control", @readonly = "readonly" } })
            </div>
        </div>
 

Note that we're using the EditorFor HtmlHelper to let the Razor engine determine the correct type of HTML element for the data type. We're also applying the form-control CSS class to be sure the control picks up the appropriate styling. The field is changed from an editable textbox to a display-only field with the application of the @readonly HTML attribute.

For the second tier data, the list of orders, we're looping through the records in the view model. But in this case we're not using a foreach loop and we're not using the EditorFor HtmlHelper. We'll look at the reasons for these choices in more detail in the section on saving data.


@if (Model.Orders != null)
{
    for (var i = 0; i < Model.Orders.Count(); i++)
    {
        <tr>
            @Html.HiddenFor(x => Model.Orders[i].CustomerID)
            <td>
                @Html.TextBoxFor(x => Model.Orders[i].OrderID, new { @class = "form-control", @readonly = "readonly" })
            </td>
            <td>
                @Html.TextBoxFor(x => Model.Orders[i].OrderDate, new { @class = "form-control", @readonly = "readonly" })
            </td>
            <td>
                @Html.TextBoxFor(x => Model.Orders[i].Description, new { @class = "form-control" })
            </td>
        </tr>
    }


You'll also note that we're using a for loop with a counting variable rather than a foreach loop. This is crucial to getting binding to work for the 'HttpPost' action, as we'll see soon.

CustomerOrdersDisplayViewModel.cs

The view model for customer orders reflects the hierarchical structure of the view shown above. It assembles the display information about the customer from the CustomersCountries, and Regions tables and includes a property that is a collection of OrderDisplayViewModel entities.


namespace Entities.Orders.ViewModels
{
    public class CustomerOrdersListViewModel
    {
        [Display(Name = "Customer Number")]
        public Guid CustomerID { get; set; }
        [Display(Name = "Customer Name")]
        public string CustomerName { get; set; }
        [Display(Name = "Country")]
        public string CountryNameEnglish { get; set; }
        [Display(Name = "Region")]
        public string RegionNameEnglish { get; set; }
        public List<OrderDisplayViewModel> Orders { get; set; }
    }
}


Lets take a look at the class that composes the Orders collection.

OrderDisplayViewModel.cs

Note that each entity in OrderDisplayViewModel is linked to the associated customer in CustomerOrdersListViewModel. When we transpose the entities into the view model structure we need to preserve the relationship between the entities (and the tables in the database).


namespace Entities.Orders.ViewModels
{
    public class OrderDisplayViewModel
    {
        public Guid CustomerID { get; set; }
        [Display(Name = "Order Number")]
        public Guid OrderID { get; set; }
        [Display(Name = "Order Date")]
        public DateTime OrderDate { get; set; }
        [Display(Name = "PO / Description")]
        public string Description { get; set; }
    }
}


Note also that there are no virtual properties in either of these classes to provide navigation between entities. The view models serve the functional purpose of the view and are uncoupled from the entity relationships of the classes and the database tables. Accordingly, when two view models are used together they reflect the relationship(s) between the view models, rather than the entities from which their data is drawn.

The repository methods take care of transposing the data from the structure of the entities to the structure of the view models and back again.

OrdersController Action for Index HttpGet

By using MVVM and the repository design pattern, we can make our controller actions succinct and provide separation of concerns between the presentation layer, business logic, and data. We can see that in action in the controller action that populates the Order/Index view.

Controllers\OrderController.cs

using System;
using System.Net;
using System.Web.Mvc;
using Data.Orders;
using Entities.Orders.ViewModels;

namespace Projects.Controllers
{
    public class OrderController : Controller
    {
        // GET: Order
        public ActionResult Index(string customerid)
        {
            if(!String.IsNullOrWhiteSpace(customerid))
            {
                if (Guid.TryParse(customerid, out Guid customerId))
                {
                    var repo = new OrdersRepository();
                    var model = repo.GetCustomerOrdersDisplay(customerId);
                    return View(model);
                }
            }
            return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
        }
...


All that this controller action needs to do when passed a CustomerID from the Customer/Index view is pass that value to the appropriate repository method and take the resultant data model, an instance of the CustomerOrdersListViewModel class, and pass it to the view.

Saving Data

Saving data using default binding can be a tricky process -- the correct approach is not well-documented. This is also a situation where the code fails silently. Developers will see their web pages being populated with data correctly, but the values won't show up in the model when it arrives at the controller action for HttpPost.

To better understand this, we're first going to take a look at the problem, then show how to code the functionality correctly.

What Not to Do

In the Razor code for the list of customers above, we saw that we could populate the list using a foreach loop and the DisplayFor HtmlHelper method. If we used a foreach loop for the list of orders, the <table> element would look like this:


<table class="table">
    <tr>
        <th>
            @Html.DisplayNameFor(model => model.Orders[0].OrderID)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Orders[0].OrderDate)
        </th>
        <th>
            @Html.DisplayNameFor(model => model.Orders[0].Description)
        </th>
    </tr>
    @if (Model.Orders != null)
    {
        foreach (var order in Model.Orders)
        {
            <tr>
                @Html.HiddenFor(x => order.CustomerID)
                <td>
                    @Html.DisplayFor(x => order.OrderID)
                </td>
                <td>
                    @Html.DisplayFor(x => order.OrderDate)
                </td>
                <td>
                    @Html.EditorFor(x => order.Description)
                </td>
            </tr>
        }
    }
</table>


That's nice and concise, and seems to leverage the power of Razor HtmlHelper extension methods to "automagically" generate HTML. The problem is; it doesn't work.

The HTML produced by the preceding code would look like this:



Form Elements with Ambiguous Element Names and ID's

Look at the areas highlighted in yellow. Each row is a separate textbox on the form shown above for the Order/Index view. Each record has the same value for the name and id elements: order.Description. Without a way to identify the records distinctly, MVC gives up and returns null for the Orders field of the CustomerOrdersListViewModel to which the Order/Index view is bound.

Correctly Binding Collection Data

In the Razor code for the list of orders for a specific customer, we used a for loop with a local variable index value i. The loop looks like this:


@if (Model.Orders != null)
{
    for (var i = 0; i < Model.Orders.Count(); i++)
    {
        <tr>
            @Html.HiddenFor(x => Model.Orders[i].CustomerID)
            <td>
                @Html.TextBoxFor(x => Model.Orders[i].OrderID, new { @class = "form-control", @readonly = "readonly" })
            </td>
            <td>
                @Html.TextBoxFor(x => Model.Orders[i].OrderDate, new { @class = "form-control", @readonly = "readonly" })
            </td>
            <td>
                @Html.TextBoxFor(x => Model.Orders[i].Description, new { @class = "form-control" })
            </td>
        </tr>
    }
}


Note the following particulars:

  1. The index value i appears in the lambda expression for each form element being generated by the loop, for example:

    (x => Model.Orders[i].OrderID)

  2. The TextBoxFor HtmlHelper is used, rather than the more general (and automagic) DisplayFor and EditorFor.

  3. The OrderID and OrderDate fields are set as readonly using the HTML class attribute, rather than using DisplayFor.

  4. Bootstrap textbox styling is applied by adding the @class = "form-control" attribute.

The generated HTML looks like this:



Form Elements with a Distinct Name and ID Attributes

As the areas highlighted in yellow show, each field has a name and id attribute that is distinct for each record. The record index gives MVC something to use to bind the form data to the data model.

By setting a breakpoint in the HttpPost controller action for the Order/Index view we can see that the new data we entered, "expedite" in the Description field of record 0, is being posted back to the server along with the values for the readonly fields.



Visual Studio Debugging Showing Property Inspector Values for an OrderDisplayViewModel Entity

Posting Display Data

As we noted above, we're using the TextBoxFor HtmlHelper for read-only fields, rather than the DisplayFor helper. This is so we can return the data to the controller when the HttpPost event fires (when the Save button on the page is pressed).

When MVC generates the HTML for a DisplayFor HTML helper, it renders the element as simple text.

For example, a table cell coded like this:

<td>
    @Html.DisplayFor(modelItem => item.OrderID)
</td>


would render like this:


<td>
    490dabe1-1570-473a-8331-5f32333b2635
</td> 


That's just straight text, so there's no way for MVC to bind it to the model.

But a table cell for a display-only text field coded like this: 


<td>
    @Html.TextBoxFor(x => Model.Orders[i].OrderID, new { @class = "form-control", @readonly = "readonly" })
</td>


would render like this: 


<td>
    <input name="Orders[0].OrderID" class="form-control" id="Orders_0__OrderID" type="text" readonly="readonly" value="490dabe1-1570-473a-8331-5f32333b2635" data-val-required="The Order Number field is required." data-val="true">
</td>


As an <input> field, this element will be passed back to the controller during the POST. Because it has a distinct id, the data in this readonly field can be bound to the view model just like data from an editable field. The values for CustomerID and OrderID give us the index values necessary to save the changes to the editable field, Description.

Note, also, that while we're displaying OrderDate as a readonly text field, and thereby returning it during the POST event, we don't have to. Only the index values necessary to save the changed data need to be returned in the POST event.

In our example, the OrderID field is displayed, but the CustomerID field is included in the form using the HiddenFor HtmlHelper. As coded, it looks like this: 


@Html.HiddenFor(x => Model.Orders[i].CustomerID)
``
And the HTML rendered by it looks like this:
```html
<input name="Orders[0].CustomerID" id="Orders_0__CustomerID" type="hidden" value="f8214550-69f6-4089-b58a-2de2d4ab01c8" data-val-required="The CustomerID field is required." data-val="true">


side from being hidden on the HTML served to the client, this is a full-featured data element that is bound to the view model received by the HttpPost controller action for the Index view. Using HiddenFor is a convenient way of keeping form layout simple while including all the data necessary for identifying the records to be updated during a POST.


The astute reader may have realized that in our case study the CustomerID field isn't necessary to save changes to an Order object. Because OrderID is a GUID it inherently provides a unique identifier for an individual order.


HttpPost Controller Actions

By using the repository design pattern we can keep our controller actions simple. In the case of the HttpPost action for the Order/Index view, all we need to do is validate CustomerOrdersListViewModel and pass it to the appropriate repository method.

Controllers\OrdersController.cs
[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Index(CustomerOrdersListViewModel model)
{
    if (ModelState.IsValid){
        if (model.Orders != null)
        {
            var repo = new OrdersRepository();
            repo.SaveOrders(model.Orders);
        }
        return View(model);
    }
    return new HttpStatusCodeResult(HttpStatusCode.BadRequest);
}
...

 Save Repository Method

The repository method associated with our order list view can also be simple. All we need to do is find the appropriate records in the Orders table and update them.

Data\Orders\OrdersRepository.cs 

...
public void SaveOrders(List<OrderDisplayViewModel> orders)
{
    if (orders != null)
    {
        using (var context = new ApplicationDbContext())
        {
            foreach (var order in orders)
            {
                var record = context.Orders.Find(order.OrderID);
                if (record != null)
                {
                    record.Description = order.Description;
                }
            }
            context.SaveChanges();
        }
    }
}
... 

No comments: