Railsamples

Practical examples to master web forms in Rails

Multi-Step Form - With persistence

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)