This article continues my series on how to enhance the user experience (UX) of your MVC applications, and how to make them faster. In the first three articles, entitled Enhance Your MVC Applications Using JavaScript and jQuery: Part 1, 2, and 3, you learned about the starting MVC application that was coded using all server-side C#. You then added JavaScript and jQuery to avoid post-backs and enhance the UX in various ways. If you haven't already read these articles, I highly recommend that you read them to learn about the application you're enhancing in this series of articles.
In this article, you continue learning how to add more Ajax to your MVC application to further speed up your Web pages.
The Problem: Year Drop-Down is Pre-Filled on Shopping Page
When you click on the Shop menu, the shopping page is loaded (Figure 1) and with it, the Years drop-down is loaded even though the drop-down doesn't show because it's in a collapsed area on the page. If the user never opens this collapsed area, the years have been loaded for no reason. Instead of loading all the years right away, wait until the user expands the “Search by Year/Make/Model” collapsible area.
The Solution: Create Web API to Load Years
Open the ControllersApi\ShoppingApiController.cs
file and add a new method named GetYears()
, as shown in Listing 1. This API call is similar to the others you have already created. You create an instance of the ShoppingViewModel
class passing in the Product
and Vehicle
repository classes plus the Shopping Cart
object retrieved from Session. The GetYears()
method is invoked on the ShoppingViewModel
object to load the Years
property. The Years
property is returned as the payload from this API call.
Listing 1: Create a Web API to return a list of years for vehicles
[HttpGet(Name = "GetYears")]
public IActionResult GetYears()
{
IActionResult ret;
// Create view model
ShoppingViewModel vm = new(_repo, _vehicleRepo, UserSession.Cart);
// Get all years
vm.GetYears();
// Return all Years
ret = StatusCode(StatusCodes.Status200OK, vm.Years);
return ret;
}
Add Method to Page Controller
You now need to write the code to call this Web API method from the shopping cart page. Open the Views\Shopping\Index.cshtml
file and add a new method to the pageController
closure named getYears()
, as shown in Listing 2. This method sets up a function to respond to the Bootstrap show.bs.collapse
event. When a user expands the collapsible region, this function checks to see if the Year drop-down has any items in it. If it doesn't, a “Loading…” message is placed into the first item in the drop down temporarily. The message is set in an italic font so the user knows this is different from the data that eventually is loaded into this drop-down. Next, clear the Makes and Models drop-down lists if there are any loaded because these two drop-downs are dependent on the year selected. Call the GetYears()
API method using the $.get()
jQuery shorthand method. Once the data comes back, clear the Years drop-down, remove the italic style, and load the data into the drop-down.
Listing 2: Load the Years drop-down when the user expands the collapsible region
function getYears() {
$("#yearMakeModel").on("show.bs.collapse", function () {
// Check if years have already been loaded
if ($("#SearchEntity_Year option").length === 0) {
// Search for element just one time
let elem = $("#SearchEntity_Year");
// Set style on Year drop-down to italic
elem.attr("style", "font-style: italic;");
// Add option to drop-down to display Loading...
elem.append("<option>Loading...</option>");
// Clear makes drop-down
$("#SearchEntity_Make").empty();
// Clear models drop-down
$("#SearchEntity_Model").empty();
$.get("/api/ShoppingApi/GetYears", function (data) {
// Clear "Loading..." from drop-down
elem.empty();
// Remove italic style
elem.removeAttr("style");
// Load the years into drop-down
$(data).each(function () {
elem.append(`<option>${this}</option>`);
});
})
.fail(function (error) {
console.error(error);
});
}
});
}
Modify the return object to expose the new getYears()
method from the closure.
return {
"setSearchArea": setSearchArea,
"modifyCart": modifyCart,
"getMakes": getMakes,
"getModels": getModels,
"getYears": getYears
}
Call the new getYears()
method from the $(document).ready()
function, as shown in the following code snippet.
$(document).ready(function () {
// Load years when search area expands
pageController.getYears();
// Should search area should be collapsed?
pageController.setSearchArea();
});
Modify ShoppingController Class
Open the Controllers\Shopping\ShoppingController.cs
file and remove the following line of code from the Index()
method so the years aren't loaded automatically when the user goes into the Shopping page.
vm.GetYears();
Try It Out
Run the application and click on the Shop menu in the main navigation bar. Expand the “Search by Year/Make/Model” search area and, depending on how fast your browser is and how fast the API call is executed, you may see a “Loading…” message appear in the Year drop-down. Or you may just see that the years are now loaded once the area is expanded. If you want to verify that the years aren't being loaded when the page is loaded, use your browser's developer tools and check the DOM once the page is loaded and again after you expand the collapsible area. If you want to the see the “Loading…” message appear, go into the GetYears()
API method call, and add the following line of code just before creating the instance of the view model class.
System.Threading.Thread.Sleep(2000);
Run the application again, and when you expand the collapsible area, you should now see the message in the Years drop-down. Be sure to remove this line of code after you've seen the message.
The Problem: Categories Drop-Down is Pre-Filled on Shopping Page
Just like you don't want to automatically load the years in the collapsible search region, you want the same treatment for the categories drop-down in the other collapsible area on the shopping page. If the user never opens the “Search by Product Name/Category” region, there's no reason to pre-load the categories upon entering the shopping page.
The Solution: Create Web API Method to Load Categories
Open the ControllersApi\ShoppingApiController.cs
file and add a new method named GetCategories()
, as shown in Listing 3. This method creates an instance of the ShoppingViewModel
class passing in the Product
and Vehicle
repository classes plus the Shopping Cart
object retrieved from Session
. The GetCategories()
method is invoked on the ShoppingViewModel
object to load the Categories
property. The Categories
property is returned as the payload from this API call.
Listing 3: Create a method to load categories on-demand
[HttpGet(Name = "GetCategories")]
public IActionResult GetCategories()
{
IActionResult ret;
// Create view model
ShoppingViewModel vm = new(_repo, _vehicleRepo, UserSession.Cart);
// Get product categories
vm.GetCategories();
// Return all categories
ret = StatusCode(StatusCodes.Status200OK, vm.Categories);
return ret;
}
Modify ShoppingController Class
Open the Controllers\Shopping\ShoppingController.cs
file and remove the following line of code from the Index()
method so the categories aren't loaded automatically when the user goes into the Shopping page.
vm.GetCategories();
Modify the Page Controller Closure
Open the Views\Shopping\Index.cshtml
file and add a new method named getCategories()
, as shown in Listing 4, to the pageController
closure. This method sets up a function to respond to the Bootstrap show.bs.collapse
event. When a user expands the “Search by Product Name/Category” collapsible region, check to see if the Categories drop-down has any items in it. If it doesn't, load a string message “Loading…” as the first item in the drop down. Set the drop-down to an italic font so the user knows this is different from the data that will eventually be in this drop-down. Call the GetCategories()
API method using the $.get()
jQuery shorthand method. Once the data comes back, clear the categories drop-down, remove the italic style and load the category data into the drop-down.
Listing 4: Create a getCategories() method in your page controller to load categories
function getCategories() {
$("#nameCategory").on("show.bs.collapse", function () {
// Check if categories have already been loaded
if ($("#SearchEntity_Category option").length === 0) {
// Search for element just one time
let elem = $("#SearchEntity_Category");
// Set style on Category drop-down to italic
elem.attr("style", "font-style: italic;");
// Add option to drop-down to display Loading...
elem.append("<option>Loading...</option>");
$.get("/api/ShoppingApi/GetCategories",
function (data) {
// Clear "Loading..." from drop-down
elem.empty();
// Remove italic style
elem.removeAttr("style");
// Load the categories into drop-down
$(data).each(function () {
elem.append(`<option>${this}</option>`);
});
})
.fail(function (error) {
console.error(error);
});
}
});
}
Modify the return object to expose this new getCategories()
method from the closure.
return {
"setSearchArea": setSearchArea,
"modifyCart": modifyCart,
"getMakes": getMakes,
"getModels": getModels,
"getYears": getYears,
"getCategories": getCategories
}
Call the getCategories()
method from the $(document).ready()
function, as shown in the following code snippet.
$(document).ready(function () {
// Load years when search area expands
pageController.getYears();
// Load categories when search area expands
pageController.getCategories();
// Should search area should be collapsed?
pageController.setSearchArea();
});
Try It Out
Run the application and click on the Shop menu in the main navigation bar. Expand the “Search by Product Name/Category” collapsible region and, depending on how fast your browser is and how fast the API call is executed, you may see a “Loading…” message appear in the category drop-down. Or you may just see that the categories are now loaded once the area is expanded.
The Problem: Don't Allow Duplicate Promo Codes
On the Promotional Code maintenance page, a user is allowed to add, edit, and delete a promotional code. When adding a new code, ensure that a duplicate code isn't accidentally entered. Checking for a duplicate can be accomplished from the client side using a Web API call and creating a custom rule using the jQuery Validation library.
The Solution: Add Functionality to Check for Duplicate Promo Codes
Open the RepositoryClasses\PromoCodeRespository.cs
file in the PaulsAutoParts.DataLayer
project. Add a new method named DoesCodeExist()
, shown in the following code snippet, that uses the Entity Framework to check whether the code passed in already exists in the PromoCode table.
public bool DoesCodeExist(string code)
{
// Does Promo Code exist in table?
return _DbContext.PromoCodes.Any(p => p.PromotionalCode == code);
}
Modify the View Model Class
Open the PromoCodeViewModel.cs
file in the PaulsAutoParts.ViewModelLayer
project and add a new Using statement at the top of the file.
using PaulsAutoParts.DataLayer;
Add a new method named DoesCodeExist()
that calls the method you created in the PromoCodeRepository
class. This method checks to ensure that the Repository
property has been set in the view model object. If the PromoCodeRepository
class isn't set into the Repository
property, you won't be able to call the DoesCodeExist()
method. Otherwise, the DoesCodeExist()
method is called on the Repository
object and a true
or false
value is returned.
public bool DoesCodeExist(string code) {
bool ret;
if (Repository == null) {
throw new ApplicationException(
"Must set the Repository property.");
}
else {
ret = ((PromoCodeRepository)Repository).DoesCodeExist(code);
}
return ret;
}
Create New Promo Code API Controller
Just like you created a ShoppingApiController
class with Web API calls to work with the shopping cart page, you should also create a new API class to work with promotional codes. Right mouse-click on the ControllersApi folder and add an empty controller named PromoCodeApiController.cs
. Replace all the code in the default class with the code shown in Listing 5.
Listing 5: Create a new Web API controller to work with promotional codes
using Microsoft.AspNetCore.Mvc;
using PaulsAutoParts.AppClasses;
using PaulsAutoParts.Common;
using PaulsAutoParts.EntityLayer;
using PaulsAutoParts.ViewModelLayer;
namespace PaulsAutoParts.ControllersApi
{
[ApiController]
[Route("api/[controller]/[action]")]
public class PromoCodeApiController : AppController
{
#region Constructor
public PromoCodeApiController(AppSession session, IRepository<PromoCode, PromoCodeSearch> repo) : base(session)
{
_repo = repo;
}
#endregion
#region Private Fields
private readonly IRepository<PromoCode, PromoCodeSearch> _repo;
#endregion
#region DoesCodeExist Method
[HttpGet("{code}", Name = "DoesCodeExist")]
public JsonResult DoesCodeExist(string code)
{
JsonResult ret;
PromoCodeViewModel vm = new(_repo);
if (string.IsNullOrEmpty(code)) {
// Return a false value
ret = new JsonResult(false);
}
else {
// See if Code exists
ret = new JsonResult(!vm.DoesCodeExist(code));
}
return ret;
}
#endregion
}
}
Just like the other API controller classes you've created so far, an instance of the AppSession
class is injected into the constructor along with an instance of the PromoCode
repository class. There's a single Web API call in this class named DoesCodeExist()
. This method accepts the promotional code from the client side and then calls the DoesCodeExist()
method on the PromoCodeViewModel
class. This method returns true
if the code passed in exists and false
if it doesn't. When using the jQuery Validation library, it expects to get results back from a Web API call as JSON. This is why the DoesCodeExists()
Web API method call returns a JsonResult
as opposed to the more common IActionResult
.
Add to the pageController Closure
Open the Views\PromoCode\PromoCodeIndex.cshtml
file. In the pageController
closure, create a new method named addValidationRules()
, as shown in Listing 6. In this method, set up the validate()
method on the <form>
element on this page. For the PromotionalCode
input element, add a custom rule using the remote
property. The remote
property allows you to make a Web API call by returning a literal object from the function defined in the remote
property. The literal object returned needs to have a url
and a type
property. The Web API method called must return a true
or false
value in JSON format. If the return value is false
, the message in the remote
property under the messages
property is displayed.
Listing 6: Create a custom jQuery Validation rule to check for duplicate promotional codes
function addValidationRules() {
$("form").validate({
// The properties in the rules and messages
// objects are based on the name= attribute
// in the HTML, not the id attribute
rules: {
"SelectedEntity.PromotionalCode": {
required: true,
remote: function () {
return {
url: "/api/PromoCodeApi/DoesCodeExist/" +
$("#SelectedEntity_PromotionalCode").val(),
type: "get"
};
}
}
},
messages: {
"SelectedEntity.PromotionalCode": {
required: "Promotional Code must be filled in.",
remote: "Promotional Code already exists.
Please use a new Code"
}
}
});
}
Modify the return object in this pageController
closure to expose the addValidationRules()
method from the closure, as shown in the following code snippet.
return {
"formSubmit": formSubmit,
"setSearchValues": setSearchValues,
"setSearchArea": mainController.setSearchArea,
"isSearchFilledIn": mainController.isSearchFilledIn,
"addValidationRules": addValidationRules
}
Add the call to this new method within the $(document).ready()
function, as shown in the following code snippet.
$(document).ready(function () {
// Setup the form submit
pageController.formSubmit();
// Add jQuery validation rules
pageController.addValidationRules();
// Collapse search area or not?
pageController.setSearchValues();
pageController.setSearchArea();
});
Try It Out
Run the application and select Admin > Promotional Codes from the top menu. Click on the Add button and in the Promotional Code input field, enter TENPERCENT
and tab off the field. You should see the validation message appear just below the input field.
The Problem: Must Post-Back to Delete a Row in an HTML Table
On all of the HTML tables in this application, there's a Delete button in each row to remove the data from the table in SQL Server. These buttons all perform a post-back to do the removal and redisplay of the table on the page. Let's modify one of these to use a Web API call to avoid the post-back.
The Solution: Create a Web API to Perform the Delete
You're now going to modify the Customer Maintenance page to call a Web API to delete a customer. You're then going to write JavaScript code to remove the deleted row from the HTML table. After you've completed this one page, you can write all the other delete APIs needed for all other tables in this application.
Create a Customer Maintenance Web API Controller
Right mouse-click on the ControllersApi
folder and add a new controller named CustomerMaintApiController.cs
. Wipe out all the default template code and add the code shown in Listing 7 to this new file.
Listing 7: Create a Web API controller for deleting customers
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using PaulsAutoParts.AppClasses;
using PaulsAutoParts.Common;
using PaulsAutoParts.EntityLayer;
using PaulsAutoParts.ViewModelLayer;
namespace PaulsAutoParts.Controllers
{
[ApiController]
[Route("api/[controller]/[action]")]
public class CustomerMaintApiController : AppController
{
#region Constructor
public CustomerMaintApiController(AppSession session, IRepository<Customer, CustomerSearch> repo) : base(session)
{
_repo = repo;
}
#endregion
#region Private Fields
private readonly IRepository<Customer, CustomerSearch> _repo;
#endregion
#region Delete Method
[HttpDelete("{id}", Name = "Delete")]
public IActionResult Delete(int id)
{
// Create view model and pass in repository
CustomerViewModel vm = new(_repo);
// Set Common View Model Properties from Session
base.SetViewModelFromSession(vm, UserSession);
// Call method to delete a record
vm.Delete(id);
return StatusCode(StatusCodes.Status200OK, true);
}
#endregion
}
}
Just like the other API controller classes you've created so far, an instance of the AppSession
class is injected into the constructor along with an instance of the Customer
repository class. There's a single Web API call in this class named Delete()
. This method accepts an integer that's the customer ID to delete. An instance of the CustomerViewModel
class is created and the Delete()
method on this object is called to delete the customer. A status code of 200 is returned to signify that the call was successful.
Modify the Customer Maintenance Page Controller
Open the Views\CustomerMaint\CustomerMaintIndex.cshtml
file. Add a new method to the pageController
closure named deleteCustomer()
, as shown in Listing 8, to make a $.ajax()
call to delete the ID passed into this method.
Listing 8: The deleteCustomer() function calls a Web API to delete a customer
function deleteCustomer(id) {
if (confirm('Delete this Customer?')) {
$.ajax({
url: "/api/CustomerMaintApi/Delete/" + id,
type: "DELETE"
})
.done(function (data) {
if (data) {
// Remove the row from the table
$("#" + id).remove();
}
})
.fail(function (error) {
console.error(error);
});
}
}
Modify the return object to expose the new deleteCustomer()
method from the closure.
return {
"formSubmit": formSubmit,
"setSearchValues": setSearchValues,
"setSearchArea": mainController.setSearchArea,
"isSearchFilledIn": mainController.isSearchFilledIn,
"addValidationRules": addValidationRules,
"deleteCustomer": deleteCustomer
}
Modify the Customer List Partial Page
Open the Views\CustomerMaint\_CustomerMaintList.cshtml
file. Delete the last <td>
element in which the @Html.ActionLink("Delete", "Delete", ...)
method is located. Add a new <td>
element, shown in the following code snippet, where you deleted the old one. This new <td>
contains an <a>
tag that calls the pageController.deleteCustomer()
method you just created, passing in the value contained in the CustomerId
property.
<td>
<a class="btn btn-danger"
onclick="pageController.deleteCustomer(@item.CustomerId)">
Delete
</a>
</td>
The deleteCustomer()
method makes the Web API call and deletes the customer from the Customer table, however you still need to delete the row in the HTML table. To make this easy to do, modify the <tr>
within the @foreach()
loop and add an id
attribute to the row set to the CustomerId
, as shown in the following code.
@foreach (Customer item in Model.DataCollection) {
<tr id="@item.CustomerId">
// REST OF THE CODE HERE
</tr>
}
If you look at the code within the .done()
function in the deleteCustomer()
method, you can see if the data returned from the Web API is a true
value. If so, use jQuery to locate the row with the id=CustomerId and remove that row from the HTML table.
Try It Out
Run the application and select the Admin > Customers > Orders menu. Add a new Customer and click the Save button. Click on the Delete button next to your new customer and click the OK button when prompted. The customer should now be removed from the database and the row where the customer was located is also removed. Notice that you didn't see a page refresh as the delete was now accomplished without a post-back. If you want, you could duplicate this process with the other maintenance pages in this application.
The Problem: Delete Row from the Shopping Cart Table without Post-Back
Deleting a row from the shopping cart is just like deleting the row in the customer maintenance page, however you must also calculate a new total price for all the items left in the shopping cart.
The Solution: Delete Row and Recalculate Total
Open the Views\Cart\Index.cshtml
file and at the bottom of the page, you need to reorganize the code within the <script>
tag. First, add a pageController
closure and create an empty setCountdown()
method, as shown in the following code.
let pageController = (function () {
function setCountdown() {
}
// Expose public functions from closure
return {
"setCountdown": setCountdown
}
})();
Next, take all of the code from within the $(document).ready()
function and move it into the setCountdown()
method. Modify the $(document).ready()
function so it calls the setCountdown()
method in the pageController
closure.
$(document).ready(function () {
pageController.setCountdown();
});
Add Method to Delete a Cart Item
Add two new methods to the pageController
closure, as shown in Listing 9. The first one is named deleteCartItem()
and the second is named calculateNewTotal()
. The deleteCartItem()
method asks the user if they wish to delete the product from the cart. If the user answers OK, the RemoveFromCart()
method located in the ShoppingApiController
class is called. This method was created earlier in the article series. If the call is successful, the .done()
function is called. In this function, remove the row from the HTML table, update the menu link “n Items in Cart”, and call the calculateNewTotal()
method. The calculateNewTotal()
method is not written yet, but first, just try out deleting a shopping cart item.
Listing 9: Create two methods to help you delete a shopping cart item and recalculate the total
function deleteCartItem(id) {
if (confirm('Delete this Product from Cart?')) {
$.ajax({
url: "/api/ShoppingApi/RemoveFromCart/" + id,
type: "DELETE"
})
.done(function (data) {
if (data) {
// Remove the row from the table
$("#" + id).remove();
// Update menu link text
mainController.modifyItemsInCartText(false);
// Calculate New Total
calculateNewTotal();
}
})
.fail(function (error) {
console.error(error);
});
}
}
function calculateNewTotal() {
}
Modify the return object to expose the deleteCartItem()
method from the closure.
return {
"setCountdown": setCountdown,
"deleteCartItem": deleteCartItem
}
In the .done()
function in the deleteCartItem()
method, if the data returned from the Web API is a true
value, use jQuery to locate the row set to the id=ProductId and remove that row from the HTML table. To make this work, modify the <tr>
within the @foreach()
loop and add an id
attribute to the row to set the ProductId, as shown in the following line of code.
@foreach (ShoppingCartItem item in Model.Cart.Items) {
<tr id="@item.ProductId">
// REST OF THE CODE HERE
}
Update the Razor Page
Delete the last <td>
element in which the @Html.ActionLink("Delete", "Delete", ...)
method is located. Add a new <td>
element, shown in the code below, where you deleted the old one. This new <td>
contains an <a>
element that calls a method named pageController.deleteCartItem()
, passing in the value in the ProductId
property.
<td>
<a class="btn btn-danger"
onclick="pageController.deleteCartItem(@item.ProductId)">
Delete
</a>
</td>
Remove Server-Side Code
Because you're no longer performing a post-back to remove a shopping cart item, you can remove a method on the server-side code. Open the Controllers\Shopping\CartController.cs
file and remove the Delete()
method.
Try It Out
Run the application and add a few items to the shopping cart. Click the Delete button on one of the items in the cart and watch it disappear from the table. Notice that the total value of items in the cart did not get updated. Delete all of the items you added to the shopping cart and notice that the blank table with the Total Price is still visible. If you delete all the rows from the shopping cart, the table should completely disappear.
Working with Currency Values in JavaScript
When the shopping cart page is displayed, the price and the total price are formatted as currency values via server-side code. To update the total price at the bottom of the table after deleting a row, loop through all the rows in the table, get the total price of an item, such as $1,999.99, and strip out the symbols, such as commas and dollar signs ($), that aren't valid for a number in JavaScript. Add up each of these prices to get the new total value, and then you need to convert that number back into a U.S. currency format to be displayed at the bottom of the shopping cart table.
Open the wwwroot\js\site.js
file and add two methods to the mainController
closure. The first method, fromUSCurrency()
, is the one used to strip the commas and dollar signs from a string. Add the method shown in the following code snippet to the mainController
closure.
function fromUSCurrency(value) {
return value.replace(/[^0-9\.-]+/g, "");
}
Next, add the method to take a number value and return it as a string in a U.S. currency format. Add the method toUSCurrency()
to the mainController
closure.
function toUSCurrency(value) {
value = parseFloat(value);
return value.toLocaleString('en-US', {
style: 'currency',
currency: 'USD',
});
}
Modify the return object on the mainController
closure to expose both those new methods.
return {
"pleaseWait": pleaseWait,
"disableAllClicks": disableAllClicks,
"setSearchValues": setSearchValues,
"isSearchFilledIn": isSearchFilledIn,
"setSearchArea": setSearchArea,
"formSubmit": formSubmit,
"modifyItemsInCartText": modifyItemsInCartText,
"toUSCurrency": toUSCurrency,
"fromUSCurrency": fromUSCurrency
}
Calculate New Total After Deleting from Cart
After deleting a row from the shopping cart table, update the total price of all items at the bottom of the table. Open the Views\Cart\Index.cshtml
file and within the <tbody>
element locate the <td>
bound to item.Price
and add a name
attribute.
<td name="itemPrice" class="text-right">
@Html.DisplayFor(m => item.Price)
</td>
Locate the <td>
element bound to item.TotalPrice
and add a name
attribute on that element as well.
<td name="itemTotalPrice" class="text-right">
@Html.DisplayFor(m => item.TotalPrice)
</td>
Within the <tfoot>
element, locate the <td>
bound to the Model.Cart.TotalCart
and add an id
attribute to the <strong>
element.
<td class="text-right">
<strong id="cartTotal">
@Html.DisplayFor(Model => Model.Cart.TotalCart)
</strong>
</td>
Write the CalculateNewTotal() Method
It's now time to write the method named calculateNewTotal()
(Listing 10) that you created previously as an empty method. This method gathers all those <td>
elements with the name
attribute set to “itemTotalPrice”. Loop through all elements using the each()
method, retrieve each <td>
element from the list, and convert the text from a currency value like $1,789.99 to simply 1789.99 using the fromUSCurrency()
method you wrote earlier. Add that value to the total
variable to keep a running total.
Listing 10: Get all elements marked as the total price and calculate the new total from all of those elements
function calculateNewTotal() {
let total = 0;
// Get all <td> elements
// that contain the total price
let list = $("table tbody tr
td[name='itemTotalPrice']");
// Loop through all rows
$(list).each(function () {
// Get the currency amount and
// strip out anything not a number
let text = mainController.fromUSCurrency($(this).text());
// Add dollar amount to total
total += parseFloat(text);
});
if (total === 0) {
// Redirect to this same window
// This clears the shopping cart
window.location.assign(window.location.href);
}
else {
// Display the new total
$("#cartTotal").text(mainController.toUSCurrency(total));
}
}
After all the looping is complete, if the total
variable is equal to zero, post back to the current page to have the server display a blank page with the message that tells the user that there are no items in the cart. If the total
variable is greater than zero, display the total into the <strong>
element with the id
attribute of “cartTotal” as a currency value using the toUSCurrency()
method.
Try It Out
Run the application and add a few items to the shopping cart. Delete an item and watch the total update. Next, remove all items in the cart and watch the page post-back. You should now be notified that you don't have any items in the cart.
The Problem: Updating the Quantity on the Cart Causes a Post-Back
If you change the quantity for a product on the shopping cart, you need to update the cart item on the server. You also need to update the total price for that line item and then update the total of all items in the cart. Currently, if you update a quantity, a button must be clicked to post-back and update the shopping cart page. Let's now change this functionality by writing a Web API and some jQuery to avoid that post-back.
Let's change the functionality by writing a Web API and some jQuery to avoid the post-back.
The Solution: Add New API Controller
Right mouse-click on the ControllersApi
folder and add a new class named CartApiController.cs
. Remove the default code in the new file and insert the code shown in Listing 11. Just like the other API controller
classes you've created so far, an instance of the AppSession
class is injected into the constructor. There's a single Web API call in this class named Delete()
. This method accepts an integer that is the customer ID to delete. An instance of the CustomerViewModel
class is created and the Delete()
method on this object is called to delete the customer. A status code of 200 is returned signifying that this call was successful.
Listing 11: The Cart API controller helps us update the item quantities in the shopping cart
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using PaulsAutoParts.AppClasses;
using PaulsAutoParts.Common;
using PaulsAutoParts.EntityLayer;
using PaulsAutoParts.ViewModelLayer;
namespace PaulsAutoParts.Controllers
{
[ApiController]
[Route("api/[controller]/[action]")]
public class CartApiController : AppController
{
public CartApiController(AppSession session): base(session)
{
}
[HttpPost(Name = "UpdateItemQuantity")]
public IActionResult UpdateItemQuantity([FromBody] ShoppingCartItem item)
{
// Set Cart from Session
ShoppingViewModel vm = new(null, null, UserSession.Cart);
// Set "Common" View Model Properties
// from Session
base.SetViewModelFromSession(vm, UserSession);
// Update item
vm.UpdateQuantity(item);
// Set updated cart back into session
UserSession.Cart = vm.Cart;
return StatusCode(StatusCodes.Status200OK, true);
}
}
}
Update the Shopping Cart Page
Open the Views\Cart\Index.cshtml
file and within the <tbody>
element locate the <td>
that has the <form method="post" asp-action="UpdateQuantity">
and remove the entire <td>
element and everything within it. Add the following <td>
where you removed the <td>
element. The input field for the Quantity calls a method named updateQuantity()
on the pageController
class as soon as the user tabs off the Quantity field.
<td>
<input class="form-control"
asp-for="@item.Quantity"
onchange="pageController.updateQuantity(@item.ProductId,this)"/>
</td>
Add an updateQuantity Method
Add a new method to the pageController
closure named updateQuantity()
, as shown in Listing 12. Two arguments are passed to the updateQuantity()
method; the product ID, and a reference to the control that triggered the call to this method. Retrieve the value from the control using the $(ctl).val()
jQuery method. Parse that value into an integer and put it into a variable named qty
. Next, build a literal object with two properties: productId
and quantity
. This literal object is posted to the UpdateItemQuantity()
method you created in the CartApiController
class.
Listing 12: The updateQuantity() method updates the server with the new quantity information and recalculates the new shopping cart total
function updateQuantity(id, ctl) {
// Get the new quantity value
let qty = parseInt($(ctl).val());
// Create shopping cart item to send to server
let item = {
"productId": id,
"quantity": qty
}
// Call UpdateItemQuantity Web API Method
$.ajax({
url: "/api/CartApi/UpdateItemQuantity",
type: "POST",
contentType: "application/json",
data: JSON.stringify(item)
})
.done(function (data) {
if (data) {
// Update this row of data
updateRow(ctl, qty);
/ Calculate New Total
calculateNewTotal();
}
})
.fail(function (error) {
console.error(error);
});
}
This JavaScript object is mapped onto a ShoppingCartItem
object that has those two property names created in C#. If the call is successful, a method named updateRow()
is called, passing in the control reference and the quantity. This method updates the total price for the row on which the quantity was updated. Finally, the calculateNewTotal()
method is called to calculate the total shopping cart value.
Add an updateRow Method
Add a new method named updateRow()
to the pageController
closure as shown in the following code snippet.
function updateRow(ctl, qty) {
// Get row where quantity was updated
let row = $(ctl).parents("tr")[0];
// Get <td> with itemPrice
let td = $(row).find("td[name='itemPrice']")[0];
// Get price in the current row
let price = parseFloat(mainController.fromUSCurrency($(td).text()));
// Get <td> with itemTotalPrice
td = $(row).find("td[name='itemTotalPrice']")[0];
// Update total price
$(td).text(mainController.toUSCurrency(qty * price));
}
The updateRow()
method uses jQuery to locate the parent <tr>
for the current control. From that row
variable, you can then get the <td>
element that contains the item price. Get the price contained in that <td>
and strip the U.S. currency symbols so you have a price
variable as a JavaScript number. Next, the <td>
with the item's total price is located. This <td>
elements' text
property is set with the result of the quantity times the price, formatted as U.S. currency.
Modify the return object to expose the updateQuantity()
method so it can be called from the onchange
event on the quantity input field.
return {
"setCountdown": setCountdown,
"deleteCartItem": deleteCartItem,
"updateQuantity": updateQuantity
}
Remove Server-Side Code
Because you're no longer performing a post-back to update the quantity, you can remove a method on the server-side code. Open the Controllers\Shopping\CartController.cs
file and remove the UpdateQuantity()
method.
Try It Out
Run the application and put some items into the shopping cart. Go into the Shopping Cart page and type in the number 2
into one of the Quantity input fields. Tab off the input field and you should see that the total price on that row updates, and the shopping cart total updates, all without any post-backs.
The Problem: Applying a Promo Code to the Shopping Cart Requires a Post-Back
If the user fills in a valid promo code and clicks on the Apply Promo Code button, a certain percentage discount is applied to the unit price on each line item in the cart. Clicking on this button causes a post-back, but you can once again solve this with a Web API call and some jQuery.
The Solution: Add a New Method to the CartApiController
In the CartController
class, a method named ApplyPromoCode()
is currently being called when the user clicks on the Apply Promo Code button from the front-end. The functionality in this method needs to be recreated in a new method in the CartApiController
class. Open the ControllersApi\CartApiController.cs
file and modify the constructor to have a promotional code repository object injected and assign that to a private field.
public CartApiController(AppSession session, IRepository<PromoCode, PromoCodeSearch> repo): base(session)
{
_repo = repo
}
private readonly IRepository<PromoCode, PromoCodeSearch> _repo;
Add a new method named GetPromoCode()
to this class, as shown in Listing 13. This method accepts the promotional code input by the user and calls the ApplyPromoCode()
method on the ShoppingViewModel
class. This method on the server updates all the items in Session with the discount to be applied. The promotional code object used for this discount is returned so the data already being displayed on the HTML table can have the same discount applied, as has been done on the server.
Listing 13: Add a new method to apply a promotional code to the shopping cart items
[HttpGet("{code}", Name = "GetPromoCode")]
public ActionResult GetPromoCode(string code)
{
ShoppingViewModel vm = new();
vm.PromoCodeRepository = _repo;
// Set promo code to apply from code passed in
vm.PromoCodeToApply = code;
// Set Common View Model Properties from Session
base.SetViewModelFromSession(vm, UserSession);
// Set Cart from Session
vm.Cart = UserSession.Cart;
// Apply Promo Code if it exists,
// and make sure another one has
// not already been applied
vm.ApplyPromoCode();
// Set cart into session
UserSession.Cart = vm.Cart;
// Return the promotional code object
return StatusCode(StatusCodes.Status200OK, vm.Cart.PromotionCode);
}
Modify the Cart Index Page
You now need to modify the Shopping Cart page, so it doesn't perform a post-back when the user clicks on the Apply Promo Code button. Open the Views\Cart\Index.cshtml
file, locate the <tfoot>
element, and find the <td>
that contains the following <form>
element.
<form method="post" asp-action="ApplyPromoCode">
Remove this <form ...>
line and remove the </form>
line as well. Modify the Apply Promo Code button to call a method you're going to create in the pageController
closure. Add the type=“button” attribute, as well as the onclick
event shown in the following code snippet.
<button type="button"
onclick="pageController.applyPromoCode();"
class="btn btn-success">
Apply Promo Code
</button>
Add a method to pageController
closure named applyPromoCode()
as shown in Listing 14. This method calls the Web API GetPromoCode()
method you just created and passes in the code input by the user. What's returned is the promotional code object with the discount percentage to apply to the values displayed in the HTML table.
Listing 14: Write a method to make the call to the GetPromoCode() Web API method
function applyPromoCode() {
// Get Promo Code to apply
let code = $("#PromoCodeToApply").val();
if (code) {
$.get("/api/CartApi/GetPromoCode/" + code,
function (data) {
if (data && data.promoCodeId) {
// Clear any previous error messages
$("#errorMsg").text("");
// Apply discount
applyDiscount(data.discountPercent);
// Calculate total price on each row
updateTotalPrice();
// Calculate total of all items in cart
calculateNewTotal();
// Disable promo code field
$("#PromoCodeToApply").prop("readonly", true);
}
else {
$("#errorMsg").text(
"No Promo Code found, or it has expired.");
}
})
.fail(function (error) {
console.error(error);
});
}
}
A new method named applyDiscount()
is going to be created to perform these calculations. After this calculation runs, a method named updateTotalPrice()
is called to update the total price for each item in the shopping cart table, and finally the new shopping cart total is calculated and displayed by calling calculateNewTotal()
. Once everything has been recalculated, the Apply Promo Code button is disabled.
If the object returned is null
, or if the promoCodeId
property in this object is null
, display a message into an element with the id
attribute set to errorMsg
. However, there's no element with this id
attribute set. Locate the following HTML on this page and add the id
property to the <strong>
element, as shown in the following code snippet.
<div class="row">
<div class="col text-center">
<span>
<strong id="errorMsg">
@Model.Message
</strong>
</span>
</div>
</div>
The discounts have been applied on the items in the Session
variable on the server. It's now time to apply those discounts to the values displayed in the HTML table. Add a method named applyDiscount()
to the pageController
closure as shown in Listing 15.
Listing 15: Apply the promotional code discount to the values in the HTML table
function applyDiscount(discount) {
let newTotal = 0;
discount = parseFloat(discount);
// Get all <tr> elements in <tbody>
// that contain the itemPrice
let list = $("table tbody tr td[name='itemPrice']");
// Loop through all rows
$(list).each(function () {
// Get the currency amount and
// strip out anything not a number
let unitPrice = mainController.fromUSCurrency($(this).text());
// Apply discount to unit price
newTotal = parseFloat(unitPrice) * (1 - discount);
$(this).text(mainController.toUSCurrency(newTotal));
});
}
In the applyDiscount()
method, the discount is passed in that needs to be applied to each row in the table. The list of <td>
elements that contain the price of each item is created using a jQuery selector. That list is then iterated over, the unit price is extracted and converted to a number, the new unit price total is calculated by applying the discount, and then it's put back into the <td>
element.
The next method called is responsible for getting the quantity and unit price from each row in the shopping cart table and calculating the new total price for that row of data. Add a new method named updateTotalPrice()
to the pageController
closure as shown in Listing 16.
Listing 16: Calculate the new total price for each item using the updateTotalPrice() method
function updateTotalPrice() {
// Get all <tr> elements in <tbody>
let list = $("table tbody tr");
// Loop through all rows
$(list).each(function () {
// Get quantity for this row
let qty = parseInt($(this)
.find("input[name='item.Quantity']").val());
// Get unit price for this row
let unitPrice = parseFloat(
mainController.fromUSCurrency($(this)
.find("td[name='itemPrice']").text()));
// Calculate new total price
let totalPrice = qty * unitPrice;
// Set new total price
$(this).find("td[name='itemTotalPrice']")
.text(mainController.toUSCurrency(totalPrice));
});
}
Modify the return object to expose the applyPromoCode()
method so it can be called by clicking on the Apply Promo Code button.
return {
"setCountdown": setCountdown,
"deleteCartItem": deleteCartItem,
"updateQuantity": updateQuantity,
"applyPromoCode": applyPromoCode
}
Remove Server-Side Code
Now that you've eliminated the post-back for applying a promotional code, you can delete a method in the CartController
class. Open the Controllers\ShoppingCart\CartController.cs
file and remove the ApplyPromoCode()
method.
Try It Out
Run the application and add some items to the shopping cart. Click on the n Items in Cart menu link to go to the shopping cart page. Click into the Promotional Code input field and enter TWENTYPERCENT
. Click the Apply Promo Code button and you should see all of the data updated. Next, try entering an invalid promo code and see if the error message appears.
Summary
This article concludes this series on enhancing your MVC application with JavaScript and jQuery. As you've seen throughout this series, adding just a little JavaScript and jQuery goes a long way toward making your MVC applications more user-friendly and efficient. Throughout this series, you learned how to display messages, disable buttons, and show the user some feedback while running long operations. You learned to avoid post-backs by doing more in JavaScript, and by adding Web API calls and update the UI using JavaScript and jQuery. With all the techniques shown in this series, you should be able to apply many of them to your MVC applications to enhance your users' UI experience.