An Exhaustive, Nested Form with dropdown guide

Ian Marshall
5 min readMar 18, 2021

Creating forms for nested resources with Ruby on Rails can be a huge headache, but after scouring the internet for every example I could find in order to create one myself, I can offer a few insights.

This post owes a lot to a few (dare I say incomplete?) resources:

My app, Regrettable, is a celebrity-notes-app-apologies-app, offering a solution for celebrity notes app apologies by allowing users to sign up, apologize for incidents, and forgive others’ apologies with a Forgive button. Here’s a visualization of the basic schema:

While nested forms by themselves are complex, I wanted to create a nested form for incidents and apologies with an option either to use a dropdown of existing incidents or to enter a new incident in a text field along with the apology. This way, users would have the option to be able to log a new incident without having to navigate to a separate form or to select an existing incident to apologize for, which would create a richer user experience overall.

In order to build a form and the associated controller actions to create and properly associate apologies and incidents, you’ll need to leverage methods in your Model, View, and Controller:

First, make sure that your child class can accept nested attributes for its parent class. In my case, this meant adding the below to my Apology class:

#app/models/apology.rbclass Apology < ApplicationRecord
belongs_to :user
belongs_to :incident, inverse_of: :apologies
has_many :forgivenesses
has_many :users, through: :forgivenesses
validates :body, length: {minimum: 50}
validates :incident, presence: :true
validates :user_id, presence: :true
accepts_nested_attributes_for :incident
.
.
.
end

and my Incident class:

#app/models/incident.rbclass Incident < ApplicationRecord
has_many :apologies, inverse_of: :incident
has_many :users, through: :apologies
validates :name, presence: :true
.
.
.
end

By analogy, if you want to create a new owner for a dog (like this), create a new artist for an artwork, create a new account for an invoice, etc, you would want to imitate this relationship between a parent (incident) and child (apology) model. Make sure you have the proper has_many / belongs_to declarations alongside inverse_of in addition to accepts_nested_attributes_for in the child model.

Next, you’ll want to build your form in your views. I created my form in a partial so that I have the flexibility to render it in multiple places in my app, like if a user wanted to edit their apology, or if you wanted to apologize on an incident view page:

#app/views/apologies/_nestedform.html.erb<%= form_for @apology do |f|%>
<div>
<label for="incident_id">Select an incident to apologize for: </label>
<%= f.collection_select :incident_id, @incidents, :id, :name, prompt: true%>
</div><br>
<div>
<%= f.fields_for :incident, @incident do |i|%>
<%= i.label :name, "Or log a new incident:" %>
<%= i.text_field :name %>
<% end %>
</div>
<div>
<%= f.label :body, "Apology:"%>
<%= f.text_area :body%>
</div><br>
<%= f.submit %>

You can see the “nested” part of the nested form right away — as soon as I declare that we’re creating a form for an @apology object, we create a dropdown for existing @incidents and a form field for a new incident.

It’s important to note that for this form to work you have to declare a few different variables in your controller actions. For the form to render, you have to declare the variables in your apologies_controller’s #new action:

#app/controllers/apologies_controller.rbdef new
@incidents = Incident.all #we need this for the dropdown options to appear
@incident = Incident.new #we need for the new incident field
@apology = Apology.new #we need this for the apology field
end

Interestingly enough, without declaring @incident, the form would still render, but without a text field for the new incident name.

You may see other versions of this #new action to create the same result — another way of writing this could be:

def new
@apology = Apology.new
@apology.build_incident
end

One issue I ran into with this method was that it implicitly links the new incident with the new apology — we’ll always be creating a new apology either way, but I wanted to keep the flexibility of associating that apology with an existing incident rather than create a new incident every time.

Now that we have the form (albeit with little to no formatting), we can turn to the #create action in the apologies controller:

#app/controllers/apologies_controller.rbdef create #new create
if params[:apology][:incident_id].to_i > 0 #if you use the dropdown there will be an incident_id >0; if not, it will be "", which is 0
@incident = Incident.find(params[:apology][:incident_id]) # so we find the incident
else
@incident = Incident.find_or_create_by(name: params[:apology][:incident_attributes][:name])#this works if you use the form
end
@apology = Apology.new(user_id: current_user.id, incident_id: @incident.id, body: params[:apology][:body])
if @apology.valid?
@apology.save
redirect_to incident_path(@incident)
else
flash.now[:messages] = @apology.errors.full_messages
@incidents = Incident.all
render :new
end
end

There’s a lot happening here (and I will be the first to admit that there can be more than one way to organize this information!) but the broad strokes of this method hinge on two if/else statements.

First: #create checks the nested params for an incident_id — this would only be present if the user selects an existing incident from the dropdown menu. Since an incident_id will always be a number greater than 0, the first conditional evaluates as true if the user has chosen an existing incident. (Why do only existing incidents have ids, you ask? Well, remember that at this point @incident is simply a blank incident class instance — it hasn’t been persisted to our database, so it hasn’t been assigned an id). If our user did not select an existing incident, then the :incident_id parameters would simply be “”, a blank string, which when converted to an integer would evaluate as 0.

Once this first conditional evaluates, the method assigns @incident to either an existing incident (a choice from the dropdown) or creates a new one with the :name parameter passed in through the form.

Next, a new apology is created with a user_id of the current_user’s id, the body text passed through the form, and the incident_id from our freshly minted @incident variable. At this point, it doesn’t matter whether our user created a new incident or selected an existing one — @incident is the incident. (It’s also worth noting that current_user is a built-in method if you’re using the Devise gem, but you could also define a method that captures that information.)

The second conditional checks whether the new apology is valid (does it satisfy the minimum length? Does it have a user_id?). If yes, the @apology variable is saved and persists in our database. The user is redirected to the @incident view where they can see their apology in a string of other apologies for the same incident (or if they created a new incident, it will be alone). If the apology is not valid (maybe it was too short), an error will appear that will alert the user of the issue when the page reloads. This method also has the benefit of reloading the information the user has written so far — if it were to redirect_to apologies#new, it would create a blank form, which would be frustrating since you’d have to start your apology from scratch.

Without the first conditional, I experienced issues where apologies were returned as invalid because they didn’t have incidents associated with them. Without the second, the integrity of the data you’re trying to collect with your hard-fought, nested form is in jeopardy.

--

--