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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Dashboard
6
+ # A single headline number with an optional change indicator.
7
+ #
8
+ # The card's block returns either the value itself or a hash:
9
+ #
10
+ # {value: 1_240} # just the number
11
+ # {value: 1_240, previous: 1_100} # change computed: +12.7%
12
+ # {value: 1_240, change: 12.7} # change in percentage points
13
+ # {value: 1_240, change: "+140", trend: :up} # change shown verbatim
14
+ # {value: 98.2, change_label: "vs. last week"} # caption after the change
15
+ #
16
+ # `format:` picks the number formatter (`:number`, `:currency`,
17
+ # `:percentage`, `:human`, or a proc run on the dashboard); `positive:`
18
+ # says which direction is good (`:up` by default, `:down` for churn or
19
+ # error rates) and drives the change colour.
20
+ class Metric < Card
21
+ include ActiveSupport::NumberHelper
22
+
23
+ private
24
+
25
+ def render_body
26
+ data = normalize(card.evaluate(dashboard))
27
+
28
+ div(class: "pu-metric") do
29
+ div(class: "pu-metric-value") { format_value(data[:value]) }
30
+ render_change(data) if data[:change] || data[:change_label]
31
+ end
32
+ end
33
+
34
+ def normalize(result)
35
+ data = result.is_a?(Hash) ? result.symbolize_keys : {value: result}
36
+ data[:change_label] ||= card.options[:change_label]
37
+
38
+ if !data.key?(:change) && data[:previous].is_a?(Numeric) && data[:value].is_a?(Numeric)
39
+ data[:change] = percent_change(data[:value], data[:previous])
40
+ end
41
+
42
+ data[:trend] ||= infer_trend(data[:change]) if data.key?(:change)
43
+ data
44
+ end
45
+
46
+ def percent_change(value, previous)
47
+ return nil if previous.zero?
48
+
49
+ ((value - previous) / previous.to_f.abs) * 100
50
+ end
51
+
52
+ def infer_trend(change)
53
+ case change
54
+ when Numeric
55
+ if change.positive? then :up
56
+ elsif change.negative? then :down
57
+ else :flat
58
+ end
59
+ when String
60
+ if change.start_with?("-") then :down
61
+ elsif change.start_with?("+") then :up
62
+ else :flat
63
+ end
64
+ end
65
+ end
66
+
67
+ def render_change(data)
68
+ trend = data[:trend] || :flat
69
+ div(class: tokens("pu-metric-change", change_tone_class(trend))) do
70
+ render_trend_icon(trend)
71
+ span(class: "font-medium") { format_change(data[:change]) } if data[:change]
72
+ span(class: "pu-metric-change-label") { data[:change_label] } if data[:change_label].present?
73
+ end
74
+ end
75
+
76
+ def render_trend_icon(trend)
77
+ icon = case trend
78
+ when :up then Phlex::TablerIcons::TrendingUp
79
+ when :down then Phlex::TablerIcons::TrendingDown
80
+ else Phlex::TablerIcons::Minus
81
+ end
82
+ render icon.new(class: "size-4 shrink-0")
83
+ end
84
+
85
+ def change_tone_class(trend)
86
+ return "pu-metric-change-neutral" if trend == :flat || trend.nil?
87
+
88
+ good = (trend == :up) == (card.options[:positive] == :up)
89
+ good ? "pu-metric-change-positive" : "pu-metric-change-negative"
90
+ end
91
+
92
+ def format_change(change)
93
+ case change
94
+ when Numeric
95
+ sign = change.positive? ? "+" : ""
96
+ "#{sign}#{number_to_rounded(change, precision: 1, strip_insignificant_zeros: true)}%"
97
+ else change.to_s
98
+ end
99
+ end
100
+
101
+ def format_value(value)
102
+ return t("plutonium.dashboard.metric.empty") if value.nil?
103
+
104
+ formatted = case (format = card.options[:format])
105
+ when Proc then dashboard.instance_exec(value, &format)
106
+ when :currency then number_to_currency(value, **currency_options)
107
+ when :percentage then number_to_percentage(value, precision: precision(1))
108
+ when :human then number_to_human(value, precision: precision(3))
109
+ else format_number(value)
110
+ end
111
+
112
+ [card.options[:prefix], formatted, card.options[:suffix]].compact.join
113
+ end
114
+
115
+ def format_number(value)
116
+ return value.to_s unless value.is_a?(Numeric)
117
+
118
+ if card.options[:precision] || value.is_a?(Float)
119
+ number_to_rounded(value, precision: precision(value.is_a?(Float) ? 2 : 0), delimiter: ",", strip_insignificant_zeros: true)
120
+ else
121
+ number_to_delimited(value)
122
+ end
123
+ end
124
+
125
+ def currency_options
126
+ options = {precision: precision(2)}
127
+ options[:unit] = card.options[:unit] if card.options[:unit]
128
+ options
129
+ end
130
+
131
+ def precision(default)
132
+ card.options.fetch(:precision, default)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Dashboard
6
+ # The placeholder a lazy card's frame shows until its response lands.
7
+ # Shaped like the card it stands in for, so the grid does not jump when
8
+ # the real card arrives.
9
+ class Skeleton < Plutonium::UI::Component::Base
10
+ def initialize(card:)
11
+ @card = card
12
+ end
13
+
14
+ def view_template
15
+ div(role: "status", class: "pu-card pu-dashboard-card pu-dashboard-skeleton h-full motion-safe:animate-pulse") do
16
+ div(class: "h-3 w-24 rounded-full bg-[var(--pu-border-strong)]")
17
+ render_body
18
+ span(class: "sr-only") { t("plutonium.dashboard.loading_card", card: @card.label) }
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def render_body
25
+ case @card.kind
26
+ when :metric
27
+ div(class: "mt-4 h-8 w-32 rounded-[var(--pu-radius-md)] bg-[var(--pu-border)]")
28
+ div(class: "mt-3 h-2 w-20 rounded-full bg-[var(--pu-border)]")
29
+ when :chart
30
+ div(class: "mt-4 rounded-[var(--pu-radius-md)] bg-[var(--pu-border)]", style: "height: #{@card.options[:height]};")
31
+ else
32
+ div(class: "mt-4 h-2 w-full rounded-full bg-[var(--pu-border)]")
33
+ div(class: "mt-3 h-2 w-5/6 rounded-full bg-[var(--pu-border)]")
34
+ div(class: "mt-3 h-2 w-2/3 rounded-full bg-[var(--pu-border)]")
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -81,7 +81,7 @@ module Plutonium
81
81
  end
82
82
 
83
83
  def attachment_extension(attachment)
84
- attachment.try(:extension) || File.extname(attachment.filename.to_s)
84
+ attachment.try(:extension).presence || File.extname(attachment.filename.to_s).delete(".")
85
85
  end
86
86
 
87
87
  def normalize_value(value)
@@ -62,10 +62,16 @@ module Plutonium
62
62
  def render_value(value)
63
63
  variant = self.class.variant_for(value, colors: @colors)
64
64
  span(**attributes, class: tokens("pu-badge", "pu-badge-#{variant}")) do
65
- plain self.class.humanize(value)
65
+ plain value_label(value)
66
66
  end
67
67
  end
68
68
 
69
+ # plutonium.values.<model>.<attr>.<value> or the Rails enum key
70
+ # activerecord.attributes.<model>.<attr>/<value>, else humanized.
71
+ def value_label(value)
72
+ Plutonium::Translation.value_label(field.object.class, field.key, value) || self.class.humanize(value)
73
+ end
74
+
69
75
  protected
70
76
 
71
77
  def build_attributes
@@ -25,9 +25,9 @@ module Plutonium
25
25
  end
26
26
  end
27
27
 
28
- def true_label = @true_label || "Yes"
28
+ def true_label = @true_label || Plutonium::Translation.t("plutonium.boolean.true")
29
29
 
30
- def false_label = @false_label || "No"
30
+ def false_label = @false_label || Plutonium::Translation.t("plutonium.boolean.false")
31
31
 
32
32
  def build_attributes
33
33
  @true_label = attributes.delete(:true_label)
@@ -123,6 +123,9 @@ module Plutonium
123
123
  condition = section.condition
124
124
  next if condition && !instance_exec(&condition)
125
125
  next if resolved.fields.empty?
126
+ # A section whose fields are all hidden by their own `condition:`
127
+ # would render as an empty card; hidden display fields emit nothing.
128
+ next if resolved.fields.all? { |name| field_condition_hidden?(name) }
126
129
 
127
130
  Plutonium::Definition::FormLayout::ResolvedSection.new(
128
131
  section: Plutonium::Definition::FormLayout::Section.new(
@@ -147,16 +150,7 @@ module Plutonium
147
150
  # `condition:` is excluded for the same reason it is on the form: it
148
151
  # asks "should this render here, now?", which is resolved separately
149
152
  # and against this display, not turned into a value up front.
150
- def resolve_section_option_procs(options)
151
- return options if options.blank?
152
-
153
- options.to_h do |key, value|
154
- resolvable = key != :condition && value.is_a?(Proc)
155
- next [key, value] unless resolvable
156
-
157
- [key, value.arity.zero? ? value.call : value.call(self)]
158
- end
159
- end
153
+ def resolve_section_option_procs(options) = resolve_option_procs(options)
160
154
 
161
155
  # Pure presentation — the section is already resolved (visible) by
162
156
  # resolve_display_layout.
@@ -206,7 +200,7 @@ module Plutonium
206
200
 
207
201
  tablist.with_tab(
208
202
  identifier: "details",
209
- title: -> { plain "Details" }
203
+ title: -> { plain t("plutonium.ui.details") }
210
204
  ) do
211
205
  render details_display
212
206
  end
@@ -279,6 +273,14 @@ module Plutonium
279
273
  raise ArgumentError, "#{object.class}##{name} defined in #permitted_associations, but #{reflection.klass} is not a registered resource"
280
274
  end
281
275
 
276
+ # Whether +name+ is hidden on this render by its own `condition:` — the
277
+ # same check render_resource_field makes before rendering it.
278
+ def field_condition_hidden?(name)
279
+ condition = resource_definition.defined_displays.dig(name, :options, :condition) ||
280
+ resource_definition.defined_fields.dig(name, :options, :condition)
281
+ condition.present? && !instance_exec(&condition)
282
+ end
283
+
282
284
  def render_resource_field(name)
283
285
  when_permitted(name) do
284
286
  # field :name, as: :string
@@ -294,6 +296,9 @@ module Plutonium
294
296
  display_definition = resource_definition.defined_displays[name] || {}
295
297
  display_options = display_definition[:options] || {}
296
298
 
299
+ field_options = resolve_field_level_procs(field_options)
300
+ display_options = resolve_field_level_procs(display_options)
301
+
297
302
  # Check for conditional rendering
298
303
  condition = display_options[:condition] || field_options[:condition]
299
304
  conditionally_hidden = condition && !instance_exec(&condition)
@@ -301,13 +306,15 @@ module Plutonium
301
306
 
302
307
  tag = display_options[:as] || field_options[:as]
303
308
 
304
- # Extract field-level options from display_options and merge into field_options
305
- # These are Phlexi field options that should be passed to field(), not to the tag builder
306
- field_level_keys = [:label, :description, :placeholder]
307
- field_level_options = display_options.slice(*field_level_keys)
309
+ # Route the display's own field-level options to field(); strip the
310
+ # full union from the tag attributes so a form-only key (e.g. :hint)
311
+ # declared here is dropped rather than leaked as an HTML attribute.
312
+ field_level_options = display_options.slice(*DISPLAY_FIELD_LEVEL_KEYS)
308
313
  field_options = field_options.merge(field_level_options)
314
+ # A blank description falls back to plutonium.fields.<model>.<attr>.description.
315
+ field_options = Plutonium::Translation.fill_field_text(field_options, object.class, name, :description)
309
316
 
310
- tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
317
+ tag_attributes = display_options.except(:wrapper, :as, :condition, *FIELD_LEVEL_KEYS)
311
318
 
312
319
  # A `formatter:` produces the display string itself, so render it
313
320
  # through the formatted-value component regardless of the field's
@@ -45,7 +45,7 @@ module Plutonium
45
45
  style: PRIMARY_STYLE
46
46
  ) do
47
47
  render Phlex::TablerIcons::Download.new(class: "w-4 h-4 shrink-0")
48
- span { "Export" }
48
+ span { t("plutonium.ui.export.export") }
49
49
  end
50
50
  end
51
51
 
@@ -54,7 +54,7 @@ module Plutonium
54
54
  type: "button",
55
55
  class: "pu-btn pu-btn-outline pu-btn-sm",
56
56
  style: CARET_STYLE,
57
- aria: {expanded: "false", haspopup: "menu", label: "More export options"},
57
+ aria: {expanded: "false", haspopup: "menu", label: t("plutonium.ui.export.more_options")},
58
58
  data: {resource_drop_down_target: "trigger"}
59
59
  ) do
60
60
  render Phlex::TablerIcons::ChevronDown.new(class: "w-4 h-4")
@@ -76,7 +76,7 @@ module Plutonium
76
76
  class: "flex items-center gap-2 px-4 py-2 text-sm text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] transition-colors"
77
77
  ) do
78
78
  render Phlex::TablerIcons::Download.new(class: "w-4 h-4")
79
- span { "Export all" }
79
+ span { t("plutonium.ui.export.export_all") }
80
80
  end
81
81
  end
82
82
  end
@@ -81,7 +81,7 @@ module Plutonium
81
81
  # Key input
82
82
  input(
83
83
  type: :text,
84
- placeholder: "Key",
84
+ placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.key_placeholder"),
85
85
  value: key,
86
86
  name: "#{field_name}[#{index}][key]",
87
87
  id: "#{field.dom.id}_#{index}_key",
@@ -92,7 +92,7 @@ module Plutonium
92
92
  # Value input
93
93
  input(
94
94
  type: :text,
95
- placeholder: "Value",
95
+ placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.value_placeholder"),
96
96
  value: value,
97
97
  name: "#{field_name}[#{index}][value]",
98
98
  id: "#{field.dom.id}_#{index}_value",
@@ -122,7 +122,7 @@ module Plutonium
122
122
  key_value_store_target: "addButton"
123
123
  }
124
124
  ) do
125
- plain "+ Add Pair"
125
+ plain Plutonium::Translation.t("plutonium.ui.form.key_value_store.add_pair")
126
126
  end
127
127
  end
128
128
  end
@@ -135,7 +135,7 @@ module Plutonium
135
135
  ) do
136
136
  input(
137
137
  type: :text,
138
- placeholder: "Key",
138
+ placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.key_placeholder"),
139
139
  name: "#{field_name}[__INDEX__][key]",
140
140
  id: "#{field.dom.id}___INDEX___key",
141
141
  class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
@@ -144,7 +144,7 @@ module Plutonium
144
144
 
145
145
  input(
146
146
  type: :text,
147
- placeholder: "Value",
147
+ placeholder: Plutonium::Translation.t("plutonium.ui.form.key_value_store.value_placeholder"),
148
148
  name: "#{field_name}[__INDEX__][value]",
149
149
  id: "#{field.dom.id}___INDEX___value",
150
150
  class: "flex-1 px-3 py-1 text-sm border border-[var(--pu-border)] rounded-[var(--pu-radius-sm)] focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-[var(--pu-surface)] text-[var(--pu-text)]",
@@ -74,9 +74,9 @@ module Plutonium
74
74
  def apply_default_hint(value)
75
75
  return if field.has_hint?
76
76
  if value == SENTINEL
77
- field.hint("Leave blank to keep the current value.")
77
+ field.hint(Plutonium::Translation.t("plutonium.ui.form.password.keep_current_hint"))
78
78
  elsif reentry_required?
79
- field.hint("Re-enter the new value to save it.")
79
+ field.hint(Plutonium::Translation.t("plutonium.ui.form.password.reenter_hint"))
80
80
  end
81
81
  end
82
82
 
@@ -34,13 +34,37 @@ module Plutonium
34
34
  # capped at `limit`. Shared by `choices` (with the limit) and
35
35
  # `normalize_simple_input` (without — so typeahead picks beyond the
36
36
  # rendered subset still validate).
37
+ #
38
+ # The custom `scope:` (set on the Association filter) narrows the
39
+ # relation BEFORE authorization, so policy can only further reduce —
40
+ # never widen — what the scope allows. Mirrors the kanban/positioning
41
+ # scope pattern.
37
42
  def authorized_relation(limit: nil)
38
43
  relation = @association_class.all
44
+ relation = apply_scope(relation, @scope)
39
45
  relation = relation.limit(limit) if limit && relation.respond_to?(:limit)
40
46
  return relation if @skip_authorization
41
47
  authorized_resource_scope(@association_class, relation: relation)
42
48
  end
43
49
 
50
+ # Applies an optional association scope to a relation.
51
+ # Symbol → relation.public_send(sym) (named scope, e.g. :verified)
52
+ # Proc → arity 0 → relation.instance_exec(&scope) (kanban form: -> { where(...) })
53
+ # → arity ≥1 → scope.call(relation) (documented form: ->(s) { s.verified })
54
+ # nil → relation unchanged
55
+ # The arity dispatch matches call_option_proc (Form::Resource) so the
56
+ # documented `->(s) { s.active }` signature works AND the zero-arg
57
+ # kanban form works. Unsupported types fail loud rather than silently
58
+ # doing nothing (the original bug).
59
+ def apply_scope(relation, scope)
60
+ case scope
61
+ when Symbol then relation.public_send(scope)
62
+ when Proc then scope.arity.zero? ? relation.instance_exec(&scope) : scope.call(relation)
63
+ when nil then relation
64
+ else raise ArgumentError, "Unsupported association scope: #{scope.inspect} (expected Symbol, Proc, or nil)"
65
+ end
66
+ end
67
+
44
68
  def build_attributes
45
69
  # Defaults must land BEFORE super — AcceptsChoices.build_attributes
46
70
  # consumes :value_method / :label_method off `attributes` into
@@ -52,6 +76,7 @@ module Plutonium
52
76
 
53
77
  @association_class = attributes.delete(:association_class)
54
78
  @skip_authorization = attributes.delete(:skip_authorization)
79
+ @scope = attributes.delete(:scope)
55
80
  @choice_limit = attributes.fetch(:choice_limit) { DEFAULT_CHOICE_LIMIT }
56
81
  attributes.delete(:choice_limit)
57
82
  # Stash the typeahead option; the URL helper needs view_context
@@ -103,6 +128,8 @@ module Plutonium
103
128
  end
104
129
 
105
130
  choices.values.find { |opt| same_record?(input_value, opt) } && input_value
131
+ rescue
132
+ nil
106
133
  end
107
134
 
108
135
  # Two values point at the same record when both decode to the
@@ -173,6 +173,8 @@ module Plutonium
173
173
 
174
174
  return nil unless sgid.model_class <= association_reflection.klass
175
175
  authorized_relation.exists?(id: sgid.model_id) ? sgid : nil
176
+ rescue
177
+ nil
176
178
  end
177
179
 
178
180
  def selected?(option)
@@ -156,7 +156,7 @@ module Plutonium
156
156
  data: {action: "click->attachment-preview#remove"}
157
157
  ) do
158
158
  span(class: "bi bi-trash")
159
- plain "Delete"
159
+ plain Plutonium::Translation.t("plutonium.ui.form.attachment.delete")
160
160
  end
161
161
  end
162
162
 
@@ -165,7 +165,7 @@ module Plutonium
165
165
  end
166
166
 
167
167
  def attachment_extension(attachment)
168
- attachment.try(:extension) || File.extname(attachment.filename.to_s)
168
+ attachment.try(:extension).presence || File.extname(attachment.filename.to_s).delete(".")
169
169
  end
170
170
 
171
171
  def build_direct_upload_options
@@ -184,13 +184,21 @@ module Plutonium
184
184
  max_file_size: nil,
185
185
  min_file_size: nil,
186
186
  max_total_size: nil,
187
- max_file_num: attributes.fetch(:size, field.multiple? ? field.limit : 1),
187
+ max_file_num: attributes.fetch(:size) { field.multiple? ? field.limit : 1 },
188
188
  min_file_num: nil,
189
189
  allowed_file_types: nil,
190
190
  required_meta_fields: nil,
191
191
  endpoint: attributes[:endpoint] || "/upload"
192
192
  }.each do |key, default_value|
193
193
  value = attributes.key?(key) ? attributes.delete(key) : default_value
194
+ # The attachment-input controller declares Array/Object value types,
195
+ # which Stimulus reads with JSON.parse. Phlex would otherwise render an
196
+ # array attribute space-joined (its class-list behaviour), so
197
+ # allowed_file_types: [".csv", "text/csv"] reached the controller as
198
+ # ".csv text/csv" and JSON.parse threw on connect — after the native
199
+ # file input was already hidden, leaving no usable file picker. Emit
200
+ # structured values as JSON, matching intl_tel_input's options.
201
+ value = value.to_json if value.is_a?(Array) || value.is_a?(Hash)
194
202
  direct_upload_options[:data][:"attachment_input_#{key}_value"] = value
195
203
  end
196
204
 
@@ -263,7 +263,7 @@ module Plutonium
263
263
  def render_nested_fields_add_button_content(name)
264
264
  span(class: "bg-secondary-700 text-white hover:bg-secondary-800 focus:ring-secondary-300 dark:bg-secondary-600 dark:hover:bg-secondary-700 dark:focus:ring-secondary-800 flex items-center justify-center px-4 py-1.5 text-sm font-medium rounded-lg focus:outline-none focus:ring-4") do
265
265
  render Phlex::TablerIcons::Plus.new(class: "w-4 h-4 mr-1")
266
- span { "Add #{name.to_s.singularize.humanize}" }
266
+ span { t("plutonium.ui.form.repeater.add", name: name.to_s.singularize.humanize) }
267
267
  end
268
268
  end
269
269
 
@@ -29,7 +29,7 @@ module Plutonium
29
29
  div(class: "flex items-center justify-end") do
30
30
  button(type: :button, class: REMOVE_BUTTON_CLASS, data_action: action) do
31
31
  render Phlex::TablerIcons::Trash.new(class: "w-4 h-4")
32
- span { "Remove" }
32
+ span { t("plutonium.ui.form.repeater.remove") }
33
33
  end
34
34
  end
35
35
  end
@@ -39,10 +39,11 @@ module Plutonium
39
39
  # a left danger stripe + struck-through label read as "pending delete".
40
40
  #
41
41
  # @param restore_action [String] Stimulus action for the Restore button
42
- # @param label [String] text shown beside the trash icon
42
+ # @param label [String, nil] text shown beside the trash icon; defaults to "Removed"
43
43
  # @param bar_data [Hash] extra data attributes (Stimulus target/marker)
44
44
  # the controller uses to find and toggle this bar
45
- def render_repeater_removed_bar(restore_action:, label: "Removed", **bar_data)
45
+ def render_repeater_removed_bar(restore_action:, label: nil, **bar_data)
46
+ label ||= t("plutonium.ui.form.repeater.removed")
46
47
  div(
47
48
  hidden: true,
48
49
  class: "-m-4 flex items-center justify-between gap-3 px-4 py-2.5 " \
@@ -56,7 +57,7 @@ module Plutonium
56
57
  end
57
58
  button(type: :button, class: RESTORE_BUTTON_CLASS, data_action: restore_action) do
58
59
  render Phlex::TablerIcons::ArrowBackUp.new(class: "w-4 h-4")
59
- span { "Restore" }
60
+ span { t("plutonium.ui.form.repeater.restore") }
60
61
  end
61
62
  end
62
63
  end
@@ -137,7 +137,7 @@ module Plutonium
137
137
  ) do
138
138
  span(class: "bg-secondary-700 text-white flex items-center justify-center px-4 py-1.5 text-sm font-medium rounded-lg") do
139
139
  render Phlex::TablerIcons::Plus.new(class: "w-4 h-4 mr-1")
140
- span { "Add #{name.to_s.singularize.humanize}" }
140
+ span { t("plutonium.ui.form.repeater.add", name: name.to_s.singularize.humanize) }
141
141
  end
142
142
  end
143
143
  end
@@ -82,7 +82,7 @@ module Plutonium
82
82
  render Phlex::TablerIcons::Search.new(class: "w-5 h-5 text-[var(--pu-text-muted)]")
83
83
  end
84
84
  render field(:search, value: search_query)
85
- .placeholder("Search...")
85
+ .placeholder(t("plutonium.ui.table.search_placeholder"))
86
86
  .input_tag(
87
87
  value: search_query,
88
88
  class: "pu-input pu-input-icon-left w-full",
@@ -106,7 +106,7 @@ module Plutonium
106
106
  data: {resource_drop_down_target: "trigger"}
107
107
  ) do
108
108
  render Phlex::TablerIcons::Filter.new(class: "w-4 h-4 inline-block align-text-bottom")
109
- plain " Filters"
109
+ plain " #{t("plutonium.ui.table.filters")}"
110
110
  if active_count > 0
111
111
  plain " "
112
112
  span(class: "inline-flex items-center justify-center w-5 h-5 text-xs font-semibold rounded-full text-gray-800 bg-white") do
@@ -142,13 +142,13 @@ module Plutonium
142
142
  ) do
143
143
  render Phlex::TablerIcons::X.new(class: "w-5 h-5")
144
144
  end
145
- span(class: "text-sm font-semibold text-[var(--pu-text)]") { "Filters" }
145
+ span(class: "text-sm font-semibold text-[var(--pu-text)]") { t("plutonium.ui.table.filters") }
146
146
  end
147
147
  button(
148
148
  type: "button",
149
149
  class: "text-sm text-[var(--pu-text-muted)] hover:text-[var(--pu-text)] transition-colors",
150
150
  data: {action: "filter-panel#clear"}
151
- ) { "Clear all" }
151
+ ) { t("plutonium.ui.table.clear_all") }
152
152
  end
153
153
 
154
154
  # Scrollable filter fields
@@ -160,9 +160,9 @@ module Plutonium
160
160
  inputs.each do |input_name, _|
161
161
  # For multi-input filters (like date range), include the input name in the label
162
162
  label = if has_multiple_inputs
163
- "#{filter_name.to_s.humanize} (#{input_name.to_s.humanize.downcase})"
163
+ t("plutonium.query.filter_input_label", label: query_object.filter_label(filter_name), input: input_name.to_s.humanize.downcase)
164
164
  else
165
- filter_name.to_s.humanize
165
+ query_object.filter_label(filter_name)
166
166
  end
167
167
  render_filter_field nested, definition, input_name, filter_label: label
168
168
  end
@@ -175,7 +175,7 @@ module Plutonium
175
175
  render field(:submit).submit_button_tag(
176
176
  name: nil,
177
177
  class!: "pu-btn pu-btn-md pu-btn-primary w-full"
178
- ) { "Apply Filters" }
178
+ ) { t("plutonium.ui.table.apply_filters") }
179
179
  end
180
180
  end
181
181
 
@@ -196,7 +196,7 @@ module Plutonium
196
196
  label(class: "text-sm font-medium text-[var(--pu-text)]") { filter_label }
197
197
  nested.field(name, **field_options) do |f|
198
198
  # Set placeholder for blank option text in selects
199
- f.placeholder(input_options[:include_blank] || "All") if input_options[:include_blank]
199
+ f.placeholder(input_options[:include_blank] || t("plutonium.ui.table.all")) if input_options[:include_blank]
200
200
  render instance_exec(f, &tag_block)
201
201
  end
202
202
  end