In the articles Use the MVVM Design Pattern in MVC Core: Part 1 and 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>
Figure 1: The product detail page allows you to add or edit data.
Figure 1: The product detail page allows you to add or edit data.

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.cshtml 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.

Figure 2: The validation error messages can be displayed all in one area if you desire.
Figure 2: The validation error messages can be displayed all in one area if you desire.

Or, they may be displayed near each field that's in error, as shown in Figure 3.

Figure 3: The validation error messages can be displayed near each field in error.
Figure 3: The validation error messages can be displayed near each field in error.

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.