Dynamic nested form "on the fly" in Rails
Tags: rails, forms, nestedform
Published: 2020-06-05 16:31:52

Rails offers the possibility to create nested forms. By default, this is a static process. It turns that we can add an associated instance field to the form  “[on the fly](https://guides.rubyonrails.org/form_helpers.html#adding-fields-on-the-fly)”, with just 4 lines of Vanilla Javascript, without jQuery nor any JS library nor advanced Ruby stuff.

# TL;TR
In the view rendering the nested form, we inject (with Javascript) modified copies of the associated input field. We previously passed an array of the desired attributes in the 'strong params' and declared the association with `accepts_nested_attributes_for` so we are able to submit this form and commit to the database.

# Setup of the example

We setup a minimalistic example. Take two models Author and Book with respective attributes ‘name’ and ‘title’ of type string. Suppose we have a `one-to-many` association, where every book is associated with at most one author. We run `rails g model Author name`  and `rails g model Book title author:references`. We tweak our models to:

```ruby
#author.rb                                  #book.rb
Class Author < ActiveRecord                 Class Book
  has_many :books                             belongs_to :author
  accepts_nested_attributes_for :books      end 
end
```

The controller should be (the ‘index’ method here is for quick rendering):

```ruby
#/controllers/authors_controller.rb
class AuthorsController < ApplicationController
  def new
    @author = Author.new;  @author.books.build
  end
  def create
    author = Author.create(author_params);   
    redirect_to authors_path
  end

  def index
   render json: Author.all.order(‘create_at DESC’).includes(:books)
               .to_json(only:[:name],include:[books:{only: :title}])
  end
  def author_params
    params.require(:author).permit(:name, books_attributes:[:title])
  end
end
```

The authors#new action uses the `collection.build()` method that comes with the `has_many` method. It will instantiate a nested new Book object that is linked to the Author object through a foreign key. We pass an array of nested attributes for books in the sanitization method `author_params`, meaning that we can pass several books to an author. 

To be ready, set the routes: 
`resources :authors, only: [:index,:new,:create]`.

# The nested form
We will use the gem Simple_Form to generate the form. We build a nested form view served by the controller’s action *authors#new*. It will display two inputs, one for the author’s name and one for the book’s title.

```ruby
#views/authors/new.html.erb
<%= simple_form_for @author do |f| %>
  <%= f.input :name %>
  <%= f.simple_fields_for :books do |b| %>
    <div id=”fieldsetContainer”>
      <fieldset id="0">
        <%= b.input :title %>
      <% end%>
      </fieldset>
    </div>
  <%= f.button :submit %>
<% end%>
```

Note that we have wrapped the input into `fieldset` and `div` tags to help us with the Javascript part for selecting and inserting code as we will see later. We can also build a wrapper, `<%= b.input :title, wrapper: :dynamic %>` in the Simple Form initializer to further clean the code (see note at the end).

# Array of nested attributes
Here is an illustration of how `collection.build` and `fields_for` statically renders an array of the nested attributes. Change the 'authors#new' to:

```ruby
# authors_controller
def new
  @author = Author.new;  @author.books.build;  @author.books.build
end
```
The 'new.html.erb' view is generated by Rails and the `form_for` will use the new `@author` object, and the `fields_for` will automagically render the array of the two associated objects `@books`. If we inspect with the browser's devtools: we have two inputs for the books, and the naming will be  `author[books_attributes][i][title]` where `i` is the index of the associated field (cf [Rails params naming convention](https://guides.rubyonrails.org/form_helpers.html#understanding-parameter-naming-conventions)). This index has to be unique for each input. On submit, the Rails logs will display params in the form:

```
{author : {
  name: “John”,
  books_attributes : [
    “0”: {title: “RoR is great”},
    “1”: {title: “Javascript is cool”}
 ]}}
```


# Javascript snippet

Firstly, add a button **after** the form in the ‘authors/new’ view to trigger the insertion of a new field input for a book title. Add for example:
```html
<button id=”addBook”>Add more books</button>
```

Then the Javascript code. It copies the fragment which contains the input field we want to replicate, and replaces the correct index and injects it into the DOM. With the strong params set, we are able to commit to the database. 

We locate the following code in, for example, the file *javascript/packs/addBook.js*:

```js
const addBook = ()=> {
  const createButton = document.getElementById(“addBook”);
  createButton.addEventListener(“click”, () => {

    const lastId = document.querySelector(‘#fieldsetContainer’).lastElementChild.id;

    const newId = parseInt(lastId, 10) + 1;

    const newFieldset = document.querySelector('[id=”0"]').outerHTML.replace(/0/g,newId);

    document.querySelector(“#fieldsetContainer”).insertAdjacentHTML(
        “beforeend”, newFieldset
    );
  });
}
export { addBook }
```

We attach an event listener on the newly added button and: 
- find the last fieldset tag within the div '#fieldsetContainer' with the lastElementChild method
- read the id: it contains the last index
- increment it: we chose to simply increment this index to get a unique one
- copy the HTML fragment, serializes it with outerHTML, and reindex it with a regex to replace all the “0”s with the new index
- and inject back into the DOM.
Et voilà!

> Note 1: Turbolinks
With Rails 6, We want to wait for Turbolinks to be loaded on the page to use our method. In the file *javascript/packs/application.js*, we add:

```js
import { addBook } from ‘addBook’

document addEventListener(‘turbolinks:load’, ()=> {
  if (document.querySelector(‘#fieldsetContainer’)) {
    addBook()
  }
})
```

and we add `defer: true` in the file */views/layouts/application.html.erb*.

```html
<%= javascript_pack_tag ‘application’, ‘data-turbolinks-track’: ‘reload’, defer: true %>
```

This simple method can be easily adapted for more complex forms. The most tricky part is probably the regex part, to change some indexes “0” to the desired value at the proper location, depending on the form.

> Note 2: Simple Form wrapper
Last tweak, we can create a custom Simple Form input wrapper to reuse this easily.

```ruby
#/config/initializers/simple_form_bootstrap.rb
config.wrappers :dynamic, tag: 'div', class: 'form-group' do |b|
  b.use :html5
  b.wrapper tag: 'div', html: {id: "fieldsetContainer"} do |d|
    d.wrapper tag: 'fieldset', html: { id: "0"} do |f|
      f.wrapper tag: 'div', class: "form-group" do |dd|
        dd.use :label
        dd.use :input, class: "form-control"
      end
    end
  end
end
```

so that we can simplify the form:
```ruby
<%= simple_form_for @author do |f| %>
  <%= f.input :name %>
  <%= f.simple_fields_for :books do |b| %>
    <%= b.input :title, wrapper: :dynamic %>
  <% end%>
  <%= f.button :submit %>
<% end%>
```

> Note 3: Delete
If we want to add a delete button to each `book.title` input, we can add a button:
 ```ruby
 <%= content_tag('a', 'DEL', id: "del-"+b.index.to_s, class:"badge badge-danger align-self-center")  %>
```
and to do something like:
```js
function removeField() {
  document.body.addEventListener("click", (e) => {
    if ( e.target.nodeName === "A" &&
         e.target.parentNode.previousElementSibling) {

/* to prevent from removing the first fieldset as it's previous sibling is null */

      e.target.parentNode.remove();
    }
  });
}
```