Object-Oriented Views in Rails

Lift off

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

We were building a CRM app responsible for collecting orders from e-commerce sites across the internet.

The system has 5 different user roles, including admins, shipping managers, and call operators.

Each order can be marked as one of 8 statuses. When an order comes to our system, its status is “new.” The order is “confirmed” when the call operator contacts the customer and is “shipped” when the shipping manager sends the items to the customer.

Object-Oriented Views Rails

A lot of data is attached to each order.

class Order < ActiveRecord::Base
  has_one :customer_information
  has_one :clarification
  has_one :cart
  has_one :shipping
  # and more
end

It has 8 separate components with information from the customer or added by users.

Now, with 5 user roles, 8 order statuses, and 8 order components, we have 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
    / }:]

Specific roles are 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 has the ability (and a button in the UI) to listen to each call.

Object-Oriented Views components Rails
Object-Oriented Views components in Rails

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

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 doesn’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:

Rails view

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

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

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

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

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
/ 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’)

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} 

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

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

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

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

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

/ app/views/order/show.slim

= Oder::Cell.build(order, current_user).show

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:

How do you deal with views complexity?

Dima Zhlobo

Dima Zhlobo

CTO at datarockets

Roman Dubrovsky

Roman Dubrovsky

Lead developer at datarockets

From our blog

Stay up to date

Check out our newsletter

© 2024 Red Panda Technology (dba datarockets). All Rights Reserved.