positionable 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +8 -0
- data/.travis.yml +7 -0
- data/Gemfile +4 -0
- data/LICENCE +20 -0
- data/README.rdoc +117 -0
- data/Rakefile +1 -0
- data/lib/positionable.rb +314 -0
- data/lib/positionable/version.rb +3 -0
- data/positionable.gemspec +28 -0
- data/spec/factories.rb +53 -0
- data/spec/lib/positionable_spec.rb +596 -0
- data/spec/spec_helper.rb +35 -0
- data/spec/support/matchers/contiguity_matcher.rb +26 -0
- data/spec/support/models.rb +41 -0
- data/spec/support/schema.rb +33 -0
- metadata +134 -0
data/.gitignore
ADDED
data/.travis.yml
ADDED
data/Gemfile
ADDED
data/LICENCE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright (c) 2011 Philippe Guégan
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.rdoc
ADDED
@@ -0,0 +1,117 @@
|
|
1
|
+
= Positionable
|
2
|
+
|
3
|
+
{<img src="https://secure.travis-ci.org/pguegan/positionable.png" />}[http://travis-ci.org/pguegan/positionable]
|
4
|
+
|
5
|
+
<b>Positionable</b> is a library which provides contiguous positionning capabilities to your ActiveRecord models.
|
6
|
+
|
7
|
+
For more functionalities, you could also have a look at acts_as_list[https://github.com/swanandp/acts_as_list].
|
8
|
+
|
9
|
+
== Installation
|
10
|
+
|
11
|
+
Edit your Gemfile, and simply add the following line:
|
12
|
+
|
13
|
+
gem 'positionable'
|
14
|
+
|
15
|
+
== Getting Started
|
16
|
+
|
17
|
+
Let's say you want to make the model +Item+ positionable.
|
18
|
+
|
19
|
+
=== Create a migration
|
20
|
+
|
21
|
+
First, create a migration to add the column +position+ to the table +items+:
|
22
|
+
|
23
|
+
rails generate migration add_position_to_items position:integer
|
24
|
+
|
25
|
+
Then, run the migration:
|
26
|
+
|
27
|
+
rake db:migrate
|
28
|
+
|
29
|
+
=== Setup your model
|
30
|
+
|
31
|
+
Simply add the +is_positionable+ method in your ActiveRecord model:
|
32
|
+
|
33
|
+
class Item < ActiveRecord::Base
|
34
|
+
is_positionable
|
35
|
+
end
|
36
|
+
|
37
|
+
==== Grouping records
|
38
|
+
|
39
|
+
Maybe your items are grouped (typically with a +belongs_to+ association). In this case, you'll want to restrict the position in each group by declaring the +:scope+ option:
|
40
|
+
|
41
|
+
class Item < ActiveRecord::Base
|
42
|
+
belongs_to :folder
|
43
|
+
is_positionable :scope => :folder
|
44
|
+
attr_accessible :folder_id, :position
|
45
|
+
end
|
46
|
+
|
47
|
+
Note that it is the model responsibility to give the white-list of attributes that can be updated <em>via</em> mass-assignement. In this case, <b>you must</b> add the +position+ attribute in the +attr_accessible+ clause.
|
48
|
+
|
49
|
+
==== Start position
|
50
|
+
|
51
|
+
By default, position starts by zero. But you may want to change this at the model level, for instance by starting at one (which seems more natural for some people):
|
52
|
+
|
53
|
+
class Item < ActiveRecord::Base
|
54
|
+
is_positionable :start => 1
|
55
|
+
end
|
56
|
+
|
57
|
+
==== Ordering
|
58
|
+
|
59
|
+
When a new record is created, it is inserted by default at the last (highest) position of its group. Thus, when record are listed, the newly created record will appear at the bottom.
|
60
|
+
|
61
|
+
It is possible to change this behaviour by setting the +order+ option as follows:
|
62
|
+
|
63
|
+
class Item < ActiveRecord::Base
|
64
|
+
is_positionable :order => :desc
|
65
|
+
end
|
66
|
+
|
67
|
+
This way, records are always listed by descending positions order. Record that have the highest position will appears at the top.
|
68
|
+
|
69
|
+
<b>Caution!</b> The semantic of +next+ or +previous+ methods remains unchanged. More precisely, even if the highest position is the position of the <em>first</em> returned record, it is still considered as the <em>last</em> one (<em>i.e.:</em> +Item.first.last?+ returns +true+). I know, this is odd. The semantic of these methods will certainly change in a further version.
|
70
|
+
|
71
|
+
==== Mixing options
|
72
|
+
|
73
|
+
Obviously, these options are not exclusive. You are free to mix them like, for example:
|
74
|
+
|
75
|
+
class Item < ActiveRecord::Base
|
76
|
+
belongs_to :folder
|
77
|
+
is_positionable :scope => :folder, :order => :desc, :start => 1
|
78
|
+
end
|
79
|
+
|
80
|
+
== Usage
|
81
|
+
|
82
|
+
=== Querying
|
83
|
+
|
84
|
+
To get the previous or next sibling items:
|
85
|
+
|
86
|
+
previous = item.previous
|
87
|
+
all_previous = item.all_previous
|
88
|
+
next = item.next
|
89
|
+
all_next = item.all_next
|
90
|
+
|
91
|
+
Both first and last items can be caracterized:
|
92
|
+
|
93
|
+
item.first? # True if item.previous is nil
|
94
|
+
item.last? # True if item.next is nil
|
95
|
+
|
96
|
+
Given a positionable item, its position is always included in a range which can be determined as follows:
|
97
|
+
|
98
|
+
item.range
|
99
|
+
|
100
|
+
If this item is aimed at being moved to another different scope, then you can pass this new scope as a parameter:
|
101
|
+
|
102
|
+
item.range(folder)
|
103
|
+
|
104
|
+
=== Moving
|
105
|
+
|
106
|
+
Rather than directly assign position attribute, you can move your items with these provided methods:
|
107
|
+
|
108
|
+
item = Item.create(...) # The newly created item is put at the last position (by default).
|
109
|
+
item.up! # Item's position is swaped with the previous item.
|
110
|
+
item.down! # Item's position is swaped with the next item.
|
111
|
+
item.move_to new_position # Moves this record to the given position, and updates sibling items positions accordingly.
|
112
|
+
|
113
|
+
Yet, it is possible to update the item's position <em>via</em> mass-assignement:
|
114
|
+
|
115
|
+
item.update_attributes { :position => new_position, ... }
|
116
|
+
|
117
|
+
This will trigger some ActiveRecord callbacks in order to maintain positions contiguity across all other concerned items, even if the item is moved from a scope to another.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/lib/positionable.rb
ADDED
@@ -0,0 +1,314 @@
|
|
1
|
+
require "positionable/version"
|
2
|
+
require 'active_record'
|
3
|
+
|
4
|
+
# <b>Positionable</b> is a library which provides contiguous positionning capabilities to your
|
5
|
+
# ActiveRecord models. This module is designed to be an ActiveRecord extension.
|
6
|
+
#
|
7
|
+
# Calling the <tt>is_positionable</tt> method in your ActiveRecord model will inject
|
8
|
+
# positionning capabilities. In particular, this will guarantee your records' positions
|
9
|
+
# to be <em>contiguous</em>, ie.: there is no 'hole' between two adjacent positions.
|
10
|
+
#
|
11
|
+
# Positionable has a strong management of records that belong to a group (a.k.a. scope). When a
|
12
|
+
# record is moved whithin its scope, or from a scope to another, other impacted records are
|
13
|
+
# also reordered accordingly.
|
14
|
+
#
|
15
|
+
# You can use the provided instance methods (<tt>up!</tt>, <tt>down!</tt> or <tt>move_to</tt>)
|
16
|
+
# to move or reorder your records, whereas it is possible to update the position <em>via</em>
|
17
|
+
# mass-assignement. In particular, <tt>update_attributes({:position => new_position})</tt> will
|
18
|
+
# trigger some ActiveRecord callbacks in order to maintain positions' contiguity.
|
19
|
+
#
|
20
|
+
# Additional methods are available to query your model: check if this the <tt>last?</tt> or
|
21
|
+
# <tt>first?</tt> of its own scope, retrieve the <tt>previous</tt> or the <tt>next</tt> records
|
22
|
+
# according to their positions, etc.
|
23
|
+
module Positionable
|
24
|
+
|
25
|
+
def self.included(base)
|
26
|
+
base.extend(PositionableMethods)
|
27
|
+
end
|
28
|
+
|
29
|
+
module PositionableMethods
|
30
|
+
|
31
|
+
# Makes this model positionable.
|
32
|
+
#
|
33
|
+
# class Item < ActiveRecord::Base
|
34
|
+
# is_positionable
|
35
|
+
# end
|
36
|
+
#
|
37
|
+
# Maybe your items are grouped (typically with a +belongs_to+ association). In this case,
|
38
|
+
# you'll want to restrict the position in each group by declaring the +:scope+ option:
|
39
|
+
#
|
40
|
+
# class Folder < ActiveRecord::Base
|
41
|
+
# has_many :items
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# class Item < ActiveRecord::Base
|
45
|
+
# belongs_to :folder
|
46
|
+
# is_positionable :scope => :folder
|
47
|
+
# end
|
48
|
+
#
|
49
|
+
# By default, position starts by zero. But you may want to change this at the model level,
|
50
|
+
# for instance by starting at one (which seems more natural for some people):
|
51
|
+
#
|
52
|
+
# class Item < ActiveRecord::Base
|
53
|
+
# is_positionable :start => 1
|
54
|
+
# end
|
55
|
+
#
|
56
|
+
# To make new records to appear at first position, the default ordering can be changed as
|
57
|
+
# follows:
|
58
|
+
#
|
59
|
+
# class Item < ActiveRecord::Base
|
60
|
+
# is_positionable :order => :desc
|
61
|
+
# end
|
62
|
+
def is_positionable(options = {})
|
63
|
+
include InstanceMethods
|
64
|
+
|
65
|
+
scope_id_attr = "#{options[:scope].to_s}_id" if options[:scope]
|
66
|
+
start = options[:start] || 0
|
67
|
+
order = options[:order] || :asc
|
68
|
+
|
69
|
+
default_scope order("\"#{self.table_name}\".\"position\" #{order}")
|
70
|
+
|
71
|
+
before_create :add_to_bottom
|
72
|
+
before_update :update_position
|
73
|
+
after_destroy :decrement_all_next
|
74
|
+
|
75
|
+
if scope_id_attr
|
76
|
+
class_eval <<-RUBY
|
77
|
+
|
78
|
+
def scope_id
|
79
|
+
send(:"#{scope_id_attr}")
|
80
|
+
end
|
81
|
+
|
82
|
+
# Gives the range of available positions for this record, whithin the provided scope.
|
83
|
+
# If no scope is provided, then it takes the record's current scope by default.
|
84
|
+
# If this record is new and no scope can be retrieved, then a <tt>RangeWithoutScopeError</tt>
|
85
|
+
# is raised.
|
86
|
+
def range(scope = nil)
|
87
|
+
raise RangeWithoutScopeError if new_record? and scope.nil? and scope_id.nil?
|
88
|
+
# Does its best to retrieve the target scope...
|
89
|
+
target_scope_id = scope.nil? ? scope_id : scope.id
|
90
|
+
# Number of records whithin the target scope
|
91
|
+
count = self.class.where("#{scope_id_attr} = ?", target_scope_id).count
|
92
|
+
# An additional position is available if this record is new, or if it's moved to another scope
|
93
|
+
if new_record? or target_scope_id != scope_id
|
94
|
+
(start..(count + 1))
|
95
|
+
else
|
96
|
+
(start..count)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
private
|
101
|
+
|
102
|
+
def scoped_condition
|
103
|
+
"#{scope_id_attr} = " + scope_id.to_s
|
104
|
+
end
|
105
|
+
|
106
|
+
def scoped_position
|
107
|
+
scoped_condition + " and position"
|
108
|
+
end
|
109
|
+
|
110
|
+
def scope_changed?
|
111
|
+
send(:"#{scope_id_attr}_changed?")
|
112
|
+
end
|
113
|
+
|
114
|
+
def scope_id_was
|
115
|
+
send(:"#{scope_id_attr}_was")
|
116
|
+
end
|
117
|
+
|
118
|
+
def scoped_condition_was
|
119
|
+
"#{scope_id_attr} = " + scope_id_was.to_s
|
120
|
+
end
|
121
|
+
|
122
|
+
def scoped_position_was
|
123
|
+
scoped_condition_was + " and position"
|
124
|
+
end
|
125
|
+
|
126
|
+
RUBY
|
127
|
+
else
|
128
|
+
class_eval <<-RUBY
|
129
|
+
|
130
|
+
# Gives the range of available positions for this record.
|
131
|
+
def range
|
132
|
+
if new_record?
|
133
|
+
(start..(bottom + 1))
|
134
|
+
else
|
135
|
+
(start..bottom)
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
private
|
140
|
+
|
141
|
+
def scope_changed?
|
142
|
+
false
|
143
|
+
end
|
144
|
+
|
145
|
+
def scoped_condition
|
146
|
+
""
|
147
|
+
end
|
148
|
+
|
149
|
+
def scoped_position
|
150
|
+
"position"
|
151
|
+
end
|
152
|
+
|
153
|
+
RUBY
|
154
|
+
end
|
155
|
+
|
156
|
+
class_eval <<-RUBY
|
157
|
+
def start
|
158
|
+
#{start}
|
159
|
+
end
|
160
|
+
RUBY
|
161
|
+
end
|
162
|
+
|
163
|
+
module InstanceMethods
|
164
|
+
|
165
|
+
# Tells whether this record is the first one (of his scope, if any).
|
166
|
+
def first?
|
167
|
+
position == start
|
168
|
+
end
|
169
|
+
|
170
|
+
# Tells whether this record is the last one (of his scope, if any).
|
171
|
+
def last?
|
172
|
+
position == bottom
|
173
|
+
end
|
174
|
+
|
175
|
+
# Swaps this record position with his previous sibling, unless this record is the first one.
|
176
|
+
def up!
|
177
|
+
swap_with(previous) unless first?
|
178
|
+
end
|
179
|
+
|
180
|
+
# Swaps this record position with his next sibling, unless this record is the last one.
|
181
|
+
def down!
|
182
|
+
swap_with(self.next) unless last?
|
183
|
+
end
|
184
|
+
|
185
|
+
# Moves this record at the given position, and updates positions of the impacted sibling
|
186
|
+
# records accordingly. If the new position is out of range, then the record is not moved.
|
187
|
+
def move_to(new_position)
|
188
|
+
if range.include? new_position
|
189
|
+
reorder(position, new_position)
|
190
|
+
update_column(:position, new_position)
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
# The next sibling record, whose position is right after this record.
|
195
|
+
def next
|
196
|
+
at(position + 1)
|
197
|
+
end
|
198
|
+
|
199
|
+
# All the next records, whose positions are greater than this record. Records
|
200
|
+
# are ordered by their respective positions, depending on the <tt>order</tt> option
|
201
|
+
# provided to <tt>is_positionable</tt>.
|
202
|
+
def all_next
|
203
|
+
self.class.where("#{scoped_position} > ?", position)
|
204
|
+
end
|
205
|
+
|
206
|
+
# All the next records <em>of the old scope</em>, whose positions are greater
|
207
|
+
# than this record before it was moved from its old record.
|
208
|
+
def all_next_was
|
209
|
+
self.class.where("#{scoped_position_was} > ?", position_was)
|
210
|
+
end
|
211
|
+
|
212
|
+
# Gives the next sibling record, whose position is right before this record.
|
213
|
+
def previous
|
214
|
+
at(position - 1)
|
215
|
+
end
|
216
|
+
|
217
|
+
# All the next records, whose positions are smaller than this record. Records
|
218
|
+
# are ordered by their respective positions, depending on the <tt>order</tt> option
|
219
|
+
# provided to <tt>is_positionable</tt> (ascending by default).
|
220
|
+
def all_previous
|
221
|
+
self.class.where("#{scoped_position} < ?", position)
|
222
|
+
end
|
223
|
+
|
224
|
+
private
|
225
|
+
|
226
|
+
# The position of the last record.
|
227
|
+
def bottom
|
228
|
+
scoped_all.size + start - 1
|
229
|
+
end
|
230
|
+
|
231
|
+
# Finds the record at the given position.
|
232
|
+
def at(position)
|
233
|
+
self.class.where("#{scoped_position} = ?", position).limit(1).first
|
234
|
+
end
|
235
|
+
|
236
|
+
# Swaps this record's position with the other provided record.
|
237
|
+
def swap_with(other)
|
238
|
+
self.class.transaction do
|
239
|
+
old_position = position
|
240
|
+
update_attribute(:position, other.position)
|
241
|
+
other.update_attribute(:position, old_position)
|
242
|
+
end
|
243
|
+
end
|
244
|
+
|
245
|
+
# All the records that belong to same scope (if any) of this record (including itself).
|
246
|
+
def scoped_all
|
247
|
+
self.class.where(scoped_condition)
|
248
|
+
end
|
249
|
+
|
250
|
+
# Reorders records between provided positions, unless the destination position is out of range.
|
251
|
+
def reorder(from, to)
|
252
|
+
if to > from
|
253
|
+
shift, siblings = -1, ((from + 1)..to).map { |p| at(p) }
|
254
|
+
elsif scope_changed?
|
255
|
+
# When scope changes, it actually inserts this record in the new scope
|
256
|
+
# All next siblings (from new position to bottom) have to be staggered
|
257
|
+
shift, siblings = 1, (to..bottom).map { |p| at(p) }
|
258
|
+
else
|
259
|
+
shift, siblings = 1, (to..(from - 1)).map { |p| at(p) }
|
260
|
+
end
|
261
|
+
self.class.transaction do
|
262
|
+
siblings.map do |sibling|
|
263
|
+
sibling.update_column(:position, sibling.position + shift)
|
264
|
+
end
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
# Reorders records between old and new position (and old and new scope).
|
269
|
+
def update_position
|
270
|
+
if scope_changed?
|
271
|
+
decrement(all_next_was)
|
272
|
+
if range.include?(position)
|
273
|
+
reorder(position_was, position)
|
274
|
+
else
|
275
|
+
add_to_bottom
|
276
|
+
end
|
277
|
+
else
|
278
|
+
if range.include?(position)
|
279
|
+
reorder(position_was, position)
|
280
|
+
else
|
281
|
+
self.position = position_was # Keep original position
|
282
|
+
end
|
283
|
+
end
|
284
|
+
end
|
285
|
+
|
286
|
+
# Adds this record to the bottom.
|
287
|
+
def add_to_bottom
|
288
|
+
self.position = bottom + 1
|
289
|
+
end
|
290
|
+
|
291
|
+
# Decrements the position of all the next sibling records of this record.
|
292
|
+
def decrement_all_next
|
293
|
+
decrement(all_next)
|
294
|
+
end
|
295
|
+
|
296
|
+
# Decrements the position of all the provided records.
|
297
|
+
def decrement(records)
|
298
|
+
self.class.transaction do
|
299
|
+
records.each do |record|
|
300
|
+
record.update_column(:position, record.position - 1)
|
301
|
+
end
|
302
|
+
end
|
303
|
+
end
|
304
|
+
|
305
|
+
end
|
306
|
+
|
307
|
+
end
|
308
|
+
|
309
|
+
class RangeWithoutScopeError < StandardError
|
310
|
+
end
|
311
|
+
|
312
|
+
ActiveRecord::Base.send(:include, Positionable)
|
313
|
+
|
314
|
+
end
|