添加导航控件 实现通过产品分类对产品进行导航,从以下方面入手。
增强ProductController中的List动作方法,使它能够过滤存储库中的Product对象。
重新考察并增强URL方案,并修订路由策略。
创建一个产品分类列表,将其放入网站工具栏,高亮显示当前分类,并对其他分类进行链接。
过滤产品列表 修改ProductsListView.cs 文件,增加CurrentCategory属性 。
public class ProductsListViewModel { public IEnumerable<Product> Products { get ; set ; } public PagingInfo PagingInfo { get ; set ; } public string CurrentCategory { get ; set ; } }
在ProductController中对List动作方法 添加分类支持 。
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); }
注意 :由于修改了List动作方法的签名,因此需要修改那些使用这个控制器的单元测试方法 ,以null作为传递给List方法的第一个参数。
运行应用程序,并在地址栏后输入:?category=Soccer ,便只会看到Soccer分类的产品。
单元测试:分类过滤 在单元测试中,添加新方法,测试分类过滤功能。
[TestMethod ] public void Can_Filter_Products ( ) { 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 = "Cat1" } }); ProductController controller = new ProductController(mock.Object); controller.PageSize = 3 ; Product[] result = ((ProductsListViewModel)controller.List("Cat2" , 1 ).Model).Products.ToArray(); Assert.AreEqual(result.Length, 2 ); Assert.IsTrue(result[0 ].Name == "P2" && result[0 ].Category == "Cat2" ); Assert.IsTrue(result[1 ].Name == "P4" && result[1 ].Category == "Cat2" ); }
调整URL方案 为了改善“/?category=Soccer ”这种URL,必须设置新的路由方案。
修改RouteConfig.cs 文件中的RegisterRoutes方法 。
public static void RegisterRoutes (RouteCollection routes ) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}" ); routes.MapRoute( null , "" , 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}" ); }
在List视图 中对分页链接添加分页信息 。
@model SportsStore.WebUI.Models.ProductsListViewModel @{ ViewBag.Title = "Products"; } @foreach (var p in Model.Products) { @Html.Partial("ProductSummary", p) } <div class ="btn-group pull-right" > @Html.PageLinks(Model.PagingInfo, x => Url.Action("List", new { page = x, category = Model.CurrentCategory })) </div >
这样,当用户单击链接时,当前分类 会被传递给List动作方法,过滤就会起作用 。
建立分类导航菜单 子动作 :ASP.NET MVC框架中的子动作 ,依赖于Html.Action 的HTML辅助器方法,能够在当前视图中包含一个任意动作方法的输出 。即,可以创建NavController ,带有Menu动作方法 ,该方法渲染了一个导航菜单,然后用Html.Action辅助器方法 ,把该动作方法的输出注入到布局之中 。
创建导航控制器 在SportsStore.WebUI项目的Controller文件夹,添加NavController ,并添加Menu动作方法 。
public class NavController : Controller { public string Menu ( ) { return "Hello from NavController" ; } }
编辑Views/Shared/_Layout.cshtml 文件,去掉占位文本,代之以调用Html.Action辅助器方法 。
<!DOCTYPE html> <html > <head > <meta charset ="utf-8" /> <meta name ="viewport" content ="width=device-width, initial-scale=1.0" > <link href ="~/Content/bootstrap.css" rel ="stylesheet" /> <link href ="~/Content/bootstrap-theme.css" rel ="stylesheet" /> <title > @ViewBag.Title</title > </head > <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" > @Html.Action("Menu", "Nav") </div > <div class ="col-xs-8" > @RenderBody() </div > </div > </body > </html >
生成分类列表 在Menu动作方法 创建分类列表。
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); } }
单元测试:生成分类列表 创建一些有重复且无序 的测试数据,传递给NavController。
[TestMethod ] public void Can_Create_Categories ( ) { 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" } }); NavController target = new NavController(mock.Object); string [] results = ((IEnumerable<string >)target.Menu().Model).ToArray(); Assert.AreEqual(results.Length, 3 ); Assert.AreEqual(results[0 ], "Apples" ); Assert.AreEqual(results[1 ], "Oranges" ); Assert.AreEqual(results[2 ], "Plums" ); }
创建视图 添加视图Menu ,代码如下。
@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" }) }
注意 :
Home链接 出现在分类列表的顶部,在无分类过滤器 作用下列举所有产品,由ActionLink辅助器方法 实现。
RouteLink辅助器方法 为每个分类创建链接,它提供一组键值对 放在匿名对象中,为路由提供数据 。
高亮显示当前分类 对NavController 中的Menu动作方法 修改,使用ViewBag特性 将数据从控制器传递给视图。
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); }
更新Menu视图 ,把CSS的class添加到被选中分类的HTML锚点元素上。
@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" + (link == ViewBag.SelectedCategory ? " btn-primary" : "" ) }) }
单元测试:报告被选中的分类 新增测试方法,测试Menu动作方法是否正确地添加了被选中分类。
[TestMethod ] public void Indicates_Selected_Category ( ) { 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" } }); NavController target = new NavController(mock.Object); string categoryToSelect = "Apples" ; string result = target.Menu(categoryToSelect).ViewBag.SelectedCategory; Assert.AreEqual(categoryToSelect, result); }
修正页面计数 当前,页面链接的数目是由产品总数确定的,而不是由被选中分类中的产品数所确定的。
修改ProductController 中的List动作方法 ,使分页信息把分类考虑进来 。
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 = category == null ? repository.Products.Count() : repository.Products.Where(e => e.Category == category).Count() }, CurrentCategory = category }; return View(model); }
单元测试:特定分类的产品数 新增测试方法,测试不同分类的当前产品数 。
[TestMethod ] public void Generate_Category_Specific_Product_Count ( ) { 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" }, }); ProductController target = new ProductController(mock.Object); target.PageSize = 3 ; 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.AreEqual(res1, 2 ); Assert.AreEqual(res2, 2 ); Assert.AreEqual(res3, 1 ); Assert.AreEqual(resAll, 5 ); }
创建购物车 在一个分类中,每个产品的旁边都显示一个“Add to cart(加入购物车)” 按钮。单击此按钮,将显示该客户目前已选产品的摘要,包括总费用。
在这里,客户可以单击“Continue shopping(继续购物)” 按钮,或者单击“Check out now(立即结算)” 按钮来完成订购,并结束购物会话。
定义购物车实体 在SportsStore.Domain项目 的Entities文件夹 下,添加Cart.cs 文件。
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 Remove (Product product ) { lineCollection.RemoveAll(p => p.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 ; } }
单元测试:测试购物车 在SportsStore.UnitTests项目 中,添加新的单元测试文件CartTests.cs ,测试Cart类的各种行为。
[TestClass ] public class CartTests { [TestMethod ] public void Can_Add_New_Lines ( ) { Product p1 = new Product { ProductID = 1 , Name = "P1" }; Product p2 = new Product { ProductID = 2 , Name = "P2" }; Cart target = new Cart(); target.AddItem(p1, 1 ); target.AddItem(p2, 1 ); CartLine[] results = target.Lines.ToArray(); Assert.AreEqual(results.Length, 2 ); Assert.AreEqual(results[0 ].Product, p1); Assert.AreEqual(results[1 ].Product, p2); } [TestMethod ] public void Can_Add_Quantity_For_Existing_Lines ( ) { Product p1 = new Product { ProductID = 1 , Name = "P1" }; Product p2 = new Product { ProductID = 2 , Name = "P2" }; Cart target = new Cart(); target.AddItem(p1, 1 ); target.AddItem(p2, 1 ); target.AddItem(p1, 10 ); CartLine[] results = target.Lines.OrderBy(c => c.Product.ProductID).ToArray(); Assert.AreEqual(results.Length, 2 ); Assert.AreEqual(results[0 ].Quantity, 11 ); Assert.AreEqual(results[1 ].Quantity, 1 ); } [TestMethod ] public void Can_Remove_Line ( ) { Product p1 = new Product { ProductID = 1 , Name = "P1" }; Product p2 = new Product { ProductID = 2 , Name = "P2" }; Product p3 = new Product { ProductID = 3 , Name = "P3" }; Cart target = new Cart(); target.AddItem(p1, 1 ); target.AddItem(p2, 3 ); target.AddItem(p3, 5 ); target.AddItem(p2, 1 ); target.Remove(p2); Assert.AreEqual(target.Lines.Where(c => c.Product == p2).Count(), 0 ); Assert.AreEqual(target.Lines.Count(), 2 ); } [TestMethod ] public void Calculate_Cart_Total ( ) { Product p1 = new Product { ProductID = 1 , Name = "P1" , Price = 100 M }; Product p2 = new Product { ProductID = 2 , Name = "P2" , Price = 50 M }; Cart target = new Cart(); target.AddItem(p1, 1 ); target.AddItem(p2, 1 ); target.AddItem(p1, 3 ); decimal result = target.ComputeTotalValue(); Assert.AreEqual(result, 450 M); } [TestMethod ] public void Can_Clear_Contents ( ) { Product p1 = new Product { ProductID = 1 , Name = "P1" , Price = 100 M }; Product p2 = new Product { ProductID = 2 , Name = "P2" , Price = 50 M }; Cart target = new Cart(); target.AddItem(p1, 1 ); target.AddItem(p2, 1 ); target.Clear(); Assert.AreEqual(target.Lines.Count(), 0 ); } }
注意 :测试一个类型的功能所需的代码,比该类型本身的代码 还要长的多,且复杂得多 ,但不要因为这种情况而放弃单元测试 。
添加“加入购物车”按钮 修改ProductsSummary.cshtml 视图,将加入购物车 按钮添加到产品列表。
@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 > @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 >
注意 :
@using代码块,为列表中的每一个产品创建一个小型的HTML表单 。当该表单被递交时,将调用Cart控制器 中的AddToCart动作方法 。
BeginForm辅助器 方法会创建一个使用HTTP POST方法 的表单。
在每个产品列表中使用Html.BeginForm辅助器方法 ,意味着每个加入购物车 按钮都会被渲染成独立的HTML的form元素 。由于MVC不使用视图状态 ,因此对所创建的表单数目没有限制 。
实现购物车控制器 在SportsStore.WebUI项目中,添加CartController 。
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().Remove(product); } return RedirectToAction("Index" , new { returnUrl }); } private Cart GetCart ( ) { Cart cart = (Cart)Session["Cart" ]; if (cart == null ) { cart = new Cart(); Session["Cart" ] = cart; } return cart; } }
注意 :
为了存储和接收Cart对象,使用ASP.NET的会话状态特性 ,它将一个用户的多个请求 关联在一起,形成一个单一的浏览会话 。
Session对象 默认存储在ASP.NET服务器的内存 中,但也可以配置其他存储方式,如使用数据库存储 。
AddToCart 和RemoveFromCart 方法,使用了与HTML表单中input元素相匹配的参数名,可以让MVC框架将输入表单的POST变量 与参数 关联起来。
AddToCart 和RemoveFromCart 方法,都调用了RedirectToAction 方法,将一个HTTP的重定向指令发送到浏览器,要求浏览器请求一个新的URL 。本例中,要求浏览器(重新)请求Cart控制器的Index动作方法。
显示购物车内容 需要将两个数据片段传递给显示购物车内容的视图:Cart对象 ,以及用户单击“Continue shopping”按钮时要显示的URL 。
在SportsStore.WebUI项目 的Models文件夹 中,新增CartIndexViewModel.cs 文件。
public class CartIndexViewModel { public Cart Cart { get ; set ; } public string ReturnUrl { get ; set ; } }
在CartController中新增Index动作方法 。
public ViewResult Index (string returnUrl ) { return View(new CartIndexViewModel { Cart = GetCart(), ReturnUrl = returnUrl }); }
创建这个Index视图 。
@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()</td > </tr > </tfoot > </table > <div class ="text-center" > <a class ="btn btn-primary" href ="@Model.ReturnUrl" > Continue shopping</a > </div >
该视图枚举了购物车中的各条信息 ,并在一个HTML的表格中显示各行的总价,以及整个购物车的总价。点击“Add to cart(加入购物车)” 时,相应的产品被添加到购物车,并显示购物车摘要。点击“Continue shopping(继续购物)” 时,返回到之前所在的产品页面。