Rafał Jaroszewicz

Create dynamic dependant dropdowns with Turbo and Stimulus in Rails 7

Remember my blog post about dynamic dropdowns in rails 6? Outdated. Now we can do it with Turbo frames and it’s so much fun! Turbo Frames allows you to easily create interactive, partial page updates in your Rails app, while Stimulus provides a simple and lightweight way to add interactivity to your HTML.

When I’m talking Turbo here, I’m only using turbo_frames for now as I’m still getting familiar with the thing but I’ll be updating my blog with a few new posts about further Turbo aspects like streams etc. 🚀

Setup

"@hotwired/stimulus": "^3.2.1",
"@hotwired/turbo-rails": "^7.2.4",
gem "rails"
gem "turbo-rails"
gem "stimulus-rails"

These are our necessities for this example. There are many tutorials out there on how to install and setup these so I won’t bother you with that.

Inside my form, I’ll have two dropdowns that will be dependent on each other.

<div class="flex justify-between gap-3">
  <span class="w-1/2">
    <%= form.label :expensable_type, class: label_class %>
    <%= form.select :expensable_type, Property::Expense.expensable_types,
{ include_blank: true }, class: input_class %>
  </span>

  <% if @property_expense.expensable_type.present? %>
    <span id="expensable_ids" class="w-1/2">
      <%= form.label :expensable_id, class: label_class %>
      <%= form.select :expensable_id,
        @property_expense.expensable_values_array, 
        {}, class: input_class %>
    </span>
  <% end %>
</div>

I am using a helper method here to get the array for existing records in for example EDIT action to show correct current value. The reason I do this is because these are dynamic so in my example it can either be Property::Sale or Property::Rental so I need to decide up front which collection I want to inject into my select tag. I also don’t want to show the second dropdown in the NEW action so user will have to choose the expensable_type first.

def expensable_values_array
  self.expensable_type.constantize.send('name_and_id_array_for_select')
end
def self.name_and_id_array_for_select
  all.map { |object| [object.get_name, object.id] }
end
Initial dropdown on the NEW action is empty, cool!

Adding Turbo and Stimulus

Now, we need to add some flavour to this view, and prepare it for using stimulus and Turbo.

<div class="flex justify-between gap-3">
  <span class="w-1/2">
    <%= form.label :expensable_type, class: label_class %>
    <%= form.select :expensable_type,
      Property::Expense.expensable_types, { include_blank: true },
      class: input_class,
      data: { controller: 'dynamic-select',
        'turbo-type': 'expensable_ids',
        url: fetch_expensables_property_expenses_path } %>
  </span>

  <%= turbo_frame_tag "expensable_ids", class:"w-1/2" do %>
    <% if @property_expense.expensable_type.present? %>
      <span id="expensable_ids" class="w-1/2">
        <%= form.label :expensable_id, class: label_class %>
        <%= form.select :expensable_id,
          @property_expense.expensable_values_array,
          {}, class: input_class %>
      </span>
    <% end %>
  <% end %>
</div>

Few things happened here.

  1. I added data attributes to the first select that will enable me to use them within Stimulus controller as well as connecting this controller.
  2. Added a turbo_frame_tag in which I will input the results from the query of method I’m gonna show next.

As you saw fetch_expensables_property_expenses_path must lead somewhere, and it’s just a simple action within the controller

def fetch_expensables
  expensables = if params[:type].present?
    params[:type].constantize.all.map { |exp| [exp.get_name, exp.id] }
  else
    []
  end
  respond_to do |format|
    format.html { render("property/expenses/frames/expensables_select", locals: { expensables: expensables }) }
  end
end
namespace :property do
  resources :expenses do
    collection do
      get :fetch_expensables
    end
  end
end

In this action I render a partial, and this is the partial we will render upon our action submission

<%= turbo_frame_tag "expensable_ids" do %>
  <% if expensables.present? %>
    <span id="expensable_ids" class="w-1/2">
      <%= label_tag 'property_expense[expensable_id]', nil,
        class: label_class %>
      <%= select_tag 'property_expense[expensable_id]',
        options_for_select(expensables), class: input_class %>
    </span>
  <% end %>
<% end %>

It’s really not that different from the previous view but the crucial part is that our turbo_frame_tag has the same ID as the one in the previous view so that it knows which partial it’s going to replace on our action.

Stimulus controller

Now that we have all our partials and controllers working, let’s get the last part of it, Stimulus controller!

rails g stimulus dynamic-select

There’s really not much to it here.

import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="dynamic-select"
export default class extends Controller {
  connect() {
    this.element.dataset.action = "change->dynamic-select#change"
  }

  change() {
    // get value of the select
    const value = this.element.value
    // get data-url from the select
    const url = this.element.dataset.url
    // fetch turbo-type from the select
    const turboType = this.element.dataset.turboType
    // create new url with the value
    this.url = (`${url}?type=${value}`)

    // replace the turbo-frame with the new url
    let frame = document.querySelector(`turbo-frame#${turboType}`)
    frame.src = this.url
    frame.reload()
  }
}

All we are doing here is using our parameters that we have passed on from the view into this controller and using them to change the source of turbo_frame. Once that is changed, we can reload the frame and get new values working. Magic!

Works like a charm!

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to top