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

Сайтостроение | создано: 22.05.2012 | опубликовано: 23.05.2012 | обновлено: 13.01.2024 | просмотров: 16567 | всего комментариев: 3

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

Содержание

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)

Сразу к делу

Так получилось, что на текущий момент уже практически всё есть, для того чтобы приступить к работе на формой добавления нового экспоната. У меня в контролере при помощи MvcScaffolding сгенерирован метод Create:

[AdminOnly]
public ActionResult Create() {
  ViewBag.PossibleHall = hallRepository.All;
  return View();
}

[HttpPost]
[AdminOnly]
public ActionResult Create(Exhibit exhibit) {
  if (ModelState.IsValid) {
    exhibitRepository.InsertOrUpdate(exhibit);
    exhibitRepository.Save();
    return RedirectToAction("Index");
  } else {
    ViewBag.PossibleHall = hallRepository.All;
    return View();
  }
}

Одно небольшое "но". MvcScaffolding сгенерировал все представления (Create, Edit, Delete и т.д.) с использванием модели Exhibit, что совершенно меня не устраивает. Предварительно сохранив только одно представление (Index, потому что в прошлой статье оно уже приобрело правильный внешний вид, а модель на ViewModel я уже поменял), я дал команду Scaffod перегенерировать все представления, но уже с использованием ExhibitViewModel:

PM> Scaffold Views -Controller Museum -ModelType ExhibitViewModel -Force

Результат выполнения:

Added Create view at 'Views\Museum\Create.cshtml'
Added Edit view at 'Views\Museum\Edit.cshtml'
Added Delete view at 'Views\Museum\Delete.cshtml'
Added Details view at 'Views\Museum\Details.cshtml'
Added Index view at 'Views\Museum\Index.cshtml'
Added _CreateOrEdit view at 'Views\Museum\_CreateOrEdit.cshtml'
PM>

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

@model Calabonga.Mvc.Humor.Models.ExhibitViewModel
@{
  ViewBag.Title = "Create";
}

<h2>Create</h2>

@using (Html.BeginForm()) {
  @Html.ValidationSummary(true)
  <fieldset>
    <legend>ExhibitViewModel</legend>

    @Html.Partial("_CreateOrEdit", Model)

    <p>
      <input type="submit" value="Create" />
    </p>
  </fieldset>
}

<div>
  @Html.ActionLink("Back to List", "Index")
</div>

Раз теперь представление "просит" ExhibitViewModel надо поменять методы контроллера на добавление (Create) записи:

[HttpPost]
[AdminOnly]
public ActionResult Create(CreateOrEditExhibitViewModel model) {
  if (ModelState.IsValid) {
    Exhibit exhibit = new Exhibit();
    if (exhibit != null) {
      <strong>exhibit.UpdateFromViewModel(model);
</strong>      exhibitRepository.InsertOrUpdate(exhibit);
      exhibit.Tags = TagsManager.ProcessTags(null, model.TagsFromString()
        .Select(x => x.Name).ToArray(),
        tagRepository,
        exhibitRepository);
      exhibitRepository.Save();
      return RedirectToAction("index");
    } else {
      // пока при отсутвии обработки ошибок
      // буду перекидывать на страницу списка
      return RedirectToAction("index");
    }
  } else {
    ViewBag.PossibleHall = hallRepository.All;
    return View();
  }
}

Вы навреное уже обратили внимание на строку кода:

exhibit.UpdateFromViewModel(model);

Это очередное расширение для класса Exhibit, которое я опять же поместил в файл ModelExtensions:

public static Exhibit UpdateFromViewModel(this Exhibit source, ExhibitViewModel model) {
  if (source == null) { return null; }
  source.Content = model.Content;
  if (model.Id > 0) {
    source.CreatedAt = model.CreatedAt;
    source.UpdatedAt = DateTime.Now;
  } else {
    source.CreatedAt = DateTime.Now;
  }
  source.HallId = model.HallId;
  source.Tags = model.Tags;
  source.Title = model.Title;
  return source;
}

Представление я пока оставлю как есть, а пока попробую добавить новый экспонат, что называется "с налета", а вдруг всё правильно сгенерировалось и мне тогда останется только "раcкрасить" страницу и "прилепить" обработку меток (tags)... Запускаю... Вид ужасный, но зато уже можно выбрать зал (hall) из выпадающего списка для нового экспоната. MVC правильно сгенерировал методы и представления, добавив в  на представление ViewBag.PossibleHall:

[AdminOnly]
public ActionResult Create() {
  ViewBag.PossibleHall = hallRepository.All;
  return View();
}

Так выглядит представление для добавления экспоната:

Попробывал просто нажать снопку Create ... Опа! Ошибка:

 Для данного объекта не определено беспараметрических конструкторов. (No parameterless constructor defined for this object)

Это значит, что при попытке отправить с формы ExhibitViewModel, MVC не може создать ViewModel, потому что у него нет конструктора по умолчанию (без параметров). Надо бы исправить этот факт, добавив конструктор без параметров в класс ExhibitViewModel:

public ExhibitViewModel() {}

И еще пока не забыл, я в ExhibitViewModel убрал наследование от Humor, а класс просто добавил поле Id:

[Key]
[Display(Name = "Идентификатор")]
public int Id { get; set; }

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

Лирическое отступление

Поразмыслив о вечном формах отображения, редактирования и создания для сущности Exhibit, я пришел в выводу, что малова-то у меня ViewModel'ов. Почему? При создании и редактировании экспоната большенство его свойств мне не потребуется, например, свойство UpdatedAt. Что же касается меток (Tags), то администратор сайта будет добавлять метки как строку разделенную запятыми, и при создании, и при редактировании экспоната, то есть во ViewModel должно быть специальное поле. А вот для отображения экспоната в списке (Index) или на детализированном представлении (Show), мне потребуются почти все поля (кроме "системных"). Таким образом получается, что для добавления нового экспоната, на форму через ViewModel мне достаточно свойств Title, Content, Tags, причем Tags типа String.

Создадим я новый ViewModel, который будет использоваться для добавления и для редактирования экспонатов. Наверное не трудно догадаться, что у меня два ViewModel'а буду содержать большое количество одинаковых строк. Думаю, будет не лишним сделать базовый ExhibitViewModel с одинаковыми свойствами, а потом от него унаследовать ShowExhibitViewModel и CreateOrEditExhibitViewModel. При этом придется немного переименовать текущий класс и, соответственно, обновить привязки моделей на представлениях и в методах контроллера, чтобы на представления отправлялся новый ShowExhibitViewModel взамен ExhibitViewModel (также придется поправить расширение ToViewModels).

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

/// <summary>
/// Базовый ViewModel для сущности Exhibit
/// </summary>
public class ExhibitViewModel {

  public ExhibitViewModel() {}


  public ExhibitViewModel(Exhibit exhibit) {
    this.Id = exhibit.Id;
    this.Content = exhibit.Content;
    this.HallId = exhibit.HallId;
    this.Title = exhibit.Title;
  }
  /// <summary>
  /// Идентификатор
  /// </summary>
  [Key]
  [Display(Name = "Идентификатор")]
  public int Id { get; set; }

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

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

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

А теперь новый ShowExhibitViewModel (используетя на представлениях Index и Show):

/// <summary>
/// ViewModel для сущности Exhibit
/// для отображения в списке и в детальном просмотре
/// </summary>
public class ShowExhibitViewModel : ExhibitViewModel {

  public ShowExhibitViewModel() : base() { }

  public ShowExhibitViewModel(Exhibit exhibit)
    : base(exhibit) {
    this.CreatedAt = exhibit.CreatedAt;
    this.HallName = exhibit.Hall.Name;
    this.Tags = exhibit.Tags;
    this.TotalViewCount = exhibit.TotalViewCount;
    this.UpdatedAt = exhibit.UpdatedAt;
    this.PeriodViewCount = exhibit.PeriodViewCount;

  }

  /// <summary>
  /// Опубликовано
  /// </summary>
  [Display(Name = "Выставлено")]
  [Required(ErrorMessage = "Выставлено - обязательно поле")]
  [DataType(DataType.Date)]
  public DateTime CreatedAt { get; set; }

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

  /// <summary>
  /// Навигационное свойство для связи с метками
  /// </summary>
  public ICollection<Tag> Tags { 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; }
}

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

/// <summary>
/// ViewModel для сущности Exhibit
/// для Создания и Редактирования
/// </summary>
public class CreateOrEditExhibitViewModel : ExhibitViewModel {

  public CreateOrEditExhibitViewModel() : base() { }


  public CreateOrEditExhibitViewModel(Exhibit exhibit)
    : base(exhibit) {
      this.Tags = exhibit.Tags.TagsToString();

  }

  /// <summary>
  /// Метки через запятую
  /// </summary>
  [Display(Name = "Метки через запятую")]
  [Required(ErrorMessage = "Метки через запятую - обязательно поле")]
  [StringLength(100, ErrorMessage = "Метки через запятую не может длиннее 100 символов")]
  public string Tags { get; set; }
}

Также мне пришлось изменить метод UpdateFromViewModel - это расширение для класса Exhibit:

public static Exhibit UpdateFromViewModel(this Exhibit source, CreateOrEditExhibitViewModel model) {
  if (source == null) { return null; }
  if (model.Id > 0) {
    source.UpdatedAt = DateTime.Now;
  } else {
    source.CreatedAt = DateTime.Now;
  }
  source.HallId = model.HallId;
  source.Title = model.Title;
  source.Content = model.Content;
  return source;
}

Можно теперь переходить к меткам. Как вы наверное обратили внимание, расширение UpdateFromViewModel не содержит какого-либо упоминания о метках, зато CreateOrEditExhibitViewModel содержит свойство Tags типа String. Я создал новый класс-помощник TagsManager, который имеет пока один метод ProcessTags:

internal static Collection<Tag> ProcessTags(
      string[] _oldTags,
      string[] _newTags,
      ITagRepository tagRepository,
      IExhibitRepository postRepository) {
  List<Tag> result = new List<Tag>();
  ArrayList toAppend = new ArrayList();
  ArrayList toDelete = new ArrayList();

  if (_oldTags == null) {
    toAppend.AddRange(_newTags);
  } else {
    foreach (string tagname in _newTags) {
      if (!toAppend.Contains(tagname)) {
        toAppend.Add(tagname);
      }
    }
    foreach (string item in _oldTags) {
      if (!_newTags.Contains(item) && !toDelete.Contains(item)) {
        toDelete.Add(item);
      }
    }

  }

  #region ищим в базе Tag если не используется в постах - удаляем его
  //... много букв
  #endregion

  #region ищим в базе Tag если не находим - создаем его
  //... много букв
  #endregion

  return new Collection<Tag>(result);
}

Надеюсь из самого метода всё понятно, для чего он нужен. Вкратце: сравнивает два массива строк (названия меток) на предмет изменений. Если экспонат новый - просто возращает все метки как новые, если экспонат редактируется, то тогда проверяет добавленные метки и удаленные, которые соответственно, потом добавляются в БД или удаляются, если этот был последний экспонат помеченный этой меткой.

Использовать этот TagManager я буду в методе Create:

[HttpPost]
[AdminOnly]
public ActionResult Create(CreateOrEditExhibitViewModel model) {
  if (ModelState.IsValid) {
    Exhibit exhibit = new Exhibit();
    if (exhibit != null) {
      exhibit.UpdateFromViewModel(model);
      exhibitRepository.InsertOrUpdate(exhibit);
      exhibit.Tags =
        <strong>TagsManager.ProcessTags(
          null,
          model.TagsFromString().Select(x => x.Name).ToArray(),
          tagRepository,
          exhibitRepository);</strong>
      exhibitRepository.Save();
      return RedirectToAction("index");
    } else {
      // пока при отсутвии обработки ошибок
      // буду перекидывать на страницу списка
      return RedirectToAction("index");
    }
  } else {
    ViewBag.PossibleHall = hallRepository.All;
    return View();
  }
}

Обратите внимание на выделенный текст, у ViewModel'а есть расширение TagsFromString, которое строку меток преобразует в массив:

public static Collection<Tag> TagsFromString(this CreateOrEditExhibitViewModel source) {
  if (source == null) throw new ArgumentNullException("source");
  List<Tag> newTags = new List<Tag>();
  string[] tagsArray = source.Tags.Trim().Split(new Char[] { ',' },
        StringSplitOptions.RemoveEmptyEntries);
  tagsArray.ToList().ForEach(x => newTags.Add(new Tag {
    CreatedAt = DateTime.Now,
    Name = x.Trim()
  }));
  return new Collection<Tag>(newTags);
}

Сразу же напишу еще одно расширение TagsToString для меток, которое делает обратное - преобразовывает коллекцию меток в строку через запятую:

public static string TagsToString(this IEnumerable<Tag> source) {
  if (source == null) throw new ArgumentNullException("source");
  StringBuilder sb = new StringBuilder();
  source.ToList().ForEach((x) => sb.Append(string.Concat(x.Name, ",")));
  if (sb.Length > 1) {
    sb.Remove(sb.Length - 1, 1);
  }
  return sb.ToString();
}

Я немно переделал форму регистрации нового экспоната:

Теперь всё готово для добавления. Попробовую добавить новый экспонат... Ура!!! Получилось как и планировалось. Экспонат добавился, и метки к нему тоже. Единственное, что пришлось писать эти самые метки наугад, то есть нет пока реализации автоподстановки меток. В следующей статье будем делать Autocomplete для выбора меток (Tags). А также займемся редактированием экспоната.

P.S.: я поменял ArrayList на List<string>, потому что в NET Framework 2.0 появились обощенные типы более оптимизированные на скорость работы и размер занимаемой памяти. В частности, List<T> стал приемников ArrayList в новой версии.

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

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

Непонятно почему игнорируются null значения, переданные в UpdateFromViewModel. В этом случае ожидаемое поведение - это ArgumentNullException.

В коде путаница с выбором типа для набора объектов. Используются: ICollection, IEnumerable, Collection и массив. Стоит придерживаться однообразия, например остановиться на одном IEnumerable или IList, если, например, задача не требует явно того, чтоб передавался именно массив.

В ProcessTags почему-то затесался даже ArrayList, от такого нужно избавиться в пользу того же обобщенного List.

для Artyom Krivokrisenko...

Что касается расширения UpdateFromViewModel и в частности: "...нужно убрать возвращаемый результат, так как из-за его наличия может возникнуть заблуждение...". К сожалению, чужими заблуждениями я пока управлять не умею, так что эту проблему я не силах предотвратить. Про "ArgumentNullException", я подумаю над вашим предложением, впрочем, как и с "стоит придерживаться однообразия".

А вот про ArrayList - да вы правы, издержки "копи-паста". При переносе из текущего "музея юмора". В процессе оптимизации сайта, перед релизом, всё будет исправлено.

Спасибо за комментарий

Однотипную склейку строк с помощью StringBuilder лучше забыть в пользу такой реализации

public static string TagsToString(this IEnumerable<Tag> source)
{
     if (source == null) throw new ArgumentNullException("source");
     return String.Join(",", source.Select(t => t.Name));
}