If you've ever needed to add commenting functionality to a Rails project, you may have found yourself writing the same code over and over again. In this article, we'll show you how to create a gem that provides comments for any model in a Rails project. With this gem, you'll be able to easily add commenting functionality to any Rails project without duplicating code.
Create the gem
First, we'll create a new gem called commenter. To do this, run the following command in your terminal:
bundle gem commenter
This will generate a new gem with a basic file structure.
At present, we have the following file structure.
├── Gemfile
├── Gemfile.lock
├── commenter-0.1.0.gem
├── commenter.gemspec
├── lib
│ ├── commenter
│ │ └── version.rb
│ ├── commenter.rb
└── spec
├── commenter_spec.rb
└── spec_helper.rb
We already have the following files present in the lib directory.
# lib/commenter.rb
require_relative "commenter/version"
module Commenter
class Error < StandardError; end
end
# lib/commenter/version.rb
module Commenter
VERSION = '0.1.0'
end
Update the commenter.gemspec
file by replacing all the TODO:
statements with relevant information.
Additionally, we will need 2 dependencies for this gem.
spec.add_dependency "rails"
spec.add_dependency "rspec-rails"
First, let's include the associated comments using ActiveSupport::Concern
in the lib/commenter/commentable.rb
file.
require 'active_support/concern'
module Commenter
module Commentable
extend ActiveSupport::Concern
included do
has_many :comments, as: :commentable, dependent: :destroy
end
end
end
We will then require this file in lib/commenter.rb
.
require_relative "commenter/version"
require_relative "commenter/commentable"
module Commenter
class Error < StandardError; end
end
Next, we will create a generator to setup up the migration for creating comments in the database in the lib/generators/commenter/comment_generator.rb
file.
require 'rails/generators/active_record'
require 'rails/generators/named_base'
module Commenter
module Generators
class CommentGenerator < Rails::Generators::NamedBase
source_root File.expand_path('templates', __dir__)
def create_migration
migration_file_name = "create_comments.rb"
timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
destination = File.join('db', 'migrate', "#{timestamp}_#{migration_file_name}")
template migration_file_name, destination
end
end
end
end
Additionally, we will create a template for this migration in the lib/generators/commenter/templates/create_comments.rb
file.
class CreateComments < ActiveRecord::Migration[6.1]
def change
create_table :comments do |t|
t.text :body
t.references :commentable, polymorphic: true, null: false
t.timestamps
end
end
end
So far we have set up the gem to create a migration file in the db/migrate
folder of your Rails application with the correct timestamp. As can be seen, the migration file contains a body
attribute, timestamps
as well as references to commentable
which will be polymorphic.
Now, we will add the method to generate the model in the same generator.
# ...
module Commenter
module Generators
class CommentGenerator < Rails::Generators::NamedBase
# ...
def create_model_file
template 'comment.rb', File.join('app/models', class_path, "#{file_name}.rb")
end
end
end
end
We will also set up the template to create the model in the lib/generators/commenter/templates/comment.rb
file.
class <%= class_name %> < ApplicationRecord
belongs_to :commentable, polymorphic: true
end
Let's test
At this stage our code is ready. However, let's set up the specs to ensure that the generators and associations work fine.
For this purpose, we will add 2 more development dependencies to our gemspec.
spec.add_development_dependency "sqlite3"
spec.add_development_dependency "generator_spec"
For testing purposes, we will use the sqlite3
database.
We will also update the spec_helper.rb
file to ensure that we have all the configurations and requirements set up for testing.
require "bundler/setup"
require "commenter"
require "rails"
require "action_view"
require "action_controller"
require "rspec/rails"
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Schema.define do
create_table :commentable_models do |t|
t.timestamps
end
end
RSpec.configure do |config|
config.filter_run_when_matching :focus
config.example_status_persistence_file_path = "spec/examples.txt"
config.disable_monkey_patching!
config.default_formatter = "doc" if config.files_to_run.one?
config.order = :random
Kernel.srand config.seed
end
Here, we have established a connection with the sqlite3
and created an ActiveRecord
migration for our commentable models.
By default, we have the spec/commenter_spec.rb
file. We can keep the spec for testing the version number, and get rid of the second placeholder spec.
Next, we will create spec/commenter/commentable_spec.rb
file to test the association.
require "spec_helper"
RSpec.describe Commenter::Commentable, type: :model do
class CommentableModel < ActiveRecord::Base
include Commenter::Commentable
end
describe "associations" do
it "has many comments" do
association = CommentableModel.reflect_on_association(:comments)
expect(association.macro).to eq(:has_many)
expect(association.options[:as]).to eq(:commentable)
expect(association.options[:dependent]).to eq(:destroy)
end
end
end
Next, we set up the specs for testing the generator in the spec/generators/commenter/comment_generator_spec.rb
file.
require 'spec_helper'
require 'generator_spec'
require 'generators/commenter/comment_generator'
RSpec.describe Commenter::Generators::CommentGenerator, type: :generator do
destination File.expand_path("../../tmp", __FILE__)
arguments %w(Comment)
before do
prepare_destination
run_generator
end
it 'creates the comments migration' do
migration_path = File.join(destination_root, 'db/migrate')
expect(File.exist?(migration_path)).to be true
migration_files = Dir.entries(migration_path)
expect(migration_files).to include(/create_comments/)
end
it 'creates the comments model' do
model_path = "#{destination_root}/app/models/comment.rb"
expect(File.exist?(model_path)).to be true
model_contents = File.read(model_path)
expect(model_contents).to match(/class Comment < ApplicationRecord/)
end
after(:all) do
FileUtils.rm_rf(destination_root)
end
end
In this spec, we prepare a temporary destination for the generated files which will be deleted on completion of the spec. The first spec tests the generation of the migration file, whereas the second one tests the generation of the model.
Now, let's get the spec ready for use in a Rails application.
First, let's build the gem.
gem build commenter.gemspec
Hook it up to rails
Next, let's add the gem to our Rails application Gemfile
. At present, since we haven't uploaded it to the https://rubygems.org repository yet, let's source it locally.
gem 'commenter', path: 'path/to/commenter`
Once done, run bundle install
.
With the gem added, let's use it to generate our migration and model files.
rails generate commenter:comment Comment
This command should create our migration file in db/migrate
as well as our Comment
model in app/models
.
Next, let's create our comments_controller.rb
file.
class CommentsController < ApplicationController
before_action :set_commentable
def create
@comment = @commentable.comments.build(comment_params)
if @comment.save
redirect_to @commentable, notice: 'Comment was successfully created.'
else
redirect_to @commentable, alert: 'Error creating comment.'
end
end
def destroy
@comment = @commentable.comments.find(params[:id])
@comment.destroy
redirect_to @commentable, notice: 'Comment was successfully destroyed.'
end
private
def set_commentable
@commentable = find_commentable
end
def find_commentable
params.each do |name, value|
if name =~ /(.+)_id$/
return $1.classify.constantize.find(value)
end
end
nil
end
def comment_params
params.require(:comment).permit(:body)
end
end
The controller has 2 basic actions - create
and destroy
. Additionally, we have set up our find_commentable
method to search for the commentable class based on the associated id
provided.
We will also set up a couple of views. First, let's create a app/views/comments/_comment_form.html.erb
view to create a comment.
<%= form_with model: [commentable, Comment.new], local: true do |form| %>
<div class="form-group">
<%= form.label :body, "Comment" %>
<%= form.text_area :body, class: 'form-control' %>
</div>
<%= form.submit 'Add Comment', class: 'btn btn-primary' %>
<% end %>
Next, we create a app/views/comments/_comment.html.erb
view for each individual comment.
<div class="comment">
<p><%= comment.body %></p>
<p>
<%= button_to 'Delete Comment', [comment.commentable, comment],
method: :delete,
data: { confirm: 'Are you sure you want to delete this comment?' },
class: 'btn btn-danger btn-sm' %>
</p>
</div>
Let's assume we already have a model called Article
that has been set up inside our Rails application. We will add the comments to our Article
model.
class Article < ApplicationRecord
has_many :comments, as: :commentable, dependent: :destroy
end
Next, we will plug in our comment and form into the show page of our article.
<p style="color: green"><%= notice %></p>
<%= render @article %>
<div class="comments">
<h3>Comments</h3>
<%= render partial: 'comments/comment', collection: @article.comments %>
<%= render partial: "comments/comment_form", locals: { commentable: @article } %>
</div>
Finally, let's add comments to our article
routes.
resources :articles do
resources :comments, only: [:create, :destroy]
end
The final result
Our application is now ready to create comments. Let's spin up our Rails application and visit the show page of any of our articles e.g. http://localhost:3000/articles/1
If all has worked well so far, we should now be able to add and delete comments on this page.
Top comments (0)