Cocooned
Cocooned makes it easier to handle nested forms in a Rails project.
Cocooned is form builder-agnostic: it works with standard Rails (>= 5.0, < 7.0) form helpers, Formtastic or SimpleForm.
Some Background
Cocooned is a fork of Cocoon by Nathan Van der Auwera. He and all Cocoon contributors did a great job to maintain it for years. Many thanks to them!
However, the project seems to have only received minimal fixes since 2018 and many pull requests, even simple ones, have been on hold for a long time. In 2019, 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.
Cocooned is almost a complete rewrite of Cocoon, with more functionnalities, a more fluent API (I hope) and integration with modern toolchains (including webpacker).
For now, Cocooned is completely compatible with Cocoon and can be used as a drop-in replacement as long as we talk about Ruby code. 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).
This compatibility layer with the original Cocoon API will be dropped in Cocooned 3.0.
On the JavaScript side, Cocoon 1.2.13 introduced the original browser event as a third parameter to all event handlers. Meanwhile, Cocooned already started to use this positional parameter to pass the Cocooned object instance (since 1.3.0). To get access to the original event, you'll have to change your handlers and use event.originalEvent.
Installation
Inside your Gemfile add the following:
gem "cocooned"
Load Cocooned styles and scripts
If you use Sprockets, you have to require cocooned in your application.js and application.css, so it compiles with the asset pipeline.
If you use Yarn to manage your non-Ruby dependencies and/or Webpack to build your assets, you can install the @notus.sh/cocooned companion package.
Usage
For all the following examples, we will consider modelisation of an administrable list with items.
Here are the two ActiveRecord models : List and Item:
class Item < ApplicationRecord
has_many :items, inverse_of: :list
accepts_nested_attributes_for :items, reject_if: :all_blank, allow_destroy: true
end
class Item < ApplicationRecord
belongs_to :list
end
We will build a form where we can dynamically add and remove items to a list.
Strong Parameters Gotcha
To destroy nested models, Rails uses a virtual attribute called _destroy.
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.
When using Rails > 4.0 (or strong parameters), you need to explicitly add both :id and :_destroy to the list of permitted parameters.
E.g. in your ListsController:
def list_params
params.require(:list).permit(:name, tasks_attributes: [:id, :description, :done, :_destroy])
end
Has One Gotcha
If you have a has_one association, then you (probably) need to set force_non_association_create: true on link_to_add_association or the associated object will be destroyed every time the edit form is rendered (which is probably not what you expect).
See the original merge request for more details.
Basic form
Rails natively supports nested forms but does not support adding or removing nested items.
<% # `app/views/lists/_form.html.erb` %>
<%= form_for @list do |f| %>
<%= f.input :name %>
<h3>Items</h3>
<%= f.fields_for :tasks do |item_form| %>
<% # This block is repeated for every task in @list.items %>
<%= item_form.label :description %>
<%= item_form.text_field :description %>
<%= item_form.check_box :done %>
<% end %>
<%= f.submit "Save" %>
<% end %>
To enable Cocooned on this form, we need to:
- Move the nested form to a partial
- Add a way to add a new item to the collection
- Add a way to remove an item from the collection
- Initialize Cocooned to handle this form
Let's do it.
1. Move the nested form to a partial
We now have two files:
<% # `app/views/lists/_form.html.erb` %>
<%= form_for @list do |form| %>
<%= form.input :name %>
<h3>Items</h3>
<%= form.fields_for :items do |item_form|
<%= render 'item_fields', f: item_form %>
<% end %>
<%= form.submit "Save" %>
<% end %>
<% # `app/views/lists/_item_fields.html.erb` %>
<%= f.label :description %>
<%= f.text_field :description %>
<%= f.check_box :done %>
2. Add a way to add a new item to the collection
<% # `app/views/lists/_form.html.erb` %>
<%= form_for @list do |form| %>
<%= form.input :name %>
<h3>Items</h3>
<div id="items">
<%= form.fields_for :tasks do |item_form| %>
<%= render 'item_fields', f: item_form %>
<% end %>
<div class="links">
<%= cocooned_add_item_link 'Add an item', form, :items %>
</div>
</div>
<%= form.submit "Save" %>
<% end %>
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.
3. Add a way to remove an item from the collection
<% # `app/views/lists/_item_fields.html.erb` %>
<div class="cocooned-item">
<%= f.label :description %>
<%= f.text_field :description %>
<%= f.check_box :done %>
<%= cocooned_remove_item_link 'Remove', f %>
</div>
The cocooned-item class is required for the cocooned_remove_item_link to work correctly.
4. Initialize Cocooned to handle this form
Cocooned will detect on page load forms it should handle and initialize itself.
This detection is based on the presence of a data-cocooned-options attribute on the nested forms container.
<% # `app/views/lists/_form.html.erb` %>
<%= form_for @list do |form| %>
<%= form.input :name %>
<h3>Items</h3>
<div id="items" data-cocooned-options="<%= {}.to_json %>">
<%= form.fields_for :tasks do |item_form| %>
<%= render 'item_fields', f: item_form %>
<% end %>
<div class="links">
<%= cocooned_add_item_link 'Add an item', form, :items %>
</div>
</div>
<%= form.submit "Save" %>
</div>
You're done!
Wait, what's the point of data-cocooned-options if it's to be empty?
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.
For now, Cocooned supports two plugins:
- Limit, to set a maximum limit of items that can be added to the association
- Reorderable, that will automatically update
positionfields when you add or remove an item or when you reorder associated items.
The limit plugin
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.
<% # `app/views/lists/_form.html.erb` %>
<%= form_for @list do |form| %>
<%= form.input :name %>
<h3>Items</h3>
<div id="items" data-cocooned-options="<%= { limit: 12 }.to_json %>">
<%= form.fields_for :tasks do |item_form| %>
<%= render 'item_fields', f: item_form %>
<% end %>
<div class="links">
<%= cocooned_add_item_link 'Add an item', form, :items %>
</div>
</div>
<%= form.submit "Save" %>
<% end %>
The reorderable plugin
The reorderable plugin is autoloaded when activated and does not support any particular options.
<% # `app/views/lists/_form.html.haml` %>
<%= form_for @list do |form| %>
<%= form.input :name %>
<h3>Items</h3>
<div id="items" data-cocooned-options="<%= { reorderable: true }.to_json %>">
<%= form.fields_for :tasks do |item_form| %>
<%= render 'item_fields', f: item_form %>
<% end %>
<div class="links">
<%= cocooned_add_item_link 'Add an item', form, :items %>
</div>
</div>
<%= form.submit "Save" %>
<% end %>
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.
<% # `app/views/lists/_item_fields.html.erb` %>
<div class="cocooned-item">
<%= f.label :description %>
<%= f.text_field :description %>
<%= f.check_box :done %>
<%= f.hidden_field :position %>
<%= cocooned_move_item_up_link 'Up', f %>
<%= cocooned_move_item_down_link 'Down', f %>
<%= cocooned_remove_item_link 'Remove', f %>
</div>
Also, remember the strong parameters gotcha we mentioned earlier.
Of course, it means your model must have a position attribute you will use to sort collections.
How it works
Cocooned defines some helper functions:
cocooned_add_item_linkwill 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.cocooned_remove_item_linkwill build a link that, when clicked, dynamically removes the surrounding partial form. Have a look at the documentation for available options.cocooned_move_item_up_linkandcocooned_move_item_down_linkwill 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.
Javascript callbacks
When your collection is modified, the following events can be triggered:
cocooned:before-insert: called before inserting a new nested child, can be canceledcocooned:after-insert: called after insertingcocooned:before-remove: called before removing the nested child, can be canceledcocooned:after-remove: called after removal
The limit plugin can trigger its own event:
cocooned:limit-reached: called when the limit is reached (before a new item will be inserted)
And so does the reorderable plugin:
cocooned:before-move: called before moving the nested child, can be canceledcocooned:after-move: called after movingcocooned:before-reindex: called before updating thepositionfields of nested items, can be canceled (even if I honestly don't know why you would)cocooned:after-reindex: called afterpositionfields update
To listen to the events in your JavaScript:
$('#container').on('cocooned:before-insert', function(event, node, cocoonedInstance) {
/* Do something */
});
An event handler is called with 3 arguments:
The event event is an instance of jQuery.Event and carry some additional data:
event.link, the clicked linkevent.node, the nested item that will be added, removed or moved, as a jQuery object. This is null forcocooned:limit-reachedandcocooned:*-reindexeventsevent.nodes, the nested items that will be or just have been reindexed oncocooned:*-reindexevents, as a jQuery object. Null otherwise.event.cocooned, the Cocooned javascript object instance handling the nested association.event.originalEvent, the original (browser) event.
The node argument is the same jQuery object as event.node.
The cocooned argument is the same as event.cocooned.
Canceling an action
You can cancel an action within the cocooned:before-<action> callback by calling event.preventDefault() or event.stopPropagation().
