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,286 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: model-features
|
|
3
|
+
description: Plutonium model features - has_cents, associations, scopes, and routing
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Model Features
|
|
7
|
+
|
|
8
|
+
Advanced features available in Plutonium resource models.
|
|
9
|
+
|
|
10
|
+
## Monetary Handling (has_cents)
|
|
11
|
+
|
|
12
|
+
Store monetary values as integers (cents) while exposing decimal interfaces.
|
|
13
|
+
|
|
14
|
+
### Basic Usage
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
class Product < ResourceRecord
|
|
18
|
+
has_cents :price_cents # Creates price getter/setter
|
|
19
|
+
has_cents :cost_cents, name: :wholesale # Custom accessor name
|
|
20
|
+
has_cents :tax_cents, rate: 1000 # 3 decimal places
|
|
21
|
+
has_cents :quantity_cents, rate: 1 # Whole numbers only
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
product = Product.new
|
|
25
|
+
product.price = 19.99
|
|
26
|
+
product.price_cents # => 1999
|
|
27
|
+
product.price # => 19.99
|
|
28
|
+
|
|
29
|
+
# Truncates (doesn't round)
|
|
30
|
+
product.price = 10.999
|
|
31
|
+
product.price_cents # => 1099
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Options
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
has_cents :field_cents,
|
|
38
|
+
name: :custom_name, # Accessor name (default: field without _cents)
|
|
39
|
+
rate: 100, # Conversion rate (default: 100)
|
|
40
|
+
suffix: "amount" # Suffix for generated name (default: "amount")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Validation
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
class Product < ResourceRecord
|
|
47
|
+
has_cents :price_cents
|
|
48
|
+
|
|
49
|
+
# Validate the cents field
|
|
50
|
+
validates :price_cents, numericality: {greater_than: 0}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
product = Product.new(price: -10)
|
|
54
|
+
product.valid? # => false
|
|
55
|
+
product.errors[:price_cents] # => ["must be greater than 0"]
|
|
56
|
+
product.errors[:price] # => ["is invalid"] (propagated)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### Introspection
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
Product.has_cents_attributes
|
|
63
|
+
# => {price_cents: {name: :price, rate: 100}, ...}
|
|
64
|
+
|
|
65
|
+
Product.has_cents_attribute?(:price_cents) # => true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Association SGID Support
|
|
69
|
+
|
|
70
|
+
All associations get Signed Global ID (SGID) methods for secure serialization.
|
|
71
|
+
|
|
72
|
+
### Singular Associations (belongs_to, has_one)
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class Post < ResourceRecord
|
|
76
|
+
belongs_to :user
|
|
77
|
+
has_one :featured_image
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
post = Post.first
|
|
81
|
+
|
|
82
|
+
# Get SGID
|
|
83
|
+
post.user_sgid # => "BAh7CEkiCG..."
|
|
84
|
+
post.featured_image_sgid # => "BAh7CEkiCG..."
|
|
85
|
+
|
|
86
|
+
# Set by SGID (finds and assigns)
|
|
87
|
+
post.user_sgid = "BAh7CEkiCG..."
|
|
88
|
+
post.featured_image_sgid = "BAh7CEkiCG..."
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Collection Associations (has_many, has_and_belongs_to_many)
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
class User < ResourceRecord
|
|
95
|
+
has_many :posts
|
|
96
|
+
has_and_belongs_to_many :roles
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
user = User.first
|
|
100
|
+
|
|
101
|
+
# Get SGIDs
|
|
102
|
+
user.post_sgids # => ["BAh7CEkiCG...", "BAh7CEkiCG..."]
|
|
103
|
+
user.role_sgids # => ["BAh7CEkiCG...", "BAh7CEkiCG..."]
|
|
104
|
+
|
|
105
|
+
# Bulk assignment
|
|
106
|
+
user.post_sgids = ["BAh7CEkiCG...", ...]
|
|
107
|
+
|
|
108
|
+
# Individual manipulation
|
|
109
|
+
user.add_post_sgid("BAh7CEkiCG...") # Add to collection
|
|
110
|
+
user.remove_post_sgid("BAh7CEkiCG...") # Remove from collection
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Use Cases
|
|
114
|
+
|
|
115
|
+
- Secure form submissions without exposing internal IDs
|
|
116
|
+
- API responses with portable references
|
|
117
|
+
- Caching and serialization
|
|
118
|
+
|
|
119
|
+
## Entity Scoping (associated_with)
|
|
120
|
+
|
|
121
|
+
Query records associated with another record. Essential for multi-tenant apps.
|
|
122
|
+
|
|
123
|
+
### Basic Usage
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class Comment < ResourceRecord
|
|
127
|
+
belongs_to :post
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Find comments for a post
|
|
131
|
+
Comment.associated_with(post)
|
|
132
|
+
# => Comment.where(post: post)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Association Detection
|
|
136
|
+
|
|
137
|
+
Works with:
|
|
138
|
+
- `belongs_to` - Uses WHERE clause (most efficient)
|
|
139
|
+
- `has_one` - Uses JOIN + WHERE
|
|
140
|
+
- `has_many` - Uses JOIN + WHERE
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# Direct association (preferred)
|
|
144
|
+
Comment.associated_with(post) # WHERE post_id = ?
|
|
145
|
+
|
|
146
|
+
# Reverse association (less efficient, logs warning)
|
|
147
|
+
Post.associated_with(comment) # JOIN comments WHERE comments.id = ?
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Custom Scopes
|
|
151
|
+
|
|
152
|
+
For optimal performance, define custom scopes:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
class Comment < ResourceRecord
|
|
156
|
+
# Custom scope naming: associated_with_{model_name}
|
|
157
|
+
scope :associated_with_user, ->(user) do
|
|
158
|
+
joins(:post).where(posts: {user_id: user.id})
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Automatically uses custom scope
|
|
163
|
+
Comment.associated_with(user)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Error Handling
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# When no association exists
|
|
170
|
+
UnrelatedModel.associated_with(user)
|
|
171
|
+
# Raises: Could not resolve the association between 'UnrelatedModel' and 'User'
|
|
172
|
+
#
|
|
173
|
+
# Define:
|
|
174
|
+
# 1. the associations between the models
|
|
175
|
+
# 2. a named scope on UnrelatedModel e.g.
|
|
176
|
+
#
|
|
177
|
+
# scope :associated_with_user, ->(user) { do_something_here }
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## URL Routing
|
|
181
|
+
|
|
182
|
+
### Default Behavior
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
user = User.find(1)
|
|
186
|
+
user.to_param # => "1"
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Custom Path Parameters
|
|
190
|
+
|
|
191
|
+
Use a stable, unique field instead of ID:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
class User < ResourceRecord
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def path_parameter(param_name)
|
|
198
|
+
:username # Must be unique
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
user = User.create(username: "john_doe")
|
|
203
|
+
user.to_param # => "john_doe"
|
|
204
|
+
# URLs: /users/john_doe
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Dynamic Path Parameters (SEO-friendly)
|
|
208
|
+
|
|
209
|
+
Include ID prefix for uniqueness with human-readable suffix:
|
|
210
|
+
|
|
211
|
+
```ruby
|
|
212
|
+
class Article < ResourceRecord
|
|
213
|
+
private
|
|
214
|
+
|
|
215
|
+
def dynamic_path_parameter(param_name)
|
|
216
|
+
:title
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
article = Article.create(id: 1, title: "My Great Article")
|
|
221
|
+
article.to_param # => "1-my-great-article"
|
|
222
|
+
# URLs: /articles/1-my-great-article
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Path Parameter Lookup
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
# Scope for finding by path parameter
|
|
229
|
+
User.from_path_param("john_doe")
|
|
230
|
+
Article.from_path_param("1-my-great-article") # Extracts ID
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Association Route Discovery
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
class User < ResourceRecord
|
|
237
|
+
has_many :posts
|
|
238
|
+
has_many :comments
|
|
239
|
+
accepts_nested_attributes_for :posts
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Get has_many association names
|
|
243
|
+
User.has_many_association_routes
|
|
244
|
+
# => ["posts", "comments"]
|
|
245
|
+
|
|
246
|
+
# Get nested attributes config
|
|
247
|
+
User.all_nested_attributes_options
|
|
248
|
+
# => {posts: {allow_destroy: false, update_only: false, macro: :has_many, class: Post}}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
## Performance Tips
|
|
252
|
+
|
|
253
|
+
### Field Introspection
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
# Cached in production, fresh in development
|
|
257
|
+
User.resource_field_names # First call queries, subsequent cached
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Association Queries
|
|
261
|
+
|
|
262
|
+
```ruby
|
|
263
|
+
# Efficient: Direct belongs_to
|
|
264
|
+
Comment.associated_with(post) # Simple WHERE
|
|
265
|
+
|
|
266
|
+
# Less efficient: Reverse has_many (logs warning)
|
|
267
|
+
Post.associated_with(comment) # JOIN required
|
|
268
|
+
|
|
269
|
+
# Optimal: Custom scope when direct isn't possible
|
|
270
|
+
scope :associated_with_user, ->(user) { where(user_id: user.id) }
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### SGID Operations
|
|
274
|
+
|
|
275
|
+
```ruby
|
|
276
|
+
# Efficient: Batch assignment
|
|
277
|
+
user.post_sgids = sgid_array # Single operation
|
|
278
|
+
|
|
279
|
+
# Inefficient: Individual adds
|
|
280
|
+
sgid_array.each { |sgid| user.add_post_sgid(sgid) }
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## Related Skills
|
|
284
|
+
|
|
285
|
+
- `model` - Model overview and structure
|
|
286
|
+
- `create-resource` - Scaffold generator
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nested-resources
|
|
3
|
+
description: Plutonium nested resources - parent/child routes, scoping, and URL generation
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Nested Resources
|
|
7
|
+
|
|
8
|
+
Plutonium automatically creates nested routes for `has_many` associations, scopes queries to the parent, and handles URL generation.
|
|
9
|
+
|
|
10
|
+
## How It Works
|
|
11
|
+
|
|
12
|
+
When you register resources with parent-child relationships:
|
|
13
|
+
|
|
14
|
+
```ruby
|
|
15
|
+
# In portal routes
|
|
16
|
+
register_resource ::Company
|
|
17
|
+
register_resource ::Property # has belongs_to :company
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Plutonium automatically creates nested routes:
|
|
21
|
+
- `/companies/:company_id/properties` - Properties scoped to company
|
|
22
|
+
- `/companies/:company_id/properties/new` - New property for company
|
|
23
|
+
- `/companies/:company_id/properties/:id` - Property in company context
|
|
24
|
+
|
|
25
|
+
## Automatic Behavior
|
|
26
|
+
|
|
27
|
+
When accessing nested routes, Plutonium automatically:
|
|
28
|
+
|
|
29
|
+
1. **Resolves the parent** via `current_parent`
|
|
30
|
+
2. **Scopes queries** to only show records belonging to parent
|
|
31
|
+
3. **Assigns parent** to new records on create
|
|
32
|
+
4. **Hides parent field** in forms (already determined by URL)
|
|
33
|
+
5. **Authorizes parent access** before proceeding
|
|
34
|
+
|
|
35
|
+
## Controller Methods
|
|
36
|
+
|
|
37
|
+
### current_parent
|
|
38
|
+
|
|
39
|
+
Returns the parent record from the URL:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# URL: /companies/123/properties
|
|
43
|
+
current_parent # => Company.find(123)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### parent_route_param
|
|
47
|
+
|
|
48
|
+
The URL parameter containing the parent ID:
|
|
49
|
+
|
|
50
|
+
```ruby
|
|
51
|
+
parent_route_param # => :company_id
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### parent_input_param
|
|
55
|
+
|
|
56
|
+
The association name on the child model:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
parent_input_param # => :company
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Presentation Hooks
|
|
63
|
+
|
|
64
|
+
Control whether parent field appears in views/forms:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class PropertiesController < ResourceController
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Show parent field in displays (default: false)
|
|
71
|
+
def present_parent?
|
|
72
|
+
true
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Allow changing parent in forms (default: same as present_parent?)
|
|
76
|
+
def submit_parent?
|
|
77
|
+
false # Parent is set from URL, don't allow changing
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Query Scoping
|
|
83
|
+
|
|
84
|
+
Collections are automatically scoped to the parent via policies:
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
class PropertyPolicy < ResourcePolicy
|
|
88
|
+
relation_scope do |relation|
|
|
89
|
+
relation = super(relation) # Applies associated_with(entity_scope)
|
|
90
|
+
# entity_scope is the current_parent
|
|
91
|
+
relation
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The `associated_with` scope finds records belonging to the parent:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# Automatic detection via belongs_to
|
|
100
|
+
Property.associated_with(company)
|
|
101
|
+
# => Property.where(company: company)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Custom Association Scope
|
|
105
|
+
|
|
106
|
+
For complex relationships, define a custom scope:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class Property < ResourceRecord
|
|
110
|
+
scope :associated_with_organization, ->(org) {
|
|
111
|
+
joins(:company).where(companies: { organization_id: org.id })
|
|
112
|
+
}
|
|
113
|
+
end
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## URL Generation
|
|
117
|
+
|
|
118
|
+
Use `resource_url_for` with the `parent:` option:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# Child collection
|
|
122
|
+
resource_url_for(Property, parent: company)
|
|
123
|
+
# => /companies/123/properties
|
|
124
|
+
|
|
125
|
+
# Child record
|
|
126
|
+
resource_url_for(property, parent: company)
|
|
127
|
+
# => /companies/123/properties/456
|
|
128
|
+
|
|
129
|
+
# New child form
|
|
130
|
+
resource_url_for(Property, action: :new, parent: company)
|
|
131
|
+
# => /companies/123/properties/new
|
|
132
|
+
|
|
133
|
+
# Edit child
|
|
134
|
+
resource_url_for(property, action: :edit, parent: company)
|
|
135
|
+
# => /companies/123/properties/456/edit
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Association Panels
|
|
139
|
+
|
|
140
|
+
On the parent's show page, child resources are displayed via association panels:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
class CompanyPolicy < ResourcePolicy
|
|
144
|
+
def permitted_associations
|
|
145
|
+
%i[properties contacts] # Shows panels for these
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The panel loads children via the nested route automatically.
|
|
151
|
+
|
|
152
|
+
## Authorization
|
|
153
|
+
|
|
154
|
+
### Parent Authorization
|
|
155
|
+
|
|
156
|
+
The parent is authorized for `:read?` before `current_parent` returns:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
def current_parent
|
|
160
|
+
# ... resolution logic ...
|
|
161
|
+
authorize! parent, to: :read?
|
|
162
|
+
parent
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Policy Context
|
|
167
|
+
|
|
168
|
+
The parent is passed to child policies as `entity_scope`:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
class PropertyPolicy < ResourcePolicy
|
|
172
|
+
def create?
|
|
173
|
+
# entity_scope is the parent company
|
|
174
|
+
entity_scope.present? && user.member_of?(entity_scope)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def read?
|
|
178
|
+
entity_scope.present? && record.company == entity_scope
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Parameter Handling
|
|
184
|
+
|
|
185
|
+
Parent is automatically injected into resource params:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# When creating a property under /companies/123/properties
|
|
189
|
+
resource_params
|
|
190
|
+
# => { name: "...", company: <Company:123>, company_id: 123 }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
You don't need to include hidden fields for the parent in forms.
|
|
194
|
+
|
|
195
|
+
## Nesting Limitations
|
|
196
|
+
|
|
197
|
+
Plutonium supports **one level of nesting**:
|
|
198
|
+
|
|
199
|
+
- ✅ `/companies/:company_id/properties` (parent → child)
|
|
200
|
+
- ❌ `/companies/:company_id/properties/:property_id/units` (grandparent → parent → child)
|
|
201
|
+
|
|
202
|
+
## Common Patterns
|
|
203
|
+
|
|
204
|
+
### Scoped Uniqueness
|
|
205
|
+
|
|
206
|
+
Validate uniqueness within parent:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
class Property < ResourceRecord
|
|
210
|
+
belongs_to :company
|
|
211
|
+
validates :code, uniqueness: { scope: :company_id }
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Conditional Parent Display
|
|
216
|
+
|
|
217
|
+
Show parent only in certain contexts:
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
class PropertiesController < ResourceController
|
|
221
|
+
private
|
|
222
|
+
|
|
223
|
+
def present_parent?
|
|
224
|
+
# Show parent when accessed standalone, hide when nested
|
|
225
|
+
current_parent.nil?
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Custom Parent Resolution
|
|
231
|
+
|
|
232
|
+
Override parent lookup:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
class PropertiesController < ResourceController
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
def current_parent
|
|
239
|
+
@current_parent ||= Company.friendly.find(params[:company_id])
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Breadcrumbs
|
|
245
|
+
|
|
246
|
+
Breadcrumbs automatically include the parent:
|
|
247
|
+
|
|
248
|
+
```
|
|
249
|
+
Companies > Acme Corp > Properties > Property #123
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Route Registration with Custom Routes
|
|
253
|
+
|
|
254
|
+
Add custom member/collection routes to nested resources:
|
|
255
|
+
|
|
256
|
+
```ruby
|
|
257
|
+
register_resource ::Property do
|
|
258
|
+
member do
|
|
259
|
+
get :analytics
|
|
260
|
+
post :archive
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Generates nested routes:
|
|
266
|
+
- `/companies/:company_id/properties/:id/analytics`
|
|
267
|
+
- `/companies/:company_id/properties/:id/archive`
|
|
268
|
+
|
|
269
|
+
## Related Skills
|
|
270
|
+
|
|
271
|
+
- `portal` - Route registration
|
|
272
|
+
- `policy` - Authorization and scoping
|
|
273
|
+
- `controller` - Presentation hooks
|
|
274
|
+
- `model-features` - associated_with scope
|