plutonium 0.62.2 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ # Staging and revival for attachments that travel as PLAIN STRINGS —
5
+ # backend-agnostic and model-free.
6
+ #
7
+ # Two subsystems need this and neither has a model to hang an attachment off
8
+ # while the work is in flight: a wizard stages a file across steps, and an
9
+ # async interaction has to carry one from the request into a job. Both hold a
10
+ # bare `attribute :photo, :string`, so the value has to BE a string.
11
+ #
12
+ # Two shapes exist, and they are distinguishable — ActiveStorage's **signed_id**
13
+ # is opaque, Shrine's **cached-file data** parses as JSON — so each revives
14
+ # through its own backend with no model and no per-field configuration.
15
+ #
16
+ # Revival is deliberately separate from staging. {stage_upload} runs in the
17
+ # request, where the file is; {resolve} runs wherever the value is read, which
18
+ # may be a render or a job. Assigning a token straight to a model attachment
19
+ # (both backends accept one) needs neither.
20
+ #
21
+ # Backend objects answer DIFFERENT method names (`filename`/`content_type` vs
22
+ # `original_filename`/`mime_type`), so a resolved token is wrapped in
23
+ # {Resolved}, a uniform view over both.
24
+ module Attachments
25
+ # Options on a file input that are consumed SERVER-SIDE to stage the upload,
26
+ # and are never form or HTML concerns.
27
+ #
28
+ # They have to be kept out of what the form renders: Phlex rejects a
29
+ # Class-valued +uploader:+ as an attribute outright, and +backend:+ would
30
+ # leak as a stray one. Both the wizard (Wizard::StepAdapter) and an
31
+ # interaction's form read this list, so the two cannot drift.
32
+ STAGING_ONLY_INPUT_OPTIONS = %i[backend uploader].freeze
33
+
34
+ module_function
35
+
36
+ # Resolve a staged attachment token (or array of them) into uniform
37
+ # {Resolved} view(s).
38
+ #
39
+ # @param value [String, Array, nil] the staged token(s).
40
+ # @return [Array<Resolved>] resolved attachments; blank, tampered, or
41
+ # unrecognized tokens are dropped (never raised), so a bad token can't 500
42
+ # the form or the review.
43
+ def resolve(value)
44
+ Array(value).filter_map { |token| resolve_token(token) }
45
+ end
46
+
47
+ # SERVER-SIDE staging: turn a submitted attachment value into a token string
48
+ # to stage in `data`, minting one from an uploaded file when needed.
49
+ #
50
+ # Handles every shape a step POST can carry for an attachment field:
51
+ # - an already-minted token String (direct upload, or the hidden preview field
52
+ # on re-submit) → kept verbatim;
53
+ # - an uploaded file (IO-like) → uploaded to the backend's cache, returning its
54
+ # token (an AS signed_id, or Shrine cached-file JSON);
55
+ # - blank / no selection → nil (the caller drops the key so the previously
56
+ # staged token survives a Back/re-submit);
57
+ # - an Array (multiple) → each element mapped, blanks dropped.
58
+ #
59
+ # @param backend [Symbol, nil] per-field override; nil → the configured default.
60
+ # @param uploader [Class, String, nil] a Shrine uploader to cache through
61
+ # (`:shrine` backend only) — its cache-stage plugins (mime/dimension
62
+ # extraction, `generate_location`, validations) run instead of base Shrine's.
63
+ # The minted token stays uploader-agnostic, so display + `execute` promotion
64
+ # are unaffected. Ignored shape for ActiveStorage (raises if given).
65
+ def stage_upload(value, backend: nil, uploader: nil)
66
+ if value.is_a?(Array)
67
+ value.filter_map { |v| stage_upload(v, backend:, uploader:) }.presence
68
+ elsif value.is_a?(String)
69
+ value.presence
70
+ elsif value.respond_to?(:read)
71
+ upload_to_cache(value, backend || default_backend, uploader:)
72
+ end
73
+ end
74
+
75
+ # The default staging backend when a caller names none: the globally
76
+ # configured one, else auto-detected (active_shrine loaded → Shrine, else
77
+ # ActiveStorage).
78
+ #
79
+ # Subsystems layer their own override on top of this rather than replacing
80
+ # it — see Wizard::Attachments.attachment_backend.
81
+ def default_backend
82
+ Plutonium.configuration.attachment_backend ||
83
+ (defined?(ActiveShrine) ? :shrine : :active_storage)
84
+ end
85
+
86
+ # Run the EFFECTIVE Shrine uploader's attacher validations against a staged
87
+ # token (or array of them), returning the validation messages — so a file that
88
+ # violates the uploader's `validate_*` rules is rejected at the STEP (stage
89
+ # phase), not deferred to `execute`'s model assignment.
90
+ #
91
+ # The effective uploader is the field's `uploader:` if given, else base
92
+ # `Shrine` — both of which may carry `Attacher.validate` rules. Returns `[]`
93
+ # when the field isn't Shrine-backed (ActiveStorage has no attacher here), when
94
+ # nothing is staged, or when the effective uploader declares no validations.
95
+ #
96
+ # @param value [String, Array, nil] the staged token(s).
97
+ # @param backend [Symbol, nil] per-field override; nil → the configured default.
98
+ # @param uploader [Class, String, nil] the field's `uploader:` option.
99
+ # @return [Array<String>] validation messages (empty ⇒ valid).
100
+ def validation_errors(value, backend: nil, uploader: nil)
101
+ return [] unless (backend || default_backend).to_sym == :shrine
102
+
103
+ klass = shrine_uploader(uploader)
104
+ # Shrine's `validation` plugin is OPTIONAL — without it (or `validation_helpers`)
105
+ # the Attacher has no `#errors` and nothing to enforce. Detect it up front so a
106
+ # plugin-less app is a clean no-op, not a per-step rescued NoMethodError.
107
+ return [] unless klass::Attacher.method_defined?(:errors)
108
+
109
+ Array(value).flat_map { |token| token_validation_errors(klass, token) }
110
+ end
111
+
112
+ # Validate one cached token through an uploader's attacher. A broad rescue
113
+ # (like {resolve_token}) — a tampered/expired token shouldn't 500 the step; it
114
+ # surfaces at `execute` instead, where it's caught as a RecordInvalid.
115
+ def token_validation_errors(uploader_class, token)
116
+ return [] if token.blank?
117
+
118
+ attacher = uploader_class::Attacher.new
119
+ attacher.assign(token)
120
+ Array(attacher.errors)
121
+ rescue => e
122
+ Rails.logger.warn { "[Plutonium] attachment validation skipped: #{e.class}: #{e.message}" }
123
+ []
124
+ end
125
+ private_class_method :token_validation_errors
126
+
127
+ # Upload a file to the backend's CACHE and return its re-postable token. The
128
+ # file lives in cache until `execute` assigns the token to a real attachment
129
+ # (which promotes it); an abandoned upload is reaped by the backend's own
130
+ # unattached-cache cleanup.
131
+ def upload_to_cache(file, backend, uploader: nil)
132
+ case backend.to_sym
133
+ when :shrine
134
+ shrine_uploader(uploader).upload(file, :cache).to_json
135
+ when :active_storage
136
+ raise ArgumentError, "`uploader:` is only supported for the :shrine backend" if uploader
137
+ ActiveStorage::Blob.create_and_upload!(
138
+ io: file, filename: file.original_filename, content_type: file.content_type
139
+ ).signed_id
140
+ else
141
+ raise ArgumentError, "unknown attachment backend: #{backend.inspect}"
142
+ end
143
+ end
144
+ private_class_method :upload_to_cache
145
+
146
+ # Resolve an `uploader:` option to the Shrine uploader class to cache through.
147
+ # nil → base `Shrine`; a class is used as-is; a String/Symbol is constantized.
148
+ # Anything that isn't a Shrine subclass is a configuration error (fail loud).
149
+ def shrine_uploader(uploader)
150
+ return Shrine if uploader.nil?
151
+
152
+ klass = uploader.is_a?(Class) ? uploader : uploader.to_s.safe_constantize
153
+ unless klass.is_a?(Class) && klass <= Shrine
154
+ raise ArgumentError, "`uploader:` must be a Shrine uploader class, got #{uploader.inspect}"
155
+ end
156
+ klass
157
+ end
158
+ private_class_method :shrine_uploader
159
+
160
+ # Revive one token through whichever backend owns it, wrapped in {Resolved}. A
161
+ # broad rescue is warranted here (unlike elsewhere): the token is arbitrary,
162
+ # user-supplied input reconstituted at a render boundary, and the two backends
163
+ # raise different error classes for a tampered/expired token — none of which
164
+ # should take down the page.
165
+ def resolve_token(token)
166
+ return if token.blank?
167
+
168
+ source = shrine_uploaded_file(token) || active_storage_blob(token)
169
+ source && Resolved.new(source, token)
170
+ rescue => e
171
+ Rails.logger.warn { "[Plutonium] could not resolve attachment token: #{e.class}: #{e.message}" }
172
+ nil
173
+ end
174
+ private_class_method :resolve_token
175
+
176
+ # A Shrine cached-file token is JSON (`{"id":…,"storage":"cache",…}`); an AS
177
+ # signed_id isn't. Parse-success → Shrine materializes it from the globally
178
+ # registered storages (no model, no per-field uploader needed for a `.url`).
179
+ def shrine_uploaded_file(token)
180
+ return unless defined?(Shrine)
181
+
182
+ data = begin
183
+ JSON.parse(token)
184
+ rescue JSON::ParserError, TypeError
185
+ nil
186
+ end
187
+ return unless data.is_a?(Hash)
188
+
189
+ Shrine.uploaded_file(data)
190
+ end
191
+ private_class_method :shrine_uploaded_file
192
+
193
+ def active_storage_blob(token)
194
+ ActiveStorage::Blob.find_signed(token) if defined?(ActiveStorage::Blob)
195
+ end
196
+ private_class_method :active_storage_blob
197
+
198
+ # A uniform view over a resolved attachment so the review display + the uppy
199
+ # preview don't care whether the source is an ActiveStorage `Blob`
200
+ # (`filename`/`content_type`/`representable?`) or a Shrine `UploadedFile`
201
+ # (`original_filename`/`mime_type`, none of the AS-only methods). Exposes
202
+ # exactly what those components call.
203
+ class Resolved
204
+ # @param source the backend object (AS Blob or Shrine UploadedFile).
205
+ # @param token [String] the ORIGINAL staged token — what the hidden preview
206
+ # field re-posts to preserve the upload across a Back/re-submit, and what
207
+ # `execute` assigns to the model attachment.
208
+ def initialize(source, token)
209
+ @source = source
210
+ @token = token
211
+ end
212
+
213
+ # `url` is lazy (called at render, inside a request, where AS url options
214
+ # exist) — never eager at resolve time.
215
+ def url(*args) = @source.url(*args)
216
+
217
+ # The re-postable token, surfaced under the name the uppy input reads.
218
+ def signed_id = @token
219
+
220
+ def filename = (@source.try(:filename) || @source.try(:original_filename)).to_s
221
+
222
+ def content_type = @source.try(:content_type) || @source.try(:mime_type)
223
+
224
+ def representable? = @source.try(:representable?) || content_type.to_s.start_with?("image/")
225
+
226
+ def extension = @source.try(:extension).presence || File.extname(filename).delete(".").presence
227
+
228
+ # Yields the attachment as an open file, cleaned up afterwards.
229
+ #
230
+ # {url} is enough for a wizard, which only ever renders a staged
231
+ # attachment. Work that RUNS on one — an import parsing the rows, a job
232
+ # promoting the file onto a model — has to read it, and in a job there is
233
+ # no request to build a URL against anyway.
234
+ #
235
+ # @yieldparam [File, Tempfile] an IO positioned at the start
236
+ # @return the block's value
237
+ def open(&) = @source.open(&)
238
+
239
+ # The bytes, as a String.
240
+ #
241
+ # Routed through {open} rather than the backends' own +download+, because
242
+ # that is one of the names they disagree on: ActiveStorage's returns the
243
+ # bytes, Shrine's returns a Tempfile. Papering over exactly that kind of
244
+ # divergence is what this class is for, so a caller can read a staged file
245
+ # without knowing which backend minted its token.
246
+ # Explicit receiver: a bare `open` here is indistinguishable from
247
+ # `Kernel#open` to a reader (and to the linter), which is a very different
248
+ # and much more dangerous method.
249
+ def download = self.open(&:read)
250
+
251
+ def present? = true
252
+ end
253
+ end
254
+ end
@@ -25,8 +25,21 @@ module Plutonium
25
25
  attr_reader :assets
26
26
 
27
27
  # @return [Plutonium::Wizard::Configuration] wizard subsystem configuration
28
+ # @return [Symbol, nil] the storage backend used to stage an attachment that
29
+ # travels as a plain string — `:active_storage` or `:shrine`. `nil`
30
+ # auto-detects (active_shrine loaded → `:shrine`, else `:active_storage`).
31
+ #
32
+ # The shared default. Wizards and async interactions each layer their own
33
+ # override on top, so setting this once covers both, and setting one of
34
+ # theirs narrows it to that subsystem.
35
+ attr_accessor :attachment_backend
36
+
28
37
  attr_reader :wizards
29
38
 
39
+ # @return [Plutonium::Interaction::Async::Configuration] persisted interaction
40
+ # runs configuration — gates the runs subsystem and its migrations
41
+ attr_reader :async_interactions
42
+
30
43
  # @return [Float] the current defaults version
31
44
  attr_reader :defaults_version
32
45
 
@@ -47,10 +60,74 @@ module Plutonium
47
60
  # change the default, or `false` (or `""`) for no symbol application-wide.
48
61
  attr_accessor :default_currency_unit
49
62
 
63
+ # @return [Symbol] the default width for detail-style pages — the show
64
+ # page, resource forms and wizard steps. One of
65
+ # {Plutonium::UI::PageWidth::VALID_SIZES}; `:full` opts out of any
66
+ # constraint. Definitions override per-resource via `page_width`
67
+ # (and `form_width` / `display_width` for one surface only).
68
+ #
69
+ # Index and table pages are NOT affected — they want every pixel.
70
+ attr_accessor :default_page_width
71
+
72
+ # @return [Boolean] whether a rendered collection — an index page, a kanban
73
+ # board, a CSV export — eager-loads the associations and attachments it is
74
+ # about to show. On by default.
75
+ #
76
+ # The rendered field set is declared, not discovered: it is resolved from
77
+ # the policy before the collection loads, so the framework already knows
78
+ # which associations will be touched and can preload exactly those. Turn it
79
+ # off globally here, or per controller via `auto_eager_load_collections?`.
80
+ attr_accessor :auto_eager_load_collections
81
+
50
82
  # @return [String, nil] the default country (ISO2 code, e.g. "gh") for phone
51
83
  # (`as: :phone`) inputs that don't set their own `initial_country:`. `nil`
52
84
  # (default) leaves it to the intl-tel-input library (no country preselected).
53
- attr_accessor :default_phone_country
85
+ # Stored verbatim — read it back exactly as set. Consumers that feed
86
+ # intl-tel-input should use {#normalized_default_phone_country}.
87
+ attr_reader :default_phone_country
88
+
89
+ # @return [String, nil] {#default_phone_country} downcased to the lowercase
90
+ # ISO2 form intl-tel-input expects, so callers can set "GH" or "gh"
91
+ # interchangeably. `nil` stays `nil`. Computed once on assignment.
92
+ attr_reader :normalized_default_phone_country
93
+
94
+ def default_phone_country=(value)
95
+ @default_phone_country = value
96
+ @normalized_default_phone_country = value&.downcase
97
+ end
98
+
99
+ # Valid values for {#nested_association_routes}.
100
+ NESTED_ASSOCIATION_ROUTE_MODES = %i[detected declared].freeze
101
+
102
+ # @return [Symbol] where a resource's nested routes come from.
103
+ #
104
+ # `:detected` (default) draws one for every `has_many` and `has_one` whose
105
+ # child is a registered resource, which is how Plutonium has always behaved.
106
+ #
107
+ # `:declared` draws only what `register_resource` names:
108
+ #
109
+ # register_resource ::Post, associations: %i[comments post_detail]
110
+ #
111
+ # A resource that names none then gets no nested routes at all. Naming
112
+ # associations works in either mode; the mode only decides what silence means.
113
+ #
114
+ # Note that a policy's `permitted_associations` renders a panel on the show
115
+ # page that links to the nested route, so an association permitted there and
116
+ # omitted here has a panel with nowhere to point.
117
+ attr_reader :nested_association_routes
118
+
119
+ # @param value [Symbol] one of {NESTED_ASSOCIATION_ROUTE_MODES}
120
+ # @raise [ArgumentError] on any other value, rather than silently drawing
121
+ # the wrong route table for a typo.
122
+ def nested_association_routes=(value)
123
+ value = value.to_sym
124
+ unless NESTED_ASSOCIATION_ROUTE_MODES.include?(value)
125
+ raise ArgumentError, "unknown nested_association_routes #{value.inspect}. " \
126
+ "Valid modes: #{NESTED_ASSOCIATION_ROUTE_MODES.map(&:inspect).join(", ")}"
127
+ end
128
+
129
+ @nested_association_routes = value
130
+ end
54
131
 
55
132
  # Map of version numbers to their default configurations
56
133
  VERSION_DEFAULTS = {
@@ -70,12 +147,16 @@ module Plutonium
70
147
  @defaults_version = nil
71
148
  @assets = AssetConfiguration.new
72
149
  @wizards = Plutonium::Wizard::Configuration.new
150
+ @async_interactions = Plutonium::Interaction::Async::Configuration.new
73
151
 
74
152
  @development = parse_boolean_env("PLUTONIUM_DEV")
75
153
  @cache_discovery = !Rails.env.development?
76
154
  @enable_hotreload = Rails.env.development?
77
155
  @shell = :modern
78
156
  @navii_host_url = "https://api.navii.dev"
157
+ @auto_eager_load_collections = true
158
+ @default_page_width = :md
159
+ @nested_association_routes = :detected
79
160
  end
80
161
 
81
162
  # Load default configuration for a specific version
@@ -40,7 +40,8 @@ module Plutonium
40
40
 
41
41
  helper Plutonium::Helpers
42
42
  helper_method :make_page_title, :resource_url_for,
43
- :resource_url_args_for, :root_path, :app_name, :route_options_to_url
43
+ :resource_url_args_for, :root_path, :app_name, :route_options_to_url,
44
+ :current_page_path, :current_page_url
44
45
 
45
46
  append_view_path File.expand_path("app/views", Plutonium.root)
46
47
  layout -> { turbo_frame_request? ? false : "resource" }
@@ -140,7 +141,7 @@ module Plutonium
140
141
 
141
142
  if wizard
142
143
  raise ArgumentError, "cannot pass both `wizard:` and `action:`" if action
143
- action = wizard_action_type_for(element, step: kwargs[:step])
144
+ action = wizard_action_type_for(element, step: kwargs[:step], wizard_action: kwargs.delete(:wizard_action))
144
145
  kwargs[:wizard_name] = wizard
145
146
  end
146
147
 
@@ -171,6 +172,24 @@ module Plutonium
171
172
  build_top_level_resource_url_args(element, action: action, **kwargs)
172
173
  end
173
174
 
175
+ # The path/URL of the PAGE this render belongs to.
176
+ #
177
+ # Almost always the current request — but not always. An action that
178
+ # answers a turbo_stream on behalf of a page the user is already looking
179
+ # at (the reposition drop POST, for one) renders that page's components
180
+ # from a DIFFERENT request path, and every URL those components build —
181
+ # sort links, the search form action, filter-pill removals, pagination,
182
+ # a row action's return_to — must point at the page, not at the POST
183
+ # endpoint. Reading them off `request` directly would bake the endpoint
184
+ # path into all of those links (a GET of the POST-only route → 404).
185
+ #
186
+ # Overriding these two is therefore the single seam for "render this
187
+ # collection as the index request would have rendered it"; see
188
+ # Plutonium::Resource::Controllers::PositionActions.
189
+ def current_page_path = request.path
190
+
191
+ def current_page_url = request.original_url
192
+
174
193
  def resource_url_for(*args, package: nil, **kwargs)
175
194
  url_args = resource_url_args_for(*args, package: package, **kwargs)
176
195
  url_helpers = route_url_helpers_for(package)
@@ -204,12 +223,24 @@ module Plutonium
204
223
  # classes/symbols/nil → collection (wizards have no bulk variant). With no
205
224
  # `step:`, target the bare LAUNCH action (which resolves the run and redirects
206
225
  # to its current step); with a `step:`, target the stepped show action.
207
- def wizard_action_type_for(element, step: nil)
226
+ #
227
+ # `wizard_action: :cancel` targets the DELETE action that abandons the run.
228
+ # A run is cancelled as a whole, so that route carries no `:step` segment and
229
+ # `step:` is ignored for it.
230
+ def wizard_action_type_for(element, step: nil, wizard_action: nil)
208
231
  member = !(element.is_a?(Class) || element.is_a?(Symbol) || element.nil?)
209
- if step.nil?
210
- member ? :launch_wizard_record_action : :launch_wizard_resource_action
232
+
233
+ case wizard_action
234
+ when nil
235
+ if step.nil?
236
+ member ? :launch_wizard_record_action : :launch_wizard_resource_action
237
+ else
238
+ member ? :wizard_record_action : :wizard_resource_action
239
+ end
240
+ when :cancel
241
+ member ? :cancel_wizard_record_action : :cancel_wizard_resource_action
211
242
  else
212
- member ? :wizard_record_action : :wizard_resource_action
243
+ raise ArgumentError, "unknown `wizard_action:` #{wizard_action.inspect} (expected :cancel or nil)"
213
244
  end
214
245
  end
215
246
 
@@ -257,7 +288,19 @@ module Plutonium
257
288
  "#{scoped_entity_param_key}_"
258
289
  end
259
290
 
260
- helper_name = :"#{helper_suffix}#{entity_prefix}#{helper_base}_path"
291
+ # For association names that singularize to themselves ("comment_series",
292
+ # "news", anything ending in an uncountable word), the member and
293
+ # collection helpers above resolve to the same string. Rails breaks that
294
+ # tie by suffixing the collection route with _index, exactly as it does
295
+ # for top-level resources — without matching it here the helper named
296
+ # does not exist, url_for falls back to param recall, and a collection
297
+ # link comes back as a member action with no id.
298
+ uncountable_index_suffix = if is_collection_action && !is_singular &&
299
+ nested_resource_name.pluralize == nested_resource_name.singularize
300
+ "_index"
301
+ end
302
+
303
+ helper_name = :"#{helper_suffix}#{entity_prefix}#{helper_base}#{uncountable_index_suffix}_path"
261
304
 
262
305
  # Build the arguments for the helper
263
306
  helper_args = []
@@ -48,6 +48,22 @@ module Plutonium
48
48
  @current_scoped_entity if scoped_to_entity?
49
49
  end
50
50
 
51
+ # Drops ActionPolicy's per-request authorization context memo.
52
+ #
53
+ # ActionPolicy builds the context once and memoizes it for the whole request.
54
+ # Our entity_scope resolves lazily, and the very first authorization of the
55
+ # request is the scoped entity's own read? check — which runs while
56
+ # @current_scoped_entity is still nil (see entity_scope_for_authorize above).
57
+ # That freezes `entity_scope: nil` into the memo, so every later
58
+ # `policy_for(record:)` / `allowed_to?` that does not pass an explicit
59
+ # `context:` builds its policy with no entity scope.
60
+ #
61
+ # Callers that resolve the scoped entity must invalidate the memo so the
62
+ # next policy build picks the entity up.
63
+ def reset_authorization_context!
64
+ @_authorization_context = nil
65
+ end
66
+
51
67
  def verify_authorized
52
68
  # we don't use action policy's inbuilt checks, so ensure they are neutered,
53
69
  # also ensures pundit checks are disabled.
@@ -77,8 +77,18 @@ module Plutonium
77
77
  # this method might be invoked even when not authenticated.
78
78
  # so let's guard against that.
79
79
  return unless current_user.present?
80
-
81
- @current_scoped_entity ||= fetch_current_scoped_entity
80
+ return @current_scoped_entity if @current_scoped_entity
81
+
82
+ @current_scoped_entity = fetch_current_scoped_entity
83
+ # fetch_current_scoped_entity authorizes while @current_scoped_entity is
84
+ # still nil, which memoizes `entity_scope: nil` into ActionPolicy's
85
+ # per-request context. Drop it now that the entity is available.
86
+ #
87
+ # Only when we actually resolved one: a strategy that yields nil leaves
88
+ # the memo correct as-is, and since nil is not memoized above, resetting
89
+ # here would re-run the fetch and rebuild the context on every call.
90
+ reset_authorization_context! if @current_scoped_entity
91
+ @current_scoped_entity
82
92
  end
83
93
 
84
94
  # Fetches the current scoped entity based on the scoping strategy.
@@ -31,11 +31,16 @@ module Plutonium
31
31
  include Actions
32
32
  include Wizards
33
33
  include Sorting
34
+ # After Actions and Sorting: `position_on` expands into `action`,
35
+ # `sort` and `default_sort`, so those must already be defined.
36
+ include Positioning
34
37
  include Scoping
35
38
  include Search
36
39
  include NestedInputs
37
40
  include StructuredInputs
38
41
  include FormLayout
42
+ include DisplayLayout
43
+ include PageWidths
39
44
  include IndexViews
40
45
  include Metadata
41
46
 
@@ -145,6 +150,52 @@ module Plutonium
145
150
  self.show_in_config = value
146
151
  end
147
152
 
153
+ # The model this definition describes — the inverse of the
154
+ # `"#{resource_class}Definition"` lookup controllers do
155
+ # (Plutonium::Resource::Controller#resource_definition).
156
+ #
157
+ # A definition is frequently namespaced under a package or portal that
158
+ # the model is NOT (`AdminPortal::Blogging::TutorialDefinition` describes
159
+ # `Blogging::Tutorial`), so leading namespace segments are dropped one at
160
+ # a time until an ActiveRecord model resolves.
161
+ #
162
+ # One case is genuinely unresolvable by name alone: `Admin::UserDefinition`
163
+ # where BOTH `Admin::User` and `::User` are models. Innermost wins, since a
164
+ # namespaced definition most often describes the namespaced model — if that
165
+ # is the wrong guess, override `model_class` on the definition.
166
+ #
167
+ # Resolved lazily and memoized: it constantizes the model, and a
168
+ # definition class body must stay loadable without forcing that.
169
+ def self.model_class
170
+ @model_class ||= infer_model_class
171
+ end
172
+
173
+ def self.infer_model_class
174
+ raise NameError, "cannot infer a model class for an anonymous definition; define `model_class` on it" if name.nil?
175
+
176
+ segments = name.split("::")
177
+ base = segments.pop.delete_suffix("Definition")
178
+ if base.empty?
179
+ raise NameError, "#{name} does not follow the `<Model>Definition` naming convention; define `model_class` on it"
180
+ end
181
+
182
+ # Only an ActiveRecord model can be the answer, so a namespace module, a
183
+ # stdlib constant (Set, Process) or a same-named PORO is skipped rather
184
+ # than accepted — the search continues outward to the model that
185
+ # actually exists. Without this the first constant that merely *resolves*
186
+ # wins, and `Billing::OrderDefinition` in an app with no `Billing::Order`
187
+ # would silently memoize something that is not a model at all.
188
+ segments.size.downto(0) do |i|
189
+ klass = (segments.last(i) + [base]).join("::").safe_constantize
190
+ return klass if klass.is_a?(Class) && klass < ActiveRecord::Base
191
+ end
192
+
193
+ raise NameError, "could not infer a model class from #{name}; define `model_class` on it"
194
+ end
195
+ private_class_method :infer_model_class
196
+
197
+ def model_class = self.class.model_class
198
+
148
199
  def initialize
149
200
  super
150
201
  end