Skip to content

Rails dynamic nested forms using vanilla JS

License

Notifications You must be signed in to change notification settings

arielj/vanilla-nested

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

92 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

vanilla-nested

Rails dynamic nested forms using vanilla JS

Similar to cocoon, but with no jquery dependency!

Example:

sample-gif

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 to display: none (triggering the removed 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 (or yarn upgrade vanilla-nested and rails 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 your a 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 and value for a button 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!