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
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # Declarative display sectioning — the `display_layout` counterpart to
6
+ # {FormLayout}, applied to the show page's field grid instead of the
7
+ # form. Same shape (`section`/`ungrouped`, first-section-wins ownership,
8
+ # `condition:`) and the same resolution semantics, so a section/field
9
+ # author only has to learn the DSL once.
10
+ #
11
+ # Reuses {FormLayout}'s `Section`/`ResolvedSection` structs and its
12
+ # ownership-resolution algorithm ({FormLayout.resolve_sections}) rather
13
+ # than duplicating them — the algorithm has nothing form-specific about
14
+ # it, only the declaring macro and the render target differ.
15
+ #
16
+ # Deliberately does NOT support `columns:`. The display side already has
17
+ # a per-field width pathway — `display :x, wrapper: {class: "col-span-2"}`
18
+ # — and every section renders into the same responsive grid, so a second,
19
+ # section-level column mechanism would be a redundant way to say the same
20
+ # thing. Grouping is what this DSL is for; widths stay on the field. A
21
+ # `columns:` option raises rather than being silently dropped.
22
+ #
23
+ # @example
24
+ # display_layout do
25
+ # section :identity, :name, :email, label: "Identification"
26
+ # section :address, :street, :city, collapsible: true,
27
+ # condition: -> { object.requires_address? }
28
+ # ungrouped label: "Other"
29
+ # end
30
+ module DisplayLayout
31
+ extend ActiveSupport::Concern
32
+
33
+ # Collects section/ungrouped calls from a display_layout block, in
34
+ # order. Builds the same Section struct FormLayout uses — the two
35
+ # layouts share a resolver, so they share a value type.
36
+ class Builder
37
+ attr_reader :sections
38
+
39
+ def initialize
40
+ @sections = []
41
+ @ungrouped_seen = false
42
+ end
43
+
44
+ def section(key, *fields, **options)
45
+ if key == FormLayout::UNGROUPED_KEY
46
+ raise ArgumentError,
47
+ "`section :#{FormLayout::UNGROUPED_KEY}` is reserved — use the `ungrouped` macro"
48
+ end
49
+ reject_columns!(options)
50
+ @sections << FormLayout::Section.new(key:, fields: fields.freeze, options: options.freeze)
51
+ end
52
+
53
+ def ungrouped(**options)
54
+ raise ArgumentError, "`ungrouped` may only be declared once" if @ungrouped_seen
55
+ @ungrouped_seen = true
56
+ reject_columns!(options)
57
+ @sections << FormLayout::Section.new(
58
+ key: FormLayout::UNGROUPED_KEY, fields: [].freeze, options: options.freeze
59
+ )
60
+ end
61
+
62
+ private
63
+
64
+ # `columns:` is a form_layout option only. Raising (rather than
65
+ # ignoring it) means an author who copies a form_layout block across
66
+ # finds out immediately, instead of wondering why the column count
67
+ # had no effect.
68
+ def reject_columns!(options)
69
+ return unless options.key?(:columns)
70
+ raise ArgumentError,
71
+ "display_layout does not support `columns:` — set a width on the field instead, " \
72
+ "e.g. `display :x, wrapper: {class: \"col-span-2\"}`"
73
+ end
74
+ end
75
+
76
+ class_methods do
77
+ # Declare the display layout. Re-declaring replaces it as a unit.
78
+ def display_layout(&block)
79
+ raise ArgumentError, "`display_layout` requires a block" unless block
80
+ builder = Builder.new
81
+ builder.instance_exec(&block)
82
+ @defined_display_layout = builder.sections.freeze
83
+ end
84
+
85
+ # Ordered Array<FormLayout::Section>, or nil when no layout was declared.
86
+ def defined_display_layout
87
+ @defined_display_layout
88
+ end
89
+
90
+ def inherited(subclass)
91
+ super
92
+ subclass.instance_variable_set(:@defined_display_layout, defined_display_layout&.dup)
93
+ end
94
+ end
95
+
96
+ # Instance access — the display render path holds a definition
97
+ # instance (mirrors the defineable_prop convention).
98
+ def defined_display_layout
99
+ self.class.defined_display_layout
100
+ end
101
+
102
+ # Resolve the policy-filtered field list into ordered ResolvedSections.
103
+ # Returns nil when no layout is declared (caller falls back to one grid).
104
+ def resolve_display_sections(resource_fields)
105
+ layout = defined_display_layout
106
+ return nil unless layout
107
+
108
+ FormLayout.resolve_sections(layout, resource_fields)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -135,17 +135,18 @@ module Plutonium
135
135
 
136
136
  # Register each column's enter_interaction as an interactive record
137
137
  # action too, so its form and params extraction exist and route the
138
- # standard way. It is flagged `kanban_drop: true` so it is excluded
139
- # from the normal show/row/index toolbars it is reachable only by
140
- # dropping a card. The key is column-scoped (enter_interaction_key →
141
- # :<column>_enter_interaction), so it is unique by construction and
142
- # never collides with another column's interaction. It carries NO
143
- # policy method of its own — the drop is authorized by kanban_move?.
138
+ # standard way. It is flagged `hidden: true` so it is excluded from
139
+ # every render surface (toolbars, row dropdowns, cards, bulk bars)
140
+ # it is reachable only by dropping a card. The key is column-scoped
141
+ # (enter_interaction_key → :<column>_enter_interaction), so it is
142
+ # unique by construction and never collides with another column's
143
+ # interaction. It carries NO policy method of its own — the drop is
144
+ # authorized by kanban_move?.
144
145
  if col.enter_interaction?
145
146
  action(
146
147
  col.enter_interaction_key,
147
148
  interaction: col.enter_interaction,
148
- kanban_drop: true
149
+ hidden: true
149
150
  )
150
151
  end
151
152
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # What an `as:` on an input / display declaration MEANS, independent of any
6
+ # view.
7
+ #
8
+ # `as:` is a union: either an alias naming a built-in tag (`:string`,
9
+ # `:uppy`, a String) OR a component Class rendered directly (see
10
+ # {Plutonium::UI::Component::ResolvesTags}). A Class has no `#to_sym`, so
11
+ # every "which alias is this?" question goes through {resolve} rather than
12
+ # coercing `as` itself — the controller, the wizard, the generators and the
13
+ # views all ask here.
14
+ module InputAliases
15
+ # The `as:` values that render through the Uppy file-upload component —
16
+ # the single source of truth, so the form builder's tag aliases and the
17
+ # attachment detection in {Plutonium::Wizard::Attachments.field?} and
18
+ # `Plutonium::Resource::Controller#attachment_input_keys` never drift.
19
+ FILE_INPUT_TYPES = %i[uppy file attachment].freeze
20
+
21
+ class << self
22
+ # @param as [Symbol, String, Class, nil] a declared `as:`.
23
+ # @return [Symbol, nil] the Symbol form of a symbol/string `as:`; nil for
24
+ # anything else, a component Class included, since it names no alias.
25
+ # The Symbol is NOT validated against the tags that actually exist —
26
+ # callers compare it against the aliases they care about.
27
+ def resolve(as)
28
+ as.to_sym if as.is_a?(Symbol) || as.is_a?(String)
29
+ end
30
+
31
+ # Whether an `as:` renders as an attachment/file input.
32
+ def file_input?(as)
33
+ FILE_INPUT_TYPES.include?(resolve(as))
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Definition
5
+ # Per-resource width for detail-style pages. `page_width` sets both the
6
+ # form and the show page; `form_width` / `display_width` override one
7
+ # surface without disturbing the other.
8
+ #
9
+ # Plutonium.configure { |c| c.default_page_width = :md } # global
10
+ #
11
+ # class PostDefinition < ResourceDefinition
12
+ # page_width :lg # this resource's form AND show page
13
+ # display_width :full # ...except the show page, which goes full
14
+ # end
15
+ #
16
+ # Resolution, most specific first: the surface-specific setting, then
17
+ # `page_width`, then `Plutonium.configuration.default_page_width`. All
18
+ # three inherit to subclasses (so a portal-specific definition keeps its
19
+ # parent's width unless it says otherwise), which is why these are
20
+ # `inheritable_config_attr` rather than plain class attributes.
21
+ module PageWidths
22
+ extend ActiveSupport::Concern
23
+
24
+ # `inheritable_config_attr` appends the `_config` suffix itself and
25
+ # defines the bare name as a singleton method on the class. A
26
+ # `class_methods` module would sit BEHIND that in the ancestor chain and
27
+ # never be called, so the validating versions are defined here, after
28
+ # generation, to replace it — the same ordering `show_in` relies on.
29
+ included do
30
+ inheritable_config_attr :page_width, :form_width, :display_width
31
+
32
+ # Reader with no argument, validated writer with one, so an unknown
33
+ # token raises at declaration rather than silently rendering at some
34
+ # other width.
35
+ %i[page_width form_width display_width].each do |name|
36
+ define_singleton_method(name) do |value = :__not_set__|
37
+ return public_send(:"#{name}_config") if value == :__not_set__
38
+
39
+ public_send(:"#{name}_config=", Plutonium::UI::PageWidth.validate!(value))
40
+ end
41
+ end
42
+ end
43
+
44
+ # The resolved token for each surface. Nil-coalescing rather than
45
+ # `||`-on-a-boolean, so an explicit `:full` (a legitimate choice) is
46
+ # honoured instead of being treated as "unset".
47
+ def resolved_form_width
48
+ self.class.form_width_config ||
49
+ self.class.page_width_config ||
50
+ Plutonium.configuration.default_page_width
51
+ end
52
+
53
+ def resolved_display_width
54
+ self.class.display_width_config ||
55
+ self.class.page_width_config ||
56
+ Plutonium.configuration.default_page_width
57
+ end
58
+
59
+ # The classes to put on the surface, or nil for `:full`.
60
+ def form_width_classes = Plutonium::UI::PageWidth.classes_for(resolved_form_width)
61
+
62
+ def display_width_classes = Plutonium::UI::PageWidth.classes_for(resolved_display_width)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "plutonium/positioning/config"
4
+
5
+ module Plutonium
6
+ module Definition
7
+ # Declares a resource drag-orderable. The MODEL owns how positions are
8
+ # stored (`positioned_on :sort_order, scope: :project_id`); this only says
9
+ # "this UI can be reordered", and never restates the column or the scope.
10
+ #
11
+ # position_on # Mode A, follows the model's column
12
+ # position_on :sort_order # Mode A, must MATCH the model's column
13
+ # position_on(:rank) { |move| … } # Mode B, another gem owns the write
14
+ # position_on false # Mode C, ordering off
15
+ #
16
+ # Mirrors kanban's `position_on` (lib/plutonium/kanban/dsl.rb) exactly, and
17
+ # builds the same Plutonium::Positioning::Config. Deliberately the same verb
18
+ # rather than a third one: the model says `positioned_on`, every UI layer
19
+ # says `position_on`, and a kanban board with no `position_on` of its own
20
+ # inherits the definition's (see Kanban::Board#position_config_for).
21
+ module Positioning
22
+ extend ActiveSupport::Concern
23
+
24
+ included do
25
+ class_attribute :defined_position_config, instance_accessor: false, default: nil
26
+
27
+ # `attribute` defaults to nil, not :position — in Mode A the model has
28
+ # already named its column, and assuming :position here would silently
29
+ # order by one column while reposition! wrote another. Mode B has no
30
+ # model contract to read, so it keeps :position as its own default.
31
+ def self.position_on(attribute = nil, &block)
32
+ config =
33
+ if attribute == false
34
+ Plutonium::Positioning::Config.disabled
35
+ elsif block
36
+ Plutonium::Positioning::Config.with_block(attribute || :position, block)
37
+ else
38
+ validate_model_is_positioned!(attribute)
39
+ Plutonium::Positioning::Config.attribute(attribute || model_class.positioning_column)
40
+ end
41
+
42
+ self.defined_position_config = config
43
+ return config if config.disabled?
44
+
45
+ # Registering the sort is load-bearing: dragging is only permitted
46
+ # when the collection is ordered by this attribute, so without a
47
+ # permitted sort there is no way back out of the disabled state.
48
+ sort config.attribute
49
+
50
+ # Only claim default_sort while nobody has chosen one. A `default_sort`
51
+ # written ABOVE position_on must survive exactly as one written below
52
+ # it does — this method is otherwise the single order-dependent line in
53
+ # a class body that is order-independent everywhere else (see
54
+ # Board#position_config_for).
55
+ #
56
+ # `equal?`, not `==`: DEFAULT_SORT is a sentinel, and `default_sort
57
+ # :id, :desc` builds a value-equal but distinct array. Comparing by
58
+ # value would read that explicit choice as "untouched" and silently
59
+ # overwrite it.
60
+ #
61
+ # NOTE this respects an inherited choice too: a base definition with
62
+ # its own `default_sort` means every resource under it renders the grip
63
+ # in the disabled state until the user sorts by position. That is the
64
+ # intended precedence — the app asked for that ordering — and it is
65
+ # documented in docs/reference/positioning.md.
66
+ default_sort config.attribute, :asc if _default_sort.equal?(Sorting::DEFAULT_SORT)
67
+
68
+ # Hidden: it has a POST member route (routing/mapper_extensions.rb) and
69
+ # a `reposition?` policy predicate (resource/policy.rb), but is
70
+ # reachable only by dragging — never rendered as a button.
71
+ action :reposition, hidden: true
72
+
73
+ config
74
+ end
75
+
76
+ # Mode A calls record.reposition! AND reads `.rebalanced?` off its
77
+ # return, so it needs the real concern — not merely something that
78
+ # responds to reposition!. A model that hand-rolls reposition!
79
+ # (wrapping acts_as_list, say) would otherwise fail at drop time,
80
+ # mid-transaction, with `NoMethodError: undefined method 'rebalanced?'`.
81
+ #
82
+ # This check lives HERE rather than in Config#reposition! for two
83
+ # reasons: the house rule against defensive call-site guards, and
84
+ # config.rb's standalone-loadability invariant — naming
85
+ # Plutonium::Positioning::Model there would pull in the concern and
86
+ # break the guard test in test/plutonium/positioning/config_test.rb.
87
+ # The DSL is where the mode is chosen, so it is where the contract belongs.
88
+ def self.validate_model_is_positioned!(attribute)
89
+ declaration = attribute ? "position_on #{attribute.inspect}" : "position_on"
90
+
91
+ unless model_class.include?(Plutonium::Positioning::Model)
92
+ raise ArgumentError,
93
+ "#{name || "definition"}: `#{declaration}` requires #{model_class} to " \
94
+ "`include Plutonium::Positioning::Model` and declare `positioned_on`. " \
95
+ "If another gem owns positioning for this model, use the block form " \
96
+ "instead: position_on(#{(attribute || :position).inspect}) { |move| … }"
97
+ end
98
+
99
+ # Including the concern is not enough: without `positioned_on` there is
100
+ # no before_create hook, so every row is created with a nil position and
101
+ # the resulting order is arbitrary — the silent failure this class-load
102
+ # check exists to prevent. positioning_column has a default and so
103
+ # cannot answer this question; positioning_declared can.
104
+ unless model_class.positioning_declared
105
+ raise ArgumentError,
106
+ "#{name || "definition"}: `#{declaration}` requires #{model_class} to declare " \
107
+ "`positioned_on` — including Plutonium::Positioning::Model alone never " \
108
+ "assigns a position on create, so every row would sort arbitrarily."
109
+ end
110
+
111
+ return if attribute.nil? || attribute.to_sym == model_class.positioning_column.to_sym
112
+
113
+ raise ArgumentError,
114
+ "#{name || "definition"}: `#{declaration}` contradicts #{model_class}'s " \
115
+ "`positioned_on #{model_class.positioning_column.inspect}` — the collection would " \
116
+ "be ordered by one column while reposition! wrote another, so dragging would " \
117
+ "appear to do nothing. Drop the argument to follow the model, or use the block " \
118
+ "form if another gem owns the write."
119
+ end
120
+ private_class_method :validate_model_is_positioned!
121
+ end
122
+
123
+ def defined_position_config = self.class.defined_position_config
124
+ end
125
+ end
126
+ end
@@ -3,6 +3,17 @@ module Plutonium
3
3
  module Sorting
4
4
  extend ActiveSupport::Concern
5
5
 
6
+ # The framework-wide default sort: newest first.
7
+ #
8
+ # A SENTINEL, compared with `equal?` — see Definition::Positioning, which
9
+ # only claims default_sort while nobody has chosen one. `==` cannot answer
10
+ # that question: `default_sort :id, :desc` builds a fresh `[field,
11
+ # direction]` array that is value-equal to this one, so an app that
12
+ # explicitly asked for newest-first would have its choice silently
13
+ # overwritten. Identity distinguishes them, which is why the installation
14
+ # below assigns this exact object rather than going through `default_sort`.
15
+ DEFAULT_SORT = [:id, :desc].freeze
16
+
6
17
  included do
7
18
  defineable_props :sort
8
19
 
@@ -21,8 +32,12 @@ module Plutonium
21
32
  _default_sort
22
33
  end
23
34
 
24
- # Set a sensible default: newest items first
25
- default_sort :id, :desc
35
+ # Set a sensible default: newest items first. Assigned directly rather
36
+ # than through `default_sort` so `_default_sort` holds the SENTINEL
37
+ # object itself — that identity is what tells a later `position_on` the
38
+ # sort is still untouched. Equivalent otherwise: `default_sort :id,
39
+ # :desc` would store a value-equal `[:id, :desc]`.
40
+ self._default_sort = DEFAULT_SORT
26
41
  end
27
42
 
28
43
  def default_sort
@@ -16,6 +16,13 @@ module Plutonium
16
16
  # modal frame specifically.
17
17
  def in_secondary_modal? = current_turbo_frame == Plutonium::REMOTE_MODAL_SECONDARY_FRAME
18
18
 
19
+ # True when this show request was opened from a kanban card in the modal
20
+ # frame — the card tags its show URL with KANBAN_MODAL_PARAM. Gated on
21
+ # in_modal? so a stray param on a full-page show never hides metadata.
22
+ # Lets the show page drop the metadata rail for kanban cards specifically,
23
+ # leaving regular show modals (and full-page shows) untouched.
24
+ def in_kanban_modal? = in_modal? && params[Plutonium::KANBAN_MODAL_PARAM].present?
25
+
19
26
  # Returns a turbo-frame-scoped element id. Two identically-named forms
20
27
  # can be on the page simultaneously (e.g. a primary modal opens a
21
28
  # secondary modal, each rendering an `id="resource-form"`). When the
@@ -14,24 +14,29 @@
14
14
 
15
15
  ## Introduction
16
16
 
17
- Interactions allows us to leverage an architectural approach that focuses on organizing code around business actions or user interactions.
18
- It builds upon the traditional MVC pattern by introducing additional layers that encapsulate business logic and improve separation of concerns.
17
+ Interactions organize code around the actions a user can take. Each one is the entry point from a page into a single, well-defined operation: it declares the inputs, renders as a button and a form, is gated by a policy, and hands back an outcome the controller turns into a message and a redirect.
19
18
 
20
19
  ### Key Benefits
21
20
 
22
- - Clear separation of business logic from controllers
23
- - Improved testability of business operations
21
+ - A uniform surface for custom operations: button, form, authorization, messaging, redirect
22
+ - Clear separation of the *request* from the controller
24
23
  - Consistent handling of success and failure cases
25
- - Flexible and expressive way to chain operations
26
- - Enhanced maintainability and readability of complex business processes
27
- - Improved code organization and discoverability of business logic
24
+ - Input validation that renders as form errors
25
+ - Improved discoverability every custom operation is a named class in `app/interactions/`
28
26
 
29
27
  ## Key Concepts
30
28
 
31
29
  ### Interactions
32
30
 
33
- Interactions are the core of this pattern. They represent specific use cases or business operations in your application. Each interaction is responsible for a single, well-defined task.
34
- Interactions encapsulate the business logic, input validation, and outcome handling, providing a clean interface between the controller and the application's core functionality.
31
+ An interaction is a **presentation object**. It owns the button, the form, the input-shape validation, and the user-facing outcome.
32
+
33
+ It is *not* where a domain operation should end up living. An interaction cannot be constructed without a `view_context:`, so anything reachable only through one is reachable only from a page. Logic may **start** in `execute` — a one-off with a single caller is fine there, and pre-extracting is YAGNI. The trigger to extract is the **second caller**: a background job, an API controller, a rake task, the console, or another interaction. At that point the behaviour moves onto the **model** (fat models, per Rails convention — not a new service layer), and the interaction shrinks to a call to a well-named method.
34
+
35
+ `view_context` is the tell: if a caller would need one purely to reach some behaviour, that behaviour is on the wrong side of the boundary.
36
+
37
+ Validations split along the same line. **Interaction validations check input shape** — is it present, does it parse, is it the right type — and exist to render form errors. **Model validations are business invariants** and must hold regardless of who is calling.
38
+
39
+ Full explanation: [Reference › Behavior › Interactions](https://radioactive-labs.github.io/plutonium-core/reference/behavior/interactions).
35
40
 
36
41
  ### Outcomes
37
42
 
@@ -99,7 +104,7 @@ We ship with these out of the box:
99
104
  .with_response(Response::Redirect.new(user_path(user)))
100
105
  .with_message("User was successfully created.")
101
106
  else
102
- failure(user.errors)
107
+ failed(user.errors)
103
108
  end
104
109
  end
105
110
  end
@@ -159,26 +164,55 @@ else
159
164
  end
160
165
  ```
161
166
 
162
- Or within another interaction:
167
+ ### Composing outcomes — `and_then`
168
+
169
+ `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. It's useful for expressing a guard as an outcome inside one `execute`:
163
170
 
164
171
  ```ruby
165
172
  def execute
166
- MyInteraction.call(some_input: "value")
167
- .and_then { |result| do_something_with(result) }
168
- .with_response(Response::Redirect.new(some_path(final_result)))
169
- .with_message("Operation completed successfully")
173
+ unlocked_resource.and_then do |post|
174
+ post.publish!
175
+ succeed(post).with_message("Published")
176
+ end
177
+ end
178
+
179
+ private
180
+
181
+ def unlocked_resource
182
+ resource.locked? ? failed("This post is locked") : succeed(resource)
170
183
  end
171
184
  ```
172
185
 
186
+ > **Don't use `and_then` to sequence business operations.** 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 [Best Practices](#best-practices) below.
187
+
173
188
  ## Best Practices
174
189
 
175
190
  1. Keep interactions focused on a single responsibility
176
191
  2. Use meaningful names for interactions that describe the action being performed
177
- 3. Leverage the `and_then` method for clean and expressive operation chaining
178
- 4. Prefer small, composable interactions over large, monolithic ones
192
+ 3. **Put the operation on the model as soon as a second caller needs it.** Logic may start in `execute`; a job, API controller, rake task, or console session wanting the same behaviour is the trigger to extract. Name the model method in domain language (`publish!`, `archive!`, `register!`), not persistence terms (`update_published_at`)
193
+ 4. **Interaction validations check input shape; model validations enforce invariants.** The interaction's exist to render form errors — they don't run for any other caller
179
194
  5. Use `with_response` to explicitly set the desired response type
180
195
  6. Keep the interaction's core logic separate from response handling
181
196
 
197
+ ### The chaining anti-pattern
198
+
199
+ ```ruby
200
+ # 🚫 Each link demands a view_context that has nothing to do with the work
201
+ CreateUserInteraction.call(view_context:, **user_params)
202
+ .and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
203
+ .and_then { |user| LogActivity.call(view_context:, user:) }
204
+ ```
205
+
206
+ Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a seeds script also has to do — and neither has a view context. Give the model the operation instead:
207
+
208
+ ```ruby
209
+ # ✅
210
+ def execute
211
+ user = User.register!(**attributes) # welcome email + audit row live in here
212
+ succeed(user).with_message("Welcome aboard!")
213
+ end
214
+ ```
215
+
182
216
  ## Testing
183
217
 
184
218
  Interactions are easy to test in isolation. Here's an example using RSpec:
@@ -314,7 +348,7 @@ module Users
314
348
  if user.save
315
349
  success(user).with_message("User created successfully")
316
350
  else
317
- failure(user.errors)
351
+ failed(user.errors)
318
352
  end
319
353
  end
320
354
  end
@@ -345,25 +379,28 @@ module Orders
345
379
 
346
380
  def find_user(user_id)
347
381
  user = User.find_by(id: user_id)
348
- user ? success(user) : failure(["User not found"])
382
+ user ? success(user) : failed(["User not found"])
349
383
  end
350
384
 
351
385
  def find_products(user, product_ids)
352
386
  products = Product.where(id: product_ids.split(','))
353
- products.empty? ? failure(["No valid products found"]) : success([user, products])
387
+ products.empty? ? failed(["No valid products found"]) : success([user, products])
354
388
  end
355
389
 
356
390
  def create_order(user, products)
357
- order = Order.create(user: user, products: products)
358
- order.persisted? ? success(order) : failure(order.errors.full_messages)
391
+ # Order.place! owns the domain work — inventory, totals, confirmation mail.
392
+ # The interaction only resolves the inputs and shapes the outcome.
393
+ success(Order.place!(user: user, products: products))
394
+ rescue ActiveRecord::RecordInvalid => e
395
+ failed(e.record.errors)
359
396
  end
360
397
  end
361
398
  end
362
399
  ```
363
400
 
364
- This example demonstrates how to chain multiple operations, handle potential failures at each step, and return an appropriate outcome with a specific response type. Note how the `with_response` and `with_message` methods are used to set the response and add a message to the outcome.
401
+ Every link here is a **private method of this one interaction** resolving an input, not a separate interaction so nothing in the chain needs a `view_context` of its own, and `Order.place!` stays callable from a job or an API. That's the shape `and_then` is for: composing outcomes inside a single `execute`, not sequencing business operations across interactions.
365
402
 
366
- By following these guidelines and examples, you can effectively implement and use the Interaction pattern in your Rails applications, leading to more maintainable and testable code.
403
+ Note also how `with_response` and `with_message` set the response and attach a message to the outcome.
367
404
 
368
405
  <!--
369
406
 
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Interaction
5
+ module Async
6
+ # Configuration for async interactions. Mirrors
7
+ # Plutonium::Wizard::Configuration: `enabled` gates both the subsystem and
8
+ # its migrations (see Plutonium::Migrations).
9
+ class Configuration
10
+ # @return [Boolean] whether runs (and their migrations) are enabled
11
+ attr_accessor :enabled
12
+
13
+ # @return [Symbol] ActiveJob queue for run jobs
14
+ attr_accessor :queue
15
+
16
+ # @return [ActiveSupport::Duration] how long a run may sit with no
17
+ # progress write before ReapJob considers it stalled
18
+ attr_accessor :stall_after
19
+
20
+ # @return [Symbol, nil] the storage backend used to stage an attachment
21
+ # attribute into the run's options — `:active_storage` or `:shrine`.
22
+ # `nil` falls back to `config.attachment_backend`, then to auto-detection.
23
+ #
24
+ # A file cannot ride the options column: it is JSON, and the request's
25
+ # tempfile is gone by the time the job runs. So an attachment attribute is
26
+ # uploaded to the backend's cache at dispatch and carried as its token.
27
+ # This is the knob for which backend does that, for runs alone.
28
+ attr_accessor :attachment_backend
29
+
30
+ def initialize
31
+ @enabled = false
32
+ @queue = :default
33
+ @stall_after = 1.hour
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end