Rails Wizards! 🎉


Books

Books represent most of the same premise as Houses but use a cache-key-in-URL approach to better accomodate for the down-sides of routing in-session. This helps the outside view appear to be more database-like even though the partial model persists only in the cache. We take a randomized character approach for this setup.


Title Author name Year published Isbn Primary topic Fictionaility
Yes Meow 2020 27272 Hi Yes Show Edit Destroy
a b 2001 3 a b Show Edit Destroy
lolita nabokov 1965 1201023 lo drama Show Edit Destroy
Agile 001 Ninja 1999 1999 test test Show Edit Destroy
Rrr Trt 2232 23659852 Rr Ggg Show Edit Destroy
Test Test Author 1995 1234 Humor Show Edit Destroy
32 3232 2020 4 a dsds Show Edit Destroy
Some some 1990 3123 sdasdasd dasdasdasd Show Edit Destroy
dw we 2020 321312 fd dff Show Edit Destroy
eee eee 2002 1 1 1 Show Edit Destroy
dsaf dfsa 2121 21 dfsa fda Show Edit Destroy
ww ww 1981 1 a aa Show Edit Destroy
title authoer 1990 123 science ok Show Edit Destroy
thing thingy 2021 something else Show Edit Destroy
test test 5555 fiction Show Edit Destroy

New Book
Book Model Code (Click to see)

# Generated from
# rails g scaffold Book title author_name year_published:integer isbn:integer primary_topic fictionaility

class Book < ApplicationRecord
  FORM_TURBO_FRAME_ID = 'book_multi_step_form'

  enum form_steps: {
    basics: [:title, :author_name],
    organizational: [:year_published, :isbn],
    details: [:primary_topic, :fictionaility]
  }
  attr_accessor :form_step

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

  with_options if: -> { required_for_step?(:organizational) } do
    validates :year_published, presence: true, numericality: { greater_than: 1950 }
    # isbn not required
  end

  with_options if: -> { required_for_step?(:details) } do
    # primary topic not required
    validates :fictionaility, 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

Book Routes Code (Click to see)

resources :books
resources :build_book, only: [] do
  resources :step, only: [:update, :show], controller: 'steps_controllers/book_steps'
end

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

class BooksController < ApplicationController
  before_action :set_book, only: %i[ show edit update destroy ]

  # GET /books or /books.json
  def index
    @books = Book.all
  end

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

  # GET /books/new
  def new
    book_builder_key = Random.urlsafe_base64(6)                                       # Only non-vanilla-Rails code here
    Rails.cache.fetch(book_builder_key) { Hash.new }                                  # Only non-vanilla-Rails code here
    redirect_to build_book_step_path(book_builder_key, Book.form_steps.keys.first)    # Only non-vanilla-Rails code here
  end

  # GET /books/1/edit
  def edit
  end

  # POST /books or /books.json
  def create
    @book = Book.new(book_params)

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

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

  # DELETE /books/1 or /books/1.json
  def destroy
    @book.destroy
    respond_to do |format|
      format.html { redirect_to books_url, notice: "Book was successfully destroyed." }
      format.json { head :no_content }
    end
  end

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

    # Only allow a list of trusted parameters through.
    def book_params
      params.require(:book).permit(:title, :author_name, :year_published, :isbn, :primary_topic, :fictionaility)
    end
end

Book Steps Controller Code (Click to see)

module StepsControllers
  class BookStepsController < ApplicationController
    include Wicked::Wizard

    steps *Book.form_steps.keys

    def show
      book_attrs = Rails.cache.read params[:build_book_id]
      @book = Book.new book_attrs
      render_wizard
    end

    def update
      book_attrs = Rails.cache.read(params[:build_book_id]).merge book_params
      @book = Book.new book_attrs

      if @book.valid?
        Rails.cache.write params[:build_book_id], book_attrs
        redirect_to_next next_step
      else
        render_wizard nil, status: :unprocessable_entity
      end
    end

    private

    def book_params
      params.require(:book).permit(Book.form_steps[step]).merge(form_step: step.to_sym)
    end

    def finish_wizard_path
      book_attrs = Rails.cache.read(params[:build_book_id])
      @book = Book.new book_attrs
      @book.save!
      Rails.cache.delete params[:build_book_id]
      book_path(@book)
    end
  end
end

Wizard Step 1 View Code (Click to see)

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

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

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

      <br/>

      <div>
        <%= link_to 'Nevermind', books_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(@book) do %>
  <%= form_with model: @book, 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>Book Organizing Details</legend>

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

      <div>
        <%= f.label :isbn %>
        <%= f.number_field :isbn %>
      </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(@book) do %>
  <%= form_with model: @book, 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>Book Details</legend>

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

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

      <br/>

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