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
|
@@ -1,256 +0,0 @@
|
|
|
1
|
-
# Deep Dive: Multitenancy
|
|
2
|
-
|
|
3
|
-
Plutonium is designed to make building sophisticated, multi-tenant applications straightforward. Multitenancy allows you to serve distinct groups of users (like different companies or teams) from a single application instance, ensuring each group's data is kept private and isolated.
|
|
4
|
-
|
|
5
|
-
This is achieved through a powerful feature called **Entity Scoping**, which automates data isolation, URL generation, and authorization with minimal setup.
|
|
6
|
-
|
|
7
|
-
::: tip What You'll Learn
|
|
8
|
-
- The core concept of Entity Scoping
|
|
9
|
-
- A step-by-step guide to configuring multitenancy
|
|
10
|
-
- The practical benefits of automatic data isolation and routing
|
|
11
|
-
- Advanced patterns for complex multi-tenant scenarios
|
|
12
|
-
- Best practices for security, testing, and performance
|
|
13
|
-
:::
|
|
14
|
-
|
|
15
|
-
## How Entity Scoping Works
|
|
16
|
-
|
|
17
|
-
Entity Scoping is the heart of Plutonium's multitenancy. Instead of manually filtering data in every controller and model, you declare a **Scoping Entity** for a user-facing **Portal**. This entity is typically a model like `Organization`, `Account`, or `Workspace`.
|
|
18
|
-
|
|
19
|
-
Once configured, Plutonium handles the rest automatically.
|
|
20
|
-
|
|
21
|
-
::: code-group
|
|
22
|
-
```ruby [Without Plutonium Scoping]
|
|
23
|
-
# Manual filtering is required everywhere
|
|
24
|
-
class PostsController < ApplicationController
|
|
25
|
-
def index
|
|
26
|
-
# Manually find the organization and scope posts
|
|
27
|
-
@organization = Organization.find(params[:organization_id])
|
|
28
|
-
@posts = @organization.posts
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def create
|
|
32
|
-
# Manually associate new records with the organization
|
|
33
|
-
@organization = Organization.find(params[:organization_id])
|
|
34
|
-
@post = @organization.posts.build(post_params)
|
|
35
|
-
# ...
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
```ruby [With Plutonium Scoping]
|
|
41
|
-
# Scoping is automatic and declarative
|
|
42
|
-
class PostsController < ResourceController
|
|
43
|
-
def index
|
|
44
|
-
# current_authorized_scope automatically returns posts scoped to the current organization
|
|
45
|
-
# When using path strategy: URL -> /organizations/123/posts
|
|
46
|
-
# When using subdomain strategy: URL -> /posts (on acme.yourapp.com)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def create
|
|
50
|
-
# resource_params automatically includes the current organization association
|
|
51
|
-
# Scoping works regardless of the strategy used
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
```
|
|
55
|
-
:::
|
|
56
|
-
|
|
57
|
-
## Setting Up Your Multi-Tenant Portal
|
|
58
|
-
|
|
59
|
-
Configuring multitenancy involves three main steps: defining your strategy, implementing it, and ensuring your models are correctly associated.
|
|
60
|
-
|
|
61
|
-
### 1. Configure Your Portal Engine
|
|
62
|
-
|
|
63
|
-
First, you tell your portal which entity to scope to and which strategy to use. This is done in the portal's `engine.rb` file using `scope_to_entity`.
|
|
64
|
-
|
|
65
|
-
A **Scoping Strategy** tells Plutonium how to identify the current tenant for each request.
|
|
66
|
-
|
|
67
|
-
::: code-group
|
|
68
|
-
```ruby [Path Strategy (Most Common)]
|
|
69
|
-
# packages/admin_portal/lib/engine.rb
|
|
70
|
-
# Tenant is identified by a URL parameter, e.g., /organizations/:organization_id
|
|
71
|
-
scope_to_entity Organization, strategy: :path
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
```ruby [Custom Strategy (Subdomain)]
|
|
75
|
-
# packages/customer_portal/lib/engine.rb
|
|
76
|
-
# Tenant is identified by the a custom strategy that checks the subdomain, e.g., acme.yourapp.com
|
|
77
|
-
scope_to_entity Organization, strategy: :current_organization
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
```ruby [Custom Parameter Key]
|
|
81
|
-
# packages/client_portal/lib/engine.rb
|
|
82
|
-
# Same as :path, but with a custom parameter name.
|
|
83
|
-
scope_to_entity Client,
|
|
84
|
-
strategy: :path,
|
|
85
|
-
param_key: :client_slug # URL -> /clients/:client_slug
|
|
86
|
-
```
|
|
87
|
-
:::
|
|
88
|
-
|
|
89
|
-
You can also use any custom name for your strategy method (the method name becomes the strategy).
|
|
90
|
-
|
|
91
|
-
### 2. Implement the Strategy Method
|
|
92
|
-
|
|
93
|
-
If you use any strategy other than `:path`, you must implement a controller method that returns the current tenant object. The method name must exactly match the strategy name.
|
|
94
|
-
|
|
95
|
-
This logic typically lives in your portal's base controller concern.
|
|
96
|
-
|
|
97
|
-
::: code-group
|
|
98
|
-
```ruby [Subdomain Strategy]
|
|
99
|
-
# packages/customer_portal/app/controllers/customer_portal/concerns/controller.rb
|
|
100
|
-
private
|
|
101
|
-
|
|
102
|
-
# Method name :current_organization matches the strategy
|
|
103
|
-
def current_organization
|
|
104
|
-
@current_organization ||= Organization.find_by!(subdomain: request.subdomain)
|
|
105
|
-
rescue ActiveRecord::RecordNotFound
|
|
106
|
-
redirect_to root_path, error: "Invalid organization subdomain"
|
|
107
|
-
end
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
```ruby [Session-Based Strategy]
|
|
111
|
-
# packages/internal_portal/app/controllers/internal_portal/concerns/controller.rb
|
|
112
|
-
# In engine.rb: scope_to_entity Workspace, strategy: :current_workspace
|
|
113
|
-
private
|
|
114
|
-
|
|
115
|
-
def current_workspace
|
|
116
|
-
return @current_workspace if defined?(@current_workspace)
|
|
117
|
-
|
|
118
|
-
workspace_id = session[:workspace_id] || params[:workspace_id]
|
|
119
|
-
@current_workspace = current_user.workspaces.find(workspace_id)
|
|
120
|
-
|
|
121
|
-
session[:workspace_id] = @current_workspace.id # Remember for next request
|
|
122
|
-
@current_workspace
|
|
123
|
-
rescue ActiveRecord::RecordNotFound
|
|
124
|
-
redirect_to workspace_selection_path, error: "Please select a workspace"
|
|
125
|
-
end
|
|
126
|
-
```
|
|
127
|
-
:::
|
|
128
|
-
|
|
129
|
-
### 3. Connect Your Models
|
|
130
|
-
|
|
131
|
-
Plutonium needs to understand how your resources relate to the scoping entity. It automatically discovers these relationships in three ways:
|
|
132
|
-
|
|
133
|
-
::: code-group
|
|
134
|
-
```ruby [1. Direct Association (Preferred)]
|
|
135
|
-
# The model belongs directly to the scoping entity.
|
|
136
|
-
class Post < ApplicationRecord
|
|
137
|
-
belongs_to :organization # Direct link
|
|
138
|
-
end
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
```ruby [2. Indirect Association]
|
|
142
|
-
# The model belongs to another model that has the direct link.
|
|
143
|
-
# Plutonium automatically follows the chain: Comment -> Post -> Organization
|
|
144
|
-
class Comment < ApplicationRecord
|
|
145
|
-
belongs_to :post
|
|
146
|
-
has_one :organization, through: :post # Indirect link
|
|
147
|
-
end
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
```ruby [3. Custom Scope (For Complex Cases)]
|
|
151
|
-
# For complex relationships, you can define an explicit scope.
|
|
152
|
-
# The scope name must be `associated_with_#{scoping_entity_name}`.
|
|
153
|
-
class Invoice < ApplicationRecord
|
|
154
|
-
belongs_to :customer
|
|
155
|
-
|
|
156
|
-
scope :associated_with_organization, ->(organization) do
|
|
157
|
-
joins(customer: :organization_memberships)
|
|
158
|
-
.where(organization_memberships: { organization_id: organization.id })
|
|
159
|
-
end
|
|
160
|
-
end
|
|
161
|
-
```
|
|
162
|
-
:::
|
|
163
|
-
|
|
164
|
-
## The Benefits in Practice
|
|
165
|
-
|
|
166
|
-
With this setup complete, you gain several powerful features across your portal.
|
|
167
|
-
|
|
168
|
-
### Tenant-Aware Routing
|
|
169
|
-
|
|
170
|
-
Your application's URLs are automatically transformed to include the tenant context, and Plutonium's URL helpers adapt accordingly.
|
|
171
|
-
|
|
172
|
-
- **URL Transformation:** Routes like `/posts` and `/posts/123` become `/:organization_id/posts` and `/:organization_id/posts/123`.
|
|
173
|
-
- **Automatic URL Generation:** The `resource_url_for` helper automatically includes the current tenant in all generated URLs, so links and forms work without any changes.
|
|
174
|
-
|
|
175
|
-
```ruby
|
|
176
|
-
# Both of these helpers are automatically aware of the current tenant.
|
|
177
|
-
resource_url_for(Post) # => "/organizations/456/posts"
|
|
178
|
-
form_with model: @post # action -> "/organizations/456/posts/123"
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
### Secure Data Scoping
|
|
182
|
-
|
|
183
|
-
All data access is automatically and securely filtered to the current tenant.
|
|
184
|
-
|
|
185
|
-
- **Query Scoping:** A query like `Post.all` is automatically converted to `current_scoped_entity.posts`. This prevents accidental data leaks.
|
|
186
|
-
- **Record Scoping:** When fetching a single record (e.g., for `show` or `edit`), Plutonium ensures it belongs to the current tenant. If not, it raises an `ActiveRecord::RecordNotFound` error, just as if the record didn't exist.
|
|
187
|
-
|
|
188
|
-
### Integrated Authorization
|
|
189
|
-
|
|
190
|
-
The current `entity_scope` is seamlessly passed to your authorization policies, allowing for fine-grained, tenant-aware rules.
|
|
191
|
-
|
|
192
|
-
```ruby
|
|
193
|
-
class PostPolicy < Plutonium::Resource::Policy
|
|
194
|
-
# `entity_scope` is automatically available in all policy methods.
|
|
195
|
-
authorize :entity_scope, allow_nil: true
|
|
196
|
-
|
|
197
|
-
def update?
|
|
198
|
-
# A user can only update a post if it belongs to their current tenant
|
|
199
|
-
# AND they are the author of the post.
|
|
200
|
-
record.organization == entity_scope && record.author == user
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
relation_scope do |relation|
|
|
204
|
-
# `super` automatically applies the base entity scoping.
|
|
205
|
-
relation = super(relation)
|
|
206
|
-
|
|
207
|
-
# Add more logic: Admins can see all posts within their organization,
|
|
208
|
-
# but others can only see published posts.
|
|
209
|
-
user.admin? ? relation : relation.where(published: true)
|
|
210
|
-
end
|
|
211
|
-
end
|
|
212
|
-
```
|
|
213
|
-
|
|
214
|
-
## Security Best Practices
|
|
215
|
-
|
|
216
|
-
Securing a multi-tenant application is critical. While Plutonium provides strong defaults, you must ensure your implementation is secure.
|
|
217
|
-
|
|
218
|
-
::: warning Always Validate Tenant Access
|
|
219
|
-
A user might belong to multiple tenants. It's crucial to verify that the logged-in user has permission to access the tenant specified in the URL. Failure to do so could allow a user to see data from another organization they don't belong to.
|
|
220
|
-
:::
|
|
221
|
-
|
|
222
|
-
```ruby
|
|
223
|
-
# ✅ Good: Proper tenant validation
|
|
224
|
-
# In your custom strategy method or a before_action:
|
|
225
|
-
private
|
|
226
|
-
|
|
227
|
-
def current_organization
|
|
228
|
-
@current_organization ||= begin
|
|
229
|
-
# Find the organization from the URL
|
|
230
|
-
organization = Organization.find(params[:organization_id])
|
|
231
|
-
|
|
232
|
-
# CRITICAL: Verify the current user is a member of that organization
|
|
233
|
-
unless current_user.organizations.include?(organization)
|
|
234
|
-
raise ActionPolicy::Unauthorized, "Access denied to organization"
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
organization
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
# ❌ Dangerous: No access validation
|
|
242
|
-
def current_organization
|
|
243
|
-
# This allows ANY authenticated user to access ANY organization's data
|
|
244
|
-
# simply by changing the ID in the URL.
|
|
245
|
-
Organization.find(params[:organization_id])
|
|
246
|
-
end
|
|
247
|
-
```
|
|
248
|
-
|
|
249
|
-
## Advanced Patterns
|
|
250
|
-
|
|
251
|
-
Plutonium's scoping is flexible enough to handle more complex scenarios.
|
|
252
|
-
|
|
253
|
-
- **Multi-Level Tenancy:** For hierarchical tenancy (e.g., Company -> Department), you can apply the primary scope at the engine level and add secondary scoping logic inside your policies' `relation_scope`.
|
|
254
|
-
- **Cross-Tenant Data Access:** For resources that can be shared, define a custom `associated_with_...` scope that includes both shared records and records belonging to the current tenant.
|
|
255
|
-
- **Tenant Switching:** Build a controller that allows users to change their active tenant by updating a `session` key, then use a session-based scoping strategy to read it.
|
|
256
|
-
- **API Multitenancy:** Create a custom scoping strategy (e.g., `:api_tenant`) that authenticates and identifies the tenant based on an API key or JWT from the request headers.
|
|
@@ -1,390 +0,0 @@
|
|
|
1
|
-
# Working with Resources
|
|
2
|
-
|
|
3
|
-
::: tip What you'll learn
|
|
4
|
-
- How to create and manage resources in Plutonium
|
|
5
|
-
- Understanding resource definitions and configurations
|
|
6
|
-
- Working with fields, associations, and nested resources
|
|
7
|
-
- Implementing resource policies and scoping
|
|
8
|
-
- Best practices for resource organization
|
|
9
|
-
:::
|
|
10
|
-
|
|
11
|
-
## Introduction
|
|
12
|
-
|
|
13
|
-
Resources are the core building blocks of a Plutonium application. A resource represents a model in your application that can be managed through a consistent interface, complete with views, controllers, and policies.
|
|
14
|
-
|
|
15
|
-
## Creating a Resource
|
|
16
|
-
|
|
17
|
-
The fastest way to create a resource is using the scaffold generator:
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
rails generate pu:res:scaffold Blog user:belongs_to \
|
|
21
|
-
title:string content:text 'published_at:datetime?'
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
This generates several files, including:
|
|
25
|
-
|
|
26
|
-
::: code-group
|
|
27
|
-
```ruby [app/models/blog.rb]
|
|
28
|
-
class Blog < ResourceRecord
|
|
29
|
-
belongs_to :user
|
|
30
|
-
end
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
```ruby [app/policies/blog_policy.rb]
|
|
34
|
-
class BlogPolicy < Plutonium::Resource::Policy
|
|
35
|
-
def create?
|
|
36
|
-
true
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def read?
|
|
40
|
-
true
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def permitted_attributes_for_create
|
|
44
|
-
%i[user title content published_at]
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def permitted_attributes_for_read
|
|
48
|
-
%i[user title content published_at]
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
```ruby [app/definitions/blog_definition.rb]
|
|
54
|
-
class BlogDefinition < Plutonium::Resource::Definition
|
|
55
|
-
end
|
|
56
|
-
```
|
|
57
|
-
:::
|
|
58
|
-
|
|
59
|
-
## Resource Definitions
|
|
60
|
-
|
|
61
|
-
Resource definitions customize how a resource behaves in your application. They define:
|
|
62
|
-
- Fields and their types
|
|
63
|
-
- Available actions
|
|
64
|
-
- Search and filtering capabilities
|
|
65
|
-
- Sorting options
|
|
66
|
-
- Display configurations
|
|
67
|
-
|
|
68
|
-
### Basic Field Configuration
|
|
69
|
-
|
|
70
|
-
::: code-group
|
|
71
|
-
```ruby [Simple Fields]
|
|
72
|
-
class BlogDefinition < Plutonium::Resource::Definition
|
|
73
|
-
# Basic field definitions
|
|
74
|
-
field :content, as: :text
|
|
75
|
-
|
|
76
|
-
# Field with custom display options
|
|
77
|
-
field :published_at,
|
|
78
|
-
as: :datetime,
|
|
79
|
-
hint: "When this post should be published"
|
|
80
|
-
end
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
```ruby [Custom Displays]
|
|
84
|
-
class BlogDefinition < Plutonium::Resource::Definition
|
|
85
|
-
# Customize how fields are displayed
|
|
86
|
-
display :title, wrapper: {class: "col-span-full"}
|
|
87
|
-
display :content, wrapper: {class: "col-span-full"} do |f|
|
|
88
|
-
f.text_tag class: "format dark:format-invert"
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Custom column display in tables
|
|
92
|
-
column :published_at, align: :end
|
|
93
|
-
end
|
|
94
|
-
```
|
|
95
|
-
:::
|
|
96
|
-
|
|
97
|
-
<!--
|
|
98
|
-
### Working with Associations
|
|
99
|
-
|
|
100
|
-
Plutonium makes it easy to work with Rails associations:
|
|
101
|
-
|
|
102
|
-
```ruby
|
|
103
|
-
class BlogDefinition < Plutonium::Resource::Definition
|
|
104
|
-
# Define belongs_to association
|
|
105
|
-
field :user, as: :belongs_to
|
|
106
|
-
|
|
107
|
-
# Has-many association with inline creation
|
|
108
|
-
field :comments do |f|
|
|
109
|
-
f.has_many_tag nested: true
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Configure nested attributes
|
|
113
|
-
define_nested_input :comments,
|
|
114
|
-
inputs: %i[content user],
|
|
115
|
-
limit: 3 do |input|
|
|
116
|
-
input.define_field_input :content,
|
|
117
|
-
type: :text,
|
|
118
|
-
hint: "Keep it constructive"
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
```
|
|
122
|
-
-->
|
|
123
|
-
|
|
124
|
-
### Adding Custom Actions
|
|
125
|
-
|
|
126
|
-
Beyond CRUD, you can add custom actions to your resources:
|
|
127
|
-
|
|
128
|
-
```ruby
|
|
129
|
-
# app/interactions/blogs/publish.rb
|
|
130
|
-
module Blogs
|
|
131
|
-
class Publish < Plutonium::Resource::Interaction
|
|
132
|
-
# Define what this interaction accepts
|
|
133
|
-
attribute :resource, class: Blog
|
|
134
|
-
attribute :publish_date, :date, default: -> { Time.current }
|
|
135
|
-
|
|
136
|
-
presents label: "Publish Blog",
|
|
137
|
-
icon: Phlex::TablerIcons::Send,
|
|
138
|
-
description: "Make this blog post public"
|
|
139
|
-
|
|
140
|
-
private
|
|
141
|
-
|
|
142
|
-
def execute
|
|
143
|
-
if resource.update(
|
|
144
|
-
published_at: publish_date
|
|
145
|
-
)
|
|
146
|
-
succeed(resource)
|
|
147
|
-
.with_message("Blog post published successfully")
|
|
148
|
-
.with_redirect_response(resource)
|
|
149
|
-
else
|
|
150
|
-
failed(resource.errors)
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# app/definitions/blog_definition.rb
|
|
157
|
-
class BlogDefinition < Plutonium::Resource::Definition
|
|
158
|
-
# Register the custom action
|
|
159
|
-
action :publish,
|
|
160
|
-
interaction: Blogs::Publish,
|
|
161
|
-
category: :primary
|
|
162
|
-
end
|
|
163
|
-
```
|
|
164
|
-
|
|
165
|
-
### Search and Filtering
|
|
166
|
-
|
|
167
|
-
Add search and filtering capabilities to your resources:
|
|
168
|
-
|
|
169
|
-
```ruby
|
|
170
|
-
class BlogDefinition < Plutonium::Resource::Definition
|
|
171
|
-
# Enable full-text search
|
|
172
|
-
search do |scope, query|
|
|
173
|
-
scope.where("title ILIKE ? OR content ILIKE ?",
|
|
174
|
-
"%#{query}%", "%#{query}%")
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
# Add filters
|
|
178
|
-
filter :published_at,
|
|
179
|
-
with: DateFilter,
|
|
180
|
-
predicate: :gteq
|
|
181
|
-
|
|
182
|
-
# Add scopes
|
|
183
|
-
scope :published do |scope|
|
|
184
|
-
scope.where.not(published_at: nil)
|
|
185
|
-
end
|
|
186
|
-
scope :draft do |scope|
|
|
187
|
-
scope.where(published_at: nil)
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
# Configure sorting
|
|
191
|
-
sort :title
|
|
192
|
-
sort :published_at
|
|
193
|
-
|
|
194
|
-
# Configure default sorting (newest first)
|
|
195
|
-
default_sort :published_at, :desc
|
|
196
|
-
end
|
|
197
|
-
```
|
|
198
|
-
|
|
199
|
-
## Resource Policies
|
|
200
|
-
|
|
201
|
-
Policies control access to your resources:
|
|
202
|
-
|
|
203
|
-
```ruby
|
|
204
|
-
class BlogPolicy < Plutonium::Resource::Policy
|
|
205
|
-
def permitted_attributes_for_create
|
|
206
|
-
%i[title content state published_at user_id]
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
def permitted_associations
|
|
210
|
-
%i[comments]
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def create?
|
|
214
|
-
# Allow logged in users to create blogs
|
|
215
|
-
user.present?
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
def update?
|
|
219
|
-
# Users can only edit their own blogs
|
|
220
|
-
record.user_id == user.id
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
def publish?
|
|
224
|
-
# Only editors can publish
|
|
225
|
-
user.editor? && record.draft?
|
|
226
|
-
end
|
|
227
|
-
|
|
228
|
-
# Scope visible records
|
|
229
|
-
relation_scope do |relation|
|
|
230
|
-
relation = super(relation)
|
|
231
|
-
next relation unless user.admin?
|
|
232
|
-
|
|
233
|
-
relation.with_deleted
|
|
234
|
-
end
|
|
235
|
-
end
|
|
236
|
-
```
|
|
237
|
-
|
|
238
|
-
## Best Practices
|
|
239
|
-
|
|
240
|
-
::: tip Resource Organization
|
|
241
|
-
1. Keep resource definitions focused and cohesive
|
|
242
|
-
2. Use packages to organize related resources
|
|
243
|
-
3. Leverage policy scopes for authorization
|
|
244
|
-
4. Extract complex logic into interactions
|
|
245
|
-
5. Use presenters for view-specific logic
|
|
246
|
-
:::
|
|
247
|
-
|
|
248
|
-
::: warning Common Pitfalls
|
|
249
|
-
- Avoid putting business logic in definitions
|
|
250
|
-
- Don't bypass policy checks
|
|
251
|
-
- Remember to scope resources appropriately
|
|
252
|
-
- Test your interactions and policies
|
|
253
|
-
:::
|
|
254
|
-
|
|
255
|
-
# Deep Dive: Building a Resource
|
|
256
|
-
|
|
257
|
-
In Plutonium, a **Resource** is the central concept for managing your application's data. It's more than just a model—it's a complete package that includes the model, controller, policy, views, and all the configuration that ties them together.
|
|
258
|
-
|
|
259
|
-
This guide will walk you through building a complete `Post` resource from scratch, demonstrating how Plutonium's different modules work together to create a powerful and consistent user experience.
|
|
260
|
-
|
|
261
|
-
## 1. Generating the Resource
|
|
262
|
-
|
|
263
|
-
We'll start with the scaffold generator, which creates all the necessary files for our `Post` resource.
|
|
264
|
-
|
|
265
|
-
```bash
|
|
266
|
-
rails generate pu:res:scaffold Post user:belongs_to title:string content:text published_at:datetime
|
|
267
|
-
```
|
|
268
|
-
|
|
269
|
-
This command generates:
|
|
270
|
-
- A `Post` model with the specified attributes and a `belongs_to :user` association.
|
|
271
|
-
- A `PostsController`.
|
|
272
|
-
- A `PostPolicy` with basic permissions.
|
|
273
|
-
- A `PostDefinition` file, which will be the focus of this guide.
|
|
274
|
-
|
|
275
|
-
## 2. Configuring Display & Forms (The Definition File)
|
|
276
|
-
|
|
277
|
-
The **Definition** file (`app/definitions/post_definition.rb`) is where you declaratively configure how your resource is displayed and edited. Let's start by defining the fields for our table, detail page, and form.
|
|
278
|
-
|
|
279
|
-
::: code-group
|
|
280
|
-
```ruby [app/definitions/post_definition.rb]
|
|
281
|
-
class PostDefinition < Plutonium::Resource::Definition
|
|
282
|
-
# Configure the table (index view)
|
|
283
|
-
column :user, label: "Author"
|
|
284
|
-
column :published_at, as: :datetime
|
|
285
|
-
|
|
286
|
-
# Configure the detail page (show view)
|
|
287
|
-
display :user, label: "Author"
|
|
288
|
-
display :published_at, as: :datetime
|
|
289
|
-
display :content, as: :rich_text
|
|
290
|
-
|
|
291
|
-
# Configure the form (new/edit views)
|
|
292
|
-
input :user, as: :select, label: "Author" # Explicitly use a select input
|
|
293
|
-
input :content, as: :rich_text
|
|
294
|
-
end
|
|
295
|
-
```
|
|
296
|
-
```ruby [app/policies/post_policy.rb]
|
|
297
|
-
# In the policy, we must permit these attributes to be read and written.
|
|
298
|
-
class PostPolicy < Plutonium::Resource::Policy
|
|
299
|
-
# ...
|
|
300
|
-
|
|
301
|
-
def permitted_attributes_for_read
|
|
302
|
-
[:title, :user, :published_at, :content]
|
|
303
|
-
end
|
|
304
|
-
|
|
305
|
-
def permitted_attributes_for_create
|
|
306
|
-
[:title, :user_id, :content]
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
def permitted_attributes_for_update
|
|
310
|
-
permitted_attributes_for_create
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
|
-
```
|
|
314
|
-
:::
|
|
315
|
-
|
|
316
|
-
Here, we've used the `display` helper to control the `index` and `show` views, and the `input` helper for the forms. We've also specified `:rich_text` to get a WYSIWYG editor for our content. Notice that we also had to permit these attributes in the policy.
|
|
317
|
-
|
|
318
|
-
## 3. Adding a Custom Action
|
|
319
|
-
|
|
320
|
-
Standard CRUD is great, but most applications have custom business logic. Let's add a "Publish" action. This involves creating an **Interaction** for the logic and registering it in the definition.
|
|
321
|
-
|
|
322
|
-
::: code-group
|
|
323
|
-
```ruby [app/interactions/post_interactions/publish.rb]
|
|
324
|
-
module PostInteractions
|
|
325
|
-
class Publish < Plutonium::Resource::Interaction
|
|
326
|
-
attribute :resource, class: "Post"
|
|
327
|
-
|
|
328
|
-
private
|
|
329
|
-
|
|
330
|
-
def execute
|
|
331
|
-
resource.update(published_at: Time.current)
|
|
332
|
-
succeed(resource).with_message("Post was successfully published.")
|
|
333
|
-
end
|
|
334
|
-
end
|
|
335
|
-
end
|
|
336
|
-
```
|
|
337
|
-
```ruby [app/definitions/post_definition.rb]
|
|
338
|
-
class PostDefinition < Plutonium::Resource::Definition
|
|
339
|
-
# ... (display and input helpers)
|
|
340
|
-
|
|
341
|
-
action :publish,
|
|
342
|
-
interaction: "PostInteractions::Publish",
|
|
343
|
-
category: :primary
|
|
344
|
-
end
|
|
345
|
-
```
|
|
346
|
-
```ruby [app/policies/post_policy.rb]
|
|
347
|
-
class PostPolicy < Plutonium::Resource::Policy
|
|
348
|
-
# ... (attribute permissions)
|
|
349
|
-
|
|
350
|
-
# An action is only visible if its policy returns true.
|
|
351
|
-
def publish?
|
|
352
|
-
# Only show the publish button if the post is not yet published.
|
|
353
|
-
update? && record.published_at.nil?
|
|
354
|
-
end
|
|
355
|
-
end
|
|
356
|
-
```
|
|
357
|
-
:::
|
|
358
|
-
|
|
359
|
-
We now have a "Publish" button on our `Post` detail page that only appears when appropriate, thanks to the combination of the Interaction, Definition, and Policy.
|
|
360
|
-
|
|
361
|
-
## 4. Configuring Search, Filters, and Sorting
|
|
362
|
-
|
|
363
|
-
To make our resource table more useful, let's add search, filtering, and sorting capabilities. This is all handled declaratively in the definition file.
|
|
364
|
-
|
|
365
|
-
```ruby
|
|
366
|
-
# app/definitions/post_definition.rb
|
|
367
|
-
class PostDefinition < Plutonium::Resource::Definition
|
|
368
|
-
# ... (display, input, and action helpers)
|
|
369
|
-
|
|
370
|
-
# Enable full-text search across title and content
|
|
371
|
-
search do |scope, query|
|
|
372
|
-
scope.where("title ILIKE :q OR content ILIKE :q", q: "%#{query}%")
|
|
373
|
-
end
|
|
374
|
-
|
|
375
|
-
# Add filters to the sidebar
|
|
376
|
-
filter :published, with: ->(scope, value) { value ? scope.where.not(published_at: nil) : scope.where(published_at: nil) }, as: :boolean
|
|
377
|
-
filter :user, as: :select, choices: User.pluck(:name, :id)
|
|
378
|
-
|
|
379
|
-
# Define named scopes that appear as buttons
|
|
380
|
-
scope :all
|
|
381
|
-
scope :published, -> { where.not(published_at: nil) }
|
|
382
|
-
scope :drafts, -> { where(published_at: nil) }
|
|
383
|
-
|
|
384
|
-
# Configure which columns are sortable
|
|
385
|
-
sort :title
|
|
386
|
-
sort :published_at
|
|
387
|
-
end
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
With just a few lines of code, we now have a powerful and interactive table view for our posts, complete with a search bar, filter sidebar, scope buttons, and sortable columns. This demonstrates how the **Resource** module integrates seamlessly with the **Query** module.
|