effective_bootstrap 0.9.9 → 0.9.10

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2db71ddfb367e9c0aba19eb1b05cd145a4cef6743bc63151ca7eb9c118c313c9
4
- data.tar.gz: 823eea41c096624761741d233d39db5431824f2a37e43f2e0797c0bc91c8cf54
3
+ metadata.gz: 5092faff38f399976a16a668ef167ca4b9f2103db81cb03617a5165a05731c6b
4
+ data.tar.gz: 3168ab3c1c38bff6e1f6020a0aea2dadc5e6f2917ffef79827054415cb6b1198
5
5
  SHA512:
6
- metadata.gz: 18716b0514719e0d48b7cfe84efb5169bca2be5fbbb79c585ac5d7e86d570e795cb1228a498cf6705c7b559bcd717f6f8868070cba757d720b7b3c93b295c5bd
7
- data.tar.gz: d493815d79bfed154f0b982689ae3e4713c283f83f75ac5a21ef4be93184ccc3e2dd7bb42f221097d923ad66bebf87fe24b582151b679acd2bba25a84b7d5423
6
+ metadata.gz: ff0e00b34d8101166e8eb0d895634e2e5099366953232fa4063c20dd504bfe10641738fb5ed90c99599de0c9f3f3d6a3d029d1cc35548ef8eab0fdb7fcbb27c6
7
+ data.tar.gz: e24803d6584be1ff0a1aea39a6790528186c5189974a1a69af5ef95a295665b932e74af3734ec1735a2a82e5ee75ed9d5dbf98af4e6352f71971d250590a68fa
data/README.md CHANGED
@@ -42,8 +42,8 @@ Add the following to your `application.js`:
42
42
  And to your `application.scss`:
43
43
 
44
44
  ```sass
45
- @import 'bootstrap';
46
- @import 'effective_bootstrap';
45
+ @import 'bootstrap'
46
+ @import 'effective_bootstrap'
47
47
  ```
48
48
 
49
49
  ## View Helpers
@@ -362,6 +362,48 @@ And then in any form, instead of a text area:
362
362
  = f.editor :body
363
363
  ```
364
364
 
365
+ ## Custom has_many
366
+
367
+ This custom form input was inspired by [cocoon](https://github.com/nathanvda/cocoon) but works with more magic.
368
+
369
+ This nested form builder allows has_many resources to be created, updated, destroyed and reordered.
370
+
371
+ Just add `has_many` and `accepts_nested_attributes_for` like normal and then use it in the form:
372
+
373
+ ```ruby
374
+ class Author < ApplicationRecord
375
+ has_many :books
376
+ accepts_nested_attributes_for :books, allow_destroy: true
377
+ end
378
+ ```
379
+
380
+ and
381
+
382
+ ```haml
383
+ = effective_form_with(model: author) do |f|
384
+ = f.text_field :name
385
+
386
+ = f.has_many :books do |fb|
387
+ = fb.text_field :title
388
+ = fb.date_field :published_at
389
+ ```
390
+
391
+ If `Book` has an integer `position` field, there will be reorder buttons to drag & drop reorder the items.
392
+
393
+ If `allow_destroy: true` there will be remove buttons.
394
+
395
+ You can customize the has many behaviour by passing the following:
396
+
397
+ ```haml
398
+ = f.has_many :books, add: true, remove: true, reorder: true
399
+ ```
400
+
401
+ or add an html class:
402
+
403
+ ```haml
404
+ = f.has_many :books, class: 'tight'
405
+ ```
406
+
365
407
  ## Custom percent_field
366
408
 
367
409
  This custom form input uses no 3rd party jQuery plugins.
@@ -15,6 +15,7 @@
15
15
  //= require ./effective_ck_editor/input
16
16
 
17
17
  //= require ./effective_file/input
18
+ //= require ./effective_has_many/input
18
19
  //= require ./effective_integer/input
19
20
  //= require ./effective_number_text/input
20
21
  //= require ./effective_percent/input
@@ -0,0 +1,74 @@
1
+ assignPositions = (target) ->
2
+ $hasMany = $(target)
3
+ return unless $hasMany.length > 0
4
+
5
+ $fields = $hasMany.children('.has-many-fields:not(.marked-for-destruction)')
6
+ positions = $fields.find("input[name$='[position]']").map(-> this.value).get()
7
+
8
+ if positions.length > 0
9
+ index = Math.min.apply(Math, positions) || 0
10
+
11
+ $fields.each((i, obj) ->
12
+ $(obj).find("input[name$='[position]']").first().val(index)
13
+ index = index + 1
14
+ )
15
+
16
+ true
17
+
18
+ (this.EffectiveBootstrap || {}).effective_has_many = ($element, options) ->
19
+ if options.sortable
20
+ $element.sortable(
21
+ containerSelector: '.form-has-many',
22
+ itemSelector: '.has-many-fields',
23
+ handle: '.has-many-move'
24
+ placeholder: "<div class='has-many-placeholder' />",
25
+ onDrop: ($item, container, _super) =>
26
+ assignPositions(container.target)
27
+ _super($item, container)
28
+ )
29
+
30
+ $(document).on 'click', '[data-effective-form-has-many-add]', (event) ->
31
+ event.preventDefault()
32
+
33
+ $obj = $(event.currentTarget)
34
+ $hasMany = $obj.closest('.form-has-many')
35
+ return unless $hasMany.length > 0
36
+
37
+ uid = (new Date).valueOf()
38
+ template = $obj.data('effective-form-has-many-template').replace(/HASMANYINDEX/g, uid)
39
+
40
+ $fields = $(template).hide().fadeIn('slow')
41
+ EffectiveBootstrap.initialize($fields)
42
+ $obj.closest('.has-many-links').before($fields)
43
+
44
+ assignPositions($hasMany)
45
+ true
46
+
47
+ $(document).on 'click', '[data-effective-form-has-many-remove]', (event) ->
48
+ event.preventDefault()
49
+
50
+ $obj = $(event.currentTarget)
51
+ $hasMany = $obj.closest('.form-has-many')
52
+ return unless $hasMany.length > 0
53
+
54
+ $input = $obj.siblings("input[name$='[_destroy]']").first()
55
+ $fields = $obj.closest('.has-many-fields').first()
56
+
57
+ if $input.length > 0
58
+ $input.val('true')
59
+ $fields.addClass('marked-for-destruction').fadeOut('slow')
60
+ else
61
+ $fields.fadeOut('slow', -> this.remove())
62
+
63
+ assignPositions($hasMany)
64
+ true
65
+
66
+ $(document).on 'click', '[data-effective-form-has-many-reorder]', (event) ->
67
+ event.preventDefault()
68
+
69
+ $obj = $(event.currentTarget)
70
+ $hasMany = $obj.closest('.form-has-many')
71
+ return unless $hasMany.length > 0
72
+
73
+ $hasMany.toggleClass('reordering')
74
+ true
@@ -0,0 +1,2 @@
1
+ //= require ./jquery.sortable
2
+ //= require ./initialize
@@ -0,0 +1,696 @@
1
+ /* ===================================================
2
+ * jquery-sortable.js v0.9.13
3
+ * http://johnny.github.com/jquery-sortable/
4
+ * ===================================================
5
+ * Copyright (c) 2012 Jonas von Andrian
6
+ * All rights reserved.
7
+ *
8
+ * Redistribution and use in source and binary forms, with or without
9
+ * modification, are permitted provided that the following conditions are met:
10
+ * * Redistributions of source code must retain the above copyright
11
+ * notice, this list of conditions and the following disclaimer.
12
+ * * Redistributions in binary form must reproduce the above copyright
13
+ * notice, this list of conditions and the following disclaimer in the
14
+ * documentation and/or other materials provided with the distribution.
15
+ * * The name of the author may not be used to endorse or promote products
16
+ * derived from this software without specific prior written permission.
17
+ *
18
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ * DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> BE LIABLE FOR ANY
22
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
+ * ========================================================== */
29
+
30
+
31
+ !function ( $, window, pluginName, undefined){
32
+ var containerDefaults = {
33
+ // If true, items can be dragged from this container
34
+ drag: true,
35
+ // If true, items can be dropped onto this container
36
+ drop: true,
37
+ // Exclude items from being draggable, if the
38
+ // selector matches the item
39
+ exclude: "",
40
+ // If true, search for nested containers within an item.If you nest containers,
41
+ // either the original selector with which you call the plugin must only match the top containers,
42
+ // or you need to specify a group (see the bootstrap nav example)
43
+ nested: true,
44
+ // If true, the items are assumed to be arranged vertically
45
+ vertical: true
46
+ }, // end container defaults
47
+ groupDefaults = {
48
+ // This is executed after the placeholder has been moved.
49
+ // $closestItemOrContainer contains the closest item, the placeholder
50
+ // has been put at or the closest empty Container, the placeholder has
51
+ // been appended to.
52
+ afterMove: function ($placeholder, container, $closestItemOrContainer) {
53
+ },
54
+ // The exact css path between the container and its items, e.g. "> tbody"
55
+ containerPath: "",
56
+ // The css selector of the containers
57
+ containerSelector: "ol, ul",
58
+ // Distance the mouse has to travel to start dragging
59
+ distance: 0,
60
+ // Time in milliseconds after mousedown until dragging should start.
61
+ // This option can be used to prevent unwanted drags when clicking on an element.
62
+ delay: 0,
63
+ // The css selector of the drag handle
64
+ handle: "",
65
+ // The exact css path between the item and its subcontainers.
66
+ // It should only match the immediate items of a container.
67
+ // No item of a subcontainer should be matched. E.g. for ol>div>li the itemPath is "> div"
68
+ itemPath: "",
69
+ // The css selector of the items
70
+ itemSelector: "li",
71
+ // The class given to "body" while an item is being dragged
72
+ bodyClass: "dragging",
73
+ // The class giving to an item while being dragged
74
+ draggedClass: "dragged",
75
+ // Check if the dragged item may be inside the container.
76
+ // Use with care, since the search for a valid container entails a depth first search
77
+ // and may be quite expensive.
78
+ isValidTarget: function ($item, container) {
79
+ return true
80
+ },
81
+ // Executed before onDrop if placeholder is detached.
82
+ // This happens if pullPlaceholder is set to false and the drop occurs outside a container.
83
+ onCancel: function ($item, container, _super, event) {
84
+ },
85
+ // Executed at the beginning of a mouse move event.
86
+ // The Placeholder has not been moved yet.
87
+ onDrag: function ($item, position, _super, event) {
88
+ $item.css(position)
89
+ },
90
+ // Called after the drag has been started,
91
+ // that is the mouse button is being held down and
92
+ // the mouse is moving.
93
+ // The container is the closest initialized container.
94
+ // Therefore it might not be the container, that actually contains the item.
95
+ onDragStart: function ($item, container, _super, event) {
96
+ $item.css({
97
+ height: $item.outerHeight(),
98
+ width: $item.outerWidth()
99
+ })
100
+ $item.addClass(container.group.options.draggedClass)
101
+ $("body").addClass(container.group.options.bodyClass)
102
+ },
103
+ // Called when the mouse button is being released
104
+ onDrop: function ($item, container, _super, event) {
105
+ $item.removeClass(container.group.options.draggedClass).removeAttr("style")
106
+ $("body").removeClass(container.group.options.bodyClass)
107
+ // START MONKEY PATCH (for submitting form when heading positions are changed on touchscreen)
108
+ $item.trigger('movimento:drop:complete', $item)
109
+ // END MONKEY PATCH
110
+ },
111
+ // Called on mousedown. If falsy value is returned, the dragging will not start.
112
+ // Ignore if element clicked is input, select or textarea
113
+ onMousedown: function ($item, _super, event) {
114
+ if (!event.target.nodeName.match(/^(input|select|textarea)$/i)) {
115
+ event.preventDefault()
116
+ return true
117
+ }
118
+ },
119
+ // The class of the placeholder (must match placeholder option markup)
120
+ placeholderClass: "placeholder",
121
+ // Template for the placeholder. Can be any valid jQuery input
122
+ // e.g. a string, a DOM element.
123
+ // The placeholder must have the class "placeholder"
124
+ placeholder: '<li class="placeholder"></li>',
125
+ // If true, the position of the placeholder is calculated on every mousemove.
126
+ // If false, it is only calculated when the mouse is above a container.
127
+ pullPlaceholder: true,
128
+ // Specifies serialization of the container group.
129
+ // The pair $parent/$children is either container/items or item/subcontainers.
130
+ serialize: function ($parent, $children, parentIsContainer) {
131
+ var result = $.extend({}, $parent.data())
132
+
133
+ if(parentIsContainer)
134
+ return [$children]
135
+ else if ($children[0]){
136
+ result.children = $children
137
+ }
138
+
139
+ delete result.subContainers
140
+ delete result.sortable
141
+
142
+ return result
143
+ },
144
+ // Set tolerance while dragging. Positive values decrease sensitivity,
145
+ // negative values increase it.
146
+ tolerance: 0
147
+ }, // end group defaults
148
+ containerGroups = {},
149
+ groupCounter = 0,
150
+ emptyBox = {
151
+ left: 0,
152
+ top: 0,
153
+ bottom: 0,
154
+ right:0
155
+ },
156
+ eventNames = {
157
+ start: "touchstart.sortable mousedown.sortable",
158
+ drop: "touchend.sortable touchcancel.sortable mouseup.sortable",
159
+ drag: "touchmove.sortable mousemove.sortable",
160
+ scroll: "scroll.sortable"
161
+ },
162
+ subContainerKey = "subContainers"
163
+
164
+ /*
165
+ * a is Array [left, right, top, bottom]
166
+ * b is array [left, top]
167
+ */
168
+ function d(a,b) {
169
+ var x = Math.max(0, a[0] - b[0], b[0] - a[1]),
170
+ y = Math.max(0, a[2] - b[1], b[1] - a[3])
171
+ return x+y;
172
+ }
173
+
174
+ function setDimensions(array, dimensions, tolerance, useOffset) {
175
+ var i = array.length,
176
+ offsetMethod = useOffset ? "offset" : "position"
177
+ tolerance = tolerance || 0
178
+
179
+ while(i--){
180
+ var el = array[i].el ? array[i].el : $(array[i]),
181
+ // use fitting method
182
+ pos = el[offsetMethod]()
183
+ pos.left += parseInt(el.css('margin-left'), 10)
184
+ pos.top += parseInt(el.css('margin-top'),10)
185
+ dimensions[i] = [
186
+ pos.left - tolerance,
187
+ pos.left + el.outerWidth() + tolerance,
188
+ pos.top - tolerance,
189
+ pos.top + el.outerHeight() + tolerance
190
+ ]
191
+ }
192
+ }
193
+
194
+ function getRelativePosition(pointer, element) {
195
+ var offset = element.offset()
196
+ return {
197
+ left: pointer.left - offset.left,
198
+ top: pointer.top - offset.top
199
+ }
200
+ }
201
+
202
+ function sortByDistanceDesc(dimensions, pointer, lastPointer) {
203
+ pointer = [pointer.left, pointer.top]
204
+ lastPointer = lastPointer && [lastPointer.left, lastPointer.top]
205
+
206
+ var dim,
207
+ i = dimensions.length,
208
+ distances = []
209
+
210
+ while(i--){
211
+ dim = dimensions[i]
212
+ distances[i] = [i,d(dim,pointer), lastPointer && d(dim, lastPointer)]
213
+ }
214
+ distances = distances.sort(function (a,b) {
215
+ return b[1] - a[1] || b[2] - a[2] || b[0] - a[0]
216
+ })
217
+
218
+ // last entry is the closest
219
+ return distances
220
+ }
221
+
222
+ function ContainerGroup(options) {
223
+ this.options = $.extend({}, groupDefaults, options)
224
+ this.containers = []
225
+
226
+ if(!this.options.rootGroup){
227
+ this.scrollProxy = $.proxy(this.scroll, this)
228
+ this.dragProxy = $.proxy(this.drag, this)
229
+ this.dropProxy = $.proxy(this.drop, this)
230
+ this.placeholder = $(this.options.placeholder)
231
+
232
+ if(!options.isValidTarget)
233
+ this.options.isValidTarget = undefined
234
+ }
235
+ }
236
+
237
+ ContainerGroup.get = function (options) {
238
+ if(!containerGroups[options.group]) {
239
+ if(options.group === undefined)
240
+ options.group = groupCounter ++
241
+
242
+ containerGroups[options.group] = new ContainerGroup(options)
243
+ }
244
+
245
+ return containerGroups[options.group]
246
+ }
247
+
248
+ ContainerGroup.prototype = {
249
+ dragInit: function (e, itemContainer) {
250
+ this.$document = $(itemContainer.el[0].ownerDocument)
251
+
252
+ // get item to drag
253
+ var closestItem = $(e.target).closest(this.options.itemSelector);
254
+ // using the length of this item, prevents the plugin from being started if there is no handle being clicked on.
255
+ // this may also be helpful in instantiating multidrag.
256
+ if (closestItem.length) {
257
+ this.item = closestItem;
258
+ this.itemContainer = itemContainer;
259
+ if (this.item.is(this.options.exclude) || !this.options.onMousedown(this.item, groupDefaults.onMousedown, e)) {
260
+ return;
261
+ }
262
+ this.setPointer(e);
263
+ this.toggleListeners('on');
264
+ this.setupDelayTimer();
265
+ this.dragInitDone = true;
266
+ }
267
+ },
268
+ drag: function (e) {
269
+ if(!this.dragging){
270
+ if(!this.distanceMet(e) || !this.delayMet)
271
+ return
272
+
273
+ this.options.onDragStart(this.item, this.itemContainer, groupDefaults.onDragStart, e)
274
+ this.item.before(this.placeholder)
275
+ this.dragging = true
276
+ }
277
+
278
+ this.setPointer(e)
279
+ // place item under the cursor
280
+ this.options.onDrag(this.item,
281
+ getRelativePosition(this.pointer, this.item.offsetParent()),
282
+ groupDefaults.onDrag,
283
+ e)
284
+
285
+ var p = this.getPointer(e),
286
+ box = this.sameResultBox,
287
+ t = this.options.tolerance
288
+
289
+ if(!box || box.top - t > p.top || box.bottom + t < p.top || box.left - t > p.left || box.right + t < p.left)
290
+ if(!this.searchValidTarget()){
291
+ this.placeholder.detach()
292
+ this.lastAppendedItem = undefined
293
+ }
294
+ },
295
+ drop: function (e) {
296
+ this.toggleListeners('off')
297
+
298
+ this.dragInitDone = false
299
+
300
+ if(this.dragging){
301
+ // processing Drop, check if placeholder is detached
302
+ if(this.placeholder.closest("html")[0]){
303
+ this.placeholder.before(this.item).detach()
304
+ } else {
305
+ this.options.onCancel(this.item, this.itemContainer, groupDefaults.onCancel, e)
306
+ }
307
+ this.options.onDrop(this.item, this.getContainer(this.item), groupDefaults.onDrop, e)
308
+
309
+ // cleanup
310
+ this.clearDimensions()
311
+ this.clearOffsetParent()
312
+ this.lastAppendedItem = this.sameResultBox = undefined
313
+ this.dragging = false
314
+ }
315
+ },
316
+ searchValidTarget: function (pointer, lastPointer) {
317
+ if(!pointer){
318
+ pointer = this.relativePointer || this.pointer
319
+ lastPointer = this.lastRelativePointer || this.lastPointer
320
+ }
321
+
322
+ var distances = sortByDistanceDesc(this.getContainerDimensions(),
323
+ pointer,
324
+ lastPointer),
325
+ i = distances.length
326
+
327
+ while(i--){
328
+ var index = distances[i][0],
329
+ distance = distances[i][1]
330
+
331
+ if(!distance || this.options.pullPlaceholder){
332
+ var container = this.containers[index]
333
+ if(!container.disabled){
334
+ if(!this.$getOffsetParent()){
335
+ var offsetParent = container.getItemOffsetParent()
336
+ pointer = getRelativePosition(pointer, offsetParent)
337
+ lastPointer = getRelativePosition(lastPointer, offsetParent)
338
+ }
339
+ if(container.searchValidTarget(pointer, lastPointer))
340
+ return true
341
+ }
342
+ }
343
+ }
344
+ if(this.sameResultBox)
345
+ this.sameResultBox = undefined
346
+ },
347
+ movePlaceholder: function (container, item, method, sameResultBox) {
348
+ var lastAppendedItem = this.lastAppendedItem
349
+ if(!sameResultBox && lastAppendedItem && lastAppendedItem[0] === item[0])
350
+ return;
351
+
352
+ item[method](this.placeholder)
353
+ this.lastAppendedItem = item
354
+ this.sameResultBox = sameResultBox
355
+ this.options.afterMove(this.placeholder, container, item)
356
+ },
357
+ getContainerDimensions: function () {
358
+ if(!this.containerDimensions)
359
+ setDimensions(this.containers, this.containerDimensions = [], this.options.tolerance, !this.$getOffsetParent())
360
+ return this.containerDimensions
361
+ },
362
+ getContainer: function (element) {
363
+ return element.closest(this.options.containerSelector).data(pluginName)
364
+ },
365
+ $getOffsetParent: function () {
366
+ if(this.offsetParent === undefined){
367
+ var i = this.containers.length - 1,
368
+ offsetParent = this.containers[i].getItemOffsetParent()
369
+
370
+ if(!this.options.rootGroup){
371
+ while(i--){
372
+ if(offsetParent[0] != this.containers[i].getItemOffsetParent()[0]){
373
+ // If every container has the same offset parent,
374
+ // use position() which is relative to this parent,
375
+ // otherwise use offset()
376
+ // compare #setDimensions
377
+ offsetParent = false
378
+ break;
379
+ }
380
+ }
381
+ }
382
+
383
+ this.offsetParent = offsetParent
384
+ }
385
+ return this.offsetParent
386
+ },
387
+ setPointer: function (e) {
388
+ var pointer = this.getPointer(e)
389
+
390
+ if(this.$getOffsetParent()){
391
+ var relativePointer = getRelativePosition(pointer, this.$getOffsetParent())
392
+ this.lastRelativePointer = this.relativePointer
393
+ this.relativePointer = relativePointer
394
+ }
395
+
396
+ this.lastPointer = this.pointer
397
+ this.pointer = pointer
398
+ },
399
+ distanceMet: function (e) {
400
+ var currentPointer = this.getPointer(e)
401
+ return (Math.max(
402
+ Math.abs(this.pointer.left - currentPointer.left),
403
+ Math.abs(this.pointer.top - currentPointer.top)
404
+ ) >= this.options.distance)
405
+ },
406
+ getPointer: function(e) {
407
+ var o = e.originalEvent || e.originalEvent.touches && e.originalEvent.touches[0]
408
+ return {
409
+ left: e.pageX || o.pageX,
410
+ top: e.pageY || o.pageY
411
+ }
412
+ },
413
+ setupDelayTimer: function () {
414
+ var that = this
415
+ this.delayMet = !this.options.delay
416
+
417
+ // init delay timer if needed
418
+ if (!this.delayMet) {
419
+ clearTimeout(this._mouseDelayTimer);
420
+ this._mouseDelayTimer = setTimeout(function() {
421
+ that.delayMet = true
422
+ }, this.options.delay)
423
+ }
424
+ },
425
+ scroll: function (e) {
426
+ this.clearDimensions()
427
+ this.clearOffsetParent() // TODO is this needed?
428
+ },
429
+ toggleListeners: function (method) {
430
+ var that = this,
431
+ events = ['drag','drop','scroll']
432
+
433
+ $.each(events,function (i,event) {
434
+ that.$document[method](eventNames[event], that[event + 'Proxy'])
435
+ })
436
+ },
437
+ clearOffsetParent: function () {
438
+ this.offsetParent = undefined
439
+ },
440
+ // Recursively clear container and item dimensions
441
+ clearDimensions: function () {
442
+ this.traverse(function(object){
443
+ object._clearDimensions()
444
+ })
445
+ },
446
+ traverse: function(callback) {
447
+ callback(this)
448
+ var i = this.containers.length
449
+ while(i--){
450
+ this.containers[i].traverse(callback)
451
+ }
452
+ },
453
+ _clearDimensions: function(){
454
+ this.containerDimensions = undefined
455
+ },
456
+ _destroy: function () {
457
+ containerGroups[this.options.group] = undefined
458
+ }
459
+ }
460
+
461
+ function Container(element, options) {
462
+ this.el = element
463
+ this.options = $.extend( {}, containerDefaults, options)
464
+
465
+ this.group = ContainerGroup.get(this.options)
466
+ this.rootGroup = this.options.rootGroup || this.group
467
+ this.handle = this.rootGroup.options.handle || this.rootGroup.options.itemSelector
468
+
469
+ var itemPath = this.rootGroup.options.itemPath
470
+ this.target = itemPath ? this.el.find(itemPath) : this.el
471
+
472
+ this.target.on(eventNames.start, this.handle, $.proxy(this.dragInit, this))
473
+
474
+ if(this.options.drop)
475
+ this.group.containers.push(this)
476
+ }
477
+
478
+ Container.prototype = {
479
+ dragInit: function (e) {
480
+ var rootGroup = this.rootGroup
481
+
482
+ if( !this.disabled &&
483
+ !rootGroup.dragInitDone &&
484
+ this.options.drag &&
485
+ this.isValidDrag(e)) {
486
+ rootGroup.dragInit(e, this)
487
+ }
488
+ },
489
+ isValidDrag: function(e) {
490
+ return e.which == 1 ||
491
+ e.type == "touchstart" && e.originalEvent.touches.length == 1
492
+ },
493
+ searchValidTarget: function (pointer, lastPointer) {
494
+ var distances = sortByDistanceDesc(this.getItemDimensions(),
495
+ pointer,
496
+ lastPointer),
497
+ i = distances.length,
498
+ rootGroup = this.rootGroup,
499
+ validTarget = !rootGroup.options.isValidTarget ||
500
+ rootGroup.options.isValidTarget(rootGroup.item, this)
501
+
502
+ if(!i && validTarget){
503
+ rootGroup.movePlaceholder(this, this.target, "append")
504
+ return true
505
+ } else
506
+ while(i--){
507
+ var index = distances[i][0],
508
+ distance = distances[i][1]
509
+ if(!distance && this.hasChildGroup(index)){
510
+ var found = this.getContainerGroup(index).searchValidTarget(pointer, lastPointer)
511
+ if(found)
512
+ return true
513
+ }
514
+ else if(validTarget){
515
+ this.movePlaceholder(index, pointer)
516
+ return true
517
+ }
518
+ }
519
+ },
520
+ movePlaceholder: function (index, pointer) {
521
+ var item = $(this.items[index]),
522
+ dim = this.itemDimensions[index],
523
+ method = "after",
524
+ width = item.outerWidth(),
525
+ height = item.outerHeight(),
526
+ offset = item.offset(),
527
+ sameResultBox = {
528
+ left: offset.left,
529
+ right: offset.left + width,
530
+ top: offset.top,
531
+ bottom: offset.top + height
532
+ }
533
+ if(this.options.vertical){
534
+ var yCenter = (dim[2] + dim[3]) / 2,
535
+ inUpperHalf = pointer.top <= yCenter
536
+ if(inUpperHalf){
537
+ method = "before"
538
+ sameResultBox.bottom -= height / 2
539
+ } else
540
+ sameResultBox.top += height / 2
541
+ } else {
542
+ var xCenter = (dim[0] + dim[1]) / 2,
543
+ inLeftHalf = pointer.left <= xCenter
544
+ if(inLeftHalf){
545
+ method = "before"
546
+ sameResultBox.right -= width / 2
547
+ } else
548
+ sameResultBox.left += width / 2
549
+ }
550
+ if(this.hasChildGroup(index))
551
+ sameResultBox = emptyBox
552
+ this.rootGroup.movePlaceholder(this, item, method, sameResultBox)
553
+ },
554
+ getItemDimensions: function () {
555
+ if(!this.itemDimensions){
556
+ this.items = this.$getChildren(this.el, "item").filter(
557
+ ":not(." + this.group.options.placeholderClass + ", ." + this.group.options.draggedClass + ")"
558
+ ).get()
559
+ setDimensions(this.items, this.itemDimensions = [], this.options.tolerance)
560
+ }
561
+ return this.itemDimensions
562
+ },
563
+ getItemOffsetParent: function () {
564
+ var offsetParent,
565
+ el = this.el
566
+ // Since el might be empty we have to check el itself and
567
+ // can not do something like el.children().first().offsetParent()
568
+ if(el.css("position") === "relative" || el.css("position") === "absolute" || el.css("position") === "fixed")
569
+ offsetParent = el
570
+ else
571
+ offsetParent = el.offsetParent()
572
+ return offsetParent
573
+ },
574
+ hasChildGroup: function (index) {
575
+ return this.options.nested && this.getContainerGroup(index)
576
+ },
577
+ getContainerGroup: function (index) {
578
+ var childGroup = $.data(this.items[index], subContainerKey)
579
+ if( childGroup === undefined){
580
+ var childContainers = this.$getChildren(this.items[index], "container")
581
+ childGroup = false
582
+
583
+ if(childContainers[0]){
584
+ var options = $.extend({}, this.options, {
585
+ rootGroup: this.rootGroup,
586
+ group: groupCounter ++
587
+ })
588
+ childGroup = childContainers[pluginName](options).data(pluginName).group
589
+ }
590
+ $.data(this.items[index], subContainerKey, childGroup)
591
+ }
592
+ return childGroup
593
+ },
594
+ $getChildren: function (parent, type) {
595
+ var options = this.rootGroup.options,
596
+ path = options[type + "Path"],
597
+ selector = options[type + "Selector"]
598
+
599
+ parent = $(parent)
600
+ if(path)
601
+ parent = parent.find(path)
602
+
603
+ return parent.children(selector)
604
+ },
605
+ _serialize: function (parent, isContainer) {
606
+ var that = this,
607
+ childType = isContainer ? "item" : "container",
608
+
609
+ children = this.$getChildren(parent, childType).not(this.options.exclude).map(function () {
610
+ return that._serialize($(this), !isContainer)
611
+ }).get()
612
+
613
+ return this.rootGroup.options.serialize(parent, children, isContainer)
614
+ },
615
+ traverse: function(callback) {
616
+ $.each(this.items || [], function(item){
617
+ var group = $.data(this, subContainerKey)
618
+ if(group)
619
+ group.traverse(callback)
620
+ });
621
+
622
+ callback(this)
623
+ },
624
+ _clearDimensions: function () {
625
+ this.itemDimensions = undefined
626
+ },
627
+ _destroy: function() {
628
+ var that = this;
629
+
630
+ this.target.off(eventNames.start, this.handle);
631
+ this.el.removeData(pluginName)
632
+
633
+ if(this.options.drop)
634
+ this.group.containers = $.grep(this.group.containers, function(val){
635
+ return val != that
636
+ })
637
+
638
+ $.each(this.items || [], function(){
639
+ $.removeData(this, subContainerKey)
640
+ })
641
+ }
642
+ }
643
+
644
+ var API = {
645
+ enable: function() {
646
+ this.traverse(function(object){
647
+ object.disabled = false
648
+ })
649
+ },
650
+ disable: function (){
651
+ this.traverse(function(object){
652
+ object.disabled = true
653
+ })
654
+ },
655
+ serialize: function () {
656
+ return this._serialize(this.el, true)
657
+ },
658
+ refresh: function() {
659
+ this.traverse(function(object){
660
+ object._clearDimensions()
661
+ })
662
+ },
663
+ destroy: function () {
664
+ this.traverse(function(object){
665
+ object._destroy();
666
+ })
667
+ }
668
+ }
669
+
670
+ $.extend(Container.prototype, API)
671
+
672
+ /**
673
+ * jQuery API
674
+ *
675
+ * Parameters are
676
+ * either options on init
677
+ * or a method name followed by arguments to pass to the method
678
+ */
679
+ $.fn[pluginName] = function(methodOrOptions) {
680
+ var args = Array.prototype.slice.call(arguments, 1)
681
+
682
+ return this.map(function(){
683
+ var $t = $(this),
684
+ object = $t.data(pluginName)
685
+
686
+ if(object && API[methodOrOptions])
687
+ return API[methodOrOptions].apply(object, args) || this
688
+ else if(!object && (methodOrOptions === undefined ||
689
+ typeof methodOrOptions === "object"))
690
+ $t.data(pluginName, new Container($t, methodOrOptions))
691
+
692
+ return this
693
+ });
694
+ };
695
+
696
+ }(jQuery, window, 'sortable');
@@ -3,6 +3,7 @@
3
3
  @import 'effective_checks/input';
4
4
  @import 'effective_datetime/input';
5
5
  @import 'effective_file/input';
6
+ @import 'effective_has_many/input';
6
7
  @import 'effective_rich_text_area/input';
7
8
  @import 'effective_select/input';
8
9
  @import 'effective_select_or_text/input';
@@ -0,0 +1,42 @@
1
+ body.dragging,
2
+ body.dragging * {
3
+ cursor: move !important;
4
+ }
5
+
6
+ .form-has-many {
7
+ .has-many-placeholder {
8
+ position: relative;
9
+ height: 2rem;
10
+
11
+ &:before {
12
+ position: absolute;
13
+ content: '';
14
+ background-image: asset-data-url('icons/arrow-right-circle.svg');
15
+ background-repeat: no-repeat;
16
+ height: 2rem;
17
+ width: 2rem;
18
+ }
19
+ }
20
+
21
+ .has-many-fields.dragged {
22
+ position: absolute;
23
+ opacity: 0;
24
+ z-index: 2000;
25
+ .has-many-move { display: none; }
26
+ }
27
+
28
+ .has-many-move svg { margin-top: 6px; }
29
+ .has-many-move { display: none; }
30
+
31
+ .has-many-remove { margin-top: 1.5rem; }
32
+ .has-many-move { margin-top: 1.5rem; }
33
+ }
34
+
35
+ .form-has-many.reordering {
36
+ .has-many-move { display: inline-block; }
37
+ }
38
+
39
+ .form-has-many.tight {
40
+ .has-many-remove { margin-top: 0; }
41
+ .has-many-move { margin-top: 0; }
42
+ }
@@ -203,5 +203,19 @@ module Effective
203
203
  Effective::FormLogics::ShowIfAny.new(*args, builder: self).to_html(&block)
204
204
  end
205
205
 
206
+ # Has Many
207
+ def has_many(name, collection = nil, options = {}, &block)
208
+ association = object.class.reflect_on_all_associations.find { |a| a.name == name }
209
+ raise("expected #{object.class.name} to has_many :#{name}") if association.blank?
210
+
211
+ nested_attributes_options = (object.class.nested_attributes_options || {})[name]
212
+ raise("expected #{object.class.name} to accepts_nested_attributes_for :#{name}") if nested_attributes_options.blank?
213
+
214
+ options = collection if collection.kind_of?(Hash)
215
+ options.merge!(collection: collection) if collection && !collection.kind_of?(Hash)
216
+
217
+ Effective::FormInputs::HasMany.new(name, options, builder: self).to_html(&block)
218
+ end
219
+
206
220
  end
207
221
  end
@@ -0,0 +1,175 @@
1
+ module Effective
2
+ module FormInputs
3
+ class HasMany < Effective::FormInput
4
+ BLANK = ''.html_safe
5
+
6
+ def to_html(&block)
7
+ content_tag(:div, options[:input]) do
8
+ has_many_fields_for(block) + has_many_links_for(block)
9
+ end
10
+ end
11
+
12
+ def input_html_options
13
+ { class: 'form-has-many mb-4' }
14
+ end
15
+
16
+ def input_js_options
17
+ { sortable: true }
18
+ end
19
+
20
+ def collection
21
+ Array(options[:input][:collection] || object.send(name))
22
+ end
23
+
24
+ # cards: true
25
+ def display
26
+ @display ||= (options[:input].delete(:cards) ? :cards : :rows)
27
+ end
28
+
29
+ # add: false
30
+ def add?
31
+ return @add unless @add.nil?
32
+
33
+ @add ||= begin
34
+ add = options[:input].delete(:add)
35
+ add.nil? ? true : add
36
+ end
37
+ end
38
+
39
+ # remove: false
40
+ def remove?
41
+ return @remove unless @remove.nil?
42
+
43
+ @remove ||= begin
44
+ remove = options[:input].delete(:remove)
45
+
46
+ if remove != nil
47
+ remove
48
+ else
49
+ opts = (object.class.nested_attributes_options[name] || {})
50
+ opts[:update_only] != true && opts[:allow_destroy] != false
51
+ end
52
+ end
53
+ end
54
+
55
+ # reorder: true
56
+ def reorder?
57
+ return @reorder unless @reorder.nil?
58
+
59
+ @reorder ||= begin
60
+ reorder = options[:input].delete(:reorder)
61
+
62
+ if reorder != nil
63
+ reorder
64
+ else
65
+ build_resource().class.columns_hash['position']&.type == :integer
66
+ end
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def has_many_fields_for(block)
73
+ collection.map { |resource| render_resource(resource, block) }.join.html_safe
74
+ end
75
+
76
+ def has_many_links_for(block)
77
+ return BLANK unless add? || reorder?
78
+
79
+ content_tag(:div, class: 'has-many-links text-center mt-2') do
80
+ [(link_to_add(block) if add?), (link_to_reorder(block) if reorder?)].compact.join(' ').html_safe
81
+ end
82
+ end
83
+
84
+ def render_resource(resource, block)
85
+ remove = BLANK
86
+
87
+ content = @builder.fields_for(name, resource) do |form|
88
+ fields = block.call(form)
89
+
90
+ remove += form.super_hidden_field(:_destroy) if remove? && resource.persisted?
91
+ remove += form.super_hidden_field(:position) if reorder? && !fields.include?('][position]')
92
+
93
+ fields
94
+ end
95
+
96
+ remove += link_to_remove(resource) if remove?
97
+
98
+ content_tag(:div, render_fields(content, remove), class: 'has-many-fields')
99
+ end
100
+
101
+ def render_fields(content, remove)
102
+ case display
103
+ when :rows
104
+ content_tag(:div, class: 'form-row') do
105
+ (reorder? ? content_tag(:div, has_many_move, class: 'col-auto') : BLANK) +
106
+ content_tag(:div, content, class: 'col mr-auto') +
107
+ content_tag(:div, remove, class: 'col-auto')
108
+ end
109
+ when :cards
110
+ raise('unsupported')
111
+ else
112
+ content + remove
113
+ end
114
+ end
115
+
116
+ def render_template(block)
117
+ resource = build_resource()
118
+ index = object.send(name).index(resource)
119
+
120
+ html = render_resource(resource, block)
121
+ html.gsub!("#{name}_attributes][#{index}]", "#{name}_attributes][HASMANYINDEX]")
122
+ html.gsub!("#{name}_attributes_#{index}_", "#{name}_attributes_HASMANYINDEX_")
123
+
124
+ html.html_safe
125
+ end
126
+
127
+ def link_to_add(block)
128
+ content_tag(
129
+ :button,
130
+ icon('plus-circle') + 'Add Another',
131
+ class: 'has-many-add btn btn-secondary',
132
+ title: 'Add Another',
133
+ data: {
134
+ 'effective-form-has-many-add': true,
135
+ 'effective-form-has-many-template': render_template(block)
136
+ }
137
+ )
138
+ end
139
+
140
+ def link_to_reorder(block)
141
+ content_tag(
142
+ :button,
143
+ icon('list') + 'Reorder',
144
+ class: 'has-many-reorder btn btn-secondary',
145
+ title: 'Reorder',
146
+ data: {
147
+ 'effective-form-has-many-reorder': true,
148
+ }
149
+ )
150
+ end
151
+
152
+ def link_to_remove(resource)
153
+ content_tag(
154
+ :button,
155
+ icon('trash-2') + 'Remove',
156
+ class: 'has-many-remove btn btn-danger',
157
+ title: 'Remove',
158
+ data: {
159
+ 'confirm': "Remove #{resource}?",
160
+ 'effective-form-has-many-remove': true,
161
+ }
162
+ )
163
+ end
164
+
165
+ def has_many_move
166
+ @has_many_move ||= content_tag(:span, icon('move'), class: 'has-many-move')
167
+ end
168
+
169
+ def build_resource
170
+ @build_resource ||= object.send(name).build().tap { |resource| object.send(name).delete(resource) }
171
+ end
172
+
173
+ end
174
+ end
175
+ end
@@ -1,3 +1,3 @@
1
1
  module EffectiveBootstrap
2
- VERSION = '0.9.9'.freeze
2
+ VERSION = '0.9.10'.freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: effective_bootstrap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.9
4
+ version: 0.9.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Code and Effect
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-11-20 00:00:00.000000000 Z
11
+ date: 2021-01-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -419,6 +419,9 @@ files:
419
419
  - app/assets/javascripts/effective_editor/quill.js
420
420
  - app/assets/javascripts/effective_file/initialize.js.coffee
421
421
  - app/assets/javascripts/effective_file/input.js
422
+ - app/assets/javascripts/effective_has_many/initialize.js.coffee
423
+ - app/assets/javascripts/effective_has_many/input.js
424
+ - app/assets/javascripts/effective_has_many/jquery.sortable.js
422
425
  - app/assets/javascripts/effective_integer/initialize.js.coffee
423
426
  - app/assets/javascripts/effective_integer/input.js
424
427
  - app/assets/javascripts/effective_number_text/initialize.js.coffee
@@ -584,6 +587,7 @@ files:
584
587
  - app/assets/stylesheets/effective_editor/overrides.scss
585
588
  - app/assets/stylesheets/effective_editor/quill.scss
586
589
  - app/assets/stylesheets/effective_file/input.scss
590
+ - app/assets/stylesheets/effective_has_many/input.scss
587
591
  - app/assets/stylesheets/effective_radio/input.scss
588
592
  - app/assets/stylesheets/effective_rich_text_area/input.scss
589
593
  - app/assets/stylesheets/effective_select/bootstrap-theme.css
@@ -613,6 +617,7 @@ files:
613
617
  - app/models/effective/form_inputs/file_field.rb
614
618
  - app/models/effective/form_inputs/float_field.rb
615
619
  - app/models/effective/form_inputs/form_group.rb
620
+ - app/models/effective/form_inputs/has_many.rb
616
621
  - app/models/effective/form_inputs/hidden_field.rb
617
622
  - app/models/effective/form_inputs/integer_field.rb
618
623
  - app/models/effective/form_inputs/number_field.rb