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,423 @@
1
+ # Actions
2
+
3
+ Custom buttons that go beyond standard CRUD — publish, archive, import, send invitation, etc. Two flavors:
4
+
5
+ - **Simple actions** — navigate to an existing URL.
6
+ - **Interactive actions** — run an [Interaction](/reference/behavior/interactions), optionally collecting input via a modal form.
7
+
8
+ ## 🚨 Critical
9
+
10
+ - **Every custom action needs a policy method.** `action :publish` requires `def publish?` on the policy. Undefined methods return `false`, so the action silently disappears.
11
+ - **For interactive actions, visibility is inferred from the interaction's attributes.** Don't declare `record_action: true` / `bulk_action: true` etc. by hand unless you're opting OUT.
12
+ - **Bulk action authorization is per-record.** If any selected record fails the policy check, the entire request is rejected.
13
+ - **Always pass `as:`** on custom routes — without it, `resource_url_for` can't generate URLs (critical for nested resources).
14
+ - **Prefer interactive actions over hand-written controller routes.** Anything with business logic belongs in an interaction.
15
+
16
+ ## Action visibility flags
17
+
18
+ | Flag | Where the button appears |
19
+ |---|---|
20
+ | `resource_action: true` | Index page (top toolbar) — for actions that operate on the collection (Import, Export, Create) |
21
+ | `record_action: true` | Show page — for actions on a single record (Edit, Archive, Delete) |
22
+ | `collection_record_action: true` | Per-row in the index table — for quick actions (Edit, Show) |
23
+ | `bulk_action: true` | Bulk-actions toolbar (shown when records are selected) |
24
+
25
+ ### Inferred visibility (interactive actions)
26
+
27
+ For `interaction:`-based actions, all four flags are **inferred from the interaction's attributes** — don't declare them by hand:
28
+
29
+ | Interaction declares | Inferred flags |
30
+ |---|---|
31
+ | `attribute :resource` | `record_action: true` + `collection_record_action: true` |
32
+ | `attribute :resources` (plural) | `bulk_action: true` |
33
+ | neither | `resource_action: true` |
34
+
35
+ User-supplied flags override the inferred ones, but only **opt-out** makes sense — the interaction's `attribute :resource` / `attribute :resources` already fixes its semantic shape:
36
+
37
+ ```ruby
38
+ # :resource interaction → defaults to record_action + collection_record_action.
39
+ # Hide from per-row menu, keep on show page:
40
+ action :archive, interaction: ArchiveInteraction, collection_record_action: false
41
+
42
+ # Hide from show page, keep per-row button:
43
+ action :preview, interaction: PreviewInteraction, record_action: false
44
+ ```
45
+
46
+ Declare the flags manually for **simple/navigation actions** (no `interaction:`) or when opting out of an inferred slot.
47
+
48
+ ## Action options
49
+
50
+ ```ruby
51
+ action :name,
52
+ # Display
53
+ label: "Custom Label", # default: name.titleize
54
+ description: "What it does",
55
+ icon: Phlex::TablerIcons::Star,
56
+ color: :danger, # :primary, :secondary, :danger
57
+
58
+ # Visibility (combine as needed)
59
+ resource_action: true,
60
+ record_action: true,
61
+ collection_record_action: true,
62
+ bulk_action: true,
63
+
64
+ # Grouping
65
+ category: :primary, # :primary, :secondary, :danger
66
+ position: 50, # display order (lower = first)
67
+
68
+ # Behavior
69
+ confirmation: "Are you sure?",
70
+ turbo_frame: "_top",
71
+ return_to: "/custom/path",
72
+ route_options: {action: :foo},
73
+ modal: :slideover # :centered (default) or :slideover — chrome for the action's interaction form
74
+ ```
75
+
76
+ ### Deriving variants — `Action#with(...)`
77
+
78
+ Action records are frozen value objects. Inside `customize_actions`, derive a copy with overrides:
79
+
80
+ ```ruby
81
+ def customize_actions
82
+ defined_actions[:edit] = defined_actions[:edit].with(turbo_frame: "_top")
83
+ end
84
+ ```
85
+
86
+ ## Simple actions (navigation)
87
+
88
+ Link to an existing route. The target route MUST exist.
89
+
90
+ ```ruby
91
+ class PostDefinition < Plutonium::Resource::Definition
92
+ # External URL
93
+ action :documentation,
94
+ label: "Documentation",
95
+ route_options: {url: "https://docs.example.com"},
96
+ icon: Phlex::TablerIcons::Book,
97
+ resource_action: true
98
+
99
+ # Custom controller action
100
+ action :reports,
101
+ route_options: {action: :reports},
102
+ icon: Phlex::TablerIcons::ChartBar,
103
+ resource_action: true
104
+ end
105
+ ```
106
+
107
+ ::: warning Custom routes need `as:`
108
+ ```ruby
109
+ resources :posts do
110
+ collection { get :reports, as: :reports } # ← `as:` required
111
+ end
112
+ ```
113
+
114
+ Without it, `resource_url_for` can't build the URL — particularly critical for nested resources.
115
+ :::
116
+
117
+ For anything with business logic, use an **interactive action** instead.
118
+
119
+ ## Interactive actions
120
+
121
+ Run an [Interaction](/reference/behavior/interactions) — automatically renders a form if the interaction declares attributes beyond `:resource`/`:resources`, otherwise executes immediately with a confirmation.
122
+
123
+ ```ruby
124
+ class PostDefinition < Plutonium::Resource::Definition
125
+ action :publish, interaction: PublishInteraction
126
+
127
+ action :archive, interaction: ArchiveInteraction,
128
+ color: :danger,
129
+ category: :danger,
130
+ position: 1000,
131
+ confirmation: "Are you sure?"
132
+ end
133
+ ```
134
+
135
+ ### Per-record interaction (record action)
136
+
137
+ ```ruby
138
+ class ArchiveInteraction < ResourceInteraction
139
+ presents label: "Archive",
140
+ icon: Phlex::TablerIcons::Archive,
141
+ description: "Move to archive"
142
+
143
+ attribute :resource
144
+
145
+ def execute
146
+ resource.archived!
147
+ succeed(resource).with_message("Record archived.")
148
+ rescue ActiveRecord::RecordInvalid => e
149
+ failed(e.record.errors)
150
+ end
151
+ end
152
+ ```
153
+
154
+ Register:
155
+
156
+ ```ruby
157
+ action :archive, interaction: ArchiveInteraction
158
+ # record_action + collection_record_action inferred automatically
159
+ ```
160
+
161
+ ### With form inputs
162
+
163
+ The interaction declares extra `attribute` and `input` lines → a modal form renders before execution.
164
+
165
+ ```ruby
166
+ class InviteUserInteraction < Plutonium::Resource::Interaction
167
+ presents label: "Invite User", icon: Phlex::TablerIcons::Mail
168
+
169
+ attribute :resource # the company
170
+ attribute :email
171
+ attribute :role
172
+
173
+ input :email, as: :email
174
+ input :role, as: :select, choices: %w[admin member viewer]
175
+
176
+ validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
177
+ validates :role, presence: true
178
+
179
+ def execute
180
+ UserInvite.create!(company: resource, email: email, role: role)
181
+ succeed(resource).with_message("Invitation sent to #{email}.")
182
+ rescue ActiveRecord::RecordInvalid => e
183
+ failed(e.record.errors)
184
+ end
185
+ end
186
+ ```
187
+
188
+ ### Bulk action
189
+
190
+ Plural `attribute :resources` → bulk action. The index table shows checkboxes and a bulk-actions toolbar.
191
+
192
+ ```ruby
193
+ class BulkArchiveInteraction < Plutonium::Resource::Interaction
194
+ presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
195
+
196
+ attribute :resources # array of records
197
+
198
+ def execute
199
+ resources.each(&:archived!)
200
+ succeed(resources).with_message("#{resources.size} records archived.")
201
+ rescue => error
202
+ failed("Bulk archive failed: #{error.message}")
203
+ end
204
+ end
205
+ ```
206
+
207
+ Register:
208
+
209
+ ```ruby
210
+ action :bulk_archive, interaction: BulkArchiveInteraction
211
+ # bulk_action: true inferred from `attribute :resources`
212
+ ```
213
+
214
+ Policy — checked per record; fails the whole request if ANY record is unauthorized:
215
+
216
+ ```ruby
217
+ def bulk_archive?
218
+ user.admin? || record.author == user
219
+ end
220
+ ```
221
+
222
+ The UI only shows bulk actions that ALL selected records support. Records are fetched via `current_authorized_scope` — users can only select records they can access.
223
+
224
+ ### Resource action (no record)
225
+
226
+ Neither `:resource` nor `:resources` → resource action (shown on the index page).
227
+
228
+ ```ruby
229
+ class ImportInteraction < Plutonium::Resource::Interaction
230
+ presents label: "Import CSV", icon: Phlex::TablerIcons::Upload
231
+
232
+ attribute :file
233
+ input :file, as: :file
234
+ validates :file, presence: true
235
+
236
+ def execute
237
+ succeed(nil).with_message("Import completed.")
238
+ end
239
+ end
240
+ ```
241
+
242
+ ```ruby
243
+ action :import, interaction: ImportInteraction
244
+ ```
245
+
246
+ ## Immediate vs form
247
+
248
+ | Interaction shape | Behavior |
249
+ |---|---|
250
+ | Only `:resource` / `:resources` (no extra inputs) | **Immediate** — browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom"` or `confirmation: false`. |
251
+ | Additional `attribute` / `input` declared | **Form** — renders the action's form in a modal first; no auto-confirmation (the form is the confirmation). |
252
+
253
+ ## Built-in CRUD actions
254
+
255
+ These are defined by default on every definition:
256
+
257
+ ```ruby
258
+ action :new,
259
+ route_options: {action: :new},
260
+ resource_action: true,
261
+ category: :primary,
262
+ icon: Phlex::TablerIcons::Plus,
263
+ position: 10
264
+
265
+ action :show,
266
+ route_options: {action: :show},
267
+ collection_record_action: true,
268
+ icon: Phlex::TablerIcons::Eye,
269
+ position: 10
270
+
271
+ action :edit,
272
+ route_options: {action: :edit},
273
+ record_action: true,
274
+ collection_record_action: true,
275
+ icon: Phlex::TablerIcons::Edit,
276
+ position: 20
277
+
278
+ action :destroy,
279
+ route_options: {method: :delete},
280
+ record_action: true,
281
+ collection_record_action: true,
282
+ category: :danger,
283
+ icon: Phlex::TablerIcons::Trash,
284
+ position: 100,
285
+ confirmation: "Are you sure?",
286
+ turbo_frame: "_top"
287
+ ```
288
+
289
+ ### Customizing built-ins
290
+
291
+ Re-declare with the options you want changed:
292
+
293
+ ```ruby
294
+ class PostDefinition < ResourceDefinition
295
+ action :destroy,
296
+ confirmation: "This will permanently delete the post and all comments.",
297
+ route_options: {method: :delete},
298
+ record_action: true,
299
+ collection_record_action: true,
300
+ category: :danger,
301
+ icon: Phlex::TablerIcons::Trash,
302
+ position: 100,
303
+ turbo_frame: "_top"
304
+ end
305
+ ```
306
+
307
+ ## Interaction responses
308
+
309
+ ```ruby
310
+ def execute
311
+ # Success — redirects to resource automatically
312
+ succeed(resource).with_message("Done!")
313
+
314
+ # Different redirect destination
315
+ succeed(resource)
316
+ .with_redirect_response(custom_dashboard_path)
317
+ .with_message("Redirecting...")
318
+
319
+ # Failures
320
+ failed(resource.errors)
321
+ failed("Something went wrong")
322
+ failed("Invalid value", :email) # attaches error to a specific attribute
323
+ failed(email: "is invalid", name: "is required") # hash form
324
+ end
325
+ ```
326
+
327
+ ::: tip Automatic redirect on success
328
+ You only need `with_redirect_response` for a non-default destination. The controller redirects to the resource (show page) by default.
329
+ :::
330
+
331
+ ## Route options
332
+
333
+ ```ruby
334
+ # Simple route to controller action
335
+ action :preview,
336
+ route_options: {action: :preview},
337
+ record_action: true
338
+
339
+ # Custom HTTP method
340
+ action :archive,
341
+ route_options: {method: :post, action: :archive},
342
+ record_action: true
343
+
344
+ # External URL
345
+ action :docs,
346
+ route_options: {url: "https://docs.example.com"},
347
+ resource_action: true
348
+
349
+ # Custom URL resolver
350
+ action :create_deployment,
351
+ route_options: Plutonium::Action::RouteOptions.new(
352
+ url_resolver: ->(subject) {
353
+ resource_url_for(Deployment, action: :new, parent: subject)
354
+ }
355
+ ),
356
+ record_action: true
357
+ ```
358
+
359
+ ## Inherited actions
360
+
361
+ Actions defined on the base `ResourceDefinition` are inherited by every resource:
362
+
363
+ ```ruby
364
+ # app/definitions/resource_definition.rb
365
+ class ResourceDefinition < Plutonium::Resource::Definition
366
+ action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
367
+ end
368
+
369
+ # All resources inherit :archive automatically
370
+ class PostDefinition < ResourceDefinition
371
+ end
372
+ ```
373
+
374
+ ## Portal-specific actions
375
+
376
+ ```ruby
377
+ class AdminPortal::PostDefinition < ::PostDefinition
378
+ action :feature, interaction: FeaturePostInteraction
379
+ action :bulk_publish, interaction: BulkPublishInteraction
380
+ end
381
+ ```
382
+
383
+ ## Authorization
384
+
385
+ The policy method name matches the action name plus `?`:
386
+
387
+ ```ruby
388
+ class PostPolicy < ResourcePolicy
389
+ def publish? = user.admin? || record.author == user
390
+ def archive? = user.admin?
391
+ def import? = create?
392
+ end
393
+ ```
394
+
395
+ Undefined → action returns `false` → button doesn't appear. See [Behavior › Policy](/reference/behavior/policies) for the full policy surface.
396
+
397
+ ## Common patterns
398
+
399
+ ### Archive / restore
400
+
401
+ ```ruby
402
+ action :archive,
403
+ interaction: ArchiveInteraction,
404
+ color: :danger
405
+
406
+ action :restore,
407
+ interaction: RestoreInteraction
408
+ ```
409
+
410
+ ### Export
411
+
412
+ ```ruby
413
+ action :export,
414
+ interaction: ExportInteraction,
415
+ icon: Phlex::TablerIcons::Download
416
+ ```
417
+
418
+ ## Related
419
+
420
+ - [Definition](./definition) — fields, page chrome
421
+ - [Query](./query) — search, filters, scopes
422
+ - [Behavior › Interactions](/reference/behavior/interactions) — writing interaction classes
423
+ - [Behavior › Policy](/reference/behavior/policies) — authorizing custom actions