Adding Items to the Cart - 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 17. Adding Items to the Cart

As my product owner at work likes to say, this is the chapter where “the rubber meets the road.” The previous chapters have been providing the necessary setup before building the “why,” and when it comes to shopping carts, the “why” is adding the item to the cart.

The Book Details

Before a book can be added to the cart, the book details page needs to be created. In the last chapter when the book listings were created, the link to the book sent the user to Books/Details/id. To start, the BooksController needs to be updated to add this new action. Example 17-1 contains an updated BooksController.

Example 17-1. Updated BooksController

using ShoppingCart.Models;

using ShoppingCart.Services;

using ShoppingCart.ViewModels;

using System;

using System.Collections.Generic;

using System.Web;

using System.Web.Mvc;

namespace ShoppingCart.Controllers

{

public class BooksController : Controller

{

private readonly BookService _bookService = new BookService();

public BooksController()

{

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

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

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

}

// GET: Books

public ActionResult Index(int categoryId)

{

var books = _bookService.GetByCategoryId(categoryId);

ViewBag.SelectedCategoryId = categoryId;

return View(

AutoMapper.Mapper.Map<List<Book>, List<BookViewModel>>(books)

);

}

public ActionResult Details(int id)

{

var book = _bookService.GetById(id);

return View(

AutoMapper.Mapper.Map<Book, BookViewModel>(book)

);

}

[ChildActionOnly]

public PartialViewResult Featured()

{

var books = _bookService.GetFeatured();

return PartialView(

AutoMapper.Mapper.Map<List<Book>, List<BookViewModel>>(books)

);

}

protected override void Dispose(bool disposing)

{

if (disposing)

{

_bookService.Dispose();

}

base.Dispose(disposing);

}

}

}

The Details action is quite similar to the other actions in the BooksController. It uses the BookService to fetch an individual book by its ID. This book is then automapped to the view.

The BookService needs to be updated to include the new GetById function as shown in Example 17-2.

Example 17-2. Updated BookService

using ShoppingCart.DAL;

using ShoppingCart.Models;

using System;

using System.Collections.Generic;

using System.Linq;

using System.Web;

namespace ShoppingCart.Services

{

public class BookService : IDisposable

{

private ShoppingCartContext _db = new ShoppingCartContext();

public List<Book> GetByCategoryId(int categoryId)

{

return _db.Books.

Include("Author").

Where(b => b.CategoryId == categoryId).

OrderByDescending(b => b.Featured).

ToList();

}

public List<Book> GetFeatured()

{

return _db.Books.

Include("Author").

Where(b => b.Featured).

ToList();

}

public Book GetById(int id)

{

var book = _db.Books.

Include("Author").

Where(b => b.Id == id).

SingleOrDefault();

if (null == book)

throw new System.Data.Entity.Core.ObjectNotFoundException

(string.Format("Unable to find book with id {0}", id));

return book;

}

public void Dispose()

{

_db.Dispose();

}

}

}

Once again, the BookService updates are almost identical. This time instead of returning a list, it returns only a single book. There is an additional check to see if the book exists. If it does not, an exception is thrown.

To complete the display of the book, a Details view is required. Inside the Views/Books folder, create a new view called Details. Like the Index view, this should not be a partial view. Example 17-3 contains the finished view.

Example 17-3. Views/Books/Details.cshtml

@model ShoppingCart.ViewModels.BookViewModel

@{

ViewBag.Title = Model.Title;

}

<h1>@Model.Title</h1>

<div id="bookDetails" class="row">

<div class="col-md-2">

<img src="@Model.ImageUrl" alt="@Model.Title" title="@Model.Title"

class="img-rounded" />

</div>

<div class="col-md-5 col-md-offset-1">

<h3>@Model.Author.FullName</h3>

<p>Your Price: $@Model.SalePrice</p>

<p>List Price: <span style="text-decoration: line-through">

$@Model.ListPrice</span></p>

<p class="label label-success">Save @Model.SavePercentage %</p>

<p>@Model.Description</p>

</div>

<div class="col-md-2 col-md-offset-2 bg-info">

<upsert-cart-item params="cartItem: cartItem, showButton: true">

</upsert-cart-item>

</div>

</div>

@Html.Partial("_CartItemForm")

@section Scripts {

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

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

<script>

var model = @Html.HtmlConvertToJson(Model);

var bookDetailViewModel = new BookDetailViewModel(model);

ko.applyBindings(bookDetailViewModel, document.getElementById("bookDetails"));

</script>

}

There is quite a bit going on inside this view. First, the view is data bound to a single BookViewModel. After this, the page title is set to the name of the book, and this is also displayed inside a header (h1).

After this, the main book details are displayed inside a div attributed with the id of bookDetails. At the end of the view, the Knockout BookDetailViewModel is data bound to this div. The Knockout ViewModel will be described in the next section.

Inside this div, the book thumbnail, author, description, and pricing information are displayed. The book details are split into three columns. The first and second contain the preceding information. The last column contains the form to add the book to the cart. This is accomplished by using a custom Knockout component. I’ve named the component upsert-cart-item because it is also used on the cart details page that will be implemented in the next chapter.

The upsert-cart-item component accepts two parameters: the first is the item being added or edited (hence the term upsert), and the second parameter is a Boolean variable that indicates whether or not the form’s button should always be displayed. When the cart details page is implemented, the button will only be shown when the book’s quantity changes.

The final part of the Details view is to include the partial view that will be used by the Knockout component, load the necessary JavaScript files, and create an instance of the ViewModel with the BookViewModel.

Inside the Scripts/ViewModels folder, create a new BookDetailViewModel.js JavaScript file as shown in Example 17-4.

Example 17-4. BookDetailViewModel

function BookDetailViewModel(model) {

var self = this;

self.cartItem = {

cartId: cartSummaryViewModel.cart.id,

quantity: ko.observable(1),

book: model

};

};

This ViewModel accepts the BookViewModel, which is bound to a book property inside the cartItem variable. This ViewModel also leverages the global cartSummaryViewModel to set the cartId for the cartItem. The quantity property is made observable to allow the user to change this value to order more copies. The cartItem variable is the object that is data bound to the Knockout custom component that will be reviewed in the next section. Figure 17-1 is an example of a fully functional book details page.

Figure 17-1. Book details

Custom Components and Custom Bindings

The previous section set everything in place to show the book details and the add-to-cart form. The add-to-cart was accomplished using a custom Knockout component. Inside the component, a custom Knockout binding is also used to show or hide the Submit button. This is a nice effect when the cart details page is implemented because the Update button will only show when the quantity has been changed.

Knockout custom components are quite powerful. They let you encapsulate both HTML and a Knockout ViewModel together in a standalone and reusable component. To create a component, you need three things:

A unique name

The custom component I created is called upsert-cart-item.

A ViewModel

The ViewModel can be inline or an existing ViewModel. I’ve chosen the latter for better organization.

A template

The template can be inline, or it can reference a template by ID. I’ve chosen the latter once again for better organization.

To start, the component needs to be registered with Knockout. Example 17-5 contains the code required to register my component.

Example 17-5. Component registration

ko.components.register('upsert-cart-item', {

viewModel: CartItemViewModel,

template: { element: 'cart-item-form' }

});

The ViewModel that the component uses is called CartItemViewModel (shown in Example 17-6), and the template parameter identifies that Knockout should use the element with the id of cart-item-form. This will be shown in Example 17-7.

Inside the Scripts/ViewModels folder, create a new JavaScript file called CartItemViewModel. The class is shown in Example 17-6. Notice at the bottom of this file is where I placed the component registration from Example 17-5.

Example 17-6. CartItemViewModel

function CartItemViewModel(params) {

var self = this;

self.sending = ko.observable(false);

self.cartItem = params.cartItem;

self.showButton = params.showButton;

self.upsertCartItem = function (form) {

if (!$(form).valid())

return false;

self.sending(true);

var data = {

id: self.cartItem.id,

cartId: self.cartItem.cartId,

bookId: self.cartItem.book.id,

quantity: self.cartItem.quantity()

};

$.ajax({

url: '/api/cartitems',

type: self.cartItem.id === undefined ? 'post' : 'put',

contentType: 'application/json',

data: ko.toJSON(data)

})

.success(self.successfulSave)

.error(self.errorSave)

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

};

self.successfulSave = function (data) {

var msg = '<div class="alert alert-success"><strong>Success!</strong>

The item has been ';

if (self.cartItem.id === undefined)

msg += 'added to';

else

msg += 'updated in';

$('.body-content').prepend(msg + ' your cart.</div>');

self.cartItem.id = data.id;

cartSummaryViewModel.updateCartItem(ko.toJS(self.cartItem));

};

self.errorSave = function () {

var msg = '<div class="alert alert-danger"><strong>Error!</strong>

There was an error ';

if (self.cartItem.id === undefined)

msg += 'adding';

else

msg += 'updating';

$('.body-content').prepend(msg + ' the item to your cart.</div>');

};

};

ko.components.register('upsert-cart-item', {

viewModel: CartItemViewModel,

template: { element: 'cart-item-form' }

});

The ViewModel accepts a params parameter. This contains the objects passed into the component as they were named when the component was added. These are then stored into local copies — one for the cartItem being added or edited, and the other for whether or not the button should always show.

The upsertCartItem function is defined next. This is called when the form is submitted. It first checks that the form is valid, and then the sending observable is set to true; this will make the progress bar show. After this, a new variable named data is defined. This builds the object that will be sent to the server with only the information that is required by the server. This is not mandatory, but is a good thing to do because if you recall, the cartItem object contains the full book object, and there is no need to send this information to the server.

This function finishes by performing the AJAX request. The endpoint it calls is api/cartItems (this controller will be created shortly). Depending on whether the cartItem is being added or edited, it will either perform a POST or PUT request, respectively. The data is then serialized to JSON, and finally, the success and error functions are set for when the request finishes. At the completion of the AJAX request (regardless of success or fail), the sending observable is set back to false to hide the progress bar.

The successfulSave function is called when the cart item is successfully saved. This function builds a message to identify whether the item was added or updated in the cart. This message is then displayed in a success alert near the top of the screen. The final thing this function does is call a new function in the global cartSummaryViewModel. The function accepts the cartItem that was just added or edited.

The final function in this class is the errorSave function. This function builds and displays an error alert if the save is not successful.

That completes the component’s ViewModel. It’s now time to create the template. I’ve placed the template in a partial view called _CartItemForm (shown in Example 17-7). Because this will be used by both a view in the Books folder and the future CartItems folder, I created the_CartItemForm in the Shared folder under Views.

Example 17-7. Views/Shared/_CartItemForm.cshtml

@{

var cartItem = new ShoppingCart.ViewModels.CartItemViewModel();

}

<template id="cart-item-form">

<form class="center-block" data-bind="submit: upsertCartItem">

<div class="form-group">

<!-- ko if: cartItem.id === undefined -->

@Html.LabelFor(m => cartItem.Quantity)

<!-- /ko -->

<div class="input-group form-group-sm">

<div class="col-sm-8">

@Html.TextBoxFor(m => cartItem.Quantity,

new { data_bind = "textInput: cartItem.quantity",

@class = "form-control" })

@Html.ValidationMessageFor(m => cartItem.Quantity, "",

new { @class = "text-danger" })

</div>

</div>

</div>

<div class="form-group" data-bind="isDirty: cartItem.quantity">

<button type="submit" class="btn btn-primary" data-bind="visible: !sending(),

text: cartItem.id === undefined ? 'Add To Cart' : 'Update'"></button>

</div>

</form>

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

</template>

The view begins by instantiating the CartItemViewModel class. This will be used to strongly bind the form to it when using the HtmlHelper.

The HTML begins by defining the template tag with the ID that matches the component registration, cart-item-form. Inside the template is the HTML to build the form. The form is data bound on the submit event to the upsertCartItem function defined in the ViewModel. Next, if this is being used to add an item to the cart, a quantity label is created. When this is used on the cart details page, it will be in a table with a header identifying that the form is for the quantity. Then the textbox is created. It is data bound to the cartItem quantity property. Instead of using the valuedata binding, it is using the textInput data binding. This acts quite similarly to the other binding, with the exception that Knockout tracks every character change and not just when the textbox loses focus. This helps provide instant feedback to the isDirty data binding that will be discussed next.

To complete the form, the submit button is added. It contains a conditional attribute to display the text “Add to Cart” or “Update” depending on whether the item is being added or updated. The button is contained within a div that has a custom Knockout binding called isDirty on it. TheisDirty binding extends the visible binding to hide the button when editing until the quantity has changed.

The view is completed with a progress bar that is displayed when the sending observable is set to true.

Example 17-8 contains the custom isDirty binding. I’ve placed this in the existing knockout.custom.js file.

Example 17-8. Updated knockout.custom.js

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);

};

The isDirty binding is added to the ko.bindingHandlers. It contains an init property that is defined as a function. This function accepts five parameters: the element that is being data bound, the property the binding is attached to, all other bindings on this element, the ViewModel (this is being deprecated), and the binding context. This is the new way to access the ViewModel.

Inside the init function, the starting quantity value is stored to a variable. After this, a pureComputed observable is defined that returns true or false, depending on whether the button should be shown or hidden. It will return true when the showButton variable is set to true or when theoriginalValue does not equal the current value. This observable is then used to tell Knockout that this binding extends the visible binding.

This same logic could be applied to the visible binding directly; however, creating the custom binding is much cleaner and reusable.

The CartItemViewModel called a function in the global CartSummaryViewModel. Example 17-9 contains an updated CartSummaryViewModel with the updateCartItem function.

Example 17-9. 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.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>');

}

This function accepts the newly added or edited cartItem as input. The existing cartItems array is looped through to see if the same item was added again. If it was, quantity is updated to the quantity in the updated cartItem. If it doesn’t already exist, the item is added to the end of thecartItems array. This will trigger the previously defined fadeIn function.

This completes the custom Knockout component and data binding. The next section will finish the add-to-cart process by defining the CartItemsController.

Saving the Cart Item

Because the add-to-cart is accomplished via AJAX, the saving will leverage a WebAPI controller to accept and return JSON data instead of full HTML forms and views. To begin, create an Api folder inside the Controllers folder and add a new WebAPI controller called CartItemsControllerto it (as shown in Example 17-10).

Example 17-10. 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);

}

protected override void Dispose(bool disposing)

{

if (disposing)

{

_cartItemService.Dispose();

}

base.Dispose(disposing);

}

}

}

Unlike the previous controllers, because this is a WebAPI controller, it extends the ApiController instead of the Controller. Otherwise, this controller follows the same pattern. It creates a CartItemService and disposes of it when the request is finished.

Previously, the other controllers have contained Automapper definitions from the Model to the ViewModel. This controller also contains definitions from the ViewModel to the Model because it does two-way mapping: once on the input and again on the output.

The Post method calls the AddToCart function in the CartService and then returns the newCartItem (after it is mapped from a Model to a ViewModel). The Put and Delete methods will be created in the next chapter when the cart details can be edited.

The CartItemService should be added as a class to the Services folder. It is defined in Example 17-11.

Example 17-11. 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 Dispose()

{

_db.Dispose();

}

}

}

Like all other services, the CartItemService implements the IDisposable interface to properly dispose of the ShoppingCartContext that is created as a private variable.

The GetByCartIdAndBookId function accepts the cartId and bookId as parameters. The CartItems DbSet is then searched for a matching CartItem. This function is used by the AddToCart function to determine if the book being added already exists in the user’s cart.

The AddToCart function calls GetByCartIdAndBookId. If there is no existing cart item, it is added to the CartItems DbSet, and the previously unset existingCartItem variable is set to the cartItem parameter because it will be used as the return value of the function. When there is an existing cart item, the quantity of that object is increased with the new quantity specified. After this, the changes are persisted to the database, and the existingCartItem is returned.

A book can now be successfully added to a user’s shopping cart. The next chapter will both leverage and extend upon this code to allow users to edit or delete items in their cart.

Summary

The shopping cart is really taking shape now. This chapter extended Knockout with custom components and data bindings to create reusable functionality that will be leveraged in the next chapter to complete the shopping cart.