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.
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.
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.
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.
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.
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
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
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.
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?
Check out our newsletter