Object-Oriented Views in Rails

yuliagaranok

Yulia Garanok

Posted on August 17, 2020

Object-Oriented Views in Rails

Views can be organized in Rails applications with many different user roles. This is the story of one refactoring.

Story

We were building a CRM app responsible for collecting orders from e-commerce sites across the internet. The system had 5 different user roles, including admins, shipping managers, and call operators.

Each order could be marked as one of 8 statuses. When an order came to our system, its status was “new.” The order was “confirmed” when the call operator contacted the customer and was “shipped” when the shipping manager sent the items to the customer.

A lot of data was attached to each order.

class Order < ActiveRecord::Base
  has_one :customer_information
  has_one :clarification
  has_one :cart
  has_one :shipping
  # and more
end
Enter fullscreen mode Exit fullscreen mode

It had 8 separate components with information from the customer or added by users. With 5 user roles, 8 order statuses, and 8 order components, we had to display orders to each user role differently. Imagine:

.order
  .order__header
    .order_id = order.id
    - if current_user.admin?
      .order__state = render('orders/editable_state_field')
    - elsif current_user.call_operator?
      .order__state = order.state
      .order__created-at = order.created_at
      .order__confirmed-at = order.confirmed_at
    - elsif current_user.shipping_manager?
      .order__state = order.state
      .order__shipped-at = order.shipped_at
  .order__body
    / }:]
Enter fullscreen mode Exit fullscreen mode

Specific roles were allowed to see specific order components. The shipping manager, for example, shouldn’t see the call log from the call operator. The call operator should see the call log, but only an administrator had the ability (and a button in the UI) to listen to each call.


First solution

Our first solution was to create a separate namespaced controller for each role.

class Admin::OrdersController < ApplicationController
  include Orders::CommonActions
  include Orders::EditActions
end

class CallOperator::OrdersController < ApplicationController
  include Orders::CommonActions
end

class ShippingManager::OrdersController < ApplicationController
  include Orders::CommonActions
end
Enter fullscreen mode Exit fullscreen mode

In this case, we could write separate templates for each role:

app/views/admin/orders/index.html.slim,
app/views/call_operator/orders/index.html.slim, etc.

We duplicated a lot of the common template code, but we could code unique parts without including tons of “if” statements. Duplications are better than untraceable conditions, but they are still not perfect.

We were constantly looking for possible solutions.

Rails don’t help much here

There are not many tools to work with templates in Rails. We can use helpers to remove complex logic from our templates. Partials help us reduce duplication.

Helpers are hard to use since they are all in the global namespace. So you may end up with name collisions. We only have 27 helper methods in the project:

Partials are very useful, but they didn’t help in our case. Even with partials, we had to write a lot of conditions.

There are even more options to deal with view complexity, including decorators, view model, and more.

Using decorated models in templates is almost always a bad idea. Developers often add HTML generation code to decorator methods, but it doesn’t make the code clearer. Sometimes it’s hard to choose where to put a particular method: in the decorator or in the model. There are no clear boundaries.

The view model pattern looks really good, but we didn’t have a chance to try it with real code. We’re not sure it could help us avoid conditionals in the views.

View ≠ Template

One day we discovered cells: a library for Rails views. This brings a new approach to writing views. What Rails offers us as views and what we put in the app/views folder aren’t really views but templates. Views are not equal to templates. Cells give us a way to write real views.

Let’s define a view as an object with the public method “show,” which returns a string — html — result of “rendering” this view.

class Order::Cell < Cell::Concept
  def show
    render("show.slim")
  end
end
Enter fullscreen mode Exit fullscreen mode

The #render method is provided by the cells library. It renders a slim file located in the “views” folder, which is funny because it should really be called “templates”:

app
`--concepts
   `--order
      |--views
      |  `--show.slim
      `--cell.rb
Enter fullscreen mode Exit fullscreen mode

You can initialize the view with the objects you want:

class Order:Cell < Cell::Concept
  attr_reader :order

  def initialize(order)
    @order = order
  end
end
Enter fullscreen mode Exit fullscreen mode

In templates, we can call methods of our view:

time datetime=#{date}
  = formatted_date

class Order::Cell < Cell::Concept
  private

  def date
    order.created_at
  end

  def formatted_date
    date.strftime('%F, %H:%M')
  end
end
Enter fullscreen mode Exit fullscreen mode

Here’s the most important part. Since “views” is just a Ruby object, we know how to deal with it. We have a lot of experience and a powerful arsenal of patterns and OOP best practices.

One of the most powerful of these patterns is inheritance. Since “views” is an OOP class, we can override some methods or HTML templates for child views.

How views help

Let’s use an example from our project. We had to show each user different things for each order status. So we moved this template into “views” and split it into smaller templates.

--order
  |--views
  |  |--partials
  |  |  `--status_history.slim
  |  |  `--calls_history.slim
  |  |  `--cart.slim
  |  |  `--contact_information.slim
  |  |  `--shipping.slim
  |  `--show.slim
  `--cell.rb
Enter fullscreen mode Exit fullscreen mode
/ show.slim

= render(‘partials/status_history.slim’)
= render(‘partials/calls_history.slim’)
= render(‘partials/contact_information.slim’)
= render(‘partials/cart.slim’)
= render(‘partials/shipping.slim’)
Enter fullscreen mode Exit fullscreen mode

Imagine that the admin can see the full history of status changes but the call operator can only see a few changes. For example, the call operator doesn’t care when the order moved from “shipped” to “delivered”.

/ status_history.slim

- transactions.each do |transaction|
  p #{transaction.from} → #{transaction.to} 
Enter fullscreen mode Exit fullscreen mode

We created a separate view for the call operator inherited it from the order view:

--call_operator
  `--order
     `--cell.rb
Enter fullscreen mode Exit fullscreen mode
class CallOperator::Order::Cell < Order::Cell
  def transactions
    order.transactions.select do |transaction|
      # ...
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Notice that we did not rewrite the templates; we inherited them from the parent view.

Since we can inherit templates, we can also redefine them, which is a very convenient feature. For example, the call operator can change the shipping address for an order.

/ order/views/partials/shipping.slim

p = shipping_address.address_line_1

/ call_operator/order/views/partials/shipping.slim

= form_for shipping_address do |f|
  = f.input :address_line_1
Enter fullscreen mode Exit fullscreen mode

Let’s make it together

To render the correct view, we can write a simple factory method .build:

class Order::Cell < Cell::Concept
  def self.build(order, current_user)
    selected_view_for(order, current_user).new(order)
  end

  def self.selected_view_for(order, current_user)
    if current_user.call_operator?
      {
        “new” => CallOperator::Order::Cell,
        “shipping” => CallOperator::ShippingOrder::Cell
      }[order.state]
    elsif current_user.admin?
      {
        “new” => Admin::Order::Cell,
        “shipping” => Admin::ShippingOrder::Cell
      }[order.state]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

So in our Rails templates, we can write something like this:

/ app/views/order/show.slim

= Oder::Cell.build(order, current_user).show
Enter fullscreen mode Exit fullscreen mode

Cells

To introduce the concept of views and describe how they are different from templates, we didn’t use all the available APIs in the cells library. You can read more about cells on the official site.

The cells library allows us to encapsulate parts of the UI into views. You can work with views as simple Ruby classes, which gives you more than a template rendering. Views enable OOP: you can make them polymorphic and use well-known OOP patterns.

Cells help you build views. Some base views may also be reusable and transferable between projects, which is a different topic.

Other solutions

There are some other similar solutions that can help us write real views, but unfortunately, we haven’t investigated them yet since they are pretty new and we didn’t know about them two years ago. They’re worth looking at, though:

1. [Hanami views](https://github.com/hanami/view)
2. [Dry views](https://github.com/dry-rb/dry-view)
3. [React components](https://reactjs.org/docs/components-and-props.html)
Enter fullscreen mode Exit fullscreen mode

How do you deal with views complexity?

Object-Oriented Views in Rails was originally written by the CTO of datarockets Dmitry Zhlobo and lead developer Roman Dubrovsky for datarockets' blog.

💖 💪 🙅 🚩
yuliagaranok
Yulia Garanok

Posted on August 17, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related