Prior to the advent of Turbo and Stimulus, my go-to for creating dynamic nested forms was Cocoon which has been around a while and uses jQuery. Tried and true.
Once Stimulus came out, Chris Oliver from GoRails re-implemented Cocoons functionality using Stimulus. This iteration was simplified and removed the dependency on Cocoon and jQuery.
Let's try a new implementation of dynamic nested forms without using any JavaScript!
Getting started
For this example, we'll make a checklist app that has projects and tasks.
rails new checklist
cd checklist
rails generate scaffold project description name
rails generate model task description project:belongs_to
rails db:migrate
To start, We need to update our Project
model to accept attributes for tasks:
# app/models/project.rb
class Project < ApplicationRecord
has_many :tasks
accepts_nested_attributes_for :tasks,
reject_if: :all_blank, allow_destroy: true
end
With Project
aware of tasks, let's modify the Project
form to render any associated Task
fields.
<%# app/views/projects/_form.html.erb %>
<%= form_with(model: project) do |form| %>
<% if project.errors.any? %>
<div style="color: red">
<h2><%= pluralize(project.errors.count, "error") %> prohibited this project from being saved:</h2>
<ul>
<% project.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div>
<%= form.label :name, style: "display: block" %>
<%= form.text_field :name %>
</div>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_field :description %>
</div>
<h4>Tasks</h4>
<div>
<%= form.fields_for :tasks do |task_form| %>
<%= task_form.hidden_field :id %>
<div>
<%= task_form.label :description, style: "display: block" %>
<%= task_form.text_field :description %>
</div>
<% end %>
</div>
<div>
<%= form.submit %>
</div>
<% end %>
When we start up the rails server and go to http://localhost:3000/projects/new
, no tasks get shown.
This is because, in our controller, when we instantiate a Project
, we aren't building any associated Task
s. We can change that by altering the new
action in the ProjectsController
.
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
[...]
def new
@project = Project.new(tasks: [Task.new])
end
[...]
end
Once we reload the page, we now have see the fields for a Task
being rendered.
To successfully submit this form, though, we need to modify our permitted parameters in the ProjectsController
.
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
[...]
private
def project_params
params.require(:project).
permit(:name, :description, tasks_attributes:
[:id, :description, :_destroy])
end
[...]
end
When we added accepts_nested_attributes_for
in our Project
model, it created a method tasks_attributes=(attrs)
that takes in a hash of tasks that it can then use to construct Task
objects. We'll dive more into the structure of the attrs
hash in a bit. You can read more about accepts_nested_attributes_for
here.
Now when we submit this form, a new Project
is created along with a new associated Task
.
Next, we'll move the form inputs for Task
s into their own partial so we can reuse it later.
<%# app/views/tasks/_form.html.erb %>
<%= form.hidden_field :id %>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_field :description %>
</div>
While updating the Project
form to use the new Task
form partial we are also going to add an id
to the surrounding div
so that we can target it in the future with turbo streams.
<%# app/views/projects/_form.html.erb %>
[...]
<h4>Tasks</h4>
<div id="tasks">
<%= form.fields_for :tasks do |task_form| %>
<%= render "tasks/form", form: task_form %>
<% end %>
</div>
[...]
Child Index
Before going any further we have to understand how fields_for
works, and how the tasks_attributes=
method works.
We'll take a look at tasks_attributes=
first.
Using the form submission parameters in our server log, we can see what the tasks_attributes
parameter looks like.
Parameters: {"authenticity_token"=>"[FILTERED]", "project"=>
{"name"=>"project 1", "description"=>"first project",
"tasks_attributes"=>{"0"=>{"description"=>"task 1"}}},
"commit"=>"Create Project"}
You might be thinking 'What's up with that "0"
key?'. That is used as a way for Rails and Rack to uniquely identify each task in our form. When we are dynamically adding new tasks, they won't yet have database ids assigned to them so we need to assign them a temporary identifier to distinguish unique tasks sent to the server. In this case, fields_for
uses a zero-based index.
If we were to have two tasks on our form (you can do this by updating the new
action in our ProjectsController
to build two Task
s on our Project
instead of one) and submit the form you would see parameters that would look like:
{ "tasks_attributes"=>{"0"=>{"description"=>"task 1" },
"1"=>{"description"=>"task 2" } } }
Let's move over to look at fields_for
now.
Calling f.fields_for :tasks do |task_form|
in our form will call the tasks
method on @project
and then loop through each task
creating a scoped form builder. With the scoped form builder we can output the inputs for a Task
.
If we open our browser and inspect the tasks description text field we'll see it has a name of project[tasks_attributes][0][description]
. For our Project
fields the name looks something like project[name]
. Calling fields_for
will add the [tasks_attributes]
scope and since Rails knows this is a has_many
relationship it will add the index as another scope to uniquely identify specific tasks.
We can alter this index by passing in a child_index
parameter on fields_for
. In our Project
s form partial if we update our fields_for
call to be <%= form.fields_for :tasks, child_index: "FOOBAR" do |task_form| %>
and inspect our description field, the fields name is now, project[tasks_attributes][FOOBAR][description]
.
With this knowledge, we can better understand how past implementations of this trick were done. We would render the task form inputs out somewhere hidden on the page, with an easily identifying child_index
. Then when we want to add a new task, we copy the template, gsub
the child_index
for a unique number, and then paste the template into the DOM tree. For removing, we would hide all the inputs, find the _destroy
hidden input, and set it to true.
Let's move on to adding the dynamic parts to our form.
Dynamically removing tasks
We'll start by wrapping the Task
s form inputs in a turbo_frame
.
<%= turbo_frame_tag "task_#{form.index}" do %>
<%= form.hidden_field :id %>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_field :description %>
</div>
<% end %>
Using the child_index
(found by calling index
on the form object) in the turbo_frame id allows us to manipulate the fields for just that Task
.
Next we are going to need a controller for Task
s so that we can remove one. Unlike normal resourceful routes, the route for removing a task requires we pass it the child_index
since it identifies the turbo_frame
we want to target.
We also need an optionally id parameter because when we are editing a Project
we might want to delete an existing Task
, in which case, we will need to pass the database id back to the server so it knows which Task
to destroy.
# config/routes.rb
Rails.application.routes.draw do
resources :projects
resources :tasks, only: [], param: :index do
member do
delete '(:id)' => "tasks#destroy", as: ""
end
end
end
This creates the route:
Prefix Verb URI Pattern Controller#Action
task DELETE /tasks/:index(/:id)(.:format) tasks#destroy
In the controller we need to setup one Project
and one Task
.
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
def destroy
@project = Project.new(tasks: [Task.new])
end
end
A Project
needs to be setup because we are going to recreate the form with different inputs.
<%# app/views/tasks/destroy.html.slim %>
<%= fields model: @project do |form| %>
<%= form.fields_for :tasks, child_index: params[:index] do |task_form| %>
<%= turbo_frame_tag "task_#{task_form.index}" do %>
<%= task_form.hidden_field :id, value: params[:id] %>
<%= task_form.hidden_field :_destroy, value: true %>
<% end %>
<% end %>
<% end %>
This view recreates the Project
form with a Task
but this time there are a few differences.
- We are using the
fields
method rather than theform_with
method because we don't need to render the actual HTML form element we just need a form builder instance. - We pass the
index
param as thechild_index
. - We change the form inputs in the turbo frame to be just the
id
and_destroy
inputs.
Now let's go back to the tasks form
partial and add a button to trigger this.
<%# app/views/tasks/_form.html.erb %>
<%= turbo_frame_tag "task_#{form.index}" do %>
<%= form.hidden_field :id %>
<div>
<%= form.label :description, style: "display: block" %>
<%= form.text_field :description %>
</div>
<%= form.submit "destroy task",
formaction: task_path(form.index, form.object.id),
formmethod: :delete,
formnovalidate: true,
data: { turbo_frame: "task_#{form.index}" } %>
<% end %>
Here we take advantage of the formaction
and formmethod
attribute of submit buttons inside the form to submit a DELETE
request over to our destroy action of Task
s, targeting this turbo frame.
After reloading the page, clicking this button removes our task from the form! Hooray! Now on to adding tasks.
Dynamically adding tasks
Just like removing, let's add a new route:
# config/routes.rb
Rails.application.routes.draw do
resources :projects
resources :tasks, only: [], param: :index do
member do
delete '(:id)' => "tasks#destroy", as: ""
post '/' => "tasks#create"
end
end
end
Our new route looks like:
POST /tasks/:index(.:format) tasks#create
In this case, we don't have need the optional id parameter since this is always a brand new record.
Now let's add a button on our Project
form to add new tasks.
<%# app/views/projects/_form.html.erb %>
[...]
<h4>Tasks</h4>
<div id="tasks">
<%= form.fields_for :tasks do |task_form| %>
<%= render "tasks/form", form: task_form %>
<% end %>
</div>
<%= form.submit "Add task",
formaction: task_path(@project.tasks.size),
formmethod: :post,
formnovalidate: true,
id: "add-task" %>
[...]
We need an id
on our submit button so we can replace the formaction
with an updated index when we add a new task to the form.
Next, we'll move to our TasksController
and setup our new
method. Since it is going to be identical to our destroy
method we can do some cleanup.
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
before_action :setup_project
def new
end
def destroy
end
private
def setup_project
@project = Project.new(tasks: [Task.new])
end
end
Now for our create template. In this case we are going to use a turbo_stream template.
<%# app/views/tasks/create.turbo_stream.erb %>
<%= fields model: @project do |form| %>
<%= form.fields_for :tasks, child_index: params[:index] do |task_form| %>
<%= turbo_stream.replace "add-task" do %>
<%= form.submit "Add task",
formaction: task_path(task_form.index.to_i + 1),
formmethod: :post,
formnovalidate: true,
id: "add-task" %>
<% end %>
<%= turbo_stream.append "tasks" do %>
<%= render "form", form: task_form %>
<% end %>
<% end %>
<% end %>
The first stream replaces our Add task
button with a new one that has a new formaction
pointing to the next index.
The second stream, appends to the #tasks
element a new task form.
If we reload the page and click the "Add task" button, BOOM! A new task is added to the form and we can then remove it.
Top comments (8)
wanted to add an additional modification where the Task fields could be more deeply nested:
The intention of the scope param is to provide the appropriate scope to the Task fields in whichever super form they may be.
The
form
local here is the super form of some other model like Issue, whichhas_one :project
, for example.And now you can control the scope of the inserted task fields should they be nested at a deeper level so you are submitting the correct params. E.g.
issue[project_attributes][tasks_attributes][0][description]: "close+the+issue"
.Amazing article! One note from my side: since the TasksController is not a "standard" controller anyway, I think it would benefit a couple of comments in the code to explain the purpose, and I'd also not strive to be a REST purist here. Using
def add
anddef remove
methods in the controller would probably be even better in this case, to highlight once again that is doing something "special"Hi, thanks for posting this!
I am able to destroy and save one task, but having a hard time getting new tasks to append via the turbo stream and not sure how to troubleshoot. Is there a step that is missing in the Projects Controller or do you have access to the source code or do you have advice on troubleshooting the turbo stream?
When I click add I can see in the rails server that the index is increasing, but just the one task is showing in the form.
This is a very late reply but I found the solution to this issue was within the
create.turbo_stream.erb
,item_form.index
wasnil
meaning that once the initial creation route had fired to create the second task, the route parameters remained the same.To debug, inspect your add more button and note the path, ending in
/1
. Press your button, then reinspect. The parameter will have remained/1
meaning it overwrites the existing additional task with an index of 1.I resolved this issue by modifying
item_form.index
to useparams[:index]
instead, allowing creation of as many additional tasks as required.If anyone else has this problem and can't seem to solve it:
Make sure that the div id (
<div id="X">
surrounding the form.fields_for) and the corresponding turbo_streams target (turbo_stream.append "X"
) are unique and match one another.It's probably better to name them something very specific; here, they are simply
"tasks"
, but you might instead name them"task-form-fields"
, just to make sure you are targeting the right element and only that element - turbo won't really provide any error messages if this goes awry.I know this thread is probably dead by now but thank you so much for this tutorial! For something as common as this there doesn't seem to be many resources out there on how to actually do it. This definitely saved me a lot of headaches.
get this from as well git repo:
github.com/Furqanameen/rails_7_nes...
Great article, I was struggling getting this working myself but your guide certainly helped.