Cells
- Last updated 14 Dec 22
Out of the frustration with Rails’ view layer, its lack of encapsulation and the convoluted code resulting from partials and helpers both accessing global state, the Cells gem emerged.
The cells gem is completely stand-alone and can be used without Trailblazer.
A cell is an object that represent a fragment of your UI. The scope of that fragment is up to you: it can embrace an entire page, a single comment container in a thread or just an avatar image link.
In other words: A cell is an object that can render a template.
Overview
Cells are faster than ActionView. While exposing a better performance, you step-wise encapsulate fragments into cell widgets and enforce interfaces.
View Model
Think of cells, or view models, as small Rails controllers, but without any HTTP coupling. Cells embrace all presentation and rendering logic to present a fragment of the UI.
Rendering a Cell
Cells can be rendered anywhere in your application. Mostly, you want to use them in controller views or actions to replace a complex helper/partial mess.
- # app/views/comments/index.html.haml
%h1 Comments
@comments.each do |comment|
= concept("comment/cell", comment) #=> Comment::Cell.new(comment).show
This will instantiate and invoke the Comment::Cell
for each comment. It is completely up to the cell how to return the necessary markup.
Cell Class
Following the Trailblazer convention, the Comment::Cell
sits in app/concepts/comment/cell.rb
.
class Comment::Cell < Cell::ViewModel
def show
"This: #{model.inspect}"
end
end
Per default, the #show
method of a cell is called when it is invoked from a view. This method is responsible to compile the HTML (or whatever else you want to present) that is returned and displayed.
Whatever you pass into the cell via the concept
helper will be available as the cell’s #model
.
Whatever you return from the show
method will be displayed in the page invoking the cell.
= concept("comment/cell", comment) #=> "This: <Comment body=\"MVC!\">"
Note that you can pass anything into a cell. This can be an ActiveRecord model, a PORO or an array of attachments. The cell provides access to it via model
and it’s your job do use it correctly.
Cell Views
While we already have a cleaner interface as compared to helpers accessing to global state, the real power of Cells comes when rendering views. This, again, is similar to controllers.
class Comment::Cell < Cell::ViewModel
def show
render # renders app/concepts/comment/views/show.haml
end
end
Using #render
without any arguments will parse and interpolate the app/concepts/comment/views/show.haml
template. Note that you’re free to use ERB, Haml, or Slim.
- # app/concepts/comment/views/show.haml
%li
= model.body
By
= link_to model.author.email, author_path(model.author)
That’s right, you can use Rails helpers in cell views.
No Helpers
While you could reference model
throughout your view and strongly couple view and model, Cells makes it extremely simple to have logicless views and move presentation code to the cell instance itself.
- # app/concepts/comment/views/show.haml
%li
= body
By #{author_link}
Every method invoked in the view is called on the cell instance. This means we have to implement #body
and #author_link
in the cell class. Note that how that completely replaces helpers with clean object-oriented methods.
class Comment::Cell < Cell::ViewModel
def show
render
end
private
def body
model.body
end
def author_link
link_to model.author.email, author_path(model.author)
end
end
What were global helper functions are now instance methods. All Rails helpers like link_to
are available on the cell instance.
Properties
Delegating attributes to the model is so common it is built into Cells.
class Comment::Cell < Cell::ViewModel
property :body
property :author
def show
render
end
private
def author_link
link_to author.email, author_path(author)
end
end
The ::property
declaration will create a delegating method for you.
Testing
The best part about Cells is: you can test them in isolation. If they work in a test, they will work just anywhere.
describe Comment::Cell do
it do
html = concept("comment/cell", Comment.find(1)).()
expect(html).to have_css("h1")
end
end
The concept
helper will behave exactly like in a controller or view and allows you to write rock-solid test for view components with a very simple API.
More
Cells comes with a bunch of helpful features like nesting, caching, view inheritance, and more.
Getting Started
The Cells gem provides view models for Ruby web applications. View models are plain objects that represent a part of the web page, such as a dashboard widget. View models can also render views, and be nested.
Cells is a replacement for ActionView and used in many Rails projects. However, Cells can be used in any web framework such as Sinatra or Hanami.
This guide discusses how to get started with Trailblazer::Cell
, the canonical view model implementation following the Trailblazer file and naming structure. Don’t worry, Trailblazer::Cell
can be used without Trailblazer.
Refactoring Legacy Views
When refactoring legacy views into a solid cell architecture, it is often advisable to start with small fragments and extract markup and logic into an object-oriented cell. After that is done, you can move up and replace a bigger fragment of the view, and so on.
Given you were running an arbitrary Ruby web application, let’s assume you had a menu bar sitting in your global layout. The menu shows navigation links to pages and - dependent on the login status of the current user - either a miniature avatar of the latter or a link to sign in.
Since this is quite a bit of logic, it’s a good idea to encapsulate that into an object.
Here’s what the old legacy layout.html.haml
looks like.
%html
%head
%body
%nav.top-bar
%ul
%li SUPPORT CHAT
%li GUIDES
%li
- if signed_in?
%img{src: avatar_url}
- else
"SIGN IN"
<html>
<head></head>
<body>
<nav class="top-bar">
<ul>
<li>SUPPORT CHAT</li>
<li>GUIDES</li>
<li>
<% if signed_in? %>
<img src="<%= avatar_url %>">
<% else %>
SIGN IN
<% end %>
</li>
</ul>
</nav>
</body>
</html>
Of course, this navigation bar doesn’t really make sense without any links added. I’ve left that out so we can focus on the structure. We will discuss how helpers work in the per-framework sections below.
Extraction
In order to convert all markup below the <nav>
node into a cell, we first need to add the respective gems to our Gemfile
.
gem "trailblazer-cells"
gem "cells-hamlit"
For now, all you need is the trailblazer-cells
gem and the Cells template engine.
gem "trailblazer-cells"
gem "cells-hamlit"
gem "cells-rails"
In Rails, you need the trailblazer-cells
gem and the Cells template engine.
The cells-rails
gem will add cell invocation helpers to controllers and views, and supply the URL helpers in cells.
Please note that trailblazer-cells
simply loads the cells
gem and then adds some simple semantics on top of it. Also, Cells supports Haml, Hamlit, ERB, and Slim.
Cut the %nav
fragment from the original layout.html.haml
and replace it with the cell invocation.
%html
%head
%body
= Pro::Cell::Navigation.(nil, current_user: current_user).()
Cells can be invoked using the call style.
%html
%head
%body
= cell(Pro::Cell::Navigation, nil, current_user: current_user)
In Rails, you can use the handy cell
helper to invoke the view model.
Instead of keeping navigation view code in the layout, or rendering a partial, the Pro::Cell::Navigation
cell is now responsible to provide the HTML fragment representing the menu bar.
The cell is invoked without a model, but we pass in the current_user
as an option. This code assumes that the current_user
object is available in the layout view. As the current user is passed from your web framework to the cell, this is called an dependency injection.
We will learn what models and options are soon.
Navigation Cell
Having extracted the “partial” from the layout, paste it into a new file app/concepts/pro/view/navigation.haml
.
%nav.top-bar
%ul
%li SUPPORT CHAT
%li GUIDES
%li
- if signed_in?
%img{src: avatar_url}
- else
"SIGN IN"
Creating a view for the cell in the correct directory is one thing. A cell is more than just a partial, it also needs a class file where the logic sits. This class goes to app/concepts/pro/cell/navigation.rb
.
module Pro
module Cell
class Navigation < Trailblazer::Cell
include ::Cell::Hamlit
def signed_in?
options[:current_user]
end
def email
options[:current_user].email
end
def avatar_url
hexed = Digest::MD5.hexdigest(email)
"https://www.gravatar.com/avatar/#{hexed}?s=36"
end
end
end
end
The reason the cell class lives in the Pro
namespace is because that’s the example app’s name and its top-level namespace. Since the navigation cell is an app-wide concept, it is best put into the Pro
namespace.
Adding the class is enough to re-render your application, and you will see, the navigation menu now comes from a cell, presenting you with a hip gravatar icon when logged in, or a string to do so otherwise. Congratulations.
Discussion: Navigation
Let’s quickly discuss what happens here in what order. After this section, you will understand how cells work and probably already plan where else to use them. They’re really simple!
- Invoking the cell in the layout via
Pro::Cell::Navigation.(nil, current_user: current_user).()
will instantiate the cell object and internally invoke the cell’s default rendering method, namedshow
. This method is automatically provided and simply renders the corresponding view. - Since the cell class name is
Pro::Cell::Navigation
, this cell will render the viewconcepts/pro/view/navigation.haml
. This is following the Trailblazer naming style. - In the cell’s view, two “helpers” are called:
signed_in?
andavatar_url
. Whatsoever, the concept of a “helper” in Cells doesn’t exist anymore. Any method or variable called in the view must be an instance method on the cell itself. This is why the cell class defines those two methods, and not some arbitrary helper module. - Dependencies like the
current_user
have to get injected from the outer world when invoking the cell. Later, in the cell, those arguments can be accessed using theoptions
cell method.
It is important to understand that the cell has no access to global state. You as the cell author have to define the interface and the dependencies necessary to render the cell.
It is a good idea to write tests for you cell now, to document and assert this very interface you’ve just created.
Test: Navigation
Testing cells can either happen via full-stack integration tests, or with module cell unit tests. This example illustrates the latter.
A very basic test for a cell with signed in user could looks as follows.
class NavigationCellTest < Minitest::Spec
it "renders avatar when user provided" do
html = Pro::Cell::Navigation.(nil, current_user: User.find(1)).()
html.must_match "Signed in: nick@trb.to"
end
end
Likewise, you can easily test the anonymous user case, where no one’s logged in. The unit-test style makes it very easy to simulate that.
it "renders SIGN IN otherwise" do
html = Pro::Cell::Navigation.(nil, current_user: nil).()
html.must_match "SIGN IN"
end
Make sure to read the full documentation on testing cells for all kinds of environments, including Capybara matchers, Rails and test frameworks such as Minitest and Rspec.
URL helpers
Besides indicating the logged-in status of the use, the Navigation
cell should also display real links. This happens with URL helpers in web frameworks.
In app/concepts/pro/view/navigation.haml
, add actual links to the view.
%nav
%ul
%li SUPPORT CHAT
%a{href: "/support"}
%li GUIDES
%a{href: "/guides"}
%li
- if signed_in?
%img{src: avatar_url}
- else
"SIGN IN"
Without any URL system, you can create links using Haml, or delegate to your own URL objects.
%nav
%ul
%li
= link_to "SUPPORT CHAT", chat_path
%li
= link_to "GUIDES", guides_path
%li
- if signed_in?
%img{src: avatar_url}
- else
"SIGN IN"
The cells-rails
gem conveniently allows to use URL helpers in cell views.
Content Cell
View models are not only great to encapsulate small fragments, but also the entire content - which is everything but the wrapping layout. For example, when browsing to /comments/1
, the content view usually provided by a controller will now come from the Comment::Cell::Show
cell. We call this a content cell in Trailblazer-speak.
In the controller, invoke this new cell instead of letting the framework render the content.
def show
comment = Comment.find(1)
html = Comment::Cell::Show.(comment).()
end
class CommentsController < ApplicationController
def show
comment = Comment.find(1)
render html: cell(Comment::Cell::Show, comment)
end
end
This instructs the controller to render the Comment::Cell::Show
cell, pass the comment
into it, and let Rails wrap it in the application layout.
Note that we now pass the comment
as the cell’s model without passing any additional options.
The new class lives in app/concepts/comment/cell/show.rb
.
module Comment::Cell
class Show < Trailblazer::Cell
property :body
end
end
The property
method creates a shortcut to model.body
.
The view for this cell goes into app/concepts/comment/view/show.haml
.
%h1 Comment #{model.id}
%p
= body
In views, you can access the model via the model
method. In this example, this is the comment
instance we passed into the cell when invoking.
Layout Cell
To render the entire page with Cells, the last missing piece is the layout. This is still being rendered by the underlying framework.
Again, the layout is represented by a cell. Its code goes to app/concepts/pro/cell/layout.rb
.
module Pro::Cell
class Layout < Trailblazer::Cell
end
end
Since this cell’s sole purpose is to render a view to wrap dynamic content, no logic is needed.
The view sits at app/concepts/pro/view/layout.haml
.
%html
%head
%body
= cell(Pro::Cell::Navigation)
= yield
The content cell’s HTML markup will be passed to the layout cell and can be yield
ed (which basically replaces yield
with the HTML string).
Putting Things Together
In the controller, the content cell needs to get instructed it will be wrapped by a layout cell.
def show
comment = Comment.find(1)
html = Comment::Cell::Show.(comment,
context: { current_user: current_user },
layout: Pro::Cell::Layout).()
end
class CommentsController < ApplicationController
def show
comment = Comment.find(params[:id])
render html: cell(Comment::Cell::Show, comment,
context: { current_user: current_user },
layout: Pro::Cell::Layout
)
end
end
The following happens now.
- First,
Comment::Cell::Show
cell is rendered. - Then,
Pro::Cell::Layout
is invoked and it will yield the content from the show cell. - In the
layout.haml
view, the navigation cell is also rendered. This cell needs acurrent_user
option which is provided by the context object.
Context Object
When rendering a hierarchy of cells, it’s often necessary to share generic data across all involved cells. This always happens via dependency injection, no global state is allowed in Trailblazer.
The :context
option will create such an object and is automatically being passed to all cells in that render workflow.
In order to access the current_user
from the context object, the navigation cell has to be changed slightly.
module Pro
module Cell
class Navigation < Trailblazer::Cell
include ::Cell::Hamlit
def signed_in?
context[:current_user]
end
def email
context[:current_user].email
end
def avatar_url
hexed = Digest::MD5.hexdigest(email)
"https://www.gravatar.com/avatar/#{hexed}?s=36"
end
end
end
end
Note that :current_user
is not an option anymore, but comes from the context
object.
Success
As demonstrated in this guide, it’s not really hard to replace the existing rendering stack, whether that’s ActionView
or Hanami::View
or Sinatra templates, with Cells.
API
A cell is an object that can render views. It represents a fragment of the page, or the entire page.
Sometimes they’re also called object-oriented partials.
The object has to define at least one method which in turn has to return the fragment. Per convention, this is #show
. In this public method, you may compile arbitrary strings or render
a cell view.
The return value of this public method (also called state) is what will be the rendered in the view using the cell.
Anatomy
Cells usually inherit from Cell::ViewModel
.
class CommentCell < Cell::ViewModel
def show
render # renders app/cells/comment/show.haml
end
end
When the CommentCell
cell is invoked, its show
method is called, the view is rendered, and returned as a HTML string.
This snippet illustrates a suffix cell, because it follows the outdated Rails-style naming and file structure. We encourage you to use Trailblazer cells. However, this document mostly describes the generic API.
Show
As per convention, #show
is the only public method of a cell class.
The return value of this method is what gets rendered as the cell.
def show
"I don't like templates!"
end
You’re free to return whatever string you desire, use your own rendering engine, or use cells’ render
for templates.
Manual Invocation
In its purest form, a cell can be rendered as follows.
Comment::Cell.new(comment).() #=> "I don't like templates!"
This can be split up into two steps: initialization and invocation.
Initialize
You may instantiate cells manually, wherever you want.
cell = Comment::Cell.new(comment)
This is helpful in environments where the helpers are not available, e.g. a Rails mailer or a Lotus::Action
.
Note that usually you pass in an arbitrary object into the cell, the “model”. Here, this is the comment
instance.
Model
The model you pass into the cell’s constructor is completely up to you! It could be an ActiveRecord instance, a Struct
, or an array of items.
The model is available via the model
reader.
def show
model.rude? ? "Offensive content." : render
end
The term model is really not to be confused with the way Rails uses it - it can be just anything.
Property
Cells allow a short form to access model’s attributes using the property
class method.
class CommentCell < Cell::ViewModel
property :email #=> model.email
def show
model.email #=> "s@trb.to"
email #=> "s@trb.to"
end
end
Using ::property
will create a convenient reader method for you to the model.
Options
Along with the model, you may also pass arbitrary options into the cell, for example the current user.
cell = Comment::Cell.new(comment, current_user: current_user)
In the cell, you can access any options using the options
reader.
def show
options[:current_user] ? render : "Not logged in!"
end
Invocation
Once you’ve got the cell instance, you may call the rendering state. This happens via ViewModel#call
.
cell.call(:show)
It’s a common idiom in Ruby to skip the explicit call
method name. The next snippet does the same as the above.
cell.(:show)
Since show
is the default state, you may simple call the cell without arguments.
cell.() #=> cell.(:show)
Note that in Rails controller views, this will be called automatically via cell’s ViewModel#to_s
method.
Call
Always invoke cell methods via call
. This will ensure that caching - if configured - is performed properly.
CommentCell.new(comment).(:show)
As discussed, this will call the cell’s show
method and return the rendered fragment.
Note that you can invoke more than one state on a cell, if desired.
- cell = CommentCell.new(Comment.last) # instantiate.
= cell.call(:show) # render main fragment.
= content_for :footer, cell.(:footer) # render footer.
See how you can combine cells with global helpers like content_for
?
You can also provide additional arguments to call
.
cell.(:show, Time.now)
All arguments after the method name are passed to the invoked method.
def show(time)
time #=> Now!
end
Even blocks are allowed.
cell.(:show) { "Yay!" }
Again, the block is passed through to the invoked method.
def show(&block)
yield #=> "Yay!"
end
This is particularly interesting when passing the block to render
and using yield
in the view. See render’s docs for that.
Default Show
Per default, every cell derived from Cell::ViewModel
has a built-in show
method.
class CommentCell < Cell::ViewModel
# #show is inherited.
end
The implementation looks as follows.
def show(&block)
render &block
end
An optional block is always passed to the render
method.
Of course, you’re free to override show
to do whatever it needs to do.
Instantiation Helper
In most environments you will instantiate cells with the concept
or cell
helper which internally does exactly the same as the manual invocation.
cell = cell(:comment, comment)
This is identical to
cell = CommentCell.new(comment)
Depending on your environment, the cell
helper might inject dependencies into the created cell. For example, in Rails, the controller is passed on into the cell behind the scenes. When manually instantiating cells, you must not forget to do so, too.
The cell
helper also allows passing in the cell constant. This means, it won’t try to infer the class constant name.
cell = cell(CommentCell, comment)
File Structure
Having a cell to represent a fragment of your page is one thing. The real power, whatsoever, comes when rendering templates in cells. The render
method does just that.
In a suffix environment, Cells expects the following file layout.
├── app
│  ├── cells
│  │  └── comment_cell.rb
│  │  └── comment
│  │  └── show.haml
Every cell - unless configured otherwise - has its own view directory named after the cell’s name (comment
). Views do only have one extension to identify the template’s format (show.haml
). Again, you’re free to provide arbitrary additional extensions.
Note that the suffix style shown above is deprecated, and will be superseded in Cells 5 by the Trailblazer-style naming and file structure.
Render
class CommentCell < Cell::ViewModel
def show
render # renders show.haml.
end
end
A simple render
will implicitly figure out the method (or state) name and attempt to render that view. Here, the file will be resolved to app/cells/comment/show.haml
.
Note that render
literally renders the template and returns the HTML string. This allows you to call render multiple times, concatenate, and so on.
def show
render + render(:footer) + "<hr/>"
end
You can provide an explicit view name as the first argument.
def show
render :item # renders item.haml
end
When providing more than one argument to render
, you have to use the :view
option.
def show
render view: :item # renders item.haml
end
If you like the clunky Rails-style file naming, you’re free to add a format to the view.
render "shot.html" # renders show.html.haml
You can pass locals to the view using :locals
.
render locals: { logged_in: options[:current_user] }
Instance Methods
While it is fine to use locals or instance variables in the view to access data, the preferred way is invoking instance methods in the view.
%h1 Show comment
= body
= author_link
Every method call in the view is dispatched to the cell instance. You have to define your “helpers” there.
class CommentCell < Cell::ViewModel
property :body # creates #body reader.
def author_link
url_for model.author.name, model.author
end
end
This allows slim, logic-less views.
Note that you can use Rails in the instance level, too, if you’re in a Rails environment.
Yield
A block passed to the cell constructor is passed on to the state method.
CommentCell.new(comment) { "Yay!" }
cell(:comment, comment) { "Yay!" }
It’s up to you if you want to use this block, or provide your own.
def show(&block)
render(&block)
end
Passing the block render
allows yielding it in the view.
%h1 Comment
= yield
Collection
Instead of manually iterating an array of models and concatenating the output of the item cell, you can use the :collection
feature.
cell(:comment, collection: Comment.all).()
This will instantiate a cell per iterated model, invoke call
and join the output into one fragment.
Pass the method name to call
when you want to invoke a state different to show
.
cell(:comment, collection: Comment.all).(:item)
You’re free to pass additional options to the call.
cell(:comment, collection: comments, size: comments.size).()
This instantiates each collection cell as follows.
CommentCell.new(comment, size: 9)
You can use the join
method to customize how each item in the collection is invoked. The return value of the block is automatically inserted in between each rendered item in the collection0
class CommentCell < Cell::ViewModel
def odd
"odd comment\n"
end
def even
"even comment\n"
end
end
cell(:comment, collection: Comment.all).join do |cell, i|
i.odd? ? cell.(:odd) : cell(:even)
end
# => "odd comment\neven comment\nodd comment\neven comment"
An optional separator string can be passed to join
when it concatenates the item fragments.
cell(:comment, collection: Comment.all).join("<hr/>") do |cell, i|
i.odd? ? cell.(:odd) : cell(:even)
end
# => "odd comment\n<hr/>even comment\n<hr/>odd comment\n<hr/>even comment"
Alternatively, if you just want to add some extra content in between each rendered item and don’t need to customize how each item is invokved, you can call join
with a separator and no block:
class PostCell
def show
'My post'
end
end
cell(:post, collection: Post.all).join("<hr/>")
# => "My post<hr/>My post<hr/>My post"
External Layout
Since Cells 4.1, you can instruct your cell to use a second cell as a wrapper. This will first render your actual content cell, then pass the content via a block to the layout cell.
Cells desiring to be wrapped in a layout have to include Layout::External
.
class CommentCell < Cell::ViewModel
include Layout::External
end
The layout cell usually can be an empty subclass.
class LayoutCell < Cell::ViewModel
end
Its show
view must contain a yield
to insert the content.
!!!
%html
%head
%title= "Gemgem"
= stylesheet_link_tag 'application', media: 'all'
= javascript_include_tag 'application'
%body
= yield
The layout cell class is then injected into the actual invocation using :layout
.
cell(:comment, comment, layout: LayoutCell)
The context object will automatically be passed to the layout cell.
Note that :layout
also works in combination with :collection
.
View Paths
Per default, the cell’s view path is set to app/cells
. You can set any number of view paths for the template file lookup.
class CommentCell < Cell::ViewModel
self.view_paths = ["app/views"]
Note that the default view paths are different if you’re using the Trailblazer-style file structure.
Template Formats
Cells provides support for a handful of popular template formats like ERB, Haml, etc.
You need to add the specific template engine to your Gemfile:
- cells-erb
- cells-hamlit We strongly recommend using Hamlit as a Haml replacement.
- cells-slim
- cells-haml Make sure to bundle Haml 4.1.
gem "haml", github: "haml/haml", ref: "7c7c169".
# Use `cells-hamlit` instead.
In Rails, this is all you need to do. In other environments, you need to include the respective module into your cells.
class CommentCell < Cell::ViewModel
include ::Cell::Erb # or Cell::Hamlit, or Cell::Haml, or Cell::Slim
end
HTML Escaping
Cells per default does no HTML escaping, anywhere. This is one of the reasons why Cells is much faster than Rails’ ActionView.
Include Escaped
to make property readers return escaped strings.
class CommentCell < Cell::ViewModel
include Escaped
property :title
end
song.title #=> "<script>Dangerous</script>"
Comment::Cell.(song).title #=> <script>Dangerous</script>
Only strings will be escaped via the property reader.
You can suppress escaping manually.
def raw_title
"#{title(escape: false)} on the edge!"
end
Of course this works in views too:
<%= title(escape: false) %>
Context Object
By default, every cell contains a context object. When nesting cells, this object gets passed in automatically. To add objects to the context, use the :context
option.
cell("comment", comment, context: { user: current_user })
You can read from the context object via the context
method.
def show
context[:user] #=> <User ..>
# ..
end
The context object is handy when dependencies need to be passed down (or up, when using layouts) a cell hierarchy.
Note that the context object gets dup
ed when adding to it into nested cells. This is to prevent leaking nested state back into parent objects.
Nesting
You can invoke cells in cells. This happens with the cell
helper.
def show
html = cell(:comment_detail, model)
# ..
end
The cell
helper will automatically pass the context object to the nested cell.
View Inheritance
Cells can inherit code from each other through Ruby’s regular inheritance mechanisms.
class CommentCell < Cell::ViewModel
end
class PostCell < CommentCell
end
Even cooler, PostCell
will now inherit views from CommentCell
.
PostCell.prefixes #=> ["app/cells/post", "app/cells/comment"]
When views can’t be found in the local post
directory, they will be looked up in comment
. This starts to become helpful when using composed cells.
If you only want to inherit views, not the entire class, use ::inherit_views
.
class PostCell < Cell::ViewModel
inherit_views Comment::Cell
end
PostCell.prefixes #=> ["app/cells/post", "app/cells/comment"]
Builder
Often, it’s good practice to replace decider code from views or classes by extracting it out into separate sub-cells. Or in case you want to render a polymorphic collection, builders come in handy.
Builders allow instantiating different cell classes for different models and options.
class CommentCell < Cell::ViewModel
include ::Cell::Builder
builds do |model, options|
if model.is_a?(Post)
PostCell
elsif model.is_a?(Comment)
CommentCell
end
end
end
The #cell
helper takes care of instantiating the right cell class for you.
cell(:comment, Post.find(1)) #=> creates a PostCell. This also works with collections.
cell(:comment, collection: [@post, @comment]) #=> renders PostCell, then CommentCell.
Multiple calls to ::builds
will be ORed. If no block returns a class, the original class will be used (CommentCell
). Builders are inherited.
Caching
Cells allow you to cache per state. It’s simple: the rendered result of a state method is cached and expired as you configure it.
To cache forever, don’t configure anything
class CartCell < Cell::Rails
cache :show
def show
render
end
This will run #show
only once, after that the rendered view comes from the cache.
Cache Options
Note that you can pass arbitrary options through to your cache store. Symbols are evaluated as instance methods, callable objects (e.g. lambdas) are evaluated in the cell instance context allowing you to call instance methods and access instance variables. All arguments passed to your state (e.g. via render_cell
) are propagated to the block.
cache :show, :expires_in => 10.minutes
If you need dynamic options evaluated at render-time, use a lambda.
cache :show, :tags => lambda { |*args| tags }
If you don’t like blocks, use instance methods instead.
class CartCell < Cell::Rails
cache :show, :tags => :cache_tags
def cache_tags(*args)
# do your magic..
end
Conditional Caching
The :if
option lets you define a condition. If it doesn’t return a true value, caching for that state is skipped.
cache :show, :if => lambda { |*| has_changed? }
Cache Keys
You can expand the state’s cache key by appending a versioner block to the ::cache
call. This way you can expire state caches yourself.
class CartCell < Cell::Rails
cache :show do
order.id
end
The versioner block is executed in the cell instance context, allowing you to access all stakeholder objects you need to compute a cache key. The return value is appended to the state key: "cells/cart/show/1"
.
As everywhere in Rails, you can also return an array.
class CartCell < Cell::Rails
cache :show do
[id, options[:items].md5]
end
Resulting in: "cells/cart/show/1/0ecb1360644ce665a4ef"
.
Debugging Cache
When caching is turned on, you might wanna see notifications. Just like a controller, Cells gives you the following notifications.
write_fragment.action_controller
for cache miss.read_fragment.action_controller
for cache hits.
To activate notifications, include the Notifications
module in your cell.
class Comment::Cell < Cell::Rails
include Cell::Caching::Notifications
Cache Inheritance
Cache configuration is inherited to derived cells.
Testing Caching
If you want to test it in development
, you need to update development.rb
to see the effect.
config.action_controller.perform_caching = true
Trailblazer
This documents the Trailblazer-style cells semantics, brought to you by the trailblazer-cells gem.
This gem can be used stand-alone without Trailblazer, its only dependency is the cells
gem.
A Trailblazer::Cell
is a normal cell with Trailblazer semantics added. Naming, file structure, and the way views are resolved follow the TRB style. Note that this will be the standard for Cells 5, which will drop all old “dialects”.
Installation
gem "trailblazer-cells"
gem "cells-slim"
Make sure you also add the view engine. We recommend cells-slim
.
File Structure
In Trailblazer, cell classes sit in their concept’s cell
directory, the corresponding views sit in the view
directory.
├── app
│  ├── concepts
│  │  └── comment # namespace/class
│  │  ├── cell # namespace/module
│  │  │  ├── index.rb # class
│  │  │  ├── new.rb # class
│  │  │  └── show.rb # class
│  │  └── view
│  │  ├── index.slim
│  │  ├── item.slim
│  │  ├── new.slim
│  │  ├── show.slim
│  │  └── user.scss
Note that one cell class can have multiple views, as well as other assets like .scss
stylesheets.
Also, the view names with Trailblazer::Cell
are not called show.slim
, but named after its corresponding cell class. For instance, Comment::Cell::Index
will render comment/view/index.slim
.
Naming
As always, the Trailblazer naming applies.
Comment[::SubConcepts]::Cell::[Name]
This results in classes such as follows.
module Comment::Cell # namespace
class New < Trailblazer::Cell # class
def show
render # renders app/concepts/comment/view/new.slim.
end
end
end
This is different to old suffix-cells. While the show
method still is the public method, calling render
will use the new.slim
view, as inferred from the cell’s last class constant segment (New
).
Default Show
Note that you don’t have to provide a show
method, it is created automatically.
module Comment::Cell
class New < Trailblazer::Cell
end
end
This is the recommended way since no setup code should be necessary.
You’re free to override show
, though.
View Names
Per default, the view name is computed from the cell’s class name.
Comment::Cell::New #=> "comment/view/new.slim"
Comment::Cell::Themed::New #=> "comment/view/themed/new.slim"
Note that the entire path after Cell::
is considered, resulting in a hierarchical view name.
Use ViewName::Flat
if you prefer a flat view name.
module Comment
module Cell
module Themed
class New < Trailblazer::Cell
extend ViewName::Flat
end
end
end
end
Comment::Cell::Themed::New #=> "comment/view/new.slim"
This will always result in a flat name where the view name is inferred from the last segment of the cell constant.
Invocation
To render a cell in controllers, views, or other cells, use cell
. You need to provide the constant directly. Ruby’s constant lookup rules apply.
html = cell(Comment::Cell::New, result["model"]).()
Layouts
It’s a common pattern to maintain a cell representing the application’s layout(s). Usually, it resides in a concept named after the application.
├── app
│  ├── concepts
│  │  └── gemgem
│  │  ├── cell
│  │  │  ├── layout.rb
│  │  └── view
│  │  ├── layout.slim
Most times, the layout cell can be an empty subclass.
module Gemgem::Cell
class Layout < Trailblazer::Cell
end
end
The view gemgem/view/layout.slim
contains a yield
where the actual content goes.
!!!
%html
%head
%title= "Gemgem"
= stylesheet_link_tag 'application', media: 'all'
= javascript_include_tag 'application'
%body
= yield
Wrapping the content cell (Comment::Cell::New
) with the layout cell (Gemgem::Cell::Layout
) happens via the public :layout
option.
concept("comment/cell/new", result["model"], layout: Gemgem::Cell::Layout)
This will render the Comment::Cell::New
, instantiate Gemgem::Cell::Layout
and pass through the context object, then render the layout around it.
Testing
Only a few methods are needed to integrate cells testing into your test suite. This is implemented in Cell::Testing
.
API
Regardless of your test environment (Rspec, MiniTest, etc.) the following methods are available.
module Testing
concept(name, *args) # instantiates Cell::Concept subclass.
cell(name, *args) # instantiates Cell::ViewModel subclass.
end
Calling the two helpers does exactly the same it does in a controller or a view.
Usually, this will give you the cell instance. It’s your job to invoke a state using #call
.
it "renders cell" do
cell(:song, @song).() #=> HTML / Capybara::Node::Simple
end
However, when invoked with :collection
, it will render the cell collection for you. In that case, #cell
/#concept
will return a string of markup.
it "renders collection" do
cell(:song, collection: [@song, @song]) #=> HTML
end
MiniTest, Test::Unit
In case you’re not using Rspec, derive your tests from Cell::TestCase
.
class SongCellTest < Cell::TestCase
it "renders" do
cell(:song, @song).().must_have_selector "b"
end
end
You can also include Cell::Testing
into an arbitrary test class if you’re not happy with Cell::TestCase
.
Optional Controller
If your cells have a controller dependency, you can set it using ::controller
.
class SongCellTest < Cell::TestCase
controller SongsController
This will provide a testable controller via #controller
, which is automatically used in Testing#concept
and Testing#cell
.
Rspec
Rspec works with the rspec-cells
gem.
Make sure to install it.
gem "rspec-cells"
You can use the #cell
and #concept
builders in your specs.
describe SongCell, type: :cell do
subject { cell(:song, Song.new).(:show) }
it { expect(subject).to have_content "Song#show" }
end
Optional Controller
If your cells have a controller dependency, you can set it using ::controller
.
describe SongCell do
controller SongsController
This will provide a testable controller via #controller
.
Capybara Support
Per default, Capybara support is enabled in Cell::TestCase
when the Capybara gem is loaded. This works for both Minitest and Rspec.
The only changed behavior will be that the result of Cell#call
is wrapped into a Capybara::Node::Simple
instance, which allows to call matchers on the result.
cell(:song, @song).().must_have_selector "b" # example for MiniTest::Spec.
In case you need access to the actual markup string, use #to_s
. Note that this is a Cells-specific extension.
cell(:song, @song).().to_s.must_match "by SNFU" # example for MiniTest::Spec.
You can disable Capybara for Cells even when the gem is loaded.
Cell::Testing.capybara = false
Capybara with Minitest (Rails)
With Minitest, the recommended approach is to use the minitest-rails-capybara
gem.
group :test do
gem "minitest-rails-capybara"
end
You also have to include certain Capybara modules into your test case. It’s a good idea to do this in your app-wide test_helper.rb
.
Cell::TestCase.class_eval do
include ::Capybara::DSL
include ::Capybara::Assertions
end
If you miss to do so, you will get an exception similar to the following.
NoMethodError: undefined method `must_have_css' for #<User::Cell::Index:0xb5a6c>
Here’s an example how we do it in Gemgem.
Capybara with Minitest::Spec
In a non-Rails environment, the capybara_minitest_spec gem is what we use.
group :test do
gem "capybara_minitest_spec"
end
Add the following to your test_helper.rb
.
require "capybara_minitest_spec"
Cell::Testing.capybara = true
After including the Testing
module, you’re ready to run specs against cells.
class NavigationCellTest < Minitest::Spec
include Cell::Testing
it "renders avatar when user provided" do
html = cell(Pro::Cell::Navigation, user).()
html.must_have_css "#avatar-signed-in"
html.to_s.must_match "Signed in: nick@trb.to"
end
end
Rendering
View Paths
Every cell class can have multiple view paths. However, I advise you not to have more than two, better one, unless you’re implementing a cell in an engine. This is simply to prevent unexpected behavior.
View paths are set via the ::view_paths
method.
class Cell::ViewModel
self.view_paths = ["app/cells"]
Use the setter to override the view paths entirely, or append as follows.
class Shopify::CartCell
self.view_paths << "/var/shopify/app/cells"
The view_paths
variable is an inheritable array.
A trick to quickly find out about the directory lookup list is to inspect the ::prefixes
class method of your particular cell.
puts Shopify::CartCell.prefixes
#=> ["app/cells/shopify/cart", "/var/shopify/app/cells/shopify/cart"]
This is the authorative list when finding templates. It will include inherited cell’s directories as well when you used inheritance. The list is traversed from left to right.
Partials
Even considered a taboo, you may render global partials from Cells.
SongCell < Cell::ViewModel
include Partial
def show
render partial: "../views/shared/sidebar.html"
end
Make sure to use the :partial
option and specify a path relative to the cell’s view path. Cells will automatically add the format and the terrible underscore, resulting in "../views/shared/_sidebar.html.erb"
.
Rails
When using cells in a Rails app there’s several nice features to benefit from.
Asset Pipeline
Cells can bundle their own assets in the cell’s view directory. This is a very popular way of writing highly reusable components.
It works with both engine cells and application cells.
├── cells
│ ├── comment_cell.rb
│ ├── comment
│ │ ├── show.haml
│ │ ├── comment.css
│ │ ├── comment.coffee
You need to register the cells with bundled assets. Preferably, this happens in config/application.rb
of the main application.
class Application < Rails::Application
# ..
config.cells.with_assets = ["comment_cell"]
The names added to with_assets
have to be the fully qualified, underscored cell name. They will get constantize
d to find the cell name at runtime.
If using namespaces, this might be something along config.cells.with_assets = ["my_engine/song/cell"]
.
In app/assets/application.js
, you need to add the cell JavaScript assets manually.
//=# require comments
Likewise, you have to reference the cell’s CSS files in app/assets/application.css
.
/*
*= require comment
*/
Asset Pipeline With Trailblazer
With Trailblazer, cells follow a different naming structure.
├── concepts
│  │  └── comment
│  │  ├── cell
│  │  │  ├── index.rb
│  │  │  └── show.rb
│  │  └── view
│  │  ├── index.haml
│  │  ├── show.haml
│  │  └── comment.scss
The comment
concept here will provide Comment::Cell::Index
and Comment::Cell::Show
. Both bundle their assets in the comment/view
directory.
To add this to Rails’ asset pipeline, you need to reference one of the cell classes in config/application.rb
.
class Application < Rails::Application
# ..
config.cells.with_assets = ["comment/cell/index"] # one of the two is ok.
You still need to require
the JS and CSS files. Here’s an example for app/assets/application.css
.
/*
*= require comment # refers to concepts/comment/view/comment.scss
*/
Assets Troubleshooting
The Asset Pipeline is a complex system. If your assets are not compiled, start debugging in Cells’ railtie and uncomment the puts
in the cells.update_asset_paths
initializer to see what directories get added.
Cell classes need to be loaded when precompiling assets! Make sure your application.rb
contains the following setting (per default, this is turned on).
config.assets.initialize_on_precompile = true
You need to compile assets using this command, which is explained here.
rake assets:precompile:all RAILS_ENV=development RAILS_GROUPS=assets
Global Partials
Although not recommended, you can also render global partials from a cell. Be warned, though, that they will be rendered using our stack, and you might have to include helpers into your view model.
This works by including Partial
and the corresponding :partial
option.
class Cell < Cell::ViewModel
include Partial
def show
render partial: "../views/shared/map.html" # app/views/shared/map.html.haml
end
The provided path is relative to your cell’s ::view_paths
directory. The format has to be added to the file name, the template engine suffix will be used from the cell.
You can provide the format in the render
call, too.
render partial: "../views/shared/map", formats: [:html]
This was mainly added to provide compatibility with 3rd-party gems like Kaminari and Cells that rely on rendering partials within a cell.
Generators
In Rails, you can generate cells and concept cells.
rails generate cell comment
Or, TRB-style concept cells.
rails generate concept comment
Engine Cells
You can bundle cells into Rails engines and maximize a clean, component architecture by making your view models easily distributable and overridable.
This pretty much works out-of-the-box, you write cells and push them into an engine. The only thing differing is that engine cells have to set their view_paths
manually to point to the gem directory.
Engine View Paths
Each engine cell has to set its view_paths
.
The easiest way is to do this in a base cell in your engine.
module MyEngine
class Cell < Cell::Concept
view_paths = ["#{MyEngine::Engine.root}/app/concepts"]
end
end
The view_paths
is inherited, you only have to define it once when using inheritance within your engine.
module MyEngine
class Song::Cell < Cell # inherits from MyEngine::Cell
This will not allow overriding views of this engine cell in app/cells
as it is not part of the engine cell’s view_paths
. When rendering MyEngine::User::Cell
or a subclass, it will not look in app/cells
.
To achieve just that, you may append the engine’s view path instead of overwriting it.
class MyEngine::User::Cell < Cell::Concept
view_paths << "#{MyEngine::Engine.root}/app/concepts"
end
Engine Render problems
You might have to include cells’ template gem into your application’s Gemfile
. This will properly require the extension.
Engine Namespace Helpers
If you need namespaced helpers, include the respective helper in your engine cell.
module MyEngine
class CommentCell < Cell::ViewModel
include Engine.routes.url_helpers
def comment_url
link_to model.title, engine_specific_path_without_any_namespaces_needed
end
end
end
# application Gemfile
gem "cells-erb"
Translation and I18N Helper
You can use the #t
helper.
require "cell/translation"
class Admin::Comment::Cell < Cell::Concept
include ActionView::Helpers::TranslationHelper
include ::Cell::Translation
def show
t(".greeting")
end
end
This will lookup the I18N path admin.comment.greeting
.
Setting a differing translation path works with ::translation_path
.
class Admin::Comment::Cell < Cell::Concept
include Cell::Translation
self.translation_path = "cell.admin"
The lookup will now be cell.admin.greeting
.
Asset Helper
When using asset path helpers like image_tag
that render different paths in production, please simply delegate to the controller.
class Comment::Cell < Cell::Concept
delegates :parent_controller, :image_tag
The delegation fixes the well-known problem of the cell rendering the “wrong” path when using Sprockets. Please note that this fix is necessary due to the way Rails includes helpers and accesses global data.
Template Engines
Cells supports various template engines.
We provide support for Haml, Erb, and Slim. You can also write your own template engine.
In a non-Rails environment, you need to include the respective module into your cells, so it knows what template to find.
class SongCell < Cell::ViewModel
include Cell::Erb
# include Cell::Haml
# include Cell::Slim
Note that you can only include one engine per class. This is due to problems with helpers in Rails and the way they have to be fixed in combination with Cells.
Multiple Template Engines in Rails
When including more than one engine in your Gemfile in Rails, the last one wins. Since each gem includes itself into Cell::ViewModel
, unfortunately there can only be one global engine.
Currently, there’s no clean way but to disable automatic inclusion from each gem (not yet implemented) and then include template modules into your application cells manually.
ERB
Haml
Slim
Your Own
Theoretically, you can use any template engine supported by Tilt.
To activate it in a cell, you only need to override #template_options_for
.
class SongCell < Cell::ViewModel
def template_options_for(options)
{
template_class: Tilt, # or Your::Template.
suffix: "your"
}
end
This will use Tilt
to instantiate a template to be evaluated. The :suffix
is needed for Cells when finding the view.
Troubleshooting
Helper Inclusion Order
One of the many problems with Rails helper implementation is that the inclusion order matters. This can lead to problems with the following exception.
undefined method `output_buffer=' for #<Comment::Cell:0xb518d8cc>
Usually, the problem arises when you have initializers to setup your application cell. When including helpers here, they might be included before the cells
gem has a chance to include its fixes.
Please include your template engine module explicitly then, after your standard helper inclusions.
# config/initializers/trailblazer.rb
Cell::Concept.class_eval do
include ActionView::Helpers::TranslationHelpers # include your helpers here.
include Cell::Erb # or Cell::Slim, or Cell::Haml after that
end
Form helpers
Sometimes you have to manually include Rails helpers into your cell classes. For instance, the following exception originating from lib/action_view/helpers/form_options_helper.rb
might disturb your sunny day.
undefined method `collection_select' for #<...::Cell::
While we still don’t understand the root of the problem, it can be fixed by including FormOptionsHelper
.
module Comment::Cell
class New < Trailblazer::Cell
include ActionView::Helpers::FormOptionsHelper
# ...
end
end
Likewise, when using simple_form
you need to manually include its helper or you will encounter the following exception.
undefined method `simple_form_for' for #<...::Cell::
It is easily fixed with an include
.
module Transaction::Cell
class New < Trailblazer::Cell
include SimpleForm::ActionViewExtensions::FormHelper
# you can include more than one helper:
# include ActionView::Helpers::FormOptionsHelper