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...