Practical examples to master web forms in Rails
This example shows how to create a complex multi-step form with a persistent record to save progress. Note that only one controller is used for all the steps. The main idea in this implementation is that every validation is run through the main job application record instead of the underlying nested records. This method is interesting as it doesn't diverge much from how a single job application form would look with all the fields displayed on one page. It also prevents the creation of several resources or bespoke routes for a job application. Each step renders a subset of the job application fields without knowing anything about the stepping logic. Note that there is only one track for the form, multiple tracks is not possible with this method without a few tweaks. Finally, adding a step is easy. Create a new partial for the job application form that matches the step, matches a next_url to the new step, and adds a link to the navigation on the edit page.
ENV["SECRET_KEY_BASE"] = "1212312313"
ENV["DATABASE_URL"] = "sqlite3:///#{__dir__}/database.sqlite"
require "bundler/inline"
gemfile do
source "https://www.rubygems.org"
gem "uni_rails", "0.4.1"
gem "sqlite3", "~> 1.7"
gem "byebug"
end
require "uni_rails"
require "sqlite3"
require "byebug"
# ==== ROUTES ====
UniRails::App.routes.append do
root "job_applications#index"
resources :job_applications
end
# ==== DB SCHEMA ====
ActiveRecord::Base.establish_connection
ActiveRecord::Schema.define do
create_table :job_applications, force: :cascade do |t|
t.text :cover_letter
t.timestamps
end
create_table :job_application_references, force: :cascade do |t|
t.belongs_to :job_application, null: false
t.string :name
t.string :phone_number
t.string :relationship
t.timestamps
end
create_table :job_application_schools, force: :cascade do |t|
t.belongs_to :job_application, null: false
t.string :name
t.string :degree
t.integer :graduation_year
t.timestamps
end
create_table :job_application_personal_details, force: :cascade do |t|
t.belongs_to :job_application, null: false
t.string :name
t.string :address
t.string :phone_number
t.string :email
t.timestamps
end
end
# ==== MODELS ====
class JobApplication < ActiveRecord::Base
has_one :personal_details
accepts_nested_attributes_for :personal_details, update_only: true
has_many :references
accepts_nested_attributes_for :references, update_only: true
has_many :schools
accepts_nested_attributes_for :schools, allow_destroy: true
validates :cover_letter, presence: true, allow_blank: true
class PersonalDetails < ActiveRecord::Base
belongs_to :job_application
validates_presence_of :name, :address, :phone_number, :email
end
class Reference < ActiveRecord::Base
belongs_to :job_application
validates_presence_of :name, :phone_number, :relationship
end
class School < ActiveRecord::Base
belongs_to :job_application
validates_presence_of :name, :degree
end
end
# ==== HELPERS ====
module JobApplicationsHelper
def step_completed?(boolean)
if boolean
content_tag :span, 'OK', style: 'color:green;'
else
content_tag :span, 'WIP', style: 'color:red;'
end
end
end
# ==== CONTROLLERS ====
class JobApplicationsController < ActionController::Base
layout 'application'
before_action :set_job_application, only: [:personal_details, :schools, :references, :cover_letter]
def index
@applications = JobApplication.all
end
def create
@application = JobApplication.create!
redirect_to edit_job_application_path(@application)
end
def edit
params['step'] ||= 'personal_details'
@application = JobApplication.find(params[:id])
build_records
end
def update
@application = JobApplication.find(params[:id])
if @application.update(job_application_params)
redirect_to params[:redirect_url]
else
render :edit
end
end
private
def build_records
case params['step']
when 'personal_details' then @application.build_personal_details unless @application.personal_details.present?
when 'schools' then @application.schools.build unless @application.schools.present?
when 'references' then 2.times { @application.references.build } unless @application.references.present?
when 'cover_letter'
end
end
helper_method def next_step_url(step)
case step
when 'personal_details' then edit_job_application_path(@application, step: 'schools')
when 'schools' then edit_job_application_path(@application, step: 'references')
when 'references' then edit_job_application_path(@application, step: 'cover_letter')
when 'cover_letter' then job_applications_path
end
end
def set_job_application
@application = JobApplication.find(params[:job_application_id])
end
def job_application_params
params.require(:job_application).permit(
:cover_letter,
personal_details_attributes: [:name, :address, :phone_number, :email],
schools_attributes: [:id, :_destroy, :name, :degree, :graduation_year],
references_attributes: [:id, :name, :phone_number, :relationship]
)
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; }
.errors, .field_with_errors { color:red; display:inline; }
.actions { margin-top:1rem; display: flex; align-items:end;
input[type="submit"] { margin-right: 1rem; }
}
}
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; }
.input-action { display: flex; align-items: center; }
table { width:100%; table-layout: fixed; border-collapse:collapse;
th, td { text-align:left; padding: .5rem; border: 1px solid black; }
.actions { a, .button_to { display: inline; } }
}
CSS
# ==== VIEWS ====
UniRails.register_view "job_applications/index.html.erb", <<~HTML
<h1>Job Applications</h1>
<p><%= button_to 'New application', job_applications_path %></p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Personal Details</th>
<th>Schools</th>
<th>References</th>
<th>Cover Letter</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% @applications.each do |application| %>
<tr>
<td><%= application.id %></td>
<td><%= step_completed?(application.personal_details.present?) %></td>
<td><%= step_completed?(application.schools.present?) %></td>
<td><%= step_completed?(application.references.present?) %></td>
<td><%= step_completed?(application.cover_letter.present?) %></td>
<td><%= link_to 'Edit', edit_job_application_path(application) %></td>
</tr>
<% end %>
</tbody>
</table>
HTML
UniRails.register_view "job_applications/edit.html.erb", <<~HTML
<nav>
<%= link_to 'Personal details', edit_job_application_path(@application, step: 'personal_details' ) %> |
<%= link_to 'Schools', edit_job_application_path(@application, step: 'schools' ) %> |
<%= link_to 'References', edit_job_application_path(@application, step: 'references' ) %> |
<%= link_to 'Cover letter', edit_job_application_path(@application, step: 'cover_letter' ) %>
</nav>
<%= render partial: params['step'], locals: { application: @application } %>
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 do |f| %>
<fieldset>
<%= hidden_field_tag 'step', 'personal_details' %>
<%= hidden_field_tag 'redirect_url', next_step_url('personal_details') %>
<%= f.fields_for :personal_details do |ff| %>
<%= ff.label :name %>
<%= ff.text_field :name %>
<%= ff.label :address %>
<%= ff.text_field :address %>
<%= ff.label :phone_number %>
<%= ff.text_field :phone_number %>
<%= ff.label :email %>
<%= ff.text_field :email %>
<% end %>
<div class="actions">
<%= f.submit 'Next' %>
<%= link_to 'Back', job_applications_path %>
</div>
</fieldset>
<% 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 do |f| %>
<%= hidden_field_tag 'step', 'schools' %>
<%= hidden_field_tag 'redirect_url', next_step_url('schools') %>
<fieldset data-controller="nested-attributes">
<div id="schools" data-nested-attributes-target="container">
<%= f.fields_for :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.new, child_index: 'X-ID' do |ff| %>
<%= render 'school_fields', form_fields: ff %>
<% end %>
</template>
</fieldset>
<div class="actions">
<%= f.submit 'Next' %>
<%= link_to 'Back', job_applications_path %>
</div>
<% end %>
HTML
UniRails.register_view "job_applications/_school_fields.html.erb", <<~HTML
<div class="school form-fields" data-persisted="<%= form_fields.object.persisted? %>" style="<%= 'display:none;' if form_fields.object.marked_for_destruction? %>">
<%= form_fields.hidden_field :id %>
<%= form_fields.hidden_field :_destroy %>
<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="input-action">
<%= link_to 'remove', '#', data: { action: 'nested-attributes#removeNestedForm', "nested-attributes-parent-param" => ".school" } %>
</div>
</div>
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 do |f| %>
<%= hidden_field_tag 'step', 'references' %>
<%= hidden_field_tag 'redirect_url', next_step_url('references') %>
<fieldset>
<div id="references">
<%= f.fields_for :references do |ff| %>
<%= ff.hidden_field :id %>
<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>
<div class="actions">
<%= f.submit 'Next' %>
<%= link_to 'Back', job_applications_path %>
</div>
<% 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 do |f| %>
<%= hidden_field_tag 'step', 'cover_letter' %>
<%= hidden_field_tag 'redirect_url', next_step_url('cover_letter') %>
<%= f.text_area :cover_letter, cols: 50, rows: 10 %>
<div class="actions">
<%= f.submit 'Next' %>
<%= link_to 'Back', job_applications_path %>
</div>
<% end %>
HTML
UniRails.run(Port: 3000)