Updating and Deleting Cart Items - A Practical Example - ASP.NET MVC 5 with Bootstrap and Knockout.js (2015)

ASP.NET MVC 5 with Bootstrap and Knockout.js (2015)

Part IV. A Practical Example

Chapter 18. Updating and Deleting Cart Items

To complete a usable shopping cart experience, this chapter will extend upon the previous examples to add functionality that will allow the user to increase the quantity of an item purchased or to remove the item completely.

The Cart Details

When the cart summary was created, a link was added to view the full details. That link defined the action as the Index of the CartsController. Example 18-1 updates the CartsController to add the new Index method.

Example 18-1. Updated CartsController

using ShoppingCart.Models;

using ShoppingCart.Services;

using ShoppingCart.ViewModels;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Web;

using System.Web.Mvc;

namespace ShoppingCart.Controllers

{

public class CartsController : Controller

{

private readonly CartService _cartService = new CartService();

public CartsController()

{

AutoMapper.Mapper.CreateMap<Cart, CartViewModel>();

AutoMapper.Mapper.CreateMap<CartItem, CartItemViewModel>();

AutoMapper.Mapper.CreateMap<Book, BookViewModel>();

AutoMapper.Mapper.CreateMap<Author, AuthorViewModel>();

AutoMapper.Mapper.CreateMap<Category, CategoryViewModel>();

}

// GET: Carts

public ActionResult Index()

{

var cart = _cartService.GetBySessionId(HttpContext.Session.SessionID);

return View(

AutoMapper.Mapper.Map<Cart, CartViewModel>(cart)

);

}

[ChildActionOnly]

public PartialViewResult Summary()

{

var cart = _cartService.GetBySessionId(HttpContext.Session.SessionID);

return PartialView(

AutoMapper.Mapper.Map<Cart, CartViewModel>(cart)

);

}

protected override void Dispose(bool disposing)

{

if (disposing)

{

_cartService.Dispose();

}

base.Dispose(disposing);

}

}

}

The Index function is nearly identical to the previously created Summary with two key differences. First, it’s not attributed with ChildActionOnly, and second, it returns a regular view and not a partial one. Otherwise, they use the same CartService function and automap to the sameCartViewModel.

Unlike previous controller updates, no service updates are required because it is using an existing function. The Index view can now be created inside the Views/Carts folder. It should not be a partial view. Example 18-2 contains the new view.

Example 18-2. Views/Carts/Index.cshtml

@model ShoppingCart.ViewModels.CartViewModel

@{

ViewBag.Title = "Cart Details";

}

<h2>Cart Details</h2>

<div id="cartDetails">

<table class="table table-bordered table-hover table-striped"

style="display:none" data-bind="visible:cart.cartItems().length > 0">

<tr>

<th>Book</th>

<th>Unit Price</th>

<th>Quantity</th>

<th>Price</th>

<th> </th>

</tr>

<!-- ko foreach: { data: cart.cartItems, beforeRemove: fadeOut } -->

<tr>

<td>

<a href="@Url.Action("Details", "Books")"

data-bind="appendToHref: book.id, text: book.title"></a>

</td>

<td data-bind="text: '$' + book.salePrice"></td>

<td>

<upsert-cart-item params="cartItem: $data, showButton: false">

</upsert-cart-item>

</td>

<td data-bind="text: '$' + quantity.subTotal()"></td>

<td>

<button type="button" class="btn btn-danger"

data-bind="click: $parent.deleteCartItem, visible:

!$parent.sending()">

<span class="glyphicon glyphicon-trash"></span></button>

</td>

</tr>

<!-- /ko -->

</table>

<div class="progress" data-bind="visible: sending">

<div class="progress-bar progress-bar-info progress-bar-striped active"

role="progressbar" aria-valuenow="100"

aria-valuemin="0" aria-valuemax="100"

style="width: 100%">

<span class="sr-only"></span>

</div>

</div>

<div class="alert alert-warning" style="display: none"

data-bind="visible: cart.cartItems().length == 0">

Your cart is currently empty.

<a href="@Url.Action("Index", "Home")">Continue shopping</a>.

</div>

<h3>Total: $<span data-bind="text: cart.total"></span></h3>

</div>

@Html.Partial("_CartItemForm")

@section Scripts {

@Scripts.Render("~/Scripts/ViewModels/CartDetailViewModel.js",

"~/Scripts/ViewModels/CartItemViewModel.js")

<script>

var model = @Html.HtmlConvertToJson(Model);

var cartDetailViewModel = new CartDetailViewModel(model);

ko.applyBindings(cartDetailViewModel, document.getElementById("cartDetails"));

</script>

}

Like the cart summary view, this view is also data bound to the CartViewModel. The HTML begins by defining a div tag with the id of cartDetails. The Knockout bindings will be applied to this div. Inside this div, a table is defined that will show each cart item. The table contains a visibledata binding that will hide the table when there are no items left in the cartItems array.

When the book index page was created, hiding the table was accomplished via a Razor if statement. Knockout is being used here because the user can dynamically delete row elements. After the table, there is an info div that contains a similar visible binding, but will only display when there are no items in the cart.

A foreach Knockout binding is created that will loop through the cartItems array. A beforeRemove callback function is defined that will call the fadeOut function from the ViewModel. This callback will do the opposite of the callback that was added in the cart summary to fade in elements when they are added.

Inside the foreach, an HTML link is defined that will take the user to the book details page. Previously, when a similar link to this was created, the foreach was being done by Razor providing server-side access to the ID to build the URL. Because this link will be created inside a Knockoutforeach, the ID needs to be dynamically appended to the end of the Book/Details URL. This is a very common behavior when building an index page, so because of this I’ve created another custom binding called appendToHref that accepts a property to append to the URL defined.

The next column contains the unit price of the book and is data bound with the text binding. The column after this reuses the previously created upsert-cart-item custom component, passing in the current cartItem being looped using the $data property of the foreach context. This time, theshowButton is set to false because I only want the Update button to show when the quantity is changed.

The subTotal is data bound in the next column. This column will dynamically recalculate when the quantity is changed. The final column contains a delete button. It is using the click data binding and will call the deleteCartItem function. It is prefixed with the $parent context because when inside a foreach binding, the current context is the item being looped. Using $parent will go to the first level outside of this where the function resides in the ViewModel.

After the foreach loop ends, the table is closed. Outside of the table is a progress bar that will be displayed when the delete button is clicked, providing feedback to the user that something is happening.

The carts total is bound to a header tag using the text binding. This value will also be dynamically recalculated when either the quantity changes or an item is removed from the cartItems array.

The shared _CartItemForm is included next that contains the previously created template for the custom component. And finally, the JavaScript includes the yet-to-be-created CartDetailViewModel, as well as the CartItemViewModel for the custom component. The model is then serialized, theCartDetailViewModel is created, and the Knockout bindings are applied. The CartDetailViewModel will be created in the next section. Figure 18-1 contains an example of the fully functional cart details page.

Figure 18-1. Cart summary

Knockout for the Cart Details

The previous section introduced a new custom data binding called appendToHref. This has been added to the existing knockout.custom.js file and is shown in Example 18-3.

Example 18-3. Updated knockout.custom.js

ko.bindingHandlers.appendToHref = {

init: function (element, valueAccessor) {

var currentHref = $(element).attr('href');

$(element).attr('href', currentHref + '/' + valueAccessor());

}

}

ko.bindingHandlers.isDirty = {

init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {

var originalValue = ko.unwrap(valueAccessor());

var interceptor = ko.pureComputed(function () {

return (bindingContext.$data.showButton !== undefined &&

bindingContext.$data.showButton)

|| originalValue != valueAccessor()();

});

ko.applyBindingsToNode(element, {

visible: interceptor

});

}

};

ko.extenders.subTotal = function (target, multiplier) {

target.subTotal = ko.observable();

function calculateTotal(newValue) {

target.subTotal((newValue * multiplier).toFixed(2));

};

calculateTotal(target());

target.subscribe(calculateTotal);

return target;

};

ko.observableArray.fn.total = function () {

return ko.pureComputed(function () {

var runningTotal = 0;

for (var i = 0; i < this().length; i++) {

runningTotal += parseFloat(this()[i].quantity.subTotal());

}

return runningTotal.toFixed(2);

}, this);

};

Just like the isDirty custom binding, the appendToHref is added to the ko.bindingHandlers. This time when the init function is defined, it only accepts the first two parameters: the element and the valueAccessor. Because the other parameters are not needed in this binding, I have omitted them.

Using jQuery, the current value of the element’s href attribute is stored in a local variable. This value is used to update the element’s href by appending the value added in the data binding.

To complete the client-side portion of the cart detail functionality, the CartDetailViewModel needs to be created inside the Scripts/ViewsModels folder as shown in Example 18-4.

Example 18-4. CartDetailViewModel

function CartDetailViewModel(model) {

var self = this;

self.sending = ko.observable(false);

self.cart = model;

for (var i = 0; i < self.cart.cartItems.length; i++) {

self.cart.cartItems[i].quantity = ko.observable(self.cart.cartItems[i].quantity)

.extend({ subTotal: self.cart.cartItems[i].book.salePrice });

}

self.cart.cartItems = ko.observableArray(self.cart.cartItems);

self.cart.total = self.cart.cartItems.total();

self.cartItemBeingChanged = null;

self.deleteCartItem = function (cartItem) {

self.sending(true);

self.cartItemBeingChanged = cartItem;

$.ajax({

url: '/api/cartitems',

type: 'delete',

contentType: 'application/json',

data: ko.toJSON(cartItem)

})

.success(self.successfulDelete)

.error(self.errorSave)

.complete(function () { self.sending(false) });

};

self.successfulDelete = function (data) {

$('.body-content').prepend('<div class="alert alert-success">

<strong>Success!</strong> The item has been deleted from your cart.</div>');

self.cart.cartItems.remove(self.cartItemBeingChanged);

cartSummaryViewModel.deleteCartItem(ko.toJS(self.cartItemBeingChanged));

self.cartItemBeingChanged = null;

};

self.errorSave = function () {

$('.body-content').prepend('<div class="alert alert-danger">

<strong>Error!</strong> There was an error updating the item to your cart.</div>');

};

self.fadeOut = function (element) {

$(element).fadeOut(1000, function () {

$(element).remove();

});

};

};

The start of the ViewModel looks quite similar to the CartSummaryViewModel. The cartItems are looped through, and the quantity is converted to an observable property and extended to use the previously created subTotal extension. The cartItems array is then converted into an observable array, and the cart total is stored in a variable leveraging the custom total function for observable arrays.

A nullable cartItemBeingChanged variable is defined. This variable is set inside the deleteCartItem function and will be used upon successful deletion to remove the element from the cartItems array. More on this in a moment.

The deleteCartItem is defined next. It works the same as previously defined AJAX requests. It marks the sending observable as true to display the progress bar and hide the delete buttons from being clicked multiple times. The AJAX request is then defined next. It goes to the sameapi/cartitems as the add and update requests went before; however, this time the request type is defined as delete. On successful save, the function successfulDelete will be called. If an error occurs, the errorSave function is called. In all scenarios, the complete function is defined to set thesending observable back to false, hiding the progress bar.

The successfulDelete function adds a success alert on the page, informing the user that the item has been removed from the cart. The previously set cartItemBeingChanged variable is used to remove the element from the array. This works because Knockout is able to remove the reference from the array. This way may seem “hacky,” but I prefer it to the alternative approach that would require looping through the cartItems array, finding a match based on the cartItem ID, and then calling the remove function on that item.

This function also calls a deleteCartItem function in the global CartSummaryViewModel (shown in Example 18-5).

The errorSave function adds an error alert message to inform the user the item was not removed from the cart.

The final function, fadeOut, is called by Knockout before an element is removed from the cartItems array. This function uses the element and applies the jQuery UI fadeout function over a period of one second. When the jQuery fade completes, the element is removed from the HTML.

Example 18-5 updates the existing CartSummaryViewModel to add the deleteCartItem function.

Example 18-5. Updated CartSummaryViewModel

function CartSummaryViewModel(model) {

var self = this;

self.cart = model;

for (var i = 0; i < self.cart.cartItems.length; i++) {

var cartItem = self.cart.cartItems[i];

cartItem.quantity = ko.observable(cartItem.quantity)

.extend({ subTotal: cartItem.book.salePrice });

}

self.cart.cartItems = ko.observableArray(self.cart.cartItems);

self.cart.total = self.cart.cartItems.total();

self.updateCartItem = function (cartItem) {

var isNewItem = true;

for (var i = 0; i < self.cart.cartItems().length; i++) {

if (self.cart.cartItems()[i].id == cartItem.id) {

self.cart.cartItems()[i].quantity(cartItem.quantity);

isNewItem = false;

break;

}

}

if (isNewItem) {

cartItem.quantity = ko.observable(cartItem.quantity)

.extend({ subTotal: cartItem.book.salePrice });

self.cart.cartItems.push(cartItem);

}

};

self.deleteCartItem = function (cartItem) {

for (var i = 0; i < self.cart.cartItems().length; i++) {

if (self.cart.cartItems()[i].id == cartItem.id) {

self.cart.cartItems.remove(self.cart.cartItems()[i]);

break;

}

}

};

self.showCart = function () {

$('#cart').popover('toggle');

};

self.fadeIn = function (element) {

setTimeout(function () {

$('#cart').popover('show');

$(element).slideDown(function () {

setTimeout(function () {

$('#cart').popover('hide');

}, 2000);

});

}, 100);

};

$('#cart').popover({

html: true,

content: function () {

return $('#cart-summary').html();

},

title: 'Cart Details',

placement: 'bottom',

animation: true,

trigger: 'manual'

});

};

if (cartSummaryData !== undefined) {

var cartSummaryViewModel = new CartSummaryViewModel(cartSummaryData);

ko.applyBindings(cartSummaryViewModel, document.getElementById("cart-details"));

} else {

$('.body-content').prepend('<div class="alert alert-danger">

<strong>Error!</strong> Could not find cart summary.</div>');

}

As you can see, this function uses my alternative suggestion and loops through the cartItems array matching on the item’s ID. When a match is found, the item is removed from the observable array, and the loop is exited.

Removing the cart item will cause the cart total to be recalculated.

Completing the Shopping Cart

The shopping cart is almost completed. The final changes are needed in the Web API CartItemsController. Example 18-6 contains an updated CartItemsController.

Example 18-6. Updated CartItemsController

using ShoppingCart.Models;

using ShoppingCart.Services;

using ShoppingCart.ViewModels;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Net;

using System.Net.Http;

using System.Web.Http;

namespace ShoppingCart.Controllers.Api

{

public class CartItemsController : ApiController

{

private readonly CartItemService _cartItemService = new CartItemService();

public CartItemsController()

{

AutoMapper.Mapper.CreateMap<Cart, CartViewModel>();

AutoMapper.Mapper.CreateMap<CartItem, CartItemViewModel>();

AutoMapper.Mapper.CreateMap<Book, BookViewModel>();

AutoMapper.Mapper.CreateMap<CartItemViewModel, CartItem>();

AutoMapper.Mapper.CreateMap<BookViewModel, Book>();

AutoMapper.Mapper.CreateMap<AuthorViewModel, Author>();

AutoMapper.Mapper.CreateMap<CategoryViewModel, Category>();

}

public CartItemViewModel Post(CartItemViewModel cartItem)

{

var newCartItem = _cartItemService.AddToCart(

AutoMapper.Mapper.Map<CartItemViewModel, CartItem>(cartItem));

return AutoMapper.Mapper.Map<CartItem, CartItemViewModel>(newCartItem);

}

public CartItemViewModel Put(CartItemViewModel cartItem)

{

_cartItemService.UpdateCartItem(

AutoMapper.Mapper.Map<CartItemViewModel, CartItem>(cartItem));

return cartItem;

}

public CartItemViewModel Delete(CartItemViewModel cartItem)

{

_cartItemService.DeleteCartItem(

AutoMapper.Mapper.Map<CartItemViewModel, CartItem>(cartItem));

return cartItem;

}

protected override void Dispose(bool disposing)

{

if (disposing)

{

_cartItemService.Dispose();

}

base.Dispose(disposing);

}

}

}

Two new functions were added: Put and Delete. The Put function is called when the update button is clicked and accepts the CartItemViewModel being updated. This function maps the ViewModel to the CartItemModel and calls the UpdateCartItem method in the CartItemService.

The Delete function works the same way with the exception that it calls the DeleteCartItem method in the CartService. Both functions return the CartItemViewModel back. It doesn’t need to be mapped back because it remains unchanged.

The final changes need to be made to the existing CartItemService as shown in Example 18-7.

Example 18-7. Updated CartItemService

using ShoppingCart.DAL;

using ShoppingCart.Models;

using System;

using System.Collections.Generic;

using System.Data.Entity;

using System.Linq;

namespace ShoppingCart.Services

{

public class CartItemService : IDisposable

{

private ShoppingCartContext _db = new ShoppingCartContext();

public CartItem GetByCartIdAndBookId(int cartId, int bookId)

{

return _db.CartItems.SingleOrDefault(ci => ci.CartId == cartId &&

ci.BookId == bookId);

}

public CartItem AddToCart(CartItem cartItem)

{

var existingCartItem = GetByCartIdAndBookId(cartItem.CartId, cartItem.BookId);

if (null == existingCartItem)

{

_db.Entry(cartItem).State = EntityState.Added;

existingCartItem = cartItem;

}

else

{

existingCartItem.Quantity += cartItem.Quantity;

}

_db.SaveChanges();

return existingCartItem;

}

public void UpdateCartItem(CartItem cartItem)

{

_db.Entry(cartItem).State = EntityState.Modified;

_db.SaveChanges();

}

public void DeleteCartItem(CartItem cartItem)

{

_db.Entry(cartItem).State = EntityState.Deleted;

_db.SaveChanges();

}

public void Dispose()

{

_db.Dispose();

}

}

}

The two functions, UpdateCartItem and DeleteCartItem, are nearly identical. One marks the item as updated in the ShoppingCartContext while the other marks it as deleted. SaveChanges is then called to commit the changes to the database.

Summary

The shopping cart is now fully functional. Users can browse the catalog of books, and when they find one they like, they can add it to the shopping cart. The cart summary is animated into view, providing the user with feedback on the newly added item.

Clicking the shopping cart item displays the summary of items in their cart with a total and a button to view the full details. Clicking the full details will direct the users to a new page that contains a table of the items in their cart. The item’s quantity can be changed or removed completely from the cart. In either case, the totals are automatically recalculated by leveraging Knockout observables.

The shopping cart used a lot of the great features of Knockout — reusable custom components, bindings, functions, and extensions — to build a nice user interface.

This book has attempted to demonstrate a variety of the features of the three technologies used — ASP.NET MVC 5, Bootstrap, and Knockout.js — both together and separately. I’ve attempted to pass on best practices and my personal experiences having used these three technologies every day for nearly two years.

Mixing three technologies can be a big balancing act. The biggest takeaway I’ve learned is to constantly analyze what you are trying to accomplish and pick the right mix of the technologies being used. This is important, because it can be easy to use Knockout for every page in the website. But as I’ve demonstrated, I use it sparingly where dynamic interaction is required. Picking the right technology for the job will provide a much better user experience than blindly picking one and sticking with it.

When it comes to using client-side libraries like Knockout, there is a lot of rave over single-page web applications. I think they look and act quite nice; however, I don’t think it is the be-all and end-all of web design either. In certain situations, they make sense, and in others, they don’t. If the website contains a single focus, single-page designs are great; why direct the user away when the UI can be updated dynamically? When the context of the pages change, though, I think it makes more sense to avoid the single-page design. Using the shopping cart as an example, the book details page and the cart details page contain similar functionality; however, what the user is attempting to accomplish is quite different. Just like picking the right technology, picking the right design is equally important.

I hope you have enjoyed this book as much as I enjoyed writing it. You can find me online through my blog End Your If and on Twitter, where I would be happy to answer questions about the book.