positionable 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,3 @@
1
+ module Positionable
2
+ VERSION = "1.0.1"
3
+ end
@@ -0,0 +1,28 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "positionable/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "positionable"
7
+ s.version = Positionable::VERSION
8
+ s.authors = ["Philippe Guégan"]
9
+ s.email = ["philippe.guegan@gmail.com"]
10
+ s.homepage = "https://github.com/pguegan/positionable"
11
+ s.summary = %q(A gem for positionning your ActiveRecord models.)
12
+ s.description = %q(This extension provides contiguous positionning capabilities to you ActiveRecord models.)
13
+
14
+ s.rubyforge_project = "positionable"
15
+
16
+ s.files = `git ls-files`.split("\n")
17
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
18
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
19
+ s.require_paths = ["lib"]
20
+
21
+ s.add_development_dependency "bundler", ">= 1.0.0"
22
+ s.add_development_dependency "rspec", "~> 2.3"
23
+ s.add_development_dependency "sqlite3-ruby"
24
+ s.add_development_dependency "simplecov"
25
+ s.add_development_dependency "factory_girl"
26
+
27
+ s.add_dependency "activerecord", "~> 3.1"
28
+ end
data/spec/factories.rb ADDED
@@ -0,0 +1,53 @@
1
+ FactoryGirl.define do
2
+
3
+ factory :folder do
4
+ sequence(:title) { |n| "Folder #{n}" }
5
+
6
+ factory :folder_with_documents do
7
+ after_create do |folder|
8
+ folder.documents = FactoryGirl.create_list(:document, 5, :folder => folder)
9
+ end
10
+ end
11
+ end
12
+
13
+ factory :document do
14
+ folder
15
+ sequence(:title) { |n| "Document #{n}" }
16
+ end
17
+
18
+ factory :default_item do
19
+ sequence(:title) { |n| "Default Item #{n}" }
20
+ end
21
+
22
+ factory :starting_at_one_item do
23
+ sequence(:title) { |n| "Starting At One Item #{n}" }
24
+ end
25
+
26
+ factory :asc_item do
27
+ sequence(:title) { |n| "Asc Item #{n}" }
28
+ end
29
+
30
+ factory :desc_item do
31
+ sequence(:title) { |n| "Desc Item #{n}" }
32
+ end
33
+
34
+ factory :group do
35
+ sequence(:title) { |n| "Group #{n}" }
36
+
37
+ factory :group_with_complex_items do
38
+ after_create do |group|
39
+ group.complex_items = FactoryGirl.create_list(:complex_item, 5, :group => group)
40
+ end
41
+ end
42
+ end
43
+
44
+ factory :complex_item do
45
+ group
46
+ sequence(:title) { |n| "Complex Item #{n}" }
47
+ end
48
+
49
+ factory :stuff do
50
+ sequence(:title) { |n| "Stuff #{n}" }
51
+ end
52
+
53
+ end
@@ -0,0 +1,596 @@
1
+ require 'spec_helper'
2
+
3
+ describe Positionable do
4
+
5
+ before do
6
+ Document.delete_all
7
+ Folder.delete_all
8
+ Item.delete_all
9
+ Dummy.delete_all
10
+ end
11
+
12
+ context "ActiveRecord extension" do
13
+
14
+ it "does not extend non positionable models" do
15
+ dummy = Dummy.new
16
+ dummy.respond_to?(:previous).should be_false
17
+ dummy.respond_to?(:next).should be_false
18
+ end
19
+
20
+ it "extends positionable models" do
21
+ item = DefaultItem.new
22
+ item.respond_to?(:previous).should be_true
23
+ item.respond_to?(:next).should be_true
24
+ end
25
+
26
+ it "prepends the table name in SQL 'order by' clause" do
27
+ sql = DefaultItem.where("1 = 1").to_sql
28
+ table = DefaultItem.table_name
29
+ sql.should include("ORDER BY \"#{table}\".\"position\"")
30
+ end
31
+
32
+ end
33
+
34
+ context "ordering" do
35
+
36
+ it "orders records by their position by default" do
37
+ shuffle_positions = (0..9).to_a.shuffle
38
+ shuffle_positions.each do |position|
39
+ item = Factory.create(:default_item)
40
+ item.update_column(:position, position)
41
+ end
42
+ DefaultItem.all.should be_contiguous
43
+ end
44
+
45
+ end
46
+
47
+ context "contiguous positionning" do
48
+
49
+ let!(:items) { FactoryGirl.create_list(:default_item, 10) }
50
+ let(:middle) { items[items.size / 2] }
51
+
52
+ it "makes the position to start at zero by default" do
53
+ items.first.position.should == 0
54
+ end
55
+
56
+ it "increments position by one after creation" do
57
+ item = Factory.create(:default_item)
58
+ item.position.should == items.last.position + 1
59
+ end
60
+
61
+ it "does not exist a previous for the first record" do
62
+ items.first.previous.should be_nil
63
+ end
64
+
65
+ it "gives the previous record according to its position" do
66
+ items[1..(items.size - 1)].each_with_index do |item, index|
67
+ item.previous.should == items[index]
68
+ end
69
+ end
70
+
71
+ it "gives all the previous records according to their positions" do
72
+ middle.all_previous.size.should == middle.position
73
+ middle.all_previous.each_with_index do |previous, index|
74
+ previous.should == items[index]
75
+ end
76
+ end
77
+
78
+ it "does not exist a next for the last record" do
79
+ items.last.next.should be_nil
80
+ end
81
+
82
+ it "gives the next record according to its position" do
83
+ items[0..(items.size - 2)].each_with_index do |item, index|
84
+ item.next.should == items[index + 1]
85
+ end
86
+ end
87
+
88
+ it "gives all the next records according to their positions" do
89
+ middle.all_next.size.should == items.size - middle.position - 1
90
+ middle.all_next.each_with_index do |neXt, index|
91
+ neXt.should == items[middle.position + index + 1]
92
+ end
93
+ end
94
+
95
+ it "caracterizes the first record" do
96
+ items.first.first?.should be_true
97
+ items.but_first.each do |item|
98
+ item.first?.should be_false
99
+ end
100
+ end
101
+
102
+ it "caracterizes the last record" do
103
+ items.but_last.each do |item|
104
+ item.last?.should be_false
105
+ end
106
+ items.last.last?.should be_true
107
+ end
108
+
109
+ it "decrements positions of next sibblings after deletion" do
110
+ position = items.size / 2
111
+ middle.destroy
112
+ items.before(position).should be_contiguous
113
+ items.after(position).should be_contiguous.starting_at(position)
114
+ end
115
+
116
+ it "does not up the first record" do
117
+ item = items.first
118
+ item.position.should == 0 # Meta!
119
+ item.up!
120
+ item.position.should == 0
121
+ end
122
+
123
+ it "does not down the last record" do
124
+ item = items.last
125
+ item.position.should == items.size - 1 # Meta!
126
+ item.down!
127
+ item.position.should == items.size - 1
128
+ end
129
+
130
+ it "reorders the records positions after upping" do
131
+ position = middle.position
132
+ previous = middle.previous
133
+ neXt = middle.next
134
+ previous.position.should == position - 1 # Meta!
135
+ neXt.position.should == position + 1 # Meta!
136
+ middle.up!
137
+ previous.reload.position.should == position
138
+ middle.position.should == position - 1
139
+ neXt.reload.position.should == position + 1
140
+ end
141
+
142
+ it "reorders the records positions after downing" do
143
+ position = middle.position
144
+ previous = middle.previous
145
+ neXt = middle.next
146
+ previous.position.should == position - 1 # Meta!
147
+ neXt.position.should == position + 1 # Meta!
148
+ middle.down!
149
+ previous.reload.position.should == position - 1
150
+ middle.position.should == position + 1
151
+ neXt.reload.position.should == position
152
+ end
153
+
154
+ describe "moving" do
155
+
156
+ context "mass-assignement" do
157
+
158
+ it "reorders records when position is updated" do
159
+ old_position = middle.position
160
+ new_position = old_position + 3
161
+ middle.update_attributes({ :position => new_position })
162
+ (0..(old_position - 1)).each do |position|
163
+ items[position].reload.position.should == position
164
+ end
165
+ middle.position.should == new_position
166
+ ((old_position + 1)..new_position).each do |position|
167
+ items[position].reload.position.should == position - 1
168
+ end
169
+ ((new_position + 1)..(items.count - 1)).each do |position|
170
+ items[position].reload.position.should == position
171
+ end
172
+ end
173
+
174
+ it "does not reorder anything when position is updated but out of range" do
175
+ middle.update_attributes({ :position => items.count + 10 })
176
+ items.should be_contiguous
177
+ end
178
+
179
+ it "does not reorder anything when position is updated but before start" do
180
+ middle.update_attributes({ :position => -1 })
181
+ items.should be_contiguous
182
+ end
183
+
184
+ end
185
+
186
+ it "also moves the previous records when moving to a lower position" do
187
+ old_position = middle.position
188
+ new_position = old_position - 3
189
+ middle.move_to new_position
190
+ (0..(new_position - 1)).each do |position|
191
+ items[position].reload.position.should == position
192
+ end
193
+ middle.position.should == new_position
194
+ (new_position..(old_position - 1)).each do |position|
195
+ items[position].reload.position.should == position + 1
196
+ end
197
+ ((old_position + 1)..(items.count - 1)).each do |position|
198
+ items[position].reload.position.should == position
199
+ end
200
+ end
201
+
202
+ it "also moves the next records when moving to a higher position" do
203
+ old_position = middle.position
204
+ new_position = old_position + 3
205
+ middle.move_to new_position
206
+ (0..(old_position - 1)).each do |position|
207
+ items[position].reload.position.should == position
208
+ end
209
+ middle.position.should == new_position
210
+ ((old_position + 1)..new_position).each do |position|
211
+ items[position].reload.position.should == position - 1
212
+ end
213
+ ((new_position + 1)..(items.count - 1)).each do |position|
214
+ items[position].reload.position.should == position
215
+ end
216
+ end
217
+
218
+ it "does not move anything if new position is before start position" do
219
+ lambda {
220
+ middle.move_to -1
221
+ }.should_not change(middle, :position)
222
+ end
223
+
224
+ it "does not move anything if new position is out of range" do
225
+ lambda {
226
+ middle.move_to items.count + 10
227
+ }.should_not change(middle, :position)
228
+ end
229
+
230
+ end
231
+
232
+ end
233
+
234
+ describe "range" do
235
+
236
+ let!(:items) { FactoryGirl.create_list(:default_item, 10) }
237
+
238
+ it "gives the range position of a new record" do
239
+ item = Factory.build(:default_item)
240
+ item.range.should == (0..items.count)
241
+ end
242
+
243
+ it "gives the range position of an existing record" do
244
+ items.sample.range.should == (0..(items.count - 1))
245
+ end
246
+
247
+ end
248
+
249
+ context "scoping" do
250
+
251
+ let!(:folders) { FactoryGirl.create_list(:folder_with_documents, 5) }
252
+
253
+ it "orders records by their position by default" do
254
+ folders.each do |folder|
255
+ documents = folder.documents
256
+ shuffled_positions = (0..(documents.size - 1)).to_a.shuffle
257
+ documents.each_with_index do |document, index|
258
+ document.update_column(:position, shuffled_positions[index])
259
+ end
260
+ documents = folder.reload.documents
261
+ documents.should be_contiguous
262
+ end
263
+ end
264
+
265
+ it "makes the position to start at zero for each folder" do
266
+ folders.each do |folder|
267
+ folder.documents.first.position.should == 0
268
+ end
269
+ end
270
+
271
+ it "increments position by one after creation inside a folder" do
272
+ folders.each do |folder|
273
+ last_position = folder.documents.last.position
274
+ document = Factory.create(:document, :folder => folder)
275
+ document.position.should == last_position + 1
276
+ end
277
+ end
278
+
279
+ it "does not exist a previous for the first record of each folder" do
280
+ folders.each do |folder|
281
+ folder.documents.first.previous.should be_nil
282
+ end
283
+ end
284
+
285
+ it "gives the previous record of the folder according to its position" do
286
+ folders.each do |folder|
287
+ folder.documents.but_first.each_with_index do |document, index|
288
+ document.previous.should == folder.documents[index]
289
+ end
290
+ end
291
+ end
292
+
293
+ it "gives all the previous records of the folder according to their positions" do
294
+ folders.each do |folder|
295
+ documents = folder.documents
296
+ middle = documents[documents.size / 2]
297
+ middle.all_previous.size.should == middle.position
298
+ middle.all_previous.each_with_index do |previous, index|
299
+ previous.should == documents[index]
300
+ end
301
+ end
302
+ end
303
+
304
+ it "does not exist a next for the last record of the folder" do
305
+ folders.each do |folder|
306
+ folder.documents.last.next.should be_nil
307
+ end
308
+ end
309
+
310
+ it "gives the next record of the folder according to its position" do
311
+ folders.each do |folder|
312
+ documents = folder.documents
313
+ documents.but_last.each_with_index do |document, index|
314
+ document.next.should == documents[index + 1]
315
+ end
316
+ end
317
+ end
318
+
319
+ it "gives all the next records of the folder according to their positions" do
320
+ folders.each do |folder|
321
+ documents = folder.documents
322
+ middle = documents[documents.size / 2]
323
+ middle.all_next.count.should == documents.count - middle.position - 1
324
+ middle.all_next.each_with_index do |neXt, index|
325
+ neXt.should == documents[middle.position + index + 1]
326
+ end
327
+ end
328
+ end
329
+
330
+ it "caracterizes the first record of the folder" do
331
+ folders.each do |folder|
332
+ documents = folder.documents
333
+ documents.first.first?.should be_true
334
+ documents.but_first.each do |document|
335
+ document.first?.should be_false
336
+ end
337
+ end
338
+ end
339
+
340
+ it "caracterizes the last record of the folder" do
341
+ folders.each do |folder|
342
+ documents = folder.documents
343
+ documents.but_last.each do |document|
344
+ document.last?.should be_false
345
+ end
346
+ documents.last.last?.should be_true
347
+ end
348
+ end
349
+
350
+ it "decrements positions of next sibblings of the folder after deletion" do
351
+ folders.each do |folder|
352
+ documents = folder.documents
353
+ middle = documents.size / 2
354
+ documents[middle].destroy
355
+ documents.before(middle).should be_contiguous
356
+ documents.after(middle).should be_contiguous.starting_at(middle)
357
+ end
358
+ end
359
+
360
+ it "does not up the first record of the folder" do
361
+ folders.each do |folder|
362
+ document = folder.documents.first
363
+ document.position.should == 0 # Meta!
364
+ document.up!
365
+ document.position.should == 0
366
+ end
367
+ end
368
+
369
+ it "does not down the last record of the folder" do
370
+ folders.each do |folder|
371
+ document = folder.documents.last
372
+ document.position.should == folder.documents.size - 1 # Meta!
373
+ document.down!
374
+ document.position.should == folder.documents.size - 1
375
+ end
376
+ end
377
+
378
+ it "reorders the records positions after upping" do
379
+ folders.each do |folder|
380
+ documents = folder.documents
381
+ middle = documents[documents.size / 2]
382
+ position = middle.position
383
+ previous = middle.previous
384
+ neXt = middle.next
385
+ previous.position.should == position - 1 # Meta!
386
+ neXt.position.should == position + 1 # Meta!
387
+ middle.up!
388
+ previous.reload.position.should == position
389
+ middle.position.should == position - 1
390
+ neXt.reload.position.should == position + 1
391
+ end
392
+ end
393
+
394
+ it "reorders the records positions after downing" do
395
+ folders.each do |folder|
396
+ documents = folder.documents
397
+ middle = documents[documents.size / 2]
398
+ position = middle.position
399
+ previous = middle.previous
400
+ neXt = middle.next
401
+ previous.position.should == position - 1 # Meta!
402
+ neXt.position.should == position + 1 # Meta!
403
+ middle.down!
404
+ previous.reload.position.should == position - 1
405
+ middle.position.should == position + 1
406
+ neXt.reload.position.should == position
407
+ end
408
+ end
409
+
410
+ context "changing scope" do
411
+
412
+ let!(:old_folder) { folders.first }
413
+ # Last document is a special case when changing scope, so it is avoided
414
+ let!(:document) { old_folder.documents.but_last.sample }
415
+ # A new folder containing a different count of documents than the old folder
416
+ let!(:new_folder) { Factory.create(:folder) }
417
+ let!(:new_documents) { FactoryGirl.create_list(:document, old_folder.documents.count + 1, :folder => new_folder) }
418
+
419
+ it "moves to bottom position when scope has changed but position is out of range" do
420
+ document.update_attributes( {:folder_id => new_folder.id, :position => new_documents.count + 10 } )
421
+ document.position.should == new_folder.documents.count - 1
422
+ document.last?.should be_true
423
+ end
424
+
425
+ it "keeps position when scope has changed but position belongs to range" do
426
+ position = document.position
427
+ document.update_attributes( {:folder_id => new_folder.id} )
428
+ document.position.should == position # Position unchanged
429
+ new_folder.reload.documents.should be_contiguous
430
+ end
431
+
432
+ it "reorders records of previous scope" do
433
+ document.update_attributes( {:folder_id => new_folder.id} )
434
+ old_folder.reload.documents.should be_contiguous
435
+ end
436
+
437
+ end
438
+
439
+ describe "range" do
440
+
441
+ context "new record" do
442
+
443
+ it "gives a range only if the scope is specified" do
444
+ lambda {
445
+ Document.new.range
446
+ }.should raise_error(Positionable::RangeWithoutScopeError)
447
+ end
448
+
449
+ it "gives the range within a scope" do
450
+ folder = Factory.create(:folder_with_documents)
451
+ document = Document.new
452
+ document.range(folder).should == (0..(folder.documents.count + 1))
453
+ end
454
+
455
+ it "gives the range within its own scope by default" do
456
+ document = Factory.build(:document)
457
+ folder = document.folder
458
+ document.range.should == (0..(folder.documents.count + 1))
459
+ end
460
+
461
+ it "gives the range within another scope" do
462
+ document = Factory.build(:document)
463
+ folder = Factory.create(:folder_with_documents)
464
+ document.folder.should_not == folder # Meta!
465
+ document.range(folder).should == (0..(folder.documents.count + 1))
466
+ end
467
+
468
+ end
469
+
470
+ context "existing record" do
471
+
472
+ it "gives the range within its own scope" do
473
+ folder = Factory.create(:folder_with_documents)
474
+ document = folder.documents.sample
475
+ document.range(folder).should == (0..folder.documents.count)
476
+ end
477
+
478
+ it "gives the range within another scope" do
479
+ document = Factory.create(:document)
480
+ folder = Factory.create(:folder_with_documents)
481
+ document.folder.should_not == folder # Meta!
482
+ document.range(folder).should == (0..(folder.documents.count + 1))
483
+ end
484
+
485
+ end
486
+
487
+ end
488
+
489
+ end
490
+
491
+ context "start position" do
492
+
493
+ let(:start) { 1 }
494
+
495
+ it "starts at the given position" do
496
+ item = Factory.create(:starting_at_one_item)
497
+ item.position.should == start
498
+ end
499
+
500
+ it "increments by one the given start position" do
501
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
502
+ item = Factory.create(:starting_at_one_item)
503
+ item.position.should == items.size + start
504
+ end
505
+
506
+ it "caracterizes the first record according the start position" do
507
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
508
+ items.first.first?.should be_true
509
+ items.but_first.each do |item|
510
+ item.first?.should be_false
511
+ end
512
+ end
513
+
514
+ it "caracterizes the last record according the start position" do
515
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
516
+ items.but_last.each do |item|
517
+ item.last?.should be_false
518
+ end
519
+ items.last.last?.should be_true
520
+ end
521
+
522
+ describe "moving" do
523
+
524
+ it "does not move anything if new position is before start position" do
525
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
526
+ item = items.sample
527
+ lambda {
528
+ item.move_to start - 1
529
+ }.should_not change(item, :position)
530
+ end
531
+
532
+ end
533
+
534
+ describe "range" do
535
+
536
+ it "staggers range with start position" do
537
+ items = FactoryGirl.create_list(:starting_at_one_item, 5)
538
+ items.sample.range.should == (start..(items.count + start - 1))
539
+ end
540
+
541
+ end
542
+
543
+ end
544
+
545
+ context "insertion order" do
546
+
547
+ describe "asc" do
548
+
549
+ it "appends at the last position" do
550
+ items = FactoryGirl.create_list(:asc_item, 5)
551
+ item = Factory.create(:asc_item)
552
+ item.position.should == items.size
553
+ end
554
+
555
+ it "orders items by ascending position" do
556
+ FactoryGirl.create_list(:asc_item, 5)
557
+ AscItem.all.each_with_index do |item, index|
558
+ item.position.should == index
559
+ end
560
+ end
561
+
562
+ end
563
+
564
+ describe "desc" do
565
+
566
+ it "appends at the last position" do
567
+ items = FactoryGirl.create_list(:desc_item, 5)
568
+ item = Factory.create(:desc_item)
569
+ item.position.should == items.size
570
+ end
571
+
572
+ it "orders items by descending position" do
573
+ items = FactoryGirl.create_list(:desc_item, 5)
574
+ DescItem.all.reverse.should be_contiguous
575
+ end
576
+
577
+ end
578
+
579
+ end
580
+
581
+ context "mixing options" do
582
+
583
+ let!(:groups) { FactoryGirl.create_list(:group_with_complex_items, 5) }
584
+ let(:start) { 1 } # Check configuration in support/models.rb
585
+
586
+ it "manages complex items" do
587
+ # All options are tested here (grouping, descending ordering and start position at 1)
588
+ groups.each do |group|
589
+ size = group.complex_items.size
590
+ group.complex_items.reverse.should be_contiguous.starting_at(start)
591
+ end
592
+ end
593
+
594
+ end
595
+
596
+ end