plutonium 0.62.2 → 0.63.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 (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  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/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -170,20 +170,48 @@ module Plutonium
170
170
  respond_to_wizard_result(runner, result)
171
171
  end
172
172
 
173
- # Advance the current step; if the POSTed step is the last visible step,
174
- # finalize. The last visible step is the terminal `review` (no fields), so
175
- # finalize runs directly; otherwise validate + stage + move the cursor.
176
- def advance_or_finalize(runner)
177
- return runner.finalize if wizard_posting_last_step?(runner)
173
+ # DELETE (/:token) abandon a run. Same entry gauntlet as every other wizard
174
+ # action (auth ownership policy) before anything is destroyed.
175
+ def wizard_cancel
176
+ require_wizard_authentication!
177
+ runner = build_wizard_runner
178
+ deny_wizard_resume_for_other_user!(runner)
179
+ authorize_wizard_entry!(runner)
178
180
 
179
- runner.advance(params[:step], wizard_params(runner), goto: params[:_goto].presence)
181
+ # No stored row at this key — nothing to abandon, so bail BEFORE `cancel`,
182
+ # which is destructive: `run_cleanup` fires every declared `on_rollback`,
183
+ # then `clear` deletes the row.
184
+ #
185
+ # A DELETE carrying a stale or forged token mints an EMPTY runner, and
186
+ # `rollback_step` only no-ops when the step ALSO declares no `on_rollback` —
187
+ # so a side-effect-only compensator (refund the charge, call the external
188
+ # API) runs for a run that never existed. The token is the only thing
189
+ # identifying a run and a caller can simply make one up, which puts this in
190
+ # reach of anyone allowed to LAUNCH the wizard.
191
+ #
192
+ # PRG to the launch path: the chooser over any runs the user still has, or
193
+ # a fresh run. A finished one-time run lands here too (it resumes no row)
194
+ # and gets its completed page.
195
+ unless runner.resumed?
196
+ return redirect_to wizard_launch_url, status: :see_other, allow_other_host: false
197
+ end
198
+
199
+ runner.cancel
200
+ clear_wizard_session_token
201
+ clear_wizard_return_to
202
+
203
+ # PRG to the bare launch path: with this run gone, that re-renders the
204
+ # chooser over the user's remaining runs, or mints a fresh one if it was
205
+ # their last.
206
+ redirect_to wizard_launch_url, status: :see_other, allow_other_host: false
180
207
  end
181
208
 
182
- # Whether the step being POSTed is the last visible step (so Next → Finish).
183
- # Computed BEFORE advancing, since advance moves the cursor past it.
184
- def wizard_posting_last_step?(runner)
185
- last = runner.visible_path.last
186
- last && last.key.to_s == params[:step].to_s
209
+ # Advance the POSTed step, finalizing when it turns out to end the flow. The
210
+ # advance-or-finalize decision lives in the runner because it can only be made
211
+ # AFTER the submission is staged — a step's `condition:` may be gated on an
212
+ # answer from the very step being submitted. See {Runner#submit}.
213
+ def advance_or_finalize(runner)
214
+ runner.submit(params[:step], wizard_params(runner), goto: params[:_goto].presence)
187
215
  end
188
216
 
189
217
  def respond_to_wizard_result(runner, result)
@@ -214,8 +242,26 @@ module Plutonium
214
242
  # `:return_to` (the page the user was bounced FROM into a one-time wizard)
215
243
  # still wins, so they resume where they were headed.
216
244
  clear_wizard_return_to
245
+ flash_wizard_messages(result.messages)
217
246
  target = session.delete(:return_to).presence || wizard_completion_url(result.value)
218
- redirect_to target, status: :see_other, allow_other_host: false
247
+
248
+ respond_to do |format|
249
+ format.turbo_stream do
250
+ render turbo_stream: helpers.turbo_stream_redirect(target)
251
+ end
252
+ format.html do
253
+ redirect_to target, status: :see_other, allow_other_host: false
254
+ end
255
+ end
256
+ end
257
+
258
+ # Carry the outcome's `with_message` entries ([message, type] pairs) into the
259
+ # flash so the page we land on shows them. Several messages of one type are
260
+ # joined — writing them in a loop would leave only the last one standing.
261
+ def flash_wizard_messages(messages)
262
+ messages.group_by(&:last).each do |type, group|
263
+ flash[type] = group.map(&:first).join(" ")
264
+ end
219
265
  end
220
266
 
221
267
  # Drop a guest run's token from the Rails session (on completion). A no-op
@@ -274,6 +320,12 @@ module Plutonium
274
320
  # the step form; otherwise re-render the whole page. Mirrors interactive
275
321
  # actions, where conditional inputs depend on sibling values.
276
322
  def render_wizard_pre_submit(runner)
323
+ # Seed the re-render from the JUST-SUBMITTED values, so an input conditional
324
+ # on the changed field appears/disappears and the user's other typed values
325
+ # survive the swap. In-memory only — `stage_inputs` never persists, so an
326
+ # abandoned refresh leaves nothing durable.
327
+ runner.stage_inputs(runner.current_step.key, wizard_pre_submit_inputs(runner))
328
+
277
329
  form = wizard_step_form(runner)
278
330
  respond_to do |format|
279
331
  format.turbo_stream do
@@ -488,12 +540,38 @@ module Plutonium
488
540
  def wizard_params(runner)
489
541
  return {} if params[:wizard].blank?
490
542
 
543
+ cleaned = wizard_extracted_inputs(runner)
544
+ stage_wizard_uploads!(runner.current_step, cleaned)
545
+ cleaned.stringify_keys
546
+ end
547
+
548
+ # Like {#wizard_params} but WITHOUT staging uploads — shared with the
549
+ # `pre_submit` re-render, which must not stage them, or every `change` event
550
+ # would push the selected file to the backend cache.
551
+ def wizard_extracted_inputs(runner)
552
+ return {} if params[:wizard].blank?
553
+
491
554
  step = runner.current_step
492
555
  form = wizard_step_form(runner)
493
556
  extracted = form.extract_input(params, view_context:)[:wizard] || {}
494
- cleaned = clean_structured_inputs(Plutonium::Wizard::StepAdapter.new(step), extracted.dup)
495
- stage_wizard_uploads!(step, cleaned)
496
- cleaned.stringify_keys
557
+ clean_structured_inputs(Plutonium::Wizard::StepAdapter.new(step), extracted.dup)
558
+ end
559
+
560
+ # The submitted values a `pre_submit` re-render may seed itself from.
561
+ #
562
+ # Attachment fields are EXCLUDED. Their extracted value is a raw upload — or,
563
+ # far more often, nothing at all, since a file input doesn't re-post on an
564
+ # unrelated field's `change`. Merging either over the token already staged in
565
+ # `data` would blank the file out of the re-rendered form, losing an upload the
566
+ # user had already made. Uploads only ever stage on a real submit
567
+ # ({#stage_wizard_uploads!}); a pre_submit leaves them exactly as they were.
568
+ def wizard_pre_submit_inputs(runner)
569
+ step = runner.current_step
570
+ inputs = wizard_extracted_inputs(runner).stringify_keys
571
+ step.inputs.each do |name, config|
572
+ inputs.delete(name.to_s) if Plutonium::Wizard::Attachments.field?(config)
573
+ end
574
+ inputs
497
575
  end
498
576
 
499
577
  # Replace each attachment field's value with a staged TOKEN, minting one from
@@ -528,6 +606,7 @@ module Plutonium
528
606
  step = runner.current_step
529
607
  Plutonium::UI::Form::Wizard.new(
530
608
  step:,
609
+ wizard: runner.wizard,
531
610
  data: Plutonium::Wizard::AttachmentData.wrap(runner.wizard.data[step.key], step),
532
611
  action: wizard_step_url(step&.key),
533
612
  fields: step.attribute_schema.keys.map(&:to_sym) + step.structured_inputs.keys.map(&:to_sym)
@@ -634,6 +713,11 @@ module Plutonium
634
713
  def wizard_step_url(step_key)
635
714
  raise NotImplementedError
636
715
  end
716
+
717
+ # @return [String] the GET URL for launching this wizard.
718
+ def wizard_launch_url
719
+ raise NotImplementedError
720
+ end
637
721
  end
638
722
  end
639
723
  end
@@ -152,6 +152,29 @@ module Plutonium
152
152
  @cleanup_after = (ttl == :never) ? nil : ttl
153
153
  end
154
154
 
155
+ # --- presentation ---
156
+
157
+ # Width of THIS wizard's step pages, one of
158
+ # {Plutonium::UI::PageWidth::VALID_SIZES}. Unset inherits
159
+ # `config.wizards.width`, and stops there — the wizard chain does NOT
160
+ # reach `config.default_page_width`. A wizard is a focused,
161
+ # self-contained flow rather than a resource detail page, so an app
162
+ # that widens its resource pages should not silently drag its wizards
163
+ # along with them.
164
+ #
165
+ # Mirrors what a resource definition gets from `page_width`: a wizard
166
+ # with a wide review table or a two-column step can widen itself
167
+ # without the app having to move every wizard.
168
+ #
169
+ # class CheckoutWizard < Plutonium::Wizard::Base
170
+ # width :lg
171
+ # end
172
+ def width(value = UNSET)
173
+ return @width || Plutonium.configuration.wizards.width if value.equal?(UNSET)
174
+
175
+ @width = Plutonium::UI::PageWidth.validate!(value)
176
+ end
177
+
155
178
  # --- concurrency (§4.2) ---
156
179
 
157
180
  # Declare the run's CONCURRENCY KEY — the value(s) a run is keyed by
@@ -36,6 +36,9 @@ module Plutonium
36
36
  :current_step_label,
37
37
  :updated_at,
38
38
  :resume_url,
39
+ # The DELETE target that abandons this run, resolved from the same mount as
40
+ # `resume_url`. nil when the mount exposes no cancel route.
41
+ :cancel_url,
39
42
  :resume_unresolved_reason,
40
43
  :session
41
44
  )
@@ -100,6 +103,7 @@ module Plutonium
100
103
  current_step_label: step&.label,
101
104
  updated_at: row.updated_at,
102
105
  resume_url: resolved[:url],
106
+ cancel_url: resolved[:cancel_url],
103
107
  resume_unresolved_reason: resolved[:reason],
104
108
  session: row
105
109
  )
@@ -111,71 +115,114 @@ module Plutonium
111
115
  wizard_class.steps.find { |s| s.key.to_s == key.to_s }
112
116
  end
113
117
 
114
- # Resolves a single row to its resume URL in the current portal.
118
+ # Resolves a single row to its resume + cancel URLs in the current portal.
119
+ #
120
+ # Both URLs come from the SAME resolved mount, through named route helpers —
121
+ # the cancel target is never derived by string-surgery on the resume URL,
122
+ # which would drop query params and break for a run whose `current_step` is
123
+ # still nil (there the resume URL is the bare launch path, so lopping off its
124
+ # last segment yields a non-route).
115
125
  class ResumeUrl
126
+ # How this row's wizard is reachable in the current portal.
127
+ #
128
+ # - +:register_wizard+ — a standalone `register_wizard` mount. `subject` is
129
+ # the GET route's name, resolved within `route_set`.
130
+ # - +:member+ — a resource-mounted ANCHORED wizard. `subject` is the anchor
131
+ # record.
132
+ # - +:collection+ — a resource-mounted non-anchored wizard. `subject` is the
133
+ # resource class whose definition registers it.
134
+ Mount = Struct.new(:kind, :subject, :wizard_name, :route_set)
135
+
116
136
  def initialize(row, wizard_class, view_context)
117
137
  @row = row
118
138
  @wizard_class = wizard_class
119
139
  @view_context = view_context
120
140
  end
121
141
 
122
- # @return [Hash] {url:, reason:} — exactly one of the two is non-nil.
142
+ # @return [Hash] {url:, cancel_url:, reason:} — `reason` is non-nil exactly
143
+ # when no resume URL could be built. The two URLs resolve INDEPENDENTLY:
144
+ # a run with no `current_step` has no stepped resume URL, but is still
145
+ # perfectly cancellable, and leaving the user no way to clear it would
146
+ # strand the row in their chooser forever.
123
147
  def resolve
124
- if (named = register_wizard_url)
125
- return {url: named, reason: nil}
126
- end
127
-
128
- if (member = resource_member_url)
129
- return {url: member, reason: nil}
130
- end
148
+ return {url: nil, cancel_url: nil, reason: unresolved_reason} if mount.nil?
131
149
 
132
- {url: nil, reason: unresolved_reason}
150
+ url = build_step_url(mount)
151
+ {url: url, cancel_url: build_cancel_url(mount), reason: url.nil? ? unresolved_reason : nil}
133
152
  end
134
153
 
135
154
  private
136
155
 
137
- # A `register_wizard` route is named and carries `defaults[:wizard_class]`.
138
- def register_wizard_url
139
- route_sets.each do |route_set|
140
- name = Plutonium::Wizard::RouteResolution.route_name(route_set, @wizard_class, action: "show")
141
- next unless name
156
+ # The first mount that claims this row, in precedence order: a standalone
157
+ # `register_wizard` route, then the row's anchor, then a resource definition
158
+ # that registers this wizard as a collection (non-anchored) mount.
159
+ def mount
160
+ return @mount if defined?(@mount)
142
161
 
143
- return build_url(route_set, name, register_wizard_params)
144
- end
145
- nil
162
+ @mount = register_wizard_mount || resource_member_mount || resource_collection_mount
146
163
  end
147
164
 
148
- # Params for a `register_wizard` named helper: the current step, the tenant
149
- # scope path segment (when the run is scoped), and the URL token for a
150
- # tokened (no concurrency_key) run.
151
- def register_wizard_params
152
- {step: @row.current_step}.merge(scope_param).merge(token_param)
165
+ # A `register_wizard` route is named and carries `defaults[:wizard_class]`.
166
+ def register_wizard_mount
167
+ return @register_wizard_mount if defined?(@register_wizard_mount)
168
+
169
+ @register_wizard_mount = route_sets.filter_map { |route_set|
170
+ name = Plutonium::Wizard::RouteResolution.route_name(route_set, @wizard_class, action: "show")
171
+ Mount.new(:register_wizard, name, nil, route_set) if name
172
+ }.first
153
173
  end
154
174
 
155
- # A resource-mounted ANCHORED wizard's member URL is built by the SAME
156
- # `resource_url_for(record, wizard:, step:)` machinery the launch button uses
157
- # (§5.1) — so it's portal- and scope-correct by construction (it resolves on
158
- # the current portal's `current_engine`, threads the entity segment when the
159
- # portal is path-scoped, and singularizes the member helper). We pass the
160
- # row's anchor as the record, the registering definition's wizard name, and
161
- # the resumed step; a tokened (non-keyed) run also carries its run token.
162
- def resource_member_url
175
+ # A resource-mounted ANCHORED wizard resolves against the row's anchor and
176
+ # the registering definition's wizard name.
177
+ def resource_member_mount
163
178
  anchor = @row.anchor
164
179
  return nil if anchor.nil?
165
180
 
166
- wizard_name = registered_wizard_name
181
+ wizard_name = registered_wizard_name_for(anchor.class)
167
182
  return nil if wizard_name.nil?
168
183
 
169
- @view_context.resource_url_for(anchor, wizard: wizard_name, step: @row.current_step, **token_param)
184
+ Mount.new(:member, anchor, wizard_name, nil)
185
+ end
186
+
187
+ # A resource-mounted non-anchored (collection) wizard carries no resource
188
+ # identity on the row, so find it the other way round: scan this portal's
189
+ # registered resources for the definition that registers this wizard class.
190
+ #
191
+ # Rescued as a whole — `current_engine` is nil (or a plain Rails app with no
192
+ # resource register) outside a portal, and `resolve`'s contract is to report
193
+ # a reason rather than raise.
194
+ def resource_collection_mount
195
+ return nil unless @row.anchor.nil?
196
+
197
+ engine = @view_context.current_engine
198
+ return nil if engine.nil?
199
+
200
+ candidates = engine.resource_register.resources.filter_map { |resource_class|
201
+ name = registered_wizard_name_for(resource_class)
202
+ [resource_class, name] if name
203
+ }.sort_by { |resource_class, _name| resource_class.name }
204
+ return nil if candidates.empty?
205
+
206
+ if candidates.size > 1
207
+ Rails.logger.warn do
208
+ "[Plutonium::Wizard] #{@wizard_class.name} is registered on more than one resource " \
209
+ "definition in #{engine.name} (#{candidates.map { |klass, _| klass.name }.join(", ")}); " \
210
+ "resolving its resume URL against #{candidates.first.first.name}"
211
+ end
212
+ end
213
+
214
+ resource_class, wizard_name = candidates.first
215
+ Mount.new(:collection, resource_class, wizard_name, nil)
170
216
  rescue => e
171
- Rails.logger.warn { "[Plutonium::Wizard] resume url build failed for #{@wizard_class.name}: #{e.message}" }
217
+ Rails.logger.warn { "[Plutonium::Wizard] collection mount lookup failed for #{@wizard_class.name}: #{e.message}" }
172
218
  nil
173
219
  end
174
220
 
175
221
  # Reverse-lookup the `wizard`-macro name registered for this wizard class on
176
- # the anchor's resource definition. nil when not found.
177
- def registered_wizard_name
178
- definition = definition_for(@row.anchor)
222
+ # a resource's definition. nil when the definition can't be loaded or doesn't
223
+ # register this wizard.
224
+ def registered_wizard_name_for(resource_class)
225
+ definition = "#{resource_class.name}Definition".safe_constantize
179
226
  return nil unless definition.respond_to?(:registered_wizards)
180
227
 
181
228
  definition.registered_wizards.find do |_name, reg|
@@ -183,8 +230,45 @@ module Plutonium
183
230
  end&.first
184
231
  end
185
232
 
186
- def definition_for(record)
187
- "#{record.class.name}Definition".safe_constantize
233
+ # The GET URL that resumes the run at its current step.
234
+ #
235
+ # For a resource mount this is the SAME `resource_url_for(subject, wizard:,
236
+ # step:)` machinery the launch button uses (§5.1) — portal- and scope-correct
237
+ # by construction (it resolves on the current portal's `current_engine`,
238
+ # threads the entity segment when the portal is path-scoped, and singularizes
239
+ # the member helper).
240
+ def build_step_url(mount)
241
+ if mount.kind == :register_wizard
242
+ build_url(mount.route_set, mount.subject, register_wizard_params)
243
+ else
244
+ resource_wizard_url(mount, step: @row.current_step)
245
+ end
246
+ end
247
+
248
+ # The DELETE target that abandons the run, from the same mount's named cancel
249
+ # route. nil when the mount predates the cancel route (or generation fails) —
250
+ # the caller then renders no cancel affordance.
251
+ def build_cancel_url(mount)
252
+ if mount.kind == :register_wizard
253
+ name = Plutonium::Wizard::RouteResolution.route_name(mount.route_set, @wizard_class, action: "cancel")
254
+ name && build_url(mount.route_set, name, scope_param.merge(token_param))
255
+ else
256
+ resource_wizard_url(mount, wizard_action: :cancel)
257
+ end
258
+ end
259
+
260
+ def resource_wizard_url(mount, **extra)
261
+ @view_context.resource_url_for(mount.subject, wizard: mount.wizard_name, **token_param, **extra)
262
+ rescue => e
263
+ Rails.logger.warn { "[Plutonium::Wizard] url build failed for #{@wizard_class.name}: #{e.message}" }
264
+ nil
265
+ end
266
+
267
+ # Params for a `register_wizard` named helper: the current step, the tenant
268
+ # scope path segment (when the run is scoped), and the URL token for a
269
+ # tokened (no concurrency_key) run.
270
+ def register_wizard_params
271
+ {step: @row.current_step}.merge(scope_param).merge(token_param)
188
272
  end
189
273
 
190
274
  # The scope path segment for an entity-scoped portal, keyed by the portal
@@ -220,24 +304,18 @@ module Plutonium
220
304
  end
221
305
 
222
306
  def unresolved_reason
223
- if @row.anchor && registered_wizard_name.nil?
307
+ if @row.anchor && registered_wizard_name_for(@row.anchor.class).nil?
224
308
  "no `wizard` macro registration found for #{@wizard_class.name} " \
225
309
  "on #{@row.anchor.class.name}Definition"
226
- elsif @row.anchor.nil? && resource_mounted_candidate?
227
- "non-anchored resource-mounted wizard — the row carries no resource " \
228
- "identity to rebuild its collection URL"
310
+ elsif @row.anchor.nil? && register_wizard_mount.nil?
311
+ "non-anchored wizard — no `register_wizard` route in this portal, and no " \
312
+ "resource definition here registers #{@wizard_class.name}"
229
313
  else
230
314
  "no route found for #{@wizard_class.name} (not registered via " \
231
315
  "register_wizard or a `wizard` macro mount this resolver can reach)"
232
316
  end
233
317
  end
234
318
 
235
- # Heuristic for the reason text only: the wizard isn't a register_wizard
236
- # mount (no named route) and has no anchor on the row.
237
- def resource_mounted_candidate?
238
- register_wizard_url.nil?
239
- end
240
-
241
319
  # The CURRENT portal's route set, plus the main app's (for `public:` mounts).
242
320
  # Scoped to this portal so a `register_wizard` wizard mounted in several
243
321
  # portals resolves here, not in whichever engine happens to be scanned first.
@@ -18,7 +18,7 @@ module Plutonium
18
18
  # - +completed+ — finalize ran `execute` to completion.
19
19
  # - +redirect_step+ — finalize found a completeness gap; the step to bounce to.
20
20
  # - +value+ — the successful `execute` outcome's value.
21
- Result = Struct.new(:ok, :errors, :completed, :redirect_step, :value) do
21
+ Result = Struct.new(:ok, :errors, :completed, :redirect_step, :value, :messages) do
22
22
  def ok? = !!ok
23
23
 
24
24
  def completed? = !!completed
@@ -147,6 +147,43 @@ module Plutonium
147
147
  end
148
148
  end
149
149
 
150
+ # Submit the posted step: advance through it, then finalize if it turns out
151
+ # to be the end of the flow (§6.3). This is the driving layer's single entry
152
+ # point for a forward POST.
153
+ #
154
+ # "Is this the last step?" CANNOT be answered before the submission is
155
+ # staged. Every `condition:` is evaluated against `data`, so a step gated on
156
+ # an answer from the step being submitted is still hidden at POST time.
157
+ # Deciding first broke two shapes:
158
+ #
159
+ # - a wizard whose later steps are all revealed BY its first step saw a
160
+ # visible path of just `[first]`, called it terminal, and finalized — which
161
+ # found `first` unsubmitted and bounced back to it, forever. The revealed
162
+ # steps were unreachable and no state was ever written.
163
+ # - a wizard with no `review` step finalized its last step WITHOUT staging
164
+ # it, silently dropping that step's params before `execute`.
165
+ #
166
+ # So the order is stage, then look: after `advance` the cursor holds the next
167
+ # visible step, recomputed against the freshly staged data, and a nil cursor
168
+ # means nothing follows — finish. This also matches what the user sees: the
169
+ # nav strip renders Finish only on the review step (§7), never on a field
170
+ # step that merely happens to be last right now.
171
+ #
172
+ # The terminal `review` step short-circuits: it declares no fields, so there
173
+ # is nothing to stage and Finish runs `execute` directly.
174
+ def submit(step_key, params, goto: nil)
175
+ step = step_for(step_key)
176
+ return finalize if step&.review?
177
+
178
+ result = advance(step_key, params, goto:)
179
+ return result unless result.ok?
180
+ # A cursor target survives → the flow continues (including the `goto:`
181
+ # "Save & review" shortcut). Nil → `advance` found no next visible step.
182
+ return result if @state.current_step.present?
183
+
184
+ finalize
185
+ end
186
+
150
187
  # Validate + stage a step, run its `on_submit` (in a transaction), then move
151
188
  # the cursor to the next visible step. On validation/`on_submit` failure the
152
189
  # cursor does not move and the errors are returned.
@@ -243,6 +280,13 @@ module Plutonium
243
280
  true
244
281
  end
245
282
 
283
+ # Stage a step's params for a render-only refresh (the pre_submit re-render).
284
+ # Unlike `advance` it never persists, so the step isn't marked submitted and
285
+ # an abandoned refresh leaves nothing durable.
286
+ def stage_inputs(step_key, params)
287
+ stage(step_key, params)
288
+ end
289
+
246
290
  # Move the cursor to the previous visible step. No validation; never discards
247
291
  # staged data (§6 — back is navigation, not submission).
248
292
  def back
@@ -301,7 +345,7 @@ module Plutonium
301
345
  else
302
346
  @store.clear(@instance_key)
303
347
  end
304
- Result.new(ok: true, completed: true, value: outcome.value)
348
+ Result.new(ok: true, completed: true, value: outcome.value, messages: outcome.messages)
305
349
  else
306
350
  revert_completing!
307
351
  Result.new(ok: false, errors: wizard_errors)
@@ -26,7 +26,7 @@ module Plutonium
26
26
  # `uploader:` as an attribute, and `backend:` would otherwise leak as a stray
27
27
  # attribute. Driving reads them off `step.inputs` directly, so removing them
28
28
  # here is invisible to staging.
29
- STAGING_ONLY_INPUT_OPTIONS = %i[backend uploader].freeze
29
+ STAGING_ONLY_INPUT_OPTIONS = Plutonium::Attachments::STAGING_ONLY_INPUT_OPTIONS
30
30
 
31
31
  def initialize(step)
32
32
  @step = step
@@ -19,6 +19,22 @@ module Plutonium
19
19
  # and an unconstantizable wizard class is skipped while the row is still reaped.
20
20
  # +completed+ rows are never touched (the +sweepable+ scope excludes them).
21
21
  class SweepJob < ActiveJob::Base
22
+ # One sweep at a time, when the host's queue offers a semaphore.
23
+ #
24
+ # A sweep that outlives its own interval would otherwise overlap the next
25
+ # tick and walk the same rows. That is not merely wasteful here: two
26
+ # sweepers can pick up the same session and both build a Runner for it, so
27
+ # a step's on_rollback compensator — refund the charge, call the external
28
+ # API — runs twice for one abandoned run.
29
+ #
30
+ # A constant key: every sweep is the same sweep, so they queue behind each
31
+ # other globally rather than per row. Only +key+ and +to+ are passed;
32
+ # +on_conflict+ arrived in Solid Queue 1.2 and passing it would turn an
33
+ # older host's boot into an ArgumentError.
34
+ if respond_to?(:limits_concurrency)
35
+ limits_concurrency to: 1, key: ->(*) { "sweep" }
36
+ end
37
+
22
38
  def perform(now: Time.current)
23
39
  store = Store::ActiveRecord.new
24
40
 
data/lib/plutonium.rb CHANGED
@@ -26,6 +26,15 @@ module Plutonium
26
26
  # Custom error class for Plutonium-specific exceptions
27
27
  class Error < StandardError; end
28
28
 
29
+ # Raised when a resource registered `singular: true` has an authorized scope
30
+ # that resolves to more than one record.
31
+ #
32
+ # A singular route carries no :id — the scope IS the identification — so the
33
+ # promise `singular: true` makes is "exactly one record here". A scope that
34
+ # returns several silently nests (or shows) an arbitrary row, so it fails
35
+ # instead. Always a registration/policy mismatch, never user input.
36
+ class SingularScopeError < Error; end
37
+
29
38
  # Turbo frame id used by the modal/slideover renderer. The layout wraps
30
39
  # itself in this frame, in_modal? checks against it, and Action and
31
40
  # Definition default to targeting it. Kept as a single constant so the
@@ -40,6 +49,18 @@ module Plutonium
40
49
 
41
50
  MODAL_FRAMES = [REMOTE_MODAL_FRAME, REMOTE_MODAL_SECONDARY_FRAME].freeze
42
51
 
52
+ # DOM id of the page's toast region (Layout::Base#render_flash). An action
53
+ # that answers a turbo_stream never renders the layout, so it never gets to
54
+ # consume `flash` — it appends a rendered toast to this region instead.
55
+ FLASH_REGION = "pu-flash"
56
+
57
+ # Query param a kanban card appends to its show URL when the board opens the
58
+ # card in the shared remote-modal frame. Since a kanban modal and a regular
59
+ # show modal both target REMOTE_MODAL_FRAME, `in_modal?` can't tell them apart
60
+ # — this flag lets `in_kanban_modal?` distinguish the two so the show page can
61
+ # render a compact detail (no metadata rail) for kanban cards only.
62
+ KANBAN_MODAL_PARAM = "kanban_modal"
63
+
43
64
  # Set up Zeitwerk loader for the Plutonium gem
44
65
  # @return [Zeitwerk::Loader] configured Zeitwerk loader instance
45
66
  Loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false).tap do |loader|