Model binding kolekcji w ASP.NET MVC

Tworząc aplikacje ASP.NET MVC wcześniej czy później zetkniemy się (lub zderzymy – jak to było w moim przypadku:) z zagadnieniem Model Bindingu. Projektując aplikacje webowe pracujemy z protokołem HTTP, co rodzi konieczność przesyłania danych w oparciu o niego właśnie z i do serwera aplikacji. ASP.NET MVC daje nam do ręki świetny mechanizm ułatwiający ten etap pracy programu – to Model Binding.

Nie jest moją intencją jednak przepisywać tutaj mądre książki o ASP.NET. W krótkich słowach: Model Binding to mechanizm, który mapuje dane otrzymane przez requesty HTTP na obiekty, których potrzebujemy w kontrolerze. Dane brane pod uwagę przez Model Bindera to: dane formularza HTML, dane ścieżki routingu ASP.NET, parametry URL oraz przesyłane pliki. Magia zawarta w Model Binderze powoduje, że na podstawie np. nazw kontrolek formularza uzupełnia on właściwości potrzebnego obiektu.

Nieco problemów mogą sprawić jednak właściwości obiektów będące różnego typu kolekcjami. Jest to jednak całkiem częsta sytuacja: formularz pozwalający na wprowadzenie prostych danych (np. zamówienia) oraz listy jakichś szczegółów (np. pozycji zamówienia). U mnie – na naszym nieśmiertelnym przykładzie obiektu typu Movie – była to lista gatunków filmowych, które filmowi chcemy przypisać.

Pokazałem w jednym z poprzednich postów jak można wykorzystać kontrolkę MvcCheckBoxList do wyświetlenia edytowalnej listy checkboxów. Zacząłem jednak zastanawiać, czy takie rozwiązanie rzeczywiście sprawdzi się u mnie najlepiej. Problemy z nim związane jawiły mi się dwa:

  • chyba niepotrzebne komplikowanie obiektu Dto – tylko do obsługi listy checkboxów musiałem dodać 3 właściwości: Genres, CurrentGenres i SelectedGenres!
  • problem podczas walidacji danych

Pierwsza kwestia jest oczywista – szukam rozwiązania prostego. Drugi problem pojawił się, gdy zauważyłem, że podczas akcji POST znika zawartość właściwości Genres i CurrentGenres (lecz nie SelectedGenres!) – a to powoduje, że ponowne wyświetlenie widoku (z informacją o błędach) nie posiada danych potrzebnych do wygenerowania listy gatunktów filmowych.

Wszystko to doprowadziło do sprawdzenia jak właściwie działa Model Binding dla kolekcji. Okazuje się, że są różnice w bindowaniu zwykłych tablic i bardziej złożonych typów kolekcji. Kontrolka MvcCheckBoxList generuje poniższy kod HTML:

<input checked="checked" id="SelectedGenres0" name="SelectedGenres" type="checkbox" value="1" /><label for="SelectedGenres0">Dramat</label>
<input id="SelectedGenres1" name="SelectedGenres" type="checkbox" value="2" /><label for="SelectedGenres1">Horror</label>
<input checked="checked" id="SelectedGenres2" name="SelectedGenres" type="checkbox" value="3" /><label for="SelectedGenres2">Science-Fiction</label>

Wszystkie inputy mają atrybut name=”SelectedGenres” i stąd właśnie domyślny ModelBinder wie, że właśnie do tej właściwości obiektu MovieCreateOrUpdateDto ma wtłaczać przesłane z formularza dane. Dodatkowo SelectedGenres to kolekcja typów prostych (int), co powoduje, że łatwo dane umieścić w tablicy. W powyższym kodzie HTML widać też przyczynę drugiego problemu z kontrolką MvcCheckBoxList – ModelBinder nie potrafił wypełnić właściwości Genres i CurrentGenres, bo ich w kodzie po prostu już nie było (zostały wykorzystane do wyrenderowania listy checkboxów i puf! – zniknęły).

Co można z tym zrobić? Jedno z rozwiązań to umieszczenie właściwości w jednym z kontenerów na dane udostępnionym przez ASP.NET – np. w TempData:

MovieCreateOrUpdateDto updateModel = movieService.GetCreateOrUpdateDto(id);
TempData["Genres"] = updateModel.Genres;

Musimy wtedy pamiętać, by w przypadku nieudanej walidacji i konieczności powrotu do widoku edycji filmu ponownie uzupełnić właściwości w obiekcie Dto:

if(ModelState.IsValid)
{
    return RedirectToAction("Index");
}
else
{
    updateDto.Genres = (List<GenreSelectionDto>)TempData["Genres"];
    updateDto.CurrentGenres = updateDto.Genres.Where(g => updateDto.SelectedGenres.Contains(g.ID))
     .ToList();
    return View(updateDto);
}

Widać, że sporo z tym zachodu i niezbyt elegancko to wygląda.

Drugim rozwiązaniem mogłaby być poprawka w kontrolce MvcCheckBoxList, tak, by generowała w kodzie HTML brakujące dane. Oznaczałoby to, że pojawi się tam (nadmiarowo) lista danych potrzebnych do odtworzenia właściwości Genres – i służąca tylko do tego! Zrezygnowałem z tego rozwiązania również – nie chcę generować zbędnego, śmieciowego kodu HTML.

Rozwiązanie, które zastosowałem rozwiązało obydwa wyżej zaznaczone problemy. Najpierw usunąłem zbędne właściwości w obiekcie Dto i zastąpiłem je jedną listą:

public List<GenreSelectionDto> Genres { get; set; }

Zmieniłem jednak nieco obiekt GenreSelectionDto:

public class GenreSelectionDto
{
    public int ID { get; set; }
    public string Name { get; set; }
    public bool Checked { get; set; }
}

dodając właściwość Checked – uzupełnianą podczas edycji filmów dla gatunków już zaznaczonych. Problem pierwszy rozwiązany.

Jak wyświetlić dane z tej listy i otrzymać je z powrotem? Posłużyłem się możliwością utworzenia częściowego widoku, który będzie służył jako edytor dla wybranego typu obiektu. Taki widok musimy umieścić w katalogu EditorTemplates (tworzymy go jako podkatalog katalogu Views/Shared lub katalogu widoków danego kontrolera np. Views/Movies). Edytor dla obiektu GenreSelectionDto wygląda tak:

@model VodSearcher.Services.Dto.GenreSelectionDto

@Html.HiddenFor(x => x.ID)
@Html.HiddenFor(x => x.Name)
@Html.CheckBoxFor(x => x.Checked)
@Html.LabelFor(x => x.Checked, Model.Name)
<br />

Natomiast w widoku akcji Edit i Create zamiast wywołania kontrolki MvcCheckBoxList używamy nowo dodanego edytora prostym zapisem:

@Html.EditorFor(m => m.Genres)

Zauważcie, że edytor mamy zdefiniowany dla pojedynczego obiektu, ale możemy wywołać go dla właściwości Genres, która jest kolekcją obiektów. Sprytny ASP.NET zajmie się poprawnym wygenerowaniem kodu dla kolekcji. Wyrenderowana zostanie lista zawierająca dane naszej listy gatunków (ukryte pola ID i Name) oraz checkbox przypisany do właściwości Checked. Jak będzie wyglądał kod HTML? Tak:

<input data-val="true" data-val-number="The field ID must be a number." data-val-required="Pole ID jest wymagane." id="Genres_0__ID" name="Genres[0].ID" type="hidden" value="1" />
<input id="Genres_0__Name" name="Genres[0].Name" type="hidden" value="Dramat" />
<input data-val="true" data-val-required="Pole Checked jest wymagane." id="Genres_0__Checked" name="Genres[0].Checked" type="checkbox" value="true" /><input name="Genres[0].Checked" type="hidden" value="false" /><label for="Genres_0__Checked">Dramat</label>
<br />
<input data-val="true" data-val-number="The field ID must be a number." data-val-required="Pole ID jest wymagane." id="Genres_1__ID" name="Genres[1].ID" type="hidden" value="2" />
<input id="Genres_1__Name" name="Genres[1].Name" type="hidden" value="Horror" />
<input data-val="true" data-val-required="Pole Checked jest wymagane." id="Genres_1__Checked" name="Genres[1].Checked" type="checkbox" value="true" /><input name="Genres[1].Checked" type="hidden" value="false" /><label for="Genres_1__Checked">Horror</label>
<br />
<input data-val="true" data-val-number="The field ID must be a number." data-val-required="Pole ID jest wymagane." id="Genres_2__ID" name="Genres[2].ID" type="hidden" value="3" />
<input id="Genres_2__Name" name="Genres[2].Name" type="hidden" value="Science-Fiction" />
<input data-val="true" data-val-required="Pole Checked jest wymagane." id="Genres_2__Checked" name="Genres[2].Checked" type="checkbox" value="true" /><input name="Genres[2].Checked" type="hidden" value="false" /><label for="Genres_2__Checked">Science-Fiction</label>
<br />

Zwróćcie uwagę na różnicę między tym kodem HTML, a tym wygenerowanym przez MvcCheckBoxList, a konkretnie atrybut name. Tutaj jest on postaci Genres[index].Wlasciwosc. To właśnie dodany index powoduje, że dla kolekcji typów złożonych ModelBinder potrafi je przekształcić w wynikową kolekcję!

Dzięki utworzeniu własnego, prostego edytora dla obiektu ASP.NET wygeneruje nam taki kod, jakiego sam potrzebuje do poprawnego model bindingu. Uff! Dziękujemy ci, ASP.NET:)

 

Marcin

Dodaj komentarz