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