Главная страница   /   8.1. Добавление элементов навигации (ASP.NET MVC 4

ASP.NET MVC 4

ASP.NET MVC 4

Адам Фриман

8.1. Добавление элементов навигации

Приложение SportsStore будет намного более удобным, если мы позволим пользователям просматривать каталог по категориям. Мы выполним эту работу в три этапа:

  • Расширим модель действия List в классе ProductController так, чтобы она могла фильтровать объекты Product в хранилище.
  • Пересмотрим и улучшим нашу схему URL и исправим стратегию изменения маршрута.
  • Создадим список категорий, который будет размещен в боковой панели сайта, подсветку текущей категории и ссылки на другие категории.

Фильтрация списка товаров

Мы начнем с расширения класса модели представления, ProductsListViewModel, который мы добавили в проект SportsStore.WebUI в предыдущей главе. Мы должны обеспечить связь выбранной на данный момент категории с представлением, чтобы визуализировать боковую панель, так что начнем с этого. Изменения показаны в листинге 8-1.

Листинг 8-1: Расширяем класс ProductsListViewModel
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; }
		public string CurrentCategory { get; set; }
	}
}

Мы добавили новое свойство под названием CurrentCategory. Далее мы обновим класс ProductController, чтобы метод действия List отфильтровывал объекты Product по категориям и использовал новое свойство, которое мы добавили к модели представления, чтобы указывать выбранную категорию. Изменения показаны в листинге 8-2.

Листинг 8-2: Добавляем поддержку категорий в метод действия List
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using SportsStore.WebUI.Models;
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 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,
					TotalItems = repository.Products.Count()
				},
				CurrentCategory = category
			};
			return View(viewModel);
		}
	}
}

Мы сделали три изменения в методе действия. Во-первых, мы добавили новый параметр под названием category. Этот параметр используется вторым изменением, которое представляет собой расширение запроса LINQ: теперь если category не содержит null, будут выбраны только те объекты Product, которые соответствуют свойству Category. Последнее изменение заключается в том, что мы установили значение свойства CurrentCategory, добавленного в класс ProductsListViewModel. Однако, эти изменения означают, что значение PagingInfo.TotalItems рассчитываются неправильно, что мы скоро исправим.

Модульный тест: обновление существующих модульных тестов

Мы изменили сигнатуру метода действия List, из-за чего некоторые из наших существующих модульных тестов не будут скомпилированы. Чтобы решить эту проблему, передайте null в качестве первого параметра в метод List в те модульные тесты, которые работают с контроллером. Например, в тесте Can_Paginate раздел действия станет таким:

...
[TestMethod]
public void Can_Paginate()
{
	// Arrange
	Mock<IProductRepository> mock = new Mock<IProductRepository>();
	mock.Setup(m => m.Products).Returns(new Product[] {
		new Product {ProductID = 1, Name = "P1"},
		new Product {ProductID = 2, Name = "P2"},
		new Product {ProductID = 3, Name = "P3"},
		new Product {ProductID = 4, Name = "P4"},
		new Product {ProductID = 5, Name = "P5"}
		}.AsQueryable());
	// create a controller and make the page size 3 items
	ProductController controller = new ProductController(mock.Object);
	controller.PageSize = 3;
	// Act
	ProductsListViewModel result
		= (ProductsListViewModel)controller.List(null, 2).Model;
	// Assert
	Product[] prodArray = result.Products.ToArray();
	Assert.IsTrue(prodArray.Length == 2);
	Assert.AreEqual(prodArray[0].Name, "P4");
	Assert.AreEqual(prodArray[1].Name, "P5");
}
...

Используя null, мы получаем все объекты Product, которые контроллер получает из хранилища, что полностью повторяет ситуацию, которая была раньше, пока мы не добавили новый параметр.

Даже с этими небольшими изменениями мы можем увидеть эффект фильтрации. Предположим, что вы запускаете приложение и выбираете категорию с помощью строки запроса, например:

http://localhost:61576/?category=Soccer

Вы увидите только товары в категории Soccer, как показано на рисунке 8-1.

Рисунок 8-1: Использование строки запроса для фильтрации по категориям

Модульный тест: фильтрация категорий

Нам нужно тщательно протестировать функцию фильтрации по категориям, чтобы гарантировать, что фильтрация проводится корректно, и мы получаем только продукты из указанной категории. Вот тест:

...
[TestMethod]
public void Can_Filter_Products()
{
	// 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"}
		}.AsQueryable());

	// Arrange - create a controller and make the page size 3 items
	ProductController controller = new ProductController(mock.Object);
	controller.PageSize = 3;

	// Action
	Product[] result = ((ProductsListViewModel)controller.List("Cat2", 1).Model)
		.Products.ToArray();

	// Assert
	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");
}
...

Этот тест создает имитированное хранилище, содержащее объекты Product, которые принадлежат к различным категориям. С помощью метода Action запрашивается одна определенная категория, и, мы проверяем результаты, чтобы убедиться, что получаем правильные объекты в правильном порядке.

Уточняем схему URL

Никому не нужны страшные URL вроде /?category=Soccer. Чтобы это исправить, мы вернемся к схеме маршрутизации и изменим ее таким образом, чтобы она лучше подходила нам (и нашим пользователям). Для реализации нашей новой схемы, измените метод RegisterRoutes в файле App_Start/RouteConfig.cs так, чтобы он соответствовал листингу 8-3, заменяя содержимое метода, который мы использовали в предыдущей главе.

Листинг 8-3: Новая схема URL
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
namespace SportsStore.WebUI
{
	public class RouteConfig
	{
		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}");
		}
	}
}

Внимание

Важно добавлять новые роуты из листинга 8-3 по очереди, как они показаны в листинге. Роуты применяются в том порядке, в котором они определены, и если вы его измените, то получите другой результат.

Таблица 8-1 описывает схему URL, которую представляют эти роуты. Мы расскажем о системе маршрутизации подробно в главе 13.

Таблица 8-1: Информация о роутах
URL Результат
/ Выводит список товаров из всех категорий для первой страницы.
/Page2 Выводит список товаров из всех категорий для указанной страницы (в данном случае страницы 2).
/Soccer Показывает первую страницу товаров из определенной категории (в данном случае категории Soccer).
/Soccer/Page2 Показывает указанную страницу (в данном случае 2) товаров из указанной категории (в данном случае Soccer).
/Anything/Else Вызывает метод действия Else контроллера Anything.

Система маршрутизации ASP.NET используется MVC для обработки входящих запросов от пользователей, но она также запрашивает исходящие URL, которые соответствуют нашей схеме URL, и которые мы можем встроить в веб-страницы. Таким образом гарантируется то, что все URL в приложении последовательны.

Заметка

Мы покажем, как создавать модульные тесты для конфигурации маршрутизации в главе 13.

Метод Url.Action является наиболее удобным способом генерации исходящих ссылок. В предыдущей главе мы использовали этот вспомогательный метод в представлении List.cshtml, чтобы отображать ссылки на страницы. Теперь, когда мы добавили поддержку фильтрации по категориям, мы должны вернуться к нему и передать эту информацию, как показано в листинге 8-4.

Листинг 8-4: Добавляем информацию о категории к ссылкам на страницы
@model SportsStore.WebUI.Models.ProductsListViewModel

@{
	ViewBag.Title = "Products";
}

@foreach (var p in Model.Products)
{
	Html.RenderPartial("ProductSummary", p);
}

<div class="pager">
	@Html.PageLinks(Model.PagingInfo, x => Url.Action("List",
		new { page = x, category = Model.CurrentCategory }))
</div>

До этого изменения ссылки на страницы выглядели так:

http://<myserver>:<port>/Page2

Если пользователь перейдет по такой ссылке, фильтр по категории будет потерян, и он попадет на страницу, содержащую товары из всех категорий. Добавляя текущую категорию, которую мы получаем из модели представления, мы генерируем такие URL:

http://<myserver>:<port>/Chess/Page2

Когда пользователь переходит по такой ссылке, текущая категория будут передана в метод действия List, и фильтрация будет сохранена. После внесения этих изменений, вы можете перейти по таким ссылкам, как /Chess или /Soccer, и увидите, что ссылка внизу страницы включает в себя правильную категорию.

Создаем меню навигации по категориям

Мы должны предоставить пользователям возможность выбора категории. Это означает, что мы должны создать список доступных категорий, в котором будет выделяться выбранная категория, если такая имеется. В процессе работы над приложением мы будем использовать этот список в разных контроллерах, поэтому он должен быть реализован отдельно и предоставлять возможность многократного использования.

В ASP.NET MVC Framework есть концепция дочерних действий, которые идеально подходят для создания таких элементов, как элемент управления навигацией многократного использования. Дочернее действие полагается на вспомогательный метод HTML под названием RenderAction, который позволяет включить вывод из произвольного метода действия в текущее представление. В этом случае мы можем создать новый контроллер (назовем его NavController) с методом действия (в данном случае Menu), который визуализирует меню навигации и внедряет вывод из данного метода в макет.

Такой подход дает нам реальный контроллер, который может содержать любую необходимую нам логику приложения, и который может быть протестирован, как и любой другой контроллер. Это действительно хороший способ создания небольших сегментов приложения, при котором сохраняется общий подход MVC Framework.

Создаем контроллер навигации

Щелкните правой кнопкой мыши папку Controllers в проекте SportsStore.WebUI и выберите пункт Add Controller из контекстного меню. Назовите новый контроллер NavController, выберите опцию Empty MVC controller из меню Template и нажмите кнопку Add to create the class.

Удалите метод Index, который Visual Studio создает по умолчанию, и добавьте метод действия Menu, показанный в листинге 8-5.

Листинг 8-5: Метод действия Menu
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace SportsStore.WebUI.Controllers
{
	public class NavController : Controller
	{
		public string Menu()
		{
			return "Hello from NavController";
		}
	}
}

Этот метод возвращает статическую строку сообщения, но, пока мы интегрируем дочернее действие в приложение, этого для нас достаточно. Мы хотим, чтобы список категории появлялся на всех страницах, так что мы собирается визуализировать дочернее действие в макете, а не в определенном представлении. Отредактируйте файл Views/Shared/_Layout.cshtml так, чтобы он вызывал вспомогательный метод RenderAction, как показано в листинге 8-6.

Листинг 8-6: Добавляем вызов к RenderAction в макет Razor
<!DOCTYPE html>
<html>
<head>
	<meta charset="utf-8" />
	<meta name="viewport" content="width=device-width" />
	<title>@ViewBag.Title</title>
	<link href="~/Content/Site.css" type="text/css" rel="stylesheet" />
</head>
<body>
	<div id="header">
		<div class="title">SPORTS STORE</div>
	</div>
	<div id="categories">
		@{ Html.RenderAction("Menu", "Nav"); }
	</div>
	<div id="content">
		@RenderBody()
	</div>
</body>
</html>

Мы удалили замещающий текст, который добавили в главе 7, и заменили его на вызов метода RenderAction. Параметрами этого метода являются метод действия, который мы хотим вызвать (Menu), и контроллер, который мы хотим использовать (Nav).

Примечание

Метод RenderAction записывает свое содержание непосредственно в поток ответа, как и метод RenderPartial, о котором мы упоминали в главе 5. Это означает, что метод возвращает void, и поэтому его нельзя использовать с регулярным тегом Razor @. Вместо этого мы должны заключить вызов метода в блок кода Razor (и не забудьте поставить точку с запятой в конце оператора). Если вам не нравится синтаксис блока кода, можно использовать метод Action в качестве альтернативы.

Если вы запустите приложение, то увидите, что вывод метода действия Menu включен в каждую страницу, как показано на рисунке 8-2.

Рисунок 8-2: Отображение результата метода действия Menu

Создаем списки категорий

Теперь мы можем вернуться к контроллеру и создать реальный набор категорий. Мы не хотим генерировать категории URL в контроллере. Для этого мы собираемся использовать вспомогательный метод в представлении. В методе действия Menu нужно только создать список категорий, что мы сделали в листинге 8-7.

Листинг 8-7: Реализация метода Menu
using SportsStore.Domain.Abstract;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
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);
		}
	}
}

Сначала мы добавляем конструктор, который принимает реализацию IProductRepository как аргумент - после создания экземпляра контроллера ее предоставит Ninject, используя привязки, которые мы создали в предыдущей главе.

Далее мы изменяем метод действия Menu, который теперь использует запрос LINQ, чтобы получить список категорий из хранилища и передать их в представление. Обратите внимание, что, так как в этом контроллере мы работаем с частичным представлением, здесь мы вызываем метод PartialView, и что результатом является объект PartialViewResult.

Модульный тест: создание списка категорий

Протестировать нашу способность создавать список категорий относительно просто. Наша цель - создать список, который отсортирован в алфавитном порядке и не содержит дубликатов. Самый простой способ это сделать - предоставить неотсортированные тестовые данные с дублирующими категориями, передать их в NavController и задать утверждение, что данные будут правильно обработаны. Вот модульный тест, который мы использовали:

...
[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"},
		}.AsQueryable());

	// 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");
}
...

Мы создали имитированную реализацию хранилища, которая содержит повторяющиеся и неотсортированные категории. Наше утверждение заключается в том, что все повторяющиеся строки будут удалены и данные будут отсортированы в алфавитном порядке.

Создаем частичное представление

Так как список категорий является всего лишь частью страницы, имеет смысл создать частичное представление для метода действия Menu. Кликните правой кнопкой мыши метод Menu в классе NavController и выберите Add View из контекстного меню.

Оставьте представлению имя Menu, отметьте флажком опцию Сreate a strongly typed view, и введите IEnumerable<string> как тип класса модели, как показано на рисунке 8-3.

Рисунок 8-3 : Создаем частичное представление Menu

Отметьте флажком опцию Create as a partial view и нажмите кнопку Add, чтобы создать представление. Измените содержание представления так, чтобы оно соответствовало листингу 8-8.

Листинг 8-8: Частичное представление Menu
@model IEnumerable<string>

@Html.ActionLink("Home", "List", "Product")

@foreach (var link in Model)
{
	@Html.RouteLink(link, new
		{
			controller = "Product",
			action = "List",
			category = link,
			page = 1
		})
}

Мы добавили ссылку под названием Home, которая будет отображаться в верхней части списка категорий и приведет пользователя на первую страницу со списком всех товаров, без фильтра по категории. Мы сделали это с помощью вспомогательного метода ActionLink, который генерирует якорный HTML-элемент с помощью информации о маршрутизации, которую мы настроили ранее.

Затем мы перечислили имена категорий и создали ссылки на каждую из них с помощью метода RouteLink. Он похож на ActionLink, но позволяет нам поставлять набор пар имя/значение, которые учитываются при генерации URL на основе конфигурации маршрутизации. Не беспокойтесь, если вы еще ничего не знаете о маршрутизации – мы подробно объясним все в главе 13.

Генерируемые ссылки будет выглядеть не очень симпатично с настройками по умолчанию, поэтому мы определили код CSS, который улучшит их внешний вид. Добавьте стили, показанные в листинге 8-9, в конец файла Content/Site.css в проекте SportsStore.WebUI.

Листинг 8-9: CSS для ссылок на категории
...
DIV#categories A
{
	font: bold 1.1em "Arial Narrow","Franklin Gothic Medium",Arial; display: block;
	text-decoration: none; padding: .6em; color: Black;
	border-bottom: 1px solid silver;
}
DIV#categories A.selected { background-color: #666; color: White; }
DIV#categories A:hover { background-color: #CCC; }
DIV#categories A.selected:hover { background-color: #666; }
...

Если вы запустите приложение, то увидите ссылки на категории, как показано на рисунке 8-4. Если вы кликните по категории, список элементов обновится и будет отображать только элементы из выбранной категории.

Рисунок 8-4: Ссылки на категории

Подсветка текущей категории

Сейчас мы не подсказываем пользователям, какую категорию они просматривают. Хотя пользователь может понять это по элементам в списке, мы все же предпочитаем обеспечить надежный визуальный индикатор. Для этого мы могли бы создать модель представления, которая содержит список категорий и выбранную категорию; в самом деле, именно это мы бы обычно и сделали. Но для разнообразия мы будем использовать ViewBag, о которой говорилось в главе 2. Этот объект позволяет передавать данные из контроллера в представление, не используя модель представления. Листинг 8-10 показывает изменения в методе действия Menu контроллера Nav.

Листинг 8-10: Использование ViewBag
using SportsStore.Domain.Abstract;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace SportsStore.WebUI.Controllers
{
	public class NavController : Controller
	{
		private IProductRepository repository;
		public NavController(IProductRepository repo)
		{
			repository = repo;
		}
		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 параметр под названием category. Значение этого параметра будет предоставлено автоматически конфигурацией маршрутизации. В теле метода мы динамически создали свойство SelectedCategory в объекте ViewBag и приравняли его значение к значению параметра category. Как мы уже объясняли в главе 2, ViewBag является динамическим объектом, и мы создаем новые свойства, просто устанавливая для них значения.

Модульный тест: Указание выбранной категории

Чтобы проверить, что метод действия Menu правильно добавляет информацию о выбранной категории, проверим в модульном тесте значение свойства ViewBag, которое доступно через класс ViewResult. Вот этот тест:

[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"},
	}.AsQueryable());

	// 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);
}

Обратите внимание, что мы не должны приводить значение свойства из ViewBag. Это одно из преимуществ использования объекта ViewBag перед ViewData.

Теперь, когда мы предоставляем информацию о выбранной категории, можно обновить представление и добавить класс CSS к якорному HTML-элементу, который представляет выбранную категорию. Листинг 8-11 показывает изменения в частичном представлении Menu.cshtml.

Листинг 8-11: Подсветка выбранной категории
@model IEnumerable<string>

@Html.ActionLink("Home", "List", "Product")

@foreach (var link in Model) {
	@Html.RouteLink(link, new {
		controller = "Product",
		action = "List",
		category = link,
		page = 1
	},
	new {
		@class = link == ViewBag.SelectedCategory ? "selected" : null
	})
}

Мы воспользовались перегруженной версией метода RouteLink, что позволяет нам предоставить объект, свойства которого будут добавлены в якорный HTML-элемент как атрибуты. В этом случае ссылке, которая представляет выбранную категорию, присваивается CSS-класс selected.

Примечание

Обратите внимание, что мы использовали @class в анонимном объекте, который мы передали как новый параметр в вспомогательный метод RouteLink. Это не тег Razor. Мы используем стандартную функцию языка C#, чтобы избежать конфликта между ключевым словом HTML class (используется для присвоения стиля CSS к элементу) и того же слова C# (используется для обозначения класса .NET). Символ @ позволяет нам использовать зарезервированные ключевые слова C#, не запутывая компилятор. Если мы просто вызовем параметр class (без @), компилятор будет считать, что мы определяем новый тип C#. Когда мы будем использовать символ @, компилятор поймет, что мы хотим создать параметр в анонимном типе под названием class, и мы получим нужный нам результат.

При запуске приложения будет виден эффект подсветки категории, которую вы также можете видеть на рисунке 8-5.

Рисунок 8-5: Подсветка выбранной категории

Корректируем количество страниц

Нам нужно исправить ссылки на страницы, чтобы они работали правильно, когда выбрана категория. На данный момент количество ссылок на страницы определяется общим количеством товаров в хранилище, а не количеством товаров в выбранной категории. Это означает, что клиент может кликнуть по ссылке на страницу 2 в категории Chess и попадет на пустую страницу, потому что для ее заполнения недостаточно товаров. Вы можете увидеть, как это выглядит, на рисунке 8-6.

Рисунок 8-6: Отображение неправильных ссылок на страницы, когда выбрана категория

Мы можем исправить это, обновив метод действия List в ProductController так, чтобы к информации о нумерации страниц были добавлены сведения о категории. Необходимые изменения показаны в листинге 8-12.

Листинг 8-12: Объединяем данные о нумерации страниц и категории
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,
			TotalItems = category == null ?
				repository.Products.Count() :
				repository.Products.Where(e => e.Category == category).Count()
		},
		CurrentCategory = category
	};
	return View(viewModel);
}

Если категория выбрана, мы возвращаем количество товаров в этой категории, если нет, мы возвращаем общее количество товаров.

Модульный тест: подсчет товаров по категориям

Тест, с помощью которого мы проверим текущий подсчет товаров в различных категориях, очень простой: мы создадим имитированное хранилище, которое содержит известное количество данных в различных категориях, а затем вызовем метод действия List, запрашивая каждую категорию по очереди. Вот модульный тест:

[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"}
	}.AsQueryable());

	// 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);
}

Обратите внимание, что мы также вызываем метод List, не указывая категорию, чтобы убедиться, мы получаем правильный подсчет всех товаров.

Теперь, когда мы просматриваем какую-либо категорию, ссылки в нижней части страницы отражают правильное количество товаров в ней, как показано на рисунке 8-7.

Рисунок 8-7: Отображается правильное количество страниц в категории