4 chap08
Jason Zhu edited this page 2021-09-18 18:49:37 +10:00

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)

  1. Enhance List action method in ProductController, so it can filter Product object in repo
  2. Refactor routing strategy
  3. 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:

  1. Modify view model ProductsListViewModel by adding property CurrentCategory into id. (Listing 8-1)
  2. Update Product controller so the List action method will filter Product objects by category using new property. (Listing 8-2)
    1. Add category parameter to action method
    2. Modify LINQ query of repository to filtering.
    3. 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:

  1. Start the app
  2. 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:

  1. Refactor routing scheme by adding new scheme in RegisterRoutes method in App_Start/RouteConfig.cs file
    1. Route Summary is shown below
  2. 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 category Soccer
  • /Soccer/Page2: Shows page2 of items from the category Soccer

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

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 the List action method

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:

  1. Create a NavController with an action method Menu that renders a navigation menu.
  2. Use Html.Action to inject the output from that method into the layout.

8.1.3.1 Creating the Navigation Controller

Steps:

  1. Create NavController.cs in /Controllers folder, remove Index method, and add new action method Menu (Listing 8-5)
  2. 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 is PartialViewResult 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:

  1. 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.
  • Then, we enumerate the categoy names and created links for each of them using RouteLink method.
    • RouteLink is similar to ActionLink, 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:

  1. Creating a view model that contains the lsit of categories and the selected category (Listing 8-9)
  2. 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 to Menu action method.
    • category will be provided automatically by routing configuration.
  • We dynamically assigned SelectedCategory property to ViewBag 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 matches SelectedCategory 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:

  1. Update the List action method in Product 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

Fig 8-7

  1. An Add to Cart button will be displayed alongside each products in catalog
  2. Clicking Add to Cart button will show a summary of the selected products
  3. User can click Continue Shopping button to return to product catalog.
  4. 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:

  1. Create an entity in domain model; i.e. Add a class Cart.cs to Entities folder of SportsStore.Domain project (Listing 8-12)
  2. Create CartTests.cs in SportsStore.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 use CartLine class
  • CartLine 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:

  1. Edit Views/Shared/ProductSummary.cshtml view (recall ProductSummary is partial view of List.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 in Cart 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:

  1. Create new controller CartController.cs in SportsStore.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 and RemoveFromCart are set to match input elements in HTML forms in ProductSumamry.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 and RemoveFromCart methods all the RedirectToAction 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:

  1. Create CartIndexViewModel.cs in /Models of WebUI project, to contain (Listing 8-15)
    1. Cart object
    2. URL to redirect after user clicks Continue shopping button.
  2. Implement Index action method in Cart controller. (Listing 8-16)
  3. 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>