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,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# The author-facing class macros: `step`, `review`, `anchored`, `navigation`,
|
|
6
|
+
# `cleanup_after`, `concurrency_key`, `one_time`, `encrypt_data`, `anonymous`.
|
|
7
|
+
# Mixed into {Base}.
|
|
8
|
+
module DSL
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
# Sentinel distinguishing `cleanup_after` (read) from `cleanup_after nil`.
|
|
12
|
+
UNSET = Object.new
|
|
13
|
+
private_constant :UNSET
|
|
14
|
+
|
|
15
|
+
# The default concurrency key an anchored wizard gets when it declares no
|
|
16
|
+
# explicit `concurrency_key`: one in-progress run per (anchor, user). The
|
|
17
|
+
# tenant folds in automatically (§4.4), and the anchor's GlobalID is already
|
|
18
|
+
# globally unique, so this is the full identity. Evaluated in the wizard
|
|
19
|
+
# instance context (where `anchor`/`current_user` live).
|
|
20
|
+
IMPLIED_ANCHOR_KEY = -> { [anchor, current_user] }
|
|
21
|
+
private_constant :IMPLIED_ANCHOR_KEY
|
|
22
|
+
|
|
23
|
+
class_methods do
|
|
24
|
+
def steps
|
|
25
|
+
@steps ||= []
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Declare an ordered step.
|
|
29
|
+
#
|
|
30
|
+
# `using:` is a step OPTION (never a block method — avoids Ruby's
|
|
31
|
+
# `Module#using` refinements clash). The block, when present, adds inline
|
|
32
|
+
# fields on top. Selector options for `using:` (only:/except:/fields:/etc.)
|
|
33
|
+
# are captured and merged in Task 3.
|
|
34
|
+
def step(key, label: nil, description: nil, condition: nil, using: nil, **using_opts, &block)
|
|
35
|
+
assert_not_after_review!(key)
|
|
36
|
+
|
|
37
|
+
capture = FieldCapture.build(using:, using_opts:, &block)
|
|
38
|
+
|
|
39
|
+
steps << Step.new(
|
|
40
|
+
key:,
|
|
41
|
+
label:,
|
|
42
|
+
description:,
|
|
43
|
+
condition:,
|
|
44
|
+
fields: capture,
|
|
45
|
+
on_submit: capture.delete_hook(:on_submit),
|
|
46
|
+
on_rollback: capture.delete_hook(:on_rollback),
|
|
47
|
+
using_spec: capture.using_spec
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Declare the terminal review step (§2.5). Must be last.
|
|
52
|
+
#
|
|
53
|
+
# `summary:` (default true) controls the auto-summary of completed steps in
|
|
54
|
+
# the COMPLETE state: with no custom block, `summary: true` renders the
|
|
55
|
+
# per-step summary, `summary: false` renders the built-in "ready to
|
|
56
|
+
# complete" panel instead — for a fully author-owned review. (The summary
|
|
57
|
+
# always renders in the INCOMPLETE state, where it's the review-and-fix
|
|
58
|
+
# view, regardless of this flag.)
|
|
59
|
+
#
|
|
60
|
+
# `header:` (default true) controls the step-header section (the label +
|
|
61
|
+
# the "check everything over" prompt). `header: false` drops it entirely,
|
|
62
|
+
# leaving just the review body in the card — for a chromeless finish.
|
|
63
|
+
def review(label: "Review", description: nil, condition: nil, summary: true, header: true, &block)
|
|
64
|
+
assert_not_after_review!(:review)
|
|
65
|
+
steps << ReviewStep.new(label:, description:, condition:, summary:, header:, block:)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# --- anchoring (§3) ---
|
|
69
|
+
|
|
70
|
+
# Declare that this wizard runs against an anchor record. Two anchoring
|
|
71
|
+
# strategies (which may combine):
|
|
72
|
+
#
|
|
73
|
+
# - `anchored with: Company` — a TYPE anchor. The anchor is resolved from
|
|
74
|
+
# the URL `:id` via the resource controller's scoped, policy-gated
|
|
75
|
+
# `resource_record!` (resource-mounted member route). IDOR-safe because
|
|
76
|
+
# the record is scoped+authorized.
|
|
77
|
+
# - `anchored via: :current_scoped_entity` — a CONTEXT anchor. The anchor
|
|
78
|
+
# is resolved by calling that method on the controller at request time
|
|
79
|
+
# (`:current_user`, `:current_scoped_entity`, or any host method). No
|
|
80
|
+
# `:id`, IDOR-safe (trusted context). Mounted portal-level via
|
|
81
|
+
# `register_wizard`.
|
|
82
|
+
# - combined `anchored via: :current_scoped_entity, with: Organization` —
|
|
83
|
+
# resolve via the method, then assert the result is an Organization.
|
|
84
|
+
#
|
|
85
|
+
# A resolved anchor that is nil raises (anchored-ness is declared).
|
|
86
|
+
def anchored(with: nil, via: nil, &resolver)
|
|
87
|
+
@anchored = true
|
|
88
|
+
@anchor_types = Array(with).presence
|
|
89
|
+
@anchor_via = via
|
|
90
|
+
@anchor_resolver = resolver
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def anchored? = !!@anchored
|
|
94
|
+
|
|
95
|
+
def anchor_types = @anchor_types
|
|
96
|
+
|
|
97
|
+
# The controller method used to resolve a CONTEXT anchor, or nil for a
|
|
98
|
+
# TYPE (`with:`-only) anchor.
|
|
99
|
+
def anchor_via = @anchor_via
|
|
100
|
+
|
|
101
|
+
# Whether this wizard's anchor is a CONTEXT anchor (resolved via a method),
|
|
102
|
+
# as opposed to a TYPE anchor (resolved from the URL `:id`).
|
|
103
|
+
def anchored_via? = !@anchor_via.nil?
|
|
104
|
+
|
|
105
|
+
def anchor_resolver = @anchor_resolver
|
|
106
|
+
|
|
107
|
+
# --- navigation (§7) ---
|
|
108
|
+
|
|
109
|
+
def navigation(mode = nil)
|
|
110
|
+
if mode
|
|
111
|
+
@navigation = mode
|
|
112
|
+
else
|
|
113
|
+
@navigation || :linear
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Whether the top rail (the step indicator, §7) is shown. On by default;
|
|
118
|
+
# `stepper false` hides it for a chromeless flow. Uses UNSET so the `false`
|
|
119
|
+
# value reads back correctly (a plain `|| true` would re-enable it).
|
|
120
|
+
def stepper(flag = UNSET)
|
|
121
|
+
return (@stepper = flag) unless flag.equal?(UNSET)
|
|
122
|
+
return @stepper unless @stepper.nil?
|
|
123
|
+
true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def stepper? = stepper
|
|
127
|
+
|
|
128
|
+
# What a bare launch does when the user already has pending (in-progress)
|
|
129
|
+
# runs of this wizard. `:prompt` (default) renders a "resume or start new"
|
|
130
|
+
# chooser so the user's in-progress work isn't silently discarded; `:new`
|
|
131
|
+
# is the explicit opt-out for wizards that always start fresh, minting a new
|
|
132
|
+
# run every time. Only meaningful for authenticated TOKENED wizards — keyed
|
|
133
|
+
# wizards already auto-resume their single keyed run, and `anonymous` runs
|
|
134
|
+
# are session-keyed; the driving layer no-ops the prompt for both. The
|
|
135
|
+
# chooser only appears when a pending run actually exists, so `:prompt` is a
|
|
136
|
+
# safe superset of `:new` (with no pending run it mints fresh either way).
|
|
137
|
+
def on_relaunch(mode = nil)
|
|
138
|
+
return @on_relaunch || :prompt if mode.nil?
|
|
139
|
+
@on_relaunch = mode
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Whether a bare launch should show the resume-or-new chooser (§4.5).
|
|
143
|
+
def relaunch_prompt? = on_relaunch == :prompt
|
|
144
|
+
|
|
145
|
+
# --- cleanup (§2.3) ---
|
|
146
|
+
|
|
147
|
+
def cleanup_after(ttl = UNSET)
|
|
148
|
+
if ttl.equal?(UNSET)
|
|
149
|
+
return @cleanup_after_set ? @cleanup_after : Plutonium.configuration.wizards.cleanup_after
|
|
150
|
+
end
|
|
151
|
+
@cleanup_after_set = true
|
|
152
|
+
@cleanup_after = (ttl == :never) ? nil : ttl
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# --- concurrency (§4.2) ---
|
|
156
|
+
|
|
157
|
+
# Declare the run's CONCURRENCY KEY — the value(s) a run is keyed by
|
|
158
|
+
# (Solid Queue-style). The keyed session row is created at start
|
|
159
|
+
# (`in_progress`) and IS the lock: a second launch with the same key
|
|
160
|
+
# resumes that row instead of forking (at most one in-progress run per
|
|
161
|
+
# key). Omit → unlimited concurrent runs, each identified by a fresh
|
|
162
|
+
# `wizard_token` (§4.3).
|
|
163
|
+
#
|
|
164
|
+
# The resolver runs in the wizard instance context (where `current_user`,
|
|
165
|
+
# `current_scoped_entity`, `anchor`, and `wizard_token` are available) and
|
|
166
|
+
# returns the value(s): records → GlobalID, scalars → to_s, arrays joined.
|
|
167
|
+
# The portal tenant (`current_scoped_entity`) is ALWAYS folded in
|
|
168
|
+
# automatically (§4.4) — authors never thread it.
|
|
169
|
+
#
|
|
170
|
+
# concurrency_key { current_user } # ≤1 in-progress per user
|
|
171
|
+
# concurrency_key { anchor } # ≤1 per anchored record (any user)
|
|
172
|
+
# concurrency_key { wizard_token } # per-run id → tokened/repeatable
|
|
173
|
+
# concurrency_key :current_user # method shorthand
|
|
174
|
+
#
|
|
175
|
+
# An `anchored` (authenticated) wizard with NO explicit key DEFAULTS to
|
|
176
|
+
# `{ [anchor, current_user] }` — one draft per user per record (see
|
|
177
|
+
# {IMPLIED_ANCHOR_KEY}). To make an anchored wizard repeatable instead
|
|
178
|
+
# (a fresh run per launch), declare `concurrency_key { wizard_token }`.
|
|
179
|
+
def concurrency_key(method = nil, &block)
|
|
180
|
+
reject_anonymous_keying!("concurrency_key") if anonymous?
|
|
181
|
+
@concurrency_key =
|
|
182
|
+
if block
|
|
183
|
+
block
|
|
184
|
+
elsif method
|
|
185
|
+
m = method.to_sym
|
|
186
|
+
-> { public_send(m) }
|
|
187
|
+
else
|
|
188
|
+
raise ArgumentError, "concurrency_key requires a block or a method name"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether this wizard is keyed (keyed/singleton runs) — an explicit
|
|
193
|
+
# `concurrency_key` OR the implied anchored default.
|
|
194
|
+
def concurrency_key? = !@concurrency_key.nil? || implied_anchor_key?
|
|
195
|
+
|
|
196
|
+
# The resolver proc, or nil when the wizard is tokened. Falls back to the
|
|
197
|
+
# implied `{ [anchor, current_user] }` for an anchored wizard with no
|
|
198
|
+
# explicit key.
|
|
199
|
+
def concurrency_key_resolver
|
|
200
|
+
@concurrency_key || (implied_anchor_key? ? IMPLIED_ANCHOR_KEY : nil)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Whether the implied anchored key applies: the wizard is `anchored`, isn't
|
|
204
|
+
# `anonymous` (a guest has no real user to key by — it stays session-keyed),
|
|
205
|
+
# and declared no explicit `concurrency_key`.
|
|
206
|
+
def implied_anchor_key? = anchored? && !anonymous? && @concurrency_key.nil?
|
|
207
|
+
|
|
208
|
+
# --- repeatability / one-time (§4.3 / §9) ---
|
|
209
|
+
|
|
210
|
+
# Opt a wizard into being ONE-TIME: on successful completion the completed
|
|
211
|
+
# row is RETAINED at its concurrency_key, permanently blocking a restart
|
|
212
|
+
# (and is what the gate, §9, checks). Without `one_time` the row is DELETED
|
|
213
|
+
# on completion → repeatable.
|
|
214
|
+
#
|
|
215
|
+
# Requires a `concurrency_key` (that's the stable row to retain); a run
|
|
216
|
+
# with no concurrency_key is tokened and always repeatable. The
|
|
217
|
+
# requirement is enforced lazily in {#one_time?} so subclass timing and
|
|
218
|
+
# declaration order don't matter.
|
|
219
|
+
def one_time
|
|
220
|
+
reject_anonymous_keying!("one_time") if anonymous?
|
|
221
|
+
@one_time = true
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Whether this wizard is one-time. Raises if `one_time` was declared
|
|
225
|
+
# without a `concurrency_key` (only keyed runs can be retained). The
|
|
226
|
+
# `one_time` + `anonymous` conflict is rejected eagerly in the macros
|
|
227
|
+
# (whichever is declared last raises), so it can't reach here.
|
|
228
|
+
def one_time?
|
|
229
|
+
return false unless @one_time
|
|
230
|
+
unless concurrency_key?
|
|
231
|
+
raise ArgumentError,
|
|
232
|
+
"#{name || "wizard"} declares `one_time` without a `concurrency_key`; " \
|
|
233
|
+
"one-time retention needs a stable key to retain (§4.3)"
|
|
234
|
+
end
|
|
235
|
+
true
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A custom body for the "already completed" page shown when a finished
|
|
239
|
+
# ONE-TIME wizard is re-opened (§9). The completion marker is retained but
|
|
240
|
+
# its `data` is cleared, so there's nothing to review — just a confirmation.
|
|
241
|
+
# The block renders in the {Plutonium::UI::Page::WizardCompleted} Phlex
|
|
242
|
+
# context (with the wizard yielded) and REPLACES the default body entirely
|
|
243
|
+
# (icon/title/message/button — the author supplies their own). Omit for the
|
|
244
|
+
# built-in confirmation page.
|
|
245
|
+
#
|
|
246
|
+
# completed do |wizard|
|
|
247
|
+
# h1 { "You're all set up!" }
|
|
248
|
+
# a(href: "/dashboard") { "Go to your dashboard" }
|
|
249
|
+
# end
|
|
250
|
+
def completed(&block)
|
|
251
|
+
@completed_block = block
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# The custom completed-page block, or nil for the built-in default.
|
|
255
|
+
def completed_block = @completed_block
|
|
256
|
+
|
|
257
|
+
# --- authentication (§4.5) ---
|
|
258
|
+
|
|
259
|
+
# Opt this wizard into GUEST (unauthenticated) access. By default a wizard
|
|
260
|
+
# requires a `current_user` to enter — entry without one is rejected. An
|
|
261
|
+
# `anonymous` wizard may run with no `current_user`; its identity is the
|
|
262
|
+
# server-minted `wizard_token` (httponly/secure/same_site cookie), and it
|
|
263
|
+
# may authenticate ONLY at its terminal `execute` (e.g. a signup flow). It
|
|
264
|
+
# NEVER crosses the auth boundary mid-flow (§4.5).
|
|
265
|
+
def anonymous
|
|
266
|
+
reject_anonymous_keying!("anonymous") if @concurrency_key || @one_time
|
|
267
|
+
@anonymous = true
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def anonymous? = !!@anonymous
|
|
271
|
+
|
|
272
|
+
# `anonymous` is mutually exclusive with `concurrency_key`/`one_time`: a
|
|
273
|
+
# guest's only sound identity is its session token, so the wizard stays
|
|
274
|
+
# session-keyed and repeatable. A custom `concurrency_key` resolved in a
|
|
275
|
+
# guest context (`current_user` → nil) would collide every guest on one
|
|
276
|
+
# run, and `one_time` has no durable principal to retain a completion
|
|
277
|
+
# against. Rejected eagerly so whichever macro is declared LAST raises,
|
|
278
|
+
# regardless of order.
|
|
279
|
+
def reject_anonymous_keying!(macro)
|
|
280
|
+
raise ArgumentError,
|
|
281
|
+
"#{name || "wizard"} declares `#{macro}` together with `anonymous`, which are " \
|
|
282
|
+
"mutually exclusive: a guest's identity is its session token, so the wizard is " \
|
|
283
|
+
"already session-keyed and repeatable. A `concurrency_key` would collide all " \
|
|
284
|
+
"guests on one run, and `one_time` has no durable identity to retain against. " \
|
|
285
|
+
"Drop `anonymous`, or drop `concurrency_key`/`one_time` (§4.5)."
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# --- encryption (§8.1) ---
|
|
289
|
+
|
|
290
|
+
# Opt this wizard's staged `data` into at-rest encryption (§8.1). Pass
|
|
291
|
+
# `false` to opt OUT explicitly when the app encrypts everything by default
|
|
292
|
+
# (`config.wizards.encrypt_data`). Left unset, the wizard inherits that
|
|
293
|
+
# global default.
|
|
294
|
+
def encrypt_data(flag = true)
|
|
295
|
+
@encrypt_data = flag
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Effective at-rest encryption for this wizard: an explicit `encrypt_data`
|
|
299
|
+
# (true) / `encrypt_data false` wins; unset inherits the global default
|
|
300
|
+
# (`config.wizards.encrypt_data`, off unless the app opts in).
|
|
301
|
+
def encrypt_data?
|
|
302
|
+
return !!@encrypt_data unless @encrypt_data.nil?
|
|
303
|
+
Plutonium.configuration.wizards.encrypt_data
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
private
|
|
307
|
+
|
|
308
|
+
def assert_not_after_review!(key)
|
|
309
|
+
return unless steps.any?(&:review?)
|
|
310
|
+
raise ArgumentError,
|
|
311
|
+
"`review` must be the last step; cannot declare step :#{key} after it"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Class-level state must not leak into subclasses by reference.
|
|
315
|
+
def inherited(subclass)
|
|
316
|
+
super
|
|
317
|
+
subclass.instance_variable_set(:@steps, steps.dup)
|
|
318
|
+
subclass.instance_variable_set(:@anchored, @anchored)
|
|
319
|
+
subclass.instance_variable_set(:@anchor_types, @anchor_types)
|
|
320
|
+
subclass.instance_variable_set(:@anchor_via, @anchor_via)
|
|
321
|
+
subclass.instance_variable_set(:@anchor_resolver, @anchor_resolver)
|
|
322
|
+
subclass.instance_variable_set(:@navigation, @navigation)
|
|
323
|
+
subclass.instance_variable_set(:@stepper, @stepper)
|
|
324
|
+
subclass.instance_variable_set(:@on_relaunch, @on_relaunch)
|
|
325
|
+
subclass.instance_variable_set(:@cleanup_after, @cleanup_after)
|
|
326
|
+
subclass.instance_variable_set(:@cleanup_after_set, @cleanup_after_set)
|
|
327
|
+
subclass.instance_variable_set(:@concurrency_key, @concurrency_key)
|
|
328
|
+
subclass.instance_variable_set(:@one_time, @one_time)
|
|
329
|
+
subclass.instance_variable_set(:@completed_block, @completed_block)
|
|
330
|
+
subclass.instance_variable_set(:@encrypt_data, @encrypt_data)
|
|
331
|
+
subclass.instance_variable_set(:@anonymous, @anonymous)
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Raised when a wizard requires an anchor record but none is available.
|
|
6
|
+
class NotAnchoredError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when the `wizard_class` route default does not resolve to a
|
|
9
|
+
# Plutonium::Wizard::Base subclass (a misconfigured mount, or a tampered
|
|
10
|
+
# path parameter).
|
|
11
|
+
class UnknownWizardError < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Raised when a wizard step fails. Carries the attribute the error should be
|
|
14
|
+
# attached to (defaults to +:base+) so it can be surfaced on a form.
|
|
15
|
+
class StepError < StandardError
|
|
16
|
+
# @return [Symbol] the attribute the error applies to
|
|
17
|
+
attr_reader :attribute
|
|
18
|
+
|
|
19
|
+
# @param message [String, nil] the error message
|
|
20
|
+
# @param attribute [Symbol] the attribute the error applies to
|
|
21
|
+
def initialize(message = nil, attribute: :base)
|
|
22
|
+
@attribute = attribute
|
|
23
|
+
super(message)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Records the field surface declared inside a `step` block.
|
|
6
|
+
#
|
|
7
|
+
# A step block reuses Plutonium's existing field DSL — `attribute`, `input`,
|
|
8
|
+
# `validates`, `structured_input`, `form_layout` — plus the per-step hooks
|
|
9
|
+
# `on_submit` and `on_rollback`. This object captures all of it by
|
|
10
|
+
# `instance_exec`-ing the block against itself.
|
|
11
|
+
#
|
|
12
|
+
# The union `data` schema (§2.6) is built from inline `attribute name, type`
|
|
13
|
+
# declarations recorded here as `attribute_schema` ({name => type}).
|
|
14
|
+
#
|
|
15
|
+
# `using:` import (a model — see FieldImporter) is recorded as a marker
|
|
16
|
+
# (`using_spec`) and merged lazily; this object only captures inline
|
|
17
|
+
# declarations and composes them over the resolved import (inline wins).
|
|
18
|
+
class FieldCapture
|
|
19
|
+
include Plutonium::Definition::DefineableProps
|
|
20
|
+
include Plutonium::Definition::StructuredInputs
|
|
21
|
+
include Plutonium::Definition::FormLayout
|
|
22
|
+
|
|
23
|
+
defineable_props :field, :input
|
|
24
|
+
|
|
25
|
+
attr_reader :validations, :hooks, :using_spec
|
|
26
|
+
|
|
27
|
+
def self.build(using: nil, using_opts: {}, &block)
|
|
28
|
+
capture = new
|
|
29
|
+
capture.record_using(using, using_opts) if using
|
|
30
|
+
capture.instance_exec(&block) if block
|
|
31
|
+
capture
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize
|
|
35
|
+
@inline_attribute_schema = {}
|
|
36
|
+
@inline_attribute_options = {}
|
|
37
|
+
@validations = []
|
|
38
|
+
@hooks = {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Inline `attribute :name, :type` — records the union-schema type and any
|
|
42
|
+
# options (default:, etc.), which are threaded into the typed `data`
|
|
43
|
+
# snapshot so e.g. `default:` applies (§2.6).
|
|
44
|
+
def attribute(name, type = :string, **options)
|
|
45
|
+
key = name.to_sym
|
|
46
|
+
@inline_attribute_schema[key] = type
|
|
47
|
+
@inline_attribute_options[key] = options unless options.empty?
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The effective union-schema types for this step ({name => type}), composing
|
|
52
|
+
# a `using:` import with inline `attribute` declarations — **inline wins on a
|
|
53
|
+
# name conflict** (§2.4). The imported surface is resolved lazily.
|
|
54
|
+
def attribute_schema
|
|
55
|
+
imported_spec ? imported_spec.attribute_schema.merge(@inline_attribute_schema) : @inline_attribute_schema
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The effective per-attribute options ({name => {default:, ...}}). Imports
|
|
59
|
+
# contribute none (types come from the source; options stay inline); inline
|
|
60
|
+
# declarations are returned as-is.
|
|
61
|
+
def attribute_options
|
|
62
|
+
@inline_attribute_options
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The effective input config ({name => {options:, block:}}) — imported inputs
|
|
66
|
+
# composed with inline `input`/`field` declarations, inline winning on
|
|
67
|
+
# conflict. Drives the step form (Task 6).
|
|
68
|
+
def inputs
|
|
69
|
+
imported = imported_spec ? imported_spec.inputs : {}
|
|
70
|
+
imported.merge(defined_inputs)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The form_layout for this step (§7.1 resolution order): an inline
|
|
74
|
+
# `form_layout` wins; else the layout inherited from a `using:` source
|
|
75
|
+
# (already filtered to the imported fields); else nil (default single grid).
|
|
76
|
+
def form_layout_sections
|
|
77
|
+
@form_layout || imported_spec&.form_layout
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The imported validation runner ({attribute => [messages]} over a data
|
|
81
|
+
# slice), or nil when there's no `using:` import or `validate: false`. The
|
|
82
|
+
# runner (Task 4) combines this with inline `validates`.
|
|
83
|
+
def imported_validate_fn
|
|
84
|
+
imported_spec&.validate_fn
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The imported model's form-relevant validators ([[name], options] pairs),
|
|
88
|
+
# replayed onto the typed data class so imported fields render their
|
|
89
|
+
# required/length/etc. metadata. Empty without a `using:` import. Distinct
|
|
90
|
+
# from `validations` (inline, runner-bound) so imports aren't double-validated.
|
|
91
|
+
def imported_form_validators
|
|
92
|
+
imported_spec&.form_validators || []
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The resolved `using:` import surface, or nil. Memoized.
|
|
96
|
+
def imported_spec
|
|
97
|
+
return @imported_spec if defined?(@imported_spec)
|
|
98
|
+
@imported_spec =
|
|
99
|
+
if @using_spec
|
|
100
|
+
FieldImporter.resolve(using: @using_spec[:using], opts: @using_spec[:opts])
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Inline `validates` — recorded as raw args for the runner (Task 4) to apply.
|
|
105
|
+
def validates(*args, **options)
|
|
106
|
+
@validations << [args, options]
|
|
107
|
+
self
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Instance-level structured_input: the step block runs at instance level,
|
|
111
|
+
# but the StructuredInputs concern only exposes a class method. Record into
|
|
112
|
+
# a per-instance registry mirroring `defined_structured_inputs`.
|
|
113
|
+
def structured_input(name, **options, &block)
|
|
114
|
+
unless block || options[:using] || options[:fields]
|
|
115
|
+
raise ArgumentError,
|
|
116
|
+
"`structured_input :#{name}` needs a block, `using:`, or `fields:`"
|
|
117
|
+
end
|
|
118
|
+
instance_structured_inputs[name] = {options:, block:}.compact
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def defined_structured_inputs
|
|
122
|
+
instance_structured_inputs
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def on_submit(&block)
|
|
126
|
+
@hooks[:on_submit] = block
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def on_rollback(&block)
|
|
130
|
+
@hooks[:on_rollback] = block
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# form_layout is provided by the FormLayout concern as a class method; the
|
|
134
|
+
# step block runs at instance level, so expose an instance-level shim that
|
|
135
|
+
# records onto this capture's own builder.
|
|
136
|
+
def form_layout(&block)
|
|
137
|
+
raise ArgumentError, "`form_layout` requires a block" unless block
|
|
138
|
+
builder = Plutonium::Definition::FormLayout::Builder.new
|
|
139
|
+
builder.instance_exec(&block)
|
|
140
|
+
@form_layout = builder.sections.freeze
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def record_using(using, opts)
|
|
144
|
+
@using_spec = {using:, opts: opts || {}}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Pop a recorded hook (used by the DSL when building the Step).
|
|
148
|
+
def delete_hook(name) = @hooks.delete(name)
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def instance_structured_inputs
|
|
153
|
+
@instance_structured_inputs ||= {}
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|