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

Układ współrzędnych HTML5 Canvas, rysowanie z wartością y rosnącą ku górze ekranu

Układ współrzędnych w HTML 5 Canvas jest ustalony w taki sposób, że za punkt początkowy (0, 0) przyjęty jest lewy-górny róg canvas. To rozwiązanie nie jest niczym niezwykłym w świecie grafiki ekranowej (tak samo jest np. w Windows Forms czy SVG). Popularne kiedyś monitory CRT wyświetlały linie obrazu w kolejności od góry do dołu a obraz w linii tworzony był od lewej do prawej. Umieszczenie punktu (0,0) w lewym-górnym rogu było więc intuicyjne i ułatwiało budowanie sprzętu i oprogramowania do obsługi grafiki… 

Niestety czasem domyślny układ współrzędnych na canvas jest mało praktyczny. Przyjmijmy, że chcesz wykonać animację lotu pocisku. Naturalnie wydaje się, że dla wznoszącego się pocisku wartość współrzędnej y powinna rosnąć. Da to jednak dziwaczny efekt odwróconej trajektorii:

Domyślny układ współrzędnych (y rośnie ku dołowi ekranu)

Można temu zaradzić poprzez modyfikację wartości y przekazywanej do funkcji rysującej:

context.fillRect(x, offsetY - y, size, size);

Dla y = 0, pocisk zostanie umieszczony w miejscu wyznaczonym przez offsetY (by y = 0 oznaczało sam spód canvas ustaw offset na wartość równą wysokości canvas). Im większa będzie wartość y tym wyżej pocisk zostanie narysowany. Problem w tym, że możesz mieć w kodzie setki miejsc, w których wykorzystywana będzie współrzędna y. Wystarczy, że raz zapomnisz uwzględnić offsetY i cały obraz może zostać uszkodzony.

Na szczęście canvas umożliwia wprowadzenie zmian w układzie współrzędnych za pomocą transformacji. Nam przydadzą się dwie metody transformujące: translate(x, y) i scale(x, y). Pierwsza z nich umożliwia przesunięcie początku układu współrzędnych. Druga służy do zmiany wielkości rysowanych obiektów, jednak może też zostać użyta do odwrócenia współrzędnych.

Pojedyncze wykonanie tego kodu sprawi, że początek układu współrzędnych znajdzie się w punkcie (0, offsetY) a wartość y będzie wyższa u góry ekranu:

context.translate(0, offsetY);
context.scale(1, -1);

Przesunięcie i skalowanie układu współrzędnych

Od tej pory możemy wywoływać metody rysujące bez konieczności modyfikacji współrzędnych!

Jest jednak pewien problem: podanie -1 jako wartości drugiego parametru metody scale powoduje, że cały obraz tworzony jest dla odwróconej współrzędnej y. Dotyczy to także tekstu (wywołanie fillText sprawi, że tekst pojawi się „do góry nogami”). Przed wypisaniem tekstu należy więc przywrócić domyślny układ osi y. Ponieważ ręczne przywracanie stanu canvas byłoby bardzo kłopotliwe, istnieją metody save() i restore(), które umożliwiają odpowiednio: odłożenie stanu na stosie i przywrócenie stanu ze stosu. Zaleca się użycie metody save przed dokonaniem transformacji. W stan canvas wchodzą nie tylko użyte transformacje ale też wartości takie jak styl wypełnienia czy grubość linii...

context.save();

context.fillStyle = 'red';
context.scale(2, 2);
context.fillRect(0, 0, 10, 10);

context.restore();

context.fillRect(0, 0, 10, 10);

Powyższy kod sprawi, że zostaną narysowane 2 kwadraty:

Pierwszy z nich ma kolor czerwony i narysowany jest w skali 2x. Drugi jest rysowany w ustawieniach domyślnych canvas (kolor czarny i skala 1x). Dzieje się tak dlatego, że przed zmianami skali i koloru, stan canvas został odłożony na stosie po czym został przywrócony przed narysowaniem drugiego kwadratu.

Jak zamknąć okna pop-up przy zamknięciu okna głównego lub wylogowaniu?

Wyobraź sobie, że dostałeś zlecenie serwisowe dla pewnej bardzo starej aplikacji webowej. Aplikacja ma jedno okno główne i okna pop-up, w których prezentowane są poufne informacje (np. dane płacowe). Klient chciałby by wszystkie pop-upy zostały automatycznie zamknięte gdy użytkownik opuści główne okno aplikacji lub naciśnie w tym oknie przycisk „wyloguj”… 

Więc… jak zamknąć wszystkie okna otworzone za pomocą metody window.open?

W sieci pytanie to pada bardzo często. Niestety najczęściej podawane jest naiwne rozwiązanie problemu, które polega na zapamiętaniu referencji do otwartych okien i późniejsze wywołanie metody close na pop-upach:

var popups = []; 

function openPopup() {
    var wnd = window.open('Home/Popup', 'popup' + popups.length, 'height=300,width=300');
    
    popups.push(wnd);
}

function closePopups() {
    for (var i = 0; i < popups.length; i++) {
        popups[i].close();
    }

    popups = [];
}

W praktyce to nie zadziała bo przecież tablica z referencjami zostaje wyczyszczona przy pełnym przeładowaniu strony (np. po kliknięciu w link albo przy postbacku)…

Inne sugerowane rozwiązanie to nadanie pop-upowi unikalnej nazwy (za pomocą drugiego parametru metody open) i późniejsze pozyskanie referencji do okna:

var wnd = window.open('', 'popup0');
wnd.close();

Chodzi o skorzystanie z faktu, że metoda window.open działa na 2 sposoby:

  1. Jeśli okno o zadanej nazwie nie istnieje, zostanie utworzone.
  2. Jeśli okno o zadanej nazwie istnieje, nie zostanie ponownie utworzone, zamiast tego zostanie zwrócona referencja do niego (jeśli zostanie podany niepusty URL zawartość okna zostanie dodatkowo przeładowana).

Problem leży w punkcie nr 1. Jeśli okno pop-up o danej nazwie nie było wcześniej otwarte, zawołanie open a potem close, spowoduje, że przez krótką chwilę pop-up będzie widoczny. Lipa…

A może da się przechować referencję do pop-upa między przeładowaniami strony?

Jeśli nie istnieje konieczność obsługi starych przeglądarek (mało prawdopodobne dla starej aplikacji) można by spróbować zapisać wskazanie na pop-up do localStorage. To jednak się nie uda:

var popup = window.open('http://morzel.net', 'test');
localStorage.setItem('key', JSON.stringify(popup)); 

TypeError: Converting circular structure to JSON

Stare tricki z utrzymaniem stanu strony oparte o cookies lub window.name też nie zadziałają.

 

Wiec… co z tym zrobić?

Nawet jeśli nie możesz sobie pozwolić na dużą zmianę jaką jest wprowadzenie ramek nie poddawaj się :)

Okna popup mają właściwość opener, która wskazuje na okno nadrzędne (tzn. to w którym znajdowało się wywołanie window.open). Okna pop-up mogą więc cyklicznie sprawdzać czy otwierające je okno główne jest nadal otwarte. Mogą też odwoływać się do zmiennych w oknie nadrzędnym. Fakt ten można wykorzystać do wymuszenia zamknięcia okien pop-up gdy użytkownik naciśnie przycisk „wyloguj” w oknie głównym. Gdy użytkownik jest zalogowany (i tylko wtedy!), należy dodać do obiektu window okna głównego znacznik zalogowania w postaci zmiennej (np. loggedIn).

Oto kod JS, który powinien zostać umieszczony na stronie wyświetlanej w pup-upie:

window.setInterval(function () {
    try {
        if (!window.opener || window.opener.closed === true || window.opener.loggedIn !== true) {
            window.close();
        }
    } catch (ex) {
        window.close(); // FF może rzucić wyjątek bezpieczeństwa przy próbie odwołania do loggedIn (dla zewnętrznego serwisu)
    }
}, 1000);

Sprawdzanie zmiennej z okna opener ma jeszcze jedną zaletę. Jeśli użytkownik w oknie głównym przejdzie do innej aplikacji (np. poprzez przycisk wstecz lub link do zewnętrznego serwisu) wówczas okna pop-up wykryją brak monitorowanej właściwości window.opener i zamkną się automatycznie.

Cóż, nie jest to kod który lubisz pisać ale osiąga pożądany efekt nawet pomimo bolesnych braków w API przeglądarek. Gdyby tylko zrobili metodę window.exists('name')…  

Wykrycie załadowania iframe stworzonego w Ext JS

Przypuśćmy, że zachodzi potrzeba wykonania jakiegoś fragmentu kodu w momencie załadowania zawartości iframe. W przy- padku gdy iframe jest utworzony statycznie w kodzie HTML strony, sprawa jest bardzo prosta. Wystarczy podłączyć funkcję JavaScript pod zdarzenie load:

<iframe src="http://wikipedia.org" width="600" height="400" onload="someFunction();" ></iframe>

Uwaga: zdarzenie load (onload) zostanie wywołane gdy cała zawartość dokumentu zostanie załadowana (w tym jego elementy zewnętrzne takie jak obrazki). Jeśli trzeba zadziałać wcześniej, tzn. w chwili gdy DOM jest gotowy, należy skorzystać z innych metod...

A co w przypadku iframe’a stworzonego w kodzie Ext JS?

Prostym sposobem na utworzenie go jest użycie Ext.BoxComponent z odpowiednio ustawioną wartością autoEl. Daje to możliwość łatwego wpisania iframe’a w layout Ext JS (np. jako składowej Ext.Window) bez rozszerzania drzewa dokumentu o zbędne elementy.

var iframeContainer = new Ext.BoxComponent({
    autoEl: {
        tag: 'iframe',
        frameborder: '0',
        src: 'http://wikipedia.org'
    },
    listeners: {
        afterrender: function () {
            console.log('rendered');

            this.getEl().on('load', function () {
                console.log('loaded');
            });
        }
    }
});


W powyższym kodzie (Ext JS 3.2.1) bardzo istotny jest moment, w którym pod zdarzenie load iframe’a podpinany jest handler. Można to zrobić jedynie po tym gdy kontrolka (BoxComponent) zostanie wyrenderowana. Jeśli zrobi się to wcześniej, wówczas wywołanie getEl() zwróci undefined i kod nie zadziała. Przed renderowaniem kontrolka Ext JS to jedynie obiekt JavaScript, dla którego nie istnieją żadne elementy w drzewie dokumentu. Poniżej znajdują się dwa screeny prezentujące fragmenty kodu HTML utworzonego przez Ext.Window, w którym jedynym elementem na liście items był BoxComponent tworzący tag iframe...

beforerender:

DOM beforerender

afterrender:

DOM afterrender


Widać wyraźnie, że zbyt wczesne podpinanie się pod load nie ma prawa zadziałać bo nie można przecież nasłuchiwać zdarzeń na czymś co nie isntnieje.

Powyższe zrzuty pochodzą z okna Elements narzędzia Chrome Developers Tools. Szybkim sposobem by pokazać to narzędzie (oczywiście w przeglądarce Googla) jest naciśnięcie F12 lub Ctrl+Shift+I. Fajną funkcją CDT jest możliwość wylistowania zdarzeń nasłuchiwanych na określonym elemencie DOM. By zobaczyć listę trzeba zaznaczyć element DOM i w menu po prawej stronie wybrać zakładkę Event Listeners. Na screenie poniżej widać, że iframe faktycznie posiada obsługę zdarzenia load:

CDT Event Listeners

Disabled CheckBoxa usuwa zaznaczenie!

Podczas korzystania z kontrolki CheckBox i ustawiania jej po stronie klienta jako nieaktywnej, natchnąłem się na problem, który (przeoczony) może sporo namieszać w pracy aplikacji. Otóż jeśli CheckBox jest zaznaczony i zablokujesz go ustawiając właściwość disabled w JavaScript to po postbacku zobaczysz, że właściwość Checked == false!

Ten fragment JS ustawia właściwość disabled (CheckBox staje się szary i przestaje reagować na akcje użytkownika):

document.getElementById('<%= CheckBox1.ClientID %>').disabled = true;

Po "powrocie na serwer" kontrolka CheckBox nie będzie zaznaczona. Dzieje się tak dlatego, że przeglądarka nie wysyła w requescie informacji o polu input type="checkbox" z atrybutem disabled.

Testowałem w IE6/8 i FF2 na .NET 2.0/3.5.