vanilla-nested
Rails dynamic nested forms using vanilla JS
Similar to cocoon, but with no jquery dependency!
Example:
Installation
Just add it to your gemfile
gem 'vanilla_nested'
# or, to use latest code from git:
gem 'vanilla_nested', github: 'arielj/vanilla-nested', branch: :main
Using Sprockets
Require the js:
//= require vanilla_nested
Using Webpacker
Add the package too (gem is required for the helper methods) using:
yarn add vanilla-nested
# or, to use latest code from git:
yarn add arielj/vanilla-nested
And then use it in your application.js as:
import "vanilla-nested";
Using Importmaps in Rails 7
Add the importmap config:
pin "vanilla-nested", to: "vanilla_nested.js", preload: true
And then use it in your application.js as:
import "vanilla-nested";
Updating
To update the gem use either:
gem update vanilla_nested # if using the gem from RubyGems
# or
gem update --source vanilla_nested # if using the gem from github
If using webpacker, you need to update the node package too with:
yarn upgrade vanilla-nested
You can clear the webpacker cache just in case if changes are not reflecting with
rails webpacker:clobber
Usage
Backend prerequisites
# models/order.rb
class Order < ApplicationRecord
has_many :order_items
accepts_nested_attributes_for :order_items, reject_if: :all_blank, allow_destroy: true
# models/order_item.rb
class OrderItem < ApplicationRecord
belongs_to :order
end
class OrdersController < ApplicationController
...
def order_params
params.require(:order).permit(:attr, order_items_attributes: [:id, :attr1, :attr2, :_destroy])
end
end
Adding/Removing nested fields
# orders/_order_item_fields.html.erb
<div class="wrapper-div">
<%= form.text_field :attr1 %>
<%= form.select :attr2 ..... %>
<%= link_to_remove_nested(form) %> <!-- adds a link to add more items -->
</div>
# order/form.html.erb
<%= form_for product do |form| %>
<%= form.text_field :attr %>
<%= link_to_add_nested(form, :order_items, '#order-items') %> <!-- adds a link to add more items -->
<div id='order-items'>
<%= form.fields_for :order_items do |order_item_f| %>
<%= render 'order_item_fields', form: order_item_f %>
<% end %>
</div>
<% end %>
Note that:
-
link_to_remove_nested
receives the nested form as a parameter, it adds a hidden[_destroy]
field -
link_to_add_nested
expects the form builder, the name of the association and the selector of the container where the gem will insert the new fields
Customizing link_to_add_nested
Link text
The default value is Add <name of the associated model>
, but it can be changed using the parameter link_text
:
link_to_add_nested(form, :order_items, '#order-items', link_text: I18n.t(:some_key))
This way you can, for example, internationalize the text.
Link classes
By default, the link to add new fields has the class vanilla-nested-add
which is required, but you can add more classes passing a string:
link_to_add_nested(form, :order_items, '#order-items', link_classes: 'btn btn-primary')
This way you can style the link with no need of targeting the specific vanilla nested class.
Insert position
By default, new fields are appended to the container, you can change that with the insert_method
option. For now, only :append
and :prepend
are supported:
link_to_add_nested(form, :order_items, '#order-items', insert_method: :prepend)
Partial name
The default partial's name is inferred using the association's class name. If your Order has_many :order_items and the class of those items is OrderItem, then the inferred name is order_item_fields
. You can use any partial name you like, though:
link_to_add_nested(form, :order_items, '#order-items', partial: 'my_partial')
Form variable used in the partial
link_to_add_nested
needs to render an empty template in order to later append/prepend it. To do that, it passes the form as a local variable. If your partial uses form
, you don't have to do anything, but if you using another variable name, just customize it here.
# orders/_order_item_fields.html.erb
<div class="wrapper-div">
<%= ff.text_field :attr1 %>
<%= ff.select :attr2 ..... %>
<%= link_to_remove_nested(ff) # adds a link to remove the element %>
</div>
link_to_add_nested(form, :order_items, '#order-items', partial_form_variable: :ff)
You can also pass more local variables to the partial by setting the partial_locals value.
link_to_add_nested(form, :order_items, '#order-items', partial_locals: { key: 'value' })
Tag
The HTML tag that will be generated. An <a>
tag by default.
link_to_add_nested(form, :order_items, '#order-items', tag: 'span')
Tag Attributes
HTML attributes to set for the generated HTML tag. It's a has with key value pairs user for the content_tag
call, so it support any attribute/value pair supported by content_tag
link_to_add_nested(form, :order_items, '#order-items', link_text: '+', tag_attributes: {title: "Add Order"})
# <a ... title="Add Order" ... >+</a>
class
attributes are appended to the vanilla-nested-add
class and the custom classes specified with the link_classes
argument.
link_to_add_nested(form, :order_items, '#order-items', link_text: '+', tag_attributes: {class: "some-class"}, link_classes: 'another-class')
# <a ... class="some-class vanilla-nested-add another-class" ... >+</a>
Link content
If you need html content, you can use a block:
<%= link_to_add_nested(form, :order_items, '#order-items') do %>
<i class='fa fa-plus'>
<% end %>
Customizing link_to_remove_nested
Link text
The default value is "X"
, but it can be changed using the parameter link_text
:
link_to_remove_nested(form, link_text: "remove")
Link content
If you need html content, you can use a block:
<%= link_to_remove_nested(form) do %>
<i class='fa fa-trash'>
<% end %>
Link classes
By default, the link to remove fields has the class vanilla-nested-remove
which is required, but you can add more classes passing a space separated string:
link_to_remove_nested(form, link_classes: 'btn btn-primary')
This way you can style the link with no need of targeting the specific vanilla nested class.
Fields wrapper
By default, the link to remove the fields assumes it's a direct child of the wrapper of the fields. You can customize this if you can't make it a direct child.
# orders/_order_item_fields.html.erb
<div class="wrapper-div">
<fieldset>
<%= ff.text_field :attr1 %>
<%= ff.select :attr2 ..... %>
</fieldset>
<span><%= link_to_remove_nested(ff, fields_wrapper_selector: '.wrapper-div') # if we don't set this, it will only hide the span %></span>
</div>
Note that:
- The link MUST be a descendant of the fields wrapper, it may not be a direct child, but the look up of the wrapper uses JavaScript's
closest()
method, so it looks on the ancestors. - Since this uses JavaScript's
closest()
, there is no IE supported (https://caniuse.com/#search=closest). You may want to add a polyfill or define the method manually if you need to support it.
Tag
The HTML tag that will be generated. An <a>
tag by default.
link_to_remove_nested(ff, tag: 'p')
Tag Attributes
HTML attributes to set for the generated HTML tag. It's a has with key value pairs user for the content_tag
call, so it support any attribute/value pair supported by content_tag
link_to_remove_nested(ff, link_text: 'X', tag_attributes: {title: "Delete!"})
# <a ... title="Delete!" ... >X</a>
class
attributes are appended to the vanilla-nested-remove
class and the custom classes specified with the link_classes
argument, just like the link_to_add_nested
helper.
Undoing
You can tell the plugin to add an "undo" link right after removing the fields (as a direct child of the fields wrapper! this is not customizable!).
link_to_remove_nested(ff, undo_link_timeout: 2000, undo_link_text: I18n.t('undo_remove_fields'), undo_link_classes: 'btn btn-secondary')
Options are:
-
undo_link_timeout
: milliseconds, greater than 0 to turn the feature on, default:nil
-
undo_link_text
: string with the text of the link, great for internationalization, default:'Undo'
-
undo_link_classes
: space separated string, default:''
Events
There are some events that you can listen to add custom callbacks on different moments. All events bubbles up the dom, so you can listen for them on any ancestor.
'vanilla-nested:fields-added'
Triggered right after the fields wrapper was inserted on the container.
document.addEventListener('vanilla-nested:fields-added', function(e){
// e.type == 'vanilla-nested:fields-added'
// e.target == container div of the fields
// e.detail.triggeredBy == the "add" link
// e.detail.added == the fields wrapper just inserted
})
'vanilla-nested:fields-limit-reached'
Triggered right after the fields wrapper was inserted on the container if the current count is >= limit, where limit is the value configured on the model: accepts_nested_attributes_for :assoc, limit: 5
. You can listen to this event to disable the "add" link for example, or to show a warning.
document.addEventListener('vanilla-nested:fields-limit-reached', function(e){
// e.type == 'vanilla-nested:fields-added'
// e.target == container div of the fields
// e.detail.triggeredBy == the "add" link
})
'vanilla-nested:fields-removed'
Triggered when the fields wrapper if fully hidden (aka ""removed""), that is: after clicking the "remove" link with no timeout OR after the timeout finished.
document.addEventListener('vanilla-nested:fields-removed', function(e){
// e.type == 'vanilla-nested:fields-removed'
// e.target == fields wrapper ""removed""
// e.detail.triggeredBy == the "remove" link if no undo action, the 'undo' link if it was triggered by the timeout })
'vanilla-nested:fields-hidden'
Triggered when the fields wrapper if hidden with an undo option.
document.addEventListener('vanilla-nested:fields-hidden', function(e){
// e.type == 'vanilla-nested:fields-hidden'
// e.target == fields wrapper hidden
// e.detail.triggeredBy == the "remove" link
})
Remove vs Hidden
Behind the scene, the wrapper is never actually removed, because we need to send the
[_destroy]
parameter. But there are 2 different stages when removing it.
- If there's no "undo" action configured, the wrapped is set to
display: none
and considered "removed".- If you use the "undo" feature, first the children of the wrapper are hidden (triggering the
hidden
event) and then, after the timeout passes, the wrapper is set todisplay: none
(triggering theremoved
event).
'vanilla-nested:fields-hidden-undo'
Triggered when the user undo the removal using the "undo" link.
document.addEventListener('vanilla-nested:fields-hidden-undo', function(e){
// e.type == 'vanilla-nested:fields-hidden-undo'
// e.target == fields wrapper unhidden
// e.detail.triggeredBy == the "undo" link
})
Testing
You can run the tests following these commands:
- cd test/VanillaNestedTests # move to the rails app dir
- bin/setup # install bundler, gems and yarn packages
- rails test # unit tests
- rails test:system # system tests
If you make changes in the JS files, you have to tell yarn to refresh the code inside the node_modules folder running
./bin/update-gem
(oryarn upgrade vanilla-nested
andrails webpacker:clobber
), and then restart the rails server or re-run the tests.
Version 1.1.0 Changes
Change the method to infere the name of the partial
Before, it used SomeClass.name.downcase
, this created a problem for classes with more than one word:
- User => 'user_fields'
- SomeClass => 'someclass_fields'
Now it uses SomeClass.name.underscore
:
- User => 'user_fields'
- SomeClass => 'some_class_fields'
If you used the old version, you'll need to change the partial name or provide the old name as the partial:
argument.
Fix some RuboCop style suggestions
Mostly single/double quotes, spacing, etc.
Added some Solagraph related doc for the view helpers
Just so Solargraph plugins on editors like VS-Code can give you some documentation.
Added some documentation on the code
Mostly on the javascript code
Added node module config
So it can be used as a node module using yarn to integrate it using webpacker.
Version 1.2.0 Changes
New event for the "limit" option of accepts_nested_attributes_for
You can listen to the vanilla-nested:fields-limit-reached
event that will fire when container has more or equals the amount of children than the limit
option set on the accepts_nested_attributes_for
configuration.
Version 1.2.1 Changes
Removed "onclick" attribute for helpers and add event listeners within js
If you were using webpacker, remember to replace the vanilla_nested.js file in your app/javascript folder
Version 1.2.2 Changes
Added "link_classes" option to "link_to_remove_nested"
You can set multiple classes for the "X" link
Added a "link_content" block parameter for both link helpers
You can pass a block to use as the content for the add and remove links
Version 1.2.3 Changes
Fix using nested html elements as the content for buttons
There was an error when using the helpers with things like:
<%= link_to_add_nested(form, :pets, '#pets') do %>
<span>Add Pet</span>
<% end %>
It would detect the wrong element for the click event, making the JS fail.
Version 1.2.4 Changes
Play nicely with Turbolinks' turbolinks:load
event.
Version 1.2.5 Changes
License change from GPL to MIT
Version 1.3.0 Changes
Custom generated HTML element tag
The default HTML tag generated by link_to_add/remove_nested
is an A
tag, but it can now be customized with the tag:
keyword argument:
<%= link_to_add_nested(form, :pets, '#pets', tag: 'span') %>
Extra class added to dynamically added fields
Elements added dynamically now have an extra added-by-vanilla-nested
class, used internally and helpful for styling.
Removed elements are actually removed from the document
Before, the elements were just hidden using display: none
on the wrapper. That lead to issues when the input elements had validations that were triggered by the browser but the elements where not accessible anymore. With this new version, removing a nested set of fields removes all the content.
Correct calculation for the limit-reached event
If the accepts_nested_attributes_for
configuration has a limit, this gem was counting the number of children wrong (it was counting removed elements and extra children of the wrapper). This fixes that by only counting the [_destroy]
hidden fields with value 0
.
Version 1.4.0 Changes
Custom HTML attributes for the generated HTML element tag
Both link_to_add_nested
and link_to_remove_nested
now support a tag_attributes
keyword argument with key value pairs representing attributes and values for the generated HTML tag.
You can use this to customize the tag as you need:
- you can add a
title
for youra
tag to make them more accessible:
link_to_remove_nested(ff, link_text: 'X', tag_attributes: {title: "Delete!"})
# <a ... title="Delete!" ... >X</a>
- you can set a
type
,name
andvalue
for abutton
tag if you want to have a non-javascript submit action to fallback in case the user has Javascript disabled:
link_to_add_nested(form, :order_items, '#order-items', tag: 'button', tag_attributes: {type: 'submit', name: 'commit', value: 'add-nested' })
# now you have a button tag that will submit your form with a `commit` param with `add-nested` as the value to handle a non-javascript fallback to add nested fields an re-render the form!
- you can set any valid html attribute accepted by
content_tag
Version 1.5.0 Changes
Added Integration with the turbo
Gem
The JavaScript part of the gem now plays nicely with the turbo
gem by initializing the needed events when the turbo:load
event is fired.
Version 1.5.1 Changes
Yarn/NPM Packages
Node package can be installed using npm or yarn without using the GitHub repo. This improves the size of the bundle and allows version flags.
Version 1.6.0 Changes
Fix undeclared variables
#45 thanks @gmeir.
Added engine config to support importmaps
You can pin the vanilla-nested module. A Rails 7 sample app is added to the test directory.
Version 1.6.1 Changes
Fix elements' style after undo
When undoing a removal, the gem was setting display: initial
to all the elements. Now it sets the display
value it had before hidding the element.
Remember to update both gem and package https://github.com/arielj/vanilla-nested#update
Version 1.6.2 Changes
Event listeners are now added once to the document
Attach the vanilla-nested event listeners to the document object. This fixes issues with turbo/hotwire where the listener was not being attached to the new elements added to the DOM. Thanks to @lenilsonjr for testing these changes!
Version 1.7.0 Changes
Fix initialization of Vanilla Nested when using importmaps and Safari
The shim to support importmaps in Safari was not firing the load events as the code was expecting. This patches that by considering if the DOM is already ready when the code loads.
Version 1.7.1 Changes
New classes added
When removing a nested element, a hidden-by-vanilla-nested
class is added if there's a timeout. The class is removed if the action is undone.
When removing a nested element, a removed-by-vanilla-nested
class is added if there's no timeout or after the timeout expires.
Thanks to @kikyous for the contribution!
Tests in multiple Ruby versions
Automated tests now run in multiple Ruby versions in CI.
This change has no impact in the use of the gem, but I wanted to thank @petergoldstein for their contribution!
New partial_locals
option for link_to_add_nested
Now you can pass a new option with locals
for the fields wrapper partial. Check #64 for an example. Thanks to @gregogalante for the contribution!