cocooned 1.3.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
[![Build Status](https://travis-ci.org/notus-sh/cocooned.png?branch=master)](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
|
+
};
|