#287 Presenters from Scratch pro
- Download:
- source codeProject Files in Zip (131 KB)
- mp4Full Size H.264 Video (30.2 MB)
- m4vSmaller H.264 Video (16.7 MB)
- webmFull Size VP8 Video (19.1 MB)
- ogvFull Size Theora Video (34.7 MB)
Dans cet épisode, nous allons créer un presenter from scratch. L'application d'exemple que nous allons utiliser est celle utilisée dans l'épisode sur Draper [regarder, lire]. Cette application contient un profile utilisateur affichant un avatar ainsi que les informations saisies par l'utilisateur.
Comme les utilisateurs ne sont pas obligés de remplir tous les champs, cette page doit pouvoir afficher des valeurs par défaut. Nous pouvons le voir pour l'utilisateur “MrMystery” qui n'a donné que son nom d'utilisateur.
Devoir gérer des valeurs par défaut signifie une logique complexe dans le code de la vue.
<div id="profile"> <%= link_to_if @user.url.present?, image_tag("avatars/#{avatar_name(@user)}", class: "avatar"), @user.url %> <h1><%= link_to_if @user.url.present?, (@user.full_name.present? ? @user.full_name : @user.username), @user.url %></h1> <dl> <dt>Username:</dt> <dd><%= @user.username %></dd> <dt>Member Since:</dt> <dd><%= @user.member_since %></dd> <dt>Website:</dt> <dd> <% if @user.url.present? %> <%= link_to @user.url, @user.url %> <% else %> <span class="none">None given</span> <% end %> </dd> <dt>Twitter:</dt> <dd> <% if @user.twitter_name.present? %> <%= link_to @user.twitter_name, "http://twitter.com/#{@user.twitter_name}" %> <% else %> <span class="none">None given</span> <% end %> </dd> <dt>Bio:</dt> <dd> <% if @user.bio.present? %> <%=raw Redcarpet.new(@user.bio, :hard_wrap, :filter_html, :autolink).to_html %> <% else %> <span class="none">None given</span> <% end %> </dd> </dl> </div>
Cette page contient de nombreux if
. Chacun d'eux permet d'afficher soit la valeurs saisie par l'utilisateur, soit la valeur par défaut. Comme cette logique est uniquement relative à la vue, une bonne idée serait d'utiliser une classe presenter pour élaguer un peu le code de la vue. Un presenter est une classe ayant connaissance d'un modèle et d'une vue, c'est une bonne pratique, orientée objet, pour gérer une logique de vue complexe. Nous allons en utiliser un dans notre application, de façon à pouvoir alléger notre vue.
Écrire notre premier presenter
Un presenter est une simple classe Ruby. Pour ranger nos presenters, nous allons créer un dossier presenters
dans le dossier app
de notre application. Nous allons ensuite créer le fichier user_presenter.rb
. Dans les versions précédentes de Rails, nous aurions dû ajouter ce nouveau dossier aux config.autoload_paths
dans le fichier /config/application.rb
. Depuis Rails 3.1, ce n'est plus nécessaire, si nous relançons notre serveur, le dossier sera automatiquement pris en compte.
Le presenter doit connaitre le modèle et la vue avec lesquels il va devoir travailler. Nous allons donc les lui fournir sous forme de paramètres pour sa méthode initialize
et les stocker dans des variables d'instance.
class UserPresenter def initialize(user, template) @user = user @template = template end end
Nous pouvons maintenant commencer d'extraire la logique de la vue pour la placer dans notre classe. Cependant, est-ce la bonne chose à faire ? Tout d'abord, nous allons devoir instancier notre classe UserPresenter
quelque part. Draper et les autres librairies de présentation, il est d'usage de faire cela dans l'action du contrôleur mais ce n'est pas ce que nous allons faire car il est discutable que le contrôleur ait accès aux presenters.
Au lieu de modifier le contrôleur, nous allons écrire un nouveau helper qui va instancier le presenter. Nous appellerons cette méthode present
, elle prendra en paramètre le modèle pour lequel nous voulons créer un presenter et un bloc recevant un objet presenter. Nous placerons tout le template de la vue dans le bloc de façon à ce que le presenter soit accessible.
<% present @user do |user_presenter| %> <div id="profile"> <!-- Rest of view code omitted --> </div> <% end %>
Nous allons écrire le helper dans ApplicationHelper
. En plus du modèle, nous allons passer un argument optionnel de façon à choisir la classe à utiliser pour le presenter. Si la classe n'est pas spécifiée, nous la déterminerons à partir du nom du modèle suivi de Presenter
, dans notre cas, le nom sera donc UserPresenter
. Nous allons ensuite appeler constantize
sur la chaîne pour retourner une classe.
Maintenant que nous avons notre classe presenter, nous allons l'instancier en appelant klass.new
à qui nous passerons notre modèle object
et self
, qui sera le template ayant les helpers auxquels nous voulons accéder. Si un bloc a été passé à la méthode, nous allons appeler yield
avec le presenter en paramètre puis retourner ce dernier.
module ApplicationHelper def present(object, klass = nil) klass ||= "{object.class}Presenter".constantize presenter = klass.new(object, self) yield presenter if block_given? presenter end end
Avec notre méthode present
, nous avons maintenant un moyen pratique d'accéder à notre presenter depuis n'importe quel objet de n'importe quel template et nous pouvons commencer de déplacer le code du template vers le presenter. Nous commencerons par le code affichant l'avatar.
<%= link_to_if @user.url.present?, image_tag("avatars/#{avatar_name(@user)}", class: "avatar"), @user.url %>
Nous allons le remplacer par un appel à une nouvelle méthode, avatar
issue de UserPresenter
.
<%= user_presenter.avatar %>
Nous allons copier/coller la logique de la vue dans cette méthode.
def avatar link_to_if @user.url.present?, image_tag("avatars/#{avatar_name(@user)}", class: "avatar"), @user.url end
Chaque helper que nous appelons dans notre presenter doit être appelé sur le template mais au lieu de mettre @template
partout, nous allons utiliser la même façon de faire que Draper et utiliser la méthode h
et ajouter cette méthode à notre presenter pour qu'elle retourne le template. Nous pouvons ensuite l'utiliser pour les appels à link_to_if
et image_tag
de façon à ce qu'ils passent par le template.
Le code que nous avons copié appelle un helper que nous avons écrit : avatar_name
. Celui-ci est défini dans UsersHelper
.
module UsersHelper def avatar_name(user) if user.avatar_image_name.present? user.avatar_image_name else "default.png" end end end
Chaque fois que nous avons un helper prenant en paramètre le modèle utilisé par notre presenter, il est préférable de le déplacer dans le presenter. Nous allons donc mettre avatar_name
dans UserPresenter
et en faire une méthode privée. Comme nous avons accès à l'utilisateur courant dans notre presenter, nous pouvons supprimer son paramètre user
et faire appel à sa variable d'instance, @user
.
class UserPresenter def initialize(user, template) @user = user @template = template end def avatar h.link_to_if @user.url.present?, h.image_tag("avatars/#{avatar_name}", class: "avatar"), @user.url end private def h @template end def avatar_name if @user.avatar_image_name.present? @user.avatar_image_name else "default.png" end end end
Si nous rechargeons la page de profile d'un utilisateur, elle est exactement comme avant, notre presenter fonctionne donc correctement.
Créer un presenter de base
Toute application utilisant des presenters en utilise évidemment plusieurs. Nous allons donc vouloir rendre générique leur fonctionnement. Chaque presenter que nous créons a les mêmes méthodes initalize
et h
, nous pouvons les mettre dans une classe de base et faire en sorte que nos presenters en héritent.
Nous allons devoir changer le nom du modèle passé à la méthode initialize
dans notre classe de base pour le rendre plus générique, object
par exemple. Cela ne nous laisse aucun moyen de référencer directement notre objet utilisateur dans UserPresenter
. Nous pourrions appeler @object
mais nous allons plutôt créer une méthode de classe, dans BasePresenter
, nommée presents
et prenant un nom en paramètre. Cette méthode va définir une méthode ayant pour nom le paramètre passé et retournant le modèle contenu dans @object
.
class BasePresenter def initialize(object, template) @object = object @template = template end def self.presents(name) define_method(name) do @object end end def h @template end end
UserPresenter
et les autres presenters que nous créons peuvent maintenant hériter de cette classe. Si nous appelons presents :user
dans UserPresenter
, cela va créer une méthode user
retournant l'utilisateur courant de façon à remplacer tous les appels à @user
par user
.
class UserPresenter < BasePresenter presents :user def avatar h.link_to_if user.url.present?, h.image_tag("avatars/#{avatar_name}", class: "avatar"), user.url end private def avatar_name if user.avatar_image_name.present? user.avatar_image_name else "default.png" end end end
Nettoyer le reste de la vue
Notre template est un peu plus clair mais il reste encore beaucoup à faire pour le nettoyer vraiment. Les actions que nous allons effectuer sont similaires à celles de l'épisode sur Draper, si vous voulez voir précisément ce qu'il faut faire, vous pouvez regarder ou lire cet épisode. Nous allons effectuer ces modifications en arrière-plan et montrer le résultat final. Une grosse part de la logique ayant été déplacée dans le presenter, notre template est beaucoup plus clair.
<% present @user do |user_presenter| %> <div id="profile"> <%= user_presenter.avatar %> <h1><%= user_presenter.linked_name %></h1> <dl> <dt>Username:</dt> <dd><%= user_presenter.username %></dd> <dt>Member Since:</dt> <dd><%= user_presenter.member_since %></dd> <dt>Website:</dt> <dd><%= user_presenter.website %></dd> <dt>Twitter:</dt> <dd><%= user_presenter.twitter %></dd> <dt>Bio:</dt> <dd><%= user_presenter.bio %></dd> </dl> </div> <% end %>
Le code du presenter est visible ci-dessous. Il est maintenant composé d'un certain nombre de méthodes plutôt courtes qui se chargent de toute la logique de la page.
class UserPresenter < BasePresenter presents :user delegate :username, to: :user def avatar site_link image_tag("avatars/#{avatar_name}", class: "avatar") end def linked_name site_link(user.full_name.present? ? user.full_name : user.username) end def member_since user.created_at.strftime("%B %e, %Y") end def website handle_none user.url do h.link_to(user.url, user.url) end end def twitter handle_none user.twitter_name do h.link_to user.twitter_name, "http://twitter.com/#{user.twitter_name}" end end def bio handle_none user.bio do markdown(user.bio) end end private def handle_none(value) if value.present? yield else h.content_tag :span, "None given", class: "none" end end def site_link(content) h.link_to_if(user.url.present?, content, user.url) end def avatar_name if user.avatar_image_name.present? user.avatar_image_name else "default.png" end end end
Il y a cependant deux choses à noter à propos des changements que nous avons effectués sur le presenter. Vers le haut du fichier, nous utilisons delegate
pour déléguer la méthode username
à la classe User
. Nous n'avons besoin d'effectuer aucun changement au champ username
issu de User
, nous le récupérons donc directement à la source.
Le template utilise Redcloth
pour afficher la bio de l'utilisateur. Cela peut être utile dans d'autres presenters. Nous allons donc créer une méthode markdown
dans BasePresenter
permettant d'afficher facilement du code Markdown depuis n'importe quel presenter. Nous pouvons ensuite appeler cette méthode depuis UserPresenter
.
def markdown(text) Recarpet.new(text, :hard_wrap, :filter_html, :autolink).to_html.html_safe end
La méthode handle_none
gère les champs comme twitter
pour lesquels nous voulons afficher “None given” si l'utilisateur n'a pas saisi d'information. Dans cette méthode, nous utilisons content_tag
pour afficher la valeur par défaut dans une balise HTML span. Cette approche fonctionne bien pour de petits morceaux de code HTML mais pour un code plus complexe, la création d'un partial ou l'utilisation d'un langage comme Markaby est probablement plus adapté.
Une alternative à la méthode ‘h’
Précédemment, nous avons créé une méthode h
pour donner accès aux helpers. Si vous ne voulez pas faire cela, une alternative est d'écrire une méthode method_missing
pour déléguer toutes les méthodes inconnues au template. Cela est très facile à mettre en place dans BasePresenter
de façon à ce que cela marche dans tous les presenters.
def method_missing(*args, &block) @template.send(*args, &block) end
Maintenant, tout ce que notre presenter ne connait pas est envoyé au template et, lorsque nous appelons un helper comme image_tag
, nous pouvons l'appeler directement plutôt que de passer par la méthode h
.
Accéder au presenters depuis les contrôleurs
À un certain point, il est possible que nous ayons besoin d'accéder aux presenters depuis un contrôleur. Par exemple, nous pourrions nous en servir depuis l'action show
de UserPresenter
pour nous aider à créer du code JSON en écrivant quelque chose comme ceci :
def show @user = User.find(params[:id]) present(@user).to_json end
Cela ne marchera pas puisque notre méthode present
est un helper. Cela dit, nous pouvons créer une méthode present
pour nos contrôleurs dans ApplicationController
. L'astuce de cette méthode est de faire appel à view_content
au lieu de self
pour obtenir le template lorsque nous instancions le presenter.
class ApplicationController < ActionController::Base protect_from_forgery private def present(object, klass = nil) klass ||= "#{object.class}Presenter".constantize klass.new(view_content, object) end end
Tester
Notre application fonctionne parfaitement avec ses nouvelles vues toutes propres mais qu'en est-il des tests ? Nous devrions avoir des tests d'intégration haut-niveau, utilisant Capybara (vu dans l'épisode 275 [regarder, lire]), mais l'un des avantages de l'utilisation des presenters est qu'il est beaucoup plus facile de tester la logique de la vue à un niveau plus bas. Nous verrons cela, avec Test::Unit puis RSpec.
Pour écrire notre premier test de presenter avec Test::Unit, nous allons créer un nouveau dossier presenters
dans /test/unit
et y ajouter un fichier user_presenter_test.rb
. La classe UserPresenterTest
que nous allons créer dans ce fichier doit hériter de ActionView::TestCase
et non de ActiveSupport::TestCase
, cela nous donne accès à la vue de façon à pouvoir la passer au presenter.
Nous allons démontrer cela avec un test simple qui valide que le texte “None given” est retourné par la méthode website
de UserPresenter
lorsque l'utilisateur n'a pas saisi de valeur. Nous commençons par créer un nouveau UserPresenter
en lui passant un nouveau User
ainsi que la vue. Comme notre test hérite de ActionView::TestCase
, nous avons accès à la variable view
contenant le template. Nous appelons ensuite sa méthode website
et validons le fait qu'elle retourne la valeur “None given”.
require 'test_helper' class UserPresenterTest < ActionView::TestCase test "says when none given" do presenter = UserPresenter.new(User.new, view) assert_match "None given", presenter.website end end
Lorsque nous lançons notre test avec rake test
, il passe.
$ rake test Loaded suite /Users/eifion/.rvm/gems/ruby-1.9.2-p180@global/gems/rake-0.9.2/lib/rake/rake_test_loader Started . Finished in 0.155069 seconds. 1 tests, 1 assertions, 0 failures, 0 errors, 0 skips
L'accès à notre presenter depuis les tests signifie que nous pouvons faire tu Développement Dirigé par les Tests (TDD) pour écrire nos presenters, en commençant avec un test qui échoue puis en modifiant le presenter pour passer le test.
Pour tester un presenter avec RSpec, nous allons commencer par créer un dossier presenters
dans /spec
et ajouter un fichier user_presenter_spec.rb
dedans. Nous allons écrire une spec testant la même chose que notre dernier test. L'astuce pour écrire les specs d'un presenter est d'inclure ActionView::TestCase::Behavior
. Cela nous done une variable view
contenant le template, cela fonctionne de la même manière que lorsque nous avons hérité de ActionView::TestCase
précédemment.
La spec en elle-même est très similaire au test que nous avons écrit. Nous créons à nouveau un presenter et vérifions que sa méthode website
retourne bien le texte par défaut.
require 'spec_helper' describe UserPresenter do include ActionView::TestCase::Behavior it "says when none given" do presenter = UserPresenter.new(User.new, view) presenter.website.should include("None given") end end
Lorsque nous lançons rake spec
, nous voyons notre spec passer.
$ rake spec /Users/eifion/.rvm/rubies/ruby-1.9.2-p180/bin/ruby -S bundle exec rspec ./spec/presenters/user_presenter_spec.rb . Finished in 0.06957 seconds 1 example, 0 failures
Au lieu d'inclure le module Behavior
dans chaque spec de presenter, nous pouvons modifier le bloc de configuration dans le fichier spec_helper.rb
de façon à ce qu'il soit automatiquement inclus dans chaque spec. Cela va inclure le module Behavior
pour chaque fichier placé dans le dossier /spec/presenters
, nous n'avons donc pas besoin de l'inclure dans chacune de nos specs.
RSpec.configure do |config| config.include ActionView::TestCase::Behavior, example_group: {file_path: %r{spec/presenters}} # Rest of block omitted. end
Il y a toutefois un petit piège lorsque l'on teste les presenters. L'objet view
que nous passons au presenter peut accéder à tous les helpers excepté ceux définis dans les contrôleurs. Par exemple, si nous avons une méthode current_user
dans le contrôleur et que nous en avons fait un helper grâce à helper_method
, nous ne pourrons pas l'appeler sur l'objet view
dans les tests. Nous allons donc devoir faire un “stubbing” de cette méthode pour faire en sorte qu'elle retourne la valeur voulue. Une méthode current_user
va généralement dépendre de la valeur d'une session ou d'un cookie, un stubbing est donc une bonne idée de toute façon.
C'est tout pour cet épisode sur les presenters from scratch. Maintenant que nous vous avons montré ce que cela implique, vous devriez avoir assez d'informations pour décider d'écrire votre propre solution ou d'utiliser une gem comme Draper. Dans tous les cas, si vos applications ont des logiques de vues complexes, cela vaut la peine de considérer l'utilisation des presenters pour nettoyer vos vues et les garder claires.