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.