Multi-step Wizard
Enlisting at the adventurers' guild in four steps. Each step is saved as you leave it. The marks along the top show which steps are correct, and cross-step validation is in place: a change on one step may break another. The rules are written once, in Ruby.
Enlist at the guild
The guild roll 0 enlisted today
- Nobody has signed the roll today.
# frozen_string_literal: true
# Enlisting at the guild, a step at a time. The form holds every step's
# fields as signals, so only the step on show is on the page. Leaving a
# step saves the draft, which the model lets through because a draft has
# no rules. Nothing here names a rule, or says which mark to update.
class Recipes::Wizard::WizardChannel < ApplicationCable::Channel
include Hibiki::Rails::Channel
Enlistment = Recipes::Wizard::Enlistment
EnlistmentForm = Recipes::Wizard::EnlistmentForm
Trail = Recipes::Wizard::Trail
VIEWS = "app/recipes/wizard"
# Typed numbers are cut to this many digits, so a draft always fits its column.
DIGITS = 3
NUMBERS = %i[age years].freeze
def build_graph
enlistment = Enlistment.resume(params[:draft])
@form = EnlistmentForm.from(enlistment)
@step = Hibiki::State.new(enlistment.stage_at(params[:step]))
@reached = Hibiki::State.new(enlistment.reached)
@tried = Hibiki::State.new(nil)
@token = Hibiki::State.new(enlistment.token)
@turns = Hibiki::State.new(0)
@saves = Hibiki::State.new(0)
@trail = Hibiki::Derived.new do
Trail.new(step: @step.value, reached: @reached.value, tried: @tried.value, wrong: @form.wrong)
end
# The marks along the top. A field on one step can turn another step's
# mark, because `wrong` is read from every field.
Hibiki::Effect.new do
broadcast_morph target: "wizard_trail", partial: "#{VIEWS}/trail", locals: { trail: @trail.value }
end
# One line of text under each field, sent only when the text changes.
# A field that is not on the page has no placeholder to write to.
EnlistmentForm.hibiki_attributes.each do |field|
transmit_value(:"wizard_error_#{field}") { @trail.value.message(@form, field) }
end
# The step on show is sent when the visitor turns to another, and not
# while they type in it.
Hibiki::Effect.new do
next unless @turns.value.positive?
paint :step
end
# The weapons on offer follow the path.
Hibiki::Effect.new do
@form.path
paint :weapon if @step.peek == Enlistment::STAGES.index(:trade)
end
# Back, Next, Sign and the line beside them.
Hibiki::Effect.new do
broadcast_morph target: "wizard_actions", partial: "#{VIEWS}/actions",
locals: { form: @form, trail: @trail.value }
end
# The roll is read again when anyone signs, here or in another tab. It
# is plain values, so a ping that changes nothing sends nothing.
roll = Hibiki::Derived.new do
@saves.value
Enlistment.roll
end
Hibiki::Effect.new do
broadcast_morph target: "wizard_roll", partial: "#{VIEWS}/roll",
locals: { roll: roll.value, mine: @form.record.id }
end
# The draft's address, once it has a row to come back to, and the step
# on show. Each turn is an entry in the browser's history.
transmit_url(history: :push) do
step = Enlistment::STAGES[@step.value] if @token.value
urls.app_recipe_path("wizard", draft: @token.value, step:)
end
end
def set_field(data)
name = data["field"].to_s.to_sym
return if signed? || EnlistmentForm.hibiki_attributes.exclude?(name)
value = data[name.to_s]
value = value.first(NUMBERS.include?(name) ? DIGITS : 60) if value.is_a?(String)
@form.public_send(:"#{name}=", value)
end
# On to the next step, if this one is right. If not, it now says why.
def next
return unless @step.value < Enlistment::REVIEW
return @tried.value = @step.value unless @form.step_valid?(@trail.value.stage)
turn_to @step.value + 1
end
def back
turn_to @step.value - 1 if @step.value.between?(1, Enlistment::REVIEW)
end
# A click on a mark, or on Change in the review.
def go(data)
to = Enlistment::STAGES.index(data["to"].to_s.to_sym)
turn_to to if to && !signed? && to <= [ @reached.value, Enlistment::REVIEW ].min
end
# Back or Forward in the browser, over the steps this page turned to.
# The step is whatever the address says, so it is held to where the
# draft has been.
def visit(data)
return if signed?
to = Enlistment::STAGES.index(data["step"].to_s.to_sym) || 0
turn_to [ to, @reached.value, Enlistment::REVIEW ].min
end
# The one save with rules. `complete?` asked the same validations a
# moment ago, so a refusal here is rare; the review then lists it.
def sign
return unless @trail.value.review? && @form.complete?
@form.record.assign_attributes(signed_at: Time.current, reached: Enlistment::SIGNED)
@form.record.assign_attributes(signed_at: nil, reached: Enlistment::REVIEW) unless @form.commit
show @form.record.reached
end
# A new enlistment, after one is signed.
def again
return unless signed?
@form.hydrate(Enlistment.start)
show 0
end
private
def subscribed
super
return if subscription_rejected?
# An enlistment signed in another tab. The block runs on a cable
# thread, so it hops onto the graph thread before touching a signal.
stream_from Enlistment::CHANGED do
graph_actor&.post { @saves.value += 1 }
end
end
def signed? = @step.value == Enlistment::SIGNED
def urls = Rails.application.routes.url_helpers
# Every turn saves the draft first, valid or not.
def turn_to(step)
created = !@form.persisted?
@form.record.reached = [ @reached.value, step ].max
@form.commit
Enlistment.prune! if created
show step
end
# The record is not a signal, so what the page needs from it is copied
# into states here.
def show(step)
@step.value = step
@reached.value = @form.record.reached
@token.value = @form.record.token
@tried.value = nil
@turns.value += 1
@saves.value += 1 if signed?
end
# Renders a partial without subscribing to what it reads.
def paint(name)
Hibiki.untrack do
broadcast_replace target: "wizard_#{name}", partial: "#{VIEWS}/#{name}",
locals: { form: @form, trail: @trail.value }
end
end
end
# frozen_string_literal: true
# The enlistment as signals, all steps of it at once. The rules are the
# model's: `draft` is a copy marked as signed, which is when the model's
# validations apply, and `problems` is what they say, sorted by step.
class Recipes::Wizard::EnlistmentForm
include Hibiki::Rails::ReactiveForm
Enlistment = Recipes::Wizard::Enlistment
reactive_attributes "Recipes::Wizard::Enlistment",
:name, :age, :hometown, :path, :weapon, :years, :rank, :guardian, :pledge
derived(:draft) { Enlistment.new(**to_h, signed_at: Time.current) }
derived(:live_errors) { draft.tap(&:validate).errors.to_hash.transform_values(&:first) }
# Per-step validity: { you: { age: "..." }, trade: {}, oath: {} }.
derived(:problems) { Enlistment::STEPS.transform_values { live_errors.slice(*it) } }
derived(:wrong) { problems.reject { |_, messages| messages.empty? }.keys }
derived(:complete?) { wrong.empty? }
derived(:weapons) { draft.weapons }
derived(:carried) { draft.carried }
derived(:guardian_needed?) { draft.guardian_needed? }
def step_valid?(step) = wrong.exclude?(step)
# What a refused save said comes first; the live answer otherwise.
def error_for(name) = super || live_errors[name.to_sym]
end
# frozen_string_literal: true
# Where a visitor is in the wizard: the step on show, the furthest stage
# reached, the step whose Next was refused, and the steps that have
# something wrong. The marks along the top and the choice of which
# messages to show are worked out from those four, for the page and for
# the channel.
class Recipes::Wizard::Trail < Data.define(:step, :reached, :tried, :wrong)
Enlistment = Recipes::Wizard::Enlistment
STAGES = Enlistment::STAGES
TITLES = { you: "You", trade: "Trade", oath: "Oath", review: "Review" }.freeze
# One mark of the trail. `state` is :done, :wrong or :todo; `open` says
# whether the visitor may go there.
Mark = Data.define(:name, :title, :number, :state, :current, :open)
# An enlistment as the page first shows it: at the furthest stage it got to.
def self.at(reached, wrong:) = new(step: reached, reached:, tried: nil, wrong:)
def stage = STAGES[step]
def signed? = step == Enlistment::SIGNED
def review? = step == Enlistment::REVIEW
def first? = step.zero?
def marks
TITLES.each_with_index.map do |(name, title), index|
Mark.new(name:, title:, number: index + 1, state: state(name, index), current: index == step,
open: !signed? && index <= reached)
end
end
# A step says what is wrong with it once the visitor has been past it,
# or has pressed Next on it. A step seen for the first time says nothing.
def shown?(name)
index = STAGES.index(name)
index < reached || index == tried
end
def message(form, field) = (form.error_for(field) if shown?(Enlistment.step_of(field)))
private
def state(name, index)
return :todo unless index < reached
wrong.include?(name) ? :wrong : :done
end
end
# frozen_string_literal: true
# An enlistment at the adventurers' guild in the Multi-step Wizard recipe.
# It is filled in over several steps and saved as each one is left, so a
# row is a draft for most of its life.
#
# A draft has no rules: every validation below applies only once the
# enlistment is signed. The form asks the same validations about a copy
# that it marks as signed, and sorts the answers by step.
#
# The rows are whatever visitors saved, and the newest signed few are the
# guild's roll, shown to everyone. Recipes::Reset empties the table each
# night, and `prune!` caps it in between.
class Recipes::Wizard::Enlistment < ApplicationRecord
self.table_name = "recipes_wizard_enlistments"
# The fields each step asks for. A message belongs to the step that has
# its field.
STEPS = {
you: %i[name age hometown],
trade: %i[path weapon years],
oath: %i[rank guardian pledge]
}.freeze
# Every place an enlistment can be. `reached` is an index into these.
STAGES = [ *STEPS.keys, :review, :signed ].freeze
REVIEW = STAGES.index(:review)
SIGNED = STAGES.index(:signed)
HOMETOWNS = %w[Brackenford Saltmere Hollowdown Thistlewick Greywater].freeze
PATHS = {
"fighter" => %w[sword axe mace],
"ranger" => %w[longbow crossbow sling],
"mage" => %w[staff wand]
}.freeze
# The years of practice each rank asks for.
RANKS = { "novice" => 0, "journeyman" => 3, "master" => 10 }.freeze
AGES = (12..90)
GUARDIAN_UNDER = 16
# Nobody starts to practise before this age.
PRACTICE_FROM = 5
# How many rows `prune!` leaves, and how many of them the roll shows.
KEEP = 200
ROLL = 5
# Every open page listens here for an enlistment signed by someone else.
CHANGED = "recipes:wizard:enlistments"
# The guild's roll: how many have signed, and the newest of them.
Roll = Data.define(:count, :entries)
Entry = Data.define(:id, :name, :hometown, :calling)
has_secure_token :token, on: :create
scope :signed, -> { where.not(signed_at: nil) }
# A draft saved at each step would wake every open page for nothing.
after_commit(if: :saved_change_to_signed_at?) { self.class.announce }
# Signing drops a guardian the age no longer calls for. A draft keeps it,
# in case the age is put back.
before_validation(if: :signed?) { self.guardian = "" unless guardian_needed? }
with_options if: :signed? do
validates :name, presence: { message: "The roll needs a name." },
length: { maximum: 30, message: "Thirty letters is all the roll has room for." }
validates :age, numericality: { only_integer: true, in: AGES,
message: "The guild takes recruits from #{AGES.min} to #{AGES.max}." }
validates :hometown, inclusion: { in: HOMETOWNS, message: "Say where you are from." }
validates :path, inclusion: { in: PATHS.keys, message: "Choose a path." }
validates :years, numericality: { only_integer: true, greater_than_or_equal_to: 0,
message: "Say how many years. None is an answer." }
validate :weapon_suits_the_path, :practice_fits_the_age
validates :rank, inclusion: { in: RANKS.keys, message: "The guild has no such rank." }
validates :guardian, length: { maximum: 30, message: "Thirty letters is all the roll has room for." }
validate :rank_is_earned, :guardian_for_the_young, :oath_is_sworn
end
def self.start = new
# The draft a page was working on, found by the token in its address.
def self.resume(token) = find_by(token: token.to_s.presence) || start
def self.step_of(field) = STEPS.keys.find { STEPS[it].include?(field) }
def self.roll
Roll.new(count: signed.count, entries: signed.order(signed_at: :desc, id: :desc).limit(ROLL).map(&:entry).reverse)
end
# A ping with nothing in it. Whoever hears it reads the roll again.
def self.announce = ActionCable.server.broadcast(CHANGED, {})
# `delete_all` runs no callback, so the ping is sent by hand.
def self.reset!
delete_all
announce
end
# Drops all but the newest rows, drafts and signed alike.
def self.prune! = where.not(id: order(id: :desc).limit(KEEP).select(:id)).delete_all
def signed? = signed_at.present?
# The stage an address opens at: the step it names, if this enlistment
# has been that far, and otherwise the furthest one. The name comes from
# the address bar, so it is only ever looked up.
def stage_at(name)
index = STAGES.index(name.to_s.to_sym)
index && index <= [ reached, REVIEW ].min && reached < SIGNED ? index : reached
end
def weapons = PATHS.fetch(path, [])
# The weapon, while the path still offers it. Changing the path leaves
# the old pick in the column, and this is what stops it counting.
def carried = (weapon if weapons.include?(weapon))
def guardian_needed? = age.present? && age < GUARDIAN_UNDER
def calling = "#{rank} #{path}"
def entry = Entry.new(id:, name:, hometown:, calling:)
private
def weapon_suits_the_path
errors.add(:weapon, path.present? ? "Choose a weapon a #{path} carries." : "Choose a weapon.") unless carried
end
def practice_fits_the_age
return unless age.in?(AGES) && years
most = age - PRACTICE_FROM
errors.add(:years, "At #{age} you cannot have practised for more than #{most} years.") if years > most
end
def rank_is_earned
needed = RANKS[rank]
return unless needed && years && years < needed
errors.add(:rank, "A #{rank} has #{needed} years of practice. You have #{years}.")
end
def guardian_for_the_young
errors.add(:guardian, "Under #{GUARDIAN_UNDER}, a guardian signs with you.") if guardian_needed? && guardian.blank?
end
def oath_is_sworn
errors.add(:pledge, "The guild takes no one who will not swear.") unless pledge
end
end
<%# The page finds the draft by the token in its address, as the channel
will, and shows the step the address names, or the furthest stage it
got to. A page with no token starts a new one, which has no row until
its first step is left. `popstate` is the browser's Back and Forward. %>
<% enlistment = Recipes::Wizard::Enlistment.resume(params[:draft]) %>
<% form = Recipes::Wizard::EnlistmentForm.from(enlistment) %>
<% step = enlistment.stage_at(params[:step]) %>
<% trail = Recipes::Wizard::Trail.new(step:, reached: enlistment.reached, tried: nil, wrong: form.wrong) %>
<%= island Recipes::Wizard::WizardChannel, params: { draft: enlistment.token, step: params[:step] }.compact,
class: "panel mx-auto w-full max-w-2xl p-4 sm:p-5", **on(:visit, event: :popstate) do %>
<header class="flex items-baseline justify-between gap-2">
<h2 class="text-xl font-bold">Enlist at the guild</h2>
<span class="loading loading-spinner loading-xs hidden in-data-hibiki-busy:inline-block" aria-hidden="true"></span>
</header>
<%= render "app/recipes/wizard/roll", roll: Recipes::Wizard::Enlistment.roll, mine: enlistment.id %>
<%= render "app/recipes/wizard/trail", trail: %>
<%= render "app/recipes/wizard/step", form:, trail: %>
<%= render "app/recipes/wizard/actions", form:, trail: %>
<% end %>
<%# locals: (trail:) -%>
<%# The marks along the top, morphed by the channel. A step the visitor
has been past is a tick or a cross; a click goes back to it. %>
<% looks = { done: [ "step-primary", "✓" ], wrong: [ "step-error", "✕" ], todo: [ "", nil ] } %>
<ol id="wizard_trail" class="steps mt-4 w-full">
<% trail.marks.each do |mark| %>
<% colour, sign = looks.fetch(mark.state) %>
<%= tag.li id: "wizard_mark_#{mark.name}", class: [ "step", colour, ("step-primary" if mark.current) ],
data: { content: sign || mark.number, state: mark.state }, aria: { current: ("step" if mark.current) } do %>
<%= tag.button mark.title, type: "button", disabled: !mark.open || mark.current,
class: [ "text-sm", mark.current ? "font-bold" : "enabled:cursor-pointer enabled:underline disabled:opacity-60" ],
**on(:go, with: { to: mark.name }) %>
<% end %>
<% end %>
</ol>
<%# locals: (form:, trail:) -%>
<%# The step on show. Replaced by the channel when the visitor turns to
another step, and never while they type, so the fields keep what is in
them. The values come from the form, which holds every step. Enter
is Next. %>
<% field = ->(name, event = :input) { on(:set_field, event:, with: { field: name }) } %>
<% error = ->(name) { tag.p(reactive(:"wizard_error_#{name}", trail.message(form, name)), class: "min-h-5 text-sm text-error") } %>
<div id="wizard_step" class="mt-4">
<% if Recipes::Wizard::Enlistment::STEPS.key?(trail.stage) %>
<%= tag.form id: "wizard_fields", class: "grid gap-x-3 gap-y-1 sm:grid-cols-2",
**on(:next, event: :submit, reset: false) do %>
<%= render "app/recipes/wizard/steps/#{trail.stage}", form:, trail:, field:, error: %>
<% end %>
<% else %>
<%= render "app/recipes/wizard/steps/#{trail.stage}", form: %>
<% end %>
</div>
<%# locals: (form:, trail:, field:, error:) -%>
<label class="flex flex-col gap-1 sm:col-span-2">
<span class="text-sm font-semibold">Name for the roll</span>
<%= tag.input type: "text", name: "name", value: form.name, maxlength: 60, autocomplete: "off",
class: "input w-full", **field.(:name).deep_merge(focus) %>
<%= error.(:name) %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Age</span>
<%= tag.input type: "number", name: "age", value: form.age, min: 0, inputmode: "numeric",
class: "input w-full tabular-nums", **field.(:age) %>
<%= error.(:age) %>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Hometown</span>
<%= tag.select name: "hometown", class: "select w-full", **field.(:hometown, :change) do %>
<%= options_for_select [ [ "Where are you from?", "" ], *Recipes::Wizard::Enlistment::HOMETOWNS ], form.hometown %>
<% end %>
<%= error.(:hometown) %>
</label>
<%# locals: (form:, trail:, field:, error:) -%>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Path</span>
<%= tag.select name: "path", class: "select w-full", **field.(:path, :change).deep_merge(focus) do %>
<%= options_for_select [ [ "Choose a path", "" ], *Recipes::Wizard::Enlistment::PATHS.keys.map { [ it.capitalize, it ] } ], form.path %>
<% end %>
<%= error.(:path) %>
</label>
<%= render "app/recipes/wizard/weapon", form:, trail: %>
<label class="flex flex-col gap-1 sm:col-span-2">
<span class="text-sm font-semibold">Years of practice</span>
<%= tag.input type: "number", name: "years", value: form.years, min: 0, inputmode: "numeric",
class: "input w-full tabular-nums sm:w-1/2", **field.(:years) %>
<%= error.(:years) %>
</label>
<%# locals: (form:, trail:) -%>
<%# Replaced by the channel when the path changes. A weapon picked for
another path is not offered, and the form does not count it. %>
<label id="wizard_weapon" class="flex flex-col gap-1">
<span class="text-sm font-semibold">Weapon</span>
<%= tag.select name: "weapon", class: "select w-full", disabled: form.weapons.empty?,
**on(:set_field, event: :change, with: { field: :weapon }) do %>
<%= options_for_select [ [ form.weapons.empty? ? "Choose a path first" : "Choose a weapon", "" ],
*form.weapons.map { [ it.capitalize, it ] } ], form.carried %>
<% end %>
<p class="min-h-5 text-sm text-error"><%= reactive :wizard_error_weapon, trail.message(form, :weapon) %></p>
</label>
<%# locals: (form:, trail:, field:, error:) -%>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Rank sought</span>
<%= tag.select name: "rank", class: "select w-full", **field.(:rank, :change).deep_merge(focus) do %>
<%= options_for_select Recipes::Wizard::Enlistment::RANKS.map { |rank, years| [ years.zero? ? rank.capitalize : "#{rank.capitalize}, #{years} years", rank ] }, form.rank %>
<% end %>
<%= error.(:rank) %>
</label>
<%# Asked for only when the age on the first step is under 16. %>
<% if form.guardian_needed? %>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold">Guardian's name</span>
<%= tag.input type: "text", name: "guardian", value: form.guardian, maxlength: 60, autocomplete: "off",
class: "input w-full", **field.(:guardian) %>
<%= error.(:guardian) %>
</label>
<% end %>
<div class="sm:col-span-2">
<label class="flex items-start gap-3">
<%= tag.input type: "checkbox", name: "pledge", checked: form.pledge, class: "checkbox checkbox-primary checkbox-sm mt-0.5",
**field.(:pledge, :change) %>
<span class="text-sm">I swear to share my loot, to mind the cabbages, and to leave no one in the dungeon.</span>
</label>
<%= error.(:pledge) %>
</div>
<%# locals: (form:) -%>
<%# Everything the form holds, by step, with what is still wrong under
each. Nothing here is typed, so it is rendered once per visit. %>
<% answers = {
you: { "Name" => form.name, "Age" => form.age, "Hometown" => form.hometown },
trade: { "Path" => form.path, "Weapon" => form.carried, "Years of practice" => form.years },
oath: { "Rank sought" => form.rank, "Guardian" => (form.guardian if form.guardian_needed?),
"Oath" => (form.pledge ? "sworn" : "not sworn") }
} %>
<div class="grid gap-3 sm:grid-cols-3">
<% answers.each do |step, lines| %>
<section id="wizard_review_<%= step %>" class="rounded-box border-2 border-base-300 bg-base-200 p-3">
<header class="flex items-baseline justify-between gap-2">
<h3 class="font-bold"><%= Recipes::Wizard::Trail::TITLES.fetch(step) %></h3>
<%= tag.button "Change", type: "button", class: "btn btn-ghost btn-xs", **on(:go, with: { to: step }) %>
</header>
<dl class="mt-1 text-sm">
<% lines.compact.each do |label, answer| %>
<div class="flex justify-between gap-2">
<dt class="opacity-70"><%= label %></dt>
<dd class="truncate font-semibold"><%= answer.to_s.presence || "—" %></dd>
</div>
<% end %>
</dl>
<% form.problems.fetch(step).each_value do |message| %>
<p class="mt-1 text-sm text-error"><%= message %></p>
<% end %>
</section>
<% end %>
</div>
<%# locals: (form:, trail:) -%>
<%# Morphed by the channel. Next is the step form's submit button, so it
and Enter do the same thing. The leave guard is optional: it warns
before the page is left with typing that no step has saved yet. %>
<div id="wizard_actions" class="mt-3 flex min-h-12 flex-wrap items-center gap-x-3 gap-y-2 border-t-2 border-base-300 pt-3"
data-controller="leave-guard" data-leave-guard-active-value="<%= form.dirty? %>">
<% unless trail.signed? %>
<%= tag.button "Back", id: "wizard_back", type: "button", class: "btn btn-sm", disabled: trail.first?, **on(:back) %>
<% if trail.review? %>
<%= tag.button "Sign the roll", id: "wizard_sign", type: "button", class: "btn btn-primary btn-sm",
disabled: !form.complete?, **on(:sign) %>
<% else %>
<button id="wizard_next" form="wizard_fields" class="btn btn-primary btn-sm">Next</button>
<% end %>
<p id="wizard_note" class="text-sm opacity-70">
<% if form.dirty? %>
Not saved yet. Leaving this step saves it.
<% elsif form.persisted? %>
Saved. This page's address will bring the draft back.
<% elsif trail.review? && !form.complete? %>
Something is still wrong.
<% end %>
</p>
<% end %>
</div>
<%# locals: (roll:, mine: nil) -%>
<%# The guild's roll: how many have signed, and the last few of them, the
latest at the bottom. Morphed by the channel whenever anyone signs. %>
<div id="wizard_roll" class="mt-3 rounded-box border-2 border-base-300 bg-base-200 px-4 pt-2 pb-3">
<p class="flex items-baseline justify-between gap-2 border-b-2 border-base-content/40 pb-1 font-serif text-xs font-bold tracking-[0.25em] uppercase opacity-70">
<span>The guild roll</span>
<span id="wizard_count" class="tracking-normal normal-case"><%= roll.count %> enlisted today</span>
</p>
<ol id="wizard_entries" class="font-serif text-sm italic">
<% roll.entries.each do |entry| %>
<li id="wizard_entry_<%= entry.id %>"
class="grid grid-cols-[1fr_auto] gap-x-3 border-b border-dashed border-base-content/30 py-1 sm:grid-cols-[1fr_8rem_9rem]">
<span class="truncate">
<%= entry.name %>
<% if entry.id == mine %><b class="ml-1 text-xs text-primary not-italic">you</b><% end %>
</span>
<span class="hidden opacity-80 sm:block"><%= entry.hometown %></span>
<span class="text-right whitespace-nowrap opacity-80"><%= entry.calling %></span>
</li>
<% end %>
<% (Recipes::Wizard::Enlistment::ROLL - roll.entries.size).times do |line| %>
<li id="wizard_blank_<%= line %>" class="border-b border-dashed border-base-content/30 py-1 opacity-50">
<%= line.zero? && roll.entries.empty? ? "Nobody has signed the roll today." : sanitize(" ") %>
</li>
<% end %>
</ol>
</div>
import { Controller } from "@hotwired/stimulus"
// Asks before the page is left while `active` is true: the browser's own
// prompt for a reload or a closed tab, and a confirm for a Turbo visit.
// The server decides `active`; this only listens.
//
// <div data-controller="leave-guard" data-leave-guard-active-value="true">
export default class extends Controller {
static values = { active: Boolean, message: { type: String, default: "Leave without saving what you typed?" } }
connect() {
window.addEventListener("beforeunload", this.unload)
document.addEventListener("turbo:before-visit", this.visit)
}
disconnect() {
window.removeEventListener("beforeunload", this.unload)
document.removeEventListener("turbo:before-visit", this.visit)
}
unload = (event) => {
if (this.activeValue) event.preventDefault()
}
visit = (event) => {
if (this.activeValue && !confirm(this.messageValue)) event.preventDefault()
}
}
The panel above enlists you at the guild in four steps, with a mark for each step along the top. Fill it in and press Next: the step is saved, and the address bar now holds the draft, so the form survives a reload. Try proceeding to the Review step, then press Change on the first step and make yourself 14. The Oath mark turns to a cross while you are still on the first step, because a recruit under 16 needs a guardian. A form like this is very difficult in stock Rails, where every rule has to be bound to the step it belongs to. Here the rules are written once in Ruby, in the model.
How it fits together
An island is the part of the page Hibiki looks after, here the whole panel. It belongs to a channel: the server side of an Action Cable connection, an object that stays alive while the page is open, one for each tab. The channel's public methods are its actions, the methods the page may call, and its states are values that remember who read them. Here the states are the step on show, the furthest stage the draft has reached, and the step whose Next was refused.
A reactive form holds a record's attributes as states of its
own, one per field. Here EnlistmentForm holds the fields
of all three steps at once. Only the step on show is on the page; the
others wait in the form, on the server, and come back filled in when
you turn to them.
A derived is a value computed from states and computed again
when one of them changes. Here Trail is a derived made
from the step on show, the stage reached, and the form's list of steps
that have something wrong. It works out the marks along the top. An
effect is a block that runs again when a state or derived it
read changes. One effect renders the marks from the trail. Another
sends the step on show, and runs only when you turn to a different
step, so it never replaces the field you are typing in. This is what
happens when you press Next:
-
The step's form submits, which calls the
nextaction. Enter in a field does the same. - The action asks the form whether the step on show is right. If not, it records the step as tried, and the messages under its fields appear.
-
If the step is right, the channel saves the draft with the form's
commitand sets the step state to the next one. - The trail is new, so the marks are rendered again, and the step you left gets a tick. The step state is new, so the next step is sent, and the address bar follows.
Validating a draft
A wizard that saves each step has to save a record that is not
finished. The usual answer in Rails is a validation context for each
step, or an if: on every rule, and either way each rule
has to say which step it belongs to. Here the model has one condition
for all of them: the rules apply once the enlistment is signed. A
draft passes whatever it holds, so saving a step is the form's
ordinary commit.
# The model. Every rule sits in one block that applies once the
# enlistment is signed, so saving a draft needs no context and no step.
with_options if: :signed? do
validates :name, presence: { message: "The roll needs a name." }
validates :age, numericality: { only_integer: true, in: AGES, message: "..." }
# ...
validate :practice_fits_the_age, :rank_is_earned, :guardian_for_the_young
end
# Lives on the Oath step, reads the age from the first step.
def guardian_for_the_young
errors.add(:guardian, "Under 16, a guardian signs with you.") if guardian_needed? && guardian.blank?
end
# The form. A copy of the fields, marked as signed, is asked the rules.
derived(:draft) { Enlistment.new(**to_h, signed_at: Time.current) }
derived(:live_errors) { draft.tap(&:validate).errors.to_hash.transform_values(&:first) }
The messages under the fields ask those same rules. The form keeps a
derived called draft: a copy of its fields with
signed_at set, so that the rules apply to it, and
live_errors asks the copy what is wrong with it. When a
field changes, the copy is made again and the rules are asked again.
A transmit_value for each field sends the message under
it, only when that message changes.
Sorting the errors by step
The marks along the top need to know which step is wrong, and the model's errors are one flat list. The model says which fields each step asks for, and the form sorts the messages by that list. That is every step's validity at once.
# The model says which fields a step asks for.
STEPS = {
you: %i[name age hometown],
trade: %i[path weapon years],
oath: %i[rank guardian pledge]
}.freeze
# The form sorts the model's answers by step. This is all of "per-step
# validity": { you: {}, trade: { years: "At 14 you cannot..." }, oath: {} }
derived(:problems) { Enlistment::STEPS.transform_values { live_errors.slice(*it) } }
derived(:wrong) { problems.reject { |_, messages| messages.empty? }.keys }
derived(:complete?) { wrong.empty? }
def step_valid?(step) = wrong.exclude?(step)
# The channel. Next reads one step, Sign reads them all.
def next
return unless @step.value < Enlistment::REVIEW
return @tried.value = @step.value unless @form.step_valid?(@trail.value.stage)
turn_to @step.value + 1
end
A rule belongs to the step whose field it complains about, and it may
read any other step. guardian_for_the_young puts its
message on the guardian field, which is on the Oath step, and reads
the age from the first step. When you change the age, the rules are
asked again, wrong now includes :oath, and
the Oath mark is rendered as a cross. We didn't write a line that
connects the age to that mark. Next asks step_valid? for
the step on show, and Sign waits for complete?.
Saving the draft and finding it again
If you fill in two steps and then close the tab, you shouldn't have to start again. So the wizard saves the draft every time you leave a step. Next saves it only when the step is right, because Next also moves you on. The Back button, the marks along the top, and the Change button on the Review step save whatever the state holds. For example, if you change your age on the first step and that breaks the Oath step, the change is still kept, and the Oath mark shows a cross until you fix it.
# The channel. Every turn saves the draft first, valid or not. `commit`
# is the form's ordinary save; the model lets it through because a draft
# has no rules.
def turn_to(step)
created = !@form.persisted?
@form.record.reached = [ @reached.value, step ].max
@form.commit
Enlistment.prune! if created
show step
end
# Signing is the same `commit`, with `signed_at` set, so now the rules apply.
def sign
return unless @trail.value.review? && @form.complete?
@form.record.assign_attributes(signed_at: Time.current, reached: Enlistment::SIGNED)
@form.record.assign_attributes(signed_at: nil, reached: Enlistment::REVIEW) unless @form.commit
show @form.record.reached
end
# The address follows the draft's token, once it has a row, and the step.
# Each turn is an entry in the browser's history.
transmit_url(history: :push) do
step = Enlistment::STAGES[@step.value] if @token.value
urls.app_recipe_path("wizard", draft: @token.value, step:)
end
# Back and Forward in the browser, held to where the draft has been.
def visit(data)
return if signed?
to = Enlistment::STAGES.index(data["step"].to_s.to_sym) || 0
turn_to [ to, @reached.value, Enlistment::REVIEW ].min
end
# The page and the channel find the draft the same way.
def self.resume(token) = find_by(token: token.to_s.presence) || start
Every turn goes through turn_to, which saves with the
form's ordinary commit before it shows the next step.
A saved draft is only useful if you can get back to it. The first save
gives the enlistment a token, and transmit_url puts the
token and the step in the address bar. Reload the page and the same
draft opens at the same step.
Each turn also adds an entry to the browser's history
(history: :push), so the browser's Back button takes you
to the step before. The island hears it with
on(:visit, event: :popstate) and calls the
visit action, which turns to the step in the address. It
won't take you past the furthest step you have reached.
Signing saves the draft one last time, with signed_at
set. This is when the model's rules apply. If they find something
wrong, signed_at is cleared again and the review lists
what needs fixing.
Warning before you leave the page (optional)
The draft is saved when you leave a step, so whatever you type on the
step in front of you isn't saved yet. Type in a field and a note
beside the buttons shows up. The form knows this through
dirty?, which is true while a field holds something the
draft doesn't.
If you close the tab at that point, the typing is lost. The browser
can ask you first, but only the browser knows that a tab is closing,
so this part needs JavaScript. Here a small Stimulus controller does
it. The server sets the controller's active value from
dirty?, and while it is true, the controller asks before
you close the tab, reload or follow a link away.
This controller is the only JavaScript in the recipe, and you can leave it out. The wizard works the same without it.
<%# The actions partial, which the channel morphs. The controller reads
one value, and the server writes it from `dirty?`. %>
<div id="wizard_actions" data-controller="leave-guard"
data-leave-guard-active-value="<%= form.dirty? %>">
...
</div>
import { Controller } from "@hotwired/stimulus"
// Asks before the page is left while `active` is true: the browser's own
// prompt for a reload or a closed tab, and a confirm for a Turbo visit.
// The server decides `active`; this only listens.
//
// <div data-controller="leave-guard" data-leave-guard-active-value="true">
export default class extends Controller {
static values = { active: Boolean, message: { type: String, default: "Leave without saving what you typed?" } }
connect() {
window.addEventListener("beforeunload", this.unload)
document.addEventListener("turbo:before-visit", this.visit)
}
disconnect() {
window.removeEventListener("beforeunload", this.unload)
document.removeEventListener("turbo:before-visit", this.visit)
}
unload = (event) => {
if (this.activeValue) event.preventDefault()
}
visit = (event) => {
if (this.activeValue && !confirm(this.messageValue)) event.preventDefault()
}
}
The guild roll
The roll at the top is shared with every visitor: how many have signed today, and the newest five of them. When an enlistment is signed, the model broadcasts that the roll changed, and every open page reads it again. A draft saved at a step does not broadcast. The enlistment table is emptied every night, drafts included.
The same feature in stock Rails
In stock Rails a form in steps is one page for each step, a validation context for each step, and a validate action answered with Turbo Streams for the live messages and marks. Here is a sketch. It is not run here, and it leaves out the rules, which are the same in both versions, and the four partials that hold the fields.
# One page for each step: GET shows it, PATCH saves it and redirects.
class Enlistments::StepsController < ApplicationController
before_action :set_enlistment, :set_step
def show
# Messages only for a step the visitor has been past.
@enlistment.valid?(@step.to_sym) if passed?
end
def update
@enlistment.assign_attributes(step_params)
# Back and a click on a mark save without the rules. Next checks this
# step only: checking the others would refuse a save on step 1
# because of something on step 3.
if params[:direction] == "next" && !@enlistment.valid?(@step.to_sym)
return render :show, status: :unprocessable_entity
end
@enlistment.reached = [ @enlistment.reached, target_index ].max
@enlistment.save!(validate: false)
redirect_to enlistment_step_path(@enlistment.token, target)
end
# Live messages: the step's fields are posted on each change, saved
# nowhere, and answered with Turbo Streams.
def validate
@enlistment.assign_attributes(step_params)
@messages = params[:touched] == "all" ? @enlistment.errors_for(@step) : {}
@states = @enlistment.step_states
end
def sign
@enlistment.signed_at = Time.current
if @enlistment.save(context: :sign)
Turbo::StreamsChannel.broadcast_replace_to "guild_roll", target: "roll", partial: "enlistments/roll"
redirect_to enlistment_path(@enlistment.token)
else
redirect_to enlistment_step_path(@enlistment.token, first_wrong_step)
end
end
private
def set_enlistment
@enlistment = Enlistment.find_by!(token: params[:enlistment_token])
redirect_to enlistment_path(@enlistment.token) if @enlistment.signed_at?
end
def set_step
@step = params[:step].presence_in(Enlistment::STEPS) or raise ActionController::RoutingError, "No such step"
redirect_to enlistment_step_path(@enlistment.token, reached_step) unless index(@step) <= @enlistment.reached
end
# Strong parameters, once for each step.
def step_params
case @step
when "you" then params.expect(enlistment: %i[name age hometown])
when "trade" then params.expect(enlistment: %i[path weapon years])
when "oath" then params.expect(enlistment: %i[rank guardian pledge])
else {}
end
end
def target
case params[:direction]
when "next" then Enlistment::STEPS[index(@step) + 1]
when "back" then Enlistment::STEPS[index(@step) - 1]
else params[:direction].presence_in(Enlistment::STEPS) || @step
end
end
def target_index = index(target)
def index(step) = Enlistment::STEPS.index(step)
def passed? = index(@step) < @enlistment.reached
def reached_step = Enlistment::STEPS[@enlistment.reached]
def first_wrong_step = @enlistment.step_states.key(:wrong) || "review"
end
# One validation context for each step, so a step can be saved before the
# later ones are filled in. A rule that reads two steps has to pick one.
class Enlistment < ApplicationRecord
STEPS = %w[you trade oath review].freeze
has_secure_token :token
with_options on: %i[you sign] do
validates :name, presence: true, length: { maximum: 30 }
validates :age, numericality: { only_integer: true, in: 12..90 }
validates :hometown, inclusion: { in: HOMETOWNS }
end
with_options on: %i[trade sign] do
validates :path, inclusion: { in: PATHS.keys }
validates :years, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validate :weapon_suits_the_path, :practice_fits_the_age
end
with_options on: %i[oath sign] do
validates :rank, inclusion: { in: RANKS.keys }
validate :rank_is_earned, :guardian_for_the_young, :oath_is_sworn
end
# The marks along the top. Each step is validated in its own context,
# one after another, because `valid?` takes one context at a time and
# clears the errors of the last.
def step_states
STEPS.first(3).each_with_index.to_h do |step, index|
next [ step, :todo ] unless index < reached
[ step, valid?(step.to_sym) ? :done : :wrong ]
end
end
def errors_for(step)
valid?(step.to_sym)
errors.to_hash.transform_values(&:first)
end
def signable? = valid?(:sign)
# ...the rules themselves, the same in both versions
end
<%# enlistments/steps/show.html.erb. The fields of each step are a
partial of their own (_you, _trade, _oath, _review), not shown here. %>
<%= turbo_stream_from "guild_roll" %>
<%= render "enlistments/roll" %>
<%= render "enlistments/steps/trail", enlistment: @enlistment, states: @enlistment.step_states, step: @step %>
<%= form_with model: @enlistment, url: enlistment_step_path(@enlistment.token, @step), method: :patch,
data: { controller: "wizard-step",
wizard_step_validate_url_value: validate_enlistment_step_path(@enlistment.token, @step),
wizard_step_passed_value: @enlistment.reached > Enlistment::STEPS.index(@step),
action: "input->wizard-step#changed change->wizard-step#changed" } do |form| %>
<%= render "enlistments/steps/#{@step}", form: %>
<button name="direction" value="back" data-action="wizard-step#leaving">Back</button>
<button name="direction" value="next" data-action="wizard-step#leaving wizard-step#tried">Next</button>
<p data-wizard-step-target="note" hidden>Not saved yet. Leaving this step saves it.</p>
<% end %>
<%# enlistments/steps/_trail.html.erb. A mark is a button of a second
form, since going to a step has to save the one on show first; here
it is a plain link, and unsaved typing is lost. %>
<ol id="trail" class="steps">
<% Enlistment::STEPS.each_with_index do |name, index| %>
<% state = states.fetch(name, :todo) %>
<li class="step <%= { done: "step-primary", wrong: "step-error" }[state] %>">
<%= link_to_if index <= enlistment.reached && name != step, name.capitalize,
enlistment_step_path(enlistment.token, name) %>
</li>
<% end %>
</ol>
<%# One line for each message slot of each step, and one for the marks.
The weapons are here too, since they follow the path. %>
<%= turbo_stream.replace "trail" do %>
<%= render "enlistments/steps/trail", enlistment: @enlistment, states: @states, step: @step %>
<% end %>
<% case @step %>
<% when "you" %>
<%= turbo_stream.update "error_name", @messages[:name] %>
<%= turbo_stream.update "error_age", @messages[:age] %>
<%= turbo_stream.update "error_hometown", @messages[:hometown] %>
<% when "trade" %>
<%= turbo_stream.update "error_path", @messages[:path] %>
<%= turbo_stream.update "error_weapon", @messages[:weapon] %>
<%= turbo_stream.update "error_years", @messages[:years] %>
<% if params[:changed] == "path" %>
<%= turbo_stream.replace "weapon_field" do %>
<%= render "enlistments/steps/weapon", enlistment: @enlistment %>
<% end %>
<% end %>
<% when "oath" %>
<%= turbo_stream.update "error_rank", @messages[:rank] %>
<%= turbo_stream.update "error_guardian", @messages[:guardian] %>
<%= turbo_stream.update "error_pledge", @messages[:pledge] %>
<% end %>
import { Controller } from "@hotwired/stimulus"
// What a request cannot know: when typing has settled, whether Next has
// been tried, and whether the step has typing that is not saved yet.
export default class extends Controller {
static targets = ["note"]
static values = { validateUrl: String, passed: Boolean }
connect() {
this.dirty = false
this.touched = this.passedValue
this.unload = (event) => { if (this.dirty) event.preventDefault() }
this.visit = (event) => { if (this.dirty && !confirm("Leave without saving?")) event.preventDefault() }
window.addEventListener("beforeunload", this.unload)
document.addEventListener("turbo:before-visit", this.visit)
}
disconnect() {
clearTimeout(this.timer)
window.removeEventListener("beforeunload", this.unload)
document.removeEventListener("turbo:before-visit", this.visit)
}
changed(event) {
this.dirty = true
this.noteTarget.hidden = false
clearTimeout(this.timer)
this.timer = setTimeout(() => this.validate(event.target.name), event.type === "input" ? 250 : 0)
}
// Messages stay hidden until Next has been pressed once.
tried() { this.touched = true }
// The form's own submit must not trip the leave guard.
leaving() { this.dirty = false }
async validate(changed) {
const body = new FormData(this.element)
body.set("_method", "post")
body.set("touched", this.touched ? "all" : "")
body.set("changed", changed.replace(/^enlistment\[(.*)\]$/, "$1"))
const seq = this.seq = (this.seq || 0) + 1
const response = await fetch(this.validateUrlValue, {
method: "POST", body, headers: { Accept: "text/vnd.turbo-stream.html" }
})
// An older reply must not overwrite a newer one.
if (seq === this.seq) Turbo.renderStreamMessage(await response.text())
}
}
resources :enlistments, only: %i[new create show], param: :token do
resources :steps, only: %i[show update], param: :step, module: :enlistments do
member do
post :validate
post :sign
end
end
end
The table lists each behaviour of the demo above and where it comes from.
| Stock Rails | Hibiki | |
|---|---|---|
| Saving a step of an unfinished record | A validation context for each step, or save(validate: false) |
One if: :signed? around the rules; commit as usual |
| Which fields belong to which step | In the contexts, in the strong parameters and in the stream template | One hash in the model |
| Whether a step is right | valid?(context) for each step, one after another |
One derived that sorts one set of messages |
| A mark that follows a field on another step | Re-render the marks on every validate request, since the server cannot tell which moved | Nothing; the marks are sent when one changes |
| A message under a field as you type | An id on each slot, a Turbo Stream line for each, and a fetch with a guard against replies out of order | One transmit_value for each field |
| No messages until Next is pressed | A flag kept in JavaScript and posted along | Trail#shown? |
| Going to another step keeps what was typed | Every way out of the step has to be a submit of its form | Every turn calls commit; the form holds all the steps |
| A field that depends on an earlier step | Read the saved record when the later page renders | The same: the step is rendered from the form when it is turned to |
| Coming back after a reload, and Back in the browser | The step's own URL, from the router | transmit_url with history: :push, one visit action, and one resume for the page and the channel |
| Not saved yet, and a warning before leaving | A dirty flag kept in JavaScript, cleared by hand on submit | dirty? in the partial; the same small controller for the browser's prompt |
| A roll every visitor sees | A stream on the page and a broadcast after signing | A broadcast after signing, and a derived and an effect in the channel |
| A new rule that reads two steps | The validation, in the context of one of them, and a check that the marks still re-validate the other | The validation |
Without the live parts, stock Rails does this well. A page for each step and a redirect is plain, works with no JavaScript, and Back in the browser does what it says. In return, each step has to be described in several places: its fields are listed in the model's contexts, in the strong parameters and in the stream template. A rule that reads two steps has to live in one step's context, and the marks have to validate the record once for each step on every request, since the server cannot tell which mark moved.
With Hibiki, each step's fields are listed once, in the model, and a rule doesn't need to know which step it belongs to. The form sorts the model's messages by step, and the marks along the top follow. This helps most when you add a rule that reads two steps: you write only the validation, and its message and the marks keep up when either step changes.