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
@@ -188,14 +188,19 @@ module Plutonium
188
188
  end
189
189
  end
190
190
 
191
- # Attachment/file input names (as Strings) declared on the current
192
- # definition — inputs whose `as:` is a file type (`:file`/`:uppy`/
193
- # `:attachment`). Excluded from the extraction-record pre-assignment so a
194
- # single-read Rack upload isn't consumed before create/update reads it.
191
+ # Attachment/file input names (as Strings) for the current resource —
192
+ # inputs declared with a file `as:` (`:file`/`:uppy`/`:attachment`)
193
+ # unioned with the attachments reflected on the model, so auto-detected
194
+ # attachments (no `input` declaration) are covered too. Excluded from the
195
+ # extraction-record pre-assignment so a single-read Rack upload isn't
196
+ # consumed before create/update reads it.
195
197
  def attachment_input_keys
196
- current_definition.defined_inputs.filter_map { |name, config|
198
+ declared = current_definition.defined_inputs.filter_map { |name, config|
197
199
  name.to_s if Plutonium::Definition::InputAliases.file_input?(config.dig(:options, :as))
198
200
  }
201
+ reflected = (resource_class.has_one_attached_field_names + resource_class.has_many_attached_field_names)
202
+ .map(&:to_s)
203
+ declared | reflected
199
204
  end
200
205
 
201
206
  # Returns the resource parameters, including scoped and parent parameters
@@ -36,7 +36,7 @@ module Plutonium
36
36
  # GET /resources/new
37
37
  def new
38
38
  authorize_current! resource_class
39
- set_page_title "Create #{resource_class.model_name.human.titleize}"
39
+ set_page_title t("plutonium.resource.titles.new", resource: resource_class.model_name.human)
40
40
 
41
41
  @resource_record = build_resource_record
42
42
  maybe_apply_submitted_resource_params!
@@ -47,7 +47,7 @@ module Plutonium
47
47
  # POST /resources(.{format})
48
48
  def create
49
49
  authorize_current! resource_class
50
- set_page_title "Create #{resource_class.model_name.human.titleize}"
50
+ set_page_title t("plutonium.resource.titles.new", resource: resource_class.model_name.human)
51
51
 
52
52
  @resource_record = build_resource_record resource_params
53
53
 
@@ -57,13 +57,13 @@ module Plutonium
57
57
  format.html { render :new, status: :unprocessable_content }
58
58
  elsif resource_record!.save
59
59
  after_create_persisted
60
+ notice = t("plutonium.resource.created", resource: resource_class.model_name.human)
60
61
  format.turbo_stream do
61
- flash.notice = "#{resource_class.model_name.human} was successfully created."
62
+ flash.notice = notice
62
63
  render turbo_stream: stacked_modal_create_streams
63
64
  end
64
65
  format.html do
65
- redirect_to redirect_url_after_submit,
66
- notice: "#{resource_class.model_name.human} was successfully created."
66
+ redirect_to redirect_url_after_submit, notice:
67
67
  end
68
68
  format.any do
69
69
  @current_policy = nil # Reset cached policy so it uses the instance instead of class
@@ -85,7 +85,7 @@ module Plutonium
85
85
  # GET /resources/1/edit
86
86
  def edit
87
87
  authorize_current! resource_record!
88
- set_page_title "Update #{resource_record!.to_label.titleize}"
88
+ set_page_title t("plutonium.resource.titles.edit", record: resource_record!.to_label.titleize)
89
89
 
90
90
  maybe_apply_submitted_resource_params!
91
91
 
@@ -95,7 +95,7 @@ module Plutonium
95
95
  # PATCH/PUT /resources/1(.{format})
96
96
  def update
97
97
  authorize_current! resource_record!
98
- set_page_title "Update #{resource_record!.to_label.titleize}"
98
+ set_page_title t("plutonium.resource.titles.edit", record: resource_record!.to_label.titleize)
99
99
 
100
100
  resource_record!.attributes = resource_params
101
101
 
@@ -104,14 +104,13 @@ module Plutonium
104
104
  format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :edit))) }
105
105
  format.html { render :edit, status: :unprocessable_content }
106
106
  elsif resource_record!.save
107
+ notice = t("plutonium.resource.updated", resource: resource_class.model_name.human)
107
108
  format.turbo_stream do
108
- flash.notice = "#{resource_class.model_name.human} was successfully updated."
109
+ flash.notice = notice
109
110
  render turbo_stream: helpers.turbo_stream_redirect(redirect_url_after_submit)
110
111
  end
111
112
  format.html do
112
- redirect_to redirect_url_after_submit,
113
- notice: "#{resource_class.model_name.human} was successfully updated.",
114
- status: :see_other
113
+ redirect_to redirect_url_after_submit, notice:, status: :see_other
115
114
  end
116
115
  format.any do
117
116
  render :show, status: :ok, location: redirect_url_after_submit
@@ -134,29 +133,27 @@ module Plutonium
134
133
  respond_to do |format|
135
134
  resource_record!.destroy
136
135
 
136
+ notice = t("plutonium.resource.deleted", resource: resource_class.model_name.human)
137
137
  format.turbo_stream do
138
- flash.notice = "#{resource_class.model_name.human} was successfully deleted."
138
+ flash.notice = notice
139
139
  render turbo_stream: helpers.turbo_stream_redirect(redirect_url_after_destroy)
140
140
  end
141
141
  format.html do
142
- redirect_to redirect_url_after_destroy,
143
- notice: "#{resource_class.model_name.human} was successfully deleted."
142
+ redirect_to redirect_url_after_destroy, notice:
144
143
  end
145
144
  format.json { head :no_content }
146
145
  rescue ActiveRecord::InvalidForeignKey, ActiveRecord::DeleteRestrictionError
146
+ alert = t("plutonium.resource.referenced", resource: resource_class.model_name.human)
147
147
  format.turbo_stream do
148
- flash.alert = "#{resource_class.model_name.human} is referenced by other records."
148
+ flash.alert = alert
149
149
  render turbo_stream: helpers.turbo_stream_redirect(resource_url_for(resource_record!))
150
150
  end
151
151
  format.html do
152
- redirect_to resource_url_for(resource_record!),
153
- alert: "#{resource_class.model_name.human} is referenced by other records."
152
+ redirect_to resource_url_for(resource_record!), alert:
154
153
  end
155
154
  format.any do
156
155
  @errors = ActiveModel::Errors.new resource_record!
157
- @errors.add :base,
158
- :existing_references,
159
- message: "is referenced by other records"
156
+ @errors.add :base, :existing_references
160
157
 
161
158
  render "errors", status: :unprocessable_content
162
159
  end
@@ -37,12 +37,6 @@ module Plutonium
37
37
  # Collections rendered here preload what they are about to show.
38
38
  include Plutonium::Resource::Controllers::EagerLoading
39
39
 
40
- # Placeholder written when a column is neither an `export` block nor a
41
- # real attribute on the record, so the export degrades to a usable file
42
- # instead of a mid-stream NoMethodError (which would truncate the
43
- # already-committed download).
44
- INVALID_COLUMN = "<<invalid column>>"
45
-
46
40
  included do
47
41
  before_action :authorize_export_csv!, only: :export_csv
48
42
  # Row-level authorization is the scope itself
@@ -121,9 +115,17 @@ module Plutonium
121
115
  # A cell beginning with = + - @ (or a leading tab/CR) is executed as a
122
116
  # formula by Excel/Sheets. Prefix it with a single quote so the value
123
117
  # imports as literal text (CSV/formula injection).
118
+ #
119
+ # The leading-character check runs against the NFKC-normalized string so
120
+ # that full-width (double-byte) variants — = + - @ (U+FF1D/FF0B/FF0D/FF20) —
121
+ # which East Asian locales (e.g. Japanese Excel) treat as formula
122
+ # initiators, are also neutralized. The *original* string is prefixed
123
+ # (not the normalized one), so legitimate full-width content is preserved
124
+ # verbatim and only gains a defensive leading quote.
124
125
  def neutralize_csv_formula(value)
125
126
  string = value.to_s
126
- /\A[=+\-@\t\r]/.match?(string) ? "'#{string}" : string
127
+ normalized = string.unicode_normalize(:nfkc)
128
+ /\A[=+\-@\t\r]/.match?(normalized) ? "'#{string}" : string
127
129
  end
128
130
 
129
131
  # The primary key is always the first column, followed by the
@@ -143,7 +145,7 @@ module Plutonium
143
145
  # Associations render as their display label — the same as the index —
144
146
  # instead of "#<User:0x…>"; scalars pass through untouched. A name that
145
147
  # is neither an `export` block nor a real attribute renders the
146
- # INVALID_COLUMN placeholder rather than aborting the stream.
148
+ # invalid-column placeholder rather than aborting the stream.
147
149
  def export_csv_value(record, name)
148
150
  definition = current_definition.defined_exports[name]
149
151
  return definition[:block].call(record) if definition && definition[:block]
@@ -151,7 +153,7 @@ module Plutonium
151
153
  begin
152
154
  value = record.public_send(name)
153
155
  rescue NoMethodError
154
- return INVALID_COLUMN
156
+ return invalid_column_placeholder
155
157
  end
156
158
 
157
159
  case value
@@ -165,6 +167,14 @@ module Plutonium
165
167
  definition = current_definition.defined_exports[name]
166
168
  definition&.dig(:options, :label) || name.to_s.humanize
167
169
  end
170
+
171
+ # Placeholder written when a column is neither an `export` block nor a
172
+ # real attribute on the record, so the export degrades to a usable file
173
+ # instead of a mid-stream NoMethodError (which would truncate the
174
+ # already-committed download).
175
+ def invalid_column_placeholder
176
+ @invalid_column_placeholder ||= I18n.t("plutonium.resource.export_csv.invalid_column")
177
+ end
168
178
  end
169
179
  end
170
180
  end
@@ -114,7 +114,7 @@ module Plutonium
114
114
  if from && !record_in_kanban_column?(record, from)
115
115
  return render_kanban_rejection(
116
116
  params[:from_column],
117
- reason: "This card is no longer in “#{from.label}”."
117
+ reason: t("plutonium.kanban.move.not_in_column", column: from.label)
118
118
  )
119
119
  end
120
120
 
@@ -123,11 +123,11 @@ module Plutonium
123
123
  unless from && to&.accepts?(from.key) && !from.locked?
124
124
  reason =
125
125
  if from&.locked?
126
- "Cards can't be moved out of “#{from.label}”."
126
+ t("plutonium.kanban.move.locked_source", column: from.label)
127
127
  elsif to
128
- "Cards can't be moved into “#{to.label}”."
128
+ t("plutonium.kanban.move.not_accepted", column: to.label)
129
129
  else
130
- "This card can't be moved there."
130
+ t("plutonium.kanban.move.not_allowed")
131
131
  end
132
132
  return render_kanban_rejection(params[:from_column], reason:)
133
133
  end
@@ -151,7 +151,7 @@ module Plutonium
151
151
  if to.wip && from.key != to.key && dest_cards.size + 1 > to.wip
152
152
  return render_kanban_rejection(
153
153
  params[:from_column],
154
- reason: "“#{to.label}” is at its WIP limit (#{to.wip})."
154
+ reason: t("plutonium.kanban.move.wip_limit_reached", column: to.label, wip: to.wip)
155
155
  )
156
156
  end
157
157
 
@@ -180,7 +180,7 @@ module Plutonium
180
180
  # board. (Static boards always register — see Definition::IndexViews.)
181
181
  if run_enter_interaction && current_definition.defined_actions[to.enter_interaction_key].nil?
182
182
  Rails.logger.warn { "[plutonium] kanban enter_interaction on column `#{to.key}` is not registered — enter_interaction is unsupported on dynamic (`columns do…end`) boards; rejecting the drop." }
183
- return render_kanban_rejection(params[:from_column], reason: "This drop can’t be completed.")
183
+ return render_kanban_rejection(params[:from_column], reason: t("plutonium.kanban.move.drop_incomplete"))
184
184
  end
185
185
 
186
186
  # An input-less drop interaction is `immediate` — the client commits it
@@ -304,7 +304,7 @@ module Plutonium
304
304
  # fix the input and resubmit.
305
305
  if drop_immediate
306
306
  reason = @interaction.errors.full_messages.to_sentence.presence ||
307
- "“#{to.label}” could not be applied."
307
+ t("plutonium.kanban.move.enter_interaction_failed", column: to.label)
308
308
  return render_kanban_rejection(from.key, reason:)
309
309
  end
310
310
  return render :kanban_move_form, formats: [:html], **modal_render_options, status: :unprocessable_content
@@ -373,7 +373,7 @@ module Plutonium
373
373
  skip_verify_authorize_current!
374
374
  render_kanban_rejection(
375
375
  params[:from_column],
376
- reason: "You are not authorized to move this card there.",
376
+ reason: t("plutonium.kanban.move.unauthorized"),
377
377
  status: :forbidden
378
378
  )
379
379
  rescue ActiveRecord::RecordNotFound
@@ -384,13 +384,13 @@ module Plutonium
384
384
  # `find` raised before authorize_current!, so satisfy that verifier; the
385
385
  # scope verifier is already satisfied by kanban_base_relation.
386
386
  skip_verify_authorize_current!
387
- render_kanban_rejection(params[:from_column], reason: "This card no longer exists.")
387
+ render_kanban_rejection(params[:from_column], reason: t("plutonium.kanban.move.record_missing"))
388
388
  rescue ActiveRecord::RecordInvalid => e
389
389
  # An on_exit/on_enter hook (or the interaction) left the record invalid,
390
390
  # so save! raised and the transaction rolled back. Snap back with the
391
391
  # validation reason rather than let a 500 HTML page morph into the board.
392
392
  reason = e.record.errors.full_messages.to_sentence.presence ||
393
- "This card could not be moved."
393
+ t("plutonium.kanban.move.record_invalid")
394
394
  render_kanban_rejection(params[:from_column], reason:)
395
395
  end
396
396
 
@@ -437,15 +437,15 @@ module Plutonium
437
437
  if from && !record_in_kanban_column?(record, from)
438
438
  return render_kanban_rejection(
439
439
  params[:from_column],
440
- reason: "This card is no longer in “#{from.label}”."
440
+ reason: t("plutonium.kanban.move.not_in_column", column: from.label)
441
441
  )
442
442
  end
443
443
  unless from && to.accepts?(from.key) && !from.locked?
444
444
  reason =
445
445
  if from&.locked?
446
- "Cards can't be moved out of “#{from.label}”."
446
+ t("plutonium.kanban.move.locked_source", column: from.label)
447
447
  else
448
- "Cards can't be moved into “#{to.label}”."
448
+ t("plutonium.kanban.move.not_accepted", column: to.label)
449
449
  end
450
450
  return render_kanban_rejection(params[:from_column], reason:)
451
451
  end
@@ -473,7 +473,7 @@ module Plutonium
473
473
  skip_verify_authorize_current!
474
474
  render_kanban_rejection(
475
475
  params[:from_column],
476
- reason: "You are not authorized to move this card there.",
476
+ reason: t("plutonium.kanban.move.unauthorized"),
477
477
  status: :forbidden
478
478
  )
479
479
  rescue ActiveRecord::RecordNotFound
@@ -527,7 +527,7 @@ module Plutonium
527
527
  # from the block for safety and dynamic edge cases.
528
528
  def current_kanban_board
529
529
  @current_kanban_board ||= current_definition.defined_kanban_board ||
530
- Plutonium::Kanban::DSL.build(&current_definition.defined_kanban_block)
530
+ Plutonium::Kanban::DSL.build(resource_class: resource_class, &current_definition.defined_kanban_block)
531
531
  end
532
532
 
533
533
  # Authorized + query-applied UN-paginated relation.
@@ -799,7 +799,7 @@ module Plutonium
799
799
  apply_kanban_column_enter!(resource_record!, column)
800
800
  rescue => e
801
801
  Rails.logger.error { "[plutonium] kanban quick-add on_enter failed for column #{params[:kanban_column].inspect}: #{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}" }
802
- flash[:alert] = "Couldn’t place it in “#{column&.label || params[:kanban_column]}” — it stayed in its default column."
802
+ flash[:alert] = t("plutonium.kanban.quick_add.default_column_fallback", column: column&.label || params[:kanban_column])
803
803
  end
804
804
 
805
805
  # Applies a column's on_enter to an already-persisted record, then appends
@@ -91,7 +91,7 @@ module Plutonium
91
91
  # writing a position derived from neighbours that never implied one.
92
92
  if config.delegate? && !current_query_object.sorted_ascending_only_by?(config.attribute)
93
93
  return render_position_reconciliation(
94
- reason: "Reordering is only available while the list is sorted by #{config.attribute.to_s.humanize.downcase}."
94
+ reason: t("plutonium.positioning.reposition.foreign_sort", attribute: config.attribute.to_s.humanize.downcase)
95
95
  )
96
96
  end
97
97
 
@@ -168,17 +168,17 @@ module Plutonium
168
168
  end
169
169
 
170
170
  render_position_reconciliation(
171
- reason: "You are not authorized to reorder this.",
171
+ reason: t("plutonium.positioning.reposition.unauthorized"),
172
172
  status: :forbidden
173
173
  )
174
174
  rescue ActiveRecord::RecordNotFound
175
175
  # The row was destroyed between render and drop. `find` raised before
176
176
  # authorize_current!, so satisfy that verifier explicitly.
177
177
  skip_verify_authorize_current!
178
- render_position_reconciliation(reason: "This record no longer exists.")
178
+ render_position_reconciliation(reason: t("plutonium.positioning.reposition.record_missing"))
179
179
  rescue ActiveRecord::RecordInvalid => e
180
180
  reason = e.record.errors.full_messages.to_sentence.presence ||
181
- "This record could not be moved."
181
+ t("plutonium.positioning.reposition.record_invalid")
182
182
  render_position_reconciliation(reason:)
183
183
  end
184
184
 
@@ -223,7 +223,7 @@ module Plutonium
223
223
  # says "the same product" rather than restating the column.
224
224
  def position_foreign_group_reason(record)
225
225
  scope = record.class.positioning_scope_attr.to_s.delete_suffix("_id").humanize.downcase
226
- "Reordering only works within the same #{scope}."
226
+ t("plutonium.positioning.reposition.foreign_group", group: scope)
227
227
  end
228
228
 
229
229
  # Fills in a neighbour the CLIENT could not see.
@@ -140,8 +140,14 @@ module Plutonium
140
140
  # gets useful typeahead without declaring `search`). If neither
141
141
  # search block nor fallback column is available, the relation is
142
142
  # returned unfiltered (capped).
143
+ #
144
+ # The input's `scope:` (set on the Association filter) narrows the
145
+ # relation BEFORE authorization — mirroring ResourceSelect so the
146
+ # dropdown and typeahead present the same candidate set (a
147
+ # filtered subset rather than all policy-permitted records).
143
148
  def filter_association(klass, query, options)
144
- relation = options[:skip_authorization] ? klass.all : authorized_resource_scope(klass)
149
+ relation = apply_scope(klass.all, options[:scope])
150
+ relation = options[:skip_authorization] ? relation : authorized_resource_scope(klass, relation: relation)
145
151
  if query.present?
146
152
  if (search_block = associated_definition_search_block(klass))
147
153
  relation = search_block.call(relation, query)
@@ -154,6 +160,23 @@ module Plutonium
154
160
  relation.limit(Typeahead::TYPEAHEAD_LIMIT + 1).to_a
155
161
  end
156
162
 
163
+ # Applies an optional association scope to a relation. Identical to
164
+ # ResourceSelect#apply_scope so dropdown and typeahead agree.
165
+ # Symbol → relation.public_send(sym) (named scope, e.g. :verified)
166
+ # Proc → arity 0 → relation.instance_exec(&scope) (kanban form: -> { where(...) })
167
+ # → arity ≥1 → scope.call(relation) (documented form: ->(s) { s.verified })
168
+ # nil → relation unchanged
169
+ # The arity dispatch matches call_option_proc (Form::Resource). Unsupported
170
+ # types fail loud rather than silently doing nothing.
171
+ def apply_scope(relation, scope)
172
+ case scope
173
+ when Symbol then relation.public_send(scope)
174
+ when Proc then scope.arity.zero? ? relation.instance_exec(&scope) : scope.call(relation)
175
+ when nil then relation
176
+ else raise ArgumentError, "Unsupported association scope: #{scope.inspect} (expected Symbol, Proc, or nil)"
177
+ end
178
+ end
179
+
157
180
  # Resolves the associated resource's `search` block, if declared.
158
181
  # Goes through `resource_definition` so portal/package namespacing
159
182
  # is honored (same fallback chain as the rest of the controller).
@@ -13,10 +13,29 @@ module Plutonium
13
13
 
14
14
  attr_accessor :default_sort_config, :default_scope_name
15
15
 
16
+ # Keys the query object reserves under the `q` namespace for built-in
17
+ # controls (e.g. `q[scope]`, `q[sort_fields]`). A filter sharing one of
18
+ # these names would collide with the control in the URL and in the filter
19
+ # form's hidden state, so defining one is rejected — see #define_filter.
20
+ RESERVED_FILTER_NAMES = %i[search scope sort_fields sort_directions].freeze
21
+
16
22
  # Initializes a QueryObject with the given resource_class and parameters.
17
23
  #
18
24
  # @param resource_class [Object] The resource class.
19
25
  # @param params [Hash] The parameters for initialization.
26
+ # The model this query object filters, for label conventions keyed by model.
27
+ attr_reader :resource_class
28
+
29
+ # Display label for a filter: its own `label` when it is a Filter (an
30
+ # explicit `label:` or the plutonium.filters.<model>.<name> convention),
31
+ # else the convention, else the humanized name.
32
+ def filter_label(name)
33
+ filter = filter_definitions[name]
34
+ return filter.label if filter.is_a?(Plutonium::Query::Filter)
35
+
36
+ Plutonium::Translation.label_for(:filter, resource_class, name) || name.to_s.humanize
37
+ end
38
+
20
39
  def initialize(resource_class, params, request_path, &)
21
40
  @resource_class = resource_class
22
41
  @params = params
@@ -32,7 +51,13 @@ module Plutonium
32
51
  #
33
52
  # @param name [Symbol] The name of the filter.
34
53
  # @param body [Proc, nil] The body of the filter.
54
+ # @raise [ArgumentError] if the name collides with a reserved query control.
35
55
  def define_filter(name, body, &)
56
+ if RESERVED_FILTER_NAMES.include?(name.to_sym)
57
+ raise ArgumentError,
58
+ "filter name :#{name} is reserved — the query string uses q[#{name}] as a " \
59
+ "built-in control (one of: #{RESERVED_FILTER_NAMES.join(", ")}). Rename the filter."
60
+ end
36
61
  filter_definitions[name] = build_query(body, &)
37
62
  end
38
63
 
@@ -170,7 +195,9 @@ module Plutonium
170
195
  next if humanized.blank?
171
196
  humanized
172
197
  else
173
- entries.map { |k, v| "#{k.to_s.humanize.downcase} #{v}" }.join(", ")
198
+ entries.map { |k, v|
199
+ Plutonium::Translation.t("plutonium.query.active_filter.input_value", input: k.to_s.humanize.downcase, value: v)
200
+ }.join(", ")
174
201
  end
175
202
  when Array
176
203
  entries = filter_params.reject(&:blank?)
@@ -187,7 +214,7 @@ module Plutonium
187
214
 
188
215
  {
189
216
  name: name,
190
- label: name.to_s.humanize,
217
+ label: filter_label(name),
191
218
  value_label: value_label,
192
219
  clear_url: build_url(name => nil)
193
220
  }
@@ -242,7 +269,7 @@ module Plutonium
242
269
 
243
270
  private
244
271
 
245
- attr_reader :resource_class, :params, :selected_sort_fields, :selected_sort_directions, :selected_scope_filter
272
+ attr_reader :params, :selected_sort_fields, :selected_sort_directions, :selected_scope_filter
246
273
 
247
274
  # Defines standard queries for search and scope.
248
275
  def define_standard_queries
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Routing
5
+ # Adds `register_dashboard` to the routing mapper, mirroring
6
+ # `register_wizard`. A dashboard is portal-hosted: its routes are drawn
7
+ # inside the engine's `routes.draw` block, so they inherit the portal's
8
+ # scope, auth and layout, and dispatch to a synthesized portal-namespaced
9
+ # `DashboardsController` that includes {Plutonium::Dashboard::Controller}.
10
+ #
11
+ # @example inside a portal engine's routes
12
+ # AdminPortal::Engine.routes.draw do
13
+ # register_dashboard HomeDashboard, at: "/" # the portal root
14
+ # register_dashboard SalesDashboard, at: "sales" # /dashboards/sales
15
+ # end
16
+ #
17
+ # Draws (portal-relative):
18
+ # GET /dashboards/sales → DashboardsController#show (sales_dashboard_path)
19
+ # GET /dashboards/sales/cards/:card → DashboardsController#card (sales_dashboard_card_path)
20
+ #
21
+ # Every path sits under the `dashboards/` segment, so a dashboard can never
22
+ # shadow (or be shadowed by) a `register_resource` route of the same name:
23
+ # `at: "sales"` and a `Sale` resource would otherwise both claim `/sales`.
24
+ # `prefix: nil` turns the segment off for a mount (`/sales`), and a string
25
+ # swaps it (`prefix: "reports"` → `/reports/sales`).
26
+ #
27
+ # A root mount draws `root` for the page and `/dashboards/<name>/cards/:card`
28
+ # for the cards, where `<name>` is `as:` or the class slug
29
+ # (`HomeDashboard` → `home`).
30
+ module DashboardRegistration
31
+ DASHBOARD_CONTROLLER_NAME = "dashboards"
32
+ DASHBOARD_PATH_SEGMENT = "dashboards"
33
+
34
+ # @param dashboard_class [Class] a Plutonium::Dashboard::Base subclass
35
+ # @param at [String] the path under the prefix; "/" (or "") mounts at the root
36
+ # @param as [String, Symbol, nil] override the route helper prefix
37
+ # @param prefix [String, nil] the leading path segment, "dashboards" by
38
+ # default; nil (or "") draws the mount at the bare `at:` path
39
+ def register_dashboard(dashboard_class, at:, as: nil, prefix: DASHBOARD_PATH_SEGMENT)
40
+ unless dashboard_class.is_a?(Class) && dashboard_class < Plutonium::Dashboard::Base
41
+ raise ArgumentError, "register_dashboard: #{dashboard_class.inspect} must subclass Plutonium::Dashboard::Base"
42
+ end
43
+
44
+ engine = dashboard_route_engine
45
+ raise ArgumentError, "register_dashboard: routes must be drawn on a Plutonium engine or the application" if engine.nil?
46
+
47
+ mount_path = strip_slashes(at)
48
+ prefix = strip_slashes(prefix)
49
+ root = mount_path.empty?
50
+ helper_name = (as || mount_path.presence || dashboard_class.route_name).to_s.tr("/", "_")
51
+ defaults = {dashboard_class: dashboard_class.name}
52
+
53
+ ensure_dashboard_controller!(engine)
54
+ engine.dashboard_register.register(dashboard_class)
55
+
56
+ controller = DASHBOARD_CONTROLLER_NAME
57
+ if root
58
+ get "/", to: "#{controller}#show", as: :root, defaults: defaults
59
+ else
60
+ get File.join(prefix, mount_path), to: "#{controller}#show", as: :"#{helper_name}_dashboard", defaults: defaults
61
+ end
62
+
63
+ cards_path = File.join(prefix, root ? helper_name : mount_path, "cards/:card")
64
+ get cards_path, to: "#{controller}#card", as: :"#{helper_name}_dashboard_card", defaults: defaults
65
+ end
66
+
67
+ private
68
+
69
+ # Possessive quantifiers (`/++`) so stripping the slashes can't backtrack:
70
+ # a plain `/+\z` is O(n²) on a string of many slashes (rb/polynomial-redos).
71
+ def strip_slashes(path)
72
+ path.to_s.sub(%r{\A/++}, "").sub(%r{/++\z}, "")
73
+ end
74
+
75
+ # Resolve (creating if needed) the controller the routes dispatch to:
76
+ # `<Portal>::DashboardsController` on a portal, `::DashboardsController`
77
+ # on the main app. An app-defined class of that name wins.
78
+ def ensure_dashboard_controller!(engine)
79
+ portal_module = dashboard_portal_module(engine)
80
+ if portal_module.nil?
81
+ define_dashboard_controller(Object, "DashboardsController", "ApplicationController", nil)
82
+ else
83
+ define_dashboard_controller(
84
+ portal_module,
85
+ "DashboardsController",
86
+ "#{portal_module.name}::PlutoniumController",
87
+ "#{portal_module.name}::Concerns::Controller"
88
+ )
89
+ end
90
+ end
91
+
92
+ # Synthesize the controller unless the app defines one. A controller this
93
+ # module synthesized earlier is rebuilt when its parent has been reloaded
94
+ # (development), so it never keeps serving a stale `PlutoniumController`.
95
+ def define_dashboard_controller(namespace, const_name, parent_name, concern_name)
96
+ parent = parent_name.safe_constantize || ActionController::Base
97
+
98
+ if namespace.const_defined?(const_name, false)
99
+ existing = namespace.const_get(const_name, false)
100
+ return existing unless existing.instance_variable_get(:@plutonium_synthesized) && existing.superclass != parent
101
+
102
+ namespace.send(:remove_const, const_name)
103
+ end
104
+
105
+ klass = Class.new(parent) do
106
+ include Plutonium::Dashboard::Controller
107
+ end
108
+ klass.instance_variable_set(:@plutonium_synthesized, true)
109
+ namespace.const_set(const_name, klass)
110
+
111
+ # `Class.new(parent)` fires Rails' `inherited` hook while the class is
112
+ # still anonymous, so `module_parents` finds no engine and the hook
113
+ # includes the *application's* URL helpers over the portal's. Include
114
+ # the portal's again, last, so bare helpers (`root_path` in the
115
+ # sidebar) resolve against the engine like a source-defined controller.
116
+ klass.include(namespace.railtie_routes_url_helpers) if namespace != Object
117
+
118
+ if concern_name && (concern = concern_name.safe_constantize)
119
+ klass.include concern
120
+ end
121
+ klass
122
+ end
123
+
124
+ # The Plutonium engine owning this route set (mirrors RouteSetExtensions#engine).
125
+ def dashboard_route_engine
126
+ rs = respond_to?(:route_set) ? route_set : @set
127
+ rs.respond_to?(:engine) ? rs.engine : nil
128
+ end
129
+
130
+ def dashboard_portal_module(engine)
131
+ return nil if engine == Rails.application.class
132
+
133
+ engine.module_parent
134
+ end
135
+ end
136
+ end
137
+ end
@@ -10,6 +10,11 @@ module Plutonium
10
10
  def resource_register
11
11
  @resource_register ||= Plutonium::Resource::Register.new
12
12
  end
13
+
14
+ # Dashboards mounted on this engine with `register_dashboard`.
15
+ def dashboard_register
16
+ @dashboard_register ||= Plutonium::Dashboard::Register.new
17
+ end
13
18
  end
14
19
  end
15
20
  end
@@ -25,6 +25,7 @@ module Plutonium
25
25
  if engine
26
26
  resource_route_config_lookup.clear
27
27
  engine.resource_register.clear
28
+ engine.dashboard_register.clear
28
29
  end
29
30
 
30
31
  super
@@ -36,7 +37,7 @@ module Plutonium
36
37
  # @return [void]
37
38
  # @yield Executes the given block in the context of route drawing.
38
39
  def draw(&block)
39
- if self.class.supported_engine?(engine)
40
+ if engine && self.class.supported_engine?(engine)
40
41
  scope_params = entity_scope_params_for_path_strategy
41
42
  ActiveSupport::Notifications.instrument("plutonium.resource_routes.draw", app: engine.to_s) do
42
43
  super do