Rails Wizards! 🎉


Desks

Desks is the baseline model for the cache-persisted wizard approach. Every time someone hits 'new' we allocate a small amount of space in the Rails Cache to hold all of the model attributes the user submits while getting through all the form steps (validating along the way). At the end we hydrate a new instance of the ActiveRecord model with the attributes and save it as a new, whole, database record.

Because Boats, Desks, and Houses all utilize a session-keyed slot in the Rails cache, the cache key must be amended to include a delineation between the two. Because the House workflow is the 'best' of the three (IMO), I've given it the plain `session.id` key and changed Desks to use a "#{session.id}_desk_form" key format (Boats is changed similarly). You won't need to worry about this if you only have one cache persisted Wizard in your project.


Material preference Color Sit height Stand height Length Width
123 1 1 213 321 Show Edit Destroy
123 12 32 25 Show Edit Destroy
ll lll dddd ddd 333 333 Show Edit Destroy
metal red 4 2 42 24 Show Edit Destroy
poop brown 1 2 32 24 Show Edit Destroy

New Desk
Desk Model Code (Click to see)

# Generated from
# rails g scaffold Desk material_preference color sit_height stand_height length:integer width:integer

class Desk < ApplicationRecord
  enum form_steps: {
    preferences: [:material_preference, :color],
    motor_requirements: [:sit_height, :stand_height],
    desktop_information: [:length, :width]
  }
  attr_accessor :form_step

  with_options if: -> { required_for_step?(:preferences) } do
    validates :material_preference, presence: true
    # color optional
  end

  with_options if: -> { required_for_step?(:motor_requirements) } do
    validates :sit_height, presence: true
    # stand_height optional
  end

  with_options if: -> { required_for_step?(:desktop_information) } do
    validates :length, presence: true, numericality: { greater_than: 31 }
    validates :width, presence: true, numericality: { greater_than: 23 }
  end

  # Checks current step to enable or disable validations appropriately
  def required_for_step?(step)
    # All fields are required if no form step is present
    return true if form_step.nil?
  
    # All fields from previous steps are required
    ordered_keys = self.class.form_steps.keys.map(&:to_sym)
    !!(ordered_keys.index(step) <= ordered_keys.index(form_step))
  end
end

Desk Routes Code (Click to see)

resources :desks
resources :build_desk, only: [:update, :show], controller: 'steps_controllers/desk_steps'

Desks Controller Code (Click to see) (Mostly vanilla Rails scaffold)

class DesksController < ApplicationController
  before_action :set_desk, only: %i[ show edit update destroy ]

  # GET /desks or /desks.json
  def index
    @desks = Desk.all
  end

  # GET /desks/1 or /desks/1.json
  def show
  end

  # GET /desks/new
  def new
    Rails.cache.fetch("#{session.id}_desk_form") { Hash.new }         # Only non-vanilla-Rails code here
    redirect_to build_desk_path(Desk.form_steps.keys.first)           # Only non-vanilla-Rails code here
  end

  # GET /desks/1/edit
  def edit
  end

  # POST /desks or /desks.json
  def create
    @desk = Desk.new(desk_params)

    respond_to do |format|
      if @desk.save
        format.html { redirect_to @desk, notice: "Desk was successfully created." }
        format.json { render :show, status: :created, location: @desk }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @desk.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /desks/1 or /desks/1.json
  def update
    respond_to do |format|
      if @desk.update(desk_params)
        format.html { redirect_to @desk, notice: "Desk was successfully updated." }
        format.json { render :show, status: :ok, location: @desk }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @desk.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /desks/1 or /desks/1.json
  def destroy
    @desk.destroy
    respond_to do |format|
      format.html { redirect_to desks_url, notice: "Desk was successfully destroyed." }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_desk
      @desk = Desk.find(params[:id])
    end

    # Only allow a list of trusted parameters through.
    def desk_params
      params.require(:desk).permit(:material_preference, :color, :sit_height, :stand_height, :length, :width)
    end
end

Desk Steps Controller Code (Click to see)

module StepsControllers
  class DeskStepsController < ApplicationController
    include Wicked::Wizard

    steps *Desk.form_steps.keys

    def show
      desk_attrs = Rails.cache.fetch "#{session.id}_desk_form"
      @desk = Desk.new desk_attrs
      render_wizard
    end

    def update
      desk_attrs = Rails.cache.fetch("#{session.id}_desk_form").merge desk_params
      @desk = Desk.new desk_attrs

      if @desk.valid?
        Rails.cache.write "#{session.id}_desk_form", desk_attrs
        redirect_to_next next_step
      else
        render_wizard nil, status: :unprocessable_entity
      end
    end

    private

    def desk_params
      params.require(:desk).permit(Desk.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      desk_attrs = Rails.cache.fetch("#{session.id}_desk_form")
      @desk = Desk.new desk_attrs
      @desk.save!
      Rails.cache.delete "#{session.id}_desk_form"
      desk_path(@desk)
    end
  end
end

Wizard Step 1 View Code (Click to see)

<%= form_with model: @desk, url: wizard_path, method: :patch do |f| %>
  <% if f.object.errors.any? %>
    <div class="error_messages">
      <% f.object.errors.full_messages.each do |error| %>
        <p><%= error %></p>
      <% end %>
    </div>
  <% end %>

  <fieldset>
    <legend>Preferences</legend>

    <div>
      <%= f.label :material_preference %>
      <%= f.text_field :material_preference %>
    </div>

    <div>
      <%= f.label :color %>
      <%= f.text_field :color %>
    </div>

    <br/>

    <div>
      <%= link_to 'Nevermind', desks_path %>
      <%= f.submit 'Next Step' %>
    </div>
  </fieldset>
<% end %>

Wizard Step 2 View Code (Click to see)

<%= form_with model: @desk, url: wizard_path, method: :patch do |f| %>
  <% if f.object.errors.any? %>
    <div class="error_messages">
      <% f.object.errors.full_messages.each do |error| %>
        <p><%= error %></p>
      <% end %>
    </div>
  <% end %>

  <fieldset>
    <legend>Usage Heights</legend>

    <div>
      <%= f.label :sit_height %>
      <%= f.text_field :sit_height %>
    </div>

    <div>
      <%= f.label :stand_height %>
      <%= f.text_field :stand_height %>
    </div>

    <br/>

    <div>
      <%= link_to 'Previous Step', previous_wizard_path %>
      <%= f.submit 'Continue' %>
    </div>
  </fieldset>
<% end %>

Wizard Step 3 View Code (Click to see)

<%= form_with model: @desk, url: wizard_path, method: :patch do |f| %>
    <% if f.object.errors.any? %>
      <div class="error_messages">
        <% f.object.errors.full_messages.each do |error| %>
          <p><%= error %></p>
        <% end %>
      </div>
    <% end %>

    <fieldset>
      <legend>Desk-Top Information</legend>

      <div>
        <%= f.label :length %>
        <%= f.number_field :length %>
      </div>

      <div>
        <%= f.label :width %>
        <%= f.number_field :width %>
      </div>

      <br/>

      <div>
        <%= link_to 'Previous Step', previous_wizard_path %>
        <%= f.submit 'Complete' %>
      </div>
    </fieldset>
  <% end %>