├── .gitignore ├── install.rb ├── uninstall.rb ├── rails └── init.rb ├── app ├── views │ ├── threaded_comments │ │ ├── remove_notifications.erb │ │ ├── new.erb │ │ ├── show.erb │ │ └── _comment_form.erb │ └── threaded_comment_notifier │ │ ├── failed_comment_creation_notification.erb │ │ ├── new_comment_notification.erb │ │ └── comment_reply_notification.erb ├── models │ ├── threaded_comment.rb │ ├── threaded_comment_notifier.rb │ └── threaded_comment_observer.rb ├── controllers │ └── threaded_comments_controller.rb └── helpers │ └── threaded_comments_helper.rb ├── test ├── setup │ ├── initialize_models.rb │ ├── initialize_routes.rb │ ├── initialize_constants.rb │ ├── config │ │ ├── database.yml │ │ └── schema.rb │ ├── initialize_database.rb │ ├── initialize_test_helper_methods.rb │ └── initialize_controllers.rb ├── factories │ ├── book_factories.rb │ └── threaded_comment_factories.rb ├── stubs │ ├── delayed_job_stubs.rb │ └── action_view_stubs.rb ├── unit │ ├── extend_actioncontroller_test.rb │ ├── config_loader_test.rb │ ├── extend_activerecord_test.rb │ ├── threaded_comment_observer_test.rb │ ├── threaded_comment_test.rb │ ├── threaded_comment_notifier_test.rb │ └── threaded_comments_helper_test.rb ├── test_helper.rb ├── performance │ └── helper_benchmark.rb └── functional │ ├── generator_test.rb │ └── threaded_comments_controller_test.rb ├── tasks └── has_threaded_comments_tasks.rake ├── generators └── install_has_threaded_comments │ ├── templates │ ├── ajax-loader.gif │ ├── upmod-arrow.gif │ ├── downmod-arrow.gif │ ├── create_threaded_comments.rb │ ├── threaded_comments_config.yml │ └── threaded_comment_styles.css │ ├── USAGE │ └── install_has_threaded_comments_generator.rb ├── lib ├── has_threaded_comments │ ├── extend_actioncontroller.rb │ └── extend_activerecord.rb └── has_threaded_comments.rb ├── config ├── initializers │ └── load_config.rb └── routes.rb ├── Rakefile ├── MIT-LICENSE ├── MORE_INFO.rdoc └── README.rdoc /.gitignore: -------------------------------------------------------------------------------- 1 | rdoc/ 2 | *.log 3 | *.db -------------------------------------------------------------------------------- /install.rb: -------------------------------------------------------------------------------- 1 | # Install hook code here 2 | -------------------------------------------------------------------------------- /uninstall.rb: -------------------------------------------------------------------------------- 1 | # Uninstall hook code here 2 | -------------------------------------------------------------------------------- /rails/init.rb: -------------------------------------------------------------------------------- 1 | require 'has_threaded_comments' -------------------------------------------------------------------------------- /app/views/threaded_comments/remove_notifications.erb: -------------------------------------------------------------------------------- 1 | <%= @message %> -------------------------------------------------------------------------------- /app/views/threaded_comments/new.erb: -------------------------------------------------------------------------------- 1 | <%= render_comment_form(@comment) %> -------------------------------------------------------------------------------- /app/views/threaded_comments/show.erb: -------------------------------------------------------------------------------- 1 | <%= render_threaded_comments([@comment]) %> -------------------------------------------------------------------------------- /test/setup/initialize_models.rb: -------------------------------------------------------------------------------- 1 | class Book < ActiveRecord::Base 2 | has_threaded_comments 3 | end -------------------------------------------------------------------------------- /test/setup/initialize_routes.rb: -------------------------------------------------------------------------------- 1 | ActionController::Routing::Routes.draw do |map| 2 | map.resources :books 3 | end -------------------------------------------------------------------------------- /tasks/has_threaded_comments_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :has_threaded_comments do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /generators/install_has_threaded_comments/templates/ajax-loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarongough/has_threaded_comments/HEAD/generators/install_has_threaded_comments/templates/ajax-loader.gif -------------------------------------------------------------------------------- /generators/install_has_threaded_comments/templates/upmod-arrow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarongough/has_threaded_comments/HEAD/generators/install_has_threaded_comments/templates/upmod-arrow.gif -------------------------------------------------------------------------------- /generators/install_has_threaded_comments/templates/downmod-arrow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aarongough/has_threaded_comments/HEAD/generators/install_has_threaded_comments/templates/downmod-arrow.gif -------------------------------------------------------------------------------- /test/factories/book_factories.rb: -------------------------------------------------------------------------------- 1 | Factory.define :book do |f| 2 | f.sequence(:title) {|n| "Book #{n}" } 3 | f.content 'Call me ishmael...' 4 | f.sequence(:email) {|n| "book#{n}@example.com" } 5 | end -------------------------------------------------------------------------------- /generators/install_has_threaded_comments/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Explain the generator 3 | 4 | Example: 5 | ./script/generate has_threaded_comments Thing 6 | 7 | This will create: 8 | what/will/it/create 9 | -------------------------------------------------------------------------------- /lib/has_threaded_comments/extend_actioncontroller.rb: -------------------------------------------------------------------------------- 1 | ActionController::Base.send(:append_after_filter, Proc.new do |controller| 2 | cookies = controller.send(:cookies) 3 | cookies[:threaded_comment_cookies_enabled] = true 4 | end) -------------------------------------------------------------------------------- /app/views/threaded_comment_notifier/failed_comment_creation_notification.erb: -------------------------------------------------------------------------------- 1 | A user tried to create a comment and encountered the following errors: 2 | 3 | * <%= @comment.errors.full_messages.join("\n* ") %> 4 | 5 | Name: <%= h @comment.name %> 6 | Email: <%= h @comment.email %> 7 | 8 | <%= h @comment.body %> -------------------------------------------------------------------------------- /lib/has_threaded_comments.rb: -------------------------------------------------------------------------------- 1 | require 'has_threaded_comments/extend_activerecord' 2 | require 'has_threaded_comments/extend_actioncontroller' 3 | require File.join(File.dirname(__FILE__), "..", "config", "initializers", "load_config.rb") 4 | 5 | ThreadedCommentObserver.instance 6 | 7 | ActionView::Base.send :include, ThreadedCommentsHelper -------------------------------------------------------------------------------- /test/stubs/delayed_job_stubs.rb: -------------------------------------------------------------------------------- 1 | module DelayedJobStubs 2 | 3 | def stub_send_later 4 | $delayed_jobs ||= [] 5 | Object.class_eval <<-EOD 6 | def send_later(*args) 7 | $delayed_jobs << 'new_delayed_job' 8 | end 9 | EOD 10 | yield 11 | Object.send(:remove_method, :send_later) 12 | end 13 | 14 | end -------------------------------------------------------------------------------- /test/unit/extend_actioncontroller_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | 3 | class ExtendActioncontrollerTest < ActiveSupport::TestCase 4 | 5 | test "ActionController::Base filter_chain should not be nil" do 6 | assert ActionController::Base.filter_chain.length > 0 7 | end 8 | 9 | end -------------------------------------------------------------------------------- /app/views/threaded_comment_notifier/new_comment_notification.erb: -------------------------------------------------------------------------------- 1 | <%= h @comment.name %> created the following comment: 2 | 3 | Name: <%= h @comment.name %> 4 | Email: <%= h @comment.email %> 5 | 6 | <%= h @comment.body %> 7 | 8 | See the comment in it's original context: 9 | <%= __send__(@comment.owner_item.class.table_name.singularize + "_url", @comment.owner_item, :host => THREADED_COMMENTS_CONFIG[:notifications][:site_domain]) + "#threaded_comment_#{@comment.id}" %> -------------------------------------------------------------------------------- /test/setup/initialize_constants.rb: -------------------------------------------------------------------------------- 1 | temp = YAML.load_file(File.join(File.dirname(__FILE__), "..", "..", "generators", "install_has_threaded_comments", "templates", "threaded_comments_config.yml")) 2 | if(!temp[RAILS_ENV].nil?) 3 | temp = temp[RAILS_ENV] 4 | else 5 | temp = temp['production'] 6 | end 7 | THREADED_COMMENTS_CONFIG = {} 8 | temp.each_pair do |key, value| 9 | THREADED_COMMENTS_CONFIG[key.to_sym] = value.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo} 10 | end 11 | -------------------------------------------------------------------------------- /config/initializers/load_config.rb: -------------------------------------------------------------------------------- 1 | load_path = File.expand_path("#{RAILS_ROOT}/config/threaded_comments_config.yml") 2 | if( File.exists?(load_path)) 3 | temp = YAML.load_file(load_path) 4 | if(!temp[RAILS_ENV].nil?) 5 | temp = temp[RAILS_ENV] 6 | else 7 | temp = temp['production'] 8 | end 9 | THREADED_COMMENTS_CONFIG = {} 10 | temp.each_pair do |key, value| 11 | THREADED_COMMENTS_CONFIG[key.to_sym] = value.inject({}){|memo,(k,v)| memo[k.to_sym] = v; memo} 12 | end 13 | end -------------------------------------------------------------------------------- /test/unit/config_loader_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | 3 | class ConfigLoaderTest < ActiveSupport::TestCase 4 | 5 | test "should loaded threaded_comments_config.yml" do 6 | assert_not_nil THREADED_COMMENTS_CONFIG 7 | assert_not_nil THREADED_COMMENTS_CONFIG[:notifications] 8 | assert_not_nil THREADED_COMMENTS_CONFIG[:render_threaded_comments] 9 | assert_not_nil THREADED_COMMENTS_CONFIG[:render_comment_form] 10 | end 11 | 12 | end -------------------------------------------------------------------------------- /test/factories/threaded_comment_factories.rb: -------------------------------------------------------------------------------- 1 | Factory.define :threaded_comment do |f| 2 | f.sequence(:id) {|n| n } 3 | f.sequence(:name) {|n| "TestCommenter#{n}" } 4 | f.sequence(:email) {|n| "commenter#{n}@example.com" } 5 | f.sequence(:body) {|n| "This is a short example comment. This comment was produced by a factory and is number: #{n}" } 6 | f.parent_id 0 7 | f.sequence(:rating) {|n| n } 8 | f.threaded_comment_polymorphic_type 'Book' 9 | f.threaded_comment_polymorphic_id 1 10 | f.created_at Time.now 11 | end -------------------------------------------------------------------------------- /test/stubs/action_view_stubs.rb: -------------------------------------------------------------------------------- 1 | module ActionViewStubs 2 | 3 | def link_to_remote(*args) 4 | if( args.last.is_a?(Hash)) 5 | url = args.last[:url] 6 | url = "/#{url[:controller]}/#{url[:action]}/#{url[:id]}" 7 | end 8 | if( args.first.is_a?(String)) 9 | "#{args.first}" 10 | else 11 | "" 12 | end 13 | end 14 | 15 | def time_ago_in_words(*args) 16 | args.first.to_s 17 | end 18 | 19 | def render(options) 20 | options 21 | end 22 | 23 | end -------------------------------------------------------------------------------- /test/setup/config/database.yml: -------------------------------------------------------------------------------- 1 | sqlite: 2 | :adapter: sqlite 3 | :database: vendor/plugins/has_threaded_comments/test/has_threaded_comments_plugin.sqlite.db 4 | 5 | sqlite3: 6 | :adapter: sqlite3 7 | :database: vendor/plugins/has_threaded_comments/test/has_threaded_comments_plugin.sqlite3.db 8 | 9 | postgresql: 10 | :adapter: postgresql 11 | :username: postgres 12 | :password: postgres 13 | :database: has_threaded_comments_plugin_test 14 | :min_messages: ERROR 15 | 16 | mysql: 17 | :adapter: mysql 18 | :host: localhost 19 | :username: root 20 | :password: password 21 | :database: has_threaded_comments_plugin_test 22 | -------------------------------------------------------------------------------- /lib/has_threaded_comments/extend_activerecord.rb: -------------------------------------------------------------------------------- 1 | module ThreadedCommentsExtension 2 | def self.included(base) 3 | base.send :extend, ClassMethods 4 | end 5 | 6 | module ClassMethods 7 | # any method placed here will apply to classes, like Book 8 | def has_threaded_comments(options = {}) 9 | has_many :comments, :as => :threaded_comment_polymorphic, :class_name => "ThreadedComment" 10 | send :include, InstanceMethods 11 | end 12 | end 13 | 14 | module InstanceMethods 15 | # any method placed here will apply to instaces, like @book 16 | end 17 | end 18 | 19 | ActiveRecord::Base.send :include, ThreadedCommentsExtension -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] = 'test' 2 | ENV['RAILS_ROOT'] ||= File.dirname(__FILE__) + '/../../../..' 3 | require File.expand_path(File.join(ENV['RAILS_ROOT'], 'config/environment.rb')) 4 | 5 | require 'test_help' 6 | require 'test/unit' 7 | require 'factory_girl' 8 | 9 | require_files = [] 10 | require_files << File.join(File.dirname(__FILE__), '..', 'rails', 'init.rb') 11 | require_files.concat Dir[File.join(File.dirname(__FILE__), 'factories', '*_factories.rb')] 12 | require_files.concat Dir[File.join(File.dirname(__FILE__), 'setup', 'initialize_*.rb')] 13 | require_files.concat Dir[File.join(File.dirname(__FILE__), 'stubs', '*_stubs.rb')] 14 | 15 | require_files.each do |file| 16 | require File.expand_path(file) 17 | end -------------------------------------------------------------------------------- /test/unit/extend_activerecord_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | 3 | class ExtendActiverecordTest < ActiveSupport::TestCase 4 | 5 | test "book schema and model has loaded correctly" do 6 | assert_difference('Book.count') do 7 | assert Book.new(Factory.attributes_for(:book)).save 8 | end 9 | end 10 | 11 | test "has_threaded_comments association" do 12 | @test_book = Book.create!(Factory.attributes_for(:book)) 13 | assert_difference('ThreadedComment.count') do 14 | assert_difference('@test_book.comments.count') do 15 | @test_book.comments.create(Factory.attributes_for(:threaded_comment)) 16 | end 17 | end 18 | end 19 | 20 | end -------------------------------------------------------------------------------- /generators/install_has_threaded_comments/templates/create_threaded_comments.rb: -------------------------------------------------------------------------------- 1 | class CreateThreadedComments < ActiveRecord::Migration 2 | def self.up 3 | create_table :threaded_comments, :force => true do |t| 4 | t.string :name, :default => "" 5 | t.text :body 6 | t.integer :rating, :default => 0 7 | t.integer :flags, :default => 0 8 | t.integer :parent_id, :default => 0 9 | t.datetime :created_at 10 | t.datetime :updated_at 11 | t.string :email, :default => "" 12 | t.boolean :notifications, :default => true 13 | 14 | t.integer :threaded_comment_polymorphic_id 15 | t.string :threaded_comment_polymorphic_type 16 | end 17 | end 18 | 19 | def self.down 20 | drop_table :threaded_comments 21 | end 22 | end -------------------------------------------------------------------------------- /test/performance/helper_benchmark.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | require 'benchmark' 3 | 4 | class HelperPerformanceTest < ActionView::TestCase 5 | 6 | include ThreadedCommentsHelper 7 | include ActionViewStubs 8 | 9 | test "render_threaded_comments performance" do 10 | puts "\n\nBenchmarking: render_threaded_comments" 11 | complex_thread = create_complex_thread(50) 12 | simple_thread = [] 13 | complex_thread.length.times do 14 | simple_thread << Factory.build(:threaded_comment) 15 | end 16 | Benchmark.bmbm do |b| 17 | b.report("Simple thread with #{simple_thread.length} comments") {render_threaded_comments(simple_thread)} 18 | b.report("Complex thread with #{complex_thread.length} comments") {render_threaded_comments(complex_thread)} 19 | end 20 | end 21 | 22 | end -------------------------------------------------------------------------------- /test/setup/initialize_database.rb: -------------------------------------------------------------------------------- 1 | config = YAML::load(IO.read(File.join(File.dirname(__FILE__), 'config', 'database.yml'))) 2 | ActiveRecord::Base.logger = Logger.new(File.join(File.dirname(__FILE__), '..','debug.log')) 3 | 4 | db_adapter = ENV['DB'] 5 | 6 | # no db passed, try one of these fine config-free DBs before bombing. 7 | db_adapter ||= 8 | begin 9 | require 'rubygems' 10 | require 'sqlite' 11 | 'sqlite' 12 | rescue MissingSourceFile 13 | begin 14 | require 'sqlite3' 15 | 'sqlite3' 16 | rescue MissingSourceFile 17 | end 18 | end 19 | 20 | if db_adapter.nil? 21 | raise "No DB Adapter selected. Pass the DB= option to pick one, or install Sqlite or Sqlite3." 22 | end 23 | 24 | ActiveRecord::Base.establish_connection(config[db_adapter]) 25 | load(File.join(File.dirname(__FILE__), 'config', 'schema.rb')) -------------------------------------------------------------------------------- /generators/install_has_threaded_comments/install_has_threaded_comments_generator.rb: -------------------------------------------------------------------------------- 1 | class InstallHasThreadedCommentsGenerator < Rails::Generator::Base 2 | def manifest 3 | record do |m| 4 | m.file "threaded_comments_config.yml", "config/threaded_comments_config.yml" 5 | m.directory "public/stylesheets" 6 | m.file "threaded_comment_styles.css", "public/stylesheets/threaded_comment_styles.css" 7 | m.directory "public/has-threaded-comments-images" 8 | m.file "downmod-arrow.gif", "public/has-threaded-comments-images/downmod-arrow.gif" 9 | m.file "upmod-arrow.gif", "public/has-threaded-comments-images/upmod-arrow.gif" 10 | m.file "ajax-loader.gif", "public/has-threaded-comments-images/ajax-loader.gif" 11 | m.migration_template "create_threaded_comments.rb", "db/migrate" 12 | end 13 | end 14 | 15 | def file_name 16 | "create_threaded_comments" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/setup/config/schema.rb: -------------------------------------------------------------------------------- 1 | # Redirect STDOUT so that the migration info is not echoed to the shell 2 | original_stdout = $stdout 3 | $stdout = File.open("/dev/null", "w") 4 | 5 | ActiveRecord::Schema.define(:version => 0) do 6 | create_table :books, :force => true do |t| 7 | t.string :title 8 | t.text :content 9 | t.string :email 10 | t.boolean :notifications, :default => true 11 | end 12 | 13 | create_table :threaded_comments, :force => true do |t| 14 | t.string :name 15 | t.text :body 16 | t.integer :rating, :default => 0 17 | t.integer :flags, :default => 0 18 | t.string :email 19 | t.boolean :notifications, :default => true 20 | t.integer :parent_id, :default => 0 21 | t.integer :threaded_comment_polymorphic_id 22 | t.string :threaded_comment_polymorphic_type 23 | t.timestamps 24 | end 25 | end 26 | 27 | # Restore STDOUT so that the rest of the tests can echo to the shell as usual 28 | $stdout = original_stdout -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rake/testtask' 3 | require 'rake/rdoctask' 4 | 5 | desc 'Default: run unit tests.' 6 | task :default => :test 7 | 8 | desc 'Test the has_threaded_comments plugin.' 9 | Rake::TestTask.new(:test) do |t| 10 | t.libs << 'lib' 11 | t.libs << 'test' 12 | t.pattern = 'test/**/*_test.rb' 13 | t.verbose = true 14 | end 15 | 16 | desc 'Test the performance of the has_threaded_comments plugin.' 17 | Rake::TestTask.new("test:performance") do |t| 18 | t.libs << 'lib' 19 | t.libs << 'test' 20 | t.pattern = 'test/**/*_benchmark.rb' 21 | t.verbose = true 22 | end 23 | 24 | desc 'Generate documentation for the has_threaded_comments plugin.' 25 | Rake::RDocTask.new(:rdoc) do |rdoc| 26 | rdoc.rdoc_dir = 'rdoc' 27 | rdoc.title = 'HasThreadedComments' 28 | rdoc.options << '--line-numbers' << '--inline-source' 29 | rdoc.rdoc_files.include('README.rdoc') 30 | rdoc.rdoc_files.include('MORE_INFO.rdoc') 31 | rdoc.rdoc_files.include('lib/**/*.rb') 32 | rdoc.rdoc_files.include('app/**/*.rb') 33 | end 34 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 Aaron Gough (http://thingsaaronmade.com/) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /app/views/threaded_comment_notifier/comment_reply_notification.erb: -------------------------------------------------------------------------------- 1 | <%= "#{@comment.name} has commented on your #{@comment.owner_item.class.table_name.singularize}: " unless(@parent_comment) -%> 2 | <%= "#{@comment.name} has replied to your comment: " if(@parent_comment) -%> 3 | 4 | 5 | "<%= h( @comment.body ) %>" 6 | 7 | 8 | To see the original <%= @comment.class.table_name.singularize -%> and all it's comments: 9 | <%= __send__(@comment.owner_item.class.table_name.singularize + "_url", @comment.owner_item, :host => THREADED_COMMENTS_CONFIG[:notifications][:site_domain]) %> 10 | 11 | To see only this comment or to reply directly to it: 12 | <%= __send__(@comment.owner_item.class.table_name.singularize + "_url", @comment.owner_item, :host => THREADED_COMMENTS_CONFIG[:notifications][:site_domain]) + "#threaded_comment_#{@comment.id}" %> 13 | 14 | ----------- 15 | Please click the link below if you don't want to receive further notifications about this comment: 16 | <%= remove_threaded_comment_notifications_url( :id => @parent_comment.id, :hash => @parent_comment.email_hash, :host => THREADED_COMMENTS_CONFIG[:notifications][:site_domain]) if( @comment.parent_id != 0 ) -%> 17 | 18 | 19 | Please do not respond to this email, this email address is not monitored. -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | ActionController::Routing::Routes.draw do |map| 2 | map.create_threaded_comment '/threaded-comments', :controller => 'threaded_comments', :action => 'create', :conditions => { :method => :post } 3 | map.new_threaded_comment '/threaded-comments/new', :controller => 'threaded_comments', :action => 'new', :conditions => { :method => :get } 4 | map.threaded_comment '/threaded-comments/:id', :controller => 'threaded_comments', :action => 'show', :conditions => { :method => :get } 5 | map.flag_threaded_comment '/threaded-comments/:id/flag', :controller => 'threaded_comments', :action => 'flag', :conditions => { :method => :post } 6 | map.upmod_threaded_comment '/threaded-comments/:id/upmod', :controller => 'threaded_comments', :action => 'upmod', :conditions => { :method => :post } 7 | map.downmod_threaded_comment '/threaded-comments/:id/downmod', :controller => 'threaded_comments', :action => 'downmod', :conditions => { :method => :post } 8 | map.remove_threaded_comment_notifications '/threaded-comments/:id/remove-notifications/:hash', :controller => 'threaded_comments', :action => 'remove_notifications', :conditions => { :method => :get } 9 | end -------------------------------------------------------------------------------- /app/models/threaded_comment.rb: -------------------------------------------------------------------------------- 1 | class ThreadedComment < ActiveRecord::Base 2 | 3 | require 'digest/md5' 4 | 5 | validates_presence_of :threaded_comment_polymorphic_id, :threaded_comment_polymorphic_type, :parent_id 6 | validates_length_of :name, :within => 2..18 7 | validates_length_of :body, :within => 30..2000 8 | validates_text_content :body if( ActiveRecord::Base.respond_to?('validates_text_content')) 9 | validates_length_of :email, :minimum => 6 10 | validates_format_of :email, :with => /.*@.*\./ 11 | 12 | belongs_to :threaded_comment_polymorphic, :polymorphic => true 13 | alias owner_item threaded_comment_polymorphic 14 | 15 | before_validation :assign_owner_info_to_nested_comment 16 | 17 | attr_accessible :name, :body, :email, :parent_id, :threaded_comment_polymorphic_id, :threaded_comment_polymorphic_type 18 | 19 | def assign_owner_info_to_nested_comment 20 | unless( self[:parent_id].nil? || self[:parent_id] == 0 ) 21 | parentComment = ThreadedComment.find(self[:parent_id]) 22 | self[:threaded_comment_polymorphic_id] = parentComment.threaded_comment_polymorphic_id 23 | self[:threaded_comment_polymorphic_type] = parentComment.threaded_comment_polymorphic_type 24 | end 25 | self[:parent_id] = 0 if( self[:parent_id].nil? ) 26 | end 27 | 28 | def email_hash 29 | return Digest::MD5.hexdigest("#{self.email}-#{self.created_at}") 30 | end 31 | 32 | end -------------------------------------------------------------------------------- /test/setup/initialize_test_helper_methods.rb: -------------------------------------------------------------------------------- 1 | # Test helper method for easily creating a complex threaded_comment 2 | # structure for use in other tests. Returns an array of comments 3 | # the same way that ACtiveRecord would. 4 | 5 | def create_complex_thread(length=100) 6 | comments = [] 7 | length.times do 8 | comments << parent_comment = Factory.build(:threaded_comment) 9 | 3.times do 10 | comments << subcomment1 = Factory.build(:threaded_comment, :parent_id => parent_comment.id) 11 | 2.times do 12 | comments << subcomment2 = Factory.build(:threaded_comment, :parent_id => subcomment1.id) 13 | 2.times do 14 | comments << subcomment3 = Factory.build(:threaded_comment, :parent_id => subcomment2.id) 15 | end 16 | end 17 | end 18 | end 19 | comments 20 | end 21 | 22 | # Test helper method for temporarily changing the value of a 23 | # configuration option. Eg: 24 | # 25 | # change_config_option(:render_threaded_comments, :enable_flagging, false) do 26 | # # some code that requires flagging to be disabled by default 27 | # end 28 | 29 | def change_config_option(namespace, key, value, &block) 30 | old_config = THREADED_COMMENTS_CONFIG.dup 31 | old_stderr = $stderr 32 | $stderr = StringIO.new 33 | THREADED_COMMENTS_CONFIG[namespace][key] = value 34 | $stderr = old_stderr 35 | yield block 36 | ensure 37 | $stderr = StringIO.new 38 | Kernel.const_set('THREADED_COMMENTS_CONFIG', old_config) 39 | $stderr = old_stderr 40 | end -------------------------------------------------------------------------------- /app/models/threaded_comment_notifier.rb: -------------------------------------------------------------------------------- 1 | class ThreadedCommentNotifier < ActionMailer::Base 2 | 3 | def new_comment_notification( comment ) 4 | recipients THREADED_COMMENTS_CONFIG[:notifications][:admin_email] 5 | from THREADED_COMMENTS_CONFIG[:notifications][:system_send_email_address] 6 | subject THREADED_COMMENTS_CONFIG[:notifications][:new_comment_subject] 7 | body :comment => comment 8 | end 9 | 10 | def comment_reply_notification( user_email, comment ) 11 | recipients user_email 12 | from THREADED_COMMENTS_CONFIG[:notifications][:system_send_email_address] 13 | if( comment.parent_id == 0 ) 14 | subject THREADED_COMMENTS_CONFIG[:notifications][:comment_reply_subject].gsub("{name}", comment.name) 15 | else 16 | body :parent_comment => ThreadedComment.find( comment.parent_id ), :comment => comment 17 | subject "#{comment.name} has replied to your comment" 18 | return 19 | end 20 | body :comment => comment 21 | end 22 | 23 | def failed_comment_creation_notification( comment ) 24 | if(THREADED_COMMENTS_CONFIG[:notifications][:enable_comment_creation_failure_notifications]) 25 | recipients THREADED_COMMENTS_CONFIG[:notifications][:admin_email] 26 | from THREADED_COMMENTS_CONFIG[:notifications][:system_send_email_address] 27 | subject THREADED_COMMENTS_CONFIG[:notifications][:failed_comment_creation_subject] 28 | body :comment => comment 29 | end 30 | end 31 | 32 | end -------------------------------------------------------------------------------- /MORE_INFO.rdoc: -------------------------------------------------------------------------------- 1 | === API Documentation 2 | 3 | To get more details API documentation for has_threaded_comments run 'rake rdoc' in the root directory 4 | of the plugin after you have installed it. 5 | 6 | === Generated Files 7 | 8 | When has_threaded_comments is fully installed it adds the following files to your application: 9 | 10 | * config/threaded_comments_connfig.yml 11 | * db/migrate/xxxxxxxxxxxxxx_create_threaded_comments.rb 12 | * public/stylesheets/threaded_comment_styles.css 13 | * public/has-threaded-comments-images/downmod-arrow.gif 14 | * public/has-threaded-comments-images/upmod-arrow.gif 15 | 16 | === Interfaces 17 | 18 | has_threaded_comments makes the following interfaces available to your application: 19 | 20 | Models: 21 | * ThreadedComment 22 | * ThreadedCommentObserver 23 | * ThreadedCommentNotifier 24 | 25 | Controllers: 26 | * ThreadedCommentsController 27 | 28 | Helpers: 29 | * render_threaded_comments 30 | * render_comment_form 31 | 32 | Routes: 33 | threaded_comment_index POST /threaded-comments(.:format) 34 | new_threaded_comment GET /threaded-comments/new(.:format) 35 | threaded_comment GET /threaded-comments/:id(.:format) 36 | flag_threaded_comment POST /threaded-comments/:id/flag 37 | upmod_threaded_comment POST /threaded-comments/:id/upmod 38 | downmod_threaded_comment POST /threaded-comments/:id/downmod 39 | remove_threaded_comment_notifications GET /threaded-comments/:id/remove-notifications/:hash -------------------------------------------------------------------------------- /generators/install_has_threaded_comments/templates/threaded_comments_config.yml: -------------------------------------------------------------------------------- 1 | production: 2 | notifications: 3 | enable_notifications: true 4 | enable_comment_creation_failure_notifications: true 5 | site_domain: yoursite.com 6 | admin_email: admin@yoursite.com 7 | system_send_email_address: noreply@yoursite.com 8 | new_comment_subject: Yoursite.com - New comment 9 | failed_comment_creation_subject: Yoursite.com - Failed comment creation 10 | comment_reply_subject: Yoursite.com - {name} has replied to your comment 11 | 12 | render_threaded_comments: 13 | enable_rating: true 14 | enable_flagging: true 15 | flag_message: Are you really sure you want to flag this comment? 16 | reply_link_text: Reply 17 | max_indent: 3 18 | flag_threshold: 3 19 | header_separator: " - " 20 | no_comments_message: There aren't any comments yet, be the first to comment! 21 | 22 | render_comment_form: 23 | name_label: Name
24 | email_label: Email (so we can notify you when someone replies to your comment)
25 | body_label: 2000 characters max. HTML is not allowed.
26 | submit_title: Add Comment 27 | partial: threaded_comments/comment_form 28 | honeypot_name: confirm_email 29 | 30 | # If the config for a particular environment is not found, the default environment settings (production) 31 | # will be used. This makes it much easier to setup multiple environments with the same settings. 32 | # You can setup multiple environments by adding new entries with the appropriate top-level name eg: 33 | # 34 | # test: 35 | # notifications: 36 | # enable_notifications: true 37 | # enable_comment_creation_failure_notifications: true 38 | # site_domain: yoursite.com 39 | # admin_email: admin@yoursite.com 40 | # ... 41 | # ... 42 | 43 | -------------------------------------------------------------------------------- /app/views/threaded_comments/_comment_form.erb: -------------------------------------------------------------------------------- 1 | <% remove_submit_script = <<-EOD 2 | var submitButton = document.getElementById('threaded_comment_submit_#{timestamp}'); 3 | var loadingDiv = document.createElement('div'); 4 | loadingDiv.className = 'threaded_comment_loading'; 5 | submitButton.parentNode.appendChild(loadingDiv); 6 | submitButton.parentNode.removeChild(submitButton); 7 | EOD 8 | 9 | remove_message_script = <<-EOD 10 | message = document.getElementById('no_comments_message'); 11 | message.parentNode.removeChild(message); 12 | EOD 13 | %> 14 | 15 |
16 | <% remote_form_for( comment, :url => {:controller => "threaded_comments", :action => "create"}, :update => 'new_threaded_comment_' + timestamp, :after => remove_submit_script, :complete => remove_message_script ) do |f| %> 17 | <%= f.error_messages %> 18 | 19 | <%= f.hidden_field :threaded_comment_polymorphic_id -%> 20 | <%= f.hidden_field :threaded_comment_polymorphic_type -%> 21 | <%= f.hidden_field :parent_id -%> 22 | 23 |

24 | <%= label_tag "threaded_comment[#{honeypot_name}]", 'Do not provide this unless you are a robot!' %>
25 | <%= text_field_tag "threaded_comment[#{honeypot_name}]" %> 26 |

27 | 28 |

29 | <%= f.label :name, name_label, :class => 'comment_name_label' -%> 30 | <%= f.text_field :name, :class => 'threaded_comment_name_input' %> 31 |

32 | 33 |

34 | <%= f.label :email, email_label, :class => 'comment_email_label' -%> 35 | <%= f.text_field :email, :class => 'threaded_comment_email_input' %> 36 |

37 | 38 |

39 | <%= f.label :body, body_label, :class => "comment_body_label" -%> 40 | <%= f.text_area :body, :class => 'threaded_comment_body_input' %> 41 |

42 | 43 |

44 | <%= f.submit submit_title, :class => 'threaded_comment_submit_input', :id => "threaded_comment_submit_#{timestamp}" %> 45 |

46 | <% end %> 47 |
-------------------------------------------------------------------------------- /app/models/threaded_comment_observer.rb: -------------------------------------------------------------------------------- 1 | class ThreadedCommentObserver < ActiveRecord::Observer 2 | def after_create( threaded_comment ) 3 | return unless(THREADED_COMMENTS_CONFIG[:notifications][:enable_notifications]) 4 | if(ThreadedCommentNotifier.respond_to?(:send_later)) 5 | # Send admin notification 6 | ThreadedCommentNotifier.send_later(:deliver_new_comment_notification, threaded_comment ) 7 | 8 | # Send user notifications if notifications are enabled 9 | # for the parent comment 10 | if(threaded_comment.parent_id == 0 || threaded_comment.parent_id.nil?) 11 | if( threaded_comment.threaded_comment_polymorphic.respond_to?(:notifications) && threaded_comment.threaded_comment_polymorphic.respond_to?(:email)) 12 | ThreadedCommentNotifier.send_later(:deliver_comment_reply_notification, threaded_comment.threaded_comment_polymorphic.email, threaded_comment ) if( threaded_comment.threaded_comment_polymorphic.notifications ) 13 | end 14 | else 15 | parent_comment = ThreadedComment.find( threaded_comment.parent_id ) 16 | ThreadedCommentNotifier.send_later(:deliver_comment_reply_notification, parent_comment.email, threaded_comment ) if( parent_comment.notifications ) 17 | end 18 | else 19 | # Send admin notification 20 | ThreadedCommentNotifier.deliver_new_comment_notification( threaded_comment ) 21 | 22 | # Send user notifications if notifications are enabled 23 | # for the parent comment 24 | if(threaded_comment.parent_id == 0 || threaded_comment.parent_id.nil?) 25 | if( threaded_comment.threaded_comment_polymorphic.respond_to?(:notifications) && threaded_comment.threaded_comment_polymorphic.respond_to?(:email)) 26 | ThreadedCommentNotifier.deliver_comment_reply_notification( threaded_comment.threaded_comment_polymorphic.email, threaded_comment ) if( threaded_comment.threaded_comment_polymorphic.notifications ) 27 | end 28 | else 29 | parent_comment = ThreadedComment.find( threaded_comment.parent_id ) 30 | ThreadedCommentNotifier.deliver_comment_reply_notification( parent_comment.email, threaded_comment ) if( parent_comment.notifications ) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/setup/initialize_controllers.rb: -------------------------------------------------------------------------------- 1 | class BooksController < ApplicationController 2 | # GET /books 3 | # GET /books.xml 4 | def index 5 | @books = Book.all 6 | 7 | respond_to do |format| 8 | format.html # index.html.erb 9 | format.xml { render :xml => @books } 10 | end 11 | end 12 | 13 | # GET /books/1 14 | # GET /books/1.xml 15 | def show 16 | @book = Book.find(params[:id], :include => {:comments => []}) 17 | @new_comment = @book.comments.new(:name => session[:name], :email => session[:email]) 18 | 19 | respond_to do |format| 20 | format.html # show.html.erb 21 | format.xml { render :xml => @book } 22 | end 23 | end 24 | 25 | # GET /books/new 26 | # GET /books/new.xml 27 | def new 28 | @book = Book.new 29 | 30 | respond_to do |format| 31 | format.html # new.html.erb 32 | format.xml { render :xml => @book } 33 | end 34 | end 35 | 36 | # GET /books/1/edit 37 | def edit 38 | @book = Book.find(params[:id]) 39 | end 40 | 41 | # POST /books 42 | # POST /books.xml 43 | def create 44 | @book = Book.new(params[:book]) 45 | 46 | respond_to do |format| 47 | if @book.save 48 | flash[:notice] = 'Book was successfully created.' 49 | format.html { redirect_to(@book) } 50 | format.xml { render :xml => @book, :status => :created, :location => @book } 51 | else 52 | format.html { render :action => "new" } 53 | format.xml { render :xml => @book.errors, :status => :unprocessable_entity } 54 | end 55 | end 56 | end 57 | 58 | # PUT /books/1 59 | # PUT /books/1.xml 60 | def update 61 | @book = Book.find(params[:id]) 62 | 63 | respond_to do |format| 64 | if @book.update_attributes(params[:book]) 65 | flash[:notice] = 'Book was successfully updated.' 66 | format.html { redirect_to(@book) } 67 | format.xml { head :ok } 68 | else 69 | format.html { render :action => "edit" } 70 | format.xml { render :xml => @book.errors, :status => :unprocessable_entity } 71 | end 72 | end 73 | end 74 | 75 | # DELETE /books/1 76 | # DELETE /books/1.xml 77 | def destroy 78 | @book = Book.find(params[:id]) 79 | @book.destroy 80 | 81 | respond_to do |format| 82 | format.html { redirect_to(books_url) } 83 | format.xml { head :ok } 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/unit/threaded_comment_observer_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | 3 | class ThreadedCommentObserverTest < ActiveSupport::TestCase 4 | 5 | include DelayedJobStubs 6 | 7 | def setup 8 | @test_book = Book.create!(Factory.attributes_for(:book)) 9 | @test_parent_comment = @test_book.comments.create!(Factory.attributes_for(:threaded_comment)) 10 | end 11 | 12 | test "should observe comment creation and send notifications" do 13 | assert_difference("ActionMailer::Base.deliveries.length", 2) do 14 | @test_book.comments.create(Factory.attributes_for(:threaded_comment)) 15 | end 16 | end 17 | 18 | test "should observe comment creation and send delayed notifications" do 19 | stub_send_later do 20 | assert_difference("$delayed_jobs.length", 2) do 21 | @test_book.comments.create(Factory.attributes_for(:threaded_comment)) 22 | end 23 | end 24 | end 25 | 26 | test "should observe subcomment creation and send notifications" do 27 | assert_difference("ActionMailer::Base.deliveries.length", 2) do 28 | @test_book.comments.create!(Factory.attributes_for(:threaded_comment, :parent_id => @test_parent_comment.id)) 29 | end 30 | end 31 | 32 | test "should observe subcomment creation and send delayed notifications" do 33 | stub_send_later do 34 | assert_difference("$delayed_jobs.length", 2) do 35 | @test_book.comments.create!(Factory.attributes_for(:threaded_comment, :parent_id => @test_parent_comment.id)) 36 | end 37 | end 38 | end 39 | 40 | test "should only send one notification after subcomment creation on comment with notifications = false" do 41 | @test_parent_comment.notifications = false 42 | @test_parent_comment.save 43 | assert_difference("ActionMailer::Base.deliveries.length", 1) do 44 | @test_book.comments.create!(Factory.attributes_for(:threaded_comment, :parent_id => @test_parent_comment.id)) 45 | end 46 | end 47 | 48 | test "should only send one delayed notification after subcomment creation on comment with notifications = false" do 49 | stub_send_later do 50 | @test_parent_comment.notifications = false 51 | @test_parent_comment.save 52 | assert_difference("$delayed_jobs.length", 1) do 53 | @test_book.comments.create!(Factory.attributes_for(:threaded_comment, :parent_id => @test_parent_comment.id)) 54 | end 55 | end 56 | end 57 | 58 | end 59 | -------------------------------------------------------------------------------- /test/functional/generator_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | require 'rails_generator' 3 | require 'rails_generator/scripts/generate' 4 | 5 | class GeneratorTest < Test::Unit::TestCase 6 | 7 | def setup 8 | FileUtils.mkdir_p(fake_rails_root) 9 | FileUtils.mkdir_p(File.join(fake_rails_root, 'config')) 10 | FileUtils.mkdir_p(File.join(fake_rails_root, 'db', 'migrate')) 11 | FileUtils.mkdir_p(File.join(fake_rails_root, 'public', 'stylesheets')) 12 | FileUtils.mkdir_p(File.join(fake_rails_root, 'public', 'has-threaded-comments-images')) 13 | end 14 | 15 | def teardown 16 | FileUtils.rm_r(fake_rails_root) 17 | end 18 | 19 | def test_generates_threaded_comments_config 20 | @original_files = file_list('config') 21 | Rails::Generator::Scripts::Generate.new.run(["install_has_threaded_comments"], :destination => fake_rails_root, :quiet => true) 22 | new_file = (file_list('config') - @original_files).first 23 | assert_equal "threaded_comments_config.yml", File.basename(new_file) 24 | end 25 | 26 | def test_generates_threaded_comments_migration 27 | @original_files = file_list('db', 'migrate') 28 | Rails::Generator::Scripts::Generate.new.run(["install_has_threaded_comments"], :destination => fake_rails_root, :quiet => true) 29 | new_file = (file_list('db', 'migrate') - @original_files).first 30 | assert new_file.index('create_threaded_comments') 31 | end 32 | 33 | def test_generates_threaded_comments_styles_stylesheet 34 | @original_files = file_list('public', 'stylesheets') 35 | Rails::Generator::Scripts::Generate.new.run(["install_has_threaded_comments"], :destination => fake_rails_root, :quiet => true) 36 | new_file = (file_list('public', 'stylesheets') - @original_files).first 37 | assert_equal "threaded_comment_styles.css", File.basename(new_file) 38 | end 39 | 40 | def test_adds_images 41 | @original_files = file_list('public', 'has-threaded-comments-images') 42 | Rails::Generator::Scripts::Generate.new.run(["install_has_threaded_comments"], :destination => fake_rails_root, :quiet => true) 43 | new_files = (file_list('public', 'has-threaded-comments-images') - @original_files) 44 | assert_equal 3, new_files.length 45 | new_files.sort! 46 | assert_equal "ajax-loader.gif", File.basename(new_files.first) 47 | assert_equal "downmod-arrow.gif", File.basename(new_files[1]) 48 | assert_equal "upmod-arrow.gif", File.basename(new_files.last) 49 | end 50 | 51 | private 52 | 53 | def fake_rails_root 54 | File.join(File.dirname(__FILE__), 'rails_root') 55 | end 56 | 57 | def file_list(*path) 58 | Dir.glob(File.join(fake_rails_root, path, '*')) 59 | end 60 | 61 | end -------------------------------------------------------------------------------- /app/controllers/threaded_comments_controller.rb: -------------------------------------------------------------------------------- 1 | class ThreadedCommentsController < ActionController::Base 2 | 3 | before_filter :was_action_already_performed, :only => [:flag, :upmod, :downmod] 4 | 5 | # GET /threaded-comments 6 | def new 7 | @comment = ThreadedComment.new(params[:threaded_comment]) 8 | @comment.name = session[:name] unless( session[:name].nil? ) 9 | @comment.email = session[:email] unless( session[:email].nil? ) 10 | render :layout => false 11 | end 12 | 13 | # GET /threaded-comments/1 14 | def show 15 | if(ThreadedComment.exists?(params[:id])) 16 | @comment = ThreadedComment.find(params[:id]) 17 | render :layout => false and return 18 | end 19 | head :bad_request 20 | end 21 | 22 | # POST /threaded-comments 23 | def create 24 | head :status => :bad_request and return if check_honeypot( 'threaded_comment' ) 25 | if( !params[:threaded_comment][:parent_id].nil? && params[:threaded_comment][:parent_id].to_i > 0 && !ThreadedComment.exists?(params[:threaded_comment][:parent_id])) 26 | flash[:notice] = "The comment you were trying to comment on no longer exists." 27 | head :status => :bad_request and return 28 | end 29 | @comment = ThreadedComment.new(params[:threaded_comment]) 30 | if( @comment.save ) 31 | session[:name] = @comment.name 32 | session[:email] = @comment.email 33 | render :action => 'show', :layout => false 34 | else 35 | render :action => 'new', :layout => false, :status => :bad_request 36 | end 37 | end 38 | 39 | # POST /threaded-comments/1/upmod 40 | def upmod 41 | begin 42 | @comment = ThreadedComment.find(params[:id]) 43 | render :text => @comment.rating.to_s and return if(@comment.increment!('rating')) 44 | rescue ActiveRecord::RecordNotFound 45 | head :error 46 | end 47 | end 48 | 49 | # POST /threaded-comments/1/downmod 50 | def downmod 51 | begin 52 | @comment = ThreadedComment.find(params[:id]) 53 | render :text => @comment.rating.to_s and return if(@comment.decrement!('rating')) 54 | rescue ActiveRecord::RecordNotFound 55 | head :error 56 | end 57 | end 58 | 59 | # POST /threaded-comments/1/flag 60 | def flag 61 | begin 62 | @comment = ThreadedComment.find(params[:id]) 63 | render :text => "Thanks!" and return if(@comment.increment!('flags')) 64 | rescue ActiveRecord::RecordNotFound 65 | head :error 66 | end 67 | end 68 | 69 | # GET /threaded-comments/1/remove-notifications 70 | def remove_notifications 71 | @message = "The comment you are looking for has been removed or is incorrect." and render :action => 'remove_notifications' and return unless( ThreadedComment.exists?(params[:id])) 72 | @comment = ThreadedComment.find(params[:id]) 73 | @message = "The information you provided does not match this comment." and render :action => 'remove_notifications' and return unless( params[:hash] == @comment.email_hash ) 74 | @message = "Thank-you. Your email (#{@comment.email}) has been removed." 75 | @comment.notifications = false 76 | @comment.save 77 | render :action => 'remove_notifications' 78 | end 79 | 80 | private 81 | 82 | def was_action_already_performed 83 | if( session["/threaded-comments/#{params[:id]}/#{params[:action]}"].nil? && !cookies[:threaded_comment_cookies_enabled].nil? ) 84 | session["/threaded-comments/#{params[:id]}/#{params[:action]}"] = true 85 | else 86 | head :status => :bad_request and return 87 | end 88 | end 89 | 90 | def check_honeypot( form_name, honeypot = "confirm_email" ) 91 | unless( params[form_name][honeypot].nil? || (params[form_name][honeypot].length == 0) ) 92 | return true 93 | end 94 | params[form_name].delete( honeypot ) 95 | return false 96 | end 97 | 98 | end -------------------------------------------------------------------------------- /test/unit/threaded_comment_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | require 'digest/md5' 3 | 4 | class ThreadedCommentTest < ActiveSupport::TestCase 5 | 6 | def setup 7 | @test_book = Book.create!(Factory.attributes_for(:book)) 8 | end 9 | 10 | test "threaded comment should be created" do 11 | assert_difference('ThreadedComment.count') do 12 | ThreadedComment.create!(Factory.attributes_for(:threaded_comment)) 13 | end 14 | end 15 | 16 | test "threaded comment should not be created without name" do 17 | assert_no_difference('ThreadedComment.count') do 18 | ThreadedComment.create(Factory.attributes_for(:threaded_comment, :name => nil)) 19 | end 20 | end 21 | 22 | test "threaded comment should not be create without body" do 23 | assert_no_difference('ThreadedComment.count') do 24 | ThreadedComment.create(Factory.attributes_for(:threaded_comment, :body => nil)) 25 | end 26 | end 27 | 28 | test "threaded comment should not be created without email" do 29 | assert_no_difference('ThreadedComment.count') do 30 | ThreadedComment.create(Factory.attributes_for(:threaded_comment, :email => nil)) 31 | end 32 | end 33 | 34 | test "threaded comment should not be created with junk email" do 35 | assert_no_difference('ThreadedComment.count') do 36 | ThreadedComment.create(Factory.attributes_for(:threaded_comment, :email => "asasdasdas")) 37 | end 38 | end 39 | 40 | test "threaded sub-comment should be created and associated with it's correct parent" do 41 | assert_difference('ThreadedComment.count', 2) do 42 | @test_comment = @test_book.comments.create!(Factory.attributes_for(:threaded_comment)) 43 | @test_subcomment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment, :parent_id => @test_comment.id, :threaded_comment_polymorphic_id => nil, :threaded_comment_polymorphic_type => nil)) 44 | @test_subcomment.reload 45 | assert_equal @test_comment.threaded_comment_polymorphic_id, @test_subcomment.threaded_comment_polymorphic_id 46 | assert_equal @test_comment.threaded_comment_polymorphic_type, @test_subcomment.threaded_comment_polymorphic_type 47 | end 48 | end 49 | 50 | test "threaded comment with nil parent_id defaults to zero" do 51 | assert_difference('ThreadedComment.count') do 52 | @test_comment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment)) 53 | @test_comment.reload 54 | assert_equal 0, @test_comment.parent_id 55 | end 56 | end 57 | 58 | test "threaded comment with empty parent_id defaults to zero" do 59 | assert_difference('ThreadedComment.count') do 60 | @test_comment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment, :parent_id => "")) 61 | @test_comment.reload 62 | assert_equal 0, @test_comment.parent_id 63 | end 64 | end 65 | 66 | test "threaded comment rating should not be able to be set via mass assignment" do 67 | assert_difference('ThreadedComment.count') do 68 | @test_comment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment, :rating => 20)) 69 | @test_comment.reload 70 | assert_equal 0, @test_comment.rating 71 | end 72 | end 73 | 74 | test "threaded comment flags should not be able to be set via mass assignment" do 75 | assert_difference('ThreadedComment.count') do 76 | @test_comment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment, :flags => 20)) 77 | @test_comment.reload 78 | assert_equal 0, @test_comment.flags 79 | end 80 | end 81 | 82 | test "threaded comment email hash creation" do 83 | assert_difference('ThreadedComment.count') do 84 | @test_comment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment)) 85 | assert_equal Digest::MD5.hexdigest("#{@test_comment.email}-#{@test_comment.created_at}"), @test_comment.email_hash 86 | end 87 | end 88 | 89 | test "owner_item should alias threaded_comment_polymorphic" do 90 | @test_comment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment)) 91 | assert_equal @test_comment.owner_item, @test_comment.threaded_comment_polymorphic 92 | end 93 | end -------------------------------------------------------------------------------- /generators/install_has_threaded_comments/templates/threaded_comment_styles.css: -------------------------------------------------------------------------------- 1 | /************************************************ 2 | Styles for displaying comment threads 3 | *************************************************/ 4 | .threaded_comment_loading{ 5 | width: 100px; 6 | height: 32px; 7 | background: transparent url(/has-threaded-comments-images/ajax-loader.gif) center center no-repeat; 8 | } 9 | 10 | .threaded_comment_container{ 11 | width: 100%; 12 | position: relative; 13 | padding-bottom: 10px; 14 | } 15 | 16 | .threaded_comment_container a{ 17 | border: none; 18 | outline: none; 19 | } 20 | 21 | .fade_level_1 .threaded_comment_body{ 22 | -moz-opacity: 0.80; 23 | -webkit-opacity: 0.80; 24 | -ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=80)"; 25 | filter: alpha(opacity=80); 26 | opacity: 0.80; 27 | } 28 | 29 | .fade_level_2 .threaded_comment_body{ 30 | -moz-opacity: 0.60; 31 | -webkit-opacity: 0.60; 32 | -ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=60)"; 33 | filter: alpha(opacity=60); 34 | opacity: 0.60; 35 | } 36 | 37 | .fade_level_3 .threaded_comment_body{ 38 | -moz-opacity: 0.35; 39 | -webkit-opacity: 0.35; 40 | -ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=35)"; 41 | filter: alpha(opacity=35); 42 | opacity: 0.35; 43 | } 44 | 45 | .fade_level_4 .threaded_comment_body{ 46 | -moz-opacity: 0.12; 47 | -webkit-opacity: 0.12; 48 | -ms-filter:"progid:DXImageTransform.Microsoft.Alpha(Opacity=12)"; 49 | filter: alpha(opacity=12); 50 | opacity: 0.12; 51 | } 52 | 53 | .threaded_comment_container_header{ 54 | color: #656565; 55 | font-size: 90%; 56 | } 57 | 58 | .threaded_comment_rating_container{ 59 | display: block; 60 | width: 65px; 61 | height: 20px; 62 | float: left; 63 | } 64 | 65 | .upmod_threaded_comment{ 66 | position: absolute; 67 | left: 0px; 68 | top: 0px; 69 | display: block; 70 | width: 20px; 71 | height: 20px; 72 | background: transparent url(/has-threaded-comments-images/upmod-arrow.gif) top left no-repeat; 73 | } 74 | 75 | .threaded_comment_rating_text{ 76 | display: block; 77 | width: 20px; 78 | height: 20px; 79 | line-height: 20px; 80 | text-align: center; 81 | position: absolute; 82 | top: 0px; 83 | left: 20px; 84 | } 85 | 86 | .downmod_threaded_comment{ 87 | position: absolute; 88 | left: 40px; 89 | top: 0px; 90 | display: block; 91 | width: 20px; 92 | height: 20px; 93 | background: transparent url(/has-threaded-comments-images/downmod-arrow.gif) top left no-repeat; 94 | } 95 | 96 | .threaded_comment_name{ 97 | color: #2a2a2a; 98 | font-size: 110%; 99 | } 100 | 101 | .threaded_comment_age{ 102 | 103 | } 104 | 105 | .threaded_comment_link{ 106 | text-decoration: none; 107 | color: #656565; 108 | } 109 | 110 | .threaded_comment_link:hover{ 111 | text-decoration: underline; 112 | } 113 | 114 | .flag_threaded_comment_container{ 115 | 116 | } 117 | 118 | .flag_threaded_comment_container a{ 119 | text-decoration: none; 120 | color: #656565; 121 | } 122 | 123 | .flag_threaded_comment_container a:hover{ 124 | text-decoration: underline; 125 | } 126 | 127 | .threaded_comment_body{ 128 | margin: 0; 129 | } 130 | 131 | .threaded_comment_body p{ 132 | margin: 5px 0 3px 0; 133 | } 134 | 135 | .threaded_comment_reply_container{ 136 | 137 | } 138 | 139 | .threaded_comment_reply_container a{ 140 | text-decoration: none 141 | } 142 | 143 | .threaded_comment_reply_container a:hover{ 144 | text-decoration: underline; 145 | } 146 | 147 | .threaded_comment_container_footer{ 148 | } 149 | 150 | .subcomment_container{ 151 | padding-left: 30px; 152 | } 153 | 154 | .subcomment_container_no_indent{ 155 | } 156 | 157 | /* prevent indenting of subcomments that are within a comment 158 | that has indenting disabled */ 159 | .subcomment_container_no_indent .subcomment_container{ 160 | padding-left: 0; 161 | } 162 | 163 | /************************************************ 164 | Styles for displaying the new comment form 165 | *************************************************/ 166 | .new_threaded_comment_form{ 167 | 168 | } 169 | 170 | .new_threaded_comment_email_confirm{ 171 | /* this is to hide the honeypot field */ 172 | position: absolute; 173 | left: -20000px; 174 | top: -20000px; 175 | } 176 | 177 | .new_threaded_comment_name{ 178 | 179 | } 180 | 181 | .threaded_comment_name_input{ 182 | width: 200px; 183 | } 184 | 185 | .new_threaded_comment_email{ 186 | 187 | } 188 | 189 | .threaded_comment_email_input{ 190 | width: 200px; 191 | } 192 | 193 | .new_threaded_comment_body{ 194 | 195 | } 196 | 197 | .threaded_comment_body_input{ 198 | width: 100%; 199 | height: 130px; 200 | } 201 | 202 | .new_threaded_comment_submit{ 203 | 204 | } 205 | 206 | .threaded_comment_submit_input{ 207 | 208 | } 209 | 210 | /************************************************ 211 | Styles for form errors 212 | *************************************************/ 213 | .fieldWithErrors input, .fieldWithErrors textarea{ 214 | margin-top: 4px; 215 | outline: red dashed 2px; 216 | } 217 | 218 | .errorExplanation{ 219 | margin-top: 15px; 220 | outline: red dashed 2px; 221 | padding: 10px 20px; 222 | background: #f2ebeb; 223 | } -------------------------------------------------------------------------------- /test/unit/threaded_comment_notifier_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | 3 | class ThreadedCommentNotifierTest < ActionMailer::TestCase 4 | 5 | def setup 6 | @test_book = Book.create!(Factory.attributes_for(:book)) 7 | @test_comment = @test_book.comments.create!(Factory.attributes_for(:threaded_comment, :threaded_comment_polymorphic_id => nil, :threaded_comment_polymorphic_type => nil)) 8 | end 9 | 10 | test "should send new comment notification" do 11 | assert_difference("ActionMailer::Base.deliveries.length", 1) do 12 | @email = ThreadedCommentNotifier.deliver_new_comment_notification( @test_comment ) 13 | end 14 | assert_equal [THREADED_COMMENTS_CONFIG[:notifications][:admin_email]], @email.to 15 | assert_equal [THREADED_COMMENTS_CONFIG[:notifications][:system_send_email_address]], @email.from 16 | assert @email.subject.index( "New" ), "Email subject did not include 'New':\n#{@email.subject}" 17 | assert @email.body.index( @test_comment.body ), "Email did not include comment body:\n#{@email.body}" 18 | assert @email.body.index( @test_comment.name ), "Email did not include comment name:\n#{@email.body}" 19 | assert @email.body.index( @test_comment.email ), "Email did not include comment email address:\n#{@email.body}" 20 | assert @email.body.index( THREADED_COMMENTS_CONFIG[:notifications][:site_domain] + "/books/#{@test_comment.owner_item.id}#threaded_comment_" + @test_comment.id.to_s ), "Email did not include link to comment:\n#{@email.body}" 21 | end 22 | 23 | test "should send user comment reply notification" do 24 | assert_difference("ActionMailer::Base.deliveries.length", 1) do 25 | @email = ThreadedCommentNotifier.deliver_comment_reply_notification( 'test@test.com', @test_comment ) 26 | end 27 | assert_equal [THREADED_COMMENTS_CONFIG[:notifications][:system_send_email_address]], @email.from 28 | assert @email.body.index( @test_comment.body ), "Email did not include comment body:\n#{@email.body}" 29 | assert @email.body.index( @test_comment.name ), "Email did not include comment name:\n#{@email.body}" 30 | assert_nil @email.body.index( @test_comment.email ), "Email should not include comment email address:\n#{@email.body}" 31 | assert @email.body.index( THREADED_COMMENTS_CONFIG[:notifications][:site_domain] + "/books/#{@test_comment.owner_item.id}\n" ), "Email did not include link to comment parent item:\n#{@email.body}" 32 | assert @email.body.index( THREADED_COMMENTS_CONFIG[:notifications][:site_domain] + "/books/#{@test_comment.owner_item.id}#threaded_comment_" + @test_comment.id.to_s ), "Email did not include link to comment:\n#{@email.body}" 33 | end 34 | 35 | test "should send user subcomment reply notification" do 36 | @test_subcomment = @test_book.comments.create!(Factory.attributes_for(:threaded_comment, :threaded_comment_polymorphic_id => nil, :threaded_comment_polymorphic_type => nil, :parent_id => @test_comment.id)) 37 | assert_difference("ActionMailer::Base.deliveries.length", 1) do 38 | @email = ThreadedCommentNotifier.deliver_comment_reply_notification( @test_comment.email, @test_subcomment ) 39 | end 40 | assert_equal [THREADED_COMMENTS_CONFIG[:notifications][:system_send_email_address]], @email.from 41 | assert @email.body.index( @test_subcomment.body ), "Email did not include comment body:\n#{@email.body}" 42 | assert @email.body.index( @test_subcomment.name ), "Email did not include comment name:\n#{@email.body}" 43 | assert_nil @email.body.index( @test_subcomment.email ), "Email should not include comment email address:\n#{@email.body}" 44 | assert @email.body.index( THREADED_COMMENTS_CONFIG[:notifications][:site_domain] + "/books/#{@test_subcomment.owner_item.id}\n" ), "Email did not include link to comment parent item:\n#{@email.body}" 45 | assert @email.body.index( THREADED_COMMENTS_CONFIG[:notifications][:site_domain] + "/books/#{@test_subcomment.owner_item.id}#threaded_comment_" + @test_subcomment.id.to_s ), "Email did not include link to comment:\n#{@email.body}" 46 | removal_link = THREADED_COMMENTS_CONFIG[:notifications][:site_domain] + "/threaded-comments/#{@test_comment.id}/remove-notifications/#{@test_comment.email_hash}" 47 | assert @email.body.index(removal_link), "Email did not include notification removal link:\n\n#{removal_link}\n\n#{@email.body}" 48 | end 49 | 50 | test "should send failed comment notification" do 51 | assert_difference("ActionMailer::Base.deliveries.length", 1) do 52 | @email = ThreadedCommentNotifier.deliver_failed_comment_creation_notification( @test_comment ) 53 | end 54 | assert_equal [THREADED_COMMENTS_CONFIG[:notifications][:admin_email]], @email.to 55 | assert_equal [THREADED_COMMENTS_CONFIG[:notifications][:system_send_email_address]], @email.from 56 | assert @email.subject.index( "Failed" ), "Email subject did not include 'Failed'" 57 | assert @email.body.index( @test_comment.body ), "Email did not include comment body" 58 | assert @email.body.index( @test_comment.name ), "Email did not include comment name" 59 | assert @email.body.index( @test_comment.email ), "Email did not include comment email address" 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /app/helpers/threaded_comments_helper.rb: -------------------------------------------------------------------------------- 1 | module ThreadedCommentsHelper 2 | 3 | def render_threaded_comments(comments, options={}) 4 | options = { 5 | :indent_level => 0, 6 | :base_indent => 0, 7 | :parent_id => 0, 8 | :bucketed => false 9 | }.merge(THREADED_COMMENTS_CONFIG[:render_threaded_comments].dup).merge(options) 10 | 11 | return '
' + options[:no_comments_message] + '
' unless(comments.length > 0) 12 | unless(options[:bucketed]) 13 | comments = comments.delete_if{|comment| (comment.flags > options[:flag_threshold]) && (options[:flag_threshold] > 0) } 14 | comments = sort_comments(comments) 15 | options[:parent_id] = comments.first.parent_id if(comments.length == 1) 16 | comments = bucket_comments(comments) 17 | end 18 | return '' if( comments[options[:parent_id]].nil? ) 19 | ret = '' 20 | this_indent = " " * (options[:base_indent] + options[:indent_level]) 21 | 22 | comments[options[:parent_id]].each do |comment| 23 | ret << this_indent << "\n" 24 | ret << this_indent << "
-5)}#{"4" if(comment.rating < -4)}\" >\n" 25 | ret << this_indent << "
\n" 26 | if(options[:enable_rating]) 27 | ret << this_indent << "
\n" 28 | ret << this_indent << " #{link_to_remote('', :url => {:controller => 'threaded_comments', :action => 'upmod', :id => comment.id}, :method => :post, :html => {:class=> 'upmod_threaded_comment'}, :update => { :success => 'threaded_comment_rating_' + comment.id.to_s })}\n" 29 | ret << this_indent << " #{comment.rating}\n" 30 | ret << this_indent << " #{link_to_remote('', :url => {:controller => 'threaded_comments', :action => 'downmod', :id => comment.id}, :method => :post, :html => {:class => 'downmod_threaded_comment'}, :update => { :success => 'threaded_comment_rating_' + comment.id.to_s })}\n" 31 | ret << this_indent << "
\n" 32 | end 33 | ret << this_indent << " By: #{h comment.name}#{options[:header_separator]}\n" 34 | ret << this_indent << " #{ time_ago_in_words( comment.created_at ) } ago#{options[:header_separator]}\n" 35 | ret << this_indent << " permalink#{options[:header_separator] if(options[:enable_flagging])}\n" 36 | if(options[:enable_flagging]) 37 | ret << this_indent << " \n" 38 | ret << this_indent << " #{link_to_remote('flag', :url => {:controller => 'threaded_comments', :action => 'flag', :id => comment.id}, :method => :post, :confirm => options[:flag_message], :update => { :success => 'flag_threaded_comment_container_' + comment.id.to_s, :failure => 'does_not_exist'})}\n" 39 | ret << this_indent << " \n" 40 | end 41 | ret << this_indent << "
\n" 42 | ret << this_indent << "
#{simple_format(h(comment.body))}
\n" 43 | ret << this_indent << "
\n" 44 | ret << this_indent << " #{link_to_remote(options[:reply_link_text], :url => {:controller => 'threaded_comments', :action => 'new', :threaded_comment => {:parent_id => comment.id, :threaded_comment_polymorphic_id => comment.threaded_comment_polymorphic_id, :threaded_comment_polymorphic_type => comment.threaded_comment_polymorphic_type}}, :method => :get, :class=> 'comment_reply_link', :update => 'subcomment_container_' + comment.id.to_s, :position => :top, :success => "$('threaded_comment_reply_container_#{comment.id}').remove()")}\n" 45 | ret << this_indent << "
\n" 46 | ret << this_indent << "
\n" 47 | ret << this_indent << "
\n" 48 | 49 | ret << this_indent << "
= options[:max_indent])}\" id=\"subcomment_container_#{comment.id}\">\n" 50 | ret << render_threaded_comments( comments, options.merge({:parent_id => comment.id, :indent_level => options[:indent_level] + 1, :bucketed => true })) unless( comments[comment.id].nil? ) 51 | ret << this_indent << "
\n" 52 | end 53 | return ret 54 | end 55 | 56 | def render_comment_form(comment, options={}) 57 | options = { 58 | :timestamp => Time.now.to_i.to_s, 59 | :comment => comment 60 | }.merge(THREADED_COMMENTS_CONFIG[:render_comment_form].dup).merge(options) 61 | render :partial => options[:partial], :locals => options 62 | end 63 | 64 | private 65 | 66 | def sort_comments(comments) 67 | comments.sort {|a,b| 68 | ((b.rating.to_f + 1.0) / ((((Time.now - b.created_at) / 3600).to_f + 0.5) ** 1.25)) <=> ((a.rating.to_f + 1.0) / ((((Time.now - a.created_at) / 3600).to_f + 0.5) ** 1.25)) 69 | } 70 | end 71 | 72 | def bucket_comments(comments) 73 | bucketed_comments = [] 74 | comments.each do |comment| 75 | bucketed_comments[comment.parent_id] = [] if( bucketed_comments[comment.parent_id].nil? ) 76 | bucketed_comments[comment.parent_id] << comment 77 | end 78 | return bucketed_comments 79 | end 80 | 81 | end 82 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = has_threaded_comments 2 | 3 | Feature highlights: 4 | * Only 7 lines of code need to be added to your application! 5 | * Complete comment system including UI 6 | * Minimal barrier to entry for users (no captchas or account creation required) 7 | * Encourages continuing community participation via comment rating, popularity based sorting, and comment reply notifications 8 | * Discourages bad behavior via fading of negatively rated comments, and user flagging of comments 9 | * Automatic comment moderation using validates_text_content (optional) 10 | 11 | has_threaded_comments is a Rails plugin that provides an entire threaded commenting 12 | system with the addition of only a few lines of code to your application. The system includes 13 | support for rating and flagging of comments as well as automatically generating email 14 | notifications (for both users and website admin) of replies to comments. Additionally 15 | if you have {delayed_job}[http://github.com/tobi/delayed_job] setup then has_threaded_comments 16 | will automatically make use of that to send email notifications in a asynchronous manner. 17 | 18 | has_threaded_comments does not require the user be logged in to create a comment, in 19 | keeping with the idea of keeping the barrier to entry as low as possible. It uses 20 | reverse captchas (honeypots) to foil spambots. 21 | 22 | Additionally has_threaded_comments will use {validates_text_content}[http://github.com/aarongough/validates_text_content] to provide automatic 23 | content moderation if the {validates_text_content}[http://github.com/aarongough/validates_text_content] plugin has been installed. 24 | 25 | If you have any questions or find an issue with this plugin please contact me at: mailto:aaron@aarongough.com 26 | 27 | === Examples 28 | 29 | To see an installation of has_threaded_comments in the wild check out the comment system at: {WhyIAmAngry.com}[http://whyiamangry.com/] 30 | 31 | === Installation 32 | 33 | To install the plugin use one of the following commands: 34 | 35 | # To install via Git using script/plugin: 36 | ./script/plugin install git://github.com/aarongough/has_threaded_comments.git 37 | 38 | # To install via SVN using script/plugin 39 | ./script/plugin install http://svn.github.com/aarongough/has_threaded_comments.git 40 | 41 | # If your application is not under version control you can still install using a SVN export: 42 | ./script/plugin install -e http://svn.github.com/aarongough/has_threaded_comments.git 43 | 44 | Then you need to copy the configuration files, database migration and UI files into your application like so: 45 | 46 | ./script/generate install_has_threaded_comments 47 | 48 | === Usage 49 | 50 | Then follow these steps to use the comment system in your application: 51 | 52 | 1. Make sure you you have no pending database migrations 53 | 54 | rake db:migrate 55 | 56 | 2. Add the has_threaded_comments declaration to your model 57 | 58 | # app/models/book.rb 59 | class Book < ActiveRecord::Base 60 | has_threaded_comments 61 | end 62 | 63 | 3. Add the code for eager-loading comments and generating a blank comment to your controller 64 | 65 | # app/controllers/books_controller.rb 66 | def show 67 | @book = Book.find(params[:id], :include => {:comments => []}) 68 | @new_comment = @book.comments.new(:name => session[:name], :email => session[:email]) 69 | end 70 | 71 | 4. Add the code for rendering the comments and the new comment form to your 'show' view 72 | 73 | # app/views/books/show.html.erb 74 |
75 | <%= render_threaded_comments(@book.comments) -%> 76 | <%= render_comment_form(@new_comment) -%> 77 |
78 | 79 | 5. Add threaded_comment_styles.css and prototype.js to your layout 80 | 81 | # app/views/layouts/books.html.erb 82 | 83 | <%= javascript_include_tag :defaults -%> 84 | <%= stylesheet_link_tag 'threaded_comment_styles' -%> 85 | 86 | 87 | 6. Change the settings in config/threaded_comments_config.yml according to the needs of your application, eg: 88 | 89 | # config/threaded_comments_config.yml 90 | production: 91 | notifications: 92 | enable_notifications: true 93 | enable_comment_creation_failure_notifications: true 94 | site_domain: yoursite.com 95 | admin_email: admin@yoursite.com 96 | system_send_email_address: noreply@yoursite.com 97 | new_comment_subject: Yoursite.com - New comment 98 | failed_comment_creation_subject: Yoursite.com - Failed comment creation 99 | comment_reply_subject: Yoursite.com - {name} has replied to your comment 100 | 101 | render_threaded_comments: 102 | enable_rating: true 103 | enable_flagging: true 104 | ... 105 | ... 106 | 107 | 7. Enjoy! 108 | 109 | === Upgrading 110 | 111 | Upgrading from a previous version of has_threaded_comments is easy! 112 | 113 | # Run this from your application's root directory 114 | ./script/plugin remove has_threaded_comments 115 | 116 | # Then re-follow the instructions listed above in the 'installation' section 117 | 118 | If you have made changes to any of the has_threaded_comments config or asset files the generator 119 | will ask you whether or not to overwrite them. It's recommended to backup all these files, overwrite 120 | them with the new versions and then make any changes you need to. Older versions of the config files 121 | will cause errors with newer plugin code unless they are updated. 122 | 123 | === More info 124 | 125 | Please refer to MORE_INFO.rdoc 126 | 127 | === Author & Credits 128 | 129 | Author:: {Aaron Gough}[mailto:aaron@aarongough.com] 130 | Contributors:: {Julio Capote}[http://github.com/capotej], {Noah Litvin}[http://github.com/noahlitvin] 131 | 132 | Copyright (c) 2010 {Aaron Gough}[http://thingsaaronmade.com/] ({thingsaaronmade.com}[http://thingsaaronmade.com/]), released under the MIT license -------------------------------------------------------------------------------- /test/functional/threaded_comments_controller_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | 3 | class ThreadedCommentsControllerTest < ActionController::TestCase 4 | 5 | def setup 6 | @test_book = Book.create!(Factory.attributes_for(:book)) 7 | ThreadedComment.create!(Factory.attributes_for(:threaded_comment)) 8 | @request.cookies['threaded_comment_cookies_enabled'] = CGI::Cookie.new('threaded_comment_cookies_enabled', 'true') 9 | end 10 | 11 | test "should get show" do 12 | @test_comment = ThreadedComment.new(Factory.attributes_for(:threaded_comment, :parent_id => 0)) 13 | @test_comment.save 14 | get :show, :id => @test_comment.id 15 | assert_response :success, @response.body 16 | assert_not_nil assigns(:comment) 17 | assert @response.body.index(@test_comment.name), "Did not include comment name" 18 | assert @response.body.index(@test_comment.body), "Did not include comment body" 19 | assert @response.body.index(upmod_threaded_comment_path(@test_comment)), "Did not include link to upmod" 20 | assert @response.body.index(downmod_threaded_comment_path(@test_comment)), "Did not include link to downmod" 21 | assert @response.body.index(flag_threaded_comment_path(@test_comment)), "Did not include link to flag" 22 | assert @response.body.index(new_threaded_comment_path), "Did not include link to new" 23 | end 24 | 25 | test "show should not display threaded comments with flags greater than flag_threshold" do 26 | @test_comment = ThreadedComment.new(Factory.attributes_for(:threaded_comment, :name => "Flagged Commenter")) 27 | @test_comment.flags = 99999999 28 | @test_comment.save 29 | get :show, :id => @test_comment.id 30 | assert_response :success, @response.body 31 | assert_not_nil assigns(:comment) 32 | assert_nil @response.body.index(@test_comment.name), "Should not include comment name" 33 | assert_nil @response.body.index(@test_comment.body), "Should not include comment body" 34 | end 35 | 36 | test "should create comment" do 37 | assert_difference('ThreadedComment.count') do 38 | @test_comment = Factory.attributes_for(:threaded_comment) 39 | put :create, :threaded_comment => @test_comment 40 | assert_response :success 41 | assert @response.body.index(@test_comment[:name]), "Did not include comment name" 42 | assert @response.body.index(@test_comment[:body]), "Did not include comment body" 43 | end 44 | end 45 | 46 | test "should create sub-comment" do 47 | @test_parent_comment = @test_book.comments.create!(Factory.attributes_for(:threaded_comment)) 48 | @test_comment = Factory.attributes_for(:threaded_comment, :parent_id => @test_parent_comment.id.to_s) 49 | assert_difference('ThreadedComment.count') do 50 | put :create, :threaded_comment => @test_comment 51 | assert_response :success 52 | assert @response.body.index(@test_comment[:name]), "Did not include comment name" 53 | assert @response.body.index(@test_comment[:body]), "Did not include comment body" 54 | end 55 | end 56 | 57 | test "should not create comment if negative captcha is filled" do 58 | assert_no_difference('ThreadedComment.count') do 59 | put :create, :threaded_comment => Factory.attributes_for(:threaded_comment, :confirm_email => "test@example.com") 60 | end 61 | assert_response :bad_request 62 | end 63 | 64 | test "should get new" do 65 | session[:name] = "Test Name" 66 | session[:email] = "Test Name" 67 | @test_comment = Factory.attributes_for(:threaded_comment, :name => nil, :email => nil, :parent_id => "2") 68 | get :new, :threaded_comment => @test_comment 69 | assert_response :success 70 | assert_not_nil assigns(:comment) 71 | assert @response.body.include?(session[:name]), "Response body did not include commenter name" 72 | assert @response.body.include?(session[:email]), "Response body did not include commenter email" 73 | assert @response.body.include?(@test_comment[:body]), "Response body did not include body" 74 | assert @response.body.include?(@test_comment[:threaded_comment_polymorphic_id].to_s), "Response body did not include threaded_comment_polymorphic_id" 75 | assert @response.body.include?(@test_comment[:threaded_comment_polymorphic_type]), "Response body did not include threaded_comment_polymorphic_type" 76 | assert @response.body.include?(@test_comment[:parent_id]), "Response body did not include parent_id" 77 | assert @response.body.include?("threaded_comment[name]"), "Response body did not include form for name" 78 | assert @response.body.include?("threaded_comment[body]"), "Response body did not include form for body" 79 | assert @response.body.include?("threaded_comment[email]"), "Response body did not include form for email" 80 | assert @response.body.include?("threaded_comment[threaded_comment_polymorphic_id]"), "Response body did not include form for threaded_comment_polymorphic_id" 81 | assert @response.body.include?("threaded_comment[threaded_comment_polymorphic_type]"), "Response body did not include form for threaded_comment_polymorphic_type" 82 | assert @response.body.include?("threaded_comment[parent_id]"), "Response body did not include form for parent_id" 83 | assert @response.body.include?("threaded_comment[#{THREADED_COMMENTS_CONFIG[:render_comment_form][:honeypot_name]}]"), "Response body did not include honeypot form" 84 | assert @response.body.include?(THREADED_COMMENTS_CONFIG[:render_comment_form][:name_label]), "Response body did not include name label" 85 | assert @response.body.include?(THREADED_COMMENTS_CONFIG[:render_comment_form][:email_label]), "Response body did not include email label" 86 | assert @response.body.include?(THREADED_COMMENTS_CONFIG[:render_comment_form][:body_label]), "Response body did not include body label" 87 | assert @response.body.include?(THREADED_COMMENTS_CONFIG[:render_comment_form][:submit_title]), "Response body did not include submit title" 88 | assert @response.body.include?('removeChild(message)'), "Response body did not include javascript callback for removing no_comments_message" 89 | end 90 | 91 | test "should upmod comment" do 92 | assert_difference('ThreadedComment.find(1).rating') do 93 | post :upmod, :id => 1 94 | assert_response :success 95 | assert @response.body.index(@expected_rating.to_s), "Response body did not include new rating" 96 | end 97 | end 98 | 99 | test "upmodding non-existant comment should cause error" do 100 | post :upmod, :id => 9999999 101 | assert_response :error 102 | end 103 | 104 | test "should downmod comment" do 105 | assert_difference('ThreadedComment.find(1).rating', -1) do 106 | post :downmod, :id => 1 107 | assert_response :success 108 | assert @response.body.index(@expected_rating.to_s), "Response body did not include new rating" 109 | end 110 | end 111 | 112 | test "downmodding non-existant comment should cause error" do 113 | post :downmod, :id => 9999999 114 | assert_response :error 115 | end 116 | 117 | test "should flag comment" do 118 | assert_difference('ThreadedComment.find(1).flags') do 119 | post :flag, :id => 1 120 | assert_response :success 121 | end 122 | end 123 | 124 | test "flagging non-existant comment should cause error" do 125 | post :flag, :id => 9999999 126 | assert_response :error 127 | end 128 | 129 | test "should only allow rating or flagging once per action per session" do 130 | @actions = [ 131 | { :action => 'flag', :field => 'flags', :difference => 1}, 132 | { :action => 'upmod', :field => 'rating', :difference => 1}, 133 | { :action => 'downmod', :field => 'rating', :difference => -1} 134 | ] 135 | @actions.each do |action| 136 | test_comment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment)) 137 | assert_difference("test_comment.#{action[:field]}", action[:difference], "Action failed first time: #{action[:action]}") do 138 | put action[:action], :id => test_comment.id 139 | assert_response :success 140 | test_comment.reload 141 | end 142 | assert_no_difference( "test_comment.#{action[:field]}", "Action succeeded when it should have failed: #{action[:action]}") do 143 | put action[:action], :id => test_comment.id 144 | assert_response :bad_request 145 | test_comment.reload 146 | end 147 | end 148 | end 149 | 150 | test "actions should fail if cookies are disabled" do 151 | @request.cookies['threaded_comment_cookies_enabled'] = nil 152 | @actions = [ 153 | { :action => 'flag', :field => 'flags', :difference => 1}, 154 | { :action => 'upmod', :field => 'rating', :difference => 1}, 155 | { :action => 'downmod', :field => 'rating', :difference => -1} 156 | ] 157 | @actions.each do |action| 158 | test_comment = ThreadedComment.create!(Factory.attributes_for(:threaded_comment)) 159 | assert_no_difference("test_comment.#{action[:field]}", "Action failed first time: #{action[:action]}") do 160 | put action[:action], :id => test_comment.id 161 | assert_response :bad_request 162 | test_comment.reload 163 | end 164 | end 165 | end 166 | 167 | test "should remove email notifications if hash matches" do 168 | test_comment = ThreadedComment.find(1) 169 | assert !test_comment.email.empty? 170 | assert test_comment.notifications == true 171 | get :remove_notifications, :id => 1, :hash => test_comment.email_hash 172 | assert_response :success 173 | test_comment.reload 174 | assert !test_comment.email.empty? 175 | assert test_comment.notifications == false 176 | assert @response.body.index( "removed" ), "Removal notice was not included in response body" 177 | end 178 | 179 | test "should not remove email notifications if hash does not match" do 180 | test_comment = ThreadedComment.find(1) 181 | assert !test_comment.email.empty? 182 | assert test_comment.notifications == true 183 | get :remove_notifications, :id => 1, :hash => test_comment.email_hash + "1" 184 | assert_response :success 185 | test_comment.reload 186 | assert !test_comment.email.empty? 187 | assert test_comment.notifications == true 188 | assert @response.body.index( "The information you provided does not match" ), "Failure notice was not included in response body" 189 | end 190 | end -------------------------------------------------------------------------------- /test/unit/threaded_comments_helper_test.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.join(File.dirname(__FILE__), '..', 'test_helper.rb')) 2 | 3 | class ThreadedCommentsHelperTest < ActionView::TestCase 4 | 5 | include ThreadedCommentsHelper 6 | include ActionViewStubs 7 | 8 | def setup 9 | @test_book = Book.create!(Factory.attributes_for(:book)) 10 | @test_comments = create_complex_thread(2) 11 | @test_comment = Factory.build(:threaded_comment) 12 | @rendered_html = render_threaded_comments(@test_comments) 13 | end 14 | 15 | test "render_threaded_comments should output no_comments_message when comments.length = 0" do 16 | @rendered_html = render_threaded_comments([]) 17 | assert @rendered_html.include?(THREADED_COMMENTS_CONFIG[:render_threaded_comments][:no_comments_message]), "The 'no comments' message was not included" 18 | assert @rendered_html.include?('id="no_comments_message"'), "The no_comments_message container was not included" 19 | end 20 | 21 | test "render_threaded_comments should output comment names" do 22 | @test_comments.each do |comment| 23 | assert @rendered_html.include?(comment.name), "Did not include comment name" 24 | end 25 | end 26 | 27 | test "render_threaded_comments should escape comment names" do 28 | test_comment = Factory.build(:threaded_comment, :name => "<> Aaron") 29 | rendered_html = render_threaded_comments([test_comment]) 30 | assert rendered_html.include?(h(test_comment.name)), "Did not escape comment name" 31 | end 32 | 33 | test "render_threaded_comments should output comment bodies" do 34 | @test_comments.each do |comment| 35 | assert @rendered_html.include?(comment.body), "Did not include comment body" 36 | end 37 | end 38 | 39 | test "render_threaded_comments should escape comment bodies" do 40 | test_comment = Factory.build(:threaded_comment, :body => "<> Aaron") 41 | rendered_html = render_threaded_comments([test_comment]) 42 | assert rendered_html.include?(h(test_comment.body)), "Did not escape comment body" 43 | end 44 | 45 | test "render_threaded_comments should output comment creation times" do 46 | @test_comments.each do |comment| 47 | assert @rendered_html.include?(time_ago_in_words(comment.created_at)), "Did not include comment creation time" 48 | end 49 | end 50 | 51 | test "render_threaded_comments should output anchor for each comment" do 52 | @test_comments.each do |comment| 53 | assert @rendered_html.include?("threaded_comment_#{comment.id}"), "Did not include anchor for comment" 54 | end 55 | end 56 | 57 | test "render_threaded_comments should output subcomment container for each comment" do 58 | @test_comments.each do |comment| 59 | assert @rendered_html.include?("subcomment_container_#{comment.id}"), "Did not include subcomment container for comment" 60 | end 61 | end 62 | 63 | test "render_threaded_comments options and config" do 64 | test_option "rating text", :enable_rating, "threaded_comment_rating_:id" 65 | test_option "upmod button", :enable_rating, link_to_remote('', :url => {:controller => "threaded_comments", :action => "upmod", :id => ":id"}) 66 | test_option "downmod button", :enable_rating, link_to_remote('', :url => {:controller => "threaded_comments", :action => "downmod", :id => ":id"}) 67 | test_option "flag button", :enable_flagging, link_to_remote('flag', :url => {:controller => "threaded_comments", :action => "flag", :id => ":id"}) 68 | test_option "flag button container", :enable_flagging, "flag_threaded_comment_container_:id" 69 | test_option "reply link text", :reply_link_text, "Reply" 70 | end 71 | 72 | test "render_threaded_comments should not overwrite global config when options are set" do 73 | old_config = old_config = THREADED_COMMENTS_CONFIG.dup 74 | @rendered_html = render_threaded_comments(@test_comments, :enable_flagging => false) 75 | assert_equal old_config, THREADED_COMMENTS_CONFIG 76 | end 77 | 78 | test "render_threaded_comments should not mark comments with more than max_indent ancestors as indented" do 79 | 10.times do |max_indent| 80 | @rendered_html = render_threaded_comments(@test_comments, :max_indent => max_indent) 81 | @test_comments.each do |comment| 82 | ancestors = 0 83 | if(comment.parent_id > 0) 84 | parent_comment = @test_comments[comment.parent_id - @test_comments.first.id] 85 | ancestors += 1 86 | assert_equal comment.parent_id, parent_comment.id 87 | until(parent_comment.parent_id == 0) do 88 | parent_comment = @test_comments[parent_comment.parent_id - @test_comments.first.id] 89 | ancestors += 1 90 | end 91 | end 92 | subcomment_container_position = @rendered_html.index("subcomment_container_#{comment.id}") 93 | assert_not_nil subcomment_container_position 94 | @subcomment_html = @rendered_html.slice(subcomment_container_position - 100, 200) 95 | assert @subcomment_html.include?('class="subcomment_container"'), "Expecting 'class=\"subcomment_container\"':\n" + @subcomment_html if(ancestors < max_indent) 96 | assert @subcomment_html.include?('class="subcomment_container_no_indent"'), "Expecting 'class=\"subcomment_container_no_indent\"':\n" + @subcomment_html if(ancestors >= max_indent) 97 | end 98 | end 99 | end 100 | 101 | test "render_threaded_comments should not mark comments with a rating of 5 with fade_level" do 102 | test_comment = Factory.build(:threaded_comment, :rating => 5) 103 | @rendered_html = render_threaded_comments([test_comment]) 104 | assert !@rendered_html.include?("fade_level"), "Comment should not be marked with fade level" 105 | end 106 | 107 | test "render_threaded_comments should not mark comments with a rating of 0 with fade_level" do 108 | test_comment = Factory.build(:threaded_comment, :rating => 0) 109 | @rendered_html = render_threaded_comments([test_comment]) 110 | assert !@rendered_html.include?("fade_level"), "Comment should not be marked with fade level" 111 | end 112 | 113 | test "render_threaded_comments should mark comments with a rating of -1 with fade_level_1" do 114 | test_comment = Factory.build(:threaded_comment, :rating => -1) 115 | @rendered_html = render_threaded_comments([test_comment]) 116 | assert @rendered_html.include?("fade_level_1"), "Comment was not marked with appropriate fade level" 117 | end 118 | 119 | test "render_threaded_comments should mark comments with a rating of -2 with fade_level_2" do 120 | test_comment = Factory.build(:threaded_comment, :rating => -2) 121 | @rendered_html = render_threaded_comments([test_comment]) 122 | assert @rendered_html.include?("fade_level_2"), "Comment was not marked with appropriate fade level" 123 | end 124 | 125 | test "render_threaded_comments should mark comments with a rating of -3 with fade_level_3" do 126 | test_comment = Factory.build(:threaded_comment, :rating => -3) 127 | @rendered_html = render_threaded_comments([test_comment]) 128 | assert @rendered_html.include?("fade_level_3"), "Comment was not marked with appropriate fade level" 129 | end 130 | 131 | test "render_threaded_comments should mark comments with a rating of -4 with fade_level_4" do 132 | test_comment = Factory.build(:threaded_comment, :rating => -4) 133 | @rendered_html = render_threaded_comments([test_comment]) 134 | assert @rendered_html.include?("fade_level_4"), "Comment was not marked with appropriate fade level" 135 | end 136 | 137 | test "render_threaded_comments should mark comments with a rating of -5 with fade_level_4" do 138 | test_comment = Factory.build(:threaded_comment, :rating => -5) 139 | @rendered_html = render_threaded_comments([test_comment]) 140 | assert @rendered_html.include?("fade_level_4"), "Comment was not marked with appropriate fade level" 141 | end 142 | 143 | test "render_threaded_comments should mark comments with a rating of -10 with fade_level_4" do 144 | test_comment = Factory.build(:threaded_comment, :rating => -10) 145 | @rendered_html = render_threaded_comments([test_comment]) 146 | assert @rendered_html.include?("fade_level_4"), "Comment was not marked with appropriate fade level" 147 | end 148 | 149 | test "render_threaded_comments child comments of a flagged comment should not be shown" do 150 | @test_comments[0].flags = 5 151 | @rendered_html = render_threaded_comments(@test_comments, :flag_threshold => 4) 152 | @test_comments.each do |comment| 153 | if(comment.parent_id == @test_comments[0].id) 154 | assert !@rendered_html.include?(comment.name), "render_threaded_comments should not show child comments of a flagged comment" 155 | end 156 | end 157 | end 158 | 159 | test "sort_comments: comments should be sorted by age if ratings are all equal" do 160 | comments = [] 161 | comments << Factory.build(:threaded_comment, :rating => 1, :created_at => 4.hours.ago) 162 | comments << Factory.build(:threaded_comment, :rating => 1, :created_at => 3.hours.ago) 163 | comments << Factory.build(:threaded_comment, :rating => 1, :created_at => 2.hours.ago) 164 | comments << Factory.build(:threaded_comment, :rating => 1, :created_at => 1.hours.ago) 165 | sorted_comments = sort_comments(comments) 166 | comments.reverse! 167 | assert sorted_comments == comments, "Should be:\n#{comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}\nBut was:\n#{sorted_comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}" 168 | end 169 | 170 | test "sort_comments: comments should be sorted by rating if ages are all equal" do 171 | age = 1.hours.ago 172 | comments = [] 173 | comments << Factory.build(:threaded_comment, :rating => 5, :created_at => age) 174 | comments << Factory.build(:threaded_comment, :rating => 4, :created_at => age) 175 | comments << Factory.build(:threaded_comment, :rating => 3, :created_at => age) 176 | comments << Factory.build(:threaded_comment, :rating => 2, :created_at => age) 177 | sorted_comments = sort_comments(comments) 178 | assert sorted_comments == comments, "Should be:\n#{comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}\nBut was:\n#{sorted_comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}" 179 | end 180 | 181 | test "sort_comments: 'hot' comment should be at the top of the comment list" do 182 | comments = [] 183 | comments << Factory.build(:threaded_comment, :rating => 10, :created_at => 4.hours.ago) 184 | comments << Factory.build(:threaded_comment, :rating => 5, :created_at => 4.hours.ago) 185 | comments << Factory.build(:threaded_comment, :rating => 1, :created_at => 2.hours.ago) 186 | comments << Factory.build(:threaded_comment, :rating => 0, :created_at => 1.hours.ago) 187 | sorted_comments = sort_comments(comments) 188 | assert sorted_comments == comments, "Should be:\n#{comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}\nBut was:\n#{sorted_comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}" 189 | end 190 | 191 | test "sort_comments: really recent comment should be at the top of the comment list" do 192 | comments = [] 193 | comments << Factory.build(:threaded_comment, :rating => 10, :created_at => 4.hours.ago) 194 | comments << Factory.build(:threaded_comment, :rating => 5, :created_at => 4.hours.ago) 195 | comments << Factory.build(:threaded_comment, :rating => 1, :created_at => 2.hours.ago) 196 | comments << Factory.build(:threaded_comment, :rating => 0, :created_at => 1.hours.ago) 197 | comments << Factory.build(:threaded_comment, :rating => 0, :created_at => 5.minutes.ago) 198 | sorted_comments = sort_comments(comments) 199 | comments.insert(0, comments.pop) 200 | assert sorted_comments == comments, "Should be:\n#{comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}\nBut was:\n#{sorted_comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}" 201 | end 202 | 203 | test "sort_comments: recent comment should be near the top of the comment list" do 204 | comments = [] 205 | comments << Factory.build(:threaded_comment, :rating => 10, :created_at => 4.hours.ago) 206 | comments << Factory.build(:threaded_comment, :rating => 5, :created_at => 4.hours.ago) 207 | comments << Factory.build(:threaded_comment, :rating => 1, :created_at => 2.hours.ago) 208 | comments << Factory.build(:threaded_comment, :rating => 0, :created_at => 1.hours.ago) 209 | comments << Factory.build(:threaded_comment, :rating => 0, :created_at => 15.minutes.ago) 210 | sorted_comments = sort_comments(comments) 211 | comments.insert(1, comments.pop) 212 | assert sorted_comments == comments, "Should be:\n#{comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}\nBut was:\n#{sorted_comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}" 213 | end 214 | 215 | test "sort_comments: somewhat recent comment should be near the top of the comment list" do 216 | comments = [] 217 | comments << Factory.build(:threaded_comment, :rating => 10, :created_at => 4.hours.ago) 218 | comments << Factory.build(:threaded_comment, :rating => 5, :created_at => 4.hours.ago) 219 | comments << Factory.build(:threaded_comment, :rating => 1, :created_at => 2.hours.ago) 220 | comments << Factory.build(:threaded_comment, :rating => 0, :created_at => 1.hours.ago) 221 | comments << Factory.build(:threaded_comment, :rating => 0, :created_at => 35.minutes.ago) 222 | sorted_comments = sort_comments(comments) 223 | comments.insert(2, comments.pop) 224 | assert sorted_comments == comments, "Should be:\n#{comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}\nBut was:\n#{sorted_comments.map{|a| " #{a.id}, #{a.rating}, #{(Time.now - a.created_at) / 3600}\n"}}" 225 | end 226 | 227 | test "should bucket comments for rendering" do 228 | test_comments = create_complex_thread(2) 229 | assert test_comments.first.is_a?(ThreadedComment) 230 | bucketed_comments = bucket_comments(test_comments) 231 | assert_not_equal bucketed_comments, test_comments 232 | assert_equal test_comments.last.id - 1, bucketed_comments.length, bucketed_comments.inspect 233 | end 234 | 235 | test "render_comment_form should use name label from config" do 236 | passthrough = render_comment_form(@test_comment) 237 | assert_equal passthrough[:locals][:name_label], THREADED_COMMENTS_CONFIG[:render_comment_form][:name_label] 238 | end 239 | 240 | test "render_comment_form should use email label from config" do 241 | passthrough = render_comment_form(@test_comment) 242 | assert_equal passthrough[:locals][:email_label], THREADED_COMMENTS_CONFIG[:render_comment_form][:email_label] 243 | end 244 | 245 | test "render_comment_form should use body label from config" do 246 | passthrough = render_comment_form(@test_comment) 247 | assert_equal passthrough[:locals][:body_label], THREADED_COMMENTS_CONFIG[:render_comment_form][:body_label] 248 | end 249 | 250 | test "render_comment_form should use submit label from config" do 251 | passthrough = render_comment_form(@test_comment) 252 | assert_equal passthrough[:locals][:submit_label], THREADED_COMMENTS_CONFIG[:render_comment_form][:submit_label] 253 | end 254 | 255 | test "render_comment_form should use name label from options" do 256 | passthrough = render_comment_form(@test_comment, :name_label => 'test_label') 257 | assert_equal passthrough[:locals][:name_label], 'test_label' 258 | end 259 | 260 | test "render_comment_form should use email label from options" do 261 | passthrough = render_comment_form(@test_comment, :email_label => 'test_label') 262 | assert_equal passthrough[:locals][:email_label], 'test_label' 263 | end 264 | 265 | test "render_comment_form should use body label from options" do 266 | passthrough = render_comment_form(@test_comment, :body_label => 'test_label') 267 | assert_equal passthrough[:locals][:body_label], 'test_label' 268 | end 269 | 270 | test "render_comment_form should use submit label from options" do 271 | passthrough = render_comment_form(@test_comment, :submit_label => 'test_label') 272 | assert_equal passthrough[:locals][:submit_label], 'test_label' 273 | end 274 | 275 | private 276 | 277 | def test_option(name, option_name, pattern, namespace = :render_threaded_comments) 278 | assert defined?(THREADED_COMMENTS_CONFIG[namespace][option_name]), "The option name '#{namespace}:#{option_name}' was not set in the default config" 279 | if(THREADED_COMMENTS_CONFIG[namespace][option_name].is_a?(TrueClass) or THREADED_COMMENTS_CONFIG[namespace][option_name].is_a?(FalseClass)) 280 | # Enabled in config - not set in options 281 | change_config_option(namespace, option_name, true) do 282 | @rendered_html = render_threaded_comments(@test_comments) 283 | @test_comments.each do |comment| 284 | @single_comment = render_threaded_comments([comment]) 285 | assert @rendered_html.include?(pattern.gsub(":id", comment.id.to_s)), "render_threaded_comments did not output '#{pattern.gsub(":id", comment.id.to_s)}' with '#{namespace}:#{option_name}' enabled in config\n ---------- \n#{@single_comment}\n" 286 | end 287 | end 288 | # Disabled in config - not set in options 289 | change_config_option(namespace, option_name, false) do 290 | @rendered_html = render_threaded_comments(@test_comments) 291 | @test_comments.each do |comment| 292 | @single_comment = render_threaded_comments([comment]) 293 | assert !@rendered_html.include?(pattern.gsub(":id", comment.id.to_s)), "render_threaded_comments should not output '#{pattern.gsub(":id", comment.id.to_s)}' when '#{namespace}:#{option_name}' disabled in config\n ---------- \n#{@single_comment}\n" 294 | end 295 | end 296 | # Enabled in options - disabled in config - options should override 297 | change_config_option(namespace, option_name, false) do 298 | @rendered_html = render_threaded_comments(@test_comments, option_name => true) 299 | @test_comments.each do |comment| 300 | @single_comment = render_threaded_comments([comment], option_name => true) 301 | assert @rendered_html.include?(pattern.gsub(":id", comment.id.to_s)), "render_threaded_comments did not output '#{pattern.gsub(":id", comment.id.to_s)}' when '#{namespace}:#{option_name}' enabled in options\n ---------- \n#{@single_comment}\n" 302 | end 303 | end 304 | # Disabled in options - enabled in config - options should override 305 | change_config_option(namespace, option_name, true) do 306 | @rendered_html = render_threaded_comments(@test_comments, option_name => false) 307 | @test_comments.each do |comment| 308 | @single_comment = render_threaded_comments([comment], option_name => false) 309 | assert !@rendered_html.include?(pattern.gsub(":id", comment.id.to_s)), "render_threaded_comments should not output '#{pattern.gsub(":id", comment.id.to_s)}' when '#{namespace}:#{option_name}' disabled in options\n ---------- \n#{@single_comment}\n" 310 | end 311 | end 312 | elsif(THREADED_COMMENTS_CONFIG[namespace][option_name].is_a?(String)) 313 | # Default - should be set in config 314 | @rendered_html = render_threaded_comments(@test_comments) 315 | @single_comment = render_threaded_comments([@test_comments[0]]) 316 | assert_equal @test_comments.length, @rendered_html.split(pattern).length - 1, "render_threaded_comments did not output '#{pattern}' for each comment by default\n ---------- \n#{@single_comment}\n" 317 | # Set in config - not set in options 318 | change_config_option(namespace, option_name, "replacement_pattern_config") do 319 | @rendered_html = render_threaded_comments(@test_comments) 320 | @single_comment = render_threaded_comments([@test_comments[0]]) 321 | assert_equal @test_comments.length, @rendered_html.split("replacement_pattern_config").length - 1, "render_threaded_comments did not output value of '#{namespace}:#{option_name}' for each comment when set in config\n ---------- \n#{@single_comment}\n" 322 | assert_equal 1, @rendered_html.split(pattern).length, "render_threaded_comments still output default value of '#{namespace}:#{option_name}' even when overwritten in config" 323 | end 324 | # Set in options - also set in config - options should override 325 | change_config_option(namespace, option_name, "replacement_pattern_config") do 326 | @rendered_html = render_threaded_comments(@test_comments, option_name => "replacement_pattern_options") 327 | @single_comment = render_threaded_comments([@test_comments[0]], option_name => "replacement_pattern_options") 328 | assert_equal @test_comments.length, @rendered_html.split("replacement_pattern_options").length - 1, "render_threaded_comments did not output value of '#{namespace}:#{option_name}' for each comment when set in options\n ---------- \n#{@single_comment}\n" 329 | assert_equal 1, @rendered_html.split(pattern).length, "render_threaded_comments still output default value of '#{namespace}:#{option_name}' even when overwritten in config and options" 330 | assert_equal 1, @rendered_html.split("replacement_pattern_config").length, "render_threaded_comments still output config value of '#{namespace}:#{option_name}' even when overwritten in options" 331 | end 332 | else 333 | flunk "Unrecognized option type: #{THREADED_COMMENTS_CONFIG[namespace][option_name].class}" 334 | end 335 | end 336 | 337 | end 338 | --------------------------------------------------------------------------------