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
@@ -11,7 +11,7 @@ A button appears in the right place (show page / table row / index header / bulk
11
11
  | Flavor | Use for |
12
12
  |---|---|
13
13
  | **Simple action** — navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
14
- | **Interactive action** — run an interaction class | Anything with business logic (the common case) |
14
+ | **Interactive action** — run an interaction class | Anything that *does* something (the common case) |
15
15
 
16
16
  Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects — all for free.
17
17
 
@@ -20,7 +20,16 @@ Prefer interactive actions. They handle authorization, form rendering, modal chr
20
20
  ### 1. Write the interaction
21
21
 
22
22
  ```ruby
23
- # app/interactions/publish_post_interaction.rb
23
+ # app/models/post.rb — what publishing actually means
24
+ class Post < ApplicationRecord
25
+ def publish!(on: Time.current)
26
+ update!(published: true, published_at: on)
27
+ end
28
+ end
29
+ ```
30
+
31
+ ```ruby
32
+ # app/interactions/publish_post_interaction.rb — the button in front of it
24
33
  class PublishPostInteraction < ResourceInteraction
25
34
  presents label: "Publish",
26
35
  icon: Phlex::TablerIcons::Send,
@@ -29,7 +38,7 @@ class PublishPostInteraction < ResourceInteraction
29
38
  attribute :resource
30
39
 
31
40
  def execute
32
- resource.update!(published: true, published_at: Time.current)
41
+ resource.publish!
33
42
  succeed(resource).with_message("Post published!")
34
43
  rescue ActiveRecord::RecordInvalid => e
35
44
  failed(e.record.errors)
@@ -41,6 +50,10 @@ end
41
50
  Plutonium doesn't rescue it automatically. Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
42
51
  :::
43
52
 
53
+ ::: tip Why `publish!` is on the model
54
+ An interaction can only be built with a `view_context` — it's a presentation object. A two-line `update!` inline in `execute` is fine while the button is the only caller; the moment a scheduled-publishing job wants the same behaviour it has to duplicate it or fake a view context. Full rule: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
55
+ :::
56
+
44
57
  ### 2. Register it in the definition
45
58
 
46
59
  ```ruby
@@ -106,7 +119,7 @@ class Company::InviteUserInteraction < ResourceInteraction
106
119
  validates :role, presence: true
107
120
 
108
121
  def execute
109
- UserInvite.create!(company: resource, email: email, role: role)
122
+ resource.invite!(email: email, role: role, by: current_user)
110
123
  succeed(resource).with_message("Invitation sent to #{email}.")
111
124
  rescue ActiveRecord::RecordInvalid => e
112
125
  failed(e.record.errors)
@@ -114,6 +127,8 @@ class Company::InviteUserInteraction < ResourceInteraction
114
127
  end
115
128
  ```
116
129
 
130
+ `Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight — see the [full worked example](/reference/behavior/interactions#complete-example).
131
+
117
132
  ## Bulk actions
118
133
 
119
134
  Plural `attribute :resources` automatically becomes a bulk action. The table gets checkboxes and a bulk-actions toolbar.
@@ -146,6 +161,36 @@ Two related behaviors:
146
161
 
147
162
  ![Bulk action toolbar with selected drafts](/images/guides/custom-actions-bulk.png)
148
163
 
164
+ ## When the work is too slow for a request
165
+
166
+ The bulk action above updates every selected record inside the request. That is fine for a screenful; it is not fine for a few thousand, or for anything that calls a slow third party. Swap `execute` for `async`, and the same interaction dispatches the work to a background run instead:
167
+
168
+ ```ruby
169
+ class BulkArchiveInteraction < ResourceInteraction
170
+ presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
171
+
172
+ attribute :resources
173
+ attribute :reason, :string
174
+
175
+ async do
176
+ on_failure :continue # :halt (default) | :continue | :transactional
177
+ def perform_on(record)
178
+ record.archive!(reason: options["reason"])
179
+ end
180
+ end
181
+ end
182
+ ```
183
+
184
+ Nothing else about the action changes — the definition, the policy method and the form are the same. Only the work moves.
185
+
186
+ Three things worth knowing:
187
+
188
+ - **The block is the run's class body, not `execute`.** The work runs later, in a job with no controller, so it cannot close over anything in the interaction. Its inputs arrive through `options`.
189
+ - **The user is sent back where they were**, and the index they land on shows a banner for the run with a link to its progress page.
190
+ - **Permissions are re-checked per record, at perform time** — not replayed from dispatch. A permission revoked while the run is working stops applying to the rest of it.
191
+
192
+ Full detail, including file attributes, failure policies and resuming a crashed run: [Async Interactions](/reference/behavior/async-interactions).
193
+
149
194
  ## Resource action (no specific record)
150
195
 
151
196
  Neither `:resource` nor `:resources` → resource action on the index page:
@@ -187,7 +232,11 @@ action :name,
187
232
  # Behavior
188
233
  confirmation: "Are you sure?",
189
234
  modal: :slideover, # :slideover / :centered — overrides definition's modal mode
190
- size: :lg # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
235
+ size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
236
+
237
+ # HTML attributes — author wins over the framework's on every key
238
+ link: {target: "_blank", rel: "noopener"}, # every <a> rendering (toolbar GET link, dropdown items, bulk links, card show link)
239
+ button: {data: {analytics: "archive"}} # the button_to <form> wrapper (non-GET toolbar rendering)
191
240
  ```
192
241
 
193
242
  Full options: [Reference › Resource › Actions › Action options](/reference/resource/actions#action-options).
@@ -201,7 +250,8 @@ action :documentation,
201
250
  label: "Docs",
202
251
  route_options: {url: "https://docs.example.com"},
203
252
  icon: Phlex::TablerIcons::Book,
204
- resource_action: true
253
+ resource_action: true,
254
+ link: {target: "_blank", rel: "noopener noreferrer"} # open in a new tab
205
255
 
206
256
  action :reports,
207
257
  route_options: {action: :reports}, # links to PostsController#reports
@@ -231,18 +281,28 @@ end
231
281
 
232
282
  Every resource gets `:archive` automatically.
233
283
 
234
- ## Chaining interactions
284
+ ## Where the logic goes
285
+
286
+ Interactions are presentation objects — they need a `view_context` to exist at all. So the reflex to reach for when an operation grows:
235
287
 
236
288
  ```ruby
289
+ # 🚫 Three interactions, three view contexts, none of it callable from a job
290
+ CreateUserInteraction.call(view_context:, **user_params)
291
+ .and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
292
+ .and_then { |user| LogActivity.call(view_context:, user:) }
293
+ ```
294
+
295
+ ```ruby
296
+ # ✅ One model method; the interaction just presents it
237
297
  def execute
238
- CreateUserInteraction.call(view_context:, **user_params)
239
- .and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
240
- .and_then { |r| LogActivity.call(view_context:, user: r.value) }
241
- .with_message("User created and welcomed!")
298
+ user = User.register!(**attributes) # welcome email + audit row live in here
299
+ succeed(user).with_message("Welcome aboard!")
242
300
  end
243
301
  ```
244
302
 
245
- The chain short-circuits on the first failure.
303
+ Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does — and neither has a view context to hand.
304
+
305
+ The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger** — and the destination is the model, Rails-style, not a new service layer. Chaining three interactions is usually the tell that you already crossed it. Full explanation: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
246
306
 
247
307
  ## Common issues
248
308
 
@@ -250,10 +310,12 @@ The chain short-circuits on the first failure.
250
310
  - **`ActiveRecord::RecordInvalid` crashes the action** — not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
251
311
  - **Bulk action fails on some records** — that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
252
312
  - **Confirmation prompt shows when you don't want one** — pass `confirmation: false` on the action.
313
+ - **The action times out on a large selection** — the work is running inside the request. Move it to a background run with `async`, above.
253
314
 
254
315
  ## Related
255
316
 
256
317
  - [Reference › Resource › Actions](/reference/resource/actions) — full action options and bulk patterns
257
318
  - [Reference › Behavior › Interactions](/reference/behavior/interactions) — interaction class anatomy
319
+ - [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions) — `async`, progress pages, resuming a crashed run
258
320
  - [Reference › Behavior › Policies](/reference/behavior/policies) — `def <action>?` methods
259
321
  - [Authorization](./authorization) — policy patterns
@@ -132,12 +132,19 @@ class PostCardComponent < Plutonium::UI::Component::Base
132
132
  end
133
133
  ```
134
134
 
135
- Use it directly in a page, or wire it as a field in the definition:
135
+ Use it directly in a page, or wire it as a field in the definition. A component
136
+ with its own constructor takes the block form — you build it, so you decide what
137
+ it receives:
136
138
 
137
139
  ```ruby
138
- display :card, as: PostCardComponent
140
+ display :card do |field|
141
+ PostCardComponent.new(post: field.object)
142
+ end
139
143
  ```
140
144
 
145
+ `as: SomeComponent` is for a *field* component, which Plutonium constructs with
146
+ the field builder (`Phlexi::Form::Components::Base` / `Phlexi::Display::Components::Base`) — see the reference below.
147
+
141
148
  → See [Reference › UI › Components](/reference/ui/components).
142
149
 
143
150
  ## Phlexi themes (recolor without rewriting)
data/docs/guides/index.md CHANGED
@@ -34,6 +34,7 @@ aside: false
34
34
  ]},
35
35
  { group: 'Quality', items: [
36
36
  { name: 'Testing', link: '/plutonium-core/guides/testing' },
37
+ { name: 'Performance', desc: 'N+1 queries and eager loading.', link: '/plutonium-core/guides/performance' },
37
38
  { name: 'Troubleshooting', link: '/plutonium-core/guides/troubleshooting' },
38
39
  ]},
39
40
  ]"
@@ -44,7 +44,7 @@ end
44
44
 
45
45
  ```ruby
46
46
  class Task < ApplicationRecord
47
- include Plutonium::Positioning
47
+ include Plutonium::Positioning::Model
48
48
 
49
49
  positioned_on :position, scope: :status
50
50
  # ^^ auto-assigns position on create; reposition! scopes to the same status
@@ -390,15 +390,17 @@ There is no card "snap-back" to worry about on cancel — native drag never move
390
390
 
391
391
  By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
392
392
 
393
- 1. A `decimal` database column (precision 10, scale 6 recommended).
394
- 2. `include Plutonium::Positioning` in the model.
393
+ 1. A `decimal` database column — use the `t.position` helper (`precision: 16, scale: 8`). Hand-rolling it, keep `scale` at **8 or more**: `scale: 6` exactly matches the `1e-6` rebalance threshold and the last subdivision can round into a neighbour.
394
+ 2. `include Plutonium::Positioning::Model` in the model.
395
395
  3. `positioned_on :position, scope: :status` — the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
396
396
 
397
397
  ### Position modes
398
398
 
399
+ `position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter — the board resolves this lazily.
400
+
399
401
  ```ruby
400
402
  kanban do
401
- # Mode A (default) — delegate to Plutonium::Positioning.
403
+ # Mode A (default) — delegate to Plutonium::Positioning::Model.
402
404
  # Uses :position attribute, requires the model concern.
403
405
  position_on :position
404
406
 
@@ -422,7 +424,7 @@ kanban do
422
424
  end
423
425
  ```
424
426
 
425
- See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted.
427
+ See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted, and [Positioning & drag-to-reorder](/reference/positioning) for the table/grid side of the same feature.
426
428
 
427
429
  ---
428
430
 
@@ -67,11 +67,21 @@ Plutonium prefixes nested routes with `nested_` so they don't conflict with top-
67
67
 
68
68
  `has_one` associations get singular routes — index redirects to show (or new if no record exists).
69
69
 
70
+ Every routable association gets one by default. To draw only some of them:
71
+
72
+ ```ruby
73
+ register_resource ::Company, associations: %i[properties company_profile]
74
+ ```
75
+
76
+ Set `config.nested_association_routes = :declared` to make that the rule, so a
77
+ resource naming none gets none. See [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources#declaring-which-associations-get-routes).
78
+
70
79
  ## What Plutonium does automatically
71
80
 
72
81
  1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
73
82
  2. **Scopes queries** via the parent association (`company.properties` for `has_many`; `where(company_id: ...)` for `has_one`).
74
- 3. **Assigns the parent** on create (injected into `resource_params`).
83
+ 3. **Assigns the parent** on create (injected into `resource_params`), building the
84
+ record on the parent's association so a scoped association supplies its defaults.
75
85
  4. **Hides the parent field** in forms and displays.
76
86
 
77
87
  No hidden fields. No manual scoping.
@@ -0,0 +1,104 @@
1
+ # Performance: N+1 Queries
2
+
3
+ Plutonium renders association values for you: a `display :author`, a table `column :organization`, a grid or kanban card showing a related name. Reading an association off a record that wasn't loaded with it costs a query, so an index page runs one query for the page plus one per row per association.
4
+
5
+ ## Spotting it
6
+
7
+ Tail the log and reload an index. A run of near-identical `SELECT * FROM users WHERE id = ?` lines, one per row, is the signature.
8
+
9
+ ```bash
10
+ tail -f log/development.log
11
+ ```
12
+
13
+ [`bullet`](https://github.com/flyerhzm/bullet) reports them in development if you'd rather be told than look.
14
+
15
+ The cost matters most where each query is a network round trip. On Postgres or MySQL an index page can spend most of its time waiting; on SQLite the same page is cheaper, though the query count is identical. Either way the count grows with page size, so a listing that is fine at 20 rows may not be at 200.
16
+
17
+ ## Collections preload themselves
18
+
19
+ Index pages, kanban boards and CSV exports already eager-load the associations and attachments they render. The field set comes from the policy, so the framework knows it before the collection loads and can preload exactly those. Nothing to declare, and nothing to keep in step when a field is added or removed.
20
+
21
+ Each rendering passes its own field set, because they differ: the index renders its permitted attributes, an export renders `permitted_attributes_for_export`, and a kanban card renders its `card_fields`.
22
+
23
+ It covers every association kind — `belongs_to`, `has_one`, `has_many` — and attachments on both ActiveStorage and Shrine.
24
+
25
+ Turn it off globally:
26
+
27
+ ```ruby
28
+ # config/initializers/plutonium.rb
29
+ Plutonium.configure do |config|
30
+ config.auto_eager_load_collections = false
31
+ end
32
+ ```
33
+
34
+ Or per resource:
35
+
36
+ ```ruby
37
+ class PostsController < ::ResourceController
38
+ private
39
+
40
+ def auto_eager_load_collections? = false
41
+ end
42
+ ```
43
+
44
+ ## Eager loading by hand
45
+
46
+ Anything the framework can't see still needs declaring: an association read inside a custom column block, or one rendered on a show page.
47
+
48
+ ### One listing
49
+
50
+ Override `filtered_resource_collection`. `super` keeps authorization scoping, search, filters, scopes and sorting:
51
+
52
+ ```ruby
53
+ class PostsController < ::ResourceController
54
+ private
55
+
56
+ def filtered_resource_collection = super.includes(:author, :category)
57
+ end
58
+ ```
59
+
60
+ See [Behavior › Controllers](/reference/behavior/controllers#index-query-hook).
61
+
62
+ ### Everywhere
63
+
64
+ When an association is read on a show page, an export or a typeahead, put it in the policy's `relation_scope`:
65
+
66
+ ```ruby
67
+ class PostPolicy < ResourcePolicy
68
+ relation_scope do |relation|
69
+ default_relation_scope(relation).includes(:author, :category)
70
+ end
71
+ end
72
+ ```
73
+
74
+ A custom `relation_scope` must still call `default_relation_scope`, or scoping is dropped. See [Behavior › Policies](/reference/behavior/policies).
75
+
76
+ ## Automatic eager loading
77
+
78
+ For the cases above that the framework can't resolve for you, [Goldiloader](https://github.com/salsify/goldiloader) removes the bookkeeping. It hooks association traversal: reading `post.author` on a record from a collection loads that association for the whole collection in one query. No `includes` anywhere.
79
+
80
+ ```ruby
81
+ # Gemfile
82
+ gem "goldiloader"
83
+ ```
84
+
85
+ It works without per-model configuration. Before adding it:
86
+
87
+ - It assumes uniform access. Reading an association on one record loads it for every record, which over-fetches when only one row needed it.
88
+ - `has_one` with an order and a limit is a sharp edge: eager loading applies `LIMIT 1` to the whole query rather than per parent.
89
+ - It disables itself for associations declared with `limit`, `offset` or `finder_sql`.
90
+ - Opt out per query (`Post.all.auto_include(false)`), per association (`has_many :comments, -> { auto_include(false) }`) or globally (`Goldiloader.globally_enabled = false`).
91
+
92
+ [`ar_lazy_preload`](https://github.com/DmitryTsepelev/ar_lazy_preload) takes the same idea from the other end, preloading lazily on first access, with `ArLazyPreload.config.auto_preload = true` for automatic behaviour everywhere. Its docs warn that enabling it on an existing app can surface edge cases.
93
+
94
+ ## Work that does not belong in a request at all
95
+
96
+ Eager loading fixes a page whose queries are wasteful. It does nothing for one that is simply doing too much work: archiving thousands of records, building a report, waiting on a third party.
97
+
98
+ Those need to leave the request rather than be optimised inside it. An interaction declares `async` and dispatches a background run, and the user gets a progress page instead of a spinner they cannot leave: [Async Interactions](/reference/behavior/async-interactions).
99
+
100
+ ## Related
101
+
102
+ - **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables — see [Resource › Query](/reference/resource/query#search).
103
+ - **Page size.** Query cost scales with rows per page.
104
+ - [Async Interactions](/reference/behavior/async-interactions) — moving slow work out of the request
@@ -73,7 +73,7 @@ configure do
73
73
  login_redirect "/welcome"
74
74
 
75
75
  after_login do
76
- session[:after_welcome_redirect] = session.delete(:login_redirect)
76
+ session[:after_welcome_redirect] = session.delete(login_redirect_session_key)
77
77
  end
78
78
  end
79
79
  ```
@@ -79,6 +79,10 @@ end
79
79
  The engine detects failure by a **raised exception**. Non-bang `create`/`save`/`update` return `false` on failure without raising — the engine can't see that, treats the step as successful, and advances, silently losing the data. Always use `create!`/`update!`/`save!`, or call `fail!("message")`.
80
80
  :::
81
81
 
82
+ ::: tip `execute` is a presentation boundary, same as an interaction's
83
+ A wizard is built with `view_context:` too, so everything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow* — which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it: the API signup that skips onboarding, the admin backfill, the importer. `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. See [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for) — the rule is identical.
84
+ :::
85
+
82
86
  Each step renders as a focused card with a numbered stepper rail (the terminal `review` shows a finish flag, not a number) and a Back / Next / Cancel strip:
83
87
 
84
88
  ![A wizard step page — numbered stepper rail, a focused step card with typed inputs, and Back/Next/Cancel navigation](/images/guides/wizards-step.png)
@@ -281,6 +285,7 @@ end
281
285
  - `fail!("msg")` aborts the step with a base (form-level) error; `fail!(:field, "msg")` attaches it to a field. Both roll back the step's transaction and re-render with input intact.
282
286
  - The engine **always** destroys every `persist`'d record on rollback (Cancel, abandonment-sweep, branch-prune), in reverse order, via `destroy!` (which respects a model's own soft-delete override). `on_rollback` is an **optional, additive** compensating block for side effects the engine can't see (refund a charge, call an external API), and runs **before** the destroy, so `persisted[:key]` is still alive inside it. Don't destroy the tracked record yourself; the engine does.
283
287
  - Because `on_submit` writes mid-flow, it isn't atomic across steps — that's why `cleanup_after` + the SweepJob exist. See [Storage & config](/reference/wizard/storage-config) and the [DSL reference](/reference/wizard/dsl#per-step-hooks).
288
+ - `on_submit` / `on_rollback` are **wizard-flow hooks**, not a home for domain logic. They belong to a single wizard step and can't be called from anywhere else, so keep them to *when* and *what gets tracked* — `persist Billing.create!(...)` above is one call to a model. Once "authorize a card and record the billing row" is something the API also does, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
284
289
 
285
290
  ## Anchored wizards
286
291
 
@@ -325,7 +330,10 @@ class WelcomeWizard < Plutonium::Wizard::Base
325
330
  review label: "All set?"
326
331
 
327
332
  def execute
328
- current_user.update!(full_name: data.profile.full_name, onboarded_at: Time.current)
333
+ # User#complete_onboarding! sets the name and stamps onboarded_at. It lives on
334
+ # the model because the gate below, an admin backfill, and the invite-accept
335
+ # flow all need to mark a user onboarded — and none of them is a wizard.
336
+ current_user.complete_onboarding!(full_name: data.profile.full_name)
329
337
  succeed.with_message("Welcome aboard!")
330
338
  end
331
339
 
data/docs/index.md CHANGED
@@ -8,11 +8,11 @@ aside: false
8
8
 
9
9
  <HomeStopWriting />
10
10
 
11
- <HomePillars />
12
-
13
11
  <HomeWalkthrough />
14
12
 
15
- <HomeAudienceSplit />
13
+ <HomeFeatureTour />
14
+
15
+ <HomeWhyPlutonium />
16
16
 
17
17
  <HomeInTheBox />
18
18
 
@@ -0,0 +1,34 @@
1
+ # Plutonium's experimental subsystems: wizards and async interactions.
2
+ #
3
+ # Both are off by default — their flags gate their MIGRATIONS as well as their
4
+ # behaviour, so an app that has not opted in has neither table. This turns them
5
+ # on and schedules the recurring jobs each one needs to stay healthy.
6
+ #
7
+ # Kept out of plutonium.rb because it is the baseline install, and these two are
8
+ # marked experimental: their DSL and behaviour may change in a future release.
9
+ # Opting a new app in is a choice, not a default.
10
+ after_bundle do
11
+ # SweepJob is the only thing that cleans up the partial domain records an
12
+ # abandoned `on_submit` wizard leaves behind. Unscheduled, those accumulate.
13
+ unless ENV["SKIP_WIZARDS"]
14
+ generate "pu:wizards:install"
15
+ git add: "."
16
+ git commit: %( -m 'chore: enable wizards') if `git status --porcelain`.present?
17
+ end
18
+
19
+ # --skip-portal: a fresh app has no portals yet, only main_app — the wrong home
20
+ # for the run resource in an app about to grow them. This turns the subsystem
21
+ # on; `rails g pu:async_interactions:install --dest=<portal>` connects the
22
+ # progress page and running banner once there is a portal worth naming.
23
+ unless ENV["SKIP_ASYNC_INTERACTIONS"]
24
+ generate "pu:async_interactions:install --skip-portal"
25
+ git add: "."
26
+ git commit: %( -m 'chore: enable async interactions') if `git status --porcelain`.present?
27
+ end
28
+
29
+ # Both gate their migration paths on the flags just written, and this process
30
+ # booted before either existed.
31
+ rails_command "db:migrate"
32
+ git add: "."
33
+ git commit: %( -m 'chore: migrate wizard + async run tables') if `git status --porcelain`.present?
34
+ end
@@ -14,4 +14,18 @@ after_bundle do
14
14
  "https://radioactive-labs.github.io/plutonium-core/templates/lite.rb"
15
15
  end
16
16
  rails_command "app:template LOCATION=#{lite_location}"
17
+
18
+ # Wizards + async interactions, last.
19
+ #
20
+ # After lite rather than straight after plutonium, because both of these
21
+ # schedule a recurring job and solid_queue — which lite installs — is what
22
+ # there is to schedule into. Run earlier and each would print "schedule it
23
+ # yourself", leaving the app with a subsystem nothing maintains and no second
24
+ # pass to fix it.
25
+ experimental_location = if ENV["LOCAL"]
26
+ "/Users/stefan/Documents/plutonium/plutonium-core/docs/public/templates/experimental.rb"
27
+ else
28
+ "https://radioactive-labs.github.io/plutonium-core/templates/experimental.rb"
29
+ end
30
+ rails_command "app:template LOCATION=#{experimental_location}"
17
31
  end
@@ -128,6 +128,16 @@ For each call, Plutonium auto-generates:
128
128
 
129
129
  You list every resource the portal exposes. If a resource isn't registered, it has no URLs in that portal — `resource_url_for` will fail.
130
130
 
131
+ ### Choosing nested associations
132
+
133
+ Name the associations that get nested routes, and the rest are not drawn:
134
+
135
+ ```ruby
136
+ register_resource ::Post, associations: %i[comments post_detail]
137
+ ```
138
+
139
+ `associations: []` draws none, and a name that is not a routable association fails the boot. Set `config.nested_association_routes = :declared` to make naming them the rule — see [Tenancy › Nested resources](../tenancy/nested-resources#declaring-which-associations-get-routes).
140
+
131
141
  ### Singular (singleton) resources
132
142
 
133
143
  For resources with no collection — a single per-user `Profile`, app-wide `Settings`, etc.:
@@ -276,13 +286,15 @@ end
276
286
  ## Per-portal overrides
277
287
 
278
288
  ```ruby
279
- # Definition — different fields per portal
289
+ # Definition — how fields render per portal
280
290
  class AdminPortal::PostDefinition < ::PostDefinition
281
- input :internal_notes, as: :text # admins see this; customers don't
282
291
  scope :pending_review
292
+ input :internal_notes, hint: "Not shown to the author"
283
293
  end
284
294
 
285
- # Policy — different rules per portal
295
+ # Policy — which fields exist, and who may act
296
+ # `internal_notes` appears for admins because THIS permits it,
297
+ # not because the definition above mentions it.
286
298
  class AdminPortal::PostPolicy < ::PostPolicy
287
299
  include AdminPortal::ResourcePolicy
288
300
 
@@ -228,6 +228,25 @@ before_create_account_route do
228
228
  end
229
229
  ```
230
230
 
231
+ ### Session isolation (multiple account types)
232
+
233
+ Emitted by the generators; both parts are required for concurrent portal logins.
234
+
235
+ ```ruby
236
+ # app/rodauth/rodauth_plugin.rb — the shared base, once
237
+ enable :session_isolation
238
+ ```
239
+
240
+ ```ruby
241
+ # app/rodauth/<name>_rodauth_plugin.rb — once per account type
242
+ session_key_prefix "admin_" # namespaces EVERY key, account id included
243
+ remember_cookie_key "_admin_remember"
244
+ ```
245
+
246
+ Drop either and signing into one portal evicts the others.
247
+
248
+ Do **not** also set `session_key`: explicit values bypass `convert_session_key` and so are not prefixed, leaving the account id rotating separately from every other key. Rodauth then raises on any session holding an account id with no `authenticated_by`. See [Guides › Authentication › Multiple portals in one browser](/guides/authentication#multiple-portals-in-one-browser).
249
+
231
250
  ## Related
232
251
 
233
252
  - [Profile](./profile) — profile resource + SecuritySection component