DEV Community

NDREAN
NDREAN

Posted on • Updated on

Dynamic nested form "on the fly" in Rails

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”, 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:

#author.rb                                  #book.rb
Class Author < ActiveRecord                 Class Book
  has_many :books                             belongs_to :author
  accepts_nested_attributes_for :books      end 
end
Enter fullscreen mode Exit fullscreen mode

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

#/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
Enter fullscreen mode Exit fullscreen mode

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.

#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%>
Enter fullscreen mode Exit fullscreen mode

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:

# authors_controller
def new
  @author = Author.new;  @author.books.build;  @author.books.build
end
Enter fullscreen mode Exit fullscreen mode

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). 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”}
 ]}}
Enter fullscreen mode Exit fullscreen mode

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:

<button id=”addBook”>Add more books</button>
Enter fullscreen mode Exit fullscreen mode

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:

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 }
Enter fullscreen mode Exit fullscreen mode

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:

import { addBook } from addBook

document addEventListener(turbolinks:load, ()=> {
  if (document.querySelector(‘#fieldsetContainer)) {
    addBook()
  }
})
Enter fullscreen mode Exit fullscreen mode

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

<%= javascript_pack_tag application’, data-turbolinks-track: reload’, defer: true %>
Enter fullscreen mode Exit fullscreen mode

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.

#/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
Enter fullscreen mode Exit fullscreen mode

so that we can simplify the form:

<%= 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%>
Enter fullscreen mode Exit fullscreen mode

Note 3: Delete
If we want to add a delete button to each book.title input, we can add a button:

 <%= content_tag('a', 'DEL', id: "del-"+b.index.to_s, class:"badge badge-danger align-self-center")  %>
Enter fullscreen mode Exit fullscreen mode

and to do something like:

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();
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Top comments (4)

Collapse
 
fullstackpete profile image
peter richards

so quick question. maybe I'm missing something obvious, but ive been trying to figure out how to integrate "on the fly" fields for forms on Rails 6 using as little javascript as possible and not breaking the Rails methods for submitting data to the controllers. I like the start you have here, but I am running into an issue with your code.

the section in the controller for Authors_Controller:
def author_params
params.require(:author).permit(:name, books_attributes[:title])
end

throws an error every time because the books_attributes[.....] comes up as an undefined method. I'd really like to find a solution to getting this running as I am trying to develop an app that uses normalized data through 3 models and allows for selecting names from a drop down and the titles of that person from a drop down as well, and then linking them to a team. The form is complex as it also allows for adding new names into the name drop down area. The title types are fixed and cannot be added to. I've seen everything from massive javascript that clones and mutates but then doesnt submit the data to the controller because the ID's are not unique or not within the model. Your method looks simple and if you can help me figure out the broken books_attributes issue that would be great. I've tried several different solutions to no avail.

Collapse
 
ndrean profile image
NDREAN • Edited

The model used here is very simple, a one-to-many with Author 1<n Book. Make sure the Author model accepts_nested_attributes_for :books so you can pass an array of books_attributes (titleis a field of the model Book). Maybe have a look at this: medium.com/@nevendrean/rails-forms... Good luck

Collapse
 
ndrean profile image
NDREAN • Edited

and this repo github.com/ndrean/Dynamic-Ajax-forms (it's just a toy example for me playing with forms)

Collapse
 
chaturvediankit10 profile image
Ankit • Edited

Just change the line in author_params

params.require(:author).permit(:name, books_attributes: [:title])

Thanks!