Dynamic forms made easy: Nested Select Fields with Hotwire

post cover picture

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.

 

Managing articles with categories and subcategories

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.

 

Implementation steps:

 

1. Setting up Hotwire and Stimulus in a Ruby on Rails project: 

This step involves integrating Hotwire and Stimulus, a process which we won’t elaborate on, assuming a fundamental understanding of Ruby on Rails.

 

2. Creating the form with Nested Select Fields using Rails Form Helpers: 

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 %>


3. Inserting the Form Inside a Turbo Frame Tag:

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 %>

4. Adding a Stimulus Controller for Interactivity: 

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();
  }
}

 

5. Implementing backend logic to respond to Stimulus controller:

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

 

6. Updating select fields dynamically:

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.

 

Conclusion

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.

 

 

Want to know more about us?

LEARN MORE
post creator picture
Ignacio Grondona
January 29, 2024

Would you like to stay updated?