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
@@ -0,0 +1,174 @@
1
+ ---
2
+ title: "Introducing Plutonium dashboards: metric cards and charts for your Rails admin"
3
+ titleTemplate: "Plutonium Blog"
4
+ date: 2026-10-02
5
+ description: Plutonium now ships dashboards. Declare metric, chart and free-form cards in one Ruby class, mount it with one routes line, and every card loads in its own turbo frame inside your portal's auth and tenancy.
6
+ author: Stefan Froelich
7
+ tags: [announcement, dashboards, charts]
8
+ ---
9
+
10
+ # Introducing Plutonium dashboards: metric cards and charts for your Rails admin
11
+
12
+ <BlogMeta />
13
+
14
+ Every portal Plutonium generates opens on a Dashboard page that lists your resources with a record count each. Anything past that, the numbers and charts an admin actually opens the app to see, was yours to build: a controller action, some instance variables, a view. Plutonium 0.65 ships it. A dashboard is a Ruby class of cards, mounted with one line in your routes.
15
+
16
+ ![A dashboard with four metric cards, an area chart of signups per day, a donut chart and a full-width welcome card](/images/blog/dashboards-overview.png)
17
+
18
+ ## What's in the box
19
+
20
+ - A class-level DSL with three kinds of card: `metric` for headline numbers, `chart` for charts, `card` for anything else.
21
+ - `register_dashboard`, which draws the routes and a controller that inherits your portal's authentication, tenant scoping and layout.
22
+ - Lazy loading: by default every card is fetched in its own turbo frame, behind a skeleton.
23
+ - A 12-column grid with a default width per kind of card, plus per-card refresh intervals, conditions, links and icons.
24
+ - A `pu:dashboard` generator, and a Dashboards group in the portal sidebar.
25
+
26
+ ## Generate one
27
+
28
+ ```bash
29
+ rails g pu:dashboard Sales --dest=admin_portal
30
+ ```
31
+
32
+ This writes `packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb` and adds the registration to the portal's routes:
33
+
34
+ ```ruby
35
+ AdminPortal::Engine.routes.draw do
36
+ register_dashboard AdminPortal::SalesDashboard, at: "sales"
37
+ end
38
+ ```
39
+
40
+ The page is served at `/admin/dashboards/sales`. Pass `--at=/` instead and the dashboard becomes the portal's root page, replacing the generated `root to: "dashboard#index"` (the old `DashboardController` and its view can then go).
41
+
42
+ ## Declare the cards
43
+
44
+ ```ruby
45
+ module AdminPortal
46
+ class SalesDashboard < Plutonium::Dashboard::Base
47
+ presents label: "Sales", icon: Phlex::TablerIcons::ChartBar
48
+
49
+ refresh 60
50
+
51
+ metric(:orders, icon: Phlex::TablerIcons::ShoppingCart, href: -> { resource_url_for(Order, parent: nil) }) do
52
+ {value: orders.where(created_at: 30.days.ago..).count,
53
+ previous: orders.where(created_at: 60.days.ago...30.days.ago).count,
54
+ change_label: "vs. previous 30 days"}
55
+ end
56
+
57
+ metric(:revenue, format: :currency) { orders.sum(:total) }
58
+
59
+ metric(:refund_rate, format: :percentage, precision: 1, positive: :down) do
60
+ {value: 2.1, previous: 1.8}
61
+ end
62
+
63
+ chart(:orders_per_day, type: :area, span: 8) do
64
+ orders.pluck(:created_at).map(&:to_date).tally.sort.to_h
65
+ end
66
+
67
+ chart(:by_status, type: :donut, span: 4) { orders.group(:status).count }
68
+
69
+ card(:latest, span: :full) do
70
+ ul { latest_orders.each { |order| li { order.number } } }
71
+ end
72
+
73
+ private
74
+
75
+ def orders = authorized_resource_scope(Order)
76
+
77
+ def latest_orders = orders.order(created_at: :desc).limit(5)
78
+ end
79
+ end
80
+ ```
81
+
82
+ **Metrics.** The block returns a number, or a hash. Give it a `previous` value and the card computes the percentage change and shows it with a trend arrow. `format:` is `:number`, `:currency`, `:percentage`, `:human` (1.23 Million) or a proc. `positive: :down` says a falling number is the good direction, so the refund rate above, up from 1.8 to 2.1, is styled as bad news.
83
+
84
+ **Charts.** The block returns [Chartkick data](https://chartkick.com/#data): a hash, an array of pairs, or an array of named series. `type:` is `:line`, `:area`, `:column`, `:bar`, `:pie`, `:donut` or `:scatter`, and every other option passes straight through to Chartkick.
85
+
86
+ **Custom cards.** The block renders Phlex markup inside the card, for a recent-activity list, a call to action, or whatever the first two do not cover.
87
+
88
+ **Layout.** The grid is 12 columns wide, so halves, thirds and quarters can share a dashboard. `span:` says how many a card takes. Left out, a metric takes 3 (four to a row) and a chart or custom card takes 6. Above, the area chart takes two thirds of its row and the donut the remaining third. Tablets drop to two columns and phones to one.
89
+
90
+ Blocks run on the dashboard instance, so private methods are how cards share a query. `authorized_resource_scope(Order)` is the same policy scope your resource pages use: the dashboard counts what this user may see, in this tenant, without restating either rule.
91
+
92
+ ## Every card loads on its own
93
+
94
+ The page response contains no query results. Each card renders as a lazy turbo frame holding a skeleton:
95
+
96
+ ```html
97
+ <turbo-frame id="pu-dashboard-card-orders" src="/admin/dashboards/sales/cards/orders" loading="lazy">
98
+ ```
99
+
100
+ The browser fetches each frame as it scrolls into view, and the card's block runs in that request. The page paints at once, the cheap cards fill in first, and one expensive aggregate delays itself and nothing else.
101
+
102
+ ![The same dashboard before its frames have loaded: every card is a grey skeleton except the Conversion metric, which is already showing 12.5%](/images/blog/dashboards-loading.png)
103
+
104
+ The Conversion card in that screenshot is declared `lazy: false`, which makes it inline: its block runs in the page request and the finished card is part of the page response, with no frame, no skeleton and no second request. The price is that the page waits for it. Use it for a number cheap enough that a round trip costs more than the query, and leave everything else lazy. An inline card never refreshes, since there is no frame to reload, and in development and test an exception in its block fails the whole page rather than one frame.
105
+
106
+ `refresh 60` on the dashboard reloads every lazy card once a minute. `refresh: 10` on a card overrides it, and `refresh: false` keeps an expensive card out of it. Reloads pause while the tab is hidden and catch up when it becomes visible again, so a dashboard left open overnight is not running your aggregates all night.
107
+
108
+ ## Who sees what
109
+
110
+ A portal dashboard sits behind the portal's login like every other page. To restrict it further, override `authorize?`; a false answer is a 403 from the page and from every card URL.
111
+
112
+ ```ruby
113
+ def authorize? = current_user.admin?
114
+ ```
115
+
116
+ `condition:` hides a single card. Because a card is an endpoint, the condition guards the endpoint too: a hidden card responds 404, so it cannot be fetched by guessing its key.
117
+
118
+ ```ruby
119
+ metric(:margin, format: :percentage, condition: -> { current_user.admin? }) { margin }
120
+ ```
121
+
122
+ On an entity-scoped portal the page and card URLs carry the tenant segment (`/org/1/dashboards/team`) and `current_scoped_entity` is available in every block:
123
+
124
+ ```ruby
125
+ class TeamDashboard < Plutonium::Dashboard::Base
126
+ metric(:members) { current_scoped_entity.memberships.count }
127
+ end
128
+ ```
129
+
130
+ ## When a card breaks
131
+
132
+ In development and test a card that raises, raises, and you fix the query. In production the card shows a short notice in place of its body and the rest of the dashboard is untouched.
133
+
134
+ ![A dashboard where the Revenue card shows a red "This card could not be loaded." notice while the metrics and the signups chart around it render normally](/images/blog/dashboards-card-error.png)
135
+
136
+ The failure is written to the Rails log with the dashboard class, the card key and the top of the backtrace, and it is reported through `Rails.error`:
137
+
138
+ ```ruby
139
+ Rails.error.report(
140
+ error,
141
+ handled: true,
142
+ source: "plutonium.dashboard",
143
+ context: {dashboard: "AdminPortal::SalesDashboard", card: "revenue"}
144
+ )
145
+ ```
146
+
147
+ Both happen because `Rails.error.report` notifies subscribers and writes nothing to the log. An app with no error tracker still gets a log line; an app with Sentry, Honeybadger or AppSignal subscribed gets a handled error it can group by card and alert on.
148
+
149
+ ## Charts without the bundle tax
150
+
151
+ Charts render with [Chart.js](https://www.chartjs.org/) through Chartkick, which is a lot of JavaScript for an app where most pages are tables and forms. It ships as a separate bundle. A chart card carries the bundle's URL in a data attribute and the `chart` Stimulus controller injects the script the first time a chart connects, so pages without a chart never download it.
152
+
153
+ Series colours come from the `--pu-chart-1` to `--pu-chart-8` design tokens and are re-read when the colour mode flips. Charts follow your theme in light and dark mode with no per-chart configuration.
154
+
155
+ ## In the sidebar
156
+
157
+ Registered dashboards are grouped under a Dashboards item in the portal sidebar, with a link per dashboard. A dashboard mounted at the root is what the Home link opens, so it stays out of the group.
158
+
159
+ ![The portal's icon rail with the Dashboards item open, listing Overview and Content](/images/blog/dashboards-sidebar.png)
160
+
161
+ If your portal has an ejected `_resource_sidebar.html.erb` from an earlier release, the [guide](/guides/dashboards#sidebar) has the block to add.
162
+
163
+ ## A note on URLs
164
+
165
+ Every dashboard is drawn under a `dashboards/` segment. A portal's URL space already belongs to `register_resource`, and a dashboard at a bare `/admin/sales` is one `Sale` model away from a route collision that Rails resolves silently, by order. If you want the bare path anyway, `prefix: nil` turns the segment off for that mount.
166
+
167
+ ## Experimental, for now
168
+
169
+ Dashboards ship marked experimental, like kanban, wizards and async interactions: fine to build on, but the DSL may still move before 1.0. If the API fights you, tell me. That feedback still changes things.
170
+
171
+ ## Where to go next
172
+
173
+ - [Dashboards guide](/guides/dashboards): the worked example, every card option, layout and translations.
174
+ - [Dashboard reference](/reference/dashboard/): the DSL, `register_dashboard` and the routes it draws, and the generator.
@@ -0,0 +1,90 @@
1
+ ---
2
+ title: "Introducing Plutonium i18n: every string translatable, the Rails way"
3
+ titleTemplate: "Plutonium Blog"
4
+ date: 2026-10-02
5
+ description: The text a framework renders has always been the framework's, not yours. Plutonium now routes every string it draws through a locale file, so translating the UI is a matter of YAML and nothing else.
6
+ author: Stefan Froelich
7
+ tags: [announcement, i18n, rails]
8
+ ---
9
+
10
+ # Introducing Plutonium i18n: every string translatable, the Rails way
11
+
12
+ <BlogMeta />
13
+
14
+ You have always been able to translate your own app. Rails has shipped `I18n.t`, per-model attribute names and localized dates since long before you needed them. What you could not translate was the framework on top. The button that says "Create", the "Search..." in the filter box, the flash that a record was saved, the empty-state line, the sentence under the paginator: an admin framework ships those in English, baked into views you adopted the framework precisely so you would never open. Translating them meant forking them.
15
+
16
+ Plutonium now routes every string it renders through a locale file, and resolves every label it derives from a key through the Rails i18n conventions you already know. Translating the UI is a matter of YAML. Nothing in a definition or a policy changes.
17
+
18
+ ## The strings come from YAML
19
+
20
+ Plutonium's own text lives under `config/locales/en/*.yml`, in a `plutonium` namespace, loaded at the lowest precedence. Rails' normal load order does the rest: your app's `config/locales` beats a portal's, a portal's beats the gem's. To reword a fixed string, or translate it, copy its key into your own file:
21
+
22
+ ```yaml
23
+ # config/locales/en.yml
24
+ en:
25
+ plutonium:
26
+ boolean:
27
+ "true": "On"
28
+ "false": "Off"
29
+ ```
30
+
31
+ That one move covers rewording the default "Yes" and "No" in English and translating them to another language. There is no second mechanism to learn for the second case.
32
+
33
+ ## The labels you never wrote translate too
34
+
35
+ The strings above are the easy half. The interesting half is the text a definition never spells out. An action, a scope, a filter, a kanban column, a field's placeholder: each one defaults to a humanized version of its key, and each has a convention key that a locale file can supply.
36
+
37
+ ```yaml
38
+ en:
39
+ plutonium:
40
+ actions:
41
+ blogging/post:
42
+ publish: "Publish now"
43
+ scopes:
44
+ blogging/post:
45
+ drafts: "Drafts"
46
+ fields:
47
+ blogging/post:
48
+ title:
49
+ placeholder: "A short, descriptive title"
50
+ hint: "Shown in search results"
51
+ ```
52
+
53
+ A field you never annotated is already translatable. You did not declare a placeholder to get one, and you do not declare a key to translate it: Plutonium looks it up by the model's `i18n_key`, the same way `human_attribute_name` walks ancestors, so a key on `blogging/post` also serves its STI subclasses. Model and attribute names flow from the standard Rails `activerecord` keys, which is why column headers, page titles and flashes come along the moment you provide them. Nothing is declared twice, and the exception, an explicit label you do want to spell out, gets a named place: the definition's own `t`, resolved per request in the right locale.
54
+
55
+ ## Switching locale is a Rails around_action
56
+
57
+ Plutonium reads `I18n.locale` on every render and never sets it, so you switch the way you would in any Rails app. The dummy app's admin portal wires an `EN | ES` toggle into the top bar and remembers the choice per session:
58
+
59
+ ```ruby
60
+ included do
61
+ around_action :switch_locale
62
+ end
63
+
64
+ def switch_locale(&)
65
+ session[:locale] = params[:locale] if params[:locale].present?
66
+ locale = session[:locale].to_s.to_sym
67
+ locale = I18n.default_locale unless I18n.available_locales.include?(locale)
68
+ I18n.with_locale(locale, &)
69
+ end
70
+ ```
71
+
72
+ Pick ES and the portal answers in Spanish: the heading, the search box, the filters and scopes, the column headers, the row and page actions, the status badges, the pager and the CRUD flashes. The heading translates because the definition sets it with the same lazy `t`, `index_page_title t("blog.index.title")`, that inputs and labels use. Two things stay English by design: the record data, because it is yours and not the framework's, and any label a definition hardcodes as a plain string, like the `column :user, label: "Author"` you can see in the shot sitting next to the translated `Autor`.
73
+
74
+ ![The dummy admin portal rendered in Spanish, with the EN | ES switch in the top bar](/images/blog/i18n-es-admin.png)
75
+
76
+ ## Even the parts that aren't Rails views
77
+
78
+ Two surfaces never touch Rails' server-side `t`, and both come along.
79
+
80
+ The Stimulus controllers bundled with the gem read their strings from a JSON blob the layout renders once per page in the current locale. Override a key in YAML and the bundled JavaScript's text changes with it, no rebuild. And pagination, which has its own dictionary, is translated by [Pagy](https://ddnexus.github.io/pagy/resources/i18n) in about thirty-five languages out of the box. Plutonium syncs Pagy's locale to `I18n.locale` on every render, so the same `around_action` that switched the chrome switched the pager, in the screenshot above, for free.
81
+
82
+ ## Ship a half-translated locale
83
+
84
+ You do not have to finish a language before you use it. The dummy demo translates the blog-posts screen and leaves the rest of the app, the dates, and Rodauth's own flashes to fall back to English through `config.i18n.fallbacks`. That matters more than it sounds: the dummy's test environment turns on `raise_on_missing_translations`, as many apps do, so a page under a half-done locale would otherwise blow up on the first key you had not reached yet. With fallbacks on, a found-via-fallback string is not missing. Translate the screens your users live on first, ship it, and fill in the rest as you go.
85
+
86
+ ## Adding a language
87
+
88
+ A full second locale is the usual Rails checklist: a [rails-i18n](https://github.com/svenfuchs/rails-i18n) locale for dates and numbers, a Pagy dictionary, `rodauth-i18n` if you use Rodauth, the `plutonium` namespace translated from the gem's English files, and your own models and attributes. Plutonium itself ships only English, and hands you the door to the rest.
89
+
90
+ The [internationalization reference](/reference/i18n) has the full key catalogue and the exact resolution order for every slot.
@@ -1,11 +1,10 @@
1
1
  ---
2
2
  title: "Introducing Plutonium: Rails conventions, past CRUD"
3
3
  titleTemplate: "Plutonium Blog"
4
- date: 2026-08-24
4
+ date: 2026-08-22
5
5
  description: Rails made a bargain. Follow the conventions and the framework carries you. Plutonium makes the same bargain about the layer Rails deliberately left alone.
6
6
  author: Stefan Froelich
7
7
  tags: [announcement, rails, architecture]
8
- draft: true
9
8
  ---
10
9
 
11
10
  # Introducing Plutonium: Rails conventions, past CRUD
@@ -146,11 +145,11 @@ input :content, as: :markdown
146
145
  display :status, as: :badge, colors: {published: :accent, draft: :neutral}
147
146
  ```
148
147
 
149
- **Render it inline.** A display takes a block, or a proc that runs inside a Phlex context, so tag methods and Tailwind classes are available without defining a class:
148
+ **Render it inline.** A display takes a block that runs inside a Phlex context, so tag methods and Tailwind classes are available without defining a class:
150
149
 
151
150
  ```ruby
152
- display :priority, as: :phlexi_render, with: ->(value, attrs) do
153
- span(class: "pu-badge") { value.humanize }
151
+ display :priority do |field|
152
+ span(class: "pu-badge") { field.value.to_s.humanize }
154
153
  end
155
154
  ```
156
155
 
@@ -119,7 +119,7 @@ See [Wizards](/guides/wizards) for the full DSL, including per-step writes, resu
119
119
 
120
120
  ## The thing underneath the board
121
121
 
122
- Kanban did not invent its ordering. It uses the same fractional positioning that powers [drag-to-reorder](/reference/positioning) on ordinary index tables, card grids and nested association tables. `positioned_on` on the model says how positions are stored, `position_on` in the definition says the list is orderable, and a drop writes exactly one decimal, the midpoint between its two neighbours. No renumbering sweep across the table.
122
+ Kanban did not invent its ordering. It uses the same fractional positioning that powers [drag-to-reorder](/reference/resource/positioning) on ordinary index tables, card grids and nested association tables. `positioned_on` on the model says how positions are stored, `position_on` in the definition says the list is orderable, and a drop writes exactly one decimal, the midpoint between its two neighbours. No renumbering sweep across the table.
123
123
 
124
124
  A board and a sortable table are the same feature wearing different clothes, which is why they share a vocabulary. Positioning itself is stable, not experimental.
125
125
 
@@ -9,7 +9,7 @@ rails new myapp -a propshaft -j esbuild -c tailwind \
9
9
  -m https://radioactive-labs.github.io/plutonium-core/templates/plutonium.rb
10
10
  ```
11
11
 
12
- This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium — plus Rodauth auth, asset pipeline, and initial migrations.
12
+ This sets up Rails with Propshaft, esbuild, TailwindCSS, and Plutonium, plus Rodauth auth, asset pipeline, and initial migrations.
13
13
 
14
14
  After the template completes:
15
15
 
@@ -62,7 +62,7 @@ For account options and customization, see [Reference › Auth](/reference/auth/
62
62
  rails generate pu:core:assets
63
63
  ```
64
64
 
65
- Installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers. Required if you want to customize the theme — see [Reference › UI › Assets](/reference/ui/assets) and [Guides › Theming](/guides/theming).
65
+ Installs npm packages, creates `tailwind.config.js` extending Plutonium's config, imports Plutonium CSS, registers Stimulus controllers (`registerControllers(application)`, which your own JS bundle must keep), and points `config.assets.stylesheet` / `script` at your `application` bundles. Until then the app serves the gem's prebuilt CSS and JS, so it is required for custom CSS, brand colors or your own Stimulus controllers. It needs `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` (an app created with `-j esbuild -c tailwind` plus Stimulus). See [Reference › UI › Assets](/reference/ui/assets) and [Guides › Theming](/guides/theming).
66
66
 
67
67
  ## Verify
68
68
 
@@ -99,6 +99,6 @@ bin/dev
99
99
 
100
100
  ## Next steps
101
101
 
102
- - [Tutorial](./tutorial/) — build a complete blog application step-by-step
103
- - [Adding resources](/guides/adding-resources) — create your first resource
104
- - [Creating packages](/guides/creating-packages) — organize code into feature and portal packages
102
+ - [Tutorial](./tutorial/): build a complete blog application step-by-step
103
+ - [Adding resources](/guides/adding-resources): create your first resource
104
+ - [Creating packages](/guides/creating-packages): organize code into feature and portal packages
@@ -118,7 +118,7 @@ rails db:prepare
118
118
 
119
119
  ## Creating a Portal
120
120
 
121
- Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away — we'll add authentication in [Chapter 3](./03-authentication).
121
+ Resources need a portal to be accessible via the web. Let's create a public admin portal so we can explore the UI right away; we'll add authentication in [Chapter 3](./03-authentication).
122
122
 
123
123
  ```bash
124
124
  rails generate pu:pkg:portal admin --public
@@ -149,9 +149,9 @@ Visit `http://localhost:3000/admin/blogging/posts`. You should see an empty post
149
149
 
150
150
  ![Empty posts index](/images/tutorial/02-empty-index.png)
151
151
 
152
- Click "New" — the form is automatically generated from your model's attributes. By default Plutonium opens it as a slideover (right) so you keep the index visible; visiting `/admin/blogging/posts/new` directly renders the same form as a standalone page (left):
152
+ Click "New": the form is automatically generated from your model's attributes. By default Plutonium opens it as a slideover (right) so you keep the index visible; visiting `/admin/blogging/posts/new` directly renders the same form as a standalone page (left):
153
153
 
154
- | Default — slideover from index | Standalone page (direct URL) |
154
+ | Default: slideover from index | Standalone page (direct URL) |
155
155
  |:--:|:--:|
156
156
  | ![Slideover new form](/images/tutorial/02-new-form-modal.png) | ![Standalone new form](/images/tutorial/02-new-form.png) |
157
157
 
@@ -14,7 +14,7 @@ Plutonium integrates Rodauth seamlessly with its portal system.
14
14
 
15
15
  ## Installing Rodauth
16
16
 
17
- Run the Plutonium Rodauth installer once per app — it creates the Rodauth app, plugin, and initializer:
17
+ Run the Plutonium Rodauth installer once per app. It creates the Rodauth app, plugin, and initializer:
18
18
 
19
19
  ```bash
20
20
  rails generate pu:rodauth:install
@@ -24,7 +24,7 @@ rails generate pu:rodauth:install
24
24
 
25
25
  ## Creating an Account Type
26
26
 
27
- Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator — it's a preset on top of `pu:rodauth:account` that enables 2FA, lockout, audit logging, and disables public signup:
27
+ Plutonium supports multiple account types. For admins, use the dedicated `pu:rodauth:admin` generator. It's a preset on top of `pu:rodauth:account` that enables 2FA, lockout, audit logging, and disables public signup:
28
28
 
29
29
  ```bash
30
30
  rails generate pu:rodauth:admin admin
@@ -71,8 +71,8 @@ rails generate pu:pkg:portal admin --auth=admin --force
71
71
 
72
72
  This updates two files:
73
73
 
74
- - `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb` — swaps `include Plutonium::Auth::Public` for `include Plutonium::Auth::Rodauth(:admin)`, giving you `current_user`, `logout_url`, and `profile_url` helpers throughout the portal.
75
- - `packages/admin_portal/config/routes.rb` — wraps the engine mount in a routes-level constraint:
74
+ - `packages/admin_portal/app/controllers/admin_portal/concerns/controller.rb`: swaps `include Plutonium::Auth::Public` for `include Plutonium::Auth::Rodauth(:admin)`, giving you `current_user`, `logout_url`, and `profile_url` helpers throughout the portal.
75
+ - `packages/admin_portal/config/routes.rb`: wraps the engine mount in a routes-level constraint:
76
76
 
77
77
  ```ruby
78
78
  constraints Rodauth::Rails.authenticate(:admin) do
@@ -80,9 +80,9 @@ This updates two files:
80
80
  end
81
81
  ```
82
82
 
83
- The routes constraint is what actually gates access — unauthenticated requests to `/admin/*` are redirected to `/admins/login` before they hit any controller or policy.
83
+ The routes constraint is what actually gates access: unauthenticated requests to `/admin/*` are redirected to `/admins/login` before they hit any controller or policy.
84
84
 
85
- (If you prefer not to regenerate, you can apply both edits by hand — they're shown above.)
85
+ (If you prefer not to regenerate, you can apply both edits by hand; they're shown above.)
86
86
 
87
87
  ## Testing Authentication
88
88
 
@@ -164,7 +164,7 @@ Now we can add the author relationship to our Post model. Generate a migration:
164
164
  rails generate migration AddUserToBloggingPosts user:belongs_to
165
165
  ```
166
166
 
167
- Update the migration to add the foreign key:
167
+ Update the migration to add the foreign key (keep the `Migration[x.y]` version Rails generated for your app):
168
168
 
169
169
  ```ruby
170
170
  class AddUserToBloggingPosts < ActiveRecord::Migration[8.0]
@@ -28,8 +28,13 @@ Let's implement basic CRUD permissions:
28
28
 
29
29
  ```ruby
30
30
  class Blogging::PostPolicy < Blogging::ResourcePolicy
31
- # Anyone can view published posts
31
+ # Anyone can open the list; relation_scope (below) decides which posts it holds
32
32
  def read?
33
+ true
34
+ end
35
+
36
+ # A single post: published, or your own
37
+ def show?
33
38
  record.published? || owner?
34
39
  end
35
40
 
@@ -56,6 +61,8 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
56
61
  end
57
62
  ```
58
63
 
64
+ `read?` also backs `index?`, which is checked against the `Blogging::Post` class rather than a post, so `record.published?` there would raise `NoMethodError` on the list page. Record-state checks go in `show?`, `update?` and `destroy?`, which always receive the post.
65
+
59
66
  ## Understanding the Policy Context
60
67
 
61
68
  Inside a policy, you have access to:
@@ -63,13 +70,13 @@ Inside a policy, you have access to:
63
70
  | Accessor | Description |
64
71
  |----------|-------------|
65
72
  | `user` | The current authenticated user |
66
- | `record` | The resource being authorized |
73
+ | `record` | The resource being authorized (the resource class on collection routes such as index and new) |
67
74
  | `entity_scope` | Parent record for scoping (e.g., Organization in multi-tenant apps) |
68
75
 
69
76
  ```ruby
70
77
  def some_permission?
71
78
  user # => Current user (from authentication)
72
- record # => The Post instance being checked
79
+ record # => The Post instance being checked (Blogging::Post on index/new)
73
80
  entity_scope # => Parent record for multi-tenancy (or nil)
74
81
  end
75
82
  ```
@@ -101,6 +108,12 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
101
108
  [:title] # Limited view for unpublished posts
102
109
  end
103
110
  end
111
+
112
+ # The table is built from the class, not a post, so this must not touch `record`
113
+ # (without it, index falls back to permitted_attributes_for_read and raises)
114
+ def permitted_attributes_for_index
115
+ [:title, :published, :created_at, :user]
116
+ end
104
117
  end
105
118
  ```
106
119
 
@@ -108,7 +121,7 @@ end
108
121
 
109
122
  Control which records appear in listings:
110
123
 
111
- `relation_scope` is a macro — it takes a block. Writing it as a plain instance
124
+ `relation_scope` is a macro: it takes a block. Writing it as a plain instance
112
125
  method (`def relation_scope(relation)`) overrides nothing and your scoping is
113
126
  silently ignored, so Plutonium raises if you try.
114
127
 
@@ -148,6 +161,10 @@ class AdminPortal::Blogging::PostPolicy < ::Blogging::PostPolicy
148
161
  true
149
162
  end
150
163
 
164
+ def show?
165
+ true
166
+ end
167
+
151
168
  def update?
152
169
  true
153
170
  end
@@ -79,11 +79,11 @@ end
79
79
 
80
80
  ## Testing the Action
81
81
 
82
- Open any unpublished post and click **Actions** in the top-right — the "Publish Post" item appears with its Tabler icon:
82
+ Open any unpublished post and click **Actions** in the top-right; the "Publish Post" item appears with its Tabler icon:
83
83
 
84
84
  ![Publish action in the show page menu](/images/tutorial/05-actions-menu.png)
85
85
 
86
- It also shows on each table row's `⋮` menu — same action, available wherever the record is rendered:
86
+ It also shows on each table row's `⋮` menu, same action, available wherever the record is rendered:
87
87
 
88
88
  ![Publish action in the row menu](/images/tutorial/05-row-actions.png)
89
89
 
@@ -13,8 +13,11 @@ Nested resources are resources that belong to a parent resource. In our blog:
13
13
 
14
14
  ```bash
15
15
  rails generate pu:res:scaffold Comment body:text user:belongs_to Blogging/Post:belongs_to --dest=blogging
16
+ rails db:prepare
16
17
  ```
17
18
 
19
+ Run the migration before connecting the resource to a portal: `pu:res:conn` reads the table's columns to seed the policy's attribute lists.
20
+
18
21
  ## Setting Up the Association
19
22
 
20
23
  Update the Post model to add the `has_many` association:
@@ -69,11 +72,11 @@ class Blogging::PostPolicy < Blogging::ResourcePolicy
69
72
  end
70
73
  ```
71
74
 
72
- The post show page now has tabs — **Details** and **Comments** — driven by the associations you permit:
75
+ The post show page now has tabs, **Details** and **Comments**, driven by the associations you permit:
73
76
 
74
77
  ![Post show page with Details and Comments tabs](/images/tutorial/06-post-with-comments.png)
75
78
 
76
- Clicking **Comments** opens the nested index for that post — a complete sub-resource view with its own paginated table, "New" button, and row actions:
79
+ Clicking **Comments** opens the nested index for that post: a complete sub-resource view with its own paginated table, "New" button, and row actions:
77
80
 
78
81
  ![Nested comments index](/images/tutorial/06-comments-tab.png)
79
82
 
@@ -168,11 +168,11 @@ Now you have two portals:
168
168
  | Admin | `/admin` | Admin | All posts |
169
169
  | Author | `/author` | User | Own posts only |
170
170
 
171
- Log in at `/users/login` with the user account and you land on the Author Portal dashboard — the same chrome as the Admin Portal but mounted at `/author`, gated by `Rodauth::Rails.authenticate(:user)`:
171
+ Log in at `/users/login` with the user account and you land on the Author Portal dashboard, the same chrome as the Admin Portal but mounted at `/author`, gated by `Rodauth::Rails.authenticate(:user)`:
172
172
 
173
173
  ![Author Portal dashboard](/images/tutorial/07-author-dashboard.png)
174
174
 
175
- The posts list lives at `/author/blogging/posts` — same `Blogging::Post` resource, different portal context (and once you add the scoping policy below, scoped to the logged-in author):
175
+ The posts list lives at `/author/blogging/posts`: same `Blogging::Post` resource, different portal context (and once you add the scoping policy below, scoped to the logged-in author):
176
176
 
177
177
  ![Author Portal posts index](/images/tutorial/07-author-portal.png)
178
178