Dependent Select

Episode #517 by Teacher's Avatar David Kimura

Summary

In this episode, we explore how to enhance standard select fields using a JavaScript library together with StimulusJS to create more dynamic and responsive dropdowns. The focus is on adding search functionality, handling dependent selections, and integrating smoothly with modern frontend setups.
rails stimulusjs select form 15:55

Chapters

  • Introduction (0:00)
  • Looking at the existing application (1:36)
  • Adding Tom Select (2:21)
  • Getting the basic functionality working (2:57)
  • Creating a different stimulus controller (4:58)
  • Triggering the filtering (5:42)
  • Creating the search controller (8:22)
  • Testing the request (10:36)
  • Filtering the results (11:03)
  • Updating the other select field (11:41)
  • Handling updating a Tom Select field (14:02)
  • Final Thoughts (14:59)

Resources

Download Source Code

Summary

# Terminal
bin/importmap pin tom-select
yarn add tom-select
bin/rails g stimulus select
bin/rails g stimulus select_with_dependent
rails g controller searches/products

# config/importmap.rb
pin "tom-select", to: "https://cdn.jsdelivr.net/npm/tom-select@2.4.3/+esm"

# app/views/layouts/application.html.erb
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.4.3/dist/css/tom-select.css" rel="stylesheet">

# app/javascript/controllers/select_controller.js
import { Controller } from "@hotwired/stimulus"
import TomSelect from "tom-select"

// Connects to data-controller="select"
export default class extends Controller {
  connect() {
    new TomSelect(this.element)
  }

  disconnect() {
    if (this.element.tomselect) {
      this.element.tomselect.destroy()
    }
  }
}

# app/views/welcome/index.html.erb
<div>
  <h1 class="font-bold text-4xl">Welcome#index</h1>
  <p>Find me in app/views/welcome/index.html.erb</p>

  <%= form_with url: root_path do |form| %>
    <%= form.label :company, "Search companies", class: "font-bold" %>

    <%= form.select :company, Company.all.collect { |c| [c.name, c.id] },
      { prompt: "Select a company" },
      "data-controller": "select-with-dependent",
      "data-action": "change->select-with-dependent#update",
      "data-select-with-dependent-url-value": searches_products_path,
      "data-select-with-dependent-target-value": "product_select",
      "data-select-with-dependent-param-value": :company_id,
      class: "mb-3" %>

    <%= form.label :product, "Search products", class: "font-bold" %>

    <%= form.select :product, Product.all.collect { |c| [c.name, c.id] },
      { prompt: "Select a product" },
      "data-controller": "select",
      id: :product_select,
      class: "mb-3" %>

    <%= form.submit "Search", class: "bg-blue-500 text-white p-2 rounded" %>
  <% end %>
</div>

# config/routes.rb
namespace :searches do
  resources :products, only: :index
end

# app/controllers/searches/products_controller.rb
module Searches
  class ProductsController < ApplicationController
    def index
      @products = Product.where(filter_by_company)
      render json: @products.to_json
    end

    private

    def filter_by_company
      return {} unless params[:company_id].present?

      { company_id: params[:company_id] }
    end
  end
end

# app/javascript/controllers/select_with_dependent_controller.js
import { Controller } from "@hotwired/stimulus"
import TomSelect from "tom-select"

// Connects to data-controller="select-with-dependent"

export default class extends Controller {
  static values = {
    url: String,
    param: "id",
    target: String,
    target_name: "name",
    target_value: "id"
  }

  connect() {
    new TomSelect(this.element)
  }

  disconnect() {
    if (this.element.tomselect) {
      this.element.tomselect.destroy()
    }
  }

  update() {
    const url = new URL(this.urlValue, window.location.origin)
    url.searchParams.set(this.paramValue, this.element.value)
    fetch(url, {
      headers: { Accept: "application/json" }
    })
      .then(response => response.json())
      .then(data => {
        const target = document.getElementById(this.targetValue)
        if (target) {
          const valueKey = this.targetValueValue
          const nameKey = this.targetNameValue
          if (target.tomselect) {
            target.tomselect.clearOptions()
            data.forEach(option => {
              target.tomselect.addOption({ value: option[valueKey], text: option[nameKey] })
            })
            target.tomselect.refreshOptions(false)
          } else {
            target.innerHTML = ""
            data.forEach(option => {
              const opt = document.createElement("option")
              opt.value = option[valueKey]
              opt.textContent = option[nameKey]
              target.appendChild(opt)
            })
          }
        }
      })
  }
}