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
@@ -8,6 +8,8 @@ module Plutonium
8
8
  # panel, scopes pills, bulk actions, footer) so view-switching is
9
9
  # purely a render-shape change.
10
10
  class Resource < Plutonium::UI::Component::Base
11
+ include Plutonium::UI::Component::Positionable
12
+
11
13
  attr_reader :collection, :resource_fields, :resource_definition
12
14
 
13
15
  def initialize(collection, resource_fields:, resource_definition:)
@@ -42,7 +44,7 @@ module Plutonium
42
44
  def render_toolbar
43
45
  TableToolbar(
44
46
  query: current_query_object,
45
- search_url: request.path,
47
+ search_url: current_query_object.request_path,
46
48
  search_value: params.dig(:q, :search) || params[:search],
47
49
  views: resource_definition.defined_index_views,
48
50
  current_view: :grid,
@@ -71,13 +73,53 @@ module Plutonium
71
73
  end
72
74
 
73
75
  def render_grid
74
- div(class: grid_class) do
76
+ div(class: grid_class, data: grid_data) do
75
77
  collection.each do |record|
76
- render Plutonium::UI::Grid::Card.new(record, resource_definition:, resource_fields:)
78
+ render Plutonium::UI::Grid::Card.new(
79
+ record,
80
+ resource_definition:,
81
+ resource_fields:,
82
+ drag_handle: drag_handle_for(record),
83
+ position_group: position_group_for(record)
84
+ )
77
85
  end
78
86
  end
79
87
  end
80
88
 
89
+ # The grid div is the tightest element containing every card and nothing
90
+ # else the drag cares about, so it is where the controller is scoped —
91
+ # exactly as Table::Base scopes it to the div wrapping <table>.
92
+ #
93
+ # Only wired when a drop can actually be honoured (see
94
+ # Positionable#position_drag_enabled?). Under any other sort the cards
95
+ # still render their grip, but as the disabled link back to position
96
+ # order, and no controller is present to drag against.
97
+ def grid_data
98
+ return {} unless position_drag_enabled?
99
+
100
+ {
101
+ controller: "positioned",
102
+ positioned_url_template_value: position_url_template,
103
+ # Cards flow left-to-right and wrap, so the vertical midpoint test a
104
+ # table uses would collapse a whole row of cards into one slot.
105
+ positioned_axis_value: "horizontal"
106
+ }
107
+ end
108
+
109
+ # The grip for `record`, or nil when this resource is not reorderable /
110
+ # this viewer may not reorder this record. `position_grip_sort_url` is
111
+ # memoized on the collection's sort, so the per-record cost here is just
112
+ # the policy check.
113
+ def drag_handle_for(record)
114
+ return nil unless position_config
115
+ return nil unless repositionable?(record)
116
+
117
+ Plutonium::UI::Table::Components::DragHandle.new(
118
+ sort_url: position_grip_sort_url,
119
+ variant: :card
120
+ )
121
+ end
122
+
81
123
  # Default responsive: 1 / 2 / 3 / 4 columns at sm/md/lg/xl. When
82
124
  # the definition pins a fixed `grid_columns N`, use that on lg+ so
83
125
  # mobile still gets sensible single-column.
@@ -92,7 +134,7 @@ module Plutonium
92
134
 
93
135
  def bulk_actions
94
136
  @bulk_actions ||= resource_definition.defined_actions
95
- .select { |k, a| a.bulk_action? }
137
+ .select { |k, a| a.bulk_action? && !a.hidden? && a.condition_met?(view_context) }
96
138
  .values
97
139
  end
98
140
 
@@ -124,7 +166,7 @@ module Plutonium
124
166
  render Plutonium::UI::Table::Components::FilterForm.new(
125
167
  filter_form_values,
126
168
  query_object: current_query_object,
127
- search_url: request.path,
169
+ search_url: current_query_object.request_path,
128
170
  search_value: params.dig(:q, :search) || params[:search]
129
171
  )
130
172
  end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Interaction
6
+ module Async
7
+ # The progress panel on a run's show page — the page IS the progress page.
8
+ #
9
+ # == How it refreshes
10
+ #
11
+ # Polling, not ActionCable: a turbo-frame the browser re-fetches on a timer
12
+ # works on any deployment, including ones with no cable server, and a run's
13
+ # progress is a handful of numbers that nobody needs sub-second. The frame
14
+ # is driven by the +run-progress+ Stimulus controller, which lives INSIDE
15
+ # the frame — so when the run finishes, the refreshed markup no longer
16
+ # carries the controller and the timer stops of its own accord. A finished
17
+ # run is a static record; a poll that never stops is a background request
18
+ # per viewer, forever.
19
+ #
20
+ # The frame's +src+ is the run's own show URL. Turbo sends
21
+ # +Turbo-Frame: <id>+ with that request, so the response must be the PANEL
22
+ # ALONE — Plutonium's DynaFrameContent already wraps every response in a
23
+ # frame named by that header, and a second tag with the same id would nest
24
+ # the whole page inside its own progress frame (and again on the next
25
+ # poll). {Async::RunDefinition::ShowPage} short-circuits the page for exactly this
26
+ # request; this class drops its own frame tag to match.
27
+ class RunProgress < Plutonium::UI::Component::Base
28
+ include Phlex::Rails::Helpers::TurboFrameTag
29
+
30
+ # Slow enough to be cheap for a page left open on a wall display, fast
31
+ # enough that a short run does not look stuck.
32
+ POLL_INTERVAL_MS = 2_000
33
+
34
+ # Must match the animation in components.css. Ruby needs it because the
35
+ # poll restarts the animation and only the server knows how far into
36
+ # the sweep the bar should be — see #sweep_phase_ms.
37
+ SWEEP_DURATION_MS = 1_400
38
+
39
+ # Semantic colour per outcome. +completed_with_errors+ is deliberately
40
+ # NOT success: see Plutonium::Interaction::Async::Run#outcome.
41
+ BADGE_VARIANTS = {
42
+ "pending" => "warning",
43
+ "running" => "info",
44
+ "completed" => "success",
45
+ "completed_with_errors" => "warning",
46
+ "failed" => "danger"
47
+ }.freeze
48
+
49
+ # Public so the page can recognise a poll of THIS panel and answer with
50
+ # the panel alone.
51
+ def self.frame_id(run) = "pu_run_progress_#{run.id}"
52
+
53
+ def initialize(run)
54
+ @run = run
55
+ end
56
+
57
+ attr_reader :run
58
+
59
+ def view_template
60
+ return render_panel if answering_own_frame?
61
+
62
+ # `turbo-frame` defaults to `display: inline`, which swallows the
63
+ # margin `sections_wrapper` needs to separate it from the card below.
64
+ turbo_frame_tag(frame_id, class: "block") { render_panel }
65
+ end
66
+
67
+ private
68
+
69
+ def frame_id = self.class.frame_id(run)
70
+
71
+ # True when this render IS the response to the frame's own poll.
72
+ def answering_own_frame? = current_turbo_frame == frame_id
73
+
74
+ # Reload once, so the fields outside this frame stop disagreeing with it.
75
+ def finished_attributes
76
+ # The STRING, not true: Phlex renders a boolean attribute bare, and a
77
+ # valueless data attribute is exactly the shape Stimulus's Boolean
78
+ # coercion is least clear about.
79
+ {data: {controller: "run-progress", run_progress_finished_value: "true"}}
80
+ end
81
+
82
+ def render_panel
83
+ div(class: "pu-card pu-run-progress", **poll_attributes) do
84
+ div(class: "pu-card-body space-y-4") do
85
+ render_status
86
+ render_progress
87
+ render_failures if run.errors_log.any?
88
+ end
89
+ end
90
+ end
91
+
92
+ # Only an in-progress run carries the poll. Absent here, nothing on the
93
+ # page has a timer — which is how the refresh stops.
94
+ #
95
+ # Except on the LAST poll, which needs one more instruction. Only this
96
+ # panel lives in the polled frame; the fields beside it were rendered
97
+ # once, when the run was dispatched. So a run that finishes leaves the
98
+ # page showing two contradicting truths — a green "Completed, 5 of 5"
99
+ # above a list still reading "Running", "0 done", "finished at -" — and
100
+ # the stale half is the more detailed one. Telling the page to reload
101
+ # itself once is what lets those fields catch up.
102
+ #
103
+ # It cannot loop. The flag is emitted only while ANSWERING A POLL, and
104
+ # the reload it triggers is a full page render, where that is false.
105
+ def poll_attributes
106
+ return finished_attributes if !run.in_progress? && answering_own_frame?
107
+ return {} unless run.in_progress?
108
+
109
+ {
110
+ data: {
111
+ controller: "run-progress",
112
+ run_progress_url_value: resource_url_for(run),
113
+ run_progress_interval_value: POLL_INTERVAL_MS
114
+ }
115
+ }
116
+ end
117
+
118
+ def render_status
119
+ div(class: "flex items-center gap-2 flex-wrap") do
120
+ span(class: "pu-badge pu-badge-#{BADGE_VARIANTS.fetch(run.outcome, "neutral")}") do
121
+ plain run.outcome.humanize
122
+ end
123
+ # The count rides alongside the badge rather than only in the list
124
+ # below: a partially-applied :continue run ends as "completed", and
125
+ # the one thing a reader must not be able to miss is that it did not
126
+ # do everything it was asked to.
127
+ if run.error_count.positive?
128
+ span(class: "text-xs font-medium text-[var(--pu-text-danger)]") do
129
+ plain "#{run.error_count} #{"error".pluralize(run.error_count)}"
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ # NEVER rendered without {#render_status} above it. After a refused
136
+ # partial batch (:halt / :transactional), progress_done counts targets
137
+ # DISPOSITIONED, not applied — so a run that performed nothing at all can
138
+ # show progress_done > 0. Paired with the state that reads correctly;
139
+ # alone it reads as work done. See Async::Executor#refuse_partial_batch.
140
+ def render_progress
141
+ fraction = run.progress_fraction
142
+
143
+ # nil total is INDETERMINATE, not zero: opaque work has no denominator,
144
+ # and a 0% bar would claim nothing has happened rather than admitting
145
+ # the amount is unknown.
146
+ return render_indeterminate if fraction.nil?
147
+
148
+ percent = (fraction * 100).round
149
+ div(class: "space-y-1") do
150
+ div(class: "w-full h-2 rounded-full bg-[var(--pu-surface-alt)] overflow-hidden") do
151
+ div(class: "h-2 rounded-full bg-primary-600", style: "width: #{percent}%")
152
+ end
153
+ div(class: "text-xs text-[var(--pu-text-muted)]") do
154
+ plain "#{run.progress_done} of #{run.progress_total} targets (#{percent}%)"
155
+ end
156
+ end
157
+ end
158
+
159
+ # Opaque work still gets a BAR, just one that admits it cannot say how
160
+ # far along it is. "Working…" as bare text read as a stalled page —
161
+ # nothing on it moved, so nothing said the run was alive. The animation
162
+ # is the part that carries that, and it is the only difference from the
163
+ # determinate bar above: same height, same track, same radius.
164
+ #
165
+ # A settled run with no total gets no bar, because there is no motion
166
+ # left to convey.
167
+ def render_indeterminate
168
+ div(class: "space-y-1") do
169
+ if run.in_progress?
170
+ div(class: "w-full h-2 rounded-full bg-[var(--pu-surface-alt)] overflow-hidden") do
171
+ div(
172
+ class: "h-2 rounded-full bg-primary-600 pu-run-progress-indeterminate",
173
+ style: "animation-delay: -#{sweep_phase_ms}ms"
174
+ )
175
+ end
176
+ end
177
+ div(class: "text-xs text-[var(--pu-text-muted)]") do
178
+ plain(run.in_progress? ? "Working…" : "No progress total was recorded")
179
+ end
180
+ end
181
+ end
182
+
183
+ # How far into the sweep the bar should already be, as a NEGATIVE
184
+ # animation-delay.
185
+ #
186
+ # The poll re-renders this panel every POLL_INTERVAL_MS, and an element
187
+ # re-inserted into the DOM restarts its CSS animation from zero —
188
+ # marking it turbo-permanent does not help, because Turbo MOVES it and
189
+ # a move is a re-insertion. So a 1.4s sweep chopped every 2s snapped
190
+ # back to the left mid-pass, forever, making the one signal that says
191
+ # "this run is alive" look like a stutter.
192
+ #
193
+ # Restarting is therefore unavoidable; restarting at the WRONG PLACE is
194
+ # not. Anchoring the phase to the run's own clock means each restart
195
+ # picks up where the last render left off, and the sweep reads as
196
+ # continuous across every poll.
197
+ def sweep_phase_ms
198
+ started = run.started_at || run.created_at
199
+ return 0 unless started
200
+
201
+ ((Time.current - started) * 1000).to_i % SWEEP_DURATION_MS
202
+ end
203
+
204
+ def render_failures
205
+ div(class: "space-y-1") do
206
+ div(class: "text-[10px] font-semibold uppercase tracking-wider text-[var(--pu-text-muted)]") do
207
+ plain "Failures"
208
+ end
209
+ ul(class: "pu-run-progress-failures text-xs space-y-1") do
210
+ run.errors_log.each do |entry|
211
+ li(class: "text-[var(--pu-text-danger)]") { plain failure_text(entry) }
212
+ end
213
+ end
214
+ end
215
+ end
216
+
217
+ # A nil target_id is the RUN-LEVEL sentinel (see AsyncRun#record_target_failure!),
218
+ # not a target whose id happens to be blank.
219
+ def failure_text(entry)
220
+ target_id = entry["target_id"]
221
+ target_id.nil? ? entry["message"].to_s : "##{target_id}: #{entry["message"]}"
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Interaction
6
+ module Async
7
+ # Lists the runs currently working on THIS resource, above its collection.
8
+ #
9
+ # == Why it exists
10
+ #
11
+ # A dispatch redirects to the run's own page, but a user who navigates away
12
+ # has no way back short of knowing the URL. The index of the resource being
13
+ # worked on is where they will look, so that is where the run has to be.
14
+ #
15
+ # == Why it queries on target_type
16
+ #
17
+ # +target_type+ is a real column rather than a key inside the +options+
18
+ # JSON precisely so this predicate can be indexed — see the migration's own
19
+ # comment. An opaque run has no +target_type+ at all and therefore appears
20
+ # on no resource index: it belongs to no resource.
21
+ #
22
+ # == Why it can render nothing
23
+ #
24
+ # An always-present empty banner is chrome that teaches the user to ignore
25
+ # the area it occupies. When nothing is running there is nothing to say.
26
+ class RunningBanner < Plutonium::UI::Component::Base
27
+ def initialize(runs:)
28
+ @runs = runs
29
+ end
30
+
31
+ attr_reader :runs
32
+
33
+ def view_template
34
+ return if runs.empty?
35
+
36
+ div(class: "pu-running-banner mb-3 rounded-[var(--pu-radius-md)] border border-[var(--pu-border)] bg-[var(--pu-surface-alt)] p-3 space-y-2") do
37
+ runs.each { |run| render_run(run) }
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def render_run(run)
44
+ div(class: "flex items-center justify-between gap-3 text-sm", data: {run_id: run.id}) do
45
+ div(class: "min-w-0") do
46
+ # The label carries the run's identity; the state carries what it
47
+ # is doing. Progress is deliberately NOT shown bare here: after a
48
+ # refused partial batch progress_done counts targets DISPOSITIONED
49
+ # rather than attempted, so a number without its state is
50
+ # misleading (see Async::Executor#refuse_partial_batch). The run's
51
+ # own page renders the pair.
52
+ span(class: "font-medium text-[var(--pu-text)]") { plain run.to_label }
53
+ plain " "
54
+ span(class: "text-[var(--pu-text-muted)]") { plain run.state.humanize }
55
+ end
56
+ a(href: resource_url_for(run), class: "pu-btn pu-btn-xs pu-btn-soft-primary shrink-0") do
57
+ plain "View progress"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -57,7 +57,8 @@ module Plutonium
57
57
  card_fields: @card_fields,
58
58
  # Escape the column's lazy turbo-frame: either "_top" (full page) or
59
59
  # the document-wide remote-modal frame, both of which resolve outside
60
- # the kanban-col-<key> frame.
60
+ # the kanban-col-<key> frame. Grid::Card keys the metadata-rail hiding
61
+ # off this frame — the modal frame here means a kanban card's modal.
61
62
  show_turbo_frame: @show_turbo_frame
62
63
  )
63
64
  end
@@ -340,12 +340,7 @@ module Plutonium
340
340
  url = resource_url_for(resource_class, interaction: col_action.key, ids: ids, return_to: @board_url)
341
341
  label = col_action.label || col_action.key.to_s.humanize
342
342
 
343
- link_to(
344
- url,
345
- class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
346
- title: label,
347
- data: column_action_link_data(col_action, registered)
348
- ) do
343
+ link_to(url, column_action_link_attributes(col_action, registered, label)) do
349
344
  render col_action.icon.new(class: "h-4 w-4") if col_action.icon
350
345
  plain label
351
346
  end
@@ -353,6 +348,17 @@ module Plutonium
353
348
  end
354
349
  end
355
350
 
351
+ # Full attribute hash for a column-action link, funnelling the
352
+ # registered action's author `link:` bag through the action's own
353
+ # merge point like every other action surface.
354
+ def column_action_link_attributes(col_action, registered, label)
355
+ registered.link_attributes({
356
+ class: "pu-btn pu-btn-ghost pu-btn-xs text-[var(--pu-text-muted)]",
357
+ title: label,
358
+ data: column_action_link_data(col_action, registered)
359
+ })
360
+ end
361
+
356
362
  # Data attributes for a column-action link, honouring the interaction's
357
363
  # shape. `immediate` actions (no inputs) POST straight to the commit route
358
364
  # with a confirmation, executed directly; the rest open the form in the
@@ -75,13 +75,12 @@ module Plutonium
75
75
  # the board carries the shared view switcher (Table | Grid | Board),
76
76
  # search, scopes, and filters rather than rendering bare columns.
77
77
  div(data: filter_panel_controller_data) do
78
- # Persistent append target for move-rejection toasts. It lives
79
- # OUTSIDE the per-column turbo-frames so it survives the frame swaps
80
- # a move triggers; toasts are position:fixed, so its location in the
81
- # DOM has no layout effect. The move handler appends a flash toast
82
- # here via turbo_stream when a drop is rejected (WIP / accepts /
83
- # locked) so the snap-back is explained rather than silent.
84
- div(id: "kanban-flash")
78
+ # NOTE: move-rejection toasts are appended to the LAYOUT's shared
79
+ # toast region (Plutonium::FLASH_REGION) rather than one the board
80
+ # owns. It sits outside every collection and column frame, so it
81
+ # survives the frame swaps a move triggers, and it is the same
82
+ # region the reposition drop uses one flash target for the
83
+ # framework, not one per draggable surface.
85
84
  render_scopes_pills
86
85
  render_toolbar
87
86
 
@@ -27,9 +27,9 @@ module Plutonium
27
27
 
28
28
  def html_attributes = {lang:, data_controller: "color-mode"}
29
29
 
30
- def body_attributes = {class: "antialiased min-h-screen bg-[var(--pu-body)] text-[var(--pu-text)]"}
30
+ def body_attributes = {class: "antialiased pu-min-h-viewport bg-[var(--pu-body)] text-[var(--pu-text)]"}
31
31
 
32
- def main_attributes = {class: "p-4 min-h-screen"}
32
+ def main_attributes = {class: "p-4 pu-min-h-viewport"}
33
33
 
34
34
  def render_head
35
35
  head {
@@ -106,8 +106,15 @@ module Plutonium
106
106
  }
107
107
  end
108
108
 
109
+ # The page's toast region. The id makes it a stable turbo_stream append
110
+ # target, so any action that answers a stream (rather than rendering a
111
+ # layout that would consume `flash`) can surface a message without
112
+ # inventing a region of its own — the reposition drop's rejection toasts
113
+ # land here. Toasts are position:fixed, so the wrapper has no layout
114
+ # effect. Rendered once per full page load, outside every collection
115
+ # frame, so a stream that replaces a collection cannot blow it away.
109
116
  def render_flash
110
- render partial("flash")
117
+ div(id: Plutonium::FLASH_REGION) { render partial("flash") }
111
118
  end
112
119
 
113
120
  def render_before_content
@@ -66,6 +66,7 @@ module Plutonium
66
66
  def render_trigger_button
67
67
  button(
68
68
  type: "button",
69
+ aria: {expanded: "false"},
69
70
  data: {resource_drop_down_target: "trigger"},
70
71
  class: "p-2 text-[var(--pu-text-muted)] rounded-[var(--pu-radius-md)] hover:text-[var(--pu-text)] hover:bg-[var(--pu-surface-alt)] focus:ring-2 focus:ring-[var(--pu-border)] transition-colors"
71
72
  ) do
@@ -127,6 +127,25 @@ module Plutonium
127
127
  def render_after_footer
128
128
  end
129
129
 
130
+ # Where a modal's "open full page" affordance points: THIS request,
131
+ # minus the params that only mean something inside a modal.
132
+ #
133
+ # Deliberately not bare `request.path`. That drops the whole query
134
+ # string, and some of it is load-bearing on the standalone page too —
135
+ # `return_to` (where to go after submitting) and `kanban_column`
136
+ # (which column a quick-add's new record belongs to) would both be
137
+ # silently lost, so the expanded form would submit to the wrong place
138
+ # or into no column.
139
+ #
140
+ # `kanban_modal` is the one param that must NOT survive: it marks a
141
+ # kanban card's modal, where the show page suppresses the metadata
142
+ # rail. Carried onto a full page it would keep metadata hidden there
143
+ # for no reason.
144
+ def open_full_page_url
145
+ query = request.query_parameters.except(Plutonium::KANBAN_MODAL_PARAM)
146
+ query.empty? ? request.path : "#{request.path}?#{query.to_query}"
147
+ end
148
+
130
149
  def page_type = raise NotImplementedError, "#{self.class}#page_type"
131
150
  end
132
151
  end
@@ -27,7 +27,10 @@ module Plutonium
27
27
  render modal_class.new(
28
28
  title: page_title,
29
29
  description: page_description,
30
- size: current_definition.modal_size
30
+ size: current_definition.modal_size,
31
+ # Opens this form standalone in a NEW TAB (target=_blank), so the
32
+ # modal — and anything already typed into it — stays put.
33
+ open_full_url: open_full_page_url
31
34
  ) do
32
35
  render partial("resource_form")
33
36
  end
@@ -22,6 +22,40 @@ module Plutonium
22
22
  path.empty? ? "/" : path
23
23
  end
24
24
 
25
+ # DOM id of the wrapper around the rendered collection (table OR grid;
26
+ # never the kanban board, which owns its own column frames).
27
+ #
28
+ # This is the turbo-stream target the reposition endpoint updates when a
29
+ # drop leaves the client's optimistic view stale, and the element the
30
+ # drag controllers (Tasks 7/8) scope themselves to. Resource-scoped so
31
+ # two collections of DIFFERENT resources on one page (a nested
32
+ # association panel inside a show page) don't collide.
33
+ def self.collection_dom_id(resource_class)
34
+ "pu-collection-#{resource_class.model_name.plural}"
35
+ end
36
+
37
+ # Resolves the index view to render. Extracted to a class method because
38
+ # the reposition endpoint has to re-render the SAME view the user is
39
+ # looking at, and re-deriving that from scratch in the controller would
40
+ # be a second, drifting copy of this precedence.
41
+ #
42
+ # Resolution order:
43
+ # 1. `?view=` URL param (so a shared link can pin a view)
44
+ # 2. The view-preference cookie (sticky per-resource selection)
45
+ # 3. The resource's `default_index_view` (which itself defaults to
46
+ # `index_views.first`)
47
+ def self.resolve_view(definition, resource_class, view_param, cookies)
48
+ enabled = definition.defined_index_views
49
+
50
+ requested = view_param&.to_sym
51
+ return requested if requested && enabled.include?(requested)
52
+
53
+ stored = cookies[view_cookie_name(resource_class)]&.to_sym
54
+ return stored if stored && enabled.include?(stored)
55
+
56
+ definition.default_index_view
57
+ end
58
+
25
59
  private
26
60
 
27
61
  def page_title
@@ -33,33 +67,50 @@ module Plutonium
33
67
  end
34
68
 
35
69
  def page_actions
36
- super || current_definition.defined_actions.values.select { |a| a.resource_action? && !a.kanban_drop? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
70
+ super || current_definition.defined_actions.values.select { |a| a.resource_action? && !a.hidden? && a.permitted_by?(current_policy) && a.condition_met?(view_context) }
37
71
  end
38
72
 
39
73
  def render_default_content
40
- case selected_view
41
- when :kanban then render partial("resource_kanban")
42
- when :grid then render partial("resource_grid")
43
- else render partial("resource_table")
74
+ # The board owns its own frames (and its own drop target), so it is
75
+ # rendered bare. Table and grid share one id'd wrapper: it is what the
76
+ # reposition endpoint streams into after a drop, so the surface the
77
+ # drag controller reads and the surface the server replaces are the
78
+ # same element in both views.
79
+ render_running_banner
80
+
81
+ return render partial("resource_kanban") if selected_view == :kanban
82
+
83
+ div(id: self.class.collection_dom_id(resource_class)) do
84
+ render partial((selected_view == :grid) ? "resource_grid" : "resource_table")
44
85
  end
45
86
  end
46
87
 
47
- # Resolution order:
48
- # 1. `?view=` URL param (so a shared link can pin a view)
49
- # 2. The view-preference cookie (sticky per-resource selection)
50
- # 3. The resource's `default_index_view` (which itself defaults to
51
- # `index_views.first`)
52
- def selected_view
53
- definition = current_definition
54
- enabled = definition.defined_index_views
88
+ # Runs currently working on this resource, above the collection.
89
+ #
90
+ # Rendered OUTSIDE the collection wrapper on purpose: that div is what
91
+ # the reposition endpoint streams into after a drag, so anything inside
92
+ # it is destroyed on the next drop.
93
+ #
94
+ # Scoped through authorized_resource_scope, the same door every other
95
+ # cross-resource read goes through, so a run dispatched in another tenant
96
+ # can never appear here — the banner cannot be a way around the policy.
97
+ def render_running_banner
98
+ return unless Plutonium.configuration.async_interactions.enabled
99
+ # A shared resource_class can be registered in a portal that never
100
+ # registered Run — its "View progress" link would then 500 building
101
+ # a URL for a route that doesn't exist here.
102
+ return unless registered_resources.include?(Plutonium::Interaction::Async::Run)
55
103
 
56
- requested = params[:view]&.to_sym
57
- return requested if requested && enabled.include?(requested)
104
+ runs = authorized_resource_scope(
105
+ Plutonium::Interaction::Async::Run,
106
+ relation: Plutonium::Interaction::Async::Run.for_target(resource_class).in_progress
107
+ ).to_a
58
108
 
59
- stored = helpers.cookies[self.class.view_cookie_name(resource_class)]&.to_sym
60
- return stored if stored && enabled.include?(stored)
109
+ render Plutonium::UI::Interaction::Async::RunningBanner.new(runs: runs)
110
+ end
61
111
 
62
- definition.default_index_view
112
+ def selected_view
113
+ self.class.resolve_view(current_definition, resource_class, params[:view], helpers.cookies)
63
114
  end
64
115
 
65
116
  def page_type = :index_page
@@ -25,7 +25,11 @@ module Plutonium
25
25
  render modal_class.new(
26
26
  title: page_title,
27
27
  description: page_description,
28
- size: current_interactive_action.modal_size(current_definition)
28
+ size: current_interactive_action.modal_size(current_definition),
29
+ # The action's own GET path renders the standalone branch below,
30
+ # and opens in a NEW TAB, so the modal keeps whatever has already
31
+ # been typed into it.
32
+ open_full_url: open_full_page_url
29
33
  ) do
30
34
  render_interactive_action_form
31
35
  end
@@ -27,7 +27,10 @@ module Plutonium
27
27
  render modal_class.new(
28
28
  title: page_title,
29
29
  description: page_description,
30
- size: current_definition.modal_size
30
+ size: current_definition.modal_size,
31
+ # Opens this form standalone in a NEW TAB (target=_blank), so the
32
+ # modal — and anything already typed into it — stays put.
33
+ open_full_url: open_full_page_url
31
34
  ) do
32
35
  render partial("resource_form")
33
36
  end