├── .gemtest ├── .gitignore ├── .travis.yml ├── init.rb ├── Gemfile ├── lib ├── acts_as_list │ ├── version.rb │ └── active_record │ │ └── acts │ │ └── list.rb └── acts_as_list.rb ├── test ├── shared.rb ├── helper.rb ├── shared_zero_based.rb ├── shared_top_addition.rb ├── shared_list_sub.rb ├── shared_array_scope_list.rb ├── shared_list.rb └── test_list.rb ├── Rakefile ├── acts_as_list.gemspec └── README.md /.gemtest: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | .rvmrc 6 | *.tmproj 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.8.7 4 | - 1.9.2 5 | - 1.9.3 6 | -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "#{File.dirname(__FILE__)}/lib" 2 | require 'acts_as_list' 3 | 4 | ActsAsList::Railtie.insert 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | # Specify your gem's dependencies in acts_as_list-rails3.gemspec 4 | gemspec 5 | 6 | gem 'rake' -------------------------------------------------------------------------------- /lib/acts_as_list/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Acts 3 | module List 4 | VERSION = "0.1.6" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/shared.rb: -------------------------------------------------------------------------------- 1 | # Common shared behaviour. 2 | module Shared 3 | autoload :List, 'shared_list' 4 | autoload :ListSub, 'shared_list_sub' 5 | autoload :ZeroBased, 'shared_zero_based' 6 | autoload :ArrayScopeList, 'shared_array_scope_list' 7 | autoload :TopAddition, 'shared_top_addition' 8 | end 9 | -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | begin 4 | Bundler.setup(:default, :development) 5 | rescue Bundler::BundlerError => e 6 | $stderr.puts e.message 7 | $stderr.puts "Run `bundle install` to install missing gems" 8 | exit e.status_code 9 | end 10 | require 'test/unit' 11 | require 'active_record' 12 | require "#{File.dirname(__FILE__)}/../init" 13 | 14 | require 'shared' 15 | -------------------------------------------------------------------------------- /lib/acts_as_list.rb: -------------------------------------------------------------------------------- 1 | require 'acts_as_list/active_record/acts/list' 2 | 3 | module ActsAsList 4 | if defined? Rails::Railtie 5 | require 'rails' 6 | class Railtie < Rails::Railtie 7 | initializer 'acts_as_list.insert_into_active_record' do 8 | ActiveSupport.on_load :active_record do 9 | ActsAsList::Railtie.insert 10 | end 11 | end 12 | end 13 | end 14 | 15 | class Railtie 16 | def self.insert 17 | if defined?(ActiveRecord) 18 | ActiveRecord::Base.send(:include, ActiveRecord::Acts::List) 19 | end 20 | end 21 | end 22 | end 23 | 24 | ActsAsList::Railtie.insert 25 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | 4 | #require 'rake' 5 | require 'rake/testtask' 6 | 7 | # Run the test with 'rake' or 'rake test' 8 | desc 'Default: run acts_as_list unit tests.' 9 | task :default => :test 10 | 11 | desc 'Test the acts_as_list plugin.' 12 | Rake::TestTask.new(:test) do |t| 13 | t.libs << 'lib' << 'test' 14 | t.pattern = 'test/**/test_*.rb' 15 | t.verbose = false 16 | end 17 | 18 | # Run the rdoc task to generate rdocs for this gem 19 | require 'rdoc/task' 20 | RDoc::Task.new do |rdoc| 21 | require "acts_as_list/version" 22 | version = ActiveRecord::Acts::List::VERSION 23 | 24 | rdoc.rdoc_dir = 'rdoc' 25 | rdoc.title = "acts_as_list-rails3 #{version}" 26 | rdoc.rdoc_files.include('README*') 27 | rdoc.rdoc_files.include('lib/**/*.rb') 28 | end 29 | 30 | -------------------------------------------------------------------------------- /acts_as_list.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path('../lib', __FILE__) 3 | require 'acts_as_list/version' 4 | 5 | Gem::Specification.new do |s| 6 | 7 | # Description Meta... 8 | s.name = 'acts_as_list' 9 | s.version = ActiveRecord::Acts::List::VERSION 10 | s.platform = Gem::Platform::RUBY 11 | s.authors = ['David Heinemeier Hansson', 'Swanand Pagnis', 'Quinn Chaffee'] 12 | s.email = ['swanand.pagnis@gmail.com'] 13 | s.homepage = 'http://github.com/swanandp/acts_as_list' 14 | s.summary = %q{A gem allowing a active_record model to act_as_list.} 15 | s.description = %q{This "acts_as" extension provides the capabilities for sorting and reordering a number of objects in a list. The class that has this specified needs to have a "position" column defined as an integer on the mapped database table.} 16 | s.rubyforge_project = 'acts_as_list' 17 | 18 | 19 | # Load Paths... 20 | s.files = `git ls-files`.split("\n") 21 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 22 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 23 | s.require_paths = ['lib'] 24 | 25 | 26 | # Dependencies (installed via 'bundle install')... 27 | s.add_development_dependency("bundler", [">= 1.0.0"]) 28 | s.add_development_dependency("activerecord", [">= 1.15.4.7794"]) 29 | s.add_development_dependency("rdoc") 30 | s.add_development_dependency("sqlite3") 31 | end 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActsAsList 2 | 3 | ## Description 4 | 5 | This `acts_as` extension provides the capabilities for sorting and reordering a number of objects in a list. The class that has this specified needs to have a `position` column defined as an integer on the mapped database table. 6 | 7 | ## Installation 8 | 9 | In your Gemfile: 10 | 11 | gem 'acts_as_list' 12 | 13 | Or, from the command line: 14 | 15 | gem install acts_as_list 16 | 17 | ## Example 18 | 19 | class TodoList < ActiveRecord::Base 20 | has_many :todo_items, :order => "position" 21 | end 22 | 23 | class TodoItem < ActiveRecord::Base 24 | belongs_to :todo_list 25 | acts_as_list :scope => :todo_list 26 | end 27 | 28 | todo_list.first.move_to_bottom 29 | todo_list.last.move_higher 30 | 31 | ## Notes 32 | If the `position` column has a default value, then there is a slight change in behavior, i.e if you have 4 items in the list, and you insert 1, with a default position 0, it would be pushed to the bottom of the list. Please look at the tests for this and some recent pull requests for discussions related to this. 33 | 34 | All `position` queries (select, update, etc.) inside gem methods are executed without the default scope (i.e. `Model.unscoped`), this will prevent nasty issues when the default scope is different from `acts_as_list` scope. 35 | 36 | ## Versions 37 | All versions `0.1.5` onwards require Rails 3.0.x and higher. 38 | 39 | ## Build Status 40 | [![Build Status](https://secure.travis-ci.org/swanandp/acts_as_list.png)](https://secure.travis-ci.org/swanandp/acts_as_list) 41 | 42 | ## Roadmap 43 | 44 | 1. Sort based feature 45 | 2. Rails 4 compatibility and bye bye Rails 2! Older versions would of course continue to work with Rails 2, but there won't be any support on those. 46 | 47 | ## Contributing to `acts_as_list` 48 | 49 | - Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet 50 | - Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it 51 | - Fork the project 52 | - Start a feature/bugfix branch 53 | - Commit and push until you are happy with your contribution 54 | - Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 55 | - Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 56 | - I would recommend using Rails 3.1.x and higher for testing the build before a pull request. The current test harness does not quite work with 3.0.x. The plugin itself works, but the issue lies with testing infrastructure. 57 | 58 | ## Copyright 59 | 60 | Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license 61 | -------------------------------------------------------------------------------- /test/shared_zero_based.rb: -------------------------------------------------------------------------------- 1 | module Shared 2 | module ZeroBased 3 | def setup 4 | (1..4).each { |counter| ZeroBasedMixin.create! :pos => counter, :parent_id => 5 } 5 | end 6 | 7 | def test_insert 8 | new = ZeroBasedMixin.create(:parent_id => 20) 9 | assert_equal 0, new.pos 10 | assert new.first? 11 | assert new.last? 12 | 13 | new = ZeroBasedMixin.create(:parent_id => 20) 14 | assert_equal 1, new.pos 15 | assert !new.first? 16 | assert new.last? 17 | 18 | new = ZeroBasedMixin.create(:parent_id => 20) 19 | assert_equal 2, new.pos 20 | assert !new.first? 21 | assert new.last? 22 | 23 | new = ZeroBasedMixin.create(:parent_id => 0) 24 | assert_equal 0, new.pos 25 | assert new.first? 26 | assert new.last? 27 | end 28 | 29 | def test_reordering 30 | assert_equal [1, 2, 3, 4], ZeroBasedMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 31 | 32 | ListMixin.find(2).move_lower 33 | assert_equal [1, 3, 2, 4], ZeroBasedMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 34 | 35 | ListMixin.find(2).move_higher 36 | assert_equal [1, 2, 3, 4], ZeroBasedMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 37 | 38 | ListMixin.find(1).move_to_bottom 39 | assert_equal [2, 3, 4, 1], ZeroBasedMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 40 | 41 | ListMixin.find(1).move_to_top 42 | assert_equal [1, 2, 3, 4], ZeroBasedMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 43 | 44 | ListMixin.find(2).move_to_bottom 45 | assert_equal [1, 3, 4, 2], ZeroBasedMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 46 | 47 | ListMixin.find(4).move_to_top 48 | assert_equal [4, 1, 3, 2], ZeroBasedMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 49 | end 50 | 51 | def test_insert_at 52 | new = ZeroBasedMixin.create(:parent_id => 20) 53 | assert_equal 0, new.pos 54 | 55 | new = ZeroBasedMixin.create(:parent_id => 20) 56 | assert_equal 1, new.pos 57 | 58 | new = ZeroBasedMixin.create(:parent_id => 20) 59 | assert_equal 2, new.pos 60 | 61 | new4 = ZeroBasedMixin.create(:parent_id => 20) 62 | assert_equal 3, new4.pos 63 | 64 | new4.insert_at(2) 65 | assert_equal 2, new4.pos 66 | 67 | new.reload 68 | assert_equal 3, new.pos 69 | 70 | new.insert_at(2) 71 | assert_equal 2, new.pos 72 | 73 | new4.reload 74 | assert_equal 3, new4.pos 75 | 76 | new5 = ListMixin.create(:parent_id => 20) 77 | assert_equal 4, new5.pos 78 | 79 | new5.insert_at(1) 80 | assert_equal 1, new5.pos 81 | 82 | new4.reload 83 | assert_equal 4, new4.pos 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/shared_top_addition.rb: -------------------------------------------------------------------------------- 1 | module Shared 2 | module TopAddition 3 | def setup 4 | (1..4).each { |counter| TopAdditionMixin.create! :pos => counter, :parent_id => 5 } 5 | end 6 | 7 | def test_reordering 8 | assert_equal [4, 3, 2, 1], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 9 | 10 | TopAdditionMixin.find(2).move_lower 11 | assert_equal [4, 3, 1, 2], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 12 | 13 | TopAdditionMixin.find(2).move_higher 14 | assert_equal [4, 3, 2, 1], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 15 | 16 | TopAdditionMixin.find(1).move_to_bottom 17 | assert_equal [4, 3, 2, 1], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 18 | 19 | TopAdditionMixin.find(1).move_to_top 20 | assert_equal [1, 4, 3, 2], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 21 | 22 | TopAdditionMixin.find(2).move_to_bottom 23 | assert_equal [1, 4, 3, 2], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 24 | 25 | TopAdditionMixin.find(4).move_to_top 26 | assert_equal [4, 1, 3, 2], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 27 | end 28 | 29 | def test_injection 30 | item = TopAdditionMixin.new(:parent_id => 1) 31 | assert_equal '"mixins"."parent_id" = 1', item.scope_condition 32 | assert_equal "pos", item.position_column 33 | end 34 | 35 | def test_insert 36 | new = TopAdditionMixin.create(:parent_id => 20) 37 | assert_equal 1, new.pos 38 | assert new.first? 39 | assert new.last? 40 | 41 | new = TopAdditionMixin.create(:parent_id => 20) 42 | assert_equal 1, new.pos 43 | assert new.first? 44 | assert !new.last? 45 | 46 | new = TopAdditionMixin.create(:parent_id => 20) 47 | assert_equal 1, new.pos 48 | assert new.first? 49 | assert !new.last? 50 | 51 | new = TopAdditionMixin.create(:parent_id => 0) 52 | assert_equal 1, new.pos 53 | assert new.first? 54 | assert new.last? 55 | end 56 | 57 | def test_insert_at 58 | new = TopAdditionMixin.create(:parent_id => 20) 59 | assert_equal 1, new.pos 60 | 61 | new = TopAdditionMixin.create(:parent_id => 20) 62 | assert_equal 1, new.pos 63 | 64 | new = TopAdditionMixin.create(:parent_id => 20) 65 | assert_equal 1, new.pos 66 | 67 | new4 = TopAdditionMixin.create(:parent_id => 20) 68 | assert_equal 1, new4.pos 69 | 70 | new4.insert_at(3) 71 | assert_equal 3, new4.pos 72 | end 73 | 74 | def test_delete_middle 75 | assert_equal [4, 3, 2, 1], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 76 | 77 | TopAdditionMixin.find(2).destroy 78 | 79 | assert_equal [4, 3, 1], TopAdditionMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 80 | 81 | assert_equal 3, TopAdditionMixin.find(1).pos 82 | assert_equal 2, TopAdditionMixin.find(3).pos 83 | assert_equal 1, TopAdditionMixin.find(4).pos 84 | end 85 | 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/shared_list_sub.rb: -------------------------------------------------------------------------------- 1 | module Shared 2 | module ListSub 3 | def setup 4 | (1..4).each { |i| ((i % 2 == 1) ? ListMixinSub1 : ListMixinSub2).create! :pos => i, :parent_id => 5000 } 5 | end 6 | 7 | def test_reordering 8 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 9 | 10 | ListMixin.find(2).move_lower 11 | assert_equal [1, 3, 2, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 12 | 13 | ListMixin.find(2).move_higher 14 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 15 | 16 | ListMixin.find(1).move_to_bottom 17 | assert_equal [2, 3, 4, 1], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 18 | 19 | ListMixin.find(1).move_to_top 20 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 21 | 22 | ListMixin.find(2).move_to_bottom 23 | assert_equal [1, 3, 4, 2], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 24 | 25 | ListMixin.find(4).move_to_top 26 | assert_equal [4, 1, 3, 2], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 27 | end 28 | 29 | def test_move_to_bottom_with_next_to_last_item 30 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 31 | ListMixin.find(3).move_to_bottom 32 | assert_equal [1, 2, 4, 3], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 33 | end 34 | 35 | def test_next_prev 36 | assert_equal ListMixin.find(2), ListMixin.find(1).lower_item 37 | assert_nil ListMixin.find(1).higher_item 38 | assert_equal ListMixin.find(3), ListMixin.find(4).higher_item 39 | assert_nil ListMixin.find(4).lower_item 40 | end 41 | 42 | def test_injection 43 | item = ListMixin.new("parent_id"=>1) 44 | assert_equal '"mixins"."parent_id" = 1', item.scope_condition 45 | assert_equal "pos", item.position_column 46 | end 47 | 48 | def test_insert_at 49 | new = ListMixin.create("parent_id" => 20) 50 | assert_equal 1, new.pos 51 | 52 | new = ListMixinSub1.create("parent_id" => 20) 53 | assert_equal 2, new.pos 54 | 55 | new = ListMixinSub1.create("parent_id" => 20) 56 | assert_equal 3, new.pos 57 | 58 | new4 = ListMixin.create("parent_id" => 20) 59 | assert_equal 4, new4.pos 60 | 61 | new4.insert_at(3) 62 | assert_equal 3, new4.pos 63 | 64 | new.reload 65 | assert_equal 4, new.pos 66 | 67 | new.insert_at(2) 68 | assert_equal 2, new.pos 69 | 70 | new4.reload 71 | assert_equal 4, new4.pos 72 | 73 | new5 = ListMixinSub1.create("parent_id" => 20) 74 | assert_equal 5, new5.pos 75 | 76 | new5.insert_at(1) 77 | assert_equal 1, new5.pos 78 | 79 | new4.reload 80 | assert_equal 5, new4.pos 81 | end 82 | 83 | def test_delete_middle 84 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 85 | 86 | ListMixin.find(2).destroy 87 | 88 | assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 89 | 90 | assert_equal 1, ListMixin.find(1).pos 91 | assert_equal 2, ListMixin.find(3).pos 92 | assert_equal 3, ListMixin.find(4).pos 93 | 94 | ListMixin.find(1).destroy 95 | 96 | assert_equal [3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 97 | 98 | assert_equal 1, ListMixin.find(3).pos 99 | assert_equal 2, ListMixin.find(4).pos 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/shared_array_scope_list.rb: -------------------------------------------------------------------------------- 1 | module Shared 2 | module ArrayScopeList 3 | def setup 4 | (1..4).each { |counter| ArrayScopeListMixin.create! :pos => counter, :parent_id => 5, :parent_type => 'ParentClass' } 5 | end 6 | 7 | def test_reordering 8 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 9 | 10 | ArrayScopeListMixin.find(2).move_lower 11 | assert_equal [1, 3, 2, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 12 | 13 | ArrayScopeListMixin.find(2).move_higher 14 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 15 | 16 | ArrayScopeListMixin.find(1).move_to_bottom 17 | assert_equal [2, 3, 4, 1], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 18 | 19 | ArrayScopeListMixin.find(1).move_to_top 20 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 21 | 22 | ArrayScopeListMixin.find(2).move_to_bottom 23 | assert_equal [1, 3, 4, 2], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 24 | 25 | ArrayScopeListMixin.find(4).move_to_top 26 | assert_equal [4, 1, 3, 2], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 27 | end 28 | 29 | def test_move_to_bottom_with_next_to_last_item 30 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 31 | ArrayScopeListMixin.find(3).move_to_bottom 32 | assert_equal [1, 2, 4, 3], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 33 | end 34 | 35 | def test_next_prev 36 | assert_equal ArrayScopeListMixin.find(2), ArrayScopeListMixin.find(1).lower_item 37 | assert_nil ArrayScopeListMixin.find(1).higher_item 38 | assert_equal ArrayScopeListMixin.find(3), ArrayScopeListMixin.find(4).higher_item 39 | assert_nil ArrayScopeListMixin.find(4).lower_item 40 | end 41 | 42 | def test_injection 43 | item = ArrayScopeListMixin.new(:parent_id => 1, :parent_type => 'ParentClass') 44 | assert_equal "pos", item.position_column 45 | end 46 | 47 | def test_insert 48 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 49 | assert_equal 1, new.pos 50 | assert new.first? 51 | assert new.last? 52 | 53 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 54 | assert_equal 2, new.pos 55 | assert !new.first? 56 | assert new.last? 57 | 58 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 59 | assert_equal 3, new.pos 60 | assert !new.first? 61 | assert new.last? 62 | 63 | new = ArrayScopeListMixin.create(:parent_id => 0, :parent_type => 'ParentClass') 64 | assert_equal 1, new.pos 65 | assert new.first? 66 | assert new.last? 67 | end 68 | 69 | def test_insert_at 70 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 71 | assert_equal 1, new.pos 72 | 73 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 74 | assert_equal 2, new.pos 75 | 76 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 77 | assert_equal 3, new.pos 78 | 79 | new4 = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 80 | assert_equal 4, new4.pos 81 | 82 | new4.insert_at(3) 83 | assert_equal 3, new4.pos 84 | 85 | new.reload 86 | assert_equal 4, new.pos 87 | 88 | new.insert_at(2) 89 | assert_equal 2, new.pos 90 | 91 | new4.reload 92 | assert_equal 4, new4.pos 93 | 94 | new5 = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 95 | assert_equal 5, new5.pos 96 | 97 | new5.insert_at(1) 98 | assert_equal 1, new5.pos 99 | 100 | new4.reload 101 | assert_equal 5, new4.pos 102 | end 103 | 104 | def test_delete_middle 105 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 106 | 107 | ArrayScopeListMixin.find(2).destroy 108 | 109 | assert_equal [1, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 110 | 111 | assert_equal 1, ArrayScopeListMixin.find(1).pos 112 | assert_equal 2, ArrayScopeListMixin.find(3).pos 113 | assert_equal 3, ArrayScopeListMixin.find(4).pos 114 | 115 | ArrayScopeListMixin.find(1).destroy 116 | 117 | assert_equal [3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 118 | 119 | assert_equal 1, ArrayScopeListMixin.find(3).pos 120 | assert_equal 2, ArrayScopeListMixin.find(4).pos 121 | end 122 | 123 | def test_remove_from_list_should_then_fail_in_list? 124 | assert_equal true, ArrayScopeListMixin.find(1).in_list? 125 | ArrayScopeListMixin.find(1).remove_from_list 126 | assert_equal false, ArrayScopeListMixin.find(1).in_list? 127 | end 128 | 129 | def test_remove_from_list_should_set_position_to_nil 130 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 131 | 132 | ArrayScopeListMixin.find(2).remove_from_list 133 | 134 | assert_equal [2, 1, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 135 | 136 | assert_equal 1, ArrayScopeListMixin.find(1).pos 137 | assert_equal nil, ArrayScopeListMixin.find(2).pos 138 | assert_equal 2, ArrayScopeListMixin.find(3).pos 139 | assert_equal 3, ArrayScopeListMixin.find(4).pos 140 | end 141 | 142 | def test_remove_before_destroy_does_not_shift_lower_items_twice 143 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 144 | 145 | ArrayScopeListMixin.find(2).remove_from_list 146 | ArrayScopeListMixin.find(2).destroy 147 | 148 | assert_equal [1, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 149 | 150 | assert_equal 1, ArrayScopeListMixin.find(1).pos 151 | assert_equal 2, ArrayScopeListMixin.find(3).pos 152 | assert_equal 3, ArrayScopeListMixin.find(4).pos 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /test/shared_list.rb: -------------------------------------------------------------------------------- 1 | module Shared 2 | module List 3 | def setup 4 | (1..4).each { |counter| ListMixin.create! :pos => counter, :parent_id => 5 } 5 | end 6 | 7 | def test_reordering 8 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 9 | 10 | ListMixin.find(2).move_lower 11 | assert_equal [1, 3, 2, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 12 | 13 | ListMixin.find(2).move_higher 14 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 15 | 16 | ListMixin.find(1).move_to_bottom 17 | assert_equal [2, 3, 4, 1], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 18 | 19 | ListMixin.find(1).move_to_top 20 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 21 | 22 | ListMixin.find(2).move_to_bottom 23 | assert_equal [1, 3, 4, 2], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 24 | 25 | ListMixin.find(4).move_to_top 26 | assert_equal [4, 1, 3, 2], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 27 | end 28 | 29 | def test_move_to_bottom_with_next_to_last_item 30 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 31 | ListMixin.find(3).move_to_bottom 32 | assert_equal [1, 2, 4, 3], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 33 | end 34 | 35 | def test_next_prev 36 | assert_equal ListMixin.find(2), ListMixin.find(1).lower_item 37 | assert_nil ListMixin.find(1).higher_item 38 | assert_equal ListMixin.find(3), ListMixin.find(4).higher_item 39 | assert_nil ListMixin.find(4).lower_item 40 | end 41 | 42 | def test_injection 43 | item = ListMixin.new(:parent_id => 1) 44 | assert_equal '"mixins"."parent_id" = 1', item.scope_condition 45 | assert_equal "pos", item.position_column 46 | end 47 | 48 | def test_insert 49 | new = ListMixin.create(:parent_id => 20) 50 | assert_equal 1, new.pos 51 | assert new.first? 52 | assert new.last? 53 | 54 | new = ListMixin.create(:parent_id => 20) 55 | assert_equal 2, new.pos 56 | assert !new.first? 57 | assert new.last? 58 | 59 | new = ListMixin.create(:parent_id => 20) 60 | assert_equal 3, new.pos 61 | assert !new.first? 62 | assert new.last? 63 | 64 | new = ListMixin.create(:parent_id => 0) 65 | assert_equal 1, new.pos 66 | assert new.first? 67 | assert new.last? 68 | end 69 | 70 | def test_insert_at 71 | new = ListMixin.create(:parent_id => 20) 72 | assert_equal 1, new.pos 73 | 74 | new = ListMixin.create(:parent_id => 20) 75 | assert_equal 2, new.pos 76 | 77 | new = ListMixin.create(:parent_id => 20) 78 | assert_equal 3, new.pos 79 | 80 | new4 = ListMixin.create(:parent_id => 20) 81 | assert_equal 4, new4.pos 82 | 83 | new4.insert_at(3) 84 | assert_equal 3, new4.pos 85 | 86 | new.reload 87 | assert_equal 4, new.pos 88 | 89 | new.insert_at(2) 90 | assert_equal 2, new.pos 91 | 92 | new4.reload 93 | assert_equal 4, new4.pos 94 | 95 | new5 = ListMixin.create(:parent_id => 20) 96 | assert_equal 5, new5.pos 97 | 98 | new5.insert_at(1) 99 | assert_equal 1, new5.pos 100 | 101 | new4.reload 102 | assert_equal 5, new4.pos 103 | end 104 | 105 | def test_delete_middle 106 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 107 | 108 | ListMixin.find(2).destroy 109 | 110 | assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 111 | 112 | assert_equal 1, ListMixin.find(1).pos 113 | assert_equal 2, ListMixin.find(3).pos 114 | assert_equal 3, ListMixin.find(4).pos 115 | 116 | ListMixin.find(1).destroy 117 | 118 | assert_equal [3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 119 | 120 | assert_equal 1, ListMixin.find(3).pos 121 | assert_equal 2, ListMixin.find(4).pos 122 | end 123 | 124 | def test_with_string_based_scope 125 | new = ListWithStringScopeMixin.create(:parent_id => 500) 126 | assert_equal 1, new.pos 127 | assert new.first? 128 | assert new.last? 129 | end 130 | 131 | def test_nil_scope 132 | new1, new2, new3 = ListMixin.create, ListMixin.create, ListMixin.create 133 | new2.move_higher 134 | assert_equal [new2, new1, new3], ListMixin.find(:all, :conditions => 'parent_id IS NULL', :order => 'pos') 135 | end 136 | 137 | def test_remove_from_list_should_then_fail_in_list? 138 | assert_equal true, ListMixin.find(1).in_list? 139 | ListMixin.find(1).remove_from_list 140 | assert_equal false, ListMixin.find(1).in_list? 141 | end 142 | 143 | def test_remove_from_list_should_set_position_to_nil 144 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 145 | 146 | ListMixin.find(2).remove_from_list 147 | 148 | assert_equal [2, 1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 149 | 150 | assert_equal 1, ListMixin.find(1).pos 151 | assert_equal nil, ListMixin.find(2).pos 152 | assert_equal 2, ListMixin.find(3).pos 153 | assert_equal 3, ListMixin.find(4).pos 154 | end 155 | 156 | def test_remove_before_destroy_does_not_shift_lower_items_twice 157 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 158 | 159 | ListMixin.find(2).remove_from_list 160 | ListMixin.find(2).destroy 161 | 162 | assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 163 | 164 | assert_equal 1, ListMixin.find(1).pos 165 | assert_equal 2, ListMixin.find(3).pos 166 | assert_equal 3, ListMixin.find(4).pos 167 | end 168 | 169 | def test_before_destroy_callbacks_do_not_update_position_to_nil_before_deleting_the_record 170 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 171 | 172 | # We need to trigger all the before_destroy callbacks without actually 173 | # destroying the record so we can see the affect the callbacks have on 174 | # the record. 175 | # NOTE: Hotfix for rails3 ActiveRecord 176 | list = ListMixin.find(2) 177 | if list.respond_to?(:run_callbacks) 178 | # Refactored to work according to Rails3 ActiveRSupport Callbacks 179 | list.run_callbacks :destroy, :before if rails_3 180 | list.run_callbacks(:before_destroy) if !rails_3 181 | else 182 | list.send(:callback, :before_destroy) 183 | end 184 | 185 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 186 | 187 | assert_equal 1, ListMixin.find(1).pos 188 | assert_equal 2, ListMixin.find(2).pos 189 | assert_equal 2, ListMixin.find(3).pos 190 | assert_equal 3, ListMixin.find(4).pos 191 | end 192 | 193 | def test_before_create_callback_adds_to_bottom 194 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 195 | 196 | new = ListMixin.create(:parent_id => 5) 197 | assert_equal 5, new.pos 198 | assert !new.first? 199 | assert new.last? 200 | 201 | assert_equal [1, 2, 3, 4, 5], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 202 | end 203 | 204 | def test_before_create_callback_adds_to_given_position 205 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 206 | 207 | new = ListMixin.create(:pos => 1, :parent_id => 5) 208 | assert_equal 1, new.pos 209 | assert new.first? 210 | assert !new.last? 211 | 212 | assert_equal [5, 1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 213 | 214 | new = ListMixin.create(:pos => 3, :parent_id => 5) 215 | assert_equal 3, new.pos 216 | assert !new.first? 217 | assert !new.last? 218 | 219 | assert_equal [5, 1, 6, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 220 | end 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /test/test_list.rb: -------------------------------------------------------------------------------- 1 | # NOTE: following now done in helper.rb (better Readability) 2 | require 'helper' 3 | 4 | ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:") 5 | ActiveRecord::Schema.verbose = false 6 | 7 | def setup_db(position_options = {}) 8 | # AR caches columns options like defaults etc. Clear them! 9 | ActiveRecord::Base.connection.schema_cache.clear! 10 | ActiveRecord::Schema.define(:version => 1) do 11 | create_table :mixins do |t| 12 | t.column :pos, :integer, position_options 13 | t.column :active, :boolean, :default => true 14 | t.column :parent_id, :integer 15 | t.column :parent_type, :string 16 | t.column :created_at, :datetime 17 | t.column :updated_at, :datetime 18 | end 19 | end 20 | end 21 | 22 | def setup_db_with_default 23 | setup_db :default => 0 24 | end 25 | 26 | # Returns true if ActiveRecord is rails3 version 27 | def rails_3 28 | defined?(ActiveRecord::VERSION) && ActiveRecord::VERSION::MAJOR >= 3 29 | end 30 | 31 | def teardown_db 32 | ActiveRecord::Base.connection.tables.each do |table| 33 | ActiveRecord::Base.connection.drop_table(table) 34 | end 35 | end 36 | 37 | class Mixin < ActiveRecord::Base 38 | self.table_name = 'mixins' 39 | end 40 | 41 | class ListMixin < Mixin 42 | acts_as_list :column => "pos", :scope => :parent 43 | end 44 | 45 | class ListMixinSub1 < ListMixin 46 | end 47 | 48 | class ListMixinSub2 < ListMixin 49 | if rails_3 50 | validates :pos, :presence => true 51 | else 52 | validates_presence_of :pos 53 | end 54 | end 55 | 56 | class ListWithStringScopeMixin < Mixin 57 | acts_as_list :column => "pos", :scope => 'parent_id = #{parent_id}' 58 | end 59 | 60 | class ArrayScopeListMixin < Mixin 61 | acts_as_list :column => "pos", :scope => [:parent_id, :parent_type] 62 | end 63 | 64 | class ZeroBasedMixin < Mixin 65 | acts_as_list :column => "pos", :top_of_list => 0, :scope => [:parent_id] 66 | end 67 | 68 | class DefaultScopedMixin < Mixin 69 | acts_as_list :column => "pos" 70 | default_scope { order('pos ASC') } 71 | end 72 | 73 | class DefaultScopedWhereMixin < Mixin 74 | acts_as_list :column => "pos" 75 | default_scope { order('pos ASC').where(:active => true) } 76 | end 77 | 78 | class TopAdditionMixin < Mixin 79 | acts_as_list :column => "pos", :add_new_at => :top, :scope => :parent_id 80 | end 81 | 82 | class ActsAsListTestCase < Test::Unit::TestCase 83 | # No default test required a this class is abstract. 84 | # Need for test/unit. 85 | undef_method :default_test if method_defined?(:default_test) 86 | 87 | def teardown 88 | teardown_db 89 | end 90 | end 91 | 92 | class ZeroBasedTest < ActsAsListTestCase 93 | include Shared::ZeroBased 94 | 95 | def setup 96 | setup_db 97 | super 98 | end 99 | end 100 | 101 | class ZeroBasedTestWithDefault < ActsAsListTestCase 102 | include Shared::ZeroBased 103 | 104 | def setup 105 | setup_db_with_default 106 | super 107 | end 108 | end 109 | 110 | class ListTest < ActsAsListTestCase 111 | include Shared::List 112 | 113 | def setup 114 | setup_db 115 | super 116 | end 117 | end 118 | 119 | class ListTestWithDefault < ActsAsListTestCase 120 | include Shared::List 121 | 122 | def setup 123 | setup_db_with_default 124 | super 125 | end 126 | end 127 | 128 | class ListSubTest < ActsAsListTestCase 129 | include Shared::ListSub 130 | 131 | def setup 132 | setup_db 133 | super 134 | end 135 | end 136 | 137 | class ListSubTestWithDefault < ActsAsListTestCase 138 | include Shared::ListSub 139 | 140 | def setup 141 | setup_db_with_default 142 | super 143 | end 144 | end 145 | 146 | class ArrayScopeListTest < ActsAsListTestCase 147 | include Shared::ArrayScopeList 148 | 149 | def setup 150 | setup_db 151 | super 152 | end 153 | end 154 | 155 | class ArrayScopeListTestWithDefault < ActsAsListTestCase 156 | include Shared::ArrayScopeList 157 | 158 | def setup 159 | setup_db_with_default 160 | super 161 | end 162 | end 163 | 164 | class DefaultScopedTest < ActsAsListTestCase 165 | def setup 166 | setup_db 167 | (1..4).each { |counter| DefaultScopedMixin.create! :pos => counter } 168 | end 169 | 170 | def test_insert 171 | new = DefaultScopedMixin.create 172 | assert_equal 5, new.pos 173 | assert !new.first? 174 | assert new.last? 175 | 176 | new = DefaultScopedMixin.create 177 | assert_equal 6, new.pos 178 | assert !new.first? 179 | assert new.last? 180 | 181 | new = DefaultScopedMixin.create 182 | assert_equal 7, new.pos 183 | assert !new.first? 184 | assert new.last? 185 | end 186 | 187 | def test_reordering 188 | assert_equal [1, 2, 3, 4], DefaultScopedMixin.find(:all).map(&:id) 189 | 190 | DefaultScopedMixin.find(2).move_lower 191 | assert_equal [1, 3, 2, 4], DefaultScopedMixin.find(:all).map(&:id) 192 | 193 | DefaultScopedMixin.find(2).move_higher 194 | assert_equal [1, 2, 3, 4], DefaultScopedMixin.find(:all).map(&:id) 195 | 196 | DefaultScopedMixin.find(1).move_to_bottom 197 | assert_equal [2, 3, 4, 1], DefaultScopedMixin.find(:all).map(&:id) 198 | 199 | DefaultScopedMixin.find(1).move_to_top 200 | assert_equal [1, 2, 3, 4], DefaultScopedMixin.find(:all).map(&:id) 201 | 202 | DefaultScopedMixin.find(2).move_to_bottom 203 | assert_equal [1, 3, 4, 2], DefaultScopedMixin.find(:all).map(&:id) 204 | 205 | DefaultScopedMixin.find(4).move_to_top 206 | assert_equal [4, 1, 3, 2], DefaultScopedMixin.find(:all).map(&:id) 207 | end 208 | 209 | def test_insert_at 210 | new = DefaultScopedMixin.create 211 | assert_equal 5, new.pos 212 | 213 | new = DefaultScopedMixin.create 214 | assert_equal 6, new.pos 215 | 216 | new = DefaultScopedMixin.create 217 | assert_equal 7, new.pos 218 | 219 | new4 = DefaultScopedMixin.create 220 | assert_equal 8, new4.pos 221 | 222 | new4.insert_at(2) 223 | assert_equal 2, new4.pos 224 | 225 | new.reload 226 | assert_equal 8, new.pos 227 | 228 | new.insert_at(2) 229 | assert_equal 2, new.pos 230 | 231 | new4.reload 232 | assert_equal 3, new4.pos 233 | 234 | new5 = DefaultScopedMixin.create 235 | assert_equal 9, new5.pos 236 | 237 | new5.insert_at(1) 238 | assert_equal 1, new5.pos 239 | 240 | new4.reload 241 | assert_equal 4, new4.pos 242 | end 243 | 244 | def test_update_position 245 | assert_equal [1, 2, 3, 4], DefaultScopedMixin.find(:all).map(&:id) 246 | DefaultScopedMixin.find(2).update_attributes!(:pos => 4) 247 | assert_equal [1, 3, 4, 2], DefaultScopedMixin.find(:all).map(&:id) 248 | DefaultScopedMixin.find(2).update_attributes!(:pos => 2) 249 | assert_equal [1, 2, 3, 4], DefaultScopedMixin.find(:all).map(&:id) 250 | DefaultScopedMixin.find(1).update_attributes!(:pos => 4) 251 | assert_equal [2, 3, 4, 1], DefaultScopedMixin.find(:all).map(&:id) 252 | DefaultScopedMixin.find(1).update_attributes!(:pos => 1) 253 | assert_equal [1, 2, 3, 4], DefaultScopedMixin.find(:all).map(&:id) 254 | end 255 | 256 | end 257 | 258 | class DefaultScopedWhereTest < ActsAsListTestCase 259 | def setup 260 | setup_db 261 | (1..4).each { |counter| DefaultScopedWhereMixin.create! :pos => counter, :active => false } 262 | end 263 | 264 | def test_insert 265 | new = DefaultScopedWhereMixin.create 266 | assert_equal 5, new.pos 267 | assert !new.first? 268 | assert new.last? 269 | 270 | new = DefaultScopedWhereMixin.create 271 | assert_equal 6, new.pos 272 | assert !new.first? 273 | assert new.last? 274 | 275 | new = DefaultScopedWhereMixin.create 276 | assert_equal 7, new.pos 277 | assert !new.first? 278 | assert new.last? 279 | end 280 | 281 | def test_reordering 282 | assert_equal [1, 2, 3, 4], DefaultScopedWhereMixin.where(:active => false).map(&:id) 283 | 284 | DefaultScopedWhereMixin.where(:active => false).find(2).move_lower 285 | assert_equal [1, 3, 2, 4], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 286 | 287 | DefaultScopedWhereMixin.where(:active => false).find(2).move_higher 288 | assert_equal [1, 2, 3, 4], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 289 | 290 | DefaultScopedWhereMixin.where(:active => false).find(1).move_to_bottom 291 | assert_equal [2, 3, 4, 1], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 292 | 293 | DefaultScopedWhereMixin.where(:active => false).find(1).move_to_top 294 | assert_equal [1, 2, 3, 4], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 295 | 296 | DefaultScopedWhereMixin.where(:active => false).find(2).move_to_bottom 297 | assert_equal [1, 3, 4, 2], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 298 | 299 | DefaultScopedWhereMixin.where(:active => false).find(4).move_to_top 300 | assert_equal [4, 1, 3, 2], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 301 | end 302 | 303 | def test_insert_at 304 | new = DefaultScopedWhereMixin.create 305 | assert_equal 5, new.pos 306 | 307 | new = DefaultScopedWhereMixin.create 308 | assert_equal 6, new.pos 309 | 310 | new = DefaultScopedWhereMixin.create 311 | assert_equal 7, new.pos 312 | 313 | new4 = DefaultScopedWhereMixin.create 314 | assert_equal 8, new4.pos 315 | 316 | new4.insert_at(2) 317 | assert_equal 2, new4.pos 318 | 319 | new.reload 320 | assert_equal 8, new.pos 321 | 322 | new.insert_at(2) 323 | assert_equal 2, new.pos 324 | 325 | new4.reload 326 | assert_equal 3, new4.pos 327 | 328 | new5 = DefaultScopedWhereMixin.create 329 | assert_equal 9, new5.pos 330 | 331 | new5.insert_at(1) 332 | assert_equal 1, new5.pos 333 | 334 | new4.reload 335 | assert_equal 4, new4.pos 336 | end 337 | 338 | def test_update_position 339 | assert_equal [1, 2, 3, 4], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 340 | DefaultScopedWhereMixin.where(:active => false).find(2).update_attributes!(:pos => 4) 341 | assert_equal [1, 3, 4, 2], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 342 | DefaultScopedWhereMixin.where(:active => false).find(2).update_attributes!(:pos => 2) 343 | assert_equal [1, 2, 3, 4], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 344 | DefaultScopedWhereMixin.where(:active => false).find(1).update_attributes!(:pos => 4) 345 | assert_equal [2, 3, 4, 1], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 346 | DefaultScopedWhereMixin.where(:active => false).find(1).update_attributes!(:pos => 1) 347 | assert_equal [1, 2, 3, 4], DefaultScopedWhereMixin.where(:active => false).find(:all).map(&:id) 348 | end 349 | 350 | end 351 | 352 | #class TopAdditionMixin < Mixin 353 | 354 | class TopAdditionTest < ActsAsListTestCase 355 | include Shared::TopAddition 356 | 357 | def setup 358 | setup_db 359 | super 360 | end 361 | end 362 | 363 | class TopAdditionTestWithDefault < ActsAsListTestCase 364 | include Shared::TopAddition 365 | 366 | def setup 367 | setup_db_with_default 368 | super 369 | end 370 | end 371 | -------------------------------------------------------------------------------- /lib/acts_as_list/active_record/acts/list.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Acts #:nodoc: 3 | module List #:nodoc: 4 | def self.included(base) 5 | base.extend(ClassMethods) 6 | end 7 | 8 | # This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list. 9 | # The class that has this specified needs to have a +position+ column defined as an integer on 10 | # the mapped database table. 11 | # 12 | # Todo list example: 13 | # 14 | # class TodoList < ActiveRecord::Base 15 | # has_many :todo_items, :order => "position" 16 | # end 17 | # 18 | # class TodoItem < ActiveRecord::Base 19 | # belongs_to :todo_list 20 | # acts_as_list :scope => :todo_list 21 | # end 22 | # 23 | # todo_list.first.move_to_bottom 24 | # todo_list.last.move_higher 25 | module ClassMethods 26 | # Configuration options are: 27 | # 28 | # * +column+ - specifies the column name to use for keeping the position integer (default: +position+) 29 | # * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach _id 30 | # (if it hasn't already been added) and use that as the foreign key restriction. It's also possible 31 | # to give it an entire string that is interpolated if you need a tighter scope than just a foreign key. 32 | # Example: acts_as_list :scope => 'todo_list_id = #{todo_list_id} AND completed = 0' 33 | # * +top_of_list+ - defines the integer used for the top of the list. Defaults to 1. Use 0 to make the collection 34 | # act more like an array in its indexing. 35 | # * +add_new_at+ - specifies whether objects get added to the :top or :bottom of the list. (default: +bottom+) 36 | def acts_as_list(options = {}) 37 | configuration = { :column => "position", :scope => "1 = 1", :top_of_list => 1, :add_new_at => :bottom} 38 | configuration.update(options) if options.is_a?(Hash) 39 | 40 | configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/ 41 | 42 | if configuration[:scope].is_a?(Symbol) 43 | scope_condition_method = %( 44 | def scope_condition 45 | self.class.send(:sanitize_sql_hash_for_conditions, { :#{configuration[:scope].to_s} => send(:#{configuration[:scope].to_s}) }) 46 | end 47 | ) 48 | elsif configuration[:scope].is_a?(Array) 49 | scope_condition_method = %( 50 | def scope_condition 51 | attrs = %w(#{configuration[:scope].join(" ")}).inject({}) do |memo,column| 52 | memo[column.intern] = send(column.intern); memo 53 | end 54 | self.class.send(:sanitize_sql_hash_for_conditions, attrs) 55 | end 56 | ) 57 | else 58 | scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end" 59 | end 60 | 61 | class_eval <<-EOV 62 | include ::ActiveRecord::Acts::List::InstanceMethods 63 | 64 | def acts_as_list_top 65 | #{configuration[:top_of_list]}.to_i 66 | end 67 | 68 | def acts_as_list_class 69 | ::#{self.name} 70 | end 71 | 72 | def position_column 73 | '#{configuration[:column]}' 74 | end 75 | 76 | #{scope_condition_method} 77 | 78 | after_destroy :decrement_positions_on_lower_items 79 | before_create :add_to_list_#{configuration[:add_new_at]} 80 | after_update :update_positions 81 | EOV 82 | end 83 | end 84 | 85 | # All the methods available to a record that has had acts_as_list specified. Each method works 86 | # by assuming the object to be the item in the list, so chapter.move_lower would move that chapter 87 | # lower in the list of all chapters. Likewise, chapter.first? would return +true+ if that chapter is 88 | # the first in the list of all chapters. 89 | module InstanceMethods 90 | # Insert the item at the given position (defaults to the top position of 1). 91 | def insert_at(position = acts_as_list_top) 92 | insert_at_position(position) 93 | end 94 | 95 | # Swap positions with the next lower item, if one exists. 96 | def move_lower 97 | return unless lower_item 98 | 99 | acts_as_list_class.transaction do 100 | lower_item.decrement_position 101 | increment_position 102 | end 103 | end 104 | 105 | # Swap positions with the next higher item, if one exists. 106 | def move_higher 107 | return unless higher_item 108 | 109 | acts_as_list_class.transaction do 110 | higher_item.increment_position 111 | decrement_position 112 | end 113 | end 114 | 115 | # Move to the bottom of the list. If the item is already in the list, the items below it have their 116 | # position adjusted accordingly. 117 | def move_to_bottom 118 | return unless in_list? 119 | acts_as_list_class.transaction do 120 | decrement_positions_on_lower_items 121 | assume_bottom_position 122 | end 123 | end 124 | 125 | # Move to the top of the list. If the item is already in the list, the items above it have their 126 | # position adjusted accordingly. 127 | def move_to_top 128 | return unless in_list? 129 | acts_as_list_class.transaction do 130 | increment_positions_on_higher_items 131 | assume_top_position 132 | end 133 | end 134 | 135 | # Removes the item from the list. 136 | def remove_from_list 137 | if in_list? 138 | decrement_positions_on_lower_items 139 | update_attributes! position_column => nil 140 | end 141 | end 142 | 143 | # Increase the position of this item without adjusting the rest of the list. 144 | def increment_position 145 | return unless in_list? 146 | update_attributes! position_column => self.send(position_column).to_i + 1 147 | end 148 | 149 | # Decrease the position of this item without adjusting the rest of the list. 150 | def decrement_position 151 | return unless in_list? 152 | update_attributes! position_column => self.send(position_column).to_i - 1 153 | end 154 | 155 | # Return +true+ if this object is the first in the list. 156 | def first? 157 | return false unless in_list? 158 | self.send(position_column) == acts_as_list_top 159 | end 160 | 161 | # Return +true+ if this object is the last in the list. 162 | def last? 163 | return false unless in_list? 164 | self.send(position_column) == bottom_position_in_list 165 | end 166 | 167 | # Return the next higher item in the list. 168 | def higher_item 169 | return nil unless in_list? 170 | acts_as_list_class.unscoped.find(:first, :conditions => 171 | "#{scope_condition} AND #{position_column} = #{(send(position_column).to_i - 1).to_s}" 172 | ) 173 | end 174 | 175 | # Return the next lower item in the list. 176 | def lower_item 177 | return nil unless in_list? 178 | acts_as_list_class.unscoped.find(:first, :conditions => 179 | "#{scope_condition} AND #{position_column} = #{(send(position_column).to_i + 1).to_s}" 180 | ) 181 | end 182 | 183 | # Test if this record is in a list 184 | def in_list? 185 | !not_in_list? 186 | end 187 | 188 | def not_in_list? 189 | send(position_column).nil? 190 | end 191 | 192 | def default_position 193 | acts_as_list_class.columns_hash[position_column.to_s].default 194 | end 195 | 196 | def default_position? 197 | default_position == send(position_column) 198 | end 199 | 200 | private 201 | def add_to_list_top 202 | increment_positions_on_all_items 203 | self[position_column] = acts_as_list_top 204 | end 205 | 206 | def add_to_list_bottom 207 | if not_in_list? || default_position? 208 | self[position_column] = bottom_position_in_list.to_i + 1 209 | else 210 | increment_positions_on_lower_items(self[position_column]) 211 | end 212 | end 213 | 214 | # Overwrite this method to define the scope of the list changes 215 | def scope_condition() "1" end 216 | 217 | # Returns the bottom position number in the list. 218 | # bottom_position_in_list # => 2 219 | def bottom_position_in_list(except = nil) 220 | item = bottom_item(except) 221 | item ? item.send(position_column) : acts_as_list_top - 1 222 | end 223 | 224 | # Returns the bottom item 225 | def bottom_item(except = nil) 226 | conditions = scope_condition 227 | conditions = "#{conditions} AND #{self.class.primary_key} != #{except.id}" if except 228 | acts_as_list_class.unscoped.find(:first, :conditions => conditions, :order => "#{acts_as_list_class.table_name}.#{position_column} DESC") 229 | end 230 | 231 | # Forces item to assume the bottom position in the list. 232 | def assume_bottom_position 233 | update_attributes!(position_column => bottom_position_in_list(self).to_i + 1) 234 | end 235 | 236 | # Forces item to assume the top position in the list. 237 | def assume_top_position 238 | update_attributes!(position_column => acts_as_list_top) 239 | end 240 | 241 | # This has the effect of moving all the higher items up one. 242 | def decrement_positions_on_higher_items(position) 243 | acts_as_list_class.unscoped.update_all( 244 | "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} <= #{position}" 245 | ) 246 | end 247 | 248 | # This has the effect of moving all the lower items up one. 249 | def decrement_positions_on_lower_items(position=nil) 250 | return unless in_list? 251 | position ||= send(position_column).to_i 252 | acts_as_list_class.unscoped.update_all( 253 | "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} > #{position}" 254 | ) 255 | end 256 | 257 | # This has the effect of moving all the higher items down one. 258 | def increment_positions_on_higher_items 259 | return unless in_list? 260 | acts_as_list_class.unscoped.update_all( 261 | "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} < #{send(position_column).to_i}" 262 | ) 263 | end 264 | 265 | # This has the effect of moving all the lower items down one. 266 | def increment_positions_on_lower_items(position) 267 | acts_as_list_class.unscoped.update_all( 268 | "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} >= #{position}" 269 | ) 270 | end 271 | 272 | # Increments position (position_column) of all items in the list. 273 | def increment_positions_on_all_items 274 | acts_as_list_class.unscoped.update_all( 275 | "#{position_column} = (#{position_column} + 1)", "#{scope_condition}" 276 | ) 277 | end 278 | 279 | # Reorders intermediate items to support moving an item from old_position to new_position. 280 | def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil) 281 | return if old_position == new_position 282 | avoid_id_condition = avoid_id ? " AND #{self.class.primary_key} != #{avoid_id}" : '' 283 | if old_position < new_position 284 | # Decrement position of intermediate items 285 | # 286 | # e.g., if moving an item from 2 to 5, 287 | # move [3, 4, 5] to [2, 3, 4] 288 | acts_as_list_class.unscoped.update_all( 289 | "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} > #{old_position} AND #{position_column} <= #{new_position}#{avoid_id_condition}" 290 | ) 291 | else 292 | # Increment position of intermediate items 293 | # 294 | # e.g., if moving an item from 5 to 2, 295 | # move [2, 3, 4] to [3, 4, 5] 296 | acts_as_list_class.unscoped.update_all( 297 | "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} >= #{new_position} AND #{position_column} < #{old_position}#{avoid_id_condition}" 298 | ) 299 | end 300 | end 301 | 302 | def insert_at_position(position) 303 | if in_list? 304 | old_position = send(position_column).to_i 305 | return if position == old_position 306 | shuffle_positions_on_intermediate_items(old_position, position) 307 | else 308 | increment_positions_on_lower_items(position) 309 | end 310 | self.update_attributes!(position_column => position) 311 | end 312 | 313 | # used by insert_at_position instead of remove_from_list, as postgresql raises error if position_column has non-null constraint 314 | def store_at_0 315 | if in_list? 316 | old_position = send(position_column).to_i 317 | update_attributes!(position_column => 0) 318 | decrement_positions_on_lower_items(old_position) 319 | end 320 | end 321 | 322 | def update_positions 323 | old_position = send("#{position_column}_was").to_i 324 | new_position = send(position_column).to_i 325 | return unless acts_as_list_class.unscoped.where("#{position_column} = #{new_position}").count > 1 326 | shuffle_positions_on_intermediate_items old_position, new_position, id 327 | end 328 | end 329 | end 330 | end 331 | end 332 | --------------------------------------------------------------------------------