Check out my consultancy offering at RailsReviews.com!

WizardReflex

Write powerful wizards using page morphs

How?

  • An empty state model is memoized in the controller (@book ||= Book.new(title: ...))
  • A general purpose WizardReflex is used to step through the wizard and perist the model’s state in the session
  • A @current_step variable is in/decreased to display the current wizard pane.
  • Validations are performed contextually, i.e. on: :step_1, etc.
  • An allowlist approach is used to centrally sanitize resource classes and strong params in that reflex.

Caveat

In these examples, the amount of steps per wizard are hardcoded.

Variations

Enrich individual WizardReflexes with custom input processing logic:

class WizardReflex < ApplicationReflex
  def refresh
    additional_attributes, processed_resource_params = yield(resource_params) if block_given?

    session[:"new_#{resource_name.underscore}"] = resource_class.new(processed_resource_params || resource_params)
    session[:"new_#{resource_name.underscore}"].assign_attributes(**additional_attributes || {})

    # ...
  end
  
  # ...
end

class BookReflex < WizardReflex
  def refresh
    super do |params|
      [{isbn: '1234'}, params.except(...)]
    end
  end
end

app/reflexes/wizard_reflex.rb

class WizardReflex < ApplicationReflex
  def refresh
    session[:"new_#{resource_name.underscore}"] = resource_class.new(resource_params)
    
    step = element.dataset.step.to_i
    @current_step = if session[:"new_#{resource_name.underscore}"].valid?("step_#{step}".to_sym) && element.dataset.incr.present?
                         step + element.dataset.incr.to_i
                    else
                      step
                    end

    cable_ready.push_state(url: "?tab=#{@current_step}")
  end

  private

  RESOURCE_ALLOWLIST = {
    "books#new" => "Book",
    "restaurants#new" => "Restaurant"
  }.freeze

  RESOURCE_PARAMS_ALLOWLIST = {
    "books#new" => "book",
    "restaurants#new" => "restaurant"
  }.freeze

  def resource_name
    RESOURCE_ALLOWLIST["#{params["controller"]}##{params["action"]}"]
  end

  def resource_class
    resource_name.safe_constantize
  end

  def resource_params
    # call to private method
    param_name = RESOURCE_PARAMS_ALLOWLIST["#{params["controller"]}##{params["action"]}"]
    controller.send("#{param_name}_params")
  end  
end

app/controllers/books_controller.rb

class BooksController < ApplicationController
  def new
    @current_step ||= 1
    
    # empty state initialization
    @book = session.fetch(:new_book, Book.new(author: "John Doe", title: "Lorem Ipsum", pages: 0))
  end

  private
  
  def book_params
    params.require(:book).permit(:author, :title, :pages)
  end
end

app/models/book.rb

class Book < ApplicationRecord
  validates :author, presence: true, on: :step_1
end

app/views/books/new.html.erb

<div data-step="<%= @current_step %>">
  <ul class="nav nav-pills nav-justified">
    <li class="nav-item">
      <a class="nav-link <%= 'disabled' if @current_step < 1 %> <%= 'active' if @current_step == 1 %>" aria-current="page" href="#">Author</a>
    </li>
    <li class="nav-item">
      <a class="nav-link <%= 'disabled' if @current_step < 2 %> <%= 'active' if @current_step == 2 %>" href="#">Title</a>
    </li>
    <li class="nav-item">
      <a class="nav-link <%= 'disabled' if @current_step < 3 %> <%= 'active' if @current_step == 3 %>" href="#">Meta</a>
    </li>
  </ul>
  
  <div class="btn-group mt-4" role="group" aria-label="Basic outlined example">
    <button type="button" class="btn btn-outline-primary" data-reflex="click->BookWizard#refresh" data-incr="-1" data-reflex-form-selector="#new_book" data-reflex-dataset="combined" <%= 'disabled' if @current_step == 1 %>>Previous</button>
    <button type="button" class="btn btn-outline-primary" data-reflex="click->BookWizard#refresh" data-incr="1" data-reflex-form-selector="#new_book" data-reflex-dataset="combined" <%= 'disabled' if @current_step == 3 %>>Next</button>
  </div>
  
  <%= form_for @book, url: "#", html: {data: {reflex_root: "#new_book"}} do |f| %>
  <div class="tab-content mt-4">
    <div class="tab-pane fade <%= "show active" if @current_step == 1 %>">
      <%= f.label :author, class: "form-label" %>
      <%= f.text_field :author, class: "form-control #{'is-invalid' if @book.errors[:author].present?}" %>
    </div>
    <div class="tab-pane fade <%= "show active" if @current_step == 2 %>">
      <%= f.label :title, class: "form-label" %>
      <%= f.text_field :title, class: "form-control #{'is-invalid' if @book.errors[:title].present?}" %>
    </div>
    <div class="tab-pane fade <%= "show active" if @current_step == 3 %>">
      <%= f.label :pages, class: "form-label" %>
      <%= f.number_field :pages, class: "form-control" %>
    </div>
  </div>
  
  <%= f.submit class: "btn btn-outline-success mt-5", disabled: @current_step != 3 %>
<% end %>
</div>