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 thesession
- 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>