Table of Contents
- Chapter 08: SportsStore: Navigation
- 8.1 Adding Navigation Controls
- 8.1.1 Filtering the Product List
- 8.1.2 Refining the URL Scheme
- 8.1.3 Building a Category Navigation Menu
- 8.1.3.1 Creating the Navigation Controller
- 8.1.3.2 Generating Category Lists
- 8.1.3.3 Creating the View
- 8.1.3.4 Highlighting the Current Category
- 8.1.4 Correcting the Page Count
- 8.2 Building the Shopping Cart
Chapter 08: SportsStore: Navigation
8.1 Adding Navigation Controls
Three phases of to improve SportsStore app to be more usable (i.e. customer can navigate product by category)
- Enhance
List
action method inProductController
, so it can filterProduct
object in repo - Refactor routing strategy
- Create a category list in site sidebar, highlight current category and linking to others
8.1.1 Filtering the Product List
Objective: Product list can be filtered based on selected category
Steps:
- Modify view model
ProductsListViewModel
by adding propertyCurrentCategory
into id. (Listing 8-1) - Update
Product
controller so theList
action method will filterProduct
objects by category using new property. (Listing 8-2)- Add category parameter to action method
- Modify LINQ query of repository to filtering.
- Set value of
CurrentCategory
using the parameter
Listing 8-1. Enhancing the ProductsListViewModel.cs
File
using System.Collections.Generic;
using SportsStore.Domain.Entities;
namespace SportsStore.WebUI.Models {
public class ProductsListViewModel {
public IEnumerable<Product> Products { get; set; }
public PagingInfo PagingInfo { get; set; }
// add CurrentCateory into view model
public string CurrentCategory { get; set; }
}
}
Where:
- A property
CurrentCategory
is added.
Listing 8-2. Adding Category Support to the List Action Method in the ProductController.cs
File
...
namespace SportsStore.WebUI.Controllers {
public class ProductController : Controller {
private IProductRepository repository;
public int PageSize = 4;
public ProductController(IProductRepository productRepository) {
this.repository = productRepository;
}
public ViewResult List(string category , int page = 1) {
ProductsListViewModel model = new ProductsListViewModel {
Products = repository.Products
.Where(p => category == null || p.Category == category)
.OrderBy(p => p.ProductID)
.Skip((page - 1) * PageSize)
.Take(PageSize),
PagingInfo = new PagingInfo {
CurrentPage = page,
ItemsPerPage = PageSize,
TotalItems = repository.Products.Count()
},
CurrentCategory = category
};
return View(model);
}
}
}
UNIT TEST: UPDATING EXISTING UNIT TESTS (omit, please refer to book for detail)
To effect can be verified by:
- Start the app
- Select a category using query string http://localhost:xxxx/?category=Soccer
UNIT TEST: CATEGORY FILTERING (omit, please refer to book for detail)
8.1.2 Refining the URL Scheme
Objective: improve URL Scheme from ugly (Page1/?category=Soccer
) to beautiful (/Soccer/Page1
)
Steps:
- Refactor routing scheme by adding new scheme in
RegisterRoutes
method inApp_Start/RouteConfig.cs
file- Route Summary is shown below
- Use
Url.Action
method to generating outgoing links (in route system), to add category info to pagination links (Listing 8-4)
Route Summary:
/
: Lists the first page of products from all category/Page2
: Lists the page 2 showing items from all category/Soccer
: Shows first page of items from categorySoccer
/Soccer/Page2
: Shows page2 of items from the categorySoccer
Listing 8-3. The New URL Scheme in the RouteConfig.cs File
...
namespace SportsStore.WebUI {
public class RouteConfig {
public static void RegisterRoutes(RouteCollection routes) {
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(null,
"", // Route
new {
controller = "Product", action = "List",
category = (string)null, page = 1
}
);
routes.MapRoute(null,
"Page{page}",
new { controller = "Product", action = "List", category = (string)null },
new { page = @"\d+" }
);
routes.MapRoute(null,
"{category}",
new { controller = "Product", action = "List", page = 1 }
);
routes.MapRoute(null,
"{category}/Page{page}",
new { controller = "Product", action = "List" },
new { page = @"\d+" }
);
routes.MapRoute(null, "{controller}/{action}");
}
}
}
where:
- New routes need to be added in order, otherwise it will fail
- e.g.
/
route has to be added as the first route scheme
- e.g.
Note: how to unit test routing configuraiton is shown in Chapter 15.
Listing 8-4. Adding Category Information to the Pagination Links in the List.cshtml
File
@model SportsStore.WebUI.Models.ProductsListViewModel
@{
ViewBag.Title = "Products";
}
@foreach (var p in Model.Products) {
@Html.Partial("ProductSummary", p)
}
<div class="btn-group pull-right">
<!-- Generate outgoing links using Url.Action method -->
@Html.PageLinks(Model.PagingInfo, x => Url.Action("List",
new { page = x, category = Model.CurrentCategory }))
</div>
where:
- Before Listing 8-4, the links generated for pagination links were http://localhost:xxxx/Page1
- Clicking this page will lose the category filter (i.e. presented with a page with all categories)
- After Listing 8-4, the generated URL is like http://localhost:xxxx/Chess/Page1
- Clicking the link, the current category (
CurrentCategory
) will be passed to theList
action method
- Clicking the link, the current category (
8.1.3 Building a Category Navigation Menu
ASP.NET MVC Framework has child actions, useful for creating items like reusable navigation control.
- Child action relies on
Html.Action
(a HTML helper method), let you to include output from an arbitrary action method (i.e. action method from any controller) in current view.
Objective: Modify view so customer can select a cateogry with a list of categories. Specifically:
- Create a
NavController
with an action methodMenu
that renders a navigation menu. - Use
Html.Action
to inject the output from that method into the layout.
8.1.3.1 Creating the Navigation Controller
Steps:
- Create
NavController.cs
in/Controllers
folder, removeIndex
method, and add new action methodMenu
(Listing 8-5) - Integrate the child action
Menu()
into page by render the child action in the layout (i.e./Views/Shared/_Layout.cshtml
) (Listing 8-6)
Listing 8-5. Adding The Menu Action Method to the NavController.cs
File
using System.Web.Mvc;
namespace SportsStore.WebUI.Controllers {
public class NavController : Controller {
public string Menu() {
return "Hello from NavController";
}
}
}
Listing 8-6. Adding the RenderAction Call to the _Layout.cshtml
File
...
<body>
<div class="navbar navbar-inverse" role="navigation">
<a class="navbar-brand" href="#">SPORTS STORE</a>
</div>
<div class="row panel">
<div id="categories" class="col-xs-3">
<!-- Add call to Html.Action method. -->
@Html.Action("Menu", "Nav")
</div>
<div class="col-xs-8">
@RenderBody()
</div>
</div>
</body>
...
where:
- The parameters to
@Html.Action
are "name of action method" and "controller that contains it".
8.1.3.2 Generating Category Lists
Objective: Create list of categories in Menu
action method, which will be accepted by helper method in view to visualize it.
Listing 8-7. Implementing the Menu Method in the NavController.cs
File
using System.Collections.Generic;
using System.Web.Mvc;
using SportsStore.Domain.Abstract;
using System.Linq;
namespace SportsStore.WebUI.Controllers {
public class NavController : Controller {
private IProductRepository repository;
public NavController(IProductRepository repo) {
repository = repo;
}
public PartialViewResult Menu() {
IEnumerable<string> categories = repository.Products
.Select(x => x.Category)
.Distinct()
.OrderBy(x => x);
return PartialView(categories);
}
}
}
where:
- As we are working with a partial view in this controller,
PartialView
method in the action method and that the result isPartialViewResult
object
UNIT TEST: GENERATING THE CATEGORY LIST
[TestMethod]
public void Can_Create_Categories() {
// Arrange
// - create the mock repository
Mock<IProductRepository> mock = new Mock<IProductRepository>();
mock.Setup(m => m.Products).Returns(new Product[] {
new Product {ProductID = 1, Name = "P1", Category = "Apples"},
new Product {ProductID = 2, Name = "P2", Category = "Apples"},
new Product {ProductID = 3, Name = "P3", Category = "Plums"},
new Product {ProductID = 4, Name = "P4", Category = "Oranges"},
});
// Arrange - create the controller
NavController target = new NavController(mock.Object);
// Act = get the set of categories
string[] results = ((IEnumerable<string>)target.Menu().Model).ToArray();
// Assert
Assert.AreEqual(results.Length, 3);
Assert.AreEqual(results[0], "Apples");
Assert.AreEqual(results[1], "Oranges");
Assert.AreEqual(results[2], "Plums");
}
8.1.3.3 Creating the View
Objective: Create partial view for Menu
action method
Steps:
- Create
Menu.cshtml
view
Listing 8-8. The Contents of the Menu.cshtml
File
@model IEnumerable<string>
@Html.ActionLink("Home",
"List",
"Product",
null,
new { @class = "btn btn-block btn-default btn-lg" })
@foreach (var link in Model) {
@Html.RouteLink(link,
new { controller = "Product",
action = "List",
category = link,
page = 1 },
new { @class = "btn btn-block btn-default btn-lg" })
}
where:
- First, we added a link called
Home
that will appear at the top of the category list.- This is achieved by using
ActionLink
helper method, which generates an HTML anchor element using the routing information configured earlier.
- This is achieved by using
- Then, we enumerate the categoy names and created links for each of them using
RouteLink
method.RouteLink
is similar toActionLink
, but it let us to supply a set of name/value pairs that are taken into account when generating the URL from the routing configuration. (details are explained in chap 15 and 16)
8.1.3.4 Highlighting the Current Category
Objective: provide visual feedback on category list, so customer could know which category button it clicked.
Steps:
- Creating a view model that contains the lsit of categories and the selected category (Listing 8-9)
- Update the view of selected category (Listing 8-10)
Listing 8-9. Using the View Bag Feature in the NavController.cs
File
namespace SportsStore.WebUI.Controllers {
public class NavController : Controller {
...
// Added a parameter category
public PartialViewResult Menu(string category = null) {
ViewBag.SelectedCategory = category;
IEnumerable<string> categories = repository.Products
.Select(x => x.Category)
.Distinct()
.OrderBy(x => x);
return PartialView(categories);
}
}
}
where:
- We add a parameter
category
toMenu
action method.category
will be provided automatically by routing configuration.
- We dynamically assigned
SelectedCategory
property toViewBag
object and set it value to be the current category
UNIT TEST: REPORTING THE SELECTED CATEGORY
[TestMethod]
public void Indicates_Selected_Category() {
// Arrange
// - create the mock repository
Mock<IProductRepository> mock = new Mock<IProductRepository>();
mock.Setup(m => m.Products).Returns(new Product[] {
new Product {ProductID = 1, Name = "P1", Category = "Apples"},
new Product {ProductID = 4, Name = "P2", Category = "Oranges"},
});
// Arrange - create the controller
NavController target = new NavController(mock.Object);
// Arrange - define the category to selected
string categoryToSelect = "Apples";
// Action
string result = target.Menu(categoryToSelect).ViewBag.SelectedCategory;
// Assert
Assert.AreEqual(categoryToSelect, result);
}
Listing 8-10. Highlighting the Selected Category in the Menu.cshtml
File
...
@foreach (var link in Model) {
@Html.RouteLink(link, new {
controller = "Product",
action = "List",
category = link,
page = 1
}, new {
@class = "btn btn-block btn-default btn-lg"
+ (link == ViewBag.SelectedCategory ? " btn-primary" : "")
})
}
Where:
- If the current
link
value matchesSelectedCategory
value, then the element I am creating to another Bootstrap class, which will cause the button to be highlighted.
8.1.4 Correcting the Page Count
Objective: correct the page links so they work correctly when a category is selected. So the number of page links is determined by number of products in selected category
Steps:
- Update the
List
action method inProduct
controller, so the pagination information takes categoires into account.
Listing 8-11. Creating Category-Aware Pagination Data in the ProductController.cs
File
...
public ViewResult List(string category, int page = 1) {
ProductsListViewModel viewModel = new ProductsListViewModel {
Products = repository.Products
.Where(p => category == null || p.Category == category)
.OrderBy(p => p.ProductID)
.Skip((page - 1) * PageSize)
.Take(PageSize),
PagingInfo = new PagingInfo {
CurrentPage = page,
ItemsPerPage = PageSize,
// If category is selected, returned item list will include correct number of items
TotalItems = category == null ?
repository.Products.Count() :
repository.Products.Where(e => e.Category == category).Count()
},
CurrentCategory = category
};
return View(viewModel);
}
UNIT TEST: CATEGORY-SPECIFIC PRODUCT COUNTS
[TestMethod]
public void Generate_Category_Specific_Product_Count() {
// Arrange
// - create the mock repository
Mock<IProductRepository> mock = new Mock<IProductRepository>();
mock.Setup(m => m.Products).Returns(new Product[] {
new Product {ProductID = 1, Name = "P1", Category = "Cat1"},
new Product {ProductID = 2, Name = "P2", Category = "Cat2"},
new Product {ProductID = 3, Name = "P3", Category = "Cat1"},
new Product {ProductID = 4, Name = "P4", Category = "Cat2"},
new Product {ProductID = 5, Name = "P5", Category = "Cat3"}
});
// Arrange - create a controller and make the page size 3 items
ProductController target = new ProductController(mock.Object);
target.PageSize = 3;
// Action - test the product counts for different categories
int res1 = ((ProductsListViewModel)target
.List("Cat1").Model).PagingInfo.TotalItems;
int res2 = ((ProductsListViewModel)target
.List("Cat2").Model).PagingInfo.TotalItems;
int res3 = ((ProductsListViewModel)target
.List("Cat3").Model).PagingInfo.TotalItems;
int resAll = ((ProductsListViewModel)target
.List(null).Model).PagingInfo.TotalItems;
// Assert
Assert.AreEqual(res1, 2);
Assert.AreEqual(res2, 2);
Assert.AreEqual(res3, 1);
Assert.AreEqual(resAll, 5);
}
8.2 Building the Shopping Cart
Objective: create shopping cart experience as shown below
- An
Add to Cart
button will be displayed alongside each products in catalog - Clicking
Add to Cart
button will show a summary of the selected products - User can click
Continue Shopping
button to return to product catalog. - User can click
Checkout Now
button to complete order
8.2.1 Defining the Cart Entity
Objective: create a shopping cart in business domain
Steps:
- Create an entity in domain model; i.e. Add a class
Cart.cs
toEntities
folder ofSportsStore.Domain
project (Listing 8-12) - Create
CartTests.cs
inSportsStore.UnitTests
project (Omit)
Listing 8-12. The Cart and CartLine Classes in the Cart.cs
File
using System.Collections.Generic;
using System.Linq;
namespace SportsStore.Domain.Entities {
public class Cart {
private List<CartLine> lineCollection = new List<CartLine>();
public void AddItem(Product product, int quantity) {
CartLine line = lineCollection
.Where(p => p.Product.ProductID == product.ProductID)
.FirstOrDefault();
if (line == null) {
lineCollection.Add(new CartLine { Product = product, Quantity = quantity });
} else {
line.Quantity += quantity;
}
}
public void RemoveLine(Product product) {
lineCollection.RemoveAll(l => l.Product.ProductID == product.ProductID);
}
public decimal ComputeTotalValue() {
return lineCollection.Sum(e => e.Product.Price * e.Quantity);
}
public void Clear() {
lineCollection.Clear();
}
public IEnumerable<CartLine> Lines {
get { return lineCollection; }
}
}
public class CartLine {
public Product Product { get; set; }
public int Quantity { get; set; }
}
}
Where:
Cart
class useCartLine
classCartLine
class represent a product selected by customer and quantity the user- As we use
List
; C# provides many methods to work with list (e.g.Add
,Clear
,Sum
, please refer to List Class)
UNIT TEST: TESTING THE CART
Omit, please refer to the textbook, the test cases in CartTests.cs
are:
Can_Add_New_Lines()
Can_Add_Quantity_For_Existing_Lines()
Can_Remote_Line()
Calculate_Cart_Total()
Can_Clear_Contents()
8.2.2 Adding the Add to Cart Buttons
Objective: add "Add to Cart" button to the product listings
Steps:
- Edit
Views/Shared/ProductSummary.cshtml
view (recall ProductSummary is partial view ofList.cshtml
)
Listing 8-13. Adding the Buttons to the ProductSummary.cshtml
File View
@model SportsStore.Domain.Entities.Product
<div class="well">
<h3>
<strong>@Model.Name</strong>
<span class="pull-right label label-primary">@Model.Price.ToString("c")</span>
</h3>
<!-- Create a Razor block to create a small HTML form -->
@using (Html.BeginForm("AddToCart", "Cart")) {
<div class="pull-right">
@Html.HiddenFor(x => x.ProductID)
@Html.Hidden("returnUrl", Request.Url.PathAndQuery)
<input type="submit" class="btn btn-success" value="Add to cart" />
</div>
}
<span class="lead"> @Model.Description</span>
</div>
Where:
- the Razor block is created for a small HTML form for the product using BeginForm. Hence, all products in listing can be added with HTML form.
- When the form is submitted, it will invoke the
AddToCart
action method inCart
controller.
Note: BeginForm helper method creates a form that uses the HTTP POST
method. (Details are not shown here)
8.2.3 Implementing the Cart Controller
Objective: Need a controller to handle "Add to cart" button presses.
Steps:
- Create new controller
CartController.cs
inSportsStore.WebUI
Listing 8-14. The Contents of the CartController.cs
File
using System.Linq;
using System.Web.Mvc;
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
namespace SportsStore.WebUI.Controllers {
public class CartController : Controller {
private IProductRepository repository;
public CartController(IProductRepository repo) {
repository = repo;
}
public RedirectToRouteResult AddToCart(int productId, string returnUrl) {
Product product = repository.Products
.FirstOrDefault(p => p.ProductID == productId);
if (product != null) {
GetCart().AddItem(product, 1);
}
return RedirectToAction("Index", new { returnUrl });
}
public RedirectToRouteResult RemoveFromCart(int productId, string returnUrl) {
Product product = repository.Products
.FirstOrDefault(p => p.ProductID == productId);
if (product != null) {
GetCart().RemoveLine(product);
}
return RedirectToAction("Index", new { returnUrl });
}
private Cart GetCart() {
// Use ASP.NET session state to store and retrieve Cart objects
Cart cart = (Cart)Session["Cart"];
if (cart == null) {
cart = new Cart();
Session["Cart"] = cart;
}
return cart;
}
}
}
Where:
- We use ASP.NET session state feature to store and retrieve
Cart
objects. - ASP.NET has a nice session feature that uses cookies or URL rewriting to associate multiple requests from a user together to form a single browsing session. Details about session state objects refer to Pro ASP.NET MVC5 Platform
- Here we use session state which associate data with a session. (i.e. each user is associated with their own cart)
- Data associated with a session is deleted when a session expires
- To add an object to session state, we set the value for a key on the
Session
object (e.g.Session["Cart"] = cart;
) - To retrieve the object, simply read the key (e.g.
Cart cart = (Cart)Session["Cart"];
)
- Parameter names of
AddToCart
andRemoveFromCart
are set to matchinput
elements in HTML forms inProductSumamry.cshtml
view (i.e. Listing 8-13,x => x.ProductID
and"return"
). So, MVC will automatically associate incoming form POST variables with those parameters. (Something similar to Model Binding) - Both
AddToCart
andRemoveFromCart
methods all theRedirectToAction
method. This method can be used to send an HTTP redirect instruction to client browser, asking the browser to request (jump to) a new URL.
8.2.4 Displaying the Contents of the Cart
Objective: Implement Index
method to display contents of Cart
Steps:
- Create
CartIndexViewModel.cs
in/Models
of WebUI project, to contain (Listing 8-15)Cart
object- URL to redirect after user clicks
Continue shopping
button.
- Implement
Index
action method inCart
controller. (Listing 8-16) - Create
Index.cshtml
to display contents of cart. (Listing 8-17)
Listing 8-15. The Contents of the CartIndexViewModel.cs
File
using SportsStore.Domain.Entities;
namespace SportsStore.WebUI.Models
{
public class CartIndexViewModel
{
public Cart Cart { get; set; }
public string ReturnUrl { get; set; } // URL for Continue Shopping button
}
}
Listing 8-16. The Index Action Method in the CartController.cs
File
using System.Web.Mvc;
using SportsStore.WebUI.Models;
namespace SportsStore.WebUI.Controllers
{
public class CartController : Controller
{
private IProductRepository repository;
public CartController(IProductRepository repo)
{
repository = repo;
}
public ViewResult Index(string returnUrl)
{
return View(new CartIndexViewModel
{
Cart = GetCart(),
ReturnUrl = returnUrl
});
}
Listing 8-17. The Contents of the Index.cshtml
File
@model SportsStore.WebUI.Models.CartIndexViewModel
@{
ViewBag.Title = "Sports Store: Your Cart";
}
<h2>Your cart</h2>
<table class="table">
<thead>
<tr>
<th>Quantity</th>
<th>Item</th>
<th class="text-right">Price</th>
<th class="text-right">Subtotal</th>
</tr>
</thead>
<tbody>
@foreach (var line in Model.Cart.Lines) {
<tr>
<td class="text-center">@line.Quantity</td>
<td class="text-left">@line.Product.Name</td>
<td class="text-right">@line.Product.Price.ToString("c")</td>
<td class="text-right">
@((line.Quantity * line.Product.Price).ToString("c"))
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="text-right">Total:</td>
<td class="text-right">
@Model.Cart.ComputeTotalValue().ToString("c")
</td>
</tr>
</tfoot>
</table>
<div class="text-center">
<a class="btn btn-primary" href="@Model.ReturnUrl">Continue shopping</a>
</div>