plutonium 0.33.1 → 0.34.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/# Plutonium: The pre-alpha demo.md +4 -2
- data/.claude/skills/assets/SKILL.md +416 -0
- data/.claude/skills/connect-resource/SKILL.md +112 -0
- data/.claude/skills/controller/SKILL.md +302 -0
- data/.claude/skills/create-resource/SKILL.md +240 -0
- data/.claude/skills/definition/SKILL.md +218 -0
- data/.claude/skills/definition-actions/SKILL.md +386 -0
- data/.claude/skills/definition-fields/SKILL.md +474 -0
- data/.claude/skills/definition-query/SKILL.md +334 -0
- data/.claude/skills/forms/SKILL.md +439 -0
- data/.claude/skills/installation/SKILL.md +300 -0
- data/.claude/skills/interaction/SKILL.md +382 -0
- data/.claude/skills/model/SKILL.md +267 -0
- data/.claude/skills/model-features/SKILL.md +286 -0
- data/.claude/skills/nested-resources/SKILL.md +274 -0
- data/.claude/skills/package/SKILL.md +191 -0
- data/.claude/skills/policy/SKILL.md +352 -0
- data/.claude/skills/portal/SKILL.md +400 -0
- data/.claude/skills/resource/SKILL.md +281 -0
- data/.claude/skills/rodauth/SKILL.md +452 -0
- data/.claude/skills/views/SKILL.md +563 -0
- data/Appraisals +46 -4
- data/CHANGELOG.md +32 -1
- data/app/assets/plutonium.css +2 -2
- data/config/brakeman.ignore +239 -0
- data/config/initializers/action_policy.rb +1 -1
- data/docs/.vitepress/config.ts +132 -47
- data/docs/concepts/architecture.md +226 -0
- data/docs/concepts/auto-detection.md +254 -0
- data/docs/concepts/index.md +61 -0
- data/docs/concepts/packages-portals.md +304 -0
- data/docs/concepts/resources.md +224 -0
- data/docs/cookbook/blog.md +412 -0
- data/docs/cookbook/index.md +289 -0
- data/docs/cookbook/saas.md +481 -0
- data/docs/getting-started/index.md +56 -0
- data/docs/getting-started/installation.md +146 -0
- data/docs/getting-started/tutorial/01-setup.md +118 -0
- data/docs/getting-started/tutorial/02-first-resource.md +180 -0
- data/docs/getting-started/tutorial/03-authentication.md +246 -0
- data/docs/getting-started/tutorial/04-authorization.md +170 -0
- data/docs/getting-started/tutorial/05-custom-actions.md +202 -0
- data/docs/getting-started/tutorial/06-nested-resources.md +147 -0
- data/docs/getting-started/tutorial/07-customizing-ui.md +254 -0
- data/docs/getting-started/tutorial/index.md +64 -0
- data/docs/guides/adding-resources.md +420 -0
- data/docs/guides/authentication.md +551 -0
- data/docs/guides/authorization.md +468 -0
- data/docs/guides/creating-packages.md +380 -0
- data/docs/guides/custom-actions.md +523 -0
- data/docs/guides/index.md +45 -0
- data/docs/guides/multi-tenancy.md +302 -0
- data/docs/guides/nested-resources.md +411 -0
- data/docs/guides/search-filtering.md +266 -0
- data/docs/guides/theming.md +321 -0
- data/docs/index.md +67 -26
- data/docs/public/CLAUDE.md +64 -21
- data/docs/reference/assets/index.md +496 -0
- data/docs/reference/controller/index.md +363 -0
- data/docs/reference/definition/actions.md +400 -0
- data/docs/reference/definition/fields.md +350 -0
- data/docs/reference/definition/index.md +252 -0
- data/docs/reference/definition/query.md +342 -0
- data/docs/reference/generators/index.md +469 -0
- data/docs/reference/index.md +49 -0
- data/docs/reference/interaction/index.md +445 -0
- data/docs/reference/model/features.md +248 -0
- data/docs/reference/model/index.md +219 -0
- data/docs/reference/policy/index.md +385 -0
- data/docs/reference/portal/index.md +382 -0
- data/docs/reference/views/forms.md +396 -0
- data/docs/reference/views/index.md +479 -0
- data/gemfiles/rails_7.gemfile +9 -2
- data/gemfiles/rails_7.gemfile.lock +146 -111
- data/gemfiles/rails_8.0.gemfile +20 -0
- data/gemfiles/rails_8.0.gemfile.lock +417 -0
- data/gemfiles/rails_8.1.gemfile +20 -0
- data/gemfiles/rails_8.1.gemfile.lock +419 -0
- data/lib/generators/pu/gem/dotenv/templates/.env +2 -0
- data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -1
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +13 -16
- data/lib/generators/pu/pkg/portal/USAGE +65 -0
- data/lib/generators/pu/pkg/portal/portal_generator.rb +22 -9
- data/lib/generators/pu/res/conn/USAGE +71 -0
- data/lib/generators/pu/res/model/USAGE +106 -110
- data/lib/generators/pu/res/model/templates/model.rb.tt +6 -2
- data/lib/generators/pu/res/scaffold/USAGE +85 -0
- data/lib/generators/pu/rodauth/install_generator.rb +2 -6
- data/lib/generators/pu/rodauth/templates/config/initializers/url_options.rb +17 -0
- data/lib/generators/pu/skills/sync/USAGE +14 -0
- data/lib/generators/pu/skills/sync/sync_generator.rb +66 -0
- data/lib/plutonium/action_policy/sti_policy_lookup.rb +1 -1
- data/lib/plutonium/core/controller.rb +2 -2
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +2 -2
- data/lib/plutonium/query/adhoc_block.rb +6 -2
- data/lib/plutonium/query/model_scope.rb +1 -1
- data/lib/plutonium/railtie.rb +4 -0
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +1 -1
- data/lib/plutonium/resource/query_object.rb +38 -8
- data/lib/plutonium/ui/table/components/scopes_bar.rb +39 -34
- data/lib/plutonium/version.rb +1 -1
- data/lib/tasks/release.rake +19 -4
- data/package.json +1 -1
- metadata +76 -39
- data/brakeman.ignore +0 -28
- data/docs/api-examples.md +0 -49
- data/docs/guide/claude-code-guide.md +0 -74
- data/docs/guide/deep-dive/authorization.md +0 -189
- data/docs/guide/deep-dive/multitenancy.md +0 -256
- data/docs/guide/deep-dive/resources.md +0 -390
- data/docs/guide/getting-started/01-installation.md +0 -165
- data/docs/guide/index.md +0 -28
- data/docs/guide/introduction/01-what-is-plutonium.md +0 -211
- data/docs/guide/introduction/02-core-concepts.md +0 -440
- data/docs/guide/tutorial/01-project-setup.md +0 -75
- data/docs/guide/tutorial/02-creating-a-feature-package.md +0 -45
- data/docs/guide/tutorial/03-defining-resources.md +0 -90
- data/docs/guide/tutorial/04-creating-a-portal.md +0 -101
- data/docs/guide/tutorial/05-customizing-the-ui.md +0 -128
- data/docs/guide/tutorial/06-adding-custom-actions.md +0 -101
- data/docs/guide/tutorial/07-implementing-authorization.md +0 -90
- data/docs/markdown-examples.md +0 -85
- data/docs/modules/action.md +0 -244
- data/docs/modules/authentication.md +0 -236
- data/docs/modules/configuration.md +0 -599
- data/docs/modules/controller.md +0 -443
- data/docs/modules/core.md +0 -316
- data/docs/modules/definition.md +0 -1308
- data/docs/modules/display.md +0 -759
- data/docs/modules/form.md +0 -495
- data/docs/modules/generator.md +0 -400
- data/docs/modules/index.md +0 -167
- data/docs/modules/interaction.md +0 -642
- data/docs/modules/package.md +0 -151
- data/docs/modules/policy.md +0 -176
- data/docs/modules/portal.md +0 -710
- data/docs/modules/query.md +0 -297
- data/docs/modules/resource_record.md +0 -618
- data/docs/modules/routing.md +0 -690
- data/docs/modules/table.md +0 -301
- data/docs/modules/ui.md +0 -631
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
# Interaction Reference
|
|
2
|
+
|
|
3
|
+
Complete reference for business logic Interactions.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Interactions encapsulate business logic for custom actions. They:
|
|
8
|
+
- Accept input from users
|
|
9
|
+
- Validate that input
|
|
10
|
+
- Execute business logic
|
|
11
|
+
- Return success or failure outcomes
|
|
12
|
+
|
|
13
|
+
## Base Class
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
# app/interactions/resource_interaction.rb (generated during install)
|
|
17
|
+
class ResourceInteraction < Plutonium::Resource::Interaction
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# app/interactions/publish_post_interaction.rb
|
|
21
|
+
class PublishPostInteraction < ResourceInteraction
|
|
22
|
+
# Interaction code
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Presentation
|
|
27
|
+
|
|
28
|
+
Configure how the action appears in the UI:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
class PublishPost < Plutonium::Resource::Interaction
|
|
32
|
+
presents label: "Publish Post",
|
|
33
|
+
icon: Phlex::TablerIcons::Send,
|
|
34
|
+
description: "Make this post visible to the public"
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Access presentation metadata:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
PublishPost.label # => "Publish Post"
|
|
42
|
+
PublishPost.icon # => Phlex::TablerIcons::Send
|
|
43
|
+
PublishPost.description # => "Make this post visible..."
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Attributes
|
|
47
|
+
|
|
48
|
+
Define inputs using ActiveModel attributes:
|
|
49
|
+
|
|
50
|
+
### Basic Types
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
attribute :title, :string
|
|
54
|
+
attribute :count, :integer
|
|
55
|
+
attribute :price, :decimal
|
|
56
|
+
attribute :active, :boolean
|
|
57
|
+
attribute :published_at, :datetime
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### With Defaults
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
attribute :status, :string, default: "pending"
|
|
64
|
+
attribute :notify, :boolean, default: true
|
|
65
|
+
attribute :count, :integer, default: 1
|
|
66
|
+
attribute :created_at, :datetime, default: -> { Time.current }
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### The resource Attribute
|
|
70
|
+
|
|
71
|
+
For record actions, declare a `resource` attribute:
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
class PublishPost < Plutonium::Resource::Interaction
|
|
75
|
+
attribute :resource # The record being acted upon
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def execute
|
|
80
|
+
resource.update!(published: true)
|
|
81
|
+
succeed(resource)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### The resources Attribute
|
|
87
|
+
|
|
88
|
+
For bulk actions, declare a `resources` attribute:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
class BulkArchive < Plutonium::Resource::Interaction
|
|
92
|
+
attribute :resources # Collection of records
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def execute
|
|
97
|
+
resources.update_all(archived: true)
|
|
98
|
+
succeed(resources)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Form Inputs
|
|
104
|
+
|
|
105
|
+
Define how attributes render in forms using the `input` method:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
class InviteUser < Plutonium::Resource::Interaction
|
|
109
|
+
attribute :resource
|
|
110
|
+
attribute :email, :string
|
|
111
|
+
attribute :role, :string
|
|
112
|
+
|
|
113
|
+
input :email, as: :email
|
|
114
|
+
input :role, as: :select, choices: %w[admin member viewer]
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
See [Fields Reference](/reference/definition/fields) for all input types and options.
|
|
119
|
+
|
|
120
|
+
## Validation
|
|
121
|
+
|
|
122
|
+
Use standard ActiveModel validations:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class SchedulePost < Plutonium::Resource::Interaction
|
|
126
|
+
attribute :resource
|
|
127
|
+
attribute :publish_at, :datetime
|
|
128
|
+
|
|
129
|
+
validates :publish_at, presence: true
|
|
130
|
+
validate :publish_at_in_future
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def publish_at_in_future
|
|
135
|
+
if publish_at.present? && publish_at <= Time.current
|
|
136
|
+
errors.add(:publish_at, "must be in the future")
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Validations run automatically before `execute`. If invalid, returns a failure outcome.
|
|
143
|
+
|
|
144
|
+
## The execute Method
|
|
145
|
+
|
|
146
|
+
Main logic goes here. Must return an outcome using `succeed()` or `failed()`:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def execute
|
|
152
|
+
resource.update!(published: true)
|
|
153
|
+
succeed(resource).with_message("Published!")
|
|
154
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
155
|
+
failed(e.record.errors)
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Constructor
|
|
160
|
+
|
|
161
|
+
Interactions require `view_context:` and accept attributes as keyword arguments:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
interaction = PublishPost.new(
|
|
165
|
+
view_context: view_context,
|
|
166
|
+
resource: post,
|
|
167
|
+
notify: true
|
|
168
|
+
)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The controller handles this automatically for interactive actions.
|
|
172
|
+
|
|
173
|
+
## Calling Interactions
|
|
174
|
+
|
|
175
|
+
### Via call Class Method
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
outcome = PublishPost.call(view_context: view_context, resource: post)
|
|
179
|
+
|
|
180
|
+
if outcome.success?
|
|
181
|
+
# Handle success
|
|
182
|
+
else
|
|
183
|
+
# Handle failure
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Via call Instance Method
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
interaction = PublishPost.new(view_context: view_context, resource: post)
|
|
191
|
+
outcome = interaction.call
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Success Outcomes
|
|
195
|
+
|
|
196
|
+
### Basic Success
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
succeed(resource)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
### With Message
|
|
203
|
+
|
|
204
|
+
```ruby
|
|
205
|
+
succeed(resource).with_message("Post published!")
|
|
206
|
+
succeed(resource).with_message("Warning: limited visibility", :alert)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### With Redirect
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
succeed(resource).with_redirect_response(posts_path)
|
|
213
|
+
succeed(resource).with_redirect_response(resource, status: :see_other)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### With File Download
|
|
217
|
+
|
|
218
|
+
```ruby
|
|
219
|
+
succeed(resource).with_file_response(file_path, filename: "report.pdf")
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### With Render
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
succeed(resource).with_render_response(:custom_template)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Chaining
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
succeed(resource)
|
|
232
|
+
.with_message("Created!")
|
|
233
|
+
.with_redirect_response(edit_post_path(resource))
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## Failure Outcomes
|
|
237
|
+
|
|
238
|
+
### Simple Failure
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
failed("Cannot publish draft posts")
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### With Attribute
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
failed("is invalid", :email)
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### With Hash of Errors
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
failed(email: "is invalid", name: "is required")
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
### With ActiveModel Errors
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
failed(resource.errors)
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Manual Error Addition
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
def execute
|
|
266
|
+
errors.add(:base, "Post must have content")
|
|
267
|
+
return failure if errors.any?
|
|
268
|
+
|
|
269
|
+
# Continue...
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Chaining Interactions
|
|
274
|
+
|
|
275
|
+
Use `and_then` to chain operations. On failure, the chain short-circuits:
|
|
276
|
+
|
|
277
|
+
```ruby
|
|
278
|
+
def execute
|
|
279
|
+
CreateUserInteraction.call(view_context:, **user_params)
|
|
280
|
+
.and_then { |result| SendWelcomeEmail.call(view_context:, user: result.value) }
|
|
281
|
+
.and_then { |result| LogActivity.call(view_context:, user: result.value) }
|
|
282
|
+
.with_message("User created and welcomed!")
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Accessing Current User
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
def execute
|
|
290
|
+
resource.update!(updated_by: current_user)
|
|
291
|
+
succeed(resource)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# current_user is provided by the base class:
|
|
295
|
+
# def current_user
|
|
296
|
+
# view_context.controller.helpers.current_user
|
|
297
|
+
# end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Complete Example
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
304
|
+
presents label: "Invite User",
|
|
305
|
+
icon: Phlex::TablerIcons::UserPlus,
|
|
306
|
+
description: "Send an invitation email"
|
|
307
|
+
|
|
308
|
+
attribute :resource # The company
|
|
309
|
+
attribute :email, :string
|
|
310
|
+
attribute :role, :string
|
|
311
|
+
|
|
312
|
+
input :email, as: :email
|
|
313
|
+
input :role, as: :select, choices: -> { UserInvite.roles.keys }
|
|
314
|
+
|
|
315
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
316
|
+
validates :role, presence: true, inclusion: { in: UserInvite.roles.keys }
|
|
317
|
+
validate :not_already_invited
|
|
318
|
+
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
def execute
|
|
322
|
+
invite = UserInvite.create!(
|
|
323
|
+
company: resource,
|
|
324
|
+
email: email,
|
|
325
|
+
role: role,
|
|
326
|
+
invited_by: current_user
|
|
327
|
+
)
|
|
328
|
+
UserInviteMailer.invitation(invite).deliver_later
|
|
329
|
+
|
|
330
|
+
succeed(resource)
|
|
331
|
+
.with_message("Invitation sent to #{email}")
|
|
332
|
+
.with_redirect_response(resource)
|
|
333
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
334
|
+
failed(e.record.errors)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def not_already_invited
|
|
338
|
+
return unless email.present?
|
|
339
|
+
|
|
340
|
+
if UserInvite.exists?(company: resource, email: email, state: :pending)
|
|
341
|
+
errors.add(:email, "already has a pending invitation")
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## Connecting to Definitions
|
|
348
|
+
|
|
349
|
+
Register interactions as actions in definitions:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
class PostDefinition < Plutonium::Resource::Definition
|
|
353
|
+
action :publish, interaction: PublishPostInteraction
|
|
354
|
+
action :invite_user, interaction: InviteUserInteraction
|
|
355
|
+
|
|
356
|
+
action :archive,
|
|
357
|
+
interaction: ArchiveInteraction,
|
|
358
|
+
confirmation: "Are you sure?",
|
|
359
|
+
category: :danger,
|
|
360
|
+
position: 100
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## Immediate vs Form Actions
|
|
365
|
+
|
|
366
|
+
Plutonium determines if an action needs a form based on whether inputs are defined:
|
|
367
|
+
|
|
368
|
+
**Shows form first** (has inputs):
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
class InviteUserInteraction < Plutonium::Resource::Interaction
|
|
372
|
+
attribute :resource
|
|
373
|
+
attribute :email
|
|
374
|
+
input :email # This triggers form display
|
|
375
|
+
end
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
**Executes immediately** (no inputs):
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
class ArchiveInteraction < Plutonium::Resource::Interaction
|
|
382
|
+
attribute :resource
|
|
383
|
+
# No inputs = immediate execution with confirmation
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
## Policy Integration
|
|
388
|
+
|
|
389
|
+
Control access with policy methods matching the action name:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
class PostPolicy < Plutonium::Resource::Policy
|
|
393
|
+
def publish?
|
|
394
|
+
update? && record.draft?
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def archive?
|
|
398
|
+
destroy? && !record.archived?
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
## Testing
|
|
404
|
+
|
|
405
|
+
```ruby
|
|
406
|
+
RSpec.describe PublishPost do
|
|
407
|
+
let(:view_context) { double("view_context", controller: double(helpers: double(current_user: user))) }
|
|
408
|
+
let(:user) { create(:user) }
|
|
409
|
+
let(:post) { create(:post, user: user, published: false) }
|
|
410
|
+
|
|
411
|
+
describe '#call' do
|
|
412
|
+
it 'publishes the post' do
|
|
413
|
+
interaction = described_class.new(view_context: view_context, resource: post)
|
|
414
|
+
outcome = interaction.call
|
|
415
|
+
|
|
416
|
+
expect(outcome).to be_success
|
|
417
|
+
expect(post.reload).to be_published
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
context 'when validation fails' do
|
|
421
|
+
it 'returns failure outcome' do
|
|
422
|
+
interaction = described_class.new(view_context: view_context, resource: nil)
|
|
423
|
+
outcome = interaction.call
|
|
424
|
+
|
|
425
|
+
expect(outcome).to be_failure
|
|
426
|
+
end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
## Best Practices
|
|
433
|
+
|
|
434
|
+
1. **Keep interactions focused** - One action per interaction
|
|
435
|
+
2. **Use validations** - Validate all inputs before execution
|
|
436
|
+
3. **Handle errors gracefully** - Rescue exceptions and return `failed()`
|
|
437
|
+
4. **Return meaningful messages** - Help users understand what happened
|
|
438
|
+
5. **Use `and_then` for chains** - Compose complex workflows from simple interactions
|
|
439
|
+
6. **Declare attributes explicitly** - Always declare `resource` or `resources` attributes
|
|
440
|
+
|
|
441
|
+
## Related
|
|
442
|
+
|
|
443
|
+
- [Actions Reference](/reference/definition/actions) - Connecting interactions to definitions
|
|
444
|
+
- [Fields Reference](/reference/definition/fields) - Input configuration
|
|
445
|
+
- [Policy Reference](/reference/policy/) - Authorization
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Model Features
|
|
2
|
+
|
|
3
|
+
Features provided by `Plutonium::Resource::Record`.
|
|
4
|
+
|
|
5
|
+
## has_cents
|
|
6
|
+
|
|
7
|
+
Store monetary values as integers (cents) while exposing decimal accessors.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class Product < ResourceRecord
|
|
11
|
+
# Column: price_cents (integer)
|
|
12
|
+
# Generates: price (decimal accessor)
|
|
13
|
+
has_cents :price_cents
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
### Usage
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
product = Product.new
|
|
21
|
+
product.price = 19.99
|
|
22
|
+
product.price_cents # => 1999
|
|
23
|
+
|
|
24
|
+
product.price_cents = 2500
|
|
25
|
+
product.price # => 25.0
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Options
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
class Order < ResourceRecord
|
|
32
|
+
# Default: rate 100 (cents to dollars)
|
|
33
|
+
has_cents :subtotal_cents
|
|
34
|
+
|
|
35
|
+
# Custom name for the accessor
|
|
36
|
+
has_cents :cost_cents, name: :wholesale_price
|
|
37
|
+
# cost_cents column, wholesale_price accessor
|
|
38
|
+
|
|
39
|
+
# Yen or other currencies without subunits (rate: 1)
|
|
40
|
+
has_cents :price_yen, name: :price_jpy, rate: 1
|
|
41
|
+
|
|
42
|
+
# Higher precision (e.g., 1000 units per dollar)
|
|
43
|
+
has_cents :amount_cents, rate: 1000
|
|
44
|
+
|
|
45
|
+
# Custom suffix when name matches column pattern
|
|
46
|
+
has_cents :total_cents, suffix: "value"
|
|
47
|
+
# Generates: total_value accessor
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Validation Inheritance
|
|
52
|
+
|
|
53
|
+
Validations on the cents column propagate to the decimal accessor:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
class Product < ResourceRecord
|
|
57
|
+
has_cents :price_cents
|
|
58
|
+
validates :price_cents, numericality: { greater_than_or_equal_to: 0 }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
product = Product.new(price: -10)
|
|
62
|
+
product.valid? # => false
|
|
63
|
+
product.errors[:price_cents] # => ["must be greater than or equal to 0"]
|
|
64
|
+
product.errors[:price] # => ["is invalid"]
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Reflection
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
Product.has_cents_attributes
|
|
71
|
+
# => { price_cents: { name: :price, rate: 100 } }
|
|
72
|
+
|
|
73
|
+
Product.has_cents_attribute?(:price_cents) # => true
|
|
74
|
+
Product.has_cents_attribute?(:name) # => false
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Labeling
|
|
78
|
+
|
|
79
|
+
The `to_label` method provides a human-readable representation for dropdowns and displays:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
post.to_label # => "My Post Title"
|
|
83
|
+
user.to_label # => "John Doe"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Resolution Order
|
|
87
|
+
|
|
88
|
+
1. Returns `name` attribute if present
|
|
89
|
+
2. Returns `title` attribute if present
|
|
90
|
+
3. Falls back to `"ModelName #id"`
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
class Post < ResourceRecord
|
|
94
|
+
# Has title column
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
post = Post.new(title: "Hello World")
|
|
98
|
+
post.to_label # => "Hello World"
|
|
99
|
+
|
|
100
|
+
post.title = nil
|
|
101
|
+
post.to_label # => "Post #123"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Route Parameters
|
|
105
|
+
|
|
106
|
+
Customize how records appear in URLs.
|
|
107
|
+
|
|
108
|
+
### Static Parameter
|
|
109
|
+
|
|
110
|
+
Use a specific column for URLs:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
class Post < ResourceRecord
|
|
114
|
+
path_parameter :slug
|
|
115
|
+
end
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
post = Post.create(slug: "hello-world")
|
|
120
|
+
post.to_param # => "hello-world"
|
|
121
|
+
|
|
122
|
+
# URL: /posts/hello-world
|
|
123
|
+
Post.from_path_param("hello-world") # Finds by slug
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Dynamic Parameter
|
|
127
|
+
|
|
128
|
+
Combine ID with a readable slug:
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class Post < ResourceRecord
|
|
132
|
+
dynamic_path_parameter :title
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
post = Post.create(id: 42, title: "Hello World")
|
|
138
|
+
post.to_param # => "42-hello-world"
|
|
139
|
+
|
|
140
|
+
# URL: /posts/42-hello-world
|
|
141
|
+
Post.from_path_param("42-hello-world") # Extracts ID, finds by id
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Secure Association SGIDs
|
|
145
|
+
|
|
146
|
+
Associations automatically get Signed Global ID accessors for secure form handling.
|
|
147
|
+
|
|
148
|
+
### Singular Associations (belongs_to, has_one)
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
class Post < ResourceRecord
|
|
152
|
+
belongs_to :author
|
|
153
|
+
end
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Generates:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
post.author_sgid # => SignedGlobalID for the author
|
|
160
|
+
post.author_sgid = sgid # Locates and assigns author from SGID
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Collection Associations (has_many)
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
class Post < ResourceRecord
|
|
167
|
+
has_many :tags
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Generates:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
post.tag_sgids # => Array of SignedGlobalIDs
|
|
175
|
+
post.tag_sgids = [sgid1, sgid2] # Locates and assigns tags from SGIDs
|
|
176
|
+
post.add_tag_sgid(sgid) # Add a single tag by SGID
|
|
177
|
+
post.remove_tag_sgid(sgid) # Remove a single tag by SGID
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Use Case
|
|
181
|
+
|
|
182
|
+
These methods enable secure association inputs in forms without exposing database IDs:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
# In form
|
|
186
|
+
f.secure_association_tag # Uses SGIDs instead of IDs
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## associated_with Scope
|
|
190
|
+
|
|
191
|
+
Finds records associated with a given parent. Used internally for nested resource scoping.
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
Comment.associated_with(post) # Comments belonging to post
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Resolution Order
|
|
198
|
+
|
|
199
|
+
1. Checks for custom scope: `associated_with_#{model_name}`
|
|
200
|
+
2. Finds direct association from self to record
|
|
201
|
+
3. Finds reverse association from record to self (with performance warning)
|
|
202
|
+
4. Raises error with helpful message
|
|
203
|
+
|
|
204
|
+
### Custom Scope
|
|
205
|
+
|
|
206
|
+
For complex relationships, define a named scope:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
class Comment < ResourceRecord
|
|
210
|
+
# Comments belong to posts, which belong to organizations
|
|
211
|
+
scope :associated_with_organization, ->(org) {
|
|
212
|
+
joins(:post).where(posts: { organization_id: org.id })
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Field Name Introspection
|
|
218
|
+
|
|
219
|
+
Class methods for discovering model fields by type:
|
|
220
|
+
|
|
221
|
+
```ruby
|
|
222
|
+
Post.resource_field_names # All fields suitable for forms/displays
|
|
223
|
+
Post.content_column_field_names # Database content columns
|
|
224
|
+
Post.belongs_to_association_field_names # belongs_to associations
|
|
225
|
+
Post.has_one_association_field_names # has_one associations (excluding attachments)
|
|
226
|
+
Post.has_many_association_field_names # has_many associations (excluding attachments)
|
|
227
|
+
Post.has_one_attached_field_names # Single file attachments
|
|
228
|
+
Post.has_many_attached_field_names # Multiple file attachments
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
These methods are cached in non-local environments for performance.
|
|
232
|
+
|
|
233
|
+
## Nested Attributes Introspection
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
Post.all_nested_attributes_options
|
|
237
|
+
# => {
|
|
238
|
+
# comments: { allow_destroy: true, limit: 10, macro: :has_many, class: Comment },
|
|
239
|
+
# metadata: { update_only: true, macro: :has_one, class: PostMetadata }
|
|
240
|
+
# }
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Returns configuration for all associations with `accepts_nested_attributes_for`.
|
|
244
|
+
|
|
245
|
+
## Related
|
|
246
|
+
|
|
247
|
+
- [Model Reference](./index)
|
|
248
|
+
- [Nested Resources Guide](/guides/nested-resources)
|