plutonium 0.50.0 → 0.52.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 +574 -0
- data/.claude/skills/plutonium-auth/SKILL.md +167 -302
- 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 +674 -0
- data/.claude/skills/plutonium-testing/SKILL.md +9 -6
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +44 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1010 -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 +38 -29
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
- data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
- data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
- data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
- data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
- data/docs/.vitepress/theme/custom.css +144 -0
- data/docs/.vitepress/theme/index.ts +58 -1
- data/docs/getting-started/index.md +33 -57
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/02-first-resource.md +17 -8
- data/docs/getting-started/tutorial/03-authentication.md +31 -23
- data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
- data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
- data/docs/getting-started/tutorial/07-author-portal.md +8 -0
- data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +98 -462
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +93 -298
- data/docs/guides/custom-actions.md +126 -441
- data/docs/guides/customizing-ui.md +258 -0
- data/docs/guides/index.md +49 -52
- data/docs/guides/multi-tenancy.md +123 -186
- data/docs/guides/nested-resources.md +137 -396
- data/docs/guides/search-filtering.md +127 -238
- data/docs/guides/testing.md +10 -5
- data/docs/guides/theming.md +168 -405
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +112 -425
- data/docs/guides/user-profile.md +82 -241
- data/docs/index.md +10 -219
- data/docs/public/asciinema/home-scaffold.cast +305 -0
- data/docs/public/images/guides/custom-actions-bulk.png +0 -0
- data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
- data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
- data/docs/public/images/guides/nested-inputs.png +0 -0
- data/docs/public/images/guides/nested-resources-tab.png +0 -0
- data/docs/public/images/guides/search-filtering-index.png +0 -0
- data/docs/public/images/guides/search-filtering-panel.png +0 -0
- data/docs/public/images/guides/theming-after.png +0 -0
- data/docs/public/images/guides/theming-before.png +0 -0
- data/docs/public/images/guides/user-invites-landing.png +0 -0
- data/docs/public/images/guides/user-profile-edit.png +0 -0
- data/docs/public/images/guides/user-profile-show.png +0 -0
- data/docs/public/images/home-index.png +0 -0
- data/docs/public/images/home-new.png +0 -0
- data/docs/public/images/home-show.png +0 -0
- data/docs/public/images/tutorial/02-empty-index.png +0 -0
- data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
- data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
- data/docs/public/images/tutorial/02-new-form.png +0 -0
- data/docs/public/images/tutorial/03-create-account.png +0 -0
- data/docs/public/images/tutorial/03-login.png +0 -0
- data/docs/public/images/tutorial/04-admin-index.png +0 -0
- data/docs/public/images/tutorial/05-actions-menu.png +0 -0
- data/docs/public/images/tutorial/05-row-actions.png +0 -0
- data/docs/public/images/tutorial/06-comments-tab.png +0 -0
- data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
- data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
- data/docs/public/images/tutorial/07-author-portal.png +0 -0
- data/docs/public/images/tutorial/08-customized-index.png +0 -0
- 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 +229 -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 +67 -48
- 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 +368 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +400 -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 +121 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
- data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -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/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -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/assets/assets_generator.rb +10 -0
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +45 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
- data/lib/generators/pu/profile/conn_generator.rb +2 -2
- data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
- data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
- data/lib/generators/pu/rodauth/account_generator.rb +2 -1
- data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
- data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
- data/lib/generators/pu/rodauth/views_generator.rb +0 -2
- data/lib/generators/pu/saas/membership/USAGE +4 -1
- data/lib/generators/pu/saas/setup_generator.rb +16 -4
- data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
- 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 +30 -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 +23 -5
- data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
- 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 +5 -0
- data/lib/plutonium/ui/form/base.rb +23 -3
- 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 +103 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/interaction.rb +1 -1
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/form/theme.rb +1 -1
- 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/edit.rb +1 -1
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/page/new.rb +1 -1
- data/lib/plutonium/ui/table/components/filter_form.rb +12 -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 +13 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/form_controller.js +5 -4
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +661 -544
- metadata +86 -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,269 @@
|
|
|
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.
|
|
32
|
+
Plutonium reads the `has_many :properties` association on `Company` and registers nested routes for `Property` automatically.
|
|
56
33
|
|
|
57
|
-
|
|
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.
|
|
63
|
-
|
|
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
|
|
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
|
-
|
|
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).
|
|
77
45
|
|
|
78
|
-
|
|
46
|
+

|
|
79
47
|
|
|
80
|
-
|
|
48
|
+
### 4. Visit the URL
|
|
81
49
|
|
|
82
|
-
```ruby
|
|
83
|
-
# Internally uses: Comment.associated_with(post)
|
|
84
|
-
# Which resolves to: Comment.where(post: post)
|
|
85
50
|
```
|
|
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 }
|
|
51
|
+
/admin/companies/1/nested_properties
|
|
94
52
|
```
|
|
95
53
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
The parent field (`post`) is automatically hidden in forms since it's determined by the URL.
|
|
54
|
+
Properties index, scoped to Company #1. Forms hide the company field (already determined by URL).
|
|
99
55
|
|
|
100
|
-
##
|
|
56
|
+
## Generated routes
|
|
101
57
|
|
|
102
|
-
|
|
58
|
+
Plutonium prefixes nested routes with `nested_` so they don't conflict with top-level:
|
|
103
59
|
|
|
104
|
-
|
|
60
|
+
| Route | Purpose |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `/companies/:company_id/nested_properties` | `has_many` index |
|
|
63
|
+
| `/companies/:company_id/nested_properties/new` | new |
|
|
64
|
+
| `/companies/:company_id/nested_properties/:id` | show |
|
|
65
|
+
| `/companies/:company_id/nested_company_profile` | `has_one` show (no `:id`) |
|
|
66
|
+
| `/companies/:company_id/nested_company_profile/new` | `has_one` new |
|
|
105
67
|
|
|
106
|
-
|
|
107
|
-
# URL: /posts/123/comments
|
|
108
|
-
current_parent # => Post.find(123)
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
### parent_route_param
|
|
112
|
-
|
|
113
|
-
The URL parameter containing the parent ID:
|
|
114
|
-
|
|
115
|
-
```ruby
|
|
116
|
-
parent_route_param # => :post_id
|
|
117
|
-
```
|
|
68
|
+
`has_one` associations get singular routes — index redirects to show (or new if no record exists).
|
|
118
69
|
|
|
119
|
-
|
|
70
|
+
## What Plutonium does automatically
|
|
120
71
|
|
|
121
|
-
|
|
72
|
+
1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
|
|
73
|
+
2. **Scopes queries** via the parent association (`company.properties` for `has_many`; `where(company_id: ...)` for `has_one`).
|
|
74
|
+
3. **Assigns the parent** on create (injected into `resource_params`).
|
|
75
|
+
4. **Hides the parent field** in forms and displays.
|
|
122
76
|
|
|
123
|
-
|
|
124
|
-
parent_input_param # => :post
|
|
125
|
-
```
|
|
77
|
+
No hidden fields. No manual scoping.
|
|
126
78
|
|
|
127
|
-
## URL
|
|
79
|
+
## URL generation
|
|
128
80
|
|
|
129
81
|
Use `resource_url_for` with the `parent:` option:
|
|
130
82
|
|
|
131
83
|
```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
|
|
84
|
+
resource_url_for(Property, parent: company)
|
|
85
|
+
# => /admin/companies/123/nested_properties
|
|
139
86
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
# => /posts/123/nested_comments/new
|
|
87
|
+
resource_url_for(property, parent: company)
|
|
88
|
+
# => /admin/companies/123/nested_properties/456
|
|
143
89
|
|
|
144
|
-
|
|
145
|
-
resource_url_for(
|
|
146
|
-
# => /posts/123/nested_comments/456/edit
|
|
90
|
+
resource_url_for(Property, action: :new, parent: company)
|
|
91
|
+
resource_url_for(property, action: :edit, parent: company)
|
|
147
92
|
|
|
148
|
-
#
|
|
149
|
-
resource_url_for(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
resource_url_for(PostMetadata, action: :new, parent: @post)
|
|
153
|
-
# => /posts/123/nested_post_metadata/new
|
|
154
|
-
|
|
155
|
-
# Interactions
|
|
156
|
-
resource_url_for(@comment, parent: @post, interaction: :archive)
|
|
157
|
-
# => /posts/123/nested_comments/456/record_actions/archive
|
|
158
|
-
|
|
159
|
-
resource_url_for(Comment, parent: @post, interaction: :import)
|
|
160
|
-
# => /posts/123/nested_comments/resource_actions/import
|
|
161
|
-
|
|
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
|
|
93
|
+
# Interactions compose with parent
|
|
94
|
+
resource_url_for(property, parent: company, interaction: :archive)
|
|
95
|
+
resource_url_for(Property, parent: company, interaction: :bulk_delete, ids: [1, 2])
|
|
164
96
|
```
|
|
165
97
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
```ruby
|
|
169
|
-
# In CommentsController under /posts/:post_id/nested_comments
|
|
170
|
-
resource_url_for(@comment) # parent: current_parent is automatic
|
|
171
|
-
```
|
|
98
|
+
## Common patterns
|
|
172
99
|
|
|
173
|
-
###
|
|
100
|
+
### Show parent on standalone listings
|
|
174
101
|
|
|
175
|
-
|
|
102
|
+
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
103
|
|
|
177
104
|
```ruby
|
|
178
|
-
|
|
179
|
-
|
|
105
|
+
class PropertiesController < ::ResourceController
|
|
106
|
+
private
|
|
107
|
+
def present_parent? = current_parent.nil?
|
|
108
|
+
end
|
|
180
109
|
```
|
|
181
110
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
Control whether the parent field appears:
|
|
111
|
+
### Custom parent resolution (e.g. by slug)
|
|
185
112
|
|
|
186
113
|
```ruby
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
114
|
+
def current_parent
|
|
115
|
+
@current_parent ||= Company.friendly.find(params[:company_id])
|
|
199
116
|
end
|
|
200
117
|
```
|
|
201
118
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
### Parent Authorization
|
|
205
|
-
|
|
206
|
-
The parent is authorized for `:read?` before being returned:
|
|
119
|
+
### Compound uniqueness within parent
|
|
207
120
|
|
|
208
121
|
```ruby
|
|
209
|
-
|
|
210
|
-
|
|
122
|
+
class Property < ResourceRecord
|
|
123
|
+
belongs_to :company
|
|
124
|
+
validates :code, uniqueness: {scope: :company_id}
|
|
125
|
+
end
|
|
211
126
|
```
|
|
212
127
|
|
|
213
|
-
|
|
128
|
+
Without the scope, the same code in different companies would collide.
|
|
214
129
|
|
|
215
|
-
|
|
130
|
+
### Custom routes on nested resources
|
|
216
131
|
|
|
217
132
|
```ruby
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
133
|
+
register_resource ::Property do
|
|
134
|
+
member do
|
|
135
|
+
get :analytics, as: :analytics # `as:` is REQUIRED
|
|
136
|
+
post :archive, as: :archive
|
|
227
137
|
end
|
|
228
138
|
end
|
|
229
139
|
```
|
|
230
140
|
|
|
231
|
-
|
|
141
|
+
::: warning Always pass `as:`
|
|
142
|
+
Without `as:`, `resource_url_for(property, parent: company, action: :analytics)` fails — no named route to look up.
|
|
143
|
+
:::
|
|
232
144
|
|
|
233
|
-
|
|
145
|
+
## Policy authorization context
|
|
234
146
|
|
|
235
|
-
|
|
236
|
-
```ruby
|
|
237
|
-
parent.send(parent_association) # e.g., post.comments
|
|
238
|
-
```
|
|
147
|
+
The child policy automatically receives the parent:
|
|
239
148
|
|
|
240
|
-
For **has_one** associations, scoping uses a where clause:
|
|
241
149
|
```ruby
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
### Entity Scope Fallback
|
|
246
|
-
|
|
247
|
-
When no parent is present (top-level resource access), entity_scope is used:
|
|
150
|
+
class PropertyPolicy < ResourcePolicy
|
|
151
|
+
# parent => the Company instance
|
|
152
|
+
# parent_association => :properties
|
|
248
153
|
|
|
249
|
-
```ruby
|
|
250
|
-
class CommentPolicy < ResourcePolicy
|
|
251
154
|
def create?
|
|
252
|
-
|
|
253
|
-
entity_scope.present? && user.can_comment_on?(entity_scope)
|
|
155
|
+
parent.present? && user.member_of?(parent)
|
|
254
156
|
end
|
|
255
157
|
end
|
|
256
158
|
```
|
|
257
159
|
|
|
258
|
-
|
|
160
|
+
The parent is authorized for `:read?` before `current_parent` returns — children inherit the parent's access requirements.
|
|
259
161
|
|
|
260
|
-
|
|
162
|
+
## Parent scoping vs entity scoping
|
|
261
163
|
|
|
262
|
-
|
|
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
|
|
273
|
-
end
|
|
274
|
-
```
|
|
164
|
+
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.
|
|
275
165
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
Plutonium verifies that `default_relation_scope` is called in every `relation_scope` to prevent multi-tenancy leaks:
|
|
166
|
+
In the child policy, just call `default_relation_scope` — it handles both cases:
|
|
279
167
|
|
|
280
168
|
```ruby
|
|
281
|
-
# ❌ This will raise an error
|
|
282
169
|
relation_scope do |relation|
|
|
283
|
-
relation
|
|
284
|
-
end
|
|
285
|
-
|
|
286
|
-
# ✅ Correct
|
|
287
|
-
relation_scope do |relation|
|
|
288
|
-
default_relation_scope(relation).where(approved: true)
|
|
170
|
+
default_relation_scope(relation) # parent when present, entity_scope otherwise
|
|
289
171
|
end
|
|
290
172
|
```
|
|
291
173
|
|
|
292
|
-
|
|
174
|
+
See [Reference › Tenancy › Nested resources › Parent vs entity scoping](/reference/tenancy/nested-resources#parent-vs-entity-scoping).
|
|
293
175
|
|
|
294
|
-
|
|
295
|
-
class AdminCommentPolicy < CommentPolicy
|
|
296
|
-
relation_scope do |relation|
|
|
297
|
-
# Replace inherited scope but keep parent scoping
|
|
298
|
-
default_relation_scope(relation)
|
|
299
|
-
end
|
|
300
|
-
end
|
|
301
|
-
```
|
|
176
|
+
## Nesting limitations
|
|
302
177
|
|
|
303
|
-
|
|
178
|
+
Plutonium supports **one level of nesting only**:
|
|
304
179
|
|
|
305
|
-
|
|
180
|
+
- ✅ `/companies/:company_id/nested_properties` (parent → child)
|
|
181
|
+
- ❌ `/companies/:company_id/nested_properties/:property_id/nested_units` (grandparent → parent → child)
|
|
306
182
|
|
|
307
|
-
|
|
308
|
-
class PostPolicy < ResourcePolicy
|
|
309
|
-
def permitted_associations
|
|
310
|
-
%i[comments tags] # Shows panels for these
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
|
-
```
|
|
183
|
+
For deeper hierarchies, use top-level routes plus association tabs on the show page (`permitted_associations`).
|
|
314
184
|
|
|
315
|
-
|
|
316
|
-
- List of child records
|
|
317
|
-
- "Add" button linking to nested new action
|
|
318
|
-
- Edit/Delete actions per record
|
|
185
|
+
## Nested inputs (sub-records inside a parent form)
|
|
319
186
|
|
|
320
|
-
|
|
187
|
+
A different feature with a confusingly similar name. **Nested *resources*** (above) give you separate URLs for the child collection. **Nested *inputs*** let you edit child records inline inside the parent's form — a single submit creates/updates/deletes them in one go, backed by Rails' `accepts_nested_attributes_for`.
|
|
321
188
|
|
|
322
|
-
|
|
189
|
+
Use nested inputs when the children are conceptually part of the parent (line items on an order, variants on a product, contact methods on a person) and don't deserve their own page.
|
|
323
190
|
|
|
324
|
-
###
|
|
191
|
+
### Setup
|
|
325
192
|
|
|
326
193
|
```ruby
|
|
194
|
+
# Model
|
|
327
195
|
class Post < ResourceRecord
|
|
328
196
|
has_many :comments
|
|
329
|
-
|
|
330
|
-
accepts_nested_attributes_for :comments,
|
|
331
|
-
allow_destroy: true,
|
|
332
|
-
reject_if: :all_blank
|
|
197
|
+
accepts_nested_attributes_for :comments, allow_destroy: true, reject_if: :all_blank
|
|
333
198
|
end
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
### 2. Configure as Nested Input
|
|
337
199
|
|
|
338
|
-
|
|
200
|
+
# Definition
|
|
339
201
|
class PostDefinition < ResourceDefinition
|
|
340
|
-
|
|
202
|
+
nested_input :comments do |definition|
|
|
203
|
+
definition.input :body, as: :text
|
|
204
|
+
end
|
|
341
205
|
end
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
### 3. Permit in Policy
|
|
345
206
|
|
|
346
|
-
|
|
207
|
+
# Policy — list the association name (NOT `comments_attributes`)
|
|
347
208
|
class PostPolicy < ResourcePolicy
|
|
348
209
|
def permitted_attributes_for_create
|
|
349
|
-
[:title, :
|
|
210
|
+
[:title, :body, :comments]
|
|
350
211
|
end
|
|
351
212
|
end
|
|
352
213
|
```
|
|
353
214
|
|
|
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
|
|
374
|
-
|
|
375
|
-
Plutonium supports **one level of nesting**:
|
|
376
|
-
|
|
377
|
-
- `/posts/:post_id/nested_comments` (parent → child)
|
|
378
|
-
- `/comments/:comment_id/nested_replies` (parent → child)
|
|
379
|
-
|
|
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:
|
|
386
|
-
|
|
387
|
-
```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.
|
|
215
|
+
::: warning Permit the association, not the strong-params shape
|
|
216
|
+
List `:comments` in `permitted_attributes_for_*` — Plutonium translates it to `comments_attributes: [...]` for you. If you write the raw hash, the form renders the field name as a literal label instead of the nested editor.
|
|
412
217
|
:::
|
|
413
218
|
|
|
414
|
-
|
|
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`
|
|
219
|
+
### Result
|
|
418
220
|
|
|
419
|
-
|
|
221
|
+

|
|
420
222
|
|
|
421
|
-
|
|
223
|
+
Each existing child renders as its own sub-form. A `Delete` checkbox appears when `allow_destroy: true`; the `+ Add` button appends a blank row.
|
|
422
224
|
|
|
423
|
-
|
|
424
|
-
Dashboard > Posts > My First Post > Comments > Comment #1
|
|
425
|
-
```
|
|
225
|
+
### Sourcing fields from an existing definition
|
|
426
226
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
Validate uniqueness within parent:
|
|
227
|
+
If the child already has its own `Definition` and you want to reuse its inputs:
|
|
430
228
|
|
|
431
229
|
```ruby
|
|
432
|
-
|
|
433
|
-
belongs_to :post
|
|
434
|
-
validates :position, uniqueness: { scope: :post_id }
|
|
435
|
-
end
|
|
230
|
+
nested_input :comments, using: CommentDefinition, fields: %i[author body]
|
|
436
231
|
```
|
|
437
232
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
### Models
|
|
233
|
+
### Limits
|
|
441
234
|
|
|
442
235
|
```ruby
|
|
443
|
-
|
|
444
|
-
|
|
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
|
|
236
|
+
nested_input :variants, limit: 5 # cap the number of children
|
|
237
|
+
nested_input :profile, macro: :has_one # singular sub-form, no Add button
|
|
456
238
|
```
|
|
457
239
|
|
|
458
|
-
###
|
|
459
|
-
|
|
460
|
-
```ruby
|
|
461
|
-
class PostPolicy < ResourcePolicy
|
|
462
|
-
def create?
|
|
463
|
-
user.present?
|
|
464
|
-
end
|
|
240
|
+
### Nested *inputs* vs nested *resources*
|
|
465
241
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
242
|
+
| | Nested inputs (`nested_input :comments`) | Nested resources (this guide's main topic) |
|
|
243
|
+
|---|---|---|
|
|
244
|
+
| URL | None — inline in parent form | `/posts/:id/nested_comments` |
|
|
245
|
+
| Submit | One — saves parent + children together | Independent CRUD per child |
|
|
246
|
+
| Discoverability | Always visible in parent form | Tab on parent show page (with `permitted_associations`) |
|
|
247
|
+
| Best for | Tightly-owned children (line items, variants) | Children users browse on their own (orders, posts) |
|
|
248
|
+
| Backing | `accepts_nested_attributes_for` | Plutonium's nested controller routing |
|
|
469
249
|
|
|
470
|
-
|
|
471
|
-
%i[title body]
|
|
472
|
-
end
|
|
250
|
+
You can use both on the same association — they're not mutually exclusive.
|
|
473
251
|
|
|
474
|
-
|
|
475
|
-
%i[title body user created_at]
|
|
476
|
-
end
|
|
252
|
+
## Inline `+` add on the parent form
|
|
477
253
|
|
|
478
|
-
|
|
479
|
-
%i[comments]
|
|
480
|
-
end
|
|
481
|
-
end
|
|
254
|
+
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).
|
|
482
255
|
|
|
483
|
-
|
|
484
|
-
def create?
|
|
485
|
-
user.present? && entity_scope.present?
|
|
486
|
-
end
|
|
256
|
+
## Common issues
|
|
487
257
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
```
|
|
258
|
+
- **Nested route doesn't exist** — both parent AND child must be registered in the same portal (`pu:res:conn`).
|
|
259
|
+
- **Parent shows up in the form anyway** — check `present_parent?` / `submit_parent?` on the controller. Default is to hide on nested routes.
|
|
260
|
+
- **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).
|
|
261
|
+
- **`resource_url_for` returns wrong URL for a nested resource** — check that custom routes use `as:`.
|
|
523
262
|
|
|
524
263
|
## Related
|
|
525
264
|
|
|
526
|
-
- [
|
|
527
|
-
- [
|
|
528
|
-
- [
|
|
265
|
+
- [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources) — full surface
|
|
266
|
+
- [Reference › Behavior › Controllers](/reference/behavior/controllers) — `current_parent`, presentation hooks
|
|
267
|
+
- [Reference › Behavior › Policies](/reference/behavior/policies#association-permissions) — `permitted_associations`
|
|
268
|
+
- [Multi-tenancy](./multi-tenancy) — how entity scoping interacts with parent scoping
|
|
269
|
+
- [Adding resources](./adding-resources) — basic resource setup
|