This repository has been archived by the owner on Apr 17, 2018. It is now read-only.
/
list.rb
620 lines (571 loc) · 23 KB
/
list.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
require 'dm-core'
require 'dm-adjust'
module DataMapper
module Is
# = dm-is-list
#
# DataMapper plugin for creating and organizing lists.
#
# == Installation
#
# === Stable
#
# Install the +dm-is-list+ gem using rubygems.
#
# $ gem install dm-is-list
#
# === Edge
#
# Download or clone +dm-is-list+ from Github[http://github.com/datamapper/dm-is-list/].
#
# $ cd /path/to/dm-is-list
#
# $ rake install
#
# == Getting started
#
# First of all, for a better understanding of this gem, make sure you study the '<tt>dm-is-list/spec/integration/list_spec.rb</tt>' file.
#
# ----
#
# Require +dm-is-list+ in your app.
#
# require 'dm-core' # must be required first
# require 'dm-is-list'
#
#
# Lets say we have a User class, and we want to give users the possibility of
# having their own todo-lists.
#
#
# class User
# include DataMapper::Resource
#
# property :id, Serial
# property :name, String
#
# has n, :todos
# end
#
# class Todo
# include DataMapper::Resource
#
# property :id, Serial
# property :title, String
# property :done, DateTime
#
# belongs_to :user
#
# # here we define that this should be a list, scoped on :user_id
# is :list, :scope => [:user_id]
# end
#
# Once we have our Users and Lists, we might want to work with...
#
# == Movements of list items
#
# Any list item can be moved around <b>within the same list</b> easily through the <tt>move</tt> method.
#
#
# === move( vector )
#
# There are number of convenient vectors that help you move items around within the list.
#
# item = Todo.get(1)
# other = Todo.get(2)
#
# item.move(:highest) # moves to top of list.
# item.move(:lowest) # moves to bottom of list.
# item.move(:top) # moves to top of list.
# item.move(:bottom) # moves to bottom of list.
# item.move(:up) # moves one up (:higher and :up is the same) within the scope.
# item.move(:down) # moves one up (:lower and :down is the same) within the scope.
# item.move(:to => position) # moves item to a specific position.
# item.move(:above => other) # moves item above the other item.*
# item.move(:below => other) # moves item above the other item.*
#
# # * won't move if the other item is in another scope. (should this be enabled?)
#
# The list will act as intelligently as possible and keep positions in a logical running order.
#
#
# === move( Integer )
#
# <b>NOTE! VERY IMPORTANT!</b>
#
# If you set the position manually, and then save, <b>the list will NOT reorganize itself</b>.
#
# item.position = 3 # setting position manually
# item.save # the item will now have position 3, but the list may have two items with the same position.
#
# # alternatively
# item.update(:position => 3) # sets the position manually, but does not reorganize the list positions.
#
#
# You should therefore <b>always use</b> the <tt>item.move(N)</tt> syntax instead.
#
# item.move(3) # does the same as above, but in one call AND *reorganizes* the list.
#
# <hr>
#
# <b>Hold On!</b>
#
# <tt>dm-is-list</tt> used to work with <tt>item.position = 1</tt> type syntax. Why this change?
#
# The main reason behind this change was that the previous version of <tt>dm-is-list</tt> created a LOT of
# extra SQL queries in order to support the manual updating of position, and as a result had a quite a few bugs/issues,
# which have been fixed in this version.
#
# The other reason is that I couldn't work out how to keep the functionality without adding the extra queries. But perhaps you can ?
#
# <hr>
#
# See "<b>Batch Changing Positions</b>" below for information on how to change the positions on a whole list.
#
# == Movements between scopes
#
# When you move items between scopes, the list will try to work with your intentions.
#
#
# Move the item from list to new list and add the item to the bottom of that list.
#
# item.user_id # => 1
# item.move_to_list(10) # => the scope id ie User.get(10).id
#
# # results in...
# item.user_id # => 10
# item.position # => < bottom of the list >
#
#
# Move the item from list to new list and add at the position given.
#
# item.user_id # => 1
# item.move_to_list(10, 2) # => the scope id ie User.get(10).id, position => 2
#
# # results in...
# item.user_id # => 10
# item.position # => 2
#
#
# == Batch Changing Positions
#
# A common scenario when working with lists is the sorting of a whole list via something like JQuery's sortable() functionality.
# <br>
# (Think re-arranging the order of Todo's according to priority or something similar)
#
#
# === Optimum scenario
#
# The most SQL query efficient way of changing the positions is:
#
#
# sort_order = [5,4,3,2,1] # list from AJAX request..
#
# items = Todo.all(:user => @u1) # loads all 5 items in the list
#
# items.each{ |item| item.update(:position => sort_order.index(item.id) + 1) } # remember the +1 since array's are indexed from 0
#
#
# The above code will result in something like these queries.
#
# # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position"
# # UPDATE "todos" SET "position" = 5 WHERE "id" = 1
# # UPDATE "todos" SET "position" = 4 WHERE "id" = 2
# # UPDATE "todos" SET "position" = 2 WHERE "id" = 4
# # UPDATE "todos" SET "position" = 1 WHERE "id" = 5
#
# <b>Remember!</b> Your sort order list has to be the same length as the found items in the list, or your loop will fail.
#
#
# === Wasteful scenario
#
# You can also use this version, but it will create upto <b>5 times as many SQL queries</b>. :(
#
#
# sort_order = ['5','4','3','2','1'] # list from AJAX request..
#
# items = Todo.all(:user => @u1) # loads all 5 items in the list
#
# items.each{ |item| item.move(sort_order.index(item.id).to_i + 1) } # remember the +1 since array's are indexed from 0
#
# The above code will result in something like these queries:
#
# # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position"
#
# # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position" DESC LIMIT 1
# # SELECT "id" FROM "todos" WHERE "user_id" = 1 AND "id" IN (1, 2, 3, 4, 5) AND "position" BETWEEN 1 AND 5 ORDER BY "position"
# # UPDATE "todos" SET "position" = "position" + -1 WHERE "user_id" = 1 AND "position" BETWEEN 1 AND 5
# # SELECT "id", "position" FROM "todos" WHERE "id" IN (1, 2, 3, 4, 5) ORDER BY "id"
# # UPDATE "todos" SET "position" = 5 WHERE "id" = 1
#
# # SELECT "id", "title", "position", "user_id" FROM "todos" WHERE "user_id" = 1 ORDER BY "position" DESC LIMIT 1
# # SELECT "id" FROM "todos" WHERE "user_id" = 1 AND "id" IN (1, 2, 3, 4, 5) AND "position" BETWEEN 1 AND 4 ORDER BY "position"
# # UPDATE "todos" SET "position" = "position" + -1 WHERE "user_id" = 1 AND "position" BETWEEN 1 AND 4
# # SELECT "id", "position" FROM "todos" WHERE "id" IN (2, 3, 4, 5) ORDER BY "id"
# # UPDATE "todos" SET "position" = 4 WHERE "id" = 2
#
# # ...
#
# As you can see it will also do the job, but will be more expensive.
#
#
# == RTFM
#
# As I said above, for a better understanding of this gem/plugin, make sure you study the '<tt>dm-is-list/spec/integration/list_spec.rb</tt>' tests.
#
#
# == Errors / Bugs
#
# If something is not behaving intuitively, it is a bug, and should be reported.
# Report it here: http://datamapper.lighthouseapp.com/
#
module List
##
# method for making your model a list.
# TODO:: this explanation is confusing. Need to translate into literal code
#
# it will define a :position property if it does not exist, so be sure to have a
# position-column in your database (will be added automatically on auto_migrate)
# if the column has a different name, simply make a :position-property and set a
# custom :field
#
# @example [Usage]
# is :list # put this in your model to make it act as a list.
# is :list, :scope => :user_id # you can also define scopes
# is :list, :scope => [ :user_id, :context_id ] # also works with multiple params
#
# @param options <Hash> a hash of options
#
# @option :scope<Array> an array of attributes that should be used to scope lists
#
# @api public
def is_list(options={})
options = { :scope => [], :first => 1 }.merge(options)
# coerce the scope into an Array
options[:scope] = Array(options[:scope])
extend DataMapper::Is::List::ClassMethods
include DataMapper::Is::List::InstanceMethods
unless properties.any? { |p| p.name == :position && p.dump_class.equal?(::Integer) }
property :position, Integer
end
@list_options = options
before :create do
# if a position has been set before save, then insert it at the position and
# move the other items in the list accordingly, else if no position has been set
# then set position to bottom of list
__send__(:move_without_saving, position || :lowest)
# on create, set moved to false so we can move the list item after creating it
# self.moved = false
end
before :update do
# a (new) position has been set => move item to this position (only if position has been set manually)
# the scope has changed => detach from old list, and possibly move into position
# the scope and position has changed => detach from old, move to pos in new
# if the scope has changed, we need to detach our item from the old list
if list_scope != original_list_scope
newpos = position
detach(original_list_scope) # removing from old list
__send__(:move_without_saving, newpos || :lowest) # moving to pos or bottom of new list
end
# NOTE:: uncommenting the following creates a large number of extra un-wanted SQL queries
# hence the commenting out of it.
# if attribute_dirty?(:position) && !moved
# __send__(:move_without_saving, position)
# end
# # on update, clean moved to prepare for the next change
# self.moved = false
end
before :destroy do
detach
end
end # is_list
module ClassMethods
attr_reader :list_options
##
# use this function to repair / build your lists.
#
# @example [Usage]
# MyModel.repair_list # repairs the list, given that lists are not scoped
# MyModel.repair_list(:user_id => 1) # fixes the list for user 1, given that the scope is [:user_id]
#
# @param scope [Hash]
#
# @api public
def repair_list(scope = {})
return false unless scope.keys.all?{ |s| list_options[:scope].include?(s) || s == :order }
retval = true
all({ :order => [ :position.asc ] | default_order }.merge(scope)).each_with_index do |item, index|
retval &= item.update(:position => index.succ)
end
retval
end
# we need to make sure that STI-models will inherit the list_scope.
def inherited(model)
super
model.instance_variable_set(:@list_options, @list_options.dup)
end
end
module InstanceMethods
# @api semipublic
attr_accessor :moved
##
# returns the scope of the current list item
#
# @return <Hash> ...?
#
# @example [Usage]
# Todo.get(2).list_scope => { :user_id => 1 }
#
#
# @api semipublic
def list_scope
Hash[ model.list_options[:scope].map { |p| [ p, attribute_get(p) ] } ]
end
##
# returns the _original_ scope of the current list item
#
# @return <Hash> ...?
#
# @example [Usage]
# item = Todo.get(2) # with user_id 1
# item.user_id = 2
# item.original_list_scope => { :user_id => 1 }
#
# @api semipublic
def original_list_scope
pairs = model.list_options[:scope].map do |p|
[ p, (property = properties[p]) && original_attributes.key?(property) ? original_attributes[property] : attribute_get(p) ]
end
Hash[ pairs ]
end
##
# returns the query conditions
#
# @return <Hash> ...?
#
# @example [Usage]
# Todo.get(2).list_query => { :user_id => 1, :order => [:position] }
#
# @api semipublic
def list_query
list_scope.merge(:order => [ :position ])
end
##
# returns the list the current item belongs to
#
# @param scope <Hash> Optional (Default is #list_query)
#
# @return <DataMapper::Collection> the list items within the given scope
#
# @example [Usage]
# Todo.get(2).list => [ list of Todo items within the same scope as item]
# Todo.get(2).list(:user_id => 2 ) => [ list of Todo items with user_id => 2]
#
# @api public
def list(scope = list_query)
model.all(scope)
end
##
# repair the list this item belongs to
#
# @api public
def repair_list
model.repair_list(list_scope)
end
##
# reorder the list this item belongs to
#
# @param order <Array> ...?
#
# @return <Boolean> True/False based upon result
#
# @example [Usage]
# Todo.get(2).reorder_list([:title.asc])
#
# @api public
def reorder_list(order)
model.repair_list(list_scope.merge(:order => order))
end
##
# detaches a list item from the list, essentially setting the position as nil
#
# @param scope <Hash> Optional (Default is #list_scope)
#
# @return <DataMapper::Collection> the list items within the given scope
#
# @example [Usage]
#
# @api public
def detach(scope = list_scope)
list(scope).all(:position.gt => position).adjust!({ :position => -1 },true)
self.position = nil
end
##
# moves an item from one list to another
#
# @param scope <Integer> must be the id value of the scope
# @param pos <Integer> Optional sets the entry position for the item in the new list
#
# @example [Usage]
# Todo.get(2).move_to_list(2)
# Todo.get(2).move_to_list(2, 10)
#
# @return <Boolean> True/False based upon result
#
# @api public
def move_to_list(scope, pos = nil)
detach # remove from current list
attribute_set(model.list_options[:scope][0], scope.to_i) # set new scope
save # save progress. Needed to get the positions correct.
reload # get a fresh new start
move(pos) unless pos.nil?
end
##
# finds the previous _higher_ item in the list (lower in number position)
#
# @return <Model> the previous list item
#
# @example [Usage]
# Todo.get(2).left_sibling => Todo.get(1)
# Todo.get(2).higher_item => Todo.get(1)
# Todo.get(2).previous_item => Todo.get(1)
#
# @api public
def left_sibling
list.reverse.first(:position.lt => position)
end
alias_method :higher_item, :left_sibling
alias_method :previous_item, :left_sibling
##
# finds the next _lower_ item in the list (higher in number position)
#
# @return <Model> the next list item
#
# @example [Usage]
# Todo.get(2).right_sibling => Todo.get(3)
# Todo.get(2).lower_item => Todo.get(3)
# Todo.get(2).next_item => Todo.get(3)
#
# @api public
def right_sibling
list.first(:position.gt => position)
end
alias_method :lower_item, :right_sibling
alias_method :next_item, :right_sibling
##
# move item to a position in the list. position should _only_ be changed through this
#
# @example [Usage]
# * node.move :higher # moves node higher unless it is at the top of list
# * node.move :lower # moves node lower unless it is at the bottom of list
# * node.move :below => other_node # moves this node below the other resource in the list
# * node.move :above => Node.get(2) # moves this node above the other resource in the list
# * node.move :to => 2 # moves this node to the position given in the list
# * node.move(2) # moves this node to the position given in the list
#
# @param vector <Symbol, Hash, Integer> An integer, a symbol, or a key-value pair that describes the requested movement
#
# @option :higher<Symbol> move item higher
# @option :lower<Symbol> move item lower
# @option :up<Symbol> move item higher
# @option :down<Symbol> move item lower
# @option :highest<Symbol> move item to the top of the list
# @option :lowest<Symbol> move item to the bottom of the list
# @option :top<Symbol> move item to the top of the list
# @option :bottom<Symbol> move item to the bottom of the list
# @option :above<Resource> move item above other item. must be in same scope
# @option :below<Resource> move item below other item. must be in same scope
# @option :to<Hash{Symbol => Integer/String}> move item to a specific position in the list
# @option <Integer> move item to a specific position in the list
#
# @return <TrueClass, FalseClass> returns false if it cannot move to the position, otherwise true
# @see move_without_saving
#
# @api public
def move(vector)
move_without_saving(vector) && save
end
private
##
# does all the actual movement in #move, but does not save afterwards. this is used internally in
# before :create / :update. Should not be used by organic beings.
#
# @see move
#
# @api private
def move_without_saving(vector)
if vector.kind_of?(Hash)
action, object = vector.keys[0], vector.values[0]
else
action = vector
end
# set the start position to 1 or, if offset in the list_options is :list, :first => X
minpos = model.list_options[:first]
# the previous position (if changed) else current position
prepos = original_attributes[properties[:position]] || position
# set the last position in the list or previous position if the last item
maxpos = (last = list.last) ? (last == self ? prepos : last.position + 1) : minpos
newpos = case action
when :highest then minpos
when :top then minpos
when :lowest then maxpos
when :bottom then maxpos
when :higher,:up then [ position - 1, minpos ].max
when :lower,:down then [ position + 1, maxpos ].min
when :above
# the object given, can either be:
# -- the same as self
# -- already below self
# -- higher up than self (lower number in list)
( (self == object) or (object.position > self.position) ) ? self.position : object.position
when :below
# the object given, can either be:
# -- the same as self
# -- already above self
# -- lower than self (higher number in list)
( self == object or (object.position < self.position) ) ? self.position : object.position + 1
when :to
# can only move within top and bottom positions of list
# -- .move(:to => 2 ) Hash with FixNum
# -- .move(:to => '2' ) Hash with String
# NOTE:: sensitive functionality
# maxpos is incremented above, so decrement by 1 to get true maxpos
# minpos is fixed, so just take the object position value given
# else add 1 to object position value
obj = object.to_i
if (obj > maxpos)
[ minpos, [ obj, maxpos - 1 ].min ].max
else
[ minpos, [ obj, maxpos ].min ].max
end
else
raise ArgumentError, "unrecognized vector: [#{action}]. Please check your spelling and/or the docs" if action.is_a?(Symbol)
# -- .move(2) as FixNum only
# -- .move('2') as String only
if action.to_i < minpos
[ minpos, maxpos - 1 ].min
else
[ action.to_i, maxpos - 1 ].min
end
end
# don't move if already at the position
return false if [ :lower, :down, :higher, :up, :top, :bottom, :highest, :lowest, :above, :below ].include?(action) && newpos == prepos
return false if !newpos || ([ :above, :below ].include?(action) && list_scope != object.list_scope)
return true if newpos == position && position == prepos || (newpos == maxpos && position == maxpos - 1)
if !position
list.all(:position.gte => newpos).adjust!({ :position => 1 }, true) unless action =~ /:(lowest|bottom)/
elsif newpos > prepos
newpos -= 1 if [:lowest,:bottom,:above,:below].include?(action)
list.all(:position => prepos..newpos).adjust!({ :position => -1 }, true)
elsif newpos < prepos
list.all(:position => newpos..prepos).adjust!({ :position => 1 }, true)
end
self.position = newpos
self.moved = true
true
end # move_without_saving
end # InstanceMethods
end # List
end # Is
Model.append_extensions(Is::List)
end # DataMapper