Two columns carry the whole state of a multistep flow here. onboarding_step is a string naming
where the user is. onboarded_at is a timestamp, null until they finish. There is no workflow table,
no state machine, and no gem.
Render the step's form, or hand it the params and see whether it saved. The controller never learns
what a step collects, which is what makes adding one a change to a form class and a partial rather
than a change here.
status: :unprocessable_entity on the failure branch is doing real work. Turbo ignores a 200
response to a form submission that is not a redirect, so a validation error rendered with the
default status produces a page that visibly does nothing: the user clicks Continue, nothing moves,
and no error appears. The 422 is what makes Turbo replace the frame and show the messages.
The order matters and it is easy to get backwards. The stored position moves after the form
saved, and it names the step the user is about to see rather than the one they just finished.
Writing it before the save would park somebody on a step they never completed the moment a
validation failed. Storing the completed step instead of the next one pushes the arithmetic into
resume, which then has to look up the step, find the one after it, and handle the case where the
completed step was the last. Naming the destination keeps the resume logic to a single lookup.
complete runs when there is no next step: it stamps onboarded_at, clears onboarding_step,
sends the welcome notification, enrolls the user in the onboarding email sequence, and redirects to
the dashboard. Clearing the step key rather than leaving it on the last one is what makes
onboarded_at the single source of truth about whether the flow is done.
Resuming, including onto a step you deleted
defresume(key)find(key)||firstend
Two words of code and they cover the case that otherwise produces a support ticket. find returns
nil for any key that does not match a current step, and the || sends that user to the beginning
instead of to an exception.
That matters because the step list is editable. Remove workspace from the flow on a Tuesday, and
every user whose onboarding_step column reads "workspace" is holding a key that resolves to
nothing. Without the fallback they get a 500 on the page they are required to visit, and they cannot
reach anything else in the product either, because
the gate keeps sending them back.
Running that case is the way to believe it. Set onboarding_step to a string no form class matches,
ask Onboarding::Flow.resume for it, and it hands back the first step rather than raising:
Onboarding::Flow is class methods over a list. It does not know the user, does not read the
database, and does not decide anything about validation. Asked for the step after this one, it
answers with a list lookup.
position and total exist for the progress indicator, and they are derived rather than stored.
A "step 2 of 5" rendered from a counter on the user record is a number that goes wrong the first
time the flow changes length. Computed from the list, it cannot.
The &. in last? covers an empty flow. A registry that finds no active steps is a broken
configuration rather than a crash, and every method here degrades to nil or zero instead of raising
inside a view.
Onboarding::Registry.active_steps globs a directory and parses a YAML file. A page rendering a
progress bar, a form and a partial can ask for the step list a dozen times, and without a memo that
is a dozen directory scans.
Current is an ActiveSupport::CurrentAttributes subclass, which is what makes this memo the right
shape rather than a constant. The Rails executor resets it at the request boundary, so the scan
happens once per request and an edit to config/onboarding_steps.yml is picked up on the very next
one. No reboot, no cache to invalidate, and no stale flow surviving in a worker that happened to
serve the request before the deploy.
Caching it in a class-level variable instead would buy a little more speed and cost you the property
that matters: composing the flow in the admin would then require a restart to take effect.
What this page does not cover
Branching. Every user walks the same list in the same order, and a flow that shows step three only
to people on a paid plan is a different design: the registry returns a list, and a list cannot ask
who is reading it.
Nor does it cover going backwards. The flow has no back button, because
each step's form writes to the user as
it goes rather than accumulating answers in the session, so there is nothing to undo and no draft
state to reconcile. Making the steps revisitable is possible and it changes what advance means.
Two columns carry the whole state of a multistep flow here.
onboarding_stepis a string naming where the user is.onboarded_atis a timestamp, null until they finish. There is no workflow table, no state machine, and no gem.The controller does two things
Render the step's form, or hand it the params and see whether it saved. The controller never learns what a step collects, which is what makes adding one a change to a form class and a partial rather than a change here.
status: :unprocessable_entityon the failure branch is doing real work. Turbo ignores a 200 response to a form submission that is not a redirect, so a validation error rendered with the default status produces a page that visibly does nothing: the user clicks Continue, nothing moves, and no error appears. The 422 is what makes Turbo replace the frame and show the messages.Where the position is written
The order matters and it is easy to get backwards. The stored position moves after the form saved, and it names the step the user is about to see rather than the one they just finished.
Writing it before the save would park somebody on a step they never completed the moment a validation failed. Storing the completed step instead of the next one pushes the arithmetic into
resume, which then has to look up the step, find the one after it, and handle the case where the completed step was the last. Naming the destination keeps the resume logic to a single lookup.completeruns when there is no next step: it stampsonboarded_at, clearsonboarding_step, sends the welcome notification, enrolls the user in the onboarding email sequence, and redirects to the dashboard. Clearing the step key rather than leaving it on the last one is what makesonboarded_atthe single source of truth about whether the flow is done.Resuming, including onto a step you deleted
Two words of code and they cover the case that otherwise produces a support ticket.
findreturns nil for any key that does not match a current step, and the||sends that user to the beginning instead of to an exception.That matters because the step list is editable. Remove
workspacefrom the flow on a Tuesday, and every user whoseonboarding_stepcolumn reads"workspace"is holding a key that resolves to nothing. Without the fallback they get a 500 on the page they are required to visit, and they cannot reach anything else in the product either, because the gate keeps sending them back.Running that case is the way to believe it. Set
onboarding_stepto a string no form class matches, askOnboarding::Flow.resumefor it, and it hands back the first step rather than raising:The navigator holds no state
Onboarding::Flowis class methods over a list. It does not know the user, does not read the database, and does not decide anything about validation. Asked for the step after this one, it answers with a list lookup.positionandtotalexist for the progress indicator, and they are derived rather than stored. A "step 2 of 5" rendered from a counter on the user record is a number that goes wrong the first time the flow changes length. Computed from the list, it cannot.The
&.inlast?covers an empty flow. A registry that finds no active steps is a broken configuration rather than a crash, and every method here degrades to nil or zero instead of raising inside a view.One scan per request
Onboarding::Registry.active_stepsglobs a directory and parses a YAML file. A page rendering a progress bar, a form and a partial can ask for the step list a dozen times, and without a memo that is a dozen directory scans.Currentis anActiveSupport::CurrentAttributessubclass, which is what makes this memo the right shape rather than a constant. The Rails executor resets it at the request boundary, so the scan happens once per request and an edit toconfig/onboarding_steps.ymlis picked up on the very next one. No reboot, no cache to invalidate, and no stale flow surviving in a worker that happened to serve the request before the deploy.Caching it in a class-level variable instead would buy a little more speed and cost you the property that matters: composing the flow in the admin would then require a restart to take effect.
What this page does not cover
Branching. Every user walks the same list in the same order, and a flow that shows step three only to people on a paid plan is a different design: the registry returns a list, and a list cannot ask who is reading it.
Nor does it cover going backwards. The flow has no back button, because each step's form writes to the user as it goes rather than accumulating answers in the session, so there is nothing to undo and no draft state to reconcile. Making the steps revisitable is possible and it changes what
advancemeans.