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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -1,108 +1,47 @@
|
|
|
1
1
|
# Custom Actions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Add buttons beyond CRUD — Publish, Archive, Import, Send invitation, Bulk-update, etc.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Goal
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
- **Interactive** - Execute business logic with optional user input
|
|
9
|
+
## Two flavors
|
|
11
10
|
|
|
12
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
18
|
+
## Quick recipe — interactive action
|
|
22
19
|
|
|
23
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
+
The "Publish" button appears in the toolbar. Clicking it shows a "Publish?" confirmation, then runs.
|
|
172
67
|
|
|
173
|
-
|
|
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
|
-
|
|
70
|
+
For `interaction:`-based actions, visibility flags are inferred from the interaction:
|
|
180
71
|
|
|
181
|
-
|
|
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
|
-
-
|
|
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
|
-
#
|
|
188
|
-
|
|
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
|
-
#
|
|
195
|
-
|
|
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
|
-
|
|
88
|
+
For simple navigation actions (no `interaction:`), declare flags manually.
|
|
202
89
|
|
|
203
|
-
|
|
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
|
-
|
|
92
|
+
If the interaction declares extra `attribute`/`input`, a modal form is rendered first:
|
|
213
93
|
|
|
214
94
|
```ruby
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
```
|
|
95
|
+
class Company::InviteUserInteraction < ResourceInteraction
|
|
96
|
+
presents label: "Invite User", icon: Phlex::TablerIcons::Mail
|
|
218
97
|
|
|
219
|
-
|
|
98
|
+
attribute :resource # the company
|
|
99
|
+
attribute :email
|
|
100
|
+
attribute :role
|
|
220
101
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
action :bulk_archive, interaction: BulkArchiveInteraction
|
|
224
|
-
```
|
|
102
|
+
input :email, as: :email
|
|
103
|
+
input :role, as: :select, choices: %w[admin member]
|
|
225
104
|
|
|
226
|
-
|
|
105
|
+
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
106
|
+
validates :role, presence: true
|
|
227
107
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
117
|
+
## Bulk actions
|
|
234
118
|
|
|
235
|
-
|
|
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
|
|
241
|
-
presents label: "
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
134
|
+
Policy — checked **per record** (fails the whole request if any record is unauthorized):
|
|
262
135
|
|
|
263
136
|
```ruby
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
157
|
+
# …import logic
|
|
307
158
|
succeed(nil).with_message("Import completed.")
|
|
308
159
|
end
|
|
309
160
|
end
|
|
310
161
|
```
|
|
311
162
|
|
|
312
|
-
##
|
|
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:
|
|
320
|
-
|
|
321
|
-
|
|
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: :
|
|
331
|
-
position: 50,
|
|
179
|
+
category: :primary, # :primary, :secondary, :danger
|
|
180
|
+
position: 50,
|
|
332
181
|
|
|
333
182
|
# Behavior
|
|
334
|
-
confirmation: "Are you sure?",
|
|
335
|
-
|
|
183
|
+
confirmation: "Are you sure?",
|
|
184
|
+
modal: :slideover # :centered (default) or :slideover
|
|
336
185
|
```
|
|
337
186
|
|
|
338
|
-
|
|
187
|
+
Full options: [Reference › Resource › Actions › Action options](/reference/resource/actions#action-options).
|
|
339
188
|
|
|
340
|
-
|
|
189
|
+
## Simple actions (navigation only)
|
|
341
190
|
|
|
342
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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
|
-
|
|
365
|
-
|
|
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
|
-
|
|
205
|
+
Custom routes MUST be named:
|
|
382
206
|
|
|
383
207
|
```ruby
|
|
384
|
-
|
|
385
|
-
#
|
|
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
|
-
|
|
213
|
+
Without `as:`, `resource_url_for` can't build the URL.
|
|
400
214
|
|
|
401
|
-
|
|
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
|
-
|
|
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
|
-
|
|
226
|
+
Every resource gets `:archive` automatically.
|
|
518
227
|
|
|
519
|
-
|
|
228
|
+
## Chaining interactions
|
|
520
229
|
|
|
521
230
|
```ruby
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
241
|
+
## Common issues
|
|
563
242
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
- [
|
|
573
|
-
- [
|
|
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
|