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,6 +11,16 @@ module Plutonium
11
11
  #
12
12
  # The `scoped_record_of_type` method checks both contexts and ensures type safety.
13
13
  #
14
+ # == Precondition
15
+ #
16
+ # These methods read the controller the interaction is rendering in, so
17
+ # they require a Plutonium controller: `current_scoped_entity` needs
18
+ # Core::Controllers::EntityScoping (which Plutonium::Core::Controller
19
+ # includes, portal or not), and `current_parent` needs
20
+ # Plutonium::Resource::Controller. Called anywhere else they raise
21
+ # NoMethodError, deliberately — see #current_parent. An interaction that
22
+ # can run outside a resource controller must not ask for a parent.
23
+ #
14
24
  # @example Using in an interaction
15
25
  # class MyInteraction < Plutonium::Resource::Interaction
16
26
  # include Plutonium::Interaction::Concerns::Scoping
@@ -33,26 +43,77 @@ module Plutonium
33
43
  #
34
44
  # @param klass [Class] the expected model class
35
45
  # @return [Object, nil] the scoped record if found and type matches, nil otherwise
46
+ # Asks for the parent only if the entity did not already answer.
47
+ #
48
+ # "Prefers entity over parent" has to mean the parent is not consulted at
49
+ # all when the entity matches, or the preference is only about which
50
+ # answer wins rather than which questions get asked. The array form
51
+ # evaluated both before looking at either, so a caller that wanted the
52
+ # TENANT still walked view_context.controller.helpers for a parent whose
53
+ # answer it then discarded.
54
+ #
55
+ # It also keeps the precondition above honest. #current_parent no longer
56
+ # rescues, so it raises wherever Plutonium::Resource::Controller is not
57
+ # in play — and every caller today is an interaction, which always runs
58
+ # under one. Nothing reaches that raise now; asking lazily is what stops
59
+ # a tenant-only caller from being the one that finds it.
36
60
  def scoped_record_of_type(klass)
37
- [current_scoped_entity, current_parent].find { |record| record.is_a?(klass) }
61
+ entity = current_scoped_entity
62
+ return entity if entity.is_a?(klass)
63
+
64
+ parent = current_parent
65
+ parent if parent.is_a?(klass)
38
66
  end
39
67
 
40
68
  # Returns the parent record from the controller (nested routes).
41
69
  #
42
- # @return [Object, nil] the current parent or nil
70
+ # Reached through +helpers+, not off the controller: both readers are
71
+ # PRIVATE controller methods published with +helper_method+ (see
72
+ # Plutonium::Resource::Controller and Core::Controllers::EntityScoping),
73
+ # so calling them on the controller raises NoMethodError.
74
+ #
75
+ # Which is exactly what it should do. Neither reader rescues, because the
76
+ # two answers must never collapse: "there is no parent" and "I could not
77
+ # determine the parent" look identical as nil, and for the tenant below
78
+ # that identity is a fail-OPEN — a nil tenant drops the entity filter
79
+ # rather than narrowing to one. A rescue here bought silence about a
80
+ # broken call for as long as it shipped; the next visibility change or
81
+ # typo would buy the same silence again.
82
+ #
83
+ # nil here means the route is not nested, which is the controller
84
+ # answering rather than an error being swallowed.
85
+ #
86
+ # @return [Object, nil] the current parent, or nil on a non-nested route
43
87
  def current_parent
44
- view_context.controller.current_parent
45
- rescue NoMethodError
46
- nil
88
+ view_context.controller.helpers.current_parent
89
+ end
90
+
91
+ # The association a nested child hangs off its parent, e.g. :comments for
92
+ # /posts/5/comments.
93
+ #
94
+ # Reached through +helpers+ and unrescued for the same reasons as
95
+ # #current_parent above, and always answered together with it: a policy
96
+ # needs both halves or neither (Policy#default_relation_scope raises on
97
+ # one alone).
98
+ #
99
+ # @return [Symbol, nil] nil on a non-nested route
100
+ def current_nested_association
101
+ view_context.controller.helpers.current_nested_association
47
102
  end
48
103
 
49
104
  # Returns the entity record from the controller (portal multi-tenancy).
50
105
  #
51
- # @return [Object, nil] the current scoped entity or nil
106
+ # nil means the portal has NO tenant, never that one could not be found.
107
+ #
108
+ # @return [Object, nil] the current scoped entity, or nil in an un-scoped portal
52
109
  def current_scoped_entity
53
- view_context.controller.current_scoped_entity
54
- rescue NoMethodError
55
- nil
110
+ controller = view_context.controller
111
+ # Asking an un-scoped portal for its entity raises NotImplementedError
112
+ # by design; "there is no tenant" is the honest answer for a caller
113
+ # that only wants to know which one it is in.
114
+ return nil unless controller.scoped_to_entity?
115
+
116
+ controller.helpers.current_scoped_entity
56
117
  end
57
118
 
58
119
  # Returns the appropriate parent for URL generation.
@@ -17,15 +17,23 @@ module Plutonium
17
17
  redirect_options = @options
18
18
 
19
19
  controller.instance_eval do
20
- # Preserve the request format unless explicitly specified
20
+ # Preserve the request format unless explicitly specified.
21
+ #
22
+ # Skipped for a target that is ALREADY A URL. There is nothing to
23
+ # merge a format into, and url_for takes exactly one argument — so
24
+ # appending the options hash raises ArgumentError rather than
25
+ # producing a formatted URL. A caller handing over a complete URL has
26
+ # already decided what it points at.
27
+ fully_formed_url = redirect_args.size == 1 && redirect_args.first.is_a?(String)
28
+
21
29
  url_options = redirect_args.last.is_a?(Hash) ? redirect_args.last : {}
22
- if !url_options.key?(:format) && request.format.symbol != :html
30
+ if !fully_formed_url && !url_options.key?(:format) && request.format.symbol != :html
23
31
  url_options = url_options.merge(format: request.format.symbol)
24
32
  redirect_args = [*redirect_args[0...-1], url_options] if redirect_args.last.is_a?(Hash)
25
33
  redirect_args = [*redirect_args, url_options] unless redirect_args.last.is_a?(Hash)
26
34
  end
27
35
 
28
- url = url_for(*redirect_args)
36
+ url = fully_formed_url ? redirect_args.first : url_for(*redirect_args)
29
37
 
30
38
  respond_to do |format|
31
39
  format.turbo_stream do
@@ -35,6 +35,20 @@ module Plutonium
35
35
  # Resolves the board's effective show_in, falling back to the definition's
36
36
  # `show_in` when the board doesn't override it. Pass the resource definition.
37
37
  def show_in_for(definition) = @show_in || definition.show_in
38
+
39
+ # The board's positioning strategy: its own `position_on` if the block
40
+ # declared one, else the definition's, else the historic default.
41
+ #
42
+ # Resolved LAZILY, not at build time: `kanban` eagerly compiles the board
43
+ # at class-load (definition/index_views.rb), so a board built before a
44
+ # later `position_on` line would silently miss it — making the declaration
45
+ # order-dependent in the class body.
46
+ #
47
+ # Takes the resource definition, exactly like #show_in_for above.
48
+ def position_config_for(definition)
49
+ @position_config || definition.defined_position_config ||
50
+ Plutonium::Positioning::Config.default
51
+ end
38
52
  end
39
53
  end
40
54
  end
@@ -32,8 +32,10 @@ module Plutonium
32
32
  # `resource` attribute), never collection/bulk-shaped (`resources`). Reject
33
33
  # anything else at definition time so a mis-shaped interaction can't (a) blow
34
34
  # up at drop time on the `resource=` assignment, or (b) get auto-classified
35
- # by Action::Interactive::Factory as a bulk action and leak into the
36
- # bulk-actions bar (which does not filter kanban_drop actions).
35
+ # by Action::Interactive::Factory as a bulk action. The bulk bars now
36
+ # filter `hidden?` themselves, so (b) is defence-in-depth but keep the
37
+ # guard: failing at definition time with a message that names the fix
38
+ # beats failing at drop time on the `resource=` assignment.
37
39
  if enter_interaction && !enter_interaction.attribute_names.map(&:to_sym).include?(:resource)
38
40
  raise ArgumentError, "enter_interaction: #{enter_interaction} must operate on a single record (declare `attribute :resource`); collection/bulk interactions cannot be used as an enter_interaction."
39
41
  end
@@ -15,7 +15,10 @@ module Plutonium
15
15
  @card_fields = nil
16
16
  @per_column = nil
17
17
  @realtime = false
18
- @position_config = Positioning::Config.default
18
+ # nil means "not declared" so the board can inherit the definition's
19
+ # position_on. Resolution — including the historic Config.default
20
+ # fallback — moves to Board#position_config_for.
21
+ @position_config = nil
19
22
  @lazy = true
20
23
  @show_in = nil
21
24
  end
@@ -2,33 +2,20 @@
2
2
 
3
3
  module Plutonium
4
4
  module Kanban
5
- # Groups an already-authorized, query-applied, UN-paginated relation into
6
- # ordered, per_column-capped column entries.
5
+ # The two primitives every kanban rendering path shares: which columns a
6
+ # board has, and how a column's scope narrows a relation.
7
+ #
8
+ # There is deliberately no `call` that groups a whole relation in one go.
9
+ # One existed and had no callers: KanbanActions renders columns
10
+ # independently — each is its own lazy turbo-frame with its own per_column
11
+ # cap and count — so a whole-board grouper only ever duplicated that logic
12
+ # while quietly counting and limiting every column on every request.
7
13
  module Grouping
8
14
  module_function
9
15
 
10
- # Returns [{column:, cards: [records], total: Integer}, ...] in column order.
11
- def call(board:, relation:, context:)
12
- columns = resolve_columns(board, context)
13
- pos = board.position_config
14
- columns.map do |col|
15
- scoped = apply_scope(relation, col.scope)
16
- ordered = pos.order(scoped)
17
- if board.per_column
18
- total = ordered.count
19
- cards = ordered.limit(board.per_column).to_a
20
- else
21
- cards = ordered.to_a
22
- total = cards.size
23
- end
24
- {column: col, cards: cards, total: total}
25
- end
26
- end
27
-
28
16
  # Resolves the column list from a board. For dynamic boards, evaluates
29
17
  # the columns_block against the context (which exposes current_user,
30
- # params, etc. via delegation to view_context). Public so Task 7 (move
31
- # handler) can call Grouping.resolve_columns(board, context) directly.
18
+ # params, etc. via delegation to view_context).
32
19
  def resolve_columns(board, context)
33
20
  return board.columns unless board.dynamic?
34
21
  Array(context.instance_exec(&board.columns_block)).flatten
@@ -4,72 +4,12 @@ require "plutonium/positioning"
4
4
 
5
5
  module Plutonium
6
6
  module Kanban
7
+ # Kanban's positioning strategy is the framework-wide one — see
8
+ # Plutonium::Positioning::Config. This alias preserves the original
9
+ # namespace so `position_on` and every existing reference keep working.
7
10
  module Positioning
8
- # Value object passed to Mode B blocks, carrying the full drop context.
9
- Move = Data.define(:record, :column, :prev, :next, :index)
10
-
11
- # Strategy configuration object created by the `position_on` DSL.
12
- #
13
- # Three modes:
14
- # Mode A (:delegate) — delegate reposition! to Plutonium::Positioning concern
15
- # Mode B (:block) — call a user-supplied block with a Move
16
- # Mode C (:disabled) — no ordering; relation returned unchanged
17
- class Config
18
- # Mode A, default attribute :position
19
- def self.default
20
- new(:delegate, :position, nil)
21
- end
22
-
23
- # Mode A, custom attribute
24
- def self.attribute(attr)
25
- new(:delegate, attr.to_sym, nil)
26
- end
27
-
28
- # Mode B — orders by attr, write delegated to block
29
- def self.with_block(attr, block)
30
- new(:block, attr.to_sym, block)
31
- end
32
-
33
- # Mode C — disabled
34
- def self.disabled
35
- new(:disabled, nil, nil)
36
- end
37
-
38
- attr_reader :attribute
39
-
40
- def initialize(mode, attribute, block)
41
- @mode = mode
42
- @attribute = attribute
43
- @block = block
44
- end
45
-
46
- def disabled?
47
- @mode == :disabled
48
- end
49
-
50
- # Apply positional ordering to a relation.
51
- # Mode A/B: relation.reorder(attribute)
52
- # Mode C: return relation unchanged
53
- def order(relation)
54
- return relation if disabled?
55
- relation.reorder(@attribute)
56
- end
57
-
58
- # Persist the new position for a dropped record.
59
- # Mode A: delegate to record.reposition!(prev_record:, next_record:)
60
- # Mode B: call the user block with a Move
61
- # Mode C: no-op
62
- def reposition!(record:, column:, prev_record:, next_record:, index:)
63
- case @mode
64
- when :delegate
65
- record.reposition!(prev_record:, next_record:)
66
- when :block
67
- @block.call(Move.new(record:, column:, prev: prev_record, next: next_record, index:))
68
- when :disabled
69
- nil
70
- end
71
- end
72
- end
11
+ Config = Plutonium::Positioning::Config
12
+ Move = Plutonium::Positioning::Move
73
13
  end
74
14
  end
75
15
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ # This file is deliberately dependency-free — no ActiveSupport, no
5
+ # ActiveRecord, not even the rest of Plutonium::Positioning. A controller,
6
+ # a definition DSL and a view component all need to name a strategy, and
7
+ # none of them should have to drag the model concern in to do it.
8
+ module Positioning
9
+ # Value object passed to Mode B blocks, carrying the full drop context.
10
+ Move = Data.define(:record, :column, :prev, :next, :index)
11
+
12
+ # Strategy configuration object created by the `position_on` DSL.
13
+ #
14
+ # Three modes:
15
+ # Mode A (:delegate) — delegate reposition! to Plutonium::Positioning::Model
16
+ # Mode B (:block) — call a user-supplied block with a Move
17
+ # Mode C (:disabled) — no ordering; relation returned unchanged
18
+ class Config
19
+ # Mode A, default attribute :position
20
+ def self.default
21
+ new(:delegate, :position, nil)
22
+ end
23
+
24
+ # Mode A, custom attribute
25
+ def self.attribute(attr)
26
+ new(:delegate, attr.to_sym, nil)
27
+ end
28
+
29
+ # Mode B — orders by attr, write delegated to block
30
+ def self.with_block(attr, block)
31
+ new(:block, attr.to_sym, block)
32
+ end
33
+
34
+ # Mode C — disabled
35
+ def self.disabled
36
+ new(:disabled, nil, nil)
37
+ end
38
+
39
+ attr_reader :attribute
40
+
41
+ def initialize(mode, attribute, block)
42
+ @mode = mode
43
+ @attribute = attribute
44
+ @block = block
45
+ end
46
+
47
+ def disabled?
48
+ @mode == :disabled
49
+ end
50
+
51
+ # Mode A — the framework owns the write, via Positioning::Model. Callers
52
+ # ask because Mode A is the only mode whose storage they may reason about:
53
+ # a fractional position between two neighbours, in a known scope group. A
54
+ # Mode B block owns both the storage and its own notion of neighbours, so
55
+ # nothing outside it may second-guess the drop it is handed.
56
+ def delegate?
57
+ @mode == :delegate
58
+ end
59
+
60
+ # Apply positional ordering to a relation.
61
+ # Mode A/B: relation.reorder(attribute)
62
+ # Mode C: return relation unchanged
63
+ def order(relation)
64
+ return relation if disabled?
65
+ relation.reorder(@attribute)
66
+ end
67
+
68
+ # Persist the new position for a dropped record. Returns true when the
69
+ # caller must reconcile its view of the list because positions other than
70
+ # this record's may have changed.
71
+ #
72
+ # Mode A: delegate to record.reposition!(prev_record:, next_record:), and
73
+ # report whether it had to rebalance the scope group.
74
+ # Mode B: call the user block with a Move; always true — the block is an
75
+ # opaque write, and gems in this space routinely renumber the whole
76
+ # group (acts_as_list does).
77
+ # Mode C: no-op; always false — nothing was written.
78
+ #
79
+ # `column` is the kanban column key on a board, and nil on every other
80
+ # surface (index tables, nested tables, grids) — those have no columns.
81
+ def reposition!(record:, prev_record:, next_record:, index:, column: nil)
82
+ case @mode
83
+ when :delegate
84
+ record.reposition!(prev_record:, next_record:).rebalanced?
85
+ when :block
86
+ @block.call(Move.new(record:, column:, prev: prev_record, next: next_record, index:))
87
+ true
88
+ when :disabled
89
+ false
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Positioning
5
+ # Standalone decimal/fractional ordering. Kanban-independent.
6
+ #
7
+ # Including this concern and calling `positioned_on` gives a model:
8
+ # - automatic position assignment on create (appends to the end of its scope group)
9
+ # - `reposition!(prev_record:, next_record:)` for drag-and-drop reordering
10
+ # - `backfill_positions!` class method to number existing rows
11
+ #
12
+ # This lives one level below Plutonium::Positioning on purpose. A concern is
13
+ # mixed into user models, and every constant nested in the included module
14
+ # joins that model's constant lookup — so a concern named Positioning would
15
+ # make a bare `Config` inside the model resolve to
16
+ # Plutonium::Positioning::Config, silently shadowing the app's own ::Config.
17
+ # Keeping the namespace and the mixin separate stops the leak.
18
+ module Model
19
+ extend ActiveSupport::Concern
20
+
21
+ included do
22
+ class_attribute :positioning_column, instance_accessor: false, default: :position
23
+ class_attribute :positioning_scope_attr, instance_accessor: false, default: nil
24
+ # Separate from positioning_column, which has a default and so cannot
25
+ # answer "did this model actually call positioned_on?". Including the
26
+ # concern without calling it skips `before_create :assign_initial_position`
27
+ # entirely — every row keeps a nil position and the order is arbitrary —
28
+ # so the definition DSL checks this flag, not mere inclusion.
29
+ class_attribute :positioning_declared, instance_accessor: false, default: false
30
+ end
31
+
32
+ class_methods do
33
+ # Opt in to positional ordering.
34
+ #
35
+ # positioned_on :position, scope: :status
36
+ #
37
+ # @param column [Symbol] the decimal column that stores positions
38
+ # @param scope [Symbol, nil] group rows by this column; nil = single global group
39
+ def positioned_on(column = :position, scope: nil)
40
+ self.positioning_column = column
41
+ self.positioning_scope_attr = scope
42
+ self.positioning_declared = true
43
+ before_create :assign_initial_position
44
+ end
45
+
46
+ # Number every row in the table per scope group as 1.0, 2.0, … in
47
+ # +order+ order. Safe to call on an empty table.
48
+ #
49
+ # @param order [Symbol] column to sort by when assigning positions
50
+ def backfill_positions!(order: :created_at)
51
+ groups = positioning_scope_attr ? all.group_by(&positioning_scope_attr) : {nil => all.to_a}
52
+ groups.each_value do |rows|
53
+ # `transaction` on THIS class, never ActiveRecord::Base.transaction.
54
+ # A transaction is opened on the receiver's connection pool; the
55
+ # UPDATEs below go out on this model's pool (update_column →
56
+ # self.class._update_record). For a model on a secondary database
57
+ # those are two different connections: ActiveRecord::Base would BEGIN
58
+ # on primary and do nothing else with it while every UPDATE
59
+ # autocommits on the model's own connection — a crash halfway leaves
60
+ # the group partly renumbered, with duplicate/gapped positions and
61
+ # nothing to roll back. Do not "simplify" this back.
62
+ transaction do
63
+ rows.sort_by { |r| r.public_send(order) }.each_with_index do |row, i|
64
+ row.update_column(positioning_column, (i + 1).to_f)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ # Move this record so it sits between +prev_record+ and +next_record+
72
+ # within its scope group. Pass nil for either neighbor to move to an end.
73
+ #
74
+ # If the gap between the two neighbors is exhausted (too small to split)
75
+ # the scope group is rebalanced first so that fresh integer positions are
76
+ # available, then the record is positioned between the reloaded neighbors.
77
+ #
78
+ # @param prev_record [ActiveRecord::Base, nil]
79
+ # @param next_record [ActiveRecord::Base, nil]
80
+ # @return [Plutonium::Positioning::Result] whose `rebalanced?` tells the
81
+ # caller whether rows OTHER than this one moved — a drag-and-drop client
82
+ # that optimistically reordered its own view needs to re-sync when so.
83
+ def reposition!(prev_record:, next_record:)
84
+ col = self.class.positioning_column
85
+ prev_val = prev_record&.public_send(col)
86
+ next_val = next_record&.public_send(col)
87
+ rebalanced = Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
88
+ if rebalanced
89
+ rebalance_scope_group!
90
+ prev_val = prev_record&.reload&.public_send(col)
91
+ next_val = next_record&.reload&.public_send(col)
92
+ end
93
+ update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
94
+ Plutonium::Positioning::Result.new(rebalanced:)
95
+ end
96
+
97
+ private
98
+
99
+ def assign_initial_position
100
+ col = self.class.positioning_column
101
+ return if public_send(col).present?
102
+ max = positioning_group_relation.maximum(col) || 0.0
103
+ public_send(:"#{col}=", max + 1)
104
+ end
105
+
106
+ def positioning_group_relation
107
+ rel = self.class.all
108
+ attr = self.class.positioning_scope_attr
109
+ attr ? rel.where(attr => public_send(attr)) : rel
110
+ end
111
+
112
+ def rebalance_scope_group!
113
+ col = self.class.positioning_column
114
+ # self.class.transaction, never ActiveRecord::Base.transaction — the
115
+ # BEGIN must land on the connection the UPDATEs below actually use. See
116
+ # the note in backfill_positions!: on a secondary-database model the
117
+ # ActiveRecord::Base form opens an empty transaction on primary and every
118
+ # renumbering UPDATE autocommits unprotected, so a failure mid-rebalance
119
+ # is unrecoverable. Rebalancing is exactly where that matters.
120
+ self.class.transaction do
121
+ positioning_group_relation.order(col).each_with_index do |row, i|
122
+ row.update_column(col, (i + 1).to_f)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -1,22 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # require_relative, not require: test/positioning_postgres_check.rb loads this
4
+ # file directly with require_relative and no $LOAD_PATH setup — it is
5
+ # deliberately framework-free, needing only activerecord + pg. A plain `require`
6
+ # resolves against $LOAD_PATH and so fails there. Matches how lib/plutonium.rb
7
+ # pulls in its own files.
8
+ require_relative "positioning/config"
9
+ require_relative "positioning/model"
10
+
3
11
  module Plutonium
4
- # Standalone decimal/fractional ordering. Kanban-independent.
12
+ # Namespace for decimal/fractional ordering. Kanban-independent.
5
13
  #
6
- # Including this concern and calling `positioned_on` gives a model:
7
- # - automatic position assignment on create (appends to the end of its scope group)
8
- # - `reposition!(prev_record:, next_record:)` for drag-and-drop reordering
9
- # - `backfill_positions!` class method to number existing rows
14
+ # This module is a pure namespace it is never included into a model. The
15
+ # AR mixin is Plutonium::Positioning::Model and the drag strategy object is
16
+ # Plutonium::Positioning::Config.
10
17
  #
11
18
  # Pure math helpers are exposed as module-level methods so they can be
12
19
  # called without an AR instance:
13
20
  # Plutonium::Positioning.position_between(1.0, 3.0) # => 2.0
14
21
  # Plutonium::Positioning.gap_exhausted?(1.0, 1.0) # => true
15
22
  module Positioning
16
- extend ActiveSupport::Concern
17
-
18
23
  EPSILON = 1e-6
19
24
 
25
+ # Outcome of a Model#reposition! call. `rebalanced?` is true when the gap
26
+ # between the neighbours was exhausted and the whole scope group had to be
27
+ # renumbered — the caller needs to know because every other row's position
28
+ # changed, so any cached client-side view of the list is stale.
29
+ #
30
+ # It lives on the namespace, NOT inside Model, for the same reason the
31
+ # concern was split out of the namespace in the first place: Model is
32
+ # included into user models, so anything nested in it joins their constant
33
+ # lookup and can shadow their own constants (see the comment in model.rb).
34
+ Result = Data.define(:rebalanced) do
35
+ def rebalanced? = rebalanced
36
+ end
37
+
20
38
  # Returns the position that sits between +prev_val+ and +next_val+.
21
39
  #
22
40
  # Rules:
@@ -38,85 +56,6 @@ module Plutonium
38
56
  (next_val - prev_val).abs < EPSILON
39
57
  end
40
58
 
41
- included do
42
- class_attribute :positioning_column, instance_accessor: false, default: :position
43
- class_attribute :positioning_scope_attr, instance_accessor: false, default: nil
44
- end
45
-
46
- class_methods do
47
- # Opt in to positional ordering.
48
- #
49
- # positioned_on :position, scope: :status
50
- #
51
- # @param column [Symbol] the decimal column that stores positions
52
- # @param scope [Symbol, nil] group rows by this column; nil = single global group
53
- def positioned_on(column = :position, scope: nil)
54
- self.positioning_column = column
55
- self.positioning_scope_attr = scope
56
- before_create :assign_initial_position
57
- end
58
-
59
- # Number every row in the table per scope group as 1.0, 2.0, … in
60
- # +order+ order. Safe to call on an empty table.
61
- #
62
- # @param order [Symbol] column to sort by when assigning positions
63
- def backfill_positions!(order: :created_at)
64
- groups = positioning_scope_attr ? all.group_by(&positioning_scope_attr) : {nil => all.to_a}
65
- groups.each_value do |rows|
66
- ActiveRecord::Base.transaction do
67
- rows.sort_by { |r| r.public_send(order) }.each_with_index do |row, i|
68
- row.update_column(positioning_column, (i + 1).to_f)
69
- end
70
- end
71
- end
72
- end
73
- end
74
-
75
- # Move this record so it sits between +prev_record+ and +next_record+
76
- # within its scope group. Pass nil for either neighbor to move to an end.
77
- #
78
- # If the gap between the two neighbors is exhausted (too small to split)
79
- # the scope group is rebalanced first so that fresh integer positions are
80
- # available, then the record is positioned between the reloaded neighbors.
81
- #
82
- # @param prev_record [ActiveRecord::Base, nil]
83
- # @param next_record [ActiveRecord::Base, nil]
84
- def reposition!(prev_record:, next_record:)
85
- col = self.class.positioning_column
86
- prev_val = prev_record&.public_send(col)
87
- next_val = next_record&.public_send(col)
88
- if Plutonium::Positioning.gap_exhausted?(prev_val, next_val)
89
- rebalance_scope_group!
90
- prev_val = prev_record&.reload&.public_send(col)
91
- next_val = next_record&.reload&.public_send(col)
92
- end
93
- update!(col => Plutonium::Positioning.position_between(prev_val, next_val))
94
- end
95
-
96
- private
97
-
98
- def assign_initial_position
99
- col = self.class.positioning_column
100
- return if public_send(col).present?
101
- max = positioning_group_relation.maximum(col) || 0.0
102
- public_send(:"#{col}=", max + 1)
103
- end
104
-
105
- def positioning_group_relation
106
- rel = self.class.all
107
- attr = self.class.positioning_scope_attr
108
- attr ? rel.where(attr => public_send(attr)) : rel
109
- end
110
-
111
- def rebalance_scope_group!
112
- col = self.class.positioning_column
113
- ActiveRecord::Base.transaction do
114
- positioning_group_relation.order(col).each_with_index do |row, i|
115
- row.update_column(col, (i + 1).to_f)
116
- end
117
- end
118
- end
119
-
120
59
  # Migration helper that adds a position column pre-tuned for fractional
121
60
  # ordering. Mixed into ActiveRecord's table-definition classes by the
122
61
  # railtie, so it is available in both create_table and change_table:
@@ -41,6 +41,7 @@ module Plutonium
41
41
 
42
42
  initializer "plutonium.register_migrations" do
43
43
  Plutonium::Migrations.register(:wizards, Plutonium.root.join("db/migrate/wizard").to_s)
44
+ Plutonium::Migrations.register(:async_interactions, Plutonium.root.join("db/migrate/async_interactions").to_s)
44
45
  end
45
46
 
46
47
  # Runs after the host's config/initializers/plutonium.rb (load_config_initializers)