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,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_model"
|
|
4
|
+
|
|
5
|
+
module Plutonium
|
|
6
|
+
module Wizard
|
|
7
|
+
# Author-facing base class for wizards (§2). A wizard declares ordered `step`s
|
|
8
|
+
# (with their own field surface, branching `condition:`, and optional per-step
|
|
9
|
+
# `on_submit`/`on_rollback`), an optional terminal `review` step, and commits
|
|
10
|
+
# at the end via `execute`.
|
|
11
|
+
#
|
|
12
|
+
# This class is pure object behaviour — declaring the DSL, exposing the
|
|
13
|
+
# ordered steps, the union `data` snapshot, and the `anchor`/`fail!`
|
|
14
|
+
# accessors. HTTP/runner/store wiring lives elsewhere.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# class CompanyOnboardingWizard < Plutonium::Wizard::Base
|
|
18
|
+
# step :company do
|
|
19
|
+
# attribute :name, :string
|
|
20
|
+
# input :name
|
|
21
|
+
# validates :name, presence: true
|
|
22
|
+
# end
|
|
23
|
+
# review label: "Review"
|
|
24
|
+
#
|
|
25
|
+
# def execute
|
|
26
|
+
# succeed(Company.create!(name: data.name))
|
|
27
|
+
# end
|
|
28
|
+
# end
|
|
29
|
+
class Base
|
|
30
|
+
include ActiveModel::Model
|
|
31
|
+
include Plutonium::Definition::Presentable
|
|
32
|
+
include DSL
|
|
33
|
+
|
|
34
|
+
attr_reader :data_attributes
|
|
35
|
+
attr_accessor :view_context
|
|
36
|
+
attr_writer :anchor, :scope, :token
|
|
37
|
+
|
|
38
|
+
# Identity/concurrency context (§4.5), supplied by the runner/driving layer
|
|
39
|
+
# so `concurrency_key` resolvers and the tenancy fold can reach them.
|
|
40
|
+
# `wizard_token` is the per-run id (the identity for guest/repeatable runs,
|
|
41
|
+
# available inside `concurrency_key`) — NOT a pre-auth principal that
|
|
42
|
+
# survives login.
|
|
43
|
+
attr_accessor :current_user, :current_scoped_entity, :wizard_token
|
|
44
|
+
|
|
45
|
+
# The runner reuses a single wizard instance across a request, reassigning
|
|
46
|
+
# `data_attributes` between reads, so invalidate the memoized `data` snapshot
|
|
47
|
+
# whenever the staged attributes change. Only rebuild when the value actually
|
|
48
|
+
# CHANGES: the runner calls this on every `visible_path` (via `sync_data`),
|
|
49
|
+
# and an unconditional reset would discard a `data` snapshot the view layer
|
|
50
|
+
# has since mutated — e.g. validation errors `seed_errors!` added before the
|
|
51
|
+
# form reads them back (they'd silently vanish).
|
|
52
|
+
def data_attributes=(attrs)
|
|
53
|
+
return if @data && attrs == @data_attributes
|
|
54
|
+
@data_attributes = attrs
|
|
55
|
+
@data = nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize(view_context: nil, **)
|
|
59
|
+
@view_context = view_context
|
|
60
|
+
@data_attributes = {}
|
|
61
|
+
super()
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
# Per-step data spec ({step_key => {schema:, options:, structured:}}), used
|
|
66
|
+
# to build the step-keyed `data` container (§2.6). Each step contributes its
|
|
67
|
+
# OWN schema/options/structured — no cross-step union, so two steps may share
|
|
68
|
+
# a field name without colliding. `using:` imports are already composed into
|
|
69
|
+
# each step's `attribute_schema`.
|
|
70
|
+
def data_steps_spec
|
|
71
|
+
steps.reject(&:review?).each_with_object({}) do |step, acc|
|
|
72
|
+
acc[step.key.to_sym] = {
|
|
73
|
+
schema: step.attribute_schema,
|
|
74
|
+
options: step.attribute_options,
|
|
75
|
+
structured: step_structured_schema(step),
|
|
76
|
+
validations: step.validations + step.imported_form_validators
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The per-step typed sub-object classes ({step_key => Class}), built once
|
|
82
|
+
# per wizard class from {data_steps_spec} and reused for every `data`
|
|
83
|
+
# snapshot (the container is cheap to instantiate; the classes aren't).
|
|
84
|
+
def data_step_classes
|
|
85
|
+
@data_step_classes ||= data_steps_spec.transform_values do |spec|
|
|
86
|
+
Data.class_for(spec[:schema], options: spec[:options], structured: spec[:structured], validations: spec[:validations])
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Per-step typed classes carrying ONLY the step's INLINE `validates` (no
|
|
91
|
+
# imported form validators — those are validated separately via the
|
|
92
|
+
# transient model, so folding them in here would double-report). Built once
|
|
93
|
+
# per wizard class and reused by the runner's inline validation, which
|
|
94
|
+
# otherwise recompiles an equivalent anonymous class on every `validate`
|
|
95
|
+
# call (and `validate` runs many times per render). `{step_key => Class}`,
|
|
96
|
+
# omitting steps with no inline validations.
|
|
97
|
+
def inline_validation_classes
|
|
98
|
+
@inline_validation_classes ||= steps.reject(&:review?).each_with_object({}) do |step, acc|
|
|
99
|
+
next if step.validations.blank?
|
|
100
|
+
acc[step.key.to_sym] = Data.class_for(step.attribute_schema, validations: step.validations)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# One step's structured collections, as {name => [sub-field names]}, so
|
|
107
|
+
# `data.<step>.<name>` exposes typed sub-objects (§2.6 / §7.2).
|
|
108
|
+
def step_structured_schema(step)
|
|
109
|
+
step.structured_inputs.each_with_object({}) do |(name, entry), acc|
|
|
110
|
+
acc[name.to_sym] = structured_sub_fields(entry)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Resolve the declared sub-field names of a structured_input entry by
|
|
115
|
+
# evaluating its block (or `using:` holder) against a FieldsDefinition.
|
|
116
|
+
def structured_sub_fields(entry)
|
|
117
|
+
options = entry[:options] || {}
|
|
118
|
+
return Array(options[:fields]).map(&:to_sym) if options[:fields]
|
|
119
|
+
|
|
120
|
+
holder =
|
|
121
|
+
if options[:using]
|
|
122
|
+
options[:using].is_a?(Class) ? options[:using].new : options[:using]
|
|
123
|
+
else
|
|
124
|
+
h = Plutonium::Definition::StructuredInputs::FieldsDefinition.new
|
|
125
|
+
entry[:block]&.call(h)
|
|
126
|
+
h
|
|
127
|
+
end
|
|
128
|
+
holder.defined_inputs.keys.map(&:to_sym)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The step-keyed `data` snapshot (§2.6), reconstituted from the nested staged
|
|
133
|
+
# `data_attributes` ({step_key => {field => value}}). Addressed as
|
|
134
|
+
# `data.<step>.<field>`; `data[:step]` for dynamic access.
|
|
135
|
+
def data
|
|
136
|
+
@data ||= Data::Container.new(self.class.data_step_classes, data_attributes)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The record this wizard was launched against (§3). Raises when the wizard
|
|
140
|
+
# was not declared `anchored` — never returns nil.
|
|
141
|
+
def anchor
|
|
142
|
+
unless self.class.anchored?
|
|
143
|
+
raise NotAnchoredError, "#{self.class} is not declared `anchored`"
|
|
144
|
+
end
|
|
145
|
+
@anchor
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Resolve this wizard's concurrency_key VALUE(S) in the wizard context
|
|
149
|
+
# (§4.2), with the tenant ALWAYS folded in (§4.4). Returns nil when no
|
|
150
|
+
# `concurrency_key` is declared (→ tokened identity). The returned value is
|
|
151
|
+
# an array `[*key_values, tenant_gid]`; {InstanceKey.concurrency} serializes
|
|
152
|
+
# it. The tenant is appended even when nil so the digest is stable.
|
|
153
|
+
def concurrency_key_value
|
|
154
|
+
resolver = self.class.concurrency_key_resolver
|
|
155
|
+
return nil unless resolver
|
|
156
|
+
|
|
157
|
+
key = instance_exec(&resolver)
|
|
158
|
+
[*Array.wrap(key), current_scoped_entity]
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The `{ "step_key" => [gids] }` source the runner injects from the stored
|
|
162
|
+
# state, backing the lazy `persisted` view. Reassigning it resets the memo.
|
|
163
|
+
def persisted_gid_source=(source)
|
|
164
|
+
@persisted = LazyPersisted.new(source)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Records the per-step `on_submit`/`persist` macro registers (§2.2), as a
|
|
168
|
+
# LAZY view over the stored GIDs: a key is located on first read and
|
|
169
|
+
# memoized, so a request that never reads `persisted` issues zero locates
|
|
170
|
+
# (§4.5). Records set this request (the `persist` macro) are live already.
|
|
171
|
+
def persisted
|
|
172
|
+
@persisted ||= LazyPersisted.new
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The at-end commit hook (§2.3). Authors override it.
|
|
176
|
+
def execute
|
|
177
|
+
raise NotImplementedError, "#{self.class} must implement #execute"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Entry authorization (§5.2/§6.5). Authors override it; false → 403. Default
|
|
181
|
+
# allow — resource-mounted surfaces additionally gate via the action policy.
|
|
182
|
+
def authorize? = true
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
# Raise a StepError from `on_submit`/`execute` (§6.1).
|
|
187
|
+
#
|
|
188
|
+
# fail!("message") → base (form-level) error
|
|
189
|
+
# fail!(:field, "message") → field-level error
|
|
190
|
+
def fail!(attribute_or_message, message = nil)
|
|
191
|
+
if message.nil?
|
|
192
|
+
raise StepError.new(attribute_or_message, attribute: :base)
|
|
193
|
+
else
|
|
194
|
+
raise StepError.new(message, attribute: attribute_or_message)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @return [Plutonium::Interaction::Outcome::Success]
|
|
199
|
+
def succeed(value = nil)
|
|
200
|
+
Plutonium::Interaction::Outcome::Success.new(value)
|
|
201
|
+
end
|
|
202
|
+
alias_method :success, :succeed
|
|
203
|
+
|
|
204
|
+
# @return [Plutonium::Interaction::Outcome::Failure]
|
|
205
|
+
def failed(errors = nil, attribute = :base)
|
|
206
|
+
case errors
|
|
207
|
+
when Hash
|
|
208
|
+
errors.each { |attr, error| self.errors.add(attr, error) }
|
|
209
|
+
else
|
|
210
|
+
Array(errors).each { |error| self.errors.add(attribute, error) }
|
|
211
|
+
end
|
|
212
|
+
Plutonium::Interaction::Outcome::Failure.new
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Convenience base for a standalone wizard controller that needs NO custom auth
|
|
6
|
+
# base: a plain `ActionController::Base` plus the wizard module. Use it when you
|
|
7
|
+
# want to drop in your own controller without an auth concern:
|
|
8
|
+
#
|
|
9
|
+
# class WizardsController < Plutonium::Wizard::BaseController; end
|
|
10
|
+
#
|
|
11
|
+
# For an AUTHENTICATED standalone wizard, don't use this — inherit your own
|
|
12
|
+
# authenticated base and `include Plutonium::Wizard::Controller` instead, so the
|
|
13
|
+
# controller carries `current_user`:
|
|
14
|
+
#
|
|
15
|
+
# class WizardsController < ApplicationController
|
|
16
|
+
# include Plutonium::Wizard::Controller
|
|
17
|
+
# include Plutonium::Auth::Rodauth(:user)
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# The module is the mechanism; this class is only sugar.
|
|
21
|
+
class BaseController < ActionController::Base
|
|
22
|
+
# A bare `ActionController::Base` host normally inherits forgery protection
|
|
23
|
+
# from the app's `default_protect_from_forgery`, but make it explicit here so
|
|
24
|
+
# a standalone wizard mount is CSRF-protected regardless of app config (the
|
|
25
|
+
# wizard `update` is a state-changing POST).
|
|
26
|
+
protect_from_forgery with: :exception
|
|
27
|
+
|
|
28
|
+
include Plutonium::Wizard::Controller
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Configuration for the Plutonium wizard subsystem.
|
|
6
|
+
#
|
|
7
|
+
# Exposed via {Plutonium::Configuration#wizards}.
|
|
8
|
+
class Configuration
|
|
9
|
+
# @return [Boolean] whether the wizard subsystem (and its migrations) is enabled
|
|
10
|
+
attr_accessor :enabled
|
|
11
|
+
|
|
12
|
+
# @return [ActiveSupport::Duration] how long completed/abandoned sessions are kept
|
|
13
|
+
attr_accessor :cleanup_after
|
|
14
|
+
|
|
15
|
+
# @return [Symbol] which database wizard tables live in
|
|
16
|
+
attr_accessor :database
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] encrypt every wizard's staged `data` at rest by default.
|
|
19
|
+
# Off by default because it needs ActiveRecord encryption keys configured;
|
|
20
|
+
# a wizard may still opt in (`encrypt_data`) or out (`encrypt_data false`)
|
|
21
|
+
# individually regardless of this default.
|
|
22
|
+
attr_accessor :encrypt_data
|
|
23
|
+
|
|
24
|
+
# @return [Symbol, nil] the storage backend used to SERVER-SIDE-stage a plain
|
|
25
|
+
# (non-direct-upload) wizard attachment field — `:active_storage` or
|
|
26
|
+
# `:shrine`. `nil` auto-detects (active_shrine loaded → `:shrine`, else
|
|
27
|
+
# `:active_storage`). A field may override with `input …, backend:`. Only
|
|
28
|
+
# relevant when a file rides the step POST; direct-upload fields already
|
|
29
|
+
# arrive as a token and ignore this.
|
|
30
|
+
attr_accessor :attachment_backend
|
|
31
|
+
|
|
32
|
+
# Initialize a new wizard Configuration instance with default values.
|
|
33
|
+
def initialize
|
|
34
|
+
@enabled = false
|
|
35
|
+
@cleanup_after = 14.days
|
|
36
|
+
@database = :primary
|
|
37
|
+
@encrypt_data = false
|
|
38
|
+
@attachment_backend = nil
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# The standalone portal-hosted controller concern for portal-level wizards
|
|
6
|
+
# registered with `register_wizard` (§5.2). It is mixed into a portal-
|
|
7
|
+
# namespaced controller (see {Plutonium::Routing::WizardRegistration}), so it
|
|
8
|
+
# inherits the portal's auth, tenant scoping entity, layout, and Phlex
|
|
9
|
+
# rendering exactly like a resource controller.
|
|
10
|
+
#
|
|
11
|
+
# All runner-driving logic lives in {Plutonium::Wizard::Driving} (shared with
|
|
12
|
+
# the resource-mounted {Plutonium::Resource::Controllers::WizardActions}). This
|
|
13
|
+
# concern only adapts that logic to the standalone surface: the `show`/`update`
|
|
14
|
+
# actions, the wizard class carried as a route default, no anchor (portal-level
|
|
15
|
+
# wizards are non-anchored — anchored wizards mount on the resource controller),
|
|
16
|
+
# and the per-step URL built from the named route helper `register_wizard` draws.
|
|
17
|
+
#
|
|
18
|
+
# Identity (§4): a guest (`anonymous`) run's per-run id lives in the Rails
|
|
19
|
+
# session, namespaced per wizard (no cookie, no TTL); it is cleared on
|
|
20
|
+
# completion and auto-cleared on login/logout (Rodauth `reset_session`). An
|
|
21
|
+
# authenticated repeatable run carries its per-run id in the URL `:token`
|
|
22
|
+
# segment, guarded by owner-scoping; neither crosses the auth boundary
|
|
23
|
+
# mid-flow (§4.5).
|
|
24
|
+
module Controller
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
# The complete include surface for a standalone wizard controller: the
|
|
27
|
+
# Plutonium rendering/scoping stack PLUS the wizard driving. Including this
|
|
28
|
+
# one module yields a fully renderable wizard controller, so the synthesizer
|
|
29
|
+
# and any app override (`class WizardsController < MyAuthBase; include
|
|
30
|
+
# Plutonium::Wizard::Controller; end`) both get everything. Re-including Core
|
|
31
|
+
# on a host that already has it (a portal controller) is a harmless no-op.
|
|
32
|
+
include Plutonium::Core::Controller
|
|
33
|
+
include Plutonium::Wizard::Driving
|
|
34
|
+
|
|
35
|
+
included do
|
|
36
|
+
helper_method :current_user
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class_methods do
|
|
40
|
+
# The gem's shared partials (`plutonium/_flash`, …) are looked up by a
|
|
41
|
+
# "plutonium" view prefix, which normally comes from inheriting a controller
|
|
42
|
+
# whose `controller_path` is "plutonium" (the app's `PlutoniumController`). A
|
|
43
|
+
# bare host (a main-app / public wizard rooted in `ActionController::Base`)
|
|
44
|
+
# has no such ancestor, so contribute the prefix here — making the module
|
|
45
|
+
# self-sufficient and the "main-app can be bare" path actually work.
|
|
46
|
+
def _prefixes
|
|
47
|
+
@_wizard_view_prefixes ||= (super | ["plutonium"])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# GET the bare mount — resolve/mint the run and redirect to its step.
|
|
52
|
+
def launch
|
|
53
|
+
wizard_launch
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# GET .../:step — render the current step.
|
|
57
|
+
def show
|
|
58
|
+
wizard_show
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# POST .../:step — advance / back / cancel.
|
|
62
|
+
def update
|
|
63
|
+
wizard_update
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Identity for a standalone wizard host. Defers to the host's own auth
|
|
69
|
+
# concern when present — a portal controller's `Rodauth(:account)`, or an
|
|
70
|
+
# app-defined `::WizardsController`'s — and is `nil` on a bare host (a public
|
|
71
|
+
# mount, or a misconfigured authenticated main-app wizard with no auth
|
|
72
|
+
# controller). An `anonymous` wizard never consults this; a non-anonymous
|
|
73
|
+
# wizard on a bare host resolves `nil` and is rejected by
|
|
74
|
+
# `require_wizard_authentication!`.
|
|
75
|
+
def current_user
|
|
76
|
+
defined?(super) ? super : nil
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The wizard class is carried as a route default (see WizardRegistration),
|
|
80
|
+
# so it is a server-set path parameter. Resolve it through an ALLOWLIST of
|
|
81
|
+
# the loaded wizard classes rather than `constantize`-ing the raw value:
|
|
82
|
+
# every standalone wizard route is drawn by handing `register_wizard` the
|
|
83
|
+
# class object, so by the time its route matches the class is loaded and
|
|
84
|
+
# registered as a `Base` descendant. Matching by name can therefore only
|
|
85
|
+
# ever return an actual wizard — it never triggers resolution of an
|
|
86
|
+
# arbitrary constant (the `constantize`-on-params code-execution surface).
|
|
87
|
+
def current_wizard_class
|
|
88
|
+
@current_wizard_class ||= begin
|
|
89
|
+
name = params.fetch(:wizard_class).to_s
|
|
90
|
+
Plutonium::Wizard::Base.descendants.find { |klass| klass.name == name } ||
|
|
91
|
+
raise(Plutonium::Wizard::UnknownWizardError, "unknown wizard #{name.inspect}")
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Portal-level wizards are either non-anchored or CONTEXT-anchored
|
|
96
|
+
# (`anchored via: :method`, §3). A `with:`-only (TYPE) anchor mounts on the
|
|
97
|
+
# resource controller instead (see {WizardActions}), where the anchor is
|
|
98
|
+
# resolved through the scoped, policy-gated `resource_record!` — never an
|
|
99
|
+
# unscoped `find_by`, which would be a cross-tenant IDOR.
|
|
100
|
+
#
|
|
101
|
+
# For a CONTEXT anchor we call the declared method on this controller; a nil
|
|
102
|
+
# result is a programming error (the wizard declared itself anchored).
|
|
103
|
+
def resolved_wizard_anchor
|
|
104
|
+
klass = current_wizard_class
|
|
105
|
+
return nil unless klass.anchored_via?
|
|
106
|
+
|
|
107
|
+
record = send(klass.anchor_via)
|
|
108
|
+
if record.nil?
|
|
109
|
+
raise Plutonium::Wizard::NotAnchoredError,
|
|
110
|
+
"#{klass.name} resolves its anchor via `#{klass.anchor_via}`, which returned nil"
|
|
111
|
+
end
|
|
112
|
+
assert_anchor_type!(klass, record)
|
|
113
|
+
record
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# When a CONTEXT anchor also declares `with:` types, type-assert the result.
|
|
117
|
+
def assert_anchor_type!(klass, record)
|
|
118
|
+
types = klass.anchor_types
|
|
119
|
+
return if types.nil?
|
|
120
|
+
return if types.any? { |t| record.is_a?(t) }
|
|
121
|
+
|
|
122
|
+
raise Plutonium::Wizard::NotAnchoredError,
|
|
123
|
+
"#{klass.name} anchor resolved to #{record.class} but expects one of " \
|
|
124
|
+
"#{types.join(", ")}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Build the GET URL for a given step of this wizard, preserving the
|
|
128
|
+
# `:token` segment. Built through the named route helper that
|
|
129
|
+
# `register_wizard` draws (resolved from the current engine's route set by
|
|
130
|
+
# the wizard class, so `at:`/`as:` overrides are honored) — never
|
|
131
|
+
# string-surgery on `request.path`, so the URL is always a same-host,
|
|
132
|
+
# route-validated path.
|
|
133
|
+
def wizard_step_url(step_key)
|
|
134
|
+
url_options = {step: step_key}
|
|
135
|
+
token = wizard_url_token
|
|
136
|
+
url_options[:token] = token if token.present?
|
|
137
|
+
# An entity-scoped portal's wizard routes carry the scope path segment
|
|
138
|
+
# (e.g. `:organization_scoped`); thread it through from the request so the
|
|
139
|
+
# generated URL stays inside the tenant.
|
|
140
|
+
if scoped_to_entity?
|
|
141
|
+
url_options[scoped_entity_param_key] = params[scoped_entity_param_key]
|
|
142
|
+
end
|
|
143
|
+
current_engine.routes.url_helpers.public_send(wizard_step_url_helper, **url_options)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The `<name>_wizard_path` helper for this wizard's standalone mount. Found
|
|
147
|
+
# by the GET route `register_wizard` draws (named `:"#{helper}_wizard"`,
|
|
148
|
+
# carrying the `wizard_class` route default) so the lookup tracks the actual
|
|
149
|
+
# `at:`/`as:` used at registration rather than re-deriving a slug.
|
|
150
|
+
def wizard_step_url_helper
|
|
151
|
+
@wizard_step_url_helper ||= begin
|
|
152
|
+
name = Plutonium::Wizard::RouteResolution.route_name(
|
|
153
|
+
current_engine.routes, current_wizard_class, action: "show"
|
|
154
|
+
)
|
|
155
|
+
raise "no register_wizard route found for #{current_wizard_class.name}" unless name
|
|
156
|
+
|
|
157
|
+
:"#{name}_path"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Builds the wizard's typed `data` snapshot (§2.6). `data` is **step-keyed**: a
|
|
6
|
+
# container exposing one typed sub-object per step, so fields are addressed as
|
|
7
|
+
# `data.<step>.<field>` (e.g. `data.identity.name`, `data.profile.tier`). Each
|
|
8
|
+
# step sub-object is backed by ActiveModel::Attributes — scalar values are cast
|
|
9
|
+
# to their declared types and uncollected fields read as `nil`. Step namespacing
|
|
10
|
+
# means two steps may declare the same field name without colliding.
|
|
11
|
+
#
|
|
12
|
+
# `structured_input ..., repeat:` collections (which declare no scalar types —
|
|
13
|
+
# their sub-fields come from `input` declarations) are exposed on their step's
|
|
14
|
+
# sub-object as arrays of typed sub-objects responding to the declared sub-field
|
|
15
|
+
# names (`data.members.invites.first.email`).
|
|
16
|
+
module Data
|
|
17
|
+
# A read-only row inside a structured collection. Responds to each declared
|
|
18
|
+
# sub-field; values are exposed as-is (string-typed, since structured inputs
|
|
19
|
+
# carry no scalar type declarations).
|
|
20
|
+
class StructuredRow
|
|
21
|
+
def initialize(fields, values)
|
|
22
|
+
@values = values
|
|
23
|
+
fields.each do |field|
|
|
24
|
+
define_singleton_method(field) { @values[field.to_s] }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def [](key) = @values[key.to_s]
|
|
29
|
+
|
|
30
|
+
def to_h = @values.dup
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param schema [Hash{Symbol=>Symbol}] scalar attribute name => type
|
|
34
|
+
# @param options [Hash{Symbol=>Hash}] scalar attribute name => options (default:, etc.)
|
|
35
|
+
# @param structured [Hash{Symbol=>Array<Symbol>}] structured name => sub-field names
|
|
36
|
+
# @param validations [Array<[Array, Hash]>] inline `validates` declarations
|
|
37
|
+
# ([args, options]) replayed onto the class so the form pipeline can infer
|
|
38
|
+
# required markers via `validators_on` (§7). The class is never `.valid?`'d
|
|
39
|
+
# in the form path — validation runs through the runner — so this only
|
|
40
|
+
# feeds introspection.
|
|
41
|
+
def self.class_for(schema, options: {}, structured: {}, validations: [])
|
|
42
|
+
Class.new do
|
|
43
|
+
include ActiveModel::Model
|
|
44
|
+
include ActiveModel::Attributes
|
|
45
|
+
|
|
46
|
+
# Anonymous classes have no name, which breaks label/error translation
|
|
47
|
+
# lookups (`human_attribute_name` / `errors.full_messages` call
|
|
48
|
+
# `model_name`). Supply a stable one so the form/display pipelines can
|
|
49
|
+
# humanize attribute labels.
|
|
50
|
+
def self.model_name = ActiveModel::Name.new(self, nil, "Wizard")
|
|
51
|
+
|
|
52
|
+
schema.each do |name, type|
|
|
53
|
+
attribute(name, Plutonium::Wizard.safe_attribute_type(type), **(options[name] || {}))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
validations.each { |args, opts| validates(*args, **opts) }
|
|
57
|
+
|
|
58
|
+
structured.each do |name, fields|
|
|
59
|
+
# Backed by a plain accessor (not an ActiveModel attribute) so the raw
|
|
60
|
+
# array survives without coercion, then wrapped on read.
|
|
61
|
+
attr_writer name
|
|
62
|
+
define_method(name) do
|
|
63
|
+
rows = Array(instance_variable_get(:"@#{name}"))
|
|
64
|
+
rows.map do |row|
|
|
65
|
+
values = row.respond_to?(:to_h) ? row.to_h.transform_keys(&:to_s) : {}
|
|
66
|
+
StructuredRow.new(fields, values)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Accept the union of scalar + structured keys, ignoring unknown keys.
|
|
72
|
+
define_method(:initialize) do |attrs = {}|
|
|
73
|
+
attrs = (attrs || {}).symbolize_keys
|
|
74
|
+
scalar = attrs.slice(*schema.keys)
|
|
75
|
+
super(scalar)
|
|
76
|
+
structured.each_key do |name|
|
|
77
|
+
instance_variable_set(:"@#{name}", attrs[name] || [])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Typed plain-hash view: cast scalars + structured rows as hashes.
|
|
82
|
+
define_method(:to_h) do
|
|
83
|
+
h = {}
|
|
84
|
+
schema.each_key { |name| h[name] = public_send(name) }
|
|
85
|
+
structured.each_key { |name| h[name] = public_send(name).map(&:to_h) }
|
|
86
|
+
h
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The step-keyed `data` snapshot — a thin dispatcher over the per-step typed
|
|
92
|
+
# sub-objects (§2.6). `data.identity` (via method_missing) or `data[:identity]`
|
|
93
|
+
# returns the step's typed sub-object, built lazily from its nested data slice
|
|
94
|
+
# and memoized; an unknown step key returns nil. `to_h` gives the nested
|
|
95
|
+
# `{step => {field => value}}` view.
|
|
96
|
+
#
|
|
97
|
+
# A plain object (not a generated class) so it isn't rebuilt every time the
|
|
98
|
+
# runner reassigns `data_attributes`; the per-step typed classes are built
|
|
99
|
+
# once per wizard class and passed in.
|
|
100
|
+
class Container
|
|
101
|
+
# @param step_classes [Hash{Symbol=>Class}] step key => typed sub-object class
|
|
102
|
+
# @param attrs [Hash] nested staged data ({step_key => {field => value}})
|
|
103
|
+
def initialize(step_classes, attrs = {})
|
|
104
|
+
@step_classes = step_classes
|
|
105
|
+
@attrs = (attrs || {}).transform_keys(&:to_sym)
|
|
106
|
+
@objects = {}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The typed sub-object for a step (lazy + memoized); nil for an unknown step.
|
|
110
|
+
def [](key)
|
|
111
|
+
key = key.to_sym
|
|
112
|
+
return nil unless @step_classes.key?(key)
|
|
113
|
+
@objects[key] ||= @step_classes[key].new(@attrs[key] || {})
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The declared step keys, in order.
|
|
117
|
+
def step_keys = @step_classes.keys
|
|
118
|
+
|
|
119
|
+
# Nested typed hash: {step_key => {field => value}}.
|
|
120
|
+
def to_h = @step_classes.keys.index_with { |key| self[key].to_h }
|
|
121
|
+
|
|
122
|
+
def respond_to_missing?(name, include_private = false)
|
|
123
|
+
@step_classes.key?(name.to_sym) || super
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `data.identity` → the identity step's typed sub-object.
|
|
127
|
+
def method_missing(name, *args)
|
|
128
|
+
return self[name] if args.empty? && @step_classes.key?(name.to_sym)
|
|
129
|
+
super
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|