Tvorba moderního e-shopu: dokončení uživatelské části

V tomto díle dokončíme uživatelskou část internetového obchodu. Čeká nás implementace jednoduchého stránkování, podíváme se také na výhody, které přináší použití NoSQL databáze a Node.js na tomto projektu.

Seriál: E-shop pomocí moderních technologií (15 dílů)

  1. Úvodní analýza pro moderní e-shop 4.1.2013
  2. Návrh uživatelské části e-shopu 11.1.2013
  3. Tvorba uživatelské části e-shopu 18.1.2013
  4. Nákupní košík pomocí HTML5 Web Storage 25.1.2013
  5. Tvorba moderního eshopu: kategorie a parametrické hledání 1.2.2013
  6. Tvorba moderního e-shopu: dokončení uživatelské části 8.2.2013
  7. Tvorba moderního e-shopu: plánování administrace 15.2.2013
  8. Tvorba moderního e-shopu: správa objednávek 22.2.2013
  9. Tvorba moderního e-shopu: nahrávání obrázků k produktu 1.3.2013
  10. Tvorba moderního e-shopu: Bower, Yeoman a Gemnasium 15.7.2013
  11. Tvorba moderního e-shopu: HTML5 drag & drop a kategorie 29.7.2013
  12. Tvorba moderního e-shopu: zpracování chyb 12.8.2013
  13. Tvorba moderního e-shopu: Rich-Text Editing a dokončení administrace 26.8.2013
  14. Autentizace v single-page aplikacích 9.9.2013
  15. Autentizace v single-page aplikacích – serverová část 7.10.2013

Podobně jako v dřívějších dílech je i dnes dostupná demoverze aktuálního stavu e-shopu. Zdrojové kódy si můžete prohlédnout na Githubu, popř. si je přes Git stáhnout příkazem git checkout -f eshop06.

Rozdíly mezi tagy a zobrazení aktuální verze

Protože je e-shop vyvíjen současně s tím, jak seriál vychází, dochází občas k úpravám ve zdrojových kódech, které byly publikovány v předchozích dílech. Refactoring je běžnou součástí vývoje každé aplikace a nelze se mu nijak vyhnout. V článku jsou obvykle popisovány jen ty nejdůležitější změny a některé z menších změn popisovány nejsou.

Díky tomu, že je projekt vyvíjen na Githubu, to však tolik nevadí, protože si pomocí Gitu můžeme snadno zobrazit rozdíly mezi dvěma tagy: git diff eshop05 eshop06. Další možnosti sledování rozdílů mezi dvěma tagy jsou uvedeny v odpovědi na StackOverflow.

Kromě toho Github umožňuje procházet nejen zdrojové kódy aktuální verze, ale také zdrojové kódy všech předchozích commitů vč. jednotlivých tagů:

github-zmeny

Stránkování z pohledu UX

Pro náš projekt budeme implementovat jednoduché stránkování, které bude mít pouze odkazy na předchozí a následující stránku. Ty mohou být buď aktivní, nebo deaktivované (v případě první a poslední stránky). V každé kategorii našeho e-shopu budeme mít pouze několik málo produktů, a tak je zbytečné implementovat složité stránkování s odkazy na jednotlivé stránky. O stránkování z pohledu UX si můžete na Zdrojáku přečíst článek Stránkujeme smysluplně…? od Davida Grudla.

Pokud chcete přesto použít stránkování nabízející více možností pro uživatele, máte k dipozici projekt Angular UI, kde lze najít i direktivu pro stránkování.

Stránkování z pohledu výkonnosti

Pokud jde o stránkování z pohledu databáze, největší výkonnostní problém obvykle představuje fakt, že je potřeba provést dva dotazy: jeden zjišťuje celkový počet vracených výsledků a druhý vrací samotné výsledky. Některé databáze tyto akce různě optimalizují, třeba MySQL nabízí direktivu SQL_CALC_FOUND_ROWS (její použití však může někdy spíše uškodit než pomoci).

Nutnost zjišťování počtu výsledků lze různě obejít, např. pokud zobrazujeme 10 produktů na stránce, vybereme pro první stránku o jeden produkt více, tedy 11. Když pak zobrazíme jen 10 produktů, víme, že musí existovat ještě minimálně jeden další produkt na další stránce, takže můžeme s klidným svědomím zobrazit i aktivní odkaz na další stránku. Nevýhoda tohoto přístupu je v tom, že uživateli nezobrazíme celkový počet nalezených výsledků, což může být matoucí (a mně osobně to většinou vadí).

Dale můžeme výsledky různě kešovat nebo využít fakt, že výsledky načítáme přes AJAX. Při prvním přístupu do kategorie získáme jak informace o celkovém počtu výsledků, tak výsledky pro první stránku. Při přechodu na další stránku však již není potřeba pokládat dotaz znova na celkový počet produktů, protože jejich počet už víme z předchozího dotazu.

Pokud se podíváme na náš konkrétní projekt, v jeho případě by byl dotaz na získání výsledků z parametrického vyhledávání v relační databázi velmi složitý. V relační databázi bychom potřebovali velké množství tabulek pro různé entity:

  • parametry (mohou být typu prostá hodnota nebo číselník složený z dalších hodnot),
  • číselníky (jednotlivé hodnoty parametrů, např. barva: červená, modrá atd.),
  • propojovací tabulku mezi parametry a hodnotami číselníků (jeden číselník má n hodnot),
  • propojovací tabulku mezi parametry a kategoriemi (abychom věděli, pro jakou kategorii jaké parametry ve filtrování zobrazit),
  • produkty,
  • propojovací tabulku mezi produkty a parametry, které jsou hodnoty (abychom řekli, že váha produktu x je 2,34 kg),
  • propojovací tabulku mezi produkty a hodnotami parametrů, které jsou typu číselník (abychom řekli, že barva produktu x je třeba „bílá“),
  • varianty produktů (pokud je vybráno několik hodnot parametru typu číselník, budeme z nich chtít vygenerovat varianty),
  • propojovací tabulku mezi variantami produktů a mezi hodnotami číselníků přiřazených ke konkrétnímu produktu,
  • atd.

Jak je vidět, v případě realizace stejného parametrického vyhledávání jako má náš e-shop pomocí klasické SQL databáze je nesmírně náročné. Dotazy jsou komplikované, obsahují propojení na mnoha tabulkách a pokládat stejný dotaz jen pro zjištění počtu výsledků je bolestivé. Pokud je v databázi větší počet produktů, tak toto řešení nelze bez různých úrovní optimalizace ani použít.

My ale používáme NoSQL databázi MongoDB, kde je jeden produkt uložen jako dokument a v něm jsou uloženy i parametry vč. konkrétních hodnot a varianty. Když pak na databázi směřuje dotaz, který vznikl z parametrického hledání, dotazujeme se pouze na jedné kolekci, jako kdybychom měli jen jednu tabulku produkty, kde jsou všechny informace uloženy. V takovém případě bychom měli jen několik SQL podmínek WHERE. Lze si asi představit, že pracovat s jednou tabulkou (nebo tedy v NoSQL s kolekcí) je mnohem snazší, než pracovat s množstvím tabulek.

Získávání informací pro vyhledávání z URL

Nejprve budeme potřebovat třídu, která vrátí informace o stránkování z URL, pokud zde nějaké jsou. Pokud nejsou, vrátí výchozí hodnoty. Třída bude pracovat výhradně s URL a bude izolovaná od frameworku AngularJS. To nám totiž přináší jednu zásadní výhodu. Stejnou URL (resp. její query část) posíláme na server a zde budeme chtít také získat informace o stránkování. A neplatí to pouze pro kategorie, ale pro všechny sekce. A rovněž nejde pouze o stránkování, ale také o parametrické hledání. A zde vidíme obrovskou výhodu použití Node.js, můžeme si tu třídu totiž napsat tak, aby šla použít jak u klienta, tak i na serveru.

Její implementace může vypadat třeba takto:

/**
 * Třída pro zpracování parametru v URL při parametrickém hledání/stránkování.
 * 
 * Konfigurační parametry:
 *   limit - maximalni pocet objektu na stranku
 *   orderColumns - sloupce, podle kterych je mozne radit
 */
function ParametricSearch(config) {
  this._limit  = config.limit;
  this._orderColumns = config.orderColumns;
}

ParametricSearch.prototype.getParams = function() {
  return this._params;
};

/**
 * Vrací hodnotu jednoho parametru z URL. 
 * Pokud parametr neexistuje, vraci se hodnota predana v parametru def.
 */
ParametricSearch.prototype.getParam = function(name, def) {
  if (this._isUndefined(this._params[name])) {
    return def;    
  }
  return this._params[name];
};

/**
 * Vraci hodnotu jednoho parametru v parametru filter. 
 * Pokud hodnota neexistuje, vraci se hodnota predana v parametru def.
 * 
 * Priklad URL:
 *   /abc?filter=aaa:2@bbb:7,3
 *   
 *   ps.getFilterParam('aaa') vrati ['2'] 
 *   ps.getFilterParam('bbb') vrati ['7','3']  
 *   ps.getFilterParam('ccc', 42) vrati '42'   
 */
ParametricSearch.prototype.getFilterParam = function(name, def) {
  var filter = this.getParam('filter');
  if (this._isUndefined(filter)) {
    return def;    
  }
  if (this._isUndefined(filter[name])) {
    return def;    
  }
  return filter[name];
};

/**
 * Vraci hodnotu jednoho parametru v parametru filter jako retezec.
 * Pokud hodnota neexistuje, vraci se hodnota predana v parametru def.
 * 
 * Priklad URL:
 *   /abc?filter=aaa:2@bbb:7,3
 *   
 *   ps.getFilterParam('aaa') vrati '2'   
 */
ParametricSearch.prototype.getFilterParamAsString = function(name, def) {
  return this.getFilterParam(name, def).toString();   
};

ParametricSearch.prototype.setParams = function(params) {
  this._params = params;
  this._parseFilter();
};

ParametricSearch.prototype.getLimit = function() {
  return this._limit;    
};

ParametricSearch.prototype.getPage = function() {
  var offset = this._params.offset;
  if (this._isUndefined(offset)) return 1;
  var page = (offset / this.getLimit()) + 1; 
  if (page < 1) return 1;
  if (offset % this.getLimit() !== 0) return 1;
  return page;
};

ParametricSearch.prototype.getOffset = function() {
  if (this.getPage() === 1) return 0;
  return this._params.offset;
};

/**
 * Vraci sloupec, podle ktereho se ma radit. 
 * Neni-li predan jako parametr, vezme se prvni z testovanych sloupcu.
 */
ParametricSearch.prototype.getOrder =  function() {
  var key = this._orderColumns.indexOf(this._params.order);
  if (~key) {
    return this._orderColumns[key];
  } else {
    return this._orderColumns[0];  
  }
};

/**
 * Projde vsechny parametry v parametru filter a vytvori z nich objekt.
 * 
 * Priklad URL:
 *   /abc?order=price&filter=aaa:2@bbb:7,3
 * 
 *   ps.getParams() vrati {order: price, filter: {aaa: ['2'], bbb: ['7', '3']}}
 */
ParametricSearch.prototype._parseFilter = function() {
  var params = {};
  var filter = this._params.filter;
  if (this._isString(filter)) {
    filter.split('@').forEach(function(rule){
      var parts = rule.split(':');
      if (parts.length !== 2) return;
      params[parts[0]] = parts[1].split(',');
    });
  } 
  this._params.filter = params; 
};

ParametricSearch.prototype._isUndefined = function(val) {
  return typeof val === 'undefined';    
};

ParametricSearch.prototype._isString = function(val) {
  return typeof val === 'string';    
};

Třída musí být dostatečně nezávislá. Všechny závislosti jí musí být předány (princip vzoru Dependency Injection). Tuto třídu musíme také pokrýt testy, protože bude použita na mnoha místech jak v uživ. části, tak v administraci. Protože nemá žádné závislosti, lze pro ni napsat testy velmi lehce:

describe('ParametricSearch', function(){   

  var ps;  
  beforeEach(function(){
    ps = new ParametricSearch({
      limit: 10, orderColumns: ['price', '-price']    
    });  
  });

  describe('strankovani', function(){
    it('vrati aktualni stranku', function() {
      ps.setParams({offset: 0}); 
      expect(ps.getPage()).toBe(1);
      expect(ps.getOffset()).toBe(0);
      ps.setParams({offset: 30}); 
      expect(ps.getPage()).toBe(4);
      expect(ps.getOffset()).toBe(30);
    });

    it('vrati stranku 1, pokud neni zadan parametr offset', function(){
      ps.setParams({});    
      expect(ps.getPage()).toBe(1);
      expect(ps.getOffset()).toBe(0);
    }); 

    it('vrati stranku 1, pokud parametr offset neni delitelny parametrem limit', function(){
      ps.setParams({offset: 234});    
      expect(ps.getPage()).toBe(1);
      expect(ps.getOffset()).toBe(0);
    }); 

    it('vrati stranku 1, pokud je parametr offset mensi nez 0', function(){
      ps.setParams({offset: -10});    
      expect(ps.getPage()).toBe(1);
      expect(ps.getOffset()).toBe(0);
    });
  });

  describe('razeni', function(){
    it('vrati sloupec, podle ktereho se ma strankovat', function(){
      ps.setParams({order: '-price'}); 
      expect(ps.getOrder()).toBe('-price');
    });

    it('vrati vychozi sloupec strankovani, pokud neni zadny zadan', function(){
      ps.setParams({}); 
      expect(ps.getOrder()).toBe('price');
    });      

    it('vrati vychozi sloupec strankovani, pokud zadany ve vyctu neexistuje', function(){
      ps.setParams({order: 'abc'}); 
      expect(ps.getOrder()).toBe('price');
    }); 
  });

  describe('filtrovani', function(){
    it('vrati jeden parametr z URL', function(){
      ps.setParams({abc: 'efg'}); 
      expect(ps.getParam('abc')).toBe('efg');
      ps.setParams({}); 
      expect(ps.getParam('abc', 123)).toBe(123);
    });

    it('vrati jeden parametr filtrovani z URL', function(){
      ps.setParams({filter: 'a:123@b:a,b,c'}); 
      expect(ps.getFilterParam('a')).toEqual(['123']);
      expect(ps.getFilterParam('b')).toEqual(['a','b','c']);
      expect(ps.getFilterParam('c', 42)).toEqual(42);
      expect(ps.getFilterParamAsString('a')).toEqual('123');
    });
  });

  describe('pomocne metody', function(){
    it('vrati true, pokud je hodnota typu undefined', function(){
      var abc = {efg: true};
      expect(ps._isUndefined(abc.abc)).toBeTruthy();
      expect(ps._isUndefined(abc.efg)).toBeFalsy();
    });
    it('vrati true, pokud je hodnota typu retezec', function(){
      expect(ps._isString('abeceda')).toBeTruthy();
      expect(ps._isString(1)).toBeFalsy();
      expect(ps._isString({})).toBeFalsy();
    });
  });
});

Abychom ji mohli používat uvnitř AngularJS, vytvoříme si službu parametricSearch, která vrátí její instanci. Implementace může vypadat např. takto:

module.factory('parametricSearch', ['$location', function($location){
  return function(config) {
    var search = new ParametricSearch(config);   
    search.setParams($location.search());  
    return search;
  }
}]);

Použití služby si můžeme ukázat v controlleru, který obsluhuje vyhledávání.

function SearchCtrl ($scope, $routeParams, $location, parametricSearch, api) {
  var query = {};
  var ps = parametricSearch({limit: 10});

  $scope.query = $routeParams.query;
  $scope.limit = ps.getLimit();
  $scope.page  = ps.getPage();

  $scope.filter = function(offset) {
    $scope.load(offset);
    $location.search({offset: query.offset, limit: query.limit});    
  }; 

  $scope.load = function(offset) {
    query.offset = offset || 0;  
    query.limit  = $scope.limit;
    query.query  = $scope.query;
    $scope.results = api.product.index(query); 
  };

  $scope.load(ps.getOffset()); 
}]);

Direktiva pagination

Nyní se nám podařilo dostat všechny informace o stránkování do šablony. Zbývá vytvořit direktivu, která HTML pro stránkování vygeneruje. Její kód vypadá následovně:

/**
 * Direktiva zobrazi strankovani.
 * 
 * Pouziti v sablone:
 *   <pagination page="page" count="count" limit="limit" move="filter"></pagination>
 *   
 * Parametry scope:
 *   count - celkovy pocet vysledku
 *   limit - maximalni pocet vysledku na stranku
 *   page - aktualni cislo stranky (nejnizsi je 1)
 *   move - funkce, ktera bude zavolana pri prechodu na dalsi stranku  
 */

module.directive('pagination', function pagination() {
  var template = 
    '<ul class="pager" ng-show="count > limit">' +
    '<li class="previous"><a>&larr; Předchozí</a></li>' + 
    '<li>Stránka: {{page}}/{{countPages}}</li>' + 
    '<li class="next"><a>Další &rarr;</a></li>' +
    '</ul>';

  function move(scope) {
    var offset = (scope.page - 1) * scope.limit;
    scope.move(offset, false);
    scope.$apply();
  }

  function disable(scope, el, page) {
    if (scope.page === page) {
      el.addClass('disabled');  
    }      
  }  

  function prev(scope, prevEl, nextEl) {
    if (scope.page <= 1) return; 
    if (scope.page > 1) {
      nextEl.removeClass('disabled');
      scope.page -= 1;    
    }
    disable(scope, prevEl, 1);
    move(scope); 
  }

  function next(scope, prevEl, nextEl) {
    if (scope.page >= scope.countPages) return;
    if (scope.page < scope.countPages) {
      prevEl.removeClass('disabled');
      scope.page += 1;    
    }
    disable(scope, nextEl, scope.countPages);
    move(scope);      
  }

  var config = {
    restrict: 'E',
    replace: true,
    scope: {
      count: '=',
      limit: '=',
      page: '=',
      move: '='
    },
    template: template,
   link: function(scope, element) {
      var children = element.children();
      var prevLi  = angular.element(children[0]);
      var nextLi  = angular.element(children[2]);

      prevLi.bind('click', function(){
        prev(scope, prevLi, nextLi);  
      }); 

      nextLi.bind('click', function(){
        next(scope, prevLi, nextLi);   
      });

      scope.$watch('count', function(){
        scope.countPages = Math.ceil(scope.count / scope.limit);   
        disable(scope, prevLi, 1);
        disable(scope, nextLi, scope.countPages);
      });
    }
  }
  return config;
});

Z předchozích dílů by mělo být vše jasné. Za zmínku stojí použití metody scope.$watch(). Ta se vykoná tehdy, jestliže dojde k změně na hlídaném modelu, v našem případě když se změní celkový počet výsledků. Zde je použití metody důležité, protože informace o celkovém počtu výsledků se získává přes API, což nějakou dobu potrvá, a než se výsledky získají, direktiva se vygeneruje do HTML. Díky této metodě jakmile získáme počet výsledků v kategorii, můžeme uživateli zobrazit i informaci, kolik produktů bylo celkově nalezeno.

Co dále

Nyní máme uživatelskou část hotovou. V tuto chvíli můžeme celou uživatelskou část odprezentovat zákazníkovi a získat od něj zpětnou vazbu. Dokonce můžeme provést i uživatelské testování. To vše, aniž bychom napsali jeden řádek na straně serveru. To přináší obrovskou výhodu v možnosti provádět bezbolestně zásahy do aplikace v této fázi, protože případné změny se dělají výrazně snáz a jednodušeji v této fázi, kdy nám stačí změnit HTML v šabloně nebo data, která získáváme přes API pomocí Apiary. Zákazník vidí reálnou aplikaci, kterou poté dostane, takže je velmi pravděpodobné, že zásadní nedostatky odhalí již v této fázi. Pro nás to znamená především velkou úsporu času.

V dalších několika dílech se budeme věnovat vytvoření administrace stejným způsobem. Můžete se také těšit na některé další novinky HTML5, které nám výrazně usnadní práci.

Komentáře: 1

Přehled komentářů

Honza Hommer Dotaz
Zdroj: https://www.zdrojak.cz/?p=6903