plutonium 0.60.5 → 0.62.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 +378 -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 +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- 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/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -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 +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -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/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -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/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -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/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -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 +70 -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 +122 -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/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- 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 +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- 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/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- 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/table/resource.rb +1 -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 +92 -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 +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -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 +945 -286
- metadata +113 -2
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Bridges a wizard's staged attachment value to the displayable attachment the
|
|
6
|
+
# `Uppy` input and `Display::Components::Attachment` render — **model-free and
|
|
7
|
+
# backend-agnostic**, for a bare `attribute :photo, :string` + `input :photo,
|
|
8
|
+
# as: :uppy, direct_upload: true` field (no `using:` model needed).
|
|
9
|
+
#
|
|
10
|
+
# A wizard stages plain strings, so an attachment field holds its backend's own
|
|
11
|
+
# direct-upload token: ActiveStorage's **signed_id** (an opaque signed string)
|
|
12
|
+
# or active_shrine/Shrine's **cached-file data** (a JSON object). Those are the
|
|
13
|
+
# only two shapes, and they're distinguishable — a Shrine token parses as JSON,
|
|
14
|
+
# an AS signed_id doesn't — so we revive each through its own backend with no
|
|
15
|
+
# model and no per-field configuration.
|
|
16
|
+
#
|
|
17
|
+
# Resolution is **display-only**: staging and `execute` (which assigns the token
|
|
18
|
+
# straight to a model attachment — both AS and active_shrine accept it) never
|
|
19
|
+
# call it. The two backends' native objects answer DIFFERENT method names
|
|
20
|
+
# (`filename`/`content_type` vs `original_filename`/`mime_type`), so a resolved
|
|
21
|
+
# token is wrapped in {Resolved}, a uniform view exposing exactly what the
|
|
22
|
+
# display + preview components call. See the wizard-attachments design spec.
|
|
23
|
+
module Attachments
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Resolve a staged attachment token (or array of them) into uniform
|
|
27
|
+
# {Resolved} view(s).
|
|
28
|
+
#
|
|
29
|
+
# @param value [String, Array, nil] the staged token(s).
|
|
30
|
+
# @return [Array<Resolved>] resolved attachments; blank, tampered, or
|
|
31
|
+
# unrecognized tokens are dropped (never raised), so a bad token can't 500
|
|
32
|
+
# the form or the review.
|
|
33
|
+
def resolve(value)
|
|
34
|
+
Array(value).filter_map { |token| resolve_token(token) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether a step input renders as an attachment (its `as:` is a file alias),
|
|
38
|
+
# so its staged token should be resolved for display. Keys off the form's
|
|
39
|
+
# canonical file-input alias set, so the two never drift.
|
|
40
|
+
def field?(input_options)
|
|
41
|
+
as = input_options&.dig(:options, :as) || input_options&.dig(:as)
|
|
42
|
+
Plutonium::UI::Form::Base::Builder::FILE_INPUT_TYPES.include?(as&.to_sym)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# SERVER-SIDE staging: turn a submitted attachment value into a token string
|
|
46
|
+
# to stage in `data`, minting one from an uploaded file when needed.
|
|
47
|
+
#
|
|
48
|
+
# Handles every shape a step POST can carry for an attachment field:
|
|
49
|
+
# - an already-minted token String (direct upload, or the hidden preview field
|
|
50
|
+
# on re-submit) → kept verbatim;
|
|
51
|
+
# - an uploaded file (IO-like) → uploaded to the backend's cache, returning its
|
|
52
|
+
# token (an AS signed_id, or Shrine cached-file JSON);
|
|
53
|
+
# - blank / no selection → nil (the caller drops the key so the previously
|
|
54
|
+
# staged token survives a Back/re-submit);
|
|
55
|
+
# - an Array (multiple) → each element mapped, blanks dropped.
|
|
56
|
+
#
|
|
57
|
+
# @param backend [Symbol, nil] per-field override; nil → the configured default.
|
|
58
|
+
# @param uploader [Class, String, nil] a Shrine uploader to cache through
|
|
59
|
+
# (`:shrine` backend only) — its cache-stage plugins (mime/dimension
|
|
60
|
+
# extraction, `generate_location`, validations) run instead of base Shrine's.
|
|
61
|
+
# The minted token stays uploader-agnostic, so display + `execute` promotion
|
|
62
|
+
# are unaffected. Ignored shape for ActiveStorage (raises if given).
|
|
63
|
+
def stage_upload(value, backend: nil, uploader: nil)
|
|
64
|
+
if value.is_a?(Array)
|
|
65
|
+
value.filter_map { |v| stage_upload(v, backend:, uploader:) }.presence
|
|
66
|
+
elsif value.is_a?(String)
|
|
67
|
+
value.presence
|
|
68
|
+
elsif value.respond_to?(:read)
|
|
69
|
+
upload_to_cache(value, backend || attachment_backend, uploader:)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The default server-side staging backend: the configured one, else
|
|
74
|
+
# auto-detected (active_shrine loaded → Shrine, else ActiveStorage).
|
|
75
|
+
def attachment_backend
|
|
76
|
+
Plutonium.configuration.wizards.attachment_backend ||
|
|
77
|
+
(defined?(ActiveShrine) ? :shrine : :active_storage)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Run the EFFECTIVE Shrine uploader's attacher validations against a staged
|
|
81
|
+
# token (or array of them), returning the validation messages — so a file that
|
|
82
|
+
# violates the uploader's `validate_*` rules is rejected at the STEP (stage
|
|
83
|
+
# phase), not deferred to `execute`'s model assignment.
|
|
84
|
+
#
|
|
85
|
+
# The effective uploader is the field's `uploader:` if given, else base
|
|
86
|
+
# `Shrine` — both of which may carry `Attacher.validate` rules. Returns `[]`
|
|
87
|
+
# when the field isn't Shrine-backed (ActiveStorage has no attacher here), when
|
|
88
|
+
# nothing is staged, or when the effective uploader declares no validations.
|
|
89
|
+
#
|
|
90
|
+
# @param value [String, Array, nil] the staged token(s).
|
|
91
|
+
# @param backend [Symbol, nil] per-field override; nil → the configured default.
|
|
92
|
+
# @param uploader [Class, String, nil] the field's `uploader:` option.
|
|
93
|
+
# @return [Array<String>] validation messages (empty ⇒ valid).
|
|
94
|
+
def validation_errors(value, backend: nil, uploader: nil)
|
|
95
|
+
return [] unless (backend || attachment_backend).to_sym == :shrine
|
|
96
|
+
|
|
97
|
+
klass = shrine_uploader(uploader)
|
|
98
|
+
# Shrine's `validation` plugin is OPTIONAL — without it (or `validation_helpers`)
|
|
99
|
+
# the Attacher has no `#errors` and nothing to enforce. Detect it up front so a
|
|
100
|
+
# plugin-less app is a clean no-op, not a per-step rescued NoMethodError.
|
|
101
|
+
return [] unless klass::Attacher.method_defined?(:errors)
|
|
102
|
+
|
|
103
|
+
Array(value).flat_map { |token| token_validation_errors(klass, token) }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Validate one cached token through an uploader's attacher. A broad rescue
|
|
107
|
+
# (like {resolve_token}) — a tampered/expired token shouldn't 500 the step; it
|
|
108
|
+
# surfaces at `execute` instead, where it's caught as a RecordInvalid.
|
|
109
|
+
def token_validation_errors(uploader_class, token)
|
|
110
|
+
return [] if token.blank?
|
|
111
|
+
|
|
112
|
+
attacher = uploader_class::Attacher.new
|
|
113
|
+
attacher.assign(token)
|
|
114
|
+
Array(attacher.errors)
|
|
115
|
+
rescue => e
|
|
116
|
+
Rails.logger.warn { "[Plutonium::Wizard] attachment validation skipped: #{e.class}: #{e.message}" }
|
|
117
|
+
[]
|
|
118
|
+
end
|
|
119
|
+
private_class_method :token_validation_errors
|
|
120
|
+
|
|
121
|
+
# Upload a file to the backend's CACHE and return its re-postable token. The
|
|
122
|
+
# file lives in cache until `execute` assigns the token to a real attachment
|
|
123
|
+
# (which promotes it); an abandoned upload is reaped by the backend's own
|
|
124
|
+
# unattached-cache cleanup.
|
|
125
|
+
def upload_to_cache(file, backend, uploader: nil)
|
|
126
|
+
case backend.to_sym
|
|
127
|
+
when :shrine
|
|
128
|
+
shrine_uploader(uploader).upload(file, :cache).to_json
|
|
129
|
+
when :active_storage
|
|
130
|
+
raise ArgumentError, "input `uploader:` is only supported for the :shrine backend" if uploader
|
|
131
|
+
ActiveStorage::Blob.create_and_upload!(
|
|
132
|
+
io: file, filename: file.original_filename, content_type: file.content_type
|
|
133
|
+
).signed_id
|
|
134
|
+
else
|
|
135
|
+
raise ArgumentError, "unknown wizard attachment backend: #{backend.inspect}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
private_class_method :upload_to_cache
|
|
139
|
+
|
|
140
|
+
# Resolve an `uploader:` option to the Shrine uploader class to cache through.
|
|
141
|
+
# nil → base `Shrine`; a class is used as-is; a String/Symbol is constantized.
|
|
142
|
+
# Anything that isn't a Shrine subclass is a configuration error (fail loud).
|
|
143
|
+
def shrine_uploader(uploader)
|
|
144
|
+
return Shrine if uploader.nil?
|
|
145
|
+
|
|
146
|
+
klass = uploader.is_a?(Class) ? uploader : uploader.to_s.safe_constantize
|
|
147
|
+
unless klass.is_a?(Class) && klass <= Shrine
|
|
148
|
+
raise ArgumentError, "input `uploader:` must be a Shrine uploader class, got #{uploader.inspect}"
|
|
149
|
+
end
|
|
150
|
+
klass
|
|
151
|
+
end
|
|
152
|
+
private_class_method :shrine_uploader
|
|
153
|
+
|
|
154
|
+
# Revive one token through whichever backend owns it, wrapped in {Resolved}. A
|
|
155
|
+
# broad rescue is warranted here (unlike elsewhere): the token is arbitrary,
|
|
156
|
+
# user-supplied input reconstituted at a render boundary, and the two backends
|
|
157
|
+
# raise different error classes for a tampered/expired token — none of which
|
|
158
|
+
# should take down the page.
|
|
159
|
+
def resolve_token(token)
|
|
160
|
+
return if token.blank?
|
|
161
|
+
|
|
162
|
+
source = shrine_uploaded_file(token) || active_storage_blob(token)
|
|
163
|
+
source && Resolved.new(source, token)
|
|
164
|
+
rescue => e
|
|
165
|
+
Rails.logger.warn { "[Plutonium::Wizard] could not resolve attachment token: #{e.class}: #{e.message}" }
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
168
|
+
private_class_method :resolve_token
|
|
169
|
+
|
|
170
|
+
# A Shrine cached-file token is JSON (`{"id":…,"storage":"cache",…}`); an AS
|
|
171
|
+
# signed_id isn't. Parse-success → Shrine materializes it from the globally
|
|
172
|
+
# registered storages (no model, no per-field uploader needed for a `.url`).
|
|
173
|
+
def shrine_uploaded_file(token)
|
|
174
|
+
return unless defined?(Shrine)
|
|
175
|
+
|
|
176
|
+
data = begin
|
|
177
|
+
JSON.parse(token)
|
|
178
|
+
rescue JSON::ParserError, TypeError
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
return unless data.is_a?(Hash)
|
|
182
|
+
|
|
183
|
+
Shrine.uploaded_file(data)
|
|
184
|
+
end
|
|
185
|
+
private_class_method :shrine_uploaded_file
|
|
186
|
+
|
|
187
|
+
def active_storage_blob(token)
|
|
188
|
+
ActiveStorage::Blob.find_signed(token) if defined?(ActiveStorage::Blob)
|
|
189
|
+
end
|
|
190
|
+
private_class_method :active_storage_blob
|
|
191
|
+
|
|
192
|
+
# A uniform view over a resolved attachment so the review display + the uppy
|
|
193
|
+
# preview don't care whether the source is an ActiveStorage `Blob`
|
|
194
|
+
# (`filename`/`content_type`/`representable?`) or a Shrine `UploadedFile`
|
|
195
|
+
# (`original_filename`/`mime_type`, none of the AS-only methods). Exposes
|
|
196
|
+
# exactly what those components call.
|
|
197
|
+
class Resolved
|
|
198
|
+
# @param source the backend object (AS Blob or Shrine UploadedFile).
|
|
199
|
+
# @param token [String] the ORIGINAL staged token — what the hidden preview
|
|
200
|
+
# field re-posts to preserve the upload across a Back/re-submit, and what
|
|
201
|
+
# `execute` assigns to the model attachment.
|
|
202
|
+
def initialize(source, token)
|
|
203
|
+
@source = source
|
|
204
|
+
@token = token
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# `url` is lazy (called at render, inside a request, where AS url options
|
|
208
|
+
# exist) — never eager at resolve time.
|
|
209
|
+
def url(*args) = @source.url(*args)
|
|
210
|
+
|
|
211
|
+
# The re-postable token, surfaced under the name the uppy input reads.
|
|
212
|
+
def signed_id = @token
|
|
213
|
+
|
|
214
|
+
def filename = (@source.try(:filename) || @source.try(:original_filename)).to_s
|
|
215
|
+
|
|
216
|
+
def content_type = @source.try(:content_type) || @source.try(:mime_type)
|
|
217
|
+
|
|
218
|
+
def representable? = @source.try(:representable?) || content_type.to_s.start_with?("image/")
|
|
219
|
+
|
|
220
|
+
def extension = @source.try(:extension).presence || File.extname(filename).delete(".").presence
|
|
221
|
+
|
|
222
|
+
def present? = true
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -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
|