DRY Off Your Rails Code with ActiveSupport::Concerns
If you have been building Rails applications for a while, you have likely noticed a folder called concerns
. This folder gets created inside the app/controllers
and app/models
directories whenever you generate a new Rails application. I thought it was useless until recently when we had to make use of it at work.
In this short tutorial, I want to show you how to harness the power of concerns
.
We are going to build a mini tweeting application in Rails, I will call it Twik
. In the application, we will have two TwitsController
. One controller will be namespaced under admin (so it’s actions are accessible to just admins), and the other will be in the conventional Rails namespace. We will share functionality using ActiveSuppourt::Concern
to make sure we abide by the DRY principle.
After seeing how it works for controllers, I’ll show you how to use ActiveSupport::Concern
in models.
Let’s get started.
Application Setup
Generate your Rails application:
rails new twik -T
Add the following gems
to your Gemfile
:
...
gem 'devise'
gem 'bootstrap-sass'
Now bundle install
your gems.
Run the command to install Devise:
rails g devise:install
Now let’s generate our Admin
model:
rails g devise Admin
Rename app/assets/stylesheets/application.css to app/assets/stylesheets/application.scss and paste in the following:
#app/assets/stylesheets/application.scss
@import "bootstrap-sprockets";
@import "bootstrap";
Navigate to app/assets/javascripts/application.js and add the line above the last require
:
...
//= require bootstrap-sprockets (ADD THIS)
//= require_tree .
Create the file app/views/layouts/_navigation.html.erb, and paste in the following:
#app/views/layouts/_navigation.html.erb
<nav class="navbar navbar-default">
<div class="container-fluid">
<!-- Brand and toggle get grouped for better mobile display -->
<div class="navbar-header">
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar-collapse" aria-expanded="false">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<% if admin_signed_in? %>
<%= link_to "Twik", admin_twits_path, class: "navbar-brand" %>
<% else %>
<%= link_to "Twik", root_path, class: "navbar-brand" %>
<% end %>
</div>
<div class="collapse navbar-collapse" id="navbar-collapse">
<ul class="nav navbar-nav navbar-right">
<li><%= link_to 'Home', root_path %></li>
<% if admin_signed_in? %>
<li><%= link_to 'My Account', edit_admin_registration_path %></li>
<li><%= link_to 'Logout', destroy_admin_session_path, :method => :delete %></li>
<% else %>
<li><%= link_to 'Login', new_admin_session_path %></li>
<% end %>
</ul>
</div>
</div>
</nav>
Now edit app/views/layouts/application.html.erb to look like this:
#app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
<head>
<title>Twik</title>
<%= csrf_meta_tags %>
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_include_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<%= render "layouts/navigation" %>
<div class="container-fluid">
<%= yield %>
</div>
</body>
</html>
Twits Controllers and Concerns.
We want to write as little code as possible, ensuring that we do not repeat things. To achieve this we will use concerns to share actions in both controllers. Confused? Do the following:
- Generate your Twit model:
rails g model Twit tweet:text
. - Generate your TwitsController:
rails g controller TwitsController
. - Navigate to app/controllers/concerns and create the file twitable.rb, pasting in the following:
#app/controllers/concerns/twitable.rb
module Twitable
extend ActiveSupport::Concern
included do
before_action :set_twit, only: [:show, :edit, :destroy, :update]
end
def index
@twits = Twit.all
end
def new
@twit = Twit.new
end
def show
end
def create
@twit = Twit.new(twit_params)
if @twit.save
flash[:notice] = "Successfully created twit."
redirect_to @twit
else
flash[:alert] = "Error creating twit."
render :new
end
end
private
def twit_params
params.require(:twit).permit(:tweet)
end
def set_twit
@twit = Twit.find(params[:id])
end
end
Now, let’s examine the code above.
Using extend ActiveSupport::Concern
tells Rails that we are creating a concern. The code within the included
block will be executed wherever the module is included. This is best for including third party functionality. In this case, we will get an error if the before_action
is written outside of the included
block. At this point, we are good to include our Twitable
module to the controllers that need this behavior.
With that done, our TwitsController
is very concise:
#app/controllers/twits_controllers.rb
class TwitsController < ApplicationController
include Twitable
end
We need an admin directory to house the controller for admins:
mkdir app/controllers/admin
touch app/controllers/admin/twits_controllers.rb
Now paste this code into the file you just created:
#app/controllers/admin/twits_controllers.rb
class Admin::TwitsController < ApplicationController
include Twitable
def edit
end
def update
if @twit.update_attributes(twit_params)
flash[:notice] = "Successfully updated twit."
redirect_to admin_twit_path
else
flash[:alert] = "Error creating twit."
render :edit
end
end
def destroy
if @twit.destroy
flash[:notice] = "Successfully deleted twit."
redirect_to twits_path
else
flash[:alert] = "Error deleting twit."
end
end
end
Is that not cool? We do not have to repeat our code. Now, let’s create the views, so we can test if all of these really works:
mkdir -p app/views/admin/twits
touch app/views/admin/twits/index.html.erb
touch app/views/admin/twits/new.html.erb
touch app/views/admin/twits/show.html.erb
touch app/views/admin/twits/edit.html.erb
touch app/views/twits/new.html.erb
touch app/views/twits/show.html.erb
touch app/views/twits/index.html.erb
Paste the code below in the respective files.
Admin Twit Edit Page
#app/views/admin/twits/edit.html.erb
<div class="container-fluid">
<div class="row">
<div class="col-sm-offset-4 col-sm-4 col-xs-12">
<%= form_for @twit, :url => {:controller => "twits", :action => "update" } do |f| %>
<div class="form-group">
<%= f.label :tweet %>
<%= f.text_field :tweet, class: "form-control" %>
</div>
<div class="form-group">
<%= f.submit "Update", class: "btn btn-primary" %>
<%= link_to "Cancel", :back, class: "btn btn-default" %>
</div>
<% end %>
</div>
</div>
</div>
Admin Twit Index Page
#app/views/admin/twits/index.html.erb
<div class="container-fluid">
<p id="notice"><%= notice %></p>
<h1>Listing Twits</h1>
<div class="row">
<div class="col-sm-12 col-xs-12">
<%= link_to "New Tweet", new_admin_twit_path, class: "btn btn-primary pull-right" %>
</div>
</div>
<div class="row">
<div class="col-sm-12 col-xs-12">
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover">
<tbody>
<% @twits.each do |twit| %>
<tr>
<td class="col-sm-8 col-xs-8"><%= twit.tweet %></td>
<td class="col-sm-4 col-xs-4"><%= link_to 'Show', admin_twit_path(twit), class: "btn btn-primary" %>
<%= link_to 'Edit', edit_admin_twit_path(twit), class: "btn btn-default" %>
<%= link_to "Delete", admin_twit_path(twit), class: "btn btn-danger", data: {:confirm => "Are you sure?"}, method: :delete %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
Admin New Twit Page
#app/views/admin/twits/new.html.erb
<div class="container-fluid">
<div class="row">
<div class="col-sm-offset-4 col-sm-4 col-xs-12">
<%= form_for @twit do |f| %>
<div class="form-group">
<%= f.label :tweet %>
<%= f.text_field :tweet, class: "form-control" %>
</div>
<div class="form-group">
<%= f.submit "Submit", class: "btn btn-primary" %>
<%= link_to "Cancel", :back, class: "btn btn-default" %>
</div>
<% end %>
</div>
</div>
</div>
Admin Twit Show Page
#app/views/admin/twits/show.html.erb
<div>
<h2><%= @twit.tweet %></h2>
</div>
Twit Index Page
#app/views/twits/index.html.erb
<div class="container-fluid">
<p id="notice"><%= notice %></p>
<h1>Listing Twits</h1>
<div class="row">
<div class="col-sm-12 col-xs-12">
<%= link_to "New Tweet", new_twit_path, class: "btn btn-primary pull-right" %>
</div>
</div>
<div class="row">
<div class="col-sm-12 col-xs-12">
<div class="table-responsive">
<table class="table table-striped table-bordered table-hover">
<tbody>
<% @twits.each do |twit| %>
<tr>
<td><%= twit.tweet %></td>
<td><%= link_to 'Show', twit, class: "btn btn-primary" %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
New Twit Page
#app/views/twits/new.html.erb
<div class="container-fluid">
<div class="row">
<div class="col-sm-offset-4 col-sm-4 col-xs-12">
<%= form_for @twit do |f| %>
<div class="form-group">
<%= f.label :tweet %>
<%= f.text_field :tweet, class: "form-control" %>
</div>
<div class="form-group">
<%= f.submit "Submit", class: "btn btn-primary" %>
<%= link_to "Cancel", :back, class: "btn btn-default" %>
</div>
<% end %>
</div>
</div>
</div>
Twit Show Page
#app/views/twits/show.html.erb
<div>
<h2><%= @twit.tweet %></h2>
</div>
Now start up your Rails server by running rails server
. Navigate through your website and you’ll see that everything works fine. Your codebase is neat and your controllers are thin, thanks to concern
.
Concerns in Models
ActiveSupport::Concern
works in Rails models, just like we saw in controllers. If you grasp what we did above you will be able to implement it in your models where necessary. Let’s say we have a reply
feature in our application. With this feature, users can reply to twits (duh). Alongside the reply feature, we have a voting feature allowing users to vote on twits and replies. So, now we have three models: Twit
, Reply
, and Vote
.
Twit
and Reply
have many votes
, so their models look like this:
class Twit < ActiveRecord::Base
has_many :votes, as: :votable
has_many :replies
def vote!
votes.create
end
end
class Reply < ActiveRecord::Base
has_many :votes, as: :votable
belongs_to :twits
def vote!
votes.create
end
end
class Vote < ActiveRecord::Base
belongs_to :votable, polymorphic: true
end
Using concerns, you can make things look pretty and neat. Here is how you might want to do it:
module Votable
extend ActiveSupport::Concern
included do
has_many :votes, as: :votable
end
def vote!
votes.create
end
end
class Twit < ActiveRecord::Base
include Votable
has_many :replies
end
class Reply < ActiveRecord::Base
include Votable
belongs_to :twit
end
I am sure you will agree with me that this path is much better.
Conclusion
The goal of this tutorial is simple. I simply wanted to show you a way to abide by the DRY principle using ActiveSupport::Concern
. I hope it was worth the time :)