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
@@ -5,11 +5,11 @@ The entry point from a Plutonium page into an operation. An interaction declares
5
5
  ## 🚨 Critical
6
6
 
7
7
  - **`ActiveRecord::RecordInvalid` is NOT rescued automatically.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
8
- - **Return `succeed(...)` or `failed(...)` from `execute`** — the controller can't tell what happened otherwise. Returning anything else raises.
9
- - **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
10
- - **Bulk actions use `attribute :resources` (plural).** Policy authorization is checked per record — if any fails, the whole request fails.
8
+ - **Return `succeed(...)` or `failed(...)` from `execute`**: the controller can't tell what happened otherwise. Returning anything else raises.
9
+ - **Redirect is automatic on success**: only use `with_redirect_response` for a *different* destination.
10
+ - **Bulk actions use `attribute :resources` (plural).** Policy authorization is checked per record; if any fails, the whole request fails.
11
11
  - **The shape of the action (record / bulk / resource) is inferred from the interaction's attributes.** See [Resource › Actions](/reference/resource/actions#inferred-visibility-interactive-actions).
12
- - **An interaction is a presentation object.** Logic may *start* in `execute`; the **second caller** — a job, an API controller, a rake task, the console — is the signal to move it to the model. See [below](#what-an-interaction-is-for).
12
+ - **An interaction is a presentation object.** Logic may *start* in `execute`; the **second caller** (a job, an API controller, a rake task, the console) is the signal to move it to the model. See [below](#what-an-interaction-is-for).
13
13
 
14
14
  ## What an interaction is for {#what-an-interaction-is-for}
15
15
 
@@ -17,20 +17,20 @@ An interaction is a **presentation object**. It exists so Plutonium can render a
17
17
 
18
18
  | An interaction owns | An interaction does not own |
19
19
  |---|---|
20
- | The button — `presents label:` / `icon:` | *Who* may click it. That's the [policy](./policies). |
21
- | The form — `attribute` + `input` declarations | — |
22
- | **Input shape** validation: present? parses? right type? | **Business invariants** — they must hold for every caller, so they belong on the model |
23
- | The user-facing outcome — `succeed` / `failed`, messages, redirect | The domain operation itself, once more than one caller needs it |
20
+ | The button: `presents label:` / `icon:` | *Who* may click it. That's the [policy](./policies). |
21
+ | The form: `attribute` + `input` declarations | - |
22
+ | **Input shape** validation: present? parses? right type? | **Business invariants**: they must hold for every caller, so they belong on the model |
23
+ | The user-facing outcome: `succeed` / `failed`, messages, redirect | The domain operation itself, once more than one caller needs it |
24
24
 
25
25
  ### Logic may start in `execute`
26
26
 
27
- A one-off operation with exactly one caller is perfectly fine written inline. Don't pre-extract a service object for a two-line `update!` — that's YAGNI, and Plutonium deliberately ships no service layer to put it in. The rule below is a **refactoring trigger**, not a prohibition.
27
+ A one-off operation with exactly one caller is perfectly fine written inline. Don't pre-extract a service object for a two-line `update!`; that's YAGNI, and Plutonium deliberately ships no service layer to put it in. The rule below is a **refactoring trigger**, not a prohibition.
28
28
 
29
29
  ### The second caller is the trigger to extract
30
30
 
31
31
  The moment a background job, an API controller, a rake task, the console, or another interaction needs the same behaviour, move it to the model.
32
32
 
33
- The deadline is *the second caller* — and not "as soon as it looks like business logic" — because of one line in the base class:
33
+ The deadline is *the second caller*, not "as soon as it looks like business logic", because of one line in the base class:
34
34
 
35
35
  ```ruby
36
36
  def initialize(view_context:, **attributes)
@@ -61,9 +61,9 @@ rescue ActiveRecord::RecordInvalid => e
61
61
  end
62
62
  ```
63
63
 
64
- Name it for the domain (`publish!`, `archive!`, `register!`), not for the persistence (`update_published_at`) — the point is that a scheduler job can now call `post.publish!` and read as if it meant it. And resist inventing a `PublishPostService`: the model is the destination, not a new layer.
64
+ Name it for the domain (`publish!`, `archive!`, `register!`), not for the persistence (`update_published_at`); the point is that a scheduler job can now call `post.publish!` and read as if it meant it. And resist inventing a `PublishPostService`: the model is the destination, not a new layer.
65
65
 
66
- ### Worked counter-example — chained interactions
66
+ ### Worked counter-example: chained interactions
67
67
 
68
68
  ```ruby
69
69
  # 🚫 Every link demands a view_context that has nothing to do with the work
@@ -72,7 +72,7 @@ CreateUserInteraction.call(view_context:, **user_params)
72
72
  .and_then { |user| LogActivity.call(view_context:, user:) }
73
73
  ```
74
74
 
75
- Sending a welcome email and writing an audit row are precisely what a signup API endpoint, a seeds script, or a console session also has to do — none of which has a `view_context`. Modelled as interactions, they are unreachable from anywhere but a Plutonium page.
75
+ Sending a welcome email and writing an audit row are precisely what a signup API endpoint, a seeds script, or a console session also has to do, none of which has a `view_context`. Modelled as interactions, they are unreachable from anywhere but a Plutonium page.
76
76
 
77
77
  ```ruby
78
78
  # ✅ The model owns registering a user; the interaction presents it
@@ -82,12 +82,12 @@ def execute
82
82
  end
83
83
  ```
84
84
 
85
- Chaining three interactions is usually the signal that you have one model method wearing three presentation costumes. `and_then` is real API and stays [documented below](#chaining) — just don't reach for it to sequence business operations.
85
+ Chaining three interactions is usually the signal that you have one model method wearing three presentation costumes. `and_then` is real API and stays [documented below](#chaining), just don't reach for it to sequence business operations.
86
86
 
87
87
  ## Structure
88
88
 
89
89
  ```ruby
90
- # app/interactions/resource_interaction.rb — installed once
90
+ # app/interactions/resource_interaction.rb: installed once
91
91
  class ResourceInteraction < Plutonium::Resource::Interaction
92
92
  end
93
93
 
@@ -107,7 +107,7 @@ class PublishPostInteraction < ResourceInteraction
107
107
  private
108
108
 
109
109
  def execute
110
- resource.publish!(on: publish_date) # Post#publish! — see above
110
+ resource.publish!(on: publish_date) # Post#publish!, see above
111
111
  succeed(resource).with_message("Post published!")
112
112
  rescue ActiveRecord::RecordInvalid => e
113
113
  failed(e.record.errors)
@@ -115,7 +115,7 @@ class PublishPostInteraction < ResourceInteraction
115
115
  end
116
116
  ```
117
117
 
118
- Note the division: the interaction declares the input, validates that a date was supplied, and phrases the flash. `Post#publish!` decides what publishing a post *means* — so the scheduled-publishing job can call it too.
118
+ Note the division: the interaction declares the input, validates that a date was supplied, and phrases the flash. `Post#publish!` decides what publishing a post *means*, so the scheduled-publishing job can call it too.
119
119
 
120
120
  ## Attributes
121
121
 
@@ -132,11 +132,11 @@ attribute :metadata, :hash
132
132
  attribute :date, :datetime
133
133
  ```
134
134
 
135
- The presence of `:resource` / `:resources` / neither determines the action type — see [Resource › Actions › Inferred visibility](/reference/resource/actions#inferred-visibility-interactive-actions).
135
+ The presence of `:resource` / `:resources` / neither determines the action type, see [Resource › Actions › Inferred visibility](/reference/resource/actions#inferred-visibility-interactive-actions).
136
136
 
137
137
  ## Inputs
138
138
 
139
- Same DSL as definition `input`. Auto-detection from the attribute type applies — declare `as:` only when overriding.
139
+ Same DSL as definition `input`. Auto-detection from the attribute type applies; declare `as:` only when overriding.
140
140
 
141
141
  ```ruby
142
142
  input :email # auto: :email type from name match
@@ -164,7 +164,7 @@ MyInteraction.description # => "Move to archive"
164
164
 
165
165
  If `action :foo, interaction: FooInteraction` doesn't override `label:` / `icon:` etc., these `presents` values are used.
166
166
 
167
- ## `execute` — outcomes
167
+ ## `execute`: outcomes
168
168
 
169
169
  `execute` MUST return a `succeed(...)` or `failed(...)` outcome. Validations run automatically before `execute`; if they fail, the interaction short-circuits to `failed()`.
170
170
 
@@ -220,7 +220,7 @@ end
220
220
  ```
221
221
 
222
222
  ::: warning Don't use `and_then` to sequence business operations
223
- A chain of three interactions is a chain of three things that each demand a `view_context`, none of which a job or an API controller can supply. That's one model method wearing three costumes — see [Worked counter-example](#what-an-interaction-is-for). `and_then` earns its keep composing outcomes *within* one interaction, or in a test.
223
+ A chain of three interactions is a chain of three things that each demand a `view_context`, none of which a job or an API controller can supply. That's one model method wearing three costumes, see [Worked counter-example](#what-an-interaction-is-for). `and_then` earns its keep composing outcomes *within* one interaction, or in a test.
224
224
  :::
225
225
 
226
226
  ## Validations
@@ -242,18 +242,18 @@ end
242
242
 
243
243
  ### Which validation goes where
244
244
 
245
- Interactions have validations and so do models, and they are not competing — they answer different questions:
245
+ Interactions have validations and so do models, and they are not competing: they answer different questions:
246
246
 
247
247
  | | Interaction validation | Model validation |
248
248
  |---|---|---|
249
- | Asks | "Can I read this input?" — present, parses, right type, plausible format | "Is this record legal?" — invariants that hold no matter who is calling |
249
+ | Asks | "Can I read this input?": present, parses, right type, plausible format | "Is this record legal?": invariants that hold no matter who is calling |
250
250
  | Exists to | render a form error next to the field | protect the data from every caller, including the ones with no form |
251
- | Runs | before `execute`, without ever touching the model | inside `save!` / `update!` — i.e. inside your model method |
251
+ | Runs | before `execute`, without ever touching the model | inside `save!` / `update!`, i.e. inside your model method |
252
252
 
253
253
  Both surface to the user, but **not identically**, and the difference should inform where you put a rule:
254
254
 
255
255
  - An **interaction** validation attaches to a declared attribute. The re-rendered modal shows it inline against that input, and again in the summary at the top of the form.
256
- - `failed(record.errors)` flattens `ActiveModel::Errors` into **full messages on `:base`** (`Array(errors)` calls `errors.to_a`, which is `full_messages`). Those land in the form's error summary only — never against a field — and they're phrased with the *model's* attribute names, which need not match your inputs.
256
+ - `failed(record.errors)` flattens `ActiveModel::Errors` into **full messages on `:base`** (`Array(errors)` calls `errors.to_a`, which is `full_messages`). Those land in the form's error summary only, never against a field, and they're phrased with the *model's* attribute names, which need not match your inputs.
257
257
 
258
258
  So it is fine, and often right, to *duplicate* a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: the moment a job calls `post.publish!`, the interaction's validations are not in the picture at all.
259
259
 
@@ -268,7 +268,7 @@ def execute
268
268
  end
269
269
  ```
270
270
 
271
- This one is *correctly* inline. "Who clicked the button" is context the presentation layer holds and nothing else does — `current_user` is read straight off the `view_context`. A job has no answer for it, so there is no second caller to extract for.
271
+ This one is *correctly* inline. "Who clicked the button" is context the presentation layer holds and nothing else does: `current_user` is read straight off the `view_context`. A job has no answer for it, so there is no second caller to extract for.
272
272
 
273
273
  ## Interaction types
274
274
 
@@ -306,7 +306,7 @@ class BulkArchiveInteraction < Plutonium::Resource::Interaction
306
306
  end
307
307
  ```
308
308
 
309
- `update_all` stays inline on purpose: it's a single-statement SQL update whose *whole point* is skipping per-record model machinery. If archiving means more than setting a column — callbacks, an audit row, a webhook — this is the wrong shape; call `resources.each(&:archive!)` and let the model own it.
309
+ `update_all` stays inline on purpose: it's a single-statement SQL update whose *whole point* is skipping per-record model machinery. If archiving means more than setting a column (callbacks, an audit row, a webhook), this is the wrong shape; call `resources.each(&:archive!)` and let the model own it.
310
310
 
311
311
  Per-record authorization details in [Resource › Actions › Bulk action](/reference/resource/actions#bulk-action).
312
312
 
@@ -327,10 +327,10 @@ end
327
327
 
328
328
  ## Calling interactions directly
329
329
 
330
- The controller handles this for interactive actions. You can also call one by hand — chiefly in **tests**, where you're exercising the interaction itself.
330
+ The controller handles this for interactive actions. You can also call one by hand, chiefly in **tests**, where you're exercising the interaction itself.
331
331
 
332
332
  ::: tip Needing this in a job or a rake task is the signal to refactor
333
- Both entry points require `view_context:`, and a job doesn't have one. If you find yourself reaching for a stub to satisfy it, you don't want the interaction — you want the model method it wraps. See [What an interaction is for](#what-an-interaction-is-for).
333
+ Both entry points require `view_context:`, and a job doesn't have one. If you find yourself reaching for a stub to satisfy it, you don't want the interaction, you want the model method it wraps. See [What an interaction is for](#what-an-interaction-is-for).
334
334
  :::
335
335
 
336
336
  ### Class method
@@ -352,14 +352,14 @@ interaction = PublishPost.new(view_context: view_context, resource: post)
352
352
  outcome = interaction.call
353
353
  ```
354
354
 
355
- The `view_context:` argument is required — interactions use it to access controller helpers and the current user. It is also the boundary marker: everything reachable *only* through an interaction is reachable only from a page.
355
+ The `view_context:` argument is required: interactions use it to access controller helpers and the current user. It is also the boundary marker: everything reachable *only* through an interaction is reachable only from a page.
356
356
 
357
357
  ## Immediate vs form
358
358
 
359
359
  | Interaction shape | Behavior |
360
360
  |---|---|
361
- | Only `:resource` / `:resources` (no extra `attribute` or `input`) | **Immediate** — browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom"` or `confirmation: false` on the action. |
362
- | Additional `attribute` / `input` declared | **Form** — renders modal form first; no auto-confirmation. |
361
+ | Only `:resource` / `:resources` (no extra `attribute` or `input`) | **Immediate**: browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom"` or `confirmation: false` on the action. |
362
+ | Additional `attribute` / `input` declared | **Form**: renders modal form first; no auto-confirmation. |
363
363
 
364
364
  See [Resource › Actions › Immediate vs form](/reference/resource/actions#immediate-vs-form).
365
365
 
@@ -368,15 +368,15 @@ See [Resource › Actions › Immediate vs form](/reference/resource/actions#imm
368
368
  `resource_url_for` with the `interaction:` kwarg. The action type (record / bulk / resource) is inferred from the element and the presence of `ids:`:
369
369
 
370
370
  ```ruby
371
- # Record action — instance argument
371
+ # Record action: instance argument
372
372
  resource_url_for(@post, interaction: :publish)
373
373
  # => /posts/:id/record_actions/publish
374
374
 
375
- # Resource action — class, no ids
375
+ # Resource action: class, no ids
376
376
  resource_url_for(Post, interaction: :import)
377
377
  # => /posts/resource_actions/import
378
378
 
379
- # Bulk action — class + ids
379
+ # Bulk action: class + ids
380
380
  resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
381
381
  # => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3
382
382
 
@@ -384,11 +384,11 @@ resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
384
384
  resource_url_for(@post, parent: @user, interaction: :publish)
385
385
  ```
386
386
 
387
- The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.
387
+ The same URL serves GET (form/confirmation) and POST (commit), the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.
388
388
 
389
389
  ## Complete example
390
390
 
391
- Inviting a user is a textbook second-caller case — a seats-provisioning job, an admin rake task and a signup API all need to send the same invitation. So the operation lives on `Company`, and the interaction is the button in front of it.
391
+ Inviting a user is a textbook second-caller case: a seats-provisioning job, an admin rake task and a signup API all need to send the same invitation. So the operation lives on `Company`, and the interaction is the button in front of it.
392
392
 
393
393
  ```ruby
394
394
  # app/models/company.rb
@@ -419,7 +419,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
419
419
  input :email
420
420
  input :role, as: :select, choices: -> { UserInvite.roles.keys }
421
421
 
422
- # Input shape — is this a readable email, is this a role that exists?
422
+ # Input shape: is this a readable email, is this a role that exists?
423
423
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
424
424
  validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
425
425
  validate :not_already_invited
@@ -460,12 +460,12 @@ RSpec.describe PublishPost do
460
460
  end
461
461
  ```
462
462
 
463
- See [Testing](/reference/testing/) for Plutonium's built-in testing helpers — `ResourceInteraction` concern wraps these patterns.
463
+ See [Testing](/reference/testing/) for Plutonium's built-in testing helpers. The `ResourceInteraction` concern wraps these patterns.
464
464
 
465
465
  ## Related
466
466
 
467
- - [Async Interactions](./async-interactions) — `async` a persisted run instead of running `execute` inline
468
- - [Resource › Actions](/reference/resource/actions) — registering interactions, inferred visibility, immediate vs form
469
- - [Policies](./policies) — `def <action>?` authorization methods
470
- - [Controllers](./controllers) — `resource_url_for(..., interaction: …)` URL generation
471
- - [UI › Forms](/reference/ui/forms) — customizing the modal form rendered for actions with inputs
467
+ - [Async Interactions](./async-interactions): `async` a persisted run instead of running `execute` inline
468
+ - [Resource › Actions](/reference/resource/actions): registering interactions, inferred visibility, immediate vs form
469
+ - [Policies](./policies): `def <action>?` authorization methods
470
+ - [Controllers](./controllers): `resource_url_for(..., interaction: …)` URL generation
471
+ - [UI › Forms](/reference/ui/forms): customizing the modal form rendered for actions with inputs
@@ -11,20 +11,20 @@ Authorization for resources. Built on [ActionPolicy](https://actionpolicy.evilma
11
11
 
12
12
  - **`create?` and `read?` default to `false`.** You MUST override them explicitly. Everything else (`update?`, `destroy?`, `index?`, `show?`, …) derives from one of those.
13
13
  - **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detects; production raises.
14
- - **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly in your override. `super` is fine when extending a parent policy (e.g., a package base) that itself calls it. The runtime check verifies it was hit somewhere — not in this specific class.
14
+ - **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly in your override. `super` is fine when extending a parent policy (e.g., a package base) that itself calls it. The runtime check verifies it was hit somewhere, not in this specific class.
15
15
  - **For `has_cents` fields, use the virtual name** (`:price`), NEVER `:price_cents`.
16
16
  - **Don't put `*_attributes` hashes in `permitted_attributes_for_*`.** Nested forms are extracted from the form definition, not the policy. List the association name (`:variants`) and the `nested_input` in the definition handles the rest.
17
17
  - **Custom action ⇒ policy method.** `action :publish` needs `def publish?`. Undefined methods return `false` → action silently disappears.
18
- - **Index has no `record`.** Record-dependent `_for_read` overrides need an explicit `_for_index` too (see [below](#index-has-no-record)).
18
+ - **Index has no `record` instance.** On collection routes `record` is the resource class. Record-dependent `_for_read` overrides need an explicit `_for_index` too (see [below](#index-has-no-record)).
19
19
 
20
20
  ## Base class
21
21
 
22
22
  ```ruby
23
- # app/policies/resource_policy.rb — installed once
23
+ # app/policies/resource_policy.rb: installed once
24
24
  class ResourcePolicy < Plutonium::Resource::Policy
25
25
  end
26
26
 
27
- # app/policies/post_policy.rb — per resource, generated
27
+ # app/policies/post_policy.rb: per resource, generated
28
28
  class PostPolicy < ResourcePolicy
29
29
  def create? = user.present?
30
30
  def read? = true
@@ -46,7 +46,7 @@ Inside a policy:
46
46
  | Variable | Description |
47
47
  |---|---|
48
48
  | `user` | Current authenticated user (required) |
49
- | `record` | Resource being authorized |
49
+ | `record` | Resource being authorized (the resource class on collection routes such as `index` and `new`) |
50
50
  | `entity_scope` | Current scoped entity (multi-tenancy) |
51
51
  | `parent` | Parent record for nested resources (nil otherwise) |
52
52
  | `parent_association` | Association name on parent (e.g. `:comments`) |
@@ -88,7 +88,7 @@ def archive? = create? && !record.archived?
88
88
  def invite_user? = user.admin?
89
89
  ```
90
90
 
91
- ### Bulk actions — per-record authorization
91
+ ### Bulk actions: per-record authorization
92
92
 
93
93
  ```ruby
94
94
  def bulk_archive?
@@ -101,7 +101,7 @@ How it works:
101
101
  - Policy is checked **per record** in the selected set.
102
102
  - **Backend:** if any record fails, the entire request is rejected.
103
103
  - **UI:** only actions ALL selected records support are shown (intersection).
104
- - Records come from `current_authorized_scope` — users can only select records they're allowed to access.
104
+ - Records come from `current_authorized_scope`, so users can only select records they're allowed to access.
105
105
 
106
106
  ## Attribute permissions
107
107
 
@@ -122,6 +122,7 @@ end
122
122
  |---|---|
123
123
  | `permitted_attributes_for_update` | `permitted_attributes_for_create` |
124
124
  | `permitted_attributes_for_index` | `permitted_attributes_for_read` |
125
+ | `permitted_attributes_for_export` | `permitted_attributes_for_index` |
125
126
  | `permitted_attributes_for_show` | `permitted_attributes_for_read` |
126
127
  | `permitted_attributes_for_new` | `permitted_attributes_for_create` |
127
128
  | `permitted_attributes_for_edit` | `permitted_attributes_for_update` |
@@ -140,7 +141,7 @@ end
140
141
 
141
142
  ### Index has no `record`
142
143
 
143
- 🚨 `permitted_attributes_for_index` is evaluated at the **collection level** — `record` is `nil`. `permitted_attributes_for_show` (and `_for_read`) ARE evaluated per record.
144
+ 🚨 `permitted_attributes_for_index` is evaluated at the **collection level**. There is no record instance: the policy subject is the resource **class**, so `record == Post`. The same class-bound policy feeds the table, the CSV export (`_for_export` defaults to `_for_index`) and kanban cards. `permitted_attributes_for_show` (and `_for_read`) ARE evaluated per record.
144
145
 
145
146
  If you write a record-dependent `_for_read`:
146
147
 
@@ -152,7 +153,7 @@ def permitted_attributes_for_read
152
153
  end
153
154
  ```
154
155
 
155
- …you MUST also define an explicit `permitted_attributes_for_index` — otherwise inheritance kicks in, runs the `_for_read` body during the table render, and `record.archived?` blows up on `NoMethodError: undefined method 'archived?' for nil`.
156
+ …you MUST also define an explicit `permitted_attributes_for_index` whose body never touches `record`. Otherwise `_for_index` falls back to `_for_read`, runs it against the class during the table render, and `record.archived?` raises `NoMethodError` (`undefined method 'archived?'` for the `Post` class).
156
157
 
157
158
  ```ruby
158
159
  def permitted_attributes_for_index
@@ -160,7 +161,7 @@ def permitted_attributes_for_index
160
161
  end
161
162
  ```
162
163
 
163
- Same rule for `permitted_attributes_for_create` vs `_for_new` (new has no persisted record).
164
+ Same rule for `permitted_attributes_for_create` vs `_for_new`: `new` is a collection route too, so `record` is the class there.
164
165
 
165
166
  ### Conditional attribute access
166
167
 
@@ -184,7 +185,7 @@ end
184
185
 
185
186
  `permitted_attributes_for_*` controls **which fields appear** on a view. The definition's `field`/`input`/`display`/`column` declarations only control **how** they render. A `field :name` in the definition does nothing unless `:name` is also in the relevant `permitted_attributes_for_*`.
186
187
 
187
- Common mistake: adding a definition declaration and wondering why the field doesn't show — check the policy.
188
+ Common mistake: adding a definition declaration and wondering why the field doesn't show. Check the policy.
188
189
 
189
190
  ### Anti-pattern: nested-attributes hashes
190
191
 
@@ -229,17 +230,34 @@ def permitted_associations
229
230
  end
230
231
  ```
231
232
 
232
- Declares which associations get their own **tab on the show page**. When non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs. If `permitted_attributes_for_show` resolves to **no fields**, the empty Details tab is omitted and the first association tab leads instead.
233
+ Declares which associations get their own **tab on the show page**. When non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association, each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs. If `permitted_attributes_for_show` resolves to **no fields**, the empty Details tab is omitted and the first association tab leads instead.
233
234
 
234
235
  Each named association must:
235
236
 
236
237
  - Exist on the model (raises `ArgumentError: unknown association ...` otherwise).
237
238
  - Point to a class that's itself a registered Plutonium resource (raises `... is not a registered resource` otherwise).
238
239
 
240
+ 🚨 **"Registered" means registered in the portal rendering the page.** Each portal engine keeps its own resource register, so a policy shared by several portals can only list associations whose class every one of those portals registers (`register_resource` in its `config/routes.rb`). Otherwise that portal's show page raises instead of dropping the tab:
241
+
242
+ ```
243
+ ArgumentError: Catalog::Product#product_metadata defined in #permitted_associations, but Catalog::ProductMetadata is not a registered resource
244
+ ```
245
+
246
+ To give only one portal the tab, leave the shared policy alone and add the association in a [portal-specific policy](#portal-specific-policies):
247
+
248
+ ```ruby
249
+ # rails g pu:res:conn Catalog::Product --dest=admin_portal --policy
250
+ class AdminPortal::Catalog::ProductPolicy < ::Catalog::ProductPolicy
251
+ include AdminPortal::ResourcePolicy
252
+
253
+ def permitted_associations = [*super, :product_metadata]
254
+ end
255
+ ```
256
+
239
257
  This is **NOT** the same as:
240
258
 
241
- - **Nested forms** — declared with `nested_input :variants` in the definition, requires `accepts_nested_attributes_for` on the model. See [Resource › Definition › Nested inputs](/reference/resource/definition#nested-inputs).
242
- - **Association fields on tables / show details** — controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
259
+ - **Nested forms**: declared with `nested_input :variants` in the definition, requires `accepts_nested_attributes_for` on the model. See [Resource › Definition › Nested inputs](/reference/resource/definition#nested-inputs).
260
+ - **Association fields on tables / show details**: controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
243
261
 
244
262
  ## Collection scoping (`relation_scope`)
245
263
 
@@ -247,10 +265,10 @@ Filter which records the user can see.
247
265
 
248
266
  ### Always compose with `default_relation_scope`
249
267
 
250
- 🚨 `relation_scope` MUST end up calling `default_relation_scope(relation)` somewhere in the chain. `super` works — `Plutonium::Resource::Policy` defines a default scope block that calls `default_relation_scope`, so a subclass that does `super(relation).where(...)` is fine. Calling `default_relation_scope` explicitly is also fine (and required when you skip the parent chain). Plutonium enforces this at runtime via `verify_default_relation_scope_applied!`.
268
+ 🚨 `relation_scope` MUST end up calling `default_relation_scope(relation)` somewhere in the chain. `super` works: `Plutonium::Resource::Policy` defines a default scope block that calls `default_relation_scope`, so a subclass that does `super(relation).where(...)` is fine. Calling `default_relation_scope` explicitly is also fine (and required when you skip the parent chain). Plutonium enforces this at runtime via `verify_default_relation_scope_applied!`.
251
269
 
252
270
  ```ruby
253
- # ✅ Best — don't override at all. The inherited scope already calls default_relation_scope.
271
+ # ✅ Best: don't override at all. The inherited scope already calls default_relation_scope.
254
272
 
255
273
  # ✅ Extra filters on top
256
274
  relation_scope do |relation|
@@ -267,13 +285,13 @@ end
267
285
  ### Wrong patterns
268
286
 
269
287
  ```ruby
270
- # ❌ Manually filtering by entity — bypasses default_relation_scope
288
+ # ❌ Manually filtering by entity, bypasses default_relation_scope
271
289
  relation_scope { |r| r.where(organization: current_scoped_entity) }
272
290
 
273
- # ❌ Manual joins — same problem
291
+ # ❌ Manual joins, same problem
274
292
  relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
275
293
 
276
- # ❌ Missing default_relation_scope entirely — raises at runtime
294
+ # ❌ Missing default_relation_scope entirely, raises at runtime
277
295
  relation_scope { |r| r.where(published: true) }
278
296
  ```
279
297
 
@@ -282,13 +300,13 @@ relation_scope { |r| r.where(published: true) }
282
300
  1. If a **parent** is present (nested resource), scopes via the parent association.
283
301
  2. Otherwise, applies `relation.associated_with(entity_scope)` for multi-tenancy.
284
302
 
285
- Parent scoping takes precedence over entity scoping — the parent was already authorized and entity-scoped during its own authorization, so double-scoping isn't needed.
303
+ Parent scoping takes precedence over entity scoping. The parent was already authorized and entity-scoped during its own authorization, so double-scoping isn't needed.
286
304
 
287
305
  Full mechanics in [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
288
306
 
289
307
  ### Intentionally skipping
290
308
 
291
- Rare. Use `skip_default_relation_scope!` explicitly — never silently bypass:
309
+ Rare. Use `skip_default_relation_scope!` explicitly, never silently bypass:
292
310
 
293
311
  ```ruby
294
312
  relation_scope do |relation|
@@ -301,12 +319,14 @@ Before reaching for this, consider a separate, unscoped portal.
301
319
 
302
320
  ## Portal-specific policies
303
321
 
322
+ Generate the override with `rails g pu:res:conn <Resource> --dest=<portal> --policy` (`--policy` forces the file even when a base policy exists). It subclasses the base policy and includes the portal's `ResourcePolicy`, so override only what differs and call `super` for the rest.
323
+
304
324
  ```ruby
305
325
  class PostPolicy < ResourcePolicy
306
326
  def create? = user.present?
307
327
  end
308
328
 
309
- # Admin — more permissive
329
+ # Admin: more permissive
310
330
  class AdminPortal::PostPolicy < ::PostPolicy
311
331
  include AdminPortal::ResourcePolicy
312
332
 
@@ -314,7 +334,7 @@ class AdminPortal::PostPolicy < ::PostPolicy
314
334
  def permitted_attributes_for_create = %i[title content featured internal_notes]
315
335
  end
316
336
 
317
- # Public — read-only
337
+ # Public: read-only
318
338
  class PublicPortal::PostPolicy < ::PostPolicy
319
339
  include PublicPortal::ResourcePolicy
320
340
  def create? = false
@@ -410,8 +430,8 @@ policy.permitted_attributes_for_update
410
430
 
411
431
  ## Related
412
432
 
413
- - [Controllers](./controllers) — call policies via `authorize_current!` and `authorized_resource_scope`
414
- - [Interactions](./interactions) — custom actions whose policy methods you define
415
- - [Resource › Actions](/reference/resource/actions) — registering actions that need policy methods
416
- - [Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — `default_relation_scope`, three model shapes, custom scopes
417
- - [ActionPolicy docs](https://actionpolicy.evilmartians.io/) — the underlying library
433
+ - [Controllers](./controllers): call policies via `authorize_current!` and `authorized_resource_scope`
434
+ - [Interactions](./interactions): custom actions whose policy methods you define
435
+ - [Resource › Actions](/reference/resource/actions): registering actions that need policy methods
436
+ - [Tenancy › Entity scoping](/reference/tenancy/entity-scoping): `default_relation_scope`, three model shapes, custom scopes
437
+ - [ActionPolicy docs](https://actionpolicy.evilmartians.io/): the underlying library
@@ -1,24 +1,27 @@
1
1
  # Configuration
2
2
 
3
- Plutonium is configured through `Plutonium.configure` in an initializer. A generated app has this at `config/initializers/plutonium.rb`:
3
+ Plutonium is configured through `Plutonium.configure` in an initializer. `pu:core:install` writes `config/initializers/plutonium.rb`:
4
4
 
5
5
  ```ruby
6
- # config/initializers/plutonium.rb
6
+ # Configure plutonium
7
+
7
8
  Plutonium.configure do |config|
8
9
  config.load_defaults 1.0
9
10
 
10
- # config.shell = :modern
11
- # config.navii_host_url = "https://api.navii.dev"
12
- # config.auto_eager_load_collections = true
13
-
14
- config.assets.logo = "plutonium.png"
15
- config.assets.favicon = "plutonium.ico"
16
- config.assets.stylesheet = "plutonium.css"
17
- config.assets.script = "plutonium.min.js"
11
+ # Shell variant: :modern (icon rail), :plain (no rail), or :classic (legacy).
12
+ config.shell = :modern
13
+ # Configure plutonium above.
18
14
  end
19
15
  ```
20
16
 
21
- Access the live config anywhere via `Plutonium.configuration`.
17
+ Everything else is opt-in. Read the live config anywhere via `Plutonium.configuration`, and query development mode with `Plutonium.configuration.development?`.
18
+
19
+ Other generators edit this file in place. `pu:core:assets` rewrites two lines to point at your own bundles:
20
+
21
+ ```ruby
22
+ config.assets.stylesheet = "application"
23
+ config.assets.script = "application"
24
+ ```
22
25
 
23
26
  ## Versioned defaults
24
27
 
@@ -26,41 +29,95 @@ Access the live config anywhere via `Plutonium.configuration`.
26
29
  config.load_defaults 1.0
27
30
  ```
28
31
 
29
- Loads the baseline defaults for a given framework version. Call this first; later versions layer their changes on top. Read the resolved version with `config.defaults_version`.
32
+ Applies the baseline defaults for a framework version, and every earlier version in order. Call it first, before any option you set yourself, or the defaults overwrite you. `1.0` is currently the only version. Read back what resolved with `config.defaults_version`, which is `nil` until you call this.
33
+
34
+ Passing a version older than the earliest available raises rather than silently applying nothing.
35
+
36
+ ## Core
37
+
38
+ | Option | Default | Description |
39
+ |--------|---------|-------------|
40
+ | `load_defaults(version)` | - | Apply versioned framework defaults. Call first. |
41
+ | `development` | `ENV["PLUTONIUM_DEV"]` | Development mode for the framework itself (local assets, hot reload, verbose errors). Query with `config.development?`. Apps rarely set this, see [Development mode](#development-mode). |
42
+ | `cache_discovery` | `true` outside the `development` env | Cache resource/route discovery. Disable to pick up new resources without a reboot. |
43
+ | `enable_hotreload` | `true` in the `development` env | Hot-reload Plutonium components on change. |
30
44
 
31
- ## Options
45
+ ## Appearance
32
46
 
33
47
  | Option | Default | Description |
34
48
  |--------|---------|-------------|
35
- | `load_defaults(version)` | — | Apply versioned framework defaults. Call first. |
36
- | `development` | `ENV["PLUTONIUM_DEV"]` | Development mode for the framework itself (local assets, hot reload, verbose errors). Query with `config.development?`. You rarely set this in an app — see [Development mode](#development-mode). |
37
- | `cache_discovery` | `true` outside `development` env | Cache resource/route discovery. Disable to pick up new resources without a reboot. |
38
- | `enable_hotreload` | `true` in `development` env | Hot-reload Plutonium components on change. |
39
49
  | `shell` | `:modern` | Chrome style: `:modern` (topbar + icon rail), `:plain` (topbar, no icon rail), or `:classic` (legacy header + sidebar, only for upgrades). See [Layouts](./ui/layouts). |
50
+ | `default_page_width` | `:md` | Width of detail-style pages: the show page and resource forms. One of `:sm` `:md` `:lg` `:xl` `:full` (`:full` opts out of any constraint). Index and table pages are unaffected. Override per-resource with `page_width` / `form_width` / `display_width`, see [Definition › Page width](./resource/definition#page-width). |
40
51
  | `navii_host_url` | `"https://api.navii.dev"` | Host of the [Navii](https://navii.dev) avatar service used by [`Avatar`](./ui/components#avatar). The component appends `/avatar/:seed`. Repoint to self-host or proxy. |
41
- | `auto_eager_load_collections` | `true` | Index pages, kanban boards and CSV exports preload the associations and attachments they render. Set `false` to disable globally, or override `auto_eager_load_collections?` in a controller. See [Performance](/guides/performance). |
42
- | `default_page_width` | `:md` | Width of detail-style pages — the show page and resource forms. One of `:sm :md :lg :xl :full` (`:full` = unconstrained). Index and table pages are unaffected. Override per-resource with `page_width` / `form_width` / `display_width`; see [Definition › Page width](./resource/definition#page-width). |
43
- | `wizards.width` | `:md` | Default width of wizard step pages. **Independent of `default_page_width`** — a wizard is a self-contained flow, so widening resource pages leaves wizards alone. Override per wizard with `width`. Same size tokens. |
44
- | `nested_association_routes` | `:detected` | Where a resource's nested routes come from. `:detected` draws one for every `has_many` / `has_one` whose child is registered. `:declared` draws only what `register_resource ..., associations:` names, so a resource that names none gets none. See [Nested resources › Declaring which associations get routes](./tenancy/nested-resources#declaring-which-associations-get-routes). |
45
52
  | `assets.logo` | `"plutonium.png"` | Brand logo asset. See [Assets](./ui/assets). |
46
53
  | `assets.favicon` | `"plutonium.ico"` | Favicon asset. |
47
- | `assets.stylesheet` | `"plutonium.css"` | Stylesheet entry. |
48
- | `assets.script` | `"plutonium.min.js"` | JavaScript entry. |
54
+ | `assets.stylesheet` | `"plutonium.css"` | Stylesheet entry. `pu:core:assets` sets this to `"application"`. |
55
+ | `assets.script` | `"plutonium.min.js"` | JavaScript entry. `pu:core:assets` sets this to `"application"`. |
56
+
57
+ ## Rendering and routing
58
+
59
+ | Option | Default | Description |
60
+ |--------|---------|-------------|
61
+ | `auto_eager_load_collections` | `true` | Index pages, kanban boards and CSV exports preload the associations and attachments they render. The field set comes from the policy, so it is known before the collection loads. Set `false` to disable globally, or override `auto_eager_load_collections?` in a controller. See [Performance](/guides/performance). |
62
+ | `nested_association_routes` | `:detected` | Where a resource's nested routes come from. `:detected` draws one for every `has_many` / `has_one` whose child is a registered resource. `:declared` draws only what `register_resource ..., associations:` names, so a resource that names none gets none. Any other value raises `ArgumentError` rather than drawing the wrong route table for a typo. See [Nested resources](./tenancy/nested-resources#declaring-which-associations-get-routes). |
63
+ | `default_currency_unit` | `nil` | Symbol used when rendering a currency value with no unit set on `has_cents` or the display. `nil` falls back to the i18n `number.currency.format.unit` when the locale defines one, otherwise no symbol. Set a literal like `"£"`, or `false` (or `""`) for no symbol application-wide. See [`has_cents`](./resource/model#has-cents) and [Currency fields](./ui/forms#currency-fields). |
64
+ | `default_phone_country` | `nil` | Default country (ISO2, e.g. `"gh"`) for `as: :phone` inputs that set no `initial_country:`. `nil` leaves it to intl-tel-input, with no country preselected. Stored verbatim; `config.normalized_default_phone_country` returns it downcased, so `"GH"` and `"gh"` are interchangeable. See [Phone fields](./ui/forms#phone-fields). |
65
+
66
+ ## Attachments
67
+
68
+ `attachment_backend` picks which library stages a file that travels as a plain string: `:active_storage` or `:shrine`. It is the shared default, and each subsystem layers its own override on top, so setting it once covers both and setting one of theirs narrows it to that subsystem.
69
+
70
+ | Option | Default | Description |
71
+ |--------|---------|-------------|
72
+ | `attachment_backend` | `nil` | Shared default for staged attachments. `nil` auto-detects. |
73
+ | `wizards.attachment_backend` | `nil` | Overrides the above for wizard attachment fields. `nil` falls through. |
74
+ | `async_interactions.attachment_backend` | `nil` | Overrides the above for run dispatch. `nil` falls through. |
75
+
76
+ Resolution runs first match wins:
77
+
78
+ 1. The field's own `backend:` option.
79
+ 2. The subsystem setting, `wizards.attachment_backend` or `async_interactions.attachment_backend`.
80
+ 3. `config.attachment_backend`.
81
+ 4. Auto-detection: `:shrine` if `ActiveShrine` is loaded, else `:active_storage`.
82
+
83
+ Only plain uploads are affected. A direct-upload field already arrives as a token and ignores all of this.
84
+
85
+ ## Wizards
86
+
87
+ `enabled` gates the subsystem and its migrations. Full detail in [Wizards › Storage & config](./wizard/storage-config).
88
+
89
+ | Option | Default | Description |
90
+ |--------|---------|-------------|
91
+ | `wizards.enabled` | `false` | Enable wizards and their migrations. See [Enabling the subsystem](./wizard/storage-config#enabling-the-subsystem). |
92
+ | `wizards.width` | `:md` | Width of wizard step pages. **Independent of `default_page_width`**: a wizard is a self-contained flow, so widening resource pages leaves wizards where they are. Same size tokens. Override per wizard with `width`. |
93
+ | `wizards.cleanup_after` | `14.days` | How long completed and abandoned sessions are kept before `SweepJob` removes them. See [Cleanup & the SweepJob](./wizard/storage-config#cleanup-the-sweepjob). |
94
+ | `wizards.database` | `:primary` | Which database the wizard tables live in. |
95
+ | `wizards.encrypt_data` | `false` | Encrypt every wizard's staged `data` at rest. Off by default because it needs ActiveRecord encryption keys configured. A wizard can still opt in with `encrypt_data` or out with `encrypt_data false` regardless. See [Encryption](./wizard/storage-config#encryption). |
96
+ | `wizards.attachment_backend` | `nil` | See [Attachments](#attachments). |
97
+
98
+ ## Async interactions
99
+
100
+ `enabled` gates the runs subsystem and its migrations. Full detail in [Async interactions](./behavior/async-interactions).
101
+
102
+ | Option | Default | Description |
103
+ |--------|---------|-------------|
104
+ | `async_interactions.enabled` | `false` | Enable persisted runs and their migrations. See [Enabling](./behavior/async-interactions#enabling). |
105
+ | `async_interactions.queue` | `:default` | ActiveJob queue for run jobs. |
106
+ | `async_interactions.stall_after` | `1.hour` | How long a run may sit with no progress write before `ReapJob` treats it as stalled. See [Stalled runs and ReapJob](./behavior/async-interactions#stalled-runs-and-reapjob). |
107
+ | `async_interactions.attachment_backend` | `nil` | See [Attachments](#attachments). |
49
108
 
50
109
  ## Development mode
51
110
 
52
- `config.development?` is driven by the `PLUTONIUM_DEV` environment variable, not set in the initializer. It’s primarily for working **on the Plutonium gem** (uses local `src/` assets, enables hot reloading, and shows more detailed errors). Applications generally leave it unset.
111
+ `config.development?` is driven by the `PLUTONIUM_DEV` environment variable, not by the initializer. It is for working **on the Plutonium gem**: it uses local `src/` assets, enables hot reloading, and shows more detailed errors. Applications leave it unset.
53
112
 
54
113
  ```bash
55
114
  export PLUTONIUM_DEV=1
56
115
  ```
57
116
 
58
- ## Assets
59
-
60
- Asset entries live under `config.assets` and point the framework at your compiled stylesheet/script and brand imagery. The `pu:core:assets` generator wires these up. See [Assets](./ui/assets) for the full asset/Tailwind/Stimulus setup.
61
-
62
117
  ## Related
63
118
 
64
- - [Assets](./ui/assets) — stylesheet, script, Tailwind, and design tokens
65
- - [Layouts](./ui/layouts) — the `shell` option and ejecting chrome
66
- - [Components › Avatar](./ui/components#avatar) — `navii_host_url`
119
+ - [Assets](./ui/assets): stylesheet, script, Tailwind, and design tokens
120
+ - [Layouts](./ui/layouts): the `shell` option and ejecting chrome
121
+ - [Components › Avatar](./ui/components#avatar): `navii_host_url`
122
+ - [Wizards › Storage & config](./wizard/storage-config): the `wizards.*` settings in context
123
+ - [Async interactions](./behavior/async-interactions): the `async_interactions.*` settings in context