This article will teach you how to create drop-down selects with Rails and Hotwire. I want to add country and state to the user. When users select a country, we want to fetch all states from this country and add to the states input-select. Let's start.
Instruction
Add gem
bundle gem countries
Create a view with form.
<div class="max-w-128 mx-auto">
<h1 class="font-bold text-4xl mb-12">Complete your details</h1>
<%= form_for @user, url: users_complete_your_profile_update_path, method: :patch do |form| %>
<div class="mb-6">
<%= form.label :country, class: 'input_text_label' %>
<%= form.select :country, options_for_select(@countries, @user.country), { prompt: "Select a country" },
class: 'input_text_field',
data: {
controller: "country-select",
action: "country-select#update_states",
}
%>
</div>
<div class="mb-6">
<%= form.label :state, class: 'input_text_label' %>
<%= form.select :state, options_for_select(@states, @user.state), { prompt: "Select a state" },
class: 'input_text_field'
%>
</div>
<%= form.submit "Complete", class: 'submit_form' %>
<% end %>
</div>
Create routes and controller
module Users
class CompleteYourProfileController < ApplicationController
def edit
@user = current_user
@countries = ISO3166::Country.all_names_with_codes
@states = @user.country? ? ISO3166::Country[@user.country].states.map { |s| [s.last.name, s.first] } : []
end
def states
country = ISO3166::Country[country_code_param]
respond_to do |format|
if country
@states = country.states.map { |s| [s.last.name, s.first] }
else
flash.now[:error] = 'Country not found'
end
format.turbo_stream
end
end
def update
result = Users::CompleteProfile.call(current_user:, params: user_params)
if result.success?
redirect_to authenticated_root_path, notice: result.success
else
respond_to do |format|
flash.now[:error] = result.failure
format.turbo_stream
end
end
end
private
def user_params
params.require(:user).permit(:country, :state)
end
def country_code_param
params.permit(:country_code)[:country_code]
end
end
end
On change country select we trigger Stimulus
controller and send a request to the controller.
// app/javascript/controllers/country_select_controller.js
import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js";
// Connects to data-controller="country-select"
export default class extends Controller {
update_states() {
const country_code = this.element.value
post('/users/complete_your_profile/states', {
query: { country_code },
responseKind: 'turbo-stream'
})
}
}
We send the request to the states action. We found country by the country_code_param and return states. We respond with turbo_stream format.
In response, we want to find element with id user_state
and replace the content to select with country states. If the user sends us a fake data country then we return an error.
<%- if flash[:error].present? %>
<%= render_turbo_stream_flash_messages %>
<% else %>
<%= turbo_stream.replace "user_state" do %>
<%= select_tag :state, options_for_select(@states),
prompt: "Select a state",
name: "user[state]",
id: "user_state",
class: "input_text_field" %>
<% end %>
<% end %>
render_turbo_stream_flash_messages helper to display flash messages
module ApplicationHelper
def render_turbo_stream_flash_messages
turbo_stream.prepend 'notifications' do
flash.map do |_type, data|
content_tag(:div, data)
end.join.html_safe
end
end
end
When user submit the form, we call service with validation params and update user.
module Users
class CompleteProfile < BaseService
def initialize(current_user:, params:)
@current_user = current_user
@params = params
end
def call
country = yield validate_country
yield validate_state(country)
yield update_user
Success('Your profile has been updated')
end
private
attr_reader :current_user, :params
def validate_country
country = ISO3166::Country[params[:country]]
country.present? ? Success(country) : Failure('Country not found')
end
def validate_state(country)
return Success() if country.states.none? && params[:state].blank?
state = country.states[params[:state]]
state.present? ? Success() : Failure('State not found')
end
def update_user
current_user.update!(country: params[:country], state: params[:state])
Success()
rescue ActiveRecord::RecordInvalid => e
Failure(e.message)
end
end
end
If it is a success then we redirect to the page, if failure then we respond with turbo_stream with displaying a flash message.
<!--app/views/users/complete_your_profile/update.turbo_stream.erb-->
ers/complete_your_profile/update.turbo_stream.erb
We already created drop-down selects.