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
@@ -1,108 +1,47 @@
1
1
  # Custom Actions
2
2
 
3
- This guide covers adding custom actions beyond standard CRUD operations.
3
+ Add buttons beyond CRUD Publish, Archive, Import, Send invitation, Bulk-update, etc.
4
4
 
5
- ## Overview
5
+ ## Goal
6
6
 
7
- Custom actions let you add buttons like "Publish", "Archive", or "Send Invoice" to your resources. Actions can be:
7
+ A button appears in the right place (show page / table row / index header / bulk-actions toolbar), the user clicks it, optional form collects input, business logic runs, a success/failure message appears.
8
8
 
9
- - **Simple** - Navigation to another page
10
- - **Interactive** - Execute business logic with optional user input
9
+ ## Two flavors
11
10
 
12
- ## Action Types
11
+ | Flavor | Use for |
12
+ |---|---|
13
+ | **Simple action** — navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
14
+ | **Interactive action** — run an interaction class | Anything with business logic (the common case) |
13
15
 
14
- | Type | Shows In | Use Case |
15
- |------|----------|----------|
16
- | `resource_action` | Index page | Import, Export, Create new |
17
- | `record_action` | Show page | Edit, Delete, Archive |
18
- | `collection_record_action` | Table rows | Quick actions per row |
19
- | `bulk_action` | Selected records | Bulk operations |
16
+ Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects — all for free.
20
17
 
21
- ## Simple Actions (Navigation)
18
+ ## Quick recipe — interactive action
22
19
 
23
- For actions that just navigate somewhere (the target route must already exist):
20
+ ### 1. Write the interaction
24
21
 
25
22
  ```ruby
26
- class PostDefinition < ResourceDefinition
27
- # Link to external URL
28
- action :documentation,
29
- label: "Documentation",
30
- route_options: {url: "https://docs.example.com"},
31
- icon: Phlex::TablerIcons::Book,
32
- resource_action: true
33
-
34
- # Link to custom controller action
35
- action :reports,
36
- route_options: {action: :reports},
37
- icon: Phlex::TablerIcons::ChartBar,
38
- resource_action: true
39
- end
40
- ```
41
-
42
- ::: warning Always Name Custom Routes
43
- When adding custom routes for actions, always use the `as:` option:
44
-
45
- ```ruby
46
- resources :posts do
47
- collection do
48
- get :reports, as: :reports # Named route required!
49
- end
50
- end
51
- ```
52
-
53
- This ensures `resource_url_for` can generate correct URLs, especially for nested resources.
54
- :::
55
-
56
- **Note:** For custom operations with business logic, use Interactive Actions with an Interaction class.
57
-
58
- ## Interactive Actions with Interactions
59
-
60
- For actions that execute business logic, use Interactions.
61
-
62
- ### Creating an Interaction
63
-
64
- ```ruby
65
- # app/interactions/resource_interaction.rb (generated during install)
66
- class ResourceInteraction < Plutonium::Resource::Interaction
67
- end
68
-
69
23
  # app/interactions/publish_post_interaction.rb
70
24
  class PublishPostInteraction < ResourceInteraction
71
- # UI configuration
72
- presents label: "Publish Post",
73
- icon: Phlex::TablerIcons::Send,
25
+ presents label: "Publish",
26
+ icon: Phlex::TablerIcons::Send,
74
27
  description: "Make this post public"
75
28
 
76
- # The record being acted on
77
29
  attribute :resource
78
30
 
79
- # Validation
80
- validate :not_already_published
81
-
82
- private
83
-
84
- # Main logic
85
31
  def execute
86
- resource.update!(
87
- published: true,
88
- published_at: Time.current
89
- )
90
-
91
- succeed(resource)
92
- .with_message("Post published successfully!")
32
+ resource.update!(published: true, published_at: Time.current)
33
+ succeed(resource).with_message("Post published!")
93
34
  rescue ActiveRecord::RecordInvalid => e
94
35
  failed(e.record.errors)
95
36
  end
96
-
97
- def not_already_published
98
- if resource.published?
99
- errors.add(:base, "Post is already published")
100
- end
101
- end
102
37
  end
103
38
  ```
104
39
 
105
- ### Registering the Action
40
+ ::: warning Rescue `ActiveRecord::RecordInvalid`
41
+ Plutonium doesn't rescue it automatically. Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
42
+ :::
43
+
44
+ ### 2. Register it in the definition
106
45
 
107
46
  ```ruby
108
47
  class PostDefinition < ResourceDefinition
@@ -110,464 +49,205 @@ class PostDefinition < ResourceDefinition
110
49
  end
111
50
  ```
112
51
 
113
- ### Authorizing the Action
52
+ Action visibility (record / bulk / resource) is **inferred** from the interaction's attributes — no need to declare `record_action: true`. See [Inferred visibility](#inferred-visibility) below.
53
+
54
+ ### 3. Add a policy method
114
55
 
115
56
  ```ruby
116
57
  class PostPolicy < ResourcePolicy
117
- def publish?
118
- update? && !record.published?
119
- end
58
+ def publish? = update? && record.draft?
120
59
  end
121
60
  ```
122
61
 
123
- ## Actions with User Input
124
-
125
- Interactions can accept user input via attributes:
126
-
127
- ```ruby
128
- class SchedulePostInteraction < ResourceInteraction
129
- presents label: "Schedule Publication",
130
- icon: Phlex::TablerIcons::Calendar
131
-
132
- # The record
133
- attribute :resource
134
-
135
- # User inputs
136
- attribute :publish_at, :datetime
137
- attribute :notify_subscribers, :boolean, default: true
138
-
139
- # Configure form inputs
140
- input :publish_at, as: :datetime
141
- input :notify_subscribers, as: :boolean
142
-
143
- # Validations
144
- validates :publish_at, presence: true
145
- validate :publish_at_in_future
146
-
147
- private
148
-
149
- def execute
150
- resource.update!(
151
- scheduled_at: publish_at,
152
- notify_on_publish: notify_subscribers
153
- )
154
-
155
- SchedulePublicationJob.perform_at(publish_at, resource.id)
156
-
157
- succeed(resource)
158
- .with_message("Post scheduled for #{publish_at.strftime('%B %d at %I:%M %p')}")
159
- rescue ActiveRecord::RecordInvalid => e
160
- failed(e.record.errors)
161
- end
62
+ 🚨 Without this, the button silently disappears (undefined methods return `false`).
162
63
 
163
- def publish_at_in_future
164
- if publish_at.present? && publish_at <= Time.current
165
- errors.add(:publish_at, "must be in the future")
166
- end
167
- end
168
- end
169
- ```
64
+ ### 4. Visit the show page
170
65
 
171
- Register with the definition:
66
+ The "Publish" button appears in the toolbar. Clicking it shows a "Publish?" confirmation, then runs.
172
67
 
173
- ```ruby
174
- action :schedule, interaction: SchedulePostInteraction
175
- ```
176
-
177
- Now users see a form with date picker and checkbox before execution.
68
+ ## Inferred visibility
178
69
 
179
- ## Immediate vs Form Actions
70
+ For `interaction:`-based actions, visibility flags are inferred from the interaction:
180
71
 
181
- Plutonium automatically determines if an action needs a form:
72
+ | Interaction declares | Inferred flag button shows up |
73
+ |---|---|
74
+ | `attribute :resource` | `record_action: true` + `collection_record_action: true` → show page + per-row |
75
+ | `attribute :resources` (plural) | `bulk_action: true` → bulk toolbar |
76
+ | neither | `resource_action: true` → index page header |
182
77
 
183
- - **Has inputs defined** Shows form first
184
- - **No inputs** → Executes immediately (with optional confirmation)
78
+ User-supplied flags can only **opt OUT** of inferred ones. Don't try to "broaden" — the interaction's attribute shape is semantic:
185
79
 
186
80
  ```ruby
187
- # Shows form (has inputs)
188
- class InviteUserInteraction < ResourceInteraction
189
- attribute :resource
190
- attribute :email
191
- input :email # This triggers form display
192
- end
81
+ # Hide from per-row menu, keep on show page
82
+ action :archive, interaction: ArchiveInteraction, collection_record_action: false
193
83
 
194
- # Immediate execution (no inputs)
195
- class ArchiveInteraction < ResourceInteraction
196
- attribute :resource
197
- # No inputs = immediate with confirmation
198
- end
84
+ # Hide from show page, keep per-row only
85
+ action :preview, interaction: PreviewInteraction, record_action: false
199
86
  ```
200
87
 
201
- ## Action Visibility
88
+ For simple navigation actions (no `interaction:`), declare flags manually.
202
89
 
203
- Control where actions appear:
204
-
205
- ```ruby
206
- action :publish,
207
- interaction: PublishPostInteraction,
208
- record_action: true, # Show on show page
209
- collection_record_action: true # Show in table rows
210
- ```
90
+ ## With form inputs
211
91
 
212
- ### Record Actions (Single Records)
92
+ If the interaction declares extra `attribute`/`input`, a modal form is rendered first:
213
93
 
214
94
  ```ruby
215
- action :publish, interaction: PublishPostInteraction
216
- action :archive, interaction: ArchiveInteraction, record_action: true
217
- ```
95
+ class Company::InviteUserInteraction < ResourceInteraction
96
+ presents label: "Invite User", icon: Phlex::TablerIcons::Mail
218
97
 
219
- ### Bulk Actions (Multiple Records)
98
+ attribute :resource # the company
99
+ attribute :email
100
+ attribute :role
220
101
 
221
- ```ruby
222
- action :bulk_publish, interaction: BulkPublishInteraction
223
- action :bulk_archive, interaction: BulkArchiveInteraction
224
- ```
102
+ input :email, as: :email
103
+ input :role, as: :select, choices: %w[admin member]
225
104
 
226
- ### Resource Actions (No Record)
105
+ validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
106
+ validates :role, presence: true
227
107
 
228
- ```ruby
229
- action :import, interaction: ImportInteraction, resource_action: true
230
- action :export, interaction: ExportInteraction, resource_action: true
108
+ def execute
109
+ UserInvite.create!(company: resource, email: email, role: role)
110
+ succeed(resource).with_message("Invitation sent to #{email}.")
111
+ rescue ActiveRecord::RecordInvalid => e
112
+ failed(e.record.errors)
113
+ end
114
+ end
231
115
  ```
232
116
 
233
- ## Bulk Action Interaction
117
+ ## Bulk actions
234
118
 
235
- Bulk actions operate on multiple selected records. When a definition has bulk actions, the resource table automatically shows:
236
- - **Selection checkboxes** in each row
237
- - **Bulk actions toolbar** that appears when records are selected
119
+ Plural `attribute :resources` automatically becomes a bulk action. The table gets checkboxes and a bulk-actions toolbar.
238
120
 
239
121
  ```ruby
240
- class BulkPublishInteraction < ResourceInteraction
241
- presents label: "Publish Selected",
242
- icon: Phlex::TablerIcons::Send
122
+ class BulkArchiveInteraction < ResourceInteraction
123
+ presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
243
124
 
244
- # Note: plural 'resources' for bulk actions
245
125
  attribute :resources
246
126
 
247
- private
248
-
249
127
  def execute
250
- count = resources.update_all(
251
- published: true,
252
- published_at: Time.current
253
- )
254
-
255
- succeed(resources)
256
- .with_message("#{count} posts published")
128
+ resources.update_all(archived: true)
129
+ succeed(resources).with_message("Archived #{resources.size} records.")
257
130
  end
258
131
  end
259
132
  ```
260
133
 
261
- Register in your definition:
134
+ Policy checked **per record** (fails the whole request if any record is unauthorized):
262
135
 
263
136
  ```ruby
264
- class PostDefinition < ResourceDefinition
265
- action :bulk_publish, interaction: BulkPublishInteraction
266
- # bulk_action: true is automatically inferred from `resources` attribute
137
+ def bulk_archive?
138
+ create? && !record.locked?
267
139
  end
268
140
  ```
269
141
 
270
- Add the policy method (checked per-record):
271
-
272
- ```ruby
273
- class PostPolicy < ResourcePolicy
274
- def bulk_publish?
275
- # Can use record attributes - checked for each selected record
276
- user.admin? || record.author == user
277
- end
278
- end
279
- ```
142
+ The UI only shows bulk actions ALL selected records support.
280
143
 
281
- ::: tip Bulk Action Authorization
282
- Bulk actions use **per-record authorization**:
283
- - The policy method (e.g., `bulk_publish?`) is checked for **each selected record** - you can use `record` attributes
284
- - Backend rejects the entire request if any record fails authorization
285
- - UI only shows actions that **all** selected records support (buttons hide dynamically as you select)
286
- - Records are fetched from `current_authorized_scope` - only accessible records can be selected
287
- :::
144
+ ## Resource action (no specific record)
288
145
 
289
- ## Resource Action (No Record)
146
+ Neither `:resource` nor `:resources` → resource action on the index page:
290
147
 
291
148
  ```ruby
292
149
  class ImportInteraction < ResourceInteraction
293
- presents label: "Import CSV",
294
- icon: Phlex::TablerIcons::Upload
150
+ presents label: "Import CSV", icon: Phlex::TablerIcons::Upload
295
151
 
296
- # No :resource or :resources = resource action
297
152
  attribute :file
298
-
299
153
  input :file, as: :file
300
-
301
154
  validates :file, presence: true
302
155
 
303
- private
304
-
305
156
  def execute
306
- # Import logic...
157
+ # …import logic
307
158
  succeed(nil).with_message("Import completed.")
308
159
  end
309
160
  end
310
161
  ```
311
162
 
312
- ## Action Options
163
+ ## Immediate vs form
164
+
165
+ - **Immediate** — interaction has only `:resource` / `:resources` (no extra inputs). Browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom message"` or `confirmation: false` on the action.
166
+ - **Form** — interaction has additional `attribute` / `input`. Renders modal form first; no auto-confirmation (the form is the confirmation).
167
+
168
+ ## Action options
313
169
 
314
170
  ```ruby
315
171
  action :name,
316
- interaction: MyInteraction,
317
-
318
172
  # Display
319
- label: "Custom Label", # Override interaction label
320
- icon: Phlex::TablerIcons::Star, # Override interaction icon
321
- color: :danger, # :primary, :secondary, :danger
322
-
323
- # Visibility
324
- resource_action: true, # Show on index page
325
- record_action: true, # Show on show page
326
- collection_record_action: true, # Show in table rows
327
- bulk_action: true, # For selected records
173
+ label: "Custom Label",
174
+ description: "What it does",
175
+ icon: Phlex::TablerIcons::Star,
176
+ color: :danger, # :primary, :secondary, :danger
328
177
 
329
178
  # Grouping
330
- category: :danger, # :primary, :secondary, :danger
331
- position: 50, # Order (lower = first)
179
+ category: :primary, # :primary, :secondary, :danger
180
+ position: 50,
332
181
 
333
182
  # Behavior
334
- confirmation: "Are you sure?", # Confirmation dialog
335
- turbo_frame: "_top" # Turbo frame target
183
+ confirmation: "Are you sure?",
184
+ modal: :slideover # :centered (default) or :slideover
336
185
  ```
337
186
 
338
- ## Confirmation Dialogs
187
+ Full options: [Reference › Resource › Actions › Action options](/reference/resource/actions#action-options).
339
188
 
340
- Require confirmation before executing:
189
+ ## Simple actions (navigation only)
341
190
 
342
- ```ruby
343
- action :delete,
344
- interaction: DeleteInteraction,
345
- confirmation: "Are you sure you want to delete this post?"
346
-
347
- action :bulk_delete,
348
- interaction: BulkDeleteInteraction,
349
- confirmation: "Delete all selected posts? This cannot be undone."
350
- ```
351
-
352
- ## Interaction Outcomes
353
-
354
- ### Success
355
-
356
- ::: tip Automatic Redirect
357
- On success, the controller automatically redirects to the resource. You can use `with_redirect_response` if you want a **different** destination.
358
- :::
191
+ When you just want to link somewhere:
359
192
 
360
193
  ```ruby
361
- def execute
362
- # ... do work ...
194
+ action :documentation,
195
+ label: "Docs",
196
+ route_options: {url: "https://docs.example.com"},
197
+ icon: Phlex::TablerIcons::Book,
198
+ resource_action: true
363
199
 
364
- # Basic success
365
- succeed(resource)
366
-
367
- # With message
368
- succeed(resource).with_message("Success!")
369
-
370
- # With redirect
371
- succeed(resource)
372
- .with_redirect_response(posts_path)
373
- .with_message("Post created!")
374
-
375
- # With file download
376
- succeed(resource)
377
- .with_file_response(pdf_path, filename: "invoice.pdf")
378
- end
200
+ action :reports,
201
+ route_options: {action: :reports}, # links to PostsController#reports
202
+ resource_action: true
379
203
  ```
380
204
 
381
- ### Failure
205
+ Custom routes MUST be named:
382
206
 
383
207
  ```ruby
384
- def execute
385
- # From ActiveModel errors
386
- failed(resource.errors)
387
-
388
- # With custom message
389
- failed("Something went wrong")
390
-
391
- # With specific field
392
- failed("is invalid", :email)
393
-
394
- # With hash of errors
395
- failed(email: "is invalid", name: "is required")
208
+ register_resource ::Post do
209
+ collection { get :reports, as: :reports } # `as:` is required
396
210
  end
397
211
  ```
398
212
 
399
- ### Chaining Interactions
213
+ Without `as:`, `resource_url_for` can't build the URL.
400
214
 
401
- ```ruby
402
- def execute
403
- CreateUserInteraction.call(view_context:, **user_params)
404
- .and_then { |result| SendWelcomeEmail.call(view_context:, user: result.value) }
405
- .and_then { |result| LogActivity.call(view_context:, user: result.value) }
406
- .with_message("User created and welcomed!")
407
- end
408
- ```
409
-
410
- On failure, the chain short-circuits and returns the failure immediately.
411
-
412
- ## Accessing Context
413
-
414
- Interactions have access to `current_user` and `view_context`:
415
-
416
- ```ruby
417
- class PublishPostInteraction < ResourceInteraction
418
- attribute :resource
419
-
420
- private
421
-
422
- def execute
423
- resource.update!(
424
- published: true,
425
- published_by: current_user # Built-in helper
426
- )
427
-
428
- succeed(resource)
429
- end
430
- end
431
- ```
215
+ ## Inherited actions
432
216
 
433
- For advanced access:
434
-
435
- ```ruby
436
- def execute
437
- # Access helpers via view_context
438
- view_context.controller.helpers.some_helper
439
-
440
- # Access params
441
- view_context.params
442
-
443
- succeed(resource)
444
- end
445
- ```
446
-
447
- ## Complete Example: Send Invoice
448
-
449
- ```ruby
450
- class SendInvoiceInteraction < ResourceInteraction
451
- presents label: "Send Invoice",
452
- icon: Phlex::TablerIcons::Mail,
453
- description: "Email invoice to recipient"
454
-
455
- attribute :resource # The invoice
456
- attribute :recipient_email, :string
457
- attribute :message, :text
458
- attribute :attach_pdf, :boolean, default: true
459
-
460
- input :recipient_email, as: :email, hint: "Recipient's email address"
461
- input :message, as: :text, hint: "Optional message to include"
462
- input :attach_pdf, as: :boolean
463
-
464
- validates :recipient_email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
465
-
466
- private
467
-
468
- def execute
469
- # Generate PDF if requested
470
- pdf = attach_pdf ? generate_pdf : nil
471
-
472
- # Send email
473
- InvoiceMailer.send_invoice(
474
- invoice: resource,
475
- to: recipient_email,
476
- message: message,
477
- attachment: pdf
478
- ).deliver_later
479
-
480
- # Update invoice status
481
- resource.update!(
482
- sent_at: Time.current,
483
- sent_to: recipient_email
484
- )
485
-
486
- succeed(resource)
487
- .with_message("Invoice sent to #{recipient_email}")
488
- rescue ActiveRecord::RecordInvalid => e
489
- failed(e.record.errors)
490
- end
491
-
492
- def generate_pdf
493
- InvoicePdfGenerator.new(resource).generate
494
- end
495
- end
496
- ```
497
-
498
- ## Inherited Actions
499
-
500
- Define common actions in your base definition:
217
+ Actions defined on the base `ResourceDefinition` propagate to every resource:
501
218
 
502
219
  ```ruby
503
220
  # app/definitions/resource_definition.rb
504
221
  class ResourceDefinition < Plutonium::Resource::Definition
505
- action :archive,
506
- interaction: ArchiveInteraction,
507
- color: :danger,
508
- position: 1000
509
- end
510
-
511
- # All definitions inherit the archive action
512
- class PostDefinition < ResourceDefinition
513
- # Already has :archive action
222
+ action :archive, interaction: ArchiveInteraction, color: :danger, position: 1000
514
223
  end
515
224
  ```
516
225
 
517
- ## Portal-Specific Actions
226
+ Every resource gets `:archive` automatically.
518
227
 
519
- Override or add actions for specific portals:
228
+ ## Chaining interactions
520
229
 
521
230
  ```ruby
522
- # packages/admin_portal/app/definitions/admin_portal/post_definition.rb
523
- class AdminPortal::PostDefinition < ::PostDefinition
524
- # Add admin-only actions
525
- action :feature, interaction: FeaturePostInteraction
526
- action :bulk_publish, interaction: BulkPublishInteraction
231
+ def execute
232
+ CreateUserInteraction.call(view_context:, **user_params)
233
+ .and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
234
+ .and_then { |r| LogActivity.call(view_context:, user: r.value) }
235
+ .with_message("User created and welcomed!")
527
236
  end
528
237
  ```
529
238
 
530
- ## Testing Interactions
531
-
532
- ```ruby
533
- RSpec.describe PublishPostInteraction do
534
- let(:user) { create(:user) }
535
- let(:post) { create(:post, user: user, published: false) }
536
- let(:view_context) { double(controller: double(helpers: double(current_user: user))) }
537
-
538
- subject { described_class.new(view_context: view_context, resource: post) }
539
-
540
- describe '#call' do
541
- it 'publishes the post' do
542
- result = subject.call
543
-
544
- expect(result).to be_success
545
- expect(post.reload.published?).to be true
546
- end
547
-
548
- context 'when already published' do
549
- before { post.update!(published: true) }
550
-
551
- it 'fails with error' do
552
- result = subject.call
553
-
554
- expect(result).to be_failure
555
- expect(subject.errors[:base]).to include("Post is already published")
556
- end
557
- end
558
- end
559
- end
560
- ```
239
+ The chain short-circuits on the first failure.
561
240
 
562
- ## Best Practices
241
+ ## Common issues
563
242
 
564
- 1. **Keep interactions focused** - One action per interaction
565
- 2. **Use validations** - Validate all inputs before execution
566
- 3. **Handle errors gracefully** - Rescue exceptions and return `failed()`
567
- 4. **Return meaningful messages** - Help users understand what happened
568
- 5. **Use `and_then` for chains** - Compose complex workflows from simple interactions
243
+ - **Action button missing** check the policy method (`def my_action?`). Undefined returns `false`.
244
+ - **`ActiveRecord::RecordInvalid` crashes the action** not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
245
+ - **Bulk action fails on some records** — that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
246
+ - **Confirmation prompt shows when you don't want one** pass `confirmation: false` on the action.
569
247
 
570
248
  ## Related
571
249
 
572
- - [Authorization](./authorization)
573
- - [Adding Resources](./adding-resources)
250
+ - [Reference › Resource › Actions](/reference/resource/actions) — full action options and bulk patterns
251
+ - [Reference › Behavior › Interactions](/reference/behavior/interactions) — interaction class anatomy
252
+ - [Reference › Behavior › Policies](/reference/behavior/policies) — `def <action>?` methods
253
+ - [Authorization](./authorization) — policy patterns