Practical examples to master web forms in Rails
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)