Railsamples

Practical examples to master web forms in Rails

Multi-Step Form - With hidden fields

This example shows how to leverage hidden fields to create multi-step forms. Note that we do not use ActiveRecord but ActiveModel, as the example has no persistence layer. A main JobApplication model wraps all the other models, and validations are applied at each step before proceeding to the next. Hidden fields keep the previous values entered in the form even when not displayed. It's like showing the same form on every page but displaying different sections at each step. While this is not the most common practice, transferring user inputs between pages with POST requests is possible the same way you'd pass parameters in the URL with GET requests.

ENV["SECRET_KEY_BASE"] = "1212312313"

require "bundler/inline"

gemfile do
  source "https://www.rubygems.org"
  gem "uni_rails", "0.4.1"
  gem "byebug"
end

require "uni_rails"
require "byebug"

#  ==== ROUTEs ====

UniRails::App.routes.append do
  root "job_applications#new"
  resource :job_applications, only: [:new, :create] do
    post 'new/personal_details', to: 'job_applications#personal_details'
    post 'new/schools', to: 'job_applications#schools'
    post 'new/references', to: 'job_applications#references'
    post 'new/cover_letter', to: 'job_applications#cover_letter'
    post 'new/review', to: 'job_applications#review'
  end
  resource :job_submission, only: [:show]
end

#  ==== MODELS ====

class JobApplication
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :cover_letter
  validates :cover_letter, presence: true

  attr_accessor :schools, :references, :personal_details

  delegate :name, :address, :email, :phone_number, to: :personal_details

  def initialize(attributes={})
    super
    @schools ||= []
    @references ||= []
    @personal_details ||= PersonalDetails.new
  end

  def valid?
    super

    personal_details.valid?
    errors.merge!(personal_details.errors)

    schools.each do |school|
      next if school.valid?
      errors.add(:schools, 'are invalid') unless errors.where(:schools).present?
    end

    references.each do |reference|
      next if reference.valid?
      errors.add(:references, 'are invalid') unless errors.where(:references).present?
    end

    errors.empty?
  end

  def personal_details_attributes=(params)
    self.personal_details = PersonalDetails.new(params)
  end

  def schools_attributes=(params)
    self.schools = params.map { |_id, attributes| School.new(attributes) }
  end

  def references_attributes=(params)
    self.references = params.map { |_id, attributes| Reference.new(attributes) }
  end

  def build_school(attributes = {})
    self.schools << (school = School.new(attributes))
    school
  end

  def build_reference(attributes = {})
    self.references << (reference = Reference.new(attributes))
    reference
  end
end

class JobApplication
  class PersonalDetails
    include ActiveModel::Model
    include ActiveModel::Attributes

    attribute :name
    validates :name, presence: true

    attribute :address
    validates :address, presence: true

    attribute :phone_number
    validates :phone_number, presence: true

    attribute :email
    validates :email, presence: true
  end

  class Reference
    include ActiveModel::Model
    include ActiveModel::Attributes

    attribute :name
    validates :name, presence: true

    attribute :phone_number
    validates :phone_number, presence: true

    attribute :relationship
    validates :relationship, presence: true
  end

  class School
    include ActiveModel::Model
    include ActiveModel::Attributes

    attribute :name
    validates :name, presence: true

    attribute :degree
    validates :degree, presence: true

    attribute :graduation_year, :integer
  end
end

#  ==== CONTROLLERS ====

class JobApplicationsController < ActionController::Base
  layout 'application'

  before_action :set_application, except: %i[new]

  def new
    @application = JobApplication.new
    @application.build_school
    2.times { @application.build_reference }
  end

  def personal_details
  end

  def schools
    if @application.personal_details.valid?
      render :schools
    else
      render :personal_details
    end
  end

  def references
    @application.schools.each(&:valid?)
    if @application.schools.map(&:errors).all?(&:empty?)
      render :references
    else
      render :schools
    end
  end

  def cover_letter
    @application.references.each(&:valid?)
    if @application.references.map(&:errors).all?(&:empty?)
      render :cover_letter
    else
      render :references
    end
  end

  def review
    if @application.valid?
      render :review
    else
      render :cover_letter
    end
  end

  def create
    if @application.valid?
      render :show
    else
      redirect_to job_submission_path
    end
  end

  private

  def set_application
    @application = JobApplication.new(job_params)
  end

  def job_params
    params
      .require(:job_application)
      .permit(
        :cover_letter,
        personal_details_attributes: [:name, :address, :phone_number, :email],
        schools_attributes: [:name, :degree, :graduation_year],
        references_attributes: [:name, :phone_number, :relationship]
      )
  end
end

class JobSubmissionsController < ActionController::Base
  layout 'application'
  def show
  end
end

#  ==== IMPORT MAPS ====

UniRails.import_maps(
  'stimulus' => 'https://unpkg.com/@hotwired/stimulus/dist/stimulus.js'
)

#  ==== JAVASCRIPT ====

UniRails.javascript <<~JAVASCRIPT

  import { Application, Controller } from "stimulus"
  window.Stimulus = Application.start()

  Stimulus.register("nested-attributes", class extends Controller {
    static targets = ["template", "container"]

    addNestedForm(event) {
      event.preventDefault();

      const uniqueKey = Date.now()
      this.containerTarget.insertAdjacentHTML(
        "beforeend",
        this.templateTarget.innerHTML.replaceAll(/X-ID/g, uniqueKey)
      )
    }

    removeNestedForm(event) {
      event.preventDefault();

      var element = event.target.parentElement
      if (event.params['parent']) {
        element = event.target.closest(event.params['parent'])
      }

      if (element.getAttribute('data-persisted') === 'false') {
        element.remove()
      } else {
        element.style.display = 'none'
        element.querySelector("input[name*='_destroy']").value = '1'
      }
    }
  })

JAVASCRIPT


#  ==== CSS ====

UniRails.css <<~CSS

  html { background-color:#EEE; }
  body { width:500px; height:700px; margin:auto;
    background-color:white; padding:1rem;
  }
  .errors { color: red; }
  form {
    label { display: block; }
    input[type="submit"] { display: block; margin-top:1rem;  }
    .field_with_errors { color:red;  display:inline; }
  }
  fieldset { max-width: 100%; margin-top: 1rem; }
  .form-fields { display: flex; align-items: flex-end; flex-wrap: wrap;
    margin-bottom: 0.5rem;
    .errors { flex-basis:100%; }
  }
  .form-field { margin-right: 1rem; }
  .form-action { display: flex; align-items: center; }

  table.review tr td:first-child { width:200px; }
  table td { padding: 0 1rem; }
CSS


#  ==== VIEWS ====

UniRails.register_view "job_applications/new.html.erb", <<~HTML

  <h1>New job application</h1>

  <%= form_with model: @application, url: new_personal_details_job_applications_path do |f| %>
    <%= render 'hidden_fields', f: f, page: :new %>
    <%= f.submit 'Start application' %>
  <% end %>
HTML


UniRails.register_view "job_applications/personal_details.html.erb", <<~HTML

  <h1>Personal Details</h1>

  <p class="errors"><%= @application.personal_details.errors.full_messages.to_sentence %></p>

  <%= form_with model: @application, url: new_schools_job_applications_path do |f| %>
    <%= render 'hidden_fields', f: f, page: :personal_details %>
    <fieldset>
      <%= f.fields_for :personal_details, @application.personal_details do |ff| %>
        <%= ff.label :name %>
        <%= ff.text_field :name %>

        <%= ff.label :address %>
        <%= ff.text_field :address, size: 50 %>

        <%= ff.label :phone_number %>
        <%= ff.text_field :phone_number %>

        <%= ff.label :email %>
        <%= ff.text_field :email %>
      <% end %>
    </fieldset>
    <%= f.submit 'Next' %>
  <% end %>
HTML


UniRails.register_view "job_applications/schools.html.erb", <<~HTML

  <h1>Schools</h1>

  <% @application.schools.each.with_index(1) do |school, idx| %>
    <% if school.errors.present? %>
      <p class="errors"> School #<%=idx %>: <%= school.errors.full_messages.to_sentence %> </p>
    <% end %>
  <% end %>

  <%= form_with model: @application, url: new_references_job_applications_path do |f| %>
    <%= render 'hidden_fields', f: f, page: :schools %>

    <fieldset data-controller="nested-attributes">
      <div id="schools" data-nested-attributes-target="container">
        <%= f.fields_for :schools, @application.schools do |ff| %>
          <%= render 'school_fields', form_fields: ff %>
        <% end %>
      </div>

      <div><%= link_to 'Add transaction', '#', data: { action: "nested-attributes#addNestedForm" } %> </div>
      <template data-nested-attributes-target="template">
        <%= f.fields_for :schools, JobApplication::School, child_index: 'X-ID' do |ff| %>
          <%= render 'school_fields', form_fields: ff %>
        <% end %>
      </template>
    </fieldset>

    <%= f.submit 'Next' %>
  <% end %>
HTML


UniRails.register_view "job_applications/references.html.erb", <<~HTML

  <h1>References</h1>

  <% @application.references.each.with_index(1) do |reference, idx| %>
    <% if reference.errors.present? %>
      <p class="errors"> Reference #<%=idx %>: <%= reference.errors.full_messages.to_sentence %> </p>
    <% end %>
  <% end %>

  <%= form_with model: @application, url: new_cover_letter_job_applications_path do |f| %>
    <%= render 'hidden_fields', f: f, page: :references %>

    <fieldset>
      <div id="references">
        <%= f.fields_for :references, @application.references do |ff| %>
          <div class="reference form-fields">
            <div class="form-field">
              <%= ff.label :name %>
              <%= ff.text_field :name %>
            </div>

            <div class="form-field">
              <%= ff.label :phone_number %>
              <%= ff.text_field :phone_number %>
            </div>

            <div class="form-field">
              <%= ff.label :relationship %>
              <%= ff.text_field :relationship %>
            </div>
          </div>
        <% end %>
      </div>
    </fieldset>

    <%= f.submit 'Next' %>
  <% end %>
HTML


UniRails.register_view "job_applications/cover_letter.html.erb", <<~HTML

  <h1>Cover Letter</h1>

  <p class="errors"> <%= @application.errors.full_messages_for(:cover_letter).to_sentence %> </p>

  <%= form_with model: @application, url: new_review_job_applications_path do |f| %>
    <%= render 'hidden_fields', f: f, page: :cover_letter %>
    <%= f.text_area :cover_letter, cols: 50, rows: 10 %>
    <%= f.submit 'Next' %>
  <% end %>
HTML


UniRails.register_view "job_applications/review.html.erb", <<~HTML

  <h1>Review your application</h1>
  <%= render 'show' %>
  <%= form_with model: @application do |f| %>
    <%= render 'hidden_fields', f: f, page: :cover_letter %>
    <%= f.submit %>
  <% end %>
HTML


UniRails.register_view "job_applications/_hidden_fields.html.erb", <<~HTML

  <% if page != :personal_details %>
    <%= f.fields_for :personal_details, f.object.personal_details do |ff| %>
      <%= ff.hidden_field :name %>
      <%= ff.hidden_field :address %>
      <%= ff.hidden_field :phone_number %>
      <%= ff.hidden_field :email %>
    <% end %>
  <% end %>

  <% if page != :schools %>
    <%= f.fields_for :schools, f.object.schools do |ff| %>
      <%= ff.hidden_field :name %>
      <%= ff.hidden_field :degree %>
      <%= ff.hidden_field :graduation_year %>
    <% end %>
  <% end %>

  <% if page != :references %>
    <%= f.fields_for :references, f.object.references do |ff| %>
      <%= ff.hidden_field :name %>
      <%= ff.hidden_field :phone_number %>
      <%= ff.hidden_field :relationship %>
    <% end %>
  <% end %>

  <% if page != :cover_letter %>
    <%= f.hidden_field :cover_letter %>
  <% end %>
HTML


UniRails.register_view "job_applications/_school_fields.html.erb", <<~HTML

  <div class="school form-fields" data-persisted="<%= form_fields.object.persisted? %>">
    <div class="form-field">
      <%= form_fields.label :name %>
      <%= form_fields.text_field :name %>
    </div>

    <div class="form-field">
      <%= form_fields.label :degree %>
      <%= form_fields.text_field :degree %>
    </div>

    <div class="form-field">
      <%= form_fields.label :graduation_year %>
      <%= form_fields.number_field :graduation_year, step: 1 %>
    </div>

    <div class="form-action">
      <%= link_to 'remove', '#', data: { action: 'nested-attributes#removeNestedForm', "nested-attributes-parent-param" => ".school" } %>
    </div>
  </div>
HTML


UniRails.register_view "job_applications/_show.html.erb", <<~HTML

  <h2>Personal Details</h2>
  <table class="review">
    <tbody>
      <tr>
        <td>Name</td>
        <td><%= @application.name %></td>
      </tr>
      <tr>
        <td>address</td>
        <td><%= @application.address %></td>
      </tr>
      <tr>
        <td>phone_number</td>
        <td><%= @application.phone_number %></td>
      </tr>
      <tr>
        <td>email</td>
        <td><%= @application.email %></td>
      </tr>
    </tbody>
  </table>

  <h2>School Background</h2>
  <table class="review">
    <tbody>
      <% @application.schools.each do |school| %>
        <tr><td colspan="2"><strong><%= school.name %></strong></td> </tr>
        <tr>
          <td>Degree</td>
          <td><%= school.degree %></td>
        </tr>
        <tr>
          <td>Graduation year</td>
          <td><%= school.graduation_year %></td>
        </tr>
      <% end %>
    </tbody>
  </table>

  <h2>References</h2>
  <table class="review">
    <tbody>
      <% @application.references.each do |reference| %>
        <tr>
          <td colspan="2"><strong><%= reference.name %></strong></td>
        </tr>
        <tr>
          <td>Phone number</td>
          <td><%= reference.phone_number %></td>
        </tr>
        <tr>
          <td>Relationship</td>
          <td><%= reference.relationship %></td>
        </tr>
      <% end %>
    </tbody>
  </table>

  <h2>Cover Letter</h2>
  <%= @application.cover_letter %>
HTML


UniRails.register_view "job_submissions/show.html.erb", <<~HTML

  <h1>You're hired!</h1>
  <%= link_to 'New job application', new_job_applications_path %>
HTML


UniRails.run(Port: 3000)