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
@@ -15,7 +15,7 @@ module Plutonium
15
15
  end
16
16
 
17
17
  def page_actions
18
- super || current_definition.defined_actions.values.select { |a| a.record_action? && !a.kanban_drop? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
18
+ super || current_definition.defined_actions.values.select { |a| a.record_action? && !a.hidden? && a.permitted_by?(current_policy) && a.condition_met?(view_context, record: resource_record!) }
19
19
  end
20
20
 
21
21
  def render_default_content
@@ -25,28 +25,45 @@ module Plutonium
25
25
  # modal: a slideover/centered dialog has no room for a side rail.
26
26
  return render_modal_details if in_modal?
27
27
 
28
- if aside_present?
29
- div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_240px] gap-6") do
30
- div { render partial("resource_details") }
31
- aside(class: "hidden lg:block") { render_aside }
28
+ # Width comes from configuration (`page_width` / `display_width` on
29
+ # the definition). A modal is excluded above — the dialog sizes
30
+ # itself and `:full` resolves to no classes, leaving the markup
31
+ # exactly as it was before widths were configurable.
32
+ constrain_width do
33
+ if aside_present?
34
+ div(class: "grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_240px] gap-6") do
35
+ div { render partial("resource_details") }
36
+ aside(class: "hidden lg:block") { render_aside }
37
+ end
38
+ else
39
+ render partial("resource_details")
32
40
  end
33
- else
34
- render partial("resource_details")
35
41
  end
36
42
  end
37
43
 
44
+ # Wraps the content in the configured width, or yields bare when the
45
+ # resolved width is `:full` — so an unconstrained page gains no
46
+ # pointless wrapper div.
47
+ def constrain_width(&block)
48
+ classes = current_definition.display_width_classes
49
+ return yield if classes.nil?
50
+
51
+ div(class: classes, &block)
52
+ end
53
+
38
54
  # The show page is ALWAYS a centered dialog when shown in a modal —
39
55
  # deliberately not the definition's modal_mode (which styles :new/:edit
40
56
  # as a slideover by default). A centered dialog reads as a focused
41
57
  # "detail card" and leaves a launching board/table visible around it.
42
- # `open_full_url` is the current show URL (request.path is the show
43
- # route here), letting the user pop the record out to its full page.
58
+ # `open_full_url` lets the user pop the record out to its full page —
59
+ # notably dropping `kanban_modal`, so a card expanded out of a board
60
+ # regains the metadata rail that the modal hides.
44
61
  def render_modal_details
45
62
  render Plutonium::UI::Modal::Centered.new(
46
63
  title: page_title,
47
64
  description: page_description,
48
65
  size: :lg,
49
- open_full_url: request.path
66
+ open_full_url: open_full_page_url
50
67
  ) do
51
68
  # The modal body owns no padding — content provides its own (the
52
69
  # form uses this same padded, scrollable region). Without it the
@@ -26,7 +26,7 @@ module Plutonium
26
26
 
27
27
  def view_template(&)
28
28
  DynaFrameContent() do
29
- article(class: "pu-wizard mx-auto max-w-3xl", data: {controller: "wizard"}) do
29
+ article(class: tokens("pu-wizard", wizard_width_classes), data: {controller: "wizard"}) do
30
30
  render_header
31
31
  render_stepper
32
32
  render_body
@@ -43,6 +43,14 @@ module Plutonium
43
43
 
44
44
  # Centered wizard header: the title and the wizard-level description
45
45
  # (`presents description:`). The per-step heading lives on the step card.
46
+ # Wizard step width. The wizard class resolves it: its own `width`,
47
+ # else `config.wizards.width`. Deliberately does NOT fall through to
48
+ # `config.default_page_width` — wizard widths are configured on their
49
+ # own axis, so moving resource pages leaves wizards where they are.
50
+ def wizard_width_classes
51
+ Plutonium::UI::PageWidth.classes_for(@runner.wizard.class.width)
52
+ end
53
+
46
54
  def render_header
47
55
  div(class: "pu-wizard-header mb-7 text-center") do
48
56
  h1(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") do
@@ -128,6 +136,7 @@ module Plutonium
128
136
  render_step_header(step)
129
137
  render Plutonium::UI::Form::Wizard.new(
130
138
  step:,
139
+ wizard: @runner.wizard,
131
140
  # Decorate so attachment fields read as resolved attachments (the
132
141
  # Uppy preview rehydrates on Back/resume); other fields pass through.
133
142
  data: Plutonium::Wizard::AttachmentData.wrap(@runner.wizard.data[step.key], step),
@@ -5,10 +5,10 @@ module Plutonium
5
5
  module Page
6
6
  # The "resume or start new" chooser (§4.5), shown at the bare launch URL when
7
7
  # a tokened wizard opts in with `on_relaunch :prompt` and the user already has
8
- # pending (in-progress) runs. Lists each pending run with a Resume link and
9
- # offers a Start-new button. Keyed/one-time/anchored wizards never reach here
10
- # (they auto-resume their single keyed run); only tokened wizards can have
11
- # several concurrent runs to choose between.
8
+ # pending (in-progress) runs. Lists each pending run with a Resume link and a
9
+ # Cancel button, and offers a Start-new button. Keyed/one-time/anchored
10
+ # wizards never reach here (they auto-resume their single keyed run); only
11
+ # tokened wizards can have several concurrent runs to choose between.
12
12
  class WizardChooser < Plutonium::UI::Page::Base
13
13
  # @param wizard_class [Class] the wizard being launched.
14
14
  # @param entries [Array<Plutonium::Wizard::Resume::Entry>] pending runs.
@@ -57,9 +57,9 @@ module Plutonium
57
57
  end
58
58
  end
59
59
 
60
- # One pending run: its current step + when it was last touched, and a Resume
61
- # link. A run whose mount can't be resolved (no `resume_url`) is shown as a
62
- # disabled row rather than a dead link.
60
+ # One pending run: its current step + when it was last touched, a Resume link
61
+ # and a Cancel button. A run whose mount can't be resolved (no `resume_url`)
62
+ # is shown as a disabled row rather than a dead link.
63
63
  def render_entry(entry)
64
64
  li(class: "flex items-center justify-between gap-4 px-5 py-4", data: {wizard_chooser_entry: entry.current_step}) do
65
65
  div(class: "min-w-0") do
@@ -68,14 +68,39 @@ module Plutonium
68
68
  end
69
69
  p(class: "mt-0.5 text-xs text-[var(--pu-text-muted)]") { "Updated #{updated_ago(entry)} ago" }
70
70
  end
71
- if entry.resume_url.present?
72
- a(href: entry.resume_url, class: "pu-btn pu-btn-sm pu-btn-outline shrink-0", data: {wizard_chooser_resume: true}) { "Resume" }
73
- else
74
- span(class: "pu-btn pu-btn-sm pu-btn-outline shrink-0 opacity-50 cursor-not-allowed") { "Resume" }
71
+ div(class: "flex items-center gap-2 shrink-0") do
72
+ if entry.resume_url.present?
73
+ a(href: entry.resume_url, class: "pu-btn pu-btn-sm pu-btn-outline", data: {wizard_chooser_resume: true}) { "Resume" }
74
+ else
75
+ span(class: "pu-btn pu-btn-sm pu-btn-outline opacity-50 cursor-not-allowed") { "Resume" }
76
+ end
77
+
78
+ render_cancel_form(entry) if entry.cancel_url.present?
75
79
  end
76
80
  end
77
81
  end
78
82
 
83
+ # Abandoning a run is destructive and irreversible — cancelling runs every
84
+ # step's `on_rollback`, destroys the records it persisted, then deletes the
85
+ # row. So it goes through a DELETE form (never a link) behind a
86
+ # `data-turbo-confirm` prompt. Turbo reads `turbo_confirm`; plain
87
+ # `data-confirm` is Rails UJS and would silently never fire here.
88
+ def render_cancel_form(entry)
89
+ form(action: entry.cancel_url, method: "post", class: "inline-block") do
90
+ input(type: "hidden", name: "_method", value: "delete")
91
+ input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
92
+ button(
93
+ type: "submit",
94
+ class: "pu-btn pu-btn-sm pu-btn-soft-danger",
95
+ aria_label: "Cancel this #{@wizard_class.label} draft",
96
+ data: {
97
+ wizard_chooser_cancel: true,
98
+ turbo_confirm: "Discard this in-progress #{@wizard_class.label}? This can't be undone."
99
+ }
100
+ ) { "Cancel" }
101
+ end
102
+ end
103
+
79
104
  def render_start_new
80
105
  div(class: "border-t border-[var(--pu-border)] bg-[var(--pu-surface-alt)] px-5 py-4") do
81
106
  a(href: @start_new_url, class: "pu-btn pu-btn-md pu-btn-primary w-full justify-center", data: {wizard_chooser_start_new: true}) do
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ # The width vocabulary for detail-style pages — the show page, resource
6
+ # forms and wizard steps.
7
+ #
8
+ # These pages are read and filled top-to-bottom in a single column. Inputs
9
+ # and values stretch to their container, so at full content width on a wide
10
+ # screen you get ~1200px-long lines and text boxes: past a comfortable
11
+ # measure, and a long eye-travel between a label and the value beside it.
12
+ # Index and table pages are deliberately NOT covered — a table wants every
13
+ # pixel it can get.
14
+ #
15
+ # The tokens deliberately mirror {Plutonium::UI::Modal::Base::VALID_SIZES}
16
+ # so the framework has one width language rather than two.
17
+ module PageWidth
18
+ # Token => the max-width utility it resolves to. `:full` is the opt-out:
19
+ # no constraint at all, the historical behaviour.
20
+ SIZES = {
21
+ sm: "max-w-2xl",
22
+ md: "max-w-4xl",
23
+ lg: "max-w-6xl",
24
+ xl: "max-w-7xl",
25
+ full: nil
26
+ }.freeze
27
+
28
+ VALID_SIZES = SIZES.keys.freeze
29
+
30
+ # The classes for a token: a max-width plus the centring that makes it
31
+ # read as a column rather than a left-hugging block. `:full` contributes
32
+ # nothing, so an unconstrained page keeps exactly the markup it had
33
+ # before this vocabulary existed.
34
+ #
35
+ # @param size [Symbol]
36
+ # @return [String, nil]
37
+ def self.classes_for(size)
38
+ max_width = SIZES.fetch(size) { raise_unknown(size) }
39
+ return nil if max_width.nil?
40
+
41
+ "#{max_width} mx-auto"
42
+ end
43
+
44
+ # Raises on an unknown token rather than silently falling back, matching
45
+ # `modal` and `show_in`. A typo'd width is a bug, not a default.
46
+ def self.validate!(size)
47
+ return size if VALID_SIZES.include?(size)
48
+ raise_unknown(size)
49
+ end
50
+
51
+ def self.raise_unknown(size)
52
+ raise ArgumentError,
53
+ "page width must be one of #{VALID_SIZES.inspect}, got #{size.inspect}"
54
+ end
55
+ private_class_method :raise_unknown
56
+ end
57
+ end
58
+ end
@@ -12,7 +12,40 @@ module Plutonium
12
12
  # button). Rows without such a target become a no-op — no
13
13
  # special-casing needed in this layer.
14
14
  def table_body_row_attributes(wrapped_object)
15
- super.merge(data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"})
15
+ attributes = super.merge(data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"})
16
+ return attributes unless positioned?
17
+
18
+ # The row — never the grip — is the single source of truth for which
19
+ # record a drag is about. See positioned_controller.js.
20
+ row_data = attributes[:data].merge(positioned_row_id: wrapped_object.identifier)
21
+
22
+ # Present only for a scoped resource, where a drop across groups is
23
+ # meaningless and the drag must refuse it (see Positionable).
24
+ group = @options[:positioned_group_resolver]&.call(wrapped_object.unwrapped)
25
+ row_data = row_data.merge(positioned_group: group) unless group.nil?
26
+
27
+ attributes.merge(data: row_data)
28
+ end
29
+
30
+ # Scope the drag controller to the div that wraps <table>: the tightest
31
+ # element that contains every row, and nothing else the drag cares about.
32
+ def table_wrapper_attributes
33
+ return super unless positioned?
34
+
35
+ super.merge(
36
+ data: {
37
+ controller: "positioned",
38
+ positioned_url_template_value: @options[:positioned_url_template]
39
+ }
40
+ )
41
+ end
42
+
43
+ # Reorderable AND currently orderable. Table::Resource passes the
44
+ # template only when the collection is in ascending position order — a
45
+ # drop under any other sort is rejected by the server, so the client must
46
+ # not offer it (the grip still renders, disabled, to say so).
47
+ def positioned?
48
+ @options[:positioned_url_template].present?
16
49
  end
17
50
 
18
51
  # Use custom SelectionColumn with Stimulus data attributes
@@ -27,7 +27,7 @@ module Plutonium
27
27
  attachment_preview_thumbnail_url_value: attachment_thumbnail_url(attachment),
28
28
  attachment_preview_target: "thumbnail"
29
29
  },
30
- title: attachment.filename
30
+ title: attachment.filename.to_s
31
31
  ) do
32
32
  a(
33
33
  href: attachment.url,
@@ -59,17 +59,19 @@ module Plutonium
59
59
  end
60
60
 
61
61
  def render_action_button(action)
62
- url = route_options_to_url(action.route_options, resource_class)
62
+ url = with_return_to(route_options_to_url(action.route_options, resource_class))
63
63
 
64
64
  link_to(
65
65
  url,
66
- class: button_classes(action),
67
- data: {
68
- bulk_actions_target: "actionButton",
69
- bulk_action_name: action.name,
70
- bulk_action_url: url,
71
- turbo_frame: action.turbo_frame(current_definition)
72
- }
66
+ action.link_attributes({
67
+ class: button_classes(action),
68
+ data: {
69
+ bulk_actions_target: "actionButton",
70
+ bulk_action_name: action.name,
71
+ bulk_action_url: url,
72
+ turbo_frame: action.turbo_frame(current_definition)
73
+ }
74
+ })
73
75
  ) do
74
76
  if action.icon
75
77
  render action.icon.new(class: "h-4 w-4")
@@ -78,6 +80,28 @@ module Plutonium
78
80
  end
79
81
  end
80
82
 
83
+ # Carries the index the user is on into the action, the same way
84
+ # ActionButton does for row and page actions.
85
+ #
86
+ # Without it a bulk action has nowhere to send the user back to. That
87
+ # is invisible for an action that redirects to the record it changed,
88
+ # and very visible for one that DISPATCHES: async interactions honour
89
+ # return_to (see Dispatchable#dispatch_redirect_target), so a user who
90
+ # archived the rows they had selected was landed on a progress page
91
+ # with their list two clicks behind them.
92
+ #
93
+ # current_page_url, not request.original_url, for the reason
94
+ # ActionButton#default_return_to gives: a toolbar re-rendered by a
95
+ # stream-producing POST must return the user to the page they are on,
96
+ # never to that POST-only endpoint.
97
+ def with_return_to(url)
98
+ uri = URI.parse(url)
99
+ params = Rack::Utils.parse_nested_query(uri.query)
100
+ params["return_to"] = current_page_url
101
+ uri.query = params.to_query
102
+ uri.to_s
103
+ end
104
+
81
105
  def button_classes(action)
82
106
  color_key = (action.color || action.category)&.to_sym || :primary
83
107
  color_class = COLOR_CLASSES[color_key] || COLOR_CLASSES[:primary]
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Table
6
+ module Components
7
+ # The drag affordance for a reorderable row.
8
+ #
9
+ # ## Why it lives inside the first cell rather than in a column of its own
10
+ #
11
+ # A dedicated grip column would cost horizontal space on EVERY row of
12
+ # every reorderable table, forever, for a gesture used occasionally. So
13
+ # the grip is pulled left into the first cell's padding with a negative
14
+ # margin exactly equal to its own width: it occupies the padding the cell
15
+ # already had, and the cell's content does not shift by a pixel — with or
16
+ # without the grip, hovered or not.
17
+ #
18
+ # (The padding, not a negative offset outside the cell: the body cell is
19
+ # `overflow-hidden`, which clips at the PADDING box. Anything positioned
20
+ # beyond it would simply be invisible.)
21
+ #
22
+ # ## Why the grip is draggable and the <tr> is not
23
+ #
24
+ # See positioned_controller.js — `draggable="true"` kills text selection
25
+ # inside the element, and a draggable row fights row_click_controller.
26
+ # Both would be silent regressions on an ordinary data table.
27
+ #
28
+ # ## The disabled state IS the way out of the disabled state
29
+ #
30
+ # Dropping between two rows only means something when the visual order is
31
+ # the stored order, so the server rejects a drop made under any other sort
32
+ # (including a DESCENDING position sort). Rather than hiding the
33
+ # affordance — which would leave the user with no hint that the table is
34
+ # reorderable at all, and no way to make it so — it renders as a link that
35
+ # applies the ascending position sort. That is precisely why `position_on`
36
+ # registers `sort <attribute>`.
37
+ #
38
+ # ## Why one component serves both the table and the card grid
39
+ #
40
+ # The behaviour — draggable button, keyboard hint, disabled-is-the-way-out
41
+ # link — is identical on both surfaces; only WHERE the grip sits differs.
42
+ # A table row has padding to hide it in, a card does not, so the placement
43
+ # is the one thing `variant:` switches (see Table::Theme). Two components
44
+ # would mean two chances for the drag contract to drift.
45
+ class DragHandle < Phlexi::Table::HTML
46
+ # Placement only — never behaviour. :row tucks the grip into a table
47
+ # cell's existing left padding; :card floats it over the card's
48
+ # top-left corner, since a card has no spare gutter to hide it in.
49
+ VARIANTS = {
50
+ row: {grip: :drag_handle, disabled: :drag_handle_disabled},
51
+ card: {grip: :drag_handle_card, disabled: :drag_handle_card_disabled}
52
+ }.freeze
53
+
54
+ # @param sort_url [String, nil] nil when dragging is live; otherwise the
55
+ # URL that puts the collection back into ascending position order.
56
+ # @param variant [Symbol] :row (table cell) or :card (grid card)
57
+ def initialize(sort_url: nil, variant: :row)
58
+ @sort_url = sort_url
59
+ @variant = VARIANTS.fetch(variant)
60
+ end
61
+
62
+ def view_template
63
+ @sort_url ? render_disabled : render_grip
64
+ end
65
+
66
+ private
67
+
68
+ def render_grip
69
+ button(
70
+ type: "button",
71
+ draggable: "true",
72
+ class: themed(@variant[:grip]),
73
+ title: "Drag to reorder",
74
+ aria: {label: "Drag to reorder. Use the up and down arrow keys to move this row."},
75
+ data: {positioned_grip: ""}
76
+ ) { icon }
77
+ end
78
+
79
+ def render_disabled
80
+ a(
81
+ href: @sort_url,
82
+ class: themed(@variant[:disabled]),
83
+ title: "Sort by position to reorder"
84
+ ) { icon }
85
+ end
86
+
87
+ def icon
88
+ render Phlex::TablerIcons::GripVertical.new(class: "w-4 h-4")
89
+ end
90
+
91
+ # A first-column cell with a grip in front of it.
92
+ #
93
+ # The table renders whatever the column's own block returned; this
94
+ # wraps that component rather than replacing it, so a formatter, a
95
+ # custom block or an inferred display component all keep rendering
96
+ # exactly as they did. Flex rather than inline flow because that inner
97
+ # component may be block or inline depending on the field type, and the
98
+ # grip must sit beside it either way.
99
+ #
100
+ # Nested inside DragHandle so referencing it loads this file — a
101
+ # sibling top-level class in drag_handle.rb would be invisible to
102
+ # Zeitwerk until something happened to touch DragHandle first.
103
+ class Cell < Phlexi::Table::HTML
104
+ def initialize(cell, sort_url: nil)
105
+ @cell = cell
106
+ @sort_url = sort_url
107
+ end
108
+
109
+ def view_template
110
+ div(class: themed(:drag_handle_cell)) do
111
+ render DragHandle.new(sort_url: @sort_url)
112
+ div(class: "min-w-0") { render @cell }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -142,10 +142,7 @@ module Plutonium
142
142
  tag = input_options[:as] || field_options[:as]
143
143
  tag_attributes = input_options.except(:wrapper, :as)
144
144
 
145
- tag_block = input_definition[:block] || ->(f) {
146
- tag ||= f.inferred_field_component
147
- f.send(:"#{tag}_tag", **tag_attributes, class: "w-full")
148
- }
145
+ tag_block = input_definition[:block] || ->(f) { f.component_for(tag, **tag_attributes, class: "w-full") }
149
146
 
150
147
  field_options = field_options.except(:as)
151
148
 
@@ -74,7 +74,7 @@ module Plutonium
74
74
  keep["q"] = cleaned
75
75
  end
76
76
  end
77
- "#{request.path}?#{keep.to_query}"
77
+ "#{@query.request_path}?#{keep.to_query}"
78
78
  end
79
79
 
80
80
  def render_result_count
@@ -34,7 +34,7 @@ module Plutonium
34
34
 
35
35
  def render_dropdown_menu
36
36
  div(
37
- class: "hidden absolute right-0 z-50 mt-1 w-40 origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden",
37
+ class: "hidden absolute right-0 z-50 mt-1 w-max min-w-40 max-w-[min(20rem,90vw)] origin-top-right bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-[var(--pu-radius-md)] overflow-hidden",
38
38
  style: "box-shadow: var(--pu-shadow-lg)",
39
39
  data: {resource_drop_down_target: "menu"}
40
40
  ) do
@@ -4,6 +4,8 @@ module Plutonium
4
4
  module UI
5
5
  module Table
6
6
  class Resource < Plutonium::UI::Component::Base
7
+ include Plutonium::UI::Component::Positionable
8
+
7
9
  attr_reader :collection, :resource_fields, :resource_definition
8
10
 
9
11
  def initialize(collection, resource_fields:, resource_definition:)
@@ -73,7 +75,7 @@ module Plutonium
73
75
  end
74
76
 
75
77
  def current_search_url
76
- request.path
78
+ current_query_object.request_path
77
79
  end
78
80
 
79
81
  def render_bulk_actions_toolbar
@@ -92,7 +94,9 @@ module Plutonium
92
94
  end
93
95
 
94
96
  def render_table
95
- render Plutonium::UI::Table::Base.new(collection) do |table|
97
+ table_options = position_drag_enabled? ? {positioned_url_template: position_url_template, positioned_group_resolver: position_group_resolver} : {}
98
+
99
+ render Plutonium::UI::Table::Base.new(collection, **table_options) do |table|
96
100
  # Selection column only renders when bulk actions exist —
97
101
  # the server already knows, so no JS toggle is needed.
98
102
  # Use :_selection as column key to avoid conflicts with field columns;
@@ -103,6 +107,11 @@ module Plutonium
103
107
  policy_resolver: ->(record) { policy_for(record:) }
104
108
  end
105
109
 
110
+ # The grip rides in the FIRST column that actually renders — tracked
111
+ # rather than indexed off @resource_fields, because a `condition:`
112
+ # can skip the nominal first column and the grip must not go with it.
113
+ grip_pending = !position_config.nil?
114
+
106
115
  @resource_fields.each do |name|
107
116
  field_options = resource_definition.defined_fields[name] ? resource_definition.defined_fields[name][:options].dup : {}
108
117
 
@@ -125,6 +134,13 @@ module Plutonium
125
134
  display_tag_attributes = display_options.except(:wrapper, :as, :condition, *field_level_keys)
126
135
  column_tag_attributes = column_options.except(:wrapper, :as, :align, :condition, *field_level_keys)
127
136
  tag_attributes = display_tag_attributes.merge(column_tag_attributes)
137
+
138
+ # A `formatter:` produces the cell string itself, so render it
139
+ # through the formatted-value component regardless of the column's
140
+ # type. Otherwise the Proc would be handed to a typed component
141
+ # (boolean pill, currency…) and leak into its HTML attributes.
142
+ tag = :formatted_value if tag_attributes.key?(:formatter)
143
+
128
144
  tag_block = if column_definition[:block]
129
145
  # User-provided blocks receive the raw record for convenience
130
146
  user_block = column_definition[:block]
@@ -132,11 +148,18 @@ module Plutonium
132
148
  else
133
149
  ->(wrapped_object, key) {
134
150
  f = wrapped_object.field(key)
151
+ # Infer ONCE per column (this block runs per row), so every row
152
+ # renders through the same tag even where values differ in type.
135
153
  tag ||= f.inferred_field_component
136
- f.send(:"#{tag}_tag", **tag_attributes)
154
+ f.component_for(tag, **tag_attributes)
137
155
  }
138
156
  end
139
157
 
158
+ if grip_pending
159
+ grip_pending = false
160
+ tag_block = wrap_with_drag_handle(tag_block)
161
+ end
162
+
140
163
  # For table columns, only extract column-level options (label and align)
141
164
  # Field-level options like description and placeholder don't make sense in table cells
142
165
  field_options = field_options.except(:condition).merge(**column_options.slice(:align, :label))
@@ -151,7 +174,7 @@ module Plutonium
151
174
  policy = policy_for(record:)
152
175
 
153
176
  actions = resource_definition.defined_actions
154
- .select { |k, a| a.collection_record_action? && !a.kanban_drop? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
177
+ .select { |k, a| a.collection_record_action? && !a.hidden? && policy.allowed_to?(:"#{k}?") && a.condition_met?(view_context, record:) }
155
178
  .values
156
179
 
157
180
  primary_actions = actions.select { |a| a.category.primary? }.sort_by(&:position)
@@ -176,9 +199,31 @@ module Plutonium
176
199
  end
177
200
  end
178
201
 
202
+ # Returns a column block that renders `inner`'s cell with the grip in
203
+ # front of it. Wraps rather than replaces, so a formatter, a custom
204
+ # column block and an inferred component all keep rendering as they did.
205
+ #
206
+ # `sort_url` is resolved ONCE, outside the per-row lambda: it depends on
207
+ # the collection's sort, not on the record, and building it per row would
208
+ # re-derive the same URL for every row of every page.
209
+ #
210
+ # The per-record `reposition?` check, by contrast, HAS to run per row.
211
+ # Rows that fail it render exactly as they did before, unwrapped.
212
+ def wrap_with_drag_handle(inner)
213
+ sort_url = position_grip_sort_url
214
+
215
+ ->(wrapped_object, key) {
216
+ record = wrapped_object.unwrapped
217
+ cell = inner.call(wrapped_object, key)
218
+ next cell unless repositionable?(record)
219
+
220
+ Plutonium::UI::Table::Components::DragHandle::Cell.new(cell, sort_url: sort_url)
221
+ }
222
+ end
223
+
179
224
  def bulk_actions
180
225
  @bulk_actions ||= resource_definition.defined_actions
181
- .select { |k, a| a.bulk_action? && a.condition_met?(view_context) }
226
+ .select { |k, a| a.bulk_action? && !a.hidden? && a.condition_met?(view_context) }
182
227
  .values
183
228
  end
184
229