ASP.NET MVC: История одного проекта "UI - всё для пользователя" (часть 5)

Опубликовано 14.05.2012 09:39:01 в категории Микросервисы

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

Содержание

ASP.NET MVC: История одного проекта "Готовимся к старту" (часть 1)
ASP.NET MVC: История одного проекта "Всё ради данных" (часть 2)
ASP.NET MVC: История одного проекта "Шаблоны и внешний вид" (часть 3)
ASP.NET MVC: История одного проекта "Еще немного классов" (часть 4)
ASP.NET MVC: История одного проекта "UI - всё для пользователя" (часть 5)
ASP.NET MVC: История одного проекта "UI - Добавление экспоната" (часть 6)
ASP.NET MVC: История одного проекта "UI - Редактирование экспоната" (часть 7)
ASP.NET MVC: История одного проекта "Обработка ошибок" (часть 8)
ASP.NET MVC: История одного проекта "Фильтрация" (часть 9)
ASP.NET MVC: История одного проекта "Поиск" (часть 10)
ASP.NET MVC: История одного проекта "Облако тегов" (часть 11)
ASP.NET MVC: История одного проекта "Главная страница" (часть 12)

Что имеем и к чему стремиться

В предыдущей части были подготовлены шаблоны, классы, репозитории и контроллеры. Теперь пришло время привести внешний вид представлений (Views) экспонатов музея к адекватному виду (про ленту говорить в этой статье не  буду). Я обещал, что к моменту написания следующей (этой) части, перенесу данные и пользователей в одну новую базу, и все последующие изменения в структуре данных будут происходить уже по средствам EF миграций (Migrations). Показывать как я проделал операцию переноса не буду, скажу что после экспорта данных в новую базу я запустил aspnet_regsql.exe (об этой утилите было рассказно в первой части), потом завел одного пользователя и добавил ему роль "Администратор". Еще надо в web.config поправить строку подключения к базе с пользователями.

<connectionStrings>
  <add name="ApplicationServices"
    connectionString="Data Source=(local);
      Initial Catalog=museumDb;Integrated Security=True"
    providerName="System.Data.SqlClient" />
  <add name="MuseumContext"
    connectionString="Data Source=(local);
      Initial Catalog=museumDb;Integrated Security=True"
    providerName="System.Data.SqlClient" />
</connectionStrings>

Обратите внимание на значения параметров Initial Catalog для обоих строк - теперь они одинаковые.

Запустил сайт, оглядел страницы музея (про ленту анекдотов на этот раз говорить не будем), и вот что я увидел. Так отображется главная страница для музея юмора (уже с данными):

А вот так выгдядит страница детальная информации:

Ужасный вид, не правда ли? А вот так выглядит форма добавление нового экспоната:

А так форма редактирования:

Душераздирающее зрелище! Особенно не радует тот факт, что я совершенно забыл разграничить доступ. То есть на страницы, которые должны заходить только администраторы, меня сайт запустил без проверки моей учетной записи и, соответстветнно, моих ролей. Сделаем это сейчас. Для этого надо в контроллере Museum на методами Edit и Create поставить атрибут (AuthorizeAttribute), который потребует от пользователя наличие роли "Administrator":

[Authorize(Roles="Administrator")]
public ActionResult Create() {
  /// ...
}
[HttpPost]
[Authorize(Roles = "Administrator")]
public ActionResult Create(Exhibit exhibit) {
  //...
}

и для методов редактирования:

[Authorize(Roles="Administrator")]
public ActionResult Edit() {
  /// ...
}
[HttpPost]
[Authorize(Roles = "Administrator")]
public ActionResult Edit(Exhibit exhibit) {
  //...
}

Теперь, если попытаться открыть страницу с формой добавления или редактирования какого-нибудь экспоната, сайт перенаправит меня на страницу входа. Именно такое поведение я и планировал.

Полезная оптимизация №1

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

Потом создаю новую строку ресурса:

И после всего этого, я могу смело писать свой собственный атрибут авторизации, который будет использовать данные из ресурсного файла:

public class AdminOnlyAttribute : AuthorizeAttribute
{
  protected override bool AuthorizeCore(System.Web.HttpContextBase httpContext)
  {
    if (httpContext == null) throw new ArgumentNullException("httpContenxt");
    if (!httpContext.User.Identity.IsAuthenticated) return false;
    bool authorize = httpContext.User.IsInRole(Resource.AdminRoleName);
    return authorize;
  }
}

При таком подходе, я могу, просто изменив (например, при переносе в другую систему функционал связанный с авторизацией) название роли в файле ресурсов, скопировать сборку по работе с авторизованными пользователями. И раз теперь я имею такой атрибут, применим его в контроллере:

[AdminOnly]
public ActionResult Create() {
  /// ...
}
[HttpPost]
[AdminOnly]
public ActionResult Create(Exhibit exhibit) {
  //...
}

и для методов редактирования:

[AdminOnly]
public ActionResult Edit() {
  /// ...
}
[HttpPost]
[AdminOnly]
public ActionResult Edit(Exhibit exhibit) {
  //...
}

Вот такое небольшое отступление от темы получилось. Надеюсь? оно было полезным (пишите в комментариях, нужные ли они в статье или можно без них). Теперь дальше пройдемся по MVC framework'у.

Лирическое отступление на тему "главная страница"

Чтобы привести главную страницу к нормальному виду, я для начала сделаю очередное отступление. Дело в том, что я взял за привычку не "вытаскивать" модели на представления (Views). Каюсь, что не показал как это делается во второй части "истории одного проекта" (далее ИОП) при отрисовке меню сайта (Hall), хотя мне и надо было всего лишь проверить, что контейнер работает правильно. На данный момент у меня меню сайта, также отображается с использованием ViewModel:

public class HallViewModel : Humor {
  public HallViewModel(Hall hall) {
    this.Id = hall.Id;
    this.Name = hall.Name;
  }
  /// <summary>
  /// Наименование
  /// </summary>
  [Display(Name = "Наименование")]
  [Required(ErrorMessage = "Наименование - обязательно поле")]
  [StringLength(50, ErrorMessage = "Наименование не может длиннее 50 символов")]
  public string Name { get; set; }
}

И теперь, чтобы быть последовательным, я постараюсь подробно рассказать, как я буду делать это для сущности Exhibit так, как будто у меня это первая сущность, в этом проекте, которая должна превращаться во ViewModel и обратно. Итак поехали...

Model -> ViewModel -> View

Для начала создаю в папке Models еще одну папку, называю ее ViewModels. Теперь в этой папке создаю новый класс ExhibitViewModel:

namespace Calabonga.Mvc.Humor.Models {
  /// <summary>
  /// ViewModel для сущности Exhibit
  /// </summary>
  public class ExhibitViewModel : Humor {
    public ExhibitViewModel(Exhibit exhibit) {
      this.Id = exhibit.Id;
      this.Content = exhibit.Content;
      this.CreatedAt = exhibit.CreatedAt;
      this.HallName = exhibit.Hall.Name;
      this.HallId = exhibit.HallId;
      this.Tags = exhibit.Tags;
      this.Title = exhibit.Title;
      this.TotalViewCount = exhibit.TotalViewCount;
      this.UpdatedAt = exhibit.UpdatedAt;
      this.PeriodViewCount = exhibit.PeriodViewCount;
    }

    /// <summary>
    /// Содержание
    /// </summary>
    [Display(Name = "Содержание")]
    [Required(ErrorMessage = "Содержание - обязательно поле")]
    [DataType(DataType.MultilineText)]
    public string Content { get; set; }

    public DateTime CreatedAt { get; set; }

    /// <summary>
    /// Идентификтор зала
    /// </summary>
    [Display(Name = "Зал экспозиции")]
    public string HallName { get; set; }

    /// <summary>
    /// Идентификтор зала
    /// </summary>
    [Required(ErrorMessage = "Номер зала - обязательное поле")]
    [Display(Name = "Номер зала")]
    public int HallId { get; set; }

    /// <summary>
    /// Навигационное свойство для связи с метками
    /// </summary>
    public ICollection<Tag> Tags { get; set; }

    /// <summary>
    /// Заголовок
    /// </summary>
    [Display(Name = "Заголовок")]
    [Required(ErrorMessage = "Заголовок - обязательно поле")]
    [StringLength(255, ErrorMessage = "Заголовок не может длиннее 255 символов")]
    public string Title { get; set; }

    /// <summary>
    /// Дата обновления
    /// </summary>
    [Display(Name = "Дата обновления")]
    [DataType(DataType.Date)]
    public DateTime? UpdatedAt { get; set; }

    /// <summary>
    /// Общее количество просмотров
    /// </summary>
    [Display(Name = "Общее количество просмотров")]
    public int TotalViewCount { get; set; }

    /// <summary>
    /// Общее количество просмотров за период
    /// </summary>
    [Display(Name = "Общее количество просмотров за перид")]
    public int PeriodViewCount { get; set; }
  }
}

Могу догадаться, что в какой-то момент времени, у вас может возникнуть вопрос: "Зачем это ViewModel нужен, если можно показать на предствалении (View) просто модель и не париться?". На что готов ответить следующее. Бывают моменты в процессе разработки, когда приходится менять структуру данных так сильно (речь идет о серьезных, больших проектах... например, на основании требования заказчика), что применение изменений для представлений, репозиториев и контроллеров под это обновление, если не в корне "убивает" всю проделанную до этого работу, то ужасно сильно затягивает процесс введения обновлений и замедляет разработку. Сие утверждение проверено на собственном опыте. Даже если вы больше никогда не будете изменять проект (структуру), хуже от того, что вместо моделей на представлении появятся их "заменители" (ViewModels) точно не будет. Единственное что вы потеряете - немного времени, зато в будующем оно (затраченное время) к вам вернется с лихвой, если придется расширить или обновить уже существующий функционал (структуру данных). Так что мой вам совет: всегда создавайте ViewModel для Model чтобы отобразить ее на представлении (View).

Итак, ViewModel есть, теперь немного расширений. Я создаю файл расширений в папке Engine, под названием ModelsExtensions (namespace я оставил Calabonga.Mvc.Humor), в который напишу расширение для класса Exhibit (и для Hall тоже).

public static class ModelsExtensions {
  #region Exhibit
  public static IEnumerable<ExhibitViewModel> ToViewModels(this IEnumerable<Exhibit> source) {
    if (source == null) { return null; }
    List<ExhibitViewModel> result = new List<ExhibitViewModel>();
    Parallel.ForEach(source, x => result.Add(new ExhibitViewModel(x)));
    return result;
  }
  #endregion
  #region Hall
  public static IEnumerable<HallViewModel> ToViewModels(this IEnumerable<Hall> source) {
    if (source == null) { return null; }
    List<HallViewModel> result = new List<HallViewModel>();
    Parallel.ForEach(source, x => result.Add(new HallViewModel(x)));
    return result;
  }
  #endregion
}

Контроллер тоже надо "поправить" изменив метод Index, который получает данные для главной страницы. Главное изменение выделено жирным:

public ViewResult Index(int? id) {
  var model = exhibitRepository
    .AllIncluding(exhibit => exhibit.Hall,
      exhibit => exhibit.Tags)
    <strong>.ToViewModels();
</strong>  return View(model.ToPagedList(id ?? 1));
}

А вот теперь пришло время поговорить о представлении (View) главной страницы музея юмора. Я немного поколдовал над внешиним видом: теперь страница выглядит так:

Главное отличие от предыдущего вида в том, что это теперь не <table>, а <div>. Я не случайно выделил на картинке место желтым маркером, тут должны быть метки (Tags), Хотя нет... об этом позже. Немного кода разметки. Так выглядит код главной страницы:

@model PagedList<Calabonga.Mvc.Humor.Models.ExhibitViewModel>
@{
  ViewBag.Title = "Экспонаты музея юмора";
}
@section header{
  <h2>
    Экспонаты музея юмора</h2>
}
@if (Model != null && Model.Count > 0) {

  foreach (Calabonga.Mvc.Humor.Models.ExhibitViewModel item in Model) {
    <strong>@Html.Partial("Templates/ExhibitTemplate", item);</strong>
  }

  @Html.PagerForPagedList(Model.PageIndex, Model, "index")
}

Обратите внимание, на жирную строку. Эта строка вызывает отрисовку шаблона для каждого объекта типа Exhibit. Код шаблона для Exhibit:

@model Calabonga.Mvc.Humor.Models.ExhibitViewModel
<div class="item">
  <div class="title">@Html.ActionLink(Model.Title, "show", new {id = Model.Id}, null)</div>
  <div class="info">@Html.ActionLink(@Model.HallName, "index", new { hall = Model.HallId })
    @Html.DisplayFor(x => Model.CreatedAt)
  </div>
  <div class="content">@Html.Raw(Model.Content)</div>
  <div class="tags">@Html.DisplayFor(x => Model.Tags)</div>
</div>

Вот только Tags указаны в разметке, но не отображаются правильно, это как раз то самое место, которое я выделил на картинке. Надо исправить данный казус. Я буду отрисовывать на странице метки при помощи специльного представления для отображения, который помещается в папку Shared/DisplayTemplates. Потому для редактирования меток к записям будет использоваться другой шаблон в другой папке Shared/EditorTemplates. Создаю папку DisplayTemplates и новое в ней представление (View) с именем Tag.cshtml.

Содержимое этой View:

@model Calabonga.Mvc.Humor.Models.Tag
<span class="tag">
  @Html.DisplayFor(modelItem => Model.Name)
</span>

Теперь мне осталось просто запустить проект. MVC 3 Framework на столько "умный", что сам поймет, что при отображение коллекции меток (Tags) нужно будет использовать именно этот шаблон для отрисовки каждой метки. Я пока просто показываю названия меток, в дальнейшем они должны стать кликабельны.

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

public static string CutLongText(string text, int maxLength, string appendText) {
  //...
}

Осталось правильно его применить на правильном представлении... Но в каком?!... Да вы совершенно правы! Нужно еще одно преставление (View). Создаю новое представление для сущности Exhibit в папке Templates, назову его ExhibitBriefTemplate:

@model Calabonga.Mvc.Humor.Models.ExhibitViewModel
<div class="item">
  <div class="title">@Html.ActionLink(Model.Title, "show", new {id = Model.Id}, null)</div>
  <div class="info">@Html.ActionLink(@Model.HallName, "index", new { hall = Model.HallId })
    @Html.DisplayFor(x => Model.CreatedAt)
  </div>
  <strong><div class="content">@Html.Raw(Calabonga.Mvc.Humor.Engine.MuseumHelpers.CutLongText(Model.Content, 280, "..."))</div>
</strong>  <div class="tags">@Html.DisplayFor(x => Model.Tags)</div>
</div>

    Ииивавы

И, соответственно, я этот шаблон буду использовать в главном представлении главной странице:

@model PagedList<Calabonga.Mvc.Humor.Models.ExhibitViewModel>
@{
  ViewBag.Title = "Экспонаты музея юмора";
}
@section header{
  <h2>
    Экспонаты музея юмора</h2>
}
@if (Model != null && Model.Count > 0) {

  foreach (Calabonga.Mvc.Humor.Models.ExhibitViewModel item in Model) {<strong>
    @Html.Partial("Templates/ExhibitBriefTemplate", item);</strong>
  }

  @Html.PagerForPagedList(Model.PageIndex, Model, "index")
}

Теперь код теперь в самом шаблоне ExhibitBriefTemplate:

@model Calabonga.Mvc.Humor.Models.ExhibitViewModel
<div class="item">
  <div class="title">@Html.ActionLink(Model.Title, "show", new {id = Model.Id}, null)</div>
  <div class="info">@Html.ActionLink(@Model.HallName, "index", new { hall = Model.HallId })
    @Html.DisplayFor(x => Model.CreatedAt)
  </div>
  <div class="content">@Html.Raw(
        Calabonga.Mvc.Humor.Engine.MuseumHelpers.CutLongText(
            Model.Content, 280,
            string.Concat(" ",
              @Html.ActionLink("[дальше...]",
            "show",
            new { id = Model.Id },
            null).ToString())))</div>
  <div class="tags">@Html.DisplayFor(x => Model.Tags)</div>
</div>

Соответственно, теперь сам внешний вид главной страницы музея принял другой вид (экспонаты с длинным текстом отмечены желтым маркером)

Заключение или что дальше.

По мере работы над каждым из представлений (просмотр списка, единичного объекта, редактирования, создания), я буду писать новые ViewModel, расширения, представления и даже придется создавать другого рода всякие полезности.

P.S.: Я могу не описывать детально, как я делал перечисленные в заключении представления. Если всё всем понятно, можно будет заняться чем-нибудь более полезным в следующих статьях, а представления я сделаю приватно (без написания статьи). Пожелания, замечания, предложения и конструктивная критика принимаются в комментариях.

Спасибо читателям

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

public static class ModelsExtensions {
  #region Exhibit
  public static IEnumerable<ExhibitViewModel> ToViewModels(this IEnumerable<Exhibit> source) {
    if (source == null) { return null; }
    List<ExhibitViewModel> result = new List<ExhibitViewModel>();
    foreach (Exhibit item in source) {
      result.Add(new ExhibitViewModel(item));
    }
    return result;
  }
  #endregion

  #region Hall
  public static IEnumerable<HallViewModel> ToViewModels(this IEnumerable<Hall> source) {
    if (source == null) { return null; }
    List<HallViewModel> result = new List<HallViewModel>();
    foreach (Hall item in source) {
      result.Add(new HallViewModel(item));
    }
    return result;
  }
  #endregion
}

Комментарии к статье ()

Загрузка...

Что-то пошло не по сценарию и завершилось ошибкой. Перезагрузить страницу (F5) 🗙

Переподключаем сервер...

Переподключение сломалось... Пробуем еще раз сек.

Не получилось переподключится.
Пожалуйста, обновите страницу F5

Сессия была поставлена сервером на паузу.

Восстановить сессию не получилось.
Пожалуйста, обновите страницу F5.