Навигация
196ASP.NET --- Интернет магазин --- Навигация
Исходный код проектаВ предыдущих статьях была построена основная инфраструктура приложения GameStore. Теперь мы воспользуемся этой инфраструктурой для добавления функциональных средств к приложению и увидим первые результаты проделанной работы. И начнем мы с добавления навигационных элементов управления - приложение GameStore станет намного удобнее, если будет предоставлять пользователям возможность навигации по товарам с учетом категории. Эта работа состоит из трех частей:
Расширение метода действия List() в классе GameController, чтобы он получил возможность фильтрации объектов Game в хранилище.
Расширение схемы URL и пересмотр стратегии маршрутизации.
Создание списка категорий, размещенного в боковой панели сайта, который будет подсвечивать текущую категорию, а также поддерживать ссылки на другие категории.
Фильтрация списка товаров
Мы начнем с расширения класса модели представления GamesListViewModel, который был добавлен к проекту GameStore.WebUI. Нам нужно обеспечить взаимодействие текущей категории с представлением, чтобы визуализировать боковую панель, и это хорошее место для старта. В примере ниже показаны изменения, внесенные в файл GamesListView.cs:
using System.Collections.Generic;
using GameStore.Domain.Entities;
namespace GameStore.WebUI.Models
{
public class GamesListViewModel
{
public IEnumerable<Game> Games { get; set; }
public PagingInfo PagingInfo { get; set; }
public string CurrentCategory { get; set; }
}
}
В класс GamesListViewModel добавлено свойство по имени CurrentCategory. Следующий шаг заключается в обновлении класса GameController, чтобы метод действия List() фильтровал объекты Game по категории и использовал только что добавленное в модель представления свойство для указания категории, выбранной в текущий момент. Соответствующие изменения приведены в примере ниже:
// ...
namespace GameStore.WebUI.Controllers
{
public class GameController : Controller
{
// ...
public ViewResult List(string category, int page = 1)
{
GamesListViewModel model = new GamesListViewModel
{
Games = repository.Games
.Where(p => category == null || p.Category == category)
.OrderBy(game => game.GameId)
.Skip((page - 1)*pageSize)
.Take(pageSize),
PagingInfo = new PagingInfo
{
CurrentPage = page,
ItemsPerPage = pageSize,
TotalItems = repository.Games.Count()
},
CurrentCategory = category
};
return View(model);
}
}
}
В этот метод действия внесены три изменения. Первое - добавлен новый параметр по имени category. Он используется вторым изменением, которое представляет собой расширение запроса LINQ. Если значение category не равно null, значит, выбраны только те объекты Game, которые соответствуют значению свойства Category. Последнее, третье, изменение касается установки значения свойства CurrentCategory, которое было добавлено в класс GamesListViewModel. Однако в результате этих изменений значение PagingInfo.TotalItems будет вычисляться некорректно. Со временем мы все исправим.
Модульное тестирование: обновление существующих модульных тестов
Мы изменили сигнатуру метода действия List(), поэтому некоторые существующие методы модульного тестирования перестали компилироваться. Для решения этой проблемы в модульных тестах, которые работают с контроллером, методу действия List() необходимо передавать в первом параметре значение null. Например, в тестовом методе Can_Paginate() раздел действия должен выглядеть следующим образом:
[TestMethod]
public void Can_Paginate()
{
// ...
// Действие (act)
GamesListViewModel result = (GamesListViewModel)controller.List(null, 2).Model;
// ...
}
За счет использования null мы получаем все объекты Game, которые контроллер извлекает из хранилища, что воспроизводит ситуацию, существовавшую перед добавлением нового параметра. Такого же рода изменение понадобится внести и в тестовый метод Can_Send_Pagination_View_Model():
[TestMethod]
public void Can_Send_Pagination_View_Model()
{
// ...
// Act
GamesListViewModel result
= (GamesListViewModel)controller.List(null, 2).Model;
// ...
}
Когда вы примете образ мышления, ориентированный на тестирование, обеспечение синхронизации модульных тестов с внесением изменений в код очень быстро станет вашей второй натурой.
Даже при таких небольших изменениях результат фильтрации категорий хорошо заметен. Запустите приложение и выберите категорию с помощью показанной ниже строки запроса, заменив номер порта тем, который был назначен проекту средой Visual Studio:
http://localhost:53985/?category=Симулятор
Вы увидите только товары из категории "Симулятор":
Очевидно, что пользователи не должны переходить по категориям с применением URL, но здесь показано, что совсем незначительные изменения в приложении MVC Framework могут оказывать существенное влияние, если базовая структура на месте.
Модульное тестирование: фильтрация по категории
Нам необходим модульный тест для проверки функциональности фильтрации по категории, чтобы удостовериться в том, что фильтр может корректно генерировать сведения о товарах указанной категории. Тестовый метод выглядит следующим образом:
[TestMethod]
public void Can_Filter_Games()
{
// Организация (arrange)
Mock<IGameRepository> mock = new Mock<IGameRepository>();
mock.Setup(m => m.Games).Returns(new List<Game>
{
new Game { GameId = 1, Name = "Игра1", Category="Cat1"},
new Game { GameId = 2, Name = "Игра2", Category="Cat2"},
new Game { GameId = 3, Name = "Игра3", Category="Cat1"},
new Game { GameId = 4, Name = "Игра4", Category="Cat2"},
new Game { GameId = 5, Name = "Игра5", Category="Cat3"}
});
GameController controller = new GameController(mock.Object);
controller.pageSize = 3;
// Action
List<Game> result = ((GamesListViewModel)controller.List("Cat2", 1).Model)
.Games.ToList();
// Assert
Assert.AreEqual(result.Count(), 2);
Assert.IsTrue(result[0].Name == "Игра2" && result[0].Category == "Cat2");
Assert.IsTrue(result[1].Name == "Игра4" && result[1].Category == "Cat2");
}
Этот тест создает имитированное хранилище, содержащее объекты Game, которые относятся к определенному диапазону категорий. С использованием метода действия List() запрашивается одна специфическая категория, а результаты проверяются на предмет содержания корректных объектов в правильном порядке.
Улучшение схемы URL
Мало кто желает видеть или пользоваться неуклюжими URL вроде "/?category=Симулятор". Для решения этой проблемы мы намерены пересмотреть схему маршрутизации, чтобы создать подход к URL, который в большей степени удовлетворяет как нашим потребностям, так и потребностям конечных пользователей. Для реализации новой схемы модифицируйте метод RegisterRoutes() в файле App_Start/RouteConfig.cs, как показано в примере ниже:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
namespace GameStore.WebUI
{
public class RouteConfig
{
public static void RegisterRoutes(RouteCollection routes)
{
routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
routes.MapRoute(null,
"",
new
{
controller = "Game",
action = "List",
category = (string)null,
page = 1
}
);
routes.MapRoute(
name: null,
url: "Page{page}",
defaults: new { controller = "Game", action = "List", category = (string)null },
constraints: new { page = @"\d+" }
);
routes.MapRoute(null,
"{category}",
new { controller = "Game", action = "List", page = 1 }
);
routes.MapRoute(null,
"{category}/Page{page}",
new { controller = "Game", action = "List" },
new { page = @"\d+" }
);
routes.MapRoute(null, "{controller}/{action}");
}
}
}
Новые маршруты важно добавлять в указанном порядке. Маршруты применяются в порядке, в котором они определены, поэтому изменение порядка может привести к нежелательным эффектам.
В таблице ниже описана схема URL, которую представляют эти маршруты.
URL | Что делает |
---|---|
/ | Выводит первую страницу списка товаров всех категорий |
/Page2 | Выводит указанную страницу (в этом случае страницу 2), отображая товары всех категорий |
/Симулятор | Отображает первую страницу элементов указанной категории (в этом случае игры в разделе "Симуляторы") |
/Симулятор/Page2 | Отображает заданную страницу (в этом случае страницу 2) элементов указанной категории (Симулятор) |
Система маршрутизации ASP.NET применяется инфраструктурой MVC для обработки входящих запросов от пользователей, а также генерирует исходящие URL, которые соответствуют схеме URL и поэтому могут быть встроены в веб-страницы. Использование системы маршрутизации для обработки входящих запросов и генерации исходящих URL позволяет гарантировать согласованность всех URL в приложении.
Метод Url.Action() - это наиболее удобный способ генерации исходящих ссылок. Этот метод применялся в представлении List для отображения ссылок на страницы. Теперь, когда добавлена поддержка фильтрации по категориям, этому методу необходимо передать соответствующую информацию, как показано в примере ниже:
@using GameStore.WebUI.Models
@using GameStore.WebUI.HtmlHelpers
@model GamesListViewModel
@{
ViewBag.Title = "Товары";
}
@foreach (var p in @Model.Games)
{
@Html.Partial("GameSummary", p)
}
<div class="btn-group pull-right">
@Html.PageLinks(Model.PagingInfo, x => Url.Action("List",
new { page = x, category = Model.CurrentCategory }))
</div>
До внесения этого изменения генерируемые ссылки на страницы имели следующий вид:
http://<сервер>:<порт>/Page1
Если пользователь щелкнет на страничной ссылке вроде этой, примененный ранее фильтр по категории теряется, и будет выведена страница, содержащая товары всех категорий. За счет добавления текущей категории, получаемой из модели представления, генерируются URL такого вида:
http://<сервер>:<порт>/Шутер/Page1
Когда пользователь щелкает на подобной ссылке, текущая категория передается методу действия List() и фильтрация сохраняется. После этого изменения, при посещении URL вида "/Шутер" или "/Симулятор" страничные ссылки в нижней части будут корректно включать категорию.
Построение меню навигации по категориям
Нам необходимо предоставить пользователям возможность выбора категории, не предусматривающую ввод чего-либо в URL. Это означает, что мы должны показать список доступных категорий с отмеченной текущей категорией, если она есть. После построения приложения этот список категорий будет применяться в более чем одном контроллере, поэтому он должен быть самодостаточным и многократно используемым.
Инфраструктура ASP.NET MVC Framework поддерживает концепцию дочерних действий, которые идеально подходят для создания компонентов, таких как многократно используемый навигационный элемент управления. Дочернее действие полагается на вспомогательный метод HTML по имени Html.Action(), который позволяет включать в текущее представление вывод из произвольного метода действия.
В этом случае мы можем создать новый контроллер (под названием NavController) с методом действия (в данном случае Menu()), который визуализирует навигационное меню. Затем посредством вспомогательного метода Html.Action() вывод из этого метода встраивается в компоновку.
Такой подход позволяет получить реальный контроллер, который может содержать любую необходимую прикладную логику и который можно подвергать модульному тестированию подобно любому другому контроллеру. Это действительно удобный способ построения небольших сегментов приложения при сохранении общего подхода, принятого в MVC Framework.
Создание контроллера навигации
Щелкните правой кнопкой мыши на папке Controllers в проекте GameStore.WebUI и выберите в контекстном меню пункт Add --> Controller (Добавить --> Контроллер). Укажите вариант MVC 5 Controller - Empty (Контроллер MVC 5 - Пустой) в диалоговом окне Add Scaffold (Добавление шаблона) и щелкните на кнопке Add. В диалоговом окне Add Controller (Добавление контроллера) введите для имени контроллера NavController и щелкните на кнопке Add, чтобы создать файл класса NavController.cs. Удалите метод Index(), который Visual Studio по умолчанию добавляет к новым контроллерам, и добавьте метод действия Menu(), приведенный в примере ниже:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace GameStore.WebUI.Controllers
{
public class NavController : Controller
{
public string Menu()
{
return "Тестируем контроллер Nav";
}
}
}
Данный метод возвращает статическую строку сообщения, но этого достаточно для начала интеграции дочернего действия в остальную часть приложения. Нам нужно, чтобы список категорий отображался на всех страницах, поэтому мы собираемся визуализировать дочернее действие в компоновке, а не в отдельном представлении.
Отредактируйте файл 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="#">GameStore - магазин компьютерных игр</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>
Текст заполнителя заменен вызовом метода Html.Action(). В качестве параметров этому методу передаются имя метода действия для вызова (Menu()) и содержащий его контроллер (Nav). Запустив приложение, вы увидите, что вывод из метода действия Menu() включен в ответ, отправляемый браузеру:
Генерация списков категорий
Теперь можно вернуться к контроллеру Nav и сгенерировать реальный набор категорий. Мы не хотим генерировать URL категорий в контроллере, а собираемся использовать для этого метод действия в представлении. Все, что планируется сделать в методе действия Menu() - это создать список категорий, как показано в примере ниже:
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using GameStore.Domain.Abstract;
namespace GameStore.WebUI.Controllers
{
public class NavController : Controller
{
private IGameRepository repository;
public NavController(IGameRepository repo)
{
repository = repo;
}
public PartialViewResult Menu()
{
IEnumerable<string> categories = repository.Games
.Select(game => game.Category)
.Distinct()
.OrderBy(x => x);
return PartialView(categories);
}
}
}
Первое изменение связано с добавлением конструктора, который принимает в качестве своего аргумента реализацию IGameRepository. Результатом является объявление зависимости, которую Ninject будет распознавать при создании экземпляров класса NavController. Второе изменение касается метода действия Menu(), который теперь использует запрос LINQ для получения списка категорий из хранилища и передачи их представлению. Обратите внимание, что поскольку работа в этом контроллере производится с частичным представлением, в методе действия вызывается метод PartialView(), а результатом является объект PartialViewResult.
Модульное тестирование: генерация списка категорий
Модульный тест, предназначенный для проверки возможности генерации списка категорий, относительно прост. Цель заключается в создании списка, который отсортирован в алфавитном порядке и не содержит дубликатов. Для этого проще всего построить тестовые данные, которые имеют дублированные категории и не отсортированы должным образом, передать их в NavController и установить утверждение, что данные будут соответствующим образом очищены.
Тестовый метод выглядит следующим образом:
[TestMethod]
public void Can_Create_Categories()
{
// Организация - создание имитированного хранилища
Mock<IGameRepository> mock = new Mock<IGameRepository>();
mock.Setup(m => m.Games).Returns(new List<Game> {
new Game { GameId = 1, Name = "Игра1", Category="Симулятор"},
new Game { GameId = 2, Name = "Игра2", Category="Симулятор"},
new Game { GameId = 3, Name = "Игра3", Category="Шутер"},
new Game { GameId = 4, Name = "Игра4", Category="RPG"},
});
// Организация - создание контроллера
NavController target = new NavController(mock.Object);
// Действие - получение набора категорий
List<string> results = ((IEnumerable<string>)target.Menu().Model).ToList();
// Утверждение
Assert.AreEqual(results.Count(), 3);
Assert.AreEqual(results[0], "RPG");
Assert.AreEqual(results[1], "Симулятор");
Assert.AreEqual(results[2], "Шутер");
}
Внутри теста создается имитированная реализация хранилища, которая содержит повторяющиеся категории и категории, не отсортированные в алфавитном порядке. Затем определяется утверждение о том, что дубликаты будут удалены и алфавитный порядок восстановлен.
Создание представления
Чтобы создать представление для метода действия Menu(), щелкните правой кнопкой мыши на папке Views/Nav и выберите в контекстном меню пункт Add --> MVC 5 View Page (Razor) (Добавить --> Страница представления MVC 5 (Razor)). Укажите в качестве имени Menu и щелкните на кнопке OK для создания файла Menu.cshtml. Удалите содержимое, которое среда Visual Studio добавляет к новым представлениям, и приведите представление в соответствие с кодом:
@model IEnumerable<string>
@Html.ActionLink("Домой", "List", "Game", null,
new { @class = "btn btn-block btn-default btn-lg" })
@foreach (var link in Model)
{
@Html.RouteLink(link, new
{
controller = "Game",
action = "List",
category = link,
page = 1
}, new
{
@class = "btn btn-block btn-default btn-lg"
})
}
Мы добавили ссылку под названием "Домой", которая будет отображаться вверху списка категорий и перемещать пользователя на первую страницу списка всех товаров, не отфильтрованного по категории. Это делается посредством вспомогательного метода ActionLink(), который генерирует HTML-элемент <a> с использованием ранее сконфигурированной информации маршрутизации.
Затем осуществляется проход по именам категорий и создание ссылок для каждой категории с применением метода RouteLink(). Он похож на ActionLink(), но позволяет передавать набор пар "имя/значение", который принимается во внимание во время генерирования URL на основе конфигурации маршрутизации.
Ссылки, сгенерированные по умолчанию, выглядят довольно неуклюжими, поэтому вспомогательным методам ActionLink() и RouteLink() необходимо предоставить объекты, в которых указываются значения для атрибутов создаваемых элементов. В этих объектах определяется атрибут class (снабженный префиксом поскольку class является зарезервированным ключевым словом C#) и применяются классы Bootstrap для стилизации ссылок в виде крупных кнопок.
Запустив приложение, вы увидите ссылки на категории. Если щелкнуть на какой-то категории, список элементов обновится, и будет отображать только элементы выбранной категории:
Подсветка текущей категории
В настоящий момент мы никак не указываем пользователям, какую категорию они просматривают. Иногда пользователям удается выяснить категорию по элементам в списке, однако предпочтительнее предоставить более надежный визуальный отклик.
Это можно было бы сделать за счет создания модели представления, содержащей список категорий и выбранную категорию, и фактически именно это обычно делается. Однако ради разнообразия мы продемонстрируем средство ViewBag. Это средство позволяет передавать данные из контроллера представлению без использования модели представления.
В примере ниже показаны изменения, которые понадобится внести в метод действия Menu() контроллера Nav:
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using GameStore.Domain.Abstract;
namespace GameStore.WebUI.Controllers
{
public class NavController : Controller
{
// ...
public PartialViewResult Menu(string category = null)
{
ViewBag.SelectedCategory = category;
IEnumerable<string> categories = repository.Games
.Select(game => game.Category)
.Distinct()
.OrderBy(x => x);
return PartialView(categories);
}
}
}
В метод действия Menu() добавлен параметр по имени category. Значение для этого параметра будет предоставлено автоматически конфигурацией маршрутизации. Внутри тела метода мы динамически создаем свойство SelectedCategory в объекте ViewBag и устанавливаем его значение равным значению параметра category. ViewBag - это динамический объект, и его новые свойства можно создавать, просто устанавливая для них значения.
Модульное тестирование: сообщение о выбранной категории
Для выполнения проверки того, что метод действия Menu() корректно добавил детали о выбранной категории, в модульном тесте можно прочитать значение свойства ViewBag, которое доступно через класс ViewResult. Ниже показан тестовый метод:
[TestMethod]
public void Indicates_Selected_Category()
{
// Организация - создание имитированного хранилища
Mock<IGameRepository> mock = new Mock<IGameRepository>();
mock.Setup(m => m.Games).Returns(new Game[] {
new Game { GameId = 1, Name = "Игра1", Category="Симулятор"},
new Game { GameId = 2, Name = "Игра2", Category="Шутер"}
});
// Организация - создание контроллера
NavController target = new NavController(mock.Object);
// Организация - определение выбранной категории
string categoryToSelect = "Шутер";
// Действие
string result = target.Menu(categoryToSelect).ViewBag.SelectedCategory;
// Утверждение
Assert.AreEqual(categoryToSelect, result);
}
Теперь, когда предоставляется информация о том, какая категория выбрана, можно соответствующим образом обновить представление и добавить класс CSS к HTML-элементу <a>, который воспроизводит выбранную категорию. Изменения в частичном представлении Menu.cshtml показаны в примере ниже:
@model IEnumerable<string>
@Html.ActionLink("Домой", "List", "Game", null,
new { @class = "btn btn-block btn-default btn-lg" })
@foreach (var link in Model)
{
@Html.RouteLink(link, new
{
controller = "Game",
action = "List",
category = link,
page = 1
}, new
{
@class = "btn btn-block btn-default btn-lg"
+ (link == ViewBag.SelectedCategory ? " btn-primary" : "")
})
}
Изменение выглядит просто. Если текущее значение link совпадает со значением SelectedCategory, созданный элемент добавляется к другому классу Bootstrap, который обеспечивает подсветку кнопки. Запустив приложение, можно увидеть результат подсвечивания выбранной категории:
Корректировка счетчика страниц
Понадобится также скорректировать ссылки на страницы, чтобы они правильно работали, когда выбрана какая-то категория. В настоящий момент количество ссылок на страницы определяется общим числом товаров, а не количеством товаров выбранной категории. Это значит, что пользователь может щелкнуть на ссылке для страницы 2 категории "Шутер" и получить пустую страницу, поскольку товаров данной категории не хватает для заполнения второй страницы.
Проблема продемонстрирована на рисунке ниже:
Это можно исправить, модифицировав метод действия List() в контроллере Game так, чтобы при разбиении на страницы категории принимались во внимание. Необходимые изменения показаны в примере ниже:
// ...
public ViewResult List(string category, int page = 1)
{
GamesListViewModel model = new GamesListViewModel
{
Games = repository.Games
.Where(p => category == null || p.Category == category)
.OrderBy(game => game.GameId)
.Skip((page - 1)*pageSize)
.Take(pageSize),
PagingInfo = new PagingInfo
{
CurrentPage = page,
ItemsPerPage = pageSize,
TotalItems = category == null ?
repository.Games.Count() :
repository.Games.Where(game => game.Category == category).Count()
},
CurrentCategory = category
};
return View(model);
}
При наличии выбранной категории возвращается количество элементов в этой категории, а в противном случае - общее количество товаров. Теперь во время просмотра товаров какой-либо категории ссылки в нижней части страницы корректно отражают количество товаров в этой категории:
Модульное тестирование: счетчик товаров определенной категории
Протестировать возможность генерации корректных счетчиков товаров для различных категорий можно очень просто - необходимо создать имитированное хранилище, которое содержит известные данные в диапазоне категорий, и затем вызывать метод действия List(), запрашивая каждую категорию по очереди. Модульный тест выглядит следующим образом:
[TestMethod]
public void Generate_Category_Specific_Game_Count()
{
/// Организация (arrange)
Mock<IGameRepository> mock = new Mock<IGameRepository>();
mock.Setup(m => m.Games).Returns(new List<Game>
{
new Game { GameId = 1, Name = "Игра1", Category="Cat1"},
new Game { GameId = 2, Name = "Игра2", Category="Cat2"},
new Game { GameId = 3, Name = "Игра3", Category="Cat1"},
new Game { GameId = 4, Name = "Игра4", Category="Cat2"},
new Game { GameId = 5, Name = "Игра5", Category="Cat3"}
});
GameController controller = new GameController(mock.Object);
controller.pageSize = 3;
// Действие - тестирование счетчиков товаров для различных категорий
int res1 = ((GamesListViewModel)controller.List("Cat1").Model).PagingInfo.TotalItems;
int res2 = ((GamesListViewModel)controller.List("Cat2").Model).PagingInfo.TotalItems;
int res3 = ((GamesListViewModel)controller.List("Cat3").Model).PagingInfo.TotalItems;
int resAll = ((GamesListViewModel)controller.List(null).Model).PagingInfo.TotalItems;
// Утверждение
Assert.AreEqual(res1, 2);
Assert.AreEqual(res2, 2);
Assert.AreEqual(res3, 1);
Assert.AreEqual(resAll, 5);
}
Обратите внимание, что в модульном тесте также вызывается метод List() без указания категории, чтобы удостовериться в правильности подсчета общего количества товаров.