Rails Wizards! 🎉


Computers

Computers are the 'final' iteration of wizards with database persistence. They give a live example of using database-persistence along with a default_scope (and other scopes) to give the application a more normalized use of the Model's base-level query interface throughout the rest of the application. With this strategy, any time I call a Computer.first I can be sure that the record I'll get is one that has completed all of the necessary form steps, not a partially-complete record.


Manufacturer Year started Model Processor Graphics chip Exterior color Weight
Apple 2010 Hello Hello Hello Blue 234 Show Edit Destroy
ret 2542 3434 67 234 det 434 Show Edit Destroy
Apple 2021 Macbook M1 Nvidia black 277 Show Edit Destroy
test 2222 test test test test 2 Show Edit Destroy
12 1986 asd asd asd asd 123 Show Edit Destroy
lutfl 1987 ktyfd li kutou kiytdfiku 1234 Show Edit Destroy
dss 1986 dupa dupa dupa yo 1 Show Edit Destroy
Intel 1999 UGC-001 P P test 19 Show Edit Destroy
M001 1999 M001 P001 GC001 test 1 Show Edit Destroy
Dell 1990 1 1 1 red 10 Show Edit Destroy
asdf 2019 adsfasdf asdfasdf asdfasdf 4345345 34534534 Show Edit Destroy
sakjdf 2002 asdflja asl;kdjf asdflj asdfkh 15 Show Edit Destroy
Apple 1998 Which Model IS this green 23 Show Edit Destroy

New Computer
Computer Model Code (Click to see)

# Generated from
# rails g scaffold Computer manufacturer year_started model processor graphics_chip exterior_color weight form_completed:boolean

class Computer < ApplicationRecord
  default_scope { where form_completed: true }
  scope :form_not_completed_only, -> { unscope(where: :form_completed).where(form_completed: false) }

  enum form_steps: {
    maker_details: [:manufacturer, :year_started],
    internals: [:model, :processor, :graphics_chip],
    externals: [:exterior_color, :weight]
  }
  attr_accessor :form_step

  with_options if: -> { required_for_step?(:maker_details) } do
    validates :manufacturer, presence: true
    validates :year_started, presence: true, numericality: { greater_than: 1985 }
  end

  with_options if: -> { required_for_step?(:internals) } do
    validates :model, presence: true
    validates :processor, presence: true
    validates :graphics_chip, presence: true
  end

  with_options if: -> { required_for_step?(:externals) } do
    validates :exterior_color, presence: true
    # Weight preference 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

Computer Routes Code (Click to see)

resources :computers do
  resources :steps, only: [:show, :update], controller: 'steps_controllers/computer_steps'
end

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

class ComputersController < ApplicationController
  before_action :set_computer, only: %i[ show edit update destroy ]

  # GET /computers or /computers.json
  def index
    @computers = Computer.all
  end

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

  # GET /computers/new
  def new
    @computer = Computer.new form_completed: false                                  # Only non-vanilla-Rails code here
    @computer.save! validate: false                                                 # Only non-vanilla-Rails code here
    redirect_to computer_step_path(@computer, Computer.form_steps.keys.first)       # Only non-vanilla-Rails code here
  end

  # GET /computers/1/edit
  def edit
  end

  # POST /computers or /computers.json
  def create
    @computer = Computer.new(computer_params)

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

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

  # DELETE /computers/1 or /computers/1.json
  def destroy
    @computer.destroy
    respond_to do |format|
      format.html { redirect_to computers_url, notice: "Computer was successfully destroyed." }
      format.json { head :no_content }
    end
  end

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

    # Only allow a list of trusted parameters through.
    def computer_params
      params.require(:computer).permit(:manufacturer, :year_started, :model, :processor, :graphics_chip, :exterior_color, :weight)
    end
end

Computer Steps Controller Code (Click to see)

module StepsControllers
  class ComputerStepsController < ApplicationController
    include Wicked::Wizard

    steps *Computer.form_steps.keys

    def show
      @computer = Computer.form_not_completed_only.find(params[:computer_id])
      render_wizard
    end

    def update
      @computer = Computer.form_not_completed_only.find(params[:computer_id])
      # Use #assign_attributes since render_wizard runs a #save for us
      @computer.assign_attributes computer_params
      if @computer.valid?
        render_wizard @computer
      else
        render_wizard @computer, status: :unprocessable_entity
      end
    end

    private

    def computer_params
      params.require(:computer).permit(Computer.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      @computer.update! form_completed: true
      computer_path(@computer)
    end
  end
end

Wizard Step 1 View Code (Click to see)

<%= turbo_frame_tag dom_id(@computer) do %>
  <%= form_with model: @computer, 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>Manufacturer Details</legend>

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

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

      <br/>

      <div>
        <%= link_to 'Nevermind', computers_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)

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

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

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

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

      <br/>

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

Wizard Step 3 View Code (Click to see)

<%= turbo_frame_tag dom_id(@computer) do %>
  <%= form_with model: @computer, url: wizard_path, 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>Externals</legend>

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

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

      <br/>

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