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,382 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: interaction
|
|
3
|
+
description: Plutonium interactions - encapsulated business logic for custom actions
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Interactions
|
|
7
|
+
|
|
8
|
+
Interactions encapsulate business logic into reusable, testable units. They handle input validation, execution, and outcomes.
|
|
9
|
+
|
|
10
|
+
## Basic Structure
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# app/interactions/resource_interaction.rb (generated during install)
|
|
14
|
+
class ResourceInteraction < Plutonium::Resource::Interaction
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# app/interactions/publish_post_interaction.rb
|
|
18
|
+
class PublishPostInteraction < ResourceInteraction
|
|
19
|
+
# Presentation
|
|
20
|
+
presents label: "Publish",
|
|
21
|
+
icon: Phlex::TablerIcons::Send,
|
|
22
|
+
description: "Make this post public"
|
|
23
|
+
|
|
24
|
+
# Attributes (inputs)
|
|
25
|
+
attribute :resource # The record being acted upon
|
|
26
|
+
attribute :publish_date, :datetime, default: -> { Time.current }
|
|
27
|
+
|
|
28
|
+
# Form inputs (what user sees)
|
|
29
|
+
input :publish_date, as: :datetime
|
|
30
|
+
|
|
31
|
+
# Validations
|
|
32
|
+
validates :publish_date, presence: true
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def execute
|
|
37
|
+
resource.update!(published_at: publish_date)
|
|
38
|
+
succeed(resource).with_message("Post published!")
|
|
39
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
40
|
+
failed(e.record.errors)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Attributes
|
|
46
|
+
|
|
47
|
+
Define inputs using ActiveModel attributes:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
attribute :resource # Record (for record actions)
|
|
51
|
+
attribute :resources # Collection (for bulk actions)
|
|
52
|
+
attribute :email, :string # String input
|
|
53
|
+
attribute :count, :integer, default: 1 # With default
|
|
54
|
+
attribute :active, :boolean, default: -> { true } # Callable default
|
|
55
|
+
attribute :tags, :array # Array
|
|
56
|
+
attribute :metadata, :hash # Hash
|
|
57
|
+
attribute :date, :datetime # DateTime
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Form Inputs
|
|
61
|
+
|
|
62
|
+
Define form fields with the `input` method (same as definitions):
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
input :email
|
|
66
|
+
input :role, as: :select, choices: %w[admin user]
|
|
67
|
+
input :content, as: :text
|
|
68
|
+
input :date, as: :date
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
See `definition-fields` skill for all input types and options.
|
|
72
|
+
|
|
73
|
+
## Presentation
|
|
74
|
+
|
|
75
|
+
Configure how the action appears in the UI:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
presents label: "Archive Record",
|
|
79
|
+
icon: Phlex::TablerIcons::Archive,
|
|
80
|
+
description: "Move to archive for later reference"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Access presentation:
|
|
84
|
+
```ruby
|
|
85
|
+
MyInteraction.label # => "Archive Record"
|
|
86
|
+
MyInteraction.icon # => Phlex::TablerIcons::Archive
|
|
87
|
+
MyInteraction.description # => "Move to archive..."
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Execution and Outcomes
|
|
91
|
+
|
|
92
|
+
### The execute Method
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def execute
|
|
98
|
+
# Your business logic here
|
|
99
|
+
# Must return succeed() or failed()
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Success Outcomes
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# Basic success
|
|
107
|
+
succeed(resource)
|
|
108
|
+
|
|
109
|
+
# With message
|
|
110
|
+
succeed(resource).with_message("Done!")
|
|
111
|
+
succeed(resource).with_message("Warning!", :alert)
|
|
112
|
+
|
|
113
|
+
# With redirect
|
|
114
|
+
succeed(resource).with_redirect_response(posts_path)
|
|
115
|
+
|
|
116
|
+
# With file download
|
|
117
|
+
succeed(resource).with_file_response(file_path, filename: "report.pdf")
|
|
118
|
+
|
|
119
|
+
# Chaining
|
|
120
|
+
succeed(resource)
|
|
121
|
+
.with_message("Created!")
|
|
122
|
+
.with_redirect_response(resource_path(resource))
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Failure Outcomes
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
# Basic failure
|
|
129
|
+
failed("Something went wrong")
|
|
130
|
+
|
|
131
|
+
# With ActiveModel errors
|
|
132
|
+
failed(resource.errors)
|
|
133
|
+
|
|
134
|
+
# With hash of errors
|
|
135
|
+
failed(email: "is invalid", name: "is required")
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Chaining Interactions
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
def execute
|
|
142
|
+
CreateUserInteraction.call(view_context:, **user_params)
|
|
143
|
+
.and_then { |result| SendWelcomeEmail.call(view_context:, user: result.value) }
|
|
144
|
+
.and_then { |result| LogActivity.call(view_context:, user: result.value) }
|
|
145
|
+
.with_message("User created and welcomed!")
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
On failure, the chain short-circuits and returns the failure immediately.
|
|
150
|
+
|
|
151
|
+
## Validations
|
|
152
|
+
|
|
153
|
+
Use standard ActiveModel validations:
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
validates :email, presence: true
|
|
157
|
+
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
158
|
+
validates :role, inclusion: { in: %w[admin user guest] }
|
|
159
|
+
|
|
160
|
+
validate :custom_validation
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
def custom_validation
|
|
165
|
+
if resource.archived?
|
|
166
|
+
errors.add(:resource, "cannot be modified when archived")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Validations run automatically before `execute`. If invalid, returns `failed()` with errors.
|
|
172
|
+
|
|
173
|
+
## Interaction Types
|
|
174
|
+
|
|
175
|
+
### Record Actions
|
|
176
|
+
|
|
177
|
+
Act on a single record:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
class ArchiveInteraction < Plutonium::Resource::Interaction
|
|
181
|
+
attribute :resource # Single record
|
|
182
|
+
|
|
183
|
+
def execute
|
|
184
|
+
resource.update!(archived: true)
|
|
185
|
+
succeed(resource)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Resource Actions
|
|
191
|
+
|
|
192
|
+
Act at the collection/class level (no specific record):
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
class ImportInteraction < Plutonium::Resource::Interaction
|
|
196
|
+
# No :resource attribute
|
|
197
|
+
attribute :file
|
|
198
|
+
|
|
199
|
+
input :file, as: :file
|
|
200
|
+
|
|
201
|
+
def execute
|
|
202
|
+
records = CSV.parse(file)
|
|
203
|
+
Post.import(records)
|
|
204
|
+
succeed(records)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Bulk Actions (Multiple Records)
|
|
210
|
+
|
|
211
|
+
Act on multiple selected records:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
class BulkArchiveInteraction < Plutonium::Resource::Interaction
|
|
215
|
+
attribute :resources # Collection of records
|
|
216
|
+
|
|
217
|
+
def execute
|
|
218
|
+
resources.update_all(archived: true)
|
|
219
|
+
succeed(resources).with_message("Archived #{resources.count} records")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Connecting to Definitions
|
|
225
|
+
|
|
226
|
+
Register interactions as actions:
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
class PostDefinition < ResourceDefinition
|
|
230
|
+
# Record action (shows on individual records)
|
|
231
|
+
action :publish, interaction: PublishPostInteraction
|
|
232
|
+
|
|
233
|
+
# Resource action (shows at collection level)
|
|
234
|
+
action :import, interaction: ImportInteraction
|
|
235
|
+
|
|
236
|
+
# With options
|
|
237
|
+
action :archive,
|
|
238
|
+
interaction: ArchiveInteraction,
|
|
239
|
+
confirmation: "Are you sure?",
|
|
240
|
+
category: :danger,
|
|
241
|
+
position: 100
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Action Options
|
|
246
|
+
|
|
247
|
+
| Option | Description |
|
|
248
|
+
|--------|-------------|
|
|
249
|
+
| `interaction:` | The interaction class |
|
|
250
|
+
| `confirmation:` | Confirmation message before execution |
|
|
251
|
+
| `category:` | `:primary`, `:secondary`, `:danger` |
|
|
252
|
+
| `position:` | Display order (lower = first) |
|
|
253
|
+
| `turbo_frame:` | Turbo frame target (default: `remote_modal`) |
|
|
254
|
+
| `icon:` | Override interaction icon |
|
|
255
|
+
| `label:` | Override interaction label |
|
|
256
|
+
|
|
257
|
+
## Policy Integration
|
|
258
|
+
|
|
259
|
+
Control access with policy methods:
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
class PostPolicy < ResourcePolicy
|
|
263
|
+
def publish?
|
|
264
|
+
update? && record.draft?
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def archive?
|
|
268
|
+
destroy? && !record.archived?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def import?
|
|
272
|
+
create? # Resource-level action
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
The policy method name matches the action name with `?`.
|
|
278
|
+
|
|
279
|
+
## Accessing Context
|
|
280
|
+
|
|
281
|
+
Inside interactions:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
def execute
|
|
285
|
+
# Access current user via view_context
|
|
286
|
+
current_user = view_context.controller.helpers.current_user
|
|
287
|
+
|
|
288
|
+
# Access the resource
|
|
289
|
+
resource.update!(updated_by: current_user)
|
|
290
|
+
|
|
291
|
+
succeed(resource)
|
|
292
|
+
end
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## Immediate vs Form Actions
|
|
296
|
+
|
|
297
|
+
Plutonium automatically determines if an action needs a form:
|
|
298
|
+
|
|
299
|
+
- **Has inputs defined** → Shows form first (GET), then executes (POST)
|
|
300
|
+
- **No inputs** → Executes immediately (POST with confirmation)
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
# Shows form (has inputs)
|
|
304
|
+
class InviteUserInteraction < Plutonium::Resource::Interaction
|
|
305
|
+
attribute :resource
|
|
306
|
+
attribute :email
|
|
307
|
+
input :email # This triggers form display
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Immediate execution (no inputs)
|
|
311
|
+
class ArchiveInteraction < Plutonium::Resource::Interaction
|
|
312
|
+
attribute :resource
|
|
313
|
+
# No inputs = immediate with confirmation
|
|
314
|
+
end
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Complete Example
|
|
318
|
+
|
|
319
|
+
```ruby
|
|
320
|
+
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
321
|
+
presents label: "Invite User",
|
|
322
|
+
icon: Phlex::TablerIcons::UserPlus,
|
|
323
|
+
description: "Send an invitation email"
|
|
324
|
+
|
|
325
|
+
attribute :resource # The company
|
|
326
|
+
attribute :email, :string
|
|
327
|
+
attribute :role, :string
|
|
328
|
+
|
|
329
|
+
input :email
|
|
330
|
+
input :role, as: :select, choices: -> { UserInvite.roles.keys }
|
|
331
|
+
|
|
332
|
+
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
333
|
+
validates :role, presence: true, inclusion: { in: UserInvite.roles.keys }
|
|
334
|
+
validate :not_already_invited
|
|
335
|
+
|
|
336
|
+
private
|
|
337
|
+
|
|
338
|
+
def execute
|
|
339
|
+
invite = UserInvite.create!(
|
|
340
|
+
company: resource,
|
|
341
|
+
email: email,
|
|
342
|
+
role: role,
|
|
343
|
+
invited_by: current_user
|
|
344
|
+
)
|
|
345
|
+
UserInviteMailer.invitation(invite).deliver_later
|
|
346
|
+
|
|
347
|
+
succeed(resource)
|
|
348
|
+
.with_message("Invitation sent to #{email}")
|
|
349
|
+
.with_redirect_response(resource)
|
|
350
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
351
|
+
failed(e.record.errors)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def not_already_invited
|
|
355
|
+
return unless email.present?
|
|
356
|
+
|
|
357
|
+
if UserInvite.exists?(company: resource, email: email, state: :pending)
|
|
358
|
+
errors.add(:email, "already has a pending invitation")
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def current_user
|
|
363
|
+
view_context.controller.helpers.current_user
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## Best Practices
|
|
369
|
+
|
|
370
|
+
1. **Keep interactions focused** - One action per interaction
|
|
371
|
+
2. **Use validations** - Validate all inputs before execution
|
|
372
|
+
3. **Handle errors gracefully** - Rescue exceptions and return `failed()`
|
|
373
|
+
4. **Return meaningful messages** - Help users understand what happened
|
|
374
|
+
5. **Use `and_then` for chains** - Compose complex workflows from simple interactions
|
|
375
|
+
6. **Test independently** - Interactions are easy to unit test
|
|
376
|
+
|
|
377
|
+
## Related Skills
|
|
378
|
+
|
|
379
|
+
- `definition-actions` - Declaring actions in definitions
|
|
380
|
+
- `forms` - Custom interaction form templates
|
|
381
|
+
- `policy` - Controlling access to actions
|
|
382
|
+
- `resource` - How interactions fit in the architecture
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: model
|
|
3
|
+
description: Overview of Plutonium resource models - structure, setup, and best practices
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Resource Models
|
|
7
|
+
|
|
8
|
+
A model becomes a Plutonium resource by including `Plutonium::Resource::Record`. This provides enhanced ActiveRecord functionality for routing, labeling, field introspection, associations, and monetary handling.
|
|
9
|
+
|
|
10
|
+
## Setup
|
|
11
|
+
|
|
12
|
+
### Standard Setup
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# app/models/application_record.rb
|
|
16
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
17
|
+
include Plutonium::Resource::Record
|
|
18
|
+
primary_abstract_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# app/models/resource_record.rb (optional abstract class)
|
|
22
|
+
class ResourceRecord < ApplicationRecord
|
|
23
|
+
self.abstract_class = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# app/models/property.rb
|
|
27
|
+
class Property < ResourceRecord
|
|
28
|
+
# Now has access to all Plutonium features
|
|
29
|
+
end
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### What's Included
|
|
33
|
+
|
|
34
|
+
`Plutonium::Resource::Record` includes six modules:
|
|
35
|
+
|
|
36
|
+
| Module | Purpose |
|
|
37
|
+
|--------|---------|
|
|
38
|
+
| `HasCents` | Monetary value handling (cents → decimal) |
|
|
39
|
+
| `Routes` | URL parameters, path customization |
|
|
40
|
+
| `Labeling` | Human-readable `to_label` method |
|
|
41
|
+
| `FieldNames` | Field introspection and categorization |
|
|
42
|
+
| `Associations` | SGID support for secure serialization |
|
|
43
|
+
| `AssociatedWith` | Entity scoping for multi-tenant apps |
|
|
44
|
+
|
|
45
|
+
## Model Structure
|
|
46
|
+
|
|
47
|
+
Follow the template structure (comment markers indicate where to add code):
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
class Property < ResourceRecord
|
|
51
|
+
# add concerns above.
|
|
52
|
+
|
|
53
|
+
TYPES = {apartment: "Apartment", house: "House"}.freeze
|
|
54
|
+
# add constants above.
|
|
55
|
+
|
|
56
|
+
enum :state, archived: 0, active: 1
|
|
57
|
+
enum :property_class, residential: 0, commercial: 1
|
|
58
|
+
# add enums above.
|
|
59
|
+
|
|
60
|
+
has_cents :market_value_cents
|
|
61
|
+
# add model configurations above.
|
|
62
|
+
|
|
63
|
+
belongs_to :company
|
|
64
|
+
# add belongs_to associations above.
|
|
65
|
+
|
|
66
|
+
has_one :address
|
|
67
|
+
# add has_one associations above.
|
|
68
|
+
|
|
69
|
+
has_many :units
|
|
70
|
+
has_many :amenities, class_name: "PropertyAmenity"
|
|
71
|
+
# add has_many associations above.
|
|
72
|
+
|
|
73
|
+
has_one_attached :photo
|
|
74
|
+
has_many_attached :documents
|
|
75
|
+
# add attachments above.
|
|
76
|
+
|
|
77
|
+
scope :active, -> { where(state: :active) }
|
|
78
|
+
scope :by_company, ->(company) { where(company: company) }
|
|
79
|
+
# add scopes above.
|
|
80
|
+
|
|
81
|
+
validates :name, presence: true
|
|
82
|
+
validates :property_code, presence: true, uniqueness: {scope: :company_id}
|
|
83
|
+
# add validations above.
|
|
84
|
+
|
|
85
|
+
before_validation :generate_code, on: :create
|
|
86
|
+
# add callbacks above.
|
|
87
|
+
|
|
88
|
+
delegate :name, to: :company, prefix: true
|
|
89
|
+
# add delegations above.
|
|
90
|
+
|
|
91
|
+
has_rich_text :description
|
|
92
|
+
# add misc attribute macros above.
|
|
93
|
+
|
|
94
|
+
def full_address
|
|
95
|
+
address&.to_s
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# add methods above. add private methods below.
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def generate_code
|
|
103
|
+
self.property_code ||= SecureRandom.hex(4).upcase
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Section Order
|
|
109
|
+
|
|
110
|
+
1. **Concerns** - `include` statements
|
|
111
|
+
2. **Constants** - `TYPES = {...}.freeze`, etc.
|
|
112
|
+
3. **Enums** - `enum :state, ...`
|
|
113
|
+
4. **Model configurations** - `has_cents`
|
|
114
|
+
5. **belongs_to associations**
|
|
115
|
+
6. **has_one associations**
|
|
116
|
+
7. **has_many associations**
|
|
117
|
+
8. **Attachments** - `has_one_attached`, `has_many_attached`
|
|
118
|
+
9. **Scopes**
|
|
119
|
+
10. **Validations**
|
|
120
|
+
11. **Callbacks**
|
|
121
|
+
12. **Delegations**
|
|
122
|
+
13. **Misc attribute macros** - `has_rich_text`, `has_secure_token`, `has_secure_password`
|
|
123
|
+
14. **Methods** - Public methods above, private methods below
|
|
124
|
+
|
|
125
|
+
## Common Patterns
|
|
126
|
+
|
|
127
|
+
### Archiving (State-Based)
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
class Property < ResourceRecord
|
|
131
|
+
enum :state, archived: 0, active: 1
|
|
132
|
+
|
|
133
|
+
scope :active, -> { where(state: :active) }
|
|
134
|
+
scope :archived, -> { where(state: :archived) }
|
|
135
|
+
|
|
136
|
+
def archive!
|
|
137
|
+
update!(state: :archived)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def restore!
|
|
141
|
+
update!(state: :active)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Multi-Tenant Scoping
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class Property < ResourceRecord
|
|
150
|
+
belongs_to :company
|
|
151
|
+
|
|
152
|
+
# Compound uniqueness for multi-tenant
|
|
153
|
+
validates :property_code, uniqueness: {scope: :company_id}
|
|
154
|
+
|
|
155
|
+
# Custom scope for entity scoping
|
|
156
|
+
scope :associated_with_company, ->(company) { where(company: company) }
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Custom Validation
|
|
161
|
+
|
|
162
|
+
```ruby
|
|
163
|
+
class Contact < ResourceRecord
|
|
164
|
+
validates :contact_type, presence: true
|
|
165
|
+
|
|
166
|
+
validate :ensure_contact_provided
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def ensure_contact_provided
|
|
171
|
+
return unless [email, phone, website].all?(&:blank?)
|
|
172
|
+
errors.add(:base, "Please provide at least one contact method")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### One-to-One Relationships
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
# Parent side
|
|
181
|
+
class Tenant < ResourceRecord
|
|
182
|
+
has_one :residential_profile, class_name: "ResidentialTenantProfile"
|
|
183
|
+
has_one :commercial_profile, class_name: "CommercialTenantProfile"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Child side (unique index on foreign key)
|
|
187
|
+
class ResidentialTenantProfile < ResourceRecord
|
|
188
|
+
belongs_to :tenant
|
|
189
|
+
# Migration: t.index :tenant_id, unique: true
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Polymorphic Associations
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class Comment < ResourceRecord
|
|
197
|
+
belongs_to :commentable, polymorphic: true
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
class Post < ResourceRecord
|
|
201
|
+
has_many :comments, as: :commentable
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
class Photo < ResourceRecord
|
|
205
|
+
has_many :comments, as: :commentable
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Labeling
|
|
210
|
+
|
|
211
|
+
The `to_label` method provides human-readable record labels:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
# Automatic - checks :name, then :title, then fallback
|
|
215
|
+
user = User.new(name: "John Doe")
|
|
216
|
+
user.to_label # => "John Doe"
|
|
217
|
+
|
|
218
|
+
user = User.create(id: 1)
|
|
219
|
+
user.to_label # => "User #1"
|
|
220
|
+
|
|
221
|
+
# Custom override
|
|
222
|
+
class Product < ResourceRecord
|
|
223
|
+
def to_label
|
|
224
|
+
"#{name} (#{sku})"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Field Introspection
|
|
230
|
+
|
|
231
|
+
Access field information programmatically:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
# All resource fields
|
|
235
|
+
User.resource_field_names
|
|
236
|
+
# => [:id, :name, :email, :company, :avatar, ...]
|
|
237
|
+
|
|
238
|
+
# By category
|
|
239
|
+
User.content_column_field_names # Database columns
|
|
240
|
+
User.belongs_to_association_field_names # belongs_to associations
|
|
241
|
+
User.has_one_association_field_names # has_one associations
|
|
242
|
+
User.has_many_association_field_names # has_many associations
|
|
243
|
+
User.has_one_attached_field_names # Active Storage single
|
|
244
|
+
User.has_many_attached_field_names # Active Storage multiple
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Best Practices
|
|
248
|
+
|
|
249
|
+
1. **Use enums for state** - `enum :state, archived: 0, active: 1` instead of soft-delete
|
|
250
|
+
2. **Compound uniqueness** - Always scope uniqueness to tenant/parent
|
|
251
|
+
3. **Organize with comments** - Use section headers for readability
|
|
252
|
+
4. **Keep models focused** - Business logic in interactions, not models
|
|
253
|
+
5. **Validate at boundaries** - Validate user input, trust internal code
|
|
254
|
+
6. **Use scopes** - Define commonly used queries as scopes
|
|
255
|
+
|
|
256
|
+
## Integration
|
|
257
|
+
|
|
258
|
+
Models integrate with:
|
|
259
|
+
- **Policies** - `resource_field_names` for auto-detection
|
|
260
|
+
- **Definitions** - Field introspection for forms/displays
|
|
261
|
+
- **Controllers** - `from_path_param` for lookups
|
|
262
|
+
- **Query Objects** - Association detection for sorting
|
|
263
|
+
|
|
264
|
+
## Related Skills
|
|
265
|
+
|
|
266
|
+
- `model-features` - has_cents, associations, scopes, routes
|
|
267
|
+
- `create-resource` - Scaffold generator for new resources
|