Dynamic nested form "on the fly" in Rails
NDREAN
Posted on June 5, 2020
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
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
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%>
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
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”}
]}}
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>
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 }
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()
}
})
and we add defer: true
in the file /views/layouts/application.html.erb.
<%= 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.
#/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:
<%= 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 eachbook.title
input, we can add a button:
<%= content_tag('a', 'DEL', id: "del-"+b.index.to_s, class:"badge badge-danger align-self-center") %>
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();
}
});
}
Posted on June 5, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.