Главная страница   /   10.1. Управление каталогом (ASP.NET MVC 4

ASP.NET MVC 4

ASP.NET MVC 4

Адам Фриман

10.1. Управление каталогом

По соглашению для управления коллекциями товаров мы должны предоставить пользователю два типа страниц: страницу со списком и страницу редактирования, как показано на рисунке 10-1.

Рисунок 10-1: Эскиз пользовательского интерфейса CRUD для каталога товаров

Вместе эти страницы позволяют пользователю создавать, читать, обновлять и удалять элементы в коллекции. В совокупности эти действия называются CRUD (Create-Read-Update-Delete – Создать-Прочитать-Обновить-Удалить). Разработчикам очень часто приходится реализовывать CRUD, так что Visual Studio предлагает сгенерировать MVC-контроллеры с готовыми методами действий для операций CRUD и шаблоны представлений, которые их поддерживают.

Создаем контроллер CRUD

Мы создадим новый контроллер для обработки функций администрирования. Кликните правой кнопкой мыши папку Controllers в проекте SportsStore.WebUI и выберите Add - Controller из контекстного меню. Назовите контроллер AdminController и выберите из выпадающего списка Template пункт Empty MVC Controller, как показано на рисунке 10-2.

Примечание

В Visual Studio есть несколько шаблонов для классов контроллеров, которые включают методы CRUD. Как мы уже говорили, они нам не нравятся и мы предпочитаем создавать классы контроллеров с нуля.

Рисунок 10-2: Создание контроллера с помощью диалогового окна Add Controller

Нажмите кнопку Add, чтобы создать контроллер. Для поддержки страницы со списком, показанной на рисунке 10-1, нужно добавить метод действия, который будет отображать все товары в хранилище. Следуя соглашениям MVC Framework, мы назовем этот метод Index. Измените содержимое класса контроллера в соответствии с листингом 10-1.

Листинг 10-1: Метод действия Index
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 AdminController : Controller
	{
		private IProductRepository repository;

		public AdminController(IProductRepository repo)
		{
			repository = repo;
		}

		public ViewResult Index()
		{
			return View(repository.Products);
		}
	}
}

Модульный тест: Действие Index

Поведение метода Index, которое нас интересует, состоит в том, правильно ли он возвращает объекты Product, которые находятся в хранилище. Мы можем протестировать его, создав имитацию реализации хранилища и сравнив тестовые данные с данными, возвращенными методом действия. Вот модульный тест, который мы добавили в новый файл тестов под названием AdminTests.cs:

using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using SportsStore.WebUI.Controllers;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;

namespace SportsStore.UnitTests
{
	[TestClass]
	public class AdminTests
	{
		[TestMethod]
		public void Index_Contains_All_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"},
				new Product {ProductID = 2, Name = "P2"},
				new Product {ProductID = 3, Name = "P3"},
			}.AsQueryable());
			// Arrange - create a controller
			AdminController target = new AdminController(mock.Object);
			// Action
			Product[] result = ((IEnumerable<Product>)target.Index().ViewData.Model).ToArray();
			// Assert
			Assert.AreEqual(result.Length, 3);
			Assert.AreEqual("P1", result[0].Name);
			Assert.AreEqual("P2", result[1].Name);
			Assert.AreEqual("P3", result[2].Name);
		}
	}
}

Создаем новый макет

Мы собираемся создать новый макет для представлений администрирования SportsStore. Эта простая разметка будет представлять собой единственную точку, в которой мы сможем применить изменения ко всем представлениям администрирования.

Чтобы создать макет, щелкните правой кнопкой мыши папку Views/Shared в проекте SportsStore.WebUI и выберите Add - New Item. Выберите шаблон MVC 4 Layout Page (Razor) и назовите его _AdminLayout.cshtml, как показано на рисунке 10-3. Нажмите кнопку Add, чтобы создать новый файл.

Рисунок 10-3: Создаем новый макет Razor

Как мы уже объясняли ранее, по соглашению имя макета начинается с символа подчеркивания (_). Razor также используется другой технологией Microsoft под названием WebMatrix, в которой символ подчеркивания нужен для того, чтобы предотвратить отправку страниц макетов в браузеры. В MVC такая защита не требуется, но соглашение об именах макетов так или иначе переносится на приложения MVC.

Мы хотим создать в макете ссылку на файл CSS, как показано в листинге 10-2.

Листинг 10-2: Файл _AdminLayout.cshtml
<!DOCTYPE html>
<html>
<head>
	<meta name="viewport" content="width=device-width" />
	<link href="~/Content/Admin.css" rel="stylesheet" type="text/css" />
	<title></title>
</head>
<body>
	<div>
		@RenderBody()
	</div>
</body>
</html>

Дополнение (выделено жирным шрифтом) является ссылкой на файл CSS под названием Admin.css в папке Content. Чтобы создать файл Admin.css, кликните правой кнопкой мыши папку Content, выберите пункт Add - New Item, шаблон Style Sheet и укажите имя Admin.css, как показано на рисунке 10-4.

Рисунок 10-4: Создание файла Admin.css

Замените содержимое файла Admin.css на стили, показанные в листинге 10-3.

Листинг 10-3: CSS-стили для представлений администрирования
BODY, TD { font-family: Segoe UI, Verdana }
H1 { padding: .5em; padding-top: 0; font-weight: bold;
	font-size: 1.5em; border-bottom: 2px solid gray; }
DIV#content { padding: .9em; }
TABLE.Grid TD, TABLE.Grid TH { border-bottom: 1px dotted gray; text-align:left; }
TABLE.Grid { border-collapse: collapse; width:100%; }
TABLE.Grid TH.NumericCol, Table.Grid TD.NumericCol {
	text-align: right; padding-right: 1em; }
FORM {margin-bottom: 0px; }
DIV.Message { background: gray; color:White; padding: .2em; margin-top:.25em; }

.field-validation-error { color: red; display: block; }
.field-validation-valid { display: none; }
.input-validation-error { border: 1px solid red; background-color: #ffeeee; }
.validation-summary-errors { font-weight: bold; color: red; }
.validation-summary-valid { display: none; }

Реализация представлений для страниц со списком

Теперь, когда новый макет готов, мы можем добавить в проект представление для метода действия Index контроллера Admin. Кликните правой кнопкой мыши по методу Index и выберите Add View из контекстного меню. Назовите представление Index, как показано на рисунке 10-5.

Рисунок 10-5: Создаем представление Index

Мы собираемся использовать заготовку (scaffold view) – то есть, представление, для которого Visual Studio сама создаст разметку в зависимости от того, какой мы выберем класс для строго типизированного представления. Чтобы ее создать, выберите Product из списка классов модели и шаблон заготовки List, как показано на рисунке 10-5.

Примечание

Когда вы используете заготовку List, Visual Studio предполагает, что вы работаете с последовательностью IEnumerable типа модели представления, так что вы можете просто выбрать одиночную форму класса из списка.

Мы хотим применить наш вновь созданный макет, так что отметьте флажком опцию Use a layout и выберите файл _AdminLayout.cshtml из папки Views/Shared. Нажмите кнопку Add, чтобы создать представление. Заготовка, которую создаст Visual Studio, показана в листинге 10-4 (мы ее немного подчистили, чтобы сделать более компактной и читабельной).

Листинг 10-4: Заготовка представления для страниц со списком
@model IEnumerable<SportsStore.Domain.Entities.Product>

@{
	ViewBag.Title = "Index";
	Layout = "~/Views/Shared/_AdminLayout.cshtml";
}
<h2>Index</h2>
<p>
	@Html.ActionLink("Create New", "Create")
</p>
<table>
	<tr>
		<th>@Html.DisplayNameFor(model => model.Name)</th>
		<th>@Html.DisplayNameFor(model => model.Description)</th>
		<th>@Html.DisplayNameFor(model => model.Price)</th>
		<th>@Html.DisplayNameFor(model => model.Category)</th>
		<th></th>
	</tr>
	@foreach (var item in Model) {
		<tr>
			<td>@Html.DisplayFor(modelItem => item.Name)</td>
			<td>@Html.DisplayFor(modelItem => item.Description)</td>
			<td>@Html.DisplayFor(modelItem => item.Price)</td>
			<td>@Html.DisplayFor(modelItem => item.Category)</td>
			<td>
				@Html.ActionLink("Edit", "Edit", new { id = item.ProductID }) |
				@Html.ActionLink("Details", "Details", new { id = item.ProductID }) |
				@Html.ActionLink("Delete", "Delete", new { id = item.ProductID })
			</td>
		</tr>
	}
</table>

Visual Studio смотрит на тип объекта модели представления и генерирует в таблице элементы, которые соответствуют его свойствам. Вы можете увидеть, как визуализируется это представление, если запустите приложение и перейдете по ссылке /Admin/Index. Результаты показаны на рисунке 10-6.

Рисунок 10-6: Визуализация представления для страниц со списком

Заготовка во многом помогает нам с настройками. У нас имеются столбцы для каждого из свойств класса Product и ссылки на другие операции CRUD, которые ведут к методам действий того же контроллера. Но стоит отметить, что она содержит излишнюю разметку. Кроме того, мы хотим использовать в представлении CSS, который мы создали ранее. Отредактируйте файл Index.cshtml в соответствии с листингом 10-5.

Листинг 10-5: Изменяем представление Index.cshtml
@model IEnumerable<SportsStore.Domain.Entities.Product>

@{
	ViewBag.Title = "Admin: All Products";
	Layout = "~/Views/Shared/_AdminLayout.cshtml";
}
<h1>All Products</h1>
<table class="Grid">
	<tr>
		<th>ID</th>
		<th>Name</th>
		<th class="NumericCol">Price</th>
		<th>Actions</th>
	</tr>
	@foreach (var item in Model) {
		<tr>
			<td>@item.ProductID</td>
			<td>@Html.ActionLink(item.Name, "Edit", new { item.ProductID })</td>
			<td class="NumericCol">@item.Price.ToString("c")</td>
			<td>
				@using (Html.BeginForm("Delete", "Admin")) {
					@Html.Hidden("ProductID", item.ProductID)
					<input type="submit" value="Delete" />
				}
			</td>
		</tr>
	}
</table>
<p>@Html.ActionLink("Add a new product", "Create")</p>

Это представление визуализирует информацию более компактно, оно опускает некоторые свойства из класса Product и по-другому представляет ссылки на конкретные товары. Вы можете увидеть, как оно выглядит, на рисунке 10-7.

Рисунок 10-7: Визуализация измененного представления Index

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

Редактирование товаров

Чтобы обеспечить поддержку создания и обновления, мы добавим страницу редактирования товаров, как показано на рисунке 10-1. Эту работу мы выполним в два этапа:

  • Отобразим страницу, которая позволит администратору изменять значения свойств товара.
  • Добавим метод действия, который будет обрабатывать изменения после их отправки.

Создаем метод действия Edit

В листинге 10-6 показан метод Edit, который мы добавили к классу AdminController. Это метод действия, который мы указали в вызовах к вспомогательному методу действия Html.ActionLink в представлении Index.

Листинг 10-6: Метод Edit
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace SportsStore.WebUI.Controllers
{
	public class AdminController : Controller
	{
		private IProductRepository repository;

		public AdminController(IProductRepository repo)
		{
			repository = repo;
		}

		public ViewResult Index()
		{
			return View(repository.Products);
		}

		public ViewResult Edit(int productId)
		{
			Product product = repository.Products
				.FirstOrDefault(p => p.ProductID == productId);
			return View(product);
		}
	}
}

Этот простой метод находит товар с ID, который соответствует параметру productId, и передает его в качестве объекта модели представления.

Модульный тест: метод действия Edit

Мы хотим протестировать два вида поведения в методе действия Edit. Первое состоит в том, получаем ли мы правильный товар, когда предоставляем действительное значение ID. Очевидно, мы хотим убедиться, что отредактируем именно тот товар, который собирались. Второй вид поведения заключается в том, что мы не получаем никакого товара вообще, когда мы предоставляем недействительное значение ID. Вот тестовые методы, которые мы добавили в файл AdminTests.cs:

[TestMethod]
public void Can_Edit_Product()
{
	// 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"},
		new Product {ProductID = 2, Name = "P2"},
		new Product {ProductID = 3, Name = "P3"},
	}.AsQueryable());
	// Arrange - create the controller
	AdminController target = new AdminController(mock.Object);
	// Act
	Product p1 = target.Edit(1).ViewData.Model as Product;
	Product p2 = target.Edit(2).ViewData.Model as Product;
	Product p3 = target.Edit(3).ViewData.Model as Product;
	// Assert
	Assert.AreEqual(1, p1.ProductID);
	Assert.AreEqual(2, p2.ProductID);
	Assert.AreEqual(3, p3.ProductID);
}

[TestMethod]
public void Cannot_Edit_Nonexistent_Product()
{
	// 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"},
		new Product {ProductID = 2, Name = "P2"},
		new Product {ProductID = 3, Name = "P3"},
	}.AsQueryable());
	// Arrange - create the controller
	AdminController target = new AdminController(mock.Object);
	// Act
	Product result = (Product)target.Edit(4).ViewData.Model;
	// Assert
	Assert.IsNull(result);
}

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

Теперь, когда у нас готов метод действия, мы можем создать представление, которое он будет визуализировать. Кликните правой кнопкой мыши по методу действия Edit и выберите пункт Add View. Оставьте имя представления Edit, отметьте флажком опцию Create a strongly-typed view и убедитесь, что в качестве класса модели выбран класс Product, как показано на рисунке 10-8.

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

Для CRUD-операции Edit имеется заготовка, которую вы можете выбрать, чтобы посмотреть, что создает Visual Studio. Мы же снова будем использовать свою разметку, поэтому выбрали из списка опций для заготовок Empty. Не забудьте отметить флажком опцию Use a layout и в качестве представления выберите _AdminLayout.cshtml. Нажмите кнопку Add, чтобы создать представление, которое будет размещено в папке Views/Admin. Изменить его содержимое в соответствии с листингом 10-7.

Листинг 10-7: Представление Edit
@model SportsStore.Domain.Entities.Product

@{
	ViewBag.Title = "Admin: Edit " + @Model.Name;
	Layout = "~/Views/Shared/_AdminLayout.cshtml";
}

<h1>Edit @Model.Name</h1>
@using (Html.BeginForm())
{
	@Html.EditorForModel()
	<input type="submit" value="Save" />
	@Html.ActionLink("Cancel and return to List", "Index")
}

Чтобы не писать разметку для каждой метки и поля ввода вручную, мы вызвали вспомогательный метод Html.EditorForModel. Этот метод сообщает MVC Framework создать интерфейс редактирования, для чего она проверит тип модели, в данном случае, класс Product.

Чтобы увидеть страницу, которая создается представлением Edit, запусите приложение и перейдите по ссылке /Admin/Index. Кликните по одному из товаров, и вы увидите страницу, показанную на рисунке 10-9.

Рисунок 10-9: Страница, сгенерированная с помощью вспомогательного метода EditorForModel

Давайте будем честными - метод EditorForModel удобен, но дает не самые привлекательные результаты. К тому же, мы не хотим, чтобы администратор мог видеть или редактировать атрибут ProductID, а текстовое поле для свойства Description слишком мало.

Мы можем дать MVC Framework указания касательно того, как создавать редакторы для свойств, с помощью метаданных модели. Это позволит нам применять атрибуты к свойствам нового класса модели, которые повлияют на вывод метода Html.EditorForModel. В листинге 10-8 показано, как использовать метаданные в классе Product в проекте SportsStore.Domain.

Листинг 10-8: Используем метаданные модели
using System.ComponentModel.DataAnnotations;
using System.Web.Mvc;

namespace SportsStore.Domain.Entities
{
	public class Product
	{
		[HiddenInput(DisplayValue = false)]
		public int ProductID { get; set; }

		public string Name { get; set; }

		[DataType(DataType.MultilineText)]
		public string Description { get; set; }

		public decimal Price { get; set; }

		public string Category { get; set; }
	}
}

Атрибут HiddenInput сообщает MVC Framework, что свойство нужно визуализировать как скрытый элемент формы, а атрибут DataType позволяет указать, как значение должно отображаться и редактироваться. В данном случае мы выбрали опцию MultilineText. Атрибут HiddenInput является частью пространства имен System.Web.Mvc, и атрибут DataType - частью пространства имен System.ComponentModel.DataAnnotations, что объясняет, почему нам нужно было добавить ссылки на коллекции этих имен в проект SportsStore.Domain в главе 7.

Рисунок 10-10 показывает страницу Edit после применения метаданных. Вы больше не можете видеть или редактировать свойство ProductId, и у вас есть многострочное текстовое поле для ввода описания. Однако UI по-прежнему выглядит довольно скромно.

Рисунок 10-10: Эффект применения метаданных

Мы можем несколько улучшить страницу с помощью CSS. Когда MVC Framework создает поле ввода для каждого свойства, она присваивает им различные классы CSS. Если вы посмотрите на исходный код страницы, показанной на рисунке 10-10, вы увидите, что элементу текстового поля для описания товара был присвоен CSS-класс text-box-multi-line:

<textarea class="text-box multi-line" id="Description" name="Description">
	Give your playing field a professional touch
</textarea>

Другим классам присваиваются подобные элементы HTML, и мы можем улучшить внешний вид представления Edit, добавив стили из листинга 10-9 в файл Admin.css из папки Content проекта SportsStore.WebUI. Эти стили назначаются разным классам, которые добавляются к элементам HTML вспомогательным методом EditorForModel.

Листинг 10-9: CSS-стили для элементов редактирования
.editor-field { margin-bottom: .8em; }
.editor-label { font-weight: bold; }
.editor-label:after { content: ":" }
.text-box { width: 25em; }
.multi-line { height: 5em; font-family: Segoe UI, Verdana; }

Рисунок 10-11 показывает эффект применения этих стилей в представлении Edit. Визуализированное представление выглядит все еще довольно незамысловато, но оно функционально и удовлетворяет нашим запросам.

Рисунок 10-11: Применение CSS к элементам редактирования

Как вы видели в этом примере, страница, которую создает шаблонный вспомогательный метод вроде EditorForModel, не будет всегда отвечать вашим требованиям. Мы обсудим использование и настройку шаблонных вспомогательных методов подробнее в главе 20.

Обновляем хранилище Product

Прежде чем мы сможем обрабатывать изменения, нам нужно обновить хранилище Product так, чтобы можно было их сохранять. Во-первых, мы добавим в интерфейс IProductRepository новый метод, который показан в листинге 10-10. (Напомним, что этот интерфейс можно найти в папке Abstract проекта SportsStore.Domain).

Листинг 10-10: Добавляем метод в интерфейс хранилища
using System.Linq;
using SportsStore.Domain.Entities;
namespace SportsStore.Domain.Abstract
{
	public interface IProductRepository
	{
		IQueryable<Product> Products { get; }
		void SaveProduct(Product product);
	}
}

Затем мы можем добавить этот метод в реализацию хранилища Entity Framework, в класс Concrete/EFProductRepository, как показано в листинге 10-11.

Листинг 10-11: Реализация метода SaveProduct
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using System.Linq;
namespace SportsStore.Domain.Concrete
{
	public class EFProductRepository : IProductRepository
	{
		private EFDbContext context = new EFDbContext();

		public IQueryable<Product> Products
		{
			get { return context.Products; }
		}

		public void SaveProduct(Product product)
		{
			if (product.ProductID == 0)
			{
				context.Products.Add(product);
			}
			else
			{
				Product dbEntry = context.Products.Find(product.ProductID);
				if (dbEntry != null)
				{
					dbEntry.Name = product.Name;
					dbEntry.Description = product.Description;
					dbEntry.Price = product.Price;
					dbEntry.Category = product.Category;
				}
			}
			context.SaveChanges();
		}
	}
}

Реализация метода SaveChanges добавляет товар в хранилище, если ProductID равен 0; в противном случае она применяет изменения к существующей записи в базе данных.

Мы не хотим подробно разбирать Entity Framework, потому что, как мы объясняли ранее, это очень обширная тема, но метод SaveProduct имеет некоторое отношение к дизайну приложения MVC.

Мы знаем, что необходимо выполнить обновление, когда получаем параметр Product с ProductID, не равным нулю. Для этого мы получаем из хранилища объект Product с таким же ProductID и обновляем каждое свойство так, чтобы оно совпадало с объектом параметра.

Это необходимо потому, что Entity Framework отслеживает объекты, которые он создает из базы данных. Объект, который передается в метод SaveChanges, создается MVC Framework с помощью стандартной модели связывания, что означает, что Entity Framework ничего не узнает об объекте параметра и не применит обновления к базе данных. Есть много путей решения этой проблемы, и мы выбрали самый простой: размещение соответствующего объекта, о котором будет знать Entity Framework, и явное его обновление.

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

Недостаток такого подхода заключается в том, что мы начали бы добавлять функциональность к абстрактному определению хранилища для того, чтобы обойти ограничения конкретной реализации. Если мы будем переключаться между реализациями хранилища в будущем, есть риск того, что придется реализовать возможность поиска, для которой нет готовой поддержки в новой технологии хранения. Гибкость MVC Framework, как, к примеру, метод SaveProduct, может показаться хорошей возможностью, чтобы не использовать обходные пути, но она поставит под удар дизайн вашего приложения.

Обработка запросов Edit POST

На данный момент мы готовы реализовать перегруженный метод действия Edit в контроллере Admin, который будет обрабатывать запросы POST, которые отправляются нажатием кнопки Save. Новый метод показан в листинге 10-12.

Листинг 10-12: Добавляем метод действия Edit для обработки запросов POST
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
namespace SportsStore.WebUI.Controllers
{
	public class AdminController : Controller
	{
		private IProductRepository repository;
		public AdminController(IProductRepository repo)
		{
			repository = repo;
		}
		public ViewResult Index()
		{
			return View(repository.Products);
		}
		public ViewResult Edit(int productId)
		{
			Product product = repository.Products
				.FirstOrDefault(p => p.ProductID == productId);
			return View(product);
		}
		[HttpPost]
		public ActionResult Edit(Product product)
		{
			if (ModelState.IsValid)
			{
				repository.SaveProduct(product);
				TempData["message"] = string.Format("{0} has been saved", product.Name);
				return RedirectToAction("Index");
			}
			else
			{
				// there is something wrong with the data values
				return View(product);
			}
		}
	}
}

Мы убеждаемся, что механизм связывания провел валидацию представленных пользователем данных, прочитав значение свойства ModelState.IsValid. Если все в порядке, мы сохраняем изменения в хранилище, а затем вызываем метод действия Index, чтобы вернуть пользователя к списку товаров. Если есть проблема с данными, мы снова визуализируем представление Edit, чтобы пользователь мог внести исправления.

После сохранения изменений в хранилище, мы сохраняем сообщение с помощью объекта TempData. Этот набор пар ключ/значение похож на данные сессии и ViewBag, которые мы использовали ранее. Его основное отличие от данных сессии заключается в том, что TempData удаляется в конце запроса HTTP.

Обратите внимание, что мы возвращаем тип ActionResult из метода Edit. До сих пор мы использовали тип ViewResult. ViewResult наследует от ActionResult и используется в тех случаях, когда платформа должна визуализировать представление. Тем не менее, доступны другие типы ActionResult, и один из них возвращается методом RedirectToAction. Мы используем его в методе действия Edit для вызова метода действия Index.

В этой ситуации мы не можем использовать ViewBag, так как нам нужно перенаправить пользователя. ViewBag передает данные между контроллером и представлением, и он не может хранить данные дольше, чем длится текущий запрос HTTP. Мы могли бы использовать данные сессии, но тогда сообщение будет храниться, пока мы явно его не удалим, а нам не хочется этого делать. Таким образом, TempData нам идеально подходит. Данные ограничиваются сессией одного пользователя (так что пользователи не видят другие TempData) и будут сохранены до тех пор, пока мы их не прочитаем. Они понадобятся нам в представлении, визуализированном тем методом действия, к которому мы перенаправили пользователя.

Модульный тест: получение данных от метода Edit

Для обработки запросов POST метода действия Edit мы должны убедиться, что для сохранения в хранилище передаются только действительные обновления объекта Product, созданного механизмом связывания. Мы также хотим гарантировать, что недействительные обновления, в которых существует ошибка модели, не передаются в хранилище. Вот тестовые методы:

[TestMethod]
public void Can_Save_Valid_Changes()
{
	// Arrange - create mock repository
	Mock<IProductRepository> mock = new Mock<IProductRepository>();
	// Arrange - create the controller
	AdminController target = new AdminController(mock.Object);
	// Arrange - create a product
	Product product = new Product { Name = "Test" };
	// Act - try to save the product
	ActionResult result = target.Edit(product);
	// Assert - check that the repository was called
	mock.Verify(m => m.SaveProduct(product));
	// Assert - check the method result type
	Assert.IsNotInstanceOfType(result, typeof(ViewResult));
}

[TestMethod]
public void Cannot_Save_Invalid_Changes()
{
	// Arrange - create mock repository
	Mock<IProductRepository> mock = new Mock<IProductRepository>();
	// Arrange - create the controller
	AdminController target = new AdminController(mock.Object);
	// Arrange - create a product
	Product product = new Product { Name = "Test" };
	// Arrange - add an error to the model state
	target.ModelState.AddModelError("error", "error");
	// Act - try to save the product
	ActionResult result = target.Edit(product);
	// Assert - check that the repository was not called
	mock.Verify(m => m.SaveProduct(It.IsAny<Product>()), Times.Never());
	// Assert - check the method result type
	Assert.IsInstanceOfType(result, typeof(ViewResult));
}

Отображаем сообщение с подтверждением

Мы будем отображать сохраненное с помощью TempData сообщение в файле макета _AdminLayout.cshtml. Обрабатывая сообщение в шаблоне, мы сможем создавать сообщения в любом представлении, которое использует этот шаблон, не создавая для них дополнительные блоки Razor. В листинге 10-13 показаны изменения в файле.

Листинг 10-13: Обработка сообщения ViewBag в макете
<!DOCTYPE html>
<html>
<head>
	<meta name="viewport" content="width=device-width" />
	<link href="~/Content/Admin.css" rel="stylesheet" type="text/css" />
	<title></title>
</head>
<body>
	<div>
		@if (TempData["message"] != null) {
			<div class="Message">@TempData["message"]</div>
		}
		@RenderBody()
	</div>
</body>
</html>

Примечание

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

Теперь у нас готовы все элементы, которые нужны для тестирования функции редактирования товаров. Запустите приложение, перейдите по ссылке Admin/Index, и внесите какие-нибудь изменения. Нажмите кнопку Save. Вы вернетесь к представлению со списком, в котором будет отображаться сообщение TempData, как показано на рисунке 10-12.

Рисунок 10-12: Редактируем товар и видим сообщение TempData

Сообщение исчезнет, если вы перезагрузите страницу со списком товаров, потому что TempData удаляется после прочтения. Это очень удобно, так как мы не хотим, чтобы сохранялись старые сообщения.

Добавляем валидацию модели

В таких случаях нам всегда нужно добавлять правила валидации для нашей сущности модели. На данный момент администратор может отправить отрицательные цены или пустое описание, и SportsStore запросто сохранит их в базе данных. В листинге 10-14 показано применение атрибута DataAnnotations к классу Product, аналогичное применению атрибута к классу ShippingDetails в предыдущей главе.

Листинг 10-14: Применение атрибутов валидации к классу Product
using System.ComponentModel.DataAnnotations;
using System.Web.Mvc;
namespace SportsStore.Domain.Entities
{
	public class Product
	{
		[HiddenInput(DisplayValue = false)]
		public int ProductID { get; set; }

		[Required(ErrorMessage = "Please enter a product name")]
		public string Name { get; set; }

		[DataType(DataType.MultilineText)]
		[Required(ErrorMessage = "Please enter a description")]
		public string Description { get; set; }

		[Required]
		[Range(0.01, double.MaxValue, ErrorMessage = "Please enter a positive price")]
		public decimal Price { get; set; }

		[Required(ErrorMessage = "Please specify a category")]
		public string Category { get; set; }
	}
}

Примечание

Теперь в классе Product больше атрибутов, чем свойств. Не волнуйтесь, если вам кажется, что они сделают класс нечитаемым. Эти атрибуты можно переместить в другой класс и сообщить MVC, где их найти. Мы покажем, как это сделать, в главе 23.

Когда мы использовали вспомогательный метод Html.EditorForModel для создания элементов формы для редактирования объекта Product, MVC Framework добавляла разметку и применяла классы CSS, необходимые для отображения ошибок валидации рядом с формой. На рисунке 10-13 показано, как при редактировании товара проявляется нарушение правил валидации, которые мы применили к классу Product.

Рисунок 10-13: Валидация данных при редактировании товаров

Включаем валидацию на стороне клиента

На данный момент валидация данных применяется только тогда, когда администратор отправляет поправки на сервер. Но, если есть проблемы с введенными данными, необходимо предоставить пользователям немедленную обратную связь. Поэтому разработчикам часто требуется выполнять валидацию на стороне клиента, то есть проверку данных браузером с помощью JavaScript. MVC Framework может выполнять валидацию на стороне клиента на основе атрибутов DataAnnotations, которые мы применили к классу доменной модели.

Эта функция включена по умолчанию, но до сих пор она не работала, потому что мы не добавили ссылки на необходимые библиотеки JavaScript. Проще всего добавить эти ссылки в файл _AdminLayout.cshtml, чтобы валидация на стороне клиента могла работать на любой странице, которая использует этот макет. В листинге 10-15 показаны изменения в макете. Функция валидации на стороне клиента MVC основана на JavaScript-библиотеке JQuery, что понятно из имен файлов сценария.

Листинг 10-15: Импорт файлов JavaScript для валидации на стороне клиента
<!DOCTYPE html>

<html>
<head>
	<meta name="viewport" content="width=device-width" />
	<link href="~/Content/Admin.css" rel="stylesheet" type="text/css" />
	<script src="~/Scripts/jquery-1.7.1.js"></script>
	<script src="~/Scripts/jquery.validate.min.js"></script>
	<script src="~/Scripts/jquery.validate.unobtrusive.min.js"></script>
	<title></title>
</head>
<body>
	<div>
		@if (TempData["message"] != null) {
			<div class="Message">@TempData["message"]</div>
		}
		@RenderBody()
	</div>
</body>
</html>

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

HtmlHelper.ClientValidationEnabled = false;
HtmlHelper.UnobtrusiveJavaScriptEnabled = false;

Если вы поместите их в представление или контроллер, то валидация на стороне клиента будет отключена только для текущего действия. Вы можете отключить валидацию на стороне клиента для всего приложения, используя эти операторы в методе Application_Start Global.asax или добавив в файл Web.config следующие значения:

<configuration>
	<appSettings>
		<add key="ClientValidationEnabled" value="false"/>
		<add key="UnobtrusiveJavaScriptEnabled" value="false"/>
	</appSettings>
</configuration>

Создаем новые товары

Далее мы реализуем метод действия Create, на который ведет ссылка Add a new product на странице со списком товаров. Он позволит администратору добавлять новые элементы в каталог товаров. Чтобы добавить поддержку создания новых товаров, потребуется только одно небольшое дополнение и одно изменение в нашем приложении. Это отличный пример возможностей и гибкости хорошо продуманного приложения MVC. Во-первых, добавьте метод Create, показанный в листинге 10-16, в класс AdminController.

Листинг 10-16: Добавляем метод действия Create в AdminController
public ViewResult Create() {
	return View("Edit", new Product());
}

Метод Create не визуализирует свое представление по умолчанию. Вместо этого он указывает, что должно быть использовано представление Edit. То, что один метод действия использует представление, обычно связанное с другим представлением, является вполне приемлемым. В данном случае мы внедряем новый объект Product в качестве модели представления, так что представление Edit заполняется пустыми полями.

Это приводит нас к следующей модификации. Обычно мы ожидаем, что форма отправляет запрос к действию, которое ее визуализировало, и именно это по умолчанию предполагает Html.BeginForm, когда генерирует HTML-форму. Тем не менее, для нашего метода Create это не работает, потому что мы хотим, чтобы форма отправляла запрос к действию Edit, чтобы можно было сохранить добавленные данные о товаре. Чтобы это исправить, можно использовать перегруженную версию вспомогательного метода Html.BeginForm, чтобы указать, что целью формы, сгенерированной в представлении Edit, является метод действия Edit контроллера Admin. Листинг 10-17 содержит изменение, которое мы внесли в файл представления Views/Admin/Edit.cshtml.

Листинг 10-17: Явно указываем метод действия и контроллер для формы
@model SportsStore.Domain.Entities.Product
@{
	ViewBag.Title = "Admin: Edit " + @Model.Name;
	Layout = "~/Views/Shared/_AdminLayout.cshtml";
}

<h1>Edit @Model.Name</h1>

@using (Html.BeginForm("Edit", "Admin"))
{
	@Html.EditorForModel()
	<input type="submit" value="Save" />
	@Html.ActionLink("Cancel and return to List", "Index")
}

Теперь форма всегда будет отправлена действию Edit независимо от того, каким действием она была визуализирована. Мы можем создавать товары, перейдя по ссылке Add a new product и заполнив поля, как показано на рисунке 10-14.

Рисунок 10-14 : Добавляем новый товар в каталог

Удаляем товары

Добавить поддержку для удаления товаров довольно просто. Для начала мы добавим новый метод в интерфейс IProductRepository, как показано в листинге 10-18.

Листинг 10-18: Добавляем метод для удаления товаров
using System.Linq;
using SportsStore.Domain.Entities;
namespace SportsStore.Domain.Abstract
{
	public interface IProductRepository
	{
		IQueryable<Product> Products { get; }
		void SaveProduct(Product product);
		Product DeleteProduct(int productID);
	}
}

Далее мы реализуем этот метод в классе хранилища Entity Framework, EFProductRepository, как показано в листинге 10-19.

Листинг 10-19: Реализуем поддержку удаления в классе хранилища Entity Framework
using SportsStore.Domain.Abstract;
using SportsStore.Domain.Entities;
using System.Linq;
namespace SportsStore.Domain.Concrete
{
	public class EFProductRepository : IProductRepository
	{
		private EFDbContext context = new EFDbContext();
		public IQueryable<Product> Products
		{
			get { return context.Products; }
		}
		public void SaveProduct(Product product)
		{
			if (product.ProductID == 0)
			{
				context.Products.Add(product);
			}
			else
			{
				Product dbEntry = context.Products.Find(product.ProductID);
				if (dbEntry != null)
				{
					dbEntry.Name = product.Name;
					dbEntry.Description = product.Description;
					dbEntry.Price = product.Price;
					dbEntry.Category = product.Category;
				}
			}
			context.SaveChanges();
		}
		public Product DeleteProduct(int productID)
		{
			Product dbEntry = context.Products.Find(productID);
			if (dbEntry != null)
			{
				context.Products.Remove(dbEntry);
				context.SaveChanges();
			}
			return dbEntry;
		}
	}
}

Последним шагом будет реализация метода действия Delete в контроллере Admin. Этот метод действия будет поддерживать только запросы POST, так как удаление объектов не является идемпотентной операцией. Как мы объясним в главе 14, браузеры и кэши могут делать запросы GET без явного согласия пользователя, поэтому мы должны проявить осторожность, чтобы избежать внесения изменений в результате запросов GET. Листинг 10-20 содержит новый метод действия.

Листинг 10-20: Метод действия Delete
[HttpPost]
public ActionResult Delete(int productId) {
	Product deletedProduct = repository.DeleteProduct(productId);
	if (deletedProduct != null) {
		TempData["message"] = string.Format("{0} was deleted", deletedProduct.Name);
	}
	return RedirectToAction("Index");
}

Модульный тест: удаление товаров

Мы хотим протестировать базовое поведение метода действия Delete: когда действительный ProductID передается ему в качестве параметра, он должен вызвать метод хранилища DeleteProduct и передать значение ProductID, которое должно быть удалено. Вот тест:

[TestMethod]
public void Can_Delete_Valid_Products()
{
	// Arrange - create a Product
	Product prod = new Product { ProductID = 2, Name = "Test" };
	// 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"},
		prod,
		new Product {ProductID = 3, Name = "P3"},
	}.AsQueryable());
	// Arrange - create the controller
	AdminController target = new AdminController(mock.Object);
	// Act - delete the product
	target.Delete(prod.ProductID);
	// Assert - ensure that the repository delete method was
	// called with the correct Product
	mock.Verify(m => m.DeleteProduct(prod.ProductID));
}

Вы сможете увидеть работу новой функции, просто нажав одну из кнопок Delete на странице со списком товаров, как показано на рисунке 10-15. Как видите, мы воспользовались переменной TempData для отображения сообщения о том, что продукт будет удален из каталога.

Рисунок 10-15: Удаляем товар из каталога