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,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Resolves a step's `using:` option (§2.4) — importing a field surface from a
|
|
6
|
+
# **model (ActiveRecord class)** instead of re-declaring it.
|
|
7
|
+
#
|
|
8
|
+
# `using:` targets a model only. A `Plutonium::Resource::Definition` carries no
|
|
9
|
+
# link to its model (it's an empty class the controller binds at request time),
|
|
10
|
+
# so the only reliable direction is **model → definition**: the importer
|
|
11
|
+
# auto-resolves `"#{Model}Definition"` to overlay input styling, best-effort.
|
|
12
|
+
#
|
|
13
|
+
# The imported surface is:
|
|
14
|
+
# - **attribute_schema** ({name => type}) — the field universe is
|
|
15
|
+
# `Model.attribute_names` (filtered by selectors); types are
|
|
16
|
+
# `Model.attribute_types[name].type`.
|
|
17
|
+
# - **inputs** ({name => {options:, block:}}) — overlaid from the resolved
|
|
18
|
+
# `<Model>Definition`'s `field`/`input` config (`as:`, options, labels),
|
|
19
|
+
# sliced to the imported names. No definition → empty input config.
|
|
20
|
+
# - **form_layout** — the resolved `<Model>Definition`'s `defined_form_layout`,
|
|
21
|
+
# filtered to the imported fields, **plus a trailing ungrouped section** for
|
|
22
|
+
# imported fields not named in any explicit section (skipped when
|
|
23
|
+
# `layout: false`).
|
|
24
|
+
# - **validate_fn** — runs a transient `Model.new(slice).valid?` and keeps
|
|
25
|
+
# errors only on the imported fields **plus `:base`** (skipped when
|
|
26
|
+
# `validate: false`).
|
|
27
|
+
#
|
|
28
|
+
# Validation is *run-and-filtered* rather than cloned: AR validators can't be
|
|
29
|
+
# cloned cleanly. Filtering to imported fields + `:base` is what prevents a
|
|
30
|
+
# partial model reporting presence errors for columns this step never collects.
|
|
31
|
+
module FieldImporter
|
|
32
|
+
# The validator kinds the form pipeline reads for field metadata (required
|
|
33
|
+
# marker, maxlength/minlength, min/max, pattern, auto-choices). Other kinds
|
|
34
|
+
# (uniqueness, custom EachValidators) carry no form meaning, so we skip them
|
|
35
|
+
# — and replaying a custom kind through `validates` would raise.
|
|
36
|
+
FORM_VALIDATOR_KINDS = %i[presence length numericality format inclusion].freeze
|
|
37
|
+
|
|
38
|
+
# The resolved import surface for one `using:` declaration.
|
|
39
|
+
Spec = Struct.new(:attribute_schema, :inputs, :form_layout, :validate_fn, :form_validators) do
|
|
40
|
+
# Run the imported validation over a staged data slice, returning a hash of
|
|
41
|
+
# {attribute => [messages]} for the imported fields + :base. Empty when
|
|
42
|
+
# `validate: false`.
|
|
43
|
+
def validate(data_slice)
|
|
44
|
+
validate_fn ? validate_fn.call(data_slice) : {}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class << self
|
|
49
|
+
# @param using [Class] an ActiveRecord model class
|
|
50
|
+
# @param opts [Hash] fields:/only:/except:/validate:/layout:/validation_context:
|
|
51
|
+
# @return [Spec]
|
|
52
|
+
def resolve(using:, opts:)
|
|
53
|
+
model = model!(using)
|
|
54
|
+
opts ||= {}
|
|
55
|
+
only = normalize(opts[:fields] || opts[:only])
|
|
56
|
+
except = normalize(opts[:except]) || []
|
|
57
|
+
do_validate = opts.fetch(:validate, true)
|
|
58
|
+
do_layout = opts.fetch(:layout, true)
|
|
59
|
+
context = opts[:validation_context]
|
|
60
|
+
|
|
61
|
+
names = select(model.attribute_names.map(&:to_sym), only:, except:)
|
|
62
|
+
definition = "#{model.name}Definition".safe_constantize
|
|
63
|
+
|
|
64
|
+
schema = names.index_with { |n| record_type(model, n) }
|
|
65
|
+
|
|
66
|
+
validate_fn = build_validate(do_validate) do |slice|
|
|
67
|
+
record = model.new(string_slice(slice, names))
|
|
68
|
+
run_and_filter(record, names, context)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
Spec.new(
|
|
72
|
+
attribute_schema: schema,
|
|
73
|
+
inputs: inputs_for(definition, names),
|
|
74
|
+
form_layout: do_layout ? layout_for(definition, names) : nil,
|
|
75
|
+
validate_fn:,
|
|
76
|
+
form_validators: form_validators_for(model, names)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# The imported fields' form-relevant validators, as raw `[[name], options]`
|
|
83
|
+
# pairs replayable through `validates` onto the typed data class — so the
|
|
84
|
+
# form pipeline surfaces imported required/length/etc. the same as inline
|
|
85
|
+
# `validates`. Kept SEPARATE from `validate_fn`: the runner validates
|
|
86
|
+
# imported fields through the transient model, so these must not feed it.
|
|
87
|
+
def form_validators_for(model, names)
|
|
88
|
+
names.flat_map do |name|
|
|
89
|
+
model.validators_on(name).filter_map do |validator|
|
|
90
|
+
next unless FORM_VALIDATOR_KINDS.include?(validator.kind)
|
|
91
|
+
[[name], {validator.kind => validator.options.presence || true}]
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# `using:` accepts a model class only. Anything else is a programming error.
|
|
97
|
+
def model!(using)
|
|
98
|
+
unless using.is_a?(Class) && using < ActiveRecord::Base
|
|
99
|
+
raise ArgumentError,
|
|
100
|
+
"using: expects a model class (an ActiveRecord::Base subclass), got #{using.inspect}"
|
|
101
|
+
end
|
|
102
|
+
using
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def normalize(value)
|
|
106
|
+
return nil if value.nil?
|
|
107
|
+
Array(value).map(&:to_sym).presence
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# `names & only` preserves source order while restricting to the selection.
|
|
111
|
+
def select(names, only:, except:)
|
|
112
|
+
names = names.select { |n| only.include?(n) } if only
|
|
113
|
+
names - except
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def record_type(model, name)
|
|
117
|
+
# AR enum columns are integer-backed, but forms submit the string enum
|
|
118
|
+
# *key* ("active"), not the integer. Importing the raw :integer type would
|
|
119
|
+
# cast the key to 0. Keep enum fields as :string so the key round-trips —
|
|
120
|
+
# the author's `Model.new(field: data.field)` then lets AR map key → int,
|
|
121
|
+
# and the review summary shows the key, not a meaningless integer.
|
|
122
|
+
return :string if model.defined_enums.key?(name.to_s)
|
|
123
|
+
model.attribute_types[name.to_s]&.type || :string
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def string_slice(slice, names)
|
|
127
|
+
slice = (slice || {}).stringify_keys
|
|
128
|
+
slice.slice(*names.map(&:to_s))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def build_validate(do_validate, &block)
|
|
132
|
+
do_validate ? block : nil
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Run `valid?` (with the optional context) and keep only the errors on the
|
|
136
|
+
# imported fields + :base.
|
|
137
|
+
def run_and_filter(obj, names, context)
|
|
138
|
+
context ? obj.valid?(context) : obj.valid?
|
|
139
|
+
keep = names + [:base]
|
|
140
|
+
obj.errors.group_by_attribute.slice(*keep)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Build an input config for every imported name (so each gets rendered),
|
|
144
|
+
# overlaying styling from the resolved `<Model>Definition` where present. A
|
|
145
|
+
# definition's `field` and `input` declarations both render on the form;
|
|
146
|
+
# merge them per name (input wins on conflicting options, matching the
|
|
147
|
+
# resource form pipeline). No definition → each name maps to an empty config.
|
|
148
|
+
def inputs_for(definition, names)
|
|
149
|
+
fields = definition&.defined_fields || {}
|
|
150
|
+
inputs = definition&.defined_inputs || {}
|
|
151
|
+
names.index_with { |n| overlay_field_input(fields[n], inputs[n]) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def overlay_field_input(field, input)
|
|
155
|
+
field ||= {}
|
|
156
|
+
input ||= {}
|
|
157
|
+
options = (field[:options] || {}).merge(input[:options] || {})
|
|
158
|
+
block = input[:block] || field[:block]
|
|
159
|
+
{options: options.presence, block:}.compact
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Inherit the resolved definition's form_layout, filtered to the imported
|
|
163
|
+
# fields. Mirrors the canonical `resolve_form_sections` leftover handling
|
|
164
|
+
# (form_layout.rb): each imported field is claimed by the first explicit
|
|
165
|
+
# section that lists it; imported fields no section names fall into a
|
|
166
|
+
# trailing **ungrouped** section, so none silently disappears.
|
|
167
|
+
def layout_for(definition, names)
|
|
168
|
+
return nil unless definition
|
|
169
|
+
layout = definition.defined_form_layout
|
|
170
|
+
return nil unless layout
|
|
171
|
+
|
|
172
|
+
# First-section-wins ownership among imported fields (shared with the
|
|
173
|
+
# canonical resolver). The ASSEMBLY below differs deliberately: an
|
|
174
|
+
# imported layout drops explicit sections that resolve to zero imported
|
|
175
|
+
# fields, and only synthesizes a trailing ungrouped section when there
|
|
176
|
+
# are leftovers — so it can't share the full `resolve_sections`.
|
|
177
|
+
owner, leftovers = Plutonium::Definition::FormLayout.assign_ownership(layout, names)
|
|
178
|
+
|
|
179
|
+
resolved = layout.filter_map do |section|
|
|
180
|
+
fields =
|
|
181
|
+
if section.ungrouped?
|
|
182
|
+
leftovers
|
|
183
|
+
else
|
|
184
|
+
section.fields.map(&:to_sym).select { |f| owner[f] == section.key }
|
|
185
|
+
end
|
|
186
|
+
# Drop explicit sections that resolve to zero imported fields; keep an
|
|
187
|
+
# explicit ungrouped section as the leftover home even when empty-listed.
|
|
188
|
+
next if fields.empty? && !section.ungrouped?
|
|
189
|
+
Plutonium::Definition::FormLayout::ResolvedSection.new(section, fields)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# No explicit ungrouped section, but leftover imported fields exist →
|
|
193
|
+
# synthesize a trailing ungrouped section so nothing disappears.
|
|
194
|
+
if leftovers.any? && layout.none?(&:ungrouped?)
|
|
195
|
+
ungrouped = Plutonium::Definition::FormLayout::Section.new(
|
|
196
|
+
key: Plutonium::Definition::FormLayout::UNGROUPED_KEY,
|
|
197
|
+
fields: [].freeze,
|
|
198
|
+
options: {}.freeze
|
|
199
|
+
)
|
|
200
|
+
resolved.push(Plutonium::Definition::FormLayout::ResolvedSection.new(ungrouped, leftovers))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
resolved
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Controller concern that gates access behind a **one-time wizard** (§9).
|
|
6
|
+
#
|
|
7
|
+
# +ensure_wizard_completed(WizardClass)+ installs a +before_action+ that
|
|
8
|
+
# recomputes the wizard's +instance_key+ (from its +concurrency_key+ — resolved
|
|
9
|
+
# against the host controller's identity context, with the tenant folded in,
|
|
10
|
+
# §4.4) and checks whether a retained +completed+ row exists at that key. If
|
|
11
|
+
# not, it stashes the intended destination and redirects into the wizard's
|
|
12
|
+
# entry step; once the wizard's own finalize retains the completion marker, the
|
|
13
|
+
# gate lets the user through and the controller bounces back to the stashed
|
|
14
|
+
# destination (PRG, wired in {Controller}).
|
|
15
|
+
#
|
|
16
|
+
# class DashboardController < AdminPortal::PlutoniumController
|
|
17
|
+
# include Plutonium::Wizard::Gate
|
|
18
|
+
# ensure_wizard_completed OnboardingWizard
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# Only **one-time** wizards (a +concurrency_key+ plus +one_time+) are gateable —
|
|
22
|
+
# they are the only ones with a durable retained marker. Gating any other
|
|
23
|
+
# wizard raises a clear error at install time.
|
|
24
|
+
#
|
|
25
|
+
# The instance_key recomputation MUST match the runner/driving digest exactly
|
|
26
|
+
# (both go through {Plutonium::Wizard.compute_instance_key}), or the gate would
|
|
27
|
+
# never see the completion the wizard recorded.
|
|
28
|
+
module Gate
|
|
29
|
+
extend ActiveSupport::Concern
|
|
30
|
+
|
|
31
|
+
class_methods do
|
|
32
|
+
# Install the gating +before_action+. Extra options (e.g. +only:/except:+)
|
|
33
|
+
# are forwarded to +before_action+.
|
|
34
|
+
#
|
|
35
|
+
# +anchor:+ tells the gate how to resolve an ANCHORED wizard's anchor in
|
|
36
|
+
# THIS controller's context (a symbol method name or a proc evaluated on the
|
|
37
|
+
# controller) — required to recompute an anchor-keyed wizard's instance_key.
|
|
38
|
+
# A `via:`-anchored wizard whose anchor method the controller exposes is
|
|
39
|
+
# resolved automatically, so this is only needed when the anchor isn't in
|
|
40
|
+
# scope (e.g. a `with:`-anchored wizard, or gating from another portal).
|
|
41
|
+
def ensure_wizard_completed(wizard_class, anchor: nil, **before_action_opts)
|
|
42
|
+
unless wizard_class.one_time?
|
|
43
|
+
raise ArgumentError,
|
|
44
|
+
"#{wizard_class.name} is not a one-time wizard (needs a " \
|
|
45
|
+
"`concurrency_key` + `one_time`); only one-time wizards are gateable (§9)."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
before_action(**before_action_opts) do
|
|
49
|
+
enforce_wizard_completion!(wizard_class, anchor)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# The before_action body: pass through when completed, else stash + redirect.
|
|
57
|
+
def enforce_wizard_completion!(wizard_class, anchor_resolver = nil)
|
|
58
|
+
return if wizard_completed?(wizard_class, anchor_resolver)
|
|
59
|
+
|
|
60
|
+
session[:return_to] ||= request.fullpath
|
|
61
|
+
redirect_to wizard_entry_path(wizard_class)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def wizard_completed?(wizard_class, anchor_resolver = nil)
|
|
65
|
+
wizard_gate_store.completed?(instance_key: wizard_gate_instance_key(wizard_class, anchor_resolver))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Recompute the wizard's instance_key on the host controller (§9). The
|
|
69
|
+
# identity context (`current_user`, `current_scoped_entity`, `anchor`, custom
|
|
70
|
+
# host methods) is read from this controller; a referenced method that's
|
|
71
|
+
# missing raises a clear error via {compute_instance_key}.
|
|
72
|
+
def wizard_gate_instance_key(wizard_class, anchor_resolver = nil)
|
|
73
|
+
Plutonium::Wizard.compute_instance_key(
|
|
74
|
+
wizard_class: wizard_class,
|
|
75
|
+
current_user: current_user,
|
|
76
|
+
current_scoped_entity: wizard_gate_scoped_entity,
|
|
77
|
+
anchor: wizard_gate_anchor(wizard_class, anchor_resolver),
|
|
78
|
+
wizard_token: nil
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Resolve an anchored wizard's anchor in this controller's context, so an
|
|
83
|
+
# anchor-keyed wizard's `instance_key` recomputes to the SAME digest the run
|
|
84
|
+
# used (§9). Order: an explicit `anchor:` resolver wins; otherwise a
|
|
85
|
+
# `via:`-anchored wizard whose anchor method the controller exposes is
|
|
86
|
+
# resolved automatically (the same method the wizard uses); otherwise nil — a
|
|
87
|
+
# non-anchored wizard, or an anchor that can't be reached here. The latter is a
|
|
88
|
+
# misconfiguration for an anchor-keyed wizard, so raise rather than mis-key.
|
|
89
|
+
def wizard_gate_anchor(wizard_class, anchor_resolver)
|
|
90
|
+
return resolve_gate_anchor(anchor_resolver) if anchor_resolver
|
|
91
|
+
return nil unless wizard_class.anchored?
|
|
92
|
+
|
|
93
|
+
via = wizard_class.anchor_via
|
|
94
|
+
return send(via) if via && respond_to?(via, true)
|
|
95
|
+
|
|
96
|
+
# Couldn't auto-resolve the anchor. When the wizard relies on the IMPLIED
|
|
97
|
+
# anchor key, the anchor is DEFINITELY part of the identity, so a nil would
|
|
98
|
+
# silently mis-key (the gate would loop forever) — raise instead. For an
|
|
99
|
+
# explicit key we can't tell whether it references the anchor, so leave it
|
|
100
|
+
# nil (best-effort, same contract as any host-method the key may reference).
|
|
101
|
+
if wizard_class.implied_anchor_key?
|
|
102
|
+
raise ArgumentError,
|
|
103
|
+
"#{wizard_class.name} is anchored and keyed by its anchor, but #{self.class} " \
|
|
104
|
+
"can't resolve it#{" (no `#{via}` here)" if via}. Pass `anchor:` to " \
|
|
105
|
+
"`ensure_wizard_completed` (a method name or proc)."
|
|
106
|
+
end
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Evaluate an explicit `anchor:` resolver: a proc runs in the controller
|
|
111
|
+
# context, a symbol is sent to the controller.
|
|
112
|
+
def resolve_gate_anchor(resolver)
|
|
113
|
+
resolver.respond_to?(:call) ? instance_exec(&resolver) : send(resolver)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The tenant folded into the gate's key recomputation (§4.4) — the portal
|
|
117
|
+
# scoping entity when the host portal is entity-scoped, else nil. Mirrors the
|
|
118
|
+
# driving layer's `resolved_wizard_scope`. `current_user`/`scoped_to_entity?`
|
|
119
|
+
# are private on portal controllers, so call them directly (a `respond_to?`
|
|
120
|
+
# check would be false for private methods and silently mis-key).
|
|
121
|
+
def wizard_gate_scoped_entity
|
|
122
|
+
return unless scoped_to_entity?
|
|
123
|
+
|
|
124
|
+
current_scoped_entity
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def wizard_gate_store
|
|
128
|
+
Plutonium::Wizard::Store::ActiveRecord.new
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The entry URL for the wizard (§5.3): the bare LAUNCH route `register_wizard`
|
|
132
|
+
# drew, resolved from THIS portal's route set by the wizard's `wizard_class`
|
|
133
|
+
# route default — so the actual `at:`/`as:` used at registration is honored
|
|
134
|
+
# (re-deriving a slug from the class name breaks whenever they differ, e.g.
|
|
135
|
+
# `register_wizard W, at: "onboarding"`). The launch action resolves/mints the
|
|
136
|
+
# run and PRGs to its current (resumed) step, so no `:step` is needed here. The
|
|
137
|
+
# tenant scope path segment is threaded for an entity-scoped portal (the route
|
|
138
|
+
# requires it). Override for a custom mount.
|
|
139
|
+
def wizard_entry_path(wizard_class)
|
|
140
|
+
route_set = wizard_gate_route_set
|
|
141
|
+
name = Plutonium::Wizard::RouteResolution.route_name(route_set, wizard_class, action: "launch")
|
|
142
|
+
unless name
|
|
143
|
+
raise ArgumentError,
|
|
144
|
+
"#{self.class} gates #{wizard_class.name} but no `register_wizard` launch " \
|
|
145
|
+
"route for it was found in #{(route_set === Rails.application.routes) ? "the application" : "this portal"}. " \
|
|
146
|
+
"Register it with `register_wizard #{wizard_class.name}, at: \"…\"`, or override " \
|
|
147
|
+
"`wizard_entry_path` for a custom mount."
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
route_set.url_helpers.public_send(:"#{name}_path", **wizard_gate_scope_param)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The route set the gated wizard is mounted in — the host portal's engine
|
|
154
|
+
# routes (it's registered alongside the portal's resources). Override if the
|
|
155
|
+
# gate lives outside the wizard's portal.
|
|
156
|
+
def wizard_gate_route_set
|
|
157
|
+
current_engine.routes
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The tenant scope path segment for an entity-scoped portal, threaded from the
|
|
161
|
+
# current request so the entry URL stays inside the tenant. The route's param
|
|
162
|
+
# key is the engine's own `scoped_entity_param_key` (honors a custom
|
|
163
|
+
# `param_key:`); empty for a non-scoped portal.
|
|
164
|
+
def wizard_gate_scope_param
|
|
165
|
+
return {} unless scoped_to_entity?
|
|
166
|
+
|
|
167
|
+
{scoped_entity_param_key => params[scoped_entity_param_key]}
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Plutonium
|
|
7
|
+
module Wizard
|
|
8
|
+
# Computes the deterministic identity digest a wizard session row is keyed by
|
|
9
|
+
# (§4). There are two builders, mirroring the two identity axes:
|
|
10
|
+
#
|
|
11
|
+
# - {.concurrency} — a wizard with a `concurrency_key`. The digest is over the
|
|
12
|
+
# wizard name and the serialized key value(s) (which already include the
|
|
13
|
+
# folded tenant, §4.4). The keyed row IS the lock — two launches with the
|
|
14
|
+
# same key collapse to one digest, so the second resumes the first.
|
|
15
|
+
# - {.tokened} — a wizard without a `concurrency_key`. The digest is over the
|
|
16
|
+
# wizard name and the per-launch `wizard_token`, so every launch is a fresh,
|
|
17
|
+
# independent (repeatable) run.
|
|
18
|
+
#
|
|
19
|
+
# The two recipes MUST stay byte-identical between the place that creates rows
|
|
20
|
+
# (runner/driving) and the place that recomputes the key (the gate), or
|
|
21
|
+
# one-time gating silently breaks.
|
|
22
|
+
module InstanceKey
|
|
23
|
+
# Keyed (concurrency_key) identity.
|
|
24
|
+
#
|
|
25
|
+
# @param wizard_name [String] the wizard class name
|
|
26
|
+
# @param key_values [Object, Array] the concurrency_key value(s); arrays are
|
|
27
|
+
# serialized element-wise then joined. The tenant is expected to already be
|
|
28
|
+
# folded in by the caller (§4.4).
|
|
29
|
+
# @return [String] a hex SHA256 digest
|
|
30
|
+
def self.concurrency(wizard_name, key_values)
|
|
31
|
+
digest("concurrency", wizard_name, serialize(key_values))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Tokened (no concurrency_key) identity.
|
|
35
|
+
#
|
|
36
|
+
# @param wizard_name [String] the wizard class name
|
|
37
|
+
# @param token [String] the per-launch wizard token
|
|
38
|
+
# @return [String] a hex SHA256 digest
|
|
39
|
+
def self.tokened(wizard_name, token)
|
|
40
|
+
digest("tokened", wizard_name, token.to_s)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Serialize a key value into a STRUCTURED, unambiguous form for the digest:
|
|
44
|
+
# - Array → each element serialized, kept as a nested array
|
|
45
|
+
# - AR record / GlobalID-able → its GlobalID string
|
|
46
|
+
# - nil → nil (a nil tenant folds to a stable, distinct blank)
|
|
47
|
+
# - scalar → to_s
|
|
48
|
+
#
|
|
49
|
+
# Structure (not a flat join) is the point: the digest hashes the JSON of the
|
|
50
|
+
# nested form, so `["a", "b"]` and the scalar `"a|b"` serialize to distinct
|
|
51
|
+
# JSON (`["a","b"]` vs `"a|b"`) and can never collide. A separator-joined form
|
|
52
|
+
# would make a key element containing the separator indistinguishable from a
|
|
53
|
+
# structural boundary (two distinct runs collapsing to one row — cross-run
|
|
54
|
+
# data exposure / one-time gating satisfied by the wrong run).
|
|
55
|
+
#
|
|
56
|
+
# @param value [Object]
|
|
57
|
+
# @return [Object] a JSON-serializable structure (String / nested Array / nil)
|
|
58
|
+
def self.serialize(value)
|
|
59
|
+
case value
|
|
60
|
+
when Array
|
|
61
|
+
value.map { |v| serialize(v) }
|
|
62
|
+
when nil
|
|
63
|
+
nil
|
|
64
|
+
when String, Symbol, Numeric, true, false
|
|
65
|
+
value.to_s
|
|
66
|
+
else
|
|
67
|
+
if value.respond_to?(:to_global_id)
|
|
68
|
+
value.to_global_id.to_s
|
|
69
|
+
else
|
|
70
|
+
value.to_s
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Hash the JSON of [salt, *parts]. JSON makes the boundaries between parts (and
|
|
76
|
+
# between nested array elements) unambiguous; the salt (the app secret) makes
|
|
77
|
+
# the digest a MAC over otherwise-public identifiers (wizard name + key GIDs),
|
|
78
|
+
# so it can't be recomputed off-app to probe another run's existence/state.
|
|
79
|
+
def self.digest(*parts)
|
|
80
|
+
Digest::SHA256.hexdigest(JSON.generate([salt, *parts]))
|
|
81
|
+
end
|
|
82
|
+
private_class_method :digest
|
|
83
|
+
|
|
84
|
+
# A stable, app-specific salt. Falls back to a constant when no Rails app /
|
|
85
|
+
# secret is configured (e.g. isolated unit contexts) so the digest is still
|
|
86
|
+
# deterministic — the MAC property simply degrades to the unsalted form there.
|
|
87
|
+
def self.salt
|
|
88
|
+
if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
89
|
+
::Rails.application.secret_key_base.to_s
|
|
90
|
+
else
|
|
91
|
+
"plutonium-wizard"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
private_class_method :salt
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Lazy view over a wizard run's persisted records (§2.2 / §4.5).
|
|
6
|
+
#
|
|
7
|
+
# The stored state only holds GIDs ({ "step_key" => [gid, ...] }); resolving
|
|
8
|
+
# them back into live records costs a `GlobalID::Locator.locate` per GID. Most
|
|
9
|
+
# requests (a GET render, a `back`, any step whose `condition:`/render never
|
|
10
|
+
# reads `persisted`) don't need those records at all, so we resolve LAZILY:
|
|
11
|
+
#
|
|
12
|
+
# - `persisted[:key]` returns the memoized live records when the key was SET
|
|
13
|
+
# this request (records created via the `persist` macro in `on_submit`) — no
|
|
14
|
+
# locate.
|
|
15
|
+
# - Otherwise it locates the GIDs stored under that key ONCE, memoizes the
|
|
16
|
+
# result, and returns it. A request that never reads `persisted` issues zero
|
|
17
|
+
# locate queries.
|
|
18
|
+
#
|
|
19
|
+
# `gid_source` is the `{ "step_key" => [gids] }` hash the runner injects from
|
|
20
|
+
# the stored state. Writes (`persisted[k] = records`) memoize live records
|
|
21
|
+
# directly and shadow any stored GIDs for that key.
|
|
22
|
+
class LazyPersisted
|
|
23
|
+
def initialize(gid_source = {})
|
|
24
|
+
@gid_source = gid_source || {}
|
|
25
|
+
@memo = {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Live records for a step key. Memoized records (set this request) are
|
|
29
|
+
# returned as-is; otherwise the key's stored GIDs are located once.
|
|
30
|
+
def [](key)
|
|
31
|
+
key = key.to_sym
|
|
32
|
+
return @memo[key] if @memo.key?(key)
|
|
33
|
+
|
|
34
|
+
@memo[key] = locate(@gid_source[key.to_s] || @gid_source[key])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Memoize live records for a key directly (the runner's post-`on_submit`
|
|
38
|
+
# set, or an author assigning into `persisted`). No locate on later reads.
|
|
39
|
+
def []=(key, records)
|
|
40
|
+
@memo[key.to_sym] = Array(records)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether this key has records available — either set this request or stored
|
|
44
|
+
# as GIDs. Does NOT trigger a locate.
|
|
45
|
+
def key?(key)
|
|
46
|
+
key = key.to_sym
|
|
47
|
+
@memo.key?(key) || @gid_source.key?(key.to_s) || @gid_source.key?(key)
|
|
48
|
+
end
|
|
49
|
+
alias_method :has_key?, :key?
|
|
50
|
+
|
|
51
|
+
# Resolve every known key to its located records (forces locates). Used where
|
|
52
|
+
# the full map is genuinely needed.
|
|
53
|
+
def to_h
|
|
54
|
+
keys.each_with_object({}) { |key, acc| acc[key] = self[key] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# All known step keys (memoized + stored), as symbols, without locating.
|
|
58
|
+
def keys
|
|
59
|
+
(@memo.keys + @gid_source.keys.map(&:to_sym)).uniq
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# Batch-resolve a key's GIDs in ONE query per model class (vs one locate per
|
|
65
|
+
# GID — an N+1 for a multi-record step). `ignore_missing: true` drops GIDs
|
|
66
|
+
# whose record no longer exists (e.g. swept/destroyed), matching the old
|
|
67
|
+
# per-GID `filter_map` that dropped nils; unparseable/invalid GIDs are dropped
|
|
68
|
+
# too, and input order is preserved.
|
|
69
|
+
def locate(gids)
|
|
70
|
+
gids = Array(gids)
|
|
71
|
+
return [] if gids.empty?
|
|
72
|
+
|
|
73
|
+
GlobalID::Locator.locate_many(gids, ignore_missing: true)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|