ASP.NET MVC5(六)SportsStore导航

作者 Zhendong Ho 日期 2021-04-10
ASP.NET MVC5(六)SportsStore导航

添加导航控件

实现通过产品分类对产品进行导航,从以下方面入手。

  • 增强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)	// 添加category参数
{
ProductsListViewModel model = new ProductsListViewModel
{
Products = repository.Products
.Where(p => category == null || p.Category == 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 // 添加到ViewModel返回值
};
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" }
});

// 准备——创建控制器,并使页面大小为3个物品
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" },
});

// 准备——创建控制器并使页面容纳3个物品
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>();

/// <summary>
/// 给购物车添加物品
/// </summary>
/// <param name="product"></param>
/// <param name="quantity"></param>
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;
}
}

/// <summary>
/// 从购物车中删除已添加的物品
/// </summary>
/// <param name="product"></param>
public void Remove(Product product)
{
lineCollection.RemoveAll(p => p.Product.ProductID == product.ProductID);
}

/// <summary>
/// 计算购物车物品的总价
/// </summary>
/// <returns></returns>
public decimal ComputeTotalValue()
{
return lineCollection.Sum(e => e.Product.Price * e.Quantity);
}

/// <summary>
/// 删除全部物品重置购物车
/// </summary>
public void Clear()
{
lineCollection.Clear();
}

/// <summary>
/// 对购物车的内容进行访问
/// </summary>
public IEnumerable<CartLine> Lines
{
get { return lineCollection; }
}
}

/// <summary>
/// 客户所选的一个产品和用户想要购买的数量
/// </summary>
public class CartLine
{
public Product Product { get; set; }
public int Quantity { get; set; }
}

单元测试:测试购物车

SportsStore.UnitTests项目中,添加新的单元测试文件CartTests.cs,测试Cart类的各种行为。

[TestClass]
public class CartTests
{
/// <summary>
/// 测试将物品添加到购物车
/// </summary>
[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);
}

/// <summary>
/// 已经对购物车添加了一个Product,希望增加相应CartLine的数量,而不是创建一个新的CartLine对象
/// </summary>
[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);
}

/// <summary>
/// 测试从购物车删除产品
/// </summary>
[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);
}

/// <summary>
/// 测试计算购物车中各物品总价
/// </summary>
[TestMethod]
public void Calculate_Cart_Total()
{
// 准备——创建一些测试产品
Product p1 = new Product { ProductID = 1, Name = "P1", Price = 100M };
Product p2 = new Product { ProductID = 2, Name = "P2", Price = 50M };

// 准备——创建一个新的购物车
Cart target = new Cart();

// 动作
target.AddItem(p1, 1);
target.AddItem(p2, 1);
target.AddItem(p1, 3);
decimal result = target.ComputeTotalValue();

// 断言
Assert.AreEqual(result, 450M);
}

/// <summary>
/// 测试重置购物车后是否删除了购物车内容
/// </summary>
[TestMethod]
public void Can_Clear_Contents()
{
// 准备——创建一些测试产品
Product p1 = new Product { ProductID = 1, Name = "P1", Price = 100M };
Product p2 = new Product { ProductID = 2, Name = "P2", Price = 50M };

// 准备——创建一个新的购物车
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服务器的内存中,但也可以配置其他存储方式,如使用数据库存储
  • AddToCartRemoveFromCart方法,使用了与HTML表单中input元素相匹配的参数名,可以让MVC框架将输入表单的POST变量参数关联起来。
  • AddToCartRemoveFromCart方法,都调用了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(继续购物)”时,返回到之前所在的产品页面。