plutonium 0.63.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 (280) 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/.standard.yml +1 -1
  8. data/CHANGELOG.md +65 -0
  9. data/CLAUDE.md +282 -210
  10. data/SECURITY.md +1 -1
  11. data/app/assets/plutonium-charts.js +20604 -0
  12. data/app/assets/plutonium-charts.js.map +7 -0
  13. data/app/assets/plutonium-charts.min.js +41 -0
  14. data/app/assets/plutonium-charts.min.js.map +7 -0
  15. data/app/assets/plutonium.css +1 -1
  16. data/app/assets/plutonium.js +794 -629
  17. data/app/assets/plutonium.js.map +4 -4
  18. data/app/assets/plutonium.min.js +48 -48
  19. data/app/assets/plutonium.min.js.map +4 -4
  20. data/app/views/plutonium/_flash_alerts.html.erb +2 -2
  21. data/app/views/plutonium/_resource_header.html.erb +2 -2
  22. data/app/views/plutonium/_resource_sidebar.html.erb +11 -2
  23. data/app/views/plutonium/_toast.html.erb +2 -2
  24. data/app/views/rodauth/_login_form.html.erb +1 -1
  25. data/app/views/rodauth/_password_visibility.html.erb +1 -1
  26. data/app/views/rodauth/add_recovery_codes.html.erb +2 -2
  27. data/app/views/rodauth/change_login.html.erb +2 -2
  28. data/app/views/rodauth/create_account.html.erb +4 -4
  29. data/app/views/rodauth/reset_password_request.html.erb +1 -1
  30. data/app/views/rodauth/verify_account_resend.html.erb +1 -1
  31. data/app/views/rodauth/webauthn_remove.html.erb +1 -1
  32. data/config/brakeman.ignore +4 -4
  33. data/config/initializers/rabl.rb +0 -40
  34. data/config/locales/en/api_client.yml +22 -0
  35. data/config/locales/en/async.yml +34 -0
  36. data/config/locales/en/dashboard.yml +25 -0
  37. data/config/locales/en/invites.yml +37 -0
  38. data/config/locales/en/js.yml +40 -0
  39. data/config/locales/en/kanban.yml +28 -0
  40. data/config/locales/en/plutonium.yml +26 -0
  41. data/config/locales/en/positioning.yml +14 -0
  42. data/config/locales/en/profile.yml +29 -0
  43. data/config/locales/en/resource.yml +60 -0
  44. data/config/locales/en/ui.yml +93 -0
  45. data/config/locales/en/wizard.yml +46 -0
  46. data/docs/.vitepress/config.ts +34 -1
  47. data/docs/.vitepress/theme/components/HomeHero.vue +1 -1
  48. data/docs/.vitepress/theme/components/HomeInTheBox.vue +14 -0
  49. data/docs/.vitepress/theme/custom.css +34 -377
  50. data/docs/blog/introducing-plutonium-dashboards.md +175 -0
  51. data/docs/blog/introducing-plutonium-i18n.md +91 -0
  52. data/docs/blog/introducing-plutonium.md +4 -5
  53. data/docs/blog/whats-new-async-kanban-wizards.md +1 -1
  54. data/docs/guides/dashboards.md +300 -0
  55. data/docs/guides/index.md +1 -0
  56. data/docs/guides/kanban.md +2 -2
  57. data/docs/public/images/blog/dashboards-card-error.png +0 -0
  58. data/docs/public/images/blog/dashboards-loading.png +0 -0
  59. data/docs/public/images/blog/dashboards-overview.png +0 -0
  60. data/docs/public/images/blog/dashboards-sidebar.png +0 -0
  61. data/docs/public/images/blog/i18n-es-admin.png +0 -0
  62. data/docs/public/images/guides/dashboard-card-error.png +0 -0
  63. data/docs/public/images/guides/dashboard-loading.png +0 -0
  64. data/docs/public/images/guides/dashboard-overview.png +0 -0
  65. data/docs/public/images/guides/dashboard-sidebar.png +0 -0
  66. data/docs/reference/app/generators.md +2 -2
  67. data/docs/reference/app/portals.md +2 -0
  68. data/docs/reference/configuration.md +84 -27
  69. data/docs/reference/dashboard/dsl.md +123 -0
  70. data/docs/reference/dashboard/index.md +35 -0
  71. data/docs/reference/dashboard/registration.md +76 -0
  72. data/docs/reference/i18n.md +239 -0
  73. data/docs/reference/index.md +10 -1
  74. data/docs/reference/kanban/dsl.md +1 -1
  75. data/docs/reference/kanban/positioning.md +2 -2
  76. data/docs/reference/resource/actions.md +1 -1
  77. data/docs/reference/resource/definition.md +96 -18
  78. data/docs/reference/resource/export.md +5 -1
  79. data/docs/reference/resource/index.md +2 -0
  80. data/docs/reference/{positioning.md → resource/positioning.md} +2 -1
  81. data/docs/reference/resource/query.md +2 -0
  82. data/docs/reference/ui/assets.md +8 -0
  83. data/docs/reference/ui/displays.md +5 -8
  84. data/docs/reference/ui/index.md +1 -1
  85. data/docs/reference/wizard/dsl.md +2 -2
  86. data/esbuild.config.js +5 -2
  87. data/gemfiles/rails_7.gemfile.lock +3 -3
  88. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  89. data/gemfiles/rails_8.1.gemfile.lock +2 -2
  90. data/lib/active_model/validations/array_validator.rb +1 -1
  91. data/lib/active_model/validations/attached_validator.rb +1 -1
  92. data/lib/active_model/validations/url_validator.rb +2 -2
  93. data/lib/generators/pu/core/assets/assets_generator.rb +36 -21
  94. data/lib/generators/pu/core/assets/templates/postcss.config.js +1 -1
  95. data/lib/generators/pu/core/update/update_generator.rb +3 -2
  96. data/lib/generators/pu/dashboard/dashboard_generator.rb +96 -0
  97. data/lib/generators/pu/dashboard/templates/dashboard.rb.tt +25 -0
  98. data/lib/generators/pu/docker/install/install_generator.rb +58 -0
  99. data/lib/generators/pu/docker/install/templates/Dockerfile.dev.tt +18 -3
  100. data/lib/generators/pu/docker/install/templates/Dockerfile.tt +20 -5
  101. data/lib/generators/pu/docker/install/templates/{docker-compose.yml → docker-compose.yml.tt} +1 -1
  102. data/lib/generators/pu/gem/dotenv/dotenv_generator.rb +1 -1
  103. data/lib/generators/pu/invites/invitable_generator.rb +18 -1
  104. data/lib/generators/pu/lib/plutonium_generators/concerns/actions.rb +0 -95
  105. data/lib/generators/pu/lib/plutonium_generators/concerns/js_package_manager.rb +146 -0
  106. data/lib/generators/pu/lib/plutonium_generators/generator.rb +1 -17
  107. data/lib/generators/pu/pkg/package/package_generator.rb +1 -0
  108. data/lib/generators/pu/pkg/package/templates/config/locales/en.yml.tt +28 -0
  109. data/lib/generators/pu/pkg/portal/portal_generator.rb +1 -0
  110. data/lib/generators/pu/pkg/portal/templates/config/locales/en.yml.tt +30 -0
  111. data/lib/generators/pu/res/scaffold/scaffold_generator.rb +0 -8
  112. data/lib/generators/pu/rodauth/concerns/feature_selector.rb +0 -18
  113. data/lib/plutonium/action/base.rb +27 -3
  114. data/lib/plutonium/action/interactive.rb +17 -4
  115. data/lib/plutonium/api_client/concerns/create_api_client.rb +12 -11
  116. data/lib/plutonium/api_client/concerns/disable_api_client.rb +3 -3
  117. data/lib/plutonium/auth/sequel_adapter.rb +1 -1
  118. data/lib/plutonium/configuration.rb +5 -0
  119. data/lib/plutonium/core/controller.rb +25 -1
  120. data/lib/plutonium/core/controllers/authorizable.rb +1 -1
  121. data/lib/plutonium/dashboard/base.rb +114 -0
  122. data/lib/plutonium/dashboard/card.rb +200 -0
  123. data/lib/plutonium/dashboard/controller.rb +101 -0
  124. data/lib/plutonium/dashboard/dsl.rb +104 -0
  125. data/lib/plutonium/dashboard/register.rb +40 -0
  126. data/lib/plutonium/dashboard/route_resolution.rb +30 -0
  127. data/lib/plutonium/dashboard.rb +42 -0
  128. data/lib/plutonium/definition/actions.rb +6 -1
  129. data/lib/plutonium/definition/base.rb +41 -3
  130. data/lib/plutonium/definition/index_views.rb +1 -1
  131. data/lib/plutonium/definition/positioning.rb +1 -1
  132. data/lib/plutonium/definition/presentable.rb +2 -2
  133. data/lib/plutonium/helpers/assets_helper.rb +15 -2
  134. data/lib/plutonium/helpers/display_helper.rb +10 -2
  135. data/lib/plutonium/interaction/async/context.rb +6 -8
  136. data/lib/plutonium/interaction/async/executor.rb +6 -7
  137. data/lib/plutonium/interaction/async/run.rb +2 -2
  138. data/lib/plutonium/interaction/async/run_definition.rb +1 -1
  139. data/lib/plutonium/interaction/base.rb +2 -0
  140. data/lib/plutonium/interaction/concerns/dispatchable.rb +15 -3
  141. data/lib/plutonium/invites/concerns/cancel_invite.rb +3 -3
  142. data/lib/plutonium/invites/concerns/invite_token.rb +25 -7
  143. data/lib/plutonium/invites/concerns/invite_user.rb +10 -5
  144. data/lib/plutonium/invites/concerns/resend_invite.rb +4 -4
  145. data/lib/plutonium/invites/controller.rb +11 -11
  146. data/lib/plutonium/kanban/column.rb +13 -3
  147. data/lib/plutonium/kanban/dsl.rb +7 -4
  148. data/lib/plutonium/models/has_cents.rb +1 -1
  149. data/lib/plutonium/profile/security_section.rb +10 -18
  150. data/lib/plutonium/query/filter.rb +10 -1
  151. data/lib/plutonium/query/filters/association.rb +27 -2
  152. data/lib/plutonium/query/filters/boolean.rb +4 -4
  153. data/lib/plutonium/query/filters/date.rb +4 -17
  154. data/lib/plutonium/query/filters/date_range.rb +2 -2
  155. data/lib/plutonium/query/filters/select.rb +10 -1
  156. data/lib/plutonium/query/filters/text.rb +9 -12
  157. data/lib/plutonium/railtie.rb +17 -0
  158. data/lib/plutonium/resource/controller.rb +10 -5
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +17 -20
  160. data/lib/plutonium/resource/controllers/export_csv.rb +19 -9
  161. data/lib/plutonium/resource/controllers/kanban_actions.rb +16 -16
  162. data/lib/plutonium/resource/controllers/position_actions.rb +5 -5
  163. data/lib/plutonium/resource/controllers/typeahead.rb +24 -1
  164. data/lib/plutonium/resource/query_object.rb +30 -3
  165. data/lib/plutonium/routing/dashboard_registration.rb +137 -0
  166. data/lib/plutonium/routing/resource_registration.rb +5 -0
  167. data/lib/plutonium/routing/route_set_extensions.rb +2 -1
  168. data/lib/plutonium/translation.rb +227 -0
  169. data/lib/plutonium/ui/actions_dropdown.rb +2 -2
  170. data/lib/plutonium/ui/breadcrumbs.rb +3 -3
  171. data/lib/plutonium/ui/color_mode_selector.rb +1 -1
  172. data/lib/plutonium/ui/component/behaviour.rb +46 -0
  173. data/lib/plutonium/ui/component/methods.rb +4 -0
  174. data/lib/plutonium/ui/dashboard/board.rb +80 -0
  175. data/lib/plutonium/ui/dashboard/card.rb +127 -0
  176. data/lib/plutonium/ui/dashboard/chart.rb +43 -0
  177. data/lib/plutonium/ui/dashboard/custom.rb +32 -0
  178. data/lib/plutonium/ui/dashboard/frame.rb +25 -0
  179. data/lib/plutonium/ui/dashboard/metric.rb +137 -0
  180. data/lib/plutonium/ui/dashboard/skeleton.rb +40 -0
  181. data/lib/plutonium/ui/display/components/attachment.rb +1 -1
  182. data/lib/plutonium/ui/display/components/badge.rb +7 -1
  183. data/lib/plutonium/ui/display/components/boolean.rb +2 -2
  184. data/lib/plutonium/ui/display/resource.rb +23 -16
  185. data/lib/plutonium/ui/export_button.rb +3 -3
  186. data/lib/plutonium/ui/form/components/key_value_store.rb +5 -5
  187. data/lib/plutonium/ui/form/components/password.rb +2 -2
  188. data/lib/plutonium/ui/form/components/resource_select.rb +27 -0
  189. data/lib/plutonium/ui/form/components/secure_association.rb +2 -0
  190. data/lib/plutonium/ui/form/components/uppy.rb +11 -3
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +1 -1
  192. data/lib/plutonium/ui/form/concerns/renders_repeater_row_controls.rb +5 -4
  193. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +1 -1
  194. data/lib/plutonium/ui/form/query.rb +8 -8
  195. data/lib/plutonium/ui/form/resource.rb +54 -26
  196. data/lib/plutonium/ui/frame_navigator_panel.rb +4 -4
  197. data/lib/plutonium/ui/grid/card.rb +3 -3
  198. data/lib/plutonium/ui/grid/resource.rb +3 -3
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +6 -6
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +2 -2
  201. data/lib/plutonium/ui/kanban/column.rb +6 -6
  202. data/lib/plutonium/ui/layout/base.rb +19 -1
  203. data/lib/plutonium/ui/layout/header.rb +1 -1
  204. data/lib/plutonium/ui/layout/icon_rail.rb +4 -4
  205. data/lib/plutonium/ui/layout/rodauth_layout.rb +1 -1
  206. data/lib/plutonium/ui/layout/sidebar.rb +1 -1
  207. data/lib/plutonium/ui/layout/topbar.rb +2 -2
  208. data/lib/plutonium/ui/modal/base.rb +4 -4
  209. data/lib/plutonium/ui/nav_grid_menu.rb +1 -1
  210. data/lib/plutonium/ui/nav_user.rb +2 -2
  211. data/lib/plutonium/ui/page/base.rb +10 -2
  212. data/lib/plutonium/ui/page/dashboard.rb +54 -0
  213. data/lib/plutonium/ui/page/edit.rb +3 -3
  214. data/lib/plutonium/ui/page/interactive_action.rb +2 -2
  215. data/lib/plutonium/ui/page/new.rb +3 -3
  216. data/lib/plutonium/ui/page/show.rb +2 -2
  217. data/lib/plutonium/ui/page/wizard.rb +22 -16
  218. data/lib/plutonium/ui/page/wizard_chooser.rb +10 -10
  219. data/lib/plutonium/ui/page/wizard_completed.rb +2 -2
  220. data/lib/plutonium/ui/skeleton_table.rb +1 -1
  221. data/lib/plutonium/ui/table/base.rb +2 -2
  222. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  223. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +19 -3
  224. data/lib/plutonium/ui/table/components/drag_handle.rb +3 -3
  225. data/lib/plutonium/ui/table/components/filter_form.rb +11 -10
  226. data/lib/plutonium/ui/table/components/filter_pills.rb +4 -4
  227. data/lib/plutonium/ui/table/components/pagy_info.rb +61 -17
  228. data/lib/plutonium/ui/table/components/pagy_pagination.rb +9 -5
  229. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  230. data/lib/plutonium/ui/table/components/scopes_bar.rb +6 -2
  231. data/lib/plutonium/ui/table/components/scopes_pills.rb +3 -3
  232. data/lib/plutonium/ui/table/components/selection_column.rb +1 -1
  233. data/lib/plutonium/ui/table/components/toolbar.rb +2 -2
  234. data/lib/plutonium/ui/table/components/view_switcher.rb +12 -8
  235. data/lib/plutonium/ui/table/resource.rb +53 -17
  236. data/lib/plutonium/ui/wizard/review.rb +7 -7
  237. data/lib/plutonium/ui/wizard/stepper.rb +1 -1
  238. data/lib/plutonium/ui/wizard/summary_display.rb +6 -0
  239. data/lib/plutonium/version.rb +1 -1
  240. data/lib/plutonium/wizard/base.rb +2 -0
  241. data/lib/plutonium/wizard/driving.rb +8 -2
  242. data/lib/plutonium/wizard/dsl.rb +4 -2
  243. data/lib/plutonium/wizard/field_importer.rb +1 -8
  244. data/lib/plutonium/wizard/review_step.rb +8 -1
  245. data/lib/plutonium/wizard/step.rb +14 -3
  246. data/package.json +4 -2
  247. data/plutonium.gemspec +15 -30
  248. data/postcss-gem-import.cjs +34 -0
  249. data/postcss-gem-import.js +3 -28
  250. data/src/css/components.css +91 -0
  251. data/src/css/slim_select.css +0 -38
  252. data/src/css/tokens.css +24 -0
  253. data/src/js/controllers/attachment_input_controller.js +15 -6
  254. data/src/js/controllers/chart_controller.js +162 -0
  255. data/src/js/controllers/clipboard_controller.js +3 -2
  256. data/src/js/controllers/dirty_form_guard_controller.js +4 -3
  257. data/src/js/controllers/easymde_controller.js +3 -0
  258. data/src/js/controllers/filter_panel_controller.js +15 -6
  259. data/src/js/controllers/flatpickr_controller.js +8 -0
  260. data/src/js/controllers/frame_refresh_controller.js +59 -0
  261. data/src/js/controllers/intl_tel_input_controller.js +5 -0
  262. data/src/js/controllers/register_controllers.js +4 -0
  263. data/src/js/controllers/slim_select_controller.js +79 -176
  264. data/src/js/core.js +9 -1
  265. data/src/js/i18n.js +69 -0
  266. data/src/js/plutonium-charts.js +15 -0
  267. data/src/js/turbo/turbo_confirm.js +5 -3
  268. metadata +71 -31
  269. data/.yarnrc.yml +0 -8
  270. data/config/initializers/hotwire_turbo_monkey_patches.rb +0 -12
  271. data/lib/generators/pu/field/input/input_generator.rb +0 -32
  272. data/lib/generators/pu/field/input/templates/input.rb.tt +0 -15
  273. data/lib/generators/pu/lib/plutonium_generators/concerns/config.rb +0 -39
  274. data/lib/generators/pu/lib/plutonium_generators/concerns/serializer.rb +0 -39
  275. data/lib/generators/pu/lib/plutonium_generators/installer.rb +0 -205
  276. data/lib/generators/pu/res/scaffold/templates/presenter.rb.tt +0 -13
  277. data/lib/generators/pu/res/scaffold/templates/query_object.rb.tt +0 -17
  278. data/public/plutonium-assets/application.js +0 -31419
  279. data/public/plutonium-assets/plutonium.ico +0 -0
  280. data/yarn.lock +0 -6858
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ # Translation lookups for everything Plutonium renders.
5
+ #
6
+ # All of Plutonium's own strings live under the `plutonium.*` namespace in
7
+ # `config/locales`, loaded at the lowest precedence so a package, portal or
8
+ # the host application can override any key. Beyond the fixed strings, this
9
+ # module resolves the *derived* text a definition never declares (field
10
+ # placeholders and hints, action / scope / filter labels, kanban columns,
11
+ # wizard steps) by convention, with a portal-specific layer on top:
12
+ #
13
+ # plutonium.portals.<portal>.fields.<model>.<attr>.placeholder
14
+ # plutonium.fields.<model>.<attr>.placeholder
15
+ #
16
+ # `<portal>` is the package namespace (`admin_portal`), `<model>` the
17
+ # `model_name.i18n_key` (`blogging/article`), walked up `lookup_ancestors`
18
+ # the way `human_attribute_name` does. Every lookup is a plain hash probe on
19
+ # the I18n backend and returns nil when nothing is defined, so a missing key
20
+ # costs a few microseconds and renders exactly what it did before.
21
+ #
22
+ # Not named `Plutonium::I18n` on purpose: that constant would shadow `::I18n`
23
+ # inside every `module Plutonium` body.
24
+ module Translation
25
+ extend self
26
+
27
+ # Request-local portal, set by the controller so lookups deep in the query
28
+ # layer (filter labels, kanban rejections) see the same portal the view does.
29
+ class Current < ActiveSupport::CurrentAttributes
30
+ attribute :portal, :value_labels
31
+ end
32
+
33
+ # Kinds of derived labels resolved by convention. The value is the key
34
+ # segment under `plutonium.` (and under `plutonium.portals.<portal>.`).
35
+ LABEL_KINDS = {
36
+ action: "actions",
37
+ scope: "scopes",
38
+ filter: "filters",
39
+ kanban_column: "kanban_columns",
40
+ wizard_step: "wizard_steps"
41
+ }.freeze
42
+
43
+ # Field slots resolved by convention when the definition leaves them blank.
44
+ FIELD_SLOTS = %i[placeholder hint description].freeze
45
+
46
+ # Plain translation of a fixed Plutonium string. Full keys only; there is
47
+ # no lazy-lookup prefix, so every call site is greppable.
48
+ def t(key, **options)
49
+ ::I18n.t(key, **options)
50
+ end
51
+ alias_method :translate, :t
52
+
53
+ # Translate a Pagy dictionary key with the current I18n locale. Pagy keeps
54
+ # its own (faster) dictionaries and a thread-local locale; sync the locale
55
+ # per call so a host that switches `I18n.locale` per request is honoured.
56
+ def pagy(key, **options)
57
+ ::Pagy::I18n.locale = ::I18n.locale.to_s
58
+ ::Pagy::I18n.translate(key, **options)
59
+ end
60
+
61
+ # Resolve an option value that may be a lazy translation (a proc built by
62
+ # {Lazy#t}) or any other zero-arity callable.
63
+ def resolve(value)
64
+ value.is_a?(Proc) ? value.call : value
65
+ end
66
+
67
+ # The i18n key segment for a package / portal module, or nil for the main app.
68
+ def portal_key(package)
69
+ return if package.nil?
70
+
71
+ (package.respond_to?(:name) ? package.name : package.to_s).underscore
72
+ end
73
+
74
+ # Text for a field slot (:placeholder, :hint, :description) resolved by
75
+ # convention. Returns nil when no key is defined.
76
+ #
77
+ # field_text(Blogging::Article, :title, :placeholder)
78
+ # # => plutonium.portals.<portal>.fields.blogging/article.title.placeholder
79
+ # # plutonium.fields.blogging/article.title.placeholder
80
+ # # helpers.placeholder.blogging/article.title (placeholder only)
81
+ def field_text(klass, attribute, slot, portal: Current.portal)
82
+ raise ArgumentError, "unknown field slot #{slot.inspect}" unless FIELD_SLOTS.include?(slot)
83
+
84
+ model_keys_for(klass).each do |model_key|
85
+ [portal_scope(portal, "fields"), "plutonium.fields"].compact.each do |scope|
86
+ value = probe("#{scope}.#{model_key}.#{attribute}.#{slot}")
87
+ return value if value
88
+ end
89
+
90
+ next unless slot == :placeholder
91
+
92
+ value = probe("helpers.placeholder.#{model_key}.#{attribute}")
93
+ return value if value
94
+ end
95
+
96
+ nil
97
+ end
98
+
99
+ # Whether the locale defines one/other forms for the model's name
100
+ # (`activerecord.models.<model>: {one:, other:}`), on it or an ancestor.
101
+ def locale_plural?(klass)
102
+ return false unless klass.respond_to?(:i18n_scope)
103
+
104
+ model_keys_for(klass).any? { |key| ::I18n.t("#{klass.i18n_scope}.models.#{key}", default: nil).is_a?(Hash) }
105
+ end
106
+
107
+ # Fill the convention text into `options` for each slot the definition
108
+ # left blank, e.g. `fill_field_text(opts, Blogging::Post, :title, :placeholder, :hint)`.
109
+ def fill_field_text(options, klass, attribute, *slots)
110
+ slots.each do |slot|
111
+ next if options.key?(slot)
112
+
113
+ text = field_text(klass, attribute, slot)
114
+ options = options.merge(slot => text) if text
115
+ end
116
+ options
117
+ end
118
+
119
+ # Label for a derived key (an action, scope, filter, kanban column or
120
+ # wizard step) resolved by convention. Returns nil when no key is defined,
121
+ # so callers keep their `humanize` fallback.
122
+ #
123
+ # label_for(:action, Blogging::Article, :publish)
124
+ # # => plutonium.portals.<portal>.actions.blogging/article.publish
125
+ # # plutonium.actions.blogging/article.publish
126
+ def label_for(kind, klass, key, portal: Current.portal)
127
+ segment = LABEL_KINDS.fetch(kind) { raise ArgumentError, "unknown label kind #{kind.inspect}" }
128
+
129
+ model_keys_for(klass).each do |model_key|
130
+ [portal_scope(portal, segment), "plutonium.#{segment}"].compact.each do |scope|
131
+ value = probe("#{scope}.#{model_key}.#{key}")
132
+ return value if value
133
+ end
134
+ end
135
+
136
+ nil
137
+ end
138
+
139
+ # Text for a dashboard or one of its cards, resolved by convention with the
140
+ # same portal layering as {#label_for}. Returns nil when nothing is defined.
141
+ #
142
+ # dashboard_text(SalesDashboard, :label)
143
+ # # => plutonium.portals.<portal>.dashboards.sales.label
144
+ # # plutonium.dashboards.sales.label
145
+ # dashboard_text(SalesDashboard, :cards, :orders, :label)
146
+ # # => plutonium.portals.<portal>.dashboards.sales.cards.orders.label
147
+ # # plutonium.dashboards.sales.cards.orders.label
148
+ def dashboard_text(dashboard_class, *path, portal: Current.portal)
149
+ return if dashboard_class.nil?
150
+
151
+ suffix = [dashboard_class.i18n_key, *path].join(".")
152
+ [portal_scope(portal, "dashboards"), "plutonium.dashboards"].compact.each do |scope|
153
+ value = probe("#{scope}.#{suffix}")
154
+ return value if value
155
+ end
156
+
157
+ nil
158
+ end
159
+
160
+ # Label for an enum-like value rendered by a badge or filter, resolved the
161
+ # way Rails resolves enum attribute values:
162
+ #
163
+ # activerecord.attributes.<model>.<attribute>/<value> (Rails convention)
164
+ # plutonium.values.<model>.<attribute>.<value>
165
+ #
166
+ # Badges render once per cell, so the answer is memoised for the request.
167
+ def value_label(klass, attribute, value, portal: Current.portal)
168
+ return if value.nil? || klass.nil?
169
+
170
+ cache = (Current.value_labels ||= {})
171
+ cache.fetch([::I18n.locale, portal_key(portal), klass, attribute, value.to_s]) do |cache_key|
172
+ cache[cache_key] = uncached_value_label(klass, attribute, value, portal)
173
+ end
174
+ end
175
+
176
+ def uncached_value_label(klass, attribute, value, portal)
177
+ model_keys_for(klass).each do |model_key|
178
+ [portal_scope(portal, "values"), "plutonium.values"].compact.each do |scope|
179
+ found = probe("#{scope}.#{model_key}.#{attribute}.#{value}")
180
+ return found if found
181
+ end
182
+
183
+ if klass.respond_to?(:human_attribute_name)
184
+ found = probe("#{klass.i18n_scope}.attributes.#{model_key}.#{attribute}/#{value}")
185
+ return found if found
186
+ end
187
+ end
188
+
189
+ nil
190
+ end
191
+
192
+ # Class-level `t` for definitions, interactions and wizards. Returns a lazy
193
+ # proc so the lookup runs on every render in the request's locale rather
194
+ # than once, in whatever locale was active when the class loaded.
195
+ #
196
+ # input :email, placeholder: t("forms.shared.email_placeholder")
197
+ # action :publish, label: t("plutonium.actions.blogging/article.publish")
198
+ module Lazy
199
+ def t(key, **options)
200
+ -> { ::I18n.t(key, **options) }
201
+ end
202
+ end
203
+
204
+ private :uncached_value_label
205
+
206
+ private
207
+
208
+ def probe(key)
209
+ value = ::I18n.t(key, default: nil)
210
+ value.is_a?(String) ? value : nil
211
+ end
212
+
213
+ def portal_scope(portal, segment)
214
+ key = portal_key(portal)
215
+ key && "plutonium.portals.#{key}.#{segment}"
216
+ end
217
+
218
+ # `blogging/article`, then each STI / abstract ancestor that has a model
219
+ # name, mirroring ActiveModel::Translation#human_attribute_name.
220
+ def model_keys_for(klass)
221
+ return [] unless klass.respond_to?(:model_name)
222
+
223
+ ancestors = klass.respond_to?(:lookup_ancestors) ? klass.lookup_ancestors : [klass]
224
+ ancestors.filter_map { |k| k.model_name.i18n_key if k.respond_to?(:model_name) }
225
+ end
226
+ end
227
+ end
@@ -26,8 +26,8 @@ module Plutonium
26
26
  aria: {expanded: "false", haspopup: "true"},
27
27
  data: {resource_drop_down_target: "trigger"}
28
28
  ) do
29
- span(class: "sr-only") { "Open actions menu" }
30
- plain "Actions"
29
+ span(class: "sr-only") { t("plutonium.ui.actions_dropdown.open_menu") }
30
+ plain t("plutonium.ui.actions_dropdown.actions")
31
31
  render Phlex::TablerIcons::ChevronDown.new(class: "w-4 h-4 ml-1")
32
32
  end
33
33
  end
@@ -23,7 +23,7 @@ module Plutonium
23
23
  # instant before the controller's first reflow.
24
24
  nav(
25
25
  class: "flex min-w-0 py-3 mb-2 overflow-hidden",
26
- aria_label: "Breadcrumb",
26
+ aria_label: t("plutonium.ui.breadcrumbs.label"),
27
27
  data: {controller: "breadcrumbs"}
28
28
  ) do
29
29
  ol(
@@ -87,7 +87,7 @@ module Plutonium
87
87
  "m19.707 9.293-2-2-7-7a1 1 0 0 0-1.414 0l-7 7-2 2a1 1 0 0 0 1.414 1.414L2 10.414V18a2 2 0 0 0 2 2h3a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1h3a2 2 0 0 0 2-2v-7.586l.293.293a1 1 0 0 0 1.414-1.414Z"
88
88
  )
89
89
  end
90
- span(class: "sr-only sm:not-sr-only") { plain "Dashboard" }
90
+ span(class: "sr-only sm:not-sr-only") { plain t("plutonium.ui.breadcrumbs.home") }
91
91
  end
92
92
  end
93
93
  end
@@ -193,7 +193,7 @@ module Plutonium
193
193
  def render_overflow_trigger
194
194
  button(
195
195
  type: "button",
196
- aria: {label: "Show collapsed breadcrumbs", haspopup: "true", expanded: "false"},
196
+ aria: {label: t("plutonium.ui.breadcrumbs.show_collapsed"), haspopup: "true", expanded: "false"},
197
197
  data: {resource_drop_down_target: "trigger"},
198
198
  class: "flex items-center justify-center ms-1 w-6 h-6 rounded-[var(--pu-radius-md)] " \
199
199
  "text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors"
@@ -16,7 +16,7 @@ module Plutonium
16
16
  class: BUTTON_CLASSES,
17
17
  data_controller: "color-mode",
18
18
  data_action: "click->color-mode#toggleMode",
19
- title: "Toggle color mode"
19
+ title: t("plutonium.ui.color_mode.toggle")
20
20
  ) do
21
21
  render Phlex::TablerIcons::DeviceDesktop.new(size: ICON_SIZE, stroke: ICON_STROKE, class: "color-mode-icon-auto")
22
22
  render Phlex::TablerIcons::Sun.new(size: ICON_SIZE, stroke: ICON_STROKE, class: "color-mode-icon-light hidden")
@@ -10,6 +10,20 @@ module Plutonium
10
10
  include Kit
11
11
  include Tokens
12
12
 
13
+ # Phlexi field-builder options that configure the field itself, not the
14
+ # rendered tag. Each surface routes its OWN keys to `field()` (displays
15
+ # say :description, forms say :hint) and strips the UNION from the tag
16
+ # attributes, so a key declared on the wrong surface is dropped rather
17
+ # than leaked as an HTML attribute.
18
+ DISPLAY_FIELD_LEVEL_KEYS = %i[label description placeholder].freeze
19
+ FORM_FIELD_LEVEL_KEYS = %i[hint label placeholder].freeze
20
+ FIELD_LEVEL_KEYS = (DISPLAY_FIELD_LEVEL_KEYS | FORM_FIELD_LEVEL_KEYS).freeze
21
+
22
+ # Table-column options that configure the header, not the cell. They may
23
+ # be declared on `field`, `display` or `column` and flow to the column
24
+ # without making the column "render alone" (see Table::Resource).
25
+ COLUMN_FIELD_LEVEL_KEYS = %i[label align].freeze
26
+
13
27
  if Rails.env.development?
14
28
  def around_template(&)
15
29
  comment { "open:#{self.class.name}" }
@@ -20,6 +34,38 @@ module Plutonium
20
34
 
21
35
  protected
22
36
 
37
+ # Translate one of Plutonium's own strings. Full keys only
38
+ # (`t("plutonium.ui.table.filters")`), so every call site is greppable.
39
+ def t(key, **options)
40
+ Plutonium::Translation.t(key, **options)
41
+ end
42
+
43
+ # Resolve proc-valued help / header options (label, hint, description,
44
+ # placeholder, align) for this render, so `label: t("...")` and other
45
+ # lazy values work on displays, tables and forms alike. Other option
46
+ # keys are left alone: a `formatter:` or `condition:` proc is a
47
+ # callback, not a value. Same arity rule as Form::Resource: a zero-arity
48
+ # proc keeps its own binding, a one-arity proc receives this component.
49
+ def resolve_field_level_procs(options)
50
+ resolvable_keys = FIELD_LEVEL_KEYS | COLUMN_FIELD_LEVEL_KEYS
51
+ resolve_option_procs(options) { |key| resolvable_keys.include?(key) }
52
+ end
53
+
54
+ # Resolve every proc-valued option the block admits (all but
55
+ # `condition:` by default), by the one arity rule: a zero-arity proc is
56
+ # called plainly and keeps its own binding, a one-arity proc receives
57
+ # this component for `object` / `params` / helpers.
58
+ def resolve_option_procs(options)
59
+ return options if options.blank?
60
+
61
+ options.to_h do |key, value|
62
+ resolvable = value.is_a?(Proc) && (block_given? ? yield(key) : key != :condition)
63
+ [key, resolvable ? call_option_proc(value) : value]
64
+ end
65
+ end
66
+
67
+ def call_option_proc(value) = value.arity.zero? ? value.call : value.call(self)
68
+
23
69
  def phlexi_render(arg, &)
24
70
  return unless arg
25
71
  raise ArgumentError, "phlexi_render requires a default render block" unless block_given?
@@ -58,6 +58,10 @@ module Plutonium
58
58
  :authorized_resource_scope,
59
59
  :allowed_to?,
60
60
  :registered_resources,
61
+ :registered_dashboards,
62
+ :dashboard_path_for,
63
+ :dashboard_card_path,
64
+ :current_dashboard,
61
65
  :root_path,
62
66
  :make_page_title,
63
67
  :resource_logo_tag,
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Dashboard
6
+ # The card grid. Every lazy card is a `<turbo-frame src loading="lazy">`
7
+ # holding a skeleton of its own shape, so the page paints at once and
8
+ # each card's queries run in a separate request as it scrolls into
9
+ # view. A card declared `lazy: false` renders inline.
10
+ class Board < Plutonium::UI::Component::Base
11
+ include Phlex::Rails::Helpers::TurboFrameTag
12
+
13
+ # The grid is 12 columns on large screens, 2 on tablets and 1 on
14
+ # phones. Written out in full so Tailwind's scanner sees every class it
15
+ # must emit; a span is picked from this table, never interpolated.
16
+ GRID_CLASSES = "pu-dashboard grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-12"
17
+
18
+ SPAN_CLASSES = {
19
+ 1 => "lg:col-span-1",
20
+ 2 => "lg:col-span-2",
21
+ 3 => "lg:col-span-3",
22
+ 4 => "lg:col-span-4",
23
+ 5 => "lg:col-span-5",
24
+ 6 => "md:col-span-2 lg:col-span-6",
25
+ 7 => "md:col-span-2 lg:col-span-7",
26
+ 8 => "md:col-span-2 lg:col-span-8",
27
+ 9 => "md:col-span-2 lg:col-span-9",
28
+ 10 => "md:col-span-2 lg:col-span-10",
29
+ 11 => "md:col-span-2 lg:col-span-11",
30
+ 12 => "md:col-span-2 lg:col-span-12"
31
+ }.freeze
32
+
33
+ def initialize(dashboard:)
34
+ @dashboard = dashboard
35
+ end
36
+
37
+ def view_template
38
+ cards = dashboard.visible_cards
39
+ return EmptyCard(t("plutonium.dashboard.no_cards")) if cards.empty?
40
+
41
+ div(class: GRID_CLASSES, data: {dashboard: dashboard.class.i18n_key}) do
42
+ cards.each { |card| render_slot(card) }
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :dashboard
49
+
50
+ def render_slot(card)
51
+ if card.lazy?
52
+ turbo_frame_tag(
53
+ card.frame_id,
54
+ src: dashboard_card_path(card),
55
+ loading: "lazy",
56
+ refresh: "morph",
57
+ class: tokens("block", span_classes(card)),
58
+ **refresh_attributes(card)
59
+ ) { render Skeleton.new(card:) }
60
+ else
61
+ div(class: span_classes(card)) { render Card.for(dashboard:, card:) }
62
+ end
63
+ end
64
+
65
+ # On a tablet's two columns a card half the desktop row or wider takes
66
+ # the full row; anything narrower takes one column.
67
+ def span_classes(card) = SPAN_CLASSES.fetch(card.span)
68
+
69
+ # `refresh` wires the `frame-refresh` controller, which reloads the
70
+ # frame every N seconds while the tab is visible.
71
+ def refresh_attributes(card)
72
+ seconds = dashboard.refresh_for(card)
73
+ return {} unless seconds
74
+
75
+ {data: {controller: "frame-refresh", frame_refresh_interval_value: seconds}}
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Dashboard
6
+ # The card chrome every kind shares: a `pu-card` with an optional icon,
7
+ # the title (linked when the card has an `href:`), an optional
8
+ # description, and a body the subclass fills in.
9
+ #
10
+ # The body is rendered under a guard: outside local-request mode a
11
+ # card whose block raises logs and reports the error and renders a short
12
+ # notice in place, so one broken query never takes the whole dashboard down.
13
+ class Card < Plutonium::UI::Component::Base
14
+ include Phlex::Rails::Helpers::LinkTo
15
+
16
+ # The component class for a declared card.
17
+ def self.for(dashboard:, card:)
18
+ component = case card.kind
19
+ when :metric then Metric
20
+ when :chart then Chart
21
+ else Custom
22
+ end
23
+ component.new(dashboard:, card:)
24
+ end
25
+
26
+ def initialize(dashboard:, card:)
27
+ @dashboard = dashboard
28
+ @card = card
29
+ end
30
+
31
+ def view_template
32
+ Block(class: tokens("pu-dashboard-card flex flex-col h-full", card_classes)) do
33
+ render_header
34
+ div(class: "pu-dashboard-card-body") { guarded { render_body } }
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :dashboard, :card
41
+
42
+ def render_body
43
+ raise NotImplementedError, "#{self.class}#render_body"
44
+ end
45
+
46
+ # Extra classes a kind adds to the card surface.
47
+ def card_classes = nil
48
+
49
+ def render_header
50
+ div(class: "pu-dashboard-card-header") do
51
+ render_icon if card.icon
52
+ div(class: "min-w-0 flex-1") do
53
+ h3(class: "pu-dashboard-card-title") { render_title }
54
+ if (description = card.description).present?
55
+ p(class: "pu-dashboard-card-description") { description }
56
+ end
57
+ end
58
+ render_link if href
59
+ end
60
+ end
61
+
62
+ def render_icon
63
+ div(class: "pu-dashboard-card-icon") { render card.icon.new(class: "size-5") }
64
+ end
65
+
66
+ def render_title
67
+ if href
68
+ a(href:, class: "hover:underline") { card.label }
69
+ else
70
+ card.label
71
+ end
72
+ end
73
+
74
+ def render_link
75
+ a(href:, class: "pu-dashboard-card-link", aria: {label: t("plutonium.dashboard.open")}) do
76
+ render Phlex::TablerIcons::ArrowUpRight.new(class: "size-4")
77
+ end
78
+ end
79
+
80
+ def href
81
+ return @href if defined?(@href)
82
+
83
+ @href = card.href_for(dashboard)
84
+ end
85
+
86
+ # Render the body into a string first, so a failure leaves no
87
+ # half-written markup behind before the notice replaces it.
88
+ def guarded(&)
89
+ html = capture(&)
90
+ raw(safe(html))
91
+ rescue => e
92
+ raise if raise_card_errors?
93
+
94
+ report_error(e)
95
+ render_error
96
+ end
97
+
98
+ # Logged as well as reported: `Rails.error.report` writes nothing to
99
+ # the log, so with no error subscriber (Sentry, Honeybadger, ...) a
100
+ # swallowed card failure would otherwise leave no trace at all.
101
+ def report_error(error)
102
+ Rails.logger.error do
103
+ "[plutonium.dashboard] #{dashboard.class.name}##{card.key} failed to load: " \
104
+ "#{error.class}: #{error.message}\n#{error.backtrace&.first(10)&.join("\n")}"
105
+ end
106
+ Rails.error.report(
107
+ error,
108
+ handled: true,
109
+ source: "plutonium.dashboard",
110
+ context: {dashboard: dashboard.class.name, card: card.key.to_s}
111
+ )
112
+ end
113
+
114
+ def raise_card_errors?
115
+ Rails.application.config.consider_all_requests_local
116
+ end
117
+
118
+ def render_error
119
+ div(class: "pu-dashboard-card-error", role: "alert") do
120
+ render Phlex::TablerIcons::AlertCircle.new(class: "size-4 shrink-0")
121
+ span { t("plutonium.dashboard.card_error") }
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Dashboard
6
+ # A Chartkick chart. The card's block returns Chartkick data (a
7
+ # `{label => value}` hash, an array of pairs, or an array of
8
+ # `{name:, data:}` series); this component serialises it onto a
9
+ # `data-controller="chart"` element and the Stimulus controller draws it
10
+ # once the on-demand charts bundle has loaded.
11
+ class Chart < Card
12
+ def initialize(dashboard:, card:, script_url: nil)
13
+ super(dashboard:, card:)
14
+ @script_url = script_url
15
+ end
16
+
17
+ private
18
+
19
+ def render_body
20
+ data = card.evaluate(dashboard)
21
+
22
+ div(
23
+ class: "pu-chart",
24
+ style: "height: #{card.options[:height]};",
25
+ data: {
26
+ controller: "chart",
27
+ chart_type_value: card.chart_class,
28
+ chart_data_value: data.to_json,
29
+ chart_options_value: card.chart_options.to_json,
30
+ chart_script_value: script_url
31
+ }
32
+ ) do
33
+ span(class: "pu-chart-loading") { t("plutonium.ui.loading") }
34
+ end
35
+ end
36
+
37
+ def script_url
38
+ @script_url || view_context.resource_charts_script_url
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Dashboard
6
+ # A free-form card. The block is `instance_exec`ed here, in Phlex, so it
7
+ # emits markup directly (`ul { ... }`, `render SomeComponent.new`); any
8
+ # method it calls that this component lacks is forwarded to the
9
+ # dashboard instance, so a dashboard's own helpers and `current_user`
10
+ # are in scope.
11
+ class Custom < Card
12
+ private
13
+
14
+ def render_body
15
+ instance_exec(&card.block)
16
+ end
17
+
18
+ def method_missing(name, ...)
19
+ if dashboard.respond_to?(name, true)
20
+ dashboard.send(name, ...)
21
+ else
22
+ super
23
+ end
24
+ end
25
+
26
+ def respond_to_missing?(name, include_private = false)
27
+ dashboard.respond_to?(name, true) || super
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Dashboard
6
+ # What the card endpoint renders: the card inside the turbo frame the
7
+ # board is waiting on. Wrapped unconditionally, so a direct visit to the
8
+ # card URL shows the same card inside the full layout.
9
+ class Frame < Plutonium::UI::Component::Base
10
+ include Phlex::Rails::Helpers::TurboFrameTag
11
+
12
+ def initialize(dashboard:, card:)
13
+ @dashboard = dashboard
14
+ @card = card
15
+ end
16
+
17
+ def view_template
18
+ turbo_frame_tag(@card.frame_id, class: "block") do
19
+ render Card.for(dashboard: @dashboard, card: @card)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end