Tvorba moderního e-shopu: HTML5 drag & drop a kategorie

Dnes se podíváme na další novinky v HTML5. Především půjde o drag & drop v sekci Kategorie. Podíváme se, jak lehce lze implementovat práci se stromovými strukturami s frameworkem AngularJS. A také dokončíme některé další sekce.
Seriál: E-shop pomocí moderních technologií (15 dílů)
- Úvodní analýza pro moderní e-shop 4. 1. 2013
- Návrh uživatelské části e-shopu 11. 1. 2013
- Tvorba uživatelské části e-shopu 18. 1. 2013
- Nákupní košík pomocí HTML5 Web Storage 25. 1. 2013
- Tvorba moderního eshopu: kategorie a parametrické hledání 1. 2. 2013
- Tvorba moderního e-shopu: dokončení uživatelské části 8. 2. 2013
- Tvorba moderního e-shopu: plánování administrace 15. 2. 2013
- Tvorba moderního e-shopu: správa objednávek 22. 2. 2013
- Tvorba moderního e-shopu: nahrávání obrázků k produktu 1. 3. 2013
- Tvorba moderního e-shopu: Bower, Yeoman a Gemnasium 15. 7. 2013
- Tvorba moderního e-shopu: HTML5 drag & drop a kategorie 29. 7. 2013
- Tvorba moderního e-shopu: zpracování chyb 12. 8. 2013
- Tvorba moderního e-shopu: Rich-Text Editing a dokončení administrace 26. 8. 2013
- Autentizace v single-page aplikacích 9. 9. 2013
- Autentizace v single-page aplikacích – serverová část 7. 10. 2013
Úvod
I dnes si můžete projít zdrojové kódy dnešního dílu. Samozřejmě si je také můžete stáhnout přes Git příkazem git checkout -f eshop011
. A dnes je k dispozici také demoverze na Heroku.
Sekce Kategorie
Kategorie chceme vidět ve stromové struktuře a nikoliv v tabulce. Práce se stromovými strukturami je obvykle hodně nepříjemná, obzvláště při jejich implementaci v relačních databázích se člověk může zapotit. S pomocí novinek HTML5, frameworku AngularJS a databáze MongoDB je to však záležitost několika desítek řádků JavaScriptu.
Začněme návrhem. Stejně jako u ostatních sekcí, i tuto budeme chtít udělat co nejpoužitelnější pro uživatele.
-
Pro vkládání nám bude stačit standardní metoda window.prompt() (podobně jako je tomu v Google Docs). Uživatel vyplní název a nová kategorie se vloží na konec stromu (v rámci vkládání ani editací neřešíme tvar URL).
-
Protože editujeme pouze název, bude nejjednodušší využít inline direktivu, kterou jsme již vytvořili v jednom z předchozích dílů. Bude-li tedy uživatel chtít změnit název, stačí na něj kliknout, objeví se textové pole, kde může název upravit.
-
Pro přesouvání uzlů využijeme drag & drop. Tímto způsobem umožníme libovolně měnit rodičovskou kategorii. Přesouvá se kompletní podstrom. Při těchto operacích musíme myslet na několik případů, které je potřeba ošetřit (např. máme-li uzel A, který má potomka B, pak není možné přesunout uzel A tak, aby se stal potomkem B).
-
Pro mazání použijeme také drag & drop. Jakmile uživatel začne nějaký uzel přesouvat, objeví se pod výpisem kategorií ikonka s košem. Pokud přesune uzel sem, dojde k jeho smazání.
Tímto způsobem půjde velmi rychle vytvořit kompletní strukturu kategorií. Tento způsob implementace ale nebude fungovat třeba na tabletech, protože je závislý na použití myši. Pokud je podmínkou podpora i na dotykových zařízeních, pak je vhodné využít třeba knihovnu Hammer, popř. rovnou doplněk pro AngularJS.
Události drag & drop
Nejprve potřebujeme říct, že daný element je možné přesouvat. To učiníme nastavením atributu draggable
na hodnotu true
.
Dále bude potřeba nastavit několik událostí, pro drag & drop jich máme celkem 7. Pro lepší názornost řekněme, že element A (třeba obrázek) je přesouván do elementu B (třeba div). Pak mají události tento význam:
ondragstart |
Událost se vyvolá, jakmile přesouvání elementu A začíná. |
ondragenter |
Událost se vyvolá při prvním vstupu nad daný element B. |
ondragover |
Událost nastává při přesouvání nad elementem B. |
ondragleave |
Událost se vyvolá, jakmile element B při přesouvání opustíme (tj. přesun neuskutečníme). |
ondrag |
Vyvolá se po dokončení přesunu na elementu, který byl přesouván (element A). |
ondrop |
Vyvolá se po dokončení přesunu na elementu, do kterého je jiný element přesouván (element B). |
ondragend |
Vyvolá se po dokončení přesunu. Nezáleží na tom, zda byl přesun úspěšný či nikoliv. |
AngularJS zatím bohužel tyto události přímo nepodporuje, takže si musíme napsat vlastní direktivy. Naštěstí je to poměrně jednoduchý úkol:
['dragstart', 'dragenter', 'dragover', 'dragleave', 'drag', 'drop', 'dragend'].forEach(function(name){
angular.module('zdrojak.directive').directive(name, function(){
return {
restrict: 'A',
link: function(scope, element, attrs) {
var fn = attrs[name];
if (!fn) return;
element.bind(name, function(ev){
scope.$apply(function(){
scope[fn](ev);
});
});
}
};
});
});
Zobrazení stromové struktury
Dále bude potřeba vypsat všechny kategorie do stromového zobrazení. V Apiary budeme mít pravidlo pro získání všech kategorií:
GET /categories
< 200
< Content-Type: application/json
[
{"id": 123, "name": "Android", "url": "android"},
{"id": 234, "name": "iPhone", "url": "iphone"},
{"id": 334, "name": "BlackBerry", "url": "blackberry"},
{"id": 434, "name": "Symbian", "url": "symbian"},
{"id": 534, "name": "Windows Phone", "url": "windows-phone"},
{"id": 634, "name": "Levné", "url": "levne"},
{"id": 734, "name": "Příslušenství", "url": "prislusenstvi", "children": [
{"id": 834, "name": "Baterie", "url": "baterie"},
{"id": 934, "name": "Držáky", "url": "drzaky"},
{"id": 144, "name": "Nabíječky", "url": "nabijecky"},
{"id": 894, "name": "Pouzdra", "url": "pouzdra"}
]}
]
Tato data získáme v controlleru přes API a pošleme je do šablony, abychom vykreslili stromovou strukturu. To jsme však již řešili v 5. díle seriálu, kde je také podrobný popis implementace. Pro kategorie použijeme jen události ondragstart
, ondragover
a ondrop
. Šablona bude vypadat takto:
<script type="text/ng-template" id="template-category">
<i class="icon-chevron-right"></i>
<inline model='data.name' action='updateCategory'></inline>
<ul class="nav nav-list">
<li ng-repeat="data in data.children" id="{{data.id}}" class="category-item" ng-include="'template-category'" draggable="true" drop="drop" dragover="dragover" dragstart="dragstart"></li>
</ul>
</script>
<ul class="nav nav-list">
<li ng-repeat="data in categories" id="{{data.id}}" class="category-item" ng-include="'template-category'" draggable="true" dragstart="dragstart" drop="drop" dragover="dragover"></li>
</ul>
Kategorie se nám pak hezky vykreslí takto:
Přesouvání kategorií
Neprve celý zdrojový kód controlleru, který následně podrobně rozebereme.
angular.module('zdrojak.controller').controller('CategoriesCtrl', ['$scope', '$window', 'api', function ($scope, $window, api) {
$scope.categories = api.category.index();
$scope.dragstart = function(ev) {
ev.dataTransfer.setData('text/plain', ev.target.id);
$scope.trashVisible = true;
};
$scope.dragover = function(ev) {
ev.preventDefault();
};
$scope.drop = function(ev) {
ev.preventDefault();
var element = getElement(ev);
var target = getTargetList(ev);
if (target.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_CONTAINS) {
//rodic se nemuze stat potomkem sveho potomka
} else {
api.category.update({id: element.id}, {parent: target.parentNode.id}, function(){
target.appendChild(element);
});
}
$scope.trashVisible = false;
};
var getTarget = function(ev) {
var el = ev.target;
do {
if (el.classList.contains('category-item')) {
return el;
}
el = el.parentNode;
} while (el);
};
var getTargetList = function(ev) {
var target = getTarget(ev);
return target.lastElementChild;
};
var getElement = function(ev) {
return $window.document.getElementById(ev.dataTransfer.getData('text/plain'));
};
}]);
Začněme metodou dragstart()
. Ta se zavolá, jakmile začne přesun. V ev.target
máme odkaz na element, který je přesouván. Z něj vezmeme ID, které jsme vložili ke každé kategorii do elementu li
. Objekt ev.dataTransfer slouží jako úschovna pro informace během přesunu elementu. Existuje několik různých typů dat, které je možné přesunout, my si vystačíme s obyčejným textem, kam si uložíme ID přesouvané položky. Protože začíná přesun, zobrazíme i ikonku koše, aby měl uživatel možnost položku odstranit.
V metodě dragover()
zavoláme na události metodu preventDefault()
, čímž řekneme, že bude možné do této kategorie přenášenou kategorii vložit.
Nakonec zde máme metodu drop()
. Ta nejprve díky pomocné funkci getElement()
vrátí odkaz na element, který je přenášen. V pomocné metodě getTarget()
získáme odkaz na položku seznamu, kam je element přenášen a v getTargetList()
vrátíme posledního potomka, což je seznam podkategorií kategorie, do které je jiná kategorie přesouvána. Dále se pomocí metody compareDocumentPosition() ujistíme, že můžeme do tohoto místa položku přesunout. Pokud ano, položku vložíme. Nakonec jen skryjeme koš.
Pro kontrolu, zda je možné na daném elementu provést drop, je určena událost ondragenter
. V našem případě jsme vše pro zjednodušení implementovali až v metodě drop()
. Pomocná funkce getTarget()
je totiž trochu složitější, protože ne vždy přesouváme položku seznamu, ale pokud kategorii uchytíme za ikonku, pak ev.target
odkazuje na tuto ikonku a až rodičovským elementem je položka seznamu, která má třídu category-item
. Všimněte si také použití kolekce classList, která poskytuje komfortní přístup k třídám elementu. Bohužel u IE je podpora až od 10. verze, takže zatímco v tomto seriálu se na starší prohlížeče ohlížet nemusíme, v praxi byste zatím nejspíš museli použít hledání třídy v řetězci className.
To je vše. Samozřejmě by bylo možné udělat práci s kategoriemi ještě vymazlenější. Navíc nyní neumožňujeme změnu pořadí, což by však nebylo tak těžké doplnit (a můžete si to vyzkoušet).
Odstraňování kategorií
Mazání kategorií je jednoduché. Stačí přidat pod výpis stromu ikonku koše, která se objeví jen při přesouvání. Šablona bude vypadat takto:
<p id="trash" ng-show="trashVisible" drop="removeCategory" dragover="dragover"><i class="icon-trash"></i> Koš </p>
Do controlleru pak bude potřeba přidat další metodu:
$scope.removeCategory = function(ev) {
var confirm = window.confirm('Chcete skutečně kategorii smazat?');
$scope.trashVisible = false;
if (!confirm) return;
var element = getElement(ev);
api.category.remove({id: element.id}, function(){
element.parentNode.removeChild(element);
});
};
Pokud uživatel odstranění povolí, pokusíme se smazat kategorii smazat také na serveru. Pokud uspějeme, je potřeba kategorii ze stromu odstranit.
Editace kategorie
Editaci řešíme pomocí direktivy inline. Jakmile uživatel klikne na název a změny uloží, vyvolá se metoda updateCategory()
a jako parametr dostane událost, která změnu doprovází. Stačí vzít pouze změněný název a odeslat ho na server.
$scope.updateCategory = function(ev) {
var name = ev.target.value;
var target = getTarget(ev);
api.category.update({id: target.id}, {name: name});
};
Vložení kategorie
Vložení nové kategorie se nejprve uskuteční na serveru. Zde se získá ID a poté se kategorie s tímto ID vloží i do kolekce s ostatními kategoriemi. A překreslení stromové struktury se už postará AngularJS sám.
$scope.addCategory = function() {
var name = window.prompt('Jak se bude nová kategorie jmenovat?');
if (!name) return;
api.category.create({name: name}, function(res){
$scope.categories.push({
id: res.id,
name: name
});
});
};
Sekce Správci
Kromě Kategorií byla vytvořena ještě sekce Správci. Můžete si ji prohlédnout v demu. Není v ní nic nového, co bychom ještě v administraci neprobírali, proto ji zde nebudu podrobně popisovat. Jen podotknu, že zatím nejsou nikde řešeny chybové stavy (budeme je řešit příště), takže vložení dvou uživatelů se stejným e-mailem se vám ještě dnes podaří.
Bower a Heroku
Od minule jsme začali používat pro správu klientského JavaScriptu nástroj Bower. Bylo řečeno, že při instalaci je nutné zavolat příkaz bower install
. Jak to ale vyřešit při přesouvání projektu na Heroku?
Naštěstí je řešení jednoduché. Stačí přidat do souboru package.json do sekce scripts
událost postinstall
a balíček bower přidat do dependencies
.
"scripts": {
"postinstall": "./node_modules/bower/bin/bower install"
}
Co dále
Pomalu se blížíme k dokončení adminstrace. S trochou štěstí ji dokončíme už v příštím díle. Pak bude potřeba aplikaci odprezentovat zákazníkovi. Může si ji vyzkoušet, pohrát si s ní a přidat případné připomínky. No a pak už zbývá jen serverová část, která bude nepoměrně jednodušší.
Na tvorbě tohoto článku se svými připomínkami podílel také Pavel Lang. Díky!
Mám malou technickou otázku. Pokud vnořím některou kategorii jako podkategorii jak ji dostanu zpět na první úroveň ?
Myslím že zde jde o nedodělek.
Martin
Ano, tohle řešené není. V článku zmiňuji, že by bylo dobré dodat třeba změnu pořadí. To by asi bylo nejjednodušší udělat tak, že by se mezi jednotlivé položky kategorie vložil ještě jeden element a jakmile by uživatel dropnul kategorii do tohoto elementu, nevytvořila by se podkategorie, ale kategorie by se zařadila na stejnou úroveň. Tak by šlo přetahovat kategorie i zpět do nejvyšší úrovně a zmíněný problém by se tím vyřešil.