Creating Dynamic Forms with Hotwire and Turbo Stream in Rails 7:
class Location < ApplicationRecord
has_many :car_brands
end
app/models/car_brand.rb
class CarBrand < ApplicationRecord
belongs_to :location
has_many :models
end
app/models/model.rb
class Model < ApplicationRecord
belongs_to :car_brand
has_many :colors
end
app/models/color.rb
class Color < ApplicationRecord
belongs_to :model
end
Notice that we also added the “rental” stimulus controller in the form in order to handle the JavaScript from the view.
We also added data-target “location” and an action to the location select. We will explain that in the next steps.</p><pre><code class="language-language-ruby">#app/views/rentals/new.html.erb
<%= form_with url: rentals_path, method: :post, data: { controller: "rental" } do |form| %>
<%= form.label :first_name %>
<%= form.test_field :first_name, autocomplete: "given_name" %>
<%= form.label :last_name %>
<%= form.text_field :last_name, autocomplete: "family_name" %>
<%= form.label :address %>
<%= form.text_field :address, autocomplete: "address" %>
<%= form.label :email %>
<%= form.email_field :email, autocomplete: "email" %>
<%= form.label :location_id, 'Location' %>
<%= form.collection_select :location_id, Location.all, :id, :name,
include_blank: true,
data: { rental_target: "location", action: "rental#updateCarBrands" } %>
<div id="car_brands">
<%= render 'car_brands', car_brands: [] %>
</div>
<div id="models">
<%= render 'models', models: [] %>
</div>
<div id="colors">
<%= render 'colors', colors: [] %>
</div>
<%= form.submit "Rent Car" %>
<% end %>
// app/javascript/controllers/rental_controller.js
import { Controller } from '@hotwired/stimulus';
import { Turbo } from '@hotwired/turbo-rails';
export default class extends Controller {
static targets = ["location", "carBrands", "models"]
connect() {
console.log("Rental controller connected");
}
updateCarBrands() {
const locationId = this.locationTarget.value;
this.fetchAndUpdate(`/update_car_brands?location_id=${locationId}`);
}
updateModels() {
const brandId = this.carBrandsTarget.value;
this.fetchAndUpdate(`/update_models?brand_id=${brandId}`);
}
updateColors() {
const modelId = this.modelsTarget.value;
this.fetchAndUpdate(`/update_colors?model_id=${modelId}`);
}
fetchAndUpdate(url) {
fetch(url, {
method: 'GET',
headers: {
Accept: 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': this.getMetaContent('csrf-token'),
'Cache-Control': 'no-cache',
},
})
.then(response => response.ok ? response.text() : Promise.reject('Response not OK'))
.then(html => Turbo.renderStreamMessage(html))
.catch(error => console.error('Error:', error));
}
getMetaContent(name) {
return document.querySelector(`meta[name="${name}"]`).getAttribute('content');
}
}
app/controllers/rentals_controller.rb
class RentalsController < ApplicationController
def update_car_brands
car_brands = CarBrand.where(location_id: params[:location_id])
render turbo_stream: turbo_stream.replace('car_brands', partial: 'car_brands', locals: { car_brands: car_brands })
end
def update_models
models = Models.where(brand_id: params[:brand_id])
render turbo_stream: turbo_stream.replace('models', partial: 'models', locals: { models: models })
end
def update_colors
colors = Colors.where(model_id: params[:model_id])
render turbo_stream: turbo_stream.replace('colors', partial: 'colors', locals: { colors: colors })
end
end
For that we are building partials that will have the updated content from the turbo stream.</p><p>Notice that the car_brands and model partial have data-target and actions. These are going to be rendered in the main form. Each partial calls its own action from the stimulus controller and has its own data-target</p><pre><code class="language-language-ruby">#app/views/rentals/_car_brands.html.erb
<%= form_with model: Rental.new do |form| %>
<%= form.label :car_brand_id, 'Car Brand' %>
<%= form.collection_select :car_brand_id, car_brands, :id, :name,
include_blank: true,
data: { rental_target: "carBrands", action: "rental#updateModels" } %>
<% end %>
app/views/rentals/_models.html.erb
<%= form_with model: Rental.new do |form| %>
<%= form.label :model_id, 'Model' %>
<%= form.collection_select :model_id, models, :id, :name,
include_blank: true,
data: { rental_target: "models", action: "rental#updateColors" } %>
<% end %>
#app/views/rentals/_colors.html.erb
<%= form_with model: Rental.new do |form| %>
<%= form.label :color_id, 'Color' %>
<%= form.collection_select :color_id, colors, :id, :name,
include_blank: true,
data: { rental_target: "colors" } %>
<% end %>
config/routes.rb
Rails.application.routes.draw do
# Other routes
get 'update_car_brands', to: 'rentals#update_car_brands'
get 'update_models', to: 'rentals#update_models'
get 'update_colors', to: 'rentals#update_colors'
end