plutonium 0.50.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +85 -102
- data/.claude/skills/plutonium-app/SKILL.md +572 -0
- data/.claude/skills/plutonium-auth/SKILL.md +163 -300
- data/.claude/skills/plutonium-behavior/SKILL.md +838 -0
- data/.claude/skills/plutonium-resource/SKILL.md +1176 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +655 -0
- data/.claude/skills/plutonium-testing/SKILL.md +6 -5
- data/.claude/skills/plutonium-ui/SKILL.md +900 -0
- data/CHANGELOG.md +27 -2
- data/Rakefile +2 -1
- data/app/assets/plutonium.css +1 -11
- data/app/assets/plutonium.js +1009 -1214
- data/app/assets/plutonium.js.map +3 -3
- data/app/assets/plutonium.min.js +52 -51
- data/app/assets/plutonium.min.js.map +3 -3
- data/docs/.vitepress/config.ts +37 -27
- data/docs/getting-started/index.md +22 -29
- data/docs/getting-started/installation.md +37 -80
- data/docs/getting-started/tutorial/index.md +4 -5
- data/docs/guides/adding-resources.md +66 -377
- data/docs/guides/authentication.md +94 -463
- data/docs/guides/authorization.md +124 -370
- data/docs/guides/creating-packages.md +94 -296
- data/docs/guides/custom-actions.md +121 -441
- data/docs/guides/index.md +22 -42
- data/docs/guides/multi-tenancy.md +116 -187
- data/docs/guides/nested-resources.md +103 -431
- data/docs/guides/search-filtering.md +123 -240
- data/docs/guides/testing.md +5 -4
- data/docs/guides/theming.md +157 -407
- data/docs/guides/troubleshooting.md +5 -3
- data/docs/guides/user-invites.md +106 -425
- data/docs/guides/user-profile.md +76 -243
- data/docs/index.md +1 -1
- data/docs/reference/app/generators.md +517 -0
- data/docs/reference/app/index.md +158 -0
- data/docs/reference/app/packages.md +146 -0
- data/docs/reference/app/portals.md +377 -0
- data/docs/reference/auth/accounts.md +230 -0
- data/docs/reference/auth/index.md +88 -0
- data/docs/reference/auth/profile.md +185 -0
- data/docs/reference/behavior/controllers.md +395 -0
- data/docs/reference/behavior/index.md +22 -0
- data/docs/reference/behavior/interactions.md +341 -0
- data/docs/reference/behavior/policies.md +417 -0
- data/docs/reference/index.md +56 -49
- data/docs/reference/resource/actions.md +423 -0
- data/docs/reference/resource/definition.md +508 -0
- data/docs/reference/resource/index.md +50 -0
- data/docs/reference/resource/model.md +348 -0
- data/docs/reference/resource/query.md +305 -0
- data/docs/reference/tenancy/entity-scoping.md +361 -0
- data/docs/reference/tenancy/index.md +36 -0
- data/docs/reference/tenancy/invites.md +393 -0
- data/docs/reference/tenancy/nested-resources.md +267 -0
- data/docs/reference/testing/index.md +287 -0
- data/docs/reference/ui/assets.md +400 -0
- data/docs/reference/ui/components.md +165 -0
- data/docs/reference/ui/displays.md +104 -0
- data/docs/reference/ui/forms.md +284 -0
- data/docs/reference/ui/index.md +30 -0
- data/docs/reference/ui/layouts.md +106 -0
- data/docs/reference/ui/pages.md +189 -0
- data/docs/reference/ui/tables.md +117 -0
- data/docs/superpowers/specs/2026-05-09-typeahead-endpoint-design.md +203 -0
- data/docs/superpowers/specs/2026-05-12-skill-compaction-design.md +99 -0
- data/docs/superpowers/specs/2026-05-13-docs-restructure-design.md +186 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/core/update/update_generator.rb +0 -20
- data/lib/generators/pu/invites/install_generator.rb +1 -0
- data/lib/plutonium/definition/base.rb +1 -1
- data/lib/plutonium/definition/{views.rb → index_views.rb} +21 -20
- data/lib/plutonium/helpers/turbo_helper.rb +11 -0
- data/lib/plutonium/helpers/turbo_stream_actions_helper.rb +14 -0
- data/lib/plutonium/resource/controller.rb +1 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +19 -1
- data/lib/plutonium/resource/controllers/typeahead.rb +180 -0
- data/lib/plutonium/resource/policy.rb +7 -0
- data/lib/plutonium/routing/mapper_extensions.rb +15 -0
- data/lib/plutonium/ui/component/methods.rb +4 -0
- data/lib/plutonium/ui/form/base.rb +6 -2
- data/lib/plutonium/ui/form/components/json.rb +58 -0
- data/lib/plutonium/ui/form/components/resource_select.rb +62 -8
- data/lib/plutonium/ui/form/components/secure_association.rb +98 -22
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +83 -0
- data/lib/plutonium/ui/form/resource.rb +0 -4
- data/lib/plutonium/ui/grid/resource.rb +1 -1
- data/lib/plutonium/ui/layout/base.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +0 -7
- data/lib/plutonium/ui/page/index.rb +4 -4
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium.rb +8 -0
- data/lib/tasks/release.rake +15 -1
- data/package.json +10 -10
- data/src/css/slim_select.css +4 -0
- data/src/js/controllers/slim_select_controller.js +61 -0
- data/src/js/turbo/turbo_actions.js +33 -0
- data/yarn.lock +553 -543
- metadata +44 -33
- data/.claude/skills/plutonium-assets/SKILL.md +0 -512
- data/.claude/skills/plutonium-controller/SKILL.md +0 -396
- data/.claude/skills/plutonium-create-resource/SKILL.md +0 -303
- data/.claude/skills/plutonium-definition/SKILL.md +0 -1223
- data/.claude/skills/plutonium-entity-scoping/SKILL.md +0 -317
- data/.claude/skills/plutonium-forms/SKILL.md +0 -465
- data/.claude/skills/plutonium-installation/SKILL.md +0 -331
- data/.claude/skills/plutonium-interaction/SKILL.md +0 -413
- data/.claude/skills/plutonium-invites/SKILL.md +0 -408
- data/.claude/skills/plutonium-model/SKILL.md +0 -440
- data/.claude/skills/plutonium-nested-resources/SKILL.md +0 -360
- data/.claude/skills/plutonium-package/SKILL.md +0 -198
- data/.claude/skills/plutonium-policy/SKILL.md +0 -456
- data/.claude/skills/plutonium-portal/SKILL.md +0 -410
- data/.claude/skills/plutonium-views/SKILL.md +0 -651
- data/docs/reference/assets/index.md +0 -496
- data/docs/reference/controller/index.md +0 -412
- data/docs/reference/definition/actions.md +0 -462
- data/docs/reference/definition/fields.md +0 -383
- data/docs/reference/definition/index.md +0 -326
- data/docs/reference/definition/query.md +0 -351
- data/docs/reference/generators/index.md +0 -648
- data/docs/reference/interaction/index.md +0 -449
- data/docs/reference/model/features.md +0 -248
- data/docs/reference/model/index.md +0 -218
- data/docs/reference/policy/index.md +0 -456
- data/docs/reference/portal/index.md +0 -379
- data/docs/reference/views/forms.md +0 -411
- data/docs/reference/views/index.md +0 -544
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plutonium-behavior
|
|
3
|
+
description: Use BEFORE writing or overriding a Plutonium controller, policy, or interaction class. Covers controller hooks, policy methods, permitted attributes, relation_scope, interaction structure, outcomes, and chaining. The single source for "how does this resource actually do things".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Behavior — Controllers, Policies, Interactions
|
|
7
|
+
|
|
8
|
+
The behavior layer is intentionally thin: **controllers route**, **policies authorize**, **interactions act**. Registering an action and rendering it lives in [[plutonium-resource]] — this skill covers how to *write* the controller hook, policy method, or interaction class behind it.
|
|
9
|
+
|
|
10
|
+
For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]].
|
|
11
|
+
|
|
12
|
+
## 🚨 Critical (read first)
|
|
13
|
+
|
|
14
|
+
- **Use generators.** `pu:res:scaffold` creates the base trio (controller/policy/interaction-base); `pu:res:conn` creates portal-specific versions. Never hand-write them.
|
|
15
|
+
- **Don't override CRUD actions.** Use hooks (`resource_params`, `redirect_url_after_submit`, presentation hooks). Overriding `create`/`update` usually breaks authorization, params filtering, or both.
|
|
16
|
+
- **`create?` and `read?` default to `false`.** Always override them explicitly. Derived methods (`update?`, `show?`, etc.) inherit automatically.
|
|
17
|
+
- **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detection works; production raises.
|
|
18
|
+
- **`ActiveRecord::RecordInvalid` is NOT rescued automatically in interactions.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
|
|
19
|
+
- **Return `succeed(...)` or `failed(...)`** from `execute` — the controller can't tell what happened otherwise.
|
|
20
|
+
- **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
|
|
21
|
+
- **`relation_scope` must compose with `default_relation_scope(relation)` explicitly** — not `super`. See [[plutonium-tenancy]].
|
|
22
|
+
- **For `has_cents` fields, use the virtual name (`:price`), not `:price_cents`** in `permitted_attributes_for_*`.
|
|
23
|
+
- **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy (undefined methods return `false`).
|
|
24
|
+
- **Named custom routes.** When adding custom routes, always pass `as:` so `resource_url_for` can build URLs.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Part 1 — Controllers
|
|
29
|
+
|
|
30
|
+
Plutonium controllers ship full CRUD out of the box; nearly all customization lives in definitions / policies / interactions. The controller stays thin.
|
|
31
|
+
|
|
32
|
+
## Base classes
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# app/controllers/resource_controller.rb (installed once)
|
|
36
|
+
class ResourceController < ApplicationController
|
|
37
|
+
include Plutonium::Resource::Controller
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# app/controllers/posts_controller.rb (per resource, generated by pu:res:scaffold)
|
|
41
|
+
class PostsController < ::ResourceController
|
|
42
|
+
# Empty — all CRUD inherited
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## What you get for free
|
|
47
|
+
|
|
48
|
+
| Action | Route | Purpose |
|
|
49
|
+
|--------|-------|---------|
|
|
50
|
+
| `index` | GET `/posts` | List with pagination, search, filters, sorting |
|
|
51
|
+
| `show` | GET `/posts/:id` | Display single record |
|
|
52
|
+
| `new` | GET `/posts/new` | Form |
|
|
53
|
+
| `create` | POST `/posts` | Create |
|
|
54
|
+
| `edit` | GET `/posts/:id/edit` | Form |
|
|
55
|
+
| `update` | PATCH `/posts/:id` | Update |
|
|
56
|
+
| `destroy` | DELETE `/posts/:id` | Delete |
|
|
57
|
+
|
|
58
|
+
Plus interactive-action routes for every action declared in the definition.
|
|
59
|
+
|
|
60
|
+
## Where customization belongs
|
|
61
|
+
|
|
62
|
+
| Concern | Lives in |
|
|
63
|
+
|---|---|
|
|
64
|
+
| Field rendering (inputs, displays, columns) | Definition |
|
|
65
|
+
| Search, filters, scopes, sorting | Definition |
|
|
66
|
+
| Custom operations (publish, archive, import) | Interaction (+ action in definition) |
|
|
67
|
+
| Authorization rules | Policy |
|
|
68
|
+
| Form/show/page chrome | Definition (custom page classes) |
|
|
69
|
+
| **Custom redirect logic** | **Controller hook** |
|
|
70
|
+
| **Param munging** | **Controller hook** |
|
|
71
|
+
| **Custom index query shape** | **Controller hook** |
|
|
72
|
+
| **Presentation of parent/entity fields** | **Controller hook** |
|
|
73
|
+
|
|
74
|
+
## Override hooks
|
|
75
|
+
|
|
76
|
+
All hooks are private methods. Override only the ones you need.
|
|
77
|
+
|
|
78
|
+
### Redirect hooks
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class PostsController < ::ResourceController
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Where to go after create/update: "show" (default), "edit", "new", "index"
|
|
85
|
+
def preferred_action_after_submit = "edit"
|
|
86
|
+
|
|
87
|
+
# Custom URL after create/update (overrides preferred_action_after_submit)
|
|
88
|
+
def redirect_url_after_submit = posts_path
|
|
89
|
+
|
|
90
|
+
# Custom URL after destroy
|
|
91
|
+
def redirect_url_after_destroy = posts_path
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Parameter hook
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
def resource_params
|
|
99
|
+
params = super
|
|
100
|
+
params[:tags] = params[:tags].split(",") if params[:tags].is_a?(String)
|
|
101
|
+
params
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Index query hook
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
def filtered_resource_collection
|
|
109
|
+
base = current_authorized_scope
|
|
110
|
+
base = base.featured if params[:featured]
|
|
111
|
+
current_query_object.apply(base, raw_resource_query_params)
|
|
112
|
+
end
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### Presentation hooks
|
|
116
|
+
|
|
117
|
+
Control whether parent / scoped-entity fields appear in forms and displays. Defaults are `false` (hidden, since they're inferred from the URL/portal).
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
def present_parent? = true # show parent field on displays
|
|
121
|
+
def submit_parent? = true # include parent field in forms (default: tracks present_parent?)
|
|
122
|
+
def present_scoped_entity? = true
|
|
123
|
+
def submit_scoped_entity? = true
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Custom actions
|
|
127
|
+
|
|
128
|
+
Prefer **interactive actions** (definition + interaction) for anything with business logic. The only reason to hand-write a controller action is unusual flows (custom response shapes, external service callbacks, etc.).
|
|
129
|
+
|
|
130
|
+
```ruby
|
|
131
|
+
class PostsController < ::ResourceController
|
|
132
|
+
def publish
|
|
133
|
+
authorize_current!(resource_record!, to: :publish?)
|
|
134
|
+
resource_record!.update!(published: true)
|
|
135
|
+
redirect_to resource_url_for(resource_record!), notice: "Published!"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Route must be named:
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
resources :posts do
|
|
144
|
+
member { post :publish, as: :publish } # `as:` required!
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Key methods
|
|
149
|
+
|
|
150
|
+
### Resource access
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
resource_class # The model class
|
|
154
|
+
resource_record! # Current record (raises if not found)
|
|
155
|
+
resource_record? # Current record (nil if not found)
|
|
156
|
+
resource_params # Permitted params for create/update
|
|
157
|
+
current_parent # Parent record for nested routes
|
|
158
|
+
current_scoped_entity # Tenant entity for the current portal (nil if not scoped)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Authorization
|
|
162
|
+
|
|
163
|
+
**Current resource:**
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
authorize_current!(record, to: :action?) # Check permission
|
|
167
|
+
current_policy
|
|
168
|
+
permitted_attributes
|
|
169
|
+
current_authorized_scope # Scoped records the user can access
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Other resources** (cross-resource auth — use these, not raw `where` / `find`):
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
authorize! other_record, to: :show? # ActionPolicy — raises if denied
|
|
176
|
+
allowed_to?(:show?, other_record) # Boolean check
|
|
177
|
+
policy_for(OtherModel) # Policy instance for class or record
|
|
178
|
+
policy_for(other_record).show?
|
|
179
|
+
|
|
180
|
+
authorized_resource_scope(OtherModel) # Scope on the model class
|
|
181
|
+
authorized_resource_scope(OtherModel, relation: OtherModel.published) # On a relation
|
|
182
|
+
authorized_resource_scope(OtherModel, type: :create) # Different action
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`authorized_resource_scope` applies the *other* resource's `relation_scope` AND the current policy context (entity scope, etc.). **Always prefer it over `OtherModel.all` / raw `where` in cross-resource controller code** — otherwise you bypass that resource's tenancy and visibility rules.
|
|
186
|
+
|
|
187
|
+
### Definition access
|
|
188
|
+
|
|
189
|
+
```ruby
|
|
190
|
+
current_definition
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### UI builders (rarely needed in controllers)
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
build_form
|
|
197
|
+
build_detail
|
|
198
|
+
build_collection
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### URL generation
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
resource_url_for(@post) # show URL
|
|
205
|
+
resource_url_for(@post, action: :edit) # edit URL
|
|
206
|
+
resource_url_for(Post) # index URL
|
|
207
|
+
|
|
208
|
+
# Nested
|
|
209
|
+
resource_url_for(@comment, parent: @post)
|
|
210
|
+
resource_url_for(Comment, action: :new, parent: @post)
|
|
211
|
+
|
|
212
|
+
# Cross-package
|
|
213
|
+
resource_url_for(@post, package: AdminPortal)
|
|
214
|
+
|
|
215
|
+
# Interactive actions (see Part 3 below)
|
|
216
|
+
resource_url_for(@post, interaction: :publish)
|
|
217
|
+
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Nested resources
|
|
221
|
+
|
|
222
|
+
Routes prefixed with `nested_` automatically resolve the parent:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
# Route: /users/:user_id/nested_posts/:id
|
|
226
|
+
class PostsController < ::ResourceController
|
|
227
|
+
# current_parent => User instance
|
|
228
|
+
# current_nested_association => :posts
|
|
229
|
+
# resource_record! => Post scoped to that User
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
| Method | Returns |
|
|
234
|
+
|---|---|
|
|
235
|
+
| `current_parent` | Parent record |
|
|
236
|
+
| `current_nested_association` | `:posts` |
|
|
237
|
+
| `parent_route_param` | `:user_id` |
|
|
238
|
+
| `parent_input_param` | `:user` |
|
|
239
|
+
|
|
240
|
+
Parent fields are excluded from forms/displays by default — toggle with the presentation hooks above. For `has_one` associations, routes are singular (no `:id`); index redirects to show (or new if no record exists). See [[plutonium-tenancy]] for the full nested-routing story.
|
|
241
|
+
|
|
242
|
+
## Entity scoping (multi-tenancy)
|
|
243
|
+
|
|
244
|
+
When a portal calls `scope_to_entity SomeModel`, every controller in that portal automatically:
|
|
245
|
+
|
|
246
|
+
- Scopes queries to the entity
|
|
247
|
+
- Excludes the entity field from forms (detected by association class)
|
|
248
|
+
- Injects the entity on create/update
|
|
249
|
+
- Exposes `current_scoped_entity`
|
|
250
|
+
|
|
251
|
+
Plutonium auto-detects which `belongs_to` association points to the scoped class, even when `param_key` differs from the association name. If a model has **multiple associations to the same scoped class**, you get a runtime error and must override:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
class MatchesController < ::ResourceController
|
|
255
|
+
private
|
|
256
|
+
def scoped_entity_association = :home_team
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
For the full mechanics, load [[plutonium-tenancy]].
|
|
261
|
+
|
|
262
|
+
## Authorization verification
|
|
263
|
+
|
|
264
|
+
After-action callbacks ensure auth was performed:
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
verify_authorize_current # all actions
|
|
268
|
+
verify_current_authorized_scope # all except new/create
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Skip only when handling auth manually. Two forms:
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
# Class-level — skip across multiple actions
|
|
275
|
+
class PostsController < ::ResourceController
|
|
276
|
+
skip_verify_authorize_current only: [:custom_action]
|
|
277
|
+
skip_verify_current_authorized_scope only: [:custom_action]
|
|
278
|
+
|
|
279
|
+
def custom_action
|
|
280
|
+
# do auth manually
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Per-action — bang methods, call inside the action body
|
|
285
|
+
def custom_action
|
|
286
|
+
skip_verify_authorize_current!
|
|
287
|
+
skip_verify_current_authorized_scope!
|
|
288
|
+
# do auth manually
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Prefer the per-action bang form when only one action skips — keeps the exception co-located with the code that needs it.
|
|
293
|
+
|
|
294
|
+
## Portal-specific controllers
|
|
295
|
+
|
|
296
|
+
Portal controllers inherit from the feature-package controller if one exists (and include the portal's `Concerns::Controller`); otherwise from the portal's `ResourceController`.
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
# Feature package controller exists
|
|
300
|
+
class AdminPortal::PostsController < ::PostsController
|
|
301
|
+
include AdminPortal::Concerns::Controller
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# No feature package — inherits portal base
|
|
305
|
+
class AdminPortal::PostsController < AdminPortal::ResourceController
|
|
306
|
+
end
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Non-resource portal pages (dashboard, settings) inherit from `PlutoniumController`:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
module AdminPortal
|
|
313
|
+
class DashboardController < PlutoniumController
|
|
314
|
+
def index; end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
# Part 2 — Policies
|
|
322
|
+
|
|
323
|
+
Built on [ActionPolicy](https://actionpolicy.evilmartians.io/). Plutonium adds:
|
|
324
|
+
|
|
325
|
+
- Attribute permissions (`permitted_attributes_for_*`)
|
|
326
|
+
- Association permissions (`permitted_associations`)
|
|
327
|
+
- Automatic entity scoping
|
|
328
|
+
- Derived action methods (`update?` inherits from `create?`, etc.)
|
|
329
|
+
|
|
330
|
+
## Base class
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
# app/policies/resource_policy.rb (installed once)
|
|
334
|
+
class ResourcePolicy < Plutonium::Resource::Policy
|
|
335
|
+
# App-wide defaults
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# app/policies/post_policy.rb (per resource, generated)
|
|
339
|
+
class PostPolicy < ResourcePolicy
|
|
340
|
+
def create? = user.present?
|
|
341
|
+
def read? = true
|
|
342
|
+
|
|
343
|
+
def permitted_attributes_for_create
|
|
344
|
+
%i[title content]
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def permitted_attributes_for_read
|
|
348
|
+
%i[title content author created_at]
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
## Action permissions
|
|
354
|
+
|
|
355
|
+
### Must override
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
def create? # default: false
|
|
359
|
+
user.present?
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def read? # default: false
|
|
363
|
+
true
|
|
364
|
+
end
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
### Derived (inherit automatically)
|
|
368
|
+
|
|
369
|
+
| Method | Inherits from | Override when |
|
|
370
|
+
|--------|---------------|---------------|
|
|
371
|
+
| `update?` | `create?` | Different update rules |
|
|
372
|
+
| `destroy?` | `create?` | Different delete rules |
|
|
373
|
+
| `index?` | `read?` | Custom listing rules |
|
|
374
|
+
| `show?` | `read?` | Record-specific read rules |
|
|
375
|
+
| `new?` | `create?` | Rarely needed |
|
|
376
|
+
| `edit?` | `update?` | Rarely needed |
|
|
377
|
+
| `search?` | `index?` | Search-specific rules |
|
|
378
|
+
|
|
379
|
+
### Custom actions
|
|
380
|
+
|
|
381
|
+
Define `def <action>?` matching the definition's `action :<action>`. Undefined methods return `false`:
|
|
382
|
+
|
|
383
|
+
```ruby
|
|
384
|
+
def publish? = update? && record.draft?
|
|
385
|
+
def archive? = create? && !record.archived?
|
|
386
|
+
def invite_user? = user.admin?
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### Bulk actions — per-record auth
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
def bulk_archive?
|
|
393
|
+
create? && !record.locked? # checked per record in the selection
|
|
394
|
+
end
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
How it works:
|
|
398
|
+
|
|
399
|
+
- Policy is checked **per record** in the selected set.
|
|
400
|
+
- **Backend:** if any record fails, the entire request is rejected.
|
|
401
|
+
- **UI:** only actions ALL selected records support are shown (intersection).
|
|
402
|
+
- Records come from `current_authorized_scope` — users can only select what they're allowed to access.
|
|
403
|
+
|
|
404
|
+
## Attribute permissions
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
# Must override for production
|
|
408
|
+
def permitted_attributes_for_read
|
|
409
|
+
%i[title content author published_at created_at]
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def permitted_attributes_for_create
|
|
413
|
+
%i[title content]
|
|
414
|
+
end
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
### Derived
|
|
418
|
+
|
|
419
|
+
| Method | Inherits from |
|
|
420
|
+
|---|---|
|
|
421
|
+
| `permitted_attributes_for_update` | `permitted_attributes_for_create` |
|
|
422
|
+
| `permitted_attributes_for_index` | `permitted_attributes_for_read` |
|
|
423
|
+
| `permitted_attributes_for_show` | `permitted_attributes_for_read` |
|
|
424
|
+
| `permitted_attributes_for_new` | `permitted_attributes_for_create` |
|
|
425
|
+
| `permitted_attributes_for_edit` | `permitted_attributes_for_update` |
|
|
426
|
+
|
|
427
|
+
### Per-action override
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
def permitted_attributes_for_index
|
|
431
|
+
%i[title author created_at] # minimal for the table
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def permitted_attributes_for_read
|
|
435
|
+
%i[title content author tags created_at] # fuller for the show page
|
|
436
|
+
end
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
🚨 **Index has no `record`.** `permitted_attributes_for_index` is evaluated at collection level — `record` is `nil`. `permitted_attributes_for_show` (and `_for_read`) ARE evaluated per record. So if you write a record-dependent `_for_read`:
|
|
440
|
+
|
|
441
|
+
```ruby
|
|
442
|
+
def permitted_attributes_for_read
|
|
443
|
+
attrs = %i[title content]
|
|
444
|
+
attrs << :archive_reason if record.archived? # uses record
|
|
445
|
+
attrs
|
|
446
|
+
end
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
…you MUST also define an explicit `permitted_attributes_for_index` — otherwise inheritance kicks in, runs the `_for_read` body during the table render, and `record.archived?` blows up on `NoMethodError: undefined method 'archived?' for nil`.
|
|
450
|
+
|
|
451
|
+
```ruby
|
|
452
|
+
def permitted_attributes_for_index
|
|
453
|
+
%i[title content] # no record-dependent fields
|
|
454
|
+
end
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Same rule for `permitted_attributes_for_create` vs `_for_new` (new has no persisted record).
|
|
458
|
+
|
|
459
|
+
### Policy vs definition — what controls what
|
|
460
|
+
|
|
461
|
+
`permitted_attributes_for_*` controls **which fields appear** on a view. Definition `field`/`input`/`display`/`column` declarations only control **how** they render. A `field :name` in the definition does nothing unless `:name` is also in the relevant `permitted_attributes_for_*`.
|
|
462
|
+
|
|
463
|
+
Common mistake: adding a definition declaration and wondering why the field doesn't show — check the policy.
|
|
464
|
+
|
|
465
|
+
### Anti-pattern: nested-attributes hashes
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
# ❌ NEVER
|
|
469
|
+
def permitted_attributes_for_create
|
|
470
|
+
[:name, {variants_attributes: [:id, :name, :_destroy]}]
|
|
471
|
+
end
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
Plutonium extracts nested params via the form definition, not the policy. Hash entries get iterated as field names by the form renderer and render as literal text inputs.
|
|
475
|
+
|
|
476
|
+
```ruby
|
|
477
|
+
# ✅ Policy permits just the association name
|
|
478
|
+
def permitted_attributes_for_create
|
|
479
|
+
[:name, :variants]
|
|
480
|
+
end
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
`nested_input :variants` in the definition handles the rest. See [[plutonium-resource]] › Nested Inputs.
|
|
484
|
+
|
|
485
|
+
## Association permissions
|
|
486
|
+
|
|
487
|
+
```ruby
|
|
488
|
+
def permitted_associations
|
|
489
|
+
%i[comments tags author]
|
|
490
|
+
end
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
Declares which associations get their own **tab on the show page**. When `permitted_associations` is non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association — each lazy-loaded via a frame navigator panel pointing at the associated `has_many` collection, `has_one` record, or `belongs_to` target. When empty, the show page renders without tabs.
|
|
494
|
+
|
|
495
|
+
Each named association must:
|
|
496
|
+
|
|
497
|
+
- Exist on the model (raises `ArgumentError: unknown association ...` otherwise).
|
|
498
|
+
- Point to a class that's itself a registered Plutonium resource (raises `... is not a registered resource` otherwise).
|
|
499
|
+
|
|
500
|
+
This is **NOT** the same as:
|
|
501
|
+
|
|
502
|
+
- **Nested forms** — declared with `nested_input :variants` in the definition, requires `accepts_nested_attributes_for` on the model. See [[plutonium-resource]] › Nested Inputs.
|
|
503
|
+
- **Association fields on tables / show details** — controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
|
|
504
|
+
|
|
505
|
+
## Collection scoping (`relation_scope`)
|
|
506
|
+
|
|
507
|
+
Filter which records the user can see. **Always compose with `default_relation_scope(relation)` explicitly** — `super` is unreliable inside the block, and bypassing this triggers `verify_default_relation_scope_applied!`:
|
|
508
|
+
|
|
509
|
+
```ruby
|
|
510
|
+
relation_scope do |relation|
|
|
511
|
+
relation = default_relation_scope(relation)
|
|
512
|
+
user.admin? ? relation : relation.where(author: user)
|
|
513
|
+
end
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
For tenant scoping, parent scoping, `skip_default_relation_scope!`, and `associated_with` resolution: load [[plutonium-tenancy]].
|
|
517
|
+
|
|
518
|
+
## Portal-specific policies
|
|
519
|
+
|
|
520
|
+
```ruby
|
|
521
|
+
class PostPolicy < ResourcePolicy
|
|
522
|
+
def create? = user.present?
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Admin: more permissive
|
|
526
|
+
class AdminPortal::PostPolicy < ::PostPolicy
|
|
527
|
+
include AdminPortal::ResourcePolicy
|
|
528
|
+
def destroy? = true
|
|
529
|
+
def permitted_attributes_for_create = %i[title content featured internal_notes]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Public: read-only
|
|
533
|
+
class PublicPortal::PostPolicy < ::PostPolicy
|
|
534
|
+
include PublicPortal::ResourcePolicy
|
|
535
|
+
def create? = false
|
|
536
|
+
end
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
## Authorization context
|
|
540
|
+
|
|
541
|
+
```ruby
|
|
542
|
+
user # current user
|
|
543
|
+
record # the resource being authorized
|
|
544
|
+
entity_scope # current scoped entity (multi-tenancy)
|
|
545
|
+
parent # parent record for nested resources (nil otherwise)
|
|
546
|
+
parent_association # association name on parent (e.g. :comments)
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
### Custom context
|
|
550
|
+
|
|
551
|
+
```ruby
|
|
552
|
+
# Policy
|
|
553
|
+
class PostPolicy < ResourcePolicy
|
|
554
|
+
authorize :department, allow_nil: true
|
|
555
|
+
|
|
556
|
+
def create? = department&.allows_posting?
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Controller
|
|
560
|
+
class PostsController < ResourceController
|
|
561
|
+
authorize :department, through: :current_department
|
|
562
|
+
|
|
563
|
+
private
|
|
564
|
+
def current_department = current_user.department
|
|
565
|
+
end
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
## Common patterns
|
|
569
|
+
|
|
570
|
+
### Block archived records
|
|
571
|
+
|
|
572
|
+
```ruby
|
|
573
|
+
def update? = !record.try(:archived?) && super
|
|
574
|
+
def destroy? = !record.try(:archived?) && super
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
### Owner-based
|
|
578
|
+
|
|
579
|
+
```ruby
|
|
580
|
+
def update? = record.author == user || user.admin?
|
|
581
|
+
def destroy? = update?
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
### Role-based
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
def create? = user.admin? || user.editor?
|
|
588
|
+
|
|
589
|
+
def update?
|
|
590
|
+
return true if user.admin?
|
|
591
|
+
user.editor? && record.author == user
|
|
592
|
+
end
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
### Conditional attribute access
|
|
596
|
+
|
|
597
|
+
```ruby
|
|
598
|
+
def permitted_attributes_for_create
|
|
599
|
+
attrs = %i[title content]
|
|
600
|
+
attrs += %i[featured author_id] if user.admin?
|
|
601
|
+
attrs
|
|
602
|
+
end
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
# Part 3 — Interactions
|
|
608
|
+
|
|
609
|
+
Interactions encapsulate business logic into testable units. They're registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.
|
|
610
|
+
|
|
611
|
+
## Structure
|
|
612
|
+
|
|
613
|
+
```ruby
|
|
614
|
+
# app/interactions/resource_interaction.rb (installed once)
|
|
615
|
+
class ResourceInteraction < Plutonium::Resource::Interaction
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# A real interaction
|
|
619
|
+
class PublishPostInteraction < ResourceInteraction
|
|
620
|
+
presents label: "Publish",
|
|
621
|
+
icon: Phlex::TablerIcons::Send,
|
|
622
|
+
description: "Make this post public"
|
|
623
|
+
|
|
624
|
+
attribute :resource
|
|
625
|
+
attribute :publish_date, :datetime, default: -> { Time.current }
|
|
626
|
+
|
|
627
|
+
input :publish_date
|
|
628
|
+
|
|
629
|
+
validates :publish_date, presence: true
|
|
630
|
+
|
|
631
|
+
private
|
|
632
|
+
|
|
633
|
+
def execute
|
|
634
|
+
resource.update!(published_at: publish_date)
|
|
635
|
+
succeed(resource).with_message("Post published!")
|
|
636
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
637
|
+
failed(e.record.errors)
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
## Attributes
|
|
643
|
+
|
|
644
|
+
ActiveModel-style:
|
|
645
|
+
|
|
646
|
+
```ruby
|
|
647
|
+
attribute :resource # single record (record action)
|
|
648
|
+
attribute :resources # array of records (bulk action)
|
|
649
|
+
attribute :email, :string
|
|
650
|
+
attribute :count, :integer, default: 1
|
|
651
|
+
attribute :active, :boolean, default: -> { true } # callable default
|
|
652
|
+
attribute :tags, :array
|
|
653
|
+
attribute :metadata, :hash
|
|
654
|
+
attribute :date, :datetime
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
The presence of `:resource` / `:resources` / neither determines the action type — see [[plutonium-resource]] › Action Types.
|
|
658
|
+
|
|
659
|
+
## Inputs
|
|
660
|
+
|
|
661
|
+
Same DSL as definition `input` (load [[plutonium-resource]] for the full list of `as:` types, options, dynamic blocks, etc.):
|
|
662
|
+
|
|
663
|
+
```ruby
|
|
664
|
+
input :email
|
|
665
|
+
input :role, as: :select, choices: %w[admin user]
|
|
666
|
+
input :content, as: :text
|
|
667
|
+
```
|
|
668
|
+
|
|
669
|
+
Auto-detection rule from [[plutonium-resource]] applies here too: if the attribute type already implies the right widget, don't redeclare `as:`.
|
|
670
|
+
|
|
671
|
+
## Presentation
|
|
672
|
+
|
|
673
|
+
```ruby
|
|
674
|
+
presents label: "Archive Record",
|
|
675
|
+
icon: Phlex::TablerIcons::Archive,
|
|
676
|
+
description: "Move to archive"
|
|
677
|
+
|
|
678
|
+
# Access
|
|
679
|
+
MyInteraction.label
|
|
680
|
+
MyInteraction.icon
|
|
681
|
+
MyInteraction.description
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
If `action :foo, interaction: FooInteraction` doesn't override `label:`/`icon:`/etc., these `presents` values are used.
|
|
685
|
+
|
|
686
|
+
## `execute` — outcomes
|
|
687
|
+
|
|
688
|
+
`execute` MUST return a `succeed(...)` or `failed(...)` outcome. Validations run automatically before `execute`; if they fail, the interaction short-circuits to `failed()`.
|
|
689
|
+
|
|
690
|
+
### Success
|
|
691
|
+
|
|
692
|
+
```ruby
|
|
693
|
+
succeed(resource) # auto-redirect to resource
|
|
694
|
+
succeed(resource).with_message("Done!")
|
|
695
|
+
succeed(resource).with_message("Heads up!", :alert)
|
|
696
|
+
succeed(resource).with_redirect_response(custom_path) # different destination
|
|
697
|
+
succeed(resource).with_file_response(path, filename: "report.pdf")
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### Failure
|
|
701
|
+
|
|
702
|
+
```ruby
|
|
703
|
+
failed("Something went wrong")
|
|
704
|
+
failed(resource.errors)
|
|
705
|
+
failed(email: "is invalid", name: "is required") # hash form
|
|
706
|
+
failed("Invalid value", :email) # string + attribute
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
### Chaining
|
|
710
|
+
|
|
711
|
+
```ruby
|
|
712
|
+
def execute
|
|
713
|
+
CreateUserInteraction.call(view_context:, **user_params)
|
|
714
|
+
.and_then { |r| SendWelcomeEmail.call(view_context:, user: r.value) }
|
|
715
|
+
.and_then { |r| LogActivity.call(view_context:, user: r.value) }
|
|
716
|
+
.with_message("User created and welcomed!")
|
|
717
|
+
end
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
The chain short-circuits on the first failure.
|
|
721
|
+
|
|
722
|
+
## Validations
|
|
723
|
+
|
|
724
|
+
Standard ActiveModel — run automatically before `execute`:
|
|
725
|
+
|
|
726
|
+
```ruby
|
|
727
|
+
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
728
|
+
validates :role, inclusion: {in: %w[admin user guest]}
|
|
729
|
+
|
|
730
|
+
validate :custom_check
|
|
731
|
+
|
|
732
|
+
private
|
|
733
|
+
|
|
734
|
+
def custom_check
|
|
735
|
+
errors.add(:resource, "cannot be modified when archived") if resource.archived?
|
|
736
|
+
end
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
## Accessing context
|
|
740
|
+
|
|
741
|
+
```ruby
|
|
742
|
+
def execute
|
|
743
|
+
current_user = view_context.controller.helpers.current_user
|
|
744
|
+
resource.update!(updated_by: current_user)
|
|
745
|
+
succeed(resource)
|
|
746
|
+
end
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
A shorter `current_user` helper is conventional:
|
|
750
|
+
|
|
751
|
+
```ruby
|
|
752
|
+
private
|
|
753
|
+
def current_user = view_context.controller.helpers.current_user
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
## Interaction types
|
|
757
|
+
|
|
758
|
+
| Attribute pattern | Action type | Where it shows up |
|
|
759
|
+
|---|---|---|
|
|
760
|
+
| `attribute :resource` | Record action | Show page + per-row in table |
|
|
761
|
+
| `attribute :resources` | Bulk action | Bulk toolbar above table |
|
|
762
|
+
| neither | Resource action | Index page header |
|
|
763
|
+
|
|
764
|
+
**Bulk action authorization:** per-record. See [[plutonium-resource]] › Action Types and Part 2 above.
|
|
765
|
+
|
|
766
|
+
## Generating interaction URLs
|
|
767
|
+
|
|
768
|
+
Use `resource_url_for` with the `interaction:` kwarg. Action type is inferred from the element and presence of `ids:`:
|
|
769
|
+
|
|
770
|
+
```ruby
|
|
771
|
+
# Record action — instance argument
|
|
772
|
+
resource_url_for(@post, interaction: :publish)
|
|
773
|
+
# => /posts/:id/record_actions/publish
|
|
774
|
+
|
|
775
|
+
# Resource action — class, no ids
|
|
776
|
+
resource_url_for(Post, interaction: :import)
|
|
777
|
+
# => /posts/resource_actions/import
|
|
778
|
+
|
|
779
|
+
# Bulk action — class + ids
|
|
780
|
+
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
|
|
781
|
+
# => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3
|
|
782
|
+
|
|
783
|
+
# Composes with parent / entity scoping
|
|
784
|
+
resource_url_for(@post, parent: @user, interaction: :publish)
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.
|
|
788
|
+
|
|
789
|
+
## Complete example
|
|
790
|
+
|
|
791
|
+
```ruby
|
|
792
|
+
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
793
|
+
presents label: "Invite User",
|
|
794
|
+
icon: Phlex::TablerIcons::UserPlus
|
|
795
|
+
|
|
796
|
+
attribute :resource # the company
|
|
797
|
+
attribute :email, :string
|
|
798
|
+
attribute :role, :string
|
|
799
|
+
|
|
800
|
+
input :email
|
|
801
|
+
input :role, as: :select, choices: -> { UserInvite.roles.keys }
|
|
802
|
+
|
|
803
|
+
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
804
|
+
validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
|
|
805
|
+
validate :not_already_invited
|
|
806
|
+
|
|
807
|
+
private
|
|
808
|
+
|
|
809
|
+
def execute
|
|
810
|
+
invite = UserInvite.create!(
|
|
811
|
+
company: resource, email: email, role: role,
|
|
812
|
+
invited_by: current_user
|
|
813
|
+
)
|
|
814
|
+
UserInviteMailer.invitation(invite).deliver_later
|
|
815
|
+
succeed(resource).with_message("Invitation sent to #{email}")
|
|
816
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
817
|
+
failed(e.record.errors)
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def not_already_invited
|
|
821
|
+
return unless email.present?
|
|
822
|
+
if UserInvite.exists?(company: resource, email: email, state: :pending)
|
|
823
|
+
errors.add(:email, "already has a pending invitation")
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def current_user = view_context.controller.helpers.current_user
|
|
828
|
+
end
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
---
|
|
832
|
+
|
|
833
|
+
## Related Skills
|
|
834
|
+
|
|
835
|
+
- [[plutonium-resource]] — registering interactions as actions; field/input/display syntax
|
|
836
|
+
- [[plutonium-tenancy]] — `relation_scope`, entity scoping, nested resources
|
|
837
|
+
- [[plutonium-ui]] — custom interaction form templates, page classes
|
|
838
|
+
- [[plutonium-testing]] — testing controllers, policies, interactions
|