Dependent drop-down selects with Rails and Hotwire

Dependent drop-down selects with Rails and Hotwire

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.

image.png

// 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. image.png

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.

image.png

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. Screenshot from 2022-10-31 18-17-46.png