plutonium 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +572 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +163 -300
  5. data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
  6. data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
  7. data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +6 -5
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +27 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1009 -1214
  14. data/app/assets/plutonium.js.map +3 -3
  15. data/app/assets/plutonium.min.js +52 -51
  16. data/app/assets/plutonium.min.js.map +3 -3
  17. data/docs/.vitepress/config.ts +37 -27
  18. data/docs/getting-started/index.md +22 -29
  19. data/docs/getting-started/installation.md +37 -80
  20. data/docs/getting-started/tutorial/index.md +4 -5
  21. data/docs/guides/adding-resources.md +66 -377
  22. data/docs/guides/authentication.md +94 -463
  23. data/docs/guides/authorization.md +124 -370
  24. data/docs/guides/creating-packages.md +94 -296
  25. data/docs/guides/custom-actions.md +121 -441
  26. data/docs/guides/index.md +22 -42
  27. data/docs/guides/multi-tenancy.md +116 -187
  28. data/docs/guides/nested-resources.md +103 -431
  29. data/docs/guides/search-filtering.md +123 -240
  30. data/docs/guides/testing.md +5 -4
  31. data/docs/guides/theming.md +157 -407
  32. data/docs/guides/troubleshooting.md +5 -3
  33. data/docs/guides/user-invites.md +106 -425
  34. data/docs/guides/user-profile.md +76 -243
  35. data/docs/index.md +1 -1
  36. data/docs/reference/app/generators.md +517 -0
  37. data/docs/reference/app/index.md +158 -0
  38. data/docs/reference/app/packages.md +146 -0
  39. data/docs/reference/app/portals.md +377 -0
  40. data/docs/reference/auth/accounts.md +230 -0
  41. data/docs/reference/auth/index.md +88 -0
  42. data/docs/reference/auth/profile.md +185 -0
  43. data/docs/reference/behavior/controllers.md +395 -0
  44. data/docs/reference/behavior/index.md +22 -0
  45. data/docs/reference/behavior/interactions.md +341 -0
  46. data/docs/reference/behavior/policies.md +417 -0
  47. data/docs/reference/index.md +56 -49
  48. data/docs/reference/resource/actions.md +423 -0
  49. data/docs/reference/resource/definition.md +508 -0
  50. data/docs/reference/resource/index.md +50 -0
  51. data/docs/reference/resource/model.md +348 -0
  52. data/docs/reference/resource/query.md +305 -0
  53. data/docs/reference/tenancy/entity-scoping.md +361 -0
  54. data/docs/reference/tenancy/index.md +36 -0
  55. data/docs/reference/tenancy/invites.md +393 -0
  56. data/docs/reference/tenancy/nested-resources.md +267 -0
  57. data/docs/reference/testing/index.md +287 -0
  58. data/docs/reference/ui/assets.md +400 -0
  59. data/docs/reference/ui/components.md +165 -0
  60. data/docs/reference/ui/displays.md +104 -0
  61. data/docs/reference/ui/forms.md +284 -0
  62. data/docs/reference/ui/index.md +30 -0
  63. data/docs/reference/ui/layouts.md +106 -0
  64. data/docs/reference/ui/pages.md +189 -0
  65. data/docs/reference/ui/tables.md +117 -0
  66. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  67. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  68. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  69. data/gemfiles/rails_7.gemfile.lock +1 -1
  70. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  72. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  73. data/lib/generators/pu/invites/install_generator.rb +1 -0
  74. data/lib/plutonium/definition/base.rb +1 -1
  75. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  76. data/lib/plutonium/helpers/turbo_helper.rb +11 -0
  77. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  78. data/lib/plutonium/resource/controller.rb +1 -0
  79. data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
  80. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  81. data/lib/plutonium/resource/policy.rb +7 -0
  82. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  83. data/lib/plutonium/ui/component/methods.rb +4 -0
  84. data/lib/plutonium/ui/form/base.rb +6 -2
  85. data/lib/plutonium/ui/form/components/json.rb +58 -0
  86. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  87. data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
  88. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  89. data/lib/plutonium/ui/form/resource.rb +0 -4
  90. data/lib/plutonium/ui/grid/resource.rb +1 -1
  91. data/lib/plutonium/ui/layout/base.rb +1 -0
  92. data/lib/plutonium/ui/page/base.rb +0 -7
  93. data/lib/plutonium/ui/page/index.rb +4 -4
  94. data/lib/plutonium/ui/table/resource.rb +1 -1
  95. data/lib/plutonium/version.rb +1 -1
  96. data/lib/plutonium.rb +8 -0
  97. data/lib/tasks/release.rake +15 -1
  98. data/package.json +10 -10
  99. data/src/css/slim_select.css +4 -0
  100. data/src/js/controllers/slim_select_controller.js +61 -0
  101. data/src/js/turbo/turbo_actions.js +33 -0
  102. data/yarn.lock +553 -543
  103. metadata +44 -33
  104. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  105. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  106. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  107. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  108. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  109. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  110. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  111. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  112. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  113. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  114. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  115. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  116. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  117. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  118. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  119. data/docs/reference/assets/index.md +0 -496
  120. data/docs/reference/controller/index.md +0 -412
  121. data/docs/reference/definition/actions.md +0 -462
  122. data/docs/reference/definition/fields.md +0 -383
  123. data/docs/reference/definition/index.md +0 -326
  124. data/docs/reference/definition/query.md +0 -351
  125. data/docs/reference/generators/index.md +0 -648
  126. data/docs/reference/interaction/index.md +0 -449
  127. data/docs/reference/model/features.md +0 -248
  128. data/docs/reference/model/index.md +0 -218
  129. data/docs/reference/policy/index.md +0 -456
  130. data/docs/reference/portal/index.md +0 -379
  131. data/docs/reference/views/forms.md +0 -411
  132. data/docs/reference/views/index.md +0 -544
@@ -1,528 +1,200 @@
1
1
  # Nested Resources
2
2
 
3
- This guide covers setting up parent/child resource relationships.
3
+ Set up parent/child relationships so `/companies/:id/nested_properties` works automatically.
4
4
 
5
- ## Overview
5
+ ## Goal
6
6
 
7
- Nested resources create URLs like `/posts/1/nested_comments` where comments belong to a specific post. Plutonium automatically handles:
7
+ `Company has_many :properties`, and you want:
8
8
 
9
- - Scoping queries to the parent
10
- - Assigning parent to new records
11
- - Hiding parent field in forms
12
- - URL generation with parent context
13
- - Breadcrumb navigation
9
+ - A "Properties" tab on the Company show page.
10
+ - A nested URL `/companies/123/nested_properties` for the company's properties.
11
+ - Forms that auto-fill the parent (no manual hidden field).
12
+ - Queries scoped to the parent (sibling companies' properties invisible).
14
13
 
15
- Plutonium supports both `has_many` (plural routes) and `has_one` (singular routes) associations.
14
+ All of this happens with no manual route wiring Plutonium generates it from the association.
16
15
 
17
- ## Setting Up Nested Resources
16
+ ## Steps
18
17
 
19
- ### 1. Define the Association
18
+ ### 1. Scaffold parent and child
20
19
 
21
- ```ruby
22
- # Parent model
23
- class Post < ResourceRecord
24
- has_many :comments, dependent: :destroy
25
- has_one :post_metadata, dependent: :destroy
26
- end
27
-
28
- # Child models
29
- class Comment < ResourceRecord
30
- belongs_to :post
31
- end
32
-
33
- class PostMetadata < ResourceRecord
34
- belongs_to :post
35
- end
20
+ ```bash
21
+ rails g pu:res:scaffold Company name:string --dest=main_app
22
+ rails g pu:res:scaffold Property company:belongs_to name:string --dest=main_app
23
+ rails db:migrate
36
24
  ```
37
25
 
38
- ### 2. Register Both Resources
26
+ ### 2. Connect both to the portal
39
27
 
40
- ```ruby
41
- # packages/admin_portal/config/routes.rb
42
- AdminPortal::Engine.routes.draw do
43
- register_resource ::Post
44
- register_resource ::Comment
45
- register_resource ::PostMetadata
46
- end
28
+ ```bash
29
+ rails g pu:res:conn Company Property --dest=admin_portal
47
30
  ```
48
31
 
49
- Plutonium automatically creates nested routes with a `nested_` prefix based on the `belongs_to` association:
50
-
51
- **has_many routes (plural):**
52
- - `GET /posts/:post_id/nested_comments`
53
- - `GET /posts/:post_id/nested_comments/new`
54
- - `GET /posts/:post_id/nested_comments/:id`
55
- - etc.
56
-
57
- **has_one routes (singular):**
58
- - `GET /posts/:post_id/nested_post_metadata`
59
- - `GET /posts/:post_id/nested_post_metadata/new`
60
- - `GET /posts/:post_id/nested_post_metadata/edit`
61
-
62
- The `nested_` prefix prevents route conflicts when the same resource is registered both as a top-level and nested resource.
32
+ Plutonium reads the `has_many :properties` association on `Company` and registers nested routes for `Property` automatically.
63
33
 
64
- ### 3. Enable Association Panel
65
-
66
- Show comments on the post detail page:
34
+ ### 3. (Optional) Expose the relationship on the Company show page
67
35
 
68
36
  ```ruby
69
- class PostPolicy < ResourcePolicy
37
+ class CompanyPolicy < ResourcePolicy
70
38
  def permitted_associations
71
- %i[comments]
39
+ %i[properties]
72
40
  end
73
41
  end
74
42
  ```
75
43
 
76
- ## How It Works
77
-
78
- ### Automatic Scoping
44
+ This adds a "Properties" tab on the Company show page that loads the nested collection. See [Reference › Behavior › Policies › Association permissions](/reference/behavior/policies#association-permissions).
79
45
 
80
- When accessing `/posts/1/comments`, queries are scoped to the parent:
46
+ ### 4. Visit the URL
81
47
 
82
- ```ruby
83
- # Internally uses: Comment.associated_with(post)
84
- # Which resolves to: Comment.where(post: post)
85
48
  ```
86
-
87
- ### Automatic Parent Assignment
88
-
89
- When creating a comment under a post, the parent is injected into params:
90
-
91
- ```ruby
92
- # POST /posts/1/comments
93
- # resource_params automatically includes { post: <Post:1>, post_id: 1 }
49
+ /admin/companies/1/nested_properties
94
50
  ```
95
51
 
96
- ### Automatic Field Hiding
97
-
98
- The parent field (`post`) is automatically hidden in forms since it's determined by the URL.
99
-
100
- ## Controller Helpers
101
-
102
- ### current_parent
103
-
104
- Returns the parent record resolved from the URL:
52
+ Properties index, scoped to Company #1. Forms hide the company field (already determined by URL).
105
53
 
106
- ```ruby
107
- # URL: /posts/123/comments
108
- current_parent # => Post.find(123)
109
- ```
54
+ ## Generated routes
110
55
 
111
- ### parent_route_param
56
+ Plutonium prefixes nested routes with `nested_` so they don't conflict with top-level:
112
57
 
113
- The URL parameter containing the parent ID:
58
+ | Route | Purpose |
59
+ |---|---|
60
+ | `/companies/:company_id/nested_properties` | `has_many` index |
61
+ | `/companies/:company_id/nested_properties/new` | new |
62
+ | `/companies/:company_id/nested_properties/:id` | show |
63
+ | `/companies/:company_id/nested_company_profile` | `has_one` show (no `:id`) |
64
+ | `/companies/:company_id/nested_company_profile/new` | `has_one` new |
114
65
 
115
- ```ruby
116
- parent_route_param # => :post_id
117
- ```
66
+ `has_one` associations get singular routes — index redirects to show (or new if no record exists).
118
67
 
119
- ### parent_input_param
68
+ ## What Plutonium does automatically
120
69
 
121
- The association name on the child model:
70
+ 1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
71
+ 2. **Scopes queries** via the parent association (`company.properties` for `has_many`; `where(company_id: ...)` for `has_one`).
72
+ 3. **Assigns the parent** on create (injected into `resource_params`).
73
+ 4. **Hides the parent field** in forms and displays.
122
74
 
123
- ```ruby
124
- parent_input_param # => :post
125
- ```
75
+ No hidden fields. No manual scoping.
126
76
 
127
- ## URL Generation
77
+ ## URL generation
128
78
 
129
79
  Use `resource_url_for` with the `parent:` option:
130
80
 
131
81
  ```ruby
132
- # Child collection (has_many)
133
- resource_url_for(Comment, parent: @post)
134
- # => /posts/123/nested_comments
135
-
136
- # Child record
137
- resource_url_for(@comment, parent: @post)
138
- # => /posts/123/nested_comments/456
139
-
140
- # New child form
141
- resource_url_for(Comment, action: :new, parent: @post)
142
- # => /posts/123/nested_comments/new
143
-
144
- # Edit child
145
- resource_url_for(@comment, action: :edit, parent: @post)
146
- # => /posts/123/nested_comments/456/edit
147
-
148
- # Singular resource (has_one)
149
- resource_url_for(@post_metadata, parent: @post)
150
- # => /posts/123/nested_post_metadata
151
-
152
- resource_url_for(PostMetadata, action: :new, parent: @post)
153
- # => /posts/123/nested_post_metadata/new
82
+ resource_url_for(Property, parent: company)
83
+ # => /admin/companies/123/nested_properties
154
84
 
155
- # Interactions
156
- resource_url_for(@comment, parent: @post, interaction: :archive)
157
- # => /posts/123/nested_comments/456/record_actions/archive
85
+ resource_url_for(property, parent: company)
86
+ # => /admin/companies/123/nested_properties/456
158
87
 
159
- resource_url_for(Comment, parent: @post, interaction: :import)
160
- # => /posts/123/nested_comments/resource_actions/import
88
+ resource_url_for(Property, action: :new, parent: company)
89
+ resource_url_for(property, action: :edit, parent: company)
161
90
 
162
- resource_url_for(Comment, parent: @post, interaction: :bulk_delete, ids: [1, 2])
163
- # => /posts/123/nested_comments/bulk_actions/bulk_delete?ids[]=1&ids[]=2
91
+ # Interactions compose with parent
92
+ resource_url_for(property, parent: company, interaction: :archive)
93
+ resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])
164
94
  ```
165
95
 
166
- Within a nested context, `parent:` defaults to `current_parent`:
96
+ ## Common patterns
167
97
 
168
- ```ruby
169
- # In CommentsController under /posts/:post_id/nested_comments
170
- resource_url_for(@comment) # parent: current_parent is automatic
171
- ```
172
-
173
- ### Cross-Package URL Generation
98
+ ### Show parent on standalone listings
174
99
 
175
- Generate URLs for resources in a different package:
100
+ By default, the parent field is hidden in forms/displays (it's in the URL). To show it on the standalone (non-nested) listing:
176
101
 
177
102
  ```ruby
178
- # From AdminPortal, generate URL to CustomerPortal resource
179
- resource_url_for(@comment, parent: @post, package: CustomerPortal)
180
- ```
181
-
182
- ## Presentation Hooks
183
-
184
- Control whether the parent field appears:
185
-
186
- ```ruby
187
- class CommentsController < ResourceController
103
+ class PropertiesController < ::ResourceController
188
104
  private
189
-
190
- # Show parent in displays (default: false when nested)
191
- def present_parent?
192
- current_parent.nil? # Only show when accessed standalone
193
- end
194
-
195
- # Allow changing parent in forms (default: same as present_parent?)
196
- def submit_parent?
197
- false
198
- end
199
- end
200
- ```
201
-
202
- ## Policy Integration
203
-
204
- ### Parent Authorization
205
-
206
- The parent is authorized for `:read?` before being returned:
207
-
208
- ```ruby
209
- # Inside current_parent
210
- authorize! parent, to: :read?
211
- ```
212
-
213
- ### Parent Scoping Context
214
-
215
- For nested resources, policies receive `parent` and `parent_association` context. This is used for automatic query scoping:
216
-
217
- ```ruby
218
- class CommentPolicy < ResourcePolicy
219
- # Available context:
220
- # - parent: the parent record (e.g., Post instance)
221
- # - parent_association: the association name (e.g., :comments)
222
- # - entity_scope: the scoped entity (for multi-tenancy)
223
-
224
- relation_scope do |relation|
225
- relation = super(relation) # Applies parent scoping automatically
226
- relation
227
- end
228
- end
229
- ```
230
-
231
- **Parent scoping takes precedence over entity scoping** - when a parent is present, the policy scopes via the parent association rather than the entity scope. This prevents double-scoping since the parent was already authorized and entity-scoped.
232
-
233
- ### has_many vs has_one Scoping
234
-
235
- For **has_many** associations, scoping uses the association directly:
236
- ```ruby
237
- parent.send(parent_association) # e.g., post.comments
238
- ```
239
-
240
- For **has_one** associations, scoping uses a where clause:
241
- ```ruby
242
- relation.where(foreign_key => parent.id) # e.g., where(post_id: post.id)
243
- ```
244
-
245
- ### Entity Scope Fallback
246
-
247
- When no parent is present (top-level resource access), entity_scope is used:
248
-
249
- ```ruby
250
- class CommentPolicy < ResourcePolicy
251
- def create?
252
- # entity_scope is available for multi-tenancy
253
- entity_scope.present? && user.can_comment_on?(entity_scope)
254
- end
255
- end
256
- ```
257
-
258
- ### Additional Scoping
259
-
260
- Add role-based filtering on top of parent scoping:
261
-
262
- ```ruby
263
- class CommentPolicy < ResourcePolicy
264
- relation_scope do |relation|
265
- relation = super(relation) # Applies parent scoping first
266
-
267
- if user.moderator?
268
- relation
269
- else
270
- relation.where(approved: true).or(relation.where(user: user))
271
- end
272
- end
105
+ def present_parent? = current_parent.nil?
273
106
  end
274
107
  ```
275
108
 
276
- ### default_relation_scope is Required
277
-
278
- Plutonium verifies that `default_relation_scope` is called in every `relation_scope` to prevent multi-tenancy leaks:
109
+ ### Custom parent resolution (e.g. by slug)
279
110
 
280
111
  ```ruby
281
- # ❌ This will raise an error
282
- relation_scope do |relation|
283
- relation.where(approved: true) # Missing default_relation_scope!
284
- end
285
-
286
- # ✅ Correct
287
- relation_scope do |relation|
288
- default_relation_scope(relation).where(approved: true)
112
+ def current_parent
113
+ @current_parent ||= Company.friendly.find(params[:company_id])
289
114
  end
290
115
  ```
291
116
 
292
- When overriding an inherited scope but still wanting parent scoping:
117
+ ### Compound uniqueness within parent
293
118
 
294
119
  ```ruby
295
- class AdminCommentPolicy < CommentPolicy
296
- relation_scope do |relation|
297
- # Replace inherited scope but keep parent scoping
298
- default_relation_scope(relation)
299
- end
120
+ class Property < ResourceRecord
121
+ belongs_to :company
122
+ validates :code, uniqueness: {scope: :company_id}
300
123
  end
301
124
  ```
302
125
 
303
- ## Association Panels
126
+ Without the scope, the same code in different companies would collide.
304
127
 
305
- Associations listed in `permitted_associations` appear on the parent's show page:
128
+ ### Custom routes on nested resources
306
129
 
307
130
  ```ruby
308
- class PostPolicy < ResourcePolicy
309
- def permitted_associations
310
- %i[comments tags] # Shows panels for these
131
+ register_resource ::Property do
132
+ member do
133
+ get :analytics, as: :analytics # `as:` is REQUIRED
134
+ post :archive, as: :archive
311
135
  end
312
136
  end
313
137
  ```
314
138
 
315
- Each panel displays:
316
- - List of child records
317
- - "Add" button linking to nested new action
318
- - Edit/Delete actions per record
319
-
320
- ## Nested Forms
321
-
322
- Edit child records inline within the parent form:
323
-
324
- ### 1. Enable Nested Attributes
325
-
326
- ```ruby
327
- class Post < ResourceRecord
328
- has_many :comments
139
+ ::: warning Always pass `as:`
140
+ Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails — no named route to look up.
141
+ :::
329
142
 
330
- accepts_nested_attributes_for :comments,
331
- allow_destroy: true,
332
- reject_if: :all_blank
333
- end
334
- ```
143
+ ## Policy authorization context
335
144
 
336
- ### 2. Configure as Nested Input
145
+ The child policy automatically receives the parent:
337
146
 
338
147
  ```ruby
339
- class PostDefinition < ResourceDefinition
340
- input :comments, as: :nested
341
- end
342
- ```
148
+ class PropertyPolicy < ResourcePolicy
149
+ # parent => the Company instance
150
+ # parent_association => :properties
343
151
 
344
- ### 3. Permit in Policy
345
-
346
- ```ruby
347
- class PostPolicy < ResourcePolicy
348
- def permitted_attributes_for_create
349
- [:title, :content, comments_attributes: [:id, :body, :_destroy]]
152
+ def create?
153
+ parent.present? && user.member_of?(parent)
350
154
  end
351
155
  end
352
156
  ```
353
157
 
354
- ## has_one Associations
355
-
356
- Plutonium supports `has_one` associations with singular routes:
357
-
358
- ```ruby
359
- class Post < ResourceRecord
360
- has_one :post_metadata, dependent: :destroy
361
- end
362
- ```
363
-
364
- Routes generated:
365
- - `GET /posts/:post_id/nested_post_metadata` - Show metadata
366
- - `GET /posts/:post_id/nested_post_metadata/new` - New metadata form
367
- - `GET /posts/:post_id/nested_post_metadata/edit` - Edit metadata form
368
- - `PATCH /posts/:post_id/nested_post_metadata` - Update metadata
369
- - `DELETE /posts/:post_id/nested_post_metadata` - Delete metadata
370
-
371
- Note: No `:id` parameter in singular routes - only one record can exist per parent.
372
-
373
- ## Nesting Depth
158
+ The parent is authorized for `:read?` before `current_parent` returns — children inherit the parent's access requirements.
374
159
 
375
- Plutonium supports **one level of nesting**:
160
+ ## Parent scoping vs entity scoping
376
161
 
377
- - `/posts/:post_id/nested_comments` (parent child)
378
- - `/comments/:comment_id/nested_replies` (parent → child)
162
+ When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already entity-scoped during its own authorization — double-scoping isn't needed.
379
163
 
380
- Not supported:
381
- - `/posts/:post_id/nested_comments/:comment_id/nested_replies` (grandparent → parent → child)
382
-
383
- ### Working with Deep Hierarchies
384
-
385
- Use through associations for data access:
164
+ In the child policy, just call `default_relation_scope` — it handles both cases:
386
165
 
387
166
  ```ruby
388
- class Post < ResourceRecord
389
- has_many :comments
390
- has_many :replies, through: :comments
391
- end
392
- ```
393
-
394
- ## Custom Routes on Nested Resources
395
-
396
- Add member/collection routes:
397
-
398
- ```ruby
399
- register_resource ::Comment do
400
- member do
401
- post :approve, as: :approve
402
- post :flag, as: :flag
403
- end
404
- collection do
405
- get :pending, as: :pending
406
- end
407
- end
408
- ```
409
-
410
- ::: warning Always Name Custom Routes
411
- Always use the `as:` option when defining custom routes. This ensures `resource_url_for` can generate correct URLs. Without named routes, URL generation will fail for nested resources.
412
- :::
413
-
414
- Generates nested routes:
415
- - `POST /posts/:post_id/nested_comments/:id/approve`
416
- - `POST /posts/:post_id/nested_comments/:id/flag`
417
- - `GET /posts/:post_id/nested_comments/pending`
418
-
419
- ## Breadcrumbs
420
-
421
- Nested resources automatically include parent in breadcrumbs:
422
-
423
- ```
424
- Dashboard > Posts > My First Post > Comments > Comment #1
425
- ```
426
-
427
- ## Scoped Uniqueness
428
-
429
- Validate uniqueness within parent:
430
-
431
- ```ruby
432
- class Comment < ResourceRecord
433
- belongs_to :post
434
- validates :position, uniqueness: { scope: :post_id }
167
+ relation_scope do |relation|
168
+ default_relation_scope(relation) # parent when present, entity_scope otherwise
435
169
  end
436
170
  ```
437
171
 
438
- ## Example: Blog with Comments
439
-
440
- ### Models
172
+ See [Reference Tenancy › Nested resources › Parent vs entity scoping](/reference/tenancy/nested-resources#parent-vs-entity-scoping).
441
173
 
442
- ```ruby
443
- class Post < ResourceRecord
444
- belongs_to :user
445
- has_many :comments, dependent: :destroy
446
-
447
- validates :title, :body, presence: true
448
- end
449
-
450
- class Comment < ResourceRecord
451
- belongs_to :post
452
- belongs_to :user
453
-
454
- validates :body, presence: true
455
- end
456
- ```
174
+ ## Nesting limitations
457
175
 
458
- ### Policies
176
+ Plutonium supports **one level of nesting only**:
459
177
 
460
- ```ruby
461
- class PostPolicy < ResourcePolicy
462
- def create?
463
- user.present?
464
- end
178
+ - ✅ `/companies/:company_id/nested_properties` (parent → child)
179
+ - `/companies/:company_id/nested_properties/:property_id/nested_units` (grandparent → parent → child)
465
180
 
466
- def read?
467
- true
468
- end
181
+ For deeper hierarchies, use top-level routes plus association tabs on the show page (`permitted_associations`).
469
182
 
470
- def permitted_attributes_for_create
471
- %i[title body]
472
- end
183
+ ## Inline `+` add on the parent form
473
184
 
474
- def permitted_attributes_for_read
475
- %i[title body user created_at]
476
- end
185
+ When a form has an association select (e.g. picking the company on a Property form), the inline `+` button next to the select opens the parent's `:new` action. If the parent form is already in a modal, the `+` opens a **stacked secondary modal** so the in-progress form isn't lost. See [Reference › UI › Forms › Association inputs](/reference/ui/forms#association-inputs).
477
186
 
478
- def permitted_associations
479
- %i[comments]
480
- end
481
- end
187
+ ## Common issues
482
188
 
483
- class CommentPolicy < ResourcePolicy
484
- def create?
485
- user.present? && entity_scope.present?
486
- end
487
-
488
- def read?
489
- true
490
- end
491
-
492
- def update?
493
- record.user_id == user.id
494
- end
495
-
496
- def destroy?
497
- record.user_id == user.id || entity_scope&.user_id == user.id
498
- end
499
-
500
- def permitted_attributes_for_create
501
- %i[body]
502
- end
503
-
504
- def permitted_attributes_for_read
505
- %i[body user created_at]
506
- end
507
- end
508
- ```
509
-
510
- ### Controller (if customization needed)
511
-
512
- ```ruby
513
- class CommentsController < ResourceController
514
- private
515
-
516
- def build_resource
517
- super.tap do |comment|
518
- comment.user = current_user
519
- end
520
- end
521
- end
522
- ```
189
+ - **Nested route doesn't exist** — both parent AND child must be registered in the same portal (`pu:res:conn`).
190
+ - **Parent shows up in the form anyway** — check `present_parent?` / `submit_parent?` on the controller. Default is to hide on nested routes.
191
+ - **Multiple `belongs_to` to the same parent class** (e.g. `Match belongs_to :home_team, :away_team`) — Plutonium raises. Override `scoped_entity_association` to specify. See [Reference › Tenancy › Entity scoping](/reference/tenancy/entity-scoping#multiple-associations-to-the-same-entity-class).
192
+ - **`resource_url_for` returns wrong URL for a nested resource** — check that custom routes use `as:`.
523
193
 
524
194
  ## Related
525
195
 
526
- - [Adding Resources](./adding-resources)
527
- - [Authorization](./authorization)
528
- - [Creating Packages](./creating-packages)
196
+ - [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources) — full surface
197
+ - [Reference › Behavior › Controllers](/reference/behavior/controllers) — `current_parent`, presentation hooks
198
+ - [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions) — `permitted_associations`
199
+ - [Multi-tenancy](./multi-tenancy) — how entity scoping interacts with parent scoping
200
+ - [Adding resources](./adding-resources) — basic resource setup