Yulia Garanok
Posted on August 17, 2020
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
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
/ }:]
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
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
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:
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)
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.
Posted on August 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.