Gdy HTML5 przestawał być odległą przyszłością głośno mówiło się o tym, że pozwoli on na pisanie aplikacji, cokolwiek to by miało oznaczać. Pozostawmy jednak nomenklaturę i zajmijmy się tym co ma dla nas faktyczne znaczenie. Czas zweryfikował możliwości HTML5, a specyfikacja dojrzała. My zaczęliśmy korzystać z dobrodziejstw nowego narzędzia.
Dzisiaj spośród wielu nowych funkcji HTML5 szczególnie interesować będą nas dwie. Jedna z nich to AppCache, który pozwoli użytkownikom na korzystanie ze strony WWW w trybie offline. Na łamach WEBroad pojawił się już artykuł dający solidne podstawy do korzystania z AppCache, a wpis z typowo praktycznym podejściem będziecie mogli przeczytać już niedługo. Druga nowość to IndexedDB. Specyfikacja jest w fazie Candidate Recommendation. Wsparcie IndexedDB pozwala już na jej funkcjonalne wykorzystanie bez zapewniania rozwiązania zastępczego na każdym kroku, chodź to zależy oczywiście od samego charakteru witryny. Dla większości użytkowników urządzeń mobilnych IndexedDB jest jednak nadal niedostępna.
Funkcje aplikacji
Przed napisaniem aplikacji musimy jasno określić jakie funkcje ma spełniać. Nasza będzie pozwalała na:
- Stworzenie listy mailingowej (dodawanie, edytowanie i usuwanie)
- Wysłanie maila, poprzez kliknięcie linku mailto, dla konkretnego kontaktu
- Wysłanie maila, poprzez kliknięcie linku mailto, do wszystkich kontaktów
- Dane będą przechowywane za pomocą bazy danych IndexedDB
- Z aplikacji będzie można korzystać bez połączenia z Internetem
- Dodawane adresy muszą mieć poprawny format, a w przypadku błędu musimy poinformować o tym użytkownika
Skoro już określiliśmy podstawową funkcjonalność pora zabrać się za pisanie aplikacji. By zaoszczędzić na czasie skorzystamy z Zurb Foundation, ale nic nie stoi na przeszkodzie żebyście napisali własny CSS.
HTML
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 | <!doctype html> <html manifest="cache.appcache" class="no-js" lang="pl_PL"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=480, initial-scale=1.0" /> <title>Mailing Lists</title> <link rel="stylesheet" href="stylesheets/app.css" /> <script src="bower_components/modernizr/modernizr.js"></script> </head> <body> <div class="row"> <header class="large-12 columns"> <h1>Mailing List<small> via IndexedDB and AppCache</small></h1> <p>Whole app is <strong>working offline</strong>. You can now disconnect from the Internet and still use this app! Your data are handled by your browser so they are completely safe. You can always see source code on <a href="https://github.com/MichalRazorZalecki/mailing-list/">my GitHub profile</a>.</p> </header> </div> <div class="row"> <div class="large-12 columns"> <table id="addresses"> <thead> <tr> <th>Name</th> <th colspan="2">Email</th> </tr> </thead> <tbody> </tbody> <tfoot> <tr> <td> <input type="text" id="new-name" name="new-name" placeholder="Name" /> </td> <td> </td> <td> <a href="#" id="add_address" class="button tiny success radius">Add address</a> <ul id="edit_buttons" class="button-group radius"> <li><a href="#" id="edit_address" class="button tiny success">Save</a></li> <li><a href="#" id="cancel_edit_address" class="button tiny alert">Cancel</a></li> </ul> </td> </tr> <tr> <td colspan="3"><a href="#" id="send_to_all" class="button tiny success radius">Send To All</a></td> </tr> </tfoot> </table> </div> </div> <div class="row"> <footer class="large-12 columns"> <p><a href="http://michal.zalecki.pl">Michał Załęcki</a> dla <a href="https://webroad.pl">WEBroad.pl</a></p> </footer> </div> <div id="email_error" class="reveal-modal tiny" data-reveal> <h2>Emaill Address Error</h2> <p>This email address is invalid. You <strong>must</strong> enter valid email, only name is optional.</p> <a class="close-reveal-modal">&#215;</a> </div> <script src="bower_components/jquery/jquery.js"></script> <script src="bower_components/foundation/js/foundation.min.js"></script> <script src="js/app.js"></script> </body> </html> |
Poza oczywistymi kwestiami warto zwrócić uwagę na to, że w tagu <head> umieściliśmy atrybut manifest. Wartością atrybutu manifest jest ścieżka do pliku manifest.
.htaccess
1 2 | <IfModule mod_mime.c> AddType text/cache-manifest appcache manifest</IfModule> |
Plik manifest musi zostać wysłany z mime-type text/cache-manifest. Może to wymagać dodania powyższego typu.
JavaScript
To właśnie tu dzieje się cała „magia”. W kontekście IndexedDB znajomość SQL jest zbędna. IndexedDB nie przechowuje danych w tabelach z sztywno określonymi wartościami jakie mają znaleźć się w odpowiednich kolumnach. IndexedDB przechowuje dane na zasadzie całych obiektów. Brzmi nieźle prawda?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | ;(function ($, window, document, undefined) { "use strict"; // Foundation JavaScript // Documentation can be found at: http://foundation.zurb.com/docs $(document).foundation(); /************* #1 *************/ function appCacheStatus(){ var appCache = window.applicationCache; switch (appCache.status) { case appCache.UNCACHED: return 'AppCache: UNCACHED'; break; case appCache.IDLE: return 'AppCache: IDLE'; break; case appCache.CHECKING: return 'AppCache: CHECKING...'; break; case appCache.DOWNLOADING: return 'AppCache: DOWNLOADING...'; break; case appCache.UPDATEREADY: return 'AppCache: UPDATEREADY'; break; case appCache.OBSOLETE: return 'AppCache: OBSOLETE'; break; default: return 'AppCache: UNKNOWN STATE'; break; }; } /************* #2 *************/ function validEmail(email) { var email_pattern = /^(([^<>()[].,;:s@"]+(.[^<>()[].,;:s@"]+)*)|(".+"))@(([[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}])|(([a-zA-Z-0-9]+.)+[a-zA-Z]{2,}))$/; return email_pattern.test(email); } /************* #3 *************/ function updateSendToAllLink(addresses) { var $stal = $("#send_to_all").attr("href", ""); var href = "mailto:"; for (var key in addresses){ href += addresses[key].email + ","; } $stal.attr("href", href); } /************* #4 *************/ function showAddresses(addresses) { var $tbody = $("#addresses tbody").html(''); var html = ""; for (var key in addresses){ html += "<tr>"; html += "<td>"+addresses[key].name+"</td>"; html += "<td><a href="mailto:"+addresses[key].email+"">"+addresses[key].email+"</a></td>"; html += "<td><ul class="button-group radius">"; html += "<li><a href="#" class="button tiny alert address_delete" data-key=""+key+"">Delete</button></li>"; html += "<li><a href="#" class="button tiny secondary address_edit" data-key=""+key+"">Edit</button></li>"; html += "<li><a href="mailto:"+addresses[key].email+"" class="button tiny">Send</button></li>"; html += "</ul></td>"; html += "</tr>"; } $tbody.append($(html)); } /************* #5 *************/ function addAddress(e) { e.preventDefault(); var $name = $("#new-name"); var $email = $("#new-email"); var name = $name.val(); var email = $email.val(); if (!validEmail($email.val())) { $('#email_error').foundation('reveal', 'open'); return; } var address = { name:name, email:email }; $name.val(''); $email.val(''); var db = e.data.db; var transaction = db.transaction(["addresses"], "readwrite"); var store = transaction.objectStore("addresses"); var request = store.add(address); request.onerror = function(e) { console.log("IndexedDB: Error " + e.target.error.name); } request.onsuccess = function(e) { console.log("IndexedDB: Adress added successfully ("+name+": "+email+")"); transaction.oncomplete = function(e) { getAddresses(db, [showAddresses, updateSendToAllLink]); } } } /************* #6 *************/ function getAddresses(db, callbacks) { var transaction = db.transaction(["addresses"], "readonly"); var store = transaction.objectStore("addresses"); var result = []; store.openCursor().onsuccess = function(e) { var cursor = e.target.result; if (cursor) { result[cursor.key] = {name: cursor.value.name, email: cursor.value.email}; cursor.continue(); } else { transaction.oncomplete = function(e) { for (var index in callbacks) callbacks[index](result); } } } } /************* #7 *************/ function deleteAddress(e) { e.preventDefault(); var key = $(this).data("key"); var db = e.data.db; var transaction = db.transaction(["addresses"], "readwrite"); var request = transaction.objectStore("addresses").delete(key); request.onsuccess = function(e) { transaction.oncomplete = function(e) { getAddresses(db, [showAddresses, updateSendToAllLink]); } } } /************* #8 *************/ function editAddress(e) { e.preventDefault(); var key = $(this).data("key"); var db = e.data.db; var transaction = db.transaction(["addresses"], "readonly"); var request = transaction.objectStore("addresses").get(key); request.onsuccess = function(e){ var result = e.target.result; var $name = $("#new-name"); var $email = $("#new-email"); var $add_address = $("#add_address"); var $edit_buttons = $("#edit_buttons"); $name.val(result.name); $email.val(result.email); $add_address.hide(); $edit_buttons.show(); $("#cancel_edit_address").off("click.edit").on("click.edit", function(e){ e.preventDefault(); $name.val(''); $email.val(''); $edit_buttons.hide(); $add_address.show(); getAddresses(db, [showAddresses, updateSendToAllLink]); $("#edit_address").off("click.edit"); }); $("#edit_address").off("click.edit").on("click.edit", function(e){ e.preventDefault(); if (!validEmail($email.val())) { $('#email_error').foundation('reveal', 'open'); return; } result.name = $name.val(); result.email = $email.val(); var transaction = db.transaction(["addresses"], "readwrite"); var request = transaction.objectStore("addresses").put(result); request.onsuccess = function(e) { transaction.oncomplete = function(e) { $("#cancel_edit_address").click(); } } }); } } console.log(appCacheStatus()); /************* #9 *************/ window.applicationCache.addEventListener('updateready', function(e) { if (confirm('Dostępna jest nowa wersja strony. Załadować stronę ponownie?')) { window.location.reload(); } }, false); /************* #10 *************/ if (Modernizr.indexeddb) { var open_db = window.indexedDB.open("mailing-list", 1); open_db.onupgradeneeded = function(e) { console.log("IndexedDB: Database Upgrading..."); var db = e.target.result; if (!db.objectStoreNames.contains("addresses")) { db.createObjectStore("addresses", {keyPath: "id", autoIncrement:true}); } console.log("IndexedDB: Database Upgreaded"); } open_db.onsuccess = function(e) { console.log("IndexedDB: Database opened correctly"); var db = e.target.result; $('#add_address').on("click", {db:db}, addAddress); $('#addresses').delegate(".address_delete", "click", {db:db}, deleteAddress); $('#addresses').delegate(".address_edit", "click", {db:db}, editAddress); getAddresses(db, [showAddresses, updateSendToAllLink]); } open_db.onerror = function(e) { console.error("IndexedDB: Open error occurred!"); console.dir(e); } } else { console.error("IndexedDB: IndexedDB is not supported in your browser!"); } }(jQuery, this, this.document)); |
1. appCacheStatus()
Funkcja appCacheStatus zwraca aktualny status cachu przeglądarki.
2. validEmail(email)
Funkcja validEmail pozwala na sprawdzenie czy podany w parametrze adres mailowy jest poprawny.
3. updateSendToAllLink(addresses)
Funkcja updateSendToAllLink uaktualnia link służący do wysłania maila wszystkim kontaktom z listy mailingowej.
4. showAddresses(addresses)
Funkcja showAddresses generuje listę kontaktów.
5. addAddress(e)
Funckcja addAddress odpowiada za dodanie adresu do listy. Zachodzi w niej sprawdzenie czy adres mailowy jest poprawny. Jeżeli tak, to wykorzystuje przekazany w zadaszeniu obiekt do stworzenia transakcji za pomocą której zostanie zapisany nowy kontakt.
6. getAddresses(db, callbacks)
Funkcja getAddresses pobiera za pomocą kursora wszystkie kontakty i uruchamia przekazane w drugim parametrze funkcje zwrotne przekazując do nich listę pobranych adresów.
7. deleteAddress(e)
Funckja deleteAddress analogicznie do addAddress odpowiada za usunięcie kontaktu z bazy danych.
8. editAddress(e)
Funkcja editAddress pełni dwa zadania. Pierwsze to pobranie edytowanego kontaktu i wypełnienie formularza. Drugie to uaktualnienie danych i wyczyszczenie formularza. Musimy pamiętać, że nie następuje tu przeładowanie witryny. Co więcej, to nawet nie jest formularz.
9. Informacja o nowej wersji
Przeglądarka załaduje witrynę i wykryje, że plik manifest uległ zmianie (sygnał do uaktualnienia pamięci cache). Nowa strona zostanie załadowana dopiero po ponownym odwiedzeniu strony. Dlatego w dobrym tonie jest poinformowanie użytkowania o tym fakcie i danie mu możliwości wyboru.
10. Big Bang!
Jeżeli tylko wspierana jest IndexedDB to otwieramy bazę danych i dodajemy zdarzenia do przycisków. Pamiętać trzeba, że niektóre z przycisków są później dołączane do DOM i przypisanie im zdarzeń musi odbyć się za pomocą zdarzenia funkcji delegate.
Przydatne linki
Tagi: promowany
Pomysł na appkę ciekawy. Kilka rzeczy, które mi się rzuciły w oczy.
1). Raz język angielski, raz język polski …
2). selektory w jQuery, lepiej stosować zapis $(’tbody’, '#table’) lub $(’#table’).find(’tbody’);
3). formatowanie kodu jest straszne …
Dzięki.
1 i 3 > Z tym formatowaniem to coś źle wyświetla(ło), spójrz jak jest na GitHub, jest tam też poprawiony język. Zauważyłem to dopiero po publikacji (wstawieniu screena).
2 > Preferuję find, o jakieś 40%. Umknęło w ferworze „walki” z problemami na FF :)
>IndexedDB przechowuje dane na zasadzie całych obiektów. Brzmi nieźle prawda?
WebSQL przy niektórych aplikacjach brzmi o wiele lepiej i przystępniej ;)
co do samego IndexedDB – byłem pewien, że nie da się napisać bardziej brzydkiego API niźli DOM < 4. Myliłem się. W3C potrafi… No cóż ;)
IndexedDB ma tak naprawdę jedną, jedyną przewagę nad localStorage – jest asynchroniczne. Gdyby localStorage było asynchroniczne, nikt o zdrowych zmysłach nie używałby tego potwora. No ale stało się, "kompatybilności Sieci łamać nie wolno", za co teraz pokutujemy…
IndexedDB bez jakiejkolwiek warstwy abstrakcji jest dla mnie po prostu nieużywalne. I po prostu przekombinowane. A za argument o SQLi, który uwalił speckę WebSQL, należy się komuś kulka w łeb.
Co do samej apki: pomyślałbym o ładnym przełożeniu tego na obiekty (lub choćby jeden namespace) i systemie szablonów. Funkcje wbrew pozorom później trudno utrzymywać ;)
Jeżeli chodzi o localStorage to nie zapominajmy, że jest ono alternatywą dla cookies, a nie żadnego z ww. systemów bazodanowych. Nad śmiercią WebSQL ubolewam szczególnie, że w SQL czuje się jak ryba w wodzie. Zastąpiono je tym nieszczęsnym IndexedDB z okropnym API. SQL injection jest/było problemem marginalnym, ale przecież można było napisać coś na zasadzie Active Record dla osób, którym zleżałoby na bezpieczeństwie i tym samym zażegnać problem.
Jeżeli chodzi o samą aplikacje to nie będę jej chyba rozwijał, ale jeżeli jednak by się tak stało to przestrzeń nazw faktycznie by się przydała.
Nie zgodzę się, że localStorage zastępuje jedynie cookies. Dzięki globalnemu interfejsowi JSON możemy „zestringowić” de facto każdy obiekt (oprócz DOM-owego) i włożyć do magazynu. A wystarczy obudować to jakimś ładnym API i można nawet wyszukiwanie wbudować. I to samo, uniwersalne API mogłoby korzystać z kilku mechanizmów przechowywania pod spodem (coś na zasadzie wzorca Adapter), więc upieklibyśmy dwie pieczenie na jednym ogniu