Skip to content

Instantly share code, notes, and snippets.

@indiesquidge
Created April 21, 2015 01:40
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save indiesquidge/c47ea20bc6071d462e47 to your computer and use it in GitHub Desktop.
Save indiesquidge/c47ea20bc6071d462e47 to your computer and use it in GitHub Desktop.
Intro to Presenters

Presenters & Decorators

Decorators

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

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.

Presenter Example

Clone the repo

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.

Fix minor side issue

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

Creating a presenter for the Account model

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.

TDD

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.

Implementing the presenter

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.

Presenter Graphic

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment