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();
}
});
}
Top comments (4)
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.
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 ofbooks_attributes
(title
is a field of the modelBook
). Maybe have a look at this: medium.com/@nevendrean/rails-forms... Good luckand this repo github.com/ndrean/Dynamic-Ajax-forms (it's just a toy example for me playing with forms)
Just change the line in author_params
params.require(:author).permit(:name, books_attributes: [:title])
Thanks!