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,452 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { beginDrag, computeDropIndex, computeDropIndexHorizontal, endDrag, hideInsertionMarker, showInsertionMarker } from "../drag/sortable"
3
+
4
+ // Applied to the row for the duration of the drag. A plain Tailwind utility
5
+ // rather than a bespoke class, so the whole affordance needs no stylesheet of
6
+ // its own — the same reason the grip itself is themed from Table::Theme.
7
+ const DRAGGING_CLASS = "opacity-30"
8
+
9
+ // Connects to data-controller="positioned"
10
+ //
11
+ // Drag-to-reorder for an index collection whose resource declares
12
+ // `position_on`. Native HTML5 drag-and-drop, sharing its mechanics with the
13
+ // kanban board via ../drag/sortable — no npm dependency.
14
+ //
15
+ // Serves BOTH index surfaces — the table and the card grid. They differ in
16
+ // exactly one respect (which axis a drop position is read off), so the grid is
17
+ // a second consumer of this controller rather than a parallel one; "row" below
18
+ // means a <tr> on a table and an <article> card on a grid.
19
+ //
20
+ // ## DOM contract
21
+ //
22
+ // Collection wrapper (this element — the div around <table>, or the grid div):
23
+ // data-controller="positioned"
24
+ // data-positioned-url-template-value="/things/__ID__/reposition"
25
+ // — the collection path with an __ID__ placeholder, substituted with the
26
+ // dragged row's record id at drop time.
27
+ // data-positioned-axis-value="horizontal"
28
+ // — grid only. See #dropIndex.
29
+ //
30
+ // Row (<tr> or a grid <article>):
31
+ // data-positioned-row-id="<id>"
32
+ // — the SINGLE source of truth for which record a drag is about. The grip
33
+ // deliberately carries no id of its own: two places to read it from is
34
+ // two places for them to disagree.
35
+ //
36
+ // Grip (a <button> inside the row):
37
+ // data-positioned-grip
38
+ //
39
+ // ## Only the grip is draggable — never the <tr>
40
+ //
41
+ // Two concrete reasons, both of which cost real functionality if ignored:
42
+ //
43
+ // 1. `draggable="true"` disables text selection inside the element in every
44
+ // major browser. On a data table that silently removes the ability to
45
+ // select and copy a cell value.
46
+ // 2. row_click_controller makes the whole row the Show affordance. A
47
+ // draggable row fights it: a drag that starts and ends in place still
48
+ // fires a click, and the user is navigated away instead of being left
49
+ // where they were.
50
+ //
51
+ // The kanban board keeps whole-card dragging because neither concern applies
52
+ // to a card. A GRID card, however, still gets a grip: unlike a kanban card it
53
+ // carries a row-click show affordance, so reason 2 applies to it just as it
54
+ // does to a table row.
55
+ //
56
+ // ## The wrapper only appears while the table IS in position order
57
+ //
58
+ // Under any other sort the visible neighbours sit in arbitrary position order,
59
+ // so "drop me between these two rows" describes nothing the server can honour
60
+ // — and it rejects such a drop outright (422, no write). The server-rendered
61
+ // table therefore omits this controller entirely under a foreign sort and
62
+ // renders the grip as a disabled link that applies the position sort instead.
63
+ // So this controller never has to reason about sorting: if it is connected,
64
+ // dragging is permitted.
65
+ //
66
+ // ## Move flow
67
+ //
68
+ // 1. dragstart — remember the row, apply the drag ghost + dimming.
69
+ // 2. dragover — preventDefault so the browser offers a "move" cursor.
70
+ // 3. drop — compute the insertion index against the OTHER rows, move the
71
+ // row optimistically, then POST {prev_id, next_id, to_index}.
72
+ // 4. dragend — clear the dimming and the cached row.
73
+ //
74
+ // Keyboard: the grip is a real <button>, so it is tabbable, and ArrowUp /
75
+ // ArrowDown move the focused row one slot through exactly the same path.
76
+ // Native HTML5 DnD is mouse-only — without this the feature is unusable by
77
+ // keyboard, and the affordance would be a tab stop that does nothing.
78
+ export default class extends Controller {
79
+ static values = { urlTemplate: String, axis: { type: String, default: "vertical" } }
80
+
81
+ connect() {
82
+ this.draggedRow = null
83
+
84
+ this.onDragStart = this.#onDragStart.bind(this)
85
+ this.onDragOver = this.#onDragOver.bind(this)
86
+ this.onDragLeave = this.#onDragLeave.bind(this)
87
+ this.onDrop = this.#onDrop.bind(this)
88
+ this.onDragEnd = this.#onDragEnd.bind(this)
89
+ this.onKeyDown = this.#onKeyDown.bind(this)
90
+
91
+ this.element.addEventListener("dragstart", this.onDragStart)
92
+ this.element.addEventListener("dragover", this.onDragOver)
93
+ this.element.addEventListener("dragleave", this.onDragLeave)
94
+ this.element.addEventListener("drop", this.onDrop)
95
+ this.element.addEventListener("dragend", this.onDragEnd)
96
+ this.element.addEventListener("keydown", this.onKeyDown)
97
+ }
98
+
99
+ disconnect() {
100
+ this.element.removeEventListener("dragstart", this.onDragStart)
101
+ this.element.removeEventListener("dragover", this.onDragOver)
102
+ this.element.removeEventListener("dragleave", this.onDragLeave)
103
+ this.element.removeEventListener("drop", this.onDrop)
104
+ this.element.removeEventListener("dragend", this.onDragEnd)
105
+ this.element.removeEventListener("keydown", this.onKeyDown)
106
+ // A drag interrupted by a Turbo navigation never reaches dragend.
107
+ hideInsertionMarker()
108
+ }
109
+
110
+ // ─── drag lifecycle ─────────────────────────────────────────────────────────
111
+
112
+ #onDragStart(event) {
113
+ // The grip is the only draggable node, but the event still bubbles from
114
+ // whatever inside it the pointer grabbed (the icon's <svg>/<path>).
115
+ const grip = event.target.closest("[data-positioned-grip]")
116
+ if (!grip) return
117
+
118
+ const row = this.#rowFor(grip)
119
+ if (!row) return
120
+
121
+ this.draggedRow = row
122
+ // Dim the ROW, not the grip: the row is what the user is moving. The class
123
+ // is applied one frame late by beginDrag so the drag ghost is snapshotted
124
+ // at full opacity (see ../drag/sortable).
125
+ beginDrag(event, row, {
126
+ draggingClass: DRAGGING_CLASS,
127
+ payload: row.dataset.positionedRowId,
128
+ // The grip carries draggable="true", so without this the browser ghosts
129
+ // the grip alone and the row looks like it never left.
130
+ dragImage: row
131
+ })
132
+ }
133
+
134
+ #onDragOver(event) {
135
+ // Without a cached row this is somebody else's drag (a kanban card, a file
136
+ // from the desktop). Leave it alone so the browser shows "no entry" rather
137
+ // than inviting a drop we would ignore.
138
+ if (!this.draggedRow) return
139
+
140
+ // A drop across positioning groups describes no position — the two records
141
+ // keep independent sequences — and the server refuses it. Withhold
142
+ // preventDefault so the browser shows "no entry" and the drop never fires,
143
+ // rather than letting the user commit to a move that silently snaps back.
144
+ if (!this.#sameGroupAs(event.target)) {
145
+ hideInsertionMarker()
146
+ return
147
+ }
148
+
149
+ event.preventDefault()
150
+ event.dataTransfer.dropEffect = "move"
151
+
152
+ // Same exclusion and same index function the drop uses, so the line the
153
+ // user aims at is the slot they get.
154
+ const others = this.#rows().filter(r => r !== this.draggedRow)
155
+ showInsertionMarker(others, this.#dropIndex(event, others), {
156
+ axis: this.axisValue === "horizontal" ? "horizontal" : "vertical",
157
+ container: this.element
158
+ })
159
+ }
160
+
161
+ #onDragLeave(event) {
162
+ // Only when the pointer leaves the collection entirely — dragleave also
163
+ // fires crossing between rows inside it, and hiding on those would strobe.
164
+ if (event.relatedTarget && this.element.contains(event.relatedTarget)) return
165
+ hideInsertionMarker()
166
+ }
167
+
168
+ #onDrop(event) {
169
+ event.preventDefault()
170
+ // On release, not on dragend: the marker should not outlive the gesture
171
+ // while the POST is in flight.
172
+ hideInsertionMarker()
173
+ if (!this.draggedRow) return
174
+
175
+ const row = this.draggedRow
176
+ // Exclude the dragged row: an index computed over a list that still counts
177
+ // the row being moved is off by one for every downward move.
178
+ const others = this.#rows().filter(r => r !== row)
179
+
180
+ this.#applyMove(row, others, this.#dropIndex(event, others))
181
+ }
182
+
183
+ // Where a drop at the cursor lands, in `items`' own order.
184
+ //
185
+ // A table stacks: one row per line, so the vertical midpoint test is the
186
+ // whole story. A grid WRAPS: cards flow left-to-right and then onto a new
187
+ // line, and reading that layout off clientY alone would collapse every card
188
+ // on a line into a single slot — the drop would land at the start of the row
189
+ // no matter which gap the user aimed at.
190
+ //
191
+ // But clientX alone is no better, because every visual row starts at the same
192
+ // left edge: x=250 is "after the third card" in row 1 and row 4 alike. So
193
+ // narrow to the row the cursor is over FIRST, then let the horizontal
194
+ // midpoint test choose the gap inside it.
195
+ #dropIndex(event, items) {
196
+ if (this.axisValue !== "horizontal") return computeDropIndex(event.clientY, items)
197
+ if (items.length === 0) return 0
198
+
199
+ const rows = this.#visualRows(items)
200
+
201
+ // Above the grid entirely → the very first slot. Below it → the last.
202
+ if (event.clientY < rows[0].top) return 0
203
+ const row = rows.find(r => event.clientY <= r.bottom)
204
+ if (!row) return items.length
205
+
206
+ return row.start + computeDropIndexHorizontal(event.clientX, items.slice(row.start, row.end))
207
+ }
208
+
209
+ // Groups `items` into the lines the browser actually laid them out on, as
210
+ // {top, bottom, start, end} spans over `items`.
211
+ //
212
+ // Recovered from geometry rather than from a column count on purpose: the
213
+ // grid is responsive (1/2/3/4 columns by breakpoint) and a definition may pin
214
+ // its own, so the only thing that reliably knows how the cards wrapped is
215
+ // where they ended up. A new line starts wherever an item's top edge leaves
216
+ // the current one's — with a pixel of slack, since equal-height cards in a
217
+ // row can still differ by a sub-pixel.
218
+ #visualRows(items) {
219
+ const rows = []
220
+
221
+ items.forEach((item, i) => {
222
+ const rect = item.getBoundingClientRect()
223
+ const row = rows[rows.length - 1]
224
+
225
+ if (row && Math.abs(rect.top - row.top) <= 1) {
226
+ row.end = i + 1
227
+ row.bottom = Math.max(row.bottom, rect.bottom)
228
+ } else {
229
+ rows.push({ top: rect.top, bottom: rect.bottom, start: i, end: i + 1 })
230
+ }
231
+ })
232
+
233
+ return rows
234
+ }
235
+
236
+ #onDragEnd(_event) {
237
+ if (!this.draggedRow) return
238
+
239
+ endDrag(this.draggedRow, { draggingClass: DRAGGING_CLASS })
240
+ this.draggedRow = null
241
+ }
242
+
243
+ // ─── keyboard ───────────────────────────────────────────────────────────────
244
+
245
+ #onKeyDown(event) {
246
+ if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return
247
+
248
+ const grip = event.target.closest("[data-positioned-grip]")
249
+ if (!grip) return
250
+
251
+ const row = this.#rowFor(grip)
252
+ if (!row) return
253
+
254
+ // Only now: an arrow key that lands on nothing movable must keep its
255
+ // native meaning (scrolling the page).
256
+ event.preventDefault()
257
+
258
+ // Deliberately LINEAR, on a grid as much as on a table: it needs no drop
259
+ // index, because "one slot earlier / later in document order" is exactly
260
+ // what a single position attribute stores. Up/Down on a wrapped grid
261
+ // therefore means the previous/next CARD, not the card one line above —
262
+ // 2D navigation would move a card by a whole row per keypress and could
263
+ // not express the in-between slots at all.
264
+ const rows = this.#rows()
265
+ const from = rows.indexOf(row)
266
+ const to = event.key === "ArrowUp" ? from - 1 : from + 1
267
+ if (to < 0 || to >= rows.length) return
268
+
269
+ // With the row itself removed, "land at slot `to`" is the insertion index
270
+ // among the remaining rows in both directions: up → to, down → to.
271
+ const others = rows.filter(r => r !== row)
272
+ this.#applyMove(row, others, to)
273
+
274
+ // Moving a node in the DOM drops focus in most browsers, which would strand
275
+ // the user after a single keypress. The grip travels with the row, so it is
276
+ // still the right element to return to.
277
+ grip.focus()
278
+ }
279
+
280
+ // ─── the move itself ────────────────────────────────────────────────────────
281
+
282
+ // Moves `row` to `index` among `others` (which must already exclude `row`),
283
+ // optimistically, then tells the server. Shared by drop and keyboard so the
284
+ // two can never compute neighbours differently.
285
+ #applyMove(row, others, index) {
286
+ const before = others[index] ?? null
287
+ const after = others[index - 1] ?? null
288
+
289
+ // The row is already exactly there — both neighbours unchanged. A drop that
290
+ // lands where it started is the commonest accidental drag there is, and
291
+ // posting it would write a position identical to the current one (or, in
292
+ // Mode B, run somebody else's renumbering block) for no reason at all.
293
+ if (row.nextElementSibling === before && row.previousElementSibling === after) return
294
+
295
+ // Where to put the row back if the server never confirms the move. Captured
296
+ // BEFORE the optimistic insert, as a sibling reference rather than an index
297
+ // — the row's own index is about to change. `nextSibling`, not
298
+ // `nextElementSibling`, so the row lands back on the same side of the
299
+ // whitespace text node it came from and the markup is byte-identical.
300
+ //
301
+ // (Unlike the kanban board, which this controller shares its drag core with,
302
+ // a reorder DOES re-parent the node. Kanban can afford to leave a rejected
303
+ // drop alone because native HTML5 DnD never moved its card; here, doing
304
+ // nothing leaves the user looking at an order that was never written.)
305
+ const restoreAnchor = row.nextSibling
306
+
307
+ row.parentElement.insertBefore(row, before)
308
+
309
+ this.#submit(row, restoreAnchor, {
310
+ // The ids of the row's VISIBLE neighbours. A blank one means "nothing on
311
+ // that side of my page" — the server treats that as a claim about the
312
+ // viewport, not about the positioning group, and looks the real boundary
313
+ // neighbour up itself (see PositionActions#resolve_position_boundaries).
314
+ prevId: after?.dataset.positionedRowId ?? "",
315
+ nextId: before?.dataset.positionedRowId ?? "",
316
+ toIndex: index
317
+ })
318
+ }
319
+
320
+ // `row` is the already-moved element and `restoreAnchor` the sibling it sat
321
+ // in front of beforehand, so every path that does not end in the server's own
322
+ // truth can undo the optimistic move rather than leave it standing.
323
+ async #submit(row, restoreAnchor, { prevId, nextId, toIndex }) {
324
+ const recordId = row.dataset.positionedRowId
325
+
326
+ // window.location.search is LOAD-BEARING, not decoration. The endpoint
327
+ // re-renders through the ordinary index pipeline, so the collection's own
328
+ // query — search, filters, scope, sort, page, view — has to arrive in
329
+ // params. Without it a drop on page 3 of a filtered list reconciles as
330
+ // page 1 of an unfiltered one.
331
+ const url = this.urlTemplateValue.replace("__ID__", recordId) + window.location.search
332
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? ""
333
+
334
+ try {
335
+ const response = await fetch(url, {
336
+ method: "POST",
337
+ headers: {
338
+ "Accept": "text/vnd.turbo-stream.html",
339
+ "Content-Type": "application/x-www-form-urlencoded",
340
+ "X-CSRF-Token": csrfToken
341
+ },
342
+ body: new URLSearchParams({
343
+ prev_id: prevId,
344
+ next_id: nextId,
345
+ to_index: toIndex
346
+ }),
347
+ credentials: "same-origin"
348
+ })
349
+
350
+ // 204 = a clean drop. The optimistic DOM is already exactly right, so
351
+ // repainting would only cost a flash and the user's focus.
352
+ if (response.status === 204) return
353
+
354
+ // Everything else the server answers with is a turbo-stream that replaces
355
+ // the collection with its own truth — the row snaps back on a rejection,
356
+ // or settles into a rebalanced order. Only feed Turbo genuine streams: a
357
+ // 302 to a login page comes back as a 200 HTML document, and morphing
358
+ // THAT into the table paints the error page over the collection.
359
+ const contentType = response.headers.get("Content-Type") || ""
360
+ if (contentType.includes("text/vnd.turbo-stream.html") && window.Turbo) {
361
+ const focusedRowId = this.#focusedRowId()
362
+ window.Turbo.renderStreamMessage(await response.text())
363
+ // The stream replaced the rows, so a grip the user was tabbed to is
364
+ // gone. Put focus back on the same record's new grip.
365
+ if (focusedRowId) this.#restoreFocus(focusedRowId)
366
+ } else if (!response.ok) {
367
+ // A rejection with no collection to reconcile against — a denied
368
+ // `index?` answers 403 with no body on purpose, because the viewer may
369
+ // not see this listing at all. Nothing was written, so undo the move.
370
+ console.error(`[positioned] reposition rejected (${response.status}); reverting the move`)
371
+ this.#revertMove(row, restoreAnchor)
372
+ } else {
373
+ // 2xx but NOT a stream. Almost always an auth boundary that
374
+ // 302-redirected to a login page, which fetch followed transparently —
375
+ // so the POST did nothing at all.
376
+ console.warn("[positioned] reposition returned a non-stream response (session expired?); reverting the move")
377
+ this.#revertMove(row, restoreAnchor)
378
+ }
379
+ } catch (error) {
380
+ // Network failure, offline, aborted. The server never saw the drop.
381
+ console.error("[positioned] reposition request failed:", error)
382
+ this.#revertMove(row, restoreAnchor)
383
+ }
384
+ }
385
+
386
+ // Undoes the optimistic move. Only meaningful while the row is still the node
387
+ // we moved: once a turbo-stream has replaced the collection the server's truth
388
+ // owns the DOM, and this instance's nodes are detached.
389
+ //
390
+ // A missing/relocated anchor falls back to appending, which is also what a
391
+ // null anchor legitimately means — the row was last before the move.
392
+ #revertMove(row, anchor) {
393
+ const parent = row.parentElement
394
+ if (!parent || !row.isConnected) return
395
+
396
+ // insertBefore drops focus in most browsers, and the keyboard path leaves
397
+ // the user tabbed to this very grip.
398
+ const refocus = row.contains(document.activeElement)
399
+
400
+ parent.insertBefore(row, anchor?.parentNode === parent ? anchor : null)
401
+
402
+ if (refocus) row.querySelector("[data-positioned-grip]")?.focus()
403
+ }
404
+
405
+ // ─── helpers ────────────────────────────────────────────────────────────────
406
+
407
+ #rows() {
408
+ return [...this.element.querySelectorAll("[data-positioned-row-id]")]
409
+ }
410
+
411
+ #rowFor(element) {
412
+ return element.closest("[data-positioned-row-id]")
413
+ }
414
+
415
+ // Whether the row under the cursor shares the dragged row's positioning
416
+ // group. An UNSCOPED resource emits no group attribute at all, so both sides
417
+ // read undefined and every row matches — the guard costs nothing there.
418
+ //
419
+ // Pointing at no row (the gutter below the last one, the wrapper's padding)
420
+ // counts as a match: the drop resolves against the collection's own ends,
421
+ // which are in the dragged row's group by construction.
422
+ #sameGroupAs(target) {
423
+ const row = this.#rowFor(target)
424
+ if (!row) return true
425
+
426
+ return row.dataset.positionedGroup === this.draggedRow.dataset.positionedGroup
427
+ }
428
+
429
+ #focusedRowId() {
430
+ const grip = document.activeElement?.closest?.("[data-positioned-grip]")
431
+ return grip ? this.#rowFor(grip)?.dataset.positionedRowId : null
432
+ }
433
+
434
+ // Deferred a frame: renderStreamMessage resolves its render asynchronously,
435
+ // so the replacement rows are not in the document yet when it returns.
436
+ //
437
+ // Queried off `document`, NOT `this.element`. The stream replaces the contents
438
+ // of the collection wrapper (Page::Index.collection_dom_id), and this
439
+ // controller's element — the div around the <table>, or the grid div — is one
440
+ // of the nodes it discards. By the time this callback runs `this.element` is
441
+ // detached, so scoping the lookup to it finds nothing and focus is silently
442
+ // lost on exactly the path this method exists to cover: a keyboard move that
443
+ // triggered a rebalance. A fresh controller is already connected to the
444
+ // replacement; `document` is the only handle that spans both.
445
+ #restoreFocus(rowId) {
446
+ requestAnimationFrame(() => {
447
+ document
448
+ .querySelector(`[data-positioned-row-id="${CSS.escape(rowId)}"] [data-positioned-grip]`)
449
+ ?.focus()
450
+ })
451
+ }
452
+ }
@@ -37,7 +37,10 @@ import AutosubmitController from "./autosubmit_controller.js"
37
37
  import DirtyFormGuardController from "./dirty_form_guard_controller.js"
38
38
  import WizardController from "./wizard_controller.js"
39
39
  import KanbanController from "./kanban_controller.js"
40
+ import PositionedController from "./positioned_controller.js"
40
41
  import CurrencyInputController from "./currency_input_controller.js"
42
+ import BreadcrumbsController from "./breadcrumbs_controller.js"
43
+ import RunProgressController from "./run_progress_controller.js"
41
44
 
42
45
  export default function (application) {
43
46
  // Register controllers here
@@ -79,5 +82,8 @@ export default function (application) {
79
82
  application.register("dirty-form-guard", DirtyFormGuardController)
80
83
  application.register("wizard", WizardController)
81
84
  application.register("kanban", KanbanController)
85
+ application.register("positioned", PositionedController)
82
86
  application.register("currency-input", CurrencyInputController)
87
+ application.register("breadcrumbs", BreadcrumbsController)
88
+ application.register("run-progress", RunProgressController)
83
89
  }
@@ -206,6 +206,10 @@ export default class extends Controller {
206
206
  this.menu.classList.remove('hidden')
207
207
  this.menu.classList.add('block')
208
208
  this.menu.removeAttribute('aria-hidden')
209
+ // Templates render aria-expanded="false" so the trigger is honest before
210
+ // hydration; from here on the controller owns it. Without this every
211
+ // dropdown in the app permanently claims to be collapsed.
212
+ this.triggerTarget.setAttribute('aria-expanded', 'true')
209
213
 
210
214
  // Enable popper event listeners
211
215
  this.popperInstance.setOptions((options) => ({
@@ -225,6 +229,7 @@ export default class extends Controller {
225
229
  this.menu.classList.remove('block')
226
230
  this.menu.classList.add('hidden')
227
231
  this.menu.setAttribute('aria-hidden', 'true')
232
+ this.triggerTarget.setAttribute('aria-expanded', 'false')
228
233
 
229
234
  // Disable popper event listeners
230
235
  this.popperInstance.setOptions((options) => ({
@@ -0,0 +1,73 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="run-progress"
4
+ //
5
+ // Re-fetches the enclosing turbo-frame on an interval so an interaction run's
6
+ // progress page updates itself. Polling rather than ActionCable: it needs no
7
+ // cable server, so it works on every deployment.
8
+ //
9
+ // The element carrying this controller lives INSIDE the frame it reloads, and
10
+ // the server only emits it while the run is in progress. So each refresh
11
+ // disconnects this instance and connects a new one — until the run finishes,
12
+ // when the replacement markup carries no controller at all and the polling
13
+ // stops. That is the whole stop condition: nothing here has to know what
14
+ // "finished" means, and no timer outlives the work it was watching.
15
+ //
16
+ // The swap is the stop condition, but it must not also be the only thing that
17
+ // KEEPS it going: a fetch that fails swaps nothing, so relying on the
18
+ // replacement to re-arm the timer means one dropped request freezes the
19
+ // progress page until the reader reloads it by hand. #refresh re-arms itself,
20
+ // and #disconnect cancels that timer the moment a successful swap happens.
21
+ export default class extends Controller {
22
+ static values = {
23
+ url: String,
24
+ interval: { type: Number, default: 2000 },
25
+ // Set by the server on the LAST poll only — see AsyncRunProgress#poll_attributes.
26
+ finished: Boolean,
27
+ }
28
+
29
+ connect() {
30
+ // The run settled. Only this panel is inside the polled frame, so the fields
31
+ // beside it still show what they showed at dispatch; one reload catches them
32
+ // up. Not a poll — there is nothing left to watch, and the reloaded page
33
+ // carries no controller at all.
34
+ if (this.finishedValue) return this.reloadPage()
35
+
36
+ this.schedule()
37
+ }
38
+
39
+ disconnect() {
40
+ if (this.timer) clearTimeout(this.timer)
41
+ this.timer = null
42
+ }
43
+
44
+ schedule() {
45
+ this.timer = setTimeout(() => this.refresh(), this.intervalValue)
46
+ }
47
+
48
+ reloadPage() {
49
+ // visit() rather than location.reload(), so Turbo treats it as a navigation
50
+ // and keeps the scroll position and cache behaviour of every other one.
51
+ window.Turbo?.visit(window.location.href, { action: "replace" })
52
+ }
53
+
54
+ refresh() {
55
+ const frame = this.element.closest("turbo-frame")
56
+ // No enclosing frame means the markup is wrong, not that the run is over —
57
+ // deliberately not rescheduled, since retrying cannot fix a template.
58
+ if (!frame) return
59
+
60
+ // Before the fetch, not after: reload() is async, and a rejected request
61
+ // must still leave a timer armed to try again.
62
+ this.schedule()
63
+
64
+ // The frame is rendered WITHOUT a src (it arrives as part of the full page,
65
+ // so an eager src would cost a second request on every page load). Setting
66
+ // it is what triggers the first fetch; after that reload() re-uses it.
67
+ if (frame.src) {
68
+ frame.reload()
69
+ } else {
70
+ frame.src = this.urlValue
71
+ }
72
+ }
73
+ }