Encapsulating backend business logic into Trailblazer operations is a very important part of building an app based on it, alongside contracts and moving logic from controllers. Another important step that ties everything from backend to frontend together, are cells1. Let's quickly go over what cells are, why we should use them and then we will find some view that could use refactoring and help him be a better, clearer view.
As explained by the cell gem webpage
[...] cells, are simple ruby classes that can render templates.
Sounds nice and simple, right? Well... that's actually cause it's nice and simple. Their base functionality is nothing complicated and that's what we will cover in this post, but going further we will keep in mind cells can be used in so many more ways, than a simple template renderer2. Let's pick a view, and to be consistent with this series, we will simply go to Proposals index view. Here's how it looks on the webpage:
And here is the code we are dealing with:
/* app/views/proposals/index.html.haml */
.row
.col-md-12
.page-header
%h1 My Proposals
.row
.col-md-12.proposals
- if proposals.blank? && invitations.blank?
.widget.widget-card.text-center
%h2.control-label You don't have any proposals.
- events.each do |event|
- talks = proposals[event] || []
- invites = invitations[event] || []
- event = event.decorate
.row
.col-md-4
.widget.widget-card.flush-top
.event-info.event-info-block
%strong.event-title
= event.name
%span{:class => "event-status-badge event-status-#{event.status}"}
CFP
= event.status
%span.pull-right
- if event.open?
= link_to 'Submit a proposal', new_event_proposal_path(event.slug), class: 'btn btn-primary btn-sm'
.event-meta
- if event.start_date? && event.end_date?
%span.event-meta-item
%i.fa.fa-fw.fa-calendar
= event.date_range
.event-meta.margin-top
%span.event-meta
= link_to 'View Guidelines', event_path(event.slug)
.col-md-8
- if invites.present?
.proposal-section.invitations.callout
%h2.callout-title Speaker Invitations
%ul.list-unstyled
- invites.each do |invitation|
%li.invitation.proposal.proposal-info-bar
.flex-container.flex-container-md
.flex-item.flex-item-padded
%h4.proposal-title= link_to invitation.proposal.title, invitation_path(invitation.slug)
.proposal-meta.proposal-description
.proposal-meta-item
%strong Track:
= invitation.proposal.track_name
.proposal-meta-item
%strong #{ 'Speaker'.pluralize(invitation.proposal.speakers.count) }:
= invitation.proposal.speakers.collect { |speaker| speaker.name }.join(', ')
.flex-item.flex-item-fixed.flex-item-padded.flex-item-right
.invitation-status
= invitation.state_label
- if invitation.pending?
.proposal-meta.invite-btns
= invitation.decline_button(small: true)
= invitation.accept_button(small: true)
.proposal-section
%ul.list-unstyled
- talks.each do |proposal|
%li.proposal.proposal-info-bar
.flex-container.flex-container-md
.flex-item.flex-item-fixed.flex-item-padded.proposal-icon
%i.fa.fa-fw.fa-file-text
.flex-item.flex-item-padded
%h4.proposal-title= link_to proposal.title, event_proposal_path(event_slug: proposal.event.slug, uuid: proposal)
.proposal-meta.proposal-description
.proposal-meta-item
%strong #{ 'Speaker'.pluralize(proposal.speakers.count) }:
%span= proposal.speakers.collect { |speaker| speaker.name }.join(', ')
.proposal-meta-item
%strong Format:
%span #{proposal.session_format_name}
.proposal-meta-item
%strong Track:
%span #{proposal.track_name}
.proposal-meta.margin-top
%strong Updated:
%span #{proposal.updated_in_words}
.flex-item.flex-item-fixed.flex-item-padded
.proposal-status
= proposal.speaker_state(small: true)
.proposal-meta
%i.fa.fa-fw.fa-comments
= pluralize(proposal.public_comments.count, 'comment')
A rather big view, isn't? Good thing it is using a decorator (with Draper gem in this case) which is a good practice and will make it easier for us to improve it even more than simply adding a decorator. First off we need to add some base cell and render it from the controller level. In our concepts we already have a concept for proposal, we just need to add two folders there: cells
and views
. This is an important concept in TB, to keep everything connected to our concept in its folder. Thanks to that we won't have a humongous views
folder where we have to look for adequate view, we will know that if we want a view for proposals, we simply go into its concept, and into view
folder there, where we will have all the relative views bundled up.
Rendering cell is rather simple but gives a lot of options. As per version 2.1 the syntax for invoking a cell is either this
=concept("your_concept/cell", your_model)
(which is a syntax for using cells as a solo gem, as you will see further down below, using it with full trailblazer offers, different, more complex helper with more options), or:
YourConcept::Cell.new(your_model).show
Please note that the haml example is called from the view, and what it does is invoking the second, ruby syntax, it's just a helper. The helper will always invoke the show
method which is a default method of returning the cell body. Also, keep in mind that I called the passed object your_model
for two reasons.
- Whatever you pass into the helper in the place of
your_model
will be calledmodel
inside the cell body - Your passed
your_model
does not have to be an AR object, you can use cell on other types of data
After adding gem 'trailblazer-cells'
we also need to add gem 'cells-hamlit'
gem 'cells-rails'
to have cells working with Trailblazer, instead of as a solo abstraction for view rendering, where we need only "cells" gem. Now we can start by adding a new class, a new base view, and render it from the controller level. Then step by step we will move stuff from the current view to the new one, using cells.
# app/controllers/proposals_controller.rb
def index
proposals = current_user.proposals.decorate.group_by {|p| p.event}
invitations = current_user.pending_invitations.decorate.group_by {|inv| inv.proposal.event}
events = (proposals.keys | invitations.keys).uniq
render locals: {
events: events,
proposals: proposals,
invitations: invitations
}
end
This is a base class that we will render in the controller.
# app/concepts/proposal/cell/index.rb
module Proposal::Cell
class Index < Trailblazer::Cell
end
end
We will only invoke this in our index action:
# app/controllers/proposals_controller.rb
def index
render html: cell(Proposal::Cell::Index, current_user, context: { current_user: current_user })
end
Using the cell
helper we pass in a cell we want to render, our model (in this case its user), and options (in this case nothing yet)
We are passing in the current user as our model, cause it is our model in this case, but we also pass it in context. This will get useful later on cause what we pass in in context, also gets passed to our cell nested cells. By default, we have controller data passed in context, so that we can have access to stuff like our current path, or CRUD action name (this will be used later on to demonstrate where and how we can use context).
This alone with a base view made in app/concepts/proposal/view/index.haml
like this:
# app/concepts/proposal/view/index.haml
.row
.col-md-12
.page-header
%h1 My Proposals
...will give us a result
"<div class='row'>\n<div class='col-md-12'>\n<div class='page-header'>\n<h1>My Proposals</h1>\n</div>\n</div>\n</div>\n"
Nothing pretty but gives us ground to work on. Developing cell further on we add what we previously had passed in as locals for the view.
# app/concepts/proposal/cell/index.rb
module Proposal::Cell
class Index < Trailblazer::Cell
alias user model
private
def proposals
user.proposals.decorate.group_by(&:event)
end
def no_proposals?
proposals.blank? && invitations.blank?
end
def invitations
user.pending_invitations.decorate.group_by { |inv| inv.proposal.event }
end
def events
(proposals.keys | invitations.keys).uniq
end
end
end
With this, we already have access to what we had while dealing with this logic in the controller. We can now move on with recreating the view TB way.
/* app/concepts/proposal/view/index.haml */
.row
.col-md-12
.page-header
%h1 My Proposals
.row
.col-md-12.proposals
- if no_proposals?
.widget.widget-card.text-center
%h2.control-label You don't have any proposals.
- events.each do |event|
= cell(Proposal::Cell::EventRow, event, current_user: user, proposals: proposals, invitations: invitations)
We start off standard, we move the condition to a separate method, that we name according to what the condition checks. Then we go over proposal events, and iterate over them, rendering a cell for each of those events. This is a pattern you will see more in our guides. Instead of using partials, helpers, etc. we only used nested cells, that we render for each object, keeping their logic and code nicely separated. We also pass some additional stuff in as options to the event cell.
The controller should not deal with finding those objects if possible, this is something cells can do for us. As we mentioned before, a decorator gem is used in the system to help with the views logic, we will try to move away from it and transfer everything into correct cells. So we create some more "helper" methods and we end up with this new cell for each event we display in our proposals view.
# app/concepts/proposal/cell/event_row.rb
module Proposal::Cell
class EventRow < Trailblazer::Cell
alias event model
private
def talks
options[:proposals][event] || []
end
def invites
options[:invitations][event] || []
end
def has_date_range?
event.start_date? && event.end_date?
end
def status_class
"event-status-badge event-status-#{event.status}"
end
def new_event_link
link_to(
new_event_proposal_path(event.slug),
'Submit a proposal',
class: 'btn btn-primary btn-sm'
)
end
def guidelines_link
link_to(event_path(event.slug), 'View Guidelines')
end
def date_range
if (event.start_date.month == event.end_date.month) && (event.start_date.day != event.end_date.day)
event.start_date.strftime('%b %d') + event.end_date.strftime(" \- %d, %Y")
elsif (event.start_date.month == event.end_date.month) && (event.start_date.day == event.end_date.day)
event.start_date.strftime('%b %d, %Y')
else
event.start_date.strftime('%b %d') + object.end_date.strftime(" \- %b %d, %Y")
end
end
end
end
This gives us this view:
# app/concepts/proposal/view/event_row.haml
.row
.col-md-4
.widget.widget-card.flush-top
.event-info.event-info-block
%strong.event-title
= event.name
%span{:class => status_class}
CFP
= event.status
%span.pull-right
- if event.open?
= new_event_link
.event-meta
-if has_date_range?
%span.event-meta-item
%i.fa.fa-fw.fa-calendar
= date_range
.event-meta.margin-top
%span.event-meta
= guidelines_link
We moved logic and everything connected to it to the cell. When you take a look at the original view, you will see that we have more nested views. This is perfect for cells (and should have probably been done with partials in the first place, but luckily we are here to do it the right way). Let's make adequate cells for invites and talks (they were previously set in the view, this also has been changed to use cells to assign those in the classes rather than the view) and render them in each event cell iteration.
In invite row I would like to point out another useful thing:
/* app/concepts/proposal/view/invite_row.haml */
%li.invitation.proposal.proposal-info-bar
.flex-container.flex-container-md
.flex-item.flex-item-padded
%h4.proposal-title
= invitation_link
.proposal-meta.proposal-description
.proposal-meta-item
%strong Track:
= track_name
.proposal-meta-item
%strong
= title
= speakers
.flex-item.flex-item-fixed.flex-item-padded.flex-item-right
.invitation-status
= content_tag :span, state, class: label_class
- if invite.pending?
.proposal-meta.invite-btns
= decline_button(small: true)
= accept_button(small: true)
/* app/concepts/proposal/cell/invite_row.rb */
module Proposal::Cell
class InviteRow < Trailblazer::Cell
alias invite model
property :state
property :proposal
property :slug
STATE_LABEL_MAP = {
Invitation::State::PENDING => 'label-default',
Invitation::State::DECLINED => 'label-danger',
Invitation::State::ACCEPTED => 'label-success'
}.freeze
private
def invitation_link
link_to(invite.proposal.title, invitation_path(invite.slug))
end
def speakers
invite.proposal.speakers.collect(&:name).join(', ')
end
def title
'Speaker'.pluralize(invite.proposal.speakers.count)
end
def track_name
proposal.track.try(:name) || Track::NO_TRACK
end
def label_class
"label #{STATE_LABEL_MAP[state]}"
end
def decline_button(small: false)
classes = 'btn btn-danger'
classes += ' btn-xs' if small
link_to 'Decline', decline_invitation_path(slug), class: classes,
data: { confirm: 'Are you sure you want to decline this invitation?' }
end
def accept_button(small: false)
classes = 'btn btn-success'
classes += ' btn-xs' if small
link_to 'Accept', accept_invitation_path(slug), class: classes
end
end
end
property
allows us to more easily access our model fields, while also nicely specifying them in the cell. So in the view = invitation.slug
becomes simply slug
cause we already know we are in the invitation "scope" and that scope has a proposal property. Going back we can also use this in EventRow, see for yourself if you can spot the lines where we can delete the event.something
and replace it by just something
by adding adequate property.
As you can see on the decline_button
and accept_button
methods we can easily add html elements in the cell. Also knowing the rules by now, and having some examples and seeing the code, try building the TalkRow cell and view on your own. My version is just below, but it might be a good check of your so far understanding of the topic.
# app/concepts/proposal/cell/talk_row.rb
module Proposal::Cell
class TalkRow < Trailblazer::Cell
alias talk model
property :title
property :event
property :speakers
property :session_format_name
property :track_name
property :updated_in_words
property :public_comments
private
def talk_link
link_to(title, event_proposal_path(event_slug: options[:event].slug, uuid: talk))
end
def title
'Speaker'.pluralize(speakers.size)
end
def speakers_collection
speakers.collect(&:name).join(', ')
end
def comments
pluralize(public_comments.size, 'comment')
end
end
end
/* app/concepts/proposal/view/talk_row.haml */
%li.proposal.proposal-info-bar
.flex-container.flex-container-md
.flex-item.flex-item-fixed.flex-item-padded.proposal-icon
%i.fa.fa-fw.fa-file-text
.flex-item.flex-item-padded
%h4.proposal-title
= talk_link
.proposal-meta.proposal-description
.proposal-meta-item
%strong
= title
%span
= speakers_collection
.proposal-meta-item
%strong Format:
%span
= session_format_name
.proposal-meta-item
%strong Track:
%span
= track_name
.proposal-meta.margin-top
%strong Updated:
%span
= updated_in_words
.flex-item.flex-item-fixed.flex-item-padded
.proposal-status
= talk.speaker_state(small: true)
.proposal-meta
%i.fa.fa-fw.fa-comments
= comments
Okay, seems like we are done? We can delete the index.html.haml file and check out how our page looks again!
What are we forgetting? Why is it so basic? Well, when using standard rails views we have an app/views/layouts/application.html.haml
to give us a base to build around, with not only base html elements, but also including styles, javascript, nav bar, footer, etc... we also need a cell for that. So let's build it quickly. Even though this still be a cell we should call it a Layout.
# app/concepts/cfp/cell/base_layout.rb
module Cfp
module Cell
class BaseLayout < Trailblazer::Cell
end
end
end
Cfp, by the way, is the name of our app. Base layout view can look something like this:
/* app/concepts/cfp/view/base_layout.haml */
!!!5
%html(lang="en")
%head
%title= "Cfp"
%meta(name="viewport" content="width=device-width, initial-scale=1.0")
= stylesheet_link_tag 'application', media: 'all'
= stylesheet_link_tag '//maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css', media: 'all'
= stylesheet_link_tag "//fonts.googleapis.com/css?family=Open+Sans:400italic,600italic,400,600"
= javascript_include_tag 'application'
= javascript_include_tag "//www.google.com/jsapi", "chartkick"
= javascript_include_tag { yield :custom_js }
// html5 shim and respond.js ie8 support of html5 elements and media queries
/[if lt ie 9]
= javascript_include_tag "/js/html5shiv.js"
= javascript_include_tag "/js/respond.min.js"
-# %body{id: body_id}
-# = render partial: "layouts/navbar"
-# #flash= show_flash
%body
.main
.main-inner
.container-fluid
=yield
-# = render partial: "layouts/event_footer"
As you can see we have some comments here. Well get to them but first, let us just talk about why the layout is important. We should edit our controller action to use the base layout:
# app/controllers/proposals_controller.rb
def index
render html: cell(Proposal::Cell::Index, current_user, context: { current_user: current_user }, layout: Cfp::Cell::BaseLayout)
end
And we get the content of our index page back.
Without nav bar though. This is a job for another cell since the nav bar is something that will get repeated every time we render the base layout. So we simply create a NavBar cell in the cfp
concept, and add the view there, we render it each time with our BaseLayout
and we yield
the content of the cell that we are using the layout for (so Proposal::Cell::Index in our case). While we are at it, we might as well create the same thing for event_footer
partial (the last comment in the haml code above). The problem with nav_bar and event_footer is that they rely heavily on ApplicationHelper and ApplicationController. Ideally, we want to skim those of methods that can be dealt with by cells, but we cant delete those from there until we refactor the whole app. For now, we are only doing it for one place, so we will have to define them anew in cells. Let's take a look at nav_bar and its many calls to helper methods, that we will move to cells.
/* app/views/layouts/_navbar.html.haml */
.navbar.navbar-default.navbar-fixed-top
.container-fluid
.navbar-header
%button.navbar-toggle{ type: "button", data: { toggle: "collapse", target: ".navbar-collapse" } }
%span.icon-bar
%span.icon-bar
%span.icon-bar
- if current_event
= link_to "#{current_event.name} CFP", event_path(current_event), class: 'navbar-brand'
- else
= link_to "CFP App", events_path, class: 'navbar-brand'
.collapse.navbar-collapse
- if current_user
%ul.nav.navbar-nav.navbar-right
- if speaker_nav?
%li{class: nav_item_class("my-proposals-link")}
= link_to proposals_path do
%i.fa.fa-file-text
%span My Proposals
- if review_nav?
%li{class: nav_item_class("event-review-proposals-link")}
= link_to event_staff_proposals_path(current_event) do
%i.fa.fa-balance-scale
%span Review
- if program_nav?
%li{class: nav_item_class("event-program-link")}
= link_to event_staff_program_proposals_path(current_event) do
%i.fa.fa-sitemap
%span Program
- if schedule_nav?
%li{class: nav_item_class("event-schedule-link")}
= link_to event_staff_schedule_grid_path(current_event) do
%i.fa.fa-calendar
%span Schedule
- if staff_nav?
%li{class: nav_item_class("event-dashboard-link")}
= link_to event_staff_path(current_event) do
%i.fa.fa-dashboard
%span Dashboard
- if admin_nav?
= render partial: "layouts/nav/admin_nav"
= render partial: "layouts/nav/notifications_list"
= render partial: "layouts/nav/user_dropdown"
- else
%ul.nav.navbar-nav.navbar-right
%li= link_to "Log in", new_user_session_path
- if display_staff_event_subnav?
= render partial: "layouts/nav/staff/event_subnav"
- elsif program_mode?
= render partial: "layouts/nav/staff/program_subnav"
- elsif schedule_mode?
= render partial: "layouts/nav/staff/schedule_subnav"
We can start moving what we can to appropriate places. By now you might be able to visualize the code we will end up with or even DIY if you so wish. If you try you will see that refactoring some legacy rails apps to TB is not that hard, and the flow of it is very fluent and logical. We will also see how we can get rid of some rails callbacks using logic in cells. Navbar in our legacy project is big enough to deserve its concept too, so we are getting more and more of code division and grouping. I will post the result of our base layout and navbar view. We will go over the most interesting and noteworthy stuff and add some to do's for later.
First of all, this is our current stack of files.
As you can see we hold only the base stuff in Cfp
namespace, when we use layout: Cfp::Cell::BaseLayout
this layout in our controller we get all the stuff that this layout gives us, and when we pass the context: { current_user: current_user }
we will get access to context[:current_user]
in EVERY cell that follows rendering of base layout. We won't have to pass it every time. So, for example, this is our BaseLayout view and cell right now:
# app/concepts/cfp/cell/base_layout.rb
module Cfp
module Cell
class BaseLayout < Trailblazer::Cell
private
def current_user
context[:current_user]
end
def body_id
"#{context[:controller].request.env['REQUEST_PATH'].tr('/', '_')}_#{context[:controller].action_name}"
end
def show_flash
context[:controller].flash.map do |key, value|
key += ' alert-info' if key == 'notice'
key = 'danger' if key == 'alert'
content_tag(:div, class: "container alert alert-dismissable alert-#{key}") do
content_tag(:button, content_tag(:span, '', class: 'glyphicon glyphicon-remove'),
class: 'close', data: { dismiss: 'alert' }) +
simple_format(value)
end
end.join.html_safe
end
def current_event
@current_event ||= set_current_event(session[:current_event_id]) if session[:current_event_id]
end
def set_current_event(event_id)
@current_event = Event.find_by(id: event_id).try(:decorate)
session[:current_event_id] = @current_event.try(:id)
@current_event
end
end
end
end
It has some helper methods, it sets current event to be passed further down to EventFooter cell, it uses context[:controller] - set by default by Trailblazer - to deal with flash messages display and classes, and it uses context[:current_user] to set an easier access to current_user via a method rather than accessing context each time. It also uses the controller from context to set some css id.
/* app/concepts/cfp/view/base_layout.haml */
!!!5
%html(lang="en")
%head
%title= title
%meta(name="viewport" content="width=device-width, initial-scale=1.0")
= stylesheet_link_tag 'application', media: 'all'
= stylesheet_link_tag '//maxcdn.bootstrapcdn.com/font-awesome/4.6.3/css/font-awesome.min.css', media: 'all'
= stylesheet_link_tag "//fonts.googleapis.com/css?family=Open+Sans:400italic,600italic,400,600"
= javascript_include_tag 'application'
= javascript_include_tag "//www.google.com/jsapi", "chartkick"
= javascript_include_tag { yield :custom_js }
// html5 shim and respond.js ie8 support of html5 elements and media queries
/[if lt ie 9]
= javascript_include_tag "/js/html5shiv.js"
= javascript_include_tag "/js/respond.min.js"
#flash= show_flash
%body{id: body_id}
= cell(Navigation::Cell::Main, nil, context: {current_event: current_event})
.main
.main-inner
.container-fluid
=yield
= cell(Cfp::Cell::EventFooter, nil, context: {current_event: current_event})
In the view, as you can see we do some stuff we usually do in application.html, but doing so in cells gives us more flexibility and makes it easier to create a separate layout for different parts of the system. We will get to that when we create a layout for unlogged users. We also added another Navigation scope in concepts where we deal with nav bar for our view.
/* app/concepts/navigation/view/main.haml */
.navbar.navbar-default.navbar-fixed-top
.container-fluid
.navbar-header
%button.navbar-toggle{ type: "button", data: { toggle: "collapse", target: ".navbar-collapse" } }
%span.icon-bar
%span.icon-bar
%span.icon-bar
- if current_event
= link_to "#{current_event.name} CFP", event_path(current_event), class: 'navbar-brand'
- else
= link_to "CFP App", events_path, class: 'navbar-brand'
.collapse.navbar-collapse
- if current_user
%ul.nav.navbar-nav.navbar-right
- if speaker_nav?
%li{class: nav_item_class("my-proposals-link")}
%a{:href => proposals_path}
%i.fa.fa-file-text
%span My Proposals
- if review_nav?
%li{class: nav_item_class("event-review-proposals-link")}
%a{:href => event_staff_proposals_path(current_event)}
%i.fa.fa-balance-scale
%span Review
- if program_nav?
%li{class: nav_item_class("event-program-link")}
%a{:href => event_staff_program_proposals_path(current_event)}
%i.fa.fa-sitemap
%span Program
- if schedule_nav?
%li{class: nav_item_class("event-schedule-link")}
%a{:href => event_staff_schedule_grid_path(current_event)}
%i.fa.fa-calendar
%span Schedule
- if staff_nav?
%li{class: nav_item_class("event-dashboard-link")}
%a{:href => event_staff_path(current_event)}
%i.fa.fa-dashboard
%span Dashboard
- if admin_nav?
= cell(Navigation::Cell::Admin)
= cell(Navigation::Cell::NotificationsList)
= cell(Navigation::Cell::UserDropdown)
- else
%ul.nav.navbar-nav.navbar-right
%li= link_to "Log in", new_user_session_path
This is the view of our nav bar, it encapsulates the logic for all the ifs in the cell, and it renders more cells. Like NotificationsList.
# app/concepts/navigation/cell/notifications_list.rb
module Navigation
module Cell
class NotificationsList < Trailblazer::Cell
def notifications_count
context[:current_user].notifications.unread.length
end
def notifications_more_unread_count
context[:current_user].notifications.more_unread_count
end
def notifications_unread
context[:current_user].notifications.recent_unread
end
def unread_notications?
context[:current_user].notifications.unread.any?
end
def more_unread_notifications?
context[:current_user].notifications.more_unread?
end
end
end
end
See? It still uses the context that was passed way back in the controller, we only passed the current user once into the context, into a completely different cell, but we still have easy access to it. Pasting all the small cells and views would be unnecessary, and at this point, you can probably figure them out by yourself. Now lets checkout out our view.
This pretty much covers the base of refactoring legacy views, there are still more options to cells of course, but this is enough to get you started. Next up we will cover testing cells, passing collections into cells, using a different layout for different views and other cells options that will come along the way.
-
The cells gem is completely stand-alone and can be used without Trailblazer.Β β©
-
As said in the gem page, cells can be used to build a proper OOP, polymorphic builders, nesting, view inheritance, using Rails helpers, asset packaging to bundle JS, CSS or images, simple distribution via gems or Rails engines, encapsulated testing, etc.Β β©
Top comments (0)