在之前的章节,偶们设置了核心的基础设施,现在我们将使用基础设计添加关键特性,你将会看到投资是如何回报的。我们能够很简单很容易地添加重要的面向客户的特性。沿途,你也会看到一些MVC框架提供的附加的特性。
1 添加导航控件
如果使用分类导航,需要做以下三个方面:
- 增强List action模型,让它能过滤repository中的Product对象
- 重访并增强URL方案,修改我们的重路由策略
- 创建sidebar风格的分类列表,高亮当前分类,并链接其它分类
1.1 过滤Product列表
偶们要增强视图模型类ProductViewModel。为了渲染sidebar,我们要传送当前分类给view。
1
public
class
ProductsListViewModel
2
{
3
public
IEnumerable
<
Product
>
Products {
get
;
set
; }
4
public
PagingInfo PagingInfo {
get
;
set
; }
5
public
string
CurrentCategory {
get
;
set
; }
6
}
我们给视图模型新增了CurrentCategory属性,下一步是更新ProductController类,让List action方法会以分类过滤Product对象,并是我用我们新增的属性指示那个分类被选中。
1
public
ViewResult List(
string
category,
int
?
id)
2
{
3
int
page
=
id.HasValue
?
id.Value :
1
;
4
ProductsListViewModel viewModel
=
new
ProductsListViewModel
5
{
6
Products
=
repository.Products
7
.Where(p
=>
category
==
null
||
p.Category
==
category)
8
.OrderBy(p
=>
p.ProductID)
9
.Skip((page
-
1
)
*
pageSize)
10
.Take(pageSize),
11
PagingInfo
=
new
PagingInfo
12
{
13
CurrentPage
=
page,
14
ItemPerpage
=
pageSize,
15
TotalItems
=
repository.Products.Count()
16
},
17
CurrentCategory
=
category
18
};
19
return
View(viewModel);
20
}
我们修改了三个部分。第一,我们添加一个叫做category的参数。第二,改进Linq查询,如果category不是Null,仅匹配Category属性的Product对象被选择。最后一个改变是设置CurrentCategory的属性。这些变化会导致不能正确计算TotalItems的值。
1.2 更新已存在的单元测试
我们修改了List action方法的签名,它会放置一些已经存在的单元测试方法被编译。为了解决此事,传递null作为List方法的第一个参数。例如Can_Send_Pagination_View_Model,会变成这样
1
ProductsListViewModel result
=
(ProductsListViewModel)controller.List(
null
,
2
).Model;
通过使用null,我们像以前一样,得到了全部的repository。
1.3 分类过滤单元测试
1
[TestMethod]
2
public
void
Can_Filter_Products()
3
{
4
Mock
<
IProductRepository
>
mock
=
new
Mock
<
IProductRepository
>
();
5
mock.Setup(m
=>
m.Products).Returns(
new
Product[]{
6
new
Product {ProductID
=
1
,Name
=
"
P1
"
,Category
=
"
Cat1
"
},
7
new
Product {ProductID
=
2
,Name
=
"
P2
"
,Category
=
"
Cat2
"
},
8
new
Product {ProductID
=
3
,Name
=
"
P3
"
,Category
=
"
Cat1
"
},
9
new
Product {ProductID
=
4
,Name
=
"
P4
"
,Category
=
"
Cat2
"
},
10
new
Product {ProductID
=
5
,Name
=
"
P5
"
,Category
=
"
Cat3
"
}
11
}.AsQueryable());
12
13
//
Arrange
14
ProductController controller
=
new
ProductController(mock.Object);
15
controller.pageSize
=
3
;
16
17
//
Action
18
Product[] result
=
((ProductsListViewModel)controller.List(
"
Cat2
"
,
1
).Model).Products.ToArray();
19
20
//
Assert
21
Assert.AreEqual(result.Length,
2
);
22
Assert.IsTrue(result[
0
].Name
==
"
P2
"
&&
result[
0
].Category
==
"
Cat2
"
);
23
Assert.IsTrue(result[
1
].Name
==
"
P4
"
&&
result[
1
].Category
==
"
Cat2
"
);
24
}
1.4 改善URL方案
没有人像看到或使用丑陋的URLs,如/?category=Soccer。
1
public
static
void
RegisterRoutes(RouteCollection routes)
2
{
3
routes.IgnoreRoute(
"
{resource}.axd/{*pathInfo}
"
);
4
5
routes.MapRoute(
null
,
6
""
,
//
匹配空URL,如 /
7
new
8
{
9
controller
=
"
Product
"
,
10
action
=
"
List
"
,
11
category
=
(
string
)
null
,
12
id
=
1
13
}
14
);
15
16
routes.MapRoute(
17
null
,
18
"
Page{id}
"
,
//
匹配 /Page2 ,但是不能匹配 /PageX
19
new
{ controller
=
"
Product
"
, action
=
"
List
"
, category
=
(
string
)
null
},
20
new
{ id
=
@"
\d+
"
}
//
约束:id必须是数字
21
);
22
23
routes.MapRoute(
null
,
24
"
{category}
"
,
//
匹配 /Football 或 /没有斜线的任何字符
25
new
26
{
27
controller
=
"
Product
"
,
28
action
=
"
List
"
,
29
id
=
1
30
});
31
32
routes.MapRoute(
33
null
,
//
路由名称
34
"
{category}/Page{id}
"
,
//
匹配 /Football/Page567
35
new
{ controller
=
"
Product
"
, action
=
"
List
"
},
36
new
{ id
=
@"
\d+
"
}
37
);
38
39
}
路由添加的顺序是很重要的。如果改变顺序,会有意想不到的效果。
URL |
Leads To |
/ |
显示所有分类的products列表的第一页 |
/Page2 |
显示所有类别的items列表的第二页 |
/Soccer |
显示指定分类的items列表的第一页 |
/Soccer/Page2 |
显示指定分类的items列表的指定页 |
/Anything/Else |
调用Anything controller的Else action |
路由系统既能处理来自客户端的请求,也能处理我们发出的URLs请求。
Url.Action方法是生成外向链接的最方便的方式。之前,我们用它来显示Page links,现在,为了分类过滤,需要传递这个信息给helper方法。
1
@Html.PageLinks(Model.PagingInfo, x
=>
Url.Action(
"
List
"
,
2
new
{ id
=
x,category
=
Model.CurrentCategory}))
通过传递CurrentCategory我们生成的URL不会丢失分类过滤信息。
2 构建分类导航目录
我们会在多个controllers中用到这个分类列表,所以它应该独立,并可以重用。MVC框架有child action的概念,特别适合用来创建可重用的导航控件。Child Action依赖RenderAction这个HTML helper方法,它能让你在当前view中包含数量的action方法的输出。
这个方法给我们一个真实的controller,包含任何我们需要的程序逻辑,并能像其他controller一样单元测试。这确实是一个不错的方法,创建程序的小片段,保持整个MVC框架的方法。
2.1 创建导航控件
需要创建一个新的NavController controller,Menu action,用来渲染导航目录,并将方法的输出注入到layout。
1
public
string
Menu()
2
{
3
return
"
Hello from NavController
"
;
4
}
要想在layout中渲染child action,编辑_Layout.cshtml文件,调用RenderAction help方法。
1
<
div id
=
"
categories
"
>
2
@{ Html.RenderAction(
"
Menu
"
,
"
Nav
"
); }
3
</
div
>
RenderAction方法直接将content写入response流,像RenderPartial方法一样。这意味着方法返回void,它不能使用常规的Razor@tag。我们必须在Razor代码块中闭合调用方法,并使用分号终止声明。也可以使用Action方法,如果不喜欢代码块语法。
2.2 生成分类列表
我们不想在controller中生成URLs,我们用helper方法来做这些。所有我们要在Menu action方法中做的,就是创建一个分类列表:
1
public
class
NavController : Controller
2
{
3
//
4
//
GET: /Nav/
5
private
IProductRepository repository;
6
7
public
NavController(IProductRepository repo)
8
{
9
repository
=
repo;
10
}
11
12
public
PartialViewResult Menu()
13
{
14
IEnumerable
<
string
>
categories
=
repository.Products
15
.Select(x
=>
x.Category)
16
.Distinct()
17
.OrderBy(x
=>
x);
18
19
return
PartialView(categories);
20
}
Menu action方法很简单,它只用Linq查询,获得分类的名字的列表,并传输他们到视图。
2.3 生成分类列表的单元测试
我们的目标是要生成一个按字母表排列的没有重复项的列表。最简单的方式,是提供含有重复分类的,没有排列顺序的测试数据,传递给NavController,断言数据已经处理了干净了。
1
[TestMethod]
2
public
void
Can_Create_Categories()
3
{
4
Mock
<
IProductRepository
>
mock
=
new
Mock
<
IProductRepository
>
();
5
mock.Setup(m
=>
m.Products).Returns(
new
6
Product[]{
7
new
Product{ProductID
=
1
,Name
=
"
P1
"
,Category
=
"
Apples
"
},
8
new
Product{ProductID
=
2
,Name
=
"
P2
"
,Category
=
"
Apples
"
},
9
new
Product{ProductID
=
3
,Name
=
"
P3
"
,Category
=
"
Plums
"
},
10
new
Product{ProductID
=
4
,Name
=
"
P4
"
,Category
=
"
Oranges
"
}
11
}.AsQueryable());
12
13
NavController target
=
new
NavController(mock.Object);
14
15
string
[] results
=
((IEnumerable
<
string
>
)target.Menu().Model).ToArray();
16
17
Assert.AreEqual(results.Length,
3
);
18
Assert.AreEqual(results[
0
],
"
Apples
"
);
19
Assert.AreEqual(results[
1
],
"
Oranges
"
);
20
Assert.AreEqual(results[
2
],
"
Plums
"
);
21
}
2.4 创建部分视图
视图名Menu,选中创建部分视图,模型类填IEnumerable<string>
1
@model IEnumerable
<
string
>
2
3
@{
4
Layout
=
null
;
5
}
6
7
@Html.ActionLink(
"
Home
"
,
"
List
"
,
"
Product
"
)
8
9
@foreach(var link
in
Model){
10
@Html.RouteLink(link,
new
11
{
12
controller
=
"
Product
"
,
13
action
=
"
List
"
,
14
category
=
link,
15
id
=
1
16
})
17
}
我们添加叫做Home的链接,会显示在分类列表的顶部,让和用户返回到没有分类过滤的,所有products列表的首页。为了做到这点,使用了ActionLink helper方法,使用偶们早前配置的路由信息生成HTML anchor元素。
然后枚举分类名字,使用RouteLink方法为他们创建连接。有点像ActionLink,但它让我们提供一组name/value pairs,当从路由配置生成URL时。
2.4 高亮当前分类
一般我们会创建一个包含分类列表和被选中的分类的视图模型。但是这次,我们展示View Bag特性。这个特性允许我们不使用视图模型,从controller传递数据到view。
1
public
ViewResult Menu(
string
category
=
null
)
2
{
3
ViewBag.SelectedCategory
=
category;
4
5
IEnumerable
<
string
>
categories
=
repository.Products
6
.Select(x
=>
x.Category)
7
.Distinct()
8
.OrderBy(x
=>
x);
9
10
return
View(categories);
11
}
我们添加给Menu action方法添加了category参数,它由路由配置自动提供。我们给View的ViewBag动态创建了SelectedCategory属性,并设置它的值。ViewBag是一个动态对象。
2.5 报告被选中分类的单元测试
通过读取ViewBag中属性的值,我们可以测试Menu action方法是否正确地添加了被选中分类的细节。
1
[TestMethod]
2
public
void
Indicates_Selected_Category()
3
{
4
Mock
<
IProductRepository
>
mock
=
new
Mock
<
IProductRepository
>
();
5
mock.Setup(m
=>
m.Products).Returns(
6
new
Product[]{
7
new
Product{ProductID
=
1
,Name
=
"
P1
"
,Category
=
"
Apples
"
},
8
new
Product{ProductID
=
4
,Name
=
"
P4
"
,Category
=
"
Oranges
"
}
9
}.AsQueryable());
10
11
//
Arrange - create to controller
12
NavController target
=
new
NavController(mock.Object);
13
14
//
Arrage - define the category to selected
15
string
categoryToSelect
=
"
Apples
"
;
16
17
//
Action
18
string
result
=
target.Menu(categoryToSelect).ViewBag.SelectedCategory;
19
20
//
Assert
21
Assert.AreEqual(categoryToSelect, result);
22
}
我们不需要转换ViewBag属性的值,这是相对于ViewData先进的地方。
1
new
{
2
@class
=
link
==
ViewBag.SelectedCategory
?
"
selected
"
:
null
3
}
在Menu.cshtml局部视图中的@html.RouteLink增加第三个参数。第一个参数是string linkText,第二个参数是object routeValues,第三个参数是object htmlAttributes。当前选中的分类会被指派 selected CSS类。
注意在匿名对象中的@class,作为新参数传递给RouteLink helper方法。它不是Razor tag。HTML使用class给元素指派CSS样式,C#使用class创建class。我们使用了C#特性,避免与HTML关键字class冲突。@符号允许我们使用保留的关键字。如果我们仅调用class参数,不加@,编译器会假设我们定义了一个新的C#类型。当我们使用@符号,编译器会知道我们想要创建在匿名类型中创建一个叫做class的参数。
2.6 修正页面总数
当前,页数指向所有的产品。当使用分类后,页数应不同。我们可以通过更新List action方法的ProductController,修复它。分页信息携带分类到总数。
1
TotalItems
=
category
==
null
?
2
repository.Products.Count():
3
repository.Products.Where(e
=>
e.Category
==
category).Count()
如果分类被选中,我们返回这个分类的items数。如果没有选中,返回总数。
1
[TestMethod]
2
public
void
Generate_Category_Specific_Product_Count()
3
{
4
Mock
<
IProductRepository
>
mock
=
new
Mock
<
IProductRepository
>
();
5
mock.Setup(m
=>
m.Products).Returns(
6
new
Product[]{
7
new
Product {ProductID
=
1
,Name
=
"
P1
"
,Category
=
"
Cat1
"
},
8
new
Product {ProductID
=
2
,Name
=
"
P2
"
,Category
=
"
Cat2
"
},
9
new
Product {ProductID
=
3
,Name
=
"
P3
"
,Category
=
"
Cat1
"
},
10
new
Product {ProductID
=
4
,Name
=
"
P4
"
,Category
=
"
Cat2
"
},
11
new
Product {ProductID
=
5
,Name
=
"
P5
"
,Category
=
"
Cat3
"
}
12
}.AsQueryable());
13
//
Arrange - create a controller and make the page size 3 items
14
ProductController target
=
new
ProductController(mock.Object);
15
target.pageSize
=
3
;
16
17
//
Action - test the product counts for different categories
18
int
res1
=
((ProductsListViewModel)target.List(
"
Cat1
"
).Model).PagingInfo.TotalItems;
19
int
res2
=
((ProductsListViewModel)target.List(
"
Cat2
"
).Model).PagingInfo.TotalItems;
20
int
res3
=
((ProductsListViewModel)target.List(
"
Cat3
"
).Model).PagingInfo.TotalItems;
21
int
res4
=
((ProductsListViewModel)target.List(
null
).Model).PagingInfo.TotalItems;
22
23
//
Assert
24
Assert.AreEqual(res1,
2
);
25
Assert.AreEqual(res2,
2
);
26
Assert.AreEqual(res3,
1
);
27
Assert.AreEqual(res4,
5
);
28
}