In the articles Use the MVVM Design Pattern in MVC Core: Part 1 and Use the MVVM Design Pattern in MVC Core: Part 2 earlier this year in CODE Magazine, you created an MVC Core application using the Model-View-View-Model (MVVM) design pattern. In those articles, you created a Web application using VS Code and a few Class Library projects in which to put your entity, repository, and view model classes. With the product table from the AdventureWorksLT database, you created a page to display product data in an HTML table. In addition, you wrote code to search for products based on user input.
In this final part of this article series, you build a product detail page to add and edit product data. You add a delete button on the list page to remove a product from the database. You learn to validate product data and display validation messages to the user. Finally, you learn to cancel out of an add or edit page, bypassing validation and returning to the product list page.
Create Product Detail Page
At some point, your user will want to add or edit the details of one product. Because they can't see all the product fields on the list page, create a detail page as shown in Figure 1. Add a new partial page named _ProductDetail.cshtml
under the \Views\Product
folder. In this new partial page, add the code shown in Listing 1.
Listing 1: The product detail page lets your user see all of the fields to add or edit.
@model MVVMEntityLayer.Product
<input asp-for="ProductID" type="hidden" />
<div class="form-group">
<label asp-for="Name" class="control-label"></label>
<input asp-for="Name" class="form-control" />
</div>
<div class="form-group">
<label asp-for="ProductNumber" class="control-label"></label>
<input asp-for="ProductNumber" class="form-control" />
</div>
<div class="form-group">
<label asp-for="Color" class="control-label"></label>
<input asp-for="Color" class="form-control" />
</div>
<div class="form-group">
<label asp-for="StandardCost" class="control-label"></label>
<input asp-for="StandardCost" class="form-control" />
</div>
<div class="form-group">
<label asp-for="ListPrice" class="control-label"></label>
<input asp-for="ListPrice" class="form-control" />
</div>
<div class="form-group">
<label asp-for="Size" class="control-label"></label>
<input asp-for="Size" class="form-control" />
</div>
<div class="form-group">
<label asp-for="Weight" class="control-label"></label>
<input asp-for="Weight" class="form-control" />
</div>
<div class="form-group">
<label asp-for="SellStartDate" class="control-label"></label>
<input asp-for="SellStartDate" class="form-control" />
</div>
<div class="form-group">
<label asp-for="SellEndDate" class="control-label"></label>
<input asp-for="SellEndDate" class="form-control" />
</div>
<div class="form-group">
<label asp-for="DiscontinuedDate" class="control-label"></label>
<input asp-for="DiscontinuedDate" class="form-control" />
</div>
<div class="form-group">
<button data-custom-cmd="save" class="btn btn-primary">Save</button>
<button formnovalidate="formnovalidate" class="btn btn-info">Cancel</button>
</div>
To get to the new product detail page, add an Edit button in each row of the product table. Open the _ProductList.cshtml
partial page and add a new table header element before the other <th>
elements in the <thead>
area.
<th>Edit</th>
Within the <tbody>
area, add a new table detail element before all the other <td>
elements, as shown below. Notice the two data-
attributes that are added to the anchor tag. The data-custom-cmd
attribute has a value of edit
, which is sent to the view model to place the user into edit mode. The second attribute, data-custom-arg
, is filled in with the primary key value of the product to edit. This key value is used by the Entity Framework to lookup the details of the product and fill in a single product object to which the product detail page is bound.
<td><a href="#" data-custom-cmd="edit" data-custom-arg="@item.ProductID" class="btn btn-primary">Edit</a></td>
Modify Repository
To retrieve a single product object based on the primary key value, add a new method to your repository classes. Open the IProductRepository.cs
interface class and add a new method stub. This method accepts a single integer value for the primary key of the product to retrieve.
Product Get(int id);
Next, open the ProductRepository.cs
class and add a new method to implement the Get(id)
method in the interface. The code for this method uses the LINQ FirstOrDefault()
method to locate the product ID in the product table. If the value is found, a Product
object is returned from this method. If the value is not found, a null
value is returned.
public Product Get(int id)
{
return DbContext.Products.FirstOrDefault(p => p.ProductID == id);
}
Modify the View Model Classes
Two new properties need to be added to your view model classes to support adding or editing a single product object. First, open the ViewModelBase.cs
class and add a Boolean value to tell the view to display the detail page you just added.
public bool IsDetailVisible { get; set; }
Next, open the ProductViewModel.cs
class and add a new property to hold the instance of the product class returned from the Get(id)
method in the repository class.
public Product SelectedProduct { get; set; }
The product ID is posted back from the view to the view model class using the EventArgument
property. Add a new method named LoadProduct()
to which you pass this product ID. This method calls the new Get(id)
method in the Repository
class to retrieve the product selected by the user.
protected virtual void LoadProduct(int id)
{
if (Repository == null)
{
throw new ApplicationException("Must set the Repository property.");
}
else
{
SelectedProduct = Repository.Get(id);
}
}
Remember that in the controller class, the HandleRequest()
method is always called from the post back method. In this method, add a new case statement to check for the “edit” command passed in via the data-custom-cmd
attribute. Call the LoadProduct()
method passing in the EventArgument
property that was set from the data-custom-arg
attribute. If the SelectedProduct
property is set to a non-null value, set the IsDetailVisible
property to true
. The SelectedProduct
property is only set to true
if a product was found in the Get(id)
method of the Repository
class.
case "edit":
LoadProduct(Convert.ToInt32(EventArgument));
IsDetailVisible = (SelectedProduct != null);
break;
Modify Views to Display Detail Page
Just like you did for the other properties in the ViewModelBase
class, you need to store the IsDetailVisible
property in a hidden input field so it can be posted back to the view model. Open the \Views\Shared\_StandardViewModelHidden.cshtml
file and add the following line of code.
<input type="hidden" asp-for="IsDetailVisible" />
Use the IsDetailVisible
property on the Products.cshtml
to either show the product detail or to show the search and product list partial pages. Open the Products.cshtml
file and modify the code within the <form>
tag to look like the following code snippet.
<form method="post">
<partial name="~/Views/Shared/_StandardViewModelHidden.cshtml" />
@if (Model.IsDetailVisible)
{
<partial name="_ProductDetail" for="SelectedProduct" />
}
else
{
<partial name="_ProductSearch.cshtml" />
<partial name="_ProductList" />
}
</form>
In the _ProductDetail.cshtm
l page, set the model as an instance of MVVMEntityLayer.Product
class. Use the for
attribute on the <partial>
element to pass in the SelectedProduct
property of the product view model to the product detail partial page. You don't need all of the properties of the product view model in the product detail page, so the for
attribute allows you to just pass what you need.
Try It Out
Now that you've created the detail page and added the appropriate properties to the view model, it's time to see the detail data. Run the application and click on an Edit button in your product list. If you've done everything correctly, you should be taken to the detail page and the product's data should appear.
Using Data Annotations
When you ask a user to input data, you must ensure that they enter the correct values. Typically, you want to validate the user's input both on the client-side and the server-side. Use client-side validation to make the Web page responsive and eliminate the need for a round-trip to the server just to check business rules. Use server-side validation to ensure that the user didn't bypass the client-side validation by turning off JavaScript.
When you first created the Product class, you added a few data annotations, such as [DataType]
and [Column]
. These annotations help MVC map the data coming from the database table to the appropriate properties. They also tell MVC what data type to put into the <input>
element for the bound property. In addition, they help validate the values posted back by the user.
Add Additional Data Annotations to Product Class
It's now time to add additional annotations to help with validation. Open the Product.cs
file and make the file look like Listing 2. In this listing, you see additional annotations, such as the [Required]
attribute. This adds client- and server-side code to ensure that the user has entered a value for the property this annotation decorates. The [StringLength]
attribute adds code to ensure that the user hasn't entered too many characters into an input field. Optionally, you can add a MinimumLength
property to ensure that they enter at least a minimum amount of characters.
Listing 2: Add data annotations to help with labels and validation.
public partial class Product
{
public int? ProductID { get; set; }
[Display(Name = "Product Name")]
[Required]
[StringLength(50, MinimumLength = 4)]
public string Name { get; set; }
[Display(Name = "Product Number")]
[StringLength(25, MinimumLength = 3)]
public string ProductNumber { get; set; }
[StringLength(15, MinimumLength = 3)]
public string Color { get; set; }
[Display(Name = "Cost")]
[Required]
[DataType(DataType.Currency)]
[Range(0.01, 9999)]
[Column(TypeName = "decimal(18, 2)")]
public decimal StandardCost { get; set; }
[Display(Name = "Price")]
[Required]
[DataType(DataType.Currency)]
[Range(0.01, 9999)]
[Column(TypeName = "decimal(18, 2)")]
public decimal ListPrice { get; set; }
[StringLength(5)]
public string Size { get; set; }
[Column(TypeName = "decimal(8, 2)")]
[Range(0.5, 2000)]
public decimal? Weight { get; set; }
[Display(Name = "Start Selling Date")]
[Required]
[DataType(DataType.Date)]
public DateTime SellStartDate { get; set; }
[Display(Name = "End Selling Date")]
[DataType(DataType.Date)]
public DateTime? SellEndDate { get; set; }
[Display(Name = "Date Discontinued")]
[DataType(DataType.Date)]
public DateTime? DiscontinuedDate { get; set; }
}
Look at Figure 1 and notice that the labels for the input fields are simply the property names. These are not good labels for your user, so add the [Display]
attribute and set the Name property to what you want displayed to the user for each property. The [Range]
attribute allows you to set a minimum and maximum range of values that the user is allowed to enter for a specific property. There are many other data annotations that you can apply to properties of your entity classes. Do a search for data annotations on Google to get a complete list.
Display a Summary of Validation Messages
Each of the data annotations applied to the Product class properties generates an error message if the data doesn't pass the checks. You need to display these messages to the user. These messages may be displayed all in one area, as shown in Figure 2.
Or, they may be displayed near each field that's in error, as shown in Figure 3.
To display the validation messages all in one location, open the _ProductDetail.cshtml
file and immediately before the first <input>
field, add the following <div>
element.
<div asp-validation-summary="All" class="text-danger"></div>
For any client-side validation to work and for the messages be displayed, you must include the jQuery validation script files. These files are included in the project when you created the MVC Core project. They're in the \Views\Shared\_ValidationScriptsPartial.cshtml
file. All you need to do is to include them in your product page. Open the Products.cshtml
file and add a <partial>
element just before the <script>
tag.
@section Scripts
{
<partial name="_ValidationScriptsPartial"/>
<script> ? </script>
}
Try It Out
Run the application and click on the Edit button next to one of the products. Delete the data in the Product Name field. Set the Cost and the Price fields to a value of minus one (-1) and click on the Save button. You should now see the error messages appear at the top of the page just like that shown in Figure 2.
Display Individual Validation Messages
Instead of putting all validation messages into a single area at the top of the page, you may place each individual message close to the input field that is in error. Add a <span>
below the input for the Name
property that looks like the following:
<div class="form-group">
<label asp-for="Name" class="control-label"></label>
<input asp-for="Name" class="form-control"/>
<span asp-validation-for="Name" class="text-danger"/>
</div>
Following the same pattern as above, add <span>
classes for each input field on this page that has a data annotation. Add the following <span>
elements at the end of each form-group
for the appropriate input field.
<span asp-validation-for="ProductNumber" class="text-danger" />
<span asp-validation-for="Color" class="text-danger" />
<span asp-validation-for="StandardCost" class="text-danger" />
<span asp-validation-for="ListPrice" class="text-danger" />
<span asp-validation-for="Size" class="text-danger" />
<span asp-validation-for="Weight" class="text-danger" />
<span asp-validation-for="SellStartDate" class="text-danger" />
<span asp-validation-for="SellEndDate" class="text-danger" />
<span asp-validation-for="DiscontinuedDate" class="text-danger" />
Try It Out
Run the application and click on the Edit button next to one of the products. Delete the data in the Product Name field and press the Tab key. You should immediately see a message appear below the product name field. Next, enter just two characters in the Product Number field and press the Tab key. Again, you should see a message appear immediately below the field. You can continue entering bad data in each field to see messages appear just below each field. Your screen might look something like that shown in Figure 3.
Checking the Data Server-Side
If the user has turned off JavaScript, or you have a hacker who is trying to post back to your controller with bad data, you need to ensure that you don't let bad data get into your database. Open the ProductController.cs file and locate the Post method. Wrap up all the code, except the return
statement, into an if
statement that checks the ModelState.IsValid
property.
[HttpPost]
public IActionResult Products(ProductViewModel vm)
{
if (ModelState.IsValid)
{
vm.Pager.PageSize = 5;
vm.Repository = _repo;
vm.AllProducts = JsonConvert.DeserializeObject<List<Product>>(HttpContext.Session.GetString("Products"));
vm.HandleRequest();
ModelState.Clear();
}
return View(vm);
}
When you post the data back, MVC automatically runs code to check all the data in your bound fields' data annotations. If any of the checks fail, the IsValid
property is set to false
. All you need to do is to check this value and bypass any code that would otherwise save the data. All the validation messages are now displayed just like they were with the client-side validation.
Try It Out
To try out the server-side validation, you need to comment out the client-side jQuery validation code. Open the Products.cshtml file and comment out the <partial>
tag you entered earlier.
@* <partial name="_ValidationScriptsPartial" /> *@
Run the application and click on the Edit button next to one of the products. Delete the data in the Product Name field. Set the Cost and the Price fields to a value of minus one (-1) and click on the Save button. You should now see the error messages appear at the top of the page just like that shown in Figure 2.
Cancel Button
If the user clicks the Edit button on the wrong product, they need a way to cancel and return to the product list page. They can either hit the Back button on the browser, or the Cancel button at the bottom of the product detail page. You need to add some JavaScript code to set the EventCommand
property to cancel. Setting this property informs the view model class that it needs to go back to the list page. Open the Products.cshtml
file and add the following function immediately before the close </script>
tag.
function cancel() {
// Fill in command to post back
$("#EventCommand").val("cancel");
$("#EventArgument").val("");
return true;
}
Next, open the _ProductDetail.cshtml
file and add an on-click
event to the Cancel button as shown below. When the user clicks on the Cancel button, the function cancel()
is called and the EventCommand
property is set to “cancel” and the EventArgument
property is set to an empty string.
<button formnovalidate="formnovalidate" class="btn btn-info" onclick="cancel();">Cancel</button>
You now need to handle the “cancel” code in your controller and your view model. Open the ProductViewModel.cs
file and locate the HandleRequest()
method. Add another case statement for the “cancel” mode. Because you were just in the “edit” mode, the Products collection is not filled in. Call the SortProducts()
and the PageProducts()
methods to fill in the Products collection so that the list of products can be displayed. Setting the IsDetailVisible property to a false value causes the search and list partial pages to be displayed.
case "cancel":
SortProducts();
PageProducts();
IsDetailVisible = false;
break;
Open the ProductController.cs and in the Post method, modify the If statement you added earlier to look like the following snippet. Because you're cancelling out of an add or edit, you don't want the validation rules to be checked; instead you want the code to go right into the `HandleRequest()`` method in the view model.
[HttpPost]
public IActionResult Products(ProductViewModel vm)
{
if (ModelState.IsValid || vm.EventCommand == "cancel")
{
// OTHER CODE HERE
}
return View(vm);
}
Try It Out
Run the application and click the Edit button on any product in the list. Click the Cancel button and you should be taken back to the list of products.
Add a Product
You have written code to edit the data of an existing product. Your user also needs the ability to add a new product. You use the same detail page for adding as you do for editing product data. The difference between an add and an edit is that when you click an Add button, you want the detail screen to display blank fields ready to accept a new product record.
To create an empty instance of a Product object, create a new method in the Repository classes that can be called from the view model. Open the IProductRepository.cs
file and add a new interface method named CreateEmpty()
.
Product CreateEmpty();
Open the ProductRepository.cs
file and implement the CreateEmpty()
method to create a blank Product object. You may default any of the product properties to any values you want. In the code below, I set the StandardCost
to one and the ListPrice
to two. The SellStartDate
property is set to the current date and time. These default values could also be read in from a configuration file if you want.
public virtual Product CreateEmpty() {
return new Product
{
ProductID = null,
Name = string.Empty,
ProductNumber = string.Empty,
Color = string.Empty,
StandardCost = 1,
ListPrice = 2,
Size = string.Empty,
Weight = 0M,
SellStartDate = DateTime.Now,
SellEndDate = null,
DiscontinuedDate = null,
};
}
Now that you have a method to create a blank Product object, you need to call that method from the ProductViewModel
class and set the SelectedProduct
property to the empty Product
object. Open the ProductViewModel.cs
file and add a new method named CreateEmptyProduct()
, as shown in the following code snippet.
public virtual void CreateEmptyProduct()
{
SelectedProduct = Repository.CreateEmpty();
}
Add a new case statement in the HandleRequest()
method so when the add
command is sent in the EventCommand
property, the CreateEmptyProduct()
method is called and the IsDetailVisible
property is set to true
.
case "add":
CreateEmptyProduct();
IsDetailVisible = true;
break;
Open the _ProductSearch.cshtml
file and create the Add button immediately after the Search button.
<button type="button" class="btn btn-info" data-custom-cmd="add">Add New Product</button>
Try It Out
Run the application and click on the Add button. You should now see the detail page with blank data in each input field. The Save button does NOT yet work, but that's what you're going to code next.
Save the Product Data
Whether the user performs an add or an edit on the product data, the product detail page is displayed. When the user clicks on the Save button, the view model attempts to either insert or update the Product table with the data input by the user. Open the IProductRepository.cs
file and add two new interface methods to support either adding or updating product data.
Product Add(Product entity);
Product Update(Product entity);
Open the ProductRepository.cs
file and add a method to insert a Product
object into the database using the Entity Framework.
public virtual Product Add(Product entity)
{
// Add new entity to Products DbSet
DbContext.Products.Add(entity);
// Save changes in database
DbContext.SaveChanges();
return entity;
}
The above code adds the new Product
entity object to the Products DbSet<>
collection in the AdventureWorks DbContext
object. Calling the SaveChanges()
method on the DbContext
object creates an INSERT
statement from the values in the entity parameter and sends that statement to the database for execution.
Add another new method in the ProductRepository
class to update the product data. In the Update()
method, a Product
object is passed to the Update()
method on the Products DbSet<>
collection. When the SaveChanges()
method is called on the DbContext
object, an Update statement is created from the values in the updated Product object and that statement is sent to the database for execution.
public virtual Product Update(Product entity)
{
// Update entity in Products DbSet
DbContext.Products.Update(entity);
// Save changes in database
DbContext.SaveChanges();
return entity;
}
Open the ProductViewModel.cs
file and add a Save()
method. The Save()
method checks the value in the ProductID
property of the Product
object to see if it's null or not. If it isn't null, you?re editing a product and thus you call the Update()
method on the Repository
object. If the ProductID
property is a null, then it?s a new product object and you call the Add()
method on the Repository object.
public virtual bool Save()
{
if (SelectedProduct.ProductID.HasValue)
{
// Editing an existing product
Repository.Update(SelectedProduct);
}
else
{
// Adding a new product
Repository.Add(SelectedProduct);
}
return true;
}
Locate the HandleRequest()
method and add a new case statement to call the Save()
method when the user clicks on the Save button. If the Save()
is successful, set the AllProducts
property to a null value. This ensures that all records in the Product table are reread from the database and brought back into memory for display in the HTML table.
case "save":
if (Save())
{
// Force a refresh of the data
AllProducts = null;
SortProducts();
PageProducts();
IsDetailVisible = false;
}
break;
Try It Out
Run the application, click on the Add button and add some good data to the product detail page. Set the Product Name field to the value “A New Product”. Click the Save button and you should see the new record appear in the table. Click on the Edit button of the new product you just added and modify some of the fields. Click on the Save button and you should see the changed values appear in the table.
Delete a Product
You?ve given the user the ability to add and edit products in the table. They also need the ability to delete a product. Open the _ProductList.cshtml file and add a new table header at the end of the table columns immediately before the closing </tr>
and </thead>
elements.
<th>Delete</th>
In the <tbody>
element, after all the other <td>
elements, add a new table detail, as shown in the code snippet below.
<td><a href="#" onclick="deleteProduct(@item.ProductID);" class="btn btn-secondary">Delete</a></td>
The onclick of the anchor tag you just added calls a JavaScript function named delete()
. This function needs to be added in the <script>
tag in the Products.cshtml
file. Open the Products.cshtml
file and add the Delete
function, as shown in the following code snippet.
function deleteProduct(id) {
if(confirm("Delete this product?")) {
// Fill in command to post back to view model
$("#EventCommand").val("delete");
$("#EventArgument").val(id);
// Submit form with hidden values filled in
$("form").submit();
return true;
} else {
return false;
}
}
The code in the JavaScript function asks the user if they really want to delete the current product. If they cancel the dialog, a false value is returned from this function that cancels the delete action. If the user confirms the dialog, the command “delete” is added into the EventCommand
property and the ProductID
of the product to delete is added into the EventArgument
property. The form is then submitted and the two values are mapped into the view model.
You now need to add the functionality in your C# code to delete a product. Open the IProductRepository.cs
file and add an interface method named `Delete()``.
bool Delete(int id);
Open the ProductRepository.cs
file and implement the Delete()
method. The following code passes in the ProductID
to delete and the Find()
method is called on the Products DbSet<>
collection to locate that product. The product object is removed from the collection and the SaveChanges()
method is called on the DbContext
object. This creates a Delete statement and submits that statement to the back-end for processing.
public virtual bool Delete(int id)
{
// Locate entity to delete
DbContext.Products.Remove(DbContext.Products.Find(id));
// Save changes in database
DbContext.SaveChanges();
return true;
}
Open the ProductViewModel.cs
file and add a DeleteProduct()
method to call the Delete()
method in the Repository
object.
public virtual bool DeleteProduct(int id)
{
Repository.Delete(id);
// Clear the EventArgument so it does not interfere with paging
EventArgument = string.Empty;
return true;
}
Add a new case statement in the HandleRequest()
method to handle the “delete” command. If the DeleteProduct()
method returns true
, refresh all of the product data again so the complete product list can be redisplayed. I haven't added any exception handling in the code in this article to keep it simple. In a real-world application, add the appropriate exception handling code, and return a true or false from the Delete()
and Save()
methods as appropriate.
case "delete":
if (DeleteProduct(Convert.ToInt32(EventArgument)))
{
// Force a refresh of the data
AllProducts = null;
SortProducts();
PageProducts();
}
break;
Try It Out
Run the application and click on the Delete button for the product you added in the previous section of this article. Clicking on the Delete button causes your browser to display a dialog to you with OK and Cancel buttons. Click on the OK button and you should see the product disappear from your product list table.
Summary
Congratulations on finishing this three-part series on using the MVVM design pattern in MVC Core. If you have followed along and input all of the code as you read, you have really learned a lot about how to design code that you can reuse in any other UI technology. This same set of Class Library projects can be used in WPF, Web Forms, MVC, and UWP. Throughout this series of articles, you learned to display product data in an HTML table and search for product data. You learned to sort and page data. You then learned to add, edit, and delete product data. Most importantly, you learned to do all this functionality using a view model class. By using a view model class, you make your code highly reusable and easy to test.