In modern web applications, interactive forms have become integral to delivering a seamless user experience. Traditionally, implementing these kinds of features usually involved some Javascript running on the client side and some asynchronous requests to the backend. However, with the emergence of Stimulus and Hotwire, this process has become even more streamlined and efficient. In this blog post, we will explore the implementation of Nested Select Fields using Stimulus and Hotwire, demonstrating how they can significantly enhance user interaction in a Ruby on Rails project.
For the purpose of this blog post, we will consider a real-world use case where articles need to be managed within an application, categorized into both primary categories and optional subcategories. To implement this, we have the following three models:
class Article < ApplicationRecord
belongs_to :category
belongs_to :subcategory, optional: true
end
class Category < ApplicationRecord
has_many :articles
has_many :subcategories
end
class SubCategory < ApplicationRecord
belongs_to :category
has_many :articles
end
The challenge lies in crafting a form for creating and updating articles where users can pick a category and, if applicable, a subcategory through select fields. We aim to dynamically filter subcategory options based on the selected category and hide the subcategory field when a category with no subcategories is chosen.
This step involves integrating Hotwire and Stimulus, a process which we won’t elaborate on, assuming a fundamental understanding of Ruby on Rails.
Then, utilize Rails Form Helpers to generate a structured form with category and subcategory select fields.
<!-- app/views/articles/new.html.erb --> <h1>New Article</h1> <%= render 'form', article: @article %>
<!-- app/views/articles/edit.html.erb --> <h1>Edit Article</h1> <%= render 'form', article: @article %>
<!-- app/views/articles/_form.html.erb --> <%= form_with model: article do |f| %> <div class="field"> <%= f.label :title %> <%= f.text_field :title %> </div> <div class="field"> <%= f.label :category_id %> <%= f.collection_select :category_id, Category.all, :id, :name, prompt: 'Select a category' %> </div> <div class="field"> <%= f.label :subcategory_id %> <%= f.collection_select :subcategory_id, [], :id, :name, prompt: 'Select a subcategory', disabled: true %> </div> <div class="actions"> <%= f.submit %> </div> <% end %>
This step allows the enhancement of the form's replaceability by enclosing it within a turbo_frame_tag. This is the key to allowing Turbo to find and replace the form using Turbo Streams.
<!-- app/views/articles/_form.html.erb -->
<%= turbo_frame_tag 'article' do %>
<%= form_with model: article do |f| %>
<%# ... %>
<% end %>
<% end %>
In order to develop a generic Stimulus controller to handle the form’s interactivity and communicate with the backend, capturing category selection events so as to trigger those updates to the form.
This controller defines two different targets: the form and the input. Event listeners are added to the input targets so that we fetch the form every time one of these inputs is changed. In our example, we want to replace the form every time the category is changed, so that we can either hide the subcategory input or display the corresponding options. On the other hand, the form target is used to retrieve the form data, which is sent to the backend every time we want to replace the form.
One important factor to consider is that this will only work when the form is being used in its corresponding action. For example, this will work whenever we create a new article in the ArticlesController#new action. This could be potentially extended to different views by adding a new value to the controller with the URL used to retrieve the form.
// app/javascript/controller/form_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form", "input"];
connect() {
this.inputTargets.forEach((input) => {
input.addEventListener("change", this.fetchForm.bind(this));
});
}
async fetchForm() {
const response = await fetch(this.urlWithQueryString(), {
headers: {
'Accept': 'text/vnd.turbo-stream.html',
}
});
const html = await response.text();
Turbo.renderStreamMessage(html);
}
urlWithQueryString() {
return `${this.url()}?${this.queryString()}`;
}
url() {
return window.location.href;
}
queryString() {
const form = new FormData(this.formTarget);
const params = new URLSearchParams();
for (const [name, value] of form.entries()) {
params.append(name, value);
}
return params.toString();
}
}
Handle the already-developed controller’s requests and retrieve the filtered options. In this step, there are two important considerations:
a. We need to assign the article parameters to the article instance in both the new and edit actions to be able to keep the state of the article. Otherwise, the form inputs would be overwritten by the default values.
b. We need to be able to respond to Turbo Stream requests and add the corresponding code that replaces the article form in the view.
# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
def new
@article = Article.new
@article.assign_attributes(article_params) if params[:article].present?
respond_to do |format|
format.html
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
'article',
partial: 'form',
locals: { article: @article }
)
end
end
end
def create
@article = Article.new(article_params)
if @article.save
redirect_to @article
else
render :new
end
end
def edit
@article = Article.find(params[:id])
@article.assign_attributes(article_params) if params[:article].present?
respond_to do |format|
format.html
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
'article',
partial: 'form',
locals: { article: @article }
)
end
end
end
def update
@article = Article.find(params[:id])
if @article.update(article_params)
redirect_to @article
else
render :edit
end
end
private
def article_params
params.require(:article).permit(:title, :category_id, :subcategory_id)
end
end
After completing the steps outlined above, proceed with modifying the form to include the Stimulus controller and the target in HTML. Ensure that the necessary code is added to facilitate filtering of subcategories when a category is selected or to hide the subcategory input if the selected category has no subcategories.
<!-- app/views/articles/_form.html.erb -->
<%= turbo_frame_tag 'article' do %>
<%= form_with model: article, data: { controller: 'form' } do |f| %>
<div class="field">
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div class="field">
<%= f.label :category_id %>
<%= f.collection_select :category_id, Category.all, :id, :name, prompt: 'Select a category', data: { form_target: 'input '} %>
</div>
<% if article.category.present? && article.category.subcategories.any? %>
<div class="field">
<%= f.label :subcategory_id %>
<%= f.collection_select :subcategory_id, article.category.subcategories, :id, :name, prompt: 'Select a subcategory' %>
</div>
<% end %>
<div class="actions">
<%= f.submit %>
</div>
<% end %>
<% end %>
And voila! Now, every time a user selects a category, a Turbo Stream request will be made, and the response will have a turbo_frame with the update form.
The integration of Nested Select Fields using Hotwire and Stimulus offers a potent solution to enhance form interactivity in modern web applications, signifying an advancement in web development and providing an efficient approach to handling dynamic forms and user interactions. Through a real-world example of managing articles with categories and subcategories in a Ruby on Rails project, this article has demonstrated a step-by-step implementation process. By utilizing Rails Form Helpers, Turbo Frames, and a Stimulus controller, developers can create a seamless user experience with dynamic category and subcategory selection with a generic implementation that can also be extended to other resources in the application.
For more in-depth exploration of this and other innovative solutions, including insights, updates, and practical guides on the latest technological advancements, dive into our blog and stay up-to-date in the ever-evolving web development landscape.