plutonium 0.62.2 → 0.63.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 (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -12,9 +12,14 @@ module Plutonium
12
12
  include Plutonium::Resource::Controllers::Defineable
13
13
  include Plutonium::Resource::Controllers::Authorizable
14
14
  include Plutonium::Resource::Controllers::Presentable
15
+ include Plutonium::Resource::Controllers::EagerLoading
15
16
  include Plutonium::Resource::Controllers::Queryable
16
17
  include Plutonium::Resource::Controllers::CrudActions
17
18
  include Plutonium::Resource::Controllers::KanbanActions
19
+ # After CrudActions on purpose: PositionActions overrides
20
+ # pagy_request_context / current_page_* so the reposition drop re-renders
21
+ # the collection for the INDEX page rather than for its own POST path.
22
+ include Plutonium::Resource::Controllers::PositionActions
18
23
  include Plutonium::Resource::Controllers::InteractiveActions
19
24
  include Plutonium::Resource::Controllers::WizardActions
20
25
  include Plutonium::Resource::Controllers::Typeahead
@@ -24,7 +29,8 @@ module Plutonium
24
29
  included do
25
30
  after_action { response.headers.merge!(@pagy.headers_hash) if @pagy }
26
31
 
27
- helper_method :current_parent, :current_nested_association, :resource_record!, :resource_record?, :resource_param_key, :resource_class
32
+ helper_method :current_parent, :current_nested_association, :resource_record!, :resource_record?, :resource_param_key, :resource_class,
33
+ :singular_resource_context?, :singular_resource_route_for?
28
34
 
29
35
  # Use class_attribute for proper inheritance
30
36
  class_attribute :_resource_class, instance_accessor: false
@@ -101,11 +107,14 @@ module Plutonium
101
107
  end
102
108
 
103
109
  def current_resource_route_config
104
- @current_resource_route_config ||= if current_parent
105
- current_engine.routes.resource_route_config_lookup["#{current_parent.class.model_name.plural}/#{current_nested_association}"]
106
- else
110
+ # The nested registration when the route says it is nested, and the
111
+ # top-level one otherwise. Previously rebuilt the nested lookup key from
112
+ # the parent's class and the association — which meant resolving the
113
+ # parent first, and reconstructing a string the router was already
114
+ # carrying.
115
+ @current_resource_route_config ||=
116
+ current_nested_route_config ||
107
117
  current_engine.routes.resource_route_config_for(resource_class.model_name.plural)[0]
108
- end
109
118
  end
110
119
 
111
120
  # Returns true if current resource is registered as a singular route
@@ -115,23 +124,22 @@ module Plutonium
115
124
  current_resource_route_config&.[](:route_type) == :resource
116
125
  end
117
126
 
118
- # Extracts the association name from the current nested route
119
- # e.g., for route /posts/:post_id/nested_comments, returns :comments
127
+ # The same question asked about another resource a breadcrumb needs to
128
+ # know whether the parent it is linking to has an index before it builds a
129
+ # collection URL for one.
130
+ # @param resource_class [Class]
131
+ # @return [Boolean]
132
+ def singular_resource_route_for?(resource_class)
133
+ current_engine.routes.singular_resource_route?(resource_class.model_name.plural)
134
+ end
135
+
136
+ # The association this request is nested through, as declared by the
137
+ # route. Previously scraped out of the request path by looking for a
138
+ # "nested_" segment, which meant stripping format extensions and could
139
+ # not survive a parent that contributes no id parameter.
120
140
  # @return [Symbol, nil] The association name
121
141
  def current_nested_association
122
- return unless parent_route_param
123
-
124
- # Extract from request path: find the nested_* segment after the parent param
125
- # e.g., /posts/123/nested_comments/456 => "comments"
126
- # Note: Strip format extension (.json, .xml, etc.) from the segment
127
- prefix = Plutonium::Routing::NESTED_ROUTE_PREFIX
128
- path_segments = request.path.split("/")
129
- nested_segment = path_segments.find { |seg| seg.start_with?(prefix) }
130
- return unless nested_segment
131
-
132
- # Remove prefix and any format extension (e.g., "nested_versions.json" -> "versions")
133
- association_name = nested_segment.delete_prefix(prefix).sub(/\.\w+\z/, "")
134
- association_name.to_sym
142
+ current_nested_route_config&.[](:association_name)
135
143
  end
136
144
 
137
145
  def resource_record!
@@ -185,9 +193,8 @@ module Plutonium
185
193
  # `:attachment`). Excluded from the extraction-record pre-assignment so a
186
194
  # single-read Rack upload isn't consumed before create/update reads it.
187
195
  def attachment_input_keys
188
- file_types = Plutonium::UI::Form::Base::Builder::FILE_INPUT_TYPES
189
196
  current_definition.defined_inputs.filter_map { |name, config|
190
- name.to_s if file_types.include?(config.dig(:options, :as)&.to_sym)
197
+ name.to_s if Plutonium::Definition::InputAliases.file_input?(config.dig(:options, :as))
191
198
  }
192
199
  end
193
200
 
@@ -237,24 +244,79 @@ module Plutonium
237
244
 
238
245
  # Returns the current parent based on path parameters
239
246
  # @return [ActiveRecord::Base, nil] The current parent
247
+ # The record this request is nested under, if any.
248
+ #
249
+ # The route says which parent it nests under (see
250
+ # Plutonium::Routing::PARENT_KEY_PARAM); all that is
251
+ # left is to load it. A plural parent narrows by the id in the path. A
252
+ # singular parent has no id to narrow by — it is whichever record the
253
+ # viewer's scope resolves to, which is what `singular: true` promises.
254
+ #
255
+ # Memoised through `defined?` so a genuine nil is remembered rather than
256
+ # re-resolved on every call.
240
257
  def current_parent
241
- return unless parent_route_param
242
-
243
- @current_parent ||= begin
244
- parent_route_key = parent_route_param.to_s.gsub(/_id$/, "").to_sym
245
- parent_class = current_engine.resource_register.route_key_lookup[parent_route_key]
246
- parent_scope = authorized_scope(parent_class.all, context: {entity_scope: entity_scope_for_authorize})
247
- parent_scope = parent_scope.from_path_param(params[parent_route_param])
248
- current_parent = parent_scope.first!
249
- authorize! current_parent, to: :read?
250
- current_parent
258
+ return @current_parent if defined?(@current_parent)
259
+
260
+ @current_parent = begin
261
+ parent_class = current_parent_class
262
+ if parent_class
263
+ scope = authorized_scope(parent_class.all, context: {entity_scope: entity_scope_for_authorize})
264
+ parent = if singular_resource_route_for?(parent_class)
265
+ # A singular parent puts no id in the path, so the scope must
266
+ # already identify exactly one record. `sole` says so: `first!`
267
+ # would quietly hand back whichever row sorted first if the scope
268
+ # ever resolved to several, and silently nesting under the wrong
269
+ # parent is far worse than failing loudly.
270
+ resolve_singular_parent(scope, parent_class)
271
+ else
272
+ # The id parameter is named by the parent's own route, so it is
273
+ # derived rather than discovered. Scanning path_parameters for
274
+ # anything ending in _id would also match an unrelated parameter
275
+ # an application happens to name that way.
276
+ scope.from_path_param(params[:"#{parent_class.model_name.singular}_id"]).first!
277
+ end
278
+ parent.tap { authorize! parent, to: :read? }
279
+ end
251
280
  end
252
281
  end
253
282
 
254
- # Returns the parent route parameter
255
- # @return [Symbol, nil] The parent route parameter
256
- def parent_route_param
257
- @parent_route_param ||= request.path_parameters.keys.reverse.find { |key| /_id$/.match? key }
283
+ # `sole`, but reporting the registration mistake instead of the symptom.
284
+ #
285
+ # `SoleRecordExceeded` says "Wanted only one User" — true, and useless to
286
+ # the person who has to fix it, because the cause is two files away: the
287
+ # resource was registered `singular: true` while its policy still scopes to
288
+ # many. Say that, and name both halves of the fix.
289
+ # @raise [Plutonium::SingularScopeError]
290
+ def resolve_singular_parent(scope, parent_class)
291
+ scope.sole
292
+ rescue ActiveRecord::SoleRecordExceeded
293
+ # Deliberately says nothing about the viewer: this runs while raising, and
294
+ # reaching for `current_user` here would swap the useful error for a
295
+ # NoMethodError in any context that has no such helper.
296
+ raise Plutonium::SingularScopeError,
297
+ "#{parent_class} is registered `singular: true`, but its authorized scope " \
298
+ "resolved to #{scope.count} records. A singular route carries no :id, so the " \
299
+ "scope has to identify exactly one record. Either give #{parent_class}'s policy " \
300
+ "a `relation_scope` that narrows to one, or register it without `singular: true`."
301
+ end
302
+
303
+ # The parent resource class, taken from the registration rather than
304
+ # re-derived from a name. Registration had the class in hand; carrying the
305
+ # name instead would mean looking it back up by a string that two
306
+ # differently-namespaced resources can share.
307
+ # @return [Class, nil]
308
+ def current_parent_class
309
+ current_nested_route_config&.[](:parent_class)
310
+ end
311
+
312
+ # The registration for the nesting this request arrived through, if any.
313
+ # @return [Hash, nil]
314
+ def current_nested_route_config
315
+ return @current_nested_route_config if defined?(@current_nested_route_config)
316
+
317
+ nested_key = request.path_parameters[Plutonium::Routing::NESTED_KEY_PARAM]
318
+ @current_nested_route_config =
319
+ nested_key && current_engine.routes.resource_route_config_lookup[nested_key]
258
320
  end
259
321
 
260
322
  # Returns the parent input parameter (the belongs_to association name on the child)
@@ -275,10 +337,28 @@ module Plutonium
275
337
  # Try inverse_of first (if explicitly set)
276
338
  return parent_assoc.inverse_of.name.to_sym if parent_assoc.inverse_of
277
339
 
278
- # Fall back to finding belongs_to by foreign key
340
+ # Fall back to finding belongs_to by foreign key.
341
+ #
342
+ # Polymorphic reflections are skipped rather than compared: asking a
343
+ # polymorphic belongs_to for its klass raises, and one sharing the
344
+ # parent's foreign key name would take the whole request down.
345
+ #
346
+ # The class is matched with is_a? rather than ==, so a parent that is an
347
+ # STI subclass still matches an association declared against its base.
279
348
  foreign_key = parent_assoc.foreign_key.to_s
280
349
  child_assoc = resource_class.reflect_on_all_associations(:belongs_to).find do |assoc|
281
- assoc.foreign_key.to_s == foreign_key && assoc.klass == current_parent.class
350
+ next false unless assoc.foreign_key.to_s == foreign_key
351
+
352
+ if assoc.polymorphic?
353
+ # A polymorphic belongs_to cannot be asked for its class — that
354
+ # raises. Its counterpart is the type column, which is exactly what
355
+ # `has_many :things, as: :owner` names on the parent side, so the
356
+ # two are matched on that instead. Assigning the parent to the
357
+ # association then sets both the id and the type.
358
+ parent_assoc.type.present? && assoc.foreign_type.to_s == parent_assoc.type.to_s
359
+ else
360
+ current_parent.is_a?(assoc.klass)
361
+ end
282
362
  end
283
363
  child_assoc&.name&.to_sym
284
364
  end
@@ -7,8 +7,38 @@ module Plutonium
7
7
 
8
8
  private
9
9
 
10
- def setup_index_action!
11
- @pagy, @resource_records = pagy(:offset, filtered_resource_collection)
10
+ # `action` names the action whose field set is about to be rendered.
11
+ # It defaults to the current one, and the reposition drop POST passes
12
+ # "index" for the same reason it does when building the collection: it
13
+ # re-renders the index on the index's behalf, and a resource must not
14
+ # have to define permitted_attributes_for_reposition to be draggable.
15
+ def setup_index_action!(action: action_name)
16
+ # Applied HERE, not inside filtered_resource_collection: what to
17
+ # preload depends on what is about to be RENDERED, and
18
+ # filtered_resource_collection is shared with the CSV export, which
19
+ # renders a different column set (`permitted_attributes_for_export`).
20
+ # Reading the index's fields in there resolved the wrong policy
21
+ # method for that action and raised. Keeping the hook to filtering and
22
+ # eager-loading at the point of use also leaves an app's own
23
+ # `filtered_resource_collection` override unaffected.
24
+ collection = auto_eager_load(filtered_resource_collection, presentable_attributes_for(action))
25
+ @pagy, @resource_records = pagy(:offset, collection, request: pagy_request_context)
26
+ end
27
+
28
+ # What Pagy builds its page URLs from. Pagy accepts either the real
29
+ # request object or a plain hash (Pagy::Request), which is the seam we
30
+ # need: an action that re-renders the collection on the index's behalf
31
+ # (the reposition drop POST) must emit page links pointing at the index
32
+ # page, not at its own POST-only path. current_page_path is that page —
33
+ # it equals request.path for a genuine index request, so the default is
34
+ # exactly today's behaviour.
35
+ def pagy_request_context
36
+ {
37
+ base_url: request.base_url,
38
+ path: current_page_path,
39
+ params: request.GET.merge(request.POST).to_h,
40
+ cookie: request.cookies["pagy"]
41
+ }
12
42
  end
13
43
 
14
44
  def filtered_resource_collection
@@ -38,7 +38,7 @@ module Plutonium
38
38
  authorize_current! resource_class
39
39
  set_page_title "Create #{resource_class.model_name.human.titleize}"
40
40
 
41
- @resource_record = resource_class.new
41
+ @resource_record = build_resource_record
42
42
  maybe_apply_submitted_resource_params!
43
43
 
44
44
  render :new, formats: [:html]
@@ -49,7 +49,7 @@ module Plutonium
49
49
  authorize_current! resource_class
50
50
  set_page_title "Create #{resource_class.model_name.human.titleize}"
51
51
 
52
- @resource_record = resource_class.new resource_params
52
+ @resource_record = build_resource_record resource_params
53
53
 
54
54
  respond_to do |format|
55
55
  if params[:pre_submit]
@@ -165,6 +165,34 @@ module Plutonium
165
165
 
166
166
  private
167
167
 
168
+ # Builds the record that :new renders a form for and :create saves.
169
+ #
170
+ # Through a nested route it is built on the parent's association rather
171
+ # than on the class, so an association carrying a scope supplies that
172
+ # scope's attributes as defaults — exactly what `parent.things.new` does
173
+ # in Rails. The list side already honours the scope (the policy merges
174
+ # the relation into that same association), so building on the class
175
+ # instead produces a record the list filters straight back out: the
176
+ # create reports success and the row never appears.
177
+ #
178
+ # Only equality conditions carry over. `where(published: true)` becomes
179
+ # an attribute; `where("expires_at > ?", Time.current)` cannot, because
180
+ # that is all Rails derives create attributes from.
181
+ #
182
+ # The foreign key is still injected as an attribute by
183
+ # override_parent_params, which stays the authority for the value. This
184
+ # only adds what the association declares beyond it.
185
+ def build_resource_record(attributes = {})
186
+ return resource_class.new(attributes) unless current_parent
187
+
188
+ association = current_parent.class.reflect_on_association(current_nested_association)
189
+ if association.collection?
190
+ current_parent.public_send(current_nested_association).new(attributes)
191
+ else
192
+ current_parent.public_send(:"build_#{current_nested_association}", attributes)
193
+ end
194
+ end
195
+
168
196
  # Hook fired once, immediately after a successful create-save and BEFORE
169
197
  # the response is built. No-op by default. KanbanActions overrides it to
170
198
  # apply a kanban quick-add column's on_enter + positioning to the freshly
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Resource
5
+ module Controllers
6
+ # Preloads the associations and attachments a collection is about to
7
+ # render, without being told which.
8
+ #
9
+ # Plutonium can do this where a general-purpose Rails app cannot. The
10
+ # rendered field set is DECLARED — resolved from the policy — rather than
11
+ # discovered by running a template, and resolving it never touches the
12
+ # collection. So the exact set of associations a page will read is known
13
+ # before the query is built. There is no `includes` list to write, and none
14
+ # to keep in step with the definition as fields come and go.
15
+ #
16
+ # Every collection rendering passes its OWN field set, because they differ:
17
+ # the index renders `presentable_attributes`, the CSV export renders
18
+ # `permitted_attributes_for_export`, and a kanban card renders its
19
+ # `card_fields` (falling back to the grid fields). Reading one action's
20
+ # fields from another's code path resolves the wrong policy method.
21
+ module EagerLoading
22
+ extend ActiveSupport::Concern
23
+
24
+ private
25
+
26
+ # Whether this controller preloads what it is about to render.
27
+ # Override to opt a single resource out (or in, against a global off).
28
+ # @return [Boolean]
29
+ def auto_eager_load_collections?
30
+ Plutonium.configuration.auto_eager_load_collections
31
+ end
32
+
33
+ # @param collection [ActiveRecord::Relation]
34
+ # @param fields [Array<Symbol>] the field set about to be rendered
35
+ # @return [ActiveRecord::Relation]
36
+ def auto_eager_load(collection, fields)
37
+ return collection unless auto_eager_load_collections?
38
+
39
+ fields = Array(fields).map(&:to_sym)
40
+
41
+ associations = fields & eager_loadable_associations
42
+ collection = collection.includes(*associations) if associations.any?
43
+
44
+ # Attachments are not associations under their declared name: for BOTH
45
+ # backends `reflect_on_association(:file)` is nil, and the reflections
46
+ # that exist are `file_attachment`/`file_blob`. `with_attached_*` is the
47
+ # supported preload and ActiveStorage and active_shrine both expose it,
48
+ # so one path serves both.
49
+ (fields & eager_loadable_attachments).each do |name|
50
+ collection = collection.public_send(:"with_attached_#{name}")
51
+ end
52
+
53
+ collection
54
+ end
55
+
56
+ # Every association kind, `has_many` included.
57
+ #
58
+ # `has_many` is preloaded because of what Plutonium actually renders: an
59
+ # association field renders the associated records' LABELS, not a count.
60
+ # The rows are read either way, so preloading only decides whether that
61
+ # costs one query or one per parent. (Excluding it on the usual "a count
62
+ # beats loading every child row" reasoning is an argument about a
63
+ # rendering this framework doesn't do.)
64
+ #
65
+ # Both lists are memoised on the model (outside dev) by
66
+ # Resource::Record::FieldNames. Its association lists already strip the
67
+ # `_attachment`/`_blob` reflections backing an attachment, so those cannot
68
+ # be loaded twice by the two branches above. Its attachment lists go
69
+ # through `reflect_on_all_attachments`, which — unlike
70
+ # `attachment_reflections`, empty under active_shrine — reports both
71
+ # backends.
72
+ def eager_loadable_associations
73
+ @eager_loadable_associations ||=
74
+ resource_class.belongs_to_association_field_names +
75
+ resource_class.has_one_association_field_names +
76
+ resource_class.has_many_association_field_names
77
+ end
78
+
79
+ def eager_loadable_attachments
80
+ @eager_loadable_attachments ||=
81
+ resource_class.has_one_attached_field_names +
82
+ resource_class.has_many_attached_field_names
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -34,6 +34,9 @@ module Plutonium
34
34
  module ExportCsv
35
35
  extend ActiveSupport::Concern
36
36
 
37
+ # Collections rendered here preload what they are about to show.
38
+ include Plutonium::Resource::Controllers::EagerLoading
39
+
37
40
  # Placeholder written when a column is neither an `export` block nor a
38
41
  # real attribute on the record, so the export degrades to a usable file
39
42
  # instead of a mid-stream NoMethodError (which would truncate the
@@ -84,7 +87,13 @@ module Plutonium
84
87
  # object entirely (no scope/filter/search/default-scope).
85
88
  # Both still respect tenant/parent scoping (current_authorized_scope).
86
89
  def export_csv_collection
87
- export_all_requested? ? current_authorized_scope : filtered_resource_collection
90
+ scope = export_all_requested? ? current_authorized_scope : filtered_resource_collection
91
+ # Preloaded against the EXPORT's own column set, which is
92
+ # `permitted_attributes_for_export` and need not match the index's.
93
+ # This is the worst N+1 of the three collection renderings: an export
94
+ # streams every matching row, not a page of them, so a per-row
95
+ # association read is unbounded.
96
+ auto_eager_load(scope, exportable_attributes)
88
97
  end
89
98
 
90
99
  def export_all_requested?
@@ -32,6 +32,9 @@ module Plutonium
32
32
  module KanbanActions
33
33
  extend ActiveSupport::Concern
34
34
 
35
+ # Collections rendered here preload what they are about to show.
36
+ include Plutonium::Resource::Controllers::EagerLoading
37
+
35
38
  # Tags board-bound redirects with kanban_reload=1 so the permanent board
36
39
  # refreshes its cached column frames on arrival (see #kanban_reload_url).
37
40
  # Wraps ALL three redirect helpers — create/update (after_submit), destroy
@@ -133,7 +136,7 @@ module Plutonium
133
136
  # neighbor computation and WIP count are correct in all cases
134
137
  # (cross-column, same-column reorder, record already in destination).
135
138
  dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, to.scope)
136
- dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
139
+ dest_cards = board.position_config_for(current_definition).order(dest_scoped).where.not(id: record.id).to_a
137
140
  # to_index is client-supplied. Clamp to [0, dest_cards.size] so a negative
138
141
  # value can't wrap via Ruby's negative array indexing (dest_cards[-1] would
139
142
  # silently anchor the drop to the LAST card) and an over-large value simply
@@ -197,7 +200,27 @@ module Plutonium
197
200
  @resource_record = record
198
201
  end
199
202
 
200
- ActiveRecord::Base.transaction do
203
+ # Opened on the RECORD'S OWN connection, not ActiveRecord::Base's. A
204
+ # transaction lives on the receiver's connection pool, and every write
205
+ # this block owns — the on_exit/on_enter save!, reposition!'s update!
206
+ # (and any rebalance it triggers), the final save! — goes out on
207
+ # `record`'s pool. For a resource on a secondary database
208
+ # ActiveRecord::Base.transaction would BEGIN on primary and leave all of
209
+ # those autocommitting unprotected: a failure mid-move could persist an
210
+ # on_enter column change with no reposition, or a half-applied
211
+ # rebalance. Do not "simplify" this back to ActiveRecord::Base.
212
+ #
213
+ # LIMIT — this is a single-connection guarantee, and the drop
214
+ # interaction (step 2) plus user-supplied on_exit/on_enter blocks may
215
+ # write models the framework knows nothing about. Those are covered iff
216
+ # they sit on the SAME connection as `record` (the overwhelmingly common
217
+ # case: one database, or the same secondary as the resource). Writes to
218
+ # a model on a DIFFERENT database are NOT rolled back by this block, and
219
+ # no choice of receiver can fix that — Rails has no two-phase commit
220
+ # across pools, and nesting a second transaction would only shrink the
221
+ # window, not close it. record.class is the best available receiver
222
+ # because it is the one whose writes this action definitely owns.
223
+ record.class.transaction do
201
224
  # (1) Apply on_exit (SOURCE column) then on_enter (DESTINATION column),
202
225
  # CROSS-column moves only. A same-column reorder skips both (see
203
226
  # cross_column above) and only repositions; on_exit/on_enter represent
@@ -256,7 +279,7 @@ module Plutonium
256
279
  # Mode A delegates to record.reposition! (calls update! for position).
257
280
  # Mode B calls the user-supplied block.
258
281
  # Mode C is a no-op (no ordering; position unchanged).
259
- board.position_config.reposition!(
282
+ board.position_config_for(current_definition).reposition!(
260
283
  record:,
261
284
  column: to.key,
262
285
  prev_record:,
@@ -320,7 +343,7 @@ module Plutonium
320
343
  if run_enter_interaction
321
344
  streams += [turbo_stream.update(Plutonium::REMOTE_MODAL_FRAME, "")]
322
345
  outcome.messages.each do |msg, type|
323
- streams += [turbo_stream.append("kanban-flash", partial: "plutonium/toast",
346
+ streams += [turbo_stream.append(Plutonium::FLASH_REGION, partial: "plutonium/toast",
324
347
  locals: {type: ((type == :notice) ? :success : type), msg:})]
325
348
  end
326
349
  end
@@ -519,10 +542,26 @@ module Plutonium
519
542
  .extract_input(params, view_context:)[:q]
520
543
 
521
544
  base_query = current_authorized_scope
522
- current_query_object.apply(base_query, query_params, context: self)
545
+ collection = current_query_object.apply(base_query, query_params, context: self)
546
+ # Preloaded here rather than at each `.to_a`, because every card query
547
+ # on the board derives from this relation — the column body, the
548
+ # lazy-loaded frames, the post-move re-render. The sites that `pluck`
549
+ # or `count` off it instantiate no records, so the preload does not
550
+ # run for them.
551
+ auto_eager_load(collection, kanban_card_field_names)
523
552
  end
524
553
  end
525
554
 
555
+ # The fields a card actually renders. A board's `card_fields` is a slot
556
+ # layout — each slot holds one field name, several, or `false` to hide the
557
+ # slot — so it is flattened to names. A board that declares no layout falls
558
+ # back to the read attribute set, the same one the column component is
559
+ # handed as `resource_fields`.
560
+ def kanban_card_field_names
561
+ slots = current_kanban_board.card_fields
562
+ slots ? slots.values.flatten.grep(Symbol) : permitted_attributes_for("index")
563
+ end
564
+
526
565
  # Intercepts the index action when view=kanban + column= is present.
527
566
  # Renders only the turbo-frame body for the requested column and halts.
528
567
  def maybe_render_kanban_column
@@ -535,9 +574,9 @@ module Plutonium
535
574
  board = current_kanban_board
536
575
 
537
576
  # Resolve only the requested column rather than grouping the whole
538
- # board: Grouping.call would scope+count+limit every column (~2 queries
539
- # each) on every lazy frame request. We compare keys as strings to
540
- # avoid interning arbitrary request input into symbols.
577
+ # board: grouping every column would scope+count+limit all of them
578
+ # (~2 queries each) on every lazy frame request. We compare keys as
579
+ # strings to avoid interning arbitrary request input into symbols.
541
580
  columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
542
581
  column = columns.find { |c| c.key.to_s == params[:column] }
543
582
 
@@ -578,7 +617,7 @@ module Plutonium
578
617
  return "".html_safe unless column
579
618
 
580
619
  scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, column.scope)
581
- ordered = board.position_config.order(scoped)
620
+ ordered = board.position_config_for(current_definition).order(scoped)
582
621
 
583
622
  if board.per_column
584
623
  total = ordered.count
@@ -729,7 +768,7 @@ module Plutonium
729
768
  case on.to_sym
730
769
  when :visible
731
770
  board = current_kanban_board
732
- ordered = board.position_config.order(scoped)
771
+ ordered = board.position_config_for(current_definition).order(scoped)
733
772
  limited = board.per_column ? ordered.limit(board.per_column) : ordered
734
773
  limited.pluck(resource_class.primary_key)
735
774
  else # :all and any unknown value
@@ -780,8 +819,8 @@ module Plutonium
780
819
  # nil), via the board's position_config — the same path a drag-drop uses.
781
820
  board = current_kanban_board
782
821
  dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, column.scope)
783
- dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
784
- board.position_config.reposition!(
822
+ dest_cards = board.position_config_for(current_definition).order(dest_scoped).where.not(id: record.id).to_a
823
+ board.position_config_for(current_definition).reposition!(
785
824
  record:,
786
825
  column: column.key,
787
826
  prev_record: dest_cards.last,
@@ -795,7 +834,7 @@ module Plutonium
795
834
  # unchanged, allowing the Stimulus drag controller to snap the card back.
796
835
  #
797
836
  # When a reason is given, a single dismissable toast is appended to the
798
- # board's #kanban-flash region so the snap-back is explained rather than
837
+ # page's shared toast region so the snap-back is explained rather than
799
838
  # silent. It renders the shared _toast partial directly (not via flash)
800
839
  # so a stale, undisplayed flash from an earlier request can't leak into
801
840
  # the turbo_stream response — these move POSTs never render the layout
@@ -810,7 +849,7 @@ module Plutonium
810
849
 
811
850
  if reason
812
851
  streams << turbo_stream.append(
813
- "kanban-flash",
852
+ Plutonium::FLASH_REGION,
814
853
  partial: "plutonium/toast",
815
854
  locals: {type: :warning, msg: reason}
816
855
  )