cocooned 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.travis.yml +14 -0
- data/Gemfile +16 -0
- data/Gemfile.lock +194 -0
- data/History.md +253 -0
- data/LICENSE +13 -0
- data/README.md +293 -0
- data/Rakefile +44 -0
- data/app/assets/javascripts/cocoon.js +14 -0
- data/app/assets/javascripts/cocooned/core.js +284 -0
- data/app/assets/javascripts/cocooned/jquery/onload.js +10 -0
- data/app/assets/javascripts/cocooned/jquery/plugin.js +20 -0
- data/app/assets/javascripts/cocooned/plugins/limit.js +22 -0
- data/app/assets/javascripts/cocooned/plugins/reorderable.js +101 -0
- data/app/assets/javascripts/cocooned.js +3 -0
- data/app/assets/stylesheets/cocoon.css +3 -0
- data/app/assets/stylesheets/cocooned.css +9 -0
- data/cocooned.gemspec +37 -0
- data/config/linters/js.json +50 -0
- data/config/linters/ruby.yml +16 -0
- data/gemfiles/Gemfile.rails-4 +8 -0
- data/gemfiles/Gemfile.rails-5 +8 -0
- data/lib/cocooned/association_builder.rb +69 -0
- data/lib/cocooned/helpers/cocoon_compatibility.rb +27 -0
- data/lib/cocooned/helpers/deprecate.rb +49 -0
- data/lib/cocooned/helpers.rb +331 -0
- data/lib/cocooned/railtie.rb +14 -0
- data/lib/cocooned/version.rb +5 -0
- data/lib/cocooned.rb +6 -0
- data/package.json +24 -0
- data/yarn.lock +1052 -0
- metadata +183 -0
data/README.md
ADDED
@@ -0,0 +1,293 @@
|
|
1
|
+
# Cocooned
|
2
|
+
|
3
|
+
[](https://travis-ci.org/notus-sh/cocooned)
|
4
|
+
|
5
|
+
Cocooned makes it easier to handle nested forms in a Rails project.
|
6
|
+
|
7
|
+
Cocooned is form builder-agnostic: it works with standard Rails (>= 4.0, < 6.0), [Formtastic](https://github.com/justinfrench/formtastic) or [SimpleForm](https://github.com/plataformatec/simple_form).
|
8
|
+
|
9
|
+
## Some Background
|
10
|
+
|
11
|
+
Cocooned is a fork of [Cocoon](https://github.com/nathanvda/cocoon) by [Nathan Van der Auwera](https://github.com/nathanvda).
|
12
|
+
|
13
|
+
He and all Cocoon contributors did a great job to maintain it for years. Many thanks to them!
|
14
|
+
|
15
|
+
But last time I checked, the project seemed to not have been actively maintained for a long time and many pull requests, even simple ones, were on hold. As I needed a more than what Cocoon provided at this time, I had the choice to either maintain an extension or to fork it and integrate everything that was waiting and more.
|
16
|
+
|
17
|
+
Cocooned is almost a complete rewrite of Cocoon, with more functionnalities and (I hope) a more fluent API.
|
18
|
+
|
19
|
+
**For now, Cocooned is completely compatible with Cocoon and can be used as a drop-in replacement.**
|
20
|
+
Just change the name of the gem in your Gemfile and you're done. It will work the same (but will add a bunch of deprecation warning to your logs).
|
21
|
+
|
22
|
+
**The compatibility layer with the original Cocoon API will be dropped in Cocooned 2.0.**
|
23
|
+
|
24
|
+
## Prerequisites
|
25
|
+
|
26
|
+
Cocooned depends on jQuery, Ruby (>= 2.2) and Rails (>= 4.0, < 6.0).
|
27
|
+
|
28
|
+
## Installation
|
29
|
+
|
30
|
+
Inside your `Gemfile` add the following:
|
31
|
+
|
32
|
+
```ruby
|
33
|
+
gem "cocooned"
|
34
|
+
```
|
35
|
+
|
36
|
+
You must also require `cocooned` in your `application.js` and `application.css`, so it compiles with the asset pipeline.
|
37
|
+
|
38
|
+
## Usage
|
39
|
+
|
40
|
+
For all the following examples, we will consider modelisation of an administrable list with items.
|
41
|
+
Here are the two ActiveRecord models : `List` and `Item`:
|
42
|
+
|
43
|
+
```ruby
|
44
|
+
class Item < ApplicationRecord
|
45
|
+
has_many :items, inverse_of: :list
|
46
|
+
accepts_nested_attributes_for :items, reject_if: :all_blank, allow_destroy: true
|
47
|
+
end
|
48
|
+
|
49
|
+
class Item < ApplicationRecord
|
50
|
+
belongs_to :list
|
51
|
+
end
|
52
|
+
```
|
53
|
+
|
54
|
+
We will build a form where we can dynamically add and remove items to a list.
|
55
|
+
|
56
|
+
### Strong Parameters Gotcha
|
57
|
+
|
58
|
+
To destroy nested models, Rails uses a virtual attribute called `_destroy`.
|
59
|
+
When `_destroy` is set, the nested model will be deleted. If the record has previously been persisted, Rails generate and use an automatic `id` field to fetch the wannabe destroyed record.
|
60
|
+
|
61
|
+
When using Rails > 4.0 (or strong parameters), you need to explicitly add both `:id` and `:_destroy` to the list of permitted parameters.
|
62
|
+
|
63
|
+
E.g. in your `ListsController`:
|
64
|
+
|
65
|
+
```ruby
|
66
|
+
def list_params
|
67
|
+
params.require(:list).permit(:name, tasks_attributes: [:id, :description, :done, :_destroy])
|
68
|
+
end
|
69
|
+
```
|
70
|
+
|
71
|
+
### Basic form
|
72
|
+
|
73
|
+
_Please note examples in this section are written with the [`haml` templating language](http://haml.info/)._
|
74
|
+
|
75
|
+
[Rails natively supports nested forms](https://guides.rubyonrails.org/form_helpers.html#nested-forms) but does not support adding or removing nested items.
|
76
|
+
|
77
|
+
```haml
|
78
|
+
/ `app/views/lists/_form.html.haml`
|
79
|
+
= form_for @list do |f|
|
80
|
+
= f.input :name
|
81
|
+
|
82
|
+
%h3 Items
|
83
|
+
= f.fields_for :tasks do |item_form|
|
84
|
+
/ This block is repeated for every task in @list.items
|
85
|
+
= item_form.label :description
|
86
|
+
= item_form.text_field :description
|
87
|
+
= item_form.check_box :done
|
88
|
+
|
89
|
+
= f.submit "Save"
|
90
|
+
```
|
91
|
+
|
92
|
+
To enable Cocooned on this first, we need to:
|
93
|
+
|
94
|
+
1. Move the nested form to a partial
|
95
|
+
2. Add a way to add a new item to the collection
|
96
|
+
3. Add a way to remove an item from the collection
|
97
|
+
4. Initialize Cocooned to handle this form
|
98
|
+
|
99
|
+
Let's do it.
|
100
|
+
|
101
|
+
#### 1. Move the nested form to a partial
|
102
|
+
|
103
|
+
We now have two files:
|
104
|
+
|
105
|
+
```haml
|
106
|
+
/ `app/views/lists/_form.html.haml`
|
107
|
+
= form_for @list do |form|
|
108
|
+
= form.input :name
|
109
|
+
|
110
|
+
%h3 Items
|
111
|
+
= form.fields_for :items do |item_form|
|
112
|
+
= render 'item_fields', f: item_form
|
113
|
+
|
114
|
+
= form.submit "Save"
|
115
|
+
```
|
116
|
+
|
117
|
+
```haml
|
118
|
+
/ `app/views/lists/_item_fields.html.haml`
|
119
|
+
= f.label :description
|
120
|
+
= f.text_field :description
|
121
|
+
= f.check_box :done
|
122
|
+
```
|
123
|
+
|
124
|
+
#### 2. Add a way to add a new item to the collection
|
125
|
+
|
126
|
+
```haml
|
127
|
+
/ `app/views/lists/_form.html.haml`
|
128
|
+
= form_for @list do |form|
|
129
|
+
= form.input :name
|
130
|
+
|
131
|
+
%h3 Items
|
132
|
+
#items
|
133
|
+
= form.fields_for :tasks do |item_form|
|
134
|
+
= render 'item_fields', f: item_form
|
135
|
+
.links
|
136
|
+
= cocooned_add_item_link 'Add an item', form, :items
|
137
|
+
|
138
|
+
= form.submit "Save"
|
139
|
+
```
|
140
|
+
|
141
|
+
By default, a new item will be inserted just before the immediate parent of the 'Add an item' link. You can have a look at the documentation of `cocooned_add_item_link` for more information about how to change that but we'll keep it simple for now.
|
142
|
+
|
143
|
+
#### 3. Add a way to remove an item from the collection
|
144
|
+
|
145
|
+
```haml
|
146
|
+
/ `app/views/lists/_item_fields.html.haml`
|
147
|
+
.cocooned-item
|
148
|
+
= f.label :description
|
149
|
+
= f.text_field :description
|
150
|
+
= f.check_box :done
|
151
|
+
= cocooned_remove_item_link 'Remove', f
|
152
|
+
```
|
153
|
+
|
154
|
+
The `.cocooned-item` class is required for the `cocooned_remove_item_link` to work correctly.
|
155
|
+
|
156
|
+
#### 4. Initialize Cocooned to handle this form
|
157
|
+
|
158
|
+
Cocooned will detect on page load forms it should handle and initialize itself.
|
159
|
+
This detection is based on the presence of a `data-cocooned-options` attribute on the nested forms container.
|
160
|
+
|
161
|
+
```haml
|
162
|
+
/ `app/views/lists/_form.html.haml`
|
163
|
+
= form_for @list do |form|
|
164
|
+
= form.input :name
|
165
|
+
|
166
|
+
%h3 Items
|
167
|
+
#items{ :data => { cocooned_options: {}.to_json } }
|
168
|
+
= form.fields_for :tasks do |item_form|
|
169
|
+
= render 'item_fields', f: item_form
|
170
|
+
.links
|
171
|
+
= cocooned_add_item_link 'Add an item', form, :items
|
172
|
+
|
173
|
+
= form.submit "Save"
|
174
|
+
```
|
175
|
+
|
176
|
+
And we're done!
|
177
|
+
|
178
|
+
### Wait, what's the point of `data-cocooned-options` if it's to be empty?
|
179
|
+
|
180
|
+
For simple use cases as the one we just demonstrated, the `data-cocooned-options` attributes only triggers the Cocooned initialization on page load. But you can use it to pass additional options to the Cocooned javascript and enable plugins.
|
181
|
+
|
182
|
+
For now, Cocooned supports two plugins:
|
183
|
+
|
184
|
+
* **Limit**, to set a maximum limit of items that can be added to the association
|
185
|
+
* **Reorderable**, that will automatically update `position` fields when you add or remove an item or when you reorder associated items.
|
186
|
+
|
187
|
+
#### The limit plugin
|
188
|
+
|
189
|
+
The limit plugin is autoloaded when needed and does not require anything more than you specifiying the maximum number of items allowed in the association.
|
190
|
+
|
191
|
+
```haml
|
192
|
+
/ `app/views/lists/_form.html.haml`
|
193
|
+
= form_for @list do |form|
|
194
|
+
= form.input :name
|
195
|
+
|
196
|
+
%h3 Items
|
197
|
+
#items{ :data => { cocooned_options: { limit: 12 }.to_json } }
|
198
|
+
= form.fields_for :tasks do |item_form|
|
199
|
+
= render 'item_fields', f: item_form
|
200
|
+
.links
|
201
|
+
= cocooned_add_item_link 'Add an item', form, :items
|
202
|
+
|
203
|
+
= form.submit "Save"
|
204
|
+
```
|
205
|
+
|
206
|
+
#### The reorderable plugin
|
207
|
+
|
208
|
+
The reorderable plugin is autoloaded when activated and does not support any particular options.
|
209
|
+
|
210
|
+
```haml
|
211
|
+
/ `app/views/lists/_form.html.haml`
|
212
|
+
= form_for @list do |form|
|
213
|
+
= form.input :name
|
214
|
+
|
215
|
+
%h3 Items
|
216
|
+
#items{ :data => { cocooned_options: { reorderable: true }.to_json } }
|
217
|
+
= form.fields_for :tasks do |item_form|
|
218
|
+
= render 'item_fields', f: item_form
|
219
|
+
.links
|
220
|
+
= cocooned_add_item_link 'Add an item', form, :items
|
221
|
+
|
222
|
+
= form.submit "Save"
|
223
|
+
```
|
224
|
+
|
225
|
+
However, you need to edit your nested partial to add the links that allow your users to move an item up or down in the collection and to add a `position` field.
|
226
|
+
|
227
|
+
```haml
|
228
|
+
/ `app/views/lists/_item_fields.html.haml`
|
229
|
+
.cocooned-item
|
230
|
+
= f.label :description
|
231
|
+
= f.text_field :description
|
232
|
+
= f.check_box :done
|
233
|
+
= f.hidden_field :position
|
234
|
+
= cocooned_move_item_up_link 'Up', f
|
235
|
+
= cocooned_move_item_down_link 'Down', f
|
236
|
+
= cocooned_remove_item_link 'Remove', f
|
237
|
+
```
|
238
|
+
|
239
|
+
Also, remember the strong parameters gotcha we mentioned earlier.
|
240
|
+
|
241
|
+
Of course, it means your model must have a `position` attribute you will use to sort collections.
|
242
|
+
|
243
|
+
## How it works
|
244
|
+
|
245
|
+
Cocooned defines some helper functions:
|
246
|
+
|
247
|
+
* `cocooned_add_item_link` will build a link that, when clicked, dynamically adds a new partial form for the given association. [Have a look at the documentation for available options](https://github.com/notus-sh/cocooned/blob/master/lib/cocooned/helpers.rb#L21).
|
248
|
+
* `cocooned_remove_item_link` will build a link that, when clicked, dynamically removes the surrounding partial form. [Have a look at the documentation for available options](https://github.com/notus-sh/cocooned/blob/master/lib/cocooned/helpers.rb#L143).
|
249
|
+
* `cocooned_move_item_up_link` and `cocooned_move_item_down_link` will build links that, when clicked, will move the surrounding partial form one step up or down in the collection. [Have a look at the documentation for available options](https://github.com/notus-sh/cocooned/blob/master/lib/cocooned/helpers.rb#L178).
|
250
|
+
|
251
|
+
### Javascript callbacks
|
252
|
+
|
253
|
+
When your collection is modified, the following events can be triggered:
|
254
|
+
|
255
|
+
* `cocooned:before-insert`: called before inserting a new nested child, can be [canceled](#canceling-an-action)
|
256
|
+
* `cocooned:after-insert`: called after inserting
|
257
|
+
* `cocooned:before-remove`: called before removing the nested child, can be [canceled](#canceling-an-action)
|
258
|
+
* `cocooned:after-remove`: called after removal
|
259
|
+
|
260
|
+
The limit plugin can trigger its own event:
|
261
|
+
|
262
|
+
* `cocooned:limit-reached`: called when the limit is reached (before a new item will be inserted)
|
263
|
+
|
264
|
+
And so does the reorderable plugin:
|
265
|
+
|
266
|
+
* `cocooned:before-move`: called before moving the nested child, can be [canceled](#canceling-an-action)
|
267
|
+
* `cocooned:after-move`: called after moving
|
268
|
+
* `cocooned:before-reindex`: called before updating the `position` fields of nested items, can be [canceled](#canceling-an-action) (even if I honestly don't know why you would)
|
269
|
+
* `cocooned:after-reindex`: called after `position` fields update
|
270
|
+
|
271
|
+
To listen to the events in your JavaScript:
|
272
|
+
|
273
|
+
```javascript
|
274
|
+
$('#container').on('cocooned:before-insert', function(event, node, cocoonedInstance) {
|
275
|
+
/* Do something */
|
276
|
+
});
|
277
|
+
```
|
278
|
+
|
279
|
+
An event handler is called with 3 arguments:
|
280
|
+
|
281
|
+
The event `event` is an instance of `jQuery.Event` and carry some additional data:
|
282
|
+
|
283
|
+
* `event.link`, the clicked link
|
284
|
+
* `event.node`, the nested item that will be added, removed or moved, as a jQuery object. This is null for `cocooned:limit-reached` and `cocooned:*-reindex` events
|
285
|
+
* `event.nodes`, the nested items that will be or just have been reindexed on `cocooned:*-reindex` events, as a jQuery object. Null otherwise.
|
286
|
+
* `event.cocooned`, the Cocooned javascript object instance handling the nested association.
|
287
|
+
|
288
|
+
The `node` argument is the same jQuery object as `event.node`.
|
289
|
+
The `cocooned` argument is the same as `event.cocooned`.
|
290
|
+
|
291
|
+
#### Canceling an action
|
292
|
+
|
293
|
+
You can cancel an action within the `cocooned:before-<action>` callback by calling `event.preventDefault()` or `event.stopPropagation()`.
|
data/Rakefile
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/gem_tasks'
|
4
|
+
|
5
|
+
# Test suites
|
6
|
+
require 'rspec/core/rake_task'
|
7
|
+
RSpec::Core::RakeTask.new
|
8
|
+
|
9
|
+
task default: :spec
|
10
|
+
|
11
|
+
require 'jasmine'
|
12
|
+
load 'jasmine/tasks/jasmine.rake'
|
13
|
+
|
14
|
+
# Linters
|
15
|
+
require 'rubocop/rake_task'
|
16
|
+
RuboCop::RakeTask.new do |task|
|
17
|
+
task.options = ['--config', 'config/linters/ruby.yml']
|
18
|
+
end
|
19
|
+
|
20
|
+
eslint_args = ['--no-eslintrc', '--config config/linters/js.json']
|
21
|
+
eslint_path = ['app/assets/**/*.js', 'spec/javascripts/**/*.js', 'spec/dummy/app/assets/**/*.js']
|
22
|
+
|
23
|
+
namespace :eslint do
|
24
|
+
desc 'Auto-correct JavaScript files'
|
25
|
+
task :auto_correct do
|
26
|
+
system("yarnpkg run eslint #{(eslint_args + ['--fix']).join(' ')} #{eslint_path.join(' ')}")
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
desc 'Lint JavaScript code'
|
31
|
+
task :eslint do
|
32
|
+
system("yarnpkg run eslint #{eslint_args.join(' ')} #{eslint_path.join(' ')}")
|
33
|
+
end
|
34
|
+
|
35
|
+
# Documentation
|
36
|
+
require 'rdoc/task'
|
37
|
+
|
38
|
+
Rake::RDocTask.new(:rdoc) do |rdoc|
|
39
|
+
rdoc.rdoc_dir = 'rdoc'
|
40
|
+
rdoc.title = 'Cocooned'
|
41
|
+
rdoc.options << '--line-numbers' << '--inline-source'
|
42
|
+
rdoc.rdoc_files.include('README.rdoc')
|
43
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
44
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
/* globals Cocooned */
|
2
|
+
//= require 'cocooned'
|
3
|
+
|
4
|
+
// Compatibility with the original Cocoon
|
5
|
+
function initCocoon () {
|
6
|
+
$(Cocooned.prototype.selector('add')).each(function (_i, addLink) {
|
7
|
+
var container = Cocooned.prototype.findContainer(addLink);
|
8
|
+
var limit = parseInt($(addLink).data('limit'), 10) || false;
|
9
|
+
|
10
|
+
container.cocooned({ limit: limit });
|
11
|
+
});
|
12
|
+
}
|
13
|
+
|
14
|
+
$(initCocoon);
|
@@ -0,0 +1,284 @@
|
|
1
|
+
var Cocooned = function (container, options) {
|
2
|
+
this.container = jQuery(container);
|
3
|
+
this.options = jQuery.extend({}, this.defaultOptions(), (options || {}));
|
4
|
+
|
5
|
+
// Autoload plugins
|
6
|
+
for (var moduleName in Cocooned.Plugins) {
|
7
|
+
if (Cocooned.Plugins.hasOwnProperty(moduleName)) {
|
8
|
+
var module = Cocooned.Plugins[moduleName];
|
9
|
+
var optionName = moduleName.charAt(0).toLowerCase() + moduleName.slice(1);
|
10
|
+
|
11
|
+
if (this.options[optionName]) {
|
12
|
+
for (var method in module) {
|
13
|
+
if (module.hasOwnProperty(method) && typeof module[method] === 'function') {
|
14
|
+
this[method] = module[method];
|
15
|
+
}
|
16
|
+
}
|
17
|
+
}
|
18
|
+
}
|
19
|
+
}
|
20
|
+
|
21
|
+
this.init();
|
22
|
+
};
|
23
|
+
|
24
|
+
Cocooned.Plugins = {};
|
25
|
+
Cocooned.prototype = {
|
26
|
+
|
27
|
+
elementsCounter: 0,
|
28
|
+
|
29
|
+
// Compatibility with Cocoon
|
30
|
+
// TODO: Remove in 2.0 (Only Cocoon namespaces).
|
31
|
+
namespaces: {
|
32
|
+
events: ['cocooned', 'cocoon']
|
33
|
+
},
|
34
|
+
|
35
|
+
// Compatibility with Cocoon
|
36
|
+
// TODO: Remove in 2.0 (Only Cocoon class names).
|
37
|
+
classes: {
|
38
|
+
// Actions link
|
39
|
+
add: ['cocooned-add', 'add_fields'],
|
40
|
+
remove: ['cocooned-remove', 'remove_fields'],
|
41
|
+
up: ['cocooned-move-up'],
|
42
|
+
down: ['cocooned-move-down'],
|
43
|
+
// Containers
|
44
|
+
container: ['cocooned-container'],
|
45
|
+
item: ['cocooned-item', 'nested-fields'],
|
46
|
+
},
|
47
|
+
|
48
|
+
defaultOptions: function () {
|
49
|
+
var options = {};
|
50
|
+
|
51
|
+
for (var moduleName in Cocooned.Plugins) {
|
52
|
+
if (Cocooned.Plugins.hasOwnProperty(moduleName)) {
|
53
|
+
var module = Cocooned.Plugins[moduleName];
|
54
|
+
var optionName = moduleName.charAt(0).toLowerCase() + moduleName.slice(1);
|
55
|
+
|
56
|
+
options[optionName] = module.defaultOptionValue;
|
57
|
+
}
|
58
|
+
}
|
59
|
+
|
60
|
+
return options;
|
61
|
+
},
|
62
|
+
|
63
|
+
notify: function (node, eventType, eventData) {
|
64
|
+
return !(this.namespaces.events.some(function(namespace) {
|
65
|
+
var namespacedEventType = [namespace, eventType].join(':');
|
66
|
+
var event = jQuery.Event(namespacedEventType, eventData);
|
67
|
+
|
68
|
+
node.trigger(event, [eventData.node, eventData.cocooned]);
|
69
|
+
|
70
|
+
return (event.isPropagationStopped() || event.isDefaultPrevented());
|
71
|
+
}));
|
72
|
+
},
|
73
|
+
|
74
|
+
selector: function (type, selector) {
|
75
|
+
var s = selector || '&';
|
76
|
+
return this.classes[type].map(function(klass) { return s.replace(/&/, '.' + klass); }).join(', ');
|
77
|
+
},
|
78
|
+
|
79
|
+
namespacedNativeEvents: function (type) {
|
80
|
+
var namespaces = this.namespaces.events.map(function(ns) { return '.' + ns; });
|
81
|
+
namespaces.unshift(type);
|
82
|
+
return namespaces.join('');
|
83
|
+
},
|
84
|
+
|
85
|
+
buildId: function () {
|
86
|
+
return (new Date().getTime() + this.elementsCounter++);
|
87
|
+
},
|
88
|
+
|
89
|
+
buildContentNode: function (content) {
|
90
|
+
var id = this.buildId();
|
91
|
+
var html = (content || this.content);
|
92
|
+
var braced = '[' + id + ']';
|
93
|
+
var underscored = '_' + id + '_';
|
94
|
+
|
95
|
+
['associations', 'association'].forEach(function (a) {
|
96
|
+
html = html.replace(this.regexps[a]['braced'], braced + '$1');
|
97
|
+
html = html.replace(this.regexps[a]['underscored'], underscored + '$1');
|
98
|
+
}, this);
|
99
|
+
|
100
|
+
return $(html);
|
101
|
+
},
|
102
|
+
|
103
|
+
getInsertionNode: function (adder) {
|
104
|
+
var $adder = $(adder);
|
105
|
+
var insertionNode = $adder.data('association-insertion-node');
|
106
|
+
var insertionTraversal = $adder.data('association-insertion-traversal');
|
107
|
+
|
108
|
+
if (!insertionNode) {
|
109
|
+
return $adder.parent();
|
110
|
+
}
|
111
|
+
|
112
|
+
if (typeof insertionNode === 'function') {
|
113
|
+
return insertionNode($adder);
|
114
|
+
}
|
115
|
+
|
116
|
+
if (insertionTraversal) {
|
117
|
+
return $adder[insertionTraversal](insertionNode);
|
118
|
+
}
|
119
|
+
|
120
|
+
return insertionNode === 'this' ? $adder : $(insertionNode);
|
121
|
+
},
|
122
|
+
|
123
|
+
getInsertionMethod: function (adder) {
|
124
|
+
var $adder = $(adder);
|
125
|
+
return $adder.data('association-insertion-method') || 'before';
|
126
|
+
},
|
127
|
+
|
128
|
+
getItems: function (selector) {
|
129
|
+
selector = selector || '';
|
130
|
+
var self = this;
|
131
|
+
return $(this.selector('item', selector), this.container).filter(function () {
|
132
|
+
return ($(this).closest(self.selector('container')).get(0) === self.container.get(0));
|
133
|
+
});
|
134
|
+
},
|
135
|
+
|
136
|
+
findContainer: function (addLink) {
|
137
|
+
var $adder = $(addLink);
|
138
|
+
var insertionNode = this.getInsertionNode($adder);
|
139
|
+
var insertionMethod = this.getInsertionMethod($adder);
|
140
|
+
|
141
|
+
switch (insertionMethod) {
|
142
|
+
case 'before':
|
143
|
+
case 'after':
|
144
|
+
case 'replaceWith':
|
145
|
+
return insertionNode.parent();
|
146
|
+
|
147
|
+
case 'append':
|
148
|
+
case 'prepend':
|
149
|
+
default:
|
150
|
+
return insertionNode;
|
151
|
+
}
|
152
|
+
},
|
153
|
+
|
154
|
+
findItem: function (removeLink) {
|
155
|
+
return $(removeLink).closest(this.selector('item'));
|
156
|
+
},
|
157
|
+
|
158
|
+
init: function () {
|
159
|
+
var self = this;
|
160
|
+
|
161
|
+
this.addLinks = $(this.selector('add')).filter(function () {
|
162
|
+
var container = self.findContainer(this);
|
163
|
+
return (container.get(0) === self.container.get(0));
|
164
|
+
});
|
165
|
+
|
166
|
+
var addLink = $(this.addLinks.get(0));
|
167
|
+
|
168
|
+
this.content = addLink.data('association-insertion-template');
|
169
|
+
this.regexps = {
|
170
|
+
association: {
|
171
|
+
braced: new RegExp('\\[new_' + addLink.data('association') + '\\](.*?\\s)', 'g'),
|
172
|
+
underscored: new RegExp('_new_' + addLink.data('association') + '_(\\w*)', 'g')
|
173
|
+
},
|
174
|
+
associations: {
|
175
|
+
braced: new RegExp('\\[new_' + addLink.data('associations') + '\\](.*?\\s)', 'g'),
|
176
|
+
underscored: new RegExp('_new_' + addLink.data('associations') + '_(\\w*)', 'g')
|
177
|
+
}
|
178
|
+
};
|
179
|
+
|
180
|
+
this.initUi();
|
181
|
+
this.bindEvents();
|
182
|
+
},
|
183
|
+
|
184
|
+
initUi: function () {
|
185
|
+
var self = this;
|
186
|
+
|
187
|
+
if (!this.container.attr('id')) {
|
188
|
+
this.container.attr('id', this.buildId());
|
189
|
+
}
|
190
|
+
this.container.addClass(this.classes['container'].join(' '));
|
191
|
+
|
192
|
+
$(function () { self.hideMarkedForDestruction(); });
|
193
|
+
$(document).on('page:load turbolinks:load', function () { self.hideMarkedForDestruction(); });
|
194
|
+
},
|
195
|
+
|
196
|
+
bindEvents: function () {
|
197
|
+
var self = this;
|
198
|
+
|
199
|
+
// Bind add links
|
200
|
+
this.addLinks.on(
|
201
|
+
this.namespacedNativeEvents('click'),
|
202
|
+
function (e) {
|
203
|
+
e.preventDefault();
|
204
|
+
self.add(this);
|
205
|
+
});
|
206
|
+
|
207
|
+
// Bind remove links
|
208
|
+
// (Binded on document instead of container to not bypass click handler defined in jquery_ujs)
|
209
|
+
$(document).on(
|
210
|
+
this.namespacedNativeEvents('click'),
|
211
|
+
this.selector('remove', '#' + this.container.attr('id') + ' &'),
|
212
|
+
function (e) {
|
213
|
+
e.preventDefault();
|
214
|
+
self.remove(this);
|
215
|
+
});
|
216
|
+
|
217
|
+
// Bind options events
|
218
|
+
$.each(this.options, function (name, value) {
|
219
|
+
var bindMethod = 'bind' + name.charAt(0).toUpperCase() + name.slice(1);
|
220
|
+
if (value && self[bindMethod]) {
|
221
|
+
self[bindMethod]();
|
222
|
+
}
|
223
|
+
});
|
224
|
+
},
|
225
|
+
|
226
|
+
add: function (adder) {
|
227
|
+
var $adder = $(adder);
|
228
|
+
var insertionMethod = this.getInsertionMethod($adder);
|
229
|
+
var insertionNode = this.getInsertionNode($adder);
|
230
|
+
var contentTemplate = $adder.data('association-insertion-template');
|
231
|
+
var count = parseInt($adder.data('count'), 10) || 1;
|
232
|
+
|
233
|
+
for (var i = 0; i < count; i++) {
|
234
|
+
var contentNode = this.buildContentNode(contentTemplate);
|
235
|
+
var eventData = { link: $adder, node: contentNode, cocooned: this };
|
236
|
+
var afterNode = (insertionMethod === 'replaceWith' ? contentNode : insertionNode);
|
237
|
+
|
238
|
+
// Insertion can be prevented through a 'cocooned:before-insert' event handler
|
239
|
+
if (!this.notify(insertionNode, 'before-insert', eventData)) {
|
240
|
+
return false;
|
241
|
+
}
|
242
|
+
|
243
|
+
insertionNode[insertionMethod](contentNode);
|
244
|
+
|
245
|
+
this.notify(afterNode, 'after-insert', eventData);
|
246
|
+
}
|
247
|
+
},
|
248
|
+
|
249
|
+
remove: function (remover) {
|
250
|
+
var self = this;
|
251
|
+
var $remover = $(remover);
|
252
|
+
var nodeToDelete = this.findItem($remover);
|
253
|
+
var triggerNode = nodeToDelete.parent();
|
254
|
+
var eventData = { link: $remover, node: nodeToDelete, cocooned: this };
|
255
|
+
|
256
|
+
// Deletion can be prevented through a 'cocooned:before-remove' event handler
|
257
|
+
if (!this.notify(triggerNode, 'before-remove', eventData)) {
|
258
|
+
return false;
|
259
|
+
}
|
260
|
+
|
261
|
+
var timeout = triggerNode.data('remove-timeout') || 0;
|
262
|
+
|
263
|
+
setTimeout(function () {
|
264
|
+
if ($remover.hasClass('dynamic')) {
|
265
|
+
nodeToDelete.remove();
|
266
|
+
} else {
|
267
|
+
nodeToDelete.find('input[required], select[required]').each(function (index, element) {
|
268
|
+
$(element).removeAttr('required');
|
269
|
+
});
|
270
|
+
$remover.siblings('input[type=hidden][name$="[_destroy]"]').val('true');
|
271
|
+
nodeToDelete.hide();
|
272
|
+
}
|
273
|
+
self.notify(triggerNode, 'after-remove', eventData);
|
274
|
+
}, timeout);
|
275
|
+
},
|
276
|
+
|
277
|
+
hideMarkedForDestruction: function () {
|
278
|
+
var self = this;
|
279
|
+
$(this.selector('remove', '&.existing.destroyed'), this.container).each(function (i, removeLink) {
|
280
|
+
var node = self.findItem(removeLink);
|
281
|
+
node.hide();
|
282
|
+
});
|
283
|
+
}
|
284
|
+
};
|
@@ -0,0 +1,20 @@
|
|
1
|
+
;(function(w, $) {
|
2
|
+
'use strict';
|
3
|
+
|
4
|
+
$.fn.cocooned = function(options) {
|
5
|
+
return this.each(function() {
|
6
|
+
var container = $(this);
|
7
|
+
|
8
|
+
if (typeof container.data('cocooned') !== 'undefined') {
|
9
|
+
if (typeof w.console !== 'undefined') {
|
10
|
+
w.console.warn('Cocooned already initialized on this element.');
|
11
|
+
w.console.debug(container);
|
12
|
+
}
|
13
|
+
return;
|
14
|
+
}
|
15
|
+
|
16
|
+
var cocooned = new Cocooned(container, options);
|
17
|
+
container.data('cocooned', cocooned);
|
18
|
+
});
|
19
|
+
};
|
20
|
+
})(window, jQuery);
|
@@ -0,0 +1,22 @@
|
|
1
|
+
Cocooned.Plugins.Limit = {
|
2
|
+
|
3
|
+
defaultOptionValue: false,
|
4
|
+
|
5
|
+
bindLimit: function() {
|
6
|
+
this.limit = this.options['limit'];
|
7
|
+
this.container.on('cocooned:before-insert', function(e) {
|
8
|
+
var cocooned = e.cocooned;
|
9
|
+
if (cocooned.getLength() < cocooned.limit) {
|
10
|
+
return;
|
11
|
+
}
|
12
|
+
|
13
|
+
e.stopPropagation();
|
14
|
+
var eventData = { link: e.link, node: e.node, cocooned: cocooned };
|
15
|
+
cocooned.notify(cocooned.container, 'limit-reached', eventData);
|
16
|
+
});
|
17
|
+
},
|
18
|
+
|
19
|
+
getLength: function() {
|
20
|
+
return this.getItems('&:visible').length;
|
21
|
+
}
|
22
|
+
};
|