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
data/docs/guides/index.md CHANGED
@@ -25,11 +25,12 @@ aside: false
25
25
  { name: 'Nested resources', link: '/plutonium-core/guides/nested-resources' },
26
26
  { name: 'Multi-tenancy', link: '/plutonium-core/guides/multi-tenancy' },
27
27
  { name: 'Search & filtering', link: '/plutonium-core/guides/search-filtering' },
28
- { name: 'Wizards', desc: 'Multi-step flows — onboarding, checkout, branching create.', link: '/plutonium-core/guides/wizards' },
29
- { name: 'Kanban boards', desc: 'Drag-and-drop board view — columns, moves, positioning, WIP limits.', link: '/plutonium-core/guides/kanban' },
28
+ { name: 'Wizards', desc: 'Multi-step flows: onboarding, checkout, branching create.', link: '/plutonium-core/guides/wizards' },
29
+ { name: 'Kanban boards', desc: 'Drag-and-drop board view: columns, moves, positioning, WIP limits.', link: '/plutonium-core/guides/kanban' },
30
+ { name: 'Dashboards', desc: 'Metric, chart and free-form cards, each loaded in its own lazy turbo frame.', link: '/plutonium-core/guides/dashboards' },
30
31
  ]},
31
32
  { group: 'Customization', items: [
32
- { name: 'Customizing the UI', desc: 'A map of the override surface — pages, forms, displays, tables, components, layouts.', link: '/plutonium-core/guides/customizing-ui' },
33
+ { name: 'Customizing the UI', desc: 'A map of the override surface: pages, forms, displays, tables, components, layouts.', link: '/plutonium-core/guides/customizing-ui' },
33
34
  { name: 'Theming', desc: 'Design tokens and brand colors.', link: '/plutonium-core/guides/theming' },
34
35
  ]},
35
36
  { group: 'Quality', items: [
@@ -1,29 +1,29 @@
1
1
  # Kanban Boards
2
2
 
3
3
  ::: warning Experimental
4
- Kanban boards are experimental — the DSL and behavior may change in a future release.
4
+ Kanban boards are experimental: the DSL and behavior may change in a future release.
5
5
  :::
6
6
 
7
- Turn any resource index into a drag-and-drop kanban board — columns, WIP limits, quick-add, column actions, and opt-in realtime — all from a single `kanban do…end` block in your definition.
7
+ Turn any resource index into a drag-and-drop kanban board: columns, WIP limits, quick-add, column actions, and opt-in realtime, all from a single `kanban do…end` block in your definition.
8
8
 
9
- ![A kanban board grouped by status — cards with badges, a WIP badge on Pending, a quick-add button, and collapsible columns](/images/guides/kanban-board.png)
9
+ ![A kanban board grouped by status: cards with badges, a WIP badge on Pending, a quick-add button, and collapsible columns](/images/guides/kanban-board.png)
10
10
 
11
11
  ## What you get
12
12
 
13
13
  - Drag cards between columns; the server persists the column change and the position within the column.
14
- - Decimal fractional positioning — cards always land exactly where you drop them without renumbering.
14
+ - Decimal fractional positioning: cards always land exactly where you drop them without renumbering.
15
15
  - Per-column `+ Add` button opens the resource's normal new form; the new card is placed in that column (`on_enter` + positioning applied post-create).
16
16
  - Column actions run an interaction against all (or visible) cards in a column.
17
17
  - WIP limits, locked columns, and cross-column drop restrictions enforced server-side.
18
18
  - Opt-in realtime: every connected viewer sees the same board state after any move.
19
19
 
20
- ## Worked example — Task board
20
+ ## Worked example: Task board
21
21
 
22
- A complete board for a `Task` model grouped by status — migration, model, definition, and policy.
22
+ A complete board for a `Task` model grouped by status, migration, model, definition, and policy.
23
23
 
24
24
  ### 1. Migration
25
25
 
26
- The model needs a `decimal` position column. Use the **`t.position`** helper — it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't pick a scale too small to rebalance cleanly (see [Positioning › Migration](/reference/kanban/positioning#migration)).
26
+ The model needs a `decimal` position column. Use the **`t.position`** helper, it adds a `decimal` column already tuned for fractional ordering (`precision: 16, scale: 8`), so you can't pick a scale too small to rebalance cleanly (see [Positioning › Migration](/reference/kanban/positioning#migration)).
27
27
 
28
28
  ```ruby
29
29
  class CreateTasks < ActiveRecord::Migration[8.1]
@@ -102,25 +102,25 @@ class TaskPolicy < ResourcePolicy
102
102
  end
103
103
  ```
104
104
 
105
- ### 5. Routes — no changes needed
105
+ ### 5. Routes: no changes needed
106
106
 
107
107
  The `kanban_move` member route is wired automatically when the controller includes `Plutonium::Resource::Controllers::KanbanActions` (included by default in all Plutonium resource controllers).
108
108
 
109
109
  Visit the resource index and use the view switcher to select the Kanban view.
110
110
 
111
- ![After dragging a card from Doing to Done — both column frames re-render and the WIP badge on Doing updates in place](/images/guides/kanban-after-move.png)
111
+ ![After dragging a card from Doing to Done: both column frames re-render and the WIP badge on Doing updates in place](/images/guides/kanban-after-move.png)
112
112
 
113
113
  ### When a drop is rejected
114
114
 
115
- If a move is refused server-side — the destination is at its `wip:` limit, its `accepts:` policy rejects the card, or the source column is `locked:` — the card snaps back to where it started **and** a dismissable toast explains why:
115
+ If a move is refused server-side: the destination is at its `wip:` limit, its `accepts:` policy rejects the card, or the source column is `locked:`, then the card snaps back to where it started **and** a dismissable toast explains why:
116
116
 
117
117
  ![A warning toast reading “Pending” is at its WIP limit (5) after a rejected drop](/images/guides/kanban-wip-toast.png)
118
118
 
119
- The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check — most commonly a WIP-full column or a `kanban_move?` denial.
119
+ The toast is appended to a `#kanban-flash` region in the board shell (outside the per-column frames, so it survives the snap-back re-render). The client-side drag hints already grey out columns a card plainly can't enter, so the toast mainly surfaces the cases the browser can't pre-check, most commonly a WIP-full column or a `kanban_move?` denial.
120
120
 
121
121
  ### Opening a card
122
122
 
123
- Clicking a card opens its show page. Where it opens is controlled by [`show_in`](/reference/kanban/dsl#show_in) — full-page by default, or a **centered modal** that keeps the board visible behind it:
123
+ Clicking a card opens its show page. Where it opens is controlled by [`show_in`](/reference/kanban/dsl#show_in), full-page by default, or a **centered modal** that keeps the board visible behind it:
124
124
 
125
125
  ![A card's show page open in a centered modal over the board, with an expand icon to open the full page](/images/guides/kanban-show-centered-modal.png)
126
126
 
@@ -135,14 +135,14 @@ end
135
135
  ```
136
136
 
137
137
  - Set `show_in :modal` on the **definition** to open show in a modal from the table, grid, and board alike. Set it on the **kanban block** to change only the board. An unset board inherits the definition (which defaults to `:page`).
138
- - The show modal is always **centered** — distinct from `new`/`edit`, which follow the definition's `modal_mode` (a slideover by default).
138
+ - The show modal is always **centered**: distinct from `new`/`edit`, which follow the definition's `modal_mode` (a slideover by default).
139
139
  - From inside the modal, an expand icon opens the record's full page in a new tab. ⌘/Ctrl-click (or middle-click) on a card does the same directly.
140
140
 
141
141
  ---
142
142
 
143
- ## Worked example — Status enum board
143
+ ## Worked example: Status enum board
144
144
 
145
- A shorter example that groups by a Rails enum for status. Cards reuse `grid_fields` for their slot layout — no explicit `card_fields` needed.
145
+ A shorter example that groups by a Rails enum for status. Cards reuse `grid_fields` for their slot layout, so no explicit `card_fields` needed.
146
146
 
147
147
  ```ruby
148
148
  class KitchenSinkDefinition < ResourceDefinition
@@ -168,7 +168,7 @@ Key points:
168
168
  - `role: :backlog` enables the `+ Add` button (equivalent to `add: true`).
169
169
  - `wip: 5` caps the Pending column; a cross-column drop that would push it past 5 is rejected server-side.
170
170
  - `role: :done` collapses the Archived column by default and shows a green header dot.
171
- - `on_enter` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_enter` returns — you do not need to call `update!` explicitly.
171
+ - `on_enter` here assigns the attribute in memory (`ks.status = :active`). The framework calls `record.save!` automatically when the record has unsaved changes after `on_enter` returns; you do not need to call `update!` explicitly.
172
172
 
173
173
  ---
174
174
 
@@ -195,7 +195,7 @@ Use `columns do…end` when the column list depends on request context (`current
195
195
  ```ruby
196
196
  kanban do
197
197
  columns do
198
- # `self` is the view_context — current_user, params, helpers all work.
198
+ # `self` is the view_context: current_user, params, helpers all work.
199
199
  current_user.projects.map do |project|
200
200
  Plutonium::Kanban::Column.new(
201
201
  :"project_#{project.id}",
@@ -228,7 +228,7 @@ class TaskDefinition < ResourceDefinition
228
228
  end
229
229
  ```
230
230
 
231
- Note that **`enter_interaction:` is not supported on dynamic boards** — its hidden action is registered from the static column list at class-load time, and its key is internal (column-scoped) so it can't be registered manually the way a column action can. A drop into such a column snaps back rather than committing (it doesn't crash). Use a static board if a column needs an `enter_interaction:`.
231
+ Note that **`enter_interaction:` is not supported on dynamic boards**: its hidden action is registered from the static column list at class-load time, and its key is internal (column-scoped) so it can't be registered manually the way a column action can. A drop into such a column snaps back rather than committing (it doesn't crash). Use a static board if a column needs an `enter_interaction:`.
232
232
  :::
233
233
 
234
234
  ### Column options
@@ -236,11 +236,11 @@ Note that **`enter_interaction:` is not supported on dynamic boards** — its hi
236
236
  | Option | Type | Default | Description |
237
237
  |--------|------|---------|-------------|
238
238
  | `label:` | String | `key.to_s.titleize` | Column header text |
239
- | `color:` | Symbol or String | `nil` | Dot color in the column header — `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
239
+ | `color:` | Symbol or String | `nil` | Dot color in the column header, `:red`, `:orange`, `:amber`, `:yellow`, `:green`, `:blue`, `:purple`, `:pink`, `:gray`, or a raw CSS value |
240
240
  | `scope:` | Symbol or Proc | `nil` | Filters the resource relation to this column's cards. Symbol → named scope; Proc → 0-arg lambda called with `instance_exec` on the relation (e.g. `-> { where(status: "todo") }`) |
241
241
  | `on_enter:` | Symbol or Proc | `nil` | Called when a card lands in this column. Symbol → `record.public_send(sym)`; Proc → 1-arg lambda `->(record) { … }` where `self` is the view context |
242
- | `on_exit:` | Symbol or Proc | `nil` | Source-side counterpart to `on_enter:` — called when a card **leaves** this column on a cross-column move, before the destination's `on_enter`, in the same transaction. For source-tied side effects (stop a timer, release a slot). Drag-moves only (not destroy/programmatic/quick-add); skipped on same-column reorders |
243
- | `enter_interaction:` | Class | `nil` | Record-scoped interaction run on a cross-column drop into this column — opens a modal to collect input, then commits atomically. See [Interaction on drop](#interaction-on-drop) |
242
+ | `on_exit:` | Symbol or Proc | `nil` | Source-side counterpart to `on_enter:`: called when a card **leaves** this column on a cross-column move, before the destination's `on_enter`, in the same transaction. For source-tied side effects (stop a timer, release a slot). Drag-moves only (not destroy/programmatic/quick-add); skipped on same-column reorders. Use a model callback instead only when other save paths (edit form, API, import) must be covered too, and then drop the `on_exit:` so the work isn't done twice. |
243
+ | `enter_interaction:` | Class | `nil` | Record-scoped interaction run on a cross-column drop into this column; opens a modal to collect input, then commits atomically. See [Interaction on drop](#interaction-on-drop) |
244
244
  | `role:` | `:backlog`, `:done`, `:lost` | `nil` | Preset shorthand (see below) |
245
245
  | `collapsed:` | Boolean | `false` | Start collapsed |
246
246
  | `add:` | Boolean | `false` | Show `+ Add` quick-add button |
@@ -256,7 +256,7 @@ Note that **`enter_interaction:` is not supported on dynamic boards** — its hi
256
256
  | `:done` | `color: :green, collapsed: true` |
257
257
  | `:lost` | `color: :red, collapsed: true` |
258
258
 
259
- `:done` and `:lost` are the two terminal roles (both collapsed by default) — the
259
+ `:done` and `:lost` are the two terminal roles (both collapsed by default), the
260
260
  won/lost pair for pipelines like leads, deals, or tickets; the colour signals the
261
261
  outcome.
262
262
 
@@ -284,8 +284,8 @@ column :done,
284
284
  end
285
285
  ```
286
286
 
287
- - `on: :all` — passes IDs of **all** cards in the column (ignoring `per_column`).
288
- - `on: :visible` — passes IDs of only the rendered, `per_column`-capped cards.
287
+ - `on: :all`: passes IDs of **all** cards in the column (ignoring `per_column`).
288
+ - `on: :visible`: passes IDs of only the rendered, `per_column`-capped cards.
289
289
 
290
290
  Column actions are rendered as buttons in the column header. They open the normal interactive-action modal (with form, authorization, success/failure handling) pre-loaded with the column's card IDs.
291
291
 
@@ -293,7 +293,7 @@ Column actions are rendered as buttons in the column header. They open the norma
293
293
 
294
294
  ## Interaction on drop
295
295
 
296
- A column can declare `enter_interaction:` to run an authorization-aware, input-collecting [Interaction](/reference/behavior/interactions) when a card is dropped **into** it from another column. Use it when entering a column needs more than a membership flip — a reason, a notification email, an audit entry.
296
+ A column can declare `enter_interaction:` to run an authorization-aware, input-collecting [Interaction](/reference/behavior/interactions) when a card is dropped **into** it from another column. Use it when entering a column needs more than a membership flip, a reason, a notification email, an audit entry.
297
297
 
298
298
  ```ruby
299
299
  column :lost,
@@ -301,13 +301,13 @@ column :lost,
301
301
  enter_interaction: MarkLostInteraction
302
302
  ```
303
303
 
304
- `enter_interaction:` takes an **Interaction class**. It must be **record-scoped** — it declares `attribute :resource` and acts on the single dropped card. A bulk (`attribute :resources`) interaction is not valid here; that shape is for [column actions](#column-actions).
304
+ `enter_interaction:` takes an **Interaction class**. It must be **record-scoped**: it declares `attribute :resource` and acts on the single dropped card. A bulk (`attribute :resources`) interaction is not valid here; that shape is for [column actions](#column-actions).
305
305
 
306
- The interaction is **auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), so two columns can reuse the same interaction class without colliding. "Hidden" means it does **not** appear as an action button on the show page, table rows, or grid cards — it is reachable only by dropping a card into the column.
306
+ The interaction is **auto-registered as a hidden record action** under a column-scoped key (`:lost` → `:lost_enter_interaction`), so two columns can reuse the same interaction class without colliding. "Hidden" means it does **not** appear as an action button on the show page, table rows, or grid cards, it is reachable only by dropping a card into the column.
307
307
 
308
308
  ### The interaction
309
309
 
310
- A drop interaction is an ordinary record-scoped interaction — nothing kanban-specific in the class:
310
+ A drop interaction is an ordinary record-scoped interaction with nothing kanban-specific in the class:
311
311
 
312
312
  ```ruby
313
313
  class MarkLostInteraction < ResourceInteraction
@@ -330,7 +330,7 @@ end
330
330
 
331
331
  ### Authorization
332
332
 
333
- The drop is authorized by the single **`kanban_move?`** predicate — the interaction has **no policy method of its own**. To gate this specific transition, branch on the destination column, which `kanban_move?` reads from its authorization context (`kanban_to`):
333
+ The drop is authorized by the single **`kanban_move?`** predicate, the interaction has **no policy method of its own**. To gate this specific transition, branch on the destination column, which `kanban_move?` reads from its authorization context (`kanban_to`):
334
334
 
335
335
  ```ruby
336
336
  class TaskPolicy < ResourcePolicy
@@ -341,7 +341,7 @@ class TaskPolicy < ResourcePolicy
341
341
  end
342
342
  ```
343
343
 
344
- This keeps authorization in one place: `kanban_move?` gates every move, and the `to` (and `from`) column context lets it gate a specific transition — no per-interaction predicate, no `condition:` proc. If the check fails the drop is refused and the card stays put. See [Authorization](../reference/kanban/authorization) for the full `from`/`to` context.
344
+ This keeps authorization in one place: `kanban_move?` gates every move, and the `to` (and `from`) column context lets it gate a specific transition: no per-interaction predicate, no `condition:` proc. If the check fails the drop is refused and the card stays put. See [Authorization](../reference/kanban/authorization) for the full `from`/`to` context.
345
345
 
346
346
  ### Two flows, split by intent
347
347
 
@@ -353,9 +353,9 @@ This keeps authorization in one place: `kanban_move?` gates every move, and the
353
353
  A column can declare `on_enter:` and `enter_interaction:` together. When it does:
354
354
 
355
355
  - `on_enter` owns the **membership attribute** (the column's grouping value, e.g. `status`).
356
- - `enter_interaction` owns the **extras** — the reason, the mail, the audit trail.
356
+ - `enter_interaction` owns the **extras**: the reason, the mail, the audit trail.
357
357
 
358
- If the interaction also writes the membership attribute it **must set the same value** `on_enter` sets (idempotent). In this dummy-app example the `:blocked` column does exactly that — `on_enter` sets `status = "blocked"` and the interaction's `execute` re-asserts `status: "blocked"` while adding the reason:
358
+ If the interaction also writes the membership attribute it **must set the same value** `on_enter` sets (idempotent). In this dummy-app example the `:blocked` column does exactly that, `on_enter` sets `status = "blocked"` and the interaction's `execute` re-asserts `status: "blocked"` while adding the reason:
359
359
 
360
360
  ```ruby
361
361
  column :blocked,
@@ -364,25 +364,25 @@ column :blocked,
364
364
  enter_interaction: BlockTaskInteraction
365
365
  ```
366
366
 
367
- When a column declares **only** a `enter_interaction` (no `on_enter`, like `:lost` above), the interaction owns everything — including the membership write — because there is no `on_enter` to do it.
367
+ When a column declares **only** a `enter_interaction` (no `on_enter`, like `:lost` above), the interaction owns everything, including the membership write, because there is no `on_enter` to do it.
368
368
 
369
369
  ### Same-column drops run positioning only
370
370
 
371
- Reordering a card **within** its current column runs positioning only. Neither `on_enter` nor the `enter_interaction` fires — both represent *entering* a column, and a same-column reorder is not an entry. Only cross-column drops trigger them.
371
+ Reordering a card **within** its current column runs positioning only. Neither `on_enter` nor the `enter_interaction` fires; both represent *entering* a column, and a same-column reorder is not an entry. Only cross-column drops trigger them.
372
372
 
373
373
  ### Atomicity and failure
374
374
 
375
- Interaction validation failure rolls the **whole transaction back** — the membership write included — and re-renders the modal with errors. The move context is preserved, so the user can fix the input and resubmit. Nothing is persisted on failure. Keep side-effects on `deliver_later` (mailers, jobs): a rolled-back failure then sends no stray mail, because the enqueue never commits.
375
+ Interaction validation failure rolls the **whole transaction back**, the membership write included, and re-renders the modal with errors. The move context is preserved, so the user can fix the input and resubmit. Nothing is persisted on failure. Keep side-effects on `deliver_later` (mailers, jobs): a rolled-back failure then sends no stray mail, because the enqueue never commits.
376
376
 
377
377
  ### Success feedback and the response limitation
378
378
 
379
379
  On success the board's column frames re-render and the modal closes. The interaction's success **message** (`succeed(resource).with_message("Marked as lost")`) is surfaced as a toast.
380
380
 
381
381
  ::: warning Custom success responses are not honored on the drop path
382
- A drop interaction's custom success *response* — `with_redirect_response`, `with_file_response`, etc. — is **not** honored when it runs from a drop: the board simply re-renders and closes the modal. Keep drop interactions to simple state + extras mutations, and use `.with_message` for feedback.
382
+ A drop interaction's custom success *response*, `with_redirect_response`, `with_file_response`, etc., is **not** honored when it runs from a drop: the board simply re-renders and closes the modal. Keep drop interactions to simple state + extras mutations, and use `.with_message` for feedback.
383
383
  :::
384
384
 
385
- There is no card "snap-back" to worry about on cancel — native drag never moves the card's DOM node, so canceling the modal just closes it and the card stays where it was.
385
+ There is no card "snap-back" to worry about on cancel: native drag never moves the card's DOM node, so canceling the modal just closes it and the card stays where it was.
386
386
 
387
387
  ---
388
388
 
@@ -390,41 +390,41 @@ There is no card "snap-back" to worry about on cancel — native drag never move
390
390
 
391
391
  By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
392
392
 
393
- 1. A `decimal` database column — use the `t.position` helper (`precision: 16, scale: 8`). Hand-rolling it, keep `scale` at **8 or more**: `scale: 6` exactly matches the `1e-6` rebalance threshold and the last subdivision can round into a neighbour.
393
+ 1. A `decimal` database column: use the `t.position` helper (`precision: 16, scale: 8`). Hand-rolling it, keep `scale` at **8 or more**: `scale: 6` exactly matches the `1e-6` rebalance threshold and the last subdivision can round into a neighbour.
394
394
  2. `include Plutonium::Positioning::Model` in the model.
395
- 3. `positioned_on :position, scope: :status` — the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
395
+ 3. `positioned_on :position, scope: :status`: the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
396
396
 
397
397
  ### Position modes
398
398
 
399
- `position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter — the board resolves this lazily.
399
+ `position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/resource/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter; the board resolves this lazily.
400
400
 
401
401
  ```ruby
402
402
  kanban do
403
- # Mode A (default) — delegate to Plutonium::Positioning::Model.
403
+ # Mode A (default): delegate to Plutonium::Positioning::Model.
404
404
  # Uses :position attribute, requires the model concern.
405
405
  position_on :position
406
406
 
407
407
  # Mode A with a custom attribute name:
408
408
  position_on :sort_order
409
409
 
410
- # Mode B — BYO positioning. The block receives a Move struct.
410
+ # Mode B: BYO positioning. The block receives a Move struct.
411
411
  # Use when you want to call a custom service or use a different ordering scheme.
412
412
  position_on :sort_order do |move|
413
- # move.record — the dropped record
414
- # move.column — the destination column key (Symbol)
415
- # move.prev — the record immediately before the drop slot (or nil)
416
- # move.next — the record immediately after the drop slot (or nil)
417
- # move.index — 0-based insertion index within the destination column
413
+ # move.record : the dropped record
414
+ # move.column : the destination column key (Symbol)
415
+ # move.prev : the record immediately before the drop slot (or nil)
416
+ # move.next : the record immediately after the drop slot (or nil)
417
+ # move.index : 0-based insertion index within the destination column
418
418
  MyPositioningService.call(move.record, prev: move.prev, next: move.next)
419
419
  end
420
420
 
421
- # Mode C — no ordering. Cards render in the relation's default order.
421
+ # Mode C: no ordering. Cards render in the relation's default order.
422
422
  # On-drop still fires; position is just never updated.
423
423
  position_on false
424
424
  end
425
425
  ```
426
426
 
427
- See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted, and [Positioning & drag-to-reorder](/reference/positioning) for the table/grid side of the same feature.
427
+ See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted, and [Positioning & drag-to-reorder](/reference/resource/positioning) for the table/grid side of the same feature.
428
428
 
429
429
  ---
430
430
 
@@ -445,7 +445,7 @@ Each column loads at most 25 cards. When the total exceeds the limit, a `+N more
445
445
 
446
446
  When `add: true` (or `role: :backlog`) is set on a column, a `+ Add` button appears in the column header. Clicking it opens the resource's normal new form in a modal.
447
447
 
448
- The record is created normally, and **then** the column's `on_enter` and positioning are applied to the **saved** record — so the new card lands in the clicked column, appended to the bottom. `on_enter` runs against a real, persisted record (exactly as it does for a drag), so `update!`-style callbacks and any side effects behave identically and fire once, on the actual create.
448
+ The record is created normally, and **then** the column's `on_enter` and positioning are applied to the **saved** record, so the new card lands in the clicked column, appended to the bottom. `on_enter` runs against a real, persisted record (exactly as it does for a drag), so `update!`-style callbacks and any side effects behave identically and fire once, on the actual create.
449
449
 
450
450
  ::: warning Give your grouping column a default
451
451
  Because `on_enter` runs **after** the record is saved, the record must be creatable **without** a grouping value. Give your grouping column (e.g. `status`) a database or model default. If it is `NOT NULL` with no default, quick-add create fails validation before `on_enter` can set it.
@@ -495,10 +495,10 @@ After a successful move, Plutonium broadcasts the updated column frames to all c
495
495
 
496
496
  Plutonium emits the `<turbo-cable-stream-source>` subscription element and broadcasts on the server, but the **client must have an ActionCable consumer** to receive it. Plutonium's bundled JavaScript ships `@hotwired/turbo` only (no cable client), so you must wire the rest up yourself:
497
497
 
498
- 1. **Gems** — `turbo-rails` and `actioncable` (Rails includes ActionCable; `turbo-rails` provides `Turbo::StreamsChannel` and `turbo_stream_from`).
499
- 2. **Cable adapter** (`config/cable.yml`) — `async` is fine for a single-process dev server; use **Redis** (or Solid Cable) for multi-process production, otherwise a broadcast from one worker won't reach clients connected to another.
500
- 3. **Mount ActionCable** — `mount ActionCable.server => "/cable"` (Rails mounts it by default when `action_cable/engine` is loaded).
501
- 4. **Load the cable client in your app's JavaScript** — this is the step most people miss. Add **one** of:
498
+ 1. **Gems**: `turbo-rails` and `actioncable` (Rails includes ActionCable; `turbo-rails` provides `Turbo::StreamsChannel` and `turbo_stream_from`).
499
+ 2. **Cable adapter** (`config/cable.yml`): `async` is fine for a single-process dev server; use **Redis** (or Solid Cable) for multi-process production, otherwise a broadcast from one worker won't reach clients connected to another.
500
+ 3. **Mount ActionCable**: `mount ActionCable.server => "/cable"` (Rails mounts it by default when `action_cable/engine` is loaded).
501
+ 4. **Load the cable client in your app's JavaScript**: this is the step most people miss. Add **one** of:
502
502
  ```js
503
503
  // app pack, alongside your other imports
504
504
  import "@hotwired/turbo-rails" // registers <turbo-cable-stream-source> + a consumer
@@ -511,7 +511,7 @@ Plutonium emits the `<turbo-cable-stream-source>` subscription element and broad
511
511
  Without this, the server broadcasts but no browser is subscribed, so other viewers won't update until they reload.
512
512
 
513
513
  ::: tip Verify it
514
- With two browser tabs on the same board, move a card in one — the other should update without a reload. If it doesn't, check the browser console/network for a `/cable` WebSocket connection; a missing connection means the cable client (step 4) isn't loaded.
514
+ With two browser tabs on the same board, move a card in one; the other should update without a reload. If it doesn't, check the browser console/network for a `/cable` WebSocket connection; a missing connection means the cable client (step 4) isn't loaded.
515
515
  :::
516
516
 
517
517
  ---
@@ -539,7 +539,7 @@ class TaskDefinition < ResourceDefinition
539
539
  # ...
540
540
  end
541
541
 
542
- # Call AFTER the kanban block — :kanban isn't a valid default until
542
+ # Call AFTER the kanban block: :kanban isn't a valid default until
543
543
  # `kanban` has enabled the view. Reversing the order raises ArgumentError
544
544
  # at class load.
545
545
  default_index_view :kanban
@@ -8,15 +8,15 @@ Each tenant sees only their own records. Queries are filtered, forms inject the
8
8
 
9
9
  ## 🚨 Critical
10
10
 
11
- - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain — explicitly here, or via `super(relation)` (the framework's `Plutonium::Resource::Policy` base calls it for you).
11
+ - **Never bypass `default_relation_scope`.** Overriding `relation_scope` with `where(organization: ...)` or manual joins triggers `verify_default_relation_scope_applied!` at runtime. Make sure `default_relation_scope(relation)` is called somewhere in the chain: explicitly here, or via `super(relation)` (the framework's `Plutonium::Resource::Policy` base calls it for you).
12
12
  - **Always declare an association path from the model to the entity.** Direct `belongs_to`, `has_one :through`, or a custom `associated_with_<entity>` scope. If `associated_with` can't resolve, fix the **model**, not the policy.
13
- - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}` — without this, uniqueness leaks across tenants.
13
+ - **Compound uniqueness scoped to the tenant FK.** `validates :code, uniqueness: {scope: :organization_id}`, without this, uniqueness leaks across tenants.
14
14
 
15
15
  After login, users with memberships in multiple entities land on a workspace selector:
16
16
 
17
17
  ![Workspace selector after login](/images/guides/multi-tenancy-welcome.png)
18
18
 
19
- Picking one lands them on the entity-scoped dashboard — note the entity slug in the URL:
19
+ Picking one lands them on the entity-scoped dashboard: note the entity slug in the URL:
20
20
 
21
21
  ![Tenant-scoped dashboard](/images/guides/multi-tenancy-dashboard.png)
22
22
 
@@ -62,14 +62,17 @@ end
62
62
 
63
63
  Or pass `--scope=Organization` to `pu:pkg:portal` and the engine wires this automatically.
64
64
 
65
- ### 4. Mount the portal
65
+ ### 4. Check the mount
66
+
67
+ `pu:pkg:portal` already mounted the engine in `packages/customer_portal/config/routes.rb`:
66
68
 
67
69
  ```ruby
68
- # config/routes.rb
69
70
  mount CustomerPortal::Engine, at: "/customer"
70
71
  ```
71
72
 
72
- URLs now include the entity id as the first path segment after the mount: `/customer/42/posts`. The underlying param name is `organization_scoped` (Plutonium suffixes `_scoped` to avoid a name collision with any `belongs_to :organization` on child models — `params[:organization_scoped]` vs `params[:organization]`). Pass `param_key:` to `scope_to_entity` if you want a different param name.
73
+ To change the path, edit `at:` there. Don't add a second `mount` to `config/routes.rb`; Rails raises `Invalid route name, already in use`.
74
+
75
+ URLs now include the entity id as the first path segment after the mount: `/customer/42/posts`. The underlying param name is `organization_scoped` (Plutonium suffixes `_scoped` to avoid a name collision with any `belongs_to :organization` on child models: `params[:organization_scoped]` vs `params[:organization]`). Pass `param_key:` to `scope_to_entity` if you want a different param name.
73
76
 
74
77
  ### 5. Compound uniqueness
75
78
 
@@ -82,6 +85,10 @@ end
82
85
 
83
86
  🚨 Without the `scope:`, the same slug in different orgs would collide.
84
87
 
88
+ ### 6. Leave the entity in the policy
89
+
90
+ `organization` can stay in `permitted_attributes_for_create`. In the scoped portal the controller removes it from the form and params and sets it from the current entity (a forged `organization_id` is overwritten), while an unscoped admin portal using the same policy still gets a normal select.
91
+
85
92
  ## Strategies
86
93
 
87
94
  ### Path strategy (default)
@@ -143,7 +150,7 @@ class Membership < ResourceRecord
143
150
  end
144
151
  ```
145
152
 
146
- ### 3. Grandchild — `has_one :through`
153
+ ### 3. Grandchild: `has_one :through`
147
154
 
148
155
  ```ruby
149
156
  class Post < ResourceRecord
@@ -187,9 +194,9 @@ relation_scope do |relation|
187
194
  end
188
195
  ```
189
196
 
190
- 🚨 `default_relation_scope(relation)` must be called somewhere in the chain — otherwise the runtime verification raises. `super(relation)` works when extending `Plutonium::Resource::Policy` directly (its block calls `default_relation_scope`); call `default_relation_scope` by name when you're not chaining via `super`.
197
+ 🚨 `default_relation_scope(relation)` must be called somewhere in the chain, otherwise the runtime verification raises. `super(relation)` works when extending `Plutonium::Resource::Policy` directly (its block calls `default_relation_scope`); call `default_relation_scope` by name when you're not chaining via `super`.
191
198
 
192
- ## Cross-tenant operations — super-admin portal
199
+ ## Cross-tenant operations: super-admin portal
193
200
 
194
201
  Create a separate portal **without** `scope_to_entity`:
195
202
 
@@ -197,7 +204,7 @@ Create a separate portal **without** `scope_to_entity`:
197
204
  module SuperAdminPortal
198
205
  class Engine < Rails::Engine
199
206
  include Plutonium::Portal::Engine
200
- # No scope_to_entity — sees all tenants
207
+ # No scope_to_entity: sees all tenants
201
208
  end
202
209
  end
203
210
  ```
@@ -206,14 +213,14 @@ This portal's policies see everything. Don't enable public signup here.
206
213
 
207
214
  ## Multiple associations to the same entity
208
215
 
209
- If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`), Plutonium raises:
216
+ If a model has two `belongs_to` to the entity class (e.g. `Match belongs_to :home_team, :away_team`), the controller raises:
210
217
 
211
218
  ```
212
219
  Match has multiple associations to Competition::Team: home_team, away_team.
213
220
  Plutonium cannot auto-detect which one to use for entity scoping.
214
221
  ```
215
222
 
216
- Override on the controller:
223
+ Override on the portal controller:
217
224
 
218
225
  ```ruby
219
226
  class MatchesController < ::ResourceController
@@ -222,18 +229,24 @@ class MatchesController < ::ResourceController
222
229
  end
223
230
  ```
224
231
 
232
+ Query scoping (`associated_with`) raises `AmbiguousAssociationError` too, so add an explicit scope on the model:
233
+
234
+ ```ruby
235
+ scope :associated_with_competition_team, ->(team) { where(home_team: team) }
236
+ ```
237
+
225
238
  ## Common issues
226
239
 
227
- - **`verify_default_relation_scope_applied!` raises** — your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
228
- - **`Could not resolve the association between 'Model' and 'Entity'`** — the model has no path to the entity. Fix on the **model** (declare `has_one :through` or a custom `associated_with_<entity>` scope). Never paper over with `where` in the policy.
229
- - **Records leak across tenants** — likely a missing compound-uniqueness scope on the model. Add `validates :code, uniqueness: {scope: :organization_id}`.
230
- - **Forms show the entity field anyway** — check `present_scoped_entity?` / `submit_scoped_entity?` on the controller (defaults are `false`).
231
- - **Want to bypass scoping in one place** — use `skip_default_relation_scope!` explicitly, NOT a silent `where` bypass.
240
+ - **`verify_default_relation_scope_applied!` raises**: your custom `relation_scope` doesn't call `default_relation_scope(relation)`. Fix by composing: `default_relation_scope(relation).where(...)`.
241
+ - **`Could not resolve the association between 'Model' and 'Entity'`**: the model has no path to the entity. Fix on the **model** (declare `has_one :through` or a custom `associated_with_<entity>` scope). Never paper over with `where` in the policy.
242
+ - **Records leak across tenants**: likely a missing compound-uniqueness scope on the model. Add `validates :code, uniqueness: {scope: :organization_id}`.
243
+ - **Forms show the entity field anyway**: check `present_scoped_entity?` / `submit_scoped_entity?` on the controller (defaults are `false`).
244
+ - **Want to bypass scoping in one place**: use `skip_default_relation_scope!` explicitly, NOT a silent `where` bypass.
232
245
 
233
246
  ## Related
234
247
 
235
- - [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — full surface
236
- - [Reference › Behavior › Policies](/reference/behavior/policies) — `relation_scope` syntax
237
- - [Reference › App › Portals](/reference/app/portals) — `scope_to_entity` engine config
238
- - [Nested resources](./nested-resources) — parent scoping (takes precedence over entity scoping)
239
- - [User invites](./user-invites) — invitation-based membership onboarding
248
+ - [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping): full surface
249
+ - [Reference › Behavior › Policies](/reference/behavior/policies): `relation_scope` syntax
250
+ - [Reference › App › Portals](/reference/app/portals): `scope_to_entity` engine config
251
+ - [Nested resources](./nested-resources): parent scoping (takes precedence over entity scoping)
252
+ - [User invites](./user-invites): invitation-based membership onboarding
@@ -11,7 +11,7 @@ Set up parent/child relationships so `/companies/:id/nested_properties` works au
11
11
  - Forms that auto-fill the parent (no manual hidden field).
12
12
  - Queries scoped to the parent (sibling companies' properties invisible).
13
13
 
14
- All of this happens with no manual route wiring — Plutonium generates it from the association.
14
+ All of this happens with no manual route wiring; Plutonium generates it from the association.
15
15
 
16
16
  ## Steps
17
17
 
@@ -65,7 +65,7 @@ Plutonium prefixes nested routes with `nested_` so they don't conflict with top-
65
65
  | `/companies/:company_id/nested_company_profile` | `has_one` show (no `:id`) |
66
66
  | `/companies/:company_id/nested_company_profile/new` | `has_one` new |
67
67
 
68
- `has_one` associations get singular routes — index redirects to show (or new if no record exists).
68
+ `has_one` associations get singular routes: index redirects to show (or new if no record exists).
69
69
 
70
70
  Every routable association gets one by default. To draw only some of them:
71
71
 
@@ -149,7 +149,7 @@ end
149
149
  ```
150
150
 
151
151
  ::: warning Always pass `as:`
152
- Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails — no named route to look up.
152
+ Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails: no named route to look up.
153
153
  :::
154
154
 
155
155
  ## Policy authorization context
@@ -167,13 +167,13 @@ class PropertyPolicy < ResourcePolicy
167
167
  end
168
168
  ```
169
169
 
170
- The parent is authorized for `:read?` before `current_parent` returns — children inherit the parent's access requirements.
170
+ The parent is authorized for `:read?` before `current_parent` returns; children inherit the parent's access requirements.
171
171
 
172
172
  ## Parent scoping vs entity scoping
173
173
 
174
- When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization — double-scoping isn't needed.
174
+ When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization; double-scoping isn't needed.
175
175
 
176
- In the child policy, just call `default_relation_scope` — it handles both cases:
176
+ In the child policy, just call `default_relation_scope`: it handles both cases:
177
177
 
178
178
  ```ruby
179
179
  relation_scope do |relation|
@@ -194,7 +194,7 @@ For deeper hierarchies, use top-level routes plus association tabs on the show p
194
194
 
195
195
  ## Nested inputs (sub-records inside a parent form)
196
196
 
197
- A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form — a single submit creates/updates/deletes them in one go, backed by Rails' `accepts_nested_attributes_for`.
197
+ A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form: a single submit creates/updates/deletes them in one go, backed by Rails' `accepts_nested_attributes_for`.
198
198
 
199
199
  Use nested inputs when the children are conceptually part of the parent (line items on an order, variants on a product, contact methods on a person) and don't deserve their own page.
200
200
 
@@ -214,7 +214,7 @@ class PostDefinition < ResourceDefinition
214
214
  end
215
215
  end
216
216
 
217
- # Policy — list the association name (NOT `comments_attributes`)
217
+ # Policy: list the association name (NOT `comments_attributes`)
218
218
  class PostPolicy < ResourcePolicy
219
219
  def permitted_attributes_for_create
220
220
  [:title, :body, :comments]
@@ -223,7 +223,7 @@ end
223
223
  ```
224
224
 
225
225
  ::: warning Permit the association, not the strong-params shape
226
- List `:comments` in `permitted_attributes_for_*` — Plutonium translates it to `comments_attributes: [...]` for you. If you write the raw hash, the form renders the field name as a literal label instead of the nested editor.
226
+ List `:comments` in `permitted_attributes_for_*`: Plutonium translates it to `comments_attributes: [...]` for you. If you write the raw hash, the form renders the field name as a literal label instead of the nested editor.
227
227
  :::
228
228
 
229
229
  ### Result
@@ -251,13 +251,13 @@ nested_input :profile, macro: :has_one # singular sub-form, no Add button
251
251
 
252
252
  | | Nested inputs (`nested_input :comments`) | Nested resources (this guide's main topic) |
253
253
  |---|---|---|
254
- | URL | None — inline in parent form | `/posts/:id/nested_comments` |
255
- | Submit | One — saves parent + children together | Independent CRUD per child |
254
+ | URL | None, inline in parent form | `/posts/:id/nested_comments` |
255
+ | Submit | One, saves parent + children together | Independent CRUD per child |
256
256
  | Discoverability | Always visible in parent form | Tab on parent show page (with `permitted_associations`) |
257
257
  | Best for | Tightly-owned children (line items, variants) | Children users browse on their own (orders, posts) |
258
258
  | Backing | `accepts_nested_attributes_for` | Plutonium's nested controller routing |
259
259
 
260
- You can use both on the same association — they're not mutually exclusive.
260
+ You can use both on the same association; they're not mutually exclusive.
261
261
 
262
262
  ## Inline `+` add on the parent form
263
263
 
@@ -265,15 +265,15 @@ When a form has an association select (e.g. picking the company on a Property fo
265
265
 
266
266
  ## Common issues
267
267
 
268
- - **Nested route doesn't exist** — both parent AND child must be registered in the same portal (`pu:res:conn`).
269
- - **Parent shows up in the form anyway** — check `present_parent?` / `submit_parent?` on the controller. Default is to hide on nested routes.
270
- - **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`) — Plutonium raises. Override `scoped_entity_association` to specify. See [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping#multiple-associations-to-the-same-entity-class).
271
- - **`resource_url_for` returns wrong URL for a nested resource** — check that custom routes use `as:`.
268
+ - **Nested route doesn't exist**: both parent AND child must be registered in the same portal (`pu:res:conn`).
269
+ - **Parent shows up in the form anyway**: check `present_parent?` / `submit_parent?` on the controller. Default is to hide on nested routes.
270
+ - **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`): give the parent one `has_many` per side (`has_many :home_matches, class_name: "Match", foreign_key: :home_team_id`). Each becomes its own nested route, and Plutonium fills in the matching `belongs_to` through `inverse_of` or the foreign key, so nothing raises. `scoped_entity_association` only matters when the parent class is also the portal's entity; see [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping#multiple-associations-to-the-same-entity-class).
271
+ - **`resource_url_for` returns wrong URL for a nested resource**: check that custom routes use `as:`.
272
272
 
273
273
  ## Related
274
274
 
275
- - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources) — full surface
276
- - [Reference › Behavior › Controllers](/reference/behavior/controllers) — `current_parent`, presentation hooks
277
- - [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions) — `permitted_associations`
278
- - [Multi-tenancy](./multi-tenancy) — how entity scoping interacts with parent scoping
279
- - [Adding resources](./adding-resources) — basic resource setup
275
+ - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources): full surface
276
+ - [Reference › Behavior › Controllers](/reference/behavior/controllers): `current_parent`, presentation hooks
277
+ - [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions): `permitted_associations`
278
+ - [Multi-tenancy](./multi-tenancy): how entity scoping interacts with parent scoping
279
+ - [Adding resources](./adding-resources): basic resource setup
@@ -20,7 +20,7 @@ Index pages, kanban boards and CSV exports already eager-load the associations a
20
20
 
21
21
  Each rendering passes its own field set, because they differ: the index renders its permitted attributes, an export renders `permitted_attributes_for_export`, and a kanban card renders its `card_fields`.
22
22
 
23
- It covers every association kind — `belongs_to`, `has_one`, `has_many` — and attachments on both ActiveStorage and Shrine.
23
+ It covers every association kind (`belongs_to`, `has_one`, `has_many`) and attachments on both ActiveStorage and Shrine.
24
24
 
25
25
  Turn it off globally:
26
26
 
@@ -99,6 +99,6 @@ Those need to leave the request rather than be optimised inside it. An interacti
99
99
 
100
100
  ## Related
101
101
 
102
- - **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables — see [Resource › Query](/reference/resource/query#search).
102
+ - **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables; see [Resource › Query](/reference/resource/query#search).
103
103
  - **Page size.** Query cost scales with rows per page.
104
- - [Async Interactions](/reference/behavior/async-interactions) — moving slow work out of the request
104
+ - [Async Interactions](/reference/behavior/async-interactions): moving slow work out of the request