plutonium 0.33.1 → 0.34.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/# Plutonium: The pre-alpha demo.md +4 -2
- data/.claude/skills/assets/SKILL.md +416 -0
- data/.claude/skills/connect-resource/SKILL.md +112 -0
- data/.claude/skills/controller/SKILL.md +302 -0
- data/.claude/skills/create-resource/SKILL.md +240 -0
- data/.claude/skills/definition/SKILL.md +218 -0
- data/.claude/skills/definition-actions/SKILL.md +386 -0
- data/.claude/skills/definition-fields/SKILL.md +474 -0
- data/.claude/skills/definition-query/SKILL.md +334 -0
- data/.claude/skills/forms/SKILL.md +439 -0
- data/.claude/skills/installation/SKILL.md +300 -0
- data/.claude/skills/interaction/SKILL.md +382 -0
- data/.claude/skills/model/SKILL.md +267 -0
- data/.claude/skills/model-features/SKILL.md +286 -0
- data/.claude/skills/nested-resources/SKILL.md +274 -0
- data/.claude/skills/package/SKILL.md +191 -0
- data/.claude/skills/policy/SKILL.md +352 -0
- data/.claude/skills/portal/SKILL.md +400 -0
- data/.claude/skills/resource/SKILL.md +281 -0
- data/.claude/skills/rodauth/SKILL.md +452 -0
- data/.claude/skills/views/SKILL.md +563 -0
- data/Appraisals +46 -4
- data/CHANGELOG.md +32 -1
- data/app/assets/plutonium.css +2 -2
- data/config/brakeman.ignore +239 -0
- data/config/initializers/action_policy.rb +1 -1
- data/docs/.vitepress/config.ts +132 -47
- data/docs/concepts/architecture.md +226 -0
- data/docs/concepts/auto-detection.md +254 -0
- data/docs/concepts/index.md +61 -0
- data/docs/concepts/packages-portals.md +304 -0
- data/docs/concepts/resources.md +224 -0
- data/docs/cookbook/blog.md +412 -0
- data/docs/cookbook/index.md +289 -0
- data/docs/cookbook/saas.md +481 -0
- data/docs/getting-started/index.md +56 -0
- data/docs/getting-started/installation.md +146 -0
- data/docs/getting-started/tutorial/01-setup.md +118 -0
- data/docs/getting-started/tutorial/02-first-resource.md +180 -0
- data/docs/getting-started/tutorial/03-authentication.md +246 -0
- data/docs/getting-started/tutorial/04-authorization.md +170 -0
- data/docs/getting-started/tutorial/05-custom-actions.md +202 -0
- data/docs/getting-started/tutorial/06-nested-resources.md +147 -0
- data/docs/getting-started/tutorial/07-customizing-ui.md +254 -0
- data/docs/getting-started/tutorial/index.md +64 -0
- data/docs/guides/adding-resources.md +420 -0
- data/docs/guides/authentication.md +551 -0
- data/docs/guides/authorization.md +468 -0
- data/docs/guides/creating-packages.md +380 -0
- data/docs/guides/custom-actions.md +523 -0
- data/docs/guides/index.md +45 -0
- data/docs/guides/multi-tenancy.md +302 -0
- data/docs/guides/nested-resources.md +411 -0
- data/docs/guides/search-filtering.md +266 -0
- data/docs/guides/theming.md +321 -0
- data/docs/index.md +67 -26
- data/docs/public/CLAUDE.md +64 -21
- data/docs/reference/assets/index.md +496 -0
- data/docs/reference/controller/index.md +363 -0
- data/docs/reference/definition/actions.md +400 -0
- data/docs/reference/definition/fields.md +350 -0
- data/docs/reference/definition/index.md +252 -0
- data/docs/reference/definition/query.md +342 -0
- data/docs/reference/generators/index.md +469 -0
- data/docs/reference/index.md +49 -0
- data/docs/reference/interaction/index.md +445 -0
- data/docs/reference/model/features.md +248 -0
- data/docs/reference/model/index.md +219 -0
- data/docs/reference/policy/index.md +385 -0
- data/docs/reference/portal/index.md +382 -0
- data/docs/reference/views/forms.md +396 -0
- data/docs/reference/views/index.md +479 -0
- data/gemfiles/rails_7.gemfile +9 -2
- data/gemfiles/rails_7.gemfile.lock +146 -111
- data/gemfiles/rails_8.0.gemfile +20 -0
- data/gemfiles/rails_8.0.gemfile.lock +417 -0
- data/gemfiles/rails_8.1.gemfile +20 -0
- data/gemfiles/rails_8.1.gemfile.lock +419 -0
- data/lib/generators/pu/gem/dotenv/templates/.env +2 -0
- data/lib/generators/pu/gem/dotenv/templates/config/initializers/001_ensure_required_env.rb +3 -1
- data/lib/generators/pu/lib/plutonium_generators/model_generator_base.rb +13 -16
- data/lib/generators/pu/pkg/portal/USAGE +65 -0
- data/lib/generators/pu/pkg/portal/portal_generator.rb +22 -9
- data/lib/generators/pu/res/conn/USAGE +71 -0
- data/lib/generators/pu/res/model/USAGE +106 -110
- data/lib/generators/pu/res/model/templates/model.rb.tt +6 -2
- data/lib/generators/pu/res/scaffold/USAGE +85 -0
- data/lib/generators/pu/rodauth/install_generator.rb +2 -6
- data/lib/generators/pu/rodauth/templates/config/initializers/url_options.rb +17 -0
- data/lib/generators/pu/skills/sync/USAGE +14 -0
- data/lib/generators/pu/skills/sync/sync_generator.rb +66 -0
- data/lib/plutonium/action_policy/sti_policy_lookup.rb +1 -1
- data/lib/plutonium/core/controller.rb +2 -2
- data/lib/plutonium/interaction/base.rb +1 -0
- data/lib/plutonium/package/engine.rb +2 -2
- data/lib/plutonium/query/adhoc_block.rb +6 -2
- data/lib/plutonium/query/model_scope.rb +1 -1
- data/lib/plutonium/railtie.rb +4 -0
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +1 -1
- data/lib/plutonium/resource/query_object.rb +38 -8
- data/lib/plutonium/ui/table/components/scopes_bar.rb +39 -34
- data/lib/plutonium/version.rb +1 -1
- data/lib/tasks/release.rake +19 -4
- data/package.json +1 -1
- metadata +76 -39
- data/brakeman.ignore +0 -28
- data/docs/api-examples.md +0 -49
- data/docs/guide/claude-code-guide.md +0 -74
- data/docs/guide/deep-dive/authorization.md +0 -189
- data/docs/guide/deep-dive/multitenancy.md +0 -256
- data/docs/guide/deep-dive/resources.md +0 -390
- data/docs/guide/getting-started/01-installation.md +0 -165
- data/docs/guide/index.md +0 -28
- data/docs/guide/introduction/01-what-is-plutonium.md +0 -211
- data/docs/guide/introduction/02-core-concepts.md +0 -440
- data/docs/guide/tutorial/01-project-setup.md +0 -75
- data/docs/guide/tutorial/02-creating-a-feature-package.md +0 -45
- data/docs/guide/tutorial/03-defining-resources.md +0 -90
- data/docs/guide/tutorial/04-creating-a-portal.md +0 -101
- data/docs/guide/tutorial/05-customizing-the-ui.md +0 -128
- data/docs/guide/tutorial/06-adding-custom-actions.md +0 -101
- data/docs/guide/tutorial/07-implementing-authorization.md +0 -90
- data/docs/markdown-examples.md +0 -85
- data/docs/modules/action.md +0 -244
- data/docs/modules/authentication.md +0 -236
- data/docs/modules/configuration.md +0 -599
- data/docs/modules/controller.md +0 -443
- data/docs/modules/core.md +0 -316
- data/docs/modules/definition.md +0 -1308
- data/docs/modules/display.md +0 -759
- data/docs/modules/form.md +0 -495
- data/docs/modules/generator.md +0 -400
- data/docs/modules/index.md +0 -167
- data/docs/modules/interaction.md +0 -642
- data/docs/modules/package.md +0 -151
- data/docs/modules/policy.md +0 -176
- data/docs/modules/portal.md +0 -710
- data/docs/modules/query.md +0 -297
- data/docs/modules/resource_record.md +0 -618
- data/docs/modules/routing.md +0 -690
- data/docs/modules/table.md +0 -301
- data/docs/modules/ui.md +0 -631
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
# Multi-tenancy
|
|
2
|
+
|
|
3
|
+
This guide covers isolating data by organization, account, or other entity.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Multi-tenancy means each tenant (organization, company, account) sees only their own data. Plutonium supports this through:
|
|
8
|
+
|
|
9
|
+
- **Entity Scoping** - Automatic query filtering via portal configuration
|
|
10
|
+
- **Path or Custom Strategies** - Flexible entity resolution
|
|
11
|
+
- **Policy Integration** - Authorization automatically respects tenancy
|
|
12
|
+
|
|
13
|
+
## Setting Up Multi-tenancy
|
|
14
|
+
|
|
15
|
+
### 1. Create the Entity Model
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# app/models/organization.rb
|
|
19
|
+
class Organization < ApplicationRecord
|
|
20
|
+
include Plutonium::Resource::Record
|
|
21
|
+
|
|
22
|
+
has_many :users
|
|
23
|
+
has_many :posts
|
|
24
|
+
end
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### 2. Add Entity Reference to Resources
|
|
28
|
+
|
|
29
|
+
Resources must have an association path to the entity:
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# Direct association (preferred)
|
|
33
|
+
class Post < ResourceRecord
|
|
34
|
+
belongs_to :organization
|
|
35
|
+
belongs_to :user
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Through association
|
|
39
|
+
class Comment < ResourceRecord
|
|
40
|
+
belongs_to :post
|
|
41
|
+
has_one :organization, through: :post
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### 3. Configure the Portal Engine
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# packages/customer_portal/lib/engine.rb
|
|
49
|
+
module CustomerPortal
|
|
50
|
+
class Engine < Rails::Engine
|
|
51
|
+
include Plutonium::Portal::Engine
|
|
52
|
+
|
|
53
|
+
config.after_initialize do
|
|
54
|
+
# Path strategy - entity ID in URL
|
|
55
|
+
scope_to_entity Organization, strategy: :path
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Routes become: `/organizations/:organization_id/posts`
|
|
62
|
+
|
|
63
|
+
## Scoping Strategies
|
|
64
|
+
|
|
65
|
+
### Path Strategy (Default)
|
|
66
|
+
|
|
67
|
+
Entity ID is included in the URL path:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
config.after_initialize do
|
|
71
|
+
scope_to_entity Organization, strategy: :path
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The user must have access to the organization (via `associated_with` scope).
|
|
76
|
+
|
|
77
|
+
### Custom Strategy
|
|
78
|
+
|
|
79
|
+
Define a method that returns the current entity:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
# packages/customer_portal/lib/engine.rb
|
|
83
|
+
config.after_initialize do
|
|
84
|
+
scope_to_entity Organization, strategy: :current_organization
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
# packages/customer_portal/app/controllers/customer_portal/concerns/controller.rb
|
|
90
|
+
module CustomerPortal
|
|
91
|
+
module Concerns
|
|
92
|
+
module Controller
|
|
93
|
+
extend ActiveSupport::Concern
|
|
94
|
+
include Plutonium::Portal::Controller
|
|
95
|
+
include Plutonium::Auth::Rodauth(:user)
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Method name must match strategy
|
|
100
|
+
def current_organization
|
|
101
|
+
@current_organization ||= current_user.organization
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## How Entity Scoping Works
|
|
109
|
+
|
|
110
|
+
### Automatic Query Filtering
|
|
111
|
+
|
|
112
|
+
All resource queries are automatically scoped via `associated_with`:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# In a scoped portal
|
|
116
|
+
Post.all # Returns only current entity's posts
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Helper Methods
|
|
120
|
+
|
|
121
|
+
Inside controllers:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
current_scoped_entity # The current Organization/Account/etc.
|
|
125
|
+
scoped_to_entity? # true if scoping is active
|
|
126
|
+
scoped_entity_class # Organization (the entity class)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Model Requirements
|
|
130
|
+
|
|
131
|
+
Models must have an association path to the scoped entity. Plutonium automatically resolves:
|
|
132
|
+
|
|
133
|
+
1. **Direct belongs_to** - `Post belongs_to :organization`
|
|
134
|
+
2. **Through association** - `Comment has_one :organization, through: :post`
|
|
135
|
+
3. **Custom scope** - For complex cases, define a named scope:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class AuditLog < ResourceRecord
|
|
139
|
+
# When automatic resolution fails, define this scope
|
|
140
|
+
scope :associated_with_organization, ->(org) {
|
|
141
|
+
joins(:user).where(users: { organization_id: org.id })
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## User Membership Patterns
|
|
147
|
+
|
|
148
|
+
### Single Organization per User
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
class User < ApplicationRecord
|
|
152
|
+
belongs_to :organization
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Custom strategy
|
|
156
|
+
def current_organization
|
|
157
|
+
current_user.organization
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Multiple Organizations per User
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
class User < ApplicationRecord
|
|
165
|
+
has_many :memberships
|
|
166
|
+
has_many :organizations, through: :memberships
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Custom strategy with session storage
|
|
170
|
+
def current_organization
|
|
171
|
+
@current_organization ||=
|
|
172
|
+
current_user.organizations.find_by(id: session[:organization_id]) ||
|
|
173
|
+
current_user.organizations.first
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Organization Switcher
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
class OrganizationSwitchController < ApplicationController
|
|
181
|
+
def update
|
|
182
|
+
org = current_user.organizations.find(params[:id])
|
|
183
|
+
session[:organization_id] = org.id
|
|
184
|
+
redirect_back(fallback_location: root_path)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Policy Integration
|
|
190
|
+
|
|
191
|
+
Entity scoping is automatic. The base `Plutonium::Resource::Policy` includes:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
relation_scope do |relation|
|
|
195
|
+
next relation unless entity_scope
|
|
196
|
+
|
|
197
|
+
relation.associated_with(entity_scope)
|
|
198
|
+
end
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The `entity_scope` context is automatically set to `current_scoped_entity`.
|
|
202
|
+
|
|
203
|
+
### Additional Filtering
|
|
204
|
+
|
|
205
|
+
Add role-based filtering on top of entity scoping:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
class PostPolicy < ResourcePolicy
|
|
209
|
+
relation_scope do |relation|
|
|
210
|
+
relation = super(relation) # Apply entity scoping first
|
|
211
|
+
|
|
212
|
+
if user.role == "viewer"
|
|
213
|
+
relation.where(published: true)
|
|
214
|
+
else
|
|
215
|
+
relation
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Subdomain-Based Tenancy
|
|
222
|
+
|
|
223
|
+
Route to different organizations by subdomain:
|
|
224
|
+
|
|
225
|
+
### Routes
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
# config/routes.rb
|
|
229
|
+
constraints subdomain: /[a-z]+/ do
|
|
230
|
+
mount CustomerPortal::Engine, at: "/"
|
|
231
|
+
end
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Custom Strategy
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
# Engine configuration
|
|
238
|
+
scope_to_entity Organization, strategy: :current_organization
|
|
239
|
+
|
|
240
|
+
# Controller concern
|
|
241
|
+
def current_organization
|
|
242
|
+
@current_organization ||=
|
|
243
|
+
Organization.find_by!(subdomain: request.subdomain)
|
|
244
|
+
end
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
## Cross-Tenant Operations
|
|
248
|
+
|
|
249
|
+
Sometimes admins need to see all data:
|
|
250
|
+
|
|
251
|
+
### Super Admin Portal (No Scoping)
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
# packages/super_admin_portal/lib/engine.rb
|
|
255
|
+
module SuperAdminPortal
|
|
256
|
+
class Engine < Rails::Engine
|
|
257
|
+
include Plutonium::Portal::Engine
|
|
258
|
+
# No scope_to_entity = sees everything
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Conditional Scoping
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
# Custom strategy that returns nil for super admins
|
|
267
|
+
def current_organization
|
|
268
|
+
return nil if current_user.super_admin?
|
|
269
|
+
current_user.organization
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
When `current_scoped_entity` returns `nil`, scoping is bypassed.
|
|
274
|
+
|
|
275
|
+
## Data Isolation Patterns
|
|
276
|
+
|
|
277
|
+
### Shared Database, Scoped Queries (Recommended)
|
|
278
|
+
|
|
279
|
+
All tenants share tables, queries filter by entity association:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
scope_to_entity Organization, strategy: :path
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
Pros:
|
|
286
|
+
- Simple setup
|
|
287
|
+
- Easy migrations
|
|
288
|
+
- Efficient for many small tenants
|
|
289
|
+
|
|
290
|
+
Cons:
|
|
291
|
+
- Risk of data leakage if scoping fails
|
|
292
|
+
- Complex queries for cross-tenant reports
|
|
293
|
+
|
|
294
|
+
### Schema-Based Isolation
|
|
295
|
+
|
|
296
|
+
Each tenant has separate database schema. This requires additional setup beyond Plutonium's built-in scoping.
|
|
297
|
+
|
|
298
|
+
## Related
|
|
299
|
+
|
|
300
|
+
- [Authorization](./authorization)
|
|
301
|
+
- [Creating Packages](./creating-packages)
|
|
302
|
+
- [Authentication](./authentication)
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
# Nested Resources
|
|
2
|
+
|
|
3
|
+
This guide covers setting up parent/child resource relationships.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Nested resources create URLs like `/posts/1/comments` where comments belong to a specific post. Plutonium automatically handles:
|
|
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
|
|
14
|
+
|
|
15
|
+
## Setting Up Nested Resources
|
|
16
|
+
|
|
17
|
+
### 1. Define the Association
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
# Parent model
|
|
21
|
+
class Post < ResourceRecord
|
|
22
|
+
has_many :comments, dependent: :destroy
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Child model
|
|
26
|
+
class Comment < ResourceRecord
|
|
27
|
+
belongs_to :post
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### 2. Register Both Resources
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
# packages/admin_portal/config/routes.rb
|
|
35
|
+
AdminPortal::Engine.routes.draw do
|
|
36
|
+
register_resource ::Post
|
|
37
|
+
register_resource ::Comment
|
|
38
|
+
end
|
|
39
|
+
```
|
|
40
|
+
|
|
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`
|
|
45
|
+
- etc.
|
|
46
|
+
|
|
47
|
+
### 3. Enable Association Panel
|
|
48
|
+
|
|
49
|
+
Show comments on the post detail page:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
class PostPolicy < ResourcePolicy
|
|
53
|
+
def permitted_associations
|
|
54
|
+
%i[comments]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## How It Works
|
|
60
|
+
|
|
61
|
+
### Automatic Scoping
|
|
62
|
+
|
|
63
|
+
When accessing `/posts/1/comments`, queries are scoped to the parent:
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# Internally uses: Comment.associated_with(post)
|
|
67
|
+
# Which resolves to: Comment.where(post: post)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Automatic Parent Assignment
|
|
71
|
+
|
|
72
|
+
When creating a comment under a post, the parent is injected into params:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
# POST /posts/1/comments
|
|
76
|
+
# resource_params automatically includes { post: <Post:1>, post_id: 1 }
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Automatic Field Hiding
|
|
80
|
+
|
|
81
|
+
The parent field (`post`) is automatically hidden in forms since it's determined by the URL.
|
|
82
|
+
|
|
83
|
+
## Controller Helpers
|
|
84
|
+
|
|
85
|
+
### current_parent
|
|
86
|
+
|
|
87
|
+
Returns the parent record resolved from the URL:
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
# URL: /posts/123/comments
|
|
91
|
+
current_parent # => Post.find(123)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### parent_route_param
|
|
95
|
+
|
|
96
|
+
The URL parameter containing the parent ID:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
parent_route_param # => :post_id
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### parent_input_param
|
|
103
|
+
|
|
104
|
+
The association name on the child model:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
parent_input_param # => :post
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## URL Generation
|
|
111
|
+
|
|
112
|
+
Use `resource_url_for` with the `parent:` option:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
# Child collection
|
|
116
|
+
resource_url_for(Comment, parent: @post)
|
|
117
|
+
# => /posts/123/comments
|
|
118
|
+
|
|
119
|
+
# Child record
|
|
120
|
+
resource_url_for(@comment, parent: @post)
|
|
121
|
+
# => /posts/123/comments/456
|
|
122
|
+
|
|
123
|
+
# New child form
|
|
124
|
+
resource_url_for(Comment, action: :new, parent: @post)
|
|
125
|
+
# => /posts/123/comments/new
|
|
126
|
+
|
|
127
|
+
# Edit child
|
|
128
|
+
resource_url_for(@comment, action: :edit, parent: @post)
|
|
129
|
+
# => /posts/123/comments/456/edit
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Within a nested context, `parent:` defaults to `current_parent`:
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# In CommentsController under /posts/:post_id/comments
|
|
136
|
+
resource_url_for(@comment) # parent: current_parent is automatic
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Presentation Hooks
|
|
140
|
+
|
|
141
|
+
Control whether the parent field appears:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
class CommentsController < ResourceController
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Show parent in displays (default: false when nested)
|
|
148
|
+
def present_parent?
|
|
149
|
+
current_parent.nil? # Only show when accessed standalone
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Allow changing parent in forms (default: same as present_parent?)
|
|
153
|
+
def submit_parent?
|
|
154
|
+
false
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Policy Integration
|
|
160
|
+
|
|
161
|
+
### Parent Authorization
|
|
162
|
+
|
|
163
|
+
The parent is authorized for `:read?` before being returned:
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
# Inside current_parent
|
|
167
|
+
authorize! parent, to: :read?
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### Entity Scope Context
|
|
171
|
+
|
|
172
|
+
The parent is passed to child policies as `entity_scope`:
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
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
|
|
180
|
+
|
|
181
|
+
def update?
|
|
182
|
+
record.user_id == user.id
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def destroy?
|
|
186
|
+
record.user_id == user.id || entity_scope&.user_id == user.id
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Additional Scoping
|
|
192
|
+
|
|
193
|
+
Add role-based filtering on top of parent scoping:
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
class CommentPolicy < ResourcePolicy
|
|
197
|
+
relation_scope do |relation|
|
|
198
|
+
relation = super(relation) # Applies associated_with(entity_scope)
|
|
199
|
+
|
|
200
|
+
if user.moderator?
|
|
201
|
+
relation
|
|
202
|
+
else
|
|
203
|
+
relation.where(approved: true).or(relation.where(user: user))
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Association Panels
|
|
210
|
+
|
|
211
|
+
Associations listed in `permitted_associations` appear on the parent's show page:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
class PostPolicy < ResourcePolicy
|
|
215
|
+
def permitted_associations
|
|
216
|
+
%i[comments tags] # Shows panels for these
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Each panel displays:
|
|
222
|
+
- List of child records
|
|
223
|
+
- "Add" button linking to nested new action
|
|
224
|
+
- Edit/Delete actions per record
|
|
225
|
+
|
|
226
|
+
## Nested Forms
|
|
227
|
+
|
|
228
|
+
Edit child records inline within the parent form:
|
|
229
|
+
|
|
230
|
+
### 1. Enable Nested Attributes
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
class Post < ResourceRecord
|
|
234
|
+
has_many :comments
|
|
235
|
+
|
|
236
|
+
accepts_nested_attributes_for :comments,
|
|
237
|
+
allow_destroy: true,
|
|
238
|
+
reject_if: :all_blank
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### 2. Configure as Nested Input
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
class PostDefinition < ResourceDefinition
|
|
246
|
+
input :comments, as: :nested
|
|
247
|
+
end
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### 3. Permit in Policy
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
class PostPolicy < ResourcePolicy
|
|
254
|
+
def permitted_attributes_for_create
|
|
255
|
+
[:title, :content, comments_attributes: [:id, :body, :_destroy]]
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
## Nesting Depth
|
|
261
|
+
|
|
262
|
+
Plutonium supports **one level of nesting**:
|
|
263
|
+
|
|
264
|
+
- `/posts/:post_id/comments` (parent → child)
|
|
265
|
+
- `/comments/:comment_id/replies` (parent → child)
|
|
266
|
+
|
|
267
|
+
Not supported:
|
|
268
|
+
- `/posts/:post_id/comments/:comment_id/replies` (grandparent → parent → child)
|
|
269
|
+
|
|
270
|
+
### Working with Deep Hierarchies
|
|
271
|
+
|
|
272
|
+
Use through associations for data access:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
class Post < ResourceRecord
|
|
276
|
+
has_many :comments
|
|
277
|
+
has_many :replies, through: :comments
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Custom Routes on Nested Resources
|
|
282
|
+
|
|
283
|
+
Add member/collection routes:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
register_resource ::Comment do
|
|
287
|
+
member do
|
|
288
|
+
post :approve
|
|
289
|
+
post :flag
|
|
290
|
+
end
|
|
291
|
+
collection do
|
|
292
|
+
get :pending
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
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`
|
|
301
|
+
|
|
302
|
+
## Breadcrumbs
|
|
303
|
+
|
|
304
|
+
Nested resources automatically include parent in breadcrumbs:
|
|
305
|
+
|
|
306
|
+
```
|
|
307
|
+
Dashboard > Posts > My First Post > Comments > Comment #1
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
## Scoped Uniqueness
|
|
311
|
+
|
|
312
|
+
Validate uniqueness within parent:
|
|
313
|
+
|
|
314
|
+
```ruby
|
|
315
|
+
class Comment < ResourceRecord
|
|
316
|
+
belongs_to :post
|
|
317
|
+
validates :position, uniqueness: { scope: :post_id }
|
|
318
|
+
end
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
## Example: Blog with Comments
|
|
322
|
+
|
|
323
|
+
### Models
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
class Post < ResourceRecord
|
|
327
|
+
belongs_to :user
|
|
328
|
+
has_many :comments, dependent: :destroy
|
|
329
|
+
|
|
330
|
+
validates :title, :body, presence: true
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
class Comment < ResourceRecord
|
|
334
|
+
belongs_to :post
|
|
335
|
+
belongs_to :user
|
|
336
|
+
|
|
337
|
+
validates :body, presence: true
|
|
338
|
+
end
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### Policies
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
class PostPolicy < ResourcePolicy
|
|
345
|
+
def create?
|
|
346
|
+
user.present?
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def read?
|
|
350
|
+
true
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def permitted_attributes_for_create
|
|
354
|
+
%i[title body]
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def permitted_attributes_for_read
|
|
358
|
+
%i[title body user created_at]
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def permitted_associations
|
|
362
|
+
%i[comments]
|
|
363
|
+
end
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
class CommentPolicy < ResourcePolicy
|
|
367
|
+
def create?
|
|
368
|
+
user.present? && entity_scope.present?
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def read?
|
|
372
|
+
true
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def update?
|
|
376
|
+
record.user_id == user.id
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def destroy?
|
|
380
|
+
record.user_id == user.id || entity_scope&.user_id == user.id
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def permitted_attributes_for_create
|
|
384
|
+
%i[body]
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def permitted_attributes_for_read
|
|
388
|
+
%i[body user created_at]
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Controller (if customization needed)
|
|
394
|
+
|
|
395
|
+
```ruby
|
|
396
|
+
class CommentsController < ResourceController
|
|
397
|
+
private
|
|
398
|
+
|
|
399
|
+
def build_resource
|
|
400
|
+
super.tap do |comment|
|
|
401
|
+
comment.user = current_user
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Related
|
|
408
|
+
|
|
409
|
+
- [Adding Resources](./adding-resources)
|
|
410
|
+
- [Authorization](./authorization)
|
|
411
|
+
- [Creating Packages](./creating-packages)
|