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
@@ -1,6 +1,6 @@
1
1
  # Interaction
2
2
 
3
- Encapsulate business logic into testable, reusable units. Registered as [actions](/reference/resource/actions) in definitions and executed by the controller. Built on ActiveModel attributes + validations.
3
+ The entry point from a Plutonium page into an operation. An interaction declares the inputs, renders as a button and a form, is gated by a policy, and returns an outcome the controller turns into a flash message and a redirect. Built on ActiveModel attributes + validations.
4
4
 
5
5
  ## 🚨 Critical
6
6
 
@@ -9,6 +9,80 @@ Encapsulate business logic into testable, reusable units. Registered as [actions
9
9
  - **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
10
10
  - **Bulk actions use `attribute :resources` (plural).** Policy authorization is checked per record — if any fails, the whole request fails.
11
11
  - **The shape of the action (record / bulk / resource) is inferred from the interaction's attributes.** See [Resource › Actions](/reference/resource/actions#inferred-visibility-interactive-actions).
12
+ - **An interaction is a presentation object.** Logic may *start* in `execute`; the **second caller** — a job, an API controller, a rake task, the console — is the signal to move it to the model. See [below](#what-an-interaction-is-for).
13
+
14
+ ## What an interaction is for {#what-an-interaction-is-for}
15
+
16
+ An interaction is a **presentation object**. It exists so Plutonium can render a button, check a policy, bind a form, and turn the result into a message and a redirect. That is the whole job:
17
+
18
+ | An interaction owns | An interaction does not own |
19
+ |---|---|
20
+ | The button — `presents label:` / `icon:` | *Who* may click it. That's the [policy](./policies). |
21
+ | The form — `attribute` + `input` declarations | — |
22
+ | **Input shape** validation: present? parses? right type? | **Business invariants** — they must hold for every caller, so they belong on the model |
23
+ | The user-facing outcome — `succeed` / `failed`, messages, redirect | The domain operation itself, once more than one caller needs it |
24
+
25
+ ### Logic may start in `execute`
26
+
27
+ A one-off operation with exactly one caller is perfectly fine written inline. Don't pre-extract a service object for a two-line `update!` — that's YAGNI, and Plutonium deliberately ships no service layer to put it in. The rule below is a **refactoring trigger**, not a prohibition.
28
+
29
+ ### The second caller is the trigger to extract
30
+
31
+ The moment a background job, an API controller, a rake task, the console, or another interaction needs the same behaviour, move it to the model.
32
+
33
+ The deadline is *the second caller* — and not "as soon as it looks like business logic" — because of one line in the base class:
34
+
35
+ ```ruby
36
+ def initialize(view_context:, **attributes)
37
+ ```
38
+
39
+ `view_context:` is required. So a caller that isn't a Plutonium page has exactly two options: duplicate the logic, or manufacture a `view_context` it has no business owning. **`view_context` is the tell.** If reaching some behaviour would force a caller to conjure one, that behaviour is on the wrong side of the boundary.
40
+
41
+ ### The destination is the model
42
+
43
+ Rails convention: fat models. Give the operation a name in domain language and hang it off the record.
44
+
45
+ ```ruby
46
+ # app/models/post.rb
47
+ class Post < ApplicationRecord
48
+ def publish!(on: Time.current)
49
+ update!(published: true, published_at: on)
50
+ end
51
+ end
52
+ ```
53
+
54
+ ```ruby
55
+ # the interaction presents it
56
+ def execute
57
+ resource.publish!(on: publish_date)
58
+ succeed(resource).with_message("Post published!")
59
+ rescue ActiveRecord::RecordInvalid => e
60
+ failed(e.record.errors)
61
+ end
62
+ ```
63
+
64
+ Name it for the domain (`publish!`, `archive!`, `register!`), not for the persistence (`update_published_at`) — the point is that a scheduler job can now call `post.publish!` and read as if it meant it. And resist inventing a `PublishPostService`: the model is the destination, not a new layer.
65
+
66
+ ### Worked counter-example — chained interactions
67
+
68
+ ```ruby
69
+ # 🚫 Every link demands a view_context that has nothing to do with the work
70
+ CreateUserInteraction.call(view_context:, **user_params)
71
+ .and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
72
+ .and_then { |user| LogActivity.call(view_context:, user:) }
73
+ ```
74
+
75
+ Sending a welcome email and writing an audit row are precisely what a signup API endpoint, a seeds script, or a console session also has to do — none of which has a `view_context`. Modelled as interactions, they are unreachable from anywhere but a Plutonium page.
76
+
77
+ ```ruby
78
+ # ✅ The model owns registering a user; the interaction presents it
79
+ def execute
80
+ user = User.register!(**attributes) # welcome email + audit row live in here
81
+ succeed(user).with_message("Welcome aboard!")
82
+ end
83
+ ```
84
+
85
+ Chaining three interactions is usually the signal that you have one model method wearing three presentation costumes. `and_then` is real API and stays [documented below](#chaining) — just don't reach for it to sequence business operations.
12
86
 
13
87
  ## Structure
14
88
 
@@ -33,7 +107,7 @@ class PublishPostInteraction < ResourceInteraction
33
107
  private
34
108
 
35
109
  def execute
36
- resource.update!(published_at: publish_date)
110
+ resource.publish!(on: publish_date) # Post#publish! — see above
37
111
  succeed(resource).with_message("Post published!")
38
112
  rescue ActiveRecord::RecordInvalid => e
39
113
  failed(e.record.errors)
@@ -41,6 +115,8 @@ class PublishPostInteraction < ResourceInteraction
41
115
  end
42
116
  ```
43
117
 
118
+ Note the division: the interaction declares the input, validates that a date was supplied, and phrases the flash. `Post#publish!` decides what publishing a post *means* — so the scheduled-publishing job can call it too.
119
+
44
120
  ## Attributes
45
121
 
46
122
  ActiveModel-style:
@@ -123,22 +199,33 @@ def execute
123
199
  end
124
200
  ```
125
201
 
126
- ### Chaining
202
+ ### Chaining {#chaining}
127
203
 
128
- `and_then` chains interactions. On failure, the chain short-circuits and returns the failure immediately.
204
+ `and_then` composes `Outcome`s. On a `Success` it yields **the value** (not the outcome) and returns whatever the block returns; on a `Failure` it short-circuits, returning the failure untouched.
129
205
 
130
206
  ```ruby
131
207
  def execute
132
- CreateUserInteraction.call(view_context:, **user_params)
133
- .and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
134
- .and_then { |r| LogActivity.call(view_context:, user: r.value) }
135
- .with_message("User created and welcomed!")
208
+ unlocked_resource.and_then do |post|
209
+ post.publish!(on: publish_date)
210
+ succeed(post).with_message("Post published!")
211
+ end
212
+ end
213
+
214
+ private
215
+
216
+ # a guard expressed as an Outcome, so the failure carries its own message
217
+ def unlocked_resource
218
+ resource.locked? ? failed("This post is locked") : succeed(resource)
136
219
  end
137
220
  ```
138
221
 
222
+ ::: warning Don't use `and_then` to sequence business operations
223
+ A chain of three interactions is a chain of three things that each demand a `view_context`, none of which a job or an API controller can supply. That's one model method wearing three costumes — see [Worked counter-example](#what-an-interaction-is-for). `and_then` earns its keep composing outcomes *within* one interaction, or in a test.
224
+ :::
225
+
139
226
  ## Validations
140
227
 
141
- Standard ActiveModel. Run automatically before `execute`:
228
+ Standard ActiveModel. Run automatically before `execute`; if they fail, `execute` never runs.
142
229
 
143
230
  ```ruby
144
231
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
@@ -153,6 +240,23 @@ def custom_check
153
240
  end
154
241
  ```
155
242
 
243
+ ### Which validation goes where
244
+
245
+ Interactions have validations and so do models, and they are not competing — they answer different questions:
246
+
247
+ | | Interaction validation | Model validation |
248
+ |---|---|---|
249
+ | Asks | "Can I read this input?" — present, parses, right type, plausible format | "Is this record legal?" — invariants that hold no matter who is calling |
250
+ | Exists to | render a form error next to the field | protect the data from every caller, including the ones with no form |
251
+ | Runs | before `execute`, without ever touching the model | inside `save!` / `update!` — i.e. inside your model method |
252
+
253
+ Both surface to the user, but **not identically**, and the difference should inform where you put a rule:
254
+
255
+ - An **interaction** validation attaches to a declared attribute. The re-rendered modal shows it inline against that input, and again in the summary at the top of the form.
256
+ - `failed(record.errors)` flattens `ActiveModel::Errors` into **full messages on `:base`** (`Array(errors)` calls `errors.to_a`, which is `full_messages`). Those land in the form's error summary only — never against a field — and they're phrased with the *model's* attribute names, which need not match your inputs.
257
+
258
+ So it is fine, and often right, to *duplicate* a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: the moment a job calls `post.publish!`, the interaction's validations are not in the picture at all.
259
+
156
260
  ## Accessing context
157
261
 
158
262
  `current_user` is provided by the base class (`view_context.controller.helpers.current_user`):
@@ -164,6 +268,8 @@ def execute
164
268
  end
165
269
  ```
166
270
 
271
+ This one is *correctly* inline. "Who clicked the button" is context the presentation layer holds and nothing else does — `current_user` is read straight off the `view_context`. A job has no answer for it, so there is no second caller to extract for.
272
+
167
273
  ## Interaction types
168
274
 
169
275
  | Attribute pattern | Action type | Where it shows up |
@@ -179,7 +285,7 @@ class ArchiveInteraction < Plutonium::Resource::Interaction
179
285
  attribute :resource
180
286
 
181
287
  def execute
182
- resource.update!(archived: true)
288
+ resource.archive!
183
289
  succeed(resource).with_message("Archived")
184
290
  rescue ActiveRecord::RecordInvalid => e
185
291
  failed(e.record.errors)
@@ -200,6 +306,8 @@ class BulkArchiveInteraction < Plutonium::Resource::Interaction
200
306
  end
201
307
  ```
202
308
 
309
+ `update_all` stays inline on purpose: it's a single-statement SQL update whose *whole point* is skipping per-record model machinery. If archiving means more than setting a column — callbacks, an audit row, a webhook — this is the wrong shape; call `resources.each(&:archive!)` and let the model own it.
310
+
203
311
  Per-record authorization details in [Resource › Actions › Bulk action](/reference/resource/actions#bulk-action).
204
312
 
205
313
  ### Resource action (no record)
@@ -219,7 +327,11 @@ end
219
327
 
220
328
  ## Calling interactions directly
221
329
 
222
- The controller handles this for interactive actions. But you can call them manually toouseful in tests, jobs, and rake tasks.
330
+ The controller handles this for interactive actions. You can also call one by handchiefly in **tests**, where you're exercising the interaction itself.
331
+
332
+ ::: tip Needing this in a job or a rake task is the signal to refactor
333
+ Both entry points require `view_context:`, and a job doesn't have one. If you find yourself reaching for a stub to satisfy it, you don't want the interaction — you want the model method it wraps. See [What an interaction is for](#what-an-interaction-is-for).
334
+ :::
223
335
 
224
336
  ### Class method
225
337
 
@@ -240,7 +352,7 @@ interaction = PublishPost.new(view_context: view_context, resource: post)
240
352
  outcome = interaction.call
241
353
  ```
242
354
 
243
- The `view_context:` argument is required — interactions use it to access controller helpers and the current user.
355
+ The `view_context:` argument is required — interactions use it to access controller helpers and the current user. It is also the boundary marker: everything reachable *only* through an interaction is reachable only from a page.
244
356
 
245
357
  ## Immediate vs form
246
358
 
@@ -276,7 +388,26 @@ The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb
276
388
 
277
389
  ## Complete example
278
390
 
391
+ Inviting a user is a textbook second-caller case — a seats-provisioning job, an admin rake task and a signup API all need to send the same invitation. So the operation lives on `Company`, and the interaction is the button in front of it.
392
+
279
393
  ```ruby
394
+ # app/models/company.rb
395
+ class Company < ApplicationRecord
396
+ has_many :user_invites
397
+
398
+ # Everything inviting means: the row, the mail, the audit trail.
399
+ def invite!(email:, role:, by:)
400
+ user_invites.create!(email: email, role: role, invited_by: by).tap do |invite|
401
+ UserInviteMailer.invitation(invite).deliver_later
402
+ end
403
+ end
404
+
405
+ def pending_invite_for?(email) = user_invites.exists?(email: email, state: :pending)
406
+ end
407
+ ```
408
+
409
+ ```ruby
410
+ # app/interactions/company/invite_user_interaction.rb
280
411
  class Company::InviteUserInteraction < Plutonium::Resource::Interaction
281
412
  presents label: "Invite User",
282
413
  icon: Phlex::TablerIcons::UserPlus
@@ -288,28 +419,26 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
288
419
  input :email
289
420
  input :role, as: :select, choices: -> { UserInvite.roles.keys }
290
421
 
422
+ # Input shape — is this a readable email, is this a role that exists?
291
423
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
292
424
  validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
293
- validate :not_already_invited
425
+ validate :not_already_invited
294
426
 
295
427
  private
296
428
 
297
429
  def execute
298
- invite = UserInvite.create!(
299
- company: resource, email: email, role: role,
300
- invited_by: current_user
301
- )
302
- UserInviteMailer.invitation(invite).deliver_later
430
+ resource.invite!(email: email, role: role, by: current_user)
303
431
  succeed(resource).with_message("Invitation sent to #{email}")
304
432
  rescue ActiveRecord::RecordInvalid => e
305
433
  failed(e.record.errors)
306
434
  end
307
435
 
436
+ # Deliberately duplicated. `UserInvite` enforces uniqueness for real (a job
437
+ # calling `company.invite!` must hit it too); this copy exists only so the
438
+ # message lands on the :email field instead of in the base error summary.
308
439
  def not_already_invited
309
- return unless email.present?
310
- if UserInvite.exists?(company: resource, email: email, state: :pending)
311
- errors.add(:email, "already has a pending invitation")
312
- end
440
+ return if email.blank?
441
+ errors.add(:email, "already has a pending invitation") if resource.pending_invite_for?(email)
313
442
  end
314
443
  end
315
444
  ```
@@ -335,6 +464,7 @@ See [Testing](/reference/testing/) for Plutonium's built-in testing helpers —
335
464
 
336
465
  ## Related
337
466
 
467
+ - [Async Interactions](./async-interactions) — `async` a persisted run instead of running `execute` inline
338
468
  - [Resource › Actions](/reference/resource/actions) — registering interactions, inferred visibility, immediate vs form
339
469
  - [Policies](./policies) — `def <action>?` authorization methods
340
470
  - [Controllers](./controllers) — `resource_url_for(..., interaction: …)` URL generation
@@ -9,6 +9,7 @@ Plutonium.configure do |config|
9
9
 
10
10
  # config.shell = :modern
11
11
  # config.navii_host_url = "https://api.navii.dev"
12
+ # config.auto_eager_load_collections = true
12
13
 
13
14
  config.assets.logo = "plutonium.png"
14
15
  config.assets.favicon = "plutonium.ico"
@@ -37,6 +38,10 @@ Loads the baseline defaults for a given framework version. Call this first; late
37
38
  | `enable_hotreload` | `true` in `development` env | Hot-reload Plutonium components on change. |
38
39
  | `shell` | `:modern` | Chrome style: `:modern` (topbar + icon rail), `:plain` (topbar, no icon rail), or `:classic` (legacy header + sidebar, only for upgrades). See [Layouts](./ui/layouts). |
39
40
  | `navii_host_url` | `"https://api.navii.dev"` | Host of the [Navii](https://navii.dev) avatar service used by [`Avatar`](./ui/components#avatar). The component appends `/avatar/:seed`. Repoint to self-host or proxy. |
41
+ | `auto_eager_load_collections` | `true` | Index pages, kanban boards and CSV exports preload the associations and attachments they render. Set `false` to disable globally, or override `auto_eager_load_collections?` in a controller. See [Performance](/guides/performance). |
42
+ | `default_page_width` | `:md` | Width of detail-style pages — the show page and resource forms. One of `:sm :md :lg :xl :full` (`:full` = unconstrained). Index and table pages are unaffected. Override per-resource with `page_width` / `form_width` / `display_width`; see [Definition › Page width](./resource/definition#page-width). |
43
+ | `wizards.width` | `:md` | Default width of wizard step pages. **Independent of `default_page_width`** — a wizard is a self-contained flow, so widening resource pages leaves wizards alone. Override per wizard with `width`. Same size tokens. |
44
+ | `nested_association_routes` | `:detected` | Where a resource's nested routes come from. `:detected` draws one for every `has_many` / `has_one` whose child is registered. `:declared` draws only what `register_resource ..., associations:` names, so a resource that names none gets none. See [Nested resources › Declaring which associations get routes](./tenancy/nested-resources#declaring-which-associations-get-routes). |
40
45
  | `assets.logo` | `"plutonium.png"` | Brand logo asset. See [Assets](./ui/assets). |
41
46
  | `assets.favicon` | `"plutonium.ico"` | Favicon asset. |
42
47
  | `assets.stylesheet` | `"plutonium.css"` | Stylesheet entry. |
@@ -22,6 +22,7 @@ aside: false
22
22
  { name: 'Definition', link: '/plutonium-core/reference/resource/definition' },
23
23
  { name: 'Query', link: '/plutonium-core/reference/resource/query' },
24
24
  { name: 'Actions', link: '/plutonium-core/reference/resource/actions' },
25
+ { name: 'Positioning', link: '/plutonium-core/reference/positioning' },
25
26
  ]},
26
27
  { group: 'Behavior', items: [
27
28
  { name: 'Overview', link: '/plutonium-core/reference/behavior/' },
@@ -49,18 +49,20 @@ Default: `nil` (unlimited).
49
49
 
50
50
  Controls how card positions are persisted after a drag-and-drop. Three modes:
51
51
 
52
- #### Mode A delegate to `Plutonium::Positioning` (default)
52
+ **Inherited from the definition.** `position_on` is the same verb outside `kanban do…end`, where it makes the resource's [table and grid drag-reorderable](/reference/positioning). A board with no `position_on` of its own uses the **definition's**, falling back to the historic default (`:position`, Mode A). Declaring it inside the board overrides that. Resolution is lazy, so declaration order in the class body does not matter.
53
+
54
+ #### Mode A — delegate to `Plutonium::Positioning::Model` (default)
53
55
 
54
56
  ```ruby
55
57
  # Default: uses :position attribute
56
- # (no explicit call needed if the model includes Plutonium::Positioning)
58
+ # (no explicit call needed if the model includes Plutonium::Positioning::Model)
57
59
 
58
60
  # Custom attribute name:
59
61
  position_on :sort_order
60
62
  ```
61
63
 
62
64
  Requires the model to:
63
- 1. `include Plutonium::Positioning`
65
+ 1. `include Plutonium::Positioning::Model`
64
66
  2. Call `positioned_on :position, scope: :grouping_attribute`
65
67
  3. Have a `decimal` column for the position attribute — add it with the `t.position` migration helper (a tuned `decimal(16,8)`) — see [Positioning › Migration](/reference/kanban/positioning#migration)
66
68
 
@@ -70,7 +72,8 @@ On drop, calls `record.reposition!(prev_record:, next_record:)` which computes t
70
72
 
71
73
  ```ruby
72
74
  position_on :sort_order do |move|
73
- # move is a Plutonium::Kanban::Positioning::Move value object:
75
+ # move is a Plutonium::Positioning::Move value object
76
+ # (also reachable as Plutonium::Kanban::Positioning::Move):
74
77
  # move.record — the dropped ActiveRecord record
75
78
  # move.column — destination column key (Symbol)
76
79
  # move.prev — record immediately before the insertion slot, or nil
@@ -7,7 +7,7 @@ Reference documentation for the Plutonium kanban board feature.
7
7
  | Page | What it covers |
8
8
  |------|---------------|
9
9
  | [DSL](/reference/kanban/dsl) | Complete `kanban do…end` DSL — board options, columns, column actions, static vs. dynamic, lazy loading, realtime |
10
- | [Positioning](/reference/kanban/positioning) | `Plutonium::Positioning` concern, `positioned_on`, `position_on` modes, `reposition!`, rebalancing |
10
+ | [Positioning](/reference/kanban/positioning) | `Plutonium::Positioning::Model` concern, `positioned_on`, `position_on` modes, `reposition!`, rebalancing |
11
11
  | [Authorization](/reference/kanban/authorization) | `kanban_move?` policy predicate, read-only fallback, separating move rights from edit rights |
12
12
 
13
13
  ## Quick start
@@ -1,14 +1,18 @@
1
1
  # Kanban Positioning
2
2
 
3
+ ::: tip Positioning is not kanban-only
4
+ The model concern, the arithmetic and the `position_on` modes on this page are shared with **table and grid drag-to-reorder** — see [Positioning & drag-to-reorder](/reference/positioning) for the index-surface half of the feature (the grip, the `reposition` endpoint, `reposition?`, and board inheritance).
5
+ :::
6
+
3
7
  Plutonium uses **decimal fractional positioning** for kanban card ordering. A drop writes a single decimal position (the midpoint between its neighbors), so the common case touches exactly one row — no bulk renumbering. The one exception is rare **rebalancing**: when the same slot has been subdivided ~20 times and the gap between two neighbors shrinks below `1e-6`, Plutonium renumbers that one scope group back to clean integers before inserting (see [Gap exhaustion](#rebalancing)).
4
8
 
5
- ## `Plutonium::Positioning` concern
9
+ ## `Plutonium::Positioning::Model` concern
6
10
 
7
11
  Include this concern in any model you want to position:
8
12
 
9
13
  ```ruby
10
14
  class Task < ApplicationRecord
11
- include Plutonium::Positioning
15
+ include Plutonium::Positioning::Model
12
16
 
13
17
  positioned_on :position, scope: :status
14
18
  end
@@ -103,6 +107,22 @@ Task.backfill_positions!(order: :created_at)
103
107
 
104
108
  The `position_on` call inside `kanban do…end` controls how Plutonium persists positions after a drag-and-drop. Three modes are available:
105
109
 
110
+ ::: tip A board inherits the definition's `position_on`
111
+ `position_on` is the **same verb** at both levels. A board resolves its strategy as: its own `position_on`, else the **definition's**, else the historic default (`:position`, Mode A). So a resource whose definition already declares `position_on` for its table and grid needs nothing inside `kanban do…end` — the board picks up the same attribute and the same mode.
112
+
113
+ Resolution is lazy, so a `kanban do…end` written **above** `position_on` in the class body still sees it.
114
+
115
+ ```ruby
116
+ class TaskDefinition < Plutonium::Resource::Definition
117
+ position_on :sort_order # table, grid AND board
118
+
119
+ kanban do
120
+ position_on :board_rank # …unless the board overrides it
121
+ end
122
+ end
123
+ ```
124
+ :::
125
+
106
126
  ### Mode A — delegate (default)
107
127
 
108
128
  ```ruby
@@ -113,7 +133,7 @@ kanban do
113
133
  end
114
134
  ```
115
135
 
116
- On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning` and call `positioned_on`.
136
+ On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning::Model` and call `positioned_on`.
117
137
 
118
138
  ### Mode B — BYO block
119
139
 
@@ -130,7 +150,9 @@ kanban do
130
150
  end
131
151
  ```
132
152
 
133
- Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Kanban::Positioning::Move` argument — it is NOT `instance_exec`'d, so `self` is the proc's original binding.
153
+ Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Positioning::Move` argument (still reachable under its original name, `Plutonium::Kanban::Positioning::Move`) — it is NOT `instance_exec`'d, so `self` is the proc's original binding.
154
+
155
+ On a table or grid the same block runs with `move.column` set to `nil`, since those surfaces have no columns. See [Mode B](/reference/positioning#mode-b) for a worked `acts_as_list` example.
134
156
 
135
157
  ### Mode C — disabled
136
158