has_features 0.1.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 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