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,639 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Surface-agnostic runner-driving logic shared by every wizard launch surface
|
|
6
|
+
# (§6). It is mixed into:
|
|
7
|
+
#
|
|
8
|
+
# - {Plutonium::Wizard::Controller} — the standalone portal-level
|
|
9
|
+
# `register_wizard` controller (non-anchored), and
|
|
10
|
+
# - {Plutonium::Resource::Controllers::WizardActions} — the resource-mounted
|
|
11
|
+
# member/collection actions (anchored anchor comes from the scoped, policy-
|
|
12
|
+
# gated `resource_record!`, exactly like interactive record actions).
|
|
13
|
+
#
|
|
14
|
+
# Both surfaces share the per-request flow: resolve the instance (URL token /
|
|
15
|
+
# anchor + `current_user` owner + portal `scoped_entity` scope) → build the
|
|
16
|
+
# {Runner} → check entry authorization → GET renders the current step / POST
|
|
17
|
+
# dispatches on `params[:_direction]` (`back` / `cancel` / advance+finalize).
|
|
18
|
+
#
|
|
19
|
+
# Surfaces differ only in a small set of hooks (the wizard class, the anchor,
|
|
20
|
+
# the per-step URL, completion/exit targets, and authorization), which the
|
|
21
|
+
# including controller overrides.
|
|
22
|
+
module Driving
|
|
23
|
+
extend ActiveSupport::Concern
|
|
24
|
+
include Plutonium::StructuredInputs::ParamsConcern
|
|
25
|
+
|
|
26
|
+
# The Rails-session bucket holding per-wizard guest run ids (§4.5). A guest
|
|
27
|
+
# (`anonymous`) run's token lives under `session[SESSION_TOKENS_KEY][wizard_key]`.
|
|
28
|
+
SESSION_TOKENS_KEY = "plutonium_wizards"
|
|
29
|
+
|
|
30
|
+
# The Rails-session bucket holding the per-wizard "return to" path captured at
|
|
31
|
+
# launch — the page the user came from. Cancel redirects there instead of the
|
|
32
|
+
# host root. Namespaced per wizard so two in-flight wizards don't clobber each
|
|
33
|
+
# other.
|
|
34
|
+
WIZARD_RETURN_TO_KEY = "plutonium_wizard_return_to"
|
|
35
|
+
|
|
36
|
+
# The per-wizard key under {SESSION_TOKENS_KEY} for a guest run's token.
|
|
37
|
+
def self.session_token_key(wizard_class)
|
|
38
|
+
wizard_class.name.underscore.tr("/", "_")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# GET the bare mount (no :step) — the canonical launch. Resolve the run (mint
|
|
44
|
+
# the per-run token for a tokened wizard, or resolve the keyed/guest identity),
|
|
45
|
+
# then PRG to its entry step — the resumed cursor for an in-progress keyed/guest
|
|
46
|
+
# run, else the first visible step. The redirect URL carries the token, so the
|
|
47
|
+
# address bar shows a stable, shareable run URL from the first paint (no more
|
|
48
|
+
# "token appears only after the first submit", and no fork-on-reload).
|
|
49
|
+
def wizard_launch
|
|
50
|
+
require_wizard_authentication!
|
|
51
|
+
stash_wizard_return_to!
|
|
52
|
+
|
|
53
|
+
# `on_relaunch :prompt` (§4.5): when the user already has pending runs of
|
|
54
|
+
# this (tokened) wizard, show a "resume or start new" chooser instead of
|
|
55
|
+
# forking a fresh run. Decided BEFORE `build_wizard_runner`, which would
|
|
56
|
+
# otherwise mint a token.
|
|
57
|
+
if wizard_relaunch_prompt?
|
|
58
|
+
return render_wizard_chooser
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
runner = build_wizard_runner
|
|
62
|
+
deny_wizard_resume_for_other_user!(runner)
|
|
63
|
+
authorize_wizard_entry!(runner)
|
|
64
|
+
|
|
65
|
+
if runner.completed_one_time?
|
|
66
|
+
return render_wizard_completed(runner)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
redirect_to wizard_step_url(runner.current_step&.key), status: :see_other, allow_other_host: false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Whether the bare launch should divert to the resume-or-new chooser: the
|
|
73
|
+
# wizard opted in (`on_relaunch :prompt`), it's an authenticated TOKENED run
|
|
74
|
+
# (keyed wizards already auto-resume; `anonymous` runs are session-keyed),
|
|
75
|
+
# the request isn't the explicit "start new" path, and a pending run exists.
|
|
76
|
+
def wizard_relaunch_prompt?
|
|
77
|
+
klass = current_wizard_class
|
|
78
|
+
return false unless klass.relaunch_prompt?
|
|
79
|
+
return false if klass.anonymous? || klass.concurrency_key?
|
|
80
|
+
return false if params[:new].present?
|
|
81
|
+
|
|
82
|
+
wizard_pending_entries.any?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# This wizard's in-progress runs for the current owner (tenant-scoped),
|
|
86
|
+
# enriched with resume URLs — via the shared {Resume} listing module. The
|
|
87
|
+
# `wizard:` filter narrows in the query, so only THIS wizard's rows are
|
|
88
|
+
# enriched (not every pending run of every wizard, then discarded).
|
|
89
|
+
def wizard_pending_entries
|
|
90
|
+
@wizard_pending_entries ||=
|
|
91
|
+
Plutonium::Wizard::Resume.entries_for(view_context, wizard: current_wizard_class)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# GET .../:step — render the current step (or bounce on a completeness gap).
|
|
95
|
+
def wizard_show
|
|
96
|
+
require_wizard_authentication!
|
|
97
|
+
runner = build_wizard_runner
|
|
98
|
+
deny_wizard_resume_for_other_user!(runner)
|
|
99
|
+
authorize_wizard_entry!(runner)
|
|
100
|
+
|
|
101
|
+
# Re-entering a finished one-time wizard (§4.3/§9): its key holds a
|
|
102
|
+
# retained `completed` row whose `data` was cleared on completion, so there
|
|
103
|
+
# is nothing to review — render the standalone "already completed" page.
|
|
104
|
+
if runner.completed_one_time?
|
|
105
|
+
return render_wizard_completed(runner)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if (target = wizard_redirect_step)
|
|
109
|
+
return redirect_to wizard_step_url(target), status: :see_other, allow_other_host: false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Honor a direct GET to a specific (visited/visible) step — stepper jumps
|
|
113
|
+
# and resume-by-URL. Forward jumps to unvisited steps are ignored.
|
|
114
|
+
runner.go_to(params[:step])
|
|
115
|
+
|
|
116
|
+
@wizard_runner = runner
|
|
117
|
+
render_wizard_step(runner)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# POST .../:step — advance / back / cancel.
|
|
121
|
+
def wizard_update
|
|
122
|
+
require_wizard_authentication!
|
|
123
|
+
runner = build_wizard_runner
|
|
124
|
+
deny_wizard_resume_for_other_user!(runner)
|
|
125
|
+
authorize_wizard_entry!(runner)
|
|
126
|
+
@wizard_runner = runner
|
|
127
|
+
|
|
128
|
+
# A POST to a finished one-time wizard (stale form / double submit): nothing
|
|
129
|
+
# to run. PRG to the step URL so the follow-up GET renders the completed page.
|
|
130
|
+
if runner.completed_one_time?
|
|
131
|
+
return redirect_to wizard_step_url(runner.current_step&.key), status: :see_other, allow_other_host: false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Align the in-memory cursor to the step being POSTed. A user can navigate
|
|
135
|
+
# BACK to an earlier step via a GET (stepper jump / direct URL), which by
|
|
136
|
+
# design does NOT persist the cursor — so the stored cursor still points at
|
|
137
|
+
# a LATER step. Without realigning, `wizard_params`/`wizard_step_form` would
|
|
138
|
+
# extract this submission through the wrong step's form: the edited fields
|
|
139
|
+
# are silently dropped and the stored-cursor step's fields leak in. The step
|
|
140
|
+
# carried in the URL is the one being submitted, so make it current.
|
|
141
|
+
#
|
|
142
|
+
# `go_to` returns false when that step is NOT reachable for this run — a
|
|
143
|
+
# branch-hidden step, or a forward jump to an unvisited step. A forged or
|
|
144
|
+
# stale POST to such a step must be REFUSED here: otherwise `advance` would
|
|
145
|
+
# look the step up across the whole declaration list and validate/stage/run
|
|
146
|
+
# its `on_submit` for a step the user can't see (the branch-prune only
|
|
147
|
+
# compensates `persist`ed records, never raw side effects). PRG back to the
|
|
148
|
+
# run's actual current step instead of processing the submission.
|
|
149
|
+
unless runner.go_to(params[:step])
|
|
150
|
+
return redirect_to wizard_step_url(runner.current_step&.key), status: :see_other, allow_other_host: false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
if params[:pre_submit]
|
|
154
|
+
return render_wizard_pre_submit(runner)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
result =
|
|
158
|
+
case params[:_direction].to_s
|
|
159
|
+
when "back"
|
|
160
|
+
runner.back
|
|
161
|
+
when "cancel"
|
|
162
|
+
runner.cancel
|
|
163
|
+
target = wizard_exit_url
|
|
164
|
+
clear_wizard_return_to
|
|
165
|
+
return redirect_to target, status: :see_other, allow_other_host: false
|
|
166
|
+
else
|
|
167
|
+
advance_or_finalize(runner)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
respond_to_wizard_result(runner, result)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Advance the current step; if the POSTed step is the last visible step,
|
|
174
|
+
# finalize. The last visible step is the terminal `review` (no fields), so
|
|
175
|
+
# finalize runs directly; otherwise validate + stage + move the cursor.
|
|
176
|
+
def advance_or_finalize(runner)
|
|
177
|
+
return runner.finalize if wizard_posting_last_step?(runner)
|
|
178
|
+
|
|
179
|
+
runner.advance(params[:step], wizard_params(runner), goto: params[:_goto].presence)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Whether the step being POSTed is the last visible step (so Next → Finish).
|
|
183
|
+
# Computed BEFORE advancing, since advance moves the cursor past it.
|
|
184
|
+
def wizard_posting_last_step?(runner)
|
|
185
|
+
last = runner.visible_path.last
|
|
186
|
+
last && last.key.to_s == params[:step].to_s
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def respond_to_wizard_result(runner, result)
|
|
190
|
+
if result.completed?
|
|
191
|
+
return complete_wizard!(result)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if (target = result.redirect_step)
|
|
195
|
+
return redirect_to wizard_step_url(target), status: :see_other, allow_other_host: false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
if result.ok?
|
|
199
|
+
redirect_to wizard_step_url(runner.current_step&.key), status: :see_other, allow_other_host: false
|
|
200
|
+
else
|
|
201
|
+
@wizard_errors = result.errors
|
|
202
|
+
render_wizard_step(runner, status: :unprocessable_content)
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# PRG out of a completed wizard: clear the guest run's session token and
|
|
207
|
+
# redirect. A gate (§9 {Plutonium::Wizard::Gate}) may have stashed the user's
|
|
208
|
+
# intended destination in `session[:return_to]` before bouncing them into a
|
|
209
|
+
# one-time wizard; prefer that bounce target over the outcome value's URL.
|
|
210
|
+
def complete_wizard!(result)
|
|
211
|
+
clear_wizard_session_token
|
|
212
|
+
# Completion lands on the RESULT (the created/updated resource) by default,
|
|
213
|
+
# not the launch origin — so drop the captured return-to. A gate's stashed
|
|
214
|
+
# `:return_to` (the page the user was bounced FROM into a one-time wizard)
|
|
215
|
+
# still wins, so they resume where they were headed.
|
|
216
|
+
clear_wizard_return_to
|
|
217
|
+
target = session.delete(:return_to).presence || wizard_completion_url(result.value)
|
|
218
|
+
redirect_to target, status: :see_other, allow_other_host: false
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Drop a guest run's token from the Rails session (on completion). A no-op
|
|
222
|
+
# for authenticated runs, whose token rides the URL, not the session.
|
|
223
|
+
def clear_wizard_session_token
|
|
224
|
+
bucket = session[Plutonium::Wizard::Driving::SESSION_TOKENS_KEY]
|
|
225
|
+
return unless bucket.is_a?(Hash)
|
|
226
|
+
|
|
227
|
+
bucket.delete(Plutonium::Wizard::Driving.session_token_key(current_wizard_class))
|
|
228
|
+
session.delete(Plutonium::Wizard::Driving::SESSION_TOKENS_KEY) if bucket.empty?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# --- rendering ---
|
|
232
|
+
|
|
233
|
+
# The "resume or start new" chooser (§4.5), rendered at the bare launch URL
|
|
234
|
+
# when `on_relaunch :prompt` and pending runs exist. "Start new" re-enters
|
|
235
|
+
# this launch with `?new=1`, which skips the chooser and mints a fresh run.
|
|
236
|
+
def render_wizard_chooser
|
|
237
|
+
render(
|
|
238
|
+
Plutonium::UI::Page::WizardChooser.new(
|
|
239
|
+
wizard_class: current_wizard_class,
|
|
240
|
+
entries: wizard_pending_entries,
|
|
241
|
+
start_new_url: "#{request.path}?new=1"
|
|
242
|
+
),
|
|
243
|
+
**wizard_modal_render_options
|
|
244
|
+
)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# The standalone "already completed" page for a re-opened one-time wizard
|
|
248
|
+
# (§9). Its `data` was cleared on completion, so this never shows the review —
|
|
249
|
+
# just a confirmation (or the wizard's `completed` block).
|
|
250
|
+
def render_wizard_completed(runner)
|
|
251
|
+
render(
|
|
252
|
+
Plutonium::UI::Page::WizardCompleted.new(
|
|
253
|
+
runner:,
|
|
254
|
+
exit_url: wizard_exit_url
|
|
255
|
+
),
|
|
256
|
+
**wizard_modal_render_options
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def render_wizard_step(runner, status: :ok)
|
|
261
|
+
render(
|
|
262
|
+
Plutonium::UI::Page::Wizard.new(
|
|
263
|
+
runner:,
|
|
264
|
+
step_url: wizard_step_url(runner.current_step&.key),
|
|
265
|
+
errors: @wizard_errors,
|
|
266
|
+
description: wizard_page_description
|
|
267
|
+
),
|
|
268
|
+
status:,
|
|
269
|
+
**wizard_modal_render_options
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# A `change->form#preSubmit` re-render: in a turbo frame (modal), replace just
|
|
274
|
+
# the step form; otherwise re-render the whole page. Mirrors interactive
|
|
275
|
+
# actions, where conditional inputs depend on sibling values.
|
|
276
|
+
def render_wizard_pre_submit(runner)
|
|
277
|
+
form = wizard_step_form(runner)
|
|
278
|
+
respond_to do |format|
|
|
279
|
+
format.turbo_stream do
|
|
280
|
+
render turbo_stream: turbo_stream.replace(
|
|
281
|
+
helpers.turbo_scoped_dom_id("wizard-form"),
|
|
282
|
+
view_context.render(form)
|
|
283
|
+
)
|
|
284
|
+
end
|
|
285
|
+
format.html { render_wizard_step(runner, status: :unprocessable_content) }
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def wizard_page_description = nil
|
|
290
|
+
|
|
291
|
+
# The render options (chiefly the layout) for a wizard page:
|
|
292
|
+
# - turbo-frame request → no layout (the embedded/modal case);
|
|
293
|
+
# - a resolved layout NAME → render in it (e.g. `basic` for an onboarding
|
|
294
|
+
# screen);
|
|
295
|
+
# - nil → inherit the controller's layout (the resource shell), so a custom
|
|
296
|
+
# controller layout still wins.
|
|
297
|
+
def wizard_modal_render_options
|
|
298
|
+
return {layout: false} if helpers.current_turbo_frame.present?
|
|
299
|
+
|
|
300
|
+
layout = wizard_layout
|
|
301
|
+
layout ? {layout:} : {}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# The Rails layout this run renders in — a layout NAME, exactly like the
|
|
305
|
+
# controller `layout` macro: `basic` (the bare BasicLayout, e.g. onboarding),
|
|
306
|
+
# `resource` (the standard shell), or any app layout. An explicit `layout:`
|
|
307
|
+
# from `register_wizard` rides as a route DEFAULT — one synthesized controller
|
|
308
|
+
# serves many mounts, so the per-mount value travels on the route, not the
|
|
309
|
+
# controller. Absent, it defaults by host: main-app → `"basic"` (no shell to
|
|
310
|
+
# embed in), portal → nil (inherit the controller's resource shell). Resource-
|
|
311
|
+
# defined wizards carry no `layout:` and render embedded (turbo frame → no
|
|
312
|
+
# layout, above).
|
|
313
|
+
def wizard_layout
|
|
314
|
+
explicit = params[:wizard_layout]
|
|
315
|
+
return explicit if explicit.present?
|
|
316
|
+
|
|
317
|
+
(current_engine == Rails.application.class) ? "basic" : nil
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# --- runner construction ---
|
|
321
|
+
|
|
322
|
+
def build_wizard_runner
|
|
323
|
+
Plutonium::Wizard::Runner.new(
|
|
324
|
+
wizard_class: current_wizard_class,
|
|
325
|
+
store: wizard_store,
|
|
326
|
+
instance_key: resolved_wizard_instance_key,
|
|
327
|
+
view_context:,
|
|
328
|
+
owner: resolved_wizard_owner,
|
|
329
|
+
anchor: resolved_wizard_anchor,
|
|
330
|
+
scope: resolved_wizard_scope,
|
|
331
|
+
token: wizard_token,
|
|
332
|
+
# The portal this run is launched in — recorded so the resume listing
|
|
333
|
+
# only ever surfaces it from THIS portal (§4.5).
|
|
334
|
+
engine: current_engine.name,
|
|
335
|
+
current_user: resolved_wizard_owner,
|
|
336
|
+
current_scoped_entity: resolved_wizard_scope
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# The authenticated user driving this run, or nil for a guest (`anonymous`)
|
|
341
|
+
# run (§4.5). The public surface stubs `current_user` to the "Guest"
|
|
342
|
+
# sentinel; a guest run has NO owner — its identity is the unguessable
|
|
343
|
+
# `wizard_token`, not a principal. Normalizing to nil keeps the runner's
|
|
344
|
+
# owner-scoping and the wizard's `current_user` honest.
|
|
345
|
+
def resolved_wizard_owner
|
|
346
|
+
return nil if current_wizard_class.anonymous?
|
|
347
|
+
|
|
348
|
+
current_user_present_for_wizard? ? current_user : nil
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def wizard_store
|
|
352
|
+
Plutonium::Wizard::Store::ActiveRecord.new
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# The instance_key for this run (§4.1). A wizard with a `concurrency_key`
|
|
356
|
+
# gets a stable digest over its resolved key value(s) (tenant folded in);
|
|
357
|
+
# otherwise the digest is over the per-launch `wizard_token` (fresh per
|
|
358
|
+
# launch → repeatable). This MUST stay byte-identical to the gate's
|
|
359
|
+
# recomputation (§9), so both go through {InstanceKey}.
|
|
360
|
+
def resolved_wizard_instance_key
|
|
361
|
+
Plutonium::Wizard.compute_instance_key(
|
|
362
|
+
wizard_class: current_wizard_class,
|
|
363
|
+
current_user: resolved_wizard_owner,
|
|
364
|
+
current_scoped_entity: resolved_wizard_scope,
|
|
365
|
+
anchor: resolved_wizard_anchor,
|
|
366
|
+
wizard_token: wizard_token
|
|
367
|
+
)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# The portal scoping entity (tenant) when the portal is entity-scoped (§4.4 /
|
|
371
|
+
# §8 multi-tenancy); nil otherwise. Folded into the key automatically.
|
|
372
|
+
def resolved_wizard_scope
|
|
373
|
+
return unless scoped_to_entity?
|
|
374
|
+
|
|
375
|
+
current_scoped_entity
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# The per-run id (§4.5). It is the identity principal for runs without a
|
|
379
|
+
# `concurrency_key` — guest (`anonymous`) runs AND authenticated repeatable
|
|
380
|
+
# runs — and is folded into `concurrency_key` resolution. It is NOT a
|
|
381
|
+
# pre-auth principal that survives login: authenticated runs are guarded by
|
|
382
|
+
# owner-scoping, and the wizard never crosses the auth boundary mid-flow.
|
|
383
|
+
#
|
|
384
|
+
# Two sources, by run identity:
|
|
385
|
+
#
|
|
386
|
+
# - **Guest (`anonymous`) runs** key off the **Rails session**, namespaced
|
|
387
|
+
# per wizard (`session["plutonium_wizards"][<wizard_key>]`), minted with
|
|
388
|
+
# `SecureRandom.alphanumeric(32)` and stored if absent, read each request. We never
|
|
389
|
+
# read the token from the URL for a guest run — the session is the only
|
|
390
|
+
# source, so there is no URL-leak surface. There is no TTL: the row's
|
|
391
|
+
# `cleanup_after` → sweep is the authoritative lifetime; the session token
|
|
392
|
+
# is just a pointer (browser-close ephemeral, auto-cleared by Rodauth's
|
|
393
|
+
# `reset_session` on login/logout, and cleared on completion).
|
|
394
|
+
# - **Authenticated repeatable runs** carry their per-run id in the URL
|
|
395
|
+
# `:token` segment (owner-scoped on the row), minting one when absent so a
|
|
396
|
+
# fresh launch is a fresh run.
|
|
397
|
+
def wizard_token
|
|
398
|
+
return @wizard_token if defined?(@wizard_token)
|
|
399
|
+
|
|
400
|
+
@wizard_token =
|
|
401
|
+
if current_wizard_class.anonymous?
|
|
402
|
+
guest_session_token
|
|
403
|
+
else
|
|
404
|
+
params[:token].presence || SecureRandom.alphanumeric(32)
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Read (minting + storing if absent) the guest run's token from the Rails
|
|
409
|
+
# session bucket. Session storage gives browser-close ephemerality and
|
|
410
|
+
# auto-clear on login/logout (Rodauth's `clear_session` → `reset_session`).
|
|
411
|
+
def guest_session_token
|
|
412
|
+
bucket = (session[Plutonium::Wizard::Driving::SESSION_TOKENS_KEY] ||= {})
|
|
413
|
+
key = Plutonium::Wizard::Driving.session_token_key(current_wizard_class)
|
|
414
|
+
bucket[key] ||= SecureRandom.alphanumeric(32)
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# The token to thread into a step URL, if any. An authenticated REPEATABLE
|
|
418
|
+
# run (no `concurrency_key` → tokened identity) carries its per-run id in the
|
|
419
|
+
# URL `:token` segment, so a fresh GET resumes rather than forks. A guest
|
|
420
|
+
# (`anonymous`) run keys off the Rails session, so its token MUST NOT appear
|
|
421
|
+
# in the URL (no leak surface); a keyed run's identity is its
|
|
422
|
+
# `concurrency_key`, so the token is irrelevant there. `nil` keeps it off.
|
|
423
|
+
def wizard_url_token
|
|
424
|
+
return nil if current_wizard_class.anonymous?
|
|
425
|
+
return nil if current_wizard_class.concurrency_key?
|
|
426
|
+
|
|
427
|
+
wizard_token
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# --- authorization ---
|
|
431
|
+
|
|
432
|
+
# Authentication gate (§4.5). Wizards REQUIRE authentication by default —
|
|
433
|
+
# entry without a `current_user` is rejected. An `anonymous` wizard opts out
|
|
434
|
+
# (guest access; it may authenticate only at its terminal `execute`).
|
|
435
|
+
#
|
|
436
|
+
# When a `current_user` is missing for a non-`anonymous` wizard we reject the
|
|
437
|
+
# way the host already handles unauthenticated access: Rodauth's
|
|
438
|
+
# `require_authentication` (redirect to login) when the portal exposes it,
|
|
439
|
+
# else a plain 401. We deliberately do NOT lean on the portal's own auth
|
|
440
|
+
# before_action because the public mount (for `anonymous` wizards) runs
|
|
441
|
+
# OUTSIDE the portal's authenticated route constraint.
|
|
442
|
+
def require_wizard_authentication!
|
|
443
|
+
return if current_wizard_class.anonymous?
|
|
444
|
+
return if current_user_present_for_wizard?
|
|
445
|
+
|
|
446
|
+
if respond_to?(:rodauth, true)
|
|
447
|
+
rodauth.require_authentication
|
|
448
|
+
else
|
|
449
|
+
head :unauthorized
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# `current_user` is truthy AND not the {Plutonium::Auth::Public} "Guest"
|
|
454
|
+
# sentinel (a public controller stubs `current_user` to the string "Guest").
|
|
455
|
+
def current_user_present_for_wizard?
|
|
456
|
+
user = current_user
|
|
457
|
+
user.present? && user != "Guest"
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Owner-scoped resume (§4.5): a non-`anonymous` wizard's row may only be
|
|
461
|
+
# resumed by its owner. The runner flags a mismatched row as forbidden; we
|
|
462
|
+
# 404 (rather than fork a fresh run) so a run id leaked in a URL can't be
|
|
463
|
+
# picked up by — or even probed by — another logged-in user.
|
|
464
|
+
def deny_wizard_resume_for_other_user!(runner)
|
|
465
|
+
return unless runner.forbidden?
|
|
466
|
+
|
|
467
|
+
raise ActiveRecord::RecordNotFound, "wizard run not found"
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Entry auth (§5.2 / §6.5). A wizard may define `authorize?` (default allow);
|
|
471
|
+
# false → 403 via the existing ActionPolicy::Unauthorized rescue. Resource-
|
|
472
|
+
# attached surfaces additionally gate via the action policy (see
|
|
473
|
+
# {WizardActions}); this base check covers the wizard-level hook common to
|
|
474
|
+
# both surfaces.
|
|
475
|
+
def authorize_wizard_entry!(runner)
|
|
476
|
+
wizard = runner.wizard
|
|
477
|
+
return if wizard.authorize?
|
|
478
|
+
|
|
479
|
+
raise ActionPolicy::Unauthorized.new(wizard.class, :authorize?)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# --- params ---
|
|
483
|
+
|
|
484
|
+
# Extract the current step's submitted params through the step form (like
|
|
485
|
+
# interactions), so typed inputs and structured/repeater inputs are parsed
|
|
486
|
+
# consistently with how they were rendered — then clean structured inputs
|
|
487
|
+
# (drop blank/template rows) and stringify keys for the data snapshot.
|
|
488
|
+
def wizard_params(runner)
|
|
489
|
+
return {} if params[:wizard].blank?
|
|
490
|
+
|
|
491
|
+
step = runner.current_step
|
|
492
|
+
form = wizard_step_form(runner)
|
|
493
|
+
extracted = form.extract_input(params, view_context:)[:wizard] || {}
|
|
494
|
+
cleaned = clean_structured_inputs(Plutonium::Wizard::StepAdapter.new(step), extracted.dup)
|
|
495
|
+
stage_wizard_uploads!(step, cleaned)
|
|
496
|
+
cleaned.stringify_keys
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Replace each attachment field's value with a staged TOKEN, minting one from
|
|
500
|
+
# an uploaded file for a plain (non-direct-upload) field. Reads the RAW param
|
|
501
|
+
# so a multipart `UploadedFile` isn't mangled by form extraction, then
|
|
502
|
+
# overrides the extracted value. A nil result (blank / no new file) drops the
|
|
503
|
+
# key, so the token already in `data` survives a Back/re-submit (`stage`
|
|
504
|
+
# merges, it doesn't replace). Direct-upload fields arrive as a token string
|
|
505
|
+
# and pass through unchanged.
|
|
506
|
+
def stage_wizard_uploads!(step, cleaned)
|
|
507
|
+
raw = params[:wizard]
|
|
508
|
+
step.inputs.each do |name, config|
|
|
509
|
+
next unless Plutonium::Wizard::Attachments.field?(config)
|
|
510
|
+
|
|
511
|
+
token = Plutonium::Wizard::Attachments.stage_upload(
|
|
512
|
+
raw[name],
|
|
513
|
+
backend: config.dig(:options, :backend),
|
|
514
|
+
uploader: config.dig(:options, :uploader)
|
|
515
|
+
)
|
|
516
|
+
if token.nil?
|
|
517
|
+
cleaned.delete(name)
|
|
518
|
+
cleaned.delete(name.to_s)
|
|
519
|
+
else
|
|
520
|
+
cleaned[name] = token
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# The form for the current step, seeded from the wizard's typed data — used
|
|
526
|
+
# for both param extraction and the pre_submit turbo re-render.
|
|
527
|
+
def wizard_step_form(runner)
|
|
528
|
+
step = runner.current_step
|
|
529
|
+
Plutonium::UI::Form::Wizard.new(
|
|
530
|
+
step:,
|
|
531
|
+
data: Plutonium::Wizard::AttachmentData.wrap(runner.wizard.data[step.key], step),
|
|
532
|
+
action: wizard_step_url(step&.key),
|
|
533
|
+
fields: step.attribute_schema.keys.map(&:to_sym) + step.structured_inputs.keys.map(&:to_sym)
|
|
534
|
+
)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def wizard_redirect_step = nil
|
|
538
|
+
|
|
539
|
+
# Where a completed wizard redirects (§6). Prefer the outcome value's resource
|
|
540
|
+
# URL; fall back to the portal root.
|
|
541
|
+
def wizard_completion_url(value)
|
|
542
|
+
if value.is_a?(ActiveRecord::Base)
|
|
543
|
+
resource_url_for(value)
|
|
544
|
+
else
|
|
545
|
+
main_or_portal_root_url
|
|
546
|
+
end
|
|
547
|
+
rescue
|
|
548
|
+
main_or_portal_root_url
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Where Cancel redirects out to: the page the user launched from (captured at
|
|
552
|
+
# launch), falling back to the host root.
|
|
553
|
+
def wizard_exit_url
|
|
554
|
+
peek_wizard_return_to || main_or_portal_root_url
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
# Capture the launch origin so Cancel can return there. Called only at the bare
|
|
558
|
+
# launch (the step pages' referer is the wizard itself). Prefers an explicit
|
|
559
|
+
# `?return_to=` over the referer; both are sanitized to a same-host local path
|
|
560
|
+
# that isn't the wizard's own mount, so there's no open-redirect surface.
|
|
561
|
+
def stash_wizard_return_to!
|
|
562
|
+
candidate = wizard_return_to_candidate
|
|
563
|
+
return if candidate.blank?
|
|
564
|
+
|
|
565
|
+
bucket = (session[WIZARD_RETURN_TO_KEY] ||= {})
|
|
566
|
+
bucket[Plutonium::Wizard::Driving.session_token_key(current_wizard_class)] = candidate
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def wizard_return_to_candidate
|
|
570
|
+
[params[:return_to].to_s, request.referer].each do |raw|
|
|
571
|
+
path = local_wizard_return_path(raw)
|
|
572
|
+
return path if path
|
|
573
|
+
end
|
|
574
|
+
nil
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# Sanitize a candidate return-to into a same-host absolute path (with query),
|
|
578
|
+
# or nil. Rejects other hosts (open-redirect), protocol-relative `//host`
|
|
579
|
+
# paths, and the wizard's own pages (so Cancel never bounces back into the
|
|
580
|
+
# flow it's leaving).
|
|
581
|
+
def local_wizard_return_path(raw)
|
|
582
|
+
return nil if raw.blank?
|
|
583
|
+
|
|
584
|
+
uri = begin
|
|
585
|
+
URI.parse(raw)
|
|
586
|
+
rescue URI::InvalidURIError
|
|
587
|
+
nil
|
|
588
|
+
end
|
|
589
|
+
return nil if uri.nil?
|
|
590
|
+
return nil unless uri.host.nil? || uri.host == request.host
|
|
591
|
+
|
|
592
|
+
path = uri.path.presence
|
|
593
|
+
return nil if path.nil? || !path.start_with?("/") || path.start_with?("//")
|
|
594
|
+
return nil if path.start_with?(request.path)
|
|
595
|
+
|
|
596
|
+
uri.query.present? ? "#{path}?#{uri.query}" : path
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def peek_wizard_return_to
|
|
600
|
+
bucket = session[WIZARD_RETURN_TO_KEY]
|
|
601
|
+
return nil unless bucket.is_a?(Hash)
|
|
602
|
+
|
|
603
|
+
bucket[Plutonium::Wizard::Driving.session_token_key(current_wizard_class)].presence
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def clear_wizard_return_to
|
|
607
|
+
bucket = session[WIZARD_RETURN_TO_KEY]
|
|
608
|
+
return unless bucket.is_a?(Hash)
|
|
609
|
+
|
|
610
|
+
bucket.delete(Plutonium::Wizard::Driving.session_token_key(current_wizard_class))
|
|
611
|
+
session.delete(WIZARD_RETURN_TO_KEY) if bucket.empty?
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
def main_or_portal_root_url
|
|
615
|
+
current_engine.routes.url_helpers.root_path
|
|
616
|
+
rescue
|
|
617
|
+
"/"
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
# --- surface hooks (overridden per surface) ---
|
|
621
|
+
|
|
622
|
+
# @return [Class] the wizard class for this request.
|
|
623
|
+
def current_wizard_class
|
|
624
|
+
raise NotImplementedError
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# @return [ActiveRecord::Base, nil] the anchor record (scoped/authorized for
|
|
628
|
+
# resource-mounted member actions; nil for non-anchored surfaces).
|
|
629
|
+
def resolved_wizard_anchor
|
|
630
|
+
nil
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# @return [String] the GET URL for a given step of this wizard.
|
|
634
|
+
def wizard_step_url(step_key)
|
|
635
|
+
raise NotImplementedError
|
|
636
|
+
end
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
end
|