Rails Wizards! 🎉


Houses

Houses is the model we're using for a cache-persisted multi-step form that also implements Turbo Frames to create a really nice SPA-like experience. Since we're using cache persistence we won't need to worry about partial objects in the database, the ActiveRecord/DB Record will only be created once the full wizard has been completed.

The only downside here is that it requires Rails Cache to be enabled during development, which isn't necessarily the most "Railsy" thing — your cache should ultimately be dispensible and your application should work 100% functionally without it — it should purely be a transparent performance booster for the Rails app. Manifesting application logic (even if it's just letting consumers 'borrow' a little cache space for their mid-wizard form fields) into the Rails cache is generally frowned upon. While not technically breaking MVC since we're not persisting a model via the cache, the dependency on the cache for the system to operate properly has a notable impact. Please take time to assess that impact for your specific use-case.


Address Exterior color Interior color Current family last name Rooms Square feet
1234 Somewhere Road Gray Cream Greatness 3 2300 Show Edit Destroy
23 harry potter road white red qwe 2 2 Show Edit Destroy
1234 Test St Red Blue Moran 4 1000 Show Edit Destroy
dafsafdsafsafsafsafsa fasfdsa fas fdasfas 3232 1 Show Edit Destroy
1233 fake street Blue Green Asdf 10 2000 Show Edit Destroy
nkkkkkkkkkkkkkkk imimi ijiji nkkkkk 9 0 Show Edit Destroy
6 janan mare r034 dfg fdg there 234 234 Show Edit Destroy
3 smith street Red Blue Smith 6 1100 Show Edit Destroy
1, New street Brown White Simpson 7 2000 Show Edit Destroy
2, New street Grey White Wilson 5 3500 Show Edit Destroy

New House
House Model Code (Click to see)

# Generated from
# rails g scaffold House address current_family_last_name interior_color exterior_color rooms:integer square_feet:integer

class House < ApplicationRecord
  enum form_steps: {
    address_info: [:address, :current_family_last_name],
    house_info: [:interior_color, :exterior_color],
    house_stats: [:rooms, :square_feet]
  }
  attr_accessor :form_step

  with_options if: -> { required_for_step?(:address_info) } do
    validates :address, presence: true, length: { minimum: 10, maximum: 50}
    validates :current_family_last_name, presence: true, length: { minimum: 2, maximum: 30}
  end

  with_options if: -> { required_for_step?(:house_info) } do
    validates :interior_color, presence: true
    validates :exterior_color, presence: true
  end

  with_options if: -> { required_for_step?(:house_stats) } do
    validates :rooms, presence: true, numericality: { greater_than: 1 }
    validates :square_feet, presence: true
  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

House Routes Code (Click to see)

resources :houses
resources :build_house, only: [:update, :show], controller: 'steps_controllers/house_steps'

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

class HousesController < ApplicationController
  before_action :set_house, only: %i[ show edit update destroy ]

  # GET /houses or /houses.json
  def index
    @houses = House.all
  end

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

  # GET /houses/new
  def new
    Rails.cache.fetch(session.id) { Hash.new }                     # Only non-vanilla-Rails code here
    redirect_to build_house_path(House.form_steps.keys.first)      # Only non-vanilla-Rails code here
  end

  # GET /houses/1/edit
  def edit
  end

  # POST /houses or /houses.json
  def create
    @house = House.new(house_params)

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

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

  # DELETE /houses/1 or /houses/1.json
  def destroy
    @house.destroy
    respond_to do |format|
      format.html { redirect_to houses_url, notice: "House was successfully destroyed." }
      format.json { head :no_content }
    end
  end

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

    # Only allow a list of trusted parameters through.
    def house_params
      params.require(:house).permit(:address, :exterior_color, :interior_color, :current_family_last_name, :rooms, :square_feet)
    end
end

House Steps Controller Code (Click to see)

module StepsControllers
  class HouseStepsController < ApplicationController
    include Wicked::Wizard

    steps *House.form_steps.keys

    def show
      house_attrs = Rails.cache.fetch session.id
      @house = House.new house_attrs
      render_wizard
    end

    def update
      house_attrs = Rails.cache.fetch(session.id).merge house_params
      @house = House.new house_attrs

      if @house.valid?
        Rails.cache.write session.id, house_attrs
        redirect_to_next next_step
      else
        render_wizard nil, status: :unprocessable_entity
      end
    end

    private

    def house_params
      params.require(:house).permit(House.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      house_attrs = Rails.cache.fetch(session.id)
      @house = House.new house_attrs
      @house.save!
      Rails.cache.delete session.id
      house_path(@house)
    end
  end
end

Wizard Step 1 View Code (Click to see)

<%= turbo_frame_tag dom_id(@house) do %>
  <%= form_with model: @house, 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>Address Info</legend>

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

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

      <br/>

      <div>
        <%= link_to 'Nevermind', houses_path, data: { turbo_frame: :_top } %> <%# NOTE the _top target %>
        <%= f.submit 'Next Step' %>
      </div>
    </fieldset>
  <% end %>
<% end %>

Wizard Step 2 View Code (Click to see)

<div>
  <p>
    This paragraph should never be shown since it would only be rendered by a full
    page load and this workflow should be doing frame-swaps not full page loads (drives)
  </p>
</div>

<%= turbo_frame_tag dom_id(@house) do %>
  <%= form_with model: @house, 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>House Info</legend>

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

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

      <br/>

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

Wizard Step 3 View Code (Click to see)

<%= turbo_frame_tag dom_id(@house) do %>
  <%= form_with model: @house, url: wizard_path, method: :patch, data: { turbo_frame: :_top } do |f| %> <%# NOTE the _top target %>
    <% 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>House Numbers</legend>

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

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

      <br/>

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