Usando presenters no Rails
Leia em 4 minutos
Um problema muito recorrente de aplicativos de médio-grande porte é que as views são uma bagunça. É muito comum termos condições em nossas views. Objetos possuem diferentes estados e muitas vezes precisamos mostrar esses estados visualmente. E normalmente começamos pelo caminho mais fácil, que é adicionar ifs
na view.
Acontece que isso não precisa ser sempre assim. Neste artigo vou mostrar como funciona um pattern chamado Presenter, que permite diminuir/remover a complexidade de views e controllers.
Esse artigo foi publicado originalmente em 18/12/2011.
Começando com o seu próprio presenter
A primeira coisa que você precisa detectar é que tipo de lógica é possível extrair de sua view. Algumas coisas mais genéricas fazem mais sentido serem extraídas como helpers. Outras, como ifs
para determinar qual partial deve ser renderizada provavelmente devem ser movidas para seu presenter.
Imagine que você tenha uma view como esta:
<h1><%= @product.name %></h1>
<% if @product.description %>
<p class="description"><%= @product.more %></p>
<% end %>
Não se deixe enganar por esse tipo de lógica. Embora pareça inofensiva, coisas como esta podem sair do controle rapidamente.
Esta view precisa de uma variável @product
que deve ser definida em nosso controller:
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
end
end
Agora, precisamos de uma classe que irá “envelopar” nossa instância da classe Product
. Crie o arquivo app/presenters/product_presenter.rb
. Apenas lembre-se de reiniciar o seu servidor para que o Rails detecte esse novo diretório.
class ProductPresenter < SimpleDelegator
attr_reader :product
def initialize(product)
@product = product
__setobj__(product)
end
def eql?(target)
target == self || product.eql?(target)
end
end
Esta classe irá expor todos os atributos do produto que nossa view irá acessar. O método ProductPresenter#eql?
precisa ser implementado para que seja possível passar o presenter como parâmetro de helpers de rotas.
Agora, podemos remover aquele if
. Se você não se lembra mais dele, dê uma última olhadela, pois logo ele não mais existirá! Adicione o método description
. Faça com que este método retorne o parágrafo com a descrição, caso ela tenha sido definida. Como é necessário retornar uma tag HTML, vamos usar o helper content_tag
.
class ProductPresenter < SimpleDelegator
attr_reader :product
def initialize(product)
@product = product
__setobj__(product)
end
def eql?(target)
target == self || product.eql?(target)
end
def description
if product.description.present?
helpers.content_tag(:p, product.description, class: "description")
end
end
private
def helpers
ApplicationController.helpers
end
end
Altere o controller para que ele passe a instância da classe Product
para o presenter.
class ProductsController < ApplicationController
def show
@product = ProductPresenter.new(Product.find(params[:id]))
end
end
Para finalizar, basta modificar nossa view.
<h1><%= @product.name %></h1>
<%= @product.description %>
Muita gente é contra gerar HTML de dentro do presenter; eu digo que faz sentido se seu presenter será usado somente para templates HTML. Caso você precise compartilhar os presenters, crie um decorator que normaliza os dados e, então, use esse decorator em seu presenter específico de HTML (ou para outro formato).
Voltando… Para o caso de partials, o funcionamento é basicamente o mesmo. No entanto, em vez de fazer a renderização no próprio presenter, é mais fácil retornar o nome da partial que deve ser renderizada.
Imagine que nossa view tenha mais um if
que irá renderizar uma partial diferente para produtos gratuitos.
<% if @product.paid? %>
<%= render "order", product: @product %>
<% else %>
<%= render "download", product: @product %>
<% end %>
Podemos implementar um método chamado ProductPresenter#checkout_partial
que irá fazer aquele if
, retornando apenas o nome da partial.
class ProductPresenter < SimpleDelegator
attr_reader :product
def initialize(product)
@product = product
__setobj__(product)
end
def eql?(target)
target == self || product.eql?(target)
end
def description
if product.description.present?
helpers.content_tag(:p, product.description, class: "description")
end
end
def checkout_partial
@product.paid? ? "order" : "download"
end
private
def helpers
ApplicationController.helpers
end
end
E na nossa view, basta renderizar o retorno do método ProductPresenter#checkout_partial
.
<%= render @product.checkout_partial, product: @product %>
A esta altura, você já deve ter percebido como presenters podem remover completamente a lógica das views. Embora seja muito fácil fazer isso sem a necessidade de bibliotecas, algumas coisas precisam ser implementadas toda vez. É o caso de helpers, rotas e métodos de internacionalização.
Pensando nisso, decidi extrair aquela organização de código que eu estava utilizando em uma gem chamada burgundy.
Usando o burgundy
Para instalar, basta executar o comando abaixo:
$ gem install burgundy
Lembre-se de adicionar a gem ao arquivo Gemfile
.
source "https://rubygems.org"
gem "rails", "4.2.0"
gem "burgundy"
Aquele mesmo presenter que definimos pode ser trocado por algo como isto:
class ProductPresenter < Burgundy::Item
def description
if item.description.present?
h.content_tag(:p, item.description, class: "description")
end
end
def checkout_partial
item.paid? ? "order" : "download"
end
end
Note que não precisamos mais definir o método ProductPresenter#initialize
, nem o método ProductPresenter#helpers
. Também tivemos que mudar todas as referências a product
para item
; isso é necessário porque é este o objeto que receberá os métodos delegados.
Por padrão, tudo o que é disponível no item
também estará disponível no presenter. Você pode marcar propriedades que não quer expor como privadas ou até mesmo removê-las.
O burgundy adiciona os métodos helpers
e h
que permite acessar os helpers do Rails. Os helpers de rotas podem ser acessados com os métodos routes
e r
. E, finalmente, os helpers de internacionalização podem ser acessados por translate
e t
, e localize
e l
.
Você também pode envelopar coleções.
class ProductsController < ApplicationController
def index
@products = Burgundy::Collection.new(
Product.sorted_by_name,
ProductPresenter
)
end
end
Alternativamente, você pode usar o método ProductPresenter.wrap
.
class ProductsController < ApplicationController
def index
@products = ProductPresenter.wrap(Product.sorted_by_name)
end
end
Escrevendo testes
Escrever testes para presenters é muito simples. No caso do RSpec, basta criar o diretório spec/presenters
. Aquele nosso presenter pode ter testes como este:
require "rails_helper"
describe ProductPresenter do
let(:product) { double(name: "Some product") }
subject(:presenter) { described_class.new(product) }
it { expect(presenter.name).to eq("Some product") }
describe "#description" do
it "returns content" do
allow(product).to receive(:description).and_return("Some description")
expected = %[<p class="description">Some description</p>]
expect(presenter.description).to eq(expected)
end
it "returns no message" do
expect(presenter.description).to be_blank
end
end
describe "#checkout_partial" do
it "returns partial for paid products" do
allow(product).to receive(:paid?).and_return(true)
expect(subject.checkout_partial).to eq("order")
end
it "returns partial for free products" do
allow(product).to receive(:paid?).and_return(false)
expect(subject.checkout_partial).to eq("download")
end
end
end
Finalizando
Você deve ter percebido que presenters permitem tornar suas views muito mais simples. Além disso, eles tem a vantagem de serem fáceis de testar.
A coisa mais difícil dos presenters é se acostumar com eles. Mas depois que você se acostuma, dificilmente terá uma view complicada e também nem vai querer deixar de usá-los!
Lembre-se que você não precisa ter um mapeamento de uma classe para um presenter; Se você quiser, pode criar um presenter que envelopa mais de um objeto, simplificando o modo como sua view interage com os objetos. Para mim, isso faz todo o sentido.