plutonium 0.64.0 → 0.65.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 (278) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +4 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +14 -0
  4. data/.claude/skills/plutonium-dashboard/SKILL.md +161 -0
  5. data/.claude/skills/plutonium-resource/SKILL.md +94 -14
  6. data/.claude/skills/plutonium-ui/SKILL.md +22 -1
  7. data/CHANGELOG.md +59 -0
  8. data/CLAUDE.md +282 -210
  9. data/SECURITY.md +1 -1
  10. data/app/assets/plutonium-charts.js +20604 -0
  11. data/app/assets/plutonium-charts.js.map +7 -0
  12. data/app/assets/plutonium-charts.min.js +41 -0
  13. data/app/assets/plutonium-charts.min.js.map +7 -0
  14. data/app/assets/plutonium.css +1 -1
  15. data/app/assets/plutonium.js +794 -629
  16. data/app/assets/plutonium.js.map +4 -4
  17. data/app/assets/plutonium.min.js +48 -48
  18. data/app/assets/plutonium.min.js.map +4 -4
  19. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  20. data/app/views/plutonium/_resource_header.html.erb +2 -2
  21. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  22. data/app/views/plutonium/_toast.html.erb +2 -2
  23. data/app/views/rodauth/_login_form.html.erb +1 -1
  24. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  25. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  26. data/app/views/rodauth/change_login.html.erb +2 -2
  27. data/app/views/rodauth/create_account.html.erb +4 -4
  28. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  29. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  30. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  31. data/config/brakeman.ignore +4 -4
  32. data/config/initializers/rabl.rb +0 -40
  33. data/config/locales/en/api_client.yml +22 -0
  34. data/config/locales/en/async.yml +34 -0
  35. data/config/locales/en/dashboard.yml +25 -0
  36. data/config/locales/en/invites.yml +37 -0
  37. data/config/locales/en/js.yml +40 -0
  38. data/config/locales/en/kanban.yml +28 -0
  39. data/config/locales/en/plutonium.yml +26 -0
  40. data/config/locales/en/positioning.yml +14 -0
  41. data/config/locales/en/profile.yml +29 -0
  42. data/config/locales/en/resource.yml +60 -0
  43. data/config/locales/en/ui.yml +93 -0
  44. data/config/locales/en/wizard.yml +46 -0
  45. data/docs/.vitepress/config.ts +34 -1
  46. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  47. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  48. data/docs/.vitepress/theme/custom.css +34 -377
  49. data/docs/blog/introducing-plutonium-dashboards.md +175 -0
  50. data/docs/blog/introducing-plutonium-i18n.md +91 -0
  51. data/docs/blog/introducing-plutonium.md +4 -5
  52. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  53. data/docs/guides/dashboards.md +300 -0
  54. data/docs/guides/index.md +1 -0
  55. data/docs/guides/kanban.md +2 -2
  56. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  57. data/docs/public/images/blog/dashboards-loading.png +0 -0
  58. data/docs/public/images/blog/dashboards-overview.png +0 -0
  59. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  60. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  61. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  62. data/docs/public/images/guides/dashboard-loading.png +0 -0
  63. data/docs/public/images/guides/dashboard-overview.png +0 -0
  64. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  65. data/docs/reference/app/generators.md +2 -2
  66. data/docs/reference/app/portals.md +2 -0
  67. data/docs/reference/configuration.md +84 -27
  68. data/docs/reference/dashboard/dsl.md +123 -0
  69. data/docs/reference/dashboard/index.md +35 -0
  70. data/docs/reference/dashboard/registration.md +76 -0
  71. data/docs/reference/i18n.md +239 -0
  72. data/docs/reference/index.md +10 -1
  73. data/docs/reference/kanban/dsl.md +1 -1
  74. data/docs/reference/kanban/positioning.md +2 -2
  75. data/docs/reference/resource/actions.md +1 -1
  76. data/docs/reference/resource/definition.md +96 -18
  77. data/docs/reference/resource/export.md +5 -1
  78. data/docs/reference/resource/index.md +2 -0
  79. data/docs/reference/{positioning.md → resource/positioning.md} +2 -1
  80. data/docs/reference/resource/query.md +2 -0
  81. data/docs/reference/ui/assets.md +8 -0
  82. data/docs/reference/ui/displays.md +5 -8
  83. data/docs/reference/ui/index.md +1 -1
  84. data/docs/reference/wizard/dsl.md +2 -2
  85. data/esbuild.config.js +5 -2
  86. data/gemfiles/rails_7.gemfile.lock +2 -2
  87. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  88. data/lib/active_model/validations/array_validator.rb +1 -1
  89. data/lib/active_model/validations/attached_validator.rb +1 -1
  90. data/lib/active_model/validations/url_validator.rb +2 -2
  91. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  92. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  93. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  94. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  95. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  96. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  97. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  98. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  99. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  100. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  101. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  102. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  103. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  104. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  105. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  106. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  107. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  108. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  109. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  110. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  111. data/lib/plutonium/action/base.rb +27 -3
  112. data/lib/plutonium/action/interactive.rb +17 -4
  113. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  114. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  115. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  116. data/lib/plutonium/configuration.rb +5 -0
  117. data/lib/plutonium/core/controller.rb +25 -1
  118. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  119. data/lib/plutonium/dashboard/base.rb +114 -0
  120. data/lib/plutonium/dashboard/card.rb +200 -0
  121. data/lib/plutonium/dashboard/controller.rb +101 -0
  122. data/lib/plutonium/dashboard/dsl.rb +104 -0
  123. data/lib/plutonium/dashboard/register.rb +40 -0
  124. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  125. data/lib/plutonium/dashboard.rb +42 -0
  126. data/lib/plutonium/definition/actions.rb +6 -1
  127. data/lib/plutonium/definition/base.rb +41 -3
  128. data/lib/plutonium/definition/index_views.rb +1 -1
  129. data/lib/plutonium/definition/positioning.rb +1 -1
  130. data/lib/plutonium/definition/presentable.rb +2 -2
  131. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  132. data/lib/plutonium/helpers/display_helper.rb +10 -2
  133. data/lib/plutonium/interaction/async/context.rb +6 -8
  134. data/lib/plutonium/interaction/async/executor.rb +6 -7
  135. data/lib/plutonium/interaction/async/run.rb +2 -2
  136. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  137. data/lib/plutonium/interaction/base.rb +2 -0
  138. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  139. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  140. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  141. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  142. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  143. data/lib/plutonium/invites/controller.rb +11 -11
  144. data/lib/plutonium/kanban/column.rb +13 -3
  145. data/lib/plutonium/kanban/dsl.rb +7 -4
  146. data/lib/plutonium/models/has_cents.rb +1 -1
  147. data/lib/plutonium/profile/security_section.rb +10 -18
  148. data/lib/plutonium/query/filter.rb +10 -1
  149. data/lib/plutonium/query/filters/association.rb +27 -2
  150. data/lib/plutonium/query/filters/boolean.rb +4 -4
  151. data/lib/plutonium/query/filters/date.rb +4 -17
  152. data/lib/plutonium/query/filters/date_range.rb +2 -2
  153. data/lib/plutonium/query/filters/select.rb +10 -1
  154. data/lib/plutonium/query/filters/text.rb +9 -12
  155. data/lib/plutonium/railtie.rb +17 -0
  156. data/lib/plutonium/resource/controller.rb +10 -5
  157. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  158. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  159. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  160. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  161. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  162. data/lib/plutonium/resource/query_object.rb +30 -3
  163. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  164. data/lib/plutonium/routing/resource_registration.rb +5 -0
  165. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  166. data/lib/plutonium/translation.rb +227 -0
  167. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  168. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  169. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  170. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  171. data/lib/plutonium/ui/component/methods.rb +4 -0
  172. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  173. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  174. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  175. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  176. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  177. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  178. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  179. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  180. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  181. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  182. data/lib/plutonium/ui/display/resource.rb +23 -16
  183. data/lib/plutonium/ui/export_button.rb +3 -3
  184. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  185. data/lib/plutonium/ui/form/components/password.rb +2 -2
  186. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  187. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  188. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  189. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  190. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  191. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  192. data/lib/plutonium/ui/form/query.rb +8 -8
  193. data/lib/plutonium/ui/form/resource.rb +54 -26
  194. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  195. data/lib/plutonium/ui/grid/card.rb +3 -3
  196. data/lib/plutonium/ui/grid/resource.rb +3 -3
  197. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  198. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  199. data/lib/plutonium/ui/kanban/column.rb +6 -6
  200. data/lib/plutonium/ui/layout/base.rb +19 -1
  201. data/lib/plutonium/ui/layout/header.rb +1 -1
  202. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  203. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  204. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  205. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  206. data/lib/plutonium/ui/modal/base.rb +4 -4
  207. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  208. data/lib/plutonium/ui/nav_user.rb +2 -2
  209. data/lib/plutonium/ui/page/base.rb +10 -2
  210. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  211. data/lib/plutonium/ui/page/edit.rb +3 -3
  212. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  213. data/lib/plutonium/ui/page/new.rb +3 -3
  214. data/lib/plutonium/ui/page/show.rb +2 -2
  215. data/lib/plutonium/ui/page/wizard.rb +22 -16
  216. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  217. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  218. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  219. data/lib/plutonium/ui/table/base.rb +2 -2
  220. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  221. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  222. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  223. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  224. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  225. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  226. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  227. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  228. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  229. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  230. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  231. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  232. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  233. data/lib/plutonium/ui/table/resource.rb +53 -17
  234. data/lib/plutonium/ui/wizard/review.rb +7 -7
  235. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  236. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  237. data/lib/plutonium/version.rb +1 -1
  238. data/lib/plutonium/wizard/base.rb +2 -0
  239. data/lib/plutonium/wizard/driving.rb +8 -2
  240. data/lib/plutonium/wizard/dsl.rb +4 -2
  241. data/lib/plutonium/wizard/field_importer.rb +1 -8
  242. data/lib/plutonium/wizard/review_step.rb +8 -1
  243. data/lib/plutonium/wizard/step.rb +14 -3
  244. data/package.json +4 -2
  245. data/plutonium.gemspec +4 -1
  246. data/postcss-gem-import.cjs +34 -0
  247. data/postcss-gem-import.js +3 -28
  248. data/src/css/components.css +91 -0
  249. data/src/css/slim_select.css +0 -38
  250. data/src/css/tokens.css +24 -0
  251. data/src/js/controllers/attachment_input_controller.js +15 -6
  252. data/src/js/controllers/chart_controller.js +162 -0
  253. data/src/js/controllers/clipboard_controller.js +3 -2
  254. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  255. data/src/js/controllers/easymde_controller.js +3 -0
  256. data/src/js/controllers/filter_panel_controller.js +15 -6
  257. data/src/js/controllers/flatpickr_controller.js +8 -0
  258. data/src/js/controllers/frame_refresh_controller.js +59 -0
  259. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  260. data/src/js/controllers/register_controllers.js +4 -0
  261. data/src/js/controllers/slim_select_controller.js +79 -176
  262. data/src/js/core.js +9 -1
  263. data/src/js/i18n.js +69 -0
  264. data/src/js/plutonium-charts.js +15 -0
  265. data/src/js/turbo/turbo_confirm.js +5 -3
  266. metadata +64 -16
  267. data/.yarnrc.yml +0 -8
  268. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  269. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  270. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  271. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  272. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  273. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  274. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  275. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  276. data/public/plutonium-assets/application.js +0 -31419
  277. data/public/plutonium-assets/plutonium.ico +0 -0
  278. data/yarn.lock +0 -6858
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # The controller concern behind `register_dashboard`. Mixed into a
6
+ # portal-namespaced `DashboardsController` synthesized at route-draw time
7
+ # (see {Plutonium::Routing::DashboardRegistration}), so it inherits the
8
+ # portal's auth, entity scoping and layout exactly like a resource
9
+ # controller. Define `<Portal>::DashboardsController` yourself (including
10
+ # this module) to take over.
11
+ #
12
+ # Two actions: `show` renders the page with one lazy turbo frame per card,
13
+ # and `card` answers a single frame. Both run `authorize?` on the dashboard;
14
+ # `card` additionally rejects unknown and hidden cards with a 404.
15
+ module Controller
16
+ extend ActiveSupport::Concern
17
+ include Plutonium::Core::Controller
18
+
19
+ included do
20
+ helper_method :current_user, :current_dashboard, :dashboard_card_path
21
+ end
22
+
23
+ class_methods do
24
+ # The gem's shared partials (`plutonium/_flash`, ...) resolve through a
25
+ # "plutonium" view prefix that a bare host has no ancestor to supply.
26
+ def _prefixes
27
+ @_dashboard_view_prefixes ||= (super | ["plutonium"])
28
+ end
29
+ end
30
+
31
+ # GET the mount — the full page.
32
+ def show
33
+ authorize_dashboard!
34
+ set_page_title(current_dashboard_class.label)
35
+ render Plutonium::UI::Page::Dashboard.new(dashboard: current_dashboard)
36
+ end
37
+
38
+ # GET <mount>/cards/:card — one card, wrapped in its turbo frame.
39
+ def card
40
+ authorize_dashboard!
41
+ card = current_dashboard.visible_card!(params[:card])
42
+ render Plutonium::UI::Dashboard::Frame.new(dashboard: current_dashboard, card:)
43
+ end
44
+
45
+ private
46
+
47
+ # Defers to the host's auth concern (a portal controller's Rodauth) and
48
+ # is nil on a bare main-app host.
49
+ def current_user
50
+ defined?(super) ? super : nil
51
+ end
52
+
53
+ # The dashboard class rides the route defaults. Resolve it through the
54
+ # allowlist of loaded dashboards rather than `constantize`-ing the raw
55
+ # value, so a route param can never reach an arbitrary constant.
56
+ def current_dashboard_class
57
+ @current_dashboard_class ||= begin
58
+ name = params.fetch(:dashboard_class).to_s
59
+ Plutonium::Dashboard::Base.descendants.find { |klass| klass.name == name } ||
60
+ raise(Plutonium::Dashboard::UnknownDashboardError, "unknown dashboard #{name.inspect}")
61
+ end
62
+ end
63
+
64
+ def current_dashboard
65
+ @current_dashboard ||= current_dashboard_class.new(view_context)
66
+ end
67
+
68
+ # `authorize?` false → 403 through the existing ActionPolicy::Unauthorized
69
+ # rescue, with a fake result object so the exception needs no policy.
70
+ def authorize_dashboard!
71
+ return if current_dashboard.authorize?
72
+
73
+ raise ::ActionPolicy::Unauthorized.new(
74
+ current_dashboard,
75
+ :authorize?,
76
+ Plutonium::Dashboard::AuthorizationDeniedResult.new(I18n.t("plutonium.dashboard.authorization_denied"))
77
+ )
78
+ end
79
+
80
+ # The card endpoint's path, threading the entity scope segment through so
81
+ # the frame stays inside the tenant. Resolved by the route's
82
+ # `dashboard_class` default, so `at:` / `as:` and a scope prefix are honoured.
83
+ def dashboard_card_path(card)
84
+ options = {card: card.key}
85
+ options[scoped_entity_param_key] = params[scoped_entity_param_key] if scoped_to_entity?
86
+ current_engine.routes.url_helpers.public_send(dashboard_card_route_helper, **options)
87
+ end
88
+
89
+ def dashboard_card_route_helper
90
+ @dashboard_card_route_helper ||= begin
91
+ name = Plutonium::Dashboard::RouteResolution.route_name(
92
+ current_engine.routes, current_dashboard_class, action: "card"
93
+ )
94
+ raise "no register_dashboard route found for #{current_dashboard_class.name}" unless name
95
+
96
+ :"#{name}_path"
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # The author-facing class macros: `metric`, `chart`, `card`, `refresh`
6
+ # and `width`. Mixed into {Base}.
7
+ module DSL
8
+ extend ActiveSupport::Concern
9
+
10
+ UNSET = Object.new
11
+ private_constant :UNSET
12
+
13
+ class_methods do
14
+ # The declared cards, in declaration order.
15
+ def cards
16
+ @cards ||= []
17
+ end
18
+
19
+ # A single number with an optional change indicator. The block returns
20
+ # the value, or a hash: `{value:, previous:}` / `{value:, change:, trend:}`.
21
+ #
22
+ # metric(:orders, icon: Phlex::TablerIcons::ShoppingCart) { Order.count }
23
+ # metric(:revenue, format: :currency, positive: :up) do
24
+ # {value: Order.this_month.sum(:total), previous: Order.last_month.sum(:total)}
25
+ # end
26
+ def metric(key, **options, &block)
27
+ add_card(key, kind: :metric, options:, block:)
28
+ end
29
+
30
+ # A Chartkick chart. The block returns Chartkick data: a `{label => value}`
31
+ # hash, an array of pairs, or an array of `{name:, data:}` series. Options
32
+ # other than `type:` / `height:` and the common card options are passed
33
+ # straight through to Chartkick (`colors:`, `stacked:`, `suffix:`, ...).
34
+ #
35
+ # chart(:signups, type: :line, span: 8) { User.group_by_day(:created_at, last: 30).count }
36
+ def chart(key, **options, &block)
37
+ add_card(key, kind: :chart, options:, block:)
38
+ end
39
+
40
+ # A free-form card. The block renders Phlex markup inside the card body
41
+ # and can call the dashboard's own methods.
42
+ #
43
+ # card(:recent, span: :full) do
44
+ # ul { dashboard.recent_orders.each { |o| li { o.number } } }
45
+ # end
46
+ def card(key, **options, &block)
47
+ add_card(key, kind: :custom, options:, block:)
48
+ end
49
+
50
+ # Default refresh interval (seconds) for every lazy card. A card's own
51
+ # `refresh:` overrides it; `refresh: false` on a card opts out.
52
+ def refresh(seconds = UNSET)
53
+ return @refresh if seconds.equal?(UNSET)
54
+
55
+ valid = seconds.nil? || (seconds.respond_to?(:to_i) && seconds.to_i.positive?)
56
+ raise ArgumentError, "refresh must be a positive number of seconds or nil, got #{seconds.inspect}" unless valid
57
+
58
+ @refresh = seconds&.to_i
59
+ end
60
+
61
+ # Page width, one of {Plutonium::UI::PageWidth::SIZES}. Defaults to
62
+ # `:full`: a card grid wants the room an index page gets, not the
63
+ # reading column detail pages are constrained to.
64
+ def width(value = UNSET)
65
+ return @width || :full if value.equal?(UNSET)
66
+
67
+ @width = Plutonium::UI::PageWidth.validate!(value)
68
+ end
69
+
70
+ # @return [Card, nil]
71
+ def find_card(key)
72
+ key = key.to_s
73
+ cards.find { |card| card.key.to_s == key }
74
+ end
75
+
76
+ # @return [Card]
77
+ # @raise [UnknownCardError] when no card has that key
78
+ def find_card!(key)
79
+ find_card(key) || raise(UnknownCardError, "#{name} declares no card #{key.inspect}")
80
+ end
81
+
82
+ private
83
+
84
+ def add_card(key, kind:, options:, block:)
85
+ key = key.to_sym
86
+ if find_card(key)
87
+ raise ArgumentError, "#{name || "dashboard"} already declares a card #{key.inspect}"
88
+ end
89
+
90
+ cards << Card.new(key, kind:, dashboard_class: self, block:, **options)
91
+ cards.last
92
+ end
93
+
94
+ # Class-level state must not leak into subclasses by reference.
95
+ def inherited(subclass)
96
+ super
97
+ subclass.instance_variable_set(:@cards, cards.dup)
98
+ subclass.instance_variable_set(:@refresh, @refresh)
99
+ subclass.instance_variable_set(:@width, @width)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # Per-engine registry of the dashboards mounted with `register_dashboard`,
6
+ # so the sidebar can list them in registration order.
7
+ #
8
+ # Classes are stored by name and resolved on read, so a code reload never
9
+ # hands out a stale class object.
10
+ class Register
11
+ def initialize
12
+ @names = []
13
+ end
14
+
15
+ # @param dashboard_class [Class] a {Base} subclass
16
+ def register(dashboard_class)
17
+ unless dashboard_class.is_a?(Class) && dashboard_class < Plutonium::Dashboard::Base
18
+ raise ArgumentError, "#{dashboard_class.inspect} must subclass Plutonium::Dashboard::Base"
19
+ end
20
+
21
+ @names << dashboard_class.name unless @names.include?(dashboard_class.name)
22
+ end
23
+
24
+ # @return [Array<Class>] registered dashboards, in registration order
25
+ def dashboards
26
+ @names.map(&:constantize)
27
+ end
28
+
29
+ def registered?(dashboard_class)
30
+ @names.include?(dashboard_class.name)
31
+ end
32
+
33
+ def clear
34
+ @names.clear
35
+ end
36
+
37
+ def empty? = @names.empty?
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # Resolves a `register_dashboard` mount's named routes from a route set by
6
+ # the `dashboard_class` route default every dashboard route carries.
7
+ #
8
+ # The helper name is not derivable from the class: it comes from `at:` /
9
+ # `as:`, and an entity-scoped portal prefixes it with the scope segment
10
+ # (`organization_scoped_team_dashboard`). Looking the route up by its
11
+ # default tracks whatever the registration actually drew.
12
+ module RouteResolution
13
+ module_function
14
+
15
+ # @param route_set [ActionDispatch::Routing::RouteSet]
16
+ # @param dashboard_class [Class]
17
+ # @param action [String, Symbol] "show" or "card"
18
+ # @return [Symbol, nil] the route name (e.g. :sales_dashboard_card)
19
+ def route_name(route_set, dashboard_class, action:)
20
+ route = route_set.routes.find do |r|
21
+ d = r.defaults
22
+ r.name.present? &&
23
+ d[:action].to_s == action.to_s &&
24
+ d[:dashboard_class].to_s == dashboard_class.name
25
+ end
26
+ route&.name&.to_sym
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ # Dashboards: a declarative page of metric, chart and free-form cards.
5
+ #
6
+ # A dashboard is a plain class (`app/dashboards/sales_dashboard.rb`) that
7
+ # subclasses {Plutonium::Dashboard::Base} and declares its cards with the
8
+ # class-level DSL. It is mounted in a portal (or the main app) with
9
+ # `register_dashboard` in the routes, exactly like `register_wizard`:
10
+ #
11
+ # class SalesDashboard < Plutonium::Dashboard::Base
12
+ # presents label: "Sales", icon: Phlex::TablerIcons::ChartBar
13
+ #
14
+ # metric(:orders) { Order.count }
15
+ # chart(:revenue, type: :line, span: 8) { Order.group_by_day(:created_at).sum(:total) }
16
+ # end
17
+ #
18
+ # AdminPortal::Engine.routes.draw do
19
+ # register_dashboard SalesDashboard, at: "sales"
20
+ # end
21
+ #
22
+ # Every card is loaded in its own lazy turbo frame, so the page renders
23
+ # instantly and each card's queries run in a separate request.
24
+ module Dashboard
25
+ # Raised when a route names a dashboard class that is not loaded (never a
26
+ # user-facing 404 in practice: the class is handed to `register_dashboard`).
27
+ class UnknownDashboardError < StandardError; end
28
+
29
+ # Raised by the card endpoint when the `:card` segment names no declared
30
+ # card, or a card whose `condition:` hides it for this request. Mapped to
31
+ # a 404 by the railtie so a hidden card is indistinguishable from a missing one.
32
+ class UnknownCardError < StandardError; end
33
+
34
+ # DOM id prefix of the per-card turbo frames, shared by the page and the
35
+ # card endpoint.
36
+ FRAME_PREFIX = "pu-dashboard-card"
37
+
38
+ # Stands in for a policy result when `authorize?` denies entry, so the
39
+ # standard ActionPolicy::Unauthorized rescue can render the message.
40
+ AuthorizationDeniedResult = Struct.new(:message)
41
+ end
42
+ end
@@ -26,6 +26,11 @@ module Plutonium
26
26
  @merged_defined_actions ||= begin
27
27
  customize_actions
28
28
  merged = self.class.defined_actions.merge(instance_defined_actions)
29
+ # The standard actions are declared on Base, so bind each action to
30
+ # THIS definition's model here, where it is known, for the
31
+ # plutonium.actions.<model>.<action> label convention.
32
+ model = self.class.model_class_or_nil
33
+ merged = merged.transform_values { |action| action.for_resource(model) } if model
29
34
  merged.sort_by { |k, v| v.position }.to_h
30
35
  end
31
36
  end
@@ -49,7 +54,7 @@ module Plutonium
49
54
  action(:destroy, route_options: {method: :delete},
50
55
  record_action: true, collection_record_action: true, category: :danger,
51
56
  icon: Phlex::TablerIcons::Trash, position: 100,
52
- confirmation: "Are you sure?", turbo_frame: "_top")
57
+ confirmation: t("plutonium.actions.confirm_destroy"), turbo_frame: "_top")
53
58
 
54
59
  # Example of dynamic route options using custom url_resolver:
55
60
  #
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Plutonium
4
4
  module Definition
5
+ # Raised when a definition's model cannot be inferred from its name.
6
+ class ModelClassError < NameError; end
7
+
5
8
  # Base class for Plutonium definitions
6
9
  #
7
10
  # @abstract Subclass and override {#customize_fields}, {#customize_inputs},
@@ -25,6 +28,10 @@ module Plutonium
25
28
  # @note This class is not thread-safe. Ensure proper synchronization
26
29
  # if used in a multi-threaded environment.
27
30
  class Base
31
+ # `input :email, placeholder: t("forms.email_placeholder")` — a lazy
32
+ # translation, resolved on every render in the request's locale.
33
+ extend Plutonium::Translation::Lazy
34
+
28
35
  include DefineableProps
29
36
  include ConfigAttr
30
37
  include InheritableConfigAttr
@@ -69,6 +76,28 @@ module Plutonium
69
76
  # fields
70
77
  defineable_props :field, :input, :display, :column
71
78
 
79
+ # `as: :phlexi_render, with:` is deprecated in favour of the block form.
80
+ # Warn here, at declaration, rather than in the component: it fires once
81
+ # per declaration with the definition's own file and line, instead of
82
+ # once per rendered cell.
83
+ module PhlexiRenderDeprecation
84
+ PHLEXI_RENDER_TAGS = %i[phlexi_render phlexi].freeze
85
+
86
+ def display(name, **options, &block)
87
+ if PHLEXI_RENDER_TAGS.include?(options[:as])
88
+ Plutonium.deprecator.warn(
89
+ "`display :#{name}, as: :#{options[:as]}, with:` is deprecated; use a block-form display " \
90
+ "instead: `display :#{name} do |f| ... end`. It renders in a Phlex context once and gives " \
91
+ "you `f.object`.",
92
+ caller_locations(1)
93
+ )
94
+ end
95
+ super
96
+ end
97
+ end
98
+ singleton_class.prepend PhlexiRenderDeprecation
99
+ prepend PhlexiRenderDeprecation
100
+
72
101
  # export
73
102
  defineable_prop :export
74
103
 
@@ -170,13 +199,22 @@ module Plutonium
170
199
  @model_class ||= infer_model_class
171
200
  end
172
201
 
202
+ # `model_class`, or nil for a definition that has no model to infer (an
203
+ # anonymous or base definition). Convention lookups keyed by the model
204
+ # (action / scope labels) use this so such definitions keep rendering.
205
+ def self.model_class_or_nil
206
+ model_class
207
+ rescue ModelClassError
208
+ nil
209
+ end
210
+
173
211
  def self.infer_model_class
174
- raise NameError, "cannot infer a model class for an anonymous definition; define `model_class` on it" if name.nil?
212
+ raise ModelClassError, "cannot infer a model class for an anonymous definition; define `model_class` on it" if name.nil?
175
213
 
176
214
  segments = name.split("::")
177
215
  base = segments.pop.delete_suffix("Definition")
178
216
  if base.empty?
179
- raise NameError, "#{name} does not follow the `<Model>Definition` naming convention; define `model_class` on it"
217
+ raise ModelClassError, "#{name} does not follow the `<Model>Definition` naming convention; define `model_class` on it"
180
218
  end
181
219
 
182
220
  # Only an ActiveRecord model can be the answer, so a namespace module, a
@@ -190,7 +228,7 @@ module Plutonium
190
228
  return klass if klass.is_a?(Class) && klass < ActiveRecord::Base
191
229
  end
192
230
 
193
- raise NameError, "could not infer a model class from #{name}; define `model_class` on it"
231
+ raise ModelClassError, "could not infer a model class from #{name}; define `model_class` on it"
194
232
  end
195
233
  private_class_method :infer_model_class
196
234
 
@@ -118,7 +118,7 @@ module Plutonium
118
118
  # interaction that references the definition at its own load time
119
119
  # would deadlock the autoloader. In practice interactions depend only
120
120
  # on their model, so this constraint is naturally satisfied.
121
- board = Plutonium::Kanban::DSL.build(&block)
121
+ board = Plutonium::Kanban::DSL.build(resource_class: model_class_or_nil, &block)
122
122
  # Cache the compiled board so the controller can reuse it instead of
123
123
  # recompiling per request (see KanbanActions#current_kanban_board).
124
124
  self.defined_kanban_board = board
@@ -62,7 +62,7 @@ module Plutonium
62
62
  # its own `default_sort` means every resource under it renders the grip
63
63
  # in the disabled state until the user sorts by position. That is the
64
64
  # intended precedence — the app asked for that ordering — and it is
65
- # documented in docs/reference/positioning.md.
65
+ # documented in docs/reference/resource/positioning.md.
66
66
  default_sort config.attribute, :asc if _default_sort.equal?(Sorting::DEFAULT_SORT)
67
67
 
68
68
  # Hidden: it has a POST member route (routing/mapper_extensions.rb) and
@@ -37,7 +37,7 @@ module Plutonium
37
37
  #
38
38
  # @return [String] The label defined in the presentation metadata or a default generated from the class name.
39
39
  def label
40
- presentation_metadata[:label] || name.demodulize.titleize
40
+ Plutonium::Translation.resolve(presentation_metadata[:label]) || name.demodulize.titleize
41
41
  end
42
42
 
43
43
  # Returns the icon for the interaction.
@@ -51,7 +51,7 @@ module Plutonium
51
51
  #
52
52
  # @return [String, nil] The description defined in the presentation metadata.
53
53
  def description
54
- presentation_metadata[:description]
54
+ Plutonium::Translation.resolve(presentation_metadata[:description])
55
55
  end
56
56
  end
57
57
 
@@ -33,6 +33,19 @@ module Plutonium
33
33
  Plutonium.configuration.assets.script
34
34
  end
35
35
 
36
+ # URL of the on-demand charts bundle. Chart cards carry it in a data
37
+ # attribute and the `chart` Stimulus controller injects it once, so pages
38
+ # without a chart never pay for Chart.js.
39
+ #
40
+ # @return [String] the charts bundle URL
41
+ def resource_charts_script_url
42
+ if Plutonium.configuration.development?
43
+ resource_development_asset_url(:js, "plutonium-charts.js")
44
+ else
45
+ asset_path(Plutonium.configuration.assets.charts_script)
46
+ end
47
+ end
48
+
36
49
  private
37
50
 
38
51
  # Generate the appropriate asset URL based on the environment
@@ -52,9 +65,9 @@ module Plutonium
52
65
  #
53
66
  # @param type [Symbol] asset type (:css or :js)
54
67
  # @return [String] asset URL for development
55
- def resource_development_asset_url(type)
68
+ def resource_development_asset_url(type, asset_key = nil)
56
69
  manifest_file = (type == :css) ? "css.manifest" : "js.manifest"
57
- asset_key = (type == :css) ? "plutonium.css" : "plutonium.js"
70
+ asset_key ||= (type == :css) ? "plutonium.css" : "plutonium.js"
58
71
 
59
72
  filename = JSON.parse(File.read(Plutonium.root.join("src", "build", manifest_file)))[asset_key]
60
73
  "/build/#{filename}"
@@ -1,8 +1,16 @@
1
1
  module Plutonium
2
2
  module Helpers
3
3
  module DisplayHelper
4
+ # The model's human name, pluralised for `count`. A locale that defines
5
+ # `activerecord.models.<model>.one/other` supplies the plural; when it
6
+ # only defines a single string (or nothing), the English inflector
7
+ # pluralises the singular as before.
4
8
  def resource_name(resource_class, count = 1)
5
- resource_class.model_name.human.pluralize(count)
9
+ model_name = resource_class.model_name
10
+ return model_name.human if count == 1
11
+ return model_name.human(count: count) if Plutonium::Translation.locale_plural?(resource_class)
12
+
13
+ model_name.human.pluralize(count)
6
14
  end
7
15
 
8
16
  def resource_name_plural(resource_class)
@@ -44,7 +52,7 @@ module Plutonium
44
52
  end
45
53
 
46
54
  # Maybe this is a record?
47
- return "#{resource_name(obj.class)} ##{obj.id}" if obj.respond_to?(:id)
55
+ return t("plutonium.resource.record_label", resource: resource_name(obj.class), id: obj.id) if obj.respond_to?(:id)
48
56
 
49
57
  # Oh well. Just convert it to a string.
50
58
  obj.to_s
@@ -351,8 +351,8 @@ module Plutonium
351
351
  # user row is gone. Refusing is the whole point: a run whose initiator
352
352
  # no longer exists has no one left to authorize as.
353
353
  if run.initiator.nil?
354
- raise UnresolvableError,
355
- "run #{run.id} has no initiator (#{run.initiator_type}##{run.initiator_id} no longer exists)"
354
+ raise UnresolvableError, I18n.t("plutonium.async.context.initiator_missing",
355
+ run: run.id, type: run.initiator_type, id: run.initiator_id)
356
356
  end
357
357
 
358
358
  # nil scoped_entity is LEGITIMATE — an unscoped portal has no tenant,
@@ -362,9 +362,8 @@ module Plutonium
362
362
  # drops the entity filter entirely, returning records from EVERY tenant.
363
363
  # That is precisely the fail-open case this class exists to prevent.
364
364
  if run.scoped_entity_type.present? && run.scoped_entity.nil?
365
- raise UnresolvableError,
366
- "run #{run.id} was scoped to #{run.scoped_entity_type}##{run.scoped_entity_id}, " \
367
- "which no longer exists; refusing to resolve targets without a tenant"
365
+ raise UnresolvableError, I18n.t("plutonium.async.context.tenant_missing",
366
+ run: run.id, type: run.scoped_entity_type, id: run.scoped_entity_id)
368
367
  end
369
368
 
370
369
  # Same distinction one more time, and the same direction of failure. A
@@ -372,9 +371,8 @@ module Plutonium
372
371
  # dispatch" — and that hands the run entity scoping instead of parent
373
372
  # scoping, which is wider.
374
373
  if run.parent_type.present? && run.parent.nil?
375
- raise UnresolvableError,
376
- "run #{run.id} was nested under #{run.parent_type}##{run.parent_id}, " \
377
- "which no longer exists; refusing to resolve targets without its parent"
374
+ raise UnresolvableError, I18n.t("plutonium.async.context.parent_missing",
375
+ run: run.id, type: run.parent_type, id: run.parent_id)
378
376
  end
379
377
  end
380
378
 
@@ -206,8 +206,7 @@ module Plutonium
206
206
  elsif (remaining = perform_each(resolved.records))
207
207
  # The loop stopped, so the remaining targets were never attempted.
208
208
  # A run that did not do its job must not read as completed.
209
- run.fail!("stopped at the first target failure (#{run.failure_policy} policy); " \
210
- "#{remaining} target(s) were not attempted")
209
+ run.fail!(I18n.t("plutonium.async.executor.halted", policy: run.failure_policy, count: remaining))
211
210
  else
212
211
  # Partial failure under :continue is COMPLETED, not failed. The author
213
212
  # declared partial application acceptable, the executor ran to the
@@ -300,7 +299,7 @@ module Plutonium
300
299
  def fail_target(record, error)
301
300
  run.reload if run.changed?
302
301
 
303
- raise BatchAbortedError, "target #{record.id} failed (#{error.message}); no targets were applied" if transactional?
302
+ raise BatchAbortedError, I18n.t("plutonium.async.executor.batch_aborted", id: record.id, error: error.message) if transactional?
304
303
 
305
304
  run.record_target_failure!(id: record.id, message: error.message)
306
305
  halt? ? :halt : nil
@@ -393,8 +392,8 @@ module Plutonium
393
392
  # part of the batch anyway would be the one outcome neither policy allows.
394
393
  def refuse_partial_batch(resolved)
395
394
  unresolved = resolved.missing_ids.size + resolved.unauthorized_ids.size
396
- run.fail!("#{unresolved} of #{run.target_ids.size} targets could not be resolved; " \
397
- "a #{run.failure_policy} run does not apply a partial batch")
395
+ run.fail!(I18n.t("plutonium.async.executor.partial_batch_refused",
396
+ unresolved: unresolved, total: run.target_ids.size, policy: run.failure_policy))
398
397
  end
399
398
 
400
399
  # Advances progress and records +id+ as dispositioned, in one write —
@@ -407,9 +406,9 @@ module Plutonium
407
406
  run.save!
408
407
  end
409
408
 
410
- def missing_message(id) = "Target #{id} is no longer available"
409
+ def missing_message(id) = I18n.t("plutonium.async.executor.target_missing", id: id)
411
410
 
412
- def unauthorized_message(id) = "Target #{id} is no longer permitted by #{context.policy_action}"
411
+ def unauthorized_message(id) = I18n.t("plutonium.async.executor.target_unauthorized", id: id, action: context.policy_action)
413
412
 
414
413
  def continue? = run.failure_policy == :continue
415
414
 
@@ -169,7 +169,7 @@ module Plutonium
169
169
  def to_label
170
170
  parts = self.class.name.split("::")
171
171
  name = (parts.last == "Run" && parts.size > 1) ? "#{parts[-2]}Run" : parts.last
172
- "#{name.titleize} ##{to_param}"
172
+ I18n.t("plutonium.async.run.label", name: name.titleize, id: to_param)
173
173
  end
174
174
 
175
175
  # nil means INDETERMINATE, not zero: opaque work has no denominator, and
@@ -385,7 +385,7 @@ module Plutonium
385
385
  def enqueue_job
386
386
  Plutonium::Interaction::Async::Job.perform_later(id)
387
387
  rescue
388
- fail!("could not be enqueued for execution")
388
+ fail!(I18n.t("plutonium.async.run.enqueue_failed"))
389
389
  raise
390
390
  end
391
391
  end
@@ -38,7 +38,7 @@ module Plutonium
38
38
  column :outcome, as: :badge,
39
39
  colors: {running: :info, completed_with_errors: :warning}
40
40
 
41
- field :target_label, label: "Target type"
41
+ field :target_label, label: t("plutonium.async.run.target_type")
42
42
 
43
43
  # The details tab, with the live panel above the fields.
44
44
  #
@@ -38,6 +38,8 @@ module Plutonium
38
38
  # @note Subclasses must implement the #execute method.
39
39
  # @see https://radioactive-labs.github.io/plutonium-core/reference/behavior/interactions
40
40
  class Base
41
+ extend Plutonium::Translation::Lazy
42
+
41
43
  include ActiveModel::Model
42
44
  include ActiveModel::Attributes
43
45
  include Plutonium::Definition::DefineableProps