How to Design a Form in Asp.net Using Table
Prologue
Developers usually stumble with default binding when handling the HttpPost
action. For example: if you have view, view model, controller actions, and data all wired-up and it looks like your form should be working, but your model is empty or partially empty when it hits the controller after you press "Save", you probably need to make some adjustments to the view to get the Razor engine to compose the HTML properly. Getting the code for the client side right is essential, and it fails silently when it's wrong.
If you just need help with that, jump to the section on Saving Data.
If you feel you would benefit from a more complete case study, read on.
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.
This guide will present an example of using ASP.NET MVC model binding to present and collect hierarchical form data in a hierarchical structure.
Skill Levels
It will be helpful to have an understanding of these topics at the indicated skill level:
Technology | Skill Level |
---|---|
ASP.NET | Intermediate |
C# | Intermediate |
Entity Framework | Beginner |
MVC | Intermediate |
MVVM | Beginner |
Scope
This guide will present an example of using ASP.NET MVC model binding to present and collect hierarchical form data in a hierarchical structure.
The example project and 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.
Case Study
The code and screenshots shown in this guide match the code and view layouts an example Visual Studio project. The solution can be forked or downloaded from a GitHub repository:
The sample solution implements the following:
- A multi-project solution with separate projects for
- Data layers (context and repositories)
- The Model-View ViewModel (MVVM) design pattern
- The repository design pattern
- The Entity Framework ORM with code-first development
Using the example solution you can follow along with each section below and experiment on your own.
Prerequisites
- A working knowledge of ASP.NET MVC
- An understanding of the Model View ViewModel (MVVM) design pattern
- Visual Studio ready to go
BlipBinding Case Study Solution
The case study implemented by the BlipBinding solution is a simple application for maintaining information about customers, their orders, and the items in their orders. The permanent data store for the application is a SQL Server database. The tables and their relationships are shown below:
BlipBinding database entity-relationship diagram
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. Each table and it's relationship to other tables is defined by a class in the Blip.Entities project of the BlipBinding solution. Let's look at the Customer
and Order
entities:
Blip.Entities\Customers\Customer.cs
1 using System ; 2 using System . Collections . Generic ; 3 using System . ComponentModel . DataAnnotations ; 4 using System . ComponentModel . DataAnnotations . Schema ; 5 using Blip . Entities . Geographies ; 6 using Blip . Entities . Orders ; 7 8 namespace Blip . Entities . Customers 9 { 10 public class Customer 11 { 12 public Customer ( ) 13 { 14 Orders = new HashSet < Order > ( ) ; 15 } 16 17 [ Key ] 18 [ Column ( Order = 0 ) ] 19 [ DatabaseGenerated ( DatabaseGeneratedOption . None ) ] 20 public Guid CustomerID { get ; set ; } 21 22 [ Required ] 23 [ MaxLength ( 128 ) ] 24 public string CustomerName { get ; set ; } 25 26 [ Required ] 27 [ MaxLength ( 3 ) ] 28 public string CountryIso3 { get ; set ; } 29 30 [ MaxLength ( 3 ) ] 31 public string RegionCode { get ; set ; } 32 33 public virtual Country Country { get ; set ; } 34 35 public virtual Region Region { get ; set ; } 36 37 public virtual ICollection < Order > Orders { get ; set ; } 38 } 39 }
csharp
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 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
The Order
entity is defined in a similar way:
1 using System ; 2 using System . Collections . Generic ; 3 using System . ComponentModel . DataAnnotations ; 4 using System . ComponentModel . DataAnnotations . Schema ; 5 using Blip . Entities . Customers ; 6 using Blip . Entities . Items ; 7 8 namespace Blip . Entities . Orders 9 { 10 public class Order 11 { 12 public Order ( ) 13 { 14 Items = new HashSet < Item > ( ) ; 15 } 16 17 [ Key ] 18 [ DatabaseGenerated ( DatabaseGeneratedOption . None ) ] 19 public Guid OrderID { get ; set ; } 20 21 [ Required ] 22 public Guid CustomerID { get ; set ; } 23 24 [ Required ] 25 public DateTime OrderDate { get ; set ; } 26 27 [ Required ] 28 [ MaxLength ( 128 ) ] 29 public string Description { get ; set ; } 30 31 public virtual ICollection < Item > Items { get ; set ; } 32 33 public virtual Customer Customer { get ; set ; } 34 } 35 }
csharp
-
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.
The data context and Entity Framework code-first migrations for the database are located in the Blip.Data project, along with the repository methods.
Presenting Data
The Blip.Web project in the BlipBinding solution is based on the standard .NET Framework MVC template, so the layout of the views is based on the default Bootstrap CSS styling and the _layout.cshtml
included with the template.
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:
BlipBinding solution, Blip.Web project, Customer/Index view
Using the Seed
method of Entity Framework code-first migrations, we have populated the database with a few customers, orders, and items. If you run the example solution the application will create the database and add the same records (the GUID's created by your computer for the key fields will be different than those shown).
Blip.Web\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:
1 @ foreach ( var item in Model ) 2 { 3 < tr > 4 < td > 5 @Html . DisplayFor ( modelItem => item . CustomerID ) 6 < / td > 7 < td > 8 @Html . DisplayFor ( modelItem => item . CustomerName ) 9 < / td > 10 < td > 11 @Html . DisplayFor ( modelItem => item . CountryName ) 12 < / td > 13 < td > 14 @Html . DisplayFor ( modelItem => item . RegionName ) 15 < / td > 16 < td > 17 @Html . ActionLink ( "Orders" , "Index" , "Order" , new { customerid = item . CustomerID } , null ) 18 < / td > 19 < / tr > 20 }
csharp
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
:
1 @model IEnumerable < Blip . Entities . Customers . ViewModels . CustomerDisplayViewModel >
csharp
Now let's take a closer look at that view model.
Blip.Entities\Customers.ViewModels\CustomerDisplayViewModel.cs
1 using System ; 2 using System . ComponentModel . DataAnnotations ; 3 4 namespace Blip . Entities . Customers . ViewModels 5 { 6 public class CustomerDisplayViewModel 7 { 8 [ Display ( Name = "Customer Number" ) ] 9 public Guid CustomerID { get ; set ; } 10 11 [ Display ( Name = "Customer Name" ) ] 12 public string CustomerName { get ; set ; } 13 14 [ Display ( Name = "Country" ) ] 15 public string CountryName { get ; set ; } 16 17 [ Display ( Name = "State / Province / Region" ) ] 18 public string RegionName { get ; set ; } 19 } 20 }
csharp
Note that we're using data annotations in the view model to provide the field labels to display on the view.
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.
BlipBinding solution, Blip.Web project, 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.
Blip.Web\Views\Order\Index.cshtml
For the top-tier data, pertaining to the customer, the fields are composed in a very standard way:
1 < div class = "form-group" > 2 @Html . LabelFor ( model => model . CustomerName , new { @ class = "control-label col-md-2" } ) 3 < div class = "col-md-10" > 4 @Html . EditorFor ( model => model . CustomerName , new { htmlAttributes = new { @ class = "form-control" , @ readonly = "readonly" } } ) 5 < / div > 6 < / div >
csharp
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.
1 @ if ( Model . Orders != null ) 2 { 3 for ( var i = 0 ; i < Model . Orders . Count ( ) ; i ++ ) 4 { 5 < tr > 6 @Html . HiddenFor ( x => Model . Orders [ i ] . CustomerID ) 7 < td > 8 @Html . TextBoxFor ( x => Model . Orders [ i ] . OrderID , new { @ class = "form-control" , @ readonly = "readonly" } ) 9 < / td > 10 < td > 11 @Html . TextBoxFor ( x => Model . Orders [ i ] . OrderDate , new { @ class = "form-control" , @ readonly = "readonly" } ) 12 < / td > 13 < td > 14 @Html . TextBoxFor ( x => Model . Orders [ i ] . Description , new { @ class = "form-control" } ) 15 < / td > 16 < / tr > 17 } 18 }
csharp
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.
Blip.Entities\Orders.ViewModels\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.
1 using System ; 2 using System . Collections . Generic ; 3 using System . ComponentModel . DataAnnotations ; 4 5 namespace Blip . Entities . Orders . ViewModels 6 { 7 public class CustomerOrdersListViewModel 8 { 9 [ Display ( Name = "Customer Number" ) ] 10 public Guid CustomerID { get ; set ; } 11 12 [ Display ( Name = "Customer Name" ) ] 13 public string CustomerName { get ; set ; } 14 15 [ Display ( Name = "Country" ) ] 16 public string CountryNameEnglish { get ; set ; } 17 18 [ Display ( Name = "Region" ) ] 19 public string RegionNameEnglish { get ; set ; } 20 21 public List < OrderDisplayViewModel > Orders { get ; set ; } 22 } 23 }
csharp
Let's take a look at the class that composes the Orders
collection.
Blip.Entities\Orders.ViewModels\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).
1 using System ; 2 using System . ComponentModel . DataAnnotations ; 3 4 namespace Blip . Entities . Orders . ViewModels 5 { 6 public class OrderDisplayViewModel 7 { 8 public Guid CustomerID { get ; set ; } 9 10 [ Display ( Name = "Order Number" ) ] 11 public Guid OrderID { get ; set ; } 12 13 [ Display ( Name = "Order Date" ) ] 14 public DateTime OrderDate { get ; set ; } 15 16 [ Display ( Name = "PO / Description" ) ] 17 public string Description { get ; set ; } 18 } 19 }
csharp
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.
Blip.Web\Controllers\OrderController.cs
1 using System ; 2 using System . Net ; 3 using System . Web . Mvc ; 4 using Blip . Data . Orders ; 5 using Blip . Entities . Orders . ViewModels ; 6 7 namespace BlipProjects . Controllers 8 { 9 public class OrderController : Controller 10 { 11 // GET: Order 12 public ActionResult Index ( string customerid ) 13 { 14 if ( ! String . IsNullOrWhiteSpace ( customerid ) ) 15 { 16 if ( Guid . TryParse ( customerid , out Guid customerId ) ) 17 { 18 var repo = new OrdersRepository ( ) ; 19 var model = repo . GetCustomerOrdersDisplay ( customerId ) ; 20 return View ( model ) ; 21 } 22 } 23 return new HttpStatusCodeResult ( HttpStatusCode . BadRequest ) ; 24 } 25 .. .
csharp
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:
1 < table class = " table " > 2 < tr > 3 < th > 4 @Html.DisplayNameFor(model => model.Orders[0].OrderID) 5 </ th > 6 < th > 7 @Html.DisplayNameFor(model => model.Orders[0].OrderDate) 8 </ th > 9 < th > 10 @Html.DisplayNameFor(model => model.Orders[0].Description) 11 </ th > 12 </ tr > 13 @if (Model.Orders != null) 14 { 15 foreach (var order in Model.Orders) 16 { 17 < tr > 18 @Html.HiddenFor(x => order.CustomerID) 19 < td > 20 @Html.DisplayFor(x => order.OrderID) 21 </ td > 22 < td > 23 @Html.DisplayFor(x => order.OrderDate) 24 </ td > 25 < td > 26 @Html.EditorFor(x => order.Description) 27 </ td > 28 </ tr > 29 } 30 } 31 </ table >
html
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:
1 @ if ( Model . Orders != null ) 2 { 3 for ( var i = 0 ; i < Model . Orders . Count ( ) ; i ++ ) 4 { 5 < tr > 6 @Html . HiddenFor ( x => Model . Orders [ i ] . CustomerID ) 7 < td > 8 @Html . TextBoxFor ( x => Model . Orders [ i ] . OrderID , new { @ class = "form-control" , @ readonly = "readonly" } ) 9 < / td > 10 < td > 11 @Html . TextBoxFor ( x => Model . Orders [ i ] . OrderDate , new { @ class = "form-control" , @ readonly = "readonly" } ) 12 < / td > 13 < td > 14 @Html . TextBoxFor ( x => Model . Orders [ i ] . Description , new { @ class = "form-control" } ) 15 < / td > 16 < / tr > 17 } 18 }
csharp
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:
1 < td > 2 @Html.DisplayFor(modelItem => item.OrderID) 3 </ td >
html
1 < td > 2 490dabe1-1570-473a-8331-5f32333b2635 3 </ td >
html
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:
1 < td > 2 @Html.TextBoxFor(x => Model.Orders[i].OrderID, new { @class = "form-control", @readonly = "readonly" }) 3 </ td >
html
1 < td > 2 < 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 " > 3 </ td >
html
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:
1 @Html.HiddenFor(x => Model.Orders[i].CustomerID) 2`` 3 4And the HTML rendered by it looks like this: 5 6```html 7 < 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 " >
html
Aside 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 anOrder
object. BecauseOrderID
is aGUID
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.
Blip.Web\Controllers\OrdersController.cs
1 .. . 2 [ HttpPost ] 3 [ ValidateAntiForgeryToken ] 4 public ActionResult Index ( CustomerOrdersListViewModel model ) 5 { 6 if ( ModelState . IsValid ) 7 { 8 if ( model . Orders != null ) 9 { 10 var repo = new OrdersRepository ( ) ; 11 repo . SaveOrders ( model . Orders ) ; 12 } 13 return View ( model ) ; 14 } 15 return new HttpStatusCodeResult ( HttpStatusCode . BadRequest ) ; 16 } 17 .. .
csharp
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.
Blip.Data\Orders\OrdersRepository.cs
1 .. . 2 public void SaveOrders ( List < OrderDisplayViewModel > orders ) 3 { 4 if ( orders != null ) 5 { 6 using ( var context = new ApplicationDbContext ( ) ) 7 { 8 foreach ( var order in orders ) 9 { 10 var record = context . Orders . Find ( order . OrderID ) ; 11 if ( record != null ) 12 { 13 record . Description = order . Description ; 14 } 15 } 16 context . SaveChanges ( ) ; 17 } 18 } 19 } 20 .. .
csharp
MoreIinformation
If you want to dive deeper into the topics discussed in this guide, the following is a curated list of resources.
Related PluralSight Training Classes
PluralSight offers a number of courses on the topics mentioned in this guide. The following are some suggestions organized by technology:
Case Study Code on GitHub
The complete Visual Studio solutions described in this guide are available on GitHub:
You can fork the projects, run the code, and experiment on your own.
Note that the sample project is not intended to be a real-life case study or production code; it exists to illustrate the topics covered in this guide.
Other Resources
Disclaimer: Pluralsight and the author of this Guide are not responsible for the content, accuracy, or availability of 3rd party resources.
How to Design a Form in Asp.net Using Table
Source: https://www.pluralsight.com/guides/asp.net-mvc-getting-default-data-binding-right-for-hierarchical-views
0 Response to "How to Design a Form in Asp.net Using Table"
Enregistrer un commentaire