Tag Archives: aac

acts_as_commentable

Extending acts_as_commentable

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 &lt;&lt; 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