plutonium 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -1,528 +1,200 @@
|
|
|
1
1
|
# Nested Resources
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Set up parent/child relationships so `/companies/:id/nested_properties` works automatically.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Goal
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`Company has_many :properties`, and you want:
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
-
|
|
14
|
+
All of this happens with no manual route wiring — Plutonium generates it from the association.
|
|
16
15
|
|
|
17
|
-
##
|
|
16
|
+
## Steps
|
|
18
17
|
|
|
19
|
-
### 1.
|
|
18
|
+
### 1. Scaffold parent and child
|
|
20
19
|
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
26
|
+
### 2. Connect both to the portal
|
|
39
27
|
|
|
40
|
-
```
|
|
41
|
-
|
|
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
|
|
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.
|
|
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
|
|
37
|
+
class CompanyPolicy < ResourcePolicy
|
|
70
38
|
def permitted_associations
|
|
71
|
-
%i[
|
|
39
|
+
%i[properties]
|
|
72
40
|
end
|
|
73
41
|
end
|
|
74
42
|
```
|
|
75
43
|
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
# URL: /posts/123/comments
|
|
108
|
-
current_parent # => Post.find(123)
|
|
109
|
-
```
|
|
54
|
+
## Generated routes
|
|
110
55
|
|
|
111
|
-
|
|
56
|
+
Plutonium prefixes nested routes with `nested_` so they don't conflict with top-level:
|
|
112
57
|
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
+
## What Plutonium does automatically
|
|
120
69
|
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
parent_input_param # => :post
|
|
125
|
-
```
|
|
75
|
+
No hidden fields. No manual scoping.
|
|
126
76
|
|
|
127
|
-
## URL
|
|
77
|
+
## URL generation
|
|
128
78
|
|
|
129
79
|
Use `resource_url_for` with the `parent:` option:
|
|
130
80
|
|
|
131
81
|
```ruby
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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(
|
|
160
|
-
|
|
88
|
+
resource_url_for(Property, action: :new, parent: company)
|
|
89
|
+
resource_url_for(property, action: :edit, parent: company)
|
|
161
90
|
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
96
|
+
## Common patterns
|
|
167
97
|
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
117
|
+
### Compound uniqueness within parent
|
|
293
118
|
|
|
294
119
|
```ruby
|
|
295
|
-
class
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
126
|
+
Without the scope, the same code in different companies would collide.
|
|
304
127
|
|
|
305
|
-
|
|
128
|
+
### Custom routes on nested resources
|
|
306
129
|
|
|
307
130
|
```ruby
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
331
|
-
allow_destroy: true,
|
|
332
|
-
reject_if: :all_blank
|
|
333
|
-
end
|
|
334
|
-
```
|
|
143
|
+
## Policy authorization context
|
|
335
144
|
|
|
336
|
-
|
|
145
|
+
The child policy automatically receives the parent:
|
|
337
146
|
|
|
338
147
|
```ruby
|
|
339
|
-
class
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
```
|
|
148
|
+
class PropertyPolicy < ResourcePolicy
|
|
149
|
+
# parent => the Company instance
|
|
150
|
+
# parent_association => :properties
|
|
343
151
|
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
160
|
+
## Parent scoping vs entity scoping
|
|
376
161
|
|
|
377
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
### Models
|
|
172
|
+
See [Reference › Tenancy › Nested resources › Parent vs entity scoping](/reference/tenancy/nested-resources#parent-vs-entity-scoping).
|
|
441
173
|
|
|
442
|
-
|
|
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
|
-
|
|
176
|
+
Plutonium supports **one level of nesting only**:
|
|
459
177
|
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
467
|
-
true
|
|
468
|
-
end
|
|
181
|
+
For deeper hierarchies, use top-level routes plus association tabs on the show page (`permitted_associations`).
|
|
469
182
|
|
|
470
|
-
|
|
471
|
-
%i[title body]
|
|
472
|
-
end
|
|
183
|
+
## Inline `+` add on the parent form
|
|
473
184
|
|
|
474
|
-
|
|
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
|
-
|
|
479
|
-
%i[comments]
|
|
480
|
-
end
|
|
481
|
-
end
|
|
187
|
+
## Common issues
|
|
482
188
|
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
- [
|
|
527
|
-
- [
|
|
528
|
-
- [
|
|
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
|