plutonium 0.50.0 → 0.51.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 (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -0,0 +1,341 @@
1
+ # Interaction
2
+
3
+ Encapsulate business logic into testable, reusable units. Registered as [actions](/reference/resource/actions) in definitions and executed by the controller. Built on ActiveModel attributes + validations.
4
+
5
+ ## 🚨 Critical
6
+
7
+ - **`ActiveRecord::RecordInvalid` is NOT rescued automatically.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
8
+ - **Return `succeed(...)` or `failed(...)` from `execute`** — the controller can't tell what happened otherwise. Returning anything else raises.
9
+ - **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
10
+ - **Bulk actions use `attribute :resources` (plural).** Policy authorization is checked per record — if any fails, the whole request fails.
11
+ - **The shape of the action (record / bulk / resource) is inferred from the interaction's attributes.** See [Resource › Actions](/reference/resource/actions#inferred-visibility-interactive-actions).
12
+
13
+ ## Structure
14
+
15
+ ```ruby
16
+ # app/interactions/resource_interaction.rb — installed once
17
+ class ResourceInteraction < Plutonium::Resource::Interaction
18
+ end
19
+
20
+ # A real interaction
21
+ class PublishPostInteraction < ResourceInteraction
22
+ presents label: "Publish",
23
+ icon: Phlex::TablerIcons::Send,
24
+ description: "Make this post public"
25
+
26
+ attribute :resource
27
+ attribute :publish_date, :datetime, default: -> { Time.current }
28
+
29
+ input :publish_date
30
+
31
+ validates :publish_date, presence: true
32
+
33
+ private
34
+
35
+ def execute
36
+ resource.update!(published_at: publish_date)
37
+ succeed(resource).with_message("Post published!")
38
+ rescue ActiveRecord::RecordInvalid => e
39
+ failed(e.record.errors)
40
+ end
41
+ end
42
+ ```
43
+
44
+ ## Attributes
45
+
46
+ ActiveModel-style:
47
+
48
+ ```ruby
49
+ attribute :resource # single record (record action)
50
+ attribute :resources # array of records (bulk action)
51
+ attribute :email, :string
52
+ attribute :count, :integer, default: 1
53
+ attribute :active, :boolean, default: -> { true } # callable default
54
+ attribute :tags, :array
55
+ attribute :metadata, :hash
56
+ attribute :date, :datetime
57
+ ```
58
+
59
+ The presence of `:resource` / `:resources` / neither determines the action type — see [Resource › Actions › Inferred visibility](/reference/resource/actions#inferred-visibility-interactive-actions).
60
+
61
+ ## Inputs
62
+
63
+ Same DSL as definition `input`. Auto-detection from the attribute type applies — declare `as:` only when overriding.
64
+
65
+ ```ruby
66
+ input :email # auto: :email type from name match
67
+ input :role, as: :select, choices: %w[admin user]
68
+ input :content, as: :text
69
+ ```
70
+
71
+ See [Resource › Definition](/reference/resource/definition#available-field-types) for all `as:` types, options, and dynamic blocks.
72
+
73
+ ## Presentation
74
+
75
+ ```ruby
76
+ presents label: "Archive Record",
77
+ icon: Phlex::TablerIcons::Archive,
78
+ description: "Move to archive"
79
+ ```
80
+
81
+ Access:
82
+
83
+ ```ruby
84
+ MyInteraction.label # => "Archive Record"
85
+ MyInteraction.icon # => Phlex::TablerIcons::Archive
86
+ MyInteraction.description # => "Move to archive"
87
+ ```
88
+
89
+ If `action :foo, interaction: FooInteraction` doesn't override `label:` / `icon:` etc., these `presents` values are used.
90
+
91
+ ## `execute` — outcomes
92
+
93
+ `execute` MUST return a `succeed(...)` or `failed(...)` outcome. Validations run automatically before `execute`; if they fail, the interaction short-circuits to `failed()`.
94
+
95
+ ### Success
96
+
97
+ ```ruby
98
+ succeed(resource) # auto-redirect to resource
99
+ succeed(resource).with_message("Done!")
100
+ succeed(resource).with_message("Heads up!", :alert)
101
+ succeed(resource).with_redirect_response(custom_path) # different destination
102
+ succeed(resource).with_file_response(path, filename: "report.pdf")
103
+ succeed(resource).with_render_response(:custom_template)
104
+ ```
105
+
106
+ ### Failure
107
+
108
+ ```ruby
109
+ failed("Something went wrong")
110
+ failed(resource.errors)
111
+ failed(email: "is invalid", name: "is required") # hash form
112
+ failed("Invalid value", :email) # string + attribute
113
+ ```
114
+
115
+ ### Manual error addition
116
+
117
+ ```ruby
118
+ def execute
119
+ errors.add(:base, "Post must have content")
120
+ return failure if errors.any?
121
+
122
+ # …continue
123
+ end
124
+ ```
125
+
126
+ ### Chaining
127
+
128
+ `and_then` chains interactions. On failure, the chain short-circuits and returns the failure immediately.
129
+
130
+ ```ruby
131
+ def execute
132
+ CreateUserInteraction.call(view_context:, **user_params)
133
+ .and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
134
+ .and_then { |r| LogActivity.call(view_context:, user: r.value) }
135
+ .with_message("User created and welcomed!")
136
+ end
137
+ ```
138
+
139
+ ## Validations
140
+
141
+ Standard ActiveModel. Run automatically before `execute`:
142
+
143
+ ```ruby
144
+ validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
145
+ validates :role, inclusion: {in: %w[admin user guest]}
146
+
147
+ validate :custom_check
148
+
149
+ private
150
+
151
+ def custom_check
152
+ errors.add(:resource, "cannot be modified when archived") if resource.archived?
153
+ end
154
+ ```
155
+
156
+ ## Accessing context
157
+
158
+ `current_user` is provided by the base class (`view_context.controller.helpers.current_user`):
159
+
160
+ ```ruby
161
+ def execute
162
+ resource.update!(updated_by: current_user)
163
+ succeed(resource)
164
+ end
165
+ ```
166
+
167
+ ## Interaction types
168
+
169
+ | Attribute pattern | Action type | Where it shows up |
170
+ |---|---|---|
171
+ | `attribute :resource` | Record action | Show page + per-row in table |
172
+ | `attribute :resources` (plural) | Bulk action | Bulk toolbar above table |
173
+ | neither | Resource action | Index page header |
174
+
175
+ ### Record action
176
+
177
+ ```ruby
178
+ class ArchiveInteraction < Plutonium::Resource::Interaction
179
+ attribute :resource
180
+
181
+ def execute
182
+ resource.update!(archived: true)
183
+ succeed(resource).with_message("Archived")
184
+ rescue ActiveRecord::RecordInvalid => e
185
+ failed(e.record.errors)
186
+ end
187
+ end
188
+ ```
189
+
190
+ ### Bulk action
191
+
192
+ ```ruby
193
+ class BulkArchiveInteraction < Plutonium::Resource::Interaction
194
+ attribute :resources
195
+
196
+ def execute
197
+ resources.update_all(archived: true)
198
+ succeed(resources).with_message("Archived #{resources.size} records")
199
+ end
200
+ end
201
+ ```
202
+
203
+ Per-record authorization details in [Resource › Actions › Bulk action](/reference/resource/actions#bulk-action).
204
+
205
+ ### Resource action (no record)
206
+
207
+ ```ruby
208
+ class ImportInteraction < Plutonium::Resource::Interaction
209
+ attribute :file
210
+ input :file, as: :file
211
+ validates :file, presence: true
212
+
213
+ def execute
214
+ # …import logic
215
+ succeed(nil).with_message("Import completed.")
216
+ end
217
+ end
218
+ ```
219
+
220
+ ## Calling interactions directly
221
+
222
+ The controller handles this for interactive actions. But you can call them manually too — useful in tests, jobs, and rake tasks.
223
+
224
+ ### Class method
225
+
226
+ ```ruby
227
+ outcome = PublishPost.call(view_context: view_context, resource: post)
228
+
229
+ if outcome.success?
230
+ # …
231
+ else
232
+ # …
233
+ end
234
+ ```
235
+
236
+ ### Instance method
237
+
238
+ ```ruby
239
+ interaction = PublishPost.new(view_context: view_context, resource: post)
240
+ outcome = interaction.call
241
+ ```
242
+
243
+ The `view_context:` argument is required — interactions use it to access controller helpers and the current user.
244
+
245
+ ## Immediate vs form
246
+
247
+ | Interaction shape | Behavior |
248
+ |---|---|
249
+ | Only `:resource` / `:resources` (no extra `attribute` or `input`) | **Immediate** — browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom"` or `confirmation: false` on the action. |
250
+ | Additional `attribute` / `input` declared | **Form** — renders modal form first; no auto-confirmation. |
251
+
252
+ See [Resource › Actions › Immediate vs form](/reference/resource/actions#immediate-vs-form).
253
+
254
+ ## Generating interaction URLs
255
+
256
+ `resource_url_for` with the `interaction:` kwarg. The action type (record / bulk / resource) is inferred from the element and the presence of `ids:`:
257
+
258
+ ```ruby
259
+ # Record action — instance argument
260
+ resource_url_for(@post, interaction: :publish)
261
+ # => /posts/:id/record_actions/publish
262
+
263
+ # Resource action — class, no ids
264
+ resource_url_for(Post, interaction: :import)
265
+ # => /posts/resource_actions/import
266
+
267
+ # Bulk action — class + ids
268
+ resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
269
+ # => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3
270
+
271
+ # Composes with parent / entity scoping
272
+ resource_url_for(@post, parent: @user, interaction: :publish)
273
+ ```
274
+
275
+ The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.
276
+
277
+ ## Complete example
278
+
279
+ ```ruby
280
+ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
281
+ presents label: "Invite User",
282
+ icon: Phlex::TablerIcons::UserPlus
283
+
284
+ attribute :resource # the company
285
+ attribute :email, :string
286
+ attribute :role, :string
287
+
288
+ input :email
289
+ input :role, as: :select, choices: -> { UserInvite.roles.keys }
290
+
291
+ validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
292
+ validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
293
+ validate :not_already_invited
294
+
295
+ private
296
+
297
+ def execute
298
+ invite = UserInvite.create!(
299
+ company: resource, email: email, role: role,
300
+ invited_by: current_user
301
+ )
302
+ UserInviteMailer.invitation(invite).deliver_later
303
+ succeed(resource).with_message("Invitation sent to #{email}")
304
+ rescue ActiveRecord::RecordInvalid => e
305
+ failed(e.record.errors)
306
+ end
307
+
308
+ def not_already_invited
309
+ return unless email.present?
310
+ if UserInvite.exists?(company: resource, email: email, state: :pending)
311
+ errors.add(:email, "already has a pending invitation")
312
+ end
313
+ end
314
+ end
315
+ ```
316
+
317
+ ## Testing
318
+
319
+ ```ruby
320
+ RSpec.describe PublishPost do
321
+ let(:view_context) { double("view_context", controller: double(helpers: double(current_user: user))) }
322
+ let(:user) { create(:user) }
323
+ let(:post) { create(:post, user: user, published: false) }
324
+
325
+ it "publishes the post" do
326
+ outcome = described_class.call(view_context: view_context, resource: post)
327
+
328
+ expect(outcome).to be_success
329
+ expect(post.reload).to be_published
330
+ end
331
+ end
332
+ ```
333
+
334
+ See [Testing](/reference/testing/) for Plutonium's built-in testing helpers — `ResourceInteraction` concern wraps these patterns.
335
+
336
+ ## Related
337
+
338
+ - [Resource › Actions](/reference/resource/actions) — registering interactions, inferred visibility, immediate vs form
339
+ - [Policies](./policies) — `def <action>?` authorization methods
340
+ - [Controllers](./controllers) — `resource_url_for(..., interaction: …)` URL generation
341
+ - [UI › Forms](/reference/ui/forms) — customizing the modal form rendered for actions with inputs