plutonium 0.60.5 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# The pure navigation/commit engine (§6). Given a wizard class, a {Store}, and
|
|
6
|
+
# an instance key, it loads (or builds) the {State}, hydrates a single wizard
|
|
7
|
+
# instance, and drives the flow: compute the visible path, validate + stage a
|
|
8
|
+
# step, run per-step `on_submit`/`persist`, navigate back, cancel (cleanup),
|
|
9
|
+
# and finalize via `execute` (with the completeness check, branch-hidden
|
|
10
|
+
# pruning, and the locked `in_progress → completing` transition).
|
|
11
|
+
#
|
|
12
|
+
# No HTTP/controller/UI here — the controller (Task 5) drives this directly.
|
|
13
|
+
class Runner
|
|
14
|
+
# The outcome of a runner operation.
|
|
15
|
+
#
|
|
16
|
+
# - +ok+ — the operation succeeded (validation passed / navigation moved).
|
|
17
|
+
# - +errors+ — {attribute => [messages]} when it didn't.
|
|
18
|
+
# - +completed+ — finalize ran `execute` to completion.
|
|
19
|
+
# - +redirect_step+ — finalize found a completeness gap; the step to bounce to.
|
|
20
|
+
# - +value+ — the successful `execute` outcome's value.
|
|
21
|
+
Result = Struct.new(:ok, :errors, :completed, :redirect_step, :value) do
|
|
22
|
+
def ok? = !!ok
|
|
23
|
+
|
|
24
|
+
def completed? = !!completed
|
|
25
|
+
|
|
26
|
+
def errors = self[:errors] || {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :wizard, :state
|
|
30
|
+
|
|
31
|
+
def initialize(wizard_class:, store:, instance_key:, view_context: nil,
|
|
32
|
+
owner: nil, anchor: nil, scope: nil, token: nil, engine: nil,
|
|
33
|
+
current_user: nil, current_scoped_entity: nil)
|
|
34
|
+
@engine = engine
|
|
35
|
+
@wizard_class = wizard_class
|
|
36
|
+
@store = store
|
|
37
|
+
@instance_key = instance_key
|
|
38
|
+
# The keyed row IS the lock (§4.2): an existing in_progress row at this
|
|
39
|
+
# instance_key is RESUMED, never forked. `read` returns it (or any prior
|
|
40
|
+
# row, incl. a completed one-time marker) for the digest; a fresh launch
|
|
41
|
+
# with no row builds new state.
|
|
42
|
+
existing = store.read(instance_key)
|
|
43
|
+
|
|
44
|
+
# Owner-scoped resume (§4.5): for a non-`anonymous` wizard, a row may only
|
|
45
|
+
# be resumed by its owner. A run id leaked in a URL can't be picked up by a
|
|
46
|
+
# different logged-in user — a mismatch reads as "no such run for you".
|
|
47
|
+
# `@forbidden` lets the driving layer 404 rather than silently fork.
|
|
48
|
+
if existing && owner_mismatch?(wizard_class, existing, current_user)
|
|
49
|
+
existing = nil
|
|
50
|
+
@forbidden = true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@resumed = !existing.nil?
|
|
54
|
+
@state = existing || new_state(owner:, anchor:, scope:, token:)
|
|
55
|
+
@wizard = wizard_class.new(view_context:)
|
|
56
|
+
@wizard.data_attributes = @state.data
|
|
57
|
+
@wizard.anchor = (@state.anchor || anchor) if wizard_class.anchored?
|
|
58
|
+
@wizard.current_user = current_user
|
|
59
|
+
@wizard.current_scoped_entity = current_scoped_entity
|
|
60
|
+
@wizard.wizard_token = token
|
|
61
|
+
# `persisted` is rehydrated LAZILY (§4.5): inject the stored GID source so
|
|
62
|
+
# `wizard.persisted[:key]` locates that key's GIDs on first read (memoized)
|
|
63
|
+
# — a request that never reads `persisted` issues zero locate queries. The
|
|
64
|
+
# anchor (the authz/scoping gate) is still resolved eagerly above.
|
|
65
|
+
@wizard.persisted_gid_source = @state.persisted
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Whether a row already existed at this key when the runner was built — i.e.
|
|
69
|
+
# this launch RESUMED rather than started fresh (§4.2).
|
|
70
|
+
def resumed? = @resumed
|
|
71
|
+
|
|
72
|
+
# Whether an existing row at this key belongs to a DIFFERENT user (§4.5
|
|
73
|
+
# owner-scoping). The driving layer turns this into a 404 so a leaked run id
|
|
74
|
+
# can't be resumed by another logged-in user.
|
|
75
|
+
def forbidden? = !!@forbidden
|
|
76
|
+
|
|
77
|
+
# Whether this run's key already has a retained `completed` one-time marker
|
|
78
|
+
# (§4.3 / §9) — re-entering a finished one-time wizard. The driving layer
|
|
79
|
+
# redirects such a request out rather than re-running it.
|
|
80
|
+
def completed_one_time?
|
|
81
|
+
@wizard_class.one_time? && @state.status.to_s == "completed"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The currently-visible step path (§6.2 subtractive branching). Each step's
|
|
85
|
+
# `condition:` is evaluated against the latest staged `data`; the review step
|
|
86
|
+
# is always last by construction.
|
|
87
|
+
#
|
|
88
|
+
# Called many times per request (current_step, step_complete?, prune_*, the
|
|
89
|
+
# page render), and each call instance_execs every step's `condition:` — so
|
|
90
|
+
# memoize the result, keyed on the IDENTITY of `@state.data`. Every data
|
|
91
|
+
# mutation reassigns `@state.data` to a NEW hash (`merge`/`except`/`deep_merge`
|
|
92
|
+
# or a fresh `@state`), so an identity change is a reliable "data moved"
|
|
93
|
+
# signal; conditions only depend on `data` (and the request-stable `anchor`).
|
|
94
|
+
# `sync_data` still runs each call to preserve the wizard's live `data` view.
|
|
95
|
+
def visible_path
|
|
96
|
+
sync_data
|
|
97
|
+
return @visible_path if defined?(@visible_path) && @visible_path_data.equal?(@state.data)
|
|
98
|
+
|
|
99
|
+
@visible_path = @wizard_class.steps.select do |step|
|
|
100
|
+
step.condition.nil? || @wizard.instance_exec(&step.condition)
|
|
101
|
+
end
|
|
102
|
+
@visible_path_data = @state.data
|
|
103
|
+
@visible_path
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The visible step matching the stored cursor, or the first visible step.
|
|
107
|
+
def current_step
|
|
108
|
+
path = visible_path
|
|
109
|
+
path.find { |s| s.key.to_s == @state.current_step.to_s } || path.first
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The keys of steps the user has REACHED (the high-water mark — every step the
|
|
113
|
+
# cursor has landed on, including the one advanced from and the one advanced
|
|
114
|
+
# to). Drives stepper clickability / `go_to` reachability (§7); it does NOT
|
|
115
|
+
# gate completeness — that's `submitted?`. Reaching a step lets you navigate
|
|
116
|
+
# back to it without forcing it to count as done.
|
|
117
|
+
def visited_keys
|
|
118
|
+
@state.visited.map(&:to_s)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Whether a visible non-review step is complete: SUBMITTED AND its staged data
|
|
122
|
+
# currently validates (§6.3). Drives the review step's per-step jump links and
|
|
123
|
+
# the gated Finish button (§2.5). A review step is "complete" iff every other
|
|
124
|
+
# visible step is. "Submitted" (advanced THROUGH, not merely reached) is the
|
|
125
|
+
# gating notion — distinct from `visited`/reached, which is for navigation —
|
|
126
|
+
# so a user can't skip a zero-validation step just by landing on it.
|
|
127
|
+
def step_complete?(step)
|
|
128
|
+
return incomplete_visible_steps.empty? if step.review?
|
|
129
|
+
|
|
130
|
+
submitted?(step) && validate(step, {}).empty?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Whether a step has been SUBMITTED (advanced through) — its staged `data`
|
|
134
|
+
# slice exists. Distinct from `visited`/reached: advancing TO a step (landing
|
|
135
|
+
# on it) doesn't stage its data, so it isn't "submitted" until the user Nexts
|
|
136
|
+
# through it. Drives the forward button label (revisiting a submitted step →
|
|
137
|
+
# "Save & continue") and gates completeness (§6.3).
|
|
138
|
+
def submitted?(step)
|
|
139
|
+
@state.data.key?(step.key.to_s)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The ordered visible non-review steps that aren't yet complete (§6.3). The
|
|
143
|
+
# review step lists these as "fix this" jump links and gates Finish.
|
|
144
|
+
def incomplete_visible_steps
|
|
145
|
+
visible_path.reject(&:review?).select do |step|
|
|
146
|
+
!submitted?(step) || validate(step, {}).any?
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Validate + stage a step, run its `on_submit` (in a transaction), then move
|
|
151
|
+
# the cursor to the next visible step. On validation/`on_submit` failure the
|
|
152
|
+
# cursor does not move and the errors are returned.
|
|
153
|
+
#
|
|
154
|
+
# `goto:` overrides the post-advance cursor target (the "Save & review"
|
|
155
|
+
# shortcut, §7): after staging this step it points the cursor at the named
|
|
156
|
+
# visible step (typically the review step) instead of the next one, so a user
|
|
157
|
+
# editing one step after completing the wizard returns straight to review. The
|
|
158
|
+
# override is ignored if it doesn't name a currently-visible step; review's
|
|
159
|
+
# own finalize re-checks completeness, so a forged jump can't skip the gate.
|
|
160
|
+
def advance(step_key, params, goto: nil)
|
|
161
|
+
step = step_for(step_key)
|
|
162
|
+
errors = validate(step, params)
|
|
163
|
+
if errors.any?
|
|
164
|
+
# Reflect the rejected submission back into the IN-MEMORY step data (via
|
|
165
|
+
# `stage`, which never persists — only `persist_state` does) so the
|
|
166
|
+
# re-rendered form shows exactly what the user just typed, with the errors
|
|
167
|
+
# attached. Without this, validation failure reverts every field on the
|
|
168
|
+
# step to its last STAGED value — so a sibling field the user filled in
|
|
169
|
+
# correctly silently empties out on the error re-render. Render-only: the
|
|
170
|
+
# cursor and stored row are untouched, so an abandoned invalid submit
|
|
171
|
+
# stages nothing.
|
|
172
|
+
stage(step.key, params)
|
|
173
|
+
return Result.new(ok: false, errors:)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Re-submitting a step whose on_submit ALREADY ran (you went back to it and
|
|
177
|
+
# Nexted again): undo the prior attempt — its on_rollback then destroy its
|
|
178
|
+
# tracked records — BEFORE re-running, so records/side effects aren't
|
|
179
|
+
# duplicated and the old records aren't orphaned. `persisted` carries the
|
|
180
|
+
# step's key once on_submit has run (a side-effect-only step records an empty
|
|
181
|
+
# list), so it's the "already ran" signal. An UNCHANGED re-submit keeps the
|
|
182
|
+
# prior result untouched — no needless rollback + re-charge.
|
|
183
|
+
ran_before = step.on_submit && @state.persisted.key?(step.key.to_s)
|
|
184
|
+
changed = step_input_changed?(step, params)
|
|
185
|
+
stage(step.key, params)
|
|
186
|
+
if step.on_submit && (!ran_before || changed)
|
|
187
|
+
rollback_prior_submit(step) if ran_before
|
|
188
|
+
run_on_submit(step)
|
|
189
|
+
end
|
|
190
|
+
@state.visited |= [step.key.to_s]
|
|
191
|
+
# Staging this step's params may have flipped a branch `condition:`, hiding
|
|
192
|
+
# an earlier step that already persisted records (save-as-you-go). Prune it
|
|
193
|
+
# NOW — roll its records back and clear its state — so nothing is orphaned
|
|
194
|
+
# for the rest of the flow (§6.3). A rollback failure here surfaces as a
|
|
195
|
+
# step failure (same as `on_submit`), it is not swallowed; the cursor does
|
|
196
|
+
# not move and the advance's data is not lost (the prune persists state).
|
|
197
|
+
prune_departed_steps
|
|
198
|
+
@state.current_step = advance_target(step, goto)&.key&.to_s
|
|
199
|
+
# Mark the step we ARRIVE at visited too, not just the one we left. `visited`
|
|
200
|
+
# is the set of steps the user has *reached* (the high-water mark) — what the
|
|
201
|
+
# stepper uses to decide which headers link. Without this, landing on a step
|
|
202
|
+
# and navigating away before completing it would leave it unreachable (you
|
|
203
|
+
# couldn't click back to it). Completeness gating is unaffected: it also
|
|
204
|
+
# checks validity, so a required step reached-but-empty still reads incomplete.
|
|
205
|
+
@state.visited |= [@state.current_step].compact
|
|
206
|
+
persist_state
|
|
207
|
+
Result.new(ok: true)
|
|
208
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
209
|
+
Result.new(ok: false, errors: message_errors(e.record))
|
|
210
|
+
rescue StepError => e
|
|
211
|
+
Result.new(ok: false, errors: {e.attribute => [e.message]})
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Point the cursor at a specific visible step on a GET (stepper jump / resume
|
|
215
|
+
# via direct URL). Only honored when the target is a currently-visible step
|
|
216
|
+
# the user has already visited — forward jumps to unvisited steps are not
|
|
217
|
+
# allowed (§7). The review step is reachable once it's the visible terminal.
|
|
218
|
+
# No persistence: a GET must not mutate stored state; the cursor move lives
|
|
219
|
+
# for this request so the right step renders seeded from staged data.
|
|
220
|
+
#
|
|
221
|
+
# Returns true when the requested step is the legitimate current step for this
|
|
222
|
+
# request — already current, or reachable and now aligned — and false when it
|
|
223
|
+
# is not reachable (blank, branch-hidden, or a forward jump to an unvisited
|
|
224
|
+
# step). The driving layer uses this confirmation to abort a POST that targets
|
|
225
|
+
# an unreachable step BEFORE it validates/stages/runs the step's on_submit, so
|
|
226
|
+
# a forged or stale submission can't drive a step the user can't see.
|
|
227
|
+
def go_to(step_key)
|
|
228
|
+
return false if step_key.blank?
|
|
229
|
+
|
|
230
|
+
target = visible_path.find { |s| s.key.to_s == step_key.to_s }
|
|
231
|
+
return false unless target
|
|
232
|
+
return true if target.key.to_s == @state.current_step.to_s
|
|
233
|
+
|
|
234
|
+
# The review step is reachable once the user has started the flow (visited
|
|
235
|
+
# at least one step): it shows the auto-summary, the outstanding "fix this"
|
|
236
|
+
# links, and a Finish that stays disabled until every step is complete —
|
|
237
|
+
# the actual finalize POST re-checks completeness regardless. Other steps
|
|
238
|
+
# are reachable only once visited (no forward jumps to unvisited steps).
|
|
239
|
+
reachable = target.review? ? visited_keys.any? : visited_keys.include?(target.key.to_s)
|
|
240
|
+
return false unless reachable
|
|
241
|
+
|
|
242
|
+
@state.current_step = target.key.to_s
|
|
243
|
+
true
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Move the cursor to the previous visible step. No validation; never discards
|
|
247
|
+
# staged data (§6 — back is navigation, not submission).
|
|
248
|
+
def back
|
|
249
|
+
@state.current_step = previous_visible&.key&.to_s
|
|
250
|
+
persist_state
|
|
251
|
+
Result.new(ok: true)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Abandon the flow: run cleanup (each step's `on_rollback`, then always
|
|
255
|
+
# destroy its tracked records, in reverse step order) BEFORE clearing the
|
|
256
|
+
# row — `clear` is a `delete_all` with no callbacks, so compensation must
|
|
257
|
+
# happen first (§2.3).
|
|
258
|
+
def cancel
|
|
259
|
+
run_cleanup
|
|
260
|
+
@store.clear(@instance_key)
|
|
261
|
+
Result.new(ok: true)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Finish the flow (§6.3): assert every visible non-review step is visited and
|
|
265
|
+
# valid (else bounce to the first gap); prune branch-hidden data on a working
|
|
266
|
+
# copy; perform the locked `in_progress → completing` transition; run
|
|
267
|
+
# `execute` in a transaction; complete the row on success, revert on failure.
|
|
268
|
+
def finalize
|
|
269
|
+
gap = first_incomplete_visible
|
|
270
|
+
return Result.new(ok: false, redirect_step: gap.key) if gap
|
|
271
|
+
|
|
272
|
+
# Safety net (§6.3): roll back + forget any branch-hidden step that still
|
|
273
|
+
# holds persisted records or staged data, so nothing orphaned survives into
|
|
274
|
+
# `execute`. `advance` prunes promptly, but a step can be hidden via paths
|
|
275
|
+
# that don't pass through `advance` (e.g. seeded/resumed state).
|
|
276
|
+
prune_departed_steps
|
|
277
|
+
pruned = prune_hidden(@state.data)
|
|
278
|
+
|
|
279
|
+
# Lost a concurrent finalize (the row is already `completing` or `completed`,
|
|
280
|
+
# §6.2): another request/tab is running — or already ran — `execute`. Don't
|
|
281
|
+
# render a blank-error 422; PRG back to the terminal step so the follow-up
|
|
282
|
+
# GET resolves to the right place (the "already completed" page for a
|
|
283
|
+
# one-time wizard, or a fresh re-render once the winner finishes).
|
|
284
|
+
unless lock_for_completion!
|
|
285
|
+
return Result.new(ok: false, redirect_step: visible_path.last&.key)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
outcome = nil
|
|
289
|
+
ActiveRecord::Base.transaction do
|
|
290
|
+
@wizard.data_attributes = pruned
|
|
291
|
+
outcome = @wizard.execute
|
|
292
|
+
raise ActiveRecord::Rollback if outcome.failure?
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
if outcome.success?
|
|
296
|
+
# Repeatability (§4.3): a one-time wizard RETAINS its completed row at
|
|
297
|
+
# the key (blocks restart, the gate checks it); every other wizard
|
|
298
|
+
# DELETES the row on completion (repeatable — tokened runs always are).
|
|
299
|
+
if @wizard_class.one_time?
|
|
300
|
+
@store.complete(@instance_key)
|
|
301
|
+
else
|
|
302
|
+
@store.clear(@instance_key)
|
|
303
|
+
end
|
|
304
|
+
Result.new(ok: true, completed: true, value: outcome.value)
|
|
305
|
+
else
|
|
306
|
+
revert_completing!
|
|
307
|
+
Result.new(ok: false, errors: wizard_errors)
|
|
308
|
+
end
|
|
309
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
310
|
+
revert_completing!
|
|
311
|
+
Result.new(ok: false, errors: message_errors(e.record))
|
|
312
|
+
rescue StepError => e
|
|
313
|
+
revert_completing!
|
|
314
|
+
Result.new(ok: false, errors: {e.attribute => [e.message]})
|
|
315
|
+
rescue
|
|
316
|
+
# `lock_for_completion!` committed `completing` in its own transaction
|
|
317
|
+
# before `execute` ran (§6.2). Any hard failure here must revert that row
|
|
318
|
+
# to `in_progress` so the user can retry, then propagate.
|
|
319
|
+
revert_completing!
|
|
320
|
+
raise
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
private
|
|
324
|
+
|
|
325
|
+
def sync_data
|
|
326
|
+
@wizard.data_attributes = @state.data
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Stage a step's submitted (flat) fields under its step key — `data` is keyed
|
|
330
|
+
# by step ({step_key => {field => value}}), so a step's writes never touch
|
|
331
|
+
# another step's slice.
|
|
332
|
+
def stage(step_key, params)
|
|
333
|
+
key = step_key.to_s
|
|
334
|
+
current = @state.data[key] || {}
|
|
335
|
+
@state.data = @state.data.merge(key => current.merge(params))
|
|
336
|
+
sync_data
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Persist the staged state. The store writes verbatim on the normal
|
|
340
|
+
# single-writer path; when a CONCURRENT advance committed since we read the
|
|
341
|
+
# row (double-submit, two tabs, or the first-step unique-index create race),
|
|
342
|
+
# it calls {#merge_concurrent_state} under a row lock so neither side's data
|
|
343
|
+
# is lost (§6.2). The returned state carries the bumped `lock_version`, so a
|
|
344
|
+
# later write in this same request is recognised as current (not a conflict).
|
|
345
|
+
def persist_state
|
|
346
|
+
@state = @store.write(@instance_key, @state, cleanup_after: @wizard_class.cleanup_after) do |latest|
|
|
347
|
+
merge_concurrent_state(latest)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Merge this request's staged changes onto the LATEST committed state (read
|
|
352
|
+
# under the store's row lock), returning the state to persist. `data` is
|
|
353
|
+
# nested ({step_key => {field => value}}) → deep-merge so a concurrent step's
|
|
354
|
+
# fields aren't clobbered. `persisted` is {step_key => [gids]} → UNION the
|
|
355
|
+
# lists per step; a shallow merge would replace the other writer's GID list
|
|
356
|
+
# for a shared step key (both raced first-step on_submits), orphaning its
|
|
357
|
+
# records (no longer tracked for rollback/sweep). Identity/context fields are
|
|
358
|
+
# taken from whichever side has them (the row may have been a bare create).
|
|
359
|
+
def merge_concurrent_state(latest)
|
|
360
|
+
latest.data = latest.data.deep_merge(@state.data)
|
|
361
|
+
latest.persisted = latest.persisted.merge(@state.persisted) { |_key, a, b| a | b }
|
|
362
|
+
latest.visited |= @state.visited
|
|
363
|
+
latest.current_step = @state.current_step
|
|
364
|
+
latest.owner ||= @state.owner
|
|
365
|
+
latest.anchor ||= @state.anchor
|
|
366
|
+
latest.scope ||= @state.scope
|
|
367
|
+
latest.token ||= @state.token
|
|
368
|
+
latest.engine ||= @state.engine
|
|
369
|
+
latest
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def step_for(key)
|
|
373
|
+
@wizard_class.steps.find { |s| s.key.to_s == key.to_s }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def next_visible_after(step)
|
|
377
|
+
path = visible_path
|
|
378
|
+
idx = path.index { |s| s.key == step.key }
|
|
379
|
+
idx ? path[idx + 1] : path.first
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# The cursor's post-advance target: the `goto` step when it names a currently-
|
|
383
|
+
# visible step (the "Save & review" shortcut), else the next visible step.
|
|
384
|
+
def advance_target(step, goto)
|
|
385
|
+
if goto.present?
|
|
386
|
+
target = visible_path.find { |s| s.key.to_s == goto.to_s }
|
|
387
|
+
return target if target
|
|
388
|
+
end
|
|
389
|
+
next_visible_after(step)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def previous_visible
|
|
393
|
+
path = visible_path
|
|
394
|
+
idx = path.index { |s| s.key.to_s == @state.current_step.to_s } || 0
|
|
395
|
+
path[[idx - 1, 0].max]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Validate a step's params: imported (model) validation merged with inline
|
|
399
|
+
# `validates`. `imported_validate_fn` MAY be nil (validate: false) — nil-guard.
|
|
400
|
+
def validate(step, params)
|
|
401
|
+
return {} if step.review?
|
|
402
|
+
|
|
403
|
+
merged = (@state.data[step.key.to_s] || {}).merge(params)
|
|
404
|
+
errors = {}
|
|
405
|
+
imported = step.imported_validate_fn&.call(merged)
|
|
406
|
+
errors.merge!(stringify_messages(imported)) if imported
|
|
407
|
+
errors.merge!(inline_errors(step, merged)) { |_k, a, b| Array(a) + Array(b) }
|
|
408
|
+
errors.merge!(attachment_errors(step, merged)) { |_k, a, b| Array(a) + Array(b) }
|
|
409
|
+
errors.reject { |_attr, msgs| Array(msgs).blank? }
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Stage-phase attachment validation: run each Shrine-backed file field's
|
|
413
|
+
# effective uploader (`uploader:` or base Shrine) validations against the
|
|
414
|
+
# staged token, so a file that violates them is rejected on THIS step — the
|
|
415
|
+
# same field-error/re-render path as `validates` — instead of only at
|
|
416
|
+
# `execute`. A no-op for ActiveStorage fields and for uploaders with no rules.
|
|
417
|
+
def attachment_errors(step, merged)
|
|
418
|
+
step.inputs.each_with_object({}) do |(name, config), acc|
|
|
419
|
+
next unless Plutonium::Wizard::Attachments.field?(config)
|
|
420
|
+
|
|
421
|
+
messages = Plutonium::Wizard::Attachments.validation_errors(
|
|
422
|
+
merged[name.to_s],
|
|
423
|
+
backend: config.dig(:options, :backend),
|
|
424
|
+
uploader: config.dig(:options, :uploader)
|
|
425
|
+
)
|
|
426
|
+
acc[name.to_sym] = messages if messages.any?
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Run the step's inline `validates` against a transient instance of the SAME
|
|
431
|
+
# typed class the form/data layer uses ({Data.class_for}), built from THIS
|
|
432
|
+
# step's schema (not a cross-step union). Passes ONLY the inline `validations`
|
|
433
|
+
# — NOT the import's `form_validators` — because imported fields are validated
|
|
434
|
+
# separately through the transient model (`imported_validate_fn`); folding the
|
|
435
|
+
# imported validators in here too would double-report them. Returns
|
|
436
|
+
# {attribute => [String messages]} keyed by symbol.
|
|
437
|
+
def inline_errors(step, merged)
|
|
438
|
+
# Reuse the wizard class's memoized inline-validation class (built once)
|
|
439
|
+
# instead of recompiling an anonymous class on every call. nil → the step
|
|
440
|
+
# has no inline validations.
|
|
441
|
+
klass = @wizard_class.inline_validation_classes[step.key.to_sym]
|
|
442
|
+
return {} unless klass
|
|
443
|
+
|
|
444
|
+
obj = klass.new(merged)
|
|
445
|
+
obj.valid?
|
|
446
|
+
message_errors(obj)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Run the step's `on_submit` in a transaction, with the `persist` macro bound
|
|
450
|
+
# to the wizard for the duration. Tracked records' GIDs land in
|
|
451
|
+
# `state.persisted[step_key]`; the live records land in `wizard.persisted`.
|
|
452
|
+
def run_on_submit(step)
|
|
453
|
+
tracker = PersistTracker.new
|
|
454
|
+
ActiveRecord::Base.transaction do
|
|
455
|
+
sync_data
|
|
456
|
+
@wizard.define_singleton_method(:persist) { |*records| tracker.add(records.flatten) }
|
|
457
|
+
begin
|
|
458
|
+
@wizard.instance_exec(&step.on_submit)
|
|
459
|
+
ensure
|
|
460
|
+
@wizard.singleton_class.send(:remove_method, :persist) if @wizard.singleton_class.method_defined?(:persist)
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
@state.persisted = @state.persisted.merge(step.key.to_s => tracker.gids)
|
|
464
|
+
@wizard.persisted[step.key.to_sym] = tracker.records
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# Undo a step's PRIOR on_submit before it is re-run (a re-submit with changed
|
|
468
|
+
# input). Runs the same compensation as cancel/branch-prune — the step's
|
|
469
|
+
# on_rollback then `destroy!` of its tracked records — in its own transaction
|
|
470
|
+
# (consistent with `run_cleanup`/`prune_departed_steps`), then forgets the
|
|
471
|
+
# step's persisted entry so the fresh on_submit starts clean.
|
|
472
|
+
def rollback_prior_submit(step)
|
|
473
|
+
ActiveRecord::Base.transaction { rollback_step(step) }
|
|
474
|
+
@state.persisted = @state.persisted.except(step.key.to_s)
|
|
475
|
+
@wizard.persisted[step.key.to_sym] = []
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Whether the incoming params would change the step's already-staged slice —
|
|
479
|
+
# i.e. the user edited something. An unchanged re-submit (back, then Next with
|
|
480
|
+
# no edits) must NOT re-run a side-effecting on_submit.
|
|
481
|
+
def step_input_changed?(step, params)
|
|
482
|
+
current = @state.data[step.key.to_s] || {}
|
|
483
|
+
current.merge(params) != current
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# Reverse-order cleanup of every step's tracked records (§2.3): run each
|
|
487
|
+
# step's `on_rollback` (if any) then always destroy its tracked records.
|
|
488
|
+
def run_cleanup
|
|
489
|
+
ActiveRecord::Base.transaction do
|
|
490
|
+
@wizard_class.steps.reverse_each { |step| rollback_step(step) }
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# Per-step rollback (§2.3), shared by `run_cleanup` (cancel/sweep) and the
|
|
495
|
+
# branch-hidden prune path (§6.3). `persist`'d records are ALWAYS destroyed
|
|
496
|
+
# by the engine; `on_rollback` is an OPTIONAL hook for ADDITIONAL cleanup of
|
|
497
|
+
# untracked side effects (refund a charge, call an external API), run while
|
|
498
|
+
# the records are still alive so it can read `persisted[step]`.
|
|
499
|
+
#
|
|
500
|
+
# Order: locate the step's tracked records and populate `wizard.persisted`,
|
|
501
|
+
# run the user's `on_rollback` FIRST (records still alive — e.g. to read a
|
|
502
|
+
# `charge_id` to refund), THEN destroy the records in reverse order via
|
|
503
|
+
# `destroy!` (which respects a model's own soft-delete/paranoia override).
|
|
504
|
+
#
|
|
505
|
+
# A no-op only when the step tracked nothing AND has no `on_rollback` (so a
|
|
506
|
+
# step never persisted to and with no compensator issues no locate beyond
|
|
507
|
+
# the single `located_records` probe). A side-effect-only step (an
|
|
508
|
+
# `on_rollback` but no persisted records) still runs its `on_rollback`.
|
|
509
|
+
# Callers wrap this in a transaction so the compensating writes are atomic.
|
|
510
|
+
def rollback_step(step)
|
|
511
|
+
records = located_records(step)
|
|
512
|
+
return if records.empty? && step.on_rollback.nil?
|
|
513
|
+
|
|
514
|
+
@wizard.persisted[step.key.to_sym] = records
|
|
515
|
+
@wizard.instance_exec(&step.on_rollback) if step.on_rollback
|
|
516
|
+
records.reverse_each(&:destroy!)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def located_records(step)
|
|
520
|
+
gids = Array(@state.persisted[step.key.to_s])
|
|
521
|
+
return [] if gids.empty?
|
|
522
|
+
|
|
523
|
+
# One query per model class (vs one locate per GID) for a multi-record step;
|
|
524
|
+
# `ignore_missing` drops already-destroyed records, order is preserved.
|
|
525
|
+
GlobalID::Locator.locate_many(gids, ignore_missing: true)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# The first visible non-review step that hasn't been submitted+validated
|
|
529
|
+
# (§6.3): a step is incomplete if it was never SUBMITTED (advanced through) OR
|
|
530
|
+
# its staged data is invalid. A zero-validation step is therefore NOT complete
|
|
531
|
+
# until submitted, so a user can't skip it and still finalize. Branch-hidden
|
|
532
|
+
# steps fall out of `visible_path` and are excluded naturally.
|
|
533
|
+
def first_incomplete_visible
|
|
534
|
+
visible_path.reject(&:review?).find do |step|
|
|
535
|
+
!submitted?(step) || validate(step, {}).any?
|
|
536
|
+
end
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
# Drop the staged slice of any step not currently visible (§6.3 pruning) —
|
|
540
|
+
# `data` is keyed by step, so this is a slice of the visible step keys.
|
|
541
|
+
# Returns a working copy; the stored data is untouched.
|
|
542
|
+
def prune_hidden(data)
|
|
543
|
+
data.slice(*visible_path.reject(&:review?).map { |s| s.key.to_s })
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# Fully prune every step that has left the visible path but still has
|
|
547
|
+
# persisted records or staged `data` (§6.3). Save-as-you-go means a step's
|
|
548
|
+
# `on_submit` may have persisted records; when a later answer hides that step
|
|
549
|
+
# those records would otherwise be orphaned (`prune_hidden` only slices the
|
|
550
|
+
# working-copy `data`, it never rolls records back). For each departed step we
|
|
551
|
+
# roll its records back (`rollback_step` — the step's `on_rollback` then the
|
|
552
|
+
# engine's destroy), clear its persisted/data/visited state, then persist so
|
|
553
|
+
# the cleared state is durable.
|
|
554
|
+
#
|
|
555
|
+
# Only departed steps that actually hold something are touched — a step never
|
|
556
|
+
# persisted to and with no staged data issues no locate (we don't probe the
|
|
557
|
+
# whole step list), so the lazy-persisted contract is preserved.
|
|
558
|
+
def prune_departed_steps
|
|
559
|
+
visible = visible_path
|
|
560
|
+
departed = @wizard_class.steps.reject do |step|
|
|
561
|
+
visible.any? { |v| v.key == step.key } || !step_has_state?(step)
|
|
562
|
+
end
|
|
563
|
+
return if departed.empty?
|
|
564
|
+
|
|
565
|
+
# Compensating writes are atomic, consistent with `run_cleanup`/`on_submit`.
|
|
566
|
+
# Reverse order so later steps unwind before the earlier ones they built on.
|
|
567
|
+
ActiveRecord::Base.transaction do
|
|
568
|
+
departed.reverse_each { |step| rollback_step(step) }
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
departed.each { |step| forget_step(step) }
|
|
572
|
+
persist_state
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Whether a step holds anything worth pruning: persisted records (its key is
|
|
576
|
+
# present in stored `persisted`) or a non-empty staged `data` slice for the
|
|
577
|
+
# step. Pure hash/key inspection — never locates.
|
|
578
|
+
def step_has_state?(step)
|
|
579
|
+
return true if @state.persisted.key?(step.key.to_s)
|
|
580
|
+
|
|
581
|
+
@state.data[step.key.to_s].present?
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Erase all trace of a step that left the visible path: its persisted GIDs
|
|
585
|
+
# (state + the live wizard view), its staged `data`, and its visited mark — so
|
|
586
|
+
# if the branch is re-entered the step is treated as unvisited and its
|
|
587
|
+
# `on_submit` re-runs cleanly (§6.3).
|
|
588
|
+
def forget_step(step)
|
|
589
|
+
key = step.key.to_s
|
|
590
|
+
@state.persisted = @state.persisted.except(key)
|
|
591
|
+
@wizard.persisted[step.key.to_sym] = []
|
|
592
|
+
|
|
593
|
+
@state.data = @state.data.except(key)
|
|
594
|
+
@state.visited = @state.visited - [key]
|
|
595
|
+
sync_data
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
# The locked `in_progress → completing` transition (§6.2). With the AR store a
|
|
599
|
+
# row exists and we lock it; the Memory store has no row, so there's nothing
|
|
600
|
+
# to lock and we proceed (the in-process test can't race). Returns false for
|
|
601
|
+
# the loser of a concurrent finalize.
|
|
602
|
+
def lock_for_completion!
|
|
603
|
+
row = Session.find_by(instance_key: @instance_key)
|
|
604
|
+
return true unless row
|
|
605
|
+
|
|
606
|
+
row.with_lock do
|
|
607
|
+
return false unless row.status_in_progress?
|
|
608
|
+
row.update!(status: "completing")
|
|
609
|
+
end
|
|
610
|
+
true
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def revert_completing!
|
|
614
|
+
Session.where(instance_key: @instance_key, status: "completing")
|
|
615
|
+
.update_all(status: "in_progress")
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# Owner-scoping check (§4.5): a non-`anonymous` wizard's row may only be
|
|
619
|
+
# resumed by the user that owns it. An `anonymous` wizard is guarded by its
|
|
620
|
+
# unguessable run id instead (no owner), so it never mismatches here.
|
|
621
|
+
def owner_mismatch?(wizard_class, state, current_user)
|
|
622
|
+
return false if wizard_class.anonymous?
|
|
623
|
+
return false if state.owner.nil?
|
|
624
|
+
|
|
625
|
+
gid(state.owner) != gid(current_user)
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def gid(record)
|
|
629
|
+
record&.to_global_id&.to_s
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def new_state(owner:, anchor:, scope:, token:)
|
|
633
|
+
State.new(
|
|
634
|
+
wizard: @wizard_class.name,
|
|
635
|
+
instance_key: @instance_key,
|
|
636
|
+
current_step: @wizard_class.steps.first&.key&.to_s,
|
|
637
|
+
status: "in_progress",
|
|
638
|
+
data: {},
|
|
639
|
+
persisted: {},
|
|
640
|
+
visited: [],
|
|
641
|
+
owner:,
|
|
642
|
+
anchor:,
|
|
643
|
+
scope:,
|
|
644
|
+
token:,
|
|
645
|
+
engine: @engine
|
|
646
|
+
)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def wizard_errors
|
|
650
|
+
message_errors(@wizard)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Normalize a model's errors to {attribute_sym => [String messages]} (§6.1).
|
|
654
|
+
def message_errors(obj)
|
|
655
|
+
obj.errors.group_by_attribute.transform_values { |errs| errs.map(&:message) }
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
# Normalize a {attribute => [ActiveModel::Error | String]} hash (e.g. from
|
|
659
|
+
# `imported_validate_fn`) to {attribute => [String messages]} (§6.1).
|
|
660
|
+
def stringify_messages(errors)
|
|
661
|
+
errors.transform_values do |msgs|
|
|
662
|
+
Array(msgs).map { |m| m.respond_to?(:message) ? m.message : m }
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Accumulates the records passed to the `persist` macro inside `on_submit`.
|
|
667
|
+
class PersistTracker
|
|
668
|
+
def initialize
|
|
669
|
+
@records = []
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def add(records)
|
|
673
|
+
@records.concat(Array(records))
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
attr_reader :records
|
|
677
|
+
|
|
678
|
+
def gids
|
|
679
|
+
@records.map { |r| r.to_global_id.to_s }
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
end
|
|
684
|
+
end
|