Extending acts_as_commentable
April 12, 2008 by Dean
acts_as_commentable is a nice little ruby on rails plugin. It extends your ActiveRecord classes giving them comments. We are going to use comments on all kinds of things, starting with recipes, of course. However, AAC lacks a critical feature: the ability for users to approve comments before they are displayed. In this post I am going to run through extending AAC using acts_as_state_machine.
The first thing I did (and do to all the plugins we use) was pistonize the plugin so I could hack on it without fear of getting my changes destroyed.
I start off simply here by adding two states to the Comment model: :pending and :approved.
-
class Comment < ActiveRecord::Base
-
# The first element of this array is the initial state
-
VALID_STATES = [ :pending, :approved ]
-
acts_as_state_machine :initial => VALID_STATES[0]
-
-
event :approve do
-
transitions :from => :pending, :to => :approved
-
end
-
-
VALID_STATES.each do |_state|
-
# Define _state as a state
-
state _state
-
end
-
-
# More code snipped
-
end
Now we are going to write some real code, so here comes a little RSpec. aac provides three class methods:
-
class Comment < ActiveRecord::Base
-
class << self
-
# Helper class method to lookup all comments assigned
-
# to all commentable types for a given user.
-
def find_comments_by_user(user)
-
-
# Helper class method to look up all comments for
-
# commentable class name and commentable id.
-
def find_comments_for_commentable(commentable_str, commentable_id)
-
-
# Helper class method to look up a commentable object
-
# given the commentable class name and id
-
def find_commentable(commentable_str, commentable_id)
-
end
Since it didn’t come with Test::Unit or RSpec tests I wrote up some test for these methods.
-
describe Comment, "class methods" do
-
fixtures :comments, :recipes, :users
-
it "should find comments by user" do
-
Comment.find_comments_by_user( comments(:comment_one).user ).should all_belong_to( comments(:comment_one).user )
-
end
-
-
# This could be more specific
-
it "should find comments for a particular class" do
-
Comment.find_comments_for_commentable( Comments(:comment_one).commentable_type, comments(:comment_one).commentable_id ).should be_an_instance_of(Array)
-
end
-
-
it "should find all comments for a particular class" do
-
# I happen to know that comment_one is a recipe comment
-
Comment.find_commentable( "Recipe", comments(:comment_one).commentable_id ).should be_an_instance_of(Recipe)
-
end
-
-
end
If you are confused by should all_belong_to then you should check out my previous post. With these specs out of the way we can go on to adding more new code.
-
it "should find approved comments by user" do
-
Comment.find_approved_comments_by_user( comments(:comment_one).user ).should all_be_in_state("approved")
-
end
-
-
it "should find pending comments by user" do
-
Comment.find_pending_comments_by_user( comments(:comment_one).user ).should all_be_in_state("pending")
-
end
-
end
Now, normally you would write one spec at a time, but I think I would bore my readers, so I combined these two. Also take note that I am using another custom RSpec matcher all_be_in_state(). It looks a lot like all_belong_to(), so I leave its implementation as an exercise to the reader (unless I can get another blog post out of it). To get these tests to pass I add a few lines of code:
-
VALID_STATES.each do |_state|
-
# Define _state as a state
-
state _state
-
-
# Add Comment.find__comments methods
-
( class << self; self; end ).instance_eval do
-
define_method "find_#{_state}_comments_by_user" do |_user|
-
find_in_state( :all, _state, :conditions => ["user_id = ?", _user.id], :order => "created_at DESC" )
-
end
-
end
-
end
I am not a method_missing kind of guy, and prefer the dynamic-method metaprogramming style. This lot of code defines class methods at runtime that find Comments in specific states. I am actually using whytheluckystiff’s metaid to hide some of the meta-junk, but I thought I should spell it out here for clarity.
Well, now we have a Comment class with two states and code to limit finds to cmments in a specific state. Right now, that is all I have. Here is the full code for the Comment class and the RSpec. You will see another custom RSpec matcher here, require_a().
-
class Comment < ActiveRecord::Base
-
-
# The first element of this array is the initial state
-
VALID_STATES = [ :pending, :approved ]
-
-
acts_as_state_machine :initial => VALID_STATES[0]
-
-
belongs_to :commentable, :polymorphic => true
-
belongs_to :user
-
-
event :approve do
-
transitions :from => :pending, :to => :approved
-
end
-
-
validates_associated :user
-
validates_presence_of :comment, :commentable_id, :commentable_type, :state, :user_id
-
-
VALID_STATES.each do |_state|
-
# Define _state as a state
-
state _state
-
-
# Add Comment.find_<state>_comments methods
-
meta_def "find_#{_state}_comments_by_user" do |_user|
-
find_in_state( :all, _state, :conditions => ["user_id = ?", _user.id],
-
:order => "created_at DESC" )
-
end
-
end
-
-
class < < self
-
-
# Helper class method to look up a commentable object
-
# given the commentable class name and id
-
def find_commentable(commentable_str, commentable_id)
-
commentable_str.constantize.find(commentable_id)
-
end
-
-
# This could be refactored into find_<state>_comments_by_user (somehow)
-
def find_comments_by_user(_user)
-
find( :all, :conditions => ["user_id = ?", _user.id],
-
:order => "created_at DESC" )
-
end
-
-
# Helper class method to look up all comments for
-
# commentable class name and commentable id.
-
def find_comments_for_commentable(commentable_str, commentable_id)
-
find( :all,
-
:conditions => [ "commentable_type = ? and commentable_id = ?",
-
commentable_str, commentable_id ],
-
:order => "created_at DESC" )
-
end
-
-
end
-
-
end</state>
-
require File.dirname(__FILE__) + ‘/../../../../spec/spec_helper’
-
-
module CommentSpecHelper
-
-
end
-
-
describe Comment do
-
-
fixtures :comments
-
-
include CommentSpecHelper
-
-
before(:each) do
-
@comment = Comment.new
-
end
-
-
it "should start out in pending state" do
-
@comment.state.should == "pending"
-
end
-
-
it "sould transition to approved" do
-
@comment = comments(:pending_comment)
-
@comment.approve!
-
@comment.state.should == "approved"
-
end
-
-
it "should require a comment" do
-
@comment.should require_a(:comment)
-
end
-
-
it "should require a commentable_id" do
-
@comment.should require_a(:commentable_id)
-
end
-
-
it "should require a commentable_type" do
-
@comment.should require_a(:commentable_type)
-
end
-
-
it "should require a state" do
-
@comment.should require_a(:state)
-
end
-
-
it "should require a user_id" do
-
@comment.should require_a(:user_id)
-
end
-
-
end
-
-
describe Comment, "class methods" do
-
-
fixtures :comments, :recipes, :users
-
-
it "should find all comments for a particular class" do
-
# I happen to know that comment_one is a recipe comment
-
Comment.find_commentable( "Recipe", comments(:comment_one).commentable_id ).should be_an_instance_of(Recipe)
-
end
-
-
it "should find comments by user" do
-
Comment.find_comments_by_user( comments(:comment_one).user ).should all_belong_to( comments(:comment_one).user )
-
end
-
-
it "should find approved comments by user" do
-
Comment.find_approved_comments_by_user( comments(:comment_one).user ).should all_be_in_state("approved")
-
end
-
-
it "should find pending comments by user" do
-
Comment.find_pending_comments_by_user( comments(:comment_one).user ).should all_be_in_state("pending")
-
end
-
-
# This could be more specific
-
it "should find comments for a particular class" do
-
Comment.find_comments_for_commentable( comments(:comment_one).commentable_type, comments(:comment_one).commentable_id ).should be_an_instance_of(Array)
-
end
-
-
end
–Dean
