Rails: the quest to dumb views and skinny controllers
(como cualquier chapiadora)
Viktor Justo Vasquez
You can stalk me out on
twitter/github/instagram
@vjustov
blog.vjustov.me
It's a mee.
Acknowledgments
Disclaimer: This is a very opinionated talk from someone who knows nothing at all.
The problem
- The default architecture for Ruby on Rails, Model View Controller, can begin to break down as Controllers become bloated and logic begins to creep into the views.
Shudder.
class ShoppersController < ApplicationController
def create
@shopper = Shopper.new(shopper_params)
authorize @shopper
@shopper = @shopper.decorate
respond_to do |format|
if @shopper.save
unless params[:timetable_shopper_id].blank?
# Only link with TimetableShopper if needed.
timetable_shopper = TimetableShopper.find(params[:timetable_shopper_id])
timetable_shopper.shopper_id = @shopper.id
timetable_shopper.save
end
flash.now[:notice] = "Shopper #{@shopper.name} was successfully created."
format.html {
redirect_to(
edit_shopper_path(@shopper),
notice: "Shopper #{@shopper.name} was successfully created."
)}
else
timetable_shoppers = TimetableShopper.all
@timetable_shoppers = timetable_shoppers.decorate
format.html { render action: 'new' }
end
end
end
end
And now Shiver.
/ app/views/users/_form.html.haml
= form_for @user do |f|
- if @user.errors.any?
= render "shared/form_errors", object: @user
.field-box
.col-xs-4.col-lg-4.col-md-3.col-sm-3
= f.hidden_field :external_avatar_url
- if @user.new_record?
#avatar-box.upload-instructions.dashed_border
- else
#avatar-box
= image_tag(@user.avatar.form_thumbnail.url)
.col-xs-6
= f.text_field :email
.field-box
= f.label :role, {class: "required-field"}
.col-md-5
.ui-select
= collection_select @user, :role_ids, Role.all, :id, :name,
{ checked: @user.role_ids.first,
include_blank: "Please select" },
{ disabled: !policy(@user).can_change_role? }
Pfft, Noob, Wouldn't helper methods solve that?
(In case you, the spectator, didn't know)
Helpers are usually generic functions that assist in providing some functionality to whichever objects it is "helping". Nothing too fancy here. Due to the fact that we are talking about views, these methods generally handle some sort of data representation.
module BlogsHelper
def display_title_for(blog)
"#{blog.name} - The Blog!"
end
end
- Helpers aren’t objects.
- They are included at the view with "rails magic", causing them to not have a explicit receiver, which doesn't tell you where that code might be located.
- Their responsibilities tend to be a little bit of everything (rendering html, perform certain validation on the the model, etc, etc...).
- It also hurts maintainability. It adds to the temptation that it's OK to put code in a helper that shouldn’t otherwise be there.
The reasons are:
# someapp/app/views/something.html.erb
# ...
<% people_without_access_from(company).each do |person| %>
<%= add_person_to_project_check_box(person, company) %>
<% end %>
# ...
class SomethingHelper
def add_person_to_project_check_box(person, company)
content_tag(:label,
check_box_tag("people_ids[]", person.id, false,
{ :class => "company_#{company.id}_person" }) +
" " + person.name
) + tag(:br)
end
end
Yuck.[3]
class ProductDecorator < Draper::Decorator
# ...
def validation_ranged_regular
regular_ranged = h.content_tag(:label, "Regular R: ")
if object[:ranged_regular_from]
regular_ranged << h.number_to_currency(object[:ranged_regular_from])
if object[:ranged_regular_to] do
regular_ranged << " - " << h.number_to_currency(object[:ranged_regular_to])
end
end
html = "" << h.content_tag(:span, regular_ranged.html_safe, class: "first-price-value")
special_ranged = h.content_tag(:label, "Special R: ")
if object[:ranged_special_from]
special_ranged << h.number_to_currency(object[:ranged_special_from])
if object[:ranged_special_to] do
special_ranged << " - " + h.number_to_currency(object[:ranged_special_to])
end
end
html << h.content_tag(:span, special_ranged.html_safe, class: "first-price-value")
html.html_safe
end
# ...
end
...
Decorators
The solution
Decorator Pattern
- The decorator pattern is a design pattern that allows behavior to be added to an individual object, without affecting the behavior of other objects from the same class.[4]
- Decorator is a design pattern that is used to extend the functionality of specific object by wrapping it, without effecting other instances of that object.[5]
class NiñitaPerfecta
def ingredients
['Azucar', 'Flores', 'Muchos Colores']
end
end
class ChicasSuperpoderosas < SimpleDelegator
def ingredients
super << 'Sustancia X'
end
end
class Burger
def price
100
end
end
class BaconCheeseBurger
def initialize(burger)
@burger = burger
end
def price
@burger.price + 30
end
end
class DobleCarneBurger
def initialize(burger)
@burger = burger
end
def price
@burger.price + 50
end
end
DobleCarneBurger.new(
BaconCheeseBurger.new(
Burger.new
)
).price
=> 180
How does a decorator solve our problem?
Come the Presenter
- So, Presenters are just decorators.
- The main goal of presenters is to keep logic out of the view.
- Its secondary goal is to keep related functionality, which would have previously existed in helpers, in close proximity to the relevant objects. Presenters maintain an object-oriented approach to logic in the view.
- If you have conditionals in your views, you'll likely benefit greatly from moving that logic to a presenter. [6]
They TURN your controllers from this:
class ProductsController < ApplicationController
def new
@page = Page.find(params[:page_id])
@product = @page.products.new(crop_params).decorate
@brands = Brand.active.pluck(:name, :id)
@units = Unit.active.pluck(:name, :id)
end
end
Into this:
class ProductsController < ApplicationController
def new
@presenter = NewProductPresenter.new(products_params)
end
end
- The Presenter pattern creates a class representation of the state of the view.
- A Presenter can encapsulate aggregating data from various objects and leave the controller with more focused responsibilities.
- This also allows for testing of aggregation or calculation behavior without a dependency on setting up a Controller to a testable state.
class CarsController < ApplicationController
def show
car = Car.find(params[:id])
dealer = Dealer.find(params[:dealer_id])
@car = ViewCarPresenter.new(car, dealer)
end
end
class ViewCarPresenter
def initialize(car, dealer)
@car, @dealer = car, dealer
end
def availability
count = dealer.cars_in_stock.select { |c| c == car }.count
"There are #{count} of this car."
end
def price_description
if price > 500_000
"Expensive piece of metal this one is!!"
else
"Damn it's was cheap!!"
end
end
end
#car-view
.availability
= @car.availability
.comments
= @car.price_description
describe ViewCarPresenter do
let(:car) { double(car, price: 2000) }
let(:dealer) { double('dealer', cars_in_stock: [car]) }
let(:presenter) { ViewCarPresenter.new(car ,dealer) }
it 'calculates the availability' do
expect(presenter.availability).to eql('There are 1 of this car.')
end
it "renders the price description" do
expect(presenter.price_description).to eql("Damn it's was cheap!!")
end
end
Enter the Exhibits
-
Wraps a single model instance.
-
Encapsulates decisions about how to render an object. The tell-tale of an Exhibit is telling an object "render yourself", rather than explicitly rendering a template and passing the object in as an argument.
-
Brings together a model and a context. Exhibits need a reference to a "context" object—either a controller or a view context—in order to be able to render templates as well as construct URLs for the object or related resources.
class ReadingListExhibit < DisplayCase::Exhibit
def self.applicable_to?(object)
object_is_any_of?(object, 'ReadingList')
end
def to_json(options={})
output = "["
books[0..-2].each do |book|
output << " { \"book\": #{book.to_json}"
output << " },\n"
end
output << " book: { #{books[-1].to_json} } "
output << "]"
end
def to_csv(options={})
output = ""
books.each do |book|
output << "\"#{book.title}\", \"#{book.author}\", \"#{book.publisher}\" \n"
end
output
end
def render_navigation(template)
template.render(
:partial => "shared/reading_list_navigation",
:locals => {
:selected_reading_list => self.name,
:other_reading_lists => ReadingList.without_current(__getobj__)
}
)
end
def render_books(template)
template.render(:partial => "shared/book", :collection => self.books)
end
def render_overview(template)
template.render(:partial => 'shared/reading_list', :object => __getobj__)
end
end
class BookExhibit < DisplayCase::Exhibit
def self.applicable_to?(object)
Rails.logger.debug("app/exhibiit #{object}")
object_is_any_of?(object, 'Book')
end
def render_book(template)
Rails.logger.info(template.class)
template.render(
partial: 'shared/book',
locals: { :book => __getobj__})
end
end
<div class="row-fluid">
<div class="span8">
<% @books.each do |book| %>
<%= book.render_book(self) %>
<% end %>
</div>
<div class="span2 well">
Suggested Reading Lists:
<% @reading_lists.each do |e| %>
<%= e.render_overview(self) %>
<% end %>
</div>
</div>
class ReadingListsController < ...
respond_to :html
def index
@books = exhibit(Book.all)
@reading_lists = exhibit(ReadingList.all)
respond_with(@reading_lists, @books)
end
end
View
Model
Presenters
Exhibits
View-model spectrum
Let's merge 'em together: Presenter + Exhibits
Presenters and exhibits are not mutually exclusive.
We can combine presenters and exhibits to take advantage of both. We use presenters to deal with any view-related logic. We use exhibits to manage rendering the objects.
class ShowAllReadingListsPresenter
attr_reader :books, :reading_list
def initialize(books, reading_list)
@books = BooksExhibit.new(books)
@reading_list = ReadingListExhibit.new(reading_list)
end
end
<div class="row-fluid">
<div class="span8">
<% @presenter.books.each do |book| %>
<%= book.render_book(self) %>
<% end %>
</div>
<div class="span2 well">
Suggested Reading Lists:
<% @presenter.reading_lists.each do |e| %>
<%= e.render_overview(self) %>
<% end %>
</div>
</div>
class ReadingListsController
respond_to :html
def index
@presenter = ViewAllReadingList.new(
Books.all,
ReadingList.all,
self
)
end
end
So, what about what i read on the internet this morning?
"Classic" presenters are oriented towards a particular view; they are, in Jay Fields' words, "class versions of your views". They have names like ReportPresenter or OrderCompletionPresenter.
In contrast, this second generation of presenters are oriented primarily towards specific models. They have names like UserPresenter or PicturePostPresenter. They enable a particular model instance to render itself to a page.
- Regarding decorators, I personally prefer to refer to them as such only when we are using them in a general way, with no particular interest on views or anything.
Word of warning
Sources
- http://nicksda.apotomo.de/2011/10/rails-misapprehensions-helpers-are-shit/
- http://blog.steveklabnik.com/posts/2011-09-09-better-ruby-presenters
- https://signalvnoise.com/posts/1108-what-belongs-in-a-helper-method
- http://en.wikipedia.org/wiki/Decorator_pattern
- http://stackoverflow.com/a/16237911/2525603
- http://mikepackdev.com/blog_posts/31-exhibit-vs-presenter
- http://blog.jayfields.com/2007/03/rails-presenter-pattern.html
- http://objectsonrails.com/#ID-2656c30c-080a-4a4e-a53e-4fbaad39c262
> file.EOF?
=> TRUE
Decorators and its flavors.
By Viktor Ml. Justo Vasquez
Decorators and its flavors.
- 1,251