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,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lib/plutonium_generators"
4
+
5
+ module Pu
6
+ # Scaffolds a dashboard class and mounts it in a portal (or the main app).
7
+ #
8
+ # rails g pu:dashboard Sales --dest=admin_portal
9
+ # rails g pu:dashboard Home --dest=admin_portal --at=/ # the portal root
10
+ #
11
+ # Writes `app/dashboards/<portal>/<name>_dashboard.rb` inside the package
12
+ # and adds `register_dashboard` to its routes. A root mount replaces the
13
+ # generated `root to: "dashboard#index"` line.
14
+ class DashboardGenerator < Rails::Generators::NamedBase
15
+ include PlutoniumGenerators::Generator
16
+
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ desc(
20
+ "Create a dashboard and mount it in a portal\n\n" \
21
+ "e.g. rails g pu:dashboard Sales --dest=admin_portal\n" \
22
+ " rails g pu:dashboard Home --dest=admin_portal --at=/"
23
+ )
24
+
25
+ class_option :at, type: :string, default: nil,
26
+ desc: "Mount path under the portal's dashboards/ segment (default: the dashboard name; \"/\" mounts at the root)"
27
+
28
+ def start
29
+ @app_namespace = portal_option(:dest, prompt: "Select destination portal").camelize
30
+
31
+ template "dashboard.rb", dashboard_file_path
32
+ register_dashboard_in_routes
33
+ rescue => e
34
+ exception "#{self.class} failed:", e
35
+ end
36
+
37
+ private
38
+
39
+ attr_reader :app_namespace
40
+
41
+ def main_app? = app_namespace == "MainApp"
42
+
43
+ def package_namespace = app_namespace.underscore
44
+
45
+ def dashboard_name = "#{class_name.delete_suffix("Dashboard")}Dashboard"
46
+
47
+ def dashboard_class_name
48
+ main_app? ? dashboard_name : "#{app_namespace}::#{dashboard_name}"
49
+ end
50
+
51
+ def dashboard_label = dashboard_name.delete_suffix("Dashboard").titleize
52
+
53
+ def mount_path
54
+ path = options[:at].presence || dashboard_name.delete_suffix("Dashboard").underscore
55
+ path.to_s.sub(%r{\A/+}, "").sub(%r{/+\z}, "")
56
+ end
57
+
58
+ def root_mount? = mount_path.empty?
59
+
60
+ def dashboard_file_path
61
+ file = "#{dashboard_name.underscore}.rb"
62
+ main_app? ? "app/dashboards/#{file}" : "packages/#{package_namespace}/app/dashboards/#{package_namespace}/#{file}"
63
+ end
64
+
65
+ def routes_path
66
+ main_app? ? "config/routes.rb" : "packages/#{package_namespace}/config/routes.rb"
67
+ end
68
+
69
+ def register_line
70
+ constant = main_app? ? "::#{dashboard_class_name}" : dashboard_class_name
71
+ %(register_dashboard #{constant}, at: "#{root_mount? ? "/" : mount_path}")
72
+ end
73
+
74
+ # Idempotent: skips when the routes already register this dashboard. A root
75
+ # mount replaces the portal's generated `root to:` line, since two roots
76
+ # would clash.
77
+ def register_dashboard_in_routes
78
+ content = File.read(File.join(destination_root, routes_path))
79
+
80
+ if /^\s*register_dashboard #{Regexp.escape("::" + dashboard_class_name)}\b|^\s*register_dashboard #{Regexp.escape(dashboard_class_name)}\b/.match?(content)
81
+ say_status :identical, "#{routes_path} already registers #{dashboard_class_name}", :blue
82
+ return
83
+ end
84
+
85
+ if root_mount? && (match = content.match(/^\s*root to: "dashboard#index".*\n/))
86
+ gsub_file routes_path, match[0], indent("#{register_line}\n", 2)
87
+ elsif /^\s*#\s*register resources above\b/.match?(content)
88
+ insert_into_file routes_path, indent("#{register_line}\n", 2), before: /^\s*#\s*register resources above\b.*/
89
+ elsif (match = content.match(/^\s*(\w+::Engine|Rails\.application)\.routes\.draw do.*\n/))
90
+ insert_into_file routes_path, indent("#{register_line}\n", 2), after: match[0]
91
+ else
92
+ say_status :warn, "Could not locate routes block in #{routes_path}; add manually: #{register_line}", :yellow
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,25 @@
1
+ <%- if main_app? -%>
2
+ class <%= dashboard_name %> < Plutonium::Dashboard::Base
3
+ presents label: "<%= dashboard_label %>"
4
+
5
+ # Cards sit on a 12-column grid (metric 3, chart and card 6 by default; `span:`
6
+ # overrides), each in its own lazy turbo frame. `group_by_day` is the groupdate gem.
7
+ #
8
+ # metric(:users, icon: Phlex::TablerIcons::Users) { authorized_resource_scope(User).count }
9
+ # chart(:signups, type: :line, span: 8) { User.group_by_day(:created_at, last: 30).count }
10
+ # card(:welcome) { p { "Signed in as #{current_user.email}" } }
11
+ end
12
+ <%- else -%>
13
+ module <%= app_namespace %>
14
+ class <%= dashboard_name %> < Plutonium::Dashboard::Base
15
+ presents label: "<%= dashboard_label %>"
16
+
17
+ # Cards sit on a 12-column grid (metric 3, chart and card 6 by default; `span:`
18
+ # overrides), each in its own lazy turbo frame. `group_by_day` is the groupdate gem.
19
+ #
20
+ # metric(:users, icon: Phlex::TablerIcons::Users) { authorized_resource_scope(User).count }
21
+ # chart(:signups, type: :line, span: 8) { User.group_by_day(:created_at, last: 30).count }
22
+ # card(:welcome) { p { "Signed in as #{current_user.email}" } }
23
+ end
24
+ end
25
+ <%- end -%>
@@ -11,6 +11,11 @@ module Pu
11
11
 
12
12
  desc "Set up docker for project"
13
13
 
14
+ # Fallbacks when the tool is not on PATH at generation time.
15
+ NODE_LTS_VERSION = "22.23.2"
16
+ YARN_CLASSIC_VERSION = "1.22.22"
17
+ BUN_VERSION = "1.4.2"
18
+
14
19
  def start
15
20
  in_root do
16
21
  template "Dockerfile", force: true
@@ -30,6 +35,59 @@ module Pu
30
35
  def ruby_version
31
36
  @ruby_version ||= File.read(".ruby-version").strip
32
37
  end
38
+
39
+ # The JavaScript toolchain is chosen the same way jsbundling-rails will
40
+ # choose it at `assets:precompile` inside the image, and versions come
41
+ # from the machine generating the Dockerfile (or NODE_VERSION /
42
+ # YARN_VERSION / BUN_VERSION), like Rails' own template.
43
+ def using_bun?
44
+ js_package_manager == :bun
45
+ end
46
+
47
+ def dockerfile_node_version
48
+ ENV.fetch("NODE_VERSION") { node_version || NODE_LTS_VERSION }
49
+ end
50
+
51
+ def dockerfile_yarn_version
52
+ ENV.fetch("YARN_VERSION") { yarn_version || YARN_CLASSIC_VERSION }
53
+ end
54
+
55
+ def dockerfile_bun_version
56
+ ENV.fetch("BUN_VERSION") { bun_version || BUN_VERSION }
57
+ end
58
+
59
+ # Yarn 2+ is installed through corepack, and its lockfile flag is
60
+ # --immutable rather than yarn 1's --frozen-lockfile.
61
+ def yarn_through_corepack?
62
+ js_package_manager == :yarn && dockerfile_yarn_version.to_i >= 2
63
+ end
64
+
65
+ def js_lockfile_copy
66
+ case js_package_manager
67
+ when :bun then "package.json bun.lock*"
68
+ when :pnpm then "package.json pnpm-lock.yaml"
69
+ when :npm then "package.json package-lock.json"
70
+ # .yarnrc.yml carries nodeLinker for yarn 2+; the glob keeps the COPY
71
+ # valid on yarn 1 apps that have none.
72
+ else "package.json yarn.lock .yarnrc.yml*"
73
+ end
74
+ end
75
+
76
+ def js_frozen_install_command
77
+ case js_package_manager
78
+ when :bun then "bun install --frozen-lockfile"
79
+ when :pnpm then "pnpm install --frozen-lockfile"
80
+ when :npm then "npm ci"
81
+ else "yarn install --#{yarn_through_corepack? ? "immutable" : "frozen-lockfile"}"
82
+ end
83
+ end
84
+
85
+ # docker-compose runs this on every container start. `npm ci` would wipe
86
+ # node_modules each boot and fails without a package-lock.json, so npm
87
+ # gets a plain install; the others already only sync what changed.
88
+ def js_dev_install_command
89
+ (js_package_manager == :npm) ? "npm install" : js_install_command
90
+ end
33
91
  end
34
92
  end
35
93
  end
@@ -11,13 +11,28 @@ RUN apt-get update -qq && \
11
11
  apt-get install --no-install-recommends -y build-essential curl git libpq-dev node-gyp pkg-config python-is-python3
12
12
 
13
13
  # Install JavaScript dependencies
14
- ARG NODE_VERSION=22.2.0
15
- ARG YARN_VERSION=1.22.22
14
+ <% if using_bun? -%>
15
+ ENV BUN_INSTALL=/usr/local/bun
16
+ ENV PATH=/usr/local/bun/bin:$PATH
17
+ ARG BUN_VERSION=<%= dockerfile_bun_version %>
18
+ RUN curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}"
19
+ <% else -%>
20
+ ARG NODE_VERSION=<%= dockerfile_node_version %>
16
21
  ENV PATH=/usr/local/node/bin:$PATH
17
22
  RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
18
23
  /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
19
- npm install -g yarn@$YARN_VERSION && \
20
24
  rm -rf /tmp/node-build-master
25
+ <% if js_package_manager == :yarn -%>
26
+ ARG YARN_VERSION=<%= dockerfile_yarn_version %>
27
+ <% if yarn_through_corepack? -%>
28
+ RUN corepack enable && corepack install -g yarn@$YARN_VERSION
29
+ <% else -%>
30
+ RUN npm install -g yarn@$YARN_VERSION
31
+ <% end -%>
32
+ <% elsif js_package_manager == :pnpm -%>
33
+ RUN corepack enable && corepack install -g pnpm@latest
34
+ <% end -%>
35
+ <% end -%>
21
36
 
22
37
  # Additional dependencies go here
23
38
 
@@ -22,13 +22,28 @@ RUN apt-get update -qq && \
22
22
  apt-get install --no-install-recommends -y build-essential curl git libpq-dev node-gyp pkg-config python-is-python3
23
23
 
24
24
  # Install JavaScript dependencies
25
- ARG NODE_VERSION=22.2.0
26
- ARG YARN_VERSION=1.22.22
25
+ <% if using_bun? -%>
26
+ ENV BUN_INSTALL=/usr/local/bun
27
+ ENV PATH=/usr/local/bun/bin:$PATH
28
+ ARG BUN_VERSION=<%= dockerfile_bun_version %>
29
+ RUN curl -fsSL https://bun.sh/install | bash -s -- "bun-v${BUN_VERSION}"
30
+ <% else -%>
31
+ ARG NODE_VERSION=<%= dockerfile_node_version %>
27
32
  ENV PATH=/usr/local/node/bin:$PATH
28
33
  RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
29
34
  /tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
30
- npm install -g yarn@$YARN_VERSION && \
31
35
  rm -rf /tmp/node-build-master
36
+ <% if js_package_manager == :yarn -%>
37
+ ARG YARN_VERSION=<%= dockerfile_yarn_version %>
38
+ <% if yarn_through_corepack? -%>
39
+ RUN corepack enable && corepack install -g yarn@$YARN_VERSION
40
+ <% else -%>
41
+ RUN npm install -g yarn@$YARN_VERSION
42
+ <% end -%>
43
+ <% elsif js_package_manager == :pnpm -%>
44
+ RUN corepack enable && corepack install -g pnpm@latest
45
+ <% end -%>
46
+ <% end -%>
32
47
 
33
48
  # Install application gems
34
49
  COPY .ruby-version Gemfile Gemfile.lock ./
@@ -37,8 +52,8 @@ RUN bundle install && \
37
52
  bundle exec bootsnap precompile --gemfile
38
53
 
39
54
  # Install node modules
40
- COPY package.json yarn.lock ./
41
- RUN yarn install --frozen-lockfile
55
+ COPY <%= js_lockfile_copy %> ./
56
+ RUN <%= js_frozen_install_command %>
42
57
 
43
58
  # Copy application code
44
59
  COPY . .
@@ -22,7 +22,7 @@ x-app: &app
22
22
  services:
23
23
  web:
24
24
  <<: *app
25
- command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle && yarn && bin/dev" # web
25
+ command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle && <%= js_dev_install_command %> && bin/dev" # web
26
26
  # web properties
27
27
  ports: # web
28
28
  - "3000:3000" # app
@@ -19,7 +19,7 @@ module Pu
19
19
 
20
20
  copy_file "config/initializers/001_ensure_required_env.rb"
21
21
 
22
- gitignore "!/.env", "!/.env.template", "!/.env.local.template", "!/.env.test.local"
22
+ gitignore "!/.env", "!/.env.template", "!/.env.local.template"
23
23
 
24
24
  insert_into_file "Gemfile", "\ngem \"dotenv\", groups: %i[development test]\n", after: /^gem ["']rails["'].*\n/
25
25
  bundle!
@@ -22,7 +22,7 @@ module Pu
22
22
  desc: "The user model name"
23
23
 
24
24
  class_option :membership_model, type: :string,
25
- desc: "The membership model name (defaults to EntityUser)"
25
+ desc: "The membership model name (e.g. OrganizationUser). When omitted, the generated interaction has no membership_class override and you must implement it yourself"
26
26
 
27
27
  class_option :dest, type: :string, default: "main_app",
28
28
  desc: "Destination package for the interaction"
@@ -51,6 +51,23 @@ module Pu
51
51
  end
52
52
  end
53
53
 
54
+ def warn_membership_model_default
55
+ return if behavior == :revoke
56
+ return if options[:membership_model].present?
57
+
58
+ say_status :warning,
59
+ "No --membership-model specified — generated interaction will not include a membership_class override.",
60
+ :yellow
61
+ say <<~MSG.indent(2)
62
+
63
+ The generated #{model_class}::InviteUserInteraction raises
64
+ NotImplementedError from #membership_class at runtime until you either:
65
+ - re-run with --membership-model=NAME (e.g. --membership-model=OrganizationUser), or
66
+ - manually override #membership_class to return your membership join model.
67
+
68
+ MSG
69
+ end
70
+
54
71
  def create_interaction
55
72
  template "invitable/invite_user_interaction.rb",
56
73
  interaction_path
@@ -29,101 +29,6 @@ module PlutoniumGenerators
29
29
  end
30
30
  end
31
31
 
32
- # #
33
- # # Adds a new gem into the Gemfile
34
- # # Existing directives are updated if they do not match
35
- # # When `:group` is specified, the gem is inserted into the approriate gem group.
36
- # #
37
- # # @param [String] name the name of the gem
38
- # # @param [Hash] **kwargs set of options to append see #super
39
- # #
40
- # # @return [void]
41
- # #
42
- # def gem(name, **kwargs)
43
- # groups = Array(kwargs.delete(:group))
44
-
45
- # in_root do
46
- # begin
47
- # # Create a temp gemfile
48
- # File.rename("Gemfile", "Gemfile.bak")
49
- # File.write("Gemfile", "")
50
- # # Generate the directive
51
- # super
52
- # # Get the generated directive
53
- # directive = gemfile.strip
54
- # ensure
55
- # # Restore our gemfile
56
- # File.delete "Gemfile"
57
- # File.rename "Gemfile.bak", "Gemfile"
58
- # end
59
-
60
- # pattern = /^# gem ['"]#{name}['"].*/
61
- # if gemfile.match(pattern)
62
- # # Replace commented out directive
63
- # gsub_file("Gemfile", pattern, directive)
64
- # break
65
- # end
66
-
67
- # # Remove existing directive
68
- # remove_gem name
69
-
70
- # # Insert the new directive
71
- # if groups != []
72
- # str = groups.sort.map(&:inspect).join(", ")
73
- # after_sentinel = "group #{str} do\n"
74
-
75
- # unless File.read("Gemfile").match?(/^#{after_sentinel}/)
76
- # inject_into_file "Gemfile", "\n#{after_sentinel}end\n"
77
- # end
78
- # else
79
- # after_sentinel = "# Project gems\n\n"
80
- # unless File.read("Gemfile").match?(/^#{after_sentinel}/)
81
- # inject_into_file "Gemfile", "\n#{after_sentinel}", after: /^ruby .*\n/
82
- # end
83
- # end
84
-
85
- # inject_into_file "Gemfile", "#{directive}\n", after: /^#{after_sentinel}/
86
- # end
87
- # end
88
-
89
- # #
90
- # # Removes a gem and any preceeding comments from the Gemfile
91
- # #
92
- # # @param gem [String] the name of the gem to remove
93
- # #
94
- # # @return [void]
95
- # #
96
- # def remove_gem(gem)
97
- # log :remove_gem, gem
98
- # gsub_file "Gemfile", /(:?^.*#.*\n)*.*gem ['"]#{gem}['"].*\n/, "", verbose: false
99
- # end
100
-
101
- # #
102
- # # Evaluates the given template and merges it with the project's docker-compose.yml
103
- # #
104
- # # @param [String] source the template filename
105
- # #
106
- # # @return [void]
107
- # #
108
- # def docker_compose(source)
109
- # log :docker_compose, source
110
-
111
- # in_root do
112
- # compose_file = "docker-compose.yml"
113
- # compose_definition = YAML.load_file(compose_file) if File.exist?(compose_file)
114
- # compose_definition ||= {
115
- # version: "3.7",
116
- # services: {}
117
- # }
118
- # compose_definition.deep_stringify_keys!
119
-
120
- # new_definition = YAML.load template_eval("docker-compose/#{source}.yml.tt")
121
- # compose_definition.deep_merge! new_definition.deep_stringify_keys
122
-
123
- # create_file compose_file, YAML.dump(compose_definition), force: true
124
- # end
125
- # end
126
-
127
32
  def managed_compose(file = "docker-compose.yml")
128
33
  @managed_compose ||= {}
129
34
 
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ # jsbundling-rails `main` ships the detector its build task uses (unreleased
5
+ # as of 1.3.1).
6
+ require "jsbundling/package_manager"
7
+ rescue LoadError
8
+ # Older jsbundling-rails, or an app without it: fall back to the same rules.
9
+ end
10
+
11
+ module PlutoniumGenerators
12
+ module Concerns
13
+ # Detects the host app's JavaScript package manager and builds the matching
14
+ # shell commands, so generators never hardcode `yarn`.
15
+ #
16
+ # Detection follows what `rails javascript:build` consults at precompile
17
+ # time. Installing with the same tool keeps a single lockfile: the lockfile
18
+ # decides, and with no lockfile the first executable on PATH among bun,
19
+ # yarn, pnpm and npm wins (yarn when none is found). `rails new -j esbuild`
20
+ # therefore yields a bun app on any machine that has bun installed, and
21
+ # this concern follows it rather than adding a yarn.lock next to bun.lock.
22
+ # When the app's jsbundling-rails provides Jsbundling::PackageManager, that
23
+ # detector is used as is (it skips yarn.lock and decides by PATH).
24
+ #
25
+ # Yarn 1 (classic) and Yarn 2+ (berry) share `add` but differ elsewhere;
26
+ # `yarn_berry?` distinguishes them by asking the yarn the app resolves to.
27
+ module JsPackageManager
28
+ ADD_COMMANDS = {
29
+ bun: "bun add %s",
30
+ pnpm: "pnpm add %s",
31
+ npm: "npm install %s",
32
+ yarn: "yarn add %s"
33
+ }.freeze
34
+
35
+ INSTALL_COMMANDS = {
36
+ bun: "bun install",
37
+ pnpm: "pnpm install",
38
+ npm: "npm ci",
39
+ yarn: "yarn install"
40
+ }.freeze
41
+
42
+ RUN_COMMANDS = {
43
+ bun: "bun run %s",
44
+ pnpm: "pnpm run %s",
45
+ npm: "npm run %s",
46
+ yarn: "yarn %s"
47
+ }.freeze
48
+
49
+ LOCKFILES = {
50
+ bun: %w[bun.lock bun.lockb],
51
+ pnpm: %w[pnpm-lock.yaml],
52
+ npm: %w[package-lock.json],
53
+ yarn: %w[yarn.lock]
54
+ }.freeze
55
+
56
+ protected
57
+
58
+ # @return [Symbol] one of :bun, :pnpm, :npm, :yarn
59
+ def js_package_manager
60
+ @js_package_manager ||= detect_js_package_manager
61
+ end
62
+
63
+ # One command that adds every package. `pkg@latest` and `pkg@^1.2.3`
64
+ # are understood identically by all four managers, which is why callers
65
+ # pass versions inline instead of running a separate upgrade step
66
+ # (yarn 1 `upgrade --latest` has no yarn 2+ equivalent).
67
+ def js_add_command(*packages)
68
+ format(ADD_COMMANDS.fetch(js_package_manager), packages.flatten.join(" "))
69
+ end
70
+
71
+ def js_install_command
72
+ INSTALL_COMMANDS.fetch(js_package_manager)
73
+ end
74
+
75
+ def js_run_command(script)
76
+ format(RUN_COMMANDS.fetch(js_package_manager), script)
77
+ end
78
+
79
+ # Yarn 2+ ("berry"). Asks the yarn the app resolves to, so corepack and
80
+ # a `packageManager` field are honoured.
81
+ def yarn_berry?
82
+ js_package_manager == :yarn && yarn_version.to_i >= 2
83
+ end
84
+
85
+ def yarn_version
86
+ @yarn_version ||= capture_tool_version("yarn --version")
87
+ end
88
+
89
+ def bun_version
90
+ @bun_version ||= capture_tool_version("bun --version")
91
+ end
92
+
93
+ def node_version
94
+ @node_version ||= capture_tool_version("node --version")
95
+ end
96
+
97
+ # Yarn 2+ defaults to Plug'n'Play, which Tailwind's PostCSS plugin cannot
98
+ # be loaded under (postcss-load-config's require() options are not
99
+ # supported by PnP). Pin the node-modules linker, which is what the rest
100
+ # of the Rails toolchain expects, unless the app already chose a linker.
101
+ def ensure_yarn_node_modules_linker
102
+ return unless yarn_berry?
103
+
104
+ yarnrc = ".yarnrc.yml"
105
+ existing = in_root_dir { File.exist?(yarnrc) ? File.read(yarnrc) : nil }
106
+
107
+ if existing.nil?
108
+ create_file yarnrc, "nodeLinker: node-modules\n"
109
+ elsif existing.match?(/^nodeLinker:/)
110
+ unless existing.match?(/^nodeLinker:\s*node-modules\b/)
111
+ warn "#{yarnrc} sets a nodeLinker other than node-modules; the Tailwind PostCSS plugin does not load under Plug'n'Play"
112
+ end
113
+ else
114
+ append_to_file yarnrc, "nodeLinker: node-modules\n"
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def detect_js_package_manager
121
+ root = Pathname(destination_root)
122
+ return Jsbundling::PackageManager.detect(root) if defined?(Jsbundling::PackageManager)
123
+
124
+ LOCKFILES.each do |manager, files|
125
+ return manager if files.any? { |f| root.join(f).exist? }
126
+ end
127
+ return :bun if root.join("bun.config.js").exist?
128
+
129
+ %i[bun yarn pnpm npm].find { |exe| system("command -v #{exe} > /dev/null") } || :yarn
130
+ end
131
+
132
+ def capture_tool_version(command)
133
+ output = in_root_dir { `#{command} 2>/dev/null` }
134
+ output[/\d+\.\d+\.\d+/]
135
+ rescue SystemCallError
136
+ nil
137
+ end
138
+
139
+ # Thor's in_root returns the block value too, but only once the generator
140
+ # has a destination stack; this stays usable from plain unit tests.
141
+ def in_root_dir(&)
142
+ Dir.chdir(destination_root, &)
143
+ end
144
+ end
145
+ end
146
+ end
@@ -5,10 +5,9 @@ require "tty-prompt"
5
5
 
6
6
  module PlutoniumGenerators
7
7
  module Generator
8
- include Concerns::Config
9
8
  include Concerns::Logger
10
- include Concerns::Serializer
11
9
  include Concerns::Actions
10
+ include Concerns::JsPackageManager
12
11
 
13
12
  # Finds the shared namespace prefix between two model names.
14
13
  # Used to derive association names when models share a namespace.
@@ -58,20 +57,5 @@ module PlutoniumGenerators
58
57
  TTY::Prompt.new
59
58
  end
60
59
  end
61
-
62
- # def appname
63
- # defined?(Rails.application) ? Rails.application.class.module_parent.name : "PlutoniumGenerators"
64
- # end
65
-
66
- # def app_name
67
- # appname.underscore
68
- # end
69
-
70
- def pug_installed?(feature, version: nil)
71
- installed_version = read_config(:installed, feature)
72
- return false unless installed_version.present?
73
-
74
- version.present? ? SemanticRange.satisfies?(installed_version, ">=#{version}") : true
75
- end
76
60
  end
77
61
  end
@@ -17,6 +17,7 @@ module Pu
17
17
  validate_package_name package_name
18
18
 
19
19
  template "lib/engine.rb", "packages/#{package_namespace}/lib/engine.rb"
20
+ template "config/locales/en.yml", "packages/#{package_namespace}/config/locales/en.yml"
20
21
 
21
22
  %w[controllers interactions models policies definitions].each do |dir|
22
23
  directory "app/#{dir}", "packages/#{package_namespace}/app/#{dir}/#{package_namespace}"
@@ -0,0 +1,28 @@
1
+ # Wording for the <%= package_name %> package's resources.
2
+ #
3
+ # This file is loaded automatically (the package is a Rails engine) ahead of
4
+ # the host app's config/locales, so the app can still override anything here.
5
+ #
6
+ # Model and attribute names follow the Rails convention; placeholders, hints
7
+ # and derived labels follow Plutonium's:
8
+ #
9
+ # en:
10
+ # activerecord:
11
+ # models:
12
+ # <%= package_namespace %>/post:
13
+ # one: "Post"
14
+ # other: "Posts"
15
+ # attributes:
16
+ # <%= package_namespace %>/post:
17
+ # title: "Title"
18
+ # plutonium:
19
+ # fields:
20
+ # <%= package_namespace %>/post:
21
+ # title:
22
+ # placeholder: "A short, descriptive title"
23
+ # actions:
24
+ # <%= package_namespace %>/post:
25
+ # publish: "Publish now"
26
+ #
27
+ # See https://radioactive-labs.github.io/plutonium-core/reference/i18n
28
+ en: {}
@@ -25,6 +25,7 @@ module Pu
25
25
 
26
26
  template "lib/engine.rb", "packages/#{package_namespace}/lib/engine.rb"
27
27
  template "config/routes.rb", "packages/#{package_namespace}/config/routes.rb"
28
+ template "config/locales/en.yml", "packages/#{package_namespace}/config/locales/en.yml"
28
29
 
29
30
  template "app/controllers/concerns/controller.rb",
30
31
  "packages/#{package_namespace}/app/controllers/#{package_namespace}/concerns/controller.rb"