plutonium 0.62.2 → 0.64.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 (256) 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/.standard.yml +1 -1
  13. data/CHANGELOG.md +73 -0
  14. data/CLAUDE.md +87 -0
  15. data/Rakefile +34 -0
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/app/assets/plutonium.js +685 -102
  19. data/app/assets/plutonium.js.map +4 -4
  20. data/app/assets/plutonium.min.js +53 -53
  21. data/app/assets/plutonium.min.js.map +4 -4
  22. data/app/views/rodauth/_login_form.html.erb +13 -0
  23. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  24. data/docs/.vitepress/config.ts +81 -3
  25. data/docs/.vitepress/theme/blog.data.ts +44 -0
  26. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  27. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  28. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  29. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  30. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  31. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  32. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  33. data/docs/.vitepress/theme/index.ts +8 -4
  34. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  35. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  36. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  37. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  38. data/docs/blog/index.md +13 -0
  39. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  40. data/docs/blog/introducing-plutonium.md +253 -0
  41. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  42. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  43. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  44. data/docs/blog/two-forms-one-dom-id.md +69 -0
  45. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  46. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  47. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  48. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  49. data/docs/guides/authentication.md +73 -0
  50. data/docs/guides/authorization.md +2 -0
  51. data/docs/guides/creating-packages.md +5 -3
  52. data/docs/guides/custom-actions.md +74 -12
  53. data/docs/guides/customizing-ui.md +9 -2
  54. data/docs/guides/index.md +1 -0
  55. data/docs/guides/kanban.md +7 -5
  56. data/docs/guides/nested-resources.md +11 -1
  57. data/docs/guides/performance.md +104 -0
  58. data/docs/guides/user-invites.md +1 -1
  59. data/docs/guides/wizards.md +9 -1
  60. data/docs/index.md +3 -3
  61. data/docs/public/images/home/tour-actions.png +0 -0
  62. data/docs/public/images/home/tour-async.png +0 -0
  63. data/docs/public/images/home/tour-kanban.png +0 -0
  64. data/docs/public/images/home/tour-tenancy.png +0 -0
  65. data/docs/public/images/home/tour-wizard.png +0 -0
  66. data/docs/public/images/reference/async-progress-page.png +0 -0
  67. data/docs/public/images/reference/async-running-banner.png +0 -0
  68. data/docs/public/templates/experimental.rb +34 -0
  69. data/docs/public/templates/pluton8.rb +14 -0
  70. data/docs/reference/app/portals.md +15 -3
  71. data/docs/reference/auth/accounts.md +19 -0
  72. data/docs/reference/behavior/async-interactions.md +295 -0
  73. data/docs/reference/behavior/controllers.md +17 -4
  74. data/docs/reference/behavior/index.md +7 -1
  75. data/docs/reference/behavior/interactions.md +152 -22
  76. data/docs/reference/configuration.md +5 -0
  77. data/docs/reference/index.md +1 -0
  78. data/docs/reference/kanban/dsl.md +7 -4
  79. data/docs/reference/kanban/index.md +1 -1
  80. data/docs/reference/kanban/positioning.md +26 -4
  81. data/docs/reference/positioning.md +568 -0
  82. data/docs/reference/resource/actions.md +97 -4
  83. data/docs/reference/resource/definition.md +181 -9
  84. data/docs/reference/tenancy/invites.md +1 -1
  85. data/docs/reference/tenancy/nested-resources.md +60 -2
  86. data/docs/reference/ui/assets.md +4 -0
  87. data/docs/reference/ui/components.md +57 -4
  88. data/docs/reference/ui/displays.md +20 -10
  89. data/docs/reference/ui/index.md +1 -1
  90. data/docs/reference/wizard/dsl.md +33 -0
  91. data/docs/reference/wizard/storage-config.md +1 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  93. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  95. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  97. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  98. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  99. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  100. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  101. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  102. data/gemfiles/postgres.gemfile.lock +85 -85
  103. data/gemfiles/rails_7.gemfile.lock +323 -141
  104. data/gemfiles/rails_8.0.gemfile.lock +126 -116
  105. data/gemfiles/rails_8.1.gemfile.lock +127 -117
  106. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  107. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  108. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  109. data/lib/generators/pu/invites/install_generator.rb +3 -3
  110. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  112. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  113. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  114. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  115. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  116. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  118. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  119. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  120. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  121. data/lib/plutonium/action/base.rb +71 -9
  122. data/lib/plutonium/action/interactive.rb +9 -0
  123. data/lib/plutonium/attachments.rb +254 -0
  124. data/lib/plutonium/configuration.rb +82 -1
  125. data/lib/plutonium/core/controller.rb +50 -7
  126. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  127. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  128. data/lib/plutonium/definition/base.rb +51 -0
  129. data/lib/plutonium/definition/display_layout.rb +112 -0
  130. data/lib/plutonium/definition/index_views.rb +8 -7
  131. data/lib/plutonium/definition/input_aliases.rb +38 -0
  132. data/lib/plutonium/definition/page_widths.rb +65 -0
  133. data/lib/plutonium/definition/positioning.rb +126 -0
  134. data/lib/plutonium/definition/sorting.rb +17 -2
  135. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  136. data/lib/plutonium/interaction/README.md +61 -24
  137. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  138. data/lib/plutonium/interaction/async/context.rb +419 -0
  139. data/lib/plutonium/interaction/async/executor.rb +422 -0
  140. data/lib/plutonium/interaction/async/job.rb +80 -0
  141. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  142. data/lib/plutonium/interaction/async/run.rb +394 -0
  143. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  144. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  145. data/lib/plutonium/interaction/base.rb +34 -7
  146. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  147. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  148. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  149. data/lib/plutonium/kanban/board.rb +14 -0
  150. data/lib/plutonium/kanban/column.rb +4 -2
  151. data/lib/plutonium/kanban/dsl.rb +4 -1
  152. data/lib/plutonium/kanban/grouping.rb +9 -22
  153. data/lib/plutonium/kanban/positioning.rb +5 -65
  154. data/lib/plutonium/positioning/config.rb +94 -0
  155. data/lib/plutonium/positioning/model.rb +128 -0
  156. data/lib/plutonium/positioning.rb +25 -86
  157. data/lib/plutonium/railtie.rb +1 -0
  158. data/lib/plutonium/resource/controller.rb +118 -38
  159. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  160. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  161. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  162. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  163. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  164. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  165. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  166. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  167. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  168. data/lib/plutonium/resource/policy.rb +33 -0
  169. data/lib/plutonium/resource/query_object.rb +36 -0
  170. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  171. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  172. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  173. data/lib/plutonium/testing/resource_policy.rb +6 -2
  174. data/lib/plutonium/ui/action_button.rb +12 -7
  175. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  176. data/lib/plutonium/ui/block.rb +21 -1
  177. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  178. data/lib/plutonium/ui/component/methods.rb +5 -0
  179. data/lib/plutonium/ui/component/positionable.rb +112 -0
  180. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  181. data/lib/plutonium/ui/component/section.rb +185 -0
  182. data/lib/plutonium/ui/display/base.rb +13 -1
  183. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  184. data/lib/plutonium/ui/display/components/section.rb +18 -0
  185. data/lib/plutonium/ui/display/resource.rb +141 -22
  186. data/lib/plutonium/ui/display/theme.rb +20 -1
  187. data/lib/plutonium/ui/export_button.rb +1 -1
  188. data/lib/plutonium/ui/form/base.rb +8 -7
  189. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  190. data/lib/plutonium/ui/form/components/section.rb +7 -62
  191. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  192. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  193. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  194. data/lib/plutonium/ui/form/query.rb +2 -4
  195. data/lib/plutonium/ui/form/resource.rb +92 -15
  196. data/lib/plutonium/ui/form/theme.rb +17 -0
  197. data/lib/plutonium/ui/form/wizard.rb +25 -1
  198. data/lib/plutonium/ui/grid/card.rb +79 -11
  199. data/lib/plutonium/ui/grid/resource.rb +47 -5
  200. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  201. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  202. data/lib/plutonium/ui/kanban/card.rb +2 -1
  203. data/lib/plutonium/ui/kanban/column.rb +12 -6
  204. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  205. data/lib/plutonium/ui/layout/base.rb +10 -3
  206. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  207. data/lib/plutonium/ui/page/base.rb +19 -0
  208. data/lib/plutonium/ui/page/edit.rb +4 -1
  209. data/lib/plutonium/ui/page/index.rb +69 -18
  210. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  211. data/lib/plutonium/ui/page/new.rb +4 -1
  212. data/lib/plutonium/ui/page/show.rb +27 -10
  213. data/lib/plutonium/ui/page/wizard.rb +10 -1
  214. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  215. data/lib/plutonium/ui/page_width.rb +58 -0
  216. data/lib/plutonium/ui/table/base.rb +34 -1
  217. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  218. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  219. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  220. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  221. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  222. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  223. data/lib/plutonium/ui/table/resource.rb +50 -5
  224. data/lib/plutonium/ui/table/theme.rb +59 -2
  225. data/lib/plutonium/ui/wizard/review.rb +4 -2
  226. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  227. data/lib/plutonium/version.rb +1 -1
  228. data/lib/plutonium/wizard/attachments.rb +32 -197
  229. data/lib/plutonium/wizard/base.rb +6 -1
  230. data/lib/plutonium/wizard/configuration.rb +12 -0
  231. data/lib/plutonium/wizard/controller.rb +14 -0
  232. data/lib/plutonium/wizard/driving.rb +99 -15
  233. data/lib/plutonium/wizard/dsl.rb +23 -0
  234. data/lib/plutonium/wizard/resume.rb +127 -49
  235. data/lib/plutonium/wizard/runner.rb +46 -2
  236. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  237. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  238. data/lib/plutonium.rb +21 -0
  239. data/lib/rodauth/features/session_isolation.rb +92 -0
  240. data/lib/rodauth/plugins.rb +1 -0
  241. data/package.json +2 -1
  242. data/plutonium.gemspec +20 -20
  243. data/src/css/components.css +89 -1
  244. data/src/css/slim_select.css +20 -0
  245. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  246. data/src/js/controllers/bulk_actions_controller.js +10 -2
  247. data/src/js/controllers/kanban_controller.js +30 -21
  248. data/src/js/controllers/positioned_controller.js +452 -0
  249. data/src/js/controllers/register_controllers.js +6 -0
  250. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  251. data/src/js/controllers/run_progress_controller.js +73 -0
  252. data/src/js/drag/sortable.js +186 -0
  253. data/yarn.lock +108 -63
  254. metadata +101 -22
  255. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  256. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Resource
5
+ module Controllers
6
+ # Drag-reorder endpoint for resources whose definition declares
7
+ # `position_on` — index tables, nested association tables, and grids.
8
+ # (The kanban board has its own richer endpoint: KanbanActions#kanban_move.)
9
+ #
10
+ # ## The request
11
+ #
12
+ # POST <member>/reposition?<the collection's own query string>
13
+ # params: prev_id, next_id, to_index
14
+ #
15
+ # prev_id/next_id are the ids of the dropped row's VISIBLE neighbours,
16
+ # either nullable for a drop at an end of the VIEWPORT. A blank id is a
17
+ # claim about the client's page, never about the positioning group: rows
18
+ # hidden by pagination or a filter may still sit beyond it, and Mode A
19
+ # looks the real boundary neighbour up rather than anchoring off nil (see
20
+ # #resolve_position_boundaries — this is what keeps a bottom-of-page drop
21
+ # from writing a duplicate position).
22
+ #
23
+ # The trailing query string is load-bearing, not decoration: it is the
24
+ # index's own query (search / filters / scope / sort / page / view), and it
25
+ # is what lets this action re-render exactly the page the user is looking
26
+ # at — same filters, same page of results — using the ordinary index
27
+ # pipeline rather than a parallel one. Task 7's client must therefore post
28
+ # to the member reposition path with `window.location.search` appended.
29
+ #
30
+ # ## The response
31
+ #
32
+ # The client moves the row optimistically, so a clean drop answers 204 and
33
+ # nothing repaints. The collection is streamed back only when the client's
34
+ # view is or may be stale:
35
+ #
36
+ # clean Mode A drop, both neighbours resolved → 204 No Content
37
+ # reposition! rebalanced the group → 200 + stream
38
+ # a neighbour did not resolve (drift) → 200 + stream
39
+ # a neighbour belongs to another positioning
40
+ # group → 200 + stream + toast
41
+ # Mode B (opaque block write) → 200 + stream
42
+ # denied reposition? → 403 + stream + toast
43
+ # denied index? → 403, no body
44
+ # dropped under a foreign sort → 422 + stream + toast
45
+ # validation failure / record gone → 422 + stream + toast
46
+ # no position_on, Mode C, or a kanban view → 404
47
+ #
48
+ # ## DOM contract (Tasks 7 & 8 depend on these)
49
+ #
50
+ # #pu-collection-<plural> the wrapper around the rendered table/grid
51
+ # (Plutonium::UI::Page::Index.collection_dom_id)
52
+ # — the stream target, and the element the drag
53
+ # controller scopes itself to.
54
+ # #pu-flash the page's toast region (Plutonium::FLASH_REGION),
55
+ # deliberately OUTSIDE the collection wrapper so
56
+ # replacing the collection cannot destroy it.
57
+ module PositionActions
58
+ extend ActiveSupport::Concern
59
+
60
+ # POST <member>/reposition
61
+ def reposition
62
+ config = current_definition.defined_position_config
63
+
64
+ # Not a reorderable surface — a 404, not an authorization failure.
65
+ # Either the resource declares no ordering at all (or Mode C), or the
66
+ # client is on the kanban board, which owns a richer endpoint of its
67
+ # own (kanban_move) and renders no collection wrapper for the
68
+ # reconciliation to target. Checked BEFORE the record is loaded (no
69
+ # point querying for a route that doesn't apply), which means neither
70
+ # verifier has been satisfied by the time we bail — so satisfy both.
71
+ if config.nil? || config.disabled? || position_collection_view == :kanban
72
+ skip_verify_authorize_current!
73
+ skip_verify_current_authorized_scope!
74
+ head :not_found
75
+ return
76
+ end
77
+
78
+ # You must be able to SEE a list to reorder it. Without this, a policy
79
+ # with `index? == false, update? == true` would be handed the whole
80
+ # listing by the reconciliation render below. Checked before the
81
+ # record is loaded so the refusal never touches the collection.
82
+ authorize_current! resource_class, to: :index?
83
+
84
+ record = current_authorized_scope.find(params[:id])
85
+ authorize_current! record, to: :reposition?
86
+
87
+ # Dropping "between the two rows either side of me" only means
88
+ # something when the visual order IS the stored order. Task 7's client
89
+ # doesn't offer the drag under a foreign sort; this is the server-side
90
+ # authority behind that, and it rejects BEFORE any write rather than
91
+ # writing a position derived from neighbours that never implied one.
92
+ if config.delegate? && !current_query_object.sorted_ascending_only_by?(config.attribute)
93
+ return render_position_reconciliation(
94
+ reason: "Reordering is only available while the list is sorted by #{config.attribute.to_s.humanize.downcase}."
95
+ )
96
+ end
97
+
98
+ # Resolve neighbours WITHIN the authorized scope. A nil here would mean
99
+ # "drop at the end", so an id that does not resolve must be treated as
100
+ # drift and reconciled — never silently coerced to nil.
101
+ prev_record, prev_ok, prev_drift = resolve_position_neighbour(params[:prev_id], record:, config:)
102
+ next_record, next_ok, next_drift = resolve_position_neighbour(params[:next_id], record:, config:)
103
+ foreign_group = [prev_drift, next_drift].include?(:foreign_group)
104
+ prev_record, next_record = resolve_position_boundaries(record, config, prev_record, next_record)
105
+
106
+ # Nothing left to anchor to. In Mode A that is never a real drag — a
107
+ # list you can drag in has a row on one side of the drop, and the
108
+ # boundary lookup above supplies the other. It means the client's view
109
+ # is stale (both neighbours drifted), and writing anyway would send the
110
+ # record to position 0.0 at the head of its group on the strength of a
111
+ # request we've just decided not to trust. Reconcile instead: the row
112
+ # snaps back to where it actually is.
113
+ if config.delegate? && prev_record.nil? && next_record.nil?
114
+ # A cross-group drop lands here with BOTH neighbours rejected, and is
115
+ # the one kind of drift worth explaining: it is a standing property
116
+ # of a list that spans groups, not transient staleness. Status stays
117
+ # :ok — the reconciliation is unchanged, this only adds the toast.
118
+ return render_position_reconciliation(
119
+ reason: foreign_group ? position_foreign_group_reason(record) : nil,
120
+ status: :ok
121
+ )
122
+ end
123
+
124
+ # Bind the return rather than branching on the call directly:
125
+ # `if config.reposition!(...)` reads as a success check, which this is
126
+ # NOT — failure raises. The boolean means "positions other than this
127
+ # record's may have changed, so the client's optimistic view is stale".
128
+ must_reconcile = config.reposition!(
129
+ record:,
130
+ prev_record:,
131
+ next_record:,
132
+ # Floor-clamped, mirroring the kanban drop (KanbanActions#kanban_move
133
+ # clamps to [0, size]): a negative index reaching a Mode B block
134
+ # would index its array from the END, silently anchoring the drop to
135
+ # the wrong row. There is no card list here to give it a ceiling, so
136
+ # the contract Move#index carries is "0 or greater".
137
+ index: [params[:to_index].to_i, 0].max
138
+ )
139
+
140
+ # No reason here, even when a neighbour was out of group: reposition!
141
+ # has already run, so the record DID move — the surviving anchor
142
+ # carried the drop and resolve_position_boundaries supplied the other.
143
+ # Telling the user reordering "only works within the same group" on a
144
+ # move that just worked contradicts what they can see. The refusal
145
+ # above is the only place that claim is true.
146
+ if must_reconcile || !prev_ok || !next_ok
147
+ render_position_reconciliation
148
+ else
149
+ head :no_content
150
+ end
151
+ rescue ::ActionPolicy::Unauthorized => e
152
+ # NOTE: the leading :: is REQUIRED — Plutonium::ActionPolicy exists, so
153
+ # a bare ActionPolicy resolves to that namespace and never matches,
154
+ # letting the exception reach the global rescue_from, which re-raises
155
+ # for turbo_stream requests → an HTML error page morphed into the table.
156
+ #
157
+ # authorize_count only bumps after a SUCCESSFUL authorize, so a denial
158
+ # leaves the verifier unsatisfied; we handled authorization by rejecting.
159
+ skip_verify_authorize_current!
160
+
161
+ # A denied index? is not a snap-back: the user may not see this listing
162
+ # at all, so the refusal must not carry it back to them. Only a denied
163
+ # reposition? gets the collection (the row has to return somewhere).
164
+ if e.rule.to_sym == :index?
165
+ skip_verify_current_authorized_scope!
166
+ head :forbidden
167
+ return
168
+ end
169
+
170
+ render_position_reconciliation(
171
+ reason: "You are not authorized to reorder this.",
172
+ status: :forbidden
173
+ )
174
+ rescue ActiveRecord::RecordNotFound
175
+ # The row was destroyed between render and drop. `find` raised before
176
+ # authorize_current!, so satisfy that verifier explicitly.
177
+ skip_verify_authorize_current!
178
+ render_position_reconciliation(reason: "This record no longer exists.")
179
+ rescue ActiveRecord::RecordInvalid => e
180
+ reason = e.record.errors.full_messages.to_sentence.presence ||
181
+ "This record could not be moved."
182
+ render_position_reconciliation(reason:)
183
+ end
184
+
185
+ private
186
+
187
+ # Returns [neighbour, resolved?]. A blank id is a legitimate end-of-page
188
+ # drop → [nil, true]. Anything the client named that we cannot honour is
189
+ # drift → [nil, false], which forces reconciliation.
190
+ #
191
+ # Two ways to fail. The id may not resolve in the authorized scope at
192
+ # all; or it may resolve to a row in a DIFFERENT positioning group. The
193
+ # second is not exotic — a status-scoped resource lists several groups
194
+ # in one table, with independent and freely interleaved numberings, so
195
+ # anchoring off a neighbour from another group flings the record to an
196
+ # arbitrary point in its own. Group membership is part of "can this
197
+ # neighbour anchor this drop", so it belongs here rather than in a
198
+ # separate check the caller could forget.
199
+ # Returns [neighbour, ok, drift]. `drift` names WHY a neighbour was
200
+ # rejected — :missing (gone, or outside this viewer's scope) versus
201
+ # :foreign_group (real, but in another positioning group). Both reconcile
202
+ # identically, but only the second has something worth telling the user:
203
+ # it is a standing property of the list they are looking at, not a
204
+ # transient staleness that a reload fixes.
205
+ def resolve_position_neighbour(id, record:, config:)
206
+ return [nil, true, nil] if id.blank?
207
+
208
+ neighbour = current_authorized_scope.find_by(id: id)
209
+ return [nil, false, :missing] if neighbour.nil?
210
+
211
+ # Mode B owns its own storage AND its own notion of a group; only the
212
+ # framework's own scope attribute is ours to police.
213
+ group_attr = config.delegate? ? record.class.positioning_scope_attr : nil
214
+ return [nil, false, :foreign_group] if group_attr && neighbour[group_attr] != record[group_attr]
215
+
216
+ [neighbour, true, nil]
217
+ end
218
+
219
+ # What to tell the user when a drop was refused because its neighbours
220
+ # belong to another positioning group — the case a collection spanning
221
+ # groups (a top-level index of a resource scoped to its parent) invites
222
+ # on nearly every row. Named after the scope attribute so the message
223
+ # says "the same product" rather than restating the column.
224
+ def position_foreign_group_reason(record)
225
+ scope = record.class.positioning_scope_attr.to_s.delete_suffix("_id").humanize.downcase
226
+ "Reordering only works within the same #{scope}."
227
+ end
228
+
229
+ # Fills in a neighbour the CLIENT could not see.
230
+ #
231
+ # A blank neighbour id means "nothing on that side of my viewport" — it
232
+ # does NOT mean "nothing on that side of the group". Pagination and
233
+ # filters both hide rows, so the last visible row is routinely not the
234
+ # last row. Taken literally, position_between would compute prev + 1 (or
235
+ # next - 1), which on a default-spaced list is EXACTLY the position of
236
+ # the row the client couldn't see: a silent duplicate, reported as a
237
+ # clean drop, that only heals if someone later drags between the tied
238
+ # pair. So we look the real boundary neighbour up instead.
239
+ #
240
+ # The lookup deliberately runs against the model's own group, NOT the
241
+ # authorized scope: a position is a property of the group, and ignoring
242
+ # rows the viewer can't see is the very bug being fixed. Nothing about
243
+ # the hidden row reaches the client — only the dropped record's own
244
+ # resulting position, which had to interleave with it either way.
245
+ #
246
+ # Mode A only: a Mode B block owns its neighbour semantics, and receives
247
+ # the drop exactly as the client described it.
248
+ def resolve_position_boundaries(record, config, prev_record, next_record)
249
+ return [prev_record, next_record] unless config.delegate?
250
+ return [prev_record, next_record] if prev_record && next_record
251
+
252
+ attr = config.attribute
253
+ group = positioning_group_for(record)
254
+
255
+ if next_record.nil? && prev_record
256
+ next_record = group.where(group.table[attr].gt(prev_record[attr])).order(attr => :asc).first
257
+ elsif prev_record.nil? && next_record
258
+ prev_record = group.where(group.table[attr].lt(next_record[attr])).order(attr => :desc).first
259
+ end
260
+
261
+ [prev_record, next_record]
262
+ end
263
+
264
+ # The record's positioning group, minus the record itself (it is being
265
+ # moved, so its current slot must not anchor its new one). Mirrors
266
+ # Positioning::Model#positioning_group_relation, which is private to the
267
+ # model instance.
268
+ def positioning_group_for(record)
269
+ klass = record.class
270
+ relation = klass.all
271
+ group_attr = klass.positioning_scope_attr
272
+ relation = relation.where(group_attr => record[group_attr]) if group_attr
273
+ relation.where.not(klass.primary_key => record.id)
274
+ end
275
+
276
+ # Streams the collection back so the client's optimistic DOM is replaced
277
+ # by the server's truth — the row snaps back on rejection, or settles
278
+ # into its rebalanced order.
279
+ #
280
+ # The status defaults off `reason`: a reconciliation that has something
281
+ # to say is a REJECTION (422 unless the caller is more specific), while a
282
+ # silent one is a successful drop whose result simply differs from what
283
+ # the client drew (a rebalance, drift, a Mode B write).
284
+ #
285
+ # The toast renders the shared _toast partial directly rather than going
286
+ # through `flash`, exactly as the kanban rejection does: this action never
287
+ # renders the layout that would consume a flash, so a stale entry from an
288
+ # earlier request would otherwise leak into the stream.
289
+ def render_position_reconciliation(reason: nil, status: reason ? :unprocessable_content : :ok)
290
+ streams = [
291
+ turbo_stream.update(
292
+ Plutonium::UI::Page::Index.collection_dom_id(resource_class),
293
+ render_position_collection_html
294
+ )
295
+ ]
296
+
297
+ if reason
298
+ streams << turbo_stream.append(
299
+ Plutonium::FLASH_REGION,
300
+ partial: "plutonium/toast",
301
+ locals: {type: :warning, msg: reason}
302
+ )
303
+ end
304
+
305
+ render turbo_stream: streams, status:
306
+ end
307
+
308
+ # Renders the collection component to an HTML-safe string.
309
+ #
310
+ # This is the INDEX's own pipeline, not a copy of it: setup_index_action!
311
+ # applies search / filters / scopes / tenant scoping AND the same
312
+ # pagination, driven by the index query string the client posted with. So
313
+ # the rows streamed back are precisely the rows the user is looking at —
314
+ # page 3 of a filtered list comes back as page 3 of that filtered list.
315
+ #
316
+ # (Kanban's equivalent, kanban_base_relation, deliberately drops
317
+ # pagination because a board pages per column instead. A table has one
318
+ # pager for the whole collection, so reusing setup_index_action! whole is
319
+ # both simpler and more correct here.)
320
+ #
321
+ # The action is passed explicitly as "index": this request's action_name
322
+ # is "reposition", and a resource must not have to define
323
+ # permitted_attributes_for_reposition to be draggable.
324
+ def render_position_collection_html
325
+ setup_index_action!(action: "index")
326
+
327
+ component = (position_collection_view == :grid) ?
328
+ build_grid_collection(action: "index") :
329
+ build_collection(action: "index")
330
+
331
+ view_context.render(component).html_safe
332
+ end
333
+
334
+ # Which of the resource's index views the client is looking at, resolved
335
+ # exactly as the index page resolves it (?view= → cookie → default).
336
+ def position_collection_view
337
+ @position_collection_view ||= Plutonium::UI::Page::Index.resolve_view(
338
+ current_definition, resource_class, params[:view], cookies
339
+ )
340
+ end
341
+
342
+ # The collection path this drop belongs to — the index (or nested
343
+ # association) URL the client is displaying, NOT this POST-only member
344
+ # path. Overriding the two Core::Controller seams re-points every URL the
345
+ # re-rendered collection builds — sort headers, the search form, filter
346
+ # pills, pagination, each row action's return_to — at that page. Without
347
+ # it a rebalanced table would come back wired to /tasks/5/reposition,
348
+ # where every one of those links is a 404 waiting to happen.
349
+ #
350
+ # Guarded on the action: every OTHER action on this controller genuinely
351
+ # is the page it renders, and must keep the request-derived defaults.
352
+ def current_page_path
353
+ return super unless action_name == "reposition"
354
+ position_collection_path
355
+ end
356
+
357
+ # Absolute, like the request.original_url it stands in for — a consumer
358
+ # that got a path here where every other action hands it a full URL
359
+ # would be a trap. request.base_url is this request's own origin, which
360
+ # is the page's origin too: the drop was posted from it.
361
+ def current_page_url
362
+ return super unless action_name == "reposition"
363
+ @position_page_url ||= begin
364
+ query = request.query_string
365
+ "#{request.base_url}#{position_collection_path}#{"?#{query}" if query.present?}"
366
+ end
367
+ end
368
+
369
+ # Pagy carries the request params into every page link. Under this POST
370
+ # that would smuggle prev_id/next_id/to_index into them, so hand Pagy the
371
+ # query string alone — which is the index's own params.
372
+ def pagy_request_context
373
+ return super unless action_name == "reposition"
374
+ super.merge(params: request.GET.to_h)
375
+ end
376
+
377
+ # The collection path this drop belongs to. `parent:` is what makes a
378
+ # nested association table work: resource_url_for ignores current_parent
379
+ # unless it is passed one, so without it a drop inside
380
+ # /posts/1/nested_comments would resolve to the TOP-LEVEL /comments and
381
+ # send every link in the streamed collection out of the frame, at the
382
+ # wrong collection. current_parent is nil off a nested route, where
383
+ # `parent: nil` is exactly the top-level lookup we want.
384
+ def position_collection_path
385
+ @position_collection_path ||= resource_url_for(resource_class, parent: current_parent)
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end
@@ -11,16 +11,22 @@ module Plutonium
11
11
  private
12
12
 
13
13
  def presentable_attributes
14
- @presentable_attributes ||= begin
15
- presentable_attributes = permitted_attributes
16
- if current_parent && !present_parent?
17
- presentable_attributes -= [parent_input_param, :"#{parent_input_param}_id"]
18
- end
19
- if scoped_to_entity? && !present_scoped_entity?
20
- presentable_attributes -= scoped_entity_field_names
21
- end
22
- presentable_attributes
14
+ @presentable_attributes ||= presentable_attributes_for(action_name)
15
+ end
16
+
17
+ # The presentable set for a NAMED action rather than the current one.
18
+ # An action that re-renders a collection on the index's behalf (the
19
+ # reposition drop POST) has no permitted_attributes_for_reposition of
20
+ # its own and must not need one — it asks for the index's set.
21
+ def presentable_attributes_for(action)
22
+ attributes = permitted_attributes_for(action)
23
+ if current_parent && !present_parent?
24
+ attributes -= [parent_input_param, :"#{parent_input_param}_id"]
25
+ end
26
+ if scoped_to_entity? && !present_scoped_entity?
27
+ attributes -= scoped_entity_field_names
23
28
  end
29
+ attributes
24
30
  end
25
31
 
26
32
  def submittable_attributes
@@ -78,12 +84,12 @@ module Plutonium
78
84
  @scoped_entity_association = matching_assocs.first&.name
79
85
  end
80
86
 
81
- def build_collection
82
- current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
87
+ def build_collection(action: action_name)
88
+ current_definition.collection_class.new(@resource_records, resource_fields: presentable_attributes_for(action), resource_definition: current_definition)
83
89
  end
84
90
 
85
- def build_grid_collection
86
- current_definition.grid_class.new(@resource_records, resource_fields: presentable_attributes, resource_definition: current_definition)
91
+ def build_grid_collection(action: action_name)
92
+ current_definition.grid_class.new(@resource_records, resource_fields: presentable_attributes_for(action), resource_definition: current_definition)
87
93
  end
88
94
 
89
95
  def build_detail
@@ -10,7 +10,11 @@ module Plutonium
10
10
 
11
11
  def current_query_object
12
12
  @current_query_object ||=
13
- Plutonium::Resource::QueryObject.new(resource_class, raw_resource_query_params, request.path) do |query_object|
13
+ # current_page_path, not request.path: every URL this object builds
14
+ # (sort headers, scope pills, filter links) belongs to the page being
15
+ # rendered, which is not the request path when a stream-producing
16
+ # POST re-renders a collection on the index's behalf.
17
+ Plutonium::Resource::QueryObject.new(resource_class, raw_resource_query_params, current_page_path) do |query_object|
14
18
  if current_definition.search_definition
15
19
  query_object.define_search proc { |scope, search:|
16
20
  current_definition.search_definition.call(scope, search)
@@ -30,16 +30,19 @@ module Plutonium
30
30
  launch_wizard_record_action launch_wizard_resource_action
31
31
  wizard_record_action commit_wizard_record_action
32
32
  wizard_resource_action commit_wizard_resource_action
33
+ cancel_wizard_record_action cancel_wizard_resource_action
33
34
  ]
34
35
 
35
36
  before_action :authorize_wizard_record_action!, only: %i[
36
37
  launch_wizard_record_action
37
38
  wizard_record_action commit_wizard_record_action
39
+ cancel_wizard_record_action
38
40
  ]
39
41
 
40
42
  before_action :authorize_wizard_resource_action!, only: %i[
41
43
  launch_wizard_resource_action
42
44
  wizard_resource_action commit_wizard_resource_action
45
+ cancel_wizard_resource_action
43
46
  ]
44
47
  end
45
48
 
@@ -64,6 +67,11 @@ module Plutonium
64
67
  wizard_update
65
68
  end
66
69
 
70
+ # DELETE /resources/:id/wizards/:wizard_name/(:token)
71
+ def cancel_wizard_record_action
72
+ wizard_cancel
73
+ end
74
+
67
75
  # GET /resources/wizards/:wizard_name/(:token)/:step
68
76
  def wizard_resource_action
69
77
  skip_verify_current_authorized_scope!
@@ -76,6 +84,12 @@ module Plutonium
76
84
  wizard_update
77
85
  end
78
86
 
87
+ # DELETE /resources/wizards/:wizard_name/(:token)
88
+ def cancel_wizard_resource_action
89
+ skip_verify_current_authorized_scope!
90
+ wizard_cancel
91
+ end
92
+
79
93
  private
80
94
 
81
95
  # --- surface hooks (override Driving) ---
@@ -109,6 +123,13 @@ module Plutonium
109
123
  )
110
124
  end
111
125
 
126
+ def wizard_launch_url
127
+ resource_url_for(
128
+ wizard_url_subject,
129
+ wizard: current_wizard_name
130
+ )
131
+ end
132
+
112
133
  # The URL anchor: the scoped record for member (record) actions, the
113
134
  # resource class for collection (resource) actions.
114
135
  def wizard_url_subject
@@ -21,6 +21,30 @@ module Plutonium
21
21
  default_relation_scope(relation)
22
22
  end
23
23
 
24
+ # `relation_scope` is a class-level macro: it registers a scope handler named
25
+ # `__scoping__active_record_relation__default`, and never defines an instance
26
+ # method called `relation_scope`. So `def relation_scope(relation)` overrides
27
+ # nothing — apply_scope keeps dispatching to the inherited handler, the author's
28
+ # narrowing never runs, and verify_default_relation_scope_applied! is happy
29
+ # because the base handler did call default_relation_scope. Fail-open and silent,
30
+ # so catch it the moment the method is defined rather than at request time.
31
+ def self.method_added(name)
32
+ super
33
+
34
+ return unless name == :relation_scope
35
+
36
+ raise <<~MSG
37
+ #{self} defines `relation_scope` as an instance method, which Plutonium never calls.
38
+ `relation_scope` is a macro, so this scoping silently does nothing.
39
+
40
+ Use the block form instead:
41
+
42
+ relation_scope do |relation|
43
+ default_relation_scope(relation).your_scope
44
+ end
45
+ MSG
46
+ end
47
+
24
48
  # Wraps apply_scope to verify default_relation_scope was called.
25
49
  # This prevents accidental multi-tenancy leaks when overriding relation_scope.
26
50
  def apply_scope(relation, type:, **options)
@@ -186,6 +210,15 @@ module Plutonium
186
210
  update?
187
211
  end
188
212
 
213
+ # Authorizes a drag-reorder drop on a table or grid. Delegates to update?
214
+ # by default — override to allow (or forbid) reordering independently of
215
+ # full edit-form access.
216
+ #
217
+ # @return [Boolean] Delegates to update?.
218
+ def reposition?
219
+ update?
220
+ end
221
+
189
222
  # Checks if record search is permitted.
190
223
  #
191
224
  # @return [Boolean] Delegates to index?.
@@ -2,6 +2,15 @@ module Plutonium
2
2
  module Resource
3
3
  class QueryObject
4
4
  attr_reader :search_filter, :search_query
5
+
6
+ # The path every URL this object builds is anchored to — the collection
7
+ # page, which is not necessarily the current request (see
8
+ # Plutonium::Core::Controller#current_page_path). Exposed so the
9
+ # components rendering alongside these URLs — the search form's action,
10
+ # the filter pills' clear links — address the same page instead of each
11
+ # re-deriving it from `request`.
12
+ attr_reader :request_path
13
+
5
14
  attr_accessor :default_sort_config, :default_scope_name
6
15
 
7
16
  # Initializes a QueryObject with the given resource_class and parameters.
@@ -204,6 +213,33 @@ module Plutonium
204
213
  }
205
214
  end
206
215
 
216
+ # Whether the collection is ordered by `name` ASCENDING and by nothing
217
+ # else — the invariant a drag-reorder depends on. Dropping a row "between
218
+ # the two rows either side of it" only means anything when the visual
219
+ # order IS the stored order: under a title sort the neighbours say nothing
220
+ # about position, and under a DESCENDING position sort they say the
221
+ # opposite of what the write assumes.
222
+ #
223
+ # An explicit selection wins; with none, the resource's default_sort is
224
+ # what the user is looking at (field/direction form only — a `default_sort`
225
+ # block is opaque, so it cannot be claimed to satisfy this).
226
+ #
227
+ # @param name [Symbol, String] the position attribute
228
+ # @return [Boolean]
229
+ def sorted_ascending_only_by?(name)
230
+ fields = selected_sort_fields.map(&:to_s)
231
+
232
+ if fields.any?
233
+ return false unless fields == [name.to_s]
234
+ direction = selected_sort_directions[name.to_sym] || selected_sort_directions[name.to_s] || "ASC"
235
+ return direction.to_s.casecmp("asc").zero?
236
+ end
237
+
238
+ return false unless default_sort_config.is_a?(Array)
239
+ field, direction = default_sort_config
240
+ field.to_s == name.to_s && (direction || :asc).to_s.casecmp("asc").zero?
241
+ end
242
+
207
243
  private
208
244
 
209
245
  attr_reader :resource_class, :params, :selected_sort_fields, :selected_sort_directions, :selected_scope_filter