plutonium 0.64.0 → 0.66.0

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