Přejít k navigační liště

Zdroják » JavaScript » Tvorba moderního e-shopu: správa objednávek

Tvorba moderního e-shopu: správa objednávek

Články JavaScript

Dnešní díl budeme věnovat vytvoření administrace objednávek. Také poprvé použijeme knihovnu Angular UI, díky které můžeme snadno implementovat složitější UI prvky, podíváme se na některé zajímavosti v AngularJS a i v tomto díle použijeme novinky přinášející HTML5.

Kompletní zdrojové kódy dnešního dílu si můžete stáhnout příkazem git checkout -f eshop08, popř. si je prohlédnout rovnou na Githubu. Samozřejmě je také dostupná demo verze současného stavu (od minula došlo k větším změnám pouze v administraci).

Layout administrace

Pro administraci vytvoříme vlastní layout a všechny šablony budeme dávat do složky admin. Zatím budeme pro jednoduchost přidávat controllery pro administraci ke všem ostatním controllerům, později vše rozdělíme na dvě části, aby běžný návštěvník zbytečně nenačítal data, která nepotřebuje. Pro administraci také použijeme inverzní verzi navbaru frameworku Bootstrap, abychom obě části viditelně odlišili.

Překlikávání mezi uživatelskou částí a administrací

Určitě budeme chtít zprovoznit překlikávání mezi uživatelskou částí a administrací a naopak, stačí v tomto případě přidat odkaz do patičky. Zde však můžeme narazit na problém, že AngularJS bude obsah daného odkazu načítat do současného layoutu a my potřebujeme načíst layout zvláštní pro uživ. část či administraci. Jak to lze provést? V tomto případě je potřeba přidat k odkazu atribut target s hodnotou _self. Takový odkaz bude AngularJS vnímat jako externí a nechá nás na daný odkaz přejít.

Zaškrtávání položek v menu administrace

V menu budeme chtít zaznačit položku, na které se právě nacházíme. To lze udělat různými způsoby. Ten nejméně hezký znamená přidat do každého controlleru něco jako $scope.menuItem = 'orders' a poté v šabloně tuto hodnotu porovnávat. To je ale trochu pracné, zbytečně se přidává do controlleru něco, co tam být nemusí, a toto řešení je náchylné k chybám (je až příliš snadné zapomenout toto do controlleru přidat).

Dále bychom mohli odchytávat změnu URL (případně název aplikovaného controlleru) a hledat v ní nějaký řetězec (tedy všechny URL objednávky budou začínat na /admin/orders, takže můžeme v menu zaznačit položku Objednávky). Zde bychom museli dodržovat striktně daný formát URL, což hned v první sekci administrace našeho e-shopu dodrženo nebude, protože v minulém díle bylo uvedeno, že nebudeme zbytečně vytvářet nějakou úvodní stránku a rovnou budeme mít objednávky na adrese /admin. Jak to tedy vyřešit?

Posílání zpráv v hierarchii scope

V jednom z minulých dílů bylo zmíněno, že AngularJS podporuje dědičnost scope. Jedno scope může být potomkem jiného scope a nejvyšší v hierarchii je $rootScope. Jednotlivé scope spolu mohou vzájemně komunikovat pomocí metod $scope.$broadcast(), $scope.$emit() a $scope.$on() v rámci implementace návrhového vzoru Observer (více podrobností v dokumentaci scope).

V libovolném controlleru zaregistrujeme posluchače pro danou událost např. takto:

$scope.$on('zprava', function(args){
  //...
});

Z jiného scope (třeba z direktivy nebo kdekoliv jinde) můžeme zaslat zprávu „zpráva“ pomocí metody $scope.$emit() nebo $scope.$broadcast(). Jejich jediný rozdíl je v tom, že $scope.$broadcast() upozorní ty scope, které jsou v hierarchii pod ním, zatímco $scope.$emit() upozorní všechny scope, které jsou v hierarchii nad ním. Tedy např. následující kód zavolá všechny posluchače, kteří čekají na zprávu „zpráva“ a jsou potomky scope, ze kterého je zpráva vyvolána.

$scope.$broadcast('zprava', args);

A právě to se nám hodí pro zatrhávání položek menu. Kdykoliv totiž dojde k úspěšnému nahrání nové stránky, je vyvolána událost $routeChangeSuccess (nebo $routeChangeError v případě chyby či $routeChangeStart na začátku routování). Stačí ji tedy odchytit a v šabloně změnit zatržení aktuální sekce.

Potřebujeme ještě vědět, jaká sekce má být pro dané pravidlo URL v menu zaškrtnuta. Tuto informaci je možné přidat k routě přímo při definici routeru, takže třeba pravidlo pro index objednávek může vypadat takto:

$routeProvider.when('/admin', {templateUrl: '/partials/admin/orders.html', controller: 'OrdersCtrl', menuItem: 'orders'});

Zde jsme si zvolili jako název položky menuItem. Oproti definici v controllerech je zde výhoda především v tom, že se nastavení provede na jednom místě a že nezasahujeme do controllerů.

Poslední krok je pak vytvoření posluchače na událost $routeChangeSuccess například takto:

$scope.$on('$routeChangeSuccess', function ($event, current) {
  $scope.menuItem = current.menuItem;
});

Ve scope pak už stačí danou položku jen zaškrtnout, což lze udělat pomocí kombinace ng-show/ng-hide nebo pomocí ng-class. Nejjednodušší bude první uvedené řešení:

<li ng-show="menuItem == 'orders'" class="active"><a ng-href="/admin/">Objednávky</a></li>
<li ng-hide="menuItem == 'orders'"><a ng-href="/admin/">Objednávky</a></li>

REST API pro objednávky

Pro celou administraci objednávek budeme potřebovat několik pravidel pro API:

GET /orders

Vrátí seznam objednávek. Je možné zadat parametry podobné jako v uživatelské části:

  • order (podle čeho řadit),
  • offset (pro stránkování, od kterého záznamu vracet výsledky),
  • limit (maximální počet vrácených záznamů),
  • fields (které sloupce vracet),
  • filter (upřesňující podmínky vrácených záznamů).

GET /orders/{číslo objednávky}

Vrátí všechny informace o dané objednávce podle čísla objednávky.

POST /orders

Toto pravidlo se použije pro vložení nové objednávky do databáze. Používáme ho v uživatelské části, v administraci novou objednávku vytvářet nebudeme.

PUT /orders/{číslo objednávky}

Použije se pro editaci objednávky podle jejího čísla. HTTP metoda PUT se používá pro vložení či editaci celého objektu, pokud známe jeho identifikátor. V detailu objednávky bude jednodušší při editaci posílat rovnou celý objekt, i když změníme jen část (třeba adresu nebo počet ks daných produktů).

POST /orders/{číslo objednávky}

Použije se pro částečnou editaci, v našem případě při změně stavu objednávky v přehledu. Dle konvencí je možné pro částečnou editaci použít buď výše uvedenou URL, nebo také metodu PUT, ovšem pak musí být URL třeba PUT /orders/{číslo objednávky}/status. Jednodušší je použít metodu POST a konkrétní informace o změně objektu uvést v těle požadavku místo v URL.

V našem e-shopu nemáme možnost objednávku smazat, místo toho bude její stav změněn na „zrušeno“. Pokud bychom chtěli přesto přidat možnost objednávku definitivně smazat, použili bychom HTTP metodu DELETE takto: DELETE /orders/{čislo objednavky}.

Poprvé zde také pracujeme s datem. Podle konvencí se u REST API používá pro formát data ISO 8601 (např.yyyyMMddTHHmmssZ). Stejný formát podporuje i AngularJS pro filtr date, takže pokud chceme naformátovat české datum, stačí v šabloně uvést jen {{order.date | date}}.

Filtrování objednávek

Sekce objednávek bude vypadat takto:

Administrace-objednavky

V některých sekcích administrace budeme potřebovat data nějakým způsobem filtrovat. Použijeme k tomu naši třídu pro parametrické vyhledávání z uživatelské části (přejmenovaná na UrlFilter). Pracuje však pouze s URL a hodilo by se nám vytvořit ještě jednu třídu, která toho bude umět víc. Obě třídy musí být odděleny, protože třídu UrlFilter budeme používat i na straně serveru, zatímco nová třída FormFilter bude čistě pro klientských JavaScript. Budeme ji chtít předat jen scope a konfiguraci a podle nastavení formulářů a URL nám vrátí hodnoty pro dotaz na API.

Její implementace bude vypadat například takto:

/**
 * Parametricke vyhledavani pro administraci.
 * 
 * @param {Object} $scope
 * @param {Object} config
 * @param {UrlFilter} filter
 * @param {$location} $location
 */

function FormFilter($scope, config, filter, $location) {
  this._$scope = $scope;
  this._$location = $location;
  this._filter = filter;
  this._limit = config.limit || 10;
  this._orderColumns  = config.orderColumns || [];
  this._filterColumns = config.filterColumns || [];
  this._querySearch  = Boolean(config.querySearch);
  this._init();
}

/**
 * Uvodni inicializace dat podle defaultniho nastaveni a 
 * podle hodnot z URL (pokud jiz nejake filtrovani probehlo).
 * 
 */

FormFilter.prototype._init = function() {
  this._offset = this.filter().getOffset();
  this._$scope.limit  = this._filter.getLimit();
  this._$scope.page   = this._filter.getPage();
  if (this._querySearch) {
    this._$scope.query  = this._filter.getParam('query');  
  }
  for (var i = 0; i < this._filterColumns.length; ++i) {
    this._$scope[this._filterColumns[i]] = this._filter.getFilterParamAsString(this._filterColumns[i], '');   
  }
};

/**
 * Projde formular filtrovani a prevede jejich hodnoty do retezece, 
 * který se pak bude zasilat na API v parametru query.
 * 
 * Napr. formular vypada takto:
 *   <form>
 *     <input ng-model="price"> 
 *     <input ng-model="vat"> 
 *   </form>
 * 
 * Po zavolani metody serialize() bude vracen tento retezec 
 * (pro price = 1000 a vat = 20): price:1000@vat:20    
 * 
 * 
 * @return {String}
 */

FormFilter.prototype.serialize = function() {
  var values = [];
  for (var i = 0; i < this._filterColumns.length; ++i) {
    if (this._$scope[this._filterColumns[i]]) {
      values.push(this._filterColumns[i] + ':' + this._$scope[this._filterColumns[i]]);   
    }  
  }
  return values.join('@');  
};

/**
 * Po zmene formulare pro filtrovani provede aktualizaci URL.
 */

FormFilter.prototype.updateUrl = function() {
  var query = this.getApiData();
  this._$location.search({
    offset: query.offset, 
    limit: query.limit,
    filter: query.filter,
    query: query.query,
    order: query.order
  });    
};

/**
 * Vrati vsechny data pro dotaz na API.
 * 
 * @return {Object}
 */

FormFilter.prototype.getApiData = function() {
  var query = {};
  query.offset = this.getOffset();  
  query.query  = this._$scope.query || '';
  query.limit  = this._$scope.limit;
  query.filter = this.serialize();
  query.order  = this.filter().getOrder();
  return query;
};

/**
 * @return {Number}
 */

FormFilter.prototype.getOffset = function() {
  return this._offset || 0;  
};

/**
 * @param {Number} offset
 */

FormFilter.prototype.setOffset = function(offset) {
  this._offset = offset;  
};

/**
 * @return {FormFilter}
 */

FormFilter.prototype.filter = function() {
  return this._filter;
};

A použití v controlleru je následující:

module.controller('OrdersCtrl', ['$scope', 'status', 'formFilter', 'api', function($scope, status, formFilter, api) {
 var filter = $scope.form = formFilter($scope, {
   limit: 10,
   orderColumns: ['date', '-date'],
   filterColumns: ['status', 'date'],
   querySearch: true
 });

 $scope.filter = function(offset) {
   filter.setOffset(offset);
   $scope.results = api.order.index(filter.getApiData());
   filter.updateUrl(); 
 };

 $scope.updateStatus = function(index) {
   var order = $scope.results.orders[index];
   api.order.updateStatus({number: order.number}, order);
 };

 $scope.results = api.order.index(filter.getApiData());
 $scope.st = status; 

}]);

Podobně jednoduše budou vypadat i ostatní controllery. Důležitá je především metoda getApiData(), která vrací ona data pro parametry požadavku na API.

Implementované filtrování je podobné jako v uživatelské části, takže podrobnější komentář nepotřebuje. Za zmínku ale stojí možnost změny stavu objednávky. U každé objednávky vypisujeme selectbox se zaškrtnutým stavem objednávky. V případě změny dojde jednak ke změně CSS třídy, která je vytvořena z názvu stavu, takže se změní podbarvení, ale také bude vyvolána metoda updateStatus(), která právě změní stav objednávky na serveru.

V tomto příkladě vypisujeme několik objednávek na stránku a u každé uvádíme jak adresu objednavatele, tak objednané produkty. V praxi bychom chtěli nejspíš některé údaje skrýt a zobrazit je až po rozkliknutí.

HTML5 placeholder a input element date

V HTML5 přibyl atribut placeholder, který předaný text vypíše přímo do elementu input. Pokud uživatel následně element edituje, text zmizí.

Z novinek také používáme HTML5 input typu date. Jak jsme viděli na screenshotu, např. Chrome ho vypisuje tak, že není možné zadat jiné než validní datum. Jinde se např. objeví dialog pro výběr data.

Úpravy objednávky

Úpravy objednávky nebudou až tak časté, takže je vložíme na samostatnou stránku. Běžně se úpravy provádějí tak, že se načtou data do formuláře, něco se upraví, formulář se odešle a data se uloží. Já tento způsob úprav moc rád nemám, protože je často ve formuláři mnoho položek a zabírá hodně místa a není tak přehledný jako naformátovaný detail. Kromě toho se často s ním načítá i WYSIWYG editor, který zabírá hodně paměti a v případě přístupu přes telefon/tablet editaci v podstatě nelze provést. Proto mám raději, když se načte jednoduchá šablona, a když potřebuji editovat jednu položku, tak na ni stačí kliknout a změní se v textové pole, které upravím.

Direktivu pro inline editaci jsme dělali v rámci seriálu o Node.js a dalších technologiích, konkrétně v díle AngularJS direktivy a testování. V takovém případě se položka v šabloně např. pro jméno vypíše takto:

<inline model='order.customer.name' action='update'></inline> 

Po kliknutí na jméno se objeví klasické textové pole, kde je možné záznam upravit a jakmile pole ztratí focus nebo uživatel klikne na klávesu Enter, zavolá se událost update(), která vypadá takto:

$scope.update = function() {
  api.order.update({number: $scope.order.number}, $scope.order);
};

Ano, tohle je vše. Změna se propíše rovnou do objektu $scope.order, který stačí předat metodě pro volání PUT /orders/{číslo objednávky}.

Kromě toho můžeme určit v direktivě i atribut typ, takže třeba po kliknutí na počet ks u daného objednaného produktu se objeví input typu number:

Detail objednávky

Detail objednávky

Kompletně celý controller pro detail vč. editací položek a mazání produktů z objednávky vypadá takto:

module.controller('OrderDetailCtrl', ['$scope', '$routeParams', 'status', 'price', 'api', function($scope, $routeParams, status, price, api){
  $scope.order = api.order.show({number: $routeParams.number});

  $scope.update = function() {
    $scope.order.price = price.total($scope.order.products, $scope.order.transport.price);
    api.order.update({number: $scope.order.number}, $scope.order);
  };

  $scope.remove = function(index) {
    $scope.order.products.splice(index, 1);
    $scope.update();
  };

  $scope.st = status;    
}]);

Používají se zde ještě dvě služby, které jsme dříve nezmínili. Služba Status obsahuje informace o dostupných stavech objednávek a služba Price  obsahuje metody pro výpočet ceny (použije se u všech tříd, které výpočet ceny provádějí, a to jak u klienta v uživatelské části a administraci, tak na serveru).

Přidání produktu

Do objednávky můžeme chtít také přidat další produkty. Asi nejjednodušší bude použít dialogové okno, kde správce zadá nějakou frázi a přes API se dotáže na produkty, které pak může do objednávky vložit.

Protože používáme framework Bootstrap, je dobré využít modální okno z tohoto projektu. Bohužel JavaScript v Bootstrapu je postaven na jQuery, které v rámci tohoto projektu použít nechci. Naštěstí existuje projekt Angular UI Bootstrap, kde jsou direktivy z Bootstrapu přímo pro AngularJS bez jQuery. Takže z úvodní stránky stačí vytvořit build (tedy říct, které direktivy budeme chtít použít), soubor načíst v šabloně a v souboru zdrojak.js načíst modul bootstrap.ui. Dialog můžeme nyní použít.

Vložení produktu k objednávce

Vložení produktu k objednávce

Nejprve vytvoříme samotnou šablonu s modálním oknem, který načteme jako externí šablonu. A dále je potřeba vytvořit controller, který modální okno bude ovládat a produkty vloží do objednávky:

module.controller('OrderAddProductCtrl', ['$scope', 'api', function($scope, api){
  $scope.open = function () {
    $scope.shouldBeOpen = true;
  };

  $scope.close = function () {
    $scope.shouldBeOpen = false;
  };

  $scope.filterProducts = function() {
    $scope.products = api.product.index({query: $scope.query, limit: 20, offset: 0});
  };

  $scope.add = function(product, variant) {
    $scope.order.products.push({
      code: product.code,
      name: product.name,
      price: product.price,
      variant: {
        code: variant.code,
        name: variant.name
      },
      quantity: 1,
      vat: product.vat
    });
    $scope.update();
    $scope.close();
  };  
}]);

Zde máme jednu velkou výhodu. API pro vyhledávání produktů máme hotovo již z dřívějška, takže ho můžeme klidně použít i tady. Jakmile uživatel najde daný produkt, klikne na tlačítko „Přidat“ a zavolá se metoda $scope.update() z rodičovského scope (tento controller je uvnitř controlleru OrderDetailCtrl), takže se okamžitě změna propíše na server a aktualizuje se i cena.

Co dále

Druhá nejdůležitější sekce v administraci je správa produktů, takže se budeme příště věnovat ji. Kromě dalších zajímavých částí AngularJS se budeme věnovat opět novinkám v HTML5, konkrétně práci se soubory.

Komentáře

Subscribe
Upozornit na
guest
6 Komentářů
Nejstarší
Nejnovější Most Voted
Inline Feedbacks
View all comments
redhead

Při přidání už existujícího produktu do objednávky by se asi měl zvýšit jeho počet a nepřidávat novou položku. Otázkou ale také je, jestli nějak uživatele neupozornit, že přidává produkt už v objednávce obsažený (možný překlik nebo chyba na straně komunikace se zákazníkem apod.)

Jinak díky za super seriál! Sice jsem nenapsal ani čárku, ale pěkně se to čte (hlavně to, že se to tváří, že tu aplikaci skutečně vytváříte pro nějakého zadavatele).

Daniel Steigerwald

Přimlouvám se za nepoužívání čestiny v kódu. Ze $scope.$on(‚zprava‘.. my slzí oči ,)

David

Zajímalo by mě proč nevytvořit třídy v souborech public/js/lib rovnou jako Angular services nebo alespoň jako Angular konstanty? Některé třídy se budou používat na serveru, to ještě chápu, ale co ty ostatní? Jaký to má význam? Akorát se tím zaplácá globální jmenný prostor.

Enum a statická analýza kódu

Mám jednu univerzální radu pro začínající programátorty. V učení sice neexistují rychlé zkratky, ovšem tuhle radu můžete snadno začít používat a zrychlit tak tempo učení. Tou tajemnou ingrediencí je statická analýza kódu. Ukážeme si to na příkladu enum.