playlist Finalisation du panier

Publié il y a 4 mois dans la série : Rails
Nicolas Cavigneaux
Votre formateur
Nicolas Cavigneaux

A la recherche d'un langage polyvalent, j'ai fait la découverte de Ruby en 2003. J'ai donc très vite commencé à utiliser Ruby au quotidien pour des tâches diverses et variées (scripting, applications lourdes…).

Courant 2004, une vague de fraicheur est apparue avec l'arrivée de Ruby on Rails qui m'a de suite conquis. J'ai donc décidé de participer activement à la communauté (forums, patches, librairies, …). En 2010, je fais la rencontre de Martin Catty et retrouve dans sa vision la rigueur et les bonnes pratiques que j'aime mettre en place, le déclic a donc été immédiat.

Synbioz met en place des solutions robustes sur la base d'outils modernes et funs, je veux faire partie de l'aventure.

Catégories : Développement

Dans cette vidéo nous allons finaliser le développement de la partie panier en la rendant plus robuste et en ajoutant les fonctionnalités manquantes.
Afficher le transcript complet de la vidéo

Robustesse face aux imprévus


Bonjour et bienvenue dans cet épisode consacré à Ruby on Rails. Dans le dernier épisode nous avons mis en place un catalogue de nos produits à destination de nos clients.


C'est fonctionnel mais nous ne l'avons testé que dans des conditions normales d'utilisation. Comment notre application réagirait elle si on l'utilisait de façon inattendue, par exemple en passant des identifiants inconnus dans les URLs.


Nous allons tester ça et voir ce que nous pouvons faire pour améliorer le comportement.


Accès à un panier inexistant


Commençons par essayer de d'accéder à un panier en changeant l'identifiant qui apparaît dans l'url.


On se retrouve face à une page d'erreur générée par Rails. Cette page nous dit qu'ActiveRecord n'a pas pu trouver le panier avec l'identifiant foo ce qui paraît tout à fait logique.


Cette erreur arrive ligne 67 de notre contrôleur CartsController. Effectivement à cette ligne on essaie tout simplement de trouver un panier sur la base du paramètre id qui est récupéré depuis l'URL.


La méthode find d'ActiveRecord a comme comportement par défaut de lever une exception ActiveRecord::RecordNotFound quand aucun enregistrement en base ne correspond à l'identifiant passé en paramètre.


En production ça se traduirait par l'affichage d'une page d'erreur 404 ce qui peut être acceptable. Pour des raisons d'apprentissage et parce que de toute façon en situation réelle on ne devrait pas pouvoir accéder à n'importe quel panier mais seulement le sien, nous allons gérer explicitement cette erreur pour en apprendre plus à propos de Rails.


Gestion explicite de l'enregistrement inexistant


On va donc attraper cette exception quand elle se produit pour y réagir autrement que par une 404. À la place, on va plutôt rediriger l'utilisateur sur la liste des produit en ajoutant au passage un message d'erreur qui lui indiquera que le panier n'est pas disponible.


Cette modification va nous faire utiliser quatre concepts différent, la gestion des exceptions au niveau contrôleur, la redirection, la gestion des messages flash et l'utilisation du système de traduction.


Modifions le fichier app/controllers/carts_controller.rb pour y apporter quelques modifications :

rescue_from ActiveRecord::RecordNotFound, with: :cart_not_found

private

def cart_not_found
  redirect_to root_url, alert: t(".cart_not_found")
end


Avec ces quelques lignes, on s'assure d'attraper l'exception à travers tout le contrôleur et de la gérer en appelant la méthode cart_not_found. Cette méthode va rediriger l'utilisateur à la racine du site et par la même occasion définir un message flash pour la clé alert. Ça reviendrait à appeler séparément flash[:alert] = avant le redirect_to mais c'est un besoin tellement commun que redirect_to nous permet de le faire à la volée.


Dans ce flash, on passe le résultat de l'appel de méthode t qui est un raccourci pour I18n.t. C'est le système d'internationalisation livré avec Rails. Cette appel signifie, trouve moi le message pour la langue courante qui correspond à la clé cart_not_found et relative à ce contrôleur parce que la clé commence par un point. Si nous n'avions pas mis le point, le système d'internationalisation chercherait la clé dans les traductions directement à la racine plutôt que dans un scope du nom du contrôleur.


Il nous manque donc deux choses pour que ça fonctionne. Afficher ce message d'alerte et mettre en place le message traduit dans le fichier dédié à cet effet.


Commençons par afficher les messages flash de type alert. Nous allons modifier le layout puisqu'après tout on aura sûrement besoin d'afficher d'autre messages de ce type à travers l'application. On édite donc app/views/layout/application.html.erb :

<p id="alert"><%= alert %></p>


Si on recharge notre page de panier non existant maintenant :


On est bien redirigé et notre flash s'affiche mais il nous indique que le message n'est pas traduit. Comme vous pouvez le voir, le premier élément de la clé de traduction est en. C'est parce que par défaut, une app Rails est considéré être en anglais et nous n'avons pas changé ça. Derrière carts qui représente le contrôleur qui a définie le flash, show pour l'action, puis finalement notre clé cart_not_found.


On va donc ajouter cette clé et sa traduction dans le fichier config/locales/en.yml :

en:
  hello: "Hello world"

  carts:
    show:
      cart_not_found: "Panier indisponible"


On sauvegarde puis on recharge la page de panier inexistant et on voit que le message traduit apparaît bien.


Dans une vraie application, on voudrait styler ce message pour le rendre plus visible. On aurait aussi créé un fichier fr.yml et passé notre app en français par défaut si c'est le comportement souhaité. Ça n'a pas de sens de mettre des traductions en français dans le fichier en.yml dédié à l'anglais.


Finalisation du panier


Maintenant que l'accès au panier a été amélioré, passons à sa finalisation en permettant de le vider puis en affichant les informations de prix qui sont aujourd'hui totalement absentes !


Commençons par le bouton de vidage du panier. On a donc besoin d'ajouter un bouton qui va appeler l'action destroy sur notre controller CartsController. Cette action aura en charge de supprimer le panier de la base de donnée tout en prenant soin de vérifier que c'est bien le panier de l'utilisateur courant. Safety first ! On pourra ensuite supprimer l'identifiant de panier en session.


Allons y, commençons par la vue app/views/carts/show.html.erb :

<%= button_to "Vider le panier", @cart, method: :delete, data: {
confirm: "Êtes-vous sûr ?" }, class: "btn btn-danger" %>


Très peu de code mais beaucoup de choses à noter. On passe l'objet @cart à notre button_to ce qui lui permet d'en déduire l'URL spécifique à cet objet. On précise ensuite qu'on veut effectuer l'appel HTTP à l'aide de delete ce qui nous permettra d'atterir sur l'action destroy du contrôleur. On passe ensuite un hash data qui est un attribut spécial pour les boutons et les liens. Ça permet de leur attacher des data-attributes HTML qui pourront être exploités ensuite notamment ici le data-confirm qui est une clé spéciale recherché par Rails pour auto-générer une boîte de confirmation en JS contenant le texte mentionné en valeur. Si la confirmation est acceptée l'action est appelée, sinon l'action utilisateur est annulée. Très pratique !


Voyons ce que ça donne.


Pas mal pour si peu de code. On passe maintenant à la modification de l'action destroy de notre contrôleur :

return unless @cart.id == session[:cart_id]

@cart.destroy
session.delete(:cart_id)

respond_to do |format|
  format.html { redirect_to root_url, notice: "Votre panier est vide"
  }
  # …


On s'assure donc que le panier pour lequel un identifiant nous a été passé est bien le panier de l'utilisateur courant en comparant l'id avec celui stocké en session.


Si c'est le cas, on supprime le panier de la base de donnée grâce à la méthode destroy puis on supprime la clé cart_id et sa valeur de la session.


Finalement, on redirige sur l'accueil avec un message confirmant que le panier est vide.


On peut jouer les tests :

$ bin/rake test


qui ne passent plus. Si on regarde le test pour destroy dans le fichier test/controller/carts_controller_test.rb, on s'aperçoit qu'aucune session n'est mise en place avant la tentative de suppression, c'est donc une bonne nouvelle. Modifions ce test et ajoutons en un pour le cas devant fonctionner.

test "should not destroy cart" do
  post line_items_url, params: { product_id: products(:one).id }

  assert_no_difference('Cart.count') do
    delete cart_url(@cart)
  end

  assert_response 204
end

test "should destroy cart if session matches" do
  post line_items_url, params: { product_id: products(:one).id }
  @cart = Cart.find(session[:cart_id])

  assert_difference('Cart.count', -1) do
    delete cart_url(@cart)
  end

  assert_redirected_to root_url
end


Pour chaque test on s'assure donc de d'abord ajouter un produit dans notre panier ce qui a pour conséquence de nous créer un panier en session. Ensuite dans le premier test, on essaye de vider un panier qui ne nous appartient pas, ce qui ne doit pas fonctionner et nous rendre une page sans réponse.


Pour le deuxième test cette fois, on s'assure d'utiliser le panier qui nous appartient et on essaie de le vider. Cette fois ci c'est censé fonctionner.


Si on lance les tests :

$ bin/rake test:controllers


Tout semble fonctionner comme attendu. Ajoutons l'affichage du message flash à notre layout puis testons dans le navigateur.

<p id="notice"><%= notice %></p>


Essayons de vider notre panier. Et ça fonctionne. Idéalement il faudrait supprimer les autres endroits dans les vues où on affichait les messages flash sinon nous allons avoir des doublons à l'affichage. Nous le ferons le moment venu si nécessaire.


Affichage des prix et totaux dans la page panier


Pour finir cette partie, il ne nous reste plus qu'à afficher les prix par ligne et le total dans notre panier. L'idéal pour faire ça serait d'ajouter une méthode à notre modèle LineItem qui nous retournera le total pour une ligne donnée puis une méthode dans le modèle Cart qui pourra exploiter ses sous-totaux pour retourner le total global.


Commençons par LineItem :

def total
  product.price * quantity
end


puis Cart :

def total
  line_items.to_a.sum(&:total)
end


Nous n'avons plus qu'à utiliser ces méthodes dans notre vue panier pour avoir les informations voulues.


On supprime la notice superflue.


Puis on ajoute un lien de retour aux produits :

<div class="row">
  <div class="col">
   <%= link_to "Retour aux produits", root_path, class: "btn btn-primary" %>
  </div> 

  <div class="col">
    <%= button_to "Vider le panier", @cart, method: :delete, data: {
    confirm: "Êtes-vous sûr ?" }, class: "btn btn-danger" %>
  </div>
</div>


On ajoute maintenant le total par line :

<span class="badge badge-info"><%= number_to_currency(item.total, unit: "€", separator: ",", format: "%n %u") %>


puis le total du panier :

<p class="font-weight-bold">Total : <span class="badge badge-info"><%= number_to_currency(@cart.total, unit: "€", separator: ",", format: "%n %u") %></span></p>


Il ne nous reste plus qu'à essayer.


On a maintenant un panier parfaitement fonctionnel !


Si on joue nos tests, on voit qu'ils sont cassés. Effectivement, la ligne de texte que nous vérifions a changée. Corrigeons ça :


test/controllers/line_items_controller_test.rb :

assert_select 'li', /#{tshirt.title} \(x 1\)/
assert_select 'li', /#{tshirt.title} \(x 2\)/


Plutôt que de rechercher une correspondance exacte entre deux chaîne, on cherche maintenant une correspondance partielle grâce à l'utilisation d'une regex.


Dans le prochain épisode, nous verrons comment mettre à jour notre panier de façon dynamique grâce à l'utilisation d'AJAX.


À bientôt.

7/9 dans la sérieRails