Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

jQuery UI Autocomplete w MVC 5 - wybór encji zagnieżdżonej

Wyobraź sobie, że chcesz stworzyć widok do edycji encji Company, która posiada dwie właściwości: Name (typu string) Boss (typu Person). Chcesz by obie właściwości były edytowalne. Dla Company.Name proste pole tekstowe zupełnie wystarczy ale dla Company.Boss potrzebny będzie widget jQuery UI Autocomplete*. Kontrolka ta musi realizować poniższe wymagania:

  • sugestie powinny pojawić się gdy użytkownik zacznie wpisywać nazwisko osoby lub naciśnie klawisz strzałki w dół;
  • identyfikator osoby wybranej jako szef powinien zostać przesłany do serwera;
  • elementy listy powinny prezentować dodatkowe informacje (imię i datę urodzenia);
  • użytkownik musi wybrać element z listy (dowolny tekst nie jest akceptowalny);
  • właściwość określająca szefa musi być sprawdzana (komunikat walidacji i styl muszą być ustawione dla odpowiedniego pola).

Powyższe wymagania pojawiają się często w aplikacjach webowych. Widziałem wiele przekombinowanych sposobów na ich implementacje. Chcę pokazać Ci jak można je zrealizować w szybki i czysty sposób. Zakładam, że posiadasz podstawową wiedzę na temat jQuery UI Autocomplete oraz ASP.NET MVC. W tym poście pokaże jedynie ten kod, który jest bezpośrednio związany z autocomplete ale możesz pobrać pełen projekt demo tutaj. To aplikacja ASP.NET MVC 5/Entity Framework 6/jQuery UI 1.10.4 stworzona w Visual Studio 2013 Express for Web i przetestowana w Chrome 34, FF 28 oraz IE 11 (w trybach 11 i 8). 

Oto nasze klasy domenowe:

public class Company
{
    public int Id { get; set; } 

    [Required]
    public string Name { get; set; }

    [Required]
    public Person Boss { get; set; }
}
public class Person
{
    public int Id { get; set; }

    [Required]
    [DisplayName("First Name")]
    public string FirstName { get; set; }
    
    [Required]
    [DisplayName("Last Name")]
    public string LastName { get; set; }

    [Required]
    [DisplayName("Date of Birth")]
    public DateTime DateOfBirth { get; set; }

    public override string ToString()
    {
        return string.Format("{0}, {1} ({2})", LastName, FirstName, DateOfBirth.ToShortDateString());
    }
}

Nic nadzwyczajnego, kilka właściwości ze standardowymi atrybutami dla wymagalności pól i dobrej prezentacji. Klasa Person posiada override metody ToString - tekst z tej metody będzie wykorzystany do budowy listy sugestii kontrolki autocomplete.

Edycja encji Company bazuje na tym modelu widoku:

public class CompanyEditViewModel
{    
    public int Id { get; set; }

    [Required]
    public string Name { get; set; }

    [Required]
    public int BossId { get; set; }

    [Required(ErrorMessage="Please select the boss")]
    [DisplayName("Boss")]
    public string BossLastName { get; set; }
}

Zauważ, że istnieją dwie właściwości stworzone dla potrzeb danych Boss.

Poniżej znajduje się fragment widoku edycji odpowiedzialny za wyświetlanie pola tekstowego z kontrolką  jQuery UI Autocomple dla property Boss:

<div class="form-group">
    @Html.LabelFor(model => model.BossLastName, new { @class = "control-label col-md-2" })
    <div class="col-md-10">
        @Html.TextBoxFor(Model => Model.BossLastName, new { @class = "autocomplete-with-hidden", data_url = Url.Action("GetListForAutocomplete", "Person") })
        @Html.HiddenFor(Model => Model.BossId)
        @Html.ValidationMessageFor(model => model.BossLastName)
    </div>
</div>

form-group i col-md-10 to klasy należące do frameworka Bootstrap, który jest użyty w szablonach projektów MVC 5 - nie przejmuj się nimi. Właściwość BossLastName jest użyta do wyświetlania etykiety, pola tekstowego i komunikatu walidacji. Widok posiada ukryte pole do przechowania identyfikatora osoby wybranej dla właściwości Boss (encja Person). Helper @Html.TextBoxFor odpowiedzialny za prezentacje widocznego pola tekstowego definiuje klasę i atrybut data. Klasa autocomplete-with-hidden służy do oznaczania inputów, które powinny otrzymać funkcjonalność autocomplete. Atrybut data-url istnieje po to by dostarczyć informacji o adresie action methody, która zwraca dane dla listy sugestii autocomplete. Użycie Url.Action to lepszy pomysł niż wstawianie adresu na sztywno do kodu JavaScript ponieważ helper bierze pod uwagę reguły routingu, które mogą ulec zmianie.

Oto HTML, który został wyprodukowany przez pokazany wyżej kod Razor:

<div class="form-group">
    <label class="control-label col-md-2" for="BossLastName">Boss</label>
    <div class="col-md-10">
        <span class="ui-helper-hidden-accessible" role="status" aria-live="polite"></span>
        <input name="BossLastName" class="autocomplete-with-hidden ui-autocomplete-input" id="BossLastName" type="text" value="Kowalski" 
         data-val-required="Please select the boss" data-val="true" data-url="/Person/GetListForAutocomplete" autocomplete="off">
        <input name="BossId" id="BossId" type="hidden" value="4" data-val-required="The BossId field is required." data-val-number="The field BossId must be a number." data-val="true">
        <span class="field-validation-valid" data-valmsg-replace="true" data-valmsg-for="BossLastName"></span>
    </div>
</div>

To kod JavaScript odpowiedzialny za instalację kontrolki jQuery UI Autocomplete:

$(function () {
    $('.autocomplete-with-hidden').autocomplete({
        minLength: 0,
        source: function (request, response) {
            var url = $(this.element).data('url');
   
            $.getJSON(url, { term: request.term }, function (data) {
                response(data);
            })
        },
        select: function (event, ui) {
            $(event.target).next('input[type=hidden]').val(ui.item.id);
        },
        change: function(event, ui) {
            if (!ui.item) {
                $(event.target).val('').next('input[type=hidden]').val('');
            }
        }
    });
})

Opcja source jest ustawiona na funkcję. Funkcja ta ściąga dane z serwera za pomocą wywołania $.getJSON. URL jest pobierany z atrybutu data-url. Jeśli chcesz kontrolować caching albo dodać obsługę błędów możesz przełączyć się na funkcje $.ajax. Zdarzenie change jest obsługiwane po to by zapewnić, że wartości BossId oraz BossLastName są ustawione jedynie wówczas gdy użytkownik wybierze element z listy sugestii.

To jest metoda kontrolera dostarczająca dane dla autocomplete:

public JsonResult GetListForAutocomplete(string term)
{               
    Person[] matching = string.IsNullOrWhiteSpace(term) ?
        db.Persons.ToArray() :
        db.Persons.Where(p => p.LastName.ToUpper().StartsWith(term.ToUpper())).ToArray();

    return Json(matching.Select(m => new { id = m.Id, value = m.LastName, label = m.ToString() }), JsonRequestBehavior.AllowGet);
}

value i label to standardowe właściwości których oczekuje widget. label określa tekst prezentowany na liście sugestii, value wyznacza jakie dane są prezentowane w polu na którym został zainstalowany widget. id to niestandardowa właściwość wskazująca która encja Person została wybrana. Informacja ta jest użyta przy obsłudze zdarzenia select (zauważ odwołanie do ui.item.id). Wybrane ui.item.id jest ustawiane jako wartość ukrytego pola - dzięki temu jest ono przesłane w żądaniu HTTP gdy użytkownik decyduje się na zapis danych firmy.

Na zakończenie metoda zapisująca dane encji Company:

public ActionResult Edit([Bind(Include="Id,Name,BossId,BossLastName")] CompanyEditViewModel companyEdit)
{
    if (ModelState.IsValid)
    {
        Company company = db.Companies.Find(companyEdit.Id);
        if (company == null)
        {
            return HttpNotFound();
        }

        company.Name = companyEdit.Name;

        Person boss = db.Persons.Find(companyEdit.BossId);
        company.Boss = boss;
        
        db.Entry(company).State = EntityState.Modified;
        db.SaveChanges();
        return RedirectToAction("Index");
    }
    return View(companyEdit);
}

Standardowy kod. Jeśli kiedykolwiek używałeś Entity Framework powyższe instrukcje powinienny być dla Ciebie jasne. Jeśli tak nie jest, nie martw się. Dla celów tego posta istotne jest zauważenie, że możemy użyć companyEdit.BossId ponieważ właściwość ta została wypełniona przez model binder dzięki istnieniu naszego ukrytego pola.

I to tyle, wszystkie wymagania spełnione! Prawda, że proste? :)

* Możesz się zastanawiać dlaczego w ogóle chcę użyć kontrolki jQuery UI w projekcie Visual Studio 2013, który domyślnie korzysta z Twitter Bootstrap. To prawda, że Bootstrap posiada pewne widgety i wtyczki ale po chwili eksperymentowania doszedłem do wniosku, że dla pewnych bardziej skomplikowanych scenariuszy jQ UI działa lepiej. Ten zestaw kontrolek jest po prostu bardziej dojrzały...

Radio buttony dla elementów listy w MVC 4 – problem z unikalnością id

Załóżmy, że mamy pewien model, który posiada listę i chcemy wytworzyć radio buttony dla elementów tej listy. Rzuć okiem na poniższy przykład.

Główna klasa modelu zawierająca listę:

using System.Collections.Generic;

public class Team
{
    public string Name { get; set; }
    public List<Player> Players { get; set; }
}

Klasa dla elementów listy:

public class Player
{
    public string Name { get; set; }
    public string Level { get; set; }
}

Istnieją trzy akceptowalne wartości dla właściwości Level:  BEG (Beginner), INT (Intermediate) oraz ADV (Advanced), chcemy więc wyświetlić trzy radio buttony (wraz z etykietami) dla każdego z zawodników w drużynie. Normalnie pewnie użylibyśmy enum dla property Level ale w imię prostoty przykładu zostaniemy przy typie string...

Metoda kontrolera zwracająca przykładowe dane:

public ActionResult Index()
{
    var team = new Team() {
        Name = "Some Team",
        Players = new List<Player> {
               new Player() {Name = "Player A", Level="BEG"},
               new Player() {Name = "Player B", Level="INT"},
               new Player() {Name = "Player C", Level="ADV"}
        }
    };

    return View(team);
}

Oto widok Index.cshtml:

@model Team

<section>
    <h1>@Model.Name</h1>        

    @Html.EditorFor(model => model.Players)            
</section>

Zauważ, że markup dla właściwości Players nie jest tworzony wewnątrz pętli. Zamiast tego użyty jest EditorTemplate. Jest to zalecane podejście ponieważ sprawia, że kod jest bardziej przejrzysty i prostszy w utrzymaniu. Framwork jest na tyle sprtytny, że używa kodu z szablonu edytora dla każdego z zawodników na liście ponieważ Team.Players implementuje interfejs IEnumerable.

A oto wspomniany Players.cshtml EditorTemplate:

@model Player
<div>
    <strong>@Model.Name:</strong>

    @Html.RadioButtonFor(model => model.Level, "BEG")
    @Html.LabelFor(model => model.Level, "Beginner")

    @Html.RadioButtonFor(model => model.Level, "INT")
    @Html.LabelFor(model => model.Level, "Intermediate")

    @Html.RadioButtonFor(model => model.Level, "ADV")
    @Html.LabelFor(model => model.Level, "Advanced")        
</div>

Kod wygląda w porządku, używa silnie typowanych helperów opartych o wyrażenia lambda (dla wychwycenia błędów podczas kompilacji i łatwiejszego refaktoringu)... niestety, jest pewien problem: HTML wygenerowany przez taki kod ma bardzo poważną wadę. Sprawdź poniższy fragment kodu źródłowego strony, który został wygenerowany dla pierwszego zawodnika z listy:

<div>
    <strong>Player A:</strong>  
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" checked="checked" value="BEG">
    <label for="Players_0__Level">Beginner</label>
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" value="INT">
    <label for="Players_0__Level">Intermediate</label>
 
    <input name="Players[0].Level" id="Players_0__Level" type="radio" value="ADV">
    <label for="Players_0__Level">Advanced</label>        
</div>

Identyfikator Players_0__Level jest użyty dla trzech różnych radio buttonów! Brak unikalności nie tylko narusza specyfikacje HTML i utrudnia oskryptowanie, powoduje też, że etykiety (tagi label) nie działają prawidłowo (tj. kliknięcie nie powoduje zaznaczenia odpowiedniego elementu input). 

Na szczęście biblioteka MVC posiada klasę TemplateInfo z metodą GetFullHtmlFieldId. Metoda ta zwraca identyfikator dla elementu DOM. Identyfikator jest budowany poprzez połączenie argumentu przekazanego do metody z automatycznie wygenerowanym prefiksem. Prefiks uwzględnia poziom zagnieżdżenia i indeks elementu listy. Wewnętrznie, GetFullHtmlFieldId odwołuje się do właściwości TemplateInfo.HtmlFieldPrefix i metody TagBuilder.CreateSanitizedId więc w przypadku przekazania do sufiksu znaków niemogących być częścią id, znaki te zostaną usunięte.

Oto zmodyfikowany kod EditorTemplate:
@model Player
<div>
    <strong>@Model.Name:</strong>

    @{string rbBeginnerId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbBeginner"); }
    @Html.RadioButtonFor(model => model.Level, "BEG", new { id = rbBeginnerId })
    @Html.LabelFor(model => model.Level, "Beginner",  new { @for = rbBeginnerId} )

    @{string rbIntermediateId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbIntermediate"); }
    @Html.RadioButtonFor(model => model.Level, "INT", new { id = rbIntermediateId })
    @Html.LabelFor(model => model.Level, "Intermediate",  new { @for = rbIntermediateId })

    @{string rbAdvancedId = ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId("rbAdvanced"); }
    @Html.RadioButtonFor(model => model.Level, "ADV", new { id = rbAdvancedId })
    @Html.LabelFor(model => model.Level, "Advanced",  new { @for = rbAdvancedId })
</div>

Wywołania metody ViewContext.ViewData.TemplateInfo.GetFullHtmlFieldId pozwalają na pozyskanie id dla radio buttonów, identyfikatory te są wykorzystane również do ustawienia atrybutów for elementów label. W MVC 3 nie było przeładowanej metody LabelFor, która akceptowałaby obiekt dla htmlAttributes. Na szczęście wersja 4 ma wbudowany stosowny overload.

Powyższy kod tworzy taki markup:

<div>
    <strong>Player A:</strong>
 
    <input name="Players[0].Level" id="Players_0__rbBeginner" type="radio" checked="checked" value="BEG">
    <label for="Players_0__rbBeginner">Beginner</label>
 
    <input name="Players[0].Level" id="Players_0__rbIntermediate" type="radio" value="INT">
    <label for="Players_0__rbIntermediate">Intermediate</label>
 
    <input name="Players[0].Level" id="Players_0__rbAdvanced" type="radio" value="ADV">
    <label for="Players_0__rbAdvanced">Advanced</label>
</div>

Teraz inputy mają unikalne identyfikatory a labele poprawnie odwołują się do radio buttonów poprzez atrybut for. I w porządku :)

Przy okazji: dziwna nazwa „radio button” pochodzi od odbiorników radiowych, które posiadały panele z przyciskami do wyboru stacji (wciśnięcie jednego guzika automatycznie wybijało pozostałe).