Class: Plutonium::Wizard::Runner
- Inherits:
-
Object
- Object
- Plutonium::Wizard::Runner
- Defined in:
- lib/plutonium/wizard/runner.rb
Overview
The pure navigation/commit engine (§6). Given a wizard class, a Store, and
an instance key, it loads (or builds) the State, hydrates a single wizard
instance, and drives the flow: compute the visible path, validate + stage a
step, run per-step on_submit/persist, navigate back, cancel (cleanup),
and finalize via execute (with the completeness check, branch-hidden
pruning, and the locked in_progress → completing transition).
No HTTP/controller/UI here — the controller (Task 5) drives this directly.
Defined Under Namespace
Classes: PersistTracker, Result
Instance Attribute Summary collapse
-
#state ⇒ Object
readonly
Returns the value of attribute state.
-
#wizard ⇒ Object
readonly
Returns the value of attribute wizard.
Instance Method Summary collapse
-
#advance(step_key, params, goto: nil) ⇒ Object
Validate + stage a step, run its
on_submit(in a transaction), then move the cursor to the next visible step. -
#back ⇒ Object
Move the cursor to the previous visible step.
-
#cancel ⇒ Object
Abandon the flow: run cleanup (each step's
on_rollback, then always destroy its tracked records, in reverse step order) BEFORE clearing the row —clearis adelete_allwith no callbacks, so compensation must happen first (§2.3). -
#completed_one_time? ⇒ Boolean
Whether this run's key already has a retained
completedone-time marker (§4.3 / §9) — re-entering a finished one-time wizard. -
#current_step ⇒ Object
The visible step matching the stored cursor, or the first visible step.
-
#finalize ⇒ Object
Finish the flow (§6.3): assert every visible non-review step is visited and valid (else bounce to the first gap); prune branch-hidden data on a working copy; perform the locked
in_progress → completingtransition; runexecutein a transaction; complete the row on success, revert on failure. -
#forbidden? ⇒ Boolean
Whether an existing row at this key belongs to a DIFFERENT user (§4.5 owner-scoping).
-
#go_to(step_key) ⇒ Object
Point the cursor at a specific visible step on a GET (stepper jump / resume via direct URL).
-
#incomplete_visible_steps ⇒ Object
The ordered visible non-review steps that aren't yet complete (§6.3).
-
#initialize(wizard_class:, store:, instance_key:, view_context: nil, owner: nil, anchor: nil, scope: nil, token: nil, engine: nil, current_user: nil, current_scoped_entity: nil) ⇒ Runner
constructor
A new instance of Runner.
-
#resumed? ⇒ Boolean
Whether a row already existed at this key when the runner was built — i.e.
-
#step_complete?(step) ⇒ Boolean
Whether a visible non-review step is complete: SUBMITTED AND its staged data currently validates (§6.3).
-
#submitted?(step) ⇒ Boolean
Whether a step has been SUBMITTED (advanced through) — its staged
dataslice exists. -
#visible_path ⇒ Object
The currently-visible step path (§6.2 subtractive branching).
-
#visited_keys ⇒ Object
The keys of steps the user has REACHED (the high-water mark — every step the cursor has landed on, including the one advanced from and the one advanced to).
Constructor Details
#initialize(wizard_class:, store:, instance_key:, view_context: nil, owner: nil, anchor: nil, scope: nil, token: nil, engine: nil, current_user: nil, current_scoped_entity: nil) ⇒ Runner
Returns a new instance of Runner.
31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/plutonium/wizard/runner.rb', line 31 def initialize(wizard_class:, store:, instance_key:, view_context: nil, owner: nil, anchor: nil, scope: nil, token: nil, engine: nil, current_user: nil, current_scoped_entity: nil) @engine = engine @wizard_class = wizard_class @store = store @instance_key = instance_key # The keyed row IS the lock (§4.2): an existing in_progress row at this # instance_key is RESUMED, never forked. `read` returns it (or any prior # row, incl. a completed one-time marker) for the digest; a fresh launch # with no row builds new state. existing = store.read(instance_key) # Owner-scoped resume (§4.5): for a non-`anonymous` wizard, a row may only # be resumed by its owner. A run id leaked in a URL can't be picked up by a # different logged-in user — a mismatch reads as "no such run for you". # `@forbidden` lets the driving layer 404 rather than silently fork. if existing && owner_mismatch?(wizard_class, existing, current_user) existing = nil @forbidden = true end @resumed = !existing.nil? @state = existing || new_state(owner:, anchor:, scope:, token:) @wizard = wizard_class.new(view_context:) @wizard.data_attributes = @state.data @wizard.anchor = (@state.anchor || anchor) if wizard_class.anchored? @wizard.current_user = current_user @wizard.current_scoped_entity = current_scoped_entity @wizard.wizard_token = token # `persisted` is rehydrated LAZILY (§4.5): inject the stored GID source so # `wizard.persisted[:key]` locates that key's GIDs on first read (memoized) # — a request that never reads `persisted` issues zero locate queries. The # anchor (the authz/scoping gate) is still resolved eagerly above. @wizard.persisted_gid_source = @state.persisted end |
Instance Attribute Details
#state ⇒ Object (readonly)
Returns the value of attribute state.
29 30 31 |
# File 'lib/plutonium/wizard/runner.rb', line 29 def state @state end |
#wizard ⇒ Object (readonly)
Returns the value of attribute wizard.
29 30 31 |
# File 'lib/plutonium/wizard/runner.rb', line 29 def wizard @wizard end |
Instance Method Details
#advance(step_key, params, goto: nil) ⇒ Object
Validate + stage a step, run its on_submit (in a transaction), then move
the cursor to the next visible step. On validation/on_submit failure the
cursor does not move and the errors are returned.
goto: overrides the post-advance cursor target (the "Save & review"
shortcut, §7): after staging this step it points the cursor at the named
visible step (typically the review step) instead of the next one, so a user
editing one step after completing the wizard returns straight to review. The
override is ignored if it doesn't name a currently-visible step; review's
own finalize re-checks completeness, so a forged jump can't skip the gate.
160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 |
# File 'lib/plutonium/wizard/runner.rb', line 160 def advance(step_key, params, goto: nil) step = step_for(step_key) errors = validate(step, params) if errors.any? # Reflect the rejected submission back into the IN-MEMORY step data (via # `stage`, which never persists — only `persist_state` does) so the # re-rendered form shows exactly what the user just typed, with the errors # attached. Without this, validation failure reverts every field on the # step to its last STAGED value — so a sibling field the user filled in # correctly silently empties out on the error re-render. Render-only: the # cursor and stored row are untouched, so an abandoned invalid submit # stages nothing. stage(step.key, params) return Result.new(ok: false, errors:) end # Re-submitting a step whose on_submit ALREADY ran (you went back to it and # Nexted again): undo the prior attempt — its on_rollback then destroy its # tracked records — BEFORE re-running, so records/side effects aren't # duplicated and the old records aren't orphaned. `persisted` carries the # step's key once on_submit has run (a side-effect-only step records an empty # list), so it's the "already ran" signal. An UNCHANGED re-submit keeps the # prior result untouched — no needless rollback + re-charge. ran_before = step.on_submit && @state.persisted.key?(step.key.to_s) changed = step_input_changed?(step, params) stage(step.key, params) if step.on_submit && (!ran_before || changed) rollback_prior_submit(step) if ran_before run_on_submit(step) end @state.visited |= [step.key.to_s] # Staging this step's params may have flipped a branch `condition:`, hiding # an earlier step that already persisted records (save-as-you-go). Prune it # NOW — roll its records back and clear its state — so nothing is orphaned # for the rest of the flow (§6.3). A rollback failure here surfaces as a # step failure (same as `on_submit`), it is not swallowed; the cursor does # not move and the advance's data is not lost (the prune persists state). prune_departed_steps @state.current_step = advance_target(step, goto)&.key&.to_s # Mark the step we ARRIVE at visited too, not just the one we left. `visited` # is the set of steps the user has *reached* (the high-water mark) — what the # stepper uses to decide which headers link. Without this, landing on a step # and navigating away before completing it would leave it unreachable (you # couldn't click back to it). Completeness gating is unaffected: it also # checks validity, so a required step reached-but-empty still reads incomplete. @state.visited |= [@state.current_step].compact persist_state Result.new(ok: true) rescue ActiveRecord::RecordInvalid => e Result.new(ok: false, errors: (e.record)) rescue StepError => e Result.new(ok: false, errors: {e.attribute => [e.]}) end |
#back ⇒ Object
Move the cursor to the previous visible step. No validation; never discards staged data (§6 — back is navigation, not submission).
248 249 250 251 252 |
# File 'lib/plutonium/wizard/runner.rb', line 248 def back @state.current_step = previous_visible&.key&.to_s persist_state Result.new(ok: true) end |
#cancel ⇒ Object
Abandon the flow: run cleanup (each step's on_rollback, then always
destroy its tracked records, in reverse step order) BEFORE clearing the
row — clear is a delete_all with no callbacks, so compensation must
happen first (§2.3).
258 259 260 261 262 |
# File 'lib/plutonium/wizard/runner.rb', line 258 def cancel run_cleanup @store.clear(@instance_key) Result.new(ok: true) end |
#completed_one_time? ⇒ Boolean
Whether this run's key already has a retained completed one-time marker
(§4.3 / §9) — re-entering a finished one-time wizard. The driving layer
redirects such a request out rather than re-running it.
80 81 82 |
# File 'lib/plutonium/wizard/runner.rb', line 80 def completed_one_time? @wizard_class.one_time? && @state.status.to_s == "completed" end |
#current_step ⇒ Object
The visible step matching the stored cursor, or the first visible step.
107 108 109 110 |
# File 'lib/plutonium/wizard/runner.rb', line 107 def current_step path = visible_path path.find { |s| s.key.to_s == @state.current_step.to_s } || path.first end |
#finalize ⇒ Object
Finish the flow (§6.3): assert every visible non-review step is visited and
valid (else bounce to the first gap); prune branch-hidden data on a working
copy; perform the locked in_progress → completing transition; run
execute in a transaction; complete the row on success, revert on failure.
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 |
# File 'lib/plutonium/wizard/runner.rb', line 268 def finalize gap = first_incomplete_visible return Result.new(ok: false, redirect_step: gap.key) if gap # Safety net (§6.3): roll back + forget any branch-hidden step that still # holds persisted records or staged data, so nothing orphaned survives into # `execute`. `advance` prunes promptly, but a step can be hidden via paths # that don't pass through `advance` (e.g. seeded/resumed state). prune_departed_steps pruned = prune_hidden(@state.data) # Lost a concurrent finalize (the row is already `completing` or `completed`, # §6.2): another request/tab is running — or already ran — `execute`. Don't # render a blank-error 422; PRG back to the terminal step so the follow-up # GET resolves to the right place (the "already completed" page for a # one-time wizard, or a fresh re-render once the winner finishes). unless lock_for_completion! return Result.new(ok: false, redirect_step: visible_path.last&.key) end outcome = nil ActiveRecord::Base.transaction do @wizard.data_attributes = pruned outcome = @wizard.execute raise ActiveRecord::Rollback if outcome.failure? end if outcome.success? # Repeatability (§4.3): a one-time wizard RETAINS its completed row at # the key (blocks restart, the gate checks it); every other wizard # DELETES the row on completion (repeatable — tokened runs always are). if @wizard_class.one_time? @store.complete(@instance_key) else @store.clear(@instance_key) end Result.new(ok: true, completed: true, value: outcome.value) else revert_completing! Result.new(ok: false, errors: wizard_errors) end rescue ActiveRecord::RecordInvalid => e revert_completing! Result.new(ok: false, errors: (e.record)) rescue StepError => e revert_completing! Result.new(ok: false, errors: {e.attribute => [e.]}) rescue # `lock_for_completion!` committed `completing` in its own transaction # before `execute` ran (§6.2). Any hard failure here must revert that row # to `in_progress` so the user can retry, then propagate. revert_completing! raise end |
#forbidden? ⇒ Boolean
Whether an existing row at this key belongs to a DIFFERENT user (§4.5 owner-scoping). The driving layer turns this into a 404 so a leaked run id can't be resumed by another logged-in user.
75 |
# File 'lib/plutonium/wizard/runner.rb', line 75 def forbidden? = !!@forbidden |
#go_to(step_key) ⇒ Object
Point the cursor at a specific visible step on a GET (stepper jump / resume via direct URL). Only honored when the target is a currently-visible step the user has already visited — forward jumps to unvisited steps are not allowed (§7). The review step is reachable once it's the visible terminal. No persistence: a GET must not mutate stored state; the cursor move lives for this request so the right step renders seeded from staged data.
Returns true when the requested step is the legitimate current step for this request — already current, or reachable and now aligned — and false when it is not reachable (blank, branch-hidden, or a forward jump to an unvisited step). The driving layer uses this confirmation to abort a POST that targets an unreachable step BEFORE it validates/stages/runs the step's on_submit, so a forged or stale submission can't drive a step the user can't see.
227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 |
# File 'lib/plutonium/wizard/runner.rb', line 227 def go_to(step_key) return false if step_key.blank? target = visible_path.find { |s| s.key.to_s == step_key.to_s } return false unless target return true if target.key.to_s == @state.current_step.to_s # The review step is reachable once the user has started the flow (visited # at least one step): it shows the auto-summary, the outstanding "fix this" # links, and a Finish that stays disabled until every step is complete — # the actual finalize POST re-checks completeness regardless. Other steps # are reachable only once visited (no forward jumps to unvisited steps). reachable = target.review? ? visited_keys.any? : visited_keys.include?(target.key.to_s) return false unless reachable @state.current_step = target.key.to_s true end |
#incomplete_visible_steps ⇒ Object
The ordered visible non-review steps that aren't yet complete (§6.3). The review step lists these as "fix this" jump links and gates Finish.
144 145 146 147 148 |
# File 'lib/plutonium/wizard/runner.rb', line 144 def incomplete_visible_steps visible_path.reject(&:review?).select do |step| !submitted?(step) || validate(step, {}).any? end end |
#resumed? ⇒ Boolean
Whether a row already existed at this key when the runner was built — i.e. this launch RESUMED rather than started fresh (§4.2).
70 |
# File 'lib/plutonium/wizard/runner.rb', line 70 def resumed? = @resumed |
#step_complete?(step) ⇒ Boolean
Whether a visible non-review step is complete: SUBMITTED AND its staged data
currently validates (§6.3). Drives the review step's per-step jump links and
the gated Finish button (§2.5). A review step is "complete" iff every other
visible step is. "Submitted" (advanced THROUGH, not merely reached) is the
gating notion — distinct from visited/reached, which is for navigation —
so a user can't skip a zero-validation step just by landing on it.
127 128 129 130 131 |
# File 'lib/plutonium/wizard/runner.rb', line 127 def step_complete?(step) return incomplete_visible_steps.empty? if step.review? submitted?(step) && validate(step, {}).empty? end |
#submitted?(step) ⇒ Boolean
Whether a step has been SUBMITTED (advanced through) — its staged data
slice exists. Distinct from visited/reached: advancing TO a step (landing
on it) doesn't stage its data, so it isn't "submitted" until the user Nexts
through it. Drives the forward button label (revisiting a submitted step →
"Save & continue") and gates completeness (§6.3).
138 139 140 |
# File 'lib/plutonium/wizard/runner.rb', line 138 def submitted?(step) @state.data.key?(step.key.to_s) end |
#visible_path ⇒ Object
The currently-visible step path (§6.2 subtractive branching). Each step's
condition: is evaluated against the latest staged data; the review step
is always last by construction.
Called many times per request (current_step, step_complete?, prune_*, the
page render), and each call instance_execs every step's condition: — so
memoize the result, keyed on the IDENTITY of @state.data. Every data
mutation reassigns @state.data to a NEW hash (merge/except/deep_merge
or a fresh @state), so an identity change is a reliable "data moved"
signal; conditions only depend on data (and the request-stable anchor).
sync_data still runs each call to preserve the wizard's live data view.
95 96 97 98 99 100 101 102 103 104 |
# File 'lib/plutonium/wizard/runner.rb', line 95 def visible_path sync_data return @visible_path if defined?(@visible_path) && @visible_path_data.equal?(@state.data) @visible_path = @wizard_class.steps.select do |step| step.condition.nil? || @wizard.instance_exec(&step.condition) end @visible_path_data = @state.data @visible_path end |
#visited_keys ⇒ Object
The keys of steps the user has REACHED (the high-water mark — every step the
cursor has landed on, including the one advanced from and the one advanced
to). Drives stepper clickability / go_to reachability (§7); it does NOT
gate completeness — that's submitted?. Reaching a step lets you navigate
back to it without forcing it to count as done.
117 118 119 |
# File 'lib/plutonium/wizard/runner.rb', line 117 def visited_keys @state.visited.map(&:to_s) end |