Админ панель: редактирование товаров

89 Исходный код проекта

Для обеспечения функций редактирования товаров из админ-панели приложения GameStore, мы добавим страницу редактирования сведений о товаре. Задача состоит из двух частей:

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

В примере ниже приведен код метода действия Edit(), добавленного в контроллер Admin. Данный метод действия был указан в вызовах вспомогательного метода Html.ActionLink() внутри представления Index.

using System.Web.Mvc;
using GameStore.Domain.Abstract;
using GameStore.Domain.Entities;
using System.Linq;

namespace GameStore.WebUI.Controllers
{
    public class AdminController : Controller
    {
        // ...

        public ViewResult Edit(int gameId)
        {
            Game game = repository.Games
                .FirstOrDefault(g => g.GameId == gameId);
            return View(game);
        }
	}
}

Этот простой метод ищет товар с идентификатором, соответствующим значению параметра gameId, и передает его как объект модели представления методу View().

Модульное тестирование: метод действия Edit()

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

[TestMethod]
public void Can_Edit_Game()
{
    // Организация - создание имитированного хранилища данных
    Mock<IGameRepository> mock = new Mock<IGameRepository>();
    mock.Setup(m => m.Games).Returns(new List<Game>
    {
        new Game { GameId = 1, Name = "Игра1"},
        new Game { GameId = 2, Name = "Игра2"},
        new Game { GameId = 3, Name = "Игра3"},
        new Game { GameId = 4, Name = "Игра4"},
        new Game { GameId = 5, Name = "Игра5"}
    });

    // Организация - создание контроллера
    AdminController controller = new AdminController(mock.Object);

    // Действие
    Game game1 = controller.Edit(1).ViewData.Model as Game;
    Game game2 = controller.Edit(2).ViewData.Model as Game;
    Game game3 = controller.Edit(3).ViewData.Model as Game;

    // Assert
    Assert.AreEqual(1, game1.GameId);
    Assert.AreEqual(2, game2.GameId);
    Assert.AreEqual(3, game3.GameId);
}

[TestMethod]
public void Cannot_Edit_Nonexistent_Game()
{
    // Организация - создание имитированного хранилища данных
    Mock<IGameRepository> mock = new Mock<IGameRepository>();
    mock.Setup(m => m.Games).Returns(new List<Game>
    {
        new Game { GameId = 1, Name = "Игра1"},
        new Game { GameId = 2, Name = "Игра2"},
        new Game { GameId = 3, Name = "Игра3"},
        new Game { GameId = 4, Name = "Игра4"},
        new Game { GameId = 5, Name = "Игра5"}
    });

    // Организация - создание контроллера
    AdminController controller = new AdminController(mock.Object);

    // Действие
    Game result = controller.Edit(6).ViewData.Model as Game;

    // Assert
}

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

Имея метод действия, можно создать представление для визуализации. Щелкните правой кнопкой мыши на папке Views/Admin в окне Solution Explorer и выберите в контекстном меню пункт Add --> MVC 5 View Page (Razor) (Добавить --> Страница представления MVC 5 (Razor)). Установите имя представления в Edit, щелкните на кнопке ОК для создания файла Edit.cshtml и приведите содержимое этого файла к виду, показанному в примере ниже:

@model GameStore.Domain.Entities.Game

@{
    ViewBag.Title = "Админ панель: редактирование товара";
    Layout = "~/Views/Shared/_AdminLayout.cshtml";
}

<h2>Редактирование игры «@Model.Name»</h2>

@using (Html.BeginForm())
{
    @Html.EditorForModel()
    <input type="submit" value="Сохранить" />
    @Html.ActionLink("Отменить изменения и вернуться к списку", "Index")
}

Вместо написания разметки для каждой метки и поля ввода вручную мы вызываем вспомогательный метод Html.EditorForModel(). Данный метод запрашивает у инфраструктуры MVC Framework создание интерфейса редактирования, и она делает это за счет инспектирования типа модели - класса Game в рассматриваемом случае. Чтобы увидеть страницу, сгенерированную из представления Edit, запустите приложение и перейдите на URL вида /Admin/Index. После щелчка на одной из ссылок с названиями товаров отобразится страница, показанная на рисунке ниже:

Страница, сгенерированная с использованием вспомогательного метода Html.EditorForModel()

Следует признать, что метод EditorForModel() удобен, однако он дает не особо привлекательные результаты. Вдобавок мы не хотим предоставлять администратору возможность видеть или редактировать свойство GameId. И, наконец, размеры текстового поля для свойства Description недостаточно велики.

Инфраструктуре MVC Framework можно указать, каким образом создавать редакторы для свойств, используя метаданные модели. Это позволяет применять к свойствам нового класса модели атрибуты, оказывая влияние на вывод метода Html.EditorForModel().

В примере ниже показано, как использовать метаданные в классе Game из проекта GameStore.Domain:

using System.Web.Mvc;
using System.ComponentModel.DataAnnotations;

namespace GameStore.Domain.Entities
{
    public class Game
    {
        [HiddenInput(DisplayValue=false)]
        public int GameId { get; set; }

        [Display(Name="Название")]
        public string Name { get; set; }

        [DataType(DataType.MultilineText)]
        [Display(Name="Описание")]
        public string Description { get; set; }

        [Display(Name = "Категория")]
        public string Category { get; set; }

        [Display(Name = "Цена (руб)")]
        public decimal Price { get; set; }
    }
}

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

На рисунке ниже показана страница Edit после применения метаданных. Теперь ни видеть, ни редактировать свойство GameId нельзя, а для ввода описания появилось многострочное текстовое поле. Тем не менее, общий внешний вид все еще недостаточно хорош.

Эффект от применения метаданных

Проблема заключается в том, что вспомогательный метод Html.EditorForModel() ничего не знает о классе Game и генерирует элементарную и безопасную HTML-разметку.

Решить эту проблему можно тремя путями. Первый способ предполагает определение стилей CSS для генерируемого вспомогательным методом содержимого, что облегчается за счет наличия классов, которые инфраструктура MVC Framework автоматически добавляет к HTML-элементам.

Если вы заглянете в исходный код страницы, показанной на рисунке выше, то заметите, что элементу textarea, который был создан для ввода описания товара, назначен CSS-класс text-box-multi-line:

<textarea class="text-box-multi-line" id="Description" name="Description">

Другим HTML-элементам назначаются похожие классы, и для каждого из них мы можем создать стили CSS. Этот подход хорошо работает, когда создаются специальные стили, но он усложняет применение заранее определенных классов вроде тех, которые определены в библиотеке Bootstrap.

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

Третий подход предполагает создание необходимых элементов напрямую, без применения вспомогательного метода уровня модели. Идея вспомогательного метода уровня модели неплоха, однако мы используем ее редко, отдавая предпочтение самостоятельному созданию HTML-разметки и применения вспомогательных методов для отдельных свойств, как показано в примере ниже:

@model GameStore.Domain.Entities.Game

@{
    ViewBag.Title = "Админ панель: редактирование товара";
    Layout = "~/Views/Shared/_AdminLayout.cshtml";
}

<div class="panel">
    <div class="panel-heading">
        <h3>Редактирование игры «@Model.Name»</h3>
    </div>

    @using (Html.BeginForm())
    {
        <div class="panel-body">
            @Html.HiddenFor(m => m.GameId)
            @foreach (var property in ViewData.ModelMetadata.Properties)
            {
                if (property.PropertyName != "GameId")
                {
                    <div class="form-group">
                        <label>@(property.DisplayName ?? property.PropertyName)</label>
                        @if (property.PropertyName == "Description")
                        {
                            @Html.TextArea(property.PropertyName, null,
                                new { @class = "form-control", rows = 5 })
                        }
                        else
                        {
                            @Html.TextBox(property.PropertyName, null,
                                new { @class = "form-control" })
                        }
                    </div>
                }
            }
        </div>
        <div class="panel-footer">
            <input type="submit" value="Сохранить" class="btn btn-primary" />
            @Html.ActionLink("Отменить изменения и вернуться к списку", "Index", null, new
            {
                @class = "btn btn-default"
            })
        </div>
    }
</div>

Это вариация приема с метаданными, который использовался ранее и который применяется наиболее часто, несмотря на то, что аналогичных результатов можно достичь посредством вспомогательных методов HTML. Такой подход хорошо сочетается с принятым стилем разработки, но, как всегда, инфраструктура MVC Framework допускает использование разных приемов, если обработки метаданных оказывается недостаточно. На рисунке ниже показано, как выглядит созданное представление при отображении в браузере:

Отображение страницы редактирования сведений о товаре

Обновление хранилища товаров

Прежде чем можно будет обрабатывать результаты редактирования, понадобится расширить хранилище товаров, добавив возможность сохранения изменений. Первым делом, мы добавим к интерфейсу IGameRepository новый метод, как показано в примере ниже. (В качестве напоминания: этот интерфейс находится в папке Abstract проекта GameStore.Domain.)

using System.Collections.Generic;
using GameStore.Domain.Entities;

namespace GameStore.Domain.Abstract
{
    public interface IGameRepository
    {
        IEnumerable<Game> Games { get; }
        void SaveGame(Game game);
    }
}

Затем можно добавить новый метод к реализации Entity Framework хранилища, определенной в файле Concrete/EFGameRepository.cs:

using System.Collections.Generic;
using GameStore.Domain.Entities;
using GameStore.Domain.Abstract;

namespace GameStore.Domain.Concrete
{
    public class EFGameRepository : IGameRepository
    {
        // ...

        public void SaveGame(Game game)
        {
            if (game.GameId == 0)
                context.Games.Add(game);
            else
            {
                Game dbEntry = context.Games.Find(game.GameId);
                if (dbEntry != null)
                {
                    dbEntry.Name = game.Name;
                    dbEntry.Description = game.Description;
                    dbEntry.Price = game.Price;
                    dbEntry.Category = game.Category;
                }
            }
            context.SaveChanges();
        }
    }
}

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

Мы не хотим здесь вдаваться в детали инфраструктуры Entity Framework, поскольку это отдельная крупная тема, к тому же она не является частью MVC Framework. Однако в методе SaveGame() есть кое-что, оказывающее влияние на проектирование приложения MVC.

Нам известно, что обновление должно выполняться, когда получен параметр Game, который имеет ненулевое значение GameId. Это делается путем извлечения из хранилища объекта Game с тем же самым значением GameID и обновлением всех его свойств согласно объекту, переданному в качестве параметра.

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

Альтернативный подход мог бы предусматривать создание специального связывателя модели, который только получает объекты из хранилища. Несмотря на то что такой подход выглядит более элегантным, он требует добавления к интерфейсу хранилища функции поиска, чтобы можно было находить объекты Game по значениям GameId.

Обработка запросов POST в методе действия Edit()

К этому моменту все готово для реализации перегруженной версии метода действия Edit(), которая будет обрабатывать запросы POST, инициируемые по щелчку администратором на кнопке "Сохранить". Код нового метода приведен в примере ниже:

using System.Web.Mvc;
using GameStore.Domain.Abstract;
using GameStore.Domain.Entities;
using System.Linq;

namespace GameStore.WebUI.Controllers
{
    public class AdminController : Controller
    {
        // ...

        public ViewResult Edit(int gameId)
        {
            Game game = repository.Games
                .FirstOrDefault(g => g.GameId == gameId);
            return View(game);
        }

        // Перегруженная версия Edit() для сохранения изменений
        [HttpPost]
        public ActionResult Edit(Game game)
        {
            if (ModelState.IsValid)
            {
                repository.SaveGame(game);
                TempData["message"] = string.Format("Изменения в игре \"{0}\" были сохранены", game.Name);
                return RedirectToAction("Index");
            }
            else
            {
                // Что-то не так со значениями данных
                return View(game);
            }
        }
	}
}

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

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

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

В такой ситуации мы не можем применять ViewBag, поскольку пользователь находится в состоянии перенаправления. Объект ViewBag передает данные между контроллером и представлением, и он не может удерживать данные дольше, чем длится HTTP-запрос. Мы могли бы воспользоваться средством данных сеанса, но тогда сообщение хранилось бы вплоть до его явного удаления, что мы предпочитаем не делать.

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

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

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

// ...

[TestMethod]
public void Can_Save_Valid_Changes()
{
    // Организация - создание имитированного хранилища данных
    Mock<IGameRepository> mock = new Mock<IGameRepository>();

    // Организация - создание контроллера
    AdminController controller = new AdminController(mock.Object);

    // Организация - создание объекта Game
    Game game = new Game { Name = "Test" };

    // Действие - попытка сохранения товара
    ActionResult result = controller.Edit(game);

    // Утверждение - проверка того, что к хранилищу производится обращение
    mock.Verify(m => m.SaveGame(game));

    // Утверждение - проверка типа результата метода
    Assert.IsNotInstanceOfType(result, typeof(ViewResult));
}

[TestMethod]
public void Cannot_Save_Invalid_Changes()
{
    // Организация - создание имитированного хранилища данных
    Mock<IGameRepository> mock = new Mock<IGameRepository>();

    // Организация - создание контроллера
    AdminController controller = new AdminController(mock.Object);

    // Организация - создание объекта Game
    Game game = new Game { Name = "Test" };

    // Организация - добавление ошибки в состояние модели
    controller.ModelState.AddModelError("error", "error");

    // Действие - попытка сохранения товара
    ActionResult result = controller.Edit(game);

    // Утверждение - проверка того, что обращение к хранилищу НЕ производится 
    mock.Verify(m => m.SaveGame(It.IsAny<Game>()), Times.Never());

    // Утверждение - проверка типа результата метода
    Assert.IsInstanceOfType(result, typeof(ViewResult));
}

Отображение подтверждающего сообщения

Мы будем иметь дело с сообщением, сохраненным с помощью TempData, в файле компоновки _AdminLayout.cshtml. За счет обработки сообщения в шаблоне мы можем создавать сообщения в любом представлении, которое использует этот шаблон, без необходимости в создании дополнительных блоков Razor. Изменения, которые потребуется внести в _AdminLayout.cshtml показаны ниже:

...
<body>
    <div>
        @if (TempData["message"] != null)
        {
            <div class="alert alert-success">@TempData["message"]</div>
        }
        @RenderBody()
    </div>
</body>
...

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

Теперь мы располагаем всеми элементами, необходимыми для редактирования сведения о товарах. Чтобы увидеть его в работе, запустите приложение, перейдите на URL вида Admin/Index и проведите редактирование. Щелкните на кнопке "Сохранить" Произойдет возврат на представление со списком и отобразится сообщение из TempData:

Редактирование товара и отображение сообщения из TempData

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

Добавление проверки достоверности модели

Как и в большинстве проектов, нам необходимо добавить к модели правила проверки достоверности. Сейчас администратор вполне может ввести отрицательные значения для цен или оставить описания пустыми, и приложение GameStore попытается сохранить эти данные в базе. Смогут ли недопустимые данные сохраниться, зависит от того, удовлетворяют ли они ограничениям в определении таблицы базы данных. В примере ниже демонстрируется применение к классу Game аннотаций данных, что уже делалось для класса ShippingDetails ранее:

using System.Web.Mvc;
using System.ComponentModel.DataAnnotations;

namespace GameStore.Domain.Entities
{
    public class Game
    {
        [HiddenInput(DisplayValue=false)]
        public int GameId { get; set; }

        [Display(Name="Название")]
        [Required(ErrorMessage="Пожалуйста, введите название игры")]
        public string Name { get; set; }

        [DataType(DataType.MultilineText)]
        [Display(Name="Описание")]
        [Required(ErrorMessage = "Пожалуйста, введите описание для игры")]
        public string Description { get; set; }

        [Display(Name = "Категория")]
        [Required(ErrorMessage = "Пожалуйста, укажите категорию для игры")]
        public string Category { get; set; }

        [Display(Name = "Цена (руб)")]
        [Required]
        [Range(0.01, double.MaxValue, ErrorMessage = "Пожалуйста, введите положительное значение для цены")]
        public decimal Price { get; set; }
    }
}

Вспомогательные методы Html.TextBox() и Html.TextArea(), которые используются в файле представления Edit.cshtml для создания элементов <input> и <textarea>, будут применяться инфраструктурой MVC Framework в целях сигнализации о наличии проблем с проверкой. Эти сигналы посылаются с использованием классов, для которых определены стили в файле Content/ErrorStyles.css, обеспечивающие эффект подсветки проблемных полей. Пользователю необходимо сообщить о деталях любой проблемы, что и делается в примере ниже:

...

@foreach (var property in ViewData.ModelMetadata.Properties)
{
    if (property.PropertyName != "GameId")
    {
        <div class="form-group">
            ...
			
            @Html.ValidationMessage(property.PropertyName)
        </div>
    }
}

В статье "Отправка заказов" посредством вспомогательного метода Html.ValidationSummary() создавался консолидированный список всех проблем, возникших во время проверки достоверности данных формы. В примере выше применяется вспомогательный метод Html.ValidationMessage(), который отображает сообщение для одиночного свойства модели.

Вызовы Html.ValidationMessage() можно размещать где угодно в рамках представления, однако по соглашению (и это вполне разумно) помещать их поближе к элементу, создавшему проблему проверки, чтобы ввести пользователя в курс дела. На рисунке ниже показано, как выглядят сообщения об ошибках проверки достоверности, если при редактировании информации о товаре вводятся данные, нарушающие правила, которые были применены к классу Game:

Проверка достоверности данных при редактировании информации о товаре

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

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

Инфраструктура MVC Framework поддерживает проверку достоверности на стороне клиента на основе аннотаций данных, применяемых к классу модели предметной области. По умолчанию это средство включено, но не работает, поскольку мы не добавили ссылки на требуемые библиотеки JavaScript. В Microsoft предлагается поддержка проверки достоверности на стороне клиента на базе библиотеки jQuery и популярного подключаемого модуля jQuery под вполне очевидным названием jQuery Validation. Эти инструменты расширены для добавления поддержки атрибутов проверки достоверности.

Прежде всего, необходимо установить пакет проверки достоверности. Выберите пункт меню Tools --> Library Package Managers --> Package Manager Console, чтобы открыть окно командной строки NuGet. Введите следующую команду:

Install-Package Microsoft.jQuery.Unobtrusive.Validation -version 3.0.0 -projectname GameStore.WebUI

He переживайте, если получите сообщение, что этот пакет уже установлен. Среда Visual Studio молча добавляет этот пакет к проекту, когда вы случайно отметили флажок Reference Script Libraries (Ссылаться на библиотеки сценариев) при использовании средства формирования шаблонов для создания представления.

Затем понадобится добавить элементы <script> для помещения файлов JavaScript из пакета в HTML-разметку приложения. Эти ссылки проще всего добавить в файл _AdminLayout.cshtml, чтобы проверка достоверности на стороне клиента работала на любой странице, которая применяет данную компоновку. Необходимые изменения в компоновке показаны в примере ниже. (Порядок следования элементов <script> играет важную роль.)

...
<head>
    <meta name="viewport" content="width=device-width" />
    <link href="~/Content/bootstrap.css" rel="stylesheet" />
    <link href="~/Content/bootstrap-theme.css" rel="stylesheet" />
    <link href="~/Content/ErrorStyles.css" rel="stylesheet" />
    <script src="~/Scripts/jquery-1.9.1.js"></script>
    <script src="~/Scripts/jquery.validate.js"></script>
    <script src="~/Scripts/jquery.validate.unobtrusive.js"></script>
    <title></title>
</head>
...

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

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

...
@{
    ViewBag.Title = "Админ панель: редактирование товара";
    Layout = "~/Views/Shared/_AdminLayout.cshtml";
    HtmlHelper.ClientValidationEnabled = false;
    HtmlHelper.UnobtrusiveJavaScriptEnabled = false;
}
...

Приведенные операторы отключают проверку достоверности на стороне клиента только для представления, в которое они добавлены. Чтобы отключить проверку достоверности на стороне клиента для всего приложения, необходимо установить ряд значений в файле Web.config:

...
<appSettings>
    ...
    <add key="ClientValidationEnabled" value="false" />
    <add key="UnobtrusiveJavaScriptEnabled" value="false" />
</appSettings>
...
Пройди тесты
Лучший чат для C# программистов