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,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Resource
5
+ module Controllers
6
+ # Resource-mounted wizard launch surface (§5.1 / Fix A). Anchored (and
7
+ # non-anchored) wizards registered via the `wizard` definition macro are
8
+ # auto-mounted as member/collection routes on the resource's OWN controller —
9
+ # the same way interactive record/resource actions are (see
10
+ # {InteractiveActions}). This is what makes the anchor IDOR-safe:
11
+ #
12
+ # - **member** actions (`wizard_record_action` / `commit_wizard_record_action`)
13
+ # resolve the anchor through the resource controller's scoped, policy-gated
14
+ # `resource_record!` — never an unscoped `find_by(id:)`. A record outside the
15
+ # portal's authorized scope 404s, exactly like a record action.
16
+ # - **collection** actions (`wizard_resource_action` /
17
+ # `commit_wizard_resource_action`) have no anchor (create flows).
18
+ #
19
+ # The runner-driving flow itself lives in {Plutonium::Wizard::Driving} (shared
20
+ # with the standalone {Plutonium::Wizard::Controller}); this concern only
21
+ # supplies the surface hooks (wizard class from the definition's registry, the
22
+ # anchor from `resource_record!`, the per-step URL) and the action authorization
23
+ # (the resource action policy predicate, mirroring interactive actions).
24
+ module WizardActions
25
+ extend ActiveSupport::Concern
26
+ include Plutonium::Wizard::Driving
27
+
28
+ included do
29
+ before_action :validate_wizard_action!, only: %i[
30
+ launch_wizard_record_action launch_wizard_resource_action
31
+ wizard_record_action commit_wizard_record_action
32
+ wizard_resource_action commit_wizard_resource_action
33
+ ]
34
+
35
+ before_action :authorize_wizard_record_action!, only: %i[
36
+ launch_wizard_record_action
37
+ wizard_record_action commit_wizard_record_action
38
+ ]
39
+
40
+ before_action :authorize_wizard_resource_action!, only: %i[
41
+ launch_wizard_resource_action
42
+ wizard_resource_action commit_wizard_resource_action
43
+ ]
44
+ end
45
+
46
+ # GET /resources/:id/wizards/:wizard_name — resolve the run, redirect to step.
47
+ def launch_wizard_record_action
48
+ wizard_launch
49
+ end
50
+
51
+ # GET /resources/wizards/:wizard_name — resolve the run, redirect to step.
52
+ def launch_wizard_resource_action
53
+ skip_verify_current_authorized_scope!
54
+ wizard_launch
55
+ end
56
+
57
+ # GET /resources/:id/wizards/:wizard_name/(:token)/:step
58
+ def wizard_record_action
59
+ wizard_show
60
+ end
61
+
62
+ # POST /resources/:id/wizards/:wizard_name/(:token)/:step
63
+ def commit_wizard_record_action
64
+ wizard_update
65
+ end
66
+
67
+ # GET /resources/wizards/:wizard_name/(:token)/:step
68
+ def wizard_resource_action
69
+ skip_verify_current_authorized_scope!
70
+ wizard_show
71
+ end
72
+
73
+ # POST /resources/wizards/:wizard_name/(:token)/:step
74
+ def commit_wizard_resource_action
75
+ skip_verify_current_authorized_scope!
76
+ wizard_update
77
+ end
78
+
79
+ private
80
+
81
+ # --- surface hooks (override Driving) ---
82
+
83
+ # The wizard class for this request, resolved from the resource definition's
84
+ # registry by the `:wizard_name` route segment.
85
+ def current_wizard_class
86
+ @current_wizard_class ||= current_wizard_registration.fetch(:wizard_class)
87
+ end
88
+
89
+ # The anchor for member (record) actions is the scoped, policy-gated record
90
+ # — the IDOR-safe path. Member routes carry `:id`; collection (create) routes
91
+ # don't, and have no anchor.
92
+ def resolved_wizard_anchor
93
+ return nil if params[:id].blank?
94
+
95
+ resource_record!
96
+ end
97
+
98
+ # Build the GET URL for a given step, preserving the `:id` (member),
99
+ # `:wizard_name`, and `:token` segments. Built through `resource_url_for`
100
+ # with the `wizard:` kwarg (mirroring how interactions build their URLs via
101
+ # `resource_url_for(..., interaction:)`) — never string-surgery on
102
+ # `request.path`, so the URL is always a same-host, route-validated path.
103
+ def wizard_step_url(step_key)
104
+ resource_url_for(
105
+ wizard_url_subject,
106
+ wizard: current_wizard_name,
107
+ step: step_key,
108
+ **wizard_token_param
109
+ )
110
+ end
111
+
112
+ # The URL anchor: the scoped record for member (record) actions, the
113
+ # resource class for collection (resource) actions.
114
+ def wizard_url_subject
115
+ params[:id].present? ? resource_record! : resource_class
116
+ end
117
+
118
+ def current_wizard_name
119
+ params[:wizard_name]
120
+ end
121
+
122
+ # Carry the `:token` segment for an authenticated repeatable (tokened) run,
123
+ # so a fresh GET resumes rather than forks (§4.5). Guest/keyed runs add no
124
+ # URL token (see Driving#wizard_url_token).
125
+ def wizard_token_param
126
+ token = wizard_url_token
127
+ token.present? ? {token: token} : {}
128
+ end
129
+
130
+ # --- registry / authorization ---
131
+
132
+ def registered_wizards
133
+ @registered_wizards ||= current_definition.class.registered_wizards
134
+ end
135
+
136
+ def current_wizard_registration
137
+ registered_wizards.fetch(params[:wizard_name]&.to_sym)
138
+ end
139
+
140
+ def wizard_page_description
141
+ current_definition.defined_actions[params[:wizard_name]&.to_sym]&.description
142
+ end
143
+
144
+ def validate_wizard_action!
145
+ key = params[:wizard_name]&.to_sym
146
+ unless registered_wizards.key?(key)
147
+ raise ::AbstractController::ActionNotFound, "Unknown wizard '#{key}'"
148
+ end
149
+ end
150
+
151
+ # Mirror interactive record-action authorization: gate via the resource
152
+ # action policy predicate named after the wizard key (e.g. `configure?`).
153
+ def authorize_wizard_record_action!
154
+ authorize_current! resource_record!, to: :"#{params[:wizard_name]}?"
155
+ end
156
+
157
+ # Mirror interactive resource-action authorization: gate via the resource
158
+ # class action policy predicate (e.g. `onboard?`).
159
+ def authorize_wizard_resource_action!
160
+ authorize_current! resource_class, to: :"#{params[:wizard_name]}?"
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
@@ -10,6 +10,12 @@ module Plutonium
10
10
  authorize :entity_scope, allow_nil: true
11
11
  authorize :parent, optional: true
12
12
  authorize :parent_association, optional: true
13
+ # Supplied only during a kanban drag-move (kanban_move? authorization): the
14
+ # source and destination Plutonium::Kanban::Column. Optional — nil for every
15
+ # other authorization. Lets a policy gate a specific transition, e.g.
16
+ # def kanban_move? = kanban_to&.key == :closed_won ? user.admin? : super
17
+ authorize :kanban_from, optional: true
18
+ authorize :kanban_to, optional: true
13
19
 
14
20
  relation_scope do |relation|
15
21
  default_relation_scope(relation)
@@ -172,6 +178,14 @@ module Plutonium
172
178
  update?
173
179
  end
174
180
 
181
+ # Authorizes a kanban board move. Delegates to update? by default — override
182
+ # to allow board drags without granting full edit-form access.
183
+ #
184
+ # @return [Boolean] Delegates to update?.
185
+ def kanban_move?
186
+ update?
187
+ end
188
+
175
189
  # Checks if record search is permitted.
176
190
  #
177
191
  # @return [Boolean] Delegates to index?.
@@ -43,6 +43,8 @@ module Plutonium
43
43
  define_collection_interactive_actions
44
44
  define_collection_typeahead_actions
45
45
  define_collection_export_actions
46
+ define_member_wizard_actions
47
+ define_collection_wizard_actions
46
48
  end
47
49
  end
48
50
 
@@ -149,6 +151,8 @@ module Plutonium
149
151
  as: :interactive_record_action
150
152
  post "record_actions/:interactive_action", action: :commit_interactive_record_action,
151
153
  as: :commit_interactive_record_action
154
+ get "kanban_move_form", action: :kanban_move_form, as: :kanban_move_form
155
+ post "kanban_move", action: :kanban_move, as: :kanban_move
152
156
  end
153
157
  end
154
158
 
@@ -183,6 +187,47 @@ module Plutonium
183
187
  end
184
188
  end
185
189
 
190
+ # Defines member-level wizard launch actions (§5.1 / Fix A). Auto-mounted on
191
+ # every Plutonium resource alongside record_actions — the action 404s unless
192
+ # `:wizard_name` is a wizard registered (anchored → record) on the resource's
193
+ # definition, mirroring how `:interactive_action` gates record_actions. The
194
+ # anchor is the scoped, policy-gated `resource_record!` (IDOR-safe).
195
+ #
196
+ # @return [void]
197
+ def define_member_wizard_actions
198
+ return unless Plutonium.configuration.wizards.enabled
199
+
200
+ member do
201
+ # Bare launch (no :step): resolve/mint the run and redirect to its step.
202
+ get "wizards/:wizard_name", action: :launch_wizard_record_action,
203
+ as: :launch_wizard_record_action
204
+ get "wizards/:wizard_name(/:token)/:step", action: :wizard_record_action,
205
+ as: :wizard_record_action
206
+ post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_record_action,
207
+ as: :commit_wizard_record_action
208
+ end
209
+ end
210
+
211
+ # Defines collection-level wizard launch actions (§5.1 / Fix A) for
212
+ # non-anchored (create) wizards. Auto-mounted alongside resource_actions;
213
+ # the action 404s unless `:wizard_name` is a collection wizard registered on
214
+ # the resource's definition.
215
+ #
216
+ # @return [void]
217
+ def define_collection_wizard_actions
218
+ return unless Plutonium.configuration.wizards.enabled
219
+
220
+ collection do
221
+ # Bare launch (no :step): resolve/mint the run and redirect to its step.
222
+ get "wizards/:wizard_name", action: :launch_wizard_resource_action,
223
+ as: :launch_wizard_resource_action
224
+ get "wizards/:wizard_name(/:token)/:step", action: :wizard_resource_action,
225
+ as: :wizard_resource_action
226
+ post "wizards/:wizard_name(/:token)/:step", action: :commit_wizard_resource_action,
227
+ as: :commit_wizard_resource_action
228
+ end
229
+ end
230
+
186
231
  # Defines the collection-level CSV export action. Auto-mounted on
187
232
  # every Plutonium resource alongside typeahead and bulk actions.
188
233
  # The action itself is gated by the `export_csv?` policy (default
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Routing
5
+ # Adds `register_wizard` to the routing mapper, mirroring `register_resource`
6
+ # (see {MapperExtensions}). A wizard is **portal-hosted** (§5.2): the routes are
7
+ # drawn inside the portal engine's `routes.draw` block, so they inherit the
8
+ # portal's scope/auth/layout, and they dispatch to a portal-namespaced wizard
9
+ # controller that includes {Plutonium::Wizard::Controller} + the portal's own
10
+ # controller concern.
11
+ #
12
+ # @example inside a portal engine's routes
13
+ # AdminPortal::Engine.routes.draw do
14
+ # register_wizard OnboardingWizard, at: "onboarding"
15
+ # register_resource ::User
16
+ # end
17
+ #
18
+ # Draws (portal-relative):
19
+ # GET /onboarding(/:token)/:step → <Portal>::WizardsController#show
20
+ # POST /onboarding(/:token)/:step → <Portal>::WizardsController#update
21
+ #
22
+ # with `onboarding_wizard_path` / `_url` helpers.
23
+ module WizardRegistration
24
+ WIZARD_CONTROLLER_NAME = "wizards"
25
+
26
+ # Tracks public wizard mounts already appended to the main app route set, so
27
+ # re-draws (boot, reload, multiple portals) don't stack duplicate named
28
+ # routes. Keyed by the wizard CLASS NAME (not the helper name) so two distinct
29
+ # anonymous wizards never collapse into one entry — a helper-name collision
30
+ # between different wizards is a hard error (see {#register_public_wizard}),
31
+ # not a silent drop. Maps `wizard_class.name => helper_name`.
32
+ class << self
33
+ attr_accessor :appended_public_wizards
34
+ end
35
+
36
+ # @param wizard_class [Class] a Plutonium::Wizard::Base subclass
37
+ # @param at [String] the portal-relative base path for the wizard's steps
38
+ # @param as [String, Symbol, nil] override the route helper name prefix
39
+ # @param public [Boolean, nil] mount on a PUBLIC (unauthenticated) route
40
+ # outside the portal's auth constraint, for an `anonymous` (guest) wizard.
41
+ # Defaults to the wizard's own `anonymous?` flag. A non-`anonymous` wizard
42
+ # may not be mounted public; an `anonymous` wizard may not be mounted
43
+ # authenticated (its whole point is pre-login access). See §4.5.
44
+ # @param layout [Symbol, String, nil] the Rails layout this mount renders in —
45
+ # a layout NAME, exactly like the controller `layout` macro: `:basic` (the
46
+ # bare `BasicLayout`, e.g. an onboarding screen), `:resource` (the standard
47
+ # shell), or any app layout. Only meaningful for `register_wizard` mounts;
48
+ # resource-defined (`wizard` macro) wizards are always embedded. Defaults by
49
+ # context (portal → the resource shell, main-app → `:basic`); turbo-frame
50
+ # requests are always layout-less regardless.
51
+ def register_wizard(wizard_class, at:, as: nil, public: nil, layout: nil)
52
+ # The wizard subsystem is opt-in (`config.wizards.enabled`). When disabled,
53
+ # draw no routes — its tables/migrations are skipped too, so a mounted route
54
+ # couldn't work anyway. Warn rather than fail silently, so a
55
+ # registered-but-disabled wizard is discoverable instead of a mystery 404.
56
+ unless Plutonium.configuration.wizards.enabled
57
+ Rails.logger.warn { "[Plutonium::Wizard] not registering routes for #{wizard_class} — config.wizards.enabled is false" }
58
+ return
59
+ end
60
+
61
+ # A CONTEXT anchor (`anchored via: :method`) is portal-level: the anchor is
62
+ # resolved by calling a controller method, needs no URL `:id`, and is
63
+ # IDOR-safe (trusted context) — so it CAN mount here. Only a TYPE anchor
64
+ # (`with:`-only, resolved from the URL `:id`) is rejected, because it needs
65
+ # the resource controller's scoped, policy-gated `resource_record!`.
66
+ if wizard_class.anchored? && !wizard_class.anchored_via?
67
+ raise ArgumentError,
68
+ "register_wizard #{wizard_class.name} — `with:`-anchored wizards are not " \
69
+ "mounted portal-level. Register them on the anchored resource's definition " \
70
+ "with the `wizard` macro, which auto-mounts a record action whose anchor is " \
71
+ "resolved through the resource controller's scoped, policy-gated " \
72
+ "`resource_record!`. (A `via:`-anchored wizard mounts here fine.)"
73
+ end
74
+
75
+ # Default the mount kind to the wizard's `anonymous?` flag, and reject
76
+ # contradictions (§4.5): an `anonymous` wizard NEEDS a public route
77
+ # (pre-login); a non-`anonymous` wizard MUST stay behind portal auth.
78
+ is_public = public.nil? ? wizard_class.anonymous? : !!public
79
+ if is_public && !wizard_class.anonymous?
80
+ raise ArgumentError,
81
+ "register_wizard #{wizard_class.name}, public: true — only an `anonymous` " \
82
+ "wizard may be mounted public. Add the `anonymous` macro to the wizard, or " \
83
+ "drop `public:`."
84
+ end
85
+ if !is_public && wizard_class.anonymous?
86
+ raise ArgumentError,
87
+ "register_wizard #{wizard_class.name} — an `anonymous` wizard must be mounted " \
88
+ "public (it runs pre-login). Pass `public: true` (it is the default for " \
89
+ "`anonymous` wizards)."
90
+ end
91
+
92
+ return register_public_wizard(wizard_class, at:, as:, layout:) if is_public
93
+
94
+ ensure_wizard_controller!(wizard_class)
95
+
96
+ # The helper name defaults to the mount path (`at:`), so
97
+ # `register_wizard W, at: "onboarding"` yields `onboarding_wizard_path`.
98
+ # `as:` overrides it; the wizard's own route name is the final fallback.
99
+ helper_name = (as || at.presence || wizard_route_name(wizard_class)).to_s.tr("/", "_")
100
+ defaults = wizard_route_defaults(wizard_class, layout)
101
+
102
+ scope path: at do
103
+ # Canonical launch: GET the bare mount → resolve/mint the run and PRG to
104
+ # its first (or resumed) step, with the token already in the URL. This is
105
+ # the shareable entry point; `wizard_step_url` builds the stepped URLs.
106
+ get "/", to: "#{WIZARD_CONTROLLER_NAME}#launch",
107
+ as: :"#{helper_name}_wizard_launch", defaults: defaults
108
+ get "(/:token)/:step", to: "#{WIZARD_CONTROLLER_NAME}#show",
109
+ as: :"#{helper_name}_wizard", defaults: defaults
110
+ post "(/:token)/:step", to: "#{WIZARD_CONTROLLER_NAME}#update",
111
+ defaults: defaults
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ # Route defaults carried on every wizard route: the wizard class (resolves the
118
+ # wizard at request time) and, when explicitly set, the `layout`
119
+ # (a layout name). An unset layout is omitted — the driving layer then
120
+ # defaults it by context (portal → the resource shell, main-app → `:basic`). The
121
+ # layout rides the route (not a controller-class setting) because one
122
+ # synthesized controller serves many mounts, so the per-mount value can't live
123
+ # on the controller.
124
+ def wizard_route_defaults(wizard_class, layout)
125
+ defaults = {wizard_class: wizard_class.name}
126
+ defaults[:wizard_layout] = layout.to_s if layout
127
+ defaults
128
+ end
129
+
130
+ # Mount an `anonymous` wizard on a PUBLIC (unauthenticated) route (§4.5).
131
+ #
132
+ # Portal engines are mounted INSIDE the host's auth constraint
133
+ # (`constraints Rodauth::Rails.authenticate(:user) { mount ... }`), so a
134
+ # route drawn in the engine is unreachable pre-login. A guest wizard must
135
+ # therefore be drawn on the MAIN application's route set, OUTSIDE that
136
+ # constraint. We append to `Rails.application.routes` so the public route is
137
+ # added after (and independent of) the engine mount.
138
+ #
139
+ # The route dispatches to a synthesized top-level `WizardsController` that
140
+ # includes the full Plutonium controller stack + `Plutonium::Auth::Public`
141
+ # (so `current_user` is the guest sentinel) + {Plutonium::Wizard::Controller}.
142
+ def register_public_wizard(wizard_class, at:, as:, layout: nil)
143
+ ensure_public_wizard_controller!
144
+
145
+ helper_name = (as || at.presence || wizard_route_name(wizard_class)).to_s.tr("/", "_")
146
+ defaults = wizard_route_defaults(wizard_class, layout)
147
+ mount_path = at.to_s.sub(%r{\A/}, "")
148
+
149
+ # `Rails.application.routes.append` blocks are RETAINED and re-run on every
150
+ # route reload — so append a given wizard's block at most once. Key by the
151
+ # wizard CLASS (not the helper name) so re-drawing the SAME wizard is a no-op
152
+ # while two DIFFERENT wizards are never silently collapsed.
153
+ registered = (Plutonium::Routing::WizardRegistration.appended_public_wizards ||= {})
154
+
155
+ # Two distinct public wizards sharing a helper name (same `at:`/`as:`) would
156
+ # draw the same route name → Rails "route name already in use", or worse, a
157
+ # silent drop. Fail loudly with a fix instead.
158
+ clash = registered.find { |klass_name, helper| helper == helper_name && klass_name != wizard_class.name }
159
+ if clash
160
+ raise ArgumentError,
161
+ "register_wizard #{wizard_class.name}, at: #{at.inspect} — the route helper " \
162
+ "`#{helper_name}_wizard` is already used by #{clash.first}. Give one of them a " \
163
+ "distinct `at:` or `as:`."
164
+ end
165
+
166
+ return unless registered[wizard_class.name].nil?
167
+ registered[wizard_class.name] = helper_name
168
+
169
+ Rails.application.routes.append do
170
+ scope path: mount_path do
171
+ get "/", to: "public_wizards#launch",
172
+ as: :"#{helper_name}_wizard_launch", defaults: defaults
173
+ get "(/:token)/:step", to: "public_wizards#show",
174
+ as: :"#{helper_name}_wizard", defaults: defaults
175
+ post "(/:token)/:step", to: "public_wizards#update",
176
+ defaults: defaults
177
+ end
178
+ end
179
+ end
180
+
181
+ # Synthesize the top-level public `PublicWizardsController` once. Unlike the
182
+ # portal controller (built on the portal's authenticated `PlutoniumController`),
183
+ # the public one is built directly on the Plutonium controller stack with
184
+ # `Plutonium::Auth::Public`, so it has the rendering/scoping infra a wizard
185
+ # needs WITHOUT requiring a login.
186
+ #
187
+ # It is a DISTINCT const from the authenticated main-app `::WizardsController`
188
+ # (see {#ensure_wizard_controller!}): the two must not share a controller, or a
189
+ # public (guest) and an authenticated main-app wizard in the same app would
190
+ # collapse onto whichever was synthesized first — an authenticated main-app
191
+ # wizard would then run through `Auth::Public` and reject every logged-in user.
192
+ def ensure_public_wizard_controller!
193
+ return if Object.const_defined?(:PublicWizardsController, false)
194
+
195
+ # Build on a BARE base, decoupled from the app's `::PlutoniumController`
196
+ # (which portals inherit and may carry auth). `Plutonium::Wizard::Controller`
197
+ # brings the full rendering stack — including `Core::Controller`'s gem
198
+ # view-path, which resolves the shared partials (`plutonium/_flash`, etc.) —
199
+ # so no PlutoniumController inheritance is needed for that.
200
+ base = "ApplicationController".safe_constantize || ActionController::Base
201
+ klass = Class.new(base) do
202
+ # `Auth::Public` provides the guest `current_user`; an `anonymous` wizard
203
+ # ignores it for identity (session-token keyed) but the host still needs a
204
+ # `current_user` defined.
205
+ include Plutonium::Auth::Public
206
+ include Plutonium::Wizard::Controller
207
+ end
208
+ Object.const_set(:PublicWizardsController, klass)
209
+ end
210
+
211
+ def wizard_route_name(wizard_class)
212
+ wizard_class.name.demodulize.underscore.sub(/_wizard\z/, "")
213
+ end
214
+
215
+ # Resolve (creating if needed) the portal-namespaced wizard controller the
216
+ # routes dispatch to. The portal engine isolates its namespace, so a route
217
+ # `controller: "wizards"` resolves to `<PortalModule>::WizardsController`.
218
+ # There is no hand-written file for it (unlike resource controllers, which
219
+ # are scaffolded), so we synthesize it here — the same idea as
220
+ # {Plutonium::Portal::DynamicControllers}, but triggered explicitly at route
221
+ # draw time rather than via `const_missing`.
222
+ def ensure_wizard_controller!(wizard_class)
223
+ engine = wizard_route_engine
224
+ return if engine.nil?
225
+
226
+ portal_module = wizard_portal_module(engine)
227
+ if portal_module.nil?
228
+ # Main-app / non-namespaced mount. Synthesize a BARE top-level
229
+ # WizardsController (ApplicationController + the wizard module) — it is NOT
230
+ # rooted in the app's `::PlutoniumController` (portals inherit that, so auth
231
+ # there would leak). A bare synthesized controller has NO auth, so a
232
+ # public/`anonymous` wizard works as-is; an AUTHENTICATED main-app wizard
233
+ # requires the app to define its own `::WizardsController` (with its auth
234
+ # concern), which the const-check below picks up instead of synthesizing.
235
+ #
236
+ # This rhymes with `register_resource` ("the app owns the controller") but
237
+ # isn't identical: `register_resource` never synthesizes a fallback, so a
238
+ # missing controller is a loud routing/constant error; here the fallback is
239
+ # auth-less, so a missing override for an authenticated wizard fails QUIETER
240
+ # — every user is bounced to login rather than erroring. Covered by the
241
+ # skill docs + main_app_wizard_test so it's an explicit, tested edge.
242
+ define_wizard_controller(Object, "WizardsController", "ApplicationController", nil)
243
+ return
244
+ end
245
+
246
+ define_wizard_controller(
247
+ portal_module,
248
+ "WizardsController",
249
+ "#{portal_module.name}::PlutoniumController",
250
+ "#{portal_module.name}::Concerns::Controller"
251
+ )
252
+ end
253
+
254
+ # Synthesize a wizard controller unless one is already defined (the app's
255
+ # override wins — define `<Portal>::WizardsController` / `::WizardsController`
256
+ # to take over). `Plutonium::Wizard::Controller` brings the full rendering
257
+ # stack, so the parent only needs to supply auth/scope (a portal's
258
+ # PlutoniumController) or nothing (a bare main-app base).
259
+ def define_wizard_controller(namespace, const_name, parent_name, concern_name)
260
+ return if namespace.const_defined?(const_name, false)
261
+
262
+ parent = parent_name.safe_constantize || ActionController::Base
263
+ klass = Class.new(parent) do
264
+ include Plutonium::Wizard::Controller
265
+ end
266
+ namespace.const_set(const_name, klass)
267
+
268
+ if concern_name && (concern = concern_name.safe_constantize)
269
+ klass.include concern
270
+ end
271
+ klass
272
+ end
273
+
274
+ # The Plutonium engine owning this route set (mirrors RouteSetExtensions#engine).
275
+ def wizard_route_engine
276
+ rs = respond_to?(:route_set) ? route_set : @set
277
+ rs.respond_to?(:engine) ? rs.engine : nil
278
+ end
279
+
280
+ # The portal module (e.g. AdminPortal) for a `SomePortal::Engine`, or nil for
281
+ # the main application.
282
+ def wizard_portal_module(engine)
283
+ return nil if engine == Rails.application.class
284
+
285
+ engine.module_parent
286
+ end
287
+ end
288
+ end
289
+ end
@@ -6,16 +6,52 @@ module Plutonium
6
6
  module UI
7
7
  module Display
8
8
  module Components
9
- # Renders a numeric value as currency (delimited, 2 decimals). No symbol
10
- # by default; pass a literal `unit:` ("£") or a Symbol read off the
11
- # record (`unit: :currency_symbol`) for per-row currencies.
9
+ # Renders a numeric value as currency (delimited, 2 decimals). The symbol
10
+ # is resolved by {resolve_unit}: an explicit `unit:` (a literal "£", a
11
+ # Symbol read off the record for per-row currencies, or `false` for no
12
+ # symbol) → the record's `has_cents` unit → `default_currency_unit` /
13
+ # the i18n `number.currency.format.unit` ($ in en).
12
14
  #
13
- # display :price, as: :currency
15
+ # display :price, as: :currency # → configured/i18n default unit
14
16
  # display :price, as: :currency, unit: "£"
15
17
  # display :price, as: :currency, unit: :currency_symbol
18
+ # display :price, as: :currency, unit: false # no symbol
16
19
  class Currency < Phlexi::Display::Components::Base
17
20
  include Phlexi::Display::Components::Concerns::DisplaysValue
18
21
 
22
+ # Resolves the currency unit string for a value, shared by this
23
+ # component and the grid/kanban {Grid::Card} so both format currency
24
+ # identically. Precedence, where `nil` means "not set, keep looking"
25
+ # and `false` means "explicitly no symbol, stop":
26
+ # explicit unit → record's has_cents unit → configured/i18n default.
27
+ #
28
+ # @param explicit [String, Symbol, false, nil] a per-display `unit:`.
29
+ # @param record [Object] the record being rendered.
30
+ # @param key [Symbol] the attribute name.
31
+ # @return [String] the unit to pass to number_to_currency ("" for none).
32
+ def self.resolve_unit(explicit, record, key)
33
+ unit = explicit
34
+ unit = record.has_cents_unit_for(key) if unit.nil? && record.respond_to?(:has_cents_unit_for)
35
+ unit = default_unit if unit.nil?
36
+
37
+ case unit
38
+ when nil, false then ""
39
+ when Symbol then record.public_send(unit).to_s
40
+ else unit.to_s
41
+ end
42
+ end
43
+
44
+ # The unit used when nothing more specific is configured. Returns the
45
+ # `default_currency_unit` config verbatim when set (including `false`
46
+ # to disable the symbol); otherwise the i18n `number.currency.format.unit`
47
+ # *if the locale defines it*, else no symbol. We don't hardcode a "$".
48
+ def self.default_unit
49
+ config = Plutonium.configuration.default_currency_unit
50
+ return config unless config.nil?
51
+
52
+ I18n.t("number.currency.format.unit", default: "")
53
+ end
54
+
19
55
  def render_value(value)
20
56
  p(**attributes) { format_currency(value) }
21
57
  end
@@ -35,11 +71,7 @@ module Plutonium
35
71
  end
36
72
 
37
73
  def resolved_unit
38
- case @unit
39
- when nil then ""
40
- when Symbol then field.object.public_send(@unit)
41
- else @unit
42
- end
74
+ self.class.resolve_unit(@unit, field.object, field.key)
43
75
  end
44
76
 
45
77
  def normalize_value(value) = value
@@ -5,6 +5,8 @@ module Plutonium
5
5
  module Display
6
6
  module Options
7
7
  module InferredTypes
8
+ include Plutonium::UI::Options::HasCentsField
9
+
8
10
  private
9
11
 
10
12
  def infer_field_component
@@ -23,11 +25,6 @@ module Plutonium
23
25
  super
24
26
  end
25
27
  end
26
-
27
- def has_cents_field?
28
- klass = object.class
29
- klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(key)
30
- end
31
28
  end
32
29
  end
33
30
  end
@@ -74,18 +74,23 @@ module Plutonium
74
74
  def render_tablist_with_details
75
75
  tablist = BuildTabList()
76
76
 
77
- # Build an inner display component for the Details tab.
78
- # It must be a standalone Phlex component so that TabList can call
79
- # `render(details_display)` from within its own context. Phlex propagates
80
- # @_state through render calls, so the inner component writes to the same
81
- # buffer as the outer Resource display even though self changes.
82
- details_display = build_details_display
83
-
84
- tablist.with_tab(
85
- identifier: "details",
86
- title: -> { plain "Details" }
87
- ) do
88
- render details_display
77
+ # Only render the Details tab when the user is permitted to see at
78
+ # least one field. With no permitted fields the tab would be empty,
79
+ # so we drop it and let the first association tab lead instead.
80
+ if resource_fields.present?
81
+ # Build an inner display component for the Details tab.
82
+ # It must be a standalone Phlex component so that TabList can call
83
+ # `render(details_display)` from within its own context. Phlex propagates
84
+ # @_state through render calls, so the inner component writes to the same
85
+ # buffer as the outer Resource display even though self changes.
86
+ details_display = build_details_display
87
+
88
+ tablist.with_tab(
89
+ identifier: "details",
90
+ title: -> { plain "Details" }
91
+ ) do
92
+ render details_display
93
+ end
89
94
  end
90
95
 
91
96
  resource_associations.each do |name|