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

  1. Nobody has signed the roll today.
  2.  
  3.  
  4.  
  5.  

# 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

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:

  1. The step's form submits, which calls the next action. Enter in a field does the same.
  2. 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.
  3. If the step is right, the channel saves the draft with the form's commit and sets the step state to the next one.
  4. 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>

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

The table lists each behaviour of the demo above and where it comes from.

Stock RailsHibiki
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.