plutonium 0.37.0 → 0.39.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-controller/SKILL.md +38 -2
  3. data/.claude/skills/plutonium-definition-actions/SKILL.md +13 -0
  4. data/.claude/skills/plutonium-definition-fields/SKILL.md +33 -0
  5. data/.claude/skills/plutonium-nested-resources/SKILL.md +85 -23
  6. data/.claude/skills/plutonium-policy/SKILL.md +93 -6
  7. data/CHANGELOG.md +42 -0
  8. data/CLAUDE.md +8 -10
  9. data/CONTRIBUTING.md +6 -8
  10. data/Rakefile +16 -1
  11. data/app/assets/plutonium.css +1 -1
  12. data/app/assets/plutonium.js +9371 -11492
  13. data/app/assets/plutonium.js.map +4 -4
  14. data/app/assets/plutonium.min.js +55 -55
  15. data/app/assets/plutonium.min.js.map +4 -4
  16. data/docs/guides/custom-actions.md +14 -0
  17. data/docs/guides/index.md +5 -0
  18. data/docs/guides/nested-resources.md +139 -32
  19. data/docs/guides/troubleshooting.md +82 -0
  20. data/docs/og-image.html +84 -0
  21. data/docs/public/og-image.png +0 -0
  22. data/docs/reference/controller/index.md +6 -2
  23. data/docs/reference/definition/actions.md +14 -0
  24. data/docs/reference/definition/fields.md +33 -0
  25. data/docs/reference/model/index.md +1 -1
  26. data/docs/reference/policy/index.md +77 -6
  27. data/gemfiles/rails_7.gemfile.lock +5 -5
  28. data/gemfiles/rails_8.0.gemfile.lock +5 -5
  29. data/gemfiles/rails_8.1.gemfile.lock +5 -5
  30. data/lib/generators/pu/rodauth/install_generator.rb +7 -11
  31. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +3 -5
  32. data/lib/plutonium/auth/sequel_adapter.rb +76 -0
  33. data/lib/plutonium/core/controller.rb +143 -19
  34. data/lib/plutonium/core/controllers/association_resolver.rb +86 -0
  35. data/lib/plutonium/helpers/display_helper.rb +12 -0
  36. data/lib/plutonium/query/filters/association.rb +25 -3
  37. data/lib/plutonium/resource/controller.rb +91 -9
  38. data/lib/plutonium/resource/controllers/authorizable.rb +17 -4
  39. data/lib/plutonium/resource/controllers/crud_actions.rb +7 -5
  40. data/lib/plutonium/resource/controllers/interactive_actions.rb +9 -0
  41. data/lib/plutonium/resource/controllers/presentable.rb +15 -11
  42. data/lib/plutonium/resource/policy.rb +85 -2
  43. data/lib/plutonium/resource/record/routes.rb +31 -1
  44. data/lib/plutonium/routing/mapper_extensions.rb +49 -10
  45. data/lib/plutonium/routing/route_set_extensions.rb +3 -0
  46. data/lib/plutonium/ui/action_button.rb +72 -11
  47. data/lib/plutonium/ui/actions_dropdown.rb +3 -25
  48. data/lib/plutonium/ui/breadcrumbs.rb +2 -2
  49. data/lib/plutonium/ui/component/methods.rb +10 -3
  50. data/lib/plutonium/ui/display/resource.rb +5 -2
  51. data/lib/plutonium/ui/form/base.rb +1 -1
  52. data/lib/plutonium/ui/form/components/key_value_store.rb +17 -5
  53. data/lib/plutonium/ui/form/interaction.rb +5 -5
  54. data/lib/plutonium/ui/form/query.rb +1 -1
  55. data/lib/plutonium/ui/form/resource.rb +1 -1
  56. data/lib/plutonium/ui/layout/base.rb +1 -1
  57. data/lib/plutonium/ui/layout/basic_layout.rb +2 -2
  58. data/lib/plutonium/ui/layout/resource_layout.rb +2 -2
  59. data/lib/plutonium/ui/layout/rodauth_layout.rb +2 -2
  60. data/lib/plutonium/ui/page/index.rb +1 -1
  61. data/lib/plutonium/ui/page/interactive_action.rb +1 -1
  62. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +3 -25
  63. data/lib/plutonium/version.rb +1 -1
  64. data/lib/tasks/release.rake +1 -1
  65. data/package.json +6 -5
  66. data/plutonium.gemspec +2 -2
  67. data/src/js/controllers/key_value_store_controller.js +6 -0
  68. data/src/js/controllers/resource_drop_down_controller.js +3 -3
  69. data/yarn.lock +1465 -693
  70. metadata +10 -7
  71. data/app/javascript/controllers/key_value_store_controller.js +0 -119
@@ -39,6 +39,20 @@ class PostDefinition < ResourceDefinition
39
39
  end
40
40
  ```
41
41
 
42
+ ::: warning Always Name Custom Routes
43
+ When adding custom routes for actions, always use the `as:` option:
44
+
45
+ ```ruby
46
+ resources :posts do
47
+ collection do
48
+ get :reports, as: :reports # Named route required!
49
+ end
50
+ end
51
+ ```
52
+
53
+ This ensures `resource_url_for` can generate correct URLs, especially for nested resources.
54
+ :::
55
+
42
56
  **Note:** For custom operations with business logic, use Interactive Actions with an Interaction class.
43
57
 
44
58
  ## Interactive Actions with Interactions
data/docs/guides/index.md CHANGED
@@ -27,6 +27,10 @@ These guides show you how to accomplish specific tasks, with complete examples.
27
27
 
28
28
  - [Theming](./theming) - Customize colors, styles, and branding
29
29
 
30
+ ### Help
31
+
32
+ - [Troubleshooting](./troubleshooting) - Common issues and solutions
33
+
30
34
  ## Finding What You Need
31
35
 
32
36
  | I want to... | Guide |
@@ -39,6 +43,7 @@ These guides show you how to accomplish specific tasks, with complete examples.
39
43
  | Separate data by company | [Multi-tenancy](./multi-tenancy) |
40
44
  | Add search to a list | [Search and Filtering](./search-filtering) |
41
45
  | Change the color scheme | [Theming](./theming) |
46
+ | Fix a confusing error | [Troubleshooting](./troubleshooting) |
42
47
 
43
48
  ## Looking for Reference Docs?
44
49
 
@@ -4,7 +4,7 @@ This guide covers setting up parent/child resource relationships.
4
4
 
5
5
  ## Overview
6
6
 
7
- Nested resources create URLs like `/posts/1/comments` where comments belong to a specific post. Plutonium automatically handles:
7
+ Nested resources create URLs like `/posts/1/nested_comments` where comments belong to a specific post. Plutonium automatically handles:
8
8
 
9
9
  - Scoping queries to the parent
10
10
  - Assigning parent to new records
@@ -12,6 +12,8 @@ Nested resources create URLs like `/posts/1/comments` where comments belong to a
12
12
  - URL generation with parent context
13
13
  - Breadcrumb navigation
14
14
 
15
+ Plutonium supports both `has_many` (plural routes) and `has_one` (singular routes) associations.
16
+
15
17
  ## Setting Up Nested Resources
16
18
 
17
19
  ### 1. Define the Association
@@ -20,12 +22,17 @@ Nested resources create URLs like `/posts/1/comments` where comments belong to a
20
22
  # Parent model
21
23
  class Post < ResourceRecord
22
24
  has_many :comments, dependent: :destroy
25
+ has_one :post_metadata, dependent: :destroy
23
26
  end
24
27
 
25
- # Child model
28
+ # Child models
26
29
  class Comment < ResourceRecord
27
30
  belongs_to :post
28
31
  end
32
+
33
+ class PostMetadata < ResourceRecord
34
+ belongs_to :post
35
+ end
29
36
  ```
30
37
 
31
38
  ### 2. Register Both Resources
@@ -35,15 +42,25 @@ end
35
42
  AdminPortal::Engine.routes.draw do
36
43
  register_resource ::Post
37
44
  register_resource ::Comment
45
+ register_resource ::PostMetadata
38
46
  end
39
47
  ```
40
48
 
41
- Plutonium automatically creates nested routes based on the `belongs_to` association:
42
- - `GET /posts/:post_id/comments`
43
- - `GET /posts/:post_id/comments/new`
44
- - `GET /posts/:post_id/comments/:id`
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`
45
55
  - etc.
46
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.
63
+
47
64
  ### 3. Enable Association Panel
48
65
 
49
66
  Show comments on the post detail page:
@@ -112,30 +129,46 @@ parent_input_param # => :post
112
129
  Use `resource_url_for` with the `parent:` option:
113
130
 
114
131
  ```ruby
115
- # Child collection
132
+ # Child collection (has_many)
116
133
  resource_url_for(Comment, parent: @post)
117
- # => /posts/123/comments
134
+ # => /posts/123/nested_comments
118
135
 
119
136
  # Child record
120
137
  resource_url_for(@comment, parent: @post)
121
- # => /posts/123/comments/456
138
+ # => /posts/123/nested_comments/456
122
139
 
123
140
  # New child form
124
141
  resource_url_for(Comment, action: :new, parent: @post)
125
- # => /posts/123/comments/new
142
+ # => /posts/123/nested_comments/new
126
143
 
127
144
  # Edit child
128
145
  resource_url_for(@comment, action: :edit, parent: @post)
129
- # => /posts/123/comments/456/edit
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
130
154
  ```
131
155
 
132
156
  Within a nested context, `parent:` defaults to `current_parent`:
133
157
 
134
158
  ```ruby
135
- # In CommentsController under /posts/:post_id/comments
159
+ # In CommentsController under /posts/:post_id/nested_comments
136
160
  resource_url_for(@comment) # parent: current_parent is automatic
137
161
  ```
138
162
 
163
+ ### Cross-Package URL Generation
164
+
165
+ Generate URLs for resources in a different package:
166
+
167
+ ```ruby
168
+ # From AdminPortal, generate URL to CustomerPortal resource
169
+ resource_url_for(@comment, parent: @post, package: CustomerPortal)
170
+ ```
171
+
139
172
  ## Presentation Hooks
140
173
 
141
174
  Control whether the parent field appears:
@@ -167,23 +200,47 @@ The parent is authorized for `:read?` before being returned:
167
200
  authorize! parent, to: :read?
168
201
  ```
169
202
 
170
- ### Entity Scope Context
203
+ ### Parent Scoping Context
171
204
 
172
- The parent is passed to child policies as `entity_scope`:
205
+ For nested resources, policies receive `parent` and `parent_association` context. This is used for automatic query scoping:
173
206
 
174
207
  ```ruby
175
208
  class CommentPolicy < ResourcePolicy
176
- def create?
177
- # entity_scope is the parent post
178
- entity_scope.present? && user.can_comment_on?(entity_scope)
179
- end
209
+ # Available context:
210
+ # - parent: the parent record (e.g., Post instance)
211
+ # - parent_association: the association name (e.g., :comments)
212
+ # - entity_scope: the scoped entity (for multi-tenancy)
180
213
 
181
- def update?
182
- record.user_id == user.id
214
+ relation_scope do |relation|
215
+ relation = super(relation) # Applies parent scoping automatically
216
+ relation
183
217
  end
218
+ end
219
+ ```
184
220
 
185
- def destroy?
186
- record.user_id == user.id || entity_scope&.user_id == user.id
221
+ **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.
222
+
223
+ ### has_many vs has_one Scoping
224
+
225
+ For **has_many** associations, scoping uses the association directly:
226
+ ```ruby
227
+ parent.send(parent_association) # e.g., post.comments
228
+ ```
229
+
230
+ For **has_one** associations, scoping uses a where clause:
231
+ ```ruby
232
+ relation.where(foreign_key => parent.id) # e.g., where(post_id: post.id)
233
+ ```
234
+
235
+ ### Entity Scope Fallback
236
+
237
+ When no parent is present (top-level resource access), entity_scope is used:
238
+
239
+ ```ruby
240
+ class CommentPolicy < ResourcePolicy
241
+ def create?
242
+ # entity_scope is available for multi-tenancy
243
+ entity_scope.present? && user.can_comment_on?(entity_scope)
187
244
  end
188
245
  end
189
246
  ```
@@ -195,7 +252,7 @@ Add role-based filtering on top of parent scoping:
195
252
  ```ruby
196
253
  class CommentPolicy < ResourcePolicy
197
254
  relation_scope do |relation|
198
- relation = super(relation) # Applies associated_with(entity_scope)
255
+ relation = super(relation) # Applies parent scoping first
199
256
 
200
257
  if user.moderator?
201
258
  relation
@@ -206,6 +263,33 @@ class CommentPolicy < ResourcePolicy
206
263
  end
207
264
  ```
208
265
 
266
+ ### default_relation_scope is Required
267
+
268
+ Plutonium verifies that `default_relation_scope` is called in every `relation_scope` to prevent multi-tenancy leaks:
269
+
270
+ ```ruby
271
+ # ❌ This will raise an error
272
+ relation_scope do |relation|
273
+ relation.where(approved: true) # Missing default_relation_scope!
274
+ end
275
+
276
+ # ✅ Correct
277
+ relation_scope do |relation|
278
+ default_relation_scope(relation).where(approved: true)
279
+ end
280
+ ```
281
+
282
+ When overriding an inherited scope but still wanting parent scoping:
283
+
284
+ ```ruby
285
+ class AdminCommentPolicy < CommentPolicy
286
+ relation_scope do |relation|
287
+ # Replace inherited scope but keep parent scoping
288
+ default_relation_scope(relation)
289
+ end
290
+ end
291
+ ```
292
+
209
293
  ## Association Panels
210
294
 
211
295
  Associations listed in `permitted_associations` appear on the parent's show page:
@@ -257,15 +341,34 @@ class PostPolicy < ResourcePolicy
257
341
  end
258
342
  ```
259
343
 
344
+ ## has_one Associations
345
+
346
+ Plutonium supports `has_one` associations with singular routes:
347
+
348
+ ```ruby
349
+ class Post < ResourceRecord
350
+ has_one :post_metadata, dependent: :destroy
351
+ end
352
+ ```
353
+
354
+ Routes generated:
355
+ - `GET /posts/:post_id/nested_post_metadata` - Show metadata
356
+ - `GET /posts/:post_id/nested_post_metadata/new` - New metadata form
357
+ - `GET /posts/:post_id/nested_post_metadata/edit` - Edit metadata form
358
+ - `PATCH /posts/:post_id/nested_post_metadata` - Update metadata
359
+ - `DELETE /posts/:post_id/nested_post_metadata` - Delete metadata
360
+
361
+ Note: No `:id` parameter in singular routes - only one record can exist per parent.
362
+
260
363
  ## Nesting Depth
261
364
 
262
365
  Plutonium supports **one level of nesting**:
263
366
 
264
- - `/posts/:post_id/comments` (parent → child)
265
- - `/comments/:comment_id/replies` (parent → child)
367
+ - `/posts/:post_id/nested_comments` (parent → child)
368
+ - `/comments/:comment_id/nested_replies` (parent → child)
266
369
 
267
370
  Not supported:
268
- - `/posts/:post_id/comments/:comment_id/replies` (grandparent → parent → child)
371
+ - `/posts/:post_id/nested_comments/:comment_id/nested_replies` (grandparent → parent → child)
269
372
 
270
373
  ### Working with Deep Hierarchies
271
374
 
@@ -285,19 +388,23 @@ Add member/collection routes:
285
388
  ```ruby
286
389
  register_resource ::Comment do
287
390
  member do
288
- post :approve
289
- post :flag
391
+ post :approve, as: :approve
392
+ post :flag, as: :flag
290
393
  end
291
394
  collection do
292
- get :pending
395
+ get :pending, as: :pending
293
396
  end
294
397
  end
295
398
  ```
296
399
 
400
+ ::: warning Always Name Custom Routes
401
+ 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.
402
+ :::
403
+
297
404
  Generates nested routes:
298
- - `POST /posts/:post_id/comments/:id/approve`
299
- - `POST /posts/:post_id/comments/:id/flag`
300
- - `GET /posts/:post_id/comments/pending`
405
+ - `POST /posts/:post_id/nested_comments/:id/approve`
406
+ - `POST /posts/:post_id/nested_comments/:id/flag`
407
+ - `GET /posts/:post_id/nested_comments/pending`
301
408
 
302
409
  ## Breadcrumbs
303
410
 
@@ -0,0 +1,82 @@
1
+ # Troubleshooting
2
+
3
+ Common issues and their solutions when working with Plutonium.
4
+
5
+ ## Resource Class Detection
6
+
7
+ ### "Failed to determine the resource class" Error
8
+
9
+ **Error messages:**
10
+ ```
11
+ NameError: Failed to determine the resource class for MyPortal::PostMetadataController.
12
+ Rails singularized "PostMetadata" to "PostMetadatum", but "PostMetadata" exists.
13
+ Add an inflection rule to config/initializers/inflections.rb.
14
+ ```
15
+
16
+ or:
17
+
18
+ ```
19
+ NameError: Failed to determine the resource class.
20
+ Please call `controller_for(MyResource)` in MyPortal::MyResourceController.
21
+ ```
22
+
23
+ **Cause:** Plutonium infers the resource class from the controller name by singularizing it. For resources with names that don't follow standard Rails pluralization (like `PostMetadata`), Rails may singularize incorrectly (`PostMetadata` → `PostMetadatum`). Plutonium detects this and provides a helpful error message.
24
+
25
+ **Solution:** Add a custom inflection rule in `config/initializers/inflections.rb`:
26
+
27
+ ```ruby
28
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
29
+ # Preserve "Metadata" when singularizing (e.g., PostMetadata stays PostMetadata)
30
+ inflect.singular(/(M)etadata$/i, '\1etadata')
31
+ end
32
+ ```
33
+
34
+ This ensures Rails correctly handles the singularization throughout your application, including:
35
+ - Controller to model resolution
36
+ - Route generation
37
+ - Association lookups
38
+
39
+ **Alternative:** If you can't modify inflections, explicitly set the resource class in your controller:
40
+
41
+ ```ruby
42
+ class MyPortal::PostMetadataController < MyPortal::ResourceController
43
+ controller_for Blogging::PostMetadata
44
+ end
45
+ ```
46
+
47
+ ### Common Words Needing Inflection Rules
48
+
49
+ | Word | Without Rule | With Rule | Inflection |
50
+ |------|--------------|-----------|------------|
51
+ | Metadata | `PostMetadatum` | `PostMetadata` | `inflect.singular(/(M)etadata$/i, '\1etadata')` |
52
+ | Media | `PostMedium` | `PostMedia` | `inflect.singular(/(M)edia$/i, '\1edia')` |
53
+ | Data | `PostDatum` | `PostData` | `inflect.singular(/(D)ata$/i, '\1ata')` |
54
+ | Criteria | `SearchCriterium` | `SearchCriteria` | `inflect.singular(/(C)riteria$/i, '\1riteria')` |
55
+
56
+ ## URL Generation
57
+
58
+ ### Wrong URLs for Nested Resources
59
+
60
+ **Symptom:** URLs for nested resources include unexpected IDs or route to the wrong path.
61
+
62
+ **Cause:** Rails "param recall" fills in missing parameters from the current request when generating URLs. This can cause issues when both top-level and nested routes exist for the same resource.
63
+
64
+ **Solution:** Plutonium handles this automatically by using named route helpers for nested resources. Ensure you're using `resource_url_for` instead of `url_for`:
65
+
66
+ ```ruby
67
+ # Good - uses Plutonium's smart URL generation
68
+ resource_url_for(@comment, parent: @post)
69
+
70
+ # May have issues with param recall
71
+ url_for(controller: 'comments', action: 'show', id: @comment.id)
72
+ ```
73
+
74
+ ## Need More Help?
75
+
76
+ If you encounter an issue not covered here, please [open an issue](https://github.com/radioactive-labs/plutonium-core/issues) on GitHub.
77
+
78
+ ## Related
79
+
80
+ - [Nested Resources Guide](./nested-resources)
81
+ - [Adding Resources Guide](./adding-resources)
82
+ - [Rails Inflections Documentation](https://api.rubyonrails.org/classes/ActiveSupport/Inflector/Inflections.html)
@@ -0,0 +1,84 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <style>
6
+ * {
7
+ margin: 0;
8
+ padding: 0;
9
+ box-sizing: border-box;
10
+ }
11
+
12
+ body {
13
+ width: 1200px;
14
+ height: 630px;
15
+ background: #1e2330;
16
+ display: flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
20
+ }
21
+
22
+ .container {
23
+ display: flex;
24
+ flex-direction: row;
25
+ align-items: center;
26
+ gap: 60px;
27
+ }
28
+
29
+ .logo {
30
+ width: 260px;
31
+ height: 260px;
32
+ object-fit: contain;
33
+ }
34
+
35
+ .content {
36
+ display: flex;
37
+ flex-direction: column;
38
+ gap: 16px;
39
+ }
40
+
41
+ .title {
42
+ font-size: 88px;
43
+ font-weight: 700;
44
+ color: #ffffff;
45
+ letter-spacing: -1px;
46
+ }
47
+
48
+ .tagline {
49
+ font-size: 40px;
50
+ color: #b8bcc8;
51
+ font-weight: 400;
52
+ }
53
+
54
+ .features {
55
+ display: flex;
56
+ gap: 24px;
57
+ margin-top: 12px;
58
+ font-size: 26px;
59
+ color: #e07c5a;
60
+ font-weight: 500;
61
+ }
62
+
63
+ .features span:not(:last-child)::after {
64
+ content: '·';
65
+ margin-left: 24px;
66
+ color: #e07c5a;
67
+ }
68
+ </style>
69
+ </head>
70
+ <body>
71
+ <div class="container">
72
+ <img src="public/plutonium.png" alt="Plutonium" class="logo">
73
+ <div class="content">
74
+ <div class="title">Plutonium</div>
75
+ <div class="tagline">Build Rails Apps in Minutes, Not Days</div>
76
+ <div class="features">
77
+ <span>Convention-driven</span>
78
+ <span>AI-ready</span>
79
+ <span>Fully customizable</span>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </body>
84
+ </html>
Binary file
@@ -219,11 +219,15 @@ end
219
219
  # In portal routes or config/routes.rb
220
220
  register_resource Post do
221
221
  member do
222
- post :publish
222
+ post :publish, as: :publish # Always use as: option!
223
223
  end
224
224
  end
225
225
  ```
226
226
 
227
+ ::: warning Always Name Custom Routes
228
+ Always use the `as:` option when defining custom routes. This ensures `resource_url_for` can generate correct URLs, especially for nested resources.
229
+ :::
230
+
227
231
  ## Authorization
228
232
 
229
233
  ### Automatic Authorization
@@ -258,7 +262,7 @@ end
258
262
  Parent records are automatically resolved:
259
263
 
260
264
  ```ruby
261
- # Route: /users/:user_id/posts/:id
265
+ # Route: /users/:user_id/nested_posts/:id
262
266
  class PostsController < ::ResourceController
263
267
  # current_parent returns the User
264
268
  # resource_record! returns the Post scoped to that User
@@ -39,6 +39,20 @@ class PostDefinition < Plutonium::Resource::Definition
39
39
  end
40
40
  ```
41
41
 
42
+ ::: warning Always Name Custom Routes
43
+ When adding custom routes for actions, always use the `as:` option:
44
+
45
+ ```ruby
46
+ resources :posts do
47
+ collection do
48
+ get :reports, as: :reports # Named route required!
49
+ end
50
+ end
51
+ ```
52
+
53
+ This ensures `resource_url_for` can generate correct URLs, especially for nested resources.
54
+ :::
55
+
42
56
  **Note:** For custom operations with business logic, use **Interactive Actions** with an Interaction class.
43
57
 
44
58
  ## Interactive Actions
@@ -232,6 +232,39 @@ column :status, align: :center # Center
232
232
  column :amount, align: :end # Right
233
233
  ```
234
234
 
235
+ ## Value Formatting
236
+
237
+ Use `formatter` for simple value transformations without a full block:
238
+
239
+ ```ruby
240
+ # Truncate long text
241
+ column :description, formatter: ->(value) { value&.truncate(30) }
242
+
243
+ # Format numbers
244
+ column :price, formatter: ->(value) { "$%.2f" % value if value }
245
+
246
+ # Transform values
247
+ column :status, formatter: ->(value) { value&.humanize&.upcase }
248
+ ```
249
+
250
+ The `formatter` option:
251
+ - Receives the field value as its argument
252
+ - Returns the transformed value for display
253
+ - Works with `column` and `display` declarations
254
+ - Is simpler than block syntax when you only need to transform the value
255
+
256
+ **formatter vs block:** Use `formatter` when you only need the value. Use a block when you need access to the full record:
257
+
258
+ ```ruby
259
+ # formatter - receives just the value
260
+ column :name, formatter: ->(value) { value&.titleize }
261
+
262
+ # block - receives the full record
263
+ column :full_name do |record|
264
+ "#{record.first_name} #{record.last_name}"
265
+ end
266
+ ```
267
+
235
268
  ## Nested Inputs
236
269
 
237
270
  Render inline forms for associated records. Requires `accepts_nested_attributes_for` on the model.
@@ -67,7 +67,7 @@ class Comment < ResourceRecord
67
67
  end
68
68
  ```
69
69
 
70
- When both `Post` and `Comment` are registered in a portal, Plutonium automatically creates nested routes (`/posts/:post_id/comments`). The `associated_with` scope is automatically available for querying.
70
+ When both `Post` and `Comment` are registered in a portal, Plutonium automatically creates nested routes (`/posts/:post_id/nested_comments`). Queries are automatically scoped to the parent via the association.
71
71
 
72
72
  See the [Nested Resources Guide](/guides/nested-resources) for details.
73
73