Dependent Dropdowns with Hotwire - Rails Tricks Issue 17

29 Aug 2023

This week I will show you how to make dependent dropdowns with Hotwire! I will use a toy app as an example. This app will have a page where addresses can be created. The address will consist of a country, a state, a city, and a postcode. Except for the postcode, we will have a list of options coming from the database and when the user selects the country, we load the states for the selected option. When the user selects the state we will load the cities in that state. Let’s start by generating a Rails app, the necessary models and a scaffold for the address:

$ rails new dependent-dropdown
    create
    create  README.md
    create  Rakefile
  ...
$ cd dependent-dropdown
$ rails g model country name:string
    invoke  active_record
    ...
$ rails g model state country:belongs_to name:string
    invoke  active_record
    ...
$ rails g model city state:belongs_to name:string lat:float lng:float
    invoke  active_record
    ...
$ rails g mode address country:belongs_to state:belongs_to city:belongs_to postcode:string
    invoke  active_record
    ...

I already have an excerpt of a list of countries, states, and cities in the necessary yaml format, so I just moved those into the fixtures folder of the project and loaded them:

$ rails db:migrate db:fixtures:load
== 20230829095520 CreateCountries: migrating ==================================
-- create_table(:countries)
   -> 0.0008s
...

We need to define the has_many associations on the Country and State model, and while we are there, let’s set a default scope to order them by name:

# app/models/country.rb
class Country < ApplicationRecord
  has_many :states
  default_scope -> { order(:name) }
end

# app/models/state.rb
class State < ApplicationRecord
  belongs_to :country
  has_many :cities
  default_scope -> { order(:name) }
end

In the view files, Rails generated text_field inputs for the associations, let’s convert all three to collection_select:

# app/views/addresses/_form.html.erb
...
  <div>
    <%= form.label :country_id, style: "display: block" %>
    <%= form.collection_select :country_id, Country.all, :id, :name, prompt: "Select a country" %>
  </div>

  <div>
    <%= form.label :state_id, style: "display: block" %>
    <%= form.collection_select :state_id, address.country&.states || [], :id, :name, prompt: "Select a state" %>
  </div>

  <div>
    <%= form.label :city_id, style: "display: block" %>
    <%= form.collection_select :city_id, address.state&.cities || [], :id, :name, prompt: "Select a city" %>
  </div>
...

We are getting to the fun part now. The goal is to submit the form when a country is selected, so the states field is populated with the appropriate ones. Let’s add a button underneath the country selector to see how this works:

# app/views/addresses/_form.html.erb
...
<div>
  <%= form.label :country_id, style: "display: block" %>
  <%= form.collection_select :country_id, Country.all, :id, :name, prompt: "Select a country" %>

  <button>Select</button>
</div>
...

If you start the Rails app and click that “Select” button, it submits the form and the the states are loaded. But the whole form is reloaded on the screen, so you see the validation errors on the top. Imagine that this is part of a larger form, that would make this an even worse user experience. But don’t worry, there is turbo frames to the rescue. Let’s wrap the section we want to reload when this button is clicked into a turbo frame, and set the button to reload that frame only:

# app/views/addresses/_form.html.erb
...
<%= turbo_frame_tag f.field_id(:address, :turbo_frame) do %>
  <%= form_with(model: address) do |form| %>
      <div>
        <%= form.label :country_id, style: "display: block" %>
        <%= form.collection_select :country_id, country.all, :id, :name, prompt: "select a country" %>

        <button data-turbo-frame="address_turbo_frame">select</button>
      </div>
      <div>
        <%= form.label :state_id, style: "display: block" %>
        <%= form.collection_select :state_id, address.country&.states || [], :id, :name, prompt: "Select a state" %>
      </div>
      ...
    <% end %>
<% end %>

If you reload the page and click the button again, it will only reload the frame with the three dropdowns. The next step is to trigger the reload on the change event of the select element. To achieve this, we can create a Stimulus controller:

$ rails g stimulus dependent_dropdown
  create  app/javascript/controllers/dependent_dropdown_controller.js

In the controller we need to set a target for the button and a function to trigger a click event when we call it:

# app/javascript/controllers/dependent_dropdown_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="dependent-dropdown"
export default class extends Controller {
  static targets = ['button']

  load() {
    this.buttonTarget.click()
  }
}

And we need to update our view to use this controller:

...
<div data-controller="dependent-dropdown">
  <%= form.label :country_id, style: "display: block" %>
  <%= form.collection_select :country_id, Country.all, :id, :name, { prompt: "Select a country" }, 'data-action': 'change->dependent-dropdown#load'%>

  <button data-turbo-frame="address_turbo_frame"data-dependent-dropdown-target="button">Select</button>
</div>
...

We can also hide the button, since the user no longer needs to click it. And we need to change the markup for the state_id section too and we will have a working dependent dropdown for the address fields.

If you look at the the network tab of the browser, you can see that Rails still returns the markup for the whole page, but since we only need the form, we can at least discard the layout from rendering to gain a little performance improvement. To achieve this, we need to change the render line in the controller to not render the layout when the request format is turbo_frame:

# app/controllers/addresses_controller.rb
...

# POST /addresses or /addresses.json
def create
  @address = Address.new(address_params)

  respond_to do |format|
    if @address.save
      format.html { redirect_to address_url(@address), notice: "Address was successfully created." }
      format.json { render :show, status: :created, location: @address }
    else
      format.html { render :new, status: :unprocessable_entity, layout: !request.format.turbo_stream? }
      format.json { render json: @address.errors, status: :unprocessable_entity }
    end
  end
end
...

If you want to see the source code for the above example, it is on Github: https://github.com/gregmolnar/dependent-dropdown

That’s it for this week! Until next time!

Hire me for a penetration test

Let's find the security holes before the bad guys do.

Did you enjoy reading this? Sign up to the Rails Tricks newsletter for more content like this!

Or follow me on Twitter

Related posts