In my last post I suggested using draper for you decorator needs, I've recently come to believe that advice is only half-right. There's really no valid reason to use draper in most use cases, if you can show me one I'd love to hear it.
What Does Draper Offer That POROs Don't?
IMO, within most generic apps, nothing! You can implement all of the core features of Draper very easily using plain ruby without all of the bloat or context manipulation.
Here's an example from the previous blog post
# app/decorators/zombie_decorator.rb
class ZombieDecorator < Draper::Decorator
def health_status
if object.is_decapitated?
"Killed at #{decapitated_at}"
else
"Undead"
end
end
def decapitated_at
object.decapitated_at.strftime("%A, %B %e")
end
end
Here's how that looks in ruby
# app/presenters/zombie_presenter.rb
class ZombiePresenter
def initialize(zombie)
@zombie = zombie
end
def health_status
if zombie.is_decapitated?
"Killed at #{decapitated_at}"
else
"Undead"
end
end
def decapitated_at
zombie.decapitated_at.strftime("%A, %B %e")
end
attr_reader :zombie
end
It is a little longer; however, we do not require Draper as a dependency meaning debugging is easier and there is less magic.
Other Issues With Draper
Draper also gently encourages you to make bad decisions. On numerous occasions I've wasted time wondering why @post.published_at
or @user.activated_at
returns a formatted date or maybe even an html element. It's very easy to overlook the fact that the attribute has been modified in one of your decorators when the object you are working with is named @post
or @user
.
Draper encourages you to instantiate your objects using their original naming schemes i.e. @book = Book.first.decorate
which, not only confuses the namespace; it also makes it difficult to search the codebase for occurrences of the non-decorated vs decorated instances, and makes it impossible to know which object you are looking at when you open a view file without also having to open the controller.
Draper also works some magic to make Rails accept the non-model objects in the forms and links and forces you to delegate calls to the original object for pagination gems such as kaminari. For simple presenter objects, Draper is very bloated, there's really no need for most of these methods in a generic presenter.
How I Create PORO Presenters
In my last example I didn't use the view context, you don't always need to use it; however, many presenters will use the view context to utilize view methods. I like to implement a base_presenter to handle this.
# app/presenters/base_presenter.rb
class BasePresenter
def initialize(view)
view.class_eval { include Haml::Helpers }
view.init_haml_helpers
@view = view
end
attr_reader :view
end
Note: If you are using haml then you will want to initialize the haml helpers, otherwise you can just instantiate the view.
Now we have the base class we can use our view helpers from within our presenters by calling super and passing in the view_context object.
# app/presenters/zombie_presenter.rb
class ZombiePresenter < BasePresenter
def initialize(zombie, view)
super view
@zombie = zombie
end
def health_icon
view.content_tag(:span, health_status, class: 'health_icon')
end
def zombie_menu
view.content_tag(:ul) do
concat(view.content_tag(:li, health_status))
concat view.content_tag(:li) do
if zombie.is_decaptiated?
view.link_to('Drag to zombie testing lab', view.zombie_lab_path)
else
view.link_to('View health stats', view.zombie_health_stats_path)
end
end
end
end
def health_status
if zombie.is_decapitated?
"Killed at #{decapitated_at}"
else
"Undead"
end
end
def decapitated_at
zombie.decapitated_at.strftime("%A, %B %e")
end
attr_reader :zombie
end
Now we can instantiate this in the controller as a separate object to the non-modified instance which we can call in the view.
# app/controllers/zombies_controller.rb
class zombies_controller < app_controller.rb
def show
@zombie = Zombie.find(params[:id])
@zombie_presenter = ZombiePresenter.new(@zombie, view_context)
end
end
With this implementation we can easily see which object we are referencing in the view without opening the controller file.
We can search the codebase for occurrences of either object.
We don't need to use magic to force Rails to play nicely with modified instances.
EDIT: As asked, adding examples for collections
zombies = Zombie.where(dead: true)
@presented_zombies = zombies.map do |zombie|
ZombiePresenter.new(zombie, view_context)
end
Or
# app/presenters/zombies_presenter.rb
class ZombiesPresenter
attr_reader :zombies
def initialize(zombies, view)
@zombies = zombies.map do |zombie|
ZombiePresenter.new(zombie, view_context)
end
end
def all_walkers?
zombies.all?(&:walker?)
end
def last_page?
some_pagination_logic
end
end
# controller action
zombies = Zombie.where(walkers: true)
@presented_zombies = ZombiesPresenter.new(zombies, view_context)