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.
Files changed (201) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +85 -102
  3. data/.claude/skills/plutonium-app/SKILL.md +574 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +167 -302
  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 +674 -0
  8. data/.claude/skills/plutonium-testing/SKILL.md +9 -6
  9. data/.claude/skills/plutonium-ui/SKILL.md +900 -0
  10. data/CHANGELOG.md +44 -2
  11. data/Rakefile +2 -1
  12. data/app/assets/plutonium.css +1 -11
  13. data/app/assets/plutonium.js +1010 -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 +38 -29
  18. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +53 -0
  19. data/docs/.vitepress/theme/components/HomeCta.vue +108 -0
  20. data/docs/.vitepress/theme/components/HomeHero.vue +70 -0
  21. data/docs/.vitepress/theme/components/HomeInTheBox.vue +74 -0
  22. data/docs/.vitepress/theme/components/HomePillars.vue +42 -0
  23. data/docs/.vitepress/theme/components/HomeStopWriting.vue +49 -0
  24. data/docs/.vitepress/theme/components/HomeWalkthrough.vue +111 -0
  25. data/docs/.vitepress/theme/components/SectionLanding.vue +115 -0
  26. data/docs/.vitepress/theme/custom.css +144 -0
  27. data/docs/.vitepress/theme/index.ts +58 -1
  28. data/docs/getting-started/index.md +33 -57
  29. data/docs/getting-started/installation.md +37 -80
  30. data/docs/getting-started/tutorial/02-first-resource.md +17 -8
  31. data/docs/getting-started/tutorial/03-authentication.md +31 -23
  32. data/docs/getting-started/tutorial/05-custom-actions.md +9 -4
  33. data/docs/getting-started/tutorial/06-nested-resources.md +7 -1
  34. data/docs/getting-started/tutorial/07-author-portal.md +8 -0
  35. data/docs/getting-started/tutorial/08-customizing-ui.md +4 -0
  36. data/docs/getting-started/tutorial/index.md +4 -5
  37. data/docs/guides/adding-resources.md +66 -377
  38. data/docs/guides/authentication.md +98 -462
  39. data/docs/guides/authorization.md +124 -370
  40. data/docs/guides/creating-packages.md +93 -298
  41. data/docs/guides/custom-actions.md +126 -441
  42. data/docs/guides/customizing-ui.md +258 -0
  43. data/docs/guides/index.md +49 -52
  44. data/docs/guides/multi-tenancy.md +123 -186
  45. data/docs/guides/nested-resources.md +137 -396
  46. data/docs/guides/search-filtering.md +127 -238
  47. data/docs/guides/testing.md +10 -5
  48. data/docs/guides/theming.md +168 -405
  49. data/docs/guides/troubleshooting.md +5 -3
  50. data/docs/guides/user-invites.md +112 -425
  51. data/docs/guides/user-profile.md +82 -241
  52. data/docs/index.md +10 -219
  53. data/docs/public/asciinema/home-scaffold.cast +305 -0
  54. data/docs/public/images/guides/custom-actions-bulk.png +0 -0
  55. data/docs/public/images/guides/multi-tenancy-dashboard.png +0 -0
  56. data/docs/public/images/guides/multi-tenancy-welcome.png +0 -0
  57. data/docs/public/images/guides/nested-inputs.png +0 -0
  58. data/docs/public/images/guides/nested-resources-tab.png +0 -0
  59. data/docs/public/images/guides/search-filtering-index.png +0 -0
  60. data/docs/public/images/guides/search-filtering-panel.png +0 -0
  61. data/docs/public/images/guides/theming-after.png +0 -0
  62. data/docs/public/images/guides/theming-before.png +0 -0
  63. data/docs/public/images/guides/user-invites-landing.png +0 -0
  64. data/docs/public/images/guides/user-profile-edit.png +0 -0
  65. data/docs/public/images/guides/user-profile-show.png +0 -0
  66. data/docs/public/images/home-index.png +0 -0
  67. data/docs/public/images/home-new.png +0 -0
  68. data/docs/public/images/home-show.png +0 -0
  69. data/docs/public/images/tutorial/02-empty-index.png +0 -0
  70. data/docs/public/images/tutorial/02-index-with-posts.png +0 -0
  71. data/docs/public/images/tutorial/02-new-form-modal.png +0 -0
  72. data/docs/public/images/tutorial/02-new-form.png +0 -0
  73. data/docs/public/images/tutorial/03-create-account.png +0 -0
  74. data/docs/public/images/tutorial/03-login.png +0 -0
  75. data/docs/public/images/tutorial/04-admin-index.png +0 -0
  76. data/docs/public/images/tutorial/05-actions-menu.png +0 -0
  77. data/docs/public/images/tutorial/05-row-actions.png +0 -0
  78. data/docs/public/images/tutorial/06-comments-tab.png +0 -0
  79. data/docs/public/images/tutorial/06-post-with-comments.png +0 -0
  80. data/docs/public/images/tutorial/07-author-dashboard.png +0 -0
  81. data/docs/public/images/tutorial/07-author-portal.png +0 -0
  82. data/docs/public/images/tutorial/08-customized-index.png +0 -0
  83. data/docs/reference/app/generators.md +517 -0
  84. data/docs/reference/app/index.md +158 -0
  85. data/docs/reference/app/packages.md +146 -0
  86. data/docs/reference/app/portals.md +377 -0
  87. data/docs/reference/auth/accounts.md +229 -0
  88. data/docs/reference/auth/index.md +88 -0
  89. data/docs/reference/auth/profile.md +185 -0
  90. data/docs/reference/behavior/controllers.md +395 -0
  91. data/docs/reference/behavior/index.md +22 -0
  92. data/docs/reference/behavior/interactions.md +341 -0
  93. data/docs/reference/behavior/policies.md +417 -0
  94. data/docs/reference/index.md +67 -48
  95. data/docs/reference/resource/actions.md +423 -0
  96. data/docs/reference/resource/definition.md +508 -0
  97. data/docs/reference/resource/index.md +50 -0
  98. data/docs/reference/resource/model.md +348 -0
  99. data/docs/reference/resource/query.md +305 -0
  100. data/docs/reference/tenancy/entity-scoping.md +368 -0
  101. data/docs/reference/tenancy/index.md +36 -0
  102. data/docs/reference/tenancy/invites.md +400 -0
  103. data/docs/reference/tenancy/nested-resources.md +267 -0
  104. data/docs/reference/testing/index.md +287 -0
  105. data/docs/reference/ui/assets.md +400 -0
  106. data/docs/reference/ui/components.md +165 -0
  107. data/docs/reference/ui/displays.md +104 -0
  108. data/docs/reference/ui/forms.md +284 -0
  109. data/docs/reference/ui/index.md +30 -0
  110. data/docs/reference/ui/layouts.md +106 -0
  111. data/docs/reference/ui/pages.md +189 -0
  112. data/docs/reference/ui/tables.md +121 -0
  113. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md +1648 -0
  114. data/docs/superpowers/plans/2026-05-15-public-pages-overhaul.md.tasks.json +109 -0
  115. data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
  116. data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
  117. data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
  118. data/docs/superpowers/specs/2026-05-15-public-pages-overhaul-design.md +263 -0
  119. data/gemfiles/rails_7.gemfile.lock +1 -1
  120. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  121. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  122. data/lib/generators/pu/core/assets/assets_generator.rb +10 -0
  123. data/lib/generators/pu/core/update/update_generator.rb +0 -20
  124. data/lib/generators/pu/invites/install_generator.rb +45 -0
  125. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +1 -0
  126. data/lib/generators/pu/profile/conn_generator.rb +2 -2
  127. data/lib/generators/pu/res/conn/conn_generator.rb +33 -6
  128. data/lib/generators/pu/res/model/templates/model.rb.tt +4 -0
  129. data/lib/generators/pu/rodauth/account_generator.rb +2 -1
  130. data/lib/generators/pu/rodauth/admin_generator.rb +0 -2
  131. data/lib/generators/pu/rodauth/migration_generator.rb +0 -2
  132. data/lib/generators/pu/rodauth/views_generator.rb +0 -2
  133. data/lib/generators/pu/saas/membership/USAGE +4 -1
  134. data/lib/generators/pu/saas/setup_generator.rb +16 -4
  135. data/lib/generators/pu/saas/welcome/templates/app/controllers/welcome_controller.rb.tt +1 -1
  136. data/lib/plutonium/definition/base.rb +1 -1
  137. data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
  138. data/lib/plutonium/helpers/turbo_helper.rb +30 -0
  139. data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
  140. data/lib/plutonium/resource/controller.rb +1 -0
  141. data/lib/plutonium/resource/controllers/crud_actions.rb +23 -5
  142. data/lib/plutonium/resource/controllers/interactive_actions.rb +3 -3
  143. data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
  144. data/lib/plutonium/resource/policy.rb +7 -0
  145. data/lib/plutonium/routing/mapper_extensions.rb +15 -0
  146. data/lib/plutonium/ui/component/methods.rb +5 -0
  147. data/lib/plutonium/ui/form/base.rb +23 -3
  148. data/lib/plutonium/ui/form/components/json.rb +58 -0
  149. data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
  150. data/lib/plutonium/ui/form/components/secure_association.rb +103 -22
  151. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
  152. data/lib/plutonium/ui/form/interaction.rb +1 -1
  153. data/lib/plutonium/ui/form/resource.rb +0 -4
  154. data/lib/plutonium/ui/form/theme.rb +1 -1
  155. data/lib/plutonium/ui/grid/resource.rb +1 -1
  156. data/lib/plutonium/ui/layout/base.rb +1 -0
  157. data/lib/plutonium/ui/page/base.rb +0 -7
  158. data/lib/plutonium/ui/page/edit.rb +1 -1
  159. data/lib/plutonium/ui/page/index.rb +4 -4
  160. data/lib/plutonium/ui/page/new.rb +1 -1
  161. data/lib/plutonium/ui/table/components/filter_form.rb +12 -4
  162. data/lib/plutonium/ui/table/resource.rb +1 -1
  163. data/lib/plutonium/version.rb +1 -1
  164. data/lib/plutonium.rb +8 -0
  165. data/lib/tasks/release.rake +15 -1
  166. data/package.json +13 -10
  167. data/src/css/slim_select.css +4 -0
  168. data/src/js/controllers/form_controller.js +5 -4
  169. data/src/js/controllers/slim_select_controller.js +61 -0
  170. data/src/js/turbo/turbo_actions.js +33 -0
  171. data/yarn.lock +661 -544
  172. metadata +86 -33
  173. data/.claude/skills/plutonium-assets/SKILL.md +0 -512
  174. data/.claude/skills/plutonium-controller/SKILL.md +0 -396
  175. data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
  176. data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
  177. data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
  178. data/.claude/skills/plutonium-forms/SKILL.md +0 -465
  179. data/.claude/skills/plutonium-installation/SKILL.md +0 -331
  180. data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
  181. data/.claude/skills/plutonium-invites/SKILL.md +0 -408
  182. data/.claude/skills/plutonium-model/SKILL.md +0 -440
  183. data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
  184. data/.claude/skills/plutonium-package/SKILL.md +0 -198
  185. data/.claude/skills/plutonium-policy/SKILL.md +0 -456
  186. data/.claude/skills/plutonium-portal/SKILL.md +0 -410
  187. data/.claude/skills/plutonium-views/SKILL.md +0 -651
  188. data/docs/reference/assets/index.md +0 -496
  189. data/docs/reference/controller/index.md +0 -412
  190. data/docs/reference/definition/actions.md +0 -462
  191. data/docs/reference/definition/fields.md +0 -383
  192. data/docs/reference/definition/index.md +0 -326
  193. data/docs/reference/definition/query.md +0 -351
  194. data/docs/reference/generators/index.md +0 -648
  195. data/docs/reference/interaction/index.md +0 -449
  196. data/docs/reference/model/features.md +0 -248
  197. data/docs/reference/model/index.md +0 -218
  198. data/docs/reference/policy/index.md +0 -456
  199. data/docs/reference/portal/index.md +0 -379
  200. data/docs/reference/views/forms.md +0 -411
  201. data/docs/reference/views/index.md +0 -544
@@ -1,528 +1,269 @@
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.
32
+ Plutonium reads the `has_many :properties` association on `Company` and registers nested routes for `Property` automatically.
56
33
 
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.
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 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
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
- ### Automatic Scoping
46
+ ![Parent show page with nested-association tab](/images/guides/nested-resources-tab.png)
79
47
 
80
- When accessing `/posts/1/comments`, queries are scoped to the parent:
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
- ### Automatic Field Hiding
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
- ## Controller Helpers
56
+ ## Generated routes
101
57
 
102
- ### current_parent
58
+ Plutonium prefixes nested routes with `nested_` so they don't conflict with top-level:
103
59
 
104
- Returns the parent record resolved from the URL:
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
- ```ruby
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
- ### parent_input_param
70
+ ## What Plutonium does automatically
120
71
 
121
- The association name on the child model:
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
- ```ruby
124
- parent_input_param # => :post
125
- ```
77
+ No hidden fields. No manual scoping.
126
78
 
127
- ## URL Generation
79
+ ## URL generation
128
80
 
129
81
  Use `resource_url_for` with the `parent:` option:
130
82
 
131
83
  ```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
84
+ resource_url_for(Property, parent: company)
85
+ # => /admin/companies/123/nested_properties
139
86
 
140
- # New child form
141
- resource_url_for(Comment, action: :new, parent: @post)
142
- # => /posts/123/nested_comments/new
87
+ resource_url_for(property, parent: company)
88
+ # => /admin/companies/123/nested_properties/456
143
89
 
144
- # Edit child
145
- resource_url_for(@comment, action: :edit, parent: @post)
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
- # 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
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
- Within a nested context, `parent:` defaults to `current_parent`:
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
- ### Cross-Package URL Generation
100
+ ### Show parent on standalone listings
174
101
 
175
- Generate URLs for resources in a different package:
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
- # From AdminPortal, generate URL to CustomerPortal resource
179
- resource_url_for(@comment, parent: @post, package: CustomerPortal)
105
+ class PropertiesController < ::ResourceController
106
+ private
107
+ def present_parent? = current_parent.nil?
108
+ end
180
109
  ```
181
110
 
182
- ## Presentation Hooks
183
-
184
- Control whether the parent field appears:
111
+ ### Custom parent resolution (e.g. by slug)
185
112
 
186
113
  ```ruby
187
- class CommentsController < ResourceController
188
- 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
114
+ def current_parent
115
+ @current_parent ||= Company.friendly.find(params[:company_id])
199
116
  end
200
117
  ```
201
118
 
202
- ## Policy Integration
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
- # Inside current_parent
210
- authorize! parent, to: :read?
122
+ class Property < ResourceRecord
123
+ belongs_to :company
124
+ validates :code, uniqueness: {scope: :company_id}
125
+ end
211
126
  ```
212
127
 
213
- ### Parent Scoping Context
128
+ Without the scope, the same code in different companies would collide.
214
129
 
215
- For nested resources, policies receive `parent` and `parent_association` context. This is used for automatic query scoping:
130
+ ### Custom routes on nested resources
216
131
 
217
132
  ```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
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
- **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.
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
- ### has_many vs has_one Scoping
145
+ ## Policy authorization context
234
146
 
235
- For **has_many** associations, scoping uses the association directly:
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
- 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:
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
- # entity_scope is available for multi-tenancy
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
- ### Additional Scoping
160
+ The parent is authorized for `:read?` before `current_parent` returns — children inherit the parent's access requirements.
259
161
 
260
- Add role-based filtering on top of parent scoping:
162
+ ## Parent scoping vs entity scoping
261
163
 
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
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
- ### default_relation_scope is Required
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.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)
170
+ default_relation_scope(relation) # parent when present, entity_scope otherwise
289
171
  end
290
172
  ```
291
173
 
292
- When overriding an inherited scope but still wanting parent scoping:
174
+ See [Reference Tenancy Nested resources Parent vs entity scoping](/reference/tenancy/nested-resources#parent-vs-entity-scoping).
293
175
 
294
- ```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
300
- end
301
- ```
176
+ ## Nesting limitations
302
177
 
303
- ## Association Panels
178
+ Plutonium supports **one level of nesting only**:
304
179
 
305
- Associations listed in `permitted_associations` appear on the parent's show page:
180
+ - `/companies/:company_id/nested_properties` (parent child)
181
+ - ❌ `/companies/:company_id/nested_properties/:property_id/nested_units` (grandparent → parent → child)
306
182
 
307
- ```ruby
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
- Each panel displays:
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
- ## Nested Forms
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
- Edit child records inline within the parent form:
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
- ### 1. Enable Nested Attributes
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
- ```ruby
200
+ # Definition
339
201
  class PostDefinition < ResourceDefinition
340
- input :comments, as: :nested
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
- ```ruby
207
+ # Policy — list the association name (NOT `comments_attributes`)
347
208
  class PostPolicy < ResourcePolicy
348
209
  def permitted_attributes_for_create
349
- [:title, :content, comments_attributes: [:id, :body, :_destroy]]
210
+ [:title, :body, :comments]
350
211
  end
351
212
  end
352
213
  ```
353
214
 
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
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
- 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`
219
+ ### Result
418
220
 
419
- ## Breadcrumbs
221
+ ![Inline nested-input editor with Add Comment button](/images/guides/nested-inputs.png)
420
222
 
421
- Nested resources automatically include parent in breadcrumbs:
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
- ## Scoped Uniqueness
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
- class Comment < ResourceRecord
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
- ## Example: Blog with Comments
439
-
440
- ### Models
233
+ ### Limits
441
234
 
442
235
  ```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
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
- ### Policies
459
-
460
- ```ruby
461
- class PostPolicy < ResourcePolicy
462
- def create?
463
- user.present?
464
- end
240
+ ### Nested *inputs* vs nested *resources*
465
241
 
466
- def read?
467
- true
468
- end
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
- def permitted_attributes_for_create
471
- %i[title body]
472
- end
250
+ You can use both on the same association — they're not mutually exclusive.
473
251
 
474
- def permitted_attributes_for_read
475
- %i[title body user created_at]
476
- end
252
+ ## Inline `+` add on the parent form
477
253
 
478
- def permitted_associations
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
- class CommentPolicy < ResourcePolicy
484
- def create?
485
- user.present? && entity_scope.present?
486
- end
256
+ ## Common issues
487
257
 
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
- ```
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
- - [Adding Resources](./adding-resources)
527
- - [Authorization](./authorization)
528
- - [Creating Packages](./creating-packages)
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