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
@@ -32,7 +32,11 @@ module Plutonium
32
32
  header_cell_link: "flex items-center gap-1 cursor-pointer hover:text-[var(--pu-text)]",
33
33
 
34
34
  # Body
35
- body_row: "pu-table-body-row",
35
+ # `group/row` is what lets the drag grip reveal itself on row hover.
36
+ # NAMED, not the bare `group`: header cells already use an unnamed
37
+ # group for their sort indicator, and a future nested group inside a
38
+ # cell would silently capture `group-hover:` from anything below it.
39
+ body_row: "pu-table-body-row group/row",
36
40
  body_cell: "pu-table-body-cell whitespace-pre max-w-[450px] overflow-hidden text-ellipsis",
37
41
 
38
42
  # Sorting
@@ -49,7 +53,60 @@ module Plutonium
49
53
  column_menu_panel: "hidden absolute right-0 top-full mt-1 z-50 min-w-[180px] bg-[var(--pu-surface)] border border-[var(--pu-border)] rounded-md shadow-lg p-1",
50
54
  column_menu_item: "flex items-center gap-2 px-2 py-1.5 text-sm text-[var(--pu-text)] rounded hover:bg-[var(--pu-surface-alt)] w-full",
51
55
  column_menu_item_disabled: "flex items-center gap-2 px-2 py-1.5 text-sm text-[var(--pu-text-subtle)] opacity-60 cursor-not-allowed",
52
- column_menu_separator: "my-1 border-t border-[var(--pu-border)]"
56
+ column_menu_separator: "my-1 border-t border-[var(--pu-border)]",
57
+
58
+ # Drag-to-reorder grip (see Components::DragHandle)
59
+ #
60
+ # `-ml-5` cancels `w-5` exactly, so the grip sits in the cell's
61
+ # existing left padding and costs the content no horizontal space.
62
+ #
63
+ # Hidden until the row is hovered — but ALWAYS visible on focus. A
64
+ # keyboard user cannot hover, and an invisible tab stop that moves
65
+ # rows when you press an arrow key is worse than no affordance at all.
66
+ drag_handle_cell: "flex items-center",
67
+ drag_handle: "-ml-5 mr-1.5 w-5 h-5 shrink-0 inline-flex items-center justify-center rounded " \
68
+ "text-[var(--pu-text-subtle)] hover:text-[var(--pu-text)] " \
69
+ "opacity-0 group-hover/row:opacity-100 focus:opacity-100 " \
70
+ "transition-opacity cursor-grab active:cursor-grabbing " \
71
+ "focus:outline-none focus:ring-2 focus:ring-[var(--pu-input-focus-ring)]",
72
+ # Dimmer than the live grip, and a pointer rather than a grab cursor:
73
+ # it looks like the same control, switched off, and clicking it is
74
+ # what switches it back on.
75
+ drag_handle_disabled: "-ml-5 mr-1.5 w-5 h-5 shrink-0 inline-flex items-center justify-center rounded " \
76
+ "text-[var(--pu-text-subtle)] " \
77
+ "opacity-0 group-hover/row:opacity-40 hover:opacity-100 focus:opacity-100 " \
78
+ "transition-opacity cursor-pointer " \
79
+ "focus:outline-none focus:ring-2 focus:ring-[var(--pu-input-focus-ring)]",
80
+
81
+ # The card-grid placement of the same grip (Grid::Card).
82
+ #
83
+ # Same principle as the row grip — live in padding the card already
84
+ # had, cost the content nothing — but expressed as a full-height rail
85
+ # rather than a negative margin, because a card's content is a
86
+ # multi-line flex column rather than one cell. `w-4` is exactly the
87
+ # `p-4` left padding of both card layouts, so the rail fills the
88
+ # gutter and stops precisely where the text begins. A floating corner
89
+ # button was tried first and sat on top of the card title.
90
+ #
91
+ # It still carries a translucent surface + blur: in the :media layout
92
+ # the cover image runs edge to edge, so the gutter is only empty in
93
+ # the compact layout.
94
+ #
95
+ # `group/card`, not `group/row`: the card names its own hover group
96
+ # so nesting a card inside a table (or vice versa) can never have one
97
+ # reveal the other's grip.
98
+ drag_handle_card: "absolute left-0 top-0 bottom-0 z-20 w-4 inline-flex items-center justify-center " \
99
+ "bg-[var(--pu-surface-alt)]/70 backdrop-blur-sm " \
100
+ "text-[var(--pu-text-subtle)] hover:text-[var(--pu-text)] " \
101
+ "opacity-0 group-hover/card:opacity-100 focus:opacity-100 " \
102
+ "transition-opacity cursor-grab active:cursor-grabbing " \
103
+ "focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[var(--pu-input-focus-ring)]",
104
+ drag_handle_card_disabled: "absolute left-0 top-0 bottom-0 z-20 w-4 inline-flex items-center justify-center " \
105
+ "bg-[var(--pu-surface-alt)]/70 backdrop-blur-sm " \
106
+ "text-[var(--pu-text-subtle)] " \
107
+ "opacity-0 group-hover/card:opacity-40 hover:opacity-100 focus:opacity-100 " \
108
+ "transition-opacity cursor-pointer " \
109
+ "focus:outline-none focus:ring-2 focus:ring-inset focus:ring-[var(--pu-input-focus-ring)]"
53
110
  })
54
111
  end
55
112
  end
@@ -114,11 +114,13 @@ module Plutonium
114
114
  div(class: "px-5 py-4") do
115
115
  # Decorate so attachment fields resolve to displayable attachments —
116
116
  # the SummaryDisplay then renders them through the normal attachment
117
- # display component, not the raw token string.
117
+ # display component, not the raw token string. (Choice fields need no
118
+ # decorator: the summary resolves their labels per field from the
119
+ # `inputs` it is already handed.)
118
120
  if fields.any?
119
121
  render SummaryDisplay.new(
120
122
  Plutonium::Wizard::AttachmentData.wrap(step_data(step), step),
121
- fields:, inputs: step.inputs
123
+ fields:, inputs: step.inputs, wizard: @runner.wizard
122
124
  )
123
125
  end
124
126
  render_structured(step) if structured.any?
@@ -14,13 +14,20 @@ module Plutonium
14
14
  # @param inputs [Hash] the step's input config ({name => {options:}}), so a
15
15
  # field's declared `as:` informs the display component (e.g. a `:text`
16
16
  # input renders via the markdown/text display tag).
17
- def initialize(object, fields:, inputs: {}, **options)
17
+ def initialize(object, fields:, inputs: {}, wizard: nil, **options)
18
18
  options[:key] = :wizard
19
19
  @summary_fields = fields
20
20
  @summary_inputs = inputs
21
+ @wizard = wizard
21
22
  super(object, **options)
22
23
  end
23
24
 
25
+ # The wizard driving this run. Present for the same reason the step form
26
+ # exposes it: an `->(form) { form.wizard… }` option asks the form for it,
27
+ # and on the review page this component stands in for the form.
28
+ # @return [Plutonium::Wizard::Base, nil]
29
+ attr_reader :wizard
30
+
24
31
  def display_template
25
32
  dl(class: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3") do
26
33
  @summary_fields.each { |name| render_summary_field(name) }
@@ -37,7 +44,7 @@ module Plutonium
37
44
  # has_cents reflection, so inference would render it as a bare number.
38
45
  # Force the currency display and thread the declared `unit:` so the
39
46
  # recap reads "$1,234.56", matching the input's prefix.
40
- if input_options[:as]&.to_sym == :currency
47
+ if Plutonium::Definition::InputAliases.resolve(input_options[:as]) == :currency
41
48
  return render field(name, **field_options).wrapped { |f|
42
49
  render f.send(:create_component, Plutonium::UI::Display::Components::Currency, :currency, unit: input_options[:unit])
43
50
  }
@@ -58,17 +65,41 @@ module Plutonium
58
65
  end
59
66
 
60
67
  # Map the raw value(s) to their choice label(s) using the SAME mapper the
61
- # form's select uses, so summary labels always match the form. Falls back
62
- # to the raw value for anything not in `choices:`; nil for an empty field
63
- # (so the display renders its placeholder).
68
+ # form's select uses, so summary labels always match the form. Falls back to
69
+ # the raw value for anything not in `choices:`; nil for an empty field (so
70
+ # the display renders its placeholder).
71
+ #
72
+ # The mapper is built ONCE per field, not once per value: `choices:` is
73
+ # routinely a proc (`-> { Member.all.map { |m| [m.name, m.id] } }`), and a
74
+ # per-value mapper would re-run that query for every element of a
75
+ # multi-select.
76
+ #
64
77
  def resolve_choice_label(name, input_options)
78
+ values = Array(Phlexi::Field::Support::Value.from(object, name))
79
+ return if values.empty?
80
+
65
81
  mapper = Phlexi::Form::SimpleChoicesMapper.new(
66
- input_options[:choices],
82
+ resolved_choices(input_options[:choices]),
67
83
  label_method: input_options[:label_method],
68
84
  value_method: input_options[:value_method]
69
85
  )
70
- raw = Phlexi::Field::Support::Value.from(object, name)
71
- Array(raw).map { |value| mapper[value] || value }.join(", ").presence
86
+ values.map { |value| mapper[value] || value }.join(", ").presence
87
+ end
88
+
89
+ # Resolve a proc'd `choices:` by the SAME arity rule the form applies to
90
+ # every input option (see Form::Resource#call_option_proc): zero-arity means
91
+ # what it reads like where it was written; `->(form) { … }` is asking for
92
+ # the form, and on the review page this component stands in for it — it
93
+ # answers `object` (the step's staged data) and `wizard`.
94
+ #
95
+ # Without this the summary hands the raw proc to the mapper, which bare-
96
+ # `call`s it — so `choices: ->(form) { form.wizard.anchor.available_tiers }`,
97
+ # a declaration Form::Wizard documents and the step form renders happily,
98
+ # would raise ArgumentError the moment the user reached review.
99
+ def resolved_choices(choices)
100
+ return choices unless choices.is_a?(Proc)
101
+
102
+ choices.arity.zero? ? choices.call : choices.call(self)
72
103
  end
73
104
 
74
105
  # Pick the display component the same way a resource display does — infer it
@@ -78,12 +109,9 @@ module Plutonium
78
109
  # data object is decorated upstream to resolve the token to an attachment).
79
110
  def summary_tag_block(name)
80
111
  ->(f) {
81
- tag = Plutonium::Wizard::Attachments.field?(@summary_inputs[name]) ? :attachment : f.inferred_field_component
82
- if tag.is_a?(Class)
83
- f.send(:create_component, tag, tag.name.demodulize.underscore.sub(/component$/, "").to_sym)
84
- else
85
- f.send(:"#{tag}_tag")
86
- end
112
+ # nil lets the builder infer the tag from the value's type.
113
+ tag = :attachment if Plutonium::Wizard::Attachments.field?(@summary_inputs[name])
114
+ f.component_for(tag)
87
115
  }
88
116
  end
89
117
  end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.62.2"
2
+ VERSION = "0.63.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
@@ -2,224 +2,59 @@
2
2
 
3
3
  module Plutonium
4
4
  module Wizard
5
- # Bridges a wizard's staged attachment value to the displayable attachment the
6
- # `Uppy` input and `Display::Components::Attachment` render — **model-free and
7
- # backend-agnostic**, for a bare `attribute :photo, :string` + `input :photo,
8
- # as: :uppy, direct_upload: true` field (no `using:` model needed).
5
+ # A wizard's view of {Plutonium::Attachments}.
9
6
  #
10
- # A wizard stages plain strings, so an attachment field holds its backend's own
11
- # direct-upload token: ActiveStorage's **signed_id** (an opaque signed string)
12
- # or active_shrine/Shrine's **cached-file data** (a JSON object). Those are the
13
- # only two shapes, and they're distinguishablea Shrine token parses as JSON,
14
- # an AS signed_id doesn't — so we revive each through its own backend with no
15
- # model and no per-field configuration.
7
+ # The staging and revival machinery moved out when async interactions needed
8
+ # the same thing: both hold an attachment as a bare string while the work is
9
+ # in flight, and neither has a model to hang it off until the work lands.
10
+ # None of that was ever wizard-specificonly the two things left here are.
16
11
  #
17
- # Resolution is **display-only**: staging and `execute` (which assigns the token
18
- # straight to a model attachment both AS and active_shrine accept it) never
19
- # call it. The two backends' native objects answer DIFFERENT method names
20
- # (`filename`/`content_type` vs `original_filename`/`mime_type`), so a resolved
21
- # token is wrapped in {Resolved}, a uniform view exposing exactly what the
22
- # display + preview components call. See the wizard-attachments design spec.
12
+ # This stays the wizard's entry point rather than having wizard code reach
13
+ # for the shared module directly, so the +backend:+ default keeps being the
14
+ # wizard's own setting without every call site remembering to pass it.
23
15
  module Attachments
24
- module_function
16
+ # Kept as a constant here because it is part of this module's published
17
+ # surface: a host rendering a staged attachment may name
18
+ # +Wizard::Attachments::Resolved+.
19
+ Resolved = Plutonium::Attachments::Resolved
25
20
 
26
- # Resolve a staged attachment token (or array of them) into uniform
27
- # {Resolved} view(s).
28
- #
29
- # @param value [String, Array, nil] the staged token(s).
30
- # @return [Array<Resolved>] resolved attachments; blank, tampered, or
31
- # unrecognized tokens are dropped (never raised), so a bad token can't 500
32
- # the form or the review.
33
- def resolve(value)
34
- Array(value).filter_map { |token| resolve_token(token) }
35
- end
21
+ module_function
36
22
 
37
23
  # Whether a step input renders as an attachment (its `as:` is a file alias),
38
24
  # so its staged token should be resolved for display. Keys off the form's
39
25
  # canonical file-input alias set, so the two never drift.
26
+ #
27
+ # Wizard-only: it reads a STEP INPUT's options, which is a shape nothing
28
+ # outside the wizard DSL has.
40
29
  def field?(input_options)
41
30
  as = input_options&.dig(:options, :as) || input_options&.dig(:as)
42
- Plutonium::UI::Form::Base::Builder::FILE_INPUT_TYPES.include?(as&.to_sym)
31
+ Plutonium::Definition::InputAliases.file_input?(as)
43
32
  end
44
33
 
45
- # SERVER-SIDE staging: turn a submitted attachment value into a token string
46
- # to stage in `data`, minting one from an uploaded file when needed.
34
+ # The server-side staging backend for wizard attachments: the wizard's own
35
+ # setting, else the global default.
47
36
  #
48
- # Handles every shape a step POST can carry for an attachment field:
49
- # - an already-minted token String (direct upload, or the hidden preview field
50
- # on re-submit) kept verbatim;
51
- # - an uploaded file (IO-like) → uploaded to the backend's cache, returning its
52
- # token (an AS signed_id, or Shrine cached-file JSON);
53
- # - blank / no selection → nil (the caller drops the key so the previously
54
- # staged token survives a Back/re-submit);
55
- # - an Array (multiple) → each element mapped, blanks dropped.
37
+ # Layered rather than replacing: an app that sets only
38
+ # +config.attachment_backend+ gets it here too, and one that sets
39
+ # +config.wizards.attachment_backend+ still overrides it for wizards alone.
56
40
  #
57
- # @param backend [Symbol, nil] per-field override; nil → the configured default.
58
- # @param uploader [Class, String, nil] a Shrine uploader to cache through
59
- # (`:shrine` backend only) — its cache-stage plugins (mime/dimension
60
- # extraction, `generate_location`, validations) run instead of base Shrine's.
61
- # The minted token stays uploader-agnostic, so display + `execute` promotion
62
- # are unaffected. Ignored shape for ActiveStorage (raises if given).
63
- def stage_upload(value, backend: nil, uploader: nil)
64
- if value.is_a?(Array)
65
- value.filter_map { |v| stage_upload(v, backend:, uploader:) }.presence
66
- elsif value.is_a?(String)
67
- value.presence
68
- elsif value.respond_to?(:read)
69
- upload_to_cache(value, backend || attachment_backend, uploader:)
70
- end
71
- end
72
-
73
- # The default server-side staging backend: the configured one, else
74
- # auto-detected (active_shrine loaded → Shrine, else ActiveStorage).
41
+ # @return [Symbol]
75
42
  def attachment_backend
76
43
  Plutonium.configuration.wizards.attachment_backend ||
77
- (defined?(ActiveShrine) ? :shrine : :active_storage)
78
- end
79
-
80
- # Run the EFFECTIVE Shrine uploader's attacher validations against a staged
81
- # token (or array of them), returning the validation messages — so a file that
82
- # violates the uploader's `validate_*` rules is rejected at the STEP (stage
83
- # phase), not deferred to `execute`'s model assignment.
84
- #
85
- # The effective uploader is the field's `uploader:` if given, else base
86
- # `Shrine` — both of which may carry `Attacher.validate` rules. Returns `[]`
87
- # when the field isn't Shrine-backed (ActiveStorage has no attacher here), when
88
- # nothing is staged, or when the effective uploader declares no validations.
89
- #
90
- # @param value [String, Array, nil] the staged token(s).
91
- # @param backend [Symbol, nil] per-field override; nil → the configured default.
92
- # @param uploader [Class, String, nil] the field's `uploader:` option.
93
- # @return [Array<String>] validation messages (empty ⇒ valid).
94
- def validation_errors(value, backend: nil, uploader: nil)
95
- return [] unless (backend || attachment_backend).to_sym == :shrine
96
-
97
- klass = shrine_uploader(uploader)
98
- # Shrine's `validation` plugin is OPTIONAL — without it (or `validation_helpers`)
99
- # the Attacher has no `#errors` and nothing to enforce. Detect it up front so a
100
- # plugin-less app is a clean no-op, not a per-step rescued NoMethodError.
101
- return [] unless klass::Attacher.method_defined?(:errors)
102
-
103
- Array(value).flat_map { |token| token_validation_errors(klass, token) }
104
- end
105
-
106
- # Validate one cached token through an uploader's attacher. A broad rescue
107
- # (like {resolve_token}) — a tampered/expired token shouldn't 500 the step; it
108
- # surfaces at `execute` instead, where it's caught as a RecordInvalid.
109
- def token_validation_errors(uploader_class, token)
110
- return [] if token.blank?
111
-
112
- attacher = uploader_class::Attacher.new
113
- attacher.assign(token)
114
- Array(attacher.errors)
115
- rescue => e
116
- Rails.logger.warn { "[Plutonium::Wizard] attachment validation skipped: #{e.class}: #{e.message}" }
117
- []
118
- end
119
- private_class_method :token_validation_errors
120
-
121
- # Upload a file to the backend's CACHE and return its re-postable token. The
122
- # file lives in cache until `execute` assigns the token to a real attachment
123
- # (which promotes it); an abandoned upload is reaped by the backend's own
124
- # unattached-cache cleanup.
125
- def upload_to_cache(file, backend, uploader: nil)
126
- case backend.to_sym
127
- when :shrine
128
- shrine_uploader(uploader).upload(file, :cache).to_json
129
- when :active_storage
130
- raise ArgumentError, "input `uploader:` is only supported for the :shrine backend" if uploader
131
- ActiveStorage::Blob.create_and_upload!(
132
- io: file, filename: file.original_filename, content_type: file.content_type
133
- ).signed_id
134
- else
135
- raise ArgumentError, "unknown wizard attachment backend: #{backend.inspect}"
136
- end
137
- end
138
- private_class_method :upload_to_cache
139
-
140
- # Resolve an `uploader:` option to the Shrine uploader class to cache through.
141
- # nil → base `Shrine`; a class is used as-is; a String/Symbol is constantized.
142
- # Anything that isn't a Shrine subclass is a configuration error (fail loud).
143
- def shrine_uploader(uploader)
144
- return Shrine if uploader.nil?
145
-
146
- klass = uploader.is_a?(Class) ? uploader : uploader.to_s.safe_constantize
147
- unless klass.is_a?(Class) && klass <= Shrine
148
- raise ArgumentError, "input `uploader:` must be a Shrine uploader class, got #{uploader.inspect}"
149
- end
150
- klass
151
- end
152
- private_class_method :shrine_uploader
153
-
154
- # Revive one token through whichever backend owns it, wrapped in {Resolved}. A
155
- # broad rescue is warranted here (unlike elsewhere): the token is arbitrary,
156
- # user-supplied input reconstituted at a render boundary, and the two backends
157
- # raise different error classes for a tampered/expired token — none of which
158
- # should take down the page.
159
- def resolve_token(token)
160
- return if token.blank?
161
-
162
- source = shrine_uploaded_file(token) || active_storage_blob(token)
163
- source && Resolved.new(source, token)
164
- rescue => e
165
- Rails.logger.warn { "[Plutonium::Wizard] could not resolve attachment token: #{e.class}: #{e.message}" }
166
- nil
44
+ Plutonium::Attachments.default_backend
167
45
  end
168
- private_class_method :resolve_token
169
-
170
- # A Shrine cached-file token is JSON (`{"id":…,"storage":"cache",…}`); an AS
171
- # signed_id isn't. Parse-success → Shrine materializes it from the globally
172
- # registered storages (no model, no per-field uploader needed for a `.url`).
173
- def shrine_uploaded_file(token)
174
- return unless defined?(Shrine)
175
46
 
176
- data = begin
177
- JSON.parse(token)
178
- rescue JSON::ParserError, TypeError
179
- nil
180
- end
181
- return unless data.is_a?(Hash)
182
-
183
- Shrine.uploaded_file(data)
184
- end
185
- private_class_method :shrine_uploaded_file
47
+ # @see Plutonium::Attachments.resolve
48
+ def resolve(value) = Plutonium::Attachments.resolve(value)
186
49
 
187
- def active_storage_blob(token)
188
- ActiveStorage::Blob.find_signed(token) if defined?(ActiveStorage::Blob)
50
+ # @see Plutonium::Attachments.stage_upload
51
+ def stage_upload(value, backend: nil, uploader: nil)
52
+ Plutonium::Attachments.stage_upload(value, backend: backend || attachment_backend, uploader:)
189
53
  end
190
- private_class_method :active_storage_blob
191
-
192
- # A uniform view over a resolved attachment so the review display + the uppy
193
- # preview don't care whether the source is an ActiveStorage `Blob`
194
- # (`filename`/`content_type`/`representable?`) or a Shrine `UploadedFile`
195
- # (`original_filename`/`mime_type`, none of the AS-only methods). Exposes
196
- # exactly what those components call.
197
- class Resolved
198
- # @param source the backend object (AS Blob or Shrine UploadedFile).
199
- # @param token [String] the ORIGINAL staged token — what the hidden preview
200
- # field re-posts to preserve the upload across a Back/re-submit, and what
201
- # `execute` assigns to the model attachment.
202
- def initialize(source, token)
203
- @source = source
204
- @token = token
205
- end
206
-
207
- # `url` is lazy (called at render, inside a request, where AS url options
208
- # exist) — never eager at resolve time.
209
- def url(*args) = @source.url(*args)
210
-
211
- # The re-postable token, surfaced under the name the uppy input reads.
212
- def signed_id = @token
213
-
214
- def filename = (@source.try(:filename) || @source.try(:original_filename)).to_s
215
54
 
216
- def content_type = @source.try(:content_type) || @source.try(:mime_type)
217
-
218
- def representable? = @source.try(:representable?) || content_type.to_s.start_with?("image/")
219
-
220
- def extension = @source.try(:extension).presence || File.extname(filename).delete(".").presence
221
-
222
- def present? = true
55
+ # @see Plutonium::Attachments.validation_errors
56
+ def validation_errors(value, backend: nil, uploader: nil)
57
+ Plutonium::Attachments.validation_errors(value, backend: backend || attachment_backend, uploader:)
223
58
  end
224
59
  end
225
60
  end
@@ -205,7 +205,12 @@ module Plutonium
205
205
  def failed(errors = nil, attribute = :base)
206
206
  case errors
207
207
  when Hash
208
- errors.each { |attr, error| self.errors.add(attr, error) }
208
+ # A per-attribute value may be a single message or a list of them
209
+ # (`{name: ["is required", "is too short"]}`). `Array.wrap`, not `Array()`
210
+ # — the latter would splat a Hash value into [key, value] pairs.
211
+ errors.each do |attr, error|
212
+ Array.wrap(error).each { |err| self.errors.add(attr, err) }
213
+ end
209
214
  else
210
215
  Array(errors).each { |error| self.errors.add(attribute, error) }
211
216
  end
@@ -29,8 +29,20 @@ module Plutonium
29
29
  # arrive as a token and ignore this.
30
30
  attr_accessor :attachment_backend
31
31
 
32
+ # @return [Symbol] width for wizard step pages, one of
33
+ # {Plutonium::UI::PageWidth::VALID_SIZES}.
34
+ #
35
+ # Deliberately INDEPENDENT of `config.default_page_width`. A wizard is
36
+ # its own kind of page — a focused, self-contained flow, not a resource
37
+ # detail page — so an app that widens (or unconstrains) its resource
38
+ # pages should not silently drag its wizards along. The default here
39
+ # happens to match the resource default, but nothing ties them: change
40
+ # one and the other stays put.
41
+ attr_accessor :width
42
+
32
43
  # Initialize a new wizard Configuration instance with default values.
33
44
  def initialize
45
+ @width = :md
34
46
  @enabled = false
35
47
  @cleanup_after = 14.days
36
48
  @database = :primary
@@ -63,6 +63,11 @@ module Plutonium
63
63
  wizard_update
64
64
  end
65
65
 
66
+ # DELETE .../:token
67
+ def cancel
68
+ wizard_cancel
69
+ end
70
+
66
71
  private
67
72
 
68
73
  # Identity for a standalone wizard host. Defers to the host's own auth
@@ -157,6 +162,15 @@ module Plutonium
157
162
  :"#{name}_path"
158
163
  end
159
164
  end
165
+
166
+ def wizard_launch_url
167
+ url_options = {}
168
+ if scoped_to_entity?
169
+ url_options[scoped_entity_param_key] = params[scoped_entity_param_key]
170
+ end
171
+ helper_name = wizard_step_url_helper.to_s.sub(/_path\z/, "_launch_path").to_sym
172
+ current_engine.routes.url_helpers.public_send(helper_name, **url_options)
173
+ end
160
174
  end
161
175
  end
162
176
  end