Decorators are used to add new functionality to an already existing object by "wrapping" it with new methods, without effecting other instances of that object. Thus, decorators are a perfect example of the Open/closed principle, in which an object is "open for extension" but "closed for modification"; you should be able to add new behavior to application without changing it's underlying source code. If any of this is a bit hard to swallow, I would recommend reading the article linked to above, reading the docs on Jeff's Draper gem, or simply thinking of decorators as "an interfeace between a model and a controller", which is an over-simplification, but a perfectly fine place to start.
Presenters are a subsection of decorators. They give you an object oriented way to write view helpers. For the Layman, all that means is that presenters are a type of decorator specifically used for view functionality. When your model gets bloated with methods that are only used in views, presenters are a great way to abstract some of that logic.
Let's start with configuring a presenter for an exisiting code base. Clone/fork this repo, provided initially by our beloved Jorge. Bundle and create the database while you're at it.
git clone git@github.com:turingschool-examples/urbank.git presenters-and-decorators;
cd presenters-and-decorators;
bundle;
rake db:create db:migrate;
rails s
Hop onto your browser and play around in the app for a minute. It's fairly basic.
One small thing before we move forward. The repo has a mismatch between the
(unused) transfers.yml
fixture and the column names for the transfers
table.
There is currently a
pull request to fix
the issue, but honestly it is easier to just delete the fixtures folder since
it won't be used in this tutorial anyway.
rm -rf test/fixtures
Take a look at the app/views/accounts/show.html.erb
file, noting the instance
variable @account
we reference a few times here.
<div class='container-fluid account'>
<div class='col-md-2'></div>
<div class='col-md-8'>
<div class='info'>
<div class='col-md-12'>
<h1>Account</h1>
</div>
<div class='col-md-12'>
<h5>Owner</h5>
<p><%= @account.user.name %></p>
</div>
<div class='col-md-4'>
<h5>Funds</h5>
<p><%= @account.amount %></p>
</div>
<div class='col-md-8'>
<h5>Created On</h5>
<p><%= @account.created_at %></p>
</div>
<div class='col-md-12'>
<p> <%= link_to 'Transfer Funds', new_transfer_path, class: 'btn btn-default' %> </p>
</div>
</div>
</div>
<div class='col-md-2'></div>
</div>
Let's go check out where that instance variablie is being defined in the
matching controller, app/controllers/accounts_controller.rb
class AccountsController < ApplicationController
def show
@account = current_user.account
end
end
Looks like we are invoking the Account instance that is a associated with our
currently logged in user. Perhaps we can wrap this up to add some extra
functionality the the @account
instance that we are calling methods on in the
view.
Before you get too excited, let's write some new tests. There isn't a current test suite on the app *cough* Jorge doesn't really believe in TDD *cough*, but since we are changing a code base that works, we want to make sure our refactoring doesn't break anything.
Inside of the test
folder, create a new folder called presenters
mkdir test/presenters
And inside of that folder, create a new test file, account_presenter_test.rb
touch test/presenters/account_presenter_test.rb
There isn't really a convention to follow when naming presenter test files, but it's always a safe bet to simply name it after the model you are adding to.
Open the file in your editor and paste the following code
require "test_helper"
class AccountPresenterTest < ActiveSupport::TestCase
test "it has an owner" do
user = User.create(name: "Ricardo")
account = Account.create(amount: 500, user_id: user.id)
account_presenter = AccountPresenter.new(account)
assert_equal "Ricardo", account_presenter.owner
end
end
This test is pretty trivial, but it will ensure that our wiring is correct.
We want to create a "middleman" (presenter) between the user and their account.
The test creates, a new user with a name, creates a new account that is linked
to the user with an amount of 500, and creates an AccountPresenter that accepts
an account instance as it's only parameter. Note that this class does not yet
exist, it is what we aim to create as our presenter. We then assert that the
account presenter will have a method called owner
, which will return the name
of the user who was attached to our account parameter.
The test is written, now the easy part; just follow the error messages it gives.
rake test
Our output from the test is
NameError: uninitialized constant AccountPresenterTest::AccountPresenter
Easy enough. Let's create the AccountPresenter class in a presenters
directory.
mkdir app/presenters;
touch app/presenters/account_presenter.rb
Open the file and create the class
class AccountPresenter
end
Running the tests gives a different error
ArgumentError: wrong number of arguments (1 for 0)
Back to our account_presenter.rb
file
class AccountPresenter
def initialize(account)
end
end
Test output:
NoMethodError: undefined method `owner'
account_presenter.rb
class AccountPresenter
def initialize(account)
end
def owner
end
end
Test output:
Expected: "Ricardo"
Actual: nil
Ah, a failure instead of an error! Of course we all see the quick and dirty way
to cheat this test; just put "Ricardo" inside the owner
method and the test
will be green. No one likes a static web app though, so let's not be lame and
actually make our methods dynamic.
But how do we get back the owner? Well, the same way we captured it in our view.
account_presenter.rb
class AccountPresenter
def attr_reader :account
def initialize(account)
@account = account
end
def owner
account.user.name
end
end
Since an Account
instance is what is passed in as a parameter, we can set that
as an instance variable on our AccountPresenter
and have access to it in our
other methods. Then, the same way we called @account.user.name
in our view,
we can now call it here. Wunderbar!
With that in place, our test should now be passing. We can now use this
presenter to serve up methods to the accounts/show.html.erb
view.
First, let's create a new instance of our presenter in our controller.
class AccountsController < ApplicationController
def show
@account = AccountPresenter.new(current_user.account)
end
end
This will give us access to our owner
method in the view.
<div class='col-md-12'>
<h5>Owner</h5>
<p><%= @account.owner %></p>
</div>
Fire up the app again and...it's broken? What is this?!
undefined method `amount'`
Hmmm. So it looks like when we changed our @account
variable to a new instance
of AccountPresenter
, we lost the methods that were previously able to be
called on our Account
instance. To fix this we can just create a wrapper
around those methods inside of our AccountPresenter
class.
class AccountPresenter
def attr_reader :account
def initialize(account)
@account = account
end
def owner
account.user.name
end
def amount
account.amount
end
def created_at
account.created_at
end
end
Jumping back to the browser and giving it a refresh, it looks like everything is working again, whew. You may be thinking to yourself, I don't know about this presenter nonsense, it looks like all we did was write more code, and you're perfectly correct! Let's refactor our presenter to be a bit more...presentable? ...ba dum tss...
By the looks of our current app/presenters/account_presenter.rb
file, it would
be nice to not have to wrap all methods that already exist on the Account
model. Because Ruby is so awesome, it comes with a module called
Forwardable
to do just that.
According to the docs, to use Forwardable
, we need to first extend our class
to include the module.
class AccountPresenter
extend Forwardable
def attr_reader :account
def initialize(account)
@account = account
end
def owner
account.user.name
end
def amount
account.amount
end
def created_at
account.created_at
end
end
Now we have access to Forwardable
's methods, particularly def_delegators
,
which is designed to allow us to define multiple delegator methods from a single
accessor. Let's see how to do that.
class AccountPresenter
extend Forwardable
def attr_reader :account
def_delegators :account, :amount, :created_at
def initialize(account)
@account = account
end
def owner
account.user.name
end
end
With our def_delegators
method in place, we can pass it our "accessor",
(:account)
, and any methods we want to delegate over (:amount, :created_at
),
which allows us to remove the wrapper methods we previously had defined.
Deleting a net five lines of code is never bad, not to mention the clarity our
class gains by only defining the extended methods we wanted to add (owner
).
As has been stated in this post a few times, presenters are simply meant to extend the functionality of an already existing class, specifically for methods used in the view.
In our case, we took our existing Account
class and extended it to include
another method, :owner
, from the AccountPresenter
class. Not the craziest
example for what presenters can do, but it's a good introduction, and widens
your knowledge of how to abstract methods away as your models grow in size.