plutonium 0.64.0 → 0.66.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 (344) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -41
  3. data/.claude/skills/plutonium-app/SKILL.md +115 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +168 -0
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +236 -145
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +171 -95
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +80 -0
  15. data/CLAUDE.md +282 -210
  16. data/README.md +9 -9
  17. data/SECURITY.md +1 -1
  18. data/app/assets/plutonium-charts.js +20604 -0
  19. data/app/assets/plutonium-charts.js.map +7 -0
  20. data/app/assets/plutonium-charts.min.js +41 -0
  21. data/app/assets/plutonium-charts.min.js.map +7 -0
  22. data/app/assets/plutonium.css +1 -1
  23. data/app/assets/plutonium.js +794 -629
  24. data/app/assets/plutonium.js.map +4 -4
  25. data/app/assets/plutonium.min.js +48 -48
  26. data/app/assets/plutonium.min.js.map +4 -4
  27. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  28. data/app/views/plutonium/_resource_header.html.erb +2 -2
  29. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  30. data/app/views/plutonium/_toast.html.erb +2 -2
  31. data/app/views/rodauth/_login_form.html.erb +1 -1
  32. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  33. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  34. data/app/views/rodauth/change_login.html.erb +2 -2
  35. data/app/views/rodauth/create_account.html.erb +4 -4
  36. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  37. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  38. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  39. data/config/brakeman.ignore +4 -4
  40. data/config/initializers/rabl.rb +0 -40
  41. data/config/locales/en/api_client.yml +22 -0
  42. data/config/locales/en/async.yml +34 -0
  43. data/config/locales/en/dashboard.yml +25 -0
  44. data/config/locales/en/invites.yml +37 -0
  45. data/config/locales/en/js.yml +40 -0
  46. data/config/locales/en/kanban.yml +28 -0
  47. data/config/locales/en/plutonium.yml +26 -0
  48. data/config/locales/en/positioning.yml +14 -0
  49. data/config/locales/en/profile.yml +29 -0
  50. data/config/locales/en/resource.yml +60 -0
  51. data/config/locales/en/ui.yml +93 -0
  52. data/config/locales/en/wizard.yml +46 -0
  53. data/docs/.vitepress/config.ts +34 -1
  54. data/docs/.vitepress/sync-skills.mjs +6 -3
  55. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  56. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  57. data/docs/.vitepress/theme/custom.css +34 -377
  58. data/docs/blog/introducing-plutonium-dashboards.md +174 -0
  59. data/docs/blog/introducing-plutonium-i18n.md +90 -0
  60. data/docs/blog/introducing-plutonium.md +4 -5
  61. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  62. data/docs/getting-started/installation.md +5 -5
  63. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  64. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  65. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  66. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  67. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  68. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  69. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  70. data/docs/getting-started/tutorial/index.md +1 -1
  71. data/docs/guides/adding-resources.md +10 -7
  72. data/docs/guides/authentication.md +25 -25
  73. data/docs/guides/authorization.md +24 -24
  74. data/docs/guides/creating-packages.md +17 -17
  75. data/docs/guides/custom-actions.md +32 -32
  76. data/docs/guides/customizing-ui.md +29 -26
  77. data/docs/guides/dashboards.md +300 -0
  78. data/docs/guides/index.md +4 -3
  79. data/docs/guides/kanban.md +56 -56
  80. data/docs/guides/multi-tenancy.md +35 -22
  81. data/docs/guides/nested-resources.md +21 -21
  82. data/docs/guides/performance.md +3 -3
  83. data/docs/guides/search-filtering.md +13 -13
  84. data/docs/guides/testing.md +16 -12
  85. data/docs/guides/theming.md +32 -17
  86. data/docs/guides/troubleshooting.md +2 -2
  87. data/docs/guides/user-invites.md +17 -17
  88. data/docs/guides/user-profile.md +51 -24
  89. data/docs/guides/wizards.md +55 -55
  90. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  91. data/docs/public/images/blog/dashboards-loading.png +0 -0
  92. data/docs/public/images/blog/dashboards-overview.png +0 -0
  93. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  94. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  95. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  96. data/docs/public/images/guides/dashboard-loading.png +0 -0
  97. data/docs/public/images/guides/dashboard-overview.png +0 -0
  98. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  99. data/docs/reference/app/generators.md +24 -24
  100. data/docs/reference/app/index.md +15 -18
  101. data/docs/reference/app/packages.md +8 -8
  102. data/docs/reference/app/portals.md +77 -29
  103. data/docs/reference/auth/accounts.md +15 -15
  104. data/docs/reference/auth/index.md +12 -12
  105. data/docs/reference/auth/profile.md +67 -29
  106. data/docs/reference/behavior/async-interactions.md +24 -24
  107. data/docs/reference/behavior/controllers.md +28 -28
  108. data/docs/reference/behavior/index.md +5 -5
  109. data/docs/reference/behavior/interactions.md +44 -44
  110. data/docs/reference/behavior/policies.md +48 -28
  111. data/docs/reference/configuration.md +88 -31
  112. data/docs/reference/dashboard/dsl.md +123 -0
  113. data/docs/reference/dashboard/index.md +35 -0
  114. data/docs/reference/dashboard/registration.md +76 -0
  115. data/docs/reference/generators/lite.md +7 -7
  116. data/docs/reference/i18n.md +262 -0
  117. data/docs/reference/index.md +11 -2
  118. data/docs/reference/kanban/authorization.md +9 -9
  119. data/docs/reference/kanban/dsl.md +33 -33
  120. data/docs/reference/kanban/index.md +1 -1
  121. data/docs/reference/kanban/positioning.md +18 -16
  122. data/docs/reference/resource/actions.md +51 -51
  123. data/docs/reference/resource/definition.md +166 -88
  124. data/docs/reference/resource/export.md +11 -7
  125. data/docs/reference/resource/index.md +16 -14
  126. data/docs/reference/resource/model.md +24 -24
  127. data/docs/reference/{positioning.md → resource/positioning.md} +78 -75
  128. data/docs/reference/resource/query.md +14 -12
  129. data/docs/reference/tenancy/entity-scoping.md +65 -35
  130. data/docs/reference/tenancy/index.md +11 -11
  131. data/docs/reference/tenancy/invites.md +20 -20
  132. data/docs/reference/tenancy/nested-resources.md +13 -13
  133. data/docs/reference/testing/index.md +116 -22
  134. data/docs/reference/ui/assets.md +65 -25
  135. data/docs/reference/ui/components.md +20 -20
  136. data/docs/reference/ui/displays.md +19 -22
  137. data/docs/reference/ui/forms.md +35 -35
  138. data/docs/reference/ui/index.md +17 -15
  139. data/docs/reference/ui/layouts.md +21 -21
  140. data/docs/reference/ui/pages.md +22 -22
  141. data/docs/reference/ui/tables.md +7 -7
  142. data/docs/reference/wizard/anchoring-resume.md +33 -32
  143. data/docs/reference/wizard/dsl.md +45 -45
  144. data/docs/reference/wizard/index.md +6 -6
  145. data/docs/reference/wizard/one-time.md +18 -18
  146. data/docs/reference/wizard/registration-launch.md +32 -32
  147. data/docs/reference/wizard/storage-config.md +23 -23
  148. data/esbuild.config.js +5 -2
  149. data/gemfiles/rails_7.gemfile.lock +2 -2
  150. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  151. data/lib/active_model/validations/array_validator.rb +1 -1
  152. data/lib/active_model/validations/attached_validator.rb +1 -1
  153. data/lib/active_model/validations/url_validator.rb +2 -2
  154. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  155. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  156. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  157. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  158. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  159. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  160. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  161. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  162. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  163. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  164. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  165. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  166. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  167. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  168. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  169. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  170. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  171. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  172. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  173. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  174. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  175. data/lib/plutonium/action/base.rb +27 -3
  176. data/lib/plutonium/action/interactive.rb +17 -4
  177. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  178. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  179. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  180. data/lib/plutonium/configuration.rb +5 -0
  181. data/lib/plutonium/core/controller.rb +25 -1
  182. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  183. data/lib/plutonium/dashboard/base.rb +114 -0
  184. data/lib/plutonium/dashboard/card.rb +200 -0
  185. data/lib/plutonium/dashboard/controller.rb +101 -0
  186. data/lib/plutonium/dashboard/dsl.rb +104 -0
  187. data/lib/plutonium/dashboard/register.rb +40 -0
  188. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  189. data/lib/plutonium/dashboard.rb +42 -0
  190. data/lib/plutonium/definition/actions.rb +6 -1
  191. data/lib/plutonium/definition/base.rb +41 -3
  192. data/lib/plutonium/definition/index_views.rb +1 -1
  193. data/lib/plutonium/definition/positioning.rb +1 -1
  194. data/lib/plutonium/definition/presentable.rb +2 -2
  195. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  196. data/lib/plutonium/helpers/display_helper.rb +10 -2
  197. data/lib/plutonium/interaction/async/context.rb +6 -8
  198. data/lib/plutonium/interaction/async/executor.rb +6 -7
  199. data/lib/plutonium/interaction/async/run.rb +2 -2
  200. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  201. data/lib/plutonium/interaction/base.rb +2 -0
  202. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  203. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  204. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  205. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  206. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  207. data/lib/plutonium/invites/controller.rb +11 -11
  208. data/lib/plutonium/kanban/column.rb +13 -3
  209. data/lib/plutonium/kanban/dsl.rb +7 -4
  210. data/lib/plutonium/models/has_cents.rb +1 -1
  211. data/lib/plutonium/profile/security_section.rb +10 -18
  212. data/lib/plutonium/query/filter.rb +10 -1
  213. data/lib/plutonium/query/filters/association.rb +27 -2
  214. data/lib/plutonium/query/filters/boolean.rb +4 -4
  215. data/lib/plutonium/query/filters/date.rb +4 -17
  216. data/lib/plutonium/query/filters/date_range.rb +2 -2
  217. data/lib/plutonium/query/filters/select.rb +10 -1
  218. data/lib/plutonium/query/filters/text.rb +9 -12
  219. data/lib/plutonium/railtie.rb +17 -0
  220. data/lib/plutonium/resource/controller.rb +10 -5
  221. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  222. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  223. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  224. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  225. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  226. data/lib/plutonium/resource/query_object.rb +30 -3
  227. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  228. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  229. data/lib/plutonium/routing/resource_registration.rb +5 -0
  230. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  231. data/lib/plutonium/translation.rb +227 -0
  232. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  233. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  234. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  235. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  236. data/lib/plutonium/ui/component/methods.rb +4 -0
  237. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  238. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  239. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  240. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  241. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  242. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  243. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  244. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  245. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  246. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  247. data/lib/plutonium/ui/display/resource.rb +23 -16
  248. data/lib/plutonium/ui/export_button.rb +3 -3
  249. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  250. data/lib/plutonium/ui/form/components/password.rb +2 -2
  251. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  252. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  253. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  254. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  255. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  256. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  257. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  258. data/lib/plutonium/ui/form/query.rb +8 -8
  259. data/lib/plutonium/ui/form/resource.rb +54 -26
  260. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  261. data/lib/plutonium/ui/grid/card.rb +3 -3
  262. data/lib/plutonium/ui/grid/resource.rb +3 -3
  263. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  264. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  265. data/lib/plutonium/ui/kanban/column.rb +6 -6
  266. data/lib/plutonium/ui/layout/base.rb +19 -1
  267. data/lib/plutonium/ui/layout/header.rb +1 -1
  268. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  269. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  270. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  271. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  272. data/lib/plutonium/ui/modal/base.rb +4 -4
  273. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  274. data/lib/plutonium/ui/nav_user.rb +2 -2
  275. data/lib/plutonium/ui/page/base.rb +10 -2
  276. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  277. data/lib/plutonium/ui/page/edit.rb +3 -3
  278. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  279. data/lib/plutonium/ui/page/new.rb +3 -3
  280. data/lib/plutonium/ui/page/show.rb +2 -2
  281. data/lib/plutonium/ui/page/wizard.rb +22 -16
  282. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  283. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  284. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  285. data/lib/plutonium/ui/table/base.rb +2 -2
  286. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  287. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  288. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  289. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  290. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  291. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  292. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  293. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  294. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  295. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  296. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  297. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  298. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  299. data/lib/plutonium/ui/table/resource.rb +53 -17
  300. data/lib/plutonium/ui/wizard/review.rb +7 -7
  301. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  302. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  303. data/lib/plutonium/version.rb +1 -1
  304. data/lib/plutonium/wizard/base.rb +2 -0
  305. data/lib/plutonium/wizard/driving.rb +8 -2
  306. data/lib/plutonium/wizard/dsl.rb +4 -2
  307. data/lib/plutonium/wizard/field_importer.rb +1 -8
  308. data/lib/plutonium/wizard/review_step.rb +8 -1
  309. data/lib/plutonium/wizard/step.rb +14 -3
  310. data/package.json +4 -2
  311. data/plutonium.gemspec +4 -1
  312. data/postcss-gem-import.cjs +34 -0
  313. data/postcss-gem-import.js +3 -28
  314. data/src/css/components.css +101 -10
  315. data/src/css/slim_select.css +0 -38
  316. data/src/css/tokens.css +24 -0
  317. data/src/js/controllers/attachment_input_controller.js +15 -6
  318. data/src/js/controllers/chart_controller.js +162 -0
  319. data/src/js/controllers/clipboard_controller.js +3 -2
  320. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  321. data/src/js/controllers/easymde_controller.js +3 -0
  322. data/src/js/controllers/filter_panel_controller.js +15 -6
  323. data/src/js/controllers/flatpickr_controller.js +8 -0
  324. data/src/js/controllers/frame_refresh_controller.js +59 -0
  325. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  326. data/src/js/controllers/register_controllers.js +4 -0
  327. data/src/js/controllers/slim_select_controller.js +79 -176
  328. data/src/js/core.js +9 -1
  329. data/src/js/i18n.js +69 -0
  330. data/src/js/plutonium-charts.js +15 -0
  331. data/src/js/turbo/turbo_confirm.js +5 -3
  332. metadata +64 -16
  333. data/.yarnrc.yml +0 -8
  334. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  335. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  336. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  337. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  338. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  339. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  340. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  341. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  342. data/public/plutonium-assets/application.js +0 -31419
  343. data/public/plutonium-assets/plutonium.ico +0 -0
  344. data/yarn.lock +0 -6858
@@ -1,10 +1,10 @@
1
1
  # Registration & launch
2
2
 
3
- A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`) — inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth — see [Hosting & the controller override hook](#hosting-the-controller-override-hook)).
3
+ A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`), inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth); see [Hosting & the controller override hook](#hosting-the-controller-override-hook).
4
4
 
5
- ## On a resource — the `wizard` macro
5
+ ## On a resource: the `wizard` macro
6
6
 
7
- A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action — sugar over the Action system.
7
+ A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action, sugar over the Action system.
8
8
 
9
9
  ```ruby
10
10
  class CompanyDefinition < Plutonium::Resource::Definition
@@ -14,19 +14,19 @@ class CompanyDefinition < Plutonium::Resource::Definition
14
14
  end
15
15
  ```
16
16
 
17
- - **Placement is dictated by the wizard, not chosen** — an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
18
- - **Auto-mounted on the resource controller** — the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
19
- - **The anchor is IDOR-safe** — an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
20
- - **Bulk wizards are not supported** — wizards are inherently per-instance flows. Use a bulk interaction instead.
21
- - **Authorization mirrors actions** — a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
17
+ - **Placement is dictated by the wizard, not chosen**: an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
18
+ - **Auto-mounted on the resource controller**: the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
19
+ - **The anchor is IDOR-safe**: an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
20
+ - **Bulk wizards are not supported**: wizards are inherently per-instance flows. Use a bulk interaction instead.
21
+ - **Authorization mirrors actions**: a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
22
22
 
23
23
  | Option | Meaning |
24
24
  |---|---|
25
25
  | `record_action:` | **(Record wizards only)** show on the record's **show page**. Default `true`; `false` removes it from there. |
26
26
  | `collection_record_action:` | **(Record wizards only)** show on each **list row** (scoped to that row's record). Default `true`; `false` keeps it on the show page but off the list. |
27
- | `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome — any `action` option passes through. |
27
+ | `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome, any `action` option passes through. |
28
28
 
29
- Placement isn't an option — it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
29
+ Placement isn't an option; it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
30
30
 
31
31
  ### Synthesized routes (resource-mounted)
32
32
 
@@ -42,9 +42,9 @@ GET/POST /companies/wizards/:wizard_name(/:token)/:step
42
42
 
43
43
  The synthesized launch action points at the **bare** wizard URL (no step). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed identity) and redirects to its current step: the **resumed cursor** for an in-progress keyed run, else the **first step**, with the token already in the URL. So clicking the launch button resumes where the user left off (rather than jumping back to step 1) and never forks a fresh run on a first-step reload.
44
44
 
45
- ## Route-mounted — `register_wizard`
45
+ ## Route-mounted: `register_wizard`
46
46
 
47
- For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard` — **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
47
+ For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard`, **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
48
48
 
49
49
  ```ruby
50
50
  # packages/admin_portal/config/routes.rb
@@ -55,7 +55,7 @@ AdminPortal::Engine.routes.draw do
55
55
  register_resource ::Company
56
56
  end
57
57
 
58
- # config/routes.rb — a main-app (portal-less) wizard
58
+ # config/routes.rb: a main-app (portal-less) wizard
59
59
  Rails.application.routes.draw do
60
60
  register_wizard ::AppOnboardingWizard, at: "onboarding" # main-app default → :basic
61
61
  end
@@ -66,21 +66,21 @@ end
66
66
  | `at:` (required) | The host-relative base path for the wizard's steps. |
67
67
  | `as:` | Override the route helper name prefix (defaults to `at:`, then the wizard's name, e.g. `OnboardOrganizationWizard` → `onboarding`). |
68
68
  | `public:` | Mount on a **public (unauthenticated) route** for an [`anonymous`](#public-mount-for-anonymous-wizards) wizard. Defaults to the wizard's own `anonymous?` flag. |
69
- | `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host — **portal → the resource shell**, **main-app → `:basic`**. |
69
+ | `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host, **portal → the resource shell**, **main-app → `:basic`**. |
70
70
 
71
71
  This draws the wizard's step routes within the host (so a portal mount inherits the portal's scope/auth/layout) and dispatches them to a wizard controller. It provides `<name>_wizard_path` / `_url` helpers.
72
72
 
73
73
  ### Hosting & the controller override hook
74
74
 
75
- `register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource` — there is no hand-written file unless you want to customize.
75
+ `register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource`, there is no hand-written file unless you want to customize.
76
76
 
77
77
  | Host | Controller | Base / auth |
78
78
  |---|---|---|
79
79
  | **Portal** | `<Portal>::WizardsController` if defined, else synthesized | the portal's `PlutoniumController` (inherits its auth/scope/layout) |
80
- | **Main app, authenticated** | `::WizardsController` — **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
80
+ | **Main app, authenticated** | `::WizardsController`, **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
81
81
  | **Main app, public** (`anonymous`) | `::PublicWizardsController` (synthesized) | a bare base + `Plutonium::Auth::Public` (guest) |
82
82
 
83
- The synthesized **main-app** controller is **bare** — rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
83
+ The synthesized **main-app** controller is **bare**, rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
84
84
 
85
85
  ```ruby
86
86
  # app/controllers/wizards_controller.rb
@@ -94,17 +94,17 @@ end
94
94
 
95
95
  ### Layout
96
96
 
97
- `layout:` is the **Rails layout** the wizard renders in — a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
97
+ `layout:` is the **Rails layout** the wizard renders in, a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
98
98
 
99
99
  | `layout:` | Appearance | Layout |
100
100
  |---|---|---|
101
- | `:basic` | no sidebar/topbar — e.g. "set up your organization" | `basic` (`BasicLayout`) |
101
+ | `:basic` | no sidebar/topbar, e.g. "set up your organization" | `basic` (`BasicLayout`) |
102
102
  | `:resource` | sidebar + topbar + wizard (in-app) | the `resource` shell layout |
103
103
  | *(any app layout)* | whatever that layout renders | passed straight to Rails |
104
- | *(omitted)* | host default — see below | — |
104
+ | *(omitted)* | host default, see below | - |
105
105
 
106
106
  - **Defaults by host:** portal → the `resource` shell; main-app → `:basic` (a bare main-app host has no shell to embed in). Pass `layout:` to override.
107
- - **Turbo-frame requests are always layout-less** regardless of the setting — that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
107
+ - **Turbo-frame requests are always layout-less** regardless of the setting; that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
108
108
  - `layout:` is a **`register_wizard` option only**; it travels with the mount, not the wizard class. Resource-defined wizards take no `layout:` (always embedded).
109
109
 
110
110
  ### Synthesized routes
@@ -123,7 +123,7 @@ The POST `_direction` param carries `next` / `back` / `cancel`. **Where Cancel s
123
123
 
124
124
  ## Entry authorization
125
125
 
126
- A portal-level wizard has no resource policy, so gate entry with an `authorize?` instance method on the wizard — checked before each request:
126
+ A `register_wizard` wizard (portal or main-app) has no resource policy, so gate entry with an `authorize?` instance method on the wizard, checked before each request:
127
127
 
128
128
  ```ruby
129
129
  class WelcomeWizard < Plutonium::Wizard::Base
@@ -135,12 +135,12 @@ end
135
135
 
136
136
  A falsy return → `ActionPolicy::Unauthorized` (403). Resource-attached wizards instead use their action's policy predicate (`def onboard?` etc.).
137
137
 
138
- ::: danger A portal-level wizard with no `authorize?` is open to ANY authenticated portal user
139
- A portal-level wizard (registered with `register_wizard`) has **no resource policy** and **defaults to allowed** — so if you omit `authorize?`, every authenticated user of that portal can run it. **Always define `def authorize?`** for anything privileged (admin-only flows, per-user gating, tenant checks). The default-allow is only safe for flows that are genuinely fine for any signed-in portal user (e.g. self-service onboarding).
138
+ ::: danger A `register_wizard` wizard with no `authorize?` is open to ANY authenticated user
139
+ A wizard registered with `register_wizard` (in a portal or on the main app) has **no resource policy**, and the base class defines `def authorize? = true`. If you omit it, every authenticated user of that portal or app can run it. **Define `def authorize?` on every `register_wizard` wizard.** A gated `one_time` wizard is no exception: the gate forces users *into* the wizard but doesn't decide who may run it. For a flow that is genuinely fine for any signed-in user (e.g. self-service onboarding), say so with `def authorize? = true`.
140
140
  :::
141
141
 
142
142
  ::: warning As-built: `authorize?` is an instance method
143
- Define `def authorize?` on the wizard. The controller checks it only when the wizard responds to it (default: allowed).
143
+ Define `def authorize?` on the wizard. The controller checks it before every request; the inherited default returns `true`.
144
144
  :::
145
145
 
146
146
  ## Public mount for `anonymous` wizards
@@ -154,12 +154,12 @@ register_wizard ::GuestSignupWizard, at: "signup", public: true
154
154
  ```
155
155
 
156
156
  - `public: true` is the **default for an `anonymous` wizard**; you can pass it explicitly for clarity.
157
- - A **non-`anonymous`** wizard may **not** be mounted public (raises) — and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
157
+ - A **non-`anonymous`** wizard may **not** be mounted public (raises), and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
158
158
  - The public route dispatches to a synthesized top-level `::PublicWizardsController` (a **distinct** const from an authenticated main-app `::WizardsController`, so the two never collapse onto each other) that renders **full-page** with a standalone layout (no resource sidebar / user menu) and treats the request as a guest via `Plutonium::Auth::Public`.
159
159
 
160
160
  ## One-time gating
161
161
 
162
- To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time) — a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
162
+ To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time), a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
163
163
 
164
164
  For a **one-time** wizard, the launch action this macro synthesizes also **hides itself once the current user has completed it** (via a render-time action `condition:`); a custom `condition:` composes with that check. See [The launch action hides itself once completed](/reference/wizard/one-time#the-launch-action-hides-itself-once-completed).
165
165
 
@@ -167,11 +167,11 @@ For a **one-time** wizard, the launch action this macro synthesizes also **hides
167
167
 
168
168
  - **`with:`-anchored wizards mount on the resource, not route-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro (it auto-mounts a member action whose anchor is the scoped `resource_record!`). Passing a `with:`-anchored wizard to **`register_wizard`** raises: a route-level mount has no resource record to anchor to. A **`via:`-anchored** (context-anchored) wizard *does* mount route-level; its anchor is resolved by a controller method, not a URL `:id`.
169
169
  - **An authenticated main-app wizard needs an app-defined controller.** The synthesized main-app controller is bare (no `current_user`); supply your own `::WizardsController` with an auth concern (see [Hosting & the controller override hook](#hosting-the-controller-override-hook)). Portal mounts and `anonymous` public mounts need nothing.
170
- - **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time — give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
170
+ - **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time, give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
171
171
 
172
172
  ## Related
173
173
 
174
- - [DSL reference](/reference/wizard/dsl) — `authorize?`, the wizard body.
175
- - [Anchoring & resume](/reference/wizard/anchoring-resume) — anchors, instance keys.
176
- - [One-time wizards](/reference/wizard/one-time) — completion + gating.
177
- - [Custom actions guide](/guides/custom-actions) — the Action system the `wizard` macro builds on.
174
+ - [DSL reference](/reference/wizard/dsl): `authorize?`, the wizard body.
175
+ - [Anchoring & resume](/reference/wizard/anchoring-resume): anchors, instance keys.
176
+ - [One-time wizards](/reference/wizard/one-time): completion + gating.
177
+ - [Custom actions guide](/guides/custom-actions): the Action system the `wizard` macro builds on.
@@ -23,25 +23,25 @@ rails db:migrate
23
23
 
24
24
  | Config | Default | Meaning |
25
25
  |---|---|---|
26
- | `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes — both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
26
+ | `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes, both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
27
27
  | `config.wizards.cleanup_after` | `14.days` | Global default idle TTL for the abandonment sweep; overridable per wizard via `cleanup_after`. |
28
- | `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only** — see below. |
28
+ | `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only**, see below. |
29
29
  | `config.wizards.encrypt_data` | `false` | Encrypt **every** wizard's staged `data` at rest by default. Off by default because it needs ActiveRecord encryption keys; a wizard still overrides it individually with `encrypt_data` / `encrypt_data false`. See [Encryption](#encryption). |
30
- | `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects — active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
30
+ | `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects, active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
31
31
 
32
32
  ## Gem-shipped migration
33
33
 
34
- The migration ships **in the gem** and Rails runs it **in place** — there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
34
+ The migration ships **in the gem** and Rails runs it **in place**; there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
35
35
 
36
36
  - Once run, the table is dumped into your `schema.rb` / `structure.sql` like any other, so `db:schema:load` on fresh/CI databases recreates it normally.
37
37
  - Disable later → the path isn't registered; the existing table is left alone (never auto-dropped).
38
- - `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed) — standard for gem-shipped migrations.
38
+ - `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed), standard for gem-shipped migrations.
39
39
 
40
40
  ::: warning v1 supports the primary database only
41
- The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use** — multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
41
+ The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use**, multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
42
42
  :::
43
43
 
44
- ## The table — `plutonium_wizard_sessions`
44
+ ## The table: `plutonium_wizard_sessions`
45
45
 
46
46
  One framework-owned table serves everything; **no changes to your models.**
47
47
 
@@ -51,7 +51,7 @@ One framework-owned table serves everything; **no changes to your models.**
51
51
  | `status` | `in_progress` \| `completing` \| `completed` (see the note below). |
52
52
  | `current_step` | The step cursor. |
53
53
  | `instance_key` (unique) | The deterministic identity digest (see [Anchoring & resume](/reference/wizard/anchoring-resume#instance-identity)). |
54
- | `owner_type` / `owner_id` | The user (nullable — `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
54
+ | `owner_type` / `owner_id` | The user (nullable, `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
55
55
  | `anchor_type` / `anchor_id` | The anchor record (nullable). |
56
56
  | `scope_type` / `scope_id` | The portal scoping entity / tenant (nullable). |
57
57
  | `engine` | The portal (engine class name) the run was launched in, e.g. `"OrgPortal::Engine"`. The "continue where you left off" listing only shows (and links) runs whose `engine` matches the portal being viewed (two portals can share an entity scope, so `scope` alone can't identify the portal). |
@@ -64,14 +64,14 @@ One framework-owned table serves everything; **no changes to your models.**
64
64
 
65
65
  What the single table powers:
66
66
 
67
- - **Resume** — look up the `in_progress` row by `instance_key`.
68
- - **One-time check** — does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
69
- - **In-progress listing** — by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
70
- - **Multi-tenancy** — the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
71
- - **Sweep** — idle `in_progress`/`completing` rows past `expires_at`.
67
+ - **Resume**: look up the `in_progress` row by `instance_key`.
68
+ - **One-time check**: does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
69
+ - **In-progress listing**: by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
70
+ - **Multi-tenancy**: the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
71
+ - **Sweep**: idle `in_progress`/`completing` rows past `expires_at`.
72
72
 
73
73
  ::: tip The `persisted` / `tracked_records` naming
74
- The column is `tracked_records`, not `persisted` — an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
74
+ The column is `tracked_records`, not `persisted`: an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
75
75
  :::
76
76
 
77
77
  ## Encryption
@@ -85,7 +85,7 @@ class CheckoutWizard < Plutonium::Wizard::Base
85
85
  end
86
86
  ```
87
87
 
88
- This encrypts the `data` column (the staged step values) — off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
88
+ This encrypts the `data` column (the staged step values), off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
89
89
 
90
90
  **Encrypt everything by default.** Once your app has ActiveRecord encryption keys, you can flip encryption on for *all* wizards with one global flag, then override per wizard:
91
91
 
@@ -99,7 +99,7 @@ class PublicSurveyWizard < Plutonium::Wizard::Base
99
99
  end
100
100
  ```
101
101
 
102
- Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys — see the warning below.
102
+ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys, see the warning below.
103
103
 
104
104
  **How it works.** Because `data` is one shared `jsonb` column across all wizards (some opting in, some not), a static model-level `encrypts :data` doesn't fit (it would encrypt every row, and fights the `jsonb` type). Instead, the store encrypts at write time using **ActiveRecord's configured encryptor** (`ActiveRecord::Encryption.encryptor`, the same keys as `encrypts`) and stores a self-describing envelope inside the column:
105
105
 
@@ -107,17 +107,17 @@ Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard alwa
107
107
  { "_enc": "<ciphertext>" }
108
108
  ```
109
109
 
110
- A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?` — so toggling the flag never strands existing runs.
110
+ A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?`, so toggling the flag never strands existing runs.
111
111
 
112
112
  ::: warning Requires ActiveRecord encryption keys
113
- `encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard — rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
113
+ `encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard, rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
114
114
  :::
115
115
 
116
116
  ## Files
117
117
 
118
118
  A file can't sit in the JSON `data` column, so a wizard stages only the backend's **upload token** (an ActiveStorage `signed_id`, or Shrine cached-file data) and `execute` assigns it to the model's attachment. This works for both server-side and direct uploads, ActiveStorage and active_shrine. See [DSL › Attachment fields](/reference/wizard/dsl#attachment-fields) and the [guide](/guides/wizards#file-uploads-attachments) for the full surface (`backend:`, `multiple:`, direct upload).
119
119
 
120
- A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it — the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
120
+ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it, the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
121
121
 
122
122
  ## Cleanup & the SweepJob
123
123
 
@@ -126,7 +126,7 @@ A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Eac
126
126
  `Plutonium::Wizard::SweepJob` reaps idle `in_progress` / `completing` rows past `expires_at`: for each it runs the wizard's cleanup (each step's `on_rollback` if declared, then always destroy every tracked record, in reverse order) and deletes the row. Completed rows are never touched. The job is idempotent and safe to re-run.
127
127
 
128
128
  ::: tip The `completing` state and its grace window
129
- A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window — long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
129
+ A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window, long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
130
130
  :::
131
131
 
132
132
  ### SweepJob is load-bearing for save-as-you-go
@@ -147,6 +147,6 @@ On completion of a one-time wizard, the row is kept as the durable marker but it
147
147
 
148
148
  ## Related
149
149
 
150
- - [Anchoring & resume](/reference/wizard/anchoring-resume) — `instance_key`, resume.
151
- - [DSL reference](/reference/wizard/dsl) — `cleanup_after`, `encrypt_data`, `persist`.
152
- - [One-time wizards](/reference/wizard/one-time) — durable completion markers.
150
+ - [Anchoring & resume](/reference/wizard/anchoring-resume): `instance_key`, resume.
151
+ - [DSL reference](/reference/wizard/dsl): `cleanup_after`, `encrypt_data`, `persist`.
152
+ - [One-time wizards](/reference/wizard/one-time): durable completion markers.
data/esbuild.config.js CHANGED
@@ -6,7 +6,8 @@ if (process.argv.includes("--dev")) {
6
6
  _context({
7
7
  outdir: "src/build",
8
8
  entryPoints: [
9
- "src/js/plutonium.js"
9
+ "src/js/plutonium.js",
10
+ "src/js/plutonium-charts.js"
10
11
  ],
11
12
  plugins: [
12
13
  manifestPlugin({
@@ -24,6 +25,7 @@ else {
24
25
  outdir: "src/dist/js",
25
26
  entryPoints: [
26
27
  "src/js/plutonium.js",
28
+ "src/js/plutonium-charts.js"
27
29
  ],
28
30
  minify,
29
31
  sourcemap: true,
@@ -34,7 +36,8 @@ else {
34
36
  _build({
35
37
  outdir: "app/assets",
36
38
  entryPoints: [
37
- "src/js/plutonium.js"
39
+ "src/js/plutonium.js",
40
+ "src/js/plutonium-charts.js"
38
41
  ],
39
42
  minify,
40
43
  sourcemap: true,
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.62.2)
4
+ plutonium (0.64.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -601,7 +601,7 @@ CHECKSUMS
601
601
  phlexi-form (0.14.3) sha256=47d69c4a49ba22279ba541b9b110e81d62acc4602513f21bb5c3d29fa9c8d5ca
602
602
  phlexi-menu (0.4.1) sha256=f6eeb1eb9597caadeee33a53f1520b3613a6ac616cf1e395e9d734a412895fb2
603
603
  phlexi-table (0.2.0) sha256=73bee135adbe73fcdb6fdd2672444373556dd9e6bba61e7b5b20ca8b7a1f822c
604
- plutonium (0.62.2)
604
+ plutonium (0.64.0)
605
605
  pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
606
606
  prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
607
607
  prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.62.2)
4
+ plutonium (0.65.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -52,7 +52,7 @@ module ActiveModel
52
52
  errors = record.errors.delete attribute
53
53
  return if errors.nil?
54
54
 
55
- @existing_errors += errors.map { |e| "item #{index + 1} #{e}" }
55
+ @existing_errors += errors.map { |e| I18n.t("errors.messages.array_item", index: index + 1, message: e) }
56
56
  end
57
57
 
58
58
  def skip?(value, validator_options)
@@ -10,7 +10,7 @@ module ActiveModel
10
10
  #
11
11
  class AttachedValidator < EachValidator
12
12
  def validate_each(record, attribute, value)
13
- record.errors.add(attribute, options[:message] || "must be attached") unless value.attached?
13
+ record.errors.add(attribute, options[:message] || :not_attached) unless value.attached?
14
14
  end
15
15
  end
16
16
  end
@@ -35,7 +35,7 @@ module ActiveModel
35
35
  def validate_url
36
36
  return true if URL_PATTERN.match?(value)
37
37
 
38
- record.errors.add attribute, (options[:message] || "is not a valid URL")
38
+ record.errors.add attribute, (options[:message] || :invalid_url)
39
39
  false
40
40
  end
41
41
 
@@ -43,7 +43,7 @@ module ActiveModel
43
43
  return true unless options[:image].present?
44
44
  return true unless FastImage.type(value).nil?
45
45
 
46
- record.errors.add(attribute, options[:message] || "is not a valid image URL")
46
+ record.errors.add(attribute, options[:message] || :invalid_image_url)
47
47
  false
48
48
  end
49
49
  end
@@ -25,15 +25,23 @@ module Pu
25
25
 
26
26
  private
27
27
 
28
- # The asset pipeline assumes the app was generated with esbuild + Tailwind.
29
- # Without those, `application.tailwind.css` doesn't exist and the generator
30
- # later crashes with a cryptic inject_into_file error. Fail early with a fix.
28
+ # The asset pipeline assumes the app was generated with esbuild + Tailwind + Stimulus.
29
+ # Without those, `application.tailwind.css` (Tailwind) and/or
30
+ # `app/javascript/controllers/index.js` (Stimulus) don't exist, and the generator
31
+ # later crashes with a cryptic inject_into_file error. Fail early listing every
32
+ # missing file with a fix.
31
33
  def verify_prerequisites
32
- return if File.exist?("app/assets/stylesheets/application.tailwind.css")
34
+ required = [
35
+ "app/assets/stylesheets/application.tailwind.css",
36
+ "app/javascript/controllers/index.js"
37
+ ]
38
+ missing = required.reject { |path| File.exist?(path) }
39
+ return if missing.empty?
33
40
 
34
41
  error <<~MSG
35
- Plutonium assets require a Rails app generated with esbuild and Tailwind.
36
- Expected app/assets/stylesheets/application.tailwind.css, but it is missing.
42
+ Plutonium assets require a Rails app generated with esbuild, Tailwind, and Stimulus.
43
+ Missing files:
44
+ - #{missing.join("\n - ")}
37
45
 
38
46
  Re-create the app with the required flags:
39
47
  rails new myapp -a propshaft -j esbuild -c tailwind \\
@@ -46,28 +54,35 @@ module Pu
46
54
  copy_file "postcss.config.js", force: true
47
55
  end
48
56
 
57
+ # Every package goes through one `add` built for the app's package manager
58
+ # (bun, yarn 1, yarn 2+, npm or pnpm; see Concerns::JsPackageManager).
59
+ # Versions ride inline because `pkg@latest` and `pkg@^x.y.z` are the one
60
+ # spelling all of them share; yarn 1's `upgrade --latest` does not exist
61
+ # on yarn 2+, and running yarn inside a bun app leaves two lockfiles.
49
62
  def install_dependencies
50
- failed = []
63
+ ensure_yarn_node_modules_linker
51
64
 
65
+ command = js_add_command(npm_packages)
66
+ return if run(command)
67
+
68
+ say_status :warn,
69
+ "`#{command}` failed — your app may not boot until you re-run it",
70
+ :yellow
71
+ end
72
+
73
+ def npm_packages
52
74
  [
53
- "@radioactive-labs/plutonium",
75
+ # Pinned to the gem so the two halves never drift; pu:core:update
76
+ # keeps them together afterwards.
77
+ "@radioactive-labs/plutonium@^#{Plutonium::VERSION}",
78
+ # cssbundling-rails already installs the latest Tailwind on new apps;
79
+ # apps created on Tailwind 3 are moved to 4 here.
80
+ "tailwindcss@latest",
54
81
  "postcss", "postcss-cli", "postcss-import",
55
82
  "@tailwindcss/postcss", "@tailwindcss/forms", "@tailwindcss/typography",
56
83
  "cssnano", "marked",
57
84
  "flowbite-typography"
58
- ].each do |package|
59
- run "yarn add #{package}"
60
- failed << "yarn add #{package}" unless $?.success?
61
- end
62
-
63
- run "yarn upgrade tailwindcss --latest"
64
- failed << "yarn upgrade tailwindcss --latest" unless $?.success?
65
-
66
- return if failed.empty?
67
-
68
- say_status :warn,
69
- "the following commands failed — your app may not boot until you resolve them:\n #{failed.join("\n ")}",
70
- :yellow
85
+ ]
71
86
  end
72
87
 
73
88
  def configure_application
@@ -3,7 +3,7 @@ const plutoniumGemPath = execSync("bundle show plutonium").toString().trim();
3
3
 
4
4
  module.exports = {
5
5
  plugins: {
6
- [`${plutoniumGemPath}/postcss-gem-import.js`]: {},
6
+ [`${plutoniumGemPath}/postcss-gem-import.cjs`]: {},
7
7
  'postcss-import': {},
8
8
  '@tailwindcss/postcss': {},
9
9
  cssnano: {}
@@ -45,9 +45,10 @@ module Pu
45
45
 
46
46
  def update_npm_package
47
47
  gem_version = installed_gem_version
48
+ package = gem_version ? "@radioactive-labs/plutonium@^#{gem_version}" : "@radioactive-labs/plutonium@latest"
48
49
 
49
- say_status :update, "Updating @radioactive-labs/plutonium to #{gem_version}...", :green
50
- run "yarn add @radioactive-labs/plutonium@^#{gem_version}"
50
+ say_status :update, "Updating #{package} with #{js_package_manager}...", :green
51
+ run js_add_command(package)
51
52
  end
52
53
 
53
54
  def installed_gem_version
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ # Scaffolds a dashboard class and mounts it in a portal (or the main app).
7
+ #
8
+ # rails g pu:dashboard Sales --dest=admin_portal
9
+ # rails g pu:dashboard Home --dest=admin_portal --at=/ # the portal root
10
+ #
11
+ # Writes `app/dashboards/<portal>/<name>_dashboard.rb` inside the package
12
+ # and adds `register_dashboard` to its routes. A root mount replaces the
13
+ # generated `root to: "dashboard#index"` line.
14
+ class DashboardGenerator < Rails::Generators::NamedBase
15
+ include PlutoniumGenerators::Generator
16
+
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ desc(
20
+ "Create a dashboard and mount it in a portal\n\n" \
21
+ "e.g. rails g pu:dashboard Sales --dest=admin_portal\n" \
22
+ " rails g pu:dashboard Home --dest=admin_portal --at=/"
23
+ )
24
+
25
+ class_option :at, type: :string, default: nil,
26
+ desc: "Mount path under the portal's dashboards/ segment (default: the dashboard name; \"/\" mounts at the root)"
27
+
28
+ def start
29
+ @app_namespace = portal_option(:dest, prompt: "Select destination portal").camelize
30
+
31
+ template "dashboard.rb", dashboard_file_path
32
+ register_dashboard_in_routes
33
+ rescue => e
34
+ exception "#{self.class} failed:", e
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :app_namespace
40
+
41
+ def main_app? = app_namespace == "MainApp"
42
+
43
+ def package_namespace = app_namespace.underscore
44
+
45
+ def dashboard_name = "#{class_name.delete_suffix("Dashboard")}Dashboard"
46
+
47
+ def dashboard_class_name
48
+ main_app? ? dashboard_name : "#{app_namespace}::#{dashboard_name}"
49
+ end
50
+
51
+ def dashboard_label = dashboard_name.delete_suffix("Dashboard").titleize
52
+
53
+ def mount_path
54
+ path = options[:at].presence || dashboard_name.delete_suffix("Dashboard").underscore
55
+ path.to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
56
+ end
57
+
58
+ def root_mount? = mount_path.empty?
59
+
60
+ def dashboard_file_path
61
+ file = "#{dashboard_name.underscore}.rb"
62
+ main_app? ? "app/dashboards/#{file}" : "packages/#{package_namespace}/app/dashboards/#{package_namespace}/#{file}"
63
+ end
64
+
65
+ def routes_path
66
+ main_app? ? "config/routes.rb" : "packages/#{package_namespace}/config/routes.rb"
67
+ end
68
+
69
+ def register_line
70
+ constant = main_app? ? "::#{dashboard_class_name}" : dashboard_class_name
71
+ %(register_dashboard #{constant}, at: "#{root_mount? ? "/" : mount_path}")
72
+ end
73
+
74
+ # Idempotent: skips when the routes already register this dashboard. A root
75
+ # mount replaces the portal's generated `root to:` line, since two roots
76
+ # would clash.
77
+ def register_dashboard_in_routes
78
+ content = File.read(File.join(destination_root, routes_path))
79
+
80
+ if /^\s*register_dashboard #{Regexp.escape("::" + dashboard_class_name)}\b|^\s*register_dashboard #{Regexp.escape(dashboard_class_name)}\b/.match?(content)
81
+ say_status :identical, "#{routes_path} already registers #{dashboard_class_name}", :blue
82
+ return
83
+ end
84
+
85
+ if root_mount? && (match = content.match(/^\s*root to: "dashboard#index".*\n/))
86
+ gsub_file routes_path, match[0], indent("#{register_line}\n", 2)
87
+ elsif /^\s*#\s*register resources above\b/.match?(content)
88
+ insert_into_file routes_path, indent("#{register_line}\n", 2), before: /^\s*#\s*register resources above\b.*/
89
+ elsif (match = content.match(/^\s*(\w+::Engine|Rails\.application)\.routes\.draw do.*\n/))
90
+ insert_into_file routes_path, indent("#{register_line}\n", 2), after: match[0]
91
+ else
92
+ say_status :warn, "Could not locate routes block in #{routes_path}; add manually: #{register_line}", :yellow
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,25 @@
1
+ <%- if main_app? -%>
2
+ class <%= dashboard_name %> < Plutonium::Dashboard::Base
3
+ presents label: "<%= dashboard_label %>"
4
+
5
+ # Cards sit on a 12-column grid (metric 3, chart and card 6 by default; `span:`
6
+ # overrides), each in its own lazy turbo frame. `group_by_day` is the groupdate gem.
7
+ #
8
+ # metric(:users, icon: Phlex::TablerIcons::Users) { authorized_resource_scope(User).count }
9
+ # chart(:signups, type: :line, span: 8) { User.group_by_day(:created_at, last: 30).count }
10
+ # card(:welcome) { p { "Signed in as #{current_user.email}" } }
11
+ end
12
+ <%- else -%>
13
+ module <%= app_namespace %>
14
+ class <%= dashboard_name %> < Plutonium::Dashboard::Base
15
+ presents label: "<%= dashboard_label %>"
16
+
17
+ # Cards sit on a 12-column grid (metric 3, chart and card 6 by default; `span:`
18
+ # overrides), each in its own lazy turbo frame. `group_by_day` is the groupdate gem.
19
+ #
20
+ # metric(:users, icon: Phlex::TablerIcons::Users) { authorized_resource_scope(User).count }
21
+ # chart(:signups, type: :line, span: 8) { User.group_by_day(:created_at, last: 30).count }
22
+ # card(:welcome) { p { "Signed in as #{current_user.email}" } }
23
+ end
24
+ end
25
+ <%- end -%>