has_features 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ test/test.db
data/README ADDED
@@ -0,0 +1,18 @@
1
+ HasFeatures
2
+ ==========
3
+
4
+ This is a minor variation on acts_as_list, with a terrible name. If the position is nil then the object is not featured.
5
+
6
+
7
+ Example
8
+ =======
9
+
10
+ class Authors < ActiveRecord::Base
11
+ has_features
12
+ end
13
+
14
+ Authors.featured
15
+
16
+
17
+ blatantly borrowed from acts_as_list (https://github.com/swanandp/acts_as_list) which is (c) David Heinemeier Hansson
18
+ Copyright (c) 2012 Seth Faxon, released under the MIT license
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+
4
+ desc 'Default: run has_features unit tests.'
5
+ task :default => :test
6
+
7
+ desc 'Test the has_features gem.'
8
+ Rake::TestTask.new(:test) do |t|
9
+ t.libs << 'lib'
10
+ t.pattern = 'test/**/*_test.rb'
11
+ t.verbose = true
12
+ end
@@ -0,0 +1,31 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path('../lib', __FILE__)
3
+ require 'has_features/version'
4
+
5
+ Gem::Specification.new do |s|
6
+
7
+ # Description Meta...
8
+ s.name = 'has_features'
9
+ s.version = ActiveRecord::Has::Features::VERSION
10
+ s.platform = Gem::Platform::RUBY
11
+ s.authors = ['Seth Faxon']
12
+ s.email = ['seth.faxon@gmail.com']
13
+ s.homepage = 'http://github.com/sfaxon/has_features'
14
+ s.summary = %q{A gem allowing a active_record model to have an ordered list of featured items.}
15
+ s.description = %q{Based on acts_as_list, but allows nil elements to be not a part of the featuerd list.}
16
+ s.rubyforge_project = 'has_features'
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", [">= 3.0.0"])
29
+ s.add_development_dependency("rdoc")
30
+ s.add_development_dependency("sqlite3")
31
+ end
data/init.rb ADDED
@@ -0,0 +1,2 @@
1
+ $:.unshift "#{File.dirname(__FILE__)}/lib"
2
+ require 'has_features'
@@ -0,0 +1,2 @@
1
+ require 'has_features/active_record/has/features'
2
+ ActiveRecord::Base.class_eval { include ActiveRecord::Has::Features }
@@ -0,0 +1,280 @@
1
+ module ActiveRecord
2
+ module Has #:nodoc:
3
+ module Features #:nodoc:
4
+ def self.included(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ # This +has+ 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 +featured_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 => "featured_position"
16
+ # end
17
+ #
18
+ # class Author < ActiveRecord::Base
19
+ # belongs_to :todo_list
20
+ # has_features :scope => :organization
21
+ # end
22
+ #
23
+ # author.featured = true
24
+ module ClassMethods
25
+ # Configuration options are:
26
+ #
27
+ # * +column+ - specifies the column name to use for keeping the featured_position integer (default: +featured_position+)
28
+ # * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach <tt>_id</tt>
29
+ # (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
30
+ # to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
31
+ # Example: <tt>has_features :scope => 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
32
+ def has_features(options = {})
33
+ configuration = { :column => "featured_position", :scope => "1 = 1" }
34
+ configuration.update(options) if options.is_a?(Hash)
35
+
36
+ configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
37
+
38
+ if configuration[:scope].is_a?(Symbol)
39
+ scope_condition_method = %(
40
+ def scope_condition
41
+ self.class.send(:sanitize_sql_hash_for_conditions, { :#{configuration[:scope].to_s} => send(:#{configuration[:scope].to_s}) })
42
+ end
43
+ )
44
+ elsif configuration[:scope].is_a?(Array)
45
+ scope_condition_method = %(
46
+ def scope_condition
47
+ attrs = %w(#{configuration[:scope].join(" ")}).inject({}) do |memo,column|
48
+ memo[column.intern] = send(column.intern); memo
49
+ end
50
+ self.class.send(:sanitize_sql_hash_for_conditions, attrs)
51
+ end
52
+ )
53
+ else
54
+ scope_condition_method = "def scope_condition() \"#{configuration[:scope]}\" end"
55
+ end
56
+
57
+ class_eval <<-EOV
58
+ include ActiveRecord::Has::Features::InstanceMethods
59
+
60
+ def featured_position_column
61
+ '#{configuration[:column]}'
62
+ end
63
+
64
+ def has_features_class
65
+ ::#{self.name}
66
+ end
67
+
68
+ scope :featured, order("#{configuration[:column]} asc").where("#{configuration[:column]} is not null")
69
+
70
+ #{scope_condition_method}
71
+
72
+ before_destroy :decrement_positions_on_lower_featured_items
73
+ EOV
74
+ end
75
+ end
76
+
77
+ # All the methods available to a record that has had <tt>has_features</tt> specified. Each method works
78
+ # by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
79
+ # lower in the list of all chapters. Likewise, <tt>chapter.first?</tt> 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 feature_at(position = 1)
84
+ feature_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
+ has_features_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
+ has_features_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
+ has_features_class.transaction do
112
+ decrement_positions_on_lower_featured_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
+ has_features_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 unfeature
129
+ if in_list?
130
+ decrement_positions_on_lower_featured_items
131
+ update_attribute featured_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 featured_position_column, self.send(featured_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 featured_position_column, self.send(featured_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(featured_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(featured_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
+ has_features_class.find(:first, :conditions =>
163
+ "#{scope_condition} AND #{featured_position_column} = #{(send(featured_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
+ has_features_class.find(:first, :conditions =>
171
+ "#{scope_condition} AND #{featured_position_column} = #{(send(featured_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(featured_position_column).nil?
178
+ end
179
+
180
+ def featured=(val)
181
+ if true == val || "true" == val
182
+ add_to_list_bottom
183
+ save
184
+ else
185
+ unfeature
186
+ end
187
+ end
188
+
189
+ def featured?
190
+ !self.send(featured_position_column).nil?
191
+ end
192
+ alias_method :featured, :featured?
193
+
194
+ def feature
195
+ add_to_list_bottom
196
+ end
197
+
198
+ private
199
+ def add_to_list_top
200
+ increment_positions_on_all_items
201
+ end
202
+
203
+ def add_to_list_bottom
204
+ self[featured_position_column] = bottom_position_in_list.to_i + 1
205
+ # update_attribute(featured_position_column, bottom_position_in_list.to_i + 1)
206
+ end
207
+
208
+ # Overwrite this method to define the scope of the list changes
209
+ def scope_condition() "1" end
210
+
211
+ # Returns the bottom position number in the list.
212
+ # bottom_position_in_list # => 2
213
+ def bottom_position_in_list(except = nil)
214
+ item = bottom_item(except)
215
+ item ? item.send(featured_position_column) : 0
216
+ end
217
+
218
+ # Returns the bottom item
219
+ def bottom_item(except = nil)
220
+ conditions = scope_condition
221
+ conditions = "#{conditions} AND #{self.class.primary_key} != #{except.id}" if except
222
+ has_features_class.find(:first, :conditions => conditions, :order => "#{featured_position_column} DESC")
223
+ end
224
+
225
+ # Forces item to assume the bottom position in the list.
226
+ def assume_bottom_position
227
+ update_attribute(featured_position_column, bottom_position_in_list(self).to_i + 1)
228
+ end
229
+
230
+ # Forces item to assume the top position in the list.
231
+ def assume_top_position
232
+ update_attribute(featured_position_column, 1)
233
+ end
234
+
235
+ # This has the effect of moving all the higher items up one.
236
+ def decrement_positions_on_higher_items(position)
237
+ has_features_class.update_all(
238
+ "#{featured_position_column} = (#{featured_position_column} - 1)", "#{scope_condition} AND #{featured_position_column} <= #{position}"
239
+ )
240
+ end
241
+
242
+ # This has the effect of moving all the lower items up one.
243
+ def decrement_positions_on_lower_featured_items
244
+ return unless in_list?
245
+ has_features_class.update_all(
246
+ "#{featured_position_column} = (#{featured_position_column} - 1)", "#{scope_condition} AND #{featured_position_column} > #{send(featured_position_column).to_i}"
247
+ )
248
+ end
249
+
250
+ # This has the effect of moving all the higher items down one.
251
+ def increment_positions_on_higher_items
252
+ return unless in_list?
253
+ has_features_class.update_all(
254
+ "#{featured_position_column} = (#{featured_position_column} + 1)", "#{scope_condition} AND #{featured_position_column} < #{send(featured_position_column).to_i}"
255
+ )
256
+ end
257
+
258
+ # This has the effect of moving all the lower items down one.
259
+ def increment_positions_on_lower_items(position)
260
+ has_features_class.update_all(
261
+ "#{featured_position_column} = (#{featured_position_column} + 1)", "#{scope_condition} AND #{featured_position_column} >= #{position}"
262
+ )
263
+ end
264
+
265
+ # Increments position (<tt>featured_position_column</tt>) of all items in the list.
266
+ def increment_positions_on_all_items
267
+ has_features_class.update_all(
268
+ "#{featured_position_column} = (#{featured_position_column} + 1)", "#{scope_condition}"
269
+ )
270
+ end
271
+
272
+ def feature_at_position(position)
273
+ unfeature
274
+ increment_positions_on_lower_items(position)
275
+ self.update_attribute(featured_position_column, position)
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,7 @@
1
+ module ActiveRecord
2
+ module Has
3
+ module Features
4
+ VERSION = "0.1.0"
5
+ end
6
+ end
7
+ end
data/test/list_test.rb ADDED
@@ -0,0 +1,523 @@
1
+ require 'test/unit'
2
+
3
+ require 'rubygems'
4
+ gem 'activerecord', '>= 3.0.0'
5
+ require 'active_record'
6
+ require 'ruby-debug'
7
+
8
+ require File.join(File.dirname(__FILE__), '../lib/has_features')
9
+ require File.join(File.dirname(__FILE__), 'schema')
10
+
11
+ class FeaturedTest < Test::Unit::TestCase
12
+
13
+ def setup
14
+ setup_db
15
+ (1..4).each { |counter| FeaturedMixin.create! :pos => counter, :parent_id => 5 }
16
+ end
17
+
18
+ def teardown
19
+ teardown_db
20
+ end
21
+
22
+ def test_reordering
23
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
24
+
25
+ FeaturedMixin.find(2).move_lower
26
+ assert_equal [1, 3, 2, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
27
+
28
+ FeaturedMixin.find(2).move_higher
29
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
30
+
31
+ FeaturedMixin.find(1).move_to_bottom
32
+ assert_equal [2, 3, 4, 1], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
33
+
34
+ FeaturedMixin.find(1).move_to_top
35
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
36
+
37
+ FeaturedMixin.find(2).move_to_bottom
38
+ assert_equal [1, 3, 4, 2], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
39
+
40
+ FeaturedMixin.find(4).move_to_top
41
+ assert_equal [4, 1, 3, 2], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
42
+ end
43
+
44
+ def test_move_to_bottom_with_next_to_last_item
45
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
46
+ FeaturedMixin.find(3).move_to_bottom
47
+ assert_equal [1, 2, 4, 3], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
48
+ end
49
+
50
+ def test_next_prev
51
+ assert_equal FeaturedMixin.find(2), FeaturedMixin.find(1).lower_item
52
+ assert_nil FeaturedMixin.find(1).higher_item
53
+ assert_equal FeaturedMixin.find(3), FeaturedMixin.find(4).higher_item
54
+ assert_nil FeaturedMixin.find(4).lower_item
55
+ end
56
+
57
+ def test_injection
58
+ item = FeaturedMixin.new(:parent_id => 1)
59
+ assert_equal '"mixins"."parent_id" = 1', item.scope_condition
60
+ assert_equal "pos", item.featured_position_column
61
+ end
62
+
63
+ def test_insert
64
+ new = FeaturedMixin.create(:parent_id => 20)
65
+ assert_equal nil, new.pos
66
+ assert !new.first?
67
+ assert !new.last?
68
+ end
69
+
70
+ def test_featuring
71
+ new = FeaturedMixin.create(:parent_id => 20)
72
+ new.featured = true
73
+ assert_equal 1, new.pos
74
+ assert new.featured?
75
+ assert new.featured
76
+ assert new.first?
77
+ assert new.last?
78
+
79
+ new = FeaturedMixin.create(:parent_id => 20)
80
+ new.featured = true
81
+ assert_equal 2, new.pos
82
+ assert !new.first?
83
+ assert new.last?
84
+
85
+ new = FeaturedMixin.create(:parent_id => 20)
86
+ new.featured = true
87
+ assert_equal 3, new.pos
88
+ assert !new.first?
89
+ assert new.last?
90
+
91
+ new = FeaturedMixin.create(:parent_id => 0)
92
+ new.featured = true
93
+ assert_equal 1, new.pos
94
+ assert new.first?
95
+ assert new.last?
96
+ end
97
+
98
+ def test_unfeaturing
99
+ new = FeaturedMixin.create(:parent_id => 20)
100
+ new.featured = true
101
+ assert_equal 1, new.pos
102
+ new.featured = false
103
+ assert_nil new.pos
104
+ end
105
+
106
+ def test_feature_at
107
+ new = FeaturedMixin.create(:parent_id => 20)
108
+ new.featured = true
109
+ assert_equal 1, new.pos
110
+
111
+ new = FeaturedMixin.create(:parent_id => 20)
112
+ new.featured = true
113
+ assert_equal 2, new.pos
114
+
115
+ new = FeaturedMixin.create(:parent_id => 20)
116
+ new.featured = true
117
+ assert_equal 3, new.pos
118
+
119
+ new4 = FeaturedMixin.create(:parent_id => 20)
120
+ new4.featured = true
121
+ assert_equal 4, new4.pos
122
+
123
+ new4.feature_at(3)
124
+ assert_equal 3, new4.pos
125
+
126
+ new.reload
127
+ assert_equal 4, new.pos
128
+
129
+ new.feature_at(2)
130
+ assert_equal 2, new.pos
131
+
132
+ new4.reload
133
+ assert_equal 4, new4.pos
134
+
135
+ new5 = FeaturedMixin.create(:parent_id => 20)
136
+ new5.featured = true
137
+ assert_equal 5, new5.pos
138
+
139
+ new5.feature_at(1)
140
+ assert_equal 1, new5.pos
141
+
142
+ new4.reload
143
+ assert_equal 5, new4.pos
144
+ end
145
+
146
+ def test_delete_middle
147
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
148
+
149
+ FeaturedMixin.find(2).destroy
150
+
151
+ assert_equal [1, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
152
+
153
+ assert_equal 1, FeaturedMixin.find(1).pos
154
+ assert_equal 2, FeaturedMixin.find(3).pos
155
+ assert_equal 3, FeaturedMixin.find(4).pos
156
+
157
+ FeaturedMixin.find(1).destroy
158
+
159
+ assert_equal [3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
160
+
161
+ assert_equal 1, FeaturedMixin.find(3).pos
162
+ assert_equal 2, FeaturedMixin.find(4).pos
163
+ end
164
+
165
+ def test_with_string_based_scope
166
+ new = FeaturedWithStringScopeMixin.create(:parent_id => 500)
167
+ new.featured = true
168
+ assert_equal 1, new.pos
169
+ assert new.first?
170
+ assert new.last?
171
+ end
172
+
173
+ def test_nil_scope
174
+ new1, new2, new3 = FeaturedMixin.create, FeaturedMixin.create, FeaturedMixin.create
175
+ new1.featured = true
176
+ new2.featured = true
177
+ new3.featured = true
178
+ new2.move_higher
179
+ assert_equal [new2, new1, new3], FeaturedMixin.where('parent_id IS NULL').order('pos')
180
+ end
181
+
182
+ def test_unfeature_should_then_fail_in_list?
183
+ assert_equal true, FeaturedMixin.find(1).in_list?
184
+ FeaturedMixin.find(1).unfeature
185
+ assert_equal false, FeaturedMixin.find(1).in_list?
186
+ end
187
+
188
+ def test_unfeature_should_set_position_to_nil
189
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
190
+
191
+ FeaturedMixin.find(2).unfeature
192
+
193
+ assert_equal [2, 1, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
194
+
195
+ assert_equal 1, FeaturedMixin.find(1).pos
196
+ assert_equal nil, FeaturedMixin.find(2).pos
197
+ assert_equal 2, FeaturedMixin.find(3).pos
198
+ assert_equal 3, FeaturedMixin.find(4).pos
199
+ end
200
+
201
+ def test_remove_before_destroy_does_not_shift_lower_items_twice
202
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
203
+
204
+ FeaturedMixin.find(2).unfeature
205
+ FeaturedMixin.find(2).destroy
206
+
207
+ assert_equal [1, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
208
+
209
+ assert_equal 1, FeaturedMixin.find(1).pos
210
+ assert_equal 2, FeaturedMixin.find(3).pos
211
+ assert_equal 3, FeaturedMixin.find(4).pos
212
+ end
213
+
214
+ def test_before_destroy_callbacks_do_not_update_position_to_nil_before_deleting_the_record
215
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
216
+
217
+ # We need to trigger all the before_destroy callbacks without actually
218
+ # destroying the record so we can see the affect the callbacks have on
219
+ # the record.
220
+ list = FeaturedMixin.find(2)
221
+ if list.respond_to?(:run_callbacks)
222
+ list.run_callbacks(:destroy)
223
+ else
224
+ list.send(:callback, :before_destroy)
225
+ end
226
+
227
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5).order('pos').map(&:id)
228
+
229
+ assert_equal 1, FeaturedMixin.find(1).pos
230
+ assert_equal 2, FeaturedMixin.find(2).pos
231
+ assert_equal 2, FeaturedMixin.find(3).pos
232
+ assert_equal 3, FeaturedMixin.find(4).pos
233
+ end
234
+
235
+ end
236
+
237
+ class FeaturedSubTest < Test::Unit::TestCase
238
+
239
+ def setup
240
+ setup_db
241
+ (1..4).each { |i| ((i % 2 == 1) ? FeaturedMixinSub1 : FeaturedMixinSub2).create! :pos => i, :parent_id => 5000 }
242
+ end
243
+
244
+ def teardown
245
+ teardown_db
246
+ end
247
+
248
+ def test_reordering
249
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
250
+
251
+ FeaturedMixin.find(2).move_lower
252
+ assert_equal [1, 3, 2, 4], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
253
+
254
+ FeaturedMixin.find(2).move_higher
255
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
256
+
257
+ FeaturedMixin.find(1).move_to_bottom
258
+ assert_equal [2, 3, 4, 1], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
259
+
260
+ FeaturedMixin.find(1).move_to_top
261
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
262
+
263
+ FeaturedMixin.find(2).move_to_bottom
264
+ assert_equal [1, 3, 4, 2], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
265
+
266
+ FeaturedMixin.find(4).move_to_top
267
+ assert_equal [4, 1, 3, 2], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
268
+ end
269
+
270
+ def test_move_to_bottom_with_next_to_last_item
271
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
272
+ FeaturedMixin.find(3).move_to_bottom
273
+ assert_equal [1, 2, 4, 3], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
274
+ end
275
+
276
+ def test_next_prev
277
+ assert_equal FeaturedMixin.find(2), FeaturedMixin.find(1).lower_item
278
+ assert_nil FeaturedMixin.find(1).higher_item
279
+ assert_equal FeaturedMixin.find(3), FeaturedMixin.find(4).higher_item
280
+ assert_nil FeaturedMixin.find(4).lower_item
281
+ end
282
+
283
+ def test_injection
284
+ item = FeaturedMixin.new("parent_id"=>1)
285
+ assert_equal '"mixins"."parent_id" = 1', item.scope_condition
286
+ assert_equal "pos", item.featured_position_column
287
+ end
288
+
289
+ def test_feature_at
290
+ new = FeaturedMixin.create("parent_id" => 20)
291
+ new.featured = true
292
+ assert_equal 1, new.pos
293
+
294
+ new = FeaturedMixinSub1.create("parent_id" => 20)
295
+ new.featured = true
296
+ assert_equal 2, new.pos
297
+
298
+ new = FeaturedMixinSub2.create("parent_id" => 20)
299
+ new.featured = true
300
+ assert_equal 3, new.pos
301
+
302
+ new4 = FeaturedMixin.create("parent_id" => 20)
303
+ new4.featured = true
304
+ assert_equal 4, new4.pos
305
+
306
+ new4.feature_at(3)
307
+ assert_equal 3, new4.pos
308
+
309
+ new.reload
310
+ assert_equal 4, new.pos
311
+
312
+ new.feature_at(2)
313
+ assert_equal 2, new.pos
314
+
315
+ new4.reload
316
+ assert_equal 4, new4.pos
317
+
318
+ new5 = FeaturedMixinSub1.create("parent_id" => 20)
319
+ new5.featured = true
320
+ assert_equal 5, new5.pos
321
+
322
+ new5.feature_at(1)
323
+ assert_equal 1, new5.pos
324
+
325
+ new4.reload
326
+ assert_equal 5, new4.pos
327
+ end
328
+
329
+ def test_delete_middle
330
+ assert_equal [1, 2, 3, 4], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
331
+
332
+ FeaturedMixin.find(2).destroy
333
+
334
+ assert_equal [1, 3, 4], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
335
+
336
+ assert_equal 1, FeaturedMixin.find(1).pos
337
+ assert_equal 2, FeaturedMixin.find(3).pos
338
+ assert_equal 3, FeaturedMixin.find(4).pos
339
+
340
+ FeaturedMixin.find(1).destroy
341
+
342
+ assert_equal [3, 4], FeaturedMixin.where(:parent_id => 5000).order('pos').map(&:id)
343
+
344
+ assert_equal 1, FeaturedMixin.find(3).pos
345
+ assert_equal 2, FeaturedMixin.find(4).pos
346
+ end
347
+
348
+ end
349
+
350
+ class ArrayScopeFeaturedTest < Test::Unit::TestCase
351
+
352
+ def setup
353
+ setup_db
354
+ (1..4).each { |counter| ArrayScopeFeaturedMixin.create! :pos => counter, :parent_id => 5, :parent_type => 'ParentClass' }
355
+ end
356
+
357
+ def teardown
358
+ teardown_db
359
+ end
360
+
361
+ def test_reordering
362
+ assert_equal [1, 2, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
363
+
364
+ ArrayScopeFeaturedMixin.find(2).move_lower
365
+ assert_equal [1, 3, 2, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
366
+
367
+ ArrayScopeFeaturedMixin.find(2).move_higher
368
+ assert_equal [1, 2, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
369
+
370
+ ArrayScopeFeaturedMixin.find(1).move_to_bottom
371
+ assert_equal [2, 3, 4, 1], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
372
+
373
+ ArrayScopeFeaturedMixin.find(1).move_to_top
374
+ assert_equal [1, 2, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
375
+
376
+ ArrayScopeFeaturedMixin.find(2).move_to_bottom
377
+ assert_equal [1, 3, 4, 2], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
378
+
379
+ ArrayScopeFeaturedMixin.find(4).move_to_top
380
+ assert_equal [4, 1, 3, 2], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
381
+ end
382
+
383
+ def test_move_to_bottom_with_next_to_last_item
384
+ assert_equal [1, 2, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
385
+ ArrayScopeFeaturedMixin.find(3).move_to_bottom
386
+ assert_equal [1, 2, 4, 3], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
387
+ end
388
+
389
+ def test_next_prev
390
+ assert_equal ArrayScopeFeaturedMixin.find(2), ArrayScopeFeaturedMixin.find(1).lower_item
391
+ assert_nil ArrayScopeFeaturedMixin.find(1).higher_item
392
+ assert_equal ArrayScopeFeaturedMixin.find(3), ArrayScopeFeaturedMixin.find(4).higher_item
393
+ assert_nil ArrayScopeFeaturedMixin.find(4).lower_item
394
+ end
395
+
396
+ def test_injection
397
+ item = ArrayScopeFeaturedMixin.new(:parent_id => 1, :parent_type => 'ParentClass')
398
+ assert_equal '"mixins"."parent_id" = 1 AND "mixins"."parent_type" = \'ParentClass\'', item.scope_condition
399
+ assert_equal "pos", item.featured_position_column
400
+ end
401
+
402
+ def test_insert
403
+ ArrayScopeFeaturedMixin.destroy_all
404
+ new = ArrayScopeFeaturedMixin.create(:parent_id => 20, :parent_type => 'ParentClass')
405
+ new.featured = true
406
+ assert_equal 1, new.pos
407
+ assert new.first?
408
+ assert new.last?
409
+
410
+ new = ArrayScopeFeaturedMixin.create(:parent_id => 20, :parent_type => 'ParentClass')
411
+ new.featured = true
412
+ assert_equal 2, new.pos
413
+ assert !new.first?
414
+ assert new.last?
415
+
416
+ new = ArrayScopeFeaturedMixin.create(:parent_id => 20, :parent_type => 'ParentClass')
417
+ new.featured = true
418
+ assert_equal 3, new.pos
419
+ assert !new.first?
420
+ assert new.last?
421
+
422
+ new = ArrayScopeFeaturedMixin.create(:parent_id => 0, :parent_type => 'ParentClass')
423
+ new.featured = true
424
+ assert_equal 1, new.pos
425
+ assert new.first?
426
+ assert new.last?
427
+ end
428
+
429
+ def test_feature_at
430
+ new = ArrayScopeFeaturedMixin.create(:parent_id => 20, :parent_type => 'ParentClass')
431
+ new.featured = true
432
+ assert new.featured?
433
+ assert new.featured
434
+ assert_equal 1, new.pos
435
+
436
+ new = ArrayScopeFeaturedMixin.create(:parent_id => 20, :parent_type => 'ParentClass')
437
+ new.featured = true
438
+ assert_equal 2, new.pos
439
+
440
+ new = ArrayScopeFeaturedMixin.create(:parent_id => 20, :parent_type => 'ParentClass')
441
+ new.featured = true
442
+ assert_equal 3, new.pos
443
+
444
+ new4 = ArrayScopeFeaturedMixin.create(:parent_id => 20, :parent_type => 'ParentClass')
445
+ new4.featured = true
446
+ assert_equal 4, new4.pos
447
+
448
+ new4.feature_at(3)
449
+ assert_equal 3, new4.pos
450
+
451
+ new.reload
452
+ assert_equal 4, new.pos
453
+
454
+ new.feature_at(2)
455
+ assert_equal 2, new.pos
456
+
457
+ new4.reload
458
+ assert_equal 4, new4.pos
459
+
460
+ new5 = ArrayScopeFeaturedMixin.create(:parent_id => 20, :parent_type => 'ParentClass')
461
+ new5.featured = true
462
+ assert_equal 5, new5.pos
463
+
464
+ new5.feature_at(1)
465
+ assert_equal 1, new5.pos
466
+
467
+ new4.reload
468
+ assert_equal 5, new4.pos
469
+ end
470
+
471
+ def test_delete_middle
472
+ assert_equal [1, 2, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
473
+
474
+ ArrayScopeFeaturedMixin.find(2).destroy
475
+
476
+ assert_equal [1, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
477
+
478
+ assert_equal 1, ArrayScopeFeaturedMixin.find(1).pos
479
+ assert_equal 2, ArrayScopeFeaturedMixin.find(3).pos
480
+ assert_equal 3, ArrayScopeFeaturedMixin.find(4).pos
481
+
482
+ ArrayScopeFeaturedMixin.find(1).destroy
483
+
484
+ assert_equal [3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
485
+
486
+ assert_equal 1, ArrayScopeFeaturedMixin.find(3).pos
487
+ assert_equal 2, ArrayScopeFeaturedMixin.find(4).pos
488
+ end
489
+
490
+ def test_unfeature_should_then_fail_in_list?
491
+ assert_equal true, ArrayScopeFeaturedMixin.find(1).in_list?
492
+ ArrayScopeFeaturedMixin.find(1).unfeature
493
+ assert_equal false, ArrayScopeFeaturedMixin.find(1).in_list?
494
+ end
495
+
496
+ def test_unfeature_should_set_position_to_nil
497
+ assert_equal [1, 2, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
498
+
499
+ ArrayScopeFeaturedMixin.find(2).unfeature
500
+
501
+ assert_equal [2, 1, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
502
+
503
+ assert_equal 1, ArrayScopeFeaturedMixin.find(1).pos
504
+ assert_equal nil, ArrayScopeFeaturedMixin.find(2).pos
505
+ assert_equal 2, ArrayScopeFeaturedMixin.find(3).pos
506
+ assert_equal 3, ArrayScopeFeaturedMixin.find(4).pos
507
+ end
508
+
509
+ def test_remove_before_destroy_does_not_shift_lower_items_twice
510
+ assert_equal [1, 2, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
511
+
512
+ ArrayScopeFeaturedMixin.find(2).unfeature
513
+ ArrayScopeFeaturedMixin.find(2).destroy
514
+
515
+ assert_equal [1, 3, 4], ArrayScopeFeaturedMixin.where(:parent_id => 5, :parent_type => 'ParentClass').order('pos').map(&:id)
516
+
517
+ assert_equal 1, ArrayScopeFeaturedMixin.find(1).pos
518
+ assert_equal 2, ArrayScopeFeaturedMixin.find(3).pos
519
+ assert_equal 3, ArrayScopeFeaturedMixin.find(4).pos
520
+ end
521
+
522
+ end
523
+
data/test/schema.rb ADDED
@@ -0,0 +1,59 @@
1
+ require 'active_record'
2
+ require 'sqlite3'
3
+
4
+ ActiveRecord::Base.establish_connection(
5
+ :adapter => defined?(RUBY_ENGINE) && RUBY_ENGINE == 'jruby' ? 'jdbcsqlite3' : 'sqlite3',
6
+ :database => File.join(File.dirname(__FILE__), 'test.db')
7
+ )
8
+
9
+ class CreateSchema < ActiveRecord::Migration
10
+ def self.up
11
+ create_table :mixins, :force => true do |t|
12
+ t.integer :pos
13
+ t.integer :parent_id
14
+ t.string :parent_type
15
+ t.timestamp
16
+ end
17
+ end
18
+ end
19
+
20
+ def setup_db
21
+ CreateSchema.suppress_messages do
22
+ CreateSchema.migrate(:up)
23
+ end
24
+ end
25
+
26
+ setup_db
27
+
28
+ def teardown_db
29
+ ActiveRecord::Base.connection.tables.each do |table|
30
+ ActiveRecord::Base.connection.drop_table(table)
31
+ end
32
+ end
33
+
34
+ class Mixin < ActiveRecord::Base
35
+ end
36
+
37
+ class FeaturedMixin < Mixin
38
+ self.table_name = 'mixins'
39
+
40
+ has_features :column => "pos", :scope => :parent
41
+ end
42
+
43
+ class FeaturedMixinSub1 < FeaturedMixin
44
+ end
45
+
46
+ class FeaturedMixinSub2 < FeaturedMixin
47
+ end
48
+
49
+ class FeaturedWithStringScopeMixin < ActiveRecord::Base
50
+ self.table_name = 'mixins'
51
+
52
+ has_features :column => "pos", :scope => 'parent_id = #{parent_id}'
53
+ end
54
+
55
+ class ArrayScopeFeaturedMixin < Mixin
56
+ self.table_name = 'mixins'
57
+
58
+ has_features :column => "pos", :scope => [:parent_id, :parent_type]
59
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: has_features
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Seth Faxon
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-02-08 00:00:00.000000000Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: bundler
16
+ requirement: &70252332686700 !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ~>
20
+ - !ruby/object:Gem::Version
21
+ version: 1.0.0
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: *70252332686700
25
+ - !ruby/object:Gem::Dependency
26
+ name: activerecord
27
+ requirement: &70252332683660 !ruby/object:Gem::Requirement
28
+ none: false
29
+ requirements:
30
+ - - ! '>='
31
+ - !ruby/object:Gem::Version
32
+ version: 3.0.0
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: *70252332683660
36
+ - !ruby/object:Gem::Dependency
37
+ name: rdoc
38
+ requirement: &70252332682960 !ruby/object:Gem::Requirement
39
+ none: false
40
+ requirements:
41
+ - - ! '>='
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :development
45
+ prerelease: false
46
+ version_requirements: *70252332682960
47
+ - !ruby/object:Gem::Dependency
48
+ name: sqlite3
49
+ requirement: &70252332682000 !ruby/object:Gem::Requirement
50
+ none: false
51
+ requirements:
52
+ - - ! '>='
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ type: :development
56
+ prerelease: false
57
+ version_requirements: *70252332682000
58
+ description: Based on acts_as_list, but allows nil elements to be not a part of the
59
+ featuerd list.
60
+ email:
61
+ - seth.faxon@gmail.com
62
+ executables: []
63
+ extensions: []
64
+ extra_rdoc_files: []
65
+ files:
66
+ - .gitignore
67
+ - README
68
+ - Rakefile
69
+ - has_features.gemspec
70
+ - init.rb
71
+ - lib/has_features.rb
72
+ - lib/has_features/active_record/has/features.rb
73
+ - lib/has_features/version.rb
74
+ - test/list_test.rb
75
+ - test/schema.rb
76
+ homepage: http://github.com/sfaxon/has_features
77
+ licenses: []
78
+ post_install_message:
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ none: false
84
+ requirements:
85
+ - - ! '>='
86
+ - !ruby/object:Gem::Version
87
+ version: '0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ requirements: []
95
+ rubyforge_project: has_features
96
+ rubygems_version: 1.8.8
97
+ signing_key:
98
+ specification_version: 3
99
+ summary: A gem allowing a active_record model to have an ordered list of featured
100
+ items.
101
+ test_files:
102
+ - test/list_test.rb
103
+ - test/schema.rb