Dependent Fields with Hotwire

Episode #472 by Teacher's Avatar David Kimura

Summary

Large forms can be overwhelming to fill out, especially if not all of the fields are required. In this episode, we'll look at creating a stimulus controller to conditionally display fields based on the input of another field.
rails hotwire form stimulusjs 20:10

Chapters

  • Introduction (0:00)
  • Generating the scaffold (2:07)
  • Enum deprecation notice (2:40)
  • Inline Fold Extension config (3:26)
  • Creating the Stimulus Controller (4:14)
  • Adding Event Listeners (5:20)
  • Adding a destructor (5:48)
  • Starting the core business logic (6:10)
  • Simple hiding example (6:46)
  • Applying the inputs to the view (7:38)
  • Conditionally showing the field (8:30)
  • Extracting the value (11:48)
  • Copy/Pasta Fail (14:48)
  • Addming another Event Listener (15:40)
  • Toggling a boolean (15:59)
  • Toggling a select (17:15)
  • Final Thoughts (19:18)

Resources

Download Source Code

Summary

# Terminal
rails g scaffold users name age:integer driver_license:boolean extend_profile:boolean twitter linkedin perferred_method_of_contact email phone
rails g stimulus toggle-fields

# settings.json
"inlineFold.regex": "(class=|className=|class:\\s*)(({(`|))|(['\"`]))(.*?)(\\2|(\\4)})",

# app/models/user.rb
class User < ApplicationRecord
  enum :perferred_method_of_contact, { email: 0, phone: 1 }
end

# app/views/users/_form.html.erb
<%= form_with(model: user) do |form| %>
  <% if user.errors.any? %>
    <div style="color: red">
      <h2><%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:</h2>

      <ul>
        <% user.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="mb-3">
    <%= form.label :name, class: 'form-label' %>
    <%= form.text_field :name, class: 'form-control' %>
  </div>

  <div data-controller="toggle-fields">
    <div class="mb-3">
      <%= form.label :age, class: 'form-label' %>
      <%= form.number_field :age, "data-toggle-fields-target": :input, class: 'form-control' %>
    </div>

    <div data-toggle-fields-target="hidden" data-greater-than=16 class="mb-3 form-check">
      <%= form.label :driver_license, class: 'form-check-label' %>
      <%= form.check_box :driver_license, class: 'form-check-input' %>
    </div>
  </div>

  <div data-controller="toggle-fields">
    <div class="mb-3 form-check">
      <%= form.label :extend_profile, class: 'form-check-label' %>
      <%= form.check_box :extend_profile, "data-toggle-fields-target": :input, class: 'form-check-input' %>
    </div>

    <div data-toggle-fields-target="hidden" data-value=true class="mb-3">
      <%= form.label :twitter, class: 'form-label' %>
      <%= form.text_field :twitter, class: 'form-control' %>
    </div>

    <div data-toggle-fields-target="hidden" data-value=true class="mb-3">
      <%= form.label :linkedin, class: 'form-label' %>
      <%= form.text_field :linkedin, class: 'form-control' %>
    </div>
  </div>

  <div data-controller="toggle-fields">
    <div class="mb-3">
      <%= form.label :perferred_method_of_contact, class: 'form-label' %>
      <%= form.select :perferred_method_of_contact,
        User.perferred_method_of_contacts.keys,
        { include_blank: true },
        "data-toggle-fields-target": :input,
        class: 'form-control' %>
    </div>

    <div data-toggle-fields-target="hidden" data-value="email" class="mb-3">
      <%= form.label :email, class: 'form-label' %>
      <%= form.text_field :email, class: 'form-control' %>
    </div>

    <div data-toggle-fields-target="hidden" data-value="phone" class="mb-3">
      <%= form.label :phone, class: 'form-label' %>
      <%= form.text_field :phone, class: 'form-control' %>
    </div>
  </div>

  <div class="actions">
    <%= form.submit class: 'btn btn-primary' %>
  </div>
<% end %>

# app/javascript/controllers/toggle_fields_controller.js
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="toggle-fields"
export default class extends Controller {
  static targets = ["hidden", "input"]

  connect() {
    this.inputTarget.addEventListener("change", this.toggle.bind(this))
    this.inputTarget.addEventListener("keyup", this.toggle.bind(this))
    this.toggle()
  }

  disconnect() {
    this.inputTarget.removeEventListener("change", this.toggle.bind(this))
    this.inputTarget.removeEventListener("keyup", this.toggle.bind(this))
  }

  toggle() {
    this.hiddenTargets.forEach((target) => {
      const targetValue = target.dataset.value ? target.dataset.value : null
      const greaterThanValue = target.dataset.greaterThan ? parseFloat(target.dataset.greaterThan) : null
      const lessThanValue = target.dataset.lessThan ? parseFloat(target.dataset.lessThan) : null
      console.log(this.value, targetValue, greaterThanValue, lessThanValue);

      let shouldShow = true

      if (targetValue !== null && this.value !== targetValue) { shouldShow = false }
      if (greaterThanValue !== null && this.value < greaterThanValue) { shouldShow = false }
      if (lessThanValue !== null && this.value > lessThanValue) { shouldShow = false }

      if (shouldShow) {
        target.classList.remove("d-none")
        // target.classList.remove("hidden", "sm:hidden", "md:hidden", "lg:hidden")
      } else {
        target.classList.add("d-none")
        // target.classList.add("hidden", "sm:hidden", "md:hidden", "lg:hidden")
      }
    })
  }

  get value() {
    switch (this.inputTarget.type) {
      case "checkbox":
        return this.inputTarget.checked ? "true" : "false"
      case "number":
        return this.inputTarget.value ? parseFloat(this.inputTarget.value) : null
      default:
        return this.inputTarget.value
    }
  }
}