acts_as_list 0.8.2 → 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.travis.yml +13 -8
- data/CHANGELOG.md +14 -0
- data/Gemfile +16 -3
- data/README.md +37 -0
- data/gemfiles/rails_3_2.gemfile +16 -2
- data/gemfiles/rails_4_1.gemfile +16 -2
- data/gemfiles/rails_4_2.gemfile +16 -2
- data/gemfiles/rails_5_0.gemfile +16 -2
- data/lib/acts_as_list.rb +8 -14
- data/lib/acts_as_list/active_record/acts/add_new_at_method_definer.rb +9 -0
- data/lib/acts_as_list/active_record/acts/aux_method_definer.rb +9 -0
- data/lib/acts_as_list/active_record/acts/callback_definer.rb +19 -0
- data/lib/acts_as_list/active_record/acts/column_method_definer.rb +50 -0
- data/lib/acts_as_list/active_record/acts/list.rb +224 -302
- data/lib/acts_as_list/active_record/acts/no_update.rb +50 -0
- data/lib/acts_as_list/active_record/acts/scope_method_definer.rb +49 -0
- data/lib/acts_as_list/active_record/acts/sequential_updates_method_definer.rb +21 -0
- data/lib/acts_as_list/active_record/acts/top_of_list_method_definer.rb +13 -0
- data/lib/acts_as_list/version.rb +1 -1
- data/test/database.yml +16 -0
- data/test/helper.rb +12 -0
- data/test/shared_array_scope_list.rb +19 -4
- data/test/shared_list.rb +34 -10
- data/test/shared_list_sub.rb +17 -1
- data/test/shared_no_addition.rb +2 -2
- data/test/shared_top_addition.rb +16 -1
- data/test/shared_zero_based.rb +11 -0
- data/test/test_joined_list.rb +8 -2
- data/test/test_list.rb +148 -44
- metadata +12 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8f0d9ba8f5cf3b633b34697998e60026fbe0e911
|
4
|
+
data.tar.gz: ade9d56a50252ff67e5254ac95af5709e3529e1c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4b3799c6c28e9484a89aafa9456d558376da4f9fd9a748ec58ece08398eb618641e44050e9c952221ed260fea7929016a07ffef68f8428e7d2d9e66bda70ab31
|
7
|
+
data.tar.gz: 64c1c416482deb55de8c40bbcb3f738eb16967c07df7e0e31729ae4c347268c421f30cf7279d562f910731318646f4dc4daa4e86d2a2a9d5fad8386c7b5e7f16
|
data/.travis.yml
CHANGED
@@ -1,17 +1,25 @@
|
|
1
1
|
language: ruby
|
2
|
+
cache: bundler
|
2
3
|
# Explicit usage of containerized builds, should provide faster feedback
|
3
4
|
# see https://docs.travis-ci.com/user/workers/container-based-infrastructure/
|
4
5
|
# and https://docs.travis-ci.com/user/ci-environment/#Virtualization-environments
|
5
6
|
sudo: false
|
6
7
|
before_install:
|
7
|
-
- gem
|
8
|
+
- gem install bundler -v 1.13.7
|
9
|
+
before_script:
|
10
|
+
- mysql -e 'create database acts_as_list;'
|
11
|
+
- psql -c 'create database acts_as_list;' -U postgres
|
8
12
|
rvm:
|
9
13
|
- 1.9.3
|
10
14
|
- 2.0.0
|
11
|
-
- 2.1.
|
12
|
-
- 2.2.
|
15
|
+
- 2.1.9
|
16
|
+
- 2.2.6
|
17
|
+
- 2.3.3
|
13
18
|
- jruby-19mode
|
14
|
-
|
19
|
+
env:
|
20
|
+
- DB=sqlite
|
21
|
+
- DB=mysql
|
22
|
+
- DB=postgresql
|
15
23
|
gemfile:
|
16
24
|
- gemfiles/rails_3_2.gemfile
|
17
25
|
- gemfiles/rails_4_1.gemfile
|
@@ -23,10 +31,7 @@ matrix:
|
|
23
31
|
gemfile: gemfiles/rails_5_0.gemfile
|
24
32
|
- rvm: 2.0.0
|
25
33
|
gemfile: gemfiles/rails_5_0.gemfile
|
26
|
-
- rvm: 2.1.
|
34
|
+
- rvm: 2.1.9
|
27
35
|
gemfile: gemfiles/rails_5_0.gemfile
|
28
36
|
- rvm: jruby-19mode
|
29
37
|
gemfile: gemfiles/rails_5_0.gemfile
|
30
|
-
- rvm: rbx-2
|
31
|
-
gemfile: gemfiles/rails_5_0.gemfile
|
32
|
-
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,19 @@
|
|
1
1
|
# Change Log
|
2
2
|
|
3
|
+
## [v0.8.2](https://github.com/swanandp/acts_as_list/tree/v0.8.2) (2016-09-23)
|
4
|
+
[Full Changelog](https://github.com/swanandp/acts_as_list/compare/v0.8.1...v0.8.2)
|
5
|
+
|
6
|
+
**Closed issues:**
|
7
|
+
|
8
|
+
- We're a repo now, no longer a fork attached to rails/acts\_as\_list [\#232](https://github.com/swanandp/acts_as_list/issues/232)
|
9
|
+
- Break away from rails/acts\_as\_list [\#224](https://github.com/swanandp/acts_as_list/issues/224)
|
10
|
+
- Problem when inserting straight at top of list [\#109](https://github.com/swanandp/acts_as_list/issues/109)
|
11
|
+
|
12
|
+
**Merged pull requests:**
|
13
|
+
|
14
|
+
- Show items with same position in higher and lower items [\#231](https://github.com/swanandp/acts_as_list/pull/231) ([jpalumickas](https://github.com/jpalumickas))
|
15
|
+
- fix setting position when previous position was nil [\#230](https://github.com/swanandp/acts_as_list/pull/230) ([StoneFrog](https://github.com/StoneFrog))
|
16
|
+
|
3
17
|
## [v0.8.1](https://github.com/swanandp/acts_as_list/tree/v0.8.1) (2016-09-06)
|
4
18
|
[Full Changelog](https://github.com/swanandp/acts_as_list/compare/v0.8.0...v0.8.1)
|
5
19
|
|
data/Gemfile
CHANGED
@@ -1,8 +1,5 @@
|
|
1
1
|
source "http://rubygems.org"
|
2
2
|
|
3
|
-
gem "sqlite3", platforms: [:ruby]
|
4
|
-
gem "activerecord-jdbcsqlite3-adapter", platforms: [:jruby]
|
5
|
-
|
6
3
|
gem "rack", "~> 1", platforms: [:ruby_19, :ruby_20, :ruby_21, :jruby]
|
7
4
|
|
8
5
|
gemspec
|
@@ -14,4 +11,20 @@ gem "github_changelog_generator", "1.9.0"
|
|
14
11
|
group :test do
|
15
12
|
gem "minitest", "~> 5.0"
|
16
13
|
gem "test_after_commit", "~> 0.4.2"
|
14
|
+
gem "timecop"
|
15
|
+
end
|
16
|
+
|
17
|
+
group :sqlite do
|
18
|
+
gem "sqlite3", platforms: [:ruby]
|
19
|
+
gem "activerecord-jdbcsqlite3-adapter", platforms: [:jruby]
|
20
|
+
end
|
21
|
+
|
22
|
+
group :postgresql do
|
23
|
+
gem "pg", "~> 0.18.0", platforms: [:ruby]
|
24
|
+
gem "activerecord-jdbcpostgresql-adapter", platforms: [:jruby]
|
25
|
+
end
|
26
|
+
|
27
|
+
group :mysql do
|
28
|
+
gem "mysql2", "~> 0.3.10", platforms: [:ruby]
|
29
|
+
gem "activerecord-jdbcmysql-adapter", platforms: [:jruby]
|
17
30
|
end
|
data/README.md
CHANGED
@@ -77,6 +77,30 @@ In `acts_as_list`, "higher" means further up the list (a lower `position`), and
|
|
77
77
|
- `list_item.lower_item`
|
78
78
|
- `list_item.lower_items` will return all the items below `list_item` in the list (ordered by the position, ascending)
|
79
79
|
|
80
|
+
## Adding `acts_as_list` To An Existing Model
|
81
|
+
As it stands `acts_as_list` requires position values to be set on the model before the instance methods above will work. Adding something like the below to your migration will set the default position. Change the parameters to order if you want a different initial ordering.
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
class AddPositionToTodoItem < ActiveRecord::Migration
|
85
|
+
def change
|
86
|
+
add_column :todo_items, :position, :integer
|
87
|
+
TodoItem.order(:updated_at).each.with_index(1) do |todo_item, index|
|
88
|
+
todo_item.update_column :position, index
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
```
|
93
|
+
|
94
|
+
If you are using the scope option things can get a bit more complicated. Let's say you have `acts_as_list scope: :todo_list`, you might instead need something like this:
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
TodoList.all.each do |todo_list|
|
98
|
+
todo_list.todo_items.order(:updated_at).each.with_index(1) do |todo_item, index|
|
99
|
+
todo_item.update_column :position, index
|
100
|
+
end
|
101
|
+
end
|
102
|
+
```
|
103
|
+
|
80
104
|
## Notes
|
81
105
|
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.
|
82
106
|
|
@@ -99,7 +123,20 @@ default: `1`. Use this option to define the top of the list. Use 0 to make the c
|
|
99
123
|
- `add_new_at`
|
100
124
|
default: `:bottom`. Use this option to specify whether objects get added to the `:top` or `:bottom` of the list. `nil` will result in new items not being added to the list on create, i.e, position will be kept nil after create.
|
101
125
|
|
126
|
+
## Disabling temporarily
|
127
|
+
|
128
|
+
If you need to temporarily disable `acts_as_list` during specific operations such as mass-update or imports:
|
129
|
+
```ruby
|
130
|
+
TodoItem.acts_as_list_no_update do
|
131
|
+
perform_mass_update
|
132
|
+
end
|
133
|
+
```
|
134
|
+
In an `acts_as_list_no_update` block, all callbacks are disabled, and positions are not updated. New records will be created with
|
135
|
+
the default value from the database. It is your responsibility to correctly manage `positions` values.
|
136
|
+
|
102
137
|
## Versions
|
138
|
+
Version `0.9.0` adds `acts_as_list_no_update` (https://github.com/swanandp/acts_as_list/pull/244) and compatibility with not-null and uniqueness constraints on the database (https://github.com/swanandp/acts_as_list/pull/246). These additions shouldn't break compatibility with existing implementations.
|
139
|
+
|
103
140
|
As of version `0.7.5` Rails 5 is supported.
|
104
141
|
|
105
142
|
All versions `0.1.5` onwards require Rails 3.0.x and higher.
|
data/gemfiles/rails_3_2.gemfile
CHANGED
@@ -2,8 +2,6 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "sqlite3", :platforms => [:ruby]
|
6
|
-
gem "activerecord-jdbcsqlite3-adapter", :platforms => [:jruby]
|
7
5
|
gem "rack", "~> 1", :platforms => [:ruby_19, :ruby_20, :ruby_21, :jruby]
|
8
6
|
gem "rake"
|
9
7
|
gem "appraisal"
|
@@ -13,7 +11,23 @@ gem "activerecord", "~> 3.2.22.2"
|
|
13
11
|
group :test do
|
14
12
|
gem "minitest", "~> 5.0"
|
15
13
|
gem "test_after_commit", "~> 0.4.2"
|
14
|
+
gem "timecop"
|
16
15
|
gem "after_commit_exception_notification"
|
17
16
|
end
|
18
17
|
|
18
|
+
group :sqlite do
|
19
|
+
gem "sqlite3", :platforms => [:ruby]
|
20
|
+
gem "activerecord-jdbcsqlite3-adapter", :platforms => [:jruby]
|
21
|
+
end
|
22
|
+
|
23
|
+
group :postgresql do
|
24
|
+
gem "pg", "~> 0.18.0", :platforms => [:ruby]
|
25
|
+
gem "activerecord-jdbcpostgresql-adapter", :platforms => [:jruby]
|
26
|
+
end
|
27
|
+
|
28
|
+
group :mysql do
|
29
|
+
gem "mysql2", "~> 0.3.10", :platforms => [:ruby]
|
30
|
+
gem "activerecord-jdbcmysql-adapter", :platforms => [:jruby]
|
31
|
+
end
|
32
|
+
|
19
33
|
gemspec :path => "../"
|
data/gemfiles/rails_4_1.gemfile
CHANGED
@@ -2,8 +2,6 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "sqlite3", :platforms => [:ruby]
|
6
|
-
gem "activerecord-jdbcsqlite3-adapter", :platforms => [:jruby]
|
7
5
|
gem "rack", "~> 1", :platforms => [:ruby_19, :ruby_20, :ruby_21, :jruby]
|
8
6
|
gem "rake"
|
9
7
|
gem "appraisal"
|
@@ -13,7 +11,23 @@ gem "activerecord", "~> 4.1.16"
|
|
13
11
|
group :test do
|
14
12
|
gem "minitest", "~> 5.0"
|
15
13
|
gem "test_after_commit", "~> 0.4.2"
|
14
|
+
gem "timecop"
|
16
15
|
gem "after_commit_exception_notification"
|
17
16
|
end
|
18
17
|
|
18
|
+
group :sqlite do
|
19
|
+
gem "sqlite3", :platforms => [:ruby]
|
20
|
+
gem "activerecord-jdbcsqlite3-adapter", :platforms => [:jruby]
|
21
|
+
end
|
22
|
+
|
23
|
+
group :postgresql do
|
24
|
+
gem "pg", "~> 0.18.0", :platforms => [:ruby]
|
25
|
+
gem "activerecord-jdbcpostgresql-adapter", :platforms => [:jruby]
|
26
|
+
end
|
27
|
+
|
28
|
+
group :mysql do
|
29
|
+
gem "mysql2", "~> 0.3.10", :platforms => [:ruby]
|
30
|
+
gem "activerecord-jdbcmysql-adapter", :platforms => [:jruby]
|
31
|
+
end
|
32
|
+
|
19
33
|
gemspec :path => "../"
|
data/gemfiles/rails_4_2.gemfile
CHANGED
@@ -2,8 +2,6 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "sqlite3", :platforms => [:ruby]
|
6
|
-
gem "activerecord-jdbcsqlite3-adapter", :platforms => [:jruby]
|
7
5
|
gem "rack", "~> 1", :platforms => [:ruby_19, :ruby_20, :ruby_21, :jruby]
|
8
6
|
gem "rake"
|
9
7
|
gem "appraisal"
|
@@ -13,6 +11,22 @@ gem "activerecord", "~> 4.2.7"
|
|
13
11
|
group :test do
|
14
12
|
gem "minitest", "~> 5.0"
|
15
13
|
gem "test_after_commit", "~> 0.4.2"
|
14
|
+
gem "timecop"
|
15
|
+
end
|
16
|
+
|
17
|
+
group :sqlite do
|
18
|
+
gem "sqlite3", :platforms => [:ruby]
|
19
|
+
gem "activerecord-jdbcsqlite3-adapter", :platforms => [:jruby]
|
20
|
+
end
|
21
|
+
|
22
|
+
group :postgresql do
|
23
|
+
gem "pg", "~> 0.18.0", :platforms => [:ruby]
|
24
|
+
gem "activerecord-jdbcpostgresql-adapter", :platforms => [:jruby]
|
25
|
+
end
|
26
|
+
|
27
|
+
group :mysql do
|
28
|
+
gem "mysql2", "~> 0.3.10", :platforms => [:ruby]
|
29
|
+
gem "activerecord-jdbcmysql-adapter", :platforms => [:jruby]
|
16
30
|
end
|
17
31
|
|
18
32
|
gemspec :path => "../"
|
data/gemfiles/rails_5_0.gemfile
CHANGED
@@ -2,8 +2,6 @@
|
|
2
2
|
|
3
3
|
source "http://rubygems.org"
|
4
4
|
|
5
|
-
gem "sqlite3", :platforms => [:ruby]
|
6
|
-
gem "activerecord-jdbcsqlite3-adapter", :platforms => [:jruby]
|
7
5
|
gem "rack", "~> 1", :platforms => [:ruby_19, :ruby_20, :ruby_21, :jruby]
|
8
6
|
gem "rake"
|
9
7
|
gem "appraisal"
|
@@ -13,6 +11,22 @@ gem "activerecord", "~> 5.0.0"
|
|
13
11
|
group :test do
|
14
12
|
gem "minitest", "~> 5.0"
|
15
13
|
gem "test_after_commit", "~> 0.4.2"
|
14
|
+
gem "timecop"
|
15
|
+
end
|
16
|
+
|
17
|
+
group :sqlite do
|
18
|
+
gem "sqlite3", :platforms => [:ruby]
|
19
|
+
gem "activerecord-jdbcsqlite3-adapter", :platforms => [:jruby]
|
20
|
+
end
|
21
|
+
|
22
|
+
group :postgresql do
|
23
|
+
gem "pg", "~> 0.18.0", :platforms => [:ruby]
|
24
|
+
gem "activerecord-jdbcpostgresql-adapter", :platforms => [:jruby]
|
25
|
+
end
|
26
|
+
|
27
|
+
group :mysql do
|
28
|
+
gem "mysql2", "~> 0.3.10", :platforms => [:ruby]
|
29
|
+
gem "activerecord-jdbcmysql-adapter", :platforms => [:jruby]
|
16
30
|
end
|
17
31
|
|
18
32
|
gemspec :path => "../"
|
data/lib/acts_as_list.rb
CHANGED
@@ -1,15 +1,9 @@
|
|
1
1
|
require 'acts_as_list/active_record/acts/list'
|
2
|
-
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
end
|
11
|
-
end
|
12
|
-
else
|
13
|
-
ActiveRecord::Base.send(:include, ActiveRecord::Acts::List) if defined?(ActiveRecord)
|
14
|
-
end
|
15
|
-
end
|
2
|
+
require "acts_as_list/active_record/acts/column_method_definer"
|
3
|
+
require "acts_as_list/active_record/acts/scope_method_definer"
|
4
|
+
require "acts_as_list/active_record/acts/top_of_list_method_definer"
|
5
|
+
require "acts_as_list/active_record/acts/add_new_at_method_definer"
|
6
|
+
require "acts_as_list/active_record/acts/aux_method_definer"
|
7
|
+
require "acts_as_list/active_record/acts/callback_definer"
|
8
|
+
require 'acts_as_list/active_record/acts/no_update'
|
9
|
+
require "acts_as_list/active_record/acts/sequential_updates_method_definer"
|
@@ -0,0 +1,19 @@
|
|
1
|
+
module ActiveRecord::Acts::List::CallbackDefiner #:nodoc:
|
2
|
+
def self.call(caller_class, add_new_at)
|
3
|
+
caller_class.class_eval do
|
4
|
+
before_validation :check_top_position, unless: :act_as_list_no_update?
|
5
|
+
|
6
|
+
before_destroy :lock!
|
7
|
+
after_destroy :decrement_positions_on_lower_items, unless: :act_as_list_no_update?
|
8
|
+
|
9
|
+
before_update :check_scope, unless: :act_as_list_no_update?
|
10
|
+
after_update :update_positions, unless: :act_as_list_no_update?
|
11
|
+
|
12
|
+
after_commit :clear_scope_changed
|
13
|
+
|
14
|
+
if add_new_at.present?
|
15
|
+
before_create "add_to_list_#{add_new_at}".to_sym, unless: :act_as_list_no_update?
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
module ActiveRecord::Acts::List::ColumnMethodDefiner #:nodoc:
|
2
|
+
def self.call(caller_class, column)
|
3
|
+
caller_class.class_eval do
|
4
|
+
attr_reader :position_changed
|
5
|
+
|
6
|
+
define_method :position_column do
|
7
|
+
column
|
8
|
+
end
|
9
|
+
|
10
|
+
define_method :"#{column}=" do |position|
|
11
|
+
write_attribute(column, position)
|
12
|
+
@position_changed = true
|
13
|
+
end
|
14
|
+
|
15
|
+
# only add to attr_accessible
|
16
|
+
# if the class has some mass_assignment_protection
|
17
|
+
if defined?(accessible_attributes) and !accessible_attributes.blank?
|
18
|
+
attr_accessible :"#{column}"
|
19
|
+
end
|
20
|
+
|
21
|
+
define_singleton_method :quoted_position_column do
|
22
|
+
@_quoted_position_column ||= connection.quote_column_name(column)
|
23
|
+
end
|
24
|
+
|
25
|
+
define_singleton_method :quoted_position_column_with_table_name do
|
26
|
+
@_quoted_position_column_with_table_name ||= "#{caller_class.quoted_table_name}.#{quoted_position_column}"
|
27
|
+
end
|
28
|
+
|
29
|
+
define_singleton_method :decrement_all do
|
30
|
+
update_all_with_touch "#{quoted_position_column} = (#{quoted_position_column_with_table_name} - 1)"
|
31
|
+
end
|
32
|
+
|
33
|
+
define_singleton_method :increment_all do
|
34
|
+
update_all_with_touch "#{quoted_position_column} = (#{quoted_position_column_with_table_name} + 1)"
|
35
|
+
end
|
36
|
+
|
37
|
+
define_singleton_method :update_all_with_touch do |updates|
|
38
|
+
record = new
|
39
|
+
attrs = record.send(:timestamp_attributes_for_update_in_model)
|
40
|
+
now = record.send(:current_time_from_proper_timezone)
|
41
|
+
|
42
|
+
attrs.each do |attr|
|
43
|
+
updates << ", #{connection.quote_column_name(attr)} = #{connection.quote(connection.quoted_date(now))}"
|
44
|
+
end
|
45
|
+
|
46
|
+
update_all(updates)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -1,10 +1,42 @@
|
|
1
|
+
class << ActiveRecord::Base
|
2
|
+
# Configuration options are:
|
3
|
+
#
|
4
|
+
# * +column+ - specifies the column name to use for keeping the position integer (default: +position+)
|
5
|
+
# * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach <tt>_id</tt>
|
6
|
+
# (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
|
7
|
+
# to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
|
8
|
+
# Example: <tt>acts_as_list scope: 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
|
9
|
+
# * +top_of_list+ - defines the integer used for the top of the list. Defaults to 1. Use 0 to make the collection
|
10
|
+
# act more like an array in its indexing.
|
11
|
+
# * +add_new_at+ - specifies whether objects get added to the :top or :bottom of the list. (default: +bottom+)
|
12
|
+
# `nil` will result in new items not being added to the list on create.
|
13
|
+
# * +sequential_updates+ - specifies whether insert_at should update objects positions during shuffling
|
14
|
+
# one by one to respect position column unique not null constraint.
|
15
|
+
# Defaults to true if position column has unique index, otherwise false.
|
16
|
+
# If constraint is <tt>deferrable initially deferred<tt>, overriding it with false will speed up insert_at.
|
17
|
+
def acts_as_list(options = {})
|
18
|
+
configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom }
|
19
|
+
configuration.update(options) if options.is_a?(Hash)
|
20
|
+
|
21
|
+
caller_class = self
|
22
|
+
|
23
|
+
ActiveRecord::Acts::List::ColumnMethodDefiner.call(caller_class, configuration[:column])
|
24
|
+
ActiveRecord::Acts::List::ScopeMethodDefiner.call(caller_class, configuration[:scope])
|
25
|
+
ActiveRecord::Acts::List::TopOfListMethodDefiner.call(caller_class, configuration[:top_of_list])
|
26
|
+
ActiveRecord::Acts::List::AddNewAtMethodDefiner.call(caller_class, configuration[:add_new_at])
|
27
|
+
|
28
|
+
ActiveRecord::Acts::List::AuxMethodDefiner.call(caller_class)
|
29
|
+
ActiveRecord::Acts::List::CallbackDefiner.call(caller_class, configuration[:add_new_at])
|
30
|
+
ActiveRecord::Acts::List::SequentialUpdatesMethodDefiner.call(caller_class, configuration[:column], configuration[:sequential_updates])
|
31
|
+
|
32
|
+
include ActiveRecord::Acts::List::InstanceMethods
|
33
|
+
include ActiveRecord::Acts::List::NoUpdate
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
1
37
|
module ActiveRecord
|
2
38
|
module Acts #:nodoc:
|
3
39
|
module List #:nodoc:
|
4
|
-
def self.included(base)
|
5
|
-
base.extend(ClassMethods)
|
6
|
-
end
|
7
|
-
|
8
40
|
# This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list.
|
9
41
|
# The class that has this specified needs to have a +position+ column defined as an integer on
|
10
42
|
# the mapped database table.
|
@@ -22,142 +54,6 @@ module ActiveRecord
|
|
22
54
|
#
|
23
55
|
# todo_list.first.move_to_bottom
|
24
56
|
# 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 <tt>_id</tt>
|
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: <tt>acts_as_list scope: 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
|
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
|
-
# `nil` will result in new items not being added to the list on create
|
37
|
-
def acts_as_list(options = {})
|
38
|
-
configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom}
|
39
|
-
configuration.update(options) if options.is_a?(Hash)
|
40
|
-
|
41
|
-
if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
|
42
|
-
configuration[:scope] = :"#{configuration[:scope]}_id"
|
43
|
-
end
|
44
|
-
|
45
|
-
caller_class = self
|
46
|
-
|
47
|
-
class_eval do
|
48
|
-
define_singleton_method :acts_as_list_top do
|
49
|
-
configuration[:top_of_list].to_i
|
50
|
-
end
|
51
|
-
|
52
|
-
define_method :acts_as_list_top do
|
53
|
-
configuration[:top_of_list].to_i
|
54
|
-
end
|
55
|
-
|
56
|
-
define_method :acts_as_list_class do
|
57
|
-
caller_class
|
58
|
-
end
|
59
|
-
|
60
|
-
define_method :position_column do
|
61
|
-
configuration[:column]
|
62
|
-
end
|
63
|
-
|
64
|
-
define_method :scope_name do
|
65
|
-
configuration[:scope]
|
66
|
-
end
|
67
|
-
|
68
|
-
define_method :add_new_at do
|
69
|
-
configuration[:add_new_at]
|
70
|
-
end
|
71
|
-
|
72
|
-
define_method :"#{configuration[:column]}=" do |position|
|
73
|
-
write_attribute(configuration[:column], position)
|
74
|
-
@position_changed = true
|
75
|
-
end
|
76
|
-
|
77
|
-
if configuration[:scope].is_a?(Symbol)
|
78
|
-
define_method :scope_condition do
|
79
|
-
{ configuration[:scope] => send(:"#{configuration[:scope]}") }
|
80
|
-
end
|
81
|
-
|
82
|
-
define_method :scope_changed? do
|
83
|
-
changed.include?(scope_name.to_s)
|
84
|
-
end
|
85
|
-
elsif configuration[:scope].is_a?(Array)
|
86
|
-
define_method :scope_condition do
|
87
|
-
configuration[:scope].inject({}) do |hash, column|
|
88
|
-
hash.merge!({ column.to_sym => read_attribute(column.to_sym) })
|
89
|
-
end
|
90
|
-
end
|
91
|
-
|
92
|
-
define_method :scope_changed? do
|
93
|
-
(scope_condition.keys & changed.map(&:to_sym)).any?
|
94
|
-
end
|
95
|
-
else
|
96
|
-
define_method :scope_condition do
|
97
|
-
eval "%{#{configuration[:scope]}}"
|
98
|
-
end
|
99
|
-
|
100
|
-
define_method :scope_changed? do
|
101
|
-
false
|
102
|
-
end
|
103
|
-
end
|
104
|
-
|
105
|
-
# only add to attr_accessible
|
106
|
-
# if the class has some mass_assignment_protection
|
107
|
-
if defined?(accessible_attributes) and !accessible_attributes.blank?
|
108
|
-
attr_accessible :"#{configuration[:column]}"
|
109
|
-
end
|
110
|
-
|
111
|
-
define_singleton_method :quoted_position_column do
|
112
|
-
@_quoted_position_column ||= connection.quote_column_name(configuration[:column])
|
113
|
-
end
|
114
|
-
|
115
|
-
define_singleton_method :quoted_position_column_with_table_name do
|
116
|
-
@_quoted_position_column_with_table_name ||= "#{caller_class.quoted_table_name}.#{quoted_position_column}"
|
117
|
-
end
|
118
|
-
|
119
|
-
scope :in_list, lambda { where("#{quoted_position_column_with_table_name} IS NOT NULL") }
|
120
|
-
|
121
|
-
define_singleton_method :decrement_all do
|
122
|
-
update_all_with_touch "#{quoted_position_column} = (#{quoted_position_column_with_table_name} - 1)"
|
123
|
-
end
|
124
|
-
|
125
|
-
define_singleton_method :increment_all do
|
126
|
-
update_all_with_touch "#{quoted_position_column} = (#{quoted_position_column_with_table_name} + 1)"
|
127
|
-
end
|
128
|
-
|
129
|
-
define_singleton_method :update_all_with_touch do |updates|
|
130
|
-
record = new
|
131
|
-
attrs = record.send(:timestamp_attributes_for_update_in_model)
|
132
|
-
now = record.send(:current_time_from_proper_timezone)
|
133
|
-
|
134
|
-
query = attrs.map { |attr| "#{connection.quote_column_name(attr)} = :now" }
|
135
|
-
query.push updates
|
136
|
-
query = query.join(", ")
|
137
|
-
|
138
|
-
update_all([query, now: now])
|
139
|
-
end
|
140
|
-
end
|
141
|
-
|
142
|
-
attr_reader :position_changed
|
143
|
-
|
144
|
-
before_validation :check_top_position
|
145
|
-
|
146
|
-
before_destroy :lock!
|
147
|
-
after_destroy :decrement_positions_on_lower_items
|
148
|
-
|
149
|
-
before_update :check_scope
|
150
|
-
after_update :update_positions
|
151
|
-
|
152
|
-
after_commit :clear_scope_changed
|
153
|
-
|
154
|
-
if configuration[:add_new_at].present?
|
155
|
-
before_create "add_to_list_#{configuration[:add_new_at]}".to_sym
|
156
|
-
end
|
157
|
-
|
158
|
-
include ::ActiveRecord::Acts::List::InstanceMethods
|
159
|
-
end
|
160
|
-
end
|
161
57
|
|
162
58
|
# All the methods available to a record that has had <tt>acts_as_list</tt> specified. Each method works
|
163
59
|
# by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
|
@@ -315,214 +211,240 @@ module ActiveRecord
|
|
315
211
|
|
316
212
|
private
|
317
213
|
|
318
|
-
|
319
|
-
|
320
|
-
item2.set_list_position(item1.send("#{position_column}_was"))
|
321
|
-
end
|
214
|
+
def swap_positions(item1, item2)
|
215
|
+
item1_position = item1.send(position_column)
|
322
216
|
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
|
217
|
+
item1.set_list_position(item2.send(position_column))
|
218
|
+
item2.set_list_position(item1_position)
|
219
|
+
end
|
220
|
+
|
221
|
+
def acts_as_list_list
|
222
|
+
acts_as_list_class.unscoped do
|
223
|
+
acts_as_list_class.where(scope_condition)
|
327
224
|
end
|
225
|
+
end
|
328
226
|
|
329
|
-
|
330
|
-
|
227
|
+
# Poorly named methods. They will insert the item at the desired position if the position
|
228
|
+
# has been set manually using position=, not necessarily the top or bottom of the list:
|
331
229
|
|
332
|
-
|
333
|
-
|
334
|
-
|
335
|
-
|
336
|
-
|
337
|
-
|
338
|
-
|
230
|
+
def add_to_list_top
|
231
|
+
if not_in_list? || internal_scope_changed? && !position_changed || default_position?
|
232
|
+
increment_positions_on_all_items
|
233
|
+
self[position_column] = acts_as_list_top
|
234
|
+
else
|
235
|
+
increment_positions_on_lower_items(self[position_column], id)
|
236
|
+
end
|
339
237
|
|
340
|
-
|
341
|
-
|
238
|
+
# Make sure we know that we've processed this scope change already
|
239
|
+
@scope_changed = false
|
342
240
|
|
343
|
-
|
344
|
-
|
241
|
+
# Don't halt the callback chain
|
242
|
+
true
|
243
|
+
end
|
244
|
+
|
245
|
+
def add_to_list_bottom
|
246
|
+
if not_in_list? || internal_scope_changed? && !position_changed || default_position?
|
247
|
+
self[position_column] = bottom_position_in_list.to_i + 1
|
248
|
+
else
|
249
|
+
increment_positions_on_lower_items(self[position_column], id)
|
345
250
|
end
|
346
251
|
|
347
|
-
|
348
|
-
|
349
|
-
self[position_column] = bottom_position_in_list.to_i + 1
|
350
|
-
else
|
351
|
-
increment_positions_on_lower_items(self[position_column], id)
|
352
|
-
end
|
252
|
+
# Make sure we know that we've processed this scope change already
|
253
|
+
@scope_changed = false
|
353
254
|
|
354
|
-
|
355
|
-
|
255
|
+
# Don't halt the callback chain
|
256
|
+
true
|
257
|
+
end
|
356
258
|
|
357
|
-
|
358
|
-
|
359
|
-
end
|
259
|
+
# Overwrite this method to define the scope of the list changes
|
260
|
+
def scope_condition() {} end
|
360
261
|
|
361
|
-
|
362
|
-
|
262
|
+
# Returns the bottom position number in the list.
|
263
|
+
# bottom_position_in_list # => 2
|
264
|
+
def bottom_position_in_list(except = nil)
|
265
|
+
item = bottom_item(except)
|
266
|
+
item ? item.send(position_column) : acts_as_list_top - 1
|
267
|
+
end
|
363
268
|
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
269
|
+
# Returns the bottom item
|
270
|
+
def bottom_item(except = nil)
|
271
|
+
conditions = except ? "#{quoted_table_name}.#{self.class.primary_key} != #{self.class.connection.quote(except.id)}" : {}
|
272
|
+
acts_as_list_list.in_list.where(
|
273
|
+
conditions
|
274
|
+
).order(
|
275
|
+
"#{quoted_position_column_with_table_name} DESC"
|
276
|
+
).first
|
277
|
+
end
|
370
278
|
|
371
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
375
|
-
conditions
|
376
|
-
).order(
|
377
|
-
"#{quoted_position_column_with_table_name} DESC"
|
378
|
-
).first
|
379
|
-
end
|
279
|
+
# Forces item to assume the bottom position in the list.
|
280
|
+
def assume_bottom_position
|
281
|
+
set_list_position(bottom_position_in_list(self).to_i + 1)
|
282
|
+
end
|
380
283
|
|
381
|
-
|
382
|
-
|
383
|
-
|
384
|
-
|
284
|
+
# Forces item to assume the top position in the list.
|
285
|
+
def assume_top_position
|
286
|
+
set_list_position(acts_as_list_top)
|
287
|
+
end
|
385
288
|
|
386
|
-
|
387
|
-
|
388
|
-
|
389
|
-
|
289
|
+
# This has the effect of moving all the higher items down one.
|
290
|
+
def increment_positions_on_higher_items
|
291
|
+
return unless in_list?
|
292
|
+
acts_as_list_list.where("#{quoted_position_column_with_table_name} < ?", send(position_column).to_i).increment_all
|
293
|
+
end
|
390
294
|
|
391
|
-
|
392
|
-
|
393
|
-
|
394
|
-
end
|
295
|
+
# This has the effect of moving all the lower items down one.
|
296
|
+
def increment_positions_on_lower_items(position, avoid_id = nil)
|
297
|
+
scope = acts_as_list_list
|
395
298
|
|
396
|
-
|
397
|
-
|
398
|
-
return unless in_list?
|
399
|
-
position ||= send(position_column).to_i
|
400
|
-
acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).decrement_all
|
299
|
+
if avoid_id
|
300
|
+
scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.class.connection.quote(avoid_id))
|
401
301
|
end
|
402
302
|
|
403
|
-
#
|
404
|
-
|
405
|
-
return unless in_list?
|
406
|
-
acts_as_list_list.where("#{quoted_position_column_with_table_name} < #{send(position_column).to_i}").increment_all
|
407
|
-
end
|
303
|
+
scope.where("#{quoted_position_column_with_table_name} >= ?", position).increment_all
|
304
|
+
end
|
408
305
|
|
409
|
-
|
410
|
-
|
411
|
-
|
306
|
+
# This has the effect of moving all the higher items up one.
|
307
|
+
def decrement_positions_on_higher_items(position)
|
308
|
+
acts_as_list_list.where("#{quoted_position_column_with_table_name} <= ?", position).decrement_all
|
309
|
+
end
|
412
310
|
|
413
|
-
|
414
|
-
|
311
|
+
# This has the effect of moving all the lower items up one.
|
312
|
+
def decrement_positions_on_lower_items(position=nil)
|
313
|
+
return unless in_list?
|
314
|
+
position ||= send(position_column).to_i
|
315
|
+
acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).decrement_all
|
316
|
+
end
|
415
317
|
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
318
|
+
# Increments position (<tt>position_column</tt>) of all items in the list.
|
319
|
+
def increment_positions_on_all_items
|
320
|
+
acts_as_list_list.increment_all
|
321
|
+
end
|
420
322
|
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
431
|
-
|
432
|
-
|
433
|
-
|
434
|
-
|
435
|
-
|
323
|
+
# Reorders intermediate items to support moving an item from old_position to new_position.
|
324
|
+
# unique constraint prevents regular increment_all and forces to do increments one by one
|
325
|
+
# http://stackoverflow.com/questions/7703196/sqlite-increment-unique-integer-field
|
326
|
+
# both SQLite and PostgreSQL (and most probably MySQL too) has same issue
|
327
|
+
# that's why *sequential_updates?* check alters implementation behavior
|
328
|
+
def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil)
|
329
|
+
return if old_position == new_position
|
330
|
+
scope = acts_as_list_list
|
331
|
+
|
332
|
+
if avoid_id
|
333
|
+
scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.class.connection.quote(avoid_id))
|
334
|
+
end
|
335
|
+
|
336
|
+
if old_position < new_position
|
337
|
+
# Decrement position of intermediate items
|
338
|
+
#
|
339
|
+
# e.g., if moving an item from 2 to 5,
|
340
|
+
# move [3, 4, 5] to [2, 3, 4]
|
341
|
+
items = scope.where(
|
342
|
+
"#{quoted_position_column_with_table_name} > ?", old_position
|
343
|
+
).where(
|
344
|
+
"#{quoted_position_column_with_table_name} <= ?", new_position
|
345
|
+
)
|
346
|
+
|
347
|
+
if sequential_updates?
|
348
|
+
items.order("#{quoted_position_column_with_table_name} ASC").each do |item|
|
349
|
+
item.decrement!(position_column)
|
350
|
+
end
|
436
351
|
else
|
437
|
-
|
438
|
-
#
|
439
|
-
# e.g., if moving an item from 5 to 2,
|
440
|
-
# move [2, 3, 4] to [3, 4, 5]
|
441
|
-
acts_as_list_list.where(
|
442
|
-
"#{quoted_position_column_with_table_name} >= ?", new_position
|
443
|
-
).where(
|
444
|
-
"#{quoted_position_column_with_table_name} < #{old_position}#{avoid_id_condition}"
|
445
|
-
).increment_all
|
352
|
+
items.decrement_all
|
446
353
|
end
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
354
|
+
else
|
355
|
+
# Increment position of intermediate items
|
356
|
+
#
|
357
|
+
# e.g., if moving an item from 5 to 2,
|
358
|
+
# move [2, 3, 4] to [3, 4, 5]
|
359
|
+
items = scope.where(
|
360
|
+
"#{quoted_position_column_with_table_name} >= ?", new_position
|
361
|
+
).where(
|
362
|
+
"#{quoted_position_column_with_table_name} < ?", old_position
|
363
|
+
)
|
364
|
+
|
365
|
+
if sequential_updates?
|
366
|
+
items.order("#{quoted_position_column_with_table_name} DESC").each do |item|
|
367
|
+
item.increment!(position_column)
|
458
368
|
end
|
459
|
-
|
369
|
+
else
|
370
|
+
items.increment_all
|
460
371
|
end
|
461
372
|
end
|
462
|
-
|
463
|
-
|
464
|
-
|
373
|
+
end
|
374
|
+
|
375
|
+
def insert_at_position(position)
|
376
|
+
return set_list_position(position) if new_record?
|
377
|
+
with_lock do
|
465
378
|
if in_list?
|
466
379
|
old_position = send(position_column).to_i
|
467
|
-
|
468
|
-
|
380
|
+
return if position == old_position
|
381
|
+
# temporary move after bottom with gap, avoiding duplicate values
|
382
|
+
# gap is required to leave room for position increments
|
383
|
+
# positive number will be valid with unique not null check (>= 0) db constraint
|
384
|
+
temporary_position = acts_as_list_class.maximum(position_column).to_i + 2
|
385
|
+
set_list_position(temporary_position)
|
386
|
+
shuffle_positions_on_intermediate_items(old_position, position, id)
|
387
|
+
else
|
388
|
+
increment_positions_on_lower_items(position)
|
469
389
|
end
|
390
|
+
set_list_position(position)
|
470
391
|
end
|
392
|
+
end
|
471
393
|
|
472
|
-
|
473
|
-
|
474
|
-
|
394
|
+
def update_positions
|
395
|
+
old_position = send("#{position_column}_was") || bottom_position_in_list + 1
|
396
|
+
new_position = send(position_column).to_i
|
475
397
|
|
476
|
-
|
477
|
-
|
478
|
-
|
479
|
-
|
480
|
-
|
398
|
+
return unless acts_as_list_list.where(
|
399
|
+
"#{quoted_position_column_with_table_name} = #{new_position}"
|
400
|
+
).count > 1
|
401
|
+
shuffle_positions_on_intermediate_items old_position, new_position, id
|
402
|
+
end
|
481
403
|
|
482
|
-
|
483
|
-
|
404
|
+
def internal_scope_changed?
|
405
|
+
return @scope_changed if defined?(@scope_changed)
|
484
406
|
|
485
|
-
|
486
|
-
|
407
|
+
@scope_changed = scope_changed?
|
408
|
+
end
|
487
409
|
|
488
|
-
|
489
|
-
|
490
|
-
|
410
|
+
def clear_scope_changed
|
411
|
+
remove_instance_variable(:@scope_changed) if defined?(@scope_changed)
|
412
|
+
end
|
491
413
|
|
492
|
-
|
493
|
-
|
494
|
-
|
414
|
+
def check_scope
|
415
|
+
if internal_scope_changed?
|
416
|
+
cached_changes = changes
|
495
417
|
|
496
|
-
|
497
|
-
|
498
|
-
|
418
|
+
cached_changes.each { |attribute, values| self[attribute] = values[0] }
|
419
|
+
send('decrement_positions_on_lower_items') if lower_item
|
420
|
+
cached_changes.each { |attribute, values| self[attribute] = values[1] }
|
499
421
|
|
500
|
-
|
501
|
-
end
|
422
|
+
send("add_to_list_#{add_new_at}") if add_new_at.present?
|
502
423
|
end
|
424
|
+
end
|
503
425
|
|
504
|
-
|
505
|
-
|
506
|
-
|
507
|
-
|
508
|
-
|
509
|
-
end
|
426
|
+
# This check is skipped if the position is currently the default position from the table
|
427
|
+
# as modifying the default position on creation is handled elsewhere
|
428
|
+
def check_top_position
|
429
|
+
if send(position_column) && !default_position? && send(position_column) < acts_as_list_top
|
430
|
+
self[position_column] = acts_as_list_top
|
510
431
|
end
|
432
|
+
end
|
511
433
|
|
512
|
-
|
513
|
-
|
514
|
-
|
515
|
-
|
434
|
+
# When using raw column name it must be quoted otherwise it can raise syntax errors with SQL keywords (e.g. order)
|
435
|
+
def quoted_position_column
|
436
|
+
@_quoted_position_column ||= self.class.connection.quote_column_name(position_column)
|
437
|
+
end
|
516
438
|
|
517
|
-
|
518
|
-
|
519
|
-
|
520
|
-
|
439
|
+
# Used in order clauses
|
440
|
+
def quoted_table_name
|
441
|
+
@_quoted_table_name ||= acts_as_list_class.quoted_table_name
|
442
|
+
end
|
521
443
|
|
522
|
-
|
523
|
-
|
524
|
-
|
444
|
+
def quoted_position_column_with_table_name
|
445
|
+
@_quoted_position_column_with_table_name ||= "#{quoted_table_name}.#{quoted_position_column}"
|
446
|
+
end
|
525
447
|
end
|
526
448
|
end
|
527
449
|
end
|
528
|
-
end
|
450
|
+
end
|