├── init.rb ├── Rakefile ├── README ├── lib └── active_record │ └── acts │ └── list.rb └── test └── list_test.rb /init.rb: -------------------------------------------------------------------------------- 1 | $:.unshift "#{File.dirname(__FILE__)}/lib" 2 | require 'active_record/acts/list' 3 | ActiveRecord::Base.class_eval { include ActiveRecord::Acts::List } 4 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rake/testtask' 3 | 4 | desc 'Default: run acts_as_list unit tests.' 5 | task :default => :test 6 | 7 | desc 'Test the acts_as_ordered_tree plugin.' 8 | Rake::TestTask.new(:test) do |t| 9 | t.libs << 'lib' 10 | t.pattern = 'test/**/*_test.rb' 11 | t.verbose = true 12 | end 13 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | ActsAsList 2 | ========== 3 | 4 | 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. 5 | 6 | 7 | Example 8 | ======= 9 | 10 | class TodoList < ActiveRecord::Base 11 | has_many :todo_items, :order => "position" 12 | end 13 | 14 | class TodoItem < ActiveRecord::Base 15 | belongs_to :todo_list 16 | acts_as_list :scope => :todo_list 17 | end 18 | 19 | todo_list.first.move_to_bottom 20 | todo_list.last.move_higher 21 | 22 | 23 | Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license -------------------------------------------------------------------------------- /lib/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 | def acts_as_list(options = {}) 34 | configuration = { :column => "position", :scope => "1 = 1" } 35 | configuration.update(options) if options.is_a?(Hash) 36 | 37 | configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/ 38 | 39 | if configuration[:scope].is_a?(Symbol) 40 | scope_condition_method = %( 41 | def scope_condition 42 | self.class.send(:sanitize_sql_hash_for_conditions, { :#{configuration[:scope].to_s} => send(:#{configuration[:scope].to_s}) }) 43 | end 44 | ) 45 | elsif configuration[:scope].is_a?(Array) 46 | scope_condition_method = %( 47 | def scope_condition 48 | attrs = %w(#{configuration[:scope].join(" ")}).inject({}) do |memo,column| 49 | memo[column.intern] = send(column.intern); memo 50 | end 51 | self.class.send(:sanitize_sql_hash_for_conditions, attrs) 52 | end 53 | ) 54 | else 55 | scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end" 56 | end 57 | 58 | class_eval <<-EOV 59 | include ActiveRecord::Acts::List::InstanceMethods 60 | 61 | def acts_as_list_class 62 | ::#{self.name} 63 | end 64 | 65 | def position_column 66 | '#{configuration[:column]}' 67 | end 68 | 69 | #{scope_condition_method} 70 | 71 | before_destroy :decrement_positions_on_lower_items 72 | before_create :add_to_list_bottom 73 | EOV 74 | end 75 | end 76 | 77 | # All the methods available to a record that has had acts_as_list specified. Each method works 78 | # by assuming the object to be the item in the list, so chapter.move_lower would move that chapter 79 | # lower in the list of all chapters. Likewise, chapter.first? would return +true+ if that chapter is 80 | # the first in the list of all chapters. 81 | module InstanceMethods 82 | # Insert the item at the given position (defaults to the top position of 1). 83 | def insert_at(position = 1) 84 | insert_at_position(position) 85 | end 86 | 87 | # Swap positions with the next lower item, if one exists. 88 | def move_lower 89 | return unless lower_item 90 | 91 | acts_as_list_class.transaction do 92 | lower_item.decrement_position 93 | increment_position 94 | end 95 | end 96 | 97 | # Swap positions with the next higher item, if one exists. 98 | def move_higher 99 | return unless higher_item 100 | 101 | acts_as_list_class.transaction do 102 | higher_item.increment_position 103 | decrement_position 104 | end 105 | end 106 | 107 | # Move to the bottom of the list. If the item is already in the list, the items below it have their 108 | # position adjusted accordingly. 109 | def move_to_bottom 110 | return unless in_list? 111 | acts_as_list_class.transaction do 112 | decrement_positions_on_lower_items 113 | assume_bottom_position 114 | end 115 | end 116 | 117 | # Move to the top of the list. If the item is already in the list, the items above it have their 118 | # position adjusted accordingly. 119 | def move_to_top 120 | return unless in_list? 121 | acts_as_list_class.transaction do 122 | increment_positions_on_higher_items 123 | assume_top_position 124 | end 125 | end 126 | 127 | # Removes the item from the list. 128 | def remove_from_list 129 | if in_list? 130 | decrement_positions_on_lower_items 131 | update_attribute position_column, nil 132 | end 133 | end 134 | 135 | # Increase the position of this item without adjusting the rest of the list. 136 | def increment_position 137 | return unless in_list? 138 | update_attribute position_column, self.send(position_column).to_i + 1 139 | end 140 | 141 | # Decrease the position of this item without adjusting the rest of the list. 142 | def decrement_position 143 | return unless in_list? 144 | update_attribute position_column, self.send(position_column).to_i - 1 145 | end 146 | 147 | # Return +true+ if this object is the first in the list. 148 | def first? 149 | return false unless in_list? 150 | self.send(position_column) == 1 151 | end 152 | 153 | # Return +true+ if this object is the last in the list. 154 | def last? 155 | return false unless in_list? 156 | self.send(position_column) == bottom_position_in_list 157 | end 158 | 159 | # Return the next higher item in the list. 160 | def higher_item 161 | return nil unless in_list? 162 | acts_as_list_class.find(:first, :conditions => 163 | "#{scope_condition} AND #{position_column} = #{(send(position_column).to_i - 1).to_s}" 164 | ) 165 | end 166 | 167 | # Return the next lower item in the list. 168 | def lower_item 169 | return nil unless in_list? 170 | acts_as_list_class.find(:first, :conditions => 171 | "#{scope_condition} AND #{position_column} = #{(send(position_column).to_i + 1).to_s}" 172 | ) 173 | end 174 | 175 | # Test if this record is in a list 176 | def in_list? 177 | !send(position_column).nil? 178 | end 179 | 180 | private 181 | def add_to_list_top 182 | increment_positions_on_all_items 183 | end 184 | 185 | def add_to_list_bottom 186 | self[position_column] = bottom_position_in_list.to_i + 1 187 | end 188 | 189 | # Overwrite this method to define the scope of the list changes 190 | def scope_condition() "1" end 191 | 192 | # Returns the bottom position number in the list. 193 | # bottom_position_in_list # => 2 194 | def bottom_position_in_list(except = nil) 195 | item = bottom_item(except) 196 | item ? item.send(position_column) : 0 197 | end 198 | 199 | # Returns the bottom item 200 | def bottom_item(except = nil) 201 | conditions = scope_condition 202 | conditions = "#{conditions} AND #{self.class.primary_key} != #{except.id}" if except 203 | acts_as_list_class.find(:first, :conditions => conditions, :order => "#{position_column} DESC") 204 | end 205 | 206 | # Forces item to assume the bottom position in the list. 207 | def assume_bottom_position 208 | update_attribute(position_column, bottom_position_in_list(self).to_i + 1) 209 | end 210 | 211 | # Forces item to assume the top position in the list. 212 | def assume_top_position 213 | update_attribute(position_column, 1) 214 | end 215 | 216 | # This has the effect of moving all the higher items up one. 217 | def decrement_positions_on_higher_items(position) 218 | acts_as_list_class.update_all( 219 | "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} <= #{position}" 220 | ) 221 | end 222 | 223 | # This has the effect of moving all the lower items up one. 224 | def decrement_positions_on_lower_items 225 | return unless in_list? 226 | acts_as_list_class.update_all( 227 | "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} > #{send(position_column).to_i}" 228 | ) 229 | end 230 | 231 | # This has the effect of moving all the higher items down one. 232 | def increment_positions_on_higher_items 233 | return unless in_list? 234 | acts_as_list_class.update_all( 235 | "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} < #{send(position_column).to_i}" 236 | ) 237 | end 238 | 239 | # This has the effect of moving all the lower items down one. 240 | def increment_positions_on_lower_items(position) 241 | acts_as_list_class.update_all( 242 | "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} >= #{position}" 243 | ) 244 | end 245 | 246 | # Increments position (position_column) of all items in the list. 247 | def increment_positions_on_all_items 248 | acts_as_list_class.update_all( 249 | "#{position_column} = (#{position_column} + 1)", "#{scope_condition}" 250 | ) 251 | end 252 | 253 | def insert_at_position(position) 254 | remove_from_list 255 | increment_positions_on_lower_items(position) 256 | self.update_attribute(position_column, position) 257 | end 258 | end 259 | end 260 | end 261 | end 262 | -------------------------------------------------------------------------------- /test/list_test.rb: -------------------------------------------------------------------------------- 1 | require 'test/unit' 2 | 3 | require 'rubygems' 4 | gem 'activerecord', '>= 1.15.4.7794' 5 | require 'active_record' 6 | 7 | require "#{File.dirname(__FILE__)}/../init" 8 | 9 | ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :database => ":memory:") 10 | 11 | def setup_db 12 | ActiveRecord::Schema.define(:version => 1) do 13 | create_table :mixins do |t| 14 | t.column :pos, :integer 15 | t.column :parent_id, :integer 16 | t.column :parent_type, :string 17 | t.column :created_at, :datetime 18 | t.column :updated_at, :datetime 19 | end 20 | end 21 | end 22 | 23 | def teardown_db 24 | ActiveRecord::Base.connection.tables.each do |table| 25 | ActiveRecord::Base.connection.drop_table(table) 26 | end 27 | end 28 | 29 | class Mixin < ActiveRecord::Base 30 | end 31 | 32 | class ListMixin < Mixin 33 | acts_as_list :column => "pos", :scope => :parent 34 | 35 | def self.table_name() "mixins" end 36 | end 37 | 38 | class ListMixinSub1 < ListMixin 39 | end 40 | 41 | class ListMixinSub2 < ListMixin 42 | end 43 | 44 | class ListWithStringScopeMixin < ActiveRecord::Base 45 | acts_as_list :column => "pos", :scope => 'parent_id = #{parent_id}' 46 | 47 | def self.table_name() "mixins" end 48 | end 49 | 50 | class ArrayScopeListMixin < Mixin 51 | acts_as_list :column => "pos", :scope => [:parent_id, :parent_type] 52 | 53 | def self.table_name() "mixins" end 54 | end 55 | 56 | class ListTest < Test::Unit::TestCase 57 | 58 | def setup 59 | setup_db 60 | (1..4).each { |counter| ListMixin.create! :pos => counter, :parent_id => 5 } 61 | end 62 | 63 | def teardown 64 | teardown_db 65 | end 66 | 67 | def test_reordering 68 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 69 | 70 | ListMixin.find(2).move_lower 71 | assert_equal [1, 3, 2, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 72 | 73 | ListMixin.find(2).move_higher 74 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 75 | 76 | ListMixin.find(1).move_to_bottom 77 | assert_equal [2, 3, 4, 1], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 78 | 79 | ListMixin.find(1).move_to_top 80 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 81 | 82 | ListMixin.find(2).move_to_bottom 83 | assert_equal [1, 3, 4, 2], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 84 | 85 | ListMixin.find(4).move_to_top 86 | assert_equal [4, 1, 3, 2], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 87 | end 88 | 89 | def test_move_to_bottom_with_next_to_last_item 90 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 91 | ListMixin.find(3).move_to_bottom 92 | assert_equal [1, 2, 4, 3], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 93 | end 94 | 95 | def test_next_prev 96 | assert_equal ListMixin.find(2), ListMixin.find(1).lower_item 97 | assert_nil ListMixin.find(1).higher_item 98 | assert_equal ListMixin.find(3), ListMixin.find(4).higher_item 99 | assert_nil ListMixin.find(4).lower_item 100 | end 101 | 102 | def test_injection 103 | item = ListMixin.new(:parent_id => 1) 104 | assert_equal '"mixins"."parent_id" = 1', item.scope_condition 105 | assert_equal "pos", item.position_column 106 | end 107 | 108 | def test_insert 109 | new = ListMixin.create(:parent_id => 20) 110 | assert_equal 1, new.pos 111 | assert new.first? 112 | assert new.last? 113 | 114 | new = ListMixin.create(:parent_id => 20) 115 | assert_equal 2, new.pos 116 | assert !new.first? 117 | assert new.last? 118 | 119 | new = ListMixin.create(:parent_id => 20) 120 | assert_equal 3, new.pos 121 | assert !new.first? 122 | assert new.last? 123 | 124 | new = ListMixin.create(:parent_id => 0) 125 | assert_equal 1, new.pos 126 | assert new.first? 127 | assert new.last? 128 | end 129 | 130 | def test_insert_at 131 | new = ListMixin.create(:parent_id => 20) 132 | assert_equal 1, new.pos 133 | 134 | new = ListMixin.create(:parent_id => 20) 135 | assert_equal 2, new.pos 136 | 137 | new = ListMixin.create(:parent_id => 20) 138 | assert_equal 3, new.pos 139 | 140 | new4 = ListMixin.create(:parent_id => 20) 141 | assert_equal 4, new4.pos 142 | 143 | new4.insert_at(3) 144 | assert_equal 3, new4.pos 145 | 146 | new.reload 147 | assert_equal 4, new.pos 148 | 149 | new.insert_at(2) 150 | assert_equal 2, new.pos 151 | 152 | new4.reload 153 | assert_equal 4, new4.pos 154 | 155 | new5 = ListMixin.create(:parent_id => 20) 156 | assert_equal 5, new5.pos 157 | 158 | new5.insert_at(1) 159 | assert_equal 1, new5.pos 160 | 161 | new4.reload 162 | assert_equal 5, new4.pos 163 | end 164 | 165 | def test_delete_middle 166 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 167 | 168 | ListMixin.find(2).destroy 169 | 170 | assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 171 | 172 | assert_equal 1, ListMixin.find(1).pos 173 | assert_equal 2, ListMixin.find(3).pos 174 | assert_equal 3, ListMixin.find(4).pos 175 | 176 | ListMixin.find(1).destroy 177 | 178 | assert_equal [3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 179 | 180 | assert_equal 1, ListMixin.find(3).pos 181 | assert_equal 2, ListMixin.find(4).pos 182 | end 183 | 184 | def test_with_string_based_scope 185 | new = ListWithStringScopeMixin.create(:parent_id => 500) 186 | assert_equal 1, new.pos 187 | assert new.first? 188 | assert new.last? 189 | end 190 | 191 | def test_nil_scope 192 | new1, new2, new3 = ListMixin.create, ListMixin.create, ListMixin.create 193 | new2.move_higher 194 | assert_equal [new2, new1, new3], ListMixin.find(:all, :conditions => 'parent_id IS NULL', :order => 'pos') 195 | end 196 | 197 | def test_remove_from_list_should_then_fail_in_list? 198 | assert_equal true, ListMixin.find(1).in_list? 199 | ListMixin.find(1).remove_from_list 200 | assert_equal false, ListMixin.find(1).in_list? 201 | end 202 | 203 | def test_remove_from_list_should_set_position_to_nil 204 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 205 | 206 | ListMixin.find(2).remove_from_list 207 | 208 | assert_equal [2, 1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 209 | 210 | assert_equal 1, ListMixin.find(1).pos 211 | assert_equal nil, ListMixin.find(2).pos 212 | assert_equal 2, ListMixin.find(3).pos 213 | assert_equal 3, ListMixin.find(4).pos 214 | end 215 | 216 | def test_remove_before_destroy_does_not_shift_lower_items_twice 217 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 218 | 219 | ListMixin.find(2).remove_from_list 220 | ListMixin.find(2).destroy 221 | 222 | assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 223 | 224 | assert_equal 1, ListMixin.find(1).pos 225 | assert_equal 2, ListMixin.find(3).pos 226 | assert_equal 3, ListMixin.find(4).pos 227 | end 228 | 229 | def test_before_destroy_callbacks_do_not_update_position_to_nil_before_deleting_the_record 230 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 231 | 232 | # We need to trigger all the before_destroy callbacks without actually 233 | # destroying the record so we can see the affect the callbacks have on 234 | # the record. 235 | list = ListMixin.find(2) 236 | if list.respond_to?(:run_callbacks) 237 | list.run_callbacks(:destroy) 238 | else 239 | list.send(:callback, :before_destroy) 240 | end 241 | 242 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5', :order => 'pos').map(&:id) 243 | 244 | assert_equal 1, ListMixin.find(1).pos 245 | assert_equal 2, ListMixin.find(2).pos 246 | assert_equal 2, ListMixin.find(3).pos 247 | assert_equal 3, ListMixin.find(4).pos 248 | end 249 | 250 | end 251 | 252 | class ListSubTest < Test::Unit::TestCase 253 | 254 | def setup 255 | setup_db 256 | (1..4).each { |i| ((i % 2 == 1) ? ListMixinSub1 : ListMixinSub2).create! :pos => i, :parent_id => 5000 } 257 | end 258 | 259 | def teardown 260 | teardown_db 261 | end 262 | 263 | def test_reordering 264 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 265 | 266 | ListMixin.find(2).move_lower 267 | assert_equal [1, 3, 2, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 268 | 269 | ListMixin.find(2).move_higher 270 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 271 | 272 | ListMixin.find(1).move_to_bottom 273 | assert_equal [2, 3, 4, 1], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 274 | 275 | ListMixin.find(1).move_to_top 276 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 277 | 278 | ListMixin.find(2).move_to_bottom 279 | assert_equal [1, 3, 4, 2], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 280 | 281 | ListMixin.find(4).move_to_top 282 | assert_equal [4, 1, 3, 2], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 283 | end 284 | 285 | def test_move_to_bottom_with_next_to_last_item 286 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 287 | ListMixin.find(3).move_to_bottom 288 | assert_equal [1, 2, 4, 3], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 289 | end 290 | 291 | def test_next_prev 292 | assert_equal ListMixin.find(2), ListMixin.find(1).lower_item 293 | assert_nil ListMixin.find(1).higher_item 294 | assert_equal ListMixin.find(3), ListMixin.find(4).higher_item 295 | assert_nil ListMixin.find(4).lower_item 296 | end 297 | 298 | def test_injection 299 | item = ListMixin.new("parent_id"=>1) 300 | assert_equal '"mixins"."parent_id" = 1', item.scope_condition 301 | assert_equal "pos", item.position_column 302 | end 303 | 304 | def test_insert_at 305 | new = ListMixin.create("parent_id" => 20) 306 | assert_equal 1, new.pos 307 | 308 | new = ListMixinSub1.create("parent_id" => 20) 309 | assert_equal 2, new.pos 310 | 311 | new = ListMixinSub2.create("parent_id" => 20) 312 | assert_equal 3, new.pos 313 | 314 | new4 = ListMixin.create("parent_id" => 20) 315 | assert_equal 4, new4.pos 316 | 317 | new4.insert_at(3) 318 | assert_equal 3, new4.pos 319 | 320 | new.reload 321 | assert_equal 4, new.pos 322 | 323 | new.insert_at(2) 324 | assert_equal 2, new.pos 325 | 326 | new4.reload 327 | assert_equal 4, new4.pos 328 | 329 | new5 = ListMixinSub1.create("parent_id" => 20) 330 | assert_equal 5, new5.pos 331 | 332 | new5.insert_at(1) 333 | assert_equal 1, new5.pos 334 | 335 | new4.reload 336 | assert_equal 5, new4.pos 337 | end 338 | 339 | def test_delete_middle 340 | assert_equal [1, 2, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 341 | 342 | ListMixin.find(2).destroy 343 | 344 | assert_equal [1, 3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 345 | 346 | assert_equal 1, ListMixin.find(1).pos 347 | assert_equal 2, ListMixin.find(3).pos 348 | assert_equal 3, ListMixin.find(4).pos 349 | 350 | ListMixin.find(1).destroy 351 | 352 | assert_equal [3, 4], ListMixin.find(:all, :conditions => 'parent_id = 5000', :order => 'pos').map(&:id) 353 | 354 | assert_equal 1, ListMixin.find(3).pos 355 | assert_equal 2, ListMixin.find(4).pos 356 | end 357 | 358 | end 359 | 360 | class ArrayScopeListTest < Test::Unit::TestCase 361 | 362 | def setup 363 | setup_db 364 | (1..4).each { |counter| ArrayScopeListMixin.create! :pos => counter, :parent_id => 5, :parent_type => 'ParentClass' } 365 | end 366 | 367 | def teardown 368 | teardown_db 369 | end 370 | 371 | def test_reordering 372 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 373 | 374 | ArrayScopeListMixin.find(2).move_lower 375 | assert_equal [1, 3, 2, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 376 | 377 | ArrayScopeListMixin.find(2).move_higher 378 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 379 | 380 | ArrayScopeListMixin.find(1).move_to_bottom 381 | assert_equal [2, 3, 4, 1], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 382 | 383 | ArrayScopeListMixin.find(1).move_to_top 384 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 385 | 386 | ArrayScopeListMixin.find(2).move_to_bottom 387 | assert_equal [1, 3, 4, 2], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 388 | 389 | ArrayScopeListMixin.find(4).move_to_top 390 | assert_equal [4, 1, 3, 2], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 391 | end 392 | 393 | def test_move_to_bottom_with_next_to_last_item 394 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 395 | ArrayScopeListMixin.find(3).move_to_bottom 396 | assert_equal [1, 2, 4, 3], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 397 | end 398 | 399 | def test_next_prev 400 | assert_equal ArrayScopeListMixin.find(2), ArrayScopeListMixin.find(1).lower_item 401 | assert_nil ArrayScopeListMixin.find(1).higher_item 402 | assert_equal ArrayScopeListMixin.find(3), ArrayScopeListMixin.find(4).higher_item 403 | assert_nil ArrayScopeListMixin.find(4).lower_item 404 | end 405 | 406 | def test_injection 407 | item = ArrayScopeListMixin.new(:parent_id => 1, :parent_type => 'ParentClass') 408 | assert_equal '"mixins"."parent_id" = 1 AND "mixins"."parent_type" = \'ParentClass\'', item.scope_condition 409 | assert_equal "pos", item.position_column 410 | end 411 | 412 | def test_insert 413 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 414 | assert_equal 1, new.pos 415 | assert new.first? 416 | assert new.last? 417 | 418 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 419 | assert_equal 2, new.pos 420 | assert !new.first? 421 | assert new.last? 422 | 423 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 424 | assert_equal 3, new.pos 425 | assert !new.first? 426 | assert new.last? 427 | 428 | new = ArrayScopeListMixin.create(:parent_id => 0, :parent_type => 'ParentClass') 429 | assert_equal 1, new.pos 430 | assert new.first? 431 | assert new.last? 432 | end 433 | 434 | def test_insert_at 435 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 436 | assert_equal 1, new.pos 437 | 438 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 439 | assert_equal 2, new.pos 440 | 441 | new = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 442 | assert_equal 3, new.pos 443 | 444 | new4 = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 445 | assert_equal 4, new4.pos 446 | 447 | new4.insert_at(3) 448 | assert_equal 3, new4.pos 449 | 450 | new.reload 451 | assert_equal 4, new.pos 452 | 453 | new.insert_at(2) 454 | assert_equal 2, new.pos 455 | 456 | new4.reload 457 | assert_equal 4, new4.pos 458 | 459 | new5 = ArrayScopeListMixin.create(:parent_id => 20, :parent_type => 'ParentClass') 460 | assert_equal 5, new5.pos 461 | 462 | new5.insert_at(1) 463 | assert_equal 1, new5.pos 464 | 465 | new4.reload 466 | assert_equal 5, new4.pos 467 | end 468 | 469 | def test_delete_middle 470 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 471 | 472 | ArrayScopeListMixin.find(2).destroy 473 | 474 | assert_equal [1, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 475 | 476 | assert_equal 1, ArrayScopeListMixin.find(1).pos 477 | assert_equal 2, ArrayScopeListMixin.find(3).pos 478 | assert_equal 3, ArrayScopeListMixin.find(4).pos 479 | 480 | ArrayScopeListMixin.find(1).destroy 481 | 482 | assert_equal [3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 483 | 484 | assert_equal 1, ArrayScopeListMixin.find(3).pos 485 | assert_equal 2, ArrayScopeListMixin.find(4).pos 486 | end 487 | 488 | def test_remove_from_list_should_then_fail_in_list? 489 | assert_equal true, ArrayScopeListMixin.find(1).in_list? 490 | ArrayScopeListMixin.find(1).remove_from_list 491 | assert_equal false, ArrayScopeListMixin.find(1).in_list? 492 | end 493 | 494 | def test_remove_from_list_should_set_position_to_nil 495 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 496 | 497 | ArrayScopeListMixin.find(2).remove_from_list 498 | 499 | assert_equal [2, 1, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 500 | 501 | assert_equal 1, ArrayScopeListMixin.find(1).pos 502 | assert_equal nil, ArrayScopeListMixin.find(2).pos 503 | assert_equal 2, ArrayScopeListMixin.find(3).pos 504 | assert_equal 3, ArrayScopeListMixin.find(4).pos 505 | end 506 | 507 | def test_remove_before_destroy_does_not_shift_lower_items_twice 508 | assert_equal [1, 2, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 509 | 510 | ArrayScopeListMixin.find(2).remove_from_list 511 | ArrayScopeListMixin.find(2).destroy 512 | 513 | assert_equal [1, 3, 4], ArrayScopeListMixin.find(:all, :conditions => "parent_id = 5 AND parent_type = 'ParentClass'", :order => 'pos').map(&:id) 514 | 515 | assert_equal 1, ArrayScopeListMixin.find(1).pos 516 | assert_equal 2, ArrayScopeListMixin.find(3).pos 517 | assert_equal 3, ArrayScopeListMixin.find(4).pos 518 | end 519 | 520 | end 521 | 522 | --------------------------------------------------------------------------------