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,30 @@
1
+ # Wording specific to the <%= package_name %> portal.
2
+ #
3
+ # This file is loaded automatically (the portal is a Rails engine) ahead of the
4
+ # host app's config/locales: anything here overrides Plutonium's defaults for
5
+ # this portal, and the app's own locale files override this.
6
+ #
7
+ # Portal-specific text for a resource goes under
8
+ # plutonium.portals.<%= package_namespace %>, mirroring the global keys:
9
+ #
10
+ # en:
11
+ # plutonium:
12
+ # portals:
13
+ # <%= package_namespace %>:
14
+ # fields:
15
+ # blogging/post:
16
+ # title:
17
+ # placeholder: "Working title"
18
+ # hint: "Shown in the sidebar"
19
+ # actions:
20
+ # blogging/post:
21
+ # publish: "Publish to site"
22
+ # scopes:
23
+ # blogging/post:
24
+ # drafts: "Drafts"
25
+ #
26
+ # See https://radioactive-labs.github.io/plutonium-core/reference/i18n
27
+ en:
28
+ plutonium:
29
+ portals:
30
+ <%= package_namespace %>: {}
@@ -49,14 +49,6 @@ module Pu
49
49
  template "definition.rb", File.join("app/definitions", class_path, "#{file_name}_definition.rb")
50
50
  end
51
51
 
52
- # def create_presenter
53
- # template "presenter.rb", File.join("app/presenters", class_path, "#{file_name}_presenter.rb")
54
- # end
55
-
56
- # def create_query_object
57
- # template "query_object.rb", File.join("app/query_objects", class_path, "#{file_name}_query_object.rb")
58
- # end
59
-
60
52
  private
61
53
 
62
54
  def default_policy_attributes
@@ -64,24 +64,6 @@ module Pu
64
64
  options[feature]
65
65
  end
66
66
 
67
- # Creates a hash of options to pass down options to an invoked sub generator
68
- def invoke_options
69
- # These are custom options we want to track.
70
- extra_options = %i[argon2 mails kitchen_sink defaults]
71
- # Append them to all the available options from our configuration
72
- valid_options = configuration.keys.map(&:to_sym).concat extra_options
73
- # Index map the list with the selection value
74
- opts = valid_options.map { |opt| [opt, send(:"#{opt}?")] }.to_h.compact
75
- # True only options. We don't care if they are false.
76
- %i[api_only force skip pretend quiet].each do |key|
77
- next unless options[key]
78
-
79
- opts[key] = options[key]
80
- end
81
-
82
- opts
83
- end
84
-
85
67
  def all_selected
86
68
  @all_selected ||= configuration.keys.select { |feature| send(:"#{feature}?") }
87
69
  end
@@ -6,11 +6,34 @@ module Plutonium
6
6
  module Action
7
7
  # Base class for all actions in the Plutonium framework.
8
8
  class Base
9
- attr_reader :name, :label, :description, :icon, :route_options, :confirmation, :turbo, :color, :category, :position, :return_to, :condition, :link, :button
9
+ attr_reader :name, :icon, :route_options, :turbo, :color, :category, :position, :return_to, :condition, :link, :button
10
+
11
+ # `label:`, `description:` and `confirmation:` may be lazy translations
12
+ # (`t(...)` in a definition) or any zero-arity proc: resolve per read so
13
+ # the text follows the request locale rather than the class load.
14
+ def label
15
+ Plutonium::Translation.resolve(@label) ||
16
+ Plutonium::Translation.label_for(:action, @resource_class, @name) ||
17
+ @name.to_s.titleize
18
+ end
19
+
20
+ def description = Plutonium::Translation.resolve(@description)
21
+
22
+ # A copy of this action bound to `resource_class`, so its default label
23
+ # resolves through plutonium.actions.<model>.<action>. Returns self when
24
+ # already bound.
25
+ def for_resource(resource_class)
26
+ return self if @resource_class || resource_class.nil?
27
+
28
+ dup.tap { |copy| copy.instance_variable_set(:@resource_class, resource_class) }
29
+ end
30
+
31
+ def confirmation = Plutonium::Translation.resolve(@confirmation)
10
32
 
11
33
  def initialize(name, **options)
12
34
  @name = name.to_sym
13
- @label = options[:label] || @name.to_s.titleize
35
+ @label = options[:label]
36
+ @resource_class = options[:resource_class]
14
37
  @description = options[:description]
15
38
  @icon = options[:icon] || Phlex::TablerIcons::ChevronRight
16
39
  @color = options[:color]
@@ -152,7 +175,8 @@ module Plutonium
152
175
  size: @modal_size,
153
176
  condition: @condition,
154
177
  link: @link,
155
- button: @button
178
+ button: @button,
179
+ resource_class: @resource_class
156
180
  }
157
181
  end
158
182
 
@@ -19,20 +19,33 @@ module Plutonium
19
19
  @interaction = interaction
20
20
  @immediate = immediate
21
21
 
22
- options[:label] ||= interaction.label
23
- options[:description] ||= interaction.description
24
22
  options[:icon] ||= interaction.icon
25
23
  options[:turbo_frame] = Plutonium::REMOTE_MODAL_FRAME unless options.key?(:turbo_frame)
26
24
 
27
25
  super(name, **options)
28
26
  end
29
27
 
28
+ # `label:` on the action, then the interaction's explicit `presents
29
+ # label:`, then plutonium.actions.<model>.<action>, then the
30
+ # interaction's class-name default. Resolved per read so lazy
31
+ # translations follow the request locale.
32
+ def label
33
+ Plutonium::Translation.resolve(@label) ||
34
+ Plutonium::Translation.resolve(interaction.presentation_metadata[:label]) ||
35
+ Plutonium::Translation.label_for(:action, @resource_class, @name) ||
36
+ interaction.label
37
+ end
38
+
39
+ def description
40
+ Plutonium::Translation.resolve(@description) || interaction.description
41
+ end
42
+
30
43
  # Get the confirmation message for the action
31
44
  #
32
45
  # @return [String, nil] The confirmation message or nil if not applicable
33
46
  def confirmation
34
- return @confirmation unless @confirmation.nil?
35
- @immediate ? "#{label}?" : nil
47
+ return Plutonium::Translation.resolve(@confirmation) unless @confirmation.nil?
48
+ @immediate ? Plutonium::Translation.t("plutonium.actions.confirm_default", label: label) : nil
36
49
  end
37
50
 
38
51
  protected
@@ -27,7 +27,7 @@ module Plutonium
27
27
  include Plutonium::Interaction::Concerns::Scoping
28
28
 
29
29
  included do
30
- presents label: "Create API Client", icon: Phlex::TablerIcons::Key
30
+ presents label: t("plutonium.api_client.create.label"), icon: Phlex::TablerIcons::Key
31
31
 
32
32
  attribute :login, :string
33
33
 
@@ -55,7 +55,7 @@ module Plutonium
55
55
  )
56
56
  )
57
57
  rescue ActiveRecord::RecordNotFound => e
58
- failed(login: "Failed to create account: #{e.message}")
58
+ failed(login: I18n.t("plutonium.api_client.create.failed", error: e.message))
59
59
  rescue => e
60
60
  failed(login: e.message)
61
61
  end
@@ -173,16 +173,17 @@ module Plutonium
173
173
  end
174
174
 
175
175
  p(class: "text-green-700 dark:text-green-300") do
176
- strong { "Important: " }
177
- plain "Save these credentials now. The password cannot be retrieved later."
176
+ strong { t("plutonium.api_client.create.credentials.important_label") }
177
+ whitespace
178
+ plain t("plutonium.api_client.create.credentials.important_text")
178
179
  end
179
180
  end
180
181
  end
181
182
 
182
183
  def render_credentials_card
183
184
  div(class: "bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-6 space-y-4") do
184
- render_credential_field("Login", @login)
185
- render_credential_field("Password", @password)
185
+ render_credential_field(t("plutonium.api_client.create.credentials.login"), @login)
186
+ render_credential_field(t("plutonium.api_client.create.credentials.password"), @password)
186
187
  end
187
188
  end
188
189
 
@@ -201,13 +202,13 @@ module Plutonium
201
202
  type: "button",
202
203
  data: {action: "clipboard#copy"},
203
204
  class: "px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-md transition-colors"
204
- ) { "Copy" }
205
+ ) { t("plutonium.api_client.create.credentials.copy") }
205
206
  end
206
207
  end
207
208
  end
208
209
 
209
210
  def render_action_buttons
210
- credentials_text = "Login: #{@login}\nPassword: #{@password}"
211
+ credentials_text = t("plutonium.api_client.create.credentials.credentials_text", login: @login, password: @password)
211
212
 
212
213
  div(class: "mt-6 flex gap-4", data: {controller: "clipboard"}) do
213
214
  input(type: "hidden", value: credentials_text, data: {clipboard_target: "source"})
@@ -215,12 +216,12 @@ module Plutonium
215
216
  type: "button",
216
217
  data: {action: "clipboard#copy"},
217
218
  class: "px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-md transition-colors"
218
- ) { "Copy All" }
219
+ ) { t("plutonium.api_client.create.credentials.copy_all") }
219
220
 
220
221
  a(
221
222
  href: done_url,
222
223
  class: "px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-md transition-colors"
223
- ) { "Done" }
224
+ ) { t("plutonium.api_client.create.credentials.done") }
224
225
  end
225
226
  end
226
227
 
@@ -242,7 +243,7 @@ module Plutonium
242
243
 
243
244
  # Override in subclass to customize
244
245
  def success_title
245
- "API Client Created Successfully"
246
+ t("plutonium.api_client.create.credentials.success_title")
246
247
  end
247
248
 
248
249
  # Override in subclass to customize the done URL
@@ -20,8 +20,8 @@ module Plutonium
20
20
  extend ActiveSupport::Concern
21
21
 
22
22
  included do
23
- presents label: "Disable",
24
- description: "Disable this API client (cannot be undone)",
23
+ presents label: t("plutonium.api_client.disable.label"),
24
+ description: t("plutonium.api_client.disable.description"),
25
25
  icon: Phlex::TablerIcons::Ban,
26
26
  color: :danger
27
27
 
@@ -52,7 +52,7 @@ module Plutonium
52
52
  # @param login [String] the login of the disabled API client
53
53
  # @return [String]
54
54
  def success_message(login)
55
- "API client '#{login}' has been disabled"
55
+ I18n.t("plutonium.api_client.disable.disabled", login: login)
56
56
  end
57
57
 
58
58
  def rodauth_instance
@@ -14,7 +14,7 @@ module Plutonium
14
14
  "mysql2" => (RUBY_ENGINE == "jruby") ? "mysql" : "mysql2",
15
15
  "sqlite3" => "sqlite",
16
16
  "oracle_enhanced" => "oracle",
17
- "sqlserver" => (RUBY_ENGINE == "jruby") ? "mssql" : "tinytds"
17
+ "sqlserver" => (RUBY_ENGINE == "jruby") ? "sqlserver" : "tinytds"
18
18
  }.freeze
19
19
 
20
20
  class << self
@@ -209,12 +209,17 @@ module Plutonium
209
209
  # @return [String] path to JavaScript file
210
210
  attr_accessor :script
211
211
 
212
+ # @return [String] path to the on-demand charts bundle (Chart.js +
213
+ # Chartkick), loaded by the first chart card on a page
214
+ attr_accessor :charts_script
215
+
212
216
  # Initialize a new AssetConfiguration instance with default values
213
217
  def initialize
214
218
  @logo = "plutonium.png"
215
219
  @favicon = "plutonium.ico"
216
220
  @stylesheet = "plutonium.css"
217
221
  @script = "plutonium.min.js"
222
+ @charts_script = "plutonium-charts.min.js"
218
223
  end
219
224
  end
220
225
  end
@@ -32,6 +32,12 @@ module Plutonium
32
32
  end
33
33
  end
34
34
 
35
+ # Convention lookups (field placeholders, action labels...) layer a
36
+ # portal-specific key over the global one. Publish the portal once per
37
+ # request so lookups made outside a view (query object, kanban flash)
38
+ # resolve the same way the view does.
39
+ before_action { Plutonium::Translation::Current.portal = current_package }
40
+
35
41
  before_action do
36
42
  next unless defined?(ActiveStorage)
37
43
 
@@ -45,7 +51,7 @@ module Plutonium
45
51
 
46
52
  append_view_path File.expand_path("app/views", Plutonium.root)
47
53
  layout -> { turbo_frame_request? ? false : "resource" }
48
- helper_method :registered_resources
54
+ helper_method :registered_resources, :registered_dashboards, :dashboard_path_for
49
55
 
50
56
  class_attribute :_rail_enabled, instance_writer: false, default: nil
51
57
  class_attribute :_shell, instance_writer: false, default: nil
@@ -402,6 +408,24 @@ module Plutonium
402
408
  current_engine.resource_register.resources
403
409
  end
404
410
 
411
+ # Dashboards mounted on the current engine with `register_dashboard`,
412
+ # in registration order.
413
+ def registered_dashboards
414
+ current_engine.dashboard_register.dashboards
415
+ end
416
+
417
+ # The page path of a registered dashboard, threading the entity scope
418
+ # segment through on a `:path`-scoped portal. Nil when the dashboard is
419
+ # not registered on the current engine.
420
+ def dashboard_path_for(dashboard_class)
421
+ name = Plutonium::Dashboard::RouteResolution.route_name(current_engine.routes, dashboard_class, action: "show")
422
+ return if name.nil?
423
+
424
+ options = {}
425
+ options[scoped_entity_param_key] = params[scoped_entity_param_key] if scoped_to_entity?
426
+ current_engine.routes.url_helpers.public_send(:"#{name}_path", **options)
427
+ end
428
+
405
429
  # Converts RouteOptions into a URL using the appropriate URL resolver.
406
430
  #
407
431
  # This method takes a RouteOptions object and generates a URL based on the url_resolver
@@ -20,7 +20,7 @@ module Plutonium
20
20
  # Use this instead of authorized_scope directly
21
21
  def authorized_resource_scope(resource, relation: nil, **options)
22
22
  unless resource.instance_of?(Class) && resource < ActiveRecord::Base
23
- raise ArgumentError("Expected resource to be a class inheriting ActiveRecord::Base")
23
+ raise ArgumentError, "Expected resource to be a class inheriting ActiveRecord::Base"
24
24
  end
25
25
 
26
26
  options[:with] ||= ::ActionPolicy.lookup(resource, namespace: authorization_namespace)
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # The base class every dashboard subclasses.
6
+ #
7
+ # class SalesDashboard < Plutonium::Dashboard::Base
8
+ # presents label: "Sales", description: "Orders and revenue at a glance"
9
+ # refresh 60
10
+ #
11
+ # metric(:orders, icon: Phlex::TablerIcons::ShoppingCart) { orders.count }
12
+ # chart(:revenue, type: :area, span: 8) { orders.group_by_day(:created_at).sum(:total) }
13
+ #
14
+ # private
15
+ #
16
+ # def orders = authorized_resource_scope(Order)
17
+ # end
18
+ #
19
+ # An instance is built per request with the view context, so card blocks
20
+ # run with `current_user`, `current_scoped_entity`, `params`,
21
+ # `authorized_resource_scope`, `resource_url_for` and the dashboard's own
22
+ # methods in scope. `authorize?` gates the page and every card endpoint.
23
+ class Base
24
+ extend Plutonium::Translation::Lazy
25
+ include Plutonium::Definition::Presentable
26
+ include DSL
27
+
28
+ attr_reader :view_context
29
+
30
+ # Everything a card block is likely to reach for, forwarded to the view
31
+ # context (which in turn exposes the controller's helper methods).
32
+ delegate :current_user, :current_scoped_entity, :scoped_to_entity?,
33
+ :params, :request, :controller, :current_engine,
34
+ :resource_url_for, :authorized_resource_scope, :allowed_to?, :policy_for,
35
+ :registered_resources, :root_path,
36
+ to: :view_context
37
+
38
+ class << self
39
+ # `plutonium.dashboards.<key>` locale segment and frame/DOM prefix:
40
+ # `AdminPortal::SalesDashboard` → `admin_portal/sales`.
41
+ def i18n_key
42
+ name.underscore.delete_suffix("_dashboard")
43
+ end
44
+
45
+ # Route slug used when `register_dashboard` gets no `as:` and mounts at
46
+ # the root: `SalesDashboard` → `sales`.
47
+ def route_name
48
+ name.demodulize.underscore.delete_suffix("_dashboard")
49
+ end
50
+
51
+ # Title: `presents label:`, then the `plutonium.dashboards.<key>.label`
52
+ # convention, then the class name minus "Dashboard".
53
+ def label
54
+ Plutonium::Translation.resolve(presentation_metadata[:label]) ||
55
+ Plutonium::Translation.dashboard_text(self, :label) ||
56
+ name.demodulize.delete_suffix("Dashboard").titleize
57
+ end
58
+
59
+ # Caption under the title: `presents description:`, then the
60
+ # `plutonium.dashboards.<key>.description` convention.
61
+ def description
62
+ Plutonium::Translation.resolve(presentation_metadata[:description]) ||
63
+ Plutonium::Translation.dashboard_text(self, :description)
64
+ end
65
+
66
+ # Sidebar icon: `presents icon:`, defaulting to a dashboard glyph.
67
+ def icon
68
+ presentation_metadata[:icon] || Phlex::TablerIcons::LayoutDashboard
69
+ end
70
+ end
71
+
72
+ def initialize(view_context)
73
+ @view_context = view_context
74
+ end
75
+
76
+ # Entry authorization. Authors override it; false → 403 on the page and
77
+ # on every card endpoint. Default allow: a portal already sits behind
78
+ # its auth constraint.
79
+ def authorize? = true
80
+
81
+ # The cards whose `condition:` passes for this request, in order.
82
+ def visible_cards
83
+ self.class.cards.select { |card| card.visible?(self) }
84
+ end
85
+
86
+ # The card the endpoint was asked for, if declared and visible.
87
+ # @raise [UnknownCardError]
88
+ def visible_card!(key)
89
+ card = self.class.find_card!(key)
90
+ raise UnknownCardError, "#{self.class.name} card #{key.inspect} is not visible" unless card.visible?(self)
91
+
92
+ card
93
+ end
94
+
95
+ # The refresh interval for a card: its own, else the dashboard default.
96
+ # A card declared `refresh: false` never refreshes.
97
+ def refresh_for(card)
98
+ return nil if card.refresh == false
99
+
100
+ card.refresh || self.class.refresh
101
+ end
102
+
103
+ # The view context, for helpers a card block wants by name
104
+ # (`helpers.number_to_currency`, route helpers, ...).
105
+ def helpers = view_context
106
+
107
+ private
108
+
109
+ def t(key, **options)
110
+ Plutonium::Translation.t(key, **options)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Dashboard
5
+ # One declared card: its kind (`:metric`, `:chart` or `:custom`), the
6
+ # presentation options every kind shares, the kind-specific options and
7
+ # the block that produces its data (or, for a custom card, its markup).
8
+ #
9
+ # Built by {DSL} at class-load and frozen; everything request-dependent
10
+ # (label translation, `condition:`, `href:`) is resolved per render.
11
+ class Card
12
+ KINDS = %i[metric chart custom].freeze
13
+
14
+ # The dashboard grid is 12 columns wide, so halves, thirds and quarters
15
+ # all divide evenly. A card spans 1 to 12 of them; `:full` is 12.
16
+ GRID_COLUMNS = 12
17
+
18
+ # The width each kind reads well at when no `span:` is given: four
19
+ # metrics, two charts or two custom cards to a row.
20
+ DEFAULT_SPANS = {metric: 3, chart: 6, custom: 6}.freeze
21
+
22
+ METRIC_FORMATS = %i[number currency percentage human].freeze
23
+ METRIC_POSITIVE = %i[up down].freeze
24
+ CHART_TYPES = %i[line area column bar pie donut scatter].freeze
25
+
26
+ # Options every kind accepts.
27
+ COMMON_OPTIONS = %i[label description icon span lazy refresh condition href].freeze
28
+ METRIC_OPTIONS = %i[format precision unit prefix suffix positive change_label].freeze
29
+ CHART_OPTIONS = %i[type height].freeze
30
+
31
+ attr_reader :key, :kind, :icon, :span, :refresh, :condition, :href, :block, :options, :dashboard_class
32
+
33
+ def initialize(key, kind:, dashboard_class:, block:, **options)
34
+ @key = key.to_sym
35
+ @kind = kind
36
+ @dashboard_class = dashboard_class
37
+ @block = block
38
+
39
+ raise ArgumentError, "card #{@key.inspect}: unknown kind #{kind.inspect}" unless KINDS.include?(kind)
40
+ raise ArgumentError, "card #{@key.inspect} requires a block" if block.nil?
41
+
42
+ common = options.slice(*COMMON_OPTIONS)
43
+ specific = options.except(*COMMON_OPTIONS)
44
+
45
+ @label = common[:label]
46
+ @description = common[:description]
47
+ @icon = common[:icon]
48
+ @span = validate_span!(common.fetch(:span) { DEFAULT_SPANS.fetch(kind) })
49
+ @lazy = common.fetch(:lazy, true) ? true : false
50
+ @refresh = validate_refresh!(common[:refresh])
51
+ @condition = common[:condition]
52
+ @href = common[:href]
53
+ @options = validate_options!(specific).freeze
54
+
55
+ if @refresh && !@lazy
56
+ raise ArgumentError,
57
+ "card #{@key.inspect}: `refresh:` reloads the card's turbo frame, so it needs `lazy: true` (the default)"
58
+ end
59
+
60
+ freeze
61
+ end
62
+
63
+ def lazy? = @lazy
64
+ def metric? = kind == :metric
65
+ def chart? = kind == :chart
66
+ def custom? = kind == :custom
67
+ def full_width? = span == GRID_COLUMNS
68
+
69
+ # The card title, resolved per request: an explicit `label:` (lazy `t`
70
+ # procs included), then the `plutonium.dashboards.<dashboard>.cards.<key>.label`
71
+ # convention, then the key titleized.
72
+ def label
73
+ Plutonium::Translation.resolve(@label) ||
74
+ Plutonium::Translation.dashboard_text(dashboard_class, :cards, key, :label) ||
75
+ key.to_s.titleize
76
+ end
77
+
78
+ # Optional caption; `description:` first, then the locale convention.
79
+ def description
80
+ Plutonium::Translation.resolve(@description) ||
81
+ Plutonium::Translation.dashboard_text(dashboard_class, :cards, key, :description)
82
+ end
83
+
84
+ # Whether the card renders for this request. `condition:` is a proc
85
+ # evaluated on the dashboard instance (zero arity) or with it as the
86
+ # argument (one arity), or a symbol naming a dashboard method. It gates
87
+ # the page AND the card endpoint, so a hidden card cannot be fetched
88
+ # directly.
89
+ def visible?(dashboard)
90
+ return true if condition.nil?
91
+
92
+ result = case condition
93
+ when Symbol then dashboard.send(condition)
94
+ when Proc then evaluate_on(dashboard, condition)
95
+ else condition
96
+ end
97
+ result ? true : false
98
+ end
99
+
100
+ # The card's link target for this request, if any.
101
+ def href_for(dashboard)
102
+ href.is_a?(Proc) ? evaluate_on(dashboard, href) : href
103
+ end
104
+
105
+ # Runs the data block on the dashboard instance.
106
+ def evaluate(dashboard)
107
+ evaluate_on(dashboard, block)
108
+ end
109
+
110
+ # DOM id of the turbo frame hosting this card. One dashboard renders per
111
+ # page, so the card key is unique enough.
112
+ def frame_id
113
+ "#{FRAME_PREFIX}-#{key.to_s.dasherize}"
114
+ end
115
+
116
+ # Chart cards: the Chartkick constructor name for `type:`.
117
+ def chart_class
118
+ case options[:type]
119
+ when :area then "AreaChart"
120
+ when :column then "ColumnChart"
121
+ when :bar then "BarChart"
122
+ when :pie, :donut then "PieChart"
123
+ when :scatter then "ScatterChart"
124
+ else "LineChart"
125
+ end
126
+ end
127
+
128
+ # Chart cards: Chartkick options passed through to the JS constructor,
129
+ # with the `donut` flag derived from `type: :donut`.
130
+ def chart_options
131
+ passthrough = options.except(*CHART_OPTIONS)
132
+ passthrough = passthrough.merge(donut: true) if options[:type] == :donut
133
+ passthrough
134
+ end
135
+
136
+ private
137
+
138
+ def evaluate_on(dashboard, callable)
139
+ callable.arity.zero? ? dashboard.instance_exec(&callable) : dashboard.instance_exec(dashboard, &callable)
140
+ end
141
+
142
+ def validate_span!(span)
143
+ return GRID_COLUMNS if span == :full
144
+ return span if span.is_a?(Integer) && span.between?(1, GRID_COLUMNS)
145
+
146
+ raise ArgumentError, "card #{key.inspect}: span must be an integer from 1 to #{GRID_COLUMNS}, or :full, got #{span.inspect}"
147
+ end
148
+
149
+ # `false` is kept distinct from nil: nil inherits the dashboard's
150
+ # `refresh`, false opts this card out of it.
151
+ def validate_refresh!(refresh)
152
+ return refresh if refresh.nil? || refresh == false
153
+ return refresh.to_i if refresh.respond_to?(:to_i) && refresh.to_i.positive?
154
+
155
+ raise ArgumentError, "card #{key.inspect}: refresh must be a positive number of seconds or false, got #{refresh.inspect}"
156
+ end
157
+
158
+ def validate_options!(specific)
159
+ case kind
160
+ when :metric then validate_metric_options!(specific)
161
+ when :chart then validate_chart_options!(specific)
162
+ else validate_custom_options!(specific)
163
+ end
164
+ end
165
+
166
+ def validate_metric_options!(specific)
167
+ unknown = specific.keys - METRIC_OPTIONS
168
+ raise ArgumentError, "metric #{key.inspect}: unknown option(s) #{unknown.inspect}" if unknown.any?
169
+
170
+ format = specific[:format]
171
+ unless format.nil? || format.is_a?(Proc) || METRIC_FORMATS.include?(format)
172
+ raise ArgumentError,
173
+ "metric #{key.inspect}: format must be one of #{METRIC_FORMATS.inspect} or a proc, got #{format.inspect}"
174
+ end
175
+
176
+ positive = specific.fetch(:positive, :up)
177
+ unless METRIC_POSITIVE.include?(positive)
178
+ raise ArgumentError, "metric #{key.inspect}: positive must be :up or :down, got #{positive.inspect}"
179
+ end
180
+
181
+ specific.merge(positive:)
182
+ end
183
+
184
+ def validate_chart_options!(specific)
185
+ type = specific.fetch(:type, :line)
186
+ unless CHART_TYPES.include?(type)
187
+ raise ArgumentError, "chart #{key.inspect}: type must be one of #{CHART_TYPES.inspect}, got #{type.inspect}"
188
+ end
189
+
190
+ specific.merge(type:, height: specific.fetch(:height, "240px"))
191
+ end
192
+
193
+ def validate_custom_options!(specific)
194
+ raise ArgumentError, "card #{key.inspect}: unknown option(s) #{specific.keys.inspect}" if specific.any?
195
+
196
+ specific
197
+ end
198
+ end
199
+ end
200
+ end