Rails Wizards! 🎉


Boats

The Boats wizard is just a variation of the Users wizard. In this case we're storing the persitent ID of the record created when the user hits 'New' in the user's session rather than directly in the URL. Still mostly straightforward

The Boats wizard contains no Turbo Frames but Turbo Drive is running, so the full page is being replaced on each navigation/submit. As discussed in Part 7, Turbo Drive expects a 422 status back for form submissions that have validation errors, so this workflow does employ the work-around shown in Part 7.


Name Location docked Length Width Displacement Maximum speed Engine count Color Primary use
Boaty McBoatface Seattle, WA 25 15 120 100 2 Red Booze Show Edit Destroy
asdasd asdasd 44 14 1020 500 1 asdad asdasd Show Edit Destroy
Show Edit Destroy
Henk Dover 12 24 120 12 2 blue Show Edit Destroy
Show Edit Destroy
Boated Boated 12 12 124 11 21 red red Show Edit Destroy
Show Edit Destroy
Show Edit Destroy
Show Edit Destroy
fda fads Show Edit Destroy
Unsinkable Titanic doc 2000000 500000 50000 4500000 80 White Show Edit Destroy
Show Edit Destroy
Show Edit Destroy
Show Edit Destroy
wer wer 123 123 123 123 123 ef df Show Edit Destroy
dfsg sdfg Show Edit Destroy
Show Edit Destroy
Show Edit Destroy

New Boat
Boat Model Code (Click to see)

# Generated from
# rails g scaffold Boat name location_docked length:integer width:integer displacement:integer maximum_speed:integer engine_count:integer color primary_use

class Boat < ApplicationRecord
  enum form_steps: {
    basics: [:name, :location_docked],
    sizes: [:length, :width, :displacement],
    powertrain: [:maximum_speed, :engine_count],
    preferences: [:color, :primary_use]
  }
  attr_accessor :form_step

  with_options if: -> { required_for_step?(:basics) } do
    validates :name, presence: true
    validates :location_docked, presence: true
  end

  with_options if: -> { required_for_step?(:sizes) } do
    validates :length, presence: true, numericality: { greater_than: 5 }
    validates :width, presence: true, numericality: { greater_than: 5 }
    validates :displacement, presence: true, numericality: { greater_than: 100 }
  end

  with_options if: -> { required_for_step?(:powertrain) } do
    validates :maximum_speed, presence: true, numericality: { greater_than: 10 }
    validates :engine_count, presence: true
  end

  with_options if: -> { required_for_step?(:preferences) } do
    validates :color, presence: true
    # Primary use optional
  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

Boat Routes Code (Click to see)

resources :boats
resources :build_boat, only: [:update, :show], controller: 'steps_controllers/boat_steps'

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

class BoatsController < ApplicationController
  before_action :set_boat, only: %i[ show edit update destroy ]

  # GET /boats or /boats.json
  def index
    @boats = Boat.all
  end

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

  # GET /boats/new
  def new
    # If they already started one, use that.
    unless boat_id = session[:boat_id]                                    # Only non-vanilla-Rails code here
      @boat = Boat.new
      @boat.save! validate: false                                         # Only non-vanilla-Rails code here
      session[:boat_id] = @boat.id                                        # Only non-vanilla-Rails code here
    end
    redirect_to build_boat_path(Boat.form_steps.keys.first)               # Only non-vanilla-Rails code here
  end

  # GET /boats/1/edit
  def edit
  end

  # POST /boats or /boats.json
  def create
    @boat = Boat.new(boat_params)

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

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

  # DELETE /boats/1 or /boats/1.json
  def destroy
    @boat.destroy
    respond_to do |format|
      format.html { redirect_to boats_url, notice: "Boat was successfully destroyed." }
      format.json { head :no_content }
    end
  end

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

    # Only allow a list of trusted parameters through.
    def boat_params
      params.require(:boat).permit(:name, :location_docked, :length, :width, :displacement, :maximum_speed, :engine_count, :color, :primary_use)
    end
end

Boat Steps Controller Code (Click to see)

module StepsControllers
  class BoatStepsController < ApplicationController
    include Wicked::Wizard

    steps *Boat.form_steps.keys

    def show
      # Boat record id persisted in session
      @boat = Boat.find session[:boat_id]
      render_wizard
    end

    def update
      @boat = Boat.find session[:boat_id]
      # Use #assign_attributes since render_wizard runs a #save for us
      @boat.assign_attributes boat_params
      # This is the work-around discussed in Part 7
      if @boat.valid? 
        render_wizard(@boat) 
      else
        render_wizard(@boat, status: :unprocessable_entity)
      end
    end

    private

    def boat_params
      params.require(:boat).permit(Boat.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      # Clear out the session cache so the user can create another object
      session[:boat_id] = nil
      boat_path(@boat)
    end
  end
end

Wizard Step 1 View Code (Click to see)

<%= form_with model: @boat, url: wizard_path 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>Boat's Basic Info</legend>

    <div>
      <%= f.label :boat_name %>
      <%= f.text_field :name %>
    </div>

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

    <br/>

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

Wizard Step 2 View Code (Click to see)

<%= form_with model: @boat, url: wizard_path 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>Boat's Sizes</legend>

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

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

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

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

Wizard Step 3 View Code (Click to see)

<%= form_with model: @boat, url: wizard_path 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>Boat's Powertrain</legend>

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

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

    <br/>

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

Wizard Step 4 View Code (Click to see)

<%= form_with model: @boat, url: wizard_path 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>Boat's User-Preferences</legend>

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

    <div>
      <%= f.label 'Primary Use (optional)' %>
      <%= f.text_field :primary_use %>
    </div>

    <br/>

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