acts_as_list 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/.gitignore CHANGED
@@ -2,3 +2,6 @@
2
2
  .bundle
3
3
  Gemfile.lock
4
4
  pkg/*
5
+ .rvmrc
6
+ *.tmproj
7
+ .rbenv-version
data/.travis.yml ADDED
@@ -0,0 +1,6 @@
1
+ language: ruby
2
+ rvm:
3
+ - 1.8.7
4
+ - 1.9.2
5
+ - 1.9.3
6
+ - 2.0.0
data/Gemfile CHANGED
@@ -2,3 +2,5 @@ source "http://rubygems.org"
2
2
 
3
3
  # Specify your gem's dependencies in acts_as_list-rails3.gemspec
4
4
  gemspec
5
+
6
+ gem 'rake'
data/README.md ADDED
@@ -0,0 +1,103 @@
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
+ At first, you need to add a `position` column to desired table:
20
+
21
+ rails g migration AddPositionToTodoItem position:integer
22
+ rake db:migrate
23
+
24
+ After that you can use `acts_as_list` method in the model:
25
+
26
+ ```ruby
27
+ class TodoList < ActiveRecord::Base
28
+ has_many :todo_items, order: :position
29
+ end
30
+
31
+ class TodoItem < ActiveRecord::Base
32
+ belongs_to :todo_list
33
+ acts_as_list scope: :todo_list
34
+ end
35
+
36
+ todo_list.first.move_to_bottom
37
+ todo_list.last.move_higher
38
+ ```
39
+
40
+ ## Instance Methods Added To ActiveRecord Models
41
+
42
+ You'll have a number of methods added to each instance of the ActiveRecord model that to which `acts_as_list` is added.
43
+
44
+ In `acts_as_list`, "higher" means further up the list (a lower `position`), and "lower" means further down the list (a higher `position`). That can be confusing, so it might make sense to add tests that validate that you're using the right method given your context.
45
+
46
+ ### Methods That Change Position and Reorder List
47
+
48
+ - `list_item.insert_at(2)`
49
+ - `list_item.move_lower` will do nothing if the item is the lowest item
50
+ - `list_item.move_higher` will do nothing if the item is the highest item
51
+ - `list_item.move_to_bottom`
52
+ - `list_item.move_to_top`
53
+ - `list_item.remove_from_list`
54
+
55
+ ### Methods That Change Position Without Reordering List
56
+
57
+ - `list_item.increment_position`
58
+ - `list_item.decrement_position`
59
+ - `list_item.set_list_position(3)`
60
+
61
+ ### Methods That Return Attributes of the Item's List Position
62
+ - `list_item.first?`
63
+ - `list_item.last?`
64
+ - `list_item.in_list?`
65
+ - `list_item.not_in_list?`
66
+ - `list_item.default_position?`
67
+ - `list_item.higher_item`
68
+ - `list_item.higher_items` will return all the items above `list_item` in the list (ordered by the position, ascending)
69
+ - `list_item.lower_item`
70
+ - `list_item.lower_items` will return all the items below `list_item` in the list (ordered by the position, ascending)
71
+
72
+ ## Notes
73
+ 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.
74
+
75
+ 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.
76
+
77
+ The `position` column is set after validations are called, so you should not put a `presence` validation on the `position` column.
78
+
79
+ ## Versions
80
+ All versions `0.1.5` onwards require Rails 3.0.x and higher.
81
+
82
+ ## Build Status
83
+ [![Build Status](https://secure.travis-ci.org/swanandp/acts_as_list.png)](https://secure.travis-ci.org/swanandp/acts_as_list)
84
+
85
+ ## Roadmap
86
+
87
+ 1. Sort based feature
88
+ 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.
89
+
90
+ ## Contributing to `acts_as_list`
91
+
92
+ - Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet
93
+ - Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it
94
+ - Fork the project
95
+ - Start a feature/bugfix branch
96
+ - Commit and push until you are happy with your contribution
97
+ - Make sure to add tests for it. This is important so I don't break it in a future version unintentionally.
98
+ - 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.
99
+ - 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.
100
+
101
+ ## Copyright
102
+
103
+ Copyright (c) 2007 David Heinemeier Hansson, released under the MIT license
data/Rakefile CHANGED
@@ -12,11 +12,9 @@ desc 'Test the acts_as_list plugin.'
12
12
  Rake::TestTask.new(:test) do |t|
13
13
  t.libs << 'lib' << 'test'
14
14
  t.pattern = 'test/**/test_*.rb'
15
- t.verbose = true
15
+ t.verbose = false
16
16
  end
17
17
 
18
-
19
-
20
18
  # Run the rdoc task to generate rdocs for this gem
21
19
  require 'rdoc/task'
22
20
  RDoc::Task.new do |rdoc|
data/acts_as_list.gemspec CHANGED
@@ -24,8 +24,8 @@ Gem::Specification.new do |s|
24
24
 
25
25
 
26
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"])
27
+ s.add_dependency("activerecord", [">= 3.0"])
28
+ s.add_development_dependency("bundler", [">= 1.0.0"])
29
29
  s.add_development_dependency("rdoc")
30
30
  s.add_development_dependency("sqlite3")
31
31
  end
data/init.rb CHANGED
@@ -1,2 +1,4 @@
1
1
  $:.unshift "#{File.dirname(__FILE__)}/lib"
2
2
  require 'acts_as_list'
3
+
4
+ ActsAsList::Railtie.insert
@@ -26,14 +26,15 @@ module ActiveRecord
26
26
  # Configuration options are:
27
27
  #
28
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 <tt>_id</tt>
30
- # (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
29
+ # * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach <tt>_id</tt>
30
+ # (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
31
31
  # to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
32
32
  # Example: <tt>acts_as_list :scope => 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
33
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 line an array in it's indexing.
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+)
35
36
  def acts_as_list(options = {})
36
- configuration = { :column => "position", :scope => "1 = 1", :top_of_list => 1}
37
+ configuration = { :column => "position", :scope => "1 = 1", :top_of_list => 1, :add_new_at => :bottom}
37
38
  configuration.update(options) if options.is_a?(Hash)
38
39
 
39
40
  configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
@@ -47,7 +48,7 @@ module ActiveRecord
47
48
  elsif configuration[:scope].is_a?(Array)
48
49
  scope_condition_method = %(
49
50
  def scope_condition
50
- attrs = %w(#{configuration[:scope].join(" ")}).inject({}) do |memo,column|
51
+ attrs = %w(#{configuration[:scope].join(" ")}).inject({}) do |memo,column|
51
52
  memo[column.intern] = send(column.intern); memo
52
53
  end
53
54
  self.class.send(:sanitize_sql_hash_for_conditions, attrs)
@@ -58,7 +59,7 @@ module ActiveRecord
58
59
  end
59
60
 
60
61
  class_eval <<-EOV
61
- include ActiveRecord::Acts::List::InstanceMethods
62
+ include ::ActiveRecord::Acts::List::InstanceMethods
62
63
 
63
64
  def acts_as_list_top
64
65
  #{configuration[:top_of_list]}.to_i
@@ -72,10 +73,28 @@ module ActiveRecord
72
73
  '#{configuration[:column]}'
73
74
  end
74
75
 
76
+ def scope_name
77
+ '#{configuration[:scope]}'
78
+ end
79
+
80
+ def add_new_at
81
+ '#{configuration[:add_new_at]}'
82
+ end
83
+
75
84
  #{scope_condition_method}
76
85
 
77
- before_destroy :decrement_positions_on_lower_items
78
- before_create :add_to_list_bottom
86
+ # only add to attr_accessible
87
+ # if the class has some mass_assignment_protection
88
+
89
+ if defined?(accessible_attributes) and !accessible_attributes.blank?
90
+ attr_accessible :#{configuration[:column]}
91
+ end
92
+
93
+ before_destroy :reload_position
94
+ after_destroy :decrement_positions_on_lower_items
95
+ before_create :add_to_list_#{configuration[:add_new_at]}
96
+ after_update :update_positions
97
+ before_update :check_scope
79
98
  EOV
80
99
  end
81
100
  end
@@ -134,20 +153,20 @@ module ActiveRecord
134
153
  def remove_from_list
135
154
  if in_list?
136
155
  decrement_positions_on_lower_items
137
- update_attribute position_column, nil
156
+ set_list_position(nil)
138
157
  end
139
158
  end
140
159
 
141
160
  # Increase the position of this item without adjusting the rest of the list.
142
161
  def increment_position
143
162
  return unless in_list?
144
- update_attribute position_column, self.send(position_column).to_i + 1
163
+ set_list_position(self.send(position_column).to_i + 1)
145
164
  end
146
165
 
147
166
  # Decrease the position of this item without adjusting the rest of the list.
148
167
  def decrement_position
149
168
  return unless in_list?
150
- update_attribute position_column, self.send(position_column).to_i - 1
169
+ set_list_position(self.send(position_column).to_i - 1)
151
170
  end
152
171
 
153
172
  # Return +true+ if this object is the first in the list.
@@ -165,31 +184,81 @@ module ActiveRecord
165
184
  # Return the next higher item in the list.
166
185
  def higher_item
167
186
  return nil unless in_list?
168
- acts_as_list_class.find(:first, :conditions =>
169
- "#{scope_condition} AND #{position_column} = #{(send(position_column).to_i - 1).to_s}"
187
+ acts_as_list_class.unscoped.find(:first, :conditions =>
188
+ "#{scope_condition} AND #{position_column} < #{(send(position_column).to_i).to_s}",
189
+ :order => "#{acts_as_list_class.table_name}.#{position_column} DESC"
170
190
  )
171
191
  end
172
192
 
193
+ # Return the next n higher items in the list
194
+ # selects all higher items by default
195
+ def higher_items(limit=nil)
196
+ limit ||= acts_as_list_list.count
197
+ position_value = send(position_column)
198
+ acts_as_list_list.
199
+ where("#{position_column} < ?", position_value).
200
+ where("#{position_column} >= ?", position_value - limit).
201
+ limit(limit).
202
+ order("#{acts_as_list_class.table_name}.#{position_column} ASC")
203
+ end
204
+
173
205
  # Return the next lower item in the list.
174
206
  def lower_item
175
207
  return nil unless in_list?
176
- acts_as_list_class.find(:first, :conditions =>
177
- "#{scope_condition} AND #{position_column} = #{(send(position_column).to_i + 1).to_s}"
208
+ acts_as_list_class.unscoped.find(:first, :conditions =>
209
+ "#{scope_condition} AND #{position_column} > #{(send(position_column).to_i).to_s}",
210
+ :order => "#{acts_as_list_class.table_name}.#{position_column} ASC"
178
211
  )
179
212
  end
180
213
 
214
+ # Return the next n lower items in the list
215
+ # selects all lower items by default
216
+ def lower_items(limit=nil)
217
+ limit ||= acts_as_list_list.count
218
+ position_value = send(position_column)
219
+ acts_as_list_list.
220
+ where("#{position_column} > ?", position_value).
221
+ where("#{position_column} <= ?", position_value + limit).
222
+ limit(limit).
223
+ order("#{acts_as_list_class.table_name}.#{position_column} ASC")
224
+ end
225
+
181
226
  # Test if this record is in a list
182
227
  def in_list?
183
- !send(position_column).nil?
228
+ !not_in_list?
229
+ end
230
+
231
+ def not_in_list?
232
+ send(position_column).nil?
233
+ end
234
+
235
+ def default_position
236
+ acts_as_list_class.columns_hash[position_column.to_s].default
237
+ end
238
+
239
+ def default_position?
240
+ default_position == send(position_column)
241
+ end
242
+
243
+ # Sets the new position and saves it
244
+ def set_list_position(new_position)
245
+ send("#{position_column}=", new_position)
246
+ save!
184
247
  end
185
248
 
186
249
  private
250
+ def acts_as_list_list
251
+ acts_as_list_class.unscoped.
252
+ where(scope_condition)
253
+ end
254
+
187
255
  def add_to_list_top
188
256
  increment_positions_on_all_items
257
+ self[position_column] = acts_as_list_top
189
258
  end
190
259
 
191
260
  def add_to_list_bottom
192
- if self[position_column].nil?
261
+ if not_in_list? || default_position?
193
262
  self[position_column] = bottom_position_in_list.to_i + 1
194
263
  else
195
264
  increment_positions_on_lower_items(self[position_column])
@@ -210,62 +279,136 @@ module ActiveRecord
210
279
  def bottom_item(except = nil)
211
280
  conditions = scope_condition
212
281
  conditions = "#{conditions} AND #{self.class.primary_key} != #{except.id}" if except
213
- acts_as_list_class.find(:first, :conditions => conditions, :order => "#{position_column} DESC")
282
+ acts_as_list_class.unscoped.where(conditions).order("#{acts_as_list_class.table_name}.#{position_column} DESC").first
214
283
  end
215
284
 
216
285
  # Forces item to assume the bottom position in the list.
217
286
  def assume_bottom_position
218
- update_attribute(position_column, bottom_position_in_list(self).to_i + 1)
287
+ set_list_position(bottom_position_in_list(self).to_i + 1)
219
288
  end
220
289
 
221
290
  # Forces item to assume the top position in the list.
222
291
  def assume_top_position
223
- update_attribute(position_column, acts_as_list_top)
292
+ set_list_position(acts_as_list_top)
224
293
  end
225
294
 
226
295
  # This has the effect of moving all the higher items up one.
227
296
  def decrement_positions_on_higher_items(position)
228
- acts_as_list_class.update_all(
229
- "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} <= #{position}"
297
+ acts_as_list_class.unscoped.where(
298
+ "#{scope_condition} AND #{position_column} <= #{position}"
299
+ ).update_all(
300
+ "#{position_column} = (#{position_column} - 1)"
230
301
  )
231
302
  end
232
303
 
233
304
  # This has the effect of moving all the lower items up one.
234
- def decrement_positions_on_lower_items
305
+ def decrement_positions_on_lower_items(position=nil)
235
306
  return unless in_list?
236
- acts_as_list_class.update_all(
237
- "#{position_column} = (#{position_column} - 1)", "#{scope_condition} AND #{position_column} > #{send(position_column).to_i}"
307
+ position ||= send(position_column).to_i
308
+ acts_as_list_class.unscoped.where(
309
+ "#{scope_condition} AND #{position_column} > #{position}"
310
+ ).update_all(
311
+ "#{position_column} = (#{position_column} - 1)"
238
312
  )
239
313
  end
240
314
 
241
315
  # This has the effect of moving all the higher items down one.
242
316
  def increment_positions_on_higher_items
243
317
  return unless in_list?
244
- acts_as_list_class.update_all(
245
- "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} < #{send(position_column).to_i}"
318
+ acts_as_list_class.unscoped.where(
319
+ "#{scope_condition} AND #{position_column} < #{send(position_column).to_i}"
320
+ ).update_all(
321
+ "#{position_column} = (#{position_column} + 1)"
246
322
  )
247
323
  end
248
324
 
249
325
  # This has the effect of moving all the lower items down one.
250
326
  def increment_positions_on_lower_items(position)
251
- acts_as_list_class.update_all(
252
- "#{position_column} = (#{position_column} + 1)", "#{scope_condition} AND #{position_column} >= #{position}"
253
- )
327
+ acts_as_list_class.unscoped.where(
328
+ "#{scope_condition} AND #{position_column} >= #{position}"
329
+ ).update_all(
330
+ "#{position_column} = (#{position_column} + 1)"
331
+ )
254
332
  end
255
333
 
256
334
  # Increments position (<tt>position_column</tt>) of all items in the list.
257
335
  def increment_positions_on_all_items
258
- acts_as_list_class.update_all(
259
- "#{position_column} = (#{position_column} + 1)", "#{scope_condition}"
336
+ acts_as_list_class.unscoped.where(
337
+ "#{scope_condition}"
338
+ ).update_all(
339
+ "#{position_column} = (#{position_column} + 1)"
260
340
  )
261
341
  end
262
342
 
343
+ # Reorders intermediate items to support moving an item from old_position to new_position.
344
+ def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil)
345
+ return if old_position == new_position
346
+ avoid_id_condition = avoid_id ? " AND #{self.class.primary_key} != #{avoid_id}" : ''
347
+ if old_position < new_position
348
+ # Decrement position of intermediate items
349
+ #
350
+ # e.g., if moving an item from 2 to 5,
351
+ # move [3, 4, 5] to [2, 3, 4]
352
+ acts_as_list_class.unscoped.where(
353
+ "#{scope_condition} AND #{position_column} > #{old_position} AND #{position_column} <= #{new_position}#{avoid_id_condition}"
354
+ ).update_all(
355
+ "#{position_column} = (#{position_column} - 1)"
356
+ )
357
+ else
358
+ # Increment position of intermediate items
359
+ #
360
+ # e.g., if moving an item from 5 to 2,
361
+ # move [2, 3, 4] to [3, 4, 5]
362
+ acts_as_list_class.unscoped.where(
363
+ "#{scope_condition} AND #{position_column} >= #{new_position} AND #{position_column} < #{old_position}#{avoid_id_condition}"
364
+ ).update_all(
365
+ "#{position_column} = (#{position_column} + 1)"
366
+ )
367
+ end
368
+ end
369
+
263
370
  def insert_at_position(position)
264
- remove_from_list
265
- increment_positions_on_lower_items(position)
266
- self.update_attribute(position_column, position)
371
+ if in_list?
372
+ old_position = send(position_column).to_i
373
+ return if position == old_position
374
+ shuffle_positions_on_intermediate_items(old_position, position)
375
+ else
376
+ increment_positions_on_lower_items(position)
377
+ end
378
+ set_list_position(position)
267
379
  end
268
- end
380
+
381
+ # used by insert_at_position instead of remove_from_list, as postgresql raises error if position_column has non-null constraint
382
+ def store_at_0
383
+ if in_list?
384
+ old_position = send(position_column).to_i
385
+ set_list_position(0)
386
+ decrement_positions_on_lower_items(old_position)
387
+ end
388
+ end
389
+
390
+ def update_positions
391
+ old_position = send("#{position_column}_was").to_i
392
+ new_position = send(position_column).to_i
393
+ return unless acts_as_list_class.unscoped.where("#{scope_condition} AND #{position_column} = #{new_position}").count > 1
394
+ shuffle_positions_on_intermediate_items old_position, new_position, id
395
+ end
396
+
397
+ def check_scope
398
+ if changes.include?("#{scope_name}")
399
+ old_scope_id = changes["#{scope_name}"].first
400
+ new_scope_id = changes["#{scope_name}"].last
401
+ self["#{scope_name}"] = old_scope_id
402
+ send("decrement_positions_on_lower_items")
403
+ self["#{scope_name}"] = new_scope_id
404
+ send("add_to_list_#{add_new_at}")
405
+ end
406
+ end
407
+
408
+ def reload_position
409
+ self.reload
410
+ end
411
+ end
269
412
  end
270
413
  end
271
414
  end
@@ -1,7 +1,7 @@
1
1
  module ActiveRecord
2
2
  module Acts
3
3
  module List
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.0"
5
5
  end
6
6
  end
7
7
  end
data/lib/acts_as_list.rb CHANGED
@@ -1,2 +1,24 @@
1
1
  require 'acts_as_list/active_record/acts/list'
2
- ActiveRecord::Base.class_eval { include ActiveRecord::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
data/test/helper.rb CHANGED
@@ -10,3 +10,5 @@ end
10
10
  require 'test/unit'
11
11
  require 'active_record'
12
12
  require "#{File.dirname(__FILE__)}/../init"
13
+
14
+ require 'shared'
data/test/shared.rb ADDED
@@ -0,0 +1,8 @@
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