Ostatnio rozmawiałem ze znajomym, który stwierdził, że jeżeli nie wykorzystuje się wbudowanych w MVC 3 klas i metod to robi się coś źle. Nie zgadzam się z tym twierdzeniem, to co jest zajebiste w MVC 3 to to, że dzięki kilku prostym linijkom kodu można rozszerzyć implementację o niezbędną w danej chwili funkcjonalność. W końcu jak powstało RoR?

Trochę teorii

Z jedną z takich funkcjonalności jest możliwość edycji dynamicznych (dodaj/usuń/edytuj) list – tak by za pomocą jednego submit przekazać wszystko do kontrolera a potem zrobić z danymi co się żywnie podoba. Dla przykładu dodając książkę fajnie by było móc dynamicznie dodawać autorów. Teraz załóżmy, że książka jest zbiorem opowiadań wielu autorów. Czyli jedna książka ma wiele rozdziałów/opowiadań pisanych przez jednego lub wielu autorów. Nagle zaczynami mieć dwie zagnieżdżone listy. Zaczyna się od książki, przez rozdziały do autorów.

MVC standardowo wspiera edycję list – jeżeli nasza książka zawierała by już rozdziały i ich autorów to bez problemu moglibyśmy stworzyć view i za pomocą foreach lub for zbudować odpowiednio zagnieżdżone listy by móc edytować dane. To co zrobimy zostanie zamienione mniej więcej na taki kod:

<input class="text-box single-line" name="Assets[0].Locations[0].Building" type="text" value="A" />
<input class="text-box single-line" name="Assets[0].Locations[1].Building" type="text" value="B" />
<input class="text-box single-line" name="Assets[1].Locations[0].Building" type="text" value="C" />
<input class="text-box single-line" name="Assets[2].Locations[0].Building" type="text" value="D" />

Problem z tym jest taki iż wartości [x] są istotne, jakakolwiek przerwa w ciągłości lub nie wystartowanie od numeru 0 spowoduje iż dalsze dane się nie zbindują. Czyli dynamiczne usunięcie wiersza 4 z 6 spowoduje iż stracimy wiersz 5 i 6 bezpowrotnie.

Istnieje też druga opcja, która jest już mniej znana. Ja to zwiem nadanym indeksem. Haacked swojego czasu bardzo skromnie o tym napisał pod koniec swojego postu. Nazwał to niesekwencyjnymi indeksami. Chodzi o to, że dla każdego kolejnego elementu kolekcji tworzenie jest ukryte pole input o nazwie nazwaWłąsności.Index (nazwaWłasnośći pochodzi od modelu jeżeli mamy Book.Authors, to nazwą będzie Authors) i wartością dowolną (może to być liczba, ciąg znaków itp.).

Kolejne własności elementu kolekcji muszą przyjmować odpowiedni prefix. Jeżeli autor zawiera pole Name, to pole input musi posiadać nazwę nazwaWłasności[Index].Name (gdzie Index to wartości ukrytego pola wcześniej utworzonego). Dzięki takiemu zabiegowi, jesteśmy wstanie sami decydować o indeksacji i nie muszą to być liczby. Także umożliwia nam to dość proste wykorzystanie dynamicznych list, kiedy to my chcemy dodawać i usuwać elementy za pomocą ajaxowych zdarzeń.

Jedyny problem jaki istnieje to tak naprawdę dynamiczne generowanie tych zmiennych wartości – index, którymi trzeba na tyle mądrze zarządzać by:

  1. Się nie powtórzyły;
  2. Byśmy byli wstanie zawsze wygenerować kolejną nową wartość;
  3. I co najważniejsze byśmy wiedzieli jaki jest prefix by w razie konieczności móc go wykorzystać do zagnieżdżonych list.

Generowanie list z indexem

Zadanie mogłoby się wydawać trudne, gdyby nie to, że Sanderson już swojego czasu to rozwiązał i nawet popełnił kilka postów na ten temat. To co ja proponuje to lekko zmodyfikowana wersja kodu Sandersona, która umożliwia bindowanie zagnieżdżonych list, jak trochę ułatwia sposób generowania listy elementów.

Ale zacznijmy od początku. Pierwsze kroki będą prawie takie same jak u Sandersona, dlatego jak wolicie możecie przeczytać jego tekst a potem wrócić do mojego i uzupełnić wiedzę o dodatkowe elementy, lub po prostu przeczytać moje wypociny, obiecuje to nie jest kopia kodu Sandresona, i wprowadziłem do niej pewne poprawki, o których będzie później.

Mając prosty model, dla przykładu:

public class CreateBookViewModel
{
    public int Shelf { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }
    public IEnumerable<CreateChapterViewModel> Chapters { get; set; }
    public IEnumerable<CreateAuthorViewModel> Authors { get; set; }

    public CreateBookViewModel()
    {
        Chapters = new List<CreateChapterViewModel>();
        Authors = new List<CreateAuthorViewModel>();
    }
}

public class CreateChapterViewModel
{
    public int Page { get; set; }
    public string Name { get; set; }
    public IEnumerable<CreateAuthorViewModel> Authors { get; set; }

    public CreateChapterViewModel()
    {
        Authors = new List<CreateAuthorViewModel>();
    }
}

public class CreateAuthorViewModel
{
    public string Name { get; set; }
}

Stwórzmy dla niego listę. Tutaj od razu warto pomyśleć o przyszłości, chcemy w końcu móc dodawać kolejne elementy do listy dynamicznie tak? Więc lepiej jest wydzielić kod odpowiedzialny za wyświetlenie danego elementu do osobnego PartialView. Tutaj mamy dowolność – przynajmniej ja z tym kłopotu nie miałem – możemy zarówno wykorzystać EditorFor z odpowiednim szablonem lub EditorTemplate dla typu, albo RenderPartial. Obydwie metody powinny działać.

<div class="rows addBookAuthor">
    @foreach(var author in Model.Authors)
    {
        Html.RenderPartial(MVC.Book.Views._CreateBookAuthorRow, author);
        //Html.EditorFor(model => author, MVC.Book.Views._CreateBookAuthorRow);
    }
</div>

Zaś nasz PartialView tak:

@model CreateAuthorViewModel

<div class="dynamicRow">
    @using (Html.BeginCollectionItemFor<CreateBookViewModel>(model => model.Authors))
    {
        @:Author: @Html.EditorFor(model => model.Name) @Html.ValidationMessageFor(model => model.Name) <a href="#" class="deleteRow">delete</a>
    }
</div>

Wykorzystujemy tutaj rozszerzeni Html.BeginCollectionItemFor (kod na końcu postu) – to właśnie dzięki jego magii, uzyskujemy możliwość tworzenia pól index, także to on jest odpowiedzialny za generowania kolejnych wartości index jak i trzymanie nad nimi pieczęci. Zasada jest dość prosta, typ generyczny tutaj to typ którego listę chcemy wykorzystać. Jest to ważne ze względu na prefix. Jeżeli mamy rozdział i w rozdziale mamy autorów to prefix dla książki powinien być Chapters[].Authors[]. Jeżeli nie podamy tego, to zostanie nam wygenerowany kod bez Chapters, co spowoduje kłopot z bindowaniem danych – nawet jeżeli byśmy się postarali by to Chapters było, to nie uda nam się w prosty sposób zrobić by był index dla Chapters bez którego nie ma jak zbindować listy autorów do rozdziałów.

Mając tak przygotowany kod możemy go uruchomić i to co zobaczymy nie powinno się wizualnie różnić od zwykłego zastosowania foreach. Jednak kolejne kroki były by znacznie trudniejsze.

Dynamiczne dodawanie elementów

By umożliwić dynamiczne dodawanie elementów potrzebujemy czegoś co nam umożliwi wykonanie akcji dodaj. W tym wypadku wszystko się może sprawdzić – button, div, a, cokolwiek co chcemy, ważne by można było do tego podjąć zdarzenie na kliknięcie. Ze względu na rozszerzenie, ja wykorzystuje tag a. Wygenerowanie linku do dodawania elementu jest beznadziejnie proste, wystarczy napisać następujący kod:

@Html.AddDynamicRowLink("Add book author", MVC.Book.ActionNames.CreateRowForBookAuthor, "addBookAuthor")

Kod generowany wygląda tak:

<a title="Add book author" href="/Book/CreateRowForBookAuthor" data-prefix="" data-placeholder="addBookAuthor" class="addRow">Add book author</a>

Chodzi o to, że nie lubię się zbytnio często powtarzać – czasami – więc zamknięcie tego w helper było moim zdaniem dobrym pomysłem. Atrybuty data-* są tutaj bardzo ważne. Jest to konwencja, którą stosuje do zagnieżdżonych list – następnie w JavaScript mam ułatwione odnajdywanie elementów, dodatkowo użytkownik nie zobaczy dziwnego linka z prefixem niezbędnym do wygenerowania pól dla zagnieżdżonych list.

Jak już pewnie zauważyliście, link wykorzystuje akcje na kontrolerze – której jeszcze nie mamy. Stworzenie jej jest banalnie proste:) Dla przykładu, akcja wyżej wymieniona wygląda tak:

public virtual ActionResult CreateRowForBookAuthor(string prefix)
{
    ViewData[NestedPrefix] = prefix;
    return PartialView(Views._CreateBookAuthorRow, new CreateAuthorViewModel());
}

Ok, pozostała nam jedynie jedna rzecz – dokładnie mówiąc dwie, napisanie dwóch metod do dynamicznego dodawania elementów i ich usuwania w JS. Jeżeli zrobiliśmy tak jak wyżej to zostało pokazane, to stworzenie JS powinno być bardzo proste:

$('a.addRow').live('click', function () {

    var $selector = '.' + $(this).data('placeholder');
    var $prefix = $(this).data('prefix');

    var $element = $(this).parents('div.dynamicRow:first');
    if ($element.length === 0) {
        $element = $($selector);
    } else {
        $element = $($element).find($selector);
    }

    $.ajax({
        url: this.href,
        cache: false,
        type: 'GET',
        dataType: 'html',
        data: {
            prefix: $prefix
        }
    }).success(function (html) {
        $($element).append(html);
    }).error(function (jqXHR, textStatus, errorThrown) {
        alert(errorThrown);
    });

    return false;
});

$('a.deleteRow').live('click', function () {
    $(this).parents('div.dynamicRow:first').remove();

    return false;
});

Wykorzystujemy LIVE ze względu na to, iż jak mamy zagnieżdżone listy to linki dodaj i usuń mogą powstawać dynamicznie, i trzeba się do nich jakoś podpiąć.

Podsumowanie

Teraz powinniśmy już móc dodawać i usuwać elementy dynamicznie z wykorzystaniem zagnieżdżonych list. Cały kod przykładu (części pierwszej) znajduje się na github. Poniżej zaś zmodyfikowana wersja klasy Sandersona do generowania i zarządzania indeksami (uwaga, wykorzystuje tam extension method do łączenia dwóch słowników w jeden).

To co nam pozostało w dwóch ostatnich częściach cyklu, to walidacja po stronie klienta – wiem, że Procent o tym pisał, ale tak czy siak lepiej by to powtórzyć jak i dodać pewne informacje bez których można stanąć w miejscu :)

using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq.Expressions;
using System.Reflection;
using Library.Web.Extensions;

namespace System.Web.Mvc.Html
{
    /// <summary>
    /// Class taken from blog post:
    /// http://blog.stevensanderson.com/2010/01/28/editing-a-variable-length-list-aspnet-mvc-2-style/
    /// Editing a variable length list, ASP.NET MVC 2-style
    /// by
    /// Steve Sanderson
    /// 
    /// Modifications:
    ///     - Extension method for HtmlHelper to include lambda expression
    ///     - Added support for nested lists
    ///     - Added support for adding nested list item dynamically
    ///       by providing __prefix key for ViewData. It needs
    ///       to be set manually!
    ///     - Changed GUID to Guid.GetHashCode(), shorter URL
    ///     - Added ext method for generating add row link
    /// Jakub Gutkowski (http://blog.gutek.pl) @gutek
    /// </summary>
    public static class HtmlPrefixScopeExtensions
    {
        private const string IdsToReuseKey = "__htmlPrefixScopeExtensions_IdsToReuse_";
        private const string ViewDataPrefixKey = "__prefix";
        private const string IndexNameWithDot = ".index";

        public static MvcHtmlString AddDynamicRowLink(this HtmlHelper html, string linktTitle, string actionName, string selector, object htmlAttributes = null)
        {
            var dictHtmlAttrs = HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes);
            var dictDefaults = new Dictionary<string, object>
                               {
                                   { "data-prefix", html.ViewData.TemplateInfo.HtmlFieldPrefix },
                                   { "data-placeholder", selector },
                                   { "title", linktTitle },
                                   { "class", "addRow" }
                               };

            return html.ActionLink(linktTitle,
                                   actionName,
                                   null,
                                   dictDefaults.MergeAttributes(dictHtmlAttrs));
        }

        public static IDisposable BeginCollectionItemFor<TModel>(this HtmlHelper html, Expression<Func<TModel, IEnumerable>> expression)
        {
            Type type = typeof(TModel);

            var member = expression.Body as MemberExpression;
            if(member == null)
            {
                throw new ArgumentException(string.Format(
                    "Expression '{0}' refers to a method, not a property.",
                    expression));
            }

            var propInfo = member.Member as PropertyInfo;
            if(propInfo == null)
            {
                throw new ArgumentException(string.Format(
                    "Expression '{0}' refers to a field, not a property.",
                    expression));
            }

            return html.BeginCollectionItem(member.Member.Name);
        }

        public static IDisposable BeginCollectionItem(this HtmlHelper html, string collectionName)
        {
            var idsToReuse = GetIdsToReuse(html.ViewContext.HttpContext, collectionName);
            string itemIndex = idsToReuse.Count > 0 ? idsToReuse.Dequeue() : Guid.NewGuid().GetHashCode().ToString();

            // section for nested lists, checking if we have prefix from current context
            // and if we don't maybe we have passed prefix using ViewData - its useful when
            // generating Row values by Ajax Request to controller
            var templatePrefix = html.ViewData.TemplateInfo.HtmlFieldPrefix;
            string viewDataPrefix = html.ViewData.ContainsKey(ViewDataPrefixKey)
                                        ? html.ViewData[ViewDataPrefixKey] as string
                                        : string.Empty;

            var currentPrefix = !string.IsNullOrEmpty(templatePrefix)
                                    ? templatePrefix
                                    : viewDataPrefix;

            string dot = string.IsNullOrEmpty(currentPrefix)
                             ? string.Empty
                             : ".";

            // myListProp[0].myCollection
            string fullPrefix = string.Format("{0}{1}{2}",
                                              currentPrefix,
                                              dot,
                                              collectionName);

            // autocomplete="off" is needed to work around a very annoying Chrome behaviour whereby it reuses old values 
            // after the user clicks "Back", which causes the xyz.index and xyz[...] values to get out of sync.   // .
            html.ViewContext.Writer.WriteLine(string.Format(
                CultureInfo.InvariantCulture,
                "<input type="hidden" name="{0}{1}" autocomplete="off" value="{2}" />",
                fullPrefix,
                IndexNameWithDot,
                html.Encode(itemIndex)));

            var fieldPrefix = string.Format(CultureInfo.InvariantCulture, "{0}[{1}]", fullPrefix, itemIndex);

            return html.BeginHtmlFieldPrefixScope(fieldPrefix);
        }

        public static IDisposable BeginHtmlFieldPrefixScope(this HtmlHelper html, string htmlFieldPrefix)
        {
            return new HtmlFieldPrefixScope(html.ViewData.TemplateInfo, htmlFieldPrefix);
        }

        private static Queue<string> GetIdsToReuse(HttpContextBase httpContext, string collectionName)
        {
            // We need to use the same sequence of IDs following a server-side validation failure,  
            // otherwise the framework won't render the validation error messages next to each item.
            string key = IdsToReuseKey + collectionName;
            var queue = (Queue<string>)httpContext.Items[key];

            if(queue != null)
            {
                return queue;
            }

            httpContext.Items[key] = queue = new Queue<string>();
            var previouslyUsedIds = httpContext.Request[collectionName + IndexNameWithDot];
            if(!string.IsNullOrEmpty(previouslyUsedIds))
            {
                foreach(var previouslyUsedId in previouslyUsedIds.Split(','))
                {
                    queue.Enqueue(previouslyUsedId);
                }
            }

            return queue;
        }

        private class HtmlFieldPrefixScope : IDisposable
        {
            private readonly TemplateInfo _templateInfo;
            private readonly string _previousHtmlFieldPrefix;

            public HtmlFieldPrefixScope(TemplateInfo templateInfo, string htmlFieldPrefix)
            {
                _templateInfo = templateInfo;

                _previousHtmlFieldPrefix = templateInfo.HtmlFieldPrefix;
                templateInfo.HtmlFieldPrefix = htmlFieldPrefix;
            }

            public void Dispose()
            {
                _templateInfo.HtmlFieldPrefix = _previousHtmlFieldPrefix;
            }
        }
    }
}

3 KOMENTARZE

  1. Dobrze że o tym w końcu piszesz, jest to aspekt asp mvc który może napsuć niemało krwi i spowodować zbielenie garści włosów na łbie dewelopera, jak nic.

  2. Właśnie "bawię się" wersją Sandersona. Czy to normalne, że zawsze nadaje (dłuuugi) GUID, a nie kolejne liczby (nawet dla statycznie załadowanej listy przez PartialView) ?

  3. Sanderson uzywal GUID wiec to std. Chodzi o to by miec pewnosc ze Index=X bedzie tym z [Index], przy korzystaniu z liczb, moze to byc ciezkie – trzeba sledzic te ktore sie juz wykorzystalo itp. Dlatego ja u siebie zrobilem HashCode z GUID by to skrocic. Dzieki temu mam taka sama pewnosc ze: Jak wezme new Guid to raczej mi sie on nie powtorzy a GetHashCode zawsze zwroci mi unikatowa wartosc.

    Teraz, jezeli nawet ladujesz statycznie dane to i tak on korzysta z Collection Context – czyli przy kolejnych elementach wykorzystuje prefix dla szablonu. Niektorym sie to moze nie podobac przy statycznych listach, ale szczerze mowiac? co szkodzi miec GUID czy HashCode zamaist INT, ktory jest na tyle nie wygodny ze jezeli cos sie popsuje to cala liste trzeba od nowa tworzyc. W ogole dziwie sie ze w MVC 3 wciaz jest std. int index a nie cos innego jak na przyklad sekwencyjny int lub sekwencyjny ciag znakow by zapobiec problemowi z nie bindowaniem wartosci.

Comments are closed.