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
We'll begin with an overview of the case study entities and the principal views of the example solution used to prepare this guide.
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.
Next, we'll look at the code required to present information to the end user.
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
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 ofOrder
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 typeCountry
.
Order.cs
Order
entity is defined in a similar way:{
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 oneCustomer
, as reflected in the field for a singleCustomerID
and the navigation propertyCustomer
.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:
{
<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>
}
@model
directive from the beginning of Index.cshtml
:Now let's take a closer look at that view model.
Customers.ViewModels\CustomerDisplayViewModel.cs
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
<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.
{
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 Customers, Countries, and Regions tables and includes a property that is a collection of OrderDisplayViewModel
entities.
{
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).
{
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
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:
<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:
{
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:
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)
The
TextBoxFor
HtmlHelper is used, rather than the more general (and automagic)DisplayFor
andEditorFor
.The
OrderID
andOrderDate
fields are set asreadonly
using the HTMLclass
attribute, rather than usingDisplayFor
.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:
@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:
@Html.TextBoxFor(x => Model.Orders[i].OrderID, new { @class = "form-control", @readonly = "readonly" })
</td>
would render like this:
<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:
``
And the HTML rendered by it looks like this:
```html
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.
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.
No comments:
Post a Comment