plutonium 0.64.0 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (344) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -41
  3. data/.claude/skills/plutonium-app/SKILL.md +115 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +168 -0
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +236 -145
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +171 -95
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +80 -0
  15. data/CLAUDE.md +282 -210
  16. data/README.md +9 -9
  17. data/SECURITY.md +1 -1
  18. data/app/assets/plutonium-charts.js +20604 -0
  19. data/app/assets/plutonium-charts.js.map +7 -0
  20. data/app/assets/plutonium-charts.min.js +41 -0
  21. data/app/assets/plutonium-charts.min.js.map +7 -0
  22. data/app/assets/plutonium.css +1 -1
  23. data/app/assets/plutonium.js +794 -629
  24. data/app/assets/plutonium.js.map +4 -4
  25. data/app/assets/plutonium.min.js +48 -48
  26. data/app/assets/plutonium.min.js.map +4 -4
  27. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  28. data/app/views/plutonium/_resource_header.html.erb +2 -2
  29. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  30. data/app/views/plutonium/_toast.html.erb +2 -2
  31. data/app/views/rodauth/_login_form.html.erb +1 -1
  32. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  33. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  34. data/app/views/rodauth/change_login.html.erb +2 -2
  35. data/app/views/rodauth/create_account.html.erb +4 -4
  36. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  37. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  38. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  39. data/config/brakeman.ignore +4 -4
  40. data/config/initializers/rabl.rb +0 -40
  41. data/config/locales/en/api_client.yml +22 -0
  42. data/config/locales/en/async.yml +34 -0
  43. data/config/locales/en/dashboard.yml +25 -0
  44. data/config/locales/en/invites.yml +37 -0
  45. data/config/locales/en/js.yml +40 -0
  46. data/config/locales/en/kanban.yml +28 -0
  47. data/config/locales/en/plutonium.yml +26 -0
  48. data/config/locales/en/positioning.yml +14 -0
  49. data/config/locales/en/profile.yml +29 -0
  50. data/config/locales/en/resource.yml +60 -0
  51. data/config/locales/en/ui.yml +93 -0
  52. data/config/locales/en/wizard.yml +46 -0
  53. data/docs/.vitepress/config.ts +34 -1
  54. data/docs/.vitepress/sync-skills.mjs +6 -3
  55. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  56. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  57. data/docs/.vitepress/theme/custom.css +34 -377
  58. data/docs/blog/introducing-plutonium-dashboards.md +174 -0
  59. data/docs/blog/introducing-plutonium-i18n.md +90 -0
  60. data/docs/blog/introducing-plutonium.md +4 -5
  61. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  62. data/docs/getting-started/installation.md +5 -5
  63. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  64. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  65. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  66. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  67. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  68. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  69. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  70. data/docs/getting-started/tutorial/index.md +1 -1
  71. data/docs/guides/adding-resources.md +10 -7
  72. data/docs/guides/authentication.md +25 -25
  73. data/docs/guides/authorization.md +24 -24
  74. data/docs/guides/creating-packages.md +17 -17
  75. data/docs/guides/custom-actions.md +32 -32
  76. data/docs/guides/customizing-ui.md +29 -26
  77. data/docs/guides/dashboards.md +300 -0
  78. data/docs/guides/index.md +4 -3
  79. data/docs/guides/kanban.md +56 -56
  80. data/docs/guides/multi-tenancy.md +35 -22
  81. data/docs/guides/nested-resources.md +21 -21
  82. data/docs/guides/performance.md +3 -3
  83. data/docs/guides/search-filtering.md +13 -13
  84. data/docs/guides/testing.md +16 -12
  85. data/docs/guides/theming.md +32 -17
  86. data/docs/guides/troubleshooting.md +2 -2
  87. data/docs/guides/user-invites.md +17 -17
  88. data/docs/guides/user-profile.md +51 -24
  89. data/docs/guides/wizards.md +55 -55
  90. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  91. data/docs/public/images/blog/dashboards-loading.png +0 -0
  92. data/docs/public/images/blog/dashboards-overview.png +0 -0
  93. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  94. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  95. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  96. data/docs/public/images/guides/dashboard-loading.png +0 -0
  97. data/docs/public/images/guides/dashboard-overview.png +0 -0
  98. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  99. data/docs/reference/app/generators.md +24 -24
  100. data/docs/reference/app/index.md +15 -18
  101. data/docs/reference/app/packages.md +8 -8
  102. data/docs/reference/app/portals.md +77 -29
  103. data/docs/reference/auth/accounts.md +15 -15
  104. data/docs/reference/auth/index.md +12 -12
  105. data/docs/reference/auth/profile.md +67 -29
  106. data/docs/reference/behavior/async-interactions.md +24 -24
  107. data/docs/reference/behavior/controllers.md +28 -28
  108. data/docs/reference/behavior/index.md +5 -5
  109. data/docs/reference/behavior/interactions.md +44 -44
  110. data/docs/reference/behavior/policies.md +48 -28
  111. data/docs/reference/configuration.md +88 -31
  112. data/docs/reference/dashboard/dsl.md +123 -0
  113. data/docs/reference/dashboard/index.md +35 -0
  114. data/docs/reference/dashboard/registration.md +76 -0
  115. data/docs/reference/generators/lite.md +7 -7
  116. data/docs/reference/i18n.md +262 -0
  117. data/docs/reference/index.md +11 -2
  118. data/docs/reference/kanban/authorization.md +9 -9
  119. data/docs/reference/kanban/dsl.md +33 -33
  120. data/docs/reference/kanban/index.md +1 -1
  121. data/docs/reference/kanban/positioning.md +18 -16
  122. data/docs/reference/resource/actions.md +51 -51
  123. data/docs/reference/resource/definition.md +166 -88
  124. data/docs/reference/resource/export.md +11 -7
  125. data/docs/reference/resource/index.md +16 -14
  126. data/docs/reference/resource/model.md +24 -24
  127. data/docs/reference/{positioning.md → resource/positioning.md} +78 -75
  128. data/docs/reference/resource/query.md +14 -12
  129. data/docs/reference/tenancy/entity-scoping.md +65 -35
  130. data/docs/reference/tenancy/index.md +11 -11
  131. data/docs/reference/tenancy/invites.md +20 -20
  132. data/docs/reference/tenancy/nested-resources.md +13 -13
  133. data/docs/reference/testing/index.md +116 -22
  134. data/docs/reference/ui/assets.md +65 -25
  135. data/docs/reference/ui/components.md +20 -20
  136. data/docs/reference/ui/displays.md +19 -22
  137. data/docs/reference/ui/forms.md +35 -35
  138. data/docs/reference/ui/index.md +17 -15
  139. data/docs/reference/ui/layouts.md +21 -21
  140. data/docs/reference/ui/pages.md +22 -22
  141. data/docs/reference/ui/tables.md +7 -7
  142. data/docs/reference/wizard/anchoring-resume.md +33 -32
  143. data/docs/reference/wizard/dsl.md +45 -45
  144. data/docs/reference/wizard/index.md +6 -6
  145. data/docs/reference/wizard/one-time.md +18 -18
  146. data/docs/reference/wizard/registration-launch.md +32 -32
  147. data/docs/reference/wizard/storage-config.md +23 -23
  148. data/esbuild.config.js +5 -2
  149. data/gemfiles/rails_7.gemfile.lock +2 -2
  150. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  151. data/lib/active_model/validations/array_validator.rb +1 -1
  152. data/lib/active_model/validations/attached_validator.rb +1 -1
  153. data/lib/active_model/validations/url_validator.rb +2 -2
  154. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  155. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  156. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  157. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  158. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  159. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  160. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  161. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  162. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  163. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  164. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  165. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  166. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  167. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  168. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  169. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  170. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  171. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  172. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  173. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  174. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  175. data/lib/plutonium/action/base.rb +27 -3
  176. data/lib/plutonium/action/interactive.rb +17 -4
  177. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  178. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  179. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  180. data/lib/plutonium/configuration.rb +5 -0
  181. data/lib/plutonium/core/controller.rb +25 -1
  182. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  183. data/lib/plutonium/dashboard/base.rb +114 -0
  184. data/lib/plutonium/dashboard/card.rb +200 -0
  185. data/lib/plutonium/dashboard/controller.rb +101 -0
  186. data/lib/plutonium/dashboard/dsl.rb +104 -0
  187. data/lib/plutonium/dashboard/register.rb +40 -0
  188. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  189. data/lib/plutonium/dashboard.rb +42 -0
  190. data/lib/plutonium/definition/actions.rb +6 -1
  191. data/lib/plutonium/definition/base.rb +41 -3
  192. data/lib/plutonium/definition/index_views.rb +1 -1
  193. data/lib/plutonium/definition/positioning.rb +1 -1
  194. data/lib/plutonium/definition/presentable.rb +2 -2
  195. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  196. data/lib/plutonium/helpers/display_helper.rb +10 -2
  197. data/lib/plutonium/interaction/async/context.rb +6 -8
  198. data/lib/plutonium/interaction/async/executor.rb +6 -7
  199. data/lib/plutonium/interaction/async/run.rb +2 -2
  200. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  201. data/lib/plutonium/interaction/base.rb +2 -0
  202. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  203. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  204. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  205. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  206. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  207. data/lib/plutonium/invites/controller.rb +11 -11
  208. data/lib/plutonium/kanban/column.rb +13 -3
  209. data/lib/plutonium/kanban/dsl.rb +7 -4
  210. data/lib/plutonium/models/has_cents.rb +1 -1
  211. data/lib/plutonium/profile/security_section.rb +10 -18
  212. data/lib/plutonium/query/filter.rb +10 -1
  213. data/lib/plutonium/query/filters/association.rb +27 -2
  214. data/lib/plutonium/query/filters/boolean.rb +4 -4
  215. data/lib/plutonium/query/filters/date.rb +4 -17
  216. data/lib/plutonium/query/filters/date_range.rb +2 -2
  217. data/lib/plutonium/query/filters/select.rb +10 -1
  218. data/lib/plutonium/query/filters/text.rb +9 -12
  219. data/lib/plutonium/railtie.rb +17 -0
  220. data/lib/plutonium/resource/controller.rb +10 -5
  221. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  222. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  223. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  224. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  225. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  226. data/lib/plutonium/resource/query_object.rb +30 -3
  227. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  228. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  229. data/lib/plutonium/routing/resource_registration.rb +5 -0
  230. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  231. data/lib/plutonium/translation.rb +227 -0
  232. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  233. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  234. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  235. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  236. data/lib/plutonium/ui/component/methods.rb +4 -0
  237. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  238. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  239. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  240. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  241. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  242. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  243. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  244. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  245. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  246. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  247. data/lib/plutonium/ui/display/resource.rb +23 -16
  248. data/lib/plutonium/ui/export_button.rb +3 -3
  249. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  250. data/lib/plutonium/ui/form/components/password.rb +2 -2
  251. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  252. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  253. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  254. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  255. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  256. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  257. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  258. data/lib/plutonium/ui/form/query.rb +8 -8
  259. data/lib/plutonium/ui/form/resource.rb +54 -26
  260. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  261. data/lib/plutonium/ui/grid/card.rb +3 -3
  262. data/lib/plutonium/ui/grid/resource.rb +3 -3
  263. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  264. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  265. data/lib/plutonium/ui/kanban/column.rb +6 -6
  266. data/lib/plutonium/ui/layout/base.rb +19 -1
  267. data/lib/plutonium/ui/layout/header.rb +1 -1
  268. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  269. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  270. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  271. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  272. data/lib/plutonium/ui/modal/base.rb +4 -4
  273. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  274. data/lib/plutonium/ui/nav_user.rb +2 -2
  275. data/lib/plutonium/ui/page/base.rb +10 -2
  276. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  277. data/lib/plutonium/ui/page/edit.rb +3 -3
  278. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  279. data/lib/plutonium/ui/page/new.rb +3 -3
  280. data/lib/plutonium/ui/page/show.rb +2 -2
  281. data/lib/plutonium/ui/page/wizard.rb +22 -16
  282. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  283. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  284. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  285. data/lib/plutonium/ui/table/base.rb +2 -2
  286. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  287. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  288. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  289. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  290. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  291. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  292. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  293. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  294. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  295. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  296. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  297. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  298. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  299. data/lib/plutonium/ui/table/resource.rb +53 -17
  300. data/lib/plutonium/ui/wizard/review.rb +7 -7
  301. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  302. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  303. data/lib/plutonium/version.rb +1 -1
  304. data/lib/plutonium/wizard/base.rb +2 -0
  305. data/lib/plutonium/wizard/driving.rb +8 -2
  306. data/lib/plutonium/wizard/dsl.rb +4 -2
  307. data/lib/plutonium/wizard/field_importer.rb +1 -8
  308. data/lib/plutonium/wizard/review_step.rb +8 -1
  309. data/lib/plutonium/wizard/step.rb +14 -3
  310. data/package.json +4 -2
  311. data/plutonium.gemspec +4 -1
  312. data/postcss-gem-import.cjs +34 -0
  313. data/postcss-gem-import.js +3 -28
  314. data/src/css/components.css +101 -10
  315. data/src/css/slim_select.css +0 -38
  316. data/src/css/tokens.css +24 -0
  317. data/src/js/controllers/attachment_input_controller.js +15 -6
  318. data/src/js/controllers/chart_controller.js +162 -0
  319. data/src/js/controllers/clipboard_controller.js +3 -2
  320. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  321. data/src/js/controllers/easymde_controller.js +3 -0
  322. data/src/js/controllers/filter_panel_controller.js +15 -6
  323. data/src/js/controllers/flatpickr_controller.js +8 -0
  324. data/src/js/controllers/frame_refresh_controller.js +59 -0
  325. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  326. data/src/js/controllers/register_controllers.js +4 -0
  327. data/src/js/controllers/slim_select_controller.js +79 -176
  328. data/src/js/core.js +9 -1
  329. data/src/js/i18n.js +69 -0
  330. data/src/js/plutonium-charts.js +15 -0
  331. data/src/js/turbo/turbo_confirm.js +5 -3
  332. metadata +64 -16
  333. data/.yarnrc.yml +0 -8
  334. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  335. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  336. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  337. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  338. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  339. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  340. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  341. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  342. data/public/plutonium-assets/application.js +0 -31419
  343. data/public/plutonium-assets/plutonium.ico +0 -0
  344. data/yarn.lock +0 -6858
@@ -1,6 +1,6 @@
1
1
  # Custom Actions
2
2
 
3
- Add buttons beyond CRUD — Publish, Archive, Import, Send invitation, Bulk-update, etc.
3
+ Add buttons beyond CRUD: Publish, Archive, Import, Send invitation, Bulk-update, etc.
4
4
 
5
5
  ## Goal
6
6
 
@@ -10,17 +10,17 @@ A button appears in the right place (show page / table row / index header / bulk
10
10
 
11
11
  | Flavor | Use for |
12
12
  |---|---|
13
- | **Simple action** — navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
14
- | **Interactive action** — run an interaction class | Anything that *does* something (the common case) |
13
+ | **Simple action**: navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
14
+ | **Interactive action**: run an interaction class | Anything that *does* something (the common case) |
15
15
 
16
- Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects — all for free.
16
+ Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects, all for free.
17
17
 
18
- ## Quick recipe — interactive action
18
+ ## Quick recipe: interactive action
19
19
 
20
20
  ### 1. Write the interaction
21
21
 
22
22
  ```ruby
23
- # app/models/post.rb — what publishing actually means
23
+ # app/models/post.rb: what publishing actually means
24
24
  class Post < ApplicationRecord
25
25
  def publish!(on: Time.current)
26
26
  update!(published: true, published_at: on)
@@ -29,7 +29,7 @@ end
29
29
  ```
30
30
 
31
31
  ```ruby
32
- # app/interactions/publish_post_interaction.rb — the button in front of it
32
+ # app/interactions/publish_post_interaction.rb: the button in front of it
33
33
  class PublishPostInteraction < ResourceInteraction
34
34
  presents label: "Publish",
35
35
  icon: Phlex::TablerIcons::Send,
@@ -51,7 +51,7 @@ Plutonium doesn't rescue it automatically. Always rescue when using `create!` /
51
51
  :::
52
52
 
53
53
  ::: tip Why `publish!` is on the model
54
- An interaction can only be built with a `view_context` — it's a presentation object. A two-line `update!` inline in `execute` is fine while the button is the only caller; the moment a scheduled-publishing job wants the same behaviour it has to duplicate it or fake a view context. Full rule: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
54
+ An interaction can only be built with a `view_context`; it's a presentation object. A two-line `update!` inline in `execute` is fine while the button is the only caller; the moment a scheduled-publishing job wants the same behaviour it has to duplicate it or fake a view context. Full rule: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
55
55
  :::
56
56
 
57
57
  ### 2. Register it in the definition
@@ -62,7 +62,7 @@ class PostDefinition < ResourceDefinition
62
62
  end
63
63
  ```
64
64
 
65
- Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes — no need to declare `record_action: true`. See [Inferred visibility](#inferred-visibility) below.
65
+ Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes; no need to declare `record_action: true`. See [Inferred visibility](#inferred-visibility) below.
66
66
 
67
67
  ### 3. Add a policy method
68
68
 
@@ -88,7 +88,7 @@ For `interaction:`-based actions, visibility flags are inferred from the interac
88
88
  | `attribute :resources` (plural) | `bulk_action: true` → bulk toolbar |
89
89
  | neither | `resource_action: true` → index page header |
90
90
 
91
- User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden" — the interaction's attribute shape is semantic:
91
+ User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden"; the interaction's attribute shape is semantic:
92
92
 
93
93
  ```ruby
94
94
  # Hide from per-row menu, keep on show page
@@ -127,7 +127,7 @@ class Company::InviteUserInteraction < ResourceInteraction
127
127
  end
128
128
  ```
129
129
 
130
- `Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight — see the [full worked example](/reference/behavior/interactions#complete-example).
130
+ `Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight, see the [full worked example](/reference/behavior/interactions#complete-example).
131
131
 
132
132
  ## Bulk actions
133
133
 
@@ -146,7 +146,7 @@ class BulkArchiveInteraction < ResourceInteraction
146
146
  end
147
147
  ```
148
148
 
149
- Policy — checked **per record** (fails the whole request if any record is unauthorized):
149
+ Policy: checked **per record** (fails the whole request if any record is unauthorized):
150
150
 
151
151
  ```ruby
152
152
  def bulk_archive?
@@ -181,13 +181,13 @@ class BulkArchiveInteraction < ResourceInteraction
181
181
  end
182
182
  ```
183
183
 
184
- Nothing else about the action changes — the definition, the policy method and the form are the same. Only the work moves.
184
+ Nothing else about the action changes: the definition, the policy method and the form are the same. Only the work moves.
185
185
 
186
186
  Three things worth knowing:
187
187
 
188
188
  - **The block is the run's class body, not `execute`.** The work runs later, in a job with no controller, so it cannot close over anything in the interaction. Its inputs arrive through `options`.
189
189
  - **The user is sent back where they were**, and the index they land on shows a banner for the run with a link to its progress page.
190
- - **Permissions are re-checked per record, at perform time** — not replayed from dispatch. A permission revoked while the run is working stops applying to the rest of it.
190
+ - **Permissions are re-checked per record, at perform time**: not replayed from dispatch. A permission revoked while the run is working stops applying to the rest of it.
191
191
 
192
192
  Full detail, including file attributes, failure policies and resuming a crashed run: [Async Interactions](/reference/behavior/async-interactions).
193
193
 
@@ -212,8 +212,8 @@ end
212
212
 
213
213
  ## Immediate vs form
214
214
 
215
- - **Immediate** — interaction has only `:resource` / `:resources` (no extra inputs). Browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom message"` or `confirmation: false` on the action.
216
- - **Form** — interaction has additional `attribute` / `input`. Renders modal form first; no auto-confirmation (the form is the confirmation).
215
+ - **Immediate**: interaction has only `:resource` / `:resources` (no extra inputs). Browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom message"` or `confirmation: false` on the action.
216
+ - **Form**: interaction has additional `attribute` / `input`. Renders modal form first; no auto-confirmation (the form is the confirmation).
217
217
 
218
218
  ## Action options
219
219
 
@@ -231,10 +231,10 @@ action :name,
231
231
 
232
232
  # Behavior
233
233
  confirmation: "Are you sure?",
234
- modal: :slideover, # :slideover / :centered — overrides definition's modal mode
235
- size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
234
+ modal: :slideover, # :slideover / :centered, overrides definition's modal mode
235
+ size: :lg, # :sm / :md / :lg / :xl / :auto / :full, overrides definition's modal size
236
236
 
237
- # HTML attributes — author wins over the framework's on every key
237
+ # HTML attributes: author wins over the framework's on every key
238
238
  link: {target: "_blank", rel: "noopener"}, # every <a> rendering (toolbar GET link, dropdown items, bulk links, card show link)
239
239
  button: {data: {analytics: "archive"}} # the button_to <form> wrapper (non-GET toolbar rendering)
240
240
  ```
@@ -283,7 +283,7 @@ Every resource gets `:archive` automatically.
283
283
 
284
284
  ## Where the logic goes
285
285
 
286
- Interactions are presentation objects — they need a `view_context` to exist at all. So the reflex to reach for when an operation grows:
286
+ Interactions are presentation objects: they need a `view_context` to exist at all. So the reflex to reach for when an operation grows:
287
287
 
288
288
  ```ruby
289
289
  # 🚫 Three interactions, three view contexts, none of it callable from a job
@@ -300,22 +300,22 @@ def execute
300
300
  end
301
301
  ```
302
302
 
303
- Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does — and neither has a view context to hand.
303
+ Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does, and neither has a view context to hand.
304
304
 
305
- The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger** — and the destination is the model, Rails-style, not a new service layer. Chaining three interactions is usually the tell that you already crossed it. Full explanation: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
305
+ The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger**, and the destination is the model, Rails-style, not a new service layer. Chaining three interactions is usually the tell that you already crossed it. Full explanation: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
306
306
 
307
307
  ## Common issues
308
308
 
309
- - **Action button missing** — check the policy method (`def my_action?`). Undefined returns `false`.
310
- - **`ActiveRecord::RecordInvalid` crashes the action** — not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
311
- - **Bulk action fails on some records** — that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
312
- - **Confirmation prompt shows when you don't want one** — pass `confirmation: false` on the action.
313
- - **The action times out on a large selection** — the work is running inside the request. Move it to a background run with `async`, above.
309
+ - **Action button missing**: check the policy method (`def my_action?`). Undefined returns `false`.
310
+ - **`ActiveRecord::RecordInvalid` crashes the action**: not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
311
+ - **Bulk action fails on some records**: that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
312
+ - **Confirmation prompt shows when you don't want one**: pass `confirmation: false` on the action.
313
+ - **The action times out on a large selection**: the work is running inside the request. Move it to a background run with `async`, above.
314
314
 
315
315
  ## Related
316
316
 
317
- - [Reference › Resource › Actions](/reference/resource/actions) — full action options and bulk patterns
318
- - [Reference › Behavior › Interactions](/reference/behavior/interactions) — interaction class anatomy
319
- - [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions) — `async`, progress pages, resuming a crashed run
320
- - [Reference › Behavior › Policies](/reference/behavior/policies) — `def <action>?` methods
321
- - [Authorization](./authorization) — policy patterns
317
+ - [Reference › Resource › Actions](/reference/resource/actions): full action options and bulk patterns
318
+ - [Reference › Behavior › Interactions](/reference/behavior/interactions): interaction class anatomy
319
+ - [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions): `async`, progress pages, resuming a crashed run
320
+ - [Reference › Behavior › Policies](/reference/behavior/policies): `def <action>?` methods
321
+ - [Authorization](./authorization): policy patterns
@@ -1,6 +1,6 @@
1
1
  # Customizing the UI
2
2
 
3
- Plutonium's UI is built on Phlex, Tailwind 4, and Stimulus. Almost everything you see — pages, forms, displays, tables, components, layouts, even the design tokens — is open for override. This guide is the map. Each section shows the smallest useful example for one kind of customization, then points to the reference for the full surface.
3
+ Plutonium's UI is built on Phlex, Tailwind 4, and Stimulus. Almost everything you see (pages, forms, displays, tables, components, layouts, even the design tokens) is open for override. This guide is the map. Each section shows the smallest useful example for one kind of customization, then points to the reference for the full surface.
4
4
 
5
5
  When you're not sure where to start, read this top to bottom. When you know what you need, jump to the right section and follow the link to reference.
6
6
 
@@ -17,7 +17,7 @@ class PostDefinition < ResourceDefinition
17
17
  end
18
18
  ```
19
19
 
20
- Each nested class inherits from Plutonium's defaults and lets you override only the methods you care about. Don't reimplement the whole layer — use the render hooks below.
20
+ Each nested class inherits from Plutonium's defaults and lets you override only the methods you care about. Don't reimplement the whole layer; use the render hooks below.
21
21
 
22
22
  → See [Reference › UI › Pages](/reference/ui/pages) for the full hook list.
23
23
 
@@ -29,15 +29,17 @@ Most page customization is "I want to add something before/after this section."
29
29
  class PostDefinition < ResourceDefinition
30
30
  class ShowPage < ShowPage
31
31
  def render_before_content
32
- div(class: "pu-card pu-card-body") do
33
- plain "This post has #{object.comments.count} comments"
32
+ div(class: "pu-alert pu-alert-info", role: "status") do
33
+ div(class: "pu-alert-message") { t("blog.posts.show.comment_count", count: object.comments.count) }
34
34
  end
35
35
  end
36
36
  end
37
37
  end
38
38
  ```
39
39
 
40
- Hooks exist around the header, breadcrumbs, page header, toolbar, content, and footer — pick the one closest to where you want the thing to appear.
40
+ `pu-alert pu-alert-<success|warning|danger|info>` is the same banner the flash messages use, so it already has its dark-mode colors. The text comes from a locale key; see [i18n](/reference/i18n#your-own-components-and-pages).
41
+
42
+ Hooks exist around the header, breadcrumbs, page header, toolbar, content, and footer: pick the one closest to where you want the thing to appear.
41
43
 
42
44
  → See [Reference › UI › Pages](/reference/ui/pages) › Page hooks.
43
45
 
@@ -56,7 +58,7 @@ class Form < Form
56
58
  render_resource_field :published_at
57
59
  render_resource_field :category
58
60
  end
59
- render_actions # REQUIRED — without this, no submit button
61
+ render_actions # REQUIRED: without this, no submit button
60
62
  end
61
63
 
62
64
  private
@@ -126,14 +128,14 @@ class PostCardComponent < Plutonium::UI::Component::Base
126
128
  div(class: "pu-card pu-card-body") do
127
129
  h3(class: "font-bold text-[var(--pu-text)]") { @post.title }
128
130
  p(class: "text-[var(--pu-text-muted)] mt-2") { @post.excerpt }
129
- a(href: resource_url_for(@post), class: "pu-btn pu-btn-sm pu-btn-ghost") { "Read more" }
131
+ a(href: resource_url_for(@post), class: "pu-btn pu-btn-sm pu-btn-ghost") { t("blog.posts.card.read_more") }
130
132
  end
131
133
  end
132
134
  end
133
135
  ```
134
136
 
135
137
  Use it directly in a page, or wire it as a field in the definition. A component
136
- with its own constructor takes the block form — you build it, so you decide what
138
+ with its own constructor takes the block form: you build it, so you decide what
137
139
  it receives:
138
140
 
139
141
  ```ruby
@@ -143,13 +145,13 @@ end
143
145
  ```
144
146
 
145
147
  `as: SomeComponent` is for a *field* component, which Plutonium constructs with
146
- the field builder (`Phlexi::Form::Components::Base` / `Phlexi::Display::Components::Base`) — see the reference below.
148
+ the field builder (`Phlexi::Form::Components::Base` / `Phlexi::Display::Components::Base`); see the reference below.
147
149
 
148
150
  → See [Reference › UI › Components](/reference/ui/components).
149
151
 
150
152
  ## Phlexi themes (recolor without rewriting)
151
153
 
152
- If all you want is to recolor or restyle the form/display/table, write a `Theme` class instead of overriding the template. Always `super.merge(...)` — never replace wholesale:
154
+ If all you want is to recolor or restyle the form/display/table, write a `Theme` class instead of overriding the template. Always `super.merge(...)`; never replace wholesale:
153
155
 
154
156
  ```ruby
155
157
  class Form < Form
@@ -173,7 +175,7 @@ By default `:new`, `:edit`, and every interactive action render in a slideover p
173
175
 
174
176
  ```ruby
175
177
  class PostDefinition < ResourceDefinition
176
- modal :slideover # default — slide-in from the right
178
+ modal :slideover # default: slide-in from the right
177
179
  # modal :centered # centered dialog
178
180
  # modal :centered, size: :lg # centered, wider container
179
181
  # modal false # full standalone page
@@ -184,7 +186,7 @@ end
184
186
 
185
187
  ## Layouts and the shell
186
188
 
187
- The layout is the chrome around every resource page — topbar, sidebar, flash region, scripts. Two ways to customize it:
189
+ The layout is the chrome around every resource page: topbar, sidebar, flash region, scripts. Two ways to customize it:
188
190
 
189
191
  ```bash
190
192
  # Per-portal: eject the shell partials and edit them directly
@@ -199,24 +201,24 @@ For programmatic overrides, subclass `Plutonium::UI::Layout::ResourceLayout` and
199
201
 
200
202
  ## Tailwind, Stimulus, and assets
201
203
 
202
- Plutonium ships with a Tailwind config, design tokens, and a set of Stimulus controllers. To plug into them in your app:
204
+ Plutonium ships with a Tailwind config, design tokens, and a set of Stimulus controllers. Out of the box the app serves the gem's prebuilt `plutonium.css` and `plutonium.min.js`, so app-side Tailwind classes, a new brand palette, token overrides and your own Stimulus controllers need your own bundles first. Run the generator rather than wiring the pipeline by hand:
203
205
 
204
206
  ```bash
205
207
  rails generate pu:core:assets
206
208
  ```
207
209
 
208
- This installs the npm packages, creates a `tailwind.config.js` that extends Plutonium's defaults via `plutoniumTailwindConfig.merge`, imports Plutonium's CSS, and registers its Stimulus controllers. After that, you can:
210
+ This installs the npm packages, creates a `tailwind.config.js` that extends Plutonium's defaults via `plutoniumTailwindConfig.merge`, imports Plutonium's CSS, registers its Stimulus controllers, and points `config.assets.stylesheet` / `config.assets.script` at your `application` bundles. It needs `app/assets/stylesheets/application.tailwind.css` and `app/javascript/controllers/index.js` to exist (an app created with `-j esbuild -c tailwind` plus Stimulus). After that, you can:
209
211
 
210
- - Extend the palette under `theme.extend.colors` (always inside `plutoniumTailwindConfig.merge` — a plain spread drops Plutonium's defaults).
211
- - Use `.pu-btn`, `.pu-card`, `.pu-input`, `.pu-table`, etc. instead of hand-rolling Tailwind chains.
212
+ - Extend the palette under `theme.extend.colors` (always inside `plutoniumTailwindConfig.merge`: a plain spread drops Plutonium's defaults), then rebuild the CSS.
213
+ - Use `.pu-btn`, `.pu-card`, `.pu-input`, `.pu-table`, `.pu-alert`, `.pu-badge`, etc. instead of hand-rolling Tailwind chains.
212
214
  - Reference design tokens directly: `bg-[var(--pu-surface)]`, `text-[var(--pu-text-muted)]`, `border-[var(--pu-border)]`. These auto-switch with dark mode.
213
- - Register your own Stimulus controllers alongside Plutonium's — `registerControllers(application)` is mandatory or the entire interactive layer is dead.
215
+ - Register your own Stimulus controllers alongside Plutonium's. Your `application.js` now replaces the gem's bundle, so `registerControllers(application)` (added by the generator) must stay, or the entire interactive layer is dead.
214
216
 
215
217
  → See [Reference › UI › Assets](/reference/ui/assets) for the full toolchain, the `.pu-*` class catalog, and design-token reference.
216
218
 
217
219
  ## ERB views (escape hatch)
218
220
 
219
- When the Phlex page class is the wrong tool — you want to keep an existing ERB layout, you're integrating with a designer's HTML, or you just want to surround the generated page with custom markup — drop an ERB view at the controller path:
221
+ When the Phlex page class is the wrong tool (you want to keep an existing ERB layout, you're integrating with a designer's HTML, or you just want to surround the generated page with custom markup), drop an ERB view at the controller path:
220
222
 
221
223
  ```
222
224
  app/views/posts/show.html.erb
@@ -237,7 +239,7 @@ Keep that line and wrap it to add chrome without giving up the generated page:
237
239
  <%= render partial: "related" %>
238
240
  ```
239
241
 
240
- Or replace the line entirely for full control. ERB views always win over the Phlex page class when both exist for the same action — reach for this only when Phlex hooks + overrides genuinely can't do the job.
242
+ Or replace the line entirely for full control. ERB views always win over the Phlex page class when both exist for the same action, so reach for this only when Phlex hooks + overrides genuinely can't do the job.
241
243
 
242
244
  ## When to reach for what
243
245
 
@@ -249,18 +251,19 @@ Or replace the line entirely for full control. ERB views always win over the Phl
249
251
  | Reuse a UI block across pages | Custom Phlex component |
250
252
  | Recolor without changing structure | Phlexi `Theme` class |
251
253
  | Swap the topbar or sidebar | `pu:eject:shell` or custom layout class |
252
- | Change brand color or radius | Design tokens — see [Theming](/guides/theming) |
254
+ | Change brand color | Tailwind `primary` palette via `plutoniumTailwindConfig.merge`, then rebuild. See [Theming](/guides/theming) |
255
+ | Change surfaces, borders or radius | Design tokens (mirrored in `.dark`). See [Theming](/guides/theming) |
253
256
  | Add a custom JS interaction | Stimulus controller registered alongside Plutonium's |
254
257
 
255
258
  ## Gotchas
256
259
 
257
- - **Don't override `view_template` in pages** when a render hook fits — you lose breadcrumbs, header, and DynaFrame (turbo-frame) behavior.
258
- - **`render_actions` is mandatory** when you write a custom `form_template` — otherwise the form has no submit button.
259
- - **Always `registerControllers(application)`** in `app/javascript/controllers/index.js`. Without it, every Plutonium-shipped Stimulus controller is dead (color mode, slim-select, flatpickr, easymde, form pre-submit).
260
+ - **Don't override `view_template` in pages** when a render hook fits, or you lose breadcrumbs, header, and DynaFrame (turbo-frame) behavior.
261
+ - **`render_actions` is mandatory** when you write a custom `form_template`, otherwise the form has no submit button.
262
+ - **Once the app serves its own JS bundle, `registerControllers(application)`** must be in `app/javascript/controllers/index.js` (`pu:core:assets` adds it). Without it, every Plutonium-shipped Stimulus controller is dead (color mode, slim-select, flatpickr, easymde, form pre-submit).
260
263
  - **Use `plutoniumTailwindConfig.merge`** when extending the Tailwind theme. A plain object spread drops Plutonium's defaults.
261
- - **Prefer `.pu-*` classes and `var(--pu-*)` tokens** over hardcoded `gray-X/dark:gray-Y` pairs — they switch with dark mode automatically.
264
+ - **Style with `.pu-*` classes first, `var(--pu-*)` tokens second, raw palette pairs last.** The classes and tokens switch with dark mode; a hand-written `gray-X/dark:gray-Y` pair needs its own dark twin and won't follow a rebrand.
262
265
 
263
266
  ## Related
264
267
 
265
- - [Theming](/guides/theming) — design tokens, brand colors.
266
- - [Reference › UI](/reference/ui/) — the full surface area for every override above.
268
+ - [Theming](/guides/theming): design tokens, brand colors.
269
+ - [Reference › UI](/reference/ui/): the full surface area for every override above.
@@ -0,0 +1,300 @@
1
+ # Dashboards
2
+
3
+ ::: warning Experimental
4
+ Dashboards are experimental: the DSL and behavior may change in a future release.
5
+ :::
6
+
7
+ A dashboard is a page of cards: headline numbers, charts and free-form panels, declared in one Ruby class and mounted with a single routes line. By default every card loads in its own lazy turbo frame, so the page paints immediately and each card's queries run in a separate request as it scrolls into view.
8
+
9
+ ![A dashboard with four metric cards, an area chart of signups per day, a donut chart and a full-width welcome card](/images/guides/dashboard-overview.png)
10
+
11
+ ## What you get
12
+
13
+ - A `Plutonium::Dashboard::Base` subclass with a class-level DSL: `metric`, `chart` and `card`.
14
+ - `register_dashboard` in a portal's routes draws the page, the per-card endpoint and a synthesized controller that inherits the portal's auth, tenant scoping and layout.
15
+ - Metric cards format numbers (delimited, currency, percentage, human) and show a change indicator against a previous value.
16
+ - Chart cards render with [Chart.js](https://www.chartjs.org/) through [Chartkick](https://chartkick.com/), using Plutonium's design tokens in light and dark mode. The chart bundle loads on demand, so pages without a chart never download it.
17
+ - Cards sit on a 12-column grid with a sensible default width per kind, and can refresh themselves on an interval, link somewhere, and hide behind a condition.
18
+ - Registered dashboards appear in the portal sidebar.
19
+
20
+ ## Worked example
21
+
22
+ ### 1. Generate the dashboard
23
+
24
+ ```bash
25
+ rails g pu:dashboard Sales --dest=admin_portal
26
+ ```
27
+
28
+ This writes `packages/admin_portal/app/dashboards/admin_portal/sales_dashboard.rb` and adds `register_dashboard AdminPortal::SalesDashboard, at: "sales"` to the portal's routes. Pass `--at=/` to make the dashboard the portal's root page instead; see [Replacing the portal's default page](#replacing-the-portal-s-default-page).
29
+
30
+ ### 2. Declare the cards
31
+
32
+ ```ruby
33
+ module AdminPortal
34
+ class SalesDashboard < Plutonium::Dashboard::Base
35
+ presents label: "Sales", description: "Orders and revenue at a glance",
36
+ icon: Phlex::TablerIcons::ChartBar
37
+
38
+ refresh 60
39
+
40
+ metric(:orders, icon: Phlex::TablerIcons::ShoppingCart,
41
+ href: -> { resource_url_for(Order, parent: nil) }) do
42
+ {value: orders.where(created_at: 30.days.ago..).count,
43
+ previous: orders.where(created_at: 60.days.ago...30.days.ago).count,
44
+ change_label: "vs. previous 30 days"}
45
+ end
46
+
47
+ metric(:revenue, format: :currency) { orders.sum(:total) }
48
+
49
+ metric(:refund_rate, format: :percentage, precision: 1, positive: :down) do
50
+ {value: refund_rate, previous: previous_refund_rate}
51
+ end
52
+
53
+ metric(:customers, format: :human) { Customer.count }
54
+
55
+ chart(:revenue_by_day, type: :area, span: 8) do
56
+ orders.group_by_day(:created_at, last: 30).sum(:total)
57
+ end
58
+
59
+ chart(:by_channel, type: :donut, span: 4) do
60
+ orders.group(:channel).count
61
+ end
62
+
63
+ card(:latest, span: :full) do
64
+ ul(class: "divide-y divide-[var(--pu-border-muted)]") do
65
+ latest_orders.each do |order|
66
+ li(class: "py-2 flex justify-between") do
67
+ span { order.number }
68
+ span(class: "text-[var(--pu-text-muted)]") { helpers.number_to_currency(order.total) }
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def orders = authorized_resource_scope(Order)
77
+ def latest_orders = orders.order(created_at: :desc).limit(5)
78
+ def refund_rate = 100.0 * orders.refunded.count / [orders.count, 1].max
79
+ def previous_refund_rate = 1.8
80
+ end
81
+ end
82
+ ```
83
+
84
+ `group_by_day` comes from the [groupdate](https://github.com/ankane/groupdate) gem, which pairs well with chart cards but is not required.
85
+
86
+ ### 3. Visit it
87
+
88
+ ![The dashboard before its frames have loaded: every card is a skeleton except the inline Conversion metric](/images/guides/dashboard-loading.png)
89
+
90
+ The page is at `/admin/dashboards/sales`. Each card is a `<turbo-frame src="/admin/dashboards/sales/cards/<key>" loading="lazy">` holding a skeleton until its response lands. Every mount sits under `dashboards/`, so `at: "sales"` never collides with a `Sale` resource registered on the same portal. Pass `prefix: nil` to mount at the bare path (`/admin/sales`), or a string to use another segment (`prefix: "reports"`).
91
+
92
+ ## Replacing the portal's default page
93
+
94
+ Every generated portal opens on `root to: "dashboard#index"`, a `DashboardController` and a view that lists the registered resources. To make a dashboard the portal's root page instead, mount it at `/`:
95
+
96
+ ```bash
97
+ rails g pu:dashboard Home --dest=admin_portal --at=/
98
+ ```
99
+
100
+ The generator writes `AdminPortal::HomeDashboard` and replaces the `root to:` line with `register_dashboard AdminPortal::HomeDashboard, at: "/"`. The page is served at the portal root (`/admin`), its cards at `/admin/dashboards/home/cards/<key>`, and the sidebar's Home link keeps pointing at it. A root-mounted dashboard is the Home link, so it is left out of the Dashboards group.
101
+
102
+ The generator does not delete the old `DashboardController` or its `dashboard/index.html.erb`; nothing routes to them any more, so remove them yourself:
103
+
104
+ ```bash
105
+ rm packages/admin_portal/app/controllers/admin_portal/dashboard_controller.rb
106
+ rm packages/admin_portal/app/views/admin_portal/dashboard/index.html.erb
107
+ ```
108
+
109
+ The same registration by hand, for an existing portal:
110
+
111
+ ```ruby
112
+ AdminPortal::Engine.routes.draw do
113
+ register_dashboard AdminPortal::HomeDashboard, at: "/" # in place of root to: "dashboard#index"
114
+ # ...
115
+ end
116
+ ```
117
+
118
+ Only one root per portal: keep either the `root to:` line or the `at: "/"` registration, never both.
119
+
120
+ ## Cards
121
+
122
+ All three kinds share the same options:
123
+
124
+ | Option | Meaning |
125
+ |---|---|
126
+ | `label:` | Card title. Defaults to a locale convention, then the key titleized. |
127
+ | `description:` | Caption under the title. |
128
+ | `icon:` | A `Phlex::TablerIcons::*` class shown beside the title. |
129
+ | `span:` | Columns of the 12-column grid to span: `1` to `12`, or `:full`. Defaults to `3` for a metric and `6` for a chart or a custom card. See [Layout](#layout). |
130
+ | `lazy:` | `true` (default) loads the card in its own turbo frame; `false` renders it inline with the page. See [Lazy and inline cards](#lazy-and-inline-cards). |
131
+ | `refresh:` | Seconds between automatic reloads of the card's frame. Needs `lazy: true`. `false` opts the card out of the dashboard's `refresh`. |
132
+ | `condition:` | A proc or a symbol naming a dashboard method. When false the card is left out of the page and its endpoint responds 404. |
133
+ | `href:` | A path, or a proc returning one, that links the title. |
134
+
135
+ Blocks run on the dashboard instance, which exposes `current_user`, `current_scoped_entity`, `params`, `authorized_resource_scope`, `resource_url_for`, `allowed_to?`, `helpers` (the view context) and the dashboard's own private methods. A one-argument block receives the dashboard instead.
136
+
137
+ ### Metric
138
+
139
+ The block returns a value, or a hash with a change:
140
+
141
+ ```ruby
142
+ metric(:signups) { User.count } # just the number
143
+ metric(:mrr, format: :currency) { {value: 12_400, previous: 11_000} } # +12.7%
144
+ metric(:latency, suffix: " ms") { {value: 182, change: "-14 ms", trend: :down} }
145
+ metric(:churn, format: :percentage, positive: :down) { {value: 1.2, previous: 1.0} }
146
+ ```
147
+
148
+ | Key | Meaning |
149
+ |---|---|
150
+ | `value` | The number (or string) to show. `nil` renders an em dash. |
151
+ | `previous` | Computes the percentage change from this value. |
152
+ | `change` | A number in percentage points (`2.5` renders `+2.5%`) or a string shown verbatim. |
153
+ | `trend` | `:up`, `:down` or `:flat`. Inferred from the sign of `change` when omitted. |
154
+ | `change_label` | Caption after the change, such as "vs. last month". Also a card option. |
155
+
156
+ Metric options: `format:` (`:number`, `:currency`, `:percentage`, `:human`, or a proc receiving the value), `precision:`, `unit:` (currency symbol), `prefix:`, `suffix:`, `positive:` (`:up` by default; `:down` when a falling number is good) and `change_label:`.
157
+
158
+ ### Chart
159
+
160
+ The block returns [Chartkick data](https://chartkick.com/#data): a `{label => value}` hash, an array of pairs, or an array of `{name:, data:}` series. Date and time keys become a time axis.
161
+
162
+ ```ruby
163
+ chart(:signups, type: :line) { User.group_by_day(:created_at, last: 30).count }
164
+ chart(:plans, type: :pie) { Subscription.group(:plan).count }
165
+ chart(:traffic, type: :column, stacked: true) do
166
+ [{name: "Web", data: web_by_week}, {name: "Mobile", data: mobile_by_week}]
167
+ end
168
+ ```
169
+
170
+ `type:` is one of `:line`, `:area`, `:column`, `:bar`, `:pie`, `:donut` or `:scatter`; `height:` sets the drawing area (default `"240px"`). Every other option (`colors:`, `stacked:`, `min:`, `max:`, `prefix:`, `suffix:`, `xtitle:`, `ytitle:`, `legend:`, `curve:`, `points:`, `library:`, ...) is passed straight through to Chartkick. Series colours default to the `--pu-chart-1` to `--pu-chart-8` design tokens.
171
+
172
+ ### Custom
173
+
174
+ The block renders Phlex markup inside the card body. It is evaluated in the card component, so `div`, `ul`, `render` and the rest are available, and any method it calls that the component lacks is forwarded to the dashboard.
175
+
176
+ ```ruby
177
+ card(:onboarding, description: "Where new tenants are") do
178
+ Plutonium::Wizard.in_progress_for(view_context).each do |entry|
179
+ a(href: entry.resume_url, class: "block py-1") { entry.label }
180
+ end
181
+ end
182
+ ```
183
+
184
+ ## Layout
185
+
186
+ The grid is 12 columns wide on large screens, so halves, thirds and quarters all divide evenly and can share a dashboard. `span:` says how many of the 12 a card takes. Left out, a metric takes `3` (four to a row) and a chart or a custom card takes `6` (two to a row).
187
+
188
+ ```ruby
189
+ metric(:orders) { orders.count } # 3 of 12: four to a row
190
+ metric(:revenue, span: 6, format: :currency) { revenue } # a headline number, half the row
191
+ chart(:revenue_by_day, type: :area, span: 8) { by_day } # two thirds
192
+ chart(:by_channel, type: :donut, span: 4) { by_channel } # the remaining third
193
+ card(:latest, span: :full) { render_latest } # the whole row (same as 12)
194
+ ```
195
+
196
+ Cards fill rows in declaration order and wrap when the next card does not fit. Tablets get two columns: a card with a span of `6` or more takes the full row, anything narrower takes one column. Phones get a single column.
197
+
198
+ `width` sets the page width with the same tokens as resource pages (`:sm` to `:full`) and defaults to `:full`.
199
+
200
+ ## Lazy and inline cards
201
+
202
+ Every card is lazy unless you say otherwise. `lazy: false` makes it inline: rendered with the page instead of fetched after it.
203
+
204
+ | | `lazy: true` (default) | `lazy: false` (inline) |
205
+ |---|---|---|
206
+ | Where the block runs | In its own request to `<mount>/cards/<key>`, when the frame scrolls into view | In the page request, before the page is sent |
207
+ | What the page response holds | A `<turbo-frame loading="lazy">` with a skeleton | The finished card; no frame, no skeleton, no second request |
208
+ | Cost | One extra request per card; the page itself never waits | The page waits for the block, so every inline card adds its query time to the page |
209
+ | `refresh` | Reloads on the card's or the dashboard's interval | Never refreshes. A number raises `ArgumentError`; the dashboard's `refresh` skips it |
210
+ | `condition:` false | Left out of the page, block never runs, endpoint is 404 | The same |
211
+ | Raises in production | Notice in place of the body, logged and reported | The same; the rest of the page still renders |
212
+ | Raises in development and test | That card's frame request fails; the page and the other cards are fine | The whole page raises, because the card is part of the page |
213
+
214
+ Make a card inline when its value is cheap (a cached number, an indexed count) and sits at the top of the page, where a skeleton flash and an extra request cost more than the query. Leave everything else lazy: one slow inline card delays the whole page, which is the problem lazy cards exist to solve.
215
+
216
+ ## Refreshing
217
+
218
+ `refresh 60` on the dashboard reloads every lazy card once a minute; `refresh: 10` on a card overrides it, and `refresh: false` keeps a card out of it (an expensive chart that does not need to be live). Reloads pause while the tab is hidden and catch up when it becomes visible. A chart re-renders in place when its data changes.
219
+
220
+ ## Authorization
221
+
222
+ A portal dashboard sits behind the portal's authentication like every other page. To restrict it further, override `authorize?`; a false answer is a 403 on the page and on every card endpoint:
223
+
224
+ ```ruby
225
+ def authorize? = current_user.admin?
226
+ ```
227
+
228
+ Use `condition:` to hide an individual card. The condition gates the card's endpoint as well, so a hidden card cannot be fetched by URL. An inline card behaves the same way.
229
+
230
+ ## Errors
231
+
232
+ When `config.consider_all_requests_local` is off (production), a card whose block raises renders a short notice in its place, and the rest of the dashboard is unaffected. The failure is written to the Rails log and reported through `Rails.error` with `source: "plutonium.dashboard"` and a `context` naming the dashboard class and the card key, so an error tracker subscribed to `Rails.error` (Sentry, Honeybadger, AppSignal) receives it. In development and test the error is raised so it is visible.
233
+
234
+ ![A dashboard where the Revenue card shows a "This card could not be loaded." notice while the cards around it render normally](/images/guides/dashboard-card-error.png)
235
+
236
+ An inline card renders under the same guard, so in production it shows the same notice. In development and test its error is the page's error: the whole dashboard raises, where a lazy card fails only its own frame. [Lazy and inline cards](#lazy-and-inline-cards) has the full comparison.
237
+
238
+ ## Multi-tenancy
239
+
240
+ On an entity-scoped portal the page and card URLs carry the tenant segment, and `current_scoped_entity` is available in every block:
241
+
242
+ ```ruby
243
+ class TeamDashboard < Plutonium::Dashboard::Base
244
+ metric(:members) { current_scoped_entity.memberships.count }
245
+ end
246
+ ```
247
+
248
+ ## Sidebar
249
+
250
+ Dashboards registered with `register_dashboard` are grouped in the portal sidebar under a **Dashboards** item, after the Home link, with one child link per dashboard. A dashboard mounted at the root is what the Home link opens, so it is left out of the group; with nothing else registered the group is not rendered. ![The icon rail with the Dashboards item open, listing the Overview and Content dashboards](/images/guides/dashboard-sidebar.png)
251
+
252
+ Portals generated before this feature carry an ejected `_resource_sidebar.html.erb`; add the block from the gem's partial to list dashboards there:
253
+
254
+ ```erb
255
+ dashboards = registered_dashboards.reject { |dashboard| dashboard_path_for(dashboard) == root_path }
256
+ if dashboards.any?
257
+ m.item t("plutonium.resource.nav.dashboards"), icon: Phlex::TablerIcons::LayoutDashboard do |n|
258
+ dashboards.each do |dashboard|
259
+ n.item dashboard.label, url: dashboard_path_for(dashboard), icon: dashboard.icon
260
+ end
261
+ end
262
+ end
263
+ ```
264
+
265
+ The labels are the `plutonium.resource.nav.home` and `plutonium.resource.nav.dashboards` locale keys.
266
+
267
+ ## Translations
268
+
269
+ Titles resolve by convention, layered per portal like every other derived label:
270
+
271
+ ```yaml
272
+ en:
273
+ plutonium:
274
+ dashboards:
275
+ admin_portal/sales:
276
+ label: "Ventes"
277
+ description: "Commandes et chiffre d'affaires"
278
+ cards:
279
+ orders:
280
+ label: "Commandes"
281
+ ```
282
+
283
+ The dashboard key is the class name underscored without the `Dashboard` suffix. See the [i18n reference](/reference/i18n).
284
+
285
+ ## Customizing the page
286
+
287
+ The page is `Plutonium::UI::Page::Dashboard`, a `Page::Base` with the usual `render_before_*` and `render_after_*` hooks. To take it over, define `<Portal>::DashboardsController` yourself, include `Plutonium::Dashboard::Controller`, and override `show`:
288
+
289
+ ```ruby
290
+ module AdminPortal
291
+ class DashboardsController < PlutoniumController
292
+ include Plutonium::Dashboard::Controller
293
+
294
+ def show
295
+ authorize_dashboard!
296
+ render CustomDashboardPage.new(dashboard: current_dashboard)
297
+ end
298
+ end
299
+ end
300
+ ```