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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. 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