plutonium 0.65.0 → 0.66.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 +43 -43
- data/.claude/skills/plutonium-app/SKILL.md +101 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +144 -133
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +151 -96
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +21 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/blog/introducing-plutonium-dashboards.md +4 -5
- data/docs/blog/introducing-plutonium-i18n.md +4 -5
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +1 -1
- data/docs/guides/index.md +3 -3
- data/docs/guides/kanban.md +55 -55
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/reference/app/generators.md +22 -22
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +75 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +6 -6
- data/docs/reference/dashboard/dsl.md +2 -2
- data/docs/reference/dashboard/index.md +1 -1
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +23 -0
- data/docs/reference/index.md +1 -1
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +32 -32
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +17 -15
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +73 -73
- data/docs/reference/resource/export.md +6 -6
- data/docs/reference/resource/index.md +16 -16
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/resource/positioning.md +78 -76
- data/docs/reference/resource/query.md +13 -13
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +57 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +14 -14
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +44 -44
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -10
- metadata +2 -2
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
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".
|
|
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
4
|
---
|
|
5
5
|
|
|
6
|
-
# Plutonium Behavior
|
|
6
|
+
# Plutonium Behavior: Controllers, Policies, Interactions
|
|
7
7
|
|
|
8
|
-
The behavior layer is intentionally thin: **controllers route**, **policies authorize**, **interactions act**. Registering an action and rendering it lives in [[plutonium-resource]]
|
|
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
9
|
|
|
10
10
|
For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]].
|
|
11
11
|
|
|
@@ -16,9 +16,9 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
|
|
|
16
16
|
- **`create?` and `read?` default to `false`.** Always override them explicitly. Derived methods (`update?`, `show?`, etc.) inherit automatically.
|
|
17
17
|
- **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detection works; production raises.
|
|
18
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
|
|
20
|
-
- **An interaction is a presentation object** (it can only be built with a `view_context`). Logic may *start* in `execute`; the **second caller**
|
|
21
|
-
- **Redirect is automatic on success
|
|
19
|
+
- **Return `succeed(...)` or `failed(...)`** from `execute`; the controller can't tell what happened otherwise.
|
|
20
|
+
- **An interaction is a presentation object** (it can only be built with a `view_context`). Logic may *start* in `execute`; the **second caller** (a job, an API controller, a rake task, the console) is the trigger to move it onto the **model**. Don't pre-extract, and don't invent a service layer. See Part 3 › Where the logic goes.
|
|
21
|
+
- **Redirect is automatic on success**: only use `with_redirect_response` for a *different* destination.
|
|
22
22
|
- **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly. `super` works when extending a parent policy (e.g., a package base) that itself calls it. See [[plutonium-tenancy]].
|
|
23
23
|
- **For `has_cents` fields, use the virtual name (`:price`), not `:price_cents`** in `permitted_attributes_for_*`.
|
|
24
24
|
- **Custom action ⇒ policy method.** `action :publish` needs `def publish?` on the policy (undefined methods return `false`).
|
|
@@ -26,35 +26,35 @@ For tenant-scoped `relation_scope` and entity scoping, load [[plutonium-tenancy]
|
|
|
26
26
|
|
|
27
27
|
---
|
|
28
28
|
|
|
29
|
-
## 🛑 Before you write behavior: place it in the right layer (ASK
|
|
29
|
+
## 🛑 Before you write behavior: place it in the right layer (ASK: don't infer)
|
|
30
30
|
|
|
31
31
|
"Make X happen" doesn't say **where** X lives. Put it in the wrong layer and you get authorization that doesn't authorize, a 500 on the happy path, or a CRUD override that breaks params/auth. First place the requirement, then confirm names against the real code (next section):
|
|
32
32
|
|
|
33
33
|
| The requirement (in plain words) | Goes in | **NOT** in |
|
|
34
34
|
|---|---|---|
|
|
35
|
-
| "only \<role/owner\> may do X"
|
|
36
|
-
| "there's a button that does X"
|
|
37
|
-
| "doing X changes state / sends mail / charges a card"
|
|
35
|
+
| "only \<role/owner\> may do X" (*who is allowed*) | **Policy** `def x?` | a `condition:` proc (that only hides the button; the route stays live and callable) |
|
|
36
|
+
| "there's a button that does X" (*the trigger*) | **Interaction** (+ action in the definition) | a hand-written controller action; an override of `create`/`update` |
|
|
37
|
+
| "doing X changes state / sends mail / charges a card" (*the work*) | a named **model** method the interaction calls (`post.publish!`); inline in `execute` is fine while the button is the only caller | a service-object layer; three chained interactions |
|
|
38
38
|
| "after create/update go to Y" · "munge a param" · "reshape the index query" | **Controller hook** (`redirect_url_after_submit`, `resource_params`, `filtered_resource_collection`) | overriding `create`/`update`/`index` |
|
|
39
|
-
| "which fields are visible / editable" | **Policy** `permitted_attributes_for_*` | the definition
|
|
39
|
+
| "which fields are visible / editable" | **Policy** `permitted_attributes_for_*` | the definition (that only controls *how* a field renders) |
|
|
40
40
|
|
|
41
41
|
Then resolve the specifics:
|
|
42
42
|
|
|
43
|
-
1. **A custom action needs BOTH:** an interaction (the work) **and** a policy `def <action>?` (the authorization). Miss the policy method ⇒ the action silently returns `false` (dead button). Put the role check in `condition:` ⇒ it isn't enforced
|
|
44
|
-
2. **`create?`/`read?` default to `false
|
|
45
|
-
3. **Any `create!`/`update!`/`save!` in `execute`** ⇒ rescue `ActiveRecord::RecordInvalid` → `failed(e.record.errors)`. Not auto-rescued
|
|
43
|
+
1. **A custom action needs BOTH:** an interaction (the work) **and** a policy `def <action>?` (the authorization). Miss the policy method ⇒ the action silently returns `false` (dead button). Put the role check in `condition:` ⇒ it isn't enforced; a direct POST still runs.
|
|
44
|
+
2. **`create?`/`read?` default to `false`**: override explicitly; derived methods (`update?`/`show?`/…) inherit.
|
|
45
|
+
3. **Any `create!`/`update!`/`save!` in `execute`** ⇒ rescue `ActiveRecord::RecordInvalid` → `failed(e.record.errors)`. Not auto-rescued; otherwise a validation failure 500s.
|
|
46
46
|
4. **`has_cents`** ⇒ permit `:price`, never `:price_cents`.
|
|
47
|
-
5. **New vs editing
|
|
47
|
+
5. **New vs editing**: never re-scaffold a controller/policy/interaction that's been customized.
|
|
48
48
|
|
|
49
|
-
**Never ship a guessed role method, column, enum value, or association as applied code.** `user.finance?`, `record.status_approved?`, `expense.submitted_by` either exist in the app or they don't
|
|
49
|
+
**Never ship a guessed role method, column, enum value, or association as applied code.** `user.finance?`, `record.status_approved?`, `expense.submitted_by` either exist in the app or they don't. Confirm them before writing; don't assume. Fall back to `AskUserQuestion` only for genuine product choices (what the rule *should* be), never for facts you can read.
|
|
50
50
|
|
|
51
|
-
## ✅ Before you edit: verify the ground truth (CHECK
|
|
51
|
+
## ✅ Before you edit: verify the ground truth (CHECK: read it, don't ask for it)
|
|
52
52
|
|
|
53
|
-
You have file access
|
|
53
|
+
You have file access, **inspect**; don't ask the user to describe their own app.
|
|
54
54
|
|
|
55
55
|
| Check | How | Why it matters |
|
|
56
56
|
|---|---|---|
|
|
57
|
-
| File already customized | Read `app/policies/<x>_policy.rb`, the controller, `app/interactions/*` | Edit incrementally
|
|
57
|
+
| File already customized | Read `app/policies/<x>_policy.rb`, the controller, `app/interactions/*` | Edit incrementally; re-scaffolding clobbers customizations |
|
|
58
58
|
| The role/method you authorize on exists | grep the user model for `def finance?` / `enum :role` / `has_role?` | `user.finance?` 500s (or is silently `false`) if absent |
|
|
59
59
|
| The columns/enum your interaction writes | Read the model + `db/schema.rb` for the enum value, `approved_by`/`approved_at`, the submitter assoc | `update!(status: :approved)` raises if the value/column is missing |
|
|
60
60
|
| Action not already wired | grep the definition for `action :<x>`; grep the policy for `def <x>?` | Avoids duplicate or dead actions |
|
|
@@ -62,18 +62,18 @@ You have file access — **inspect**; don't ask the user to describe their own a
|
|
|
62
62
|
|
|
63
63
|
Inspect with your own tools **before** proposing code.
|
|
64
64
|
|
|
65
|
-
## 🛠 Use the generator
|
|
65
|
+
## 🛠 Use the generator, and know what's hand-authored
|
|
66
66
|
|
|
67
67
|
| Task | How | Verify first |
|
|
68
68
|
|---|---|---|
|
|
69
69
|
| Base trio (controller + policy + interaction-base) | `pu:res:scaffold` | New resource |
|
|
70
70
|
| Portal-specific controller/policy | `pu:res:conn … --dest=portal` | Resource exists |
|
|
71
|
-
| **A custom-action interaction** | **Hand-author** in `app/interactions/<name>_interaction.rb` (subclass `ResourceInteraction`)
|
|
72
|
-
| Edit an existing customized policy/controller/interaction | Hand-edit the file | It was already generated
|
|
71
|
+
| **A custom-action interaction** | **Hand-author** in `app/interactions/<name>_interaction.rb` (subclass `ResourceInteraction`), **there is NO `pu:res:interaction` generator, so don't invent one** | - |
|
|
72
|
+
| Edit an existing customized policy/controller/interaction | Hand-edit the file | It was already generated; re-scaffolding clobbers it |
|
|
73
73
|
|
|
74
74
|
---
|
|
75
75
|
|
|
76
|
-
# Part 1
|
|
76
|
+
# Part 1: Controllers
|
|
77
77
|
|
|
78
78
|
Plutonium controllers ship full CRUD out of the box; nearly all customization lives in definitions / policies / interactions. The controller stays thin.
|
|
79
79
|
|
|
@@ -87,7 +87,7 @@ end
|
|
|
87
87
|
|
|
88
88
|
# app/controllers/posts_controller.rb (per resource, generated by pu:res:scaffold)
|
|
89
89
|
class PostsController < ::ResourceController
|
|
90
|
-
# Empty
|
|
90
|
+
# Empty: all CRUD inherited
|
|
91
91
|
end
|
|
92
92
|
```
|
|
93
93
|
|
|
@@ -111,8 +111,8 @@ Plus interactive-action routes for every action declared in the definition.
|
|
|
111
111
|
|---|---|
|
|
112
112
|
| Field rendering (inputs, displays, columns) | Definition |
|
|
113
113
|
| Search, filters, scopes, sorting | Definition |
|
|
114
|
-
| Custom operations (publish, archive, import)
|
|
115
|
-
| The operation itself, once a job/API/task also needs it | The **model** (`post.publish!`)
|
|
114
|
+
| Custom operations (publish, archive, import): the *button* | Interaction (+ action in definition) |
|
|
115
|
+
| The operation itself, once a job/API/task also needs it | The **model** (`post.publish!`); see Part 3 › Where the logic goes |
|
|
116
116
|
| Authorization rules | Policy |
|
|
117
117
|
| Form/show/page chrome | Definition (custom page classes) |
|
|
118
118
|
| **Custom redirect logic** | **Controller hook** |
|
|
@@ -163,7 +163,7 @@ end
|
|
|
163
163
|
|
|
164
164
|
**Don't add eager loading unprompted.** Which associations a page renders is decided by the definition, so an `includes` list written now is a guess that goes stale when a column is added. Adding one is a performance change the user didn't ask for.
|
|
165
165
|
|
|
166
|
-
When a user actually reports a slow index or an N+1: suggest [goldiloader](https://github.com/salsify/goldiloader) first
|
|
166
|
+
When a user actually reports a slow index or an N+1: suggest [goldiloader](https://github.com/salsify/goldiloader) first: it eager-loads on traversal, so it tracks whatever the definition renders and needs no list to maintain. Only hand-write `def filtered_resource_collection = super.includes(...)` if they decline the gem, and use the policy's `relation_scope` instead when the association is also read on show/export/typeahead. Full detail: [Guides › Performance](/guides/performance).
|
|
167
167
|
|
|
168
168
|
### Presentation hooks
|
|
169
169
|
|
|
@@ -180,7 +180,7 @@ def submit_scoped_entity? = true
|
|
|
180
180
|
|
|
181
181
|
Prefer **interactive actions** (definition + interaction) for anything a user triggers from a page. The only reason to hand-write a controller action is unusual flows (custom response shapes, external service callbacks, etc.).
|
|
182
182
|
|
|
183
|
-
Either way the *operation* is a named model method
|
|
183
|
+
Either way the *operation* is a named model method; the controller and the interaction are two front doors onto the same `post.publish!`.
|
|
184
184
|
|
|
185
185
|
```ruby
|
|
186
186
|
class PostsController < ::ResourceController
|
|
@@ -224,10 +224,10 @@ permitted_attributes
|
|
|
224
224
|
current_authorized_scope # Scoped records the user can access
|
|
225
225
|
```
|
|
226
226
|
|
|
227
|
-
**Other resources** (cross-resource auth
|
|
227
|
+
**Other resources** (cross-resource auth; use these, not raw `where` / `find`):
|
|
228
228
|
|
|
229
229
|
```ruby
|
|
230
|
-
authorize! other_record, to: :show? # ActionPolicy
|
|
230
|
+
authorize! other_record, to: :show? # ActionPolicy, raises if denied
|
|
231
231
|
allowed_to?(:show?, other_record) # Boolean check
|
|
232
232
|
policy_for(OtherModel) # Policy instance for class or record
|
|
233
233
|
policy_for(other_record).show?
|
|
@@ -237,7 +237,7 @@ authorized_resource_scope(OtherModel, relation: OtherModel.published) # On a re
|
|
|
237
237
|
authorized_resource_scope(OtherModel, type: :create) # Different action
|
|
238
238
|
```
|
|
239
239
|
|
|
240
|
-
`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
|
|
240
|
+
`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.
|
|
241
241
|
|
|
242
242
|
### Definition access
|
|
243
243
|
|
|
@@ -294,7 +294,7 @@ end
|
|
|
294
294
|
|
|
295
295
|
The parent class and association come from the **route** (each nested route carries its registration key), not from parsing the URL. There is no `parent_route_param`.
|
|
296
296
|
|
|
297
|
-
Parent fields are excluded from forms/displays by default
|
|
297
|
+
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.
|
|
298
298
|
|
|
299
299
|
## Entity scoping (multi-tenancy)
|
|
300
300
|
|
|
@@ -328,7 +328,7 @@ verify_current_authorized_scope # all except new/create
|
|
|
328
328
|
Skip only when handling auth manually. Two forms:
|
|
329
329
|
|
|
330
330
|
```ruby
|
|
331
|
-
# Class-level
|
|
331
|
+
# Class-level: skip across multiple actions
|
|
332
332
|
class PostsController < ::ResourceController
|
|
333
333
|
skip_verify_authorize_current only: [:custom_action]
|
|
334
334
|
skip_verify_current_authorized_scope only: [:custom_action]
|
|
@@ -338,7 +338,7 @@ class PostsController < ::ResourceController
|
|
|
338
338
|
end
|
|
339
339
|
end
|
|
340
340
|
|
|
341
|
-
# Per-action
|
|
341
|
+
# Per-action: bang methods, call inside the action body
|
|
342
342
|
def custom_action
|
|
343
343
|
skip_verify_authorize_current!
|
|
344
344
|
skip_verify_current_authorized_scope!
|
|
@@ -346,7 +346,7 @@ def custom_action
|
|
|
346
346
|
end
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
-
Prefer the per-action bang form when only one action skips
|
|
349
|
+
Prefer the per-action bang form when only one action skips; it keeps the exception co-located with the code that needs it.
|
|
350
350
|
|
|
351
351
|
## Portal-specific controllers
|
|
352
352
|
|
|
@@ -358,7 +358,7 @@ class AdminPortal::PostsController < ::PostsController
|
|
|
358
358
|
include AdminPortal::Concerns::Controller
|
|
359
359
|
end
|
|
360
360
|
|
|
361
|
-
# No feature package
|
|
361
|
+
# No feature package: inherits portal base
|
|
362
362
|
class AdminPortal::PostsController < AdminPortal::ResourceController
|
|
363
363
|
end
|
|
364
364
|
```
|
|
@@ -375,7 +375,7 @@ end
|
|
|
375
375
|
|
|
376
376
|
---
|
|
377
377
|
|
|
378
|
-
# Part 2
|
|
378
|
+
# Part 2: Policies
|
|
379
379
|
|
|
380
380
|
Built on [ActionPolicy](https://actionpolicy.evilmartians.io/). Plutonium adds:
|
|
381
381
|
|
|
@@ -434,9 +434,9 @@ end
|
|
|
434
434
|
| `search?` | `index?` | Search-specific rules |
|
|
435
435
|
| `typeahead?` | `index?` | Autocomplete-specific rules |
|
|
436
436
|
|
|
437
|
-
🚨 **`record` is the resource CLASS on collection routes** (`current_policy_subject = resource_record? || resource_class`). `read?` backs both `show?` (instance) and `index?` (class); `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates (incl. kanban column actions) are class-backed too. `def read? = record.published?` raises `NoMethodError` on index
|
|
437
|
+
🚨 **`record` is the resource CLASS on collection routes** (`current_policy_subject = resource_record? || resource_class`). `read?` backs both `show?` (instance) and `index?` (class); `create?`/`new?`, `export_csv?`, `search?`, and resource-action gates (incl. kanban column actions) are class-backed too. `def read? = record.published?` raises `NoMethodError` on index, so filter the list in `relation_scope` and gate individual records in `show?`. Record actions (`publish?` etc.) and bulk actions are always evaluated per record instance, so no type guard is needed.
|
|
438
438
|
|
|
439
|
-
`export_csv?` is the exception
|
|
439
|
+
`export_csv?` is the exception: it defaults to `false` (not derived) so CSV export is strictly opt-in. Override it to `true` (or `index?`) to enable the built-in export. The exported column set is `permitted_attributes_for_export` (defaults to `permitted_attributes_for_index`). See [[plutonium-resource]] → CSV Export.
|
|
440
440
|
|
|
441
441
|
### Custom actions
|
|
442
442
|
|
|
@@ -448,7 +448,7 @@ def archive? = create? && !record.archived?
|
|
|
448
448
|
def invite_user? = user.admin?
|
|
449
449
|
```
|
|
450
450
|
|
|
451
|
-
### Bulk actions
|
|
451
|
+
### Bulk actions: per-record auth
|
|
452
452
|
|
|
453
453
|
```ruby
|
|
454
454
|
def bulk_archive?
|
|
@@ -461,7 +461,7 @@ How it works:
|
|
|
461
461
|
- Policy is checked **per record** in the selected set.
|
|
462
462
|
- **Backend:** if any record fails, the entire request is rejected.
|
|
463
463
|
- **UI:** only actions ALL selected records support are shown (intersection).
|
|
464
|
-
- Records come from `current_authorized_scope
|
|
464
|
+
- Records come from `current_authorized_scope`, so users can only select what they're allowed to access.
|
|
465
465
|
|
|
466
466
|
## Attribute permissions
|
|
467
467
|
|
|
@@ -499,7 +499,7 @@ def permitted_attributes_for_read
|
|
|
499
499
|
end
|
|
500
500
|
```
|
|
501
501
|
|
|
502
|
-
🚨 **Index has no
|
|
502
|
+
🚨 **Index has no record instance.** On collection routes the policy subject is the resource **class** (`current_policy_subject = resource_record? || resource_class`), so `permitted_attributes_for_index` runs with `record == Post`. The same class-bound policy feeds the table, the CSV export (`_for_export` defaults to `_for_index`) and kanban cards. `permitted_attributes_for_show` (and `_for_read`) run per record. So if you write a record-dependent `_for_read`:
|
|
503
503
|
|
|
504
504
|
```ruby
|
|
505
505
|
def permitted_attributes_for_read
|
|
@@ -509,7 +509,7 @@ def permitted_attributes_for_read
|
|
|
509
509
|
end
|
|
510
510
|
```
|
|
511
511
|
|
|
512
|
-
…you MUST also define an explicit `permitted_attributes_for_index`
|
|
512
|
+
…you MUST also define an explicit `permitted_attributes_for_index` whose body never touches `record`. Otherwise `_for_index` falls back to `_for_read`, runs it against the class during the table render, and `record.archived?` raises `NoMethodError` (`undefined method 'archived?'` for the `Post` class).
|
|
513
513
|
|
|
514
514
|
```ruby
|
|
515
515
|
def permitted_attributes_for_index
|
|
@@ -517,13 +517,26 @@ def permitted_attributes_for_index
|
|
|
517
517
|
end
|
|
518
518
|
```
|
|
519
519
|
|
|
520
|
-
|
|
520
|
+
Index columns are therefore decided **once for the whole collection**: a field is either a column for every row or for none. "Hide X on rows in state Y" cannot be done through the policy; drop the column, keep it for all rows, or (if the user wants a blank cell) render the cell conditionally in a definition `column` block; see [[plutonium-resource]]), and say which one you chose.
|
|
521
521
|
|
|
522
|
-
|
|
522
|
+
Same rule for `permitted_attributes_for_create` vs `_for_new`: `new` is a collection route too, so `record` is the class there.
|
|
523
|
+
|
|
524
|
+
**Gate on the state the user named, not on its complement.** "Show the price once it's active" is `record.active?`, not `!record.draft?`. With `enum :status, {draft:, active:, discontinued:}` the complement also lets `discontinued` through, and a later enum value would leak too. Remove every attribute that carries the value, e.g. both `:price` and `:price_cents` if the policy lists both:
|
|
525
|
+
|
|
526
|
+
```ruby
|
|
527
|
+
def permitted_attributes_for_read
|
|
528
|
+
attrs = %i[name sku status price price_cents]
|
|
529
|
+
record.active? ? attrs : attrs - %i[price price_cents]
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def permitted_attributes_for_index = %i[name sku status price] # class-safe
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### Policy vs definition: what controls what
|
|
523
536
|
|
|
524
537
|
`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_*`.
|
|
525
538
|
|
|
526
|
-
Common mistake: adding a definition declaration and wondering why the field doesn't show
|
|
539
|
+
Common mistake: adding a definition declaration and wondering why the field doesn't show; check the policy.
|
|
527
540
|
|
|
528
541
|
### Anti-pattern: nested-attributes hashes
|
|
529
542
|
|
|
@@ -553,21 +566,38 @@ def permitted_associations
|
|
|
553
566
|
end
|
|
554
567
|
```
|
|
555
568
|
|
|
556
|
-
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
|
|
569
|
+
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. If `permitted_attributes_for_show` resolves to **no fields**, the empty Details tab is omitted and the first association tab leads instead.
|
|
557
570
|
|
|
558
571
|
Each named association must:
|
|
559
572
|
|
|
560
573
|
- Exist on the model (raises `ArgumentError: unknown association ...` otherwise).
|
|
561
574
|
- Point to a class that's itself a registered Plutonium resource (raises `... is not a registered resource` otherwise).
|
|
562
575
|
|
|
576
|
+
🚨 **"Registered" means registered in the portal rendering the page.** `registered_resources` is the current portal engine's `resource_register`, so a policy shared by several portals can only list associations whose class every one of those portals registers (`register_resource` in its `config/routes.rb`). Otherwise that portal's show page raises, it does not just drop the tab:
|
|
577
|
+
|
|
578
|
+
```
|
|
579
|
+
ArgumentError: Catalog::Product#product_metadata defined in #permitted_associations, but Catalog::ProductMetadata is not a registered resource
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
Before adding an association, grep every portal's `config/routes.rb` for the child class and find which portals use the policy (the package policy, unless the portal has its own override). For "only admins get the tab", leave the shared policy alone and add it in a portal-specific policy (next section):
|
|
583
|
+
|
|
584
|
+
```ruby
|
|
585
|
+
# rails g pu:res:conn Catalog::Product --dest=admin_portal --policy
|
|
586
|
+
class AdminPortal::Catalog::ProductPolicy < ::Catalog::ProductPolicy
|
|
587
|
+
include AdminPortal::ResourcePolicy
|
|
588
|
+
|
|
589
|
+
def permitted_associations = [*super, :product_metadata]
|
|
590
|
+
end
|
|
591
|
+
```
|
|
592
|
+
|
|
563
593
|
This is **NOT** the same as:
|
|
564
594
|
|
|
565
|
-
- **Nested forms
|
|
566
|
-
- **Association fields on tables / show details
|
|
595
|
+
- **Nested forms**: declared with `nested_input :variants` in the definition; requires `accepts_nested_attributes_for` on the model. See [[plutonium-resource]] › Nested Inputs.
|
|
596
|
+
- **Association fields on tables / show details**: controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
|
|
567
597
|
|
|
568
598
|
## Collection scoping (`relation_scope`)
|
|
569
599
|
|
|
570
|
-
Filter which records the user can see. **Always compose with `default_relation_scope(relation)` explicitly
|
|
600
|
+
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!`:
|
|
571
601
|
|
|
572
602
|
```ruby
|
|
573
603
|
relation_scope do |relation|
|
|
@@ -580,6 +610,8 @@ For tenant scoping, parent scoping, `skip_default_relation_scope!`, and `associa
|
|
|
580
610
|
|
|
581
611
|
## Portal-specific policies
|
|
582
612
|
|
|
613
|
+
Generate the override with `rails g pu:res:conn <Resource> --dest=<portal> --policy` (`--policy` forces the file even when a base policy exists). It subclasses the base policy and includes the portal's `ResourcePolicy`, so override only what differs and call `super` for the rest.
|
|
614
|
+
|
|
583
615
|
```ruby
|
|
584
616
|
class PostPolicy < ResourcePolicy
|
|
585
617
|
def create? = user.present?
|
|
@@ -667,7 +699,7 @@ end
|
|
|
667
699
|
|
|
668
700
|
---
|
|
669
701
|
|
|
670
|
-
# Part 3
|
|
702
|
+
# Part 3: Interactions
|
|
671
703
|
|
|
672
704
|
An interaction is the entry point from a Plutonium page into an operation: it declares the inputs, renders as a button and a form, is gated by a policy, and returns an outcome the controller turns into a flash + redirect. Registered as actions in definitions (see [[plutonium-resource]] › Actions) and executed by the controller.
|
|
673
705
|
|
|
@@ -685,13 +717,13 @@ def initialize(view_context:, **attributes)
|
|
|
685
717
|
|
|
686
718
|
| | |
|
|
687
719
|
|---|---|
|
|
688
|
-
| **Logic may start in `execute`** | A one-off with a single caller is fine inline. Don't pre-extract
|
|
720
|
+
| **Logic may start in `execute`** | A one-off with a single caller is fine inline. Don't pre-extract: Plutonium ships no service layer to put it in, and YAGNI. |
|
|
689
721
|
| **The trigger to extract is the second caller** | A background job, an API controller, a rake task, the console, another interaction. |
|
|
690
|
-
| **The destination is the model** | Fat models, per Rails convention. Name it in domain language
|
|
722
|
+
| **The destination is the model** | Fat models, per Rails convention. Name it in domain language (`publish!`, `archive!`, `register!`), not persistence (`update_published_at`). Never a `PublishPostService`. |
|
|
691
723
|
|
|
692
724
|
```ruby
|
|
693
725
|
# 🚫 Three interactions, three view_contexts. A signup API or a seeds script
|
|
694
|
-
# can supply none of them
|
|
726
|
+
# can supply none of them, so the email and the audit row are stranded.
|
|
695
727
|
CreateUserInteraction.call(view_context:, **user_params)
|
|
696
728
|
.and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
|
|
697
729
|
.and_then { |user| LogActivity.call(view_context:, user:) }
|
|
@@ -712,14 +744,14 @@ Chaining three interactions is usually one model method wearing three presentati
|
|
|
712
744
|
class ResourceInteraction < Plutonium::Resource::Interaction
|
|
713
745
|
end
|
|
714
746
|
|
|
715
|
-
# app/models/post.rb
|
|
747
|
+
# app/models/post.rb: what publishing MEANS (a scheduler job can call this too)
|
|
716
748
|
class Post < ApplicationRecord
|
|
717
749
|
def publish!(on: Time.current)
|
|
718
750
|
update!(published: true, published_at: on)
|
|
719
751
|
end
|
|
720
752
|
end
|
|
721
753
|
|
|
722
|
-
# A real interaction
|
|
754
|
+
# A real interaction: the button in front of it
|
|
723
755
|
class PublishPostInteraction < ResourceInteraction
|
|
724
756
|
presents label: "Publish",
|
|
725
757
|
icon: Phlex::TablerIcons::Send,
|
|
@@ -758,7 +790,7 @@ attribute :metadata, :hash
|
|
|
758
790
|
attribute :date, :datetime
|
|
759
791
|
```
|
|
760
792
|
|
|
761
|
-
The presence of `:resource` / `:resources` / neither determines the action type
|
|
793
|
+
The presence of `:resource` / `:resources` / neither determines the action type; see [[plutonium-resource]] › Action Types.
|
|
762
794
|
|
|
763
795
|
### Structured / repeating input
|
|
764
796
|
|
|
@@ -778,7 +810,7 @@ end
|
|
|
778
810
|
```
|
|
779
811
|
|
|
780
812
|
⚠️ **`nested_input` and `accepts_nested_attributes_for` are NOT available on
|
|
781
|
-
interactions** (they were model-backed). Use `structured_input` instead
|
|
813
|
+
interactions** (they were model-backed). Use `structured_input` instead; it's
|
|
782
814
|
classless and collects plain hashes/arrays. See [[plutonium-resource]] ›
|
|
783
815
|
Structured Inputs for options (`repeat:`, `using:`, `fields:`).
|
|
784
816
|
|
|
@@ -809,7 +841,7 @@ MyInteraction.description
|
|
|
809
841
|
|
|
810
842
|
If `action :foo, interaction: FooInteraction` doesn't override `label:`/`icon:`/etc., these `presents` values are used.
|
|
811
843
|
|
|
812
|
-
## `execute`
|
|
844
|
+
## `execute` outcomes
|
|
813
845
|
|
|
814
846
|
`execute` MUST return a `succeed(...)` or `failed(...)` outcome. Validations run automatically before `execute`; if they fail, the interaction short-circuits to `failed()`.
|
|
815
847
|
|
|
@@ -832,9 +864,9 @@ failed(email: "is invalid", name: "is required") # hash form
|
|
|
832
864
|
failed("Invalid value", :email) # string + attribute
|
|
833
865
|
```
|
|
834
866
|
|
|
835
|
-
### Chaining
|
|
867
|
+
### Chaining with `and_then`
|
|
836
868
|
|
|
837
|
-
On a `Success`, `and_then` yields **the value** (NOT the outcome
|
|
869
|
+
On a `Success`, `and_then` yields **the value** (NOT the outcome; there is no `r.value` in the block) and returns whatever the block returns; on a `Failure` it short-circuits, returning the failure untouched.
|
|
838
870
|
|
|
839
871
|
Use it to compose outcomes **inside one `execute`**, e.g. a guard:
|
|
840
872
|
|
|
@@ -853,11 +885,11 @@ def unlocked_resource
|
|
|
853
885
|
end
|
|
854
886
|
```
|
|
855
887
|
|
|
856
|
-
⚠️ **Don't chain interactions to sequence business operations
|
|
888
|
+
⚠️ **Don't chain interactions to sequence business operations**; see Where the logic goes above.
|
|
857
889
|
|
|
858
890
|
## Validations
|
|
859
891
|
|
|
860
|
-
Standard ActiveModel
|
|
892
|
+
Standard ActiveModel, run automatically before `execute`; if they fail, `execute` never runs:
|
|
861
893
|
|
|
862
894
|
```ruby
|
|
863
895
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
@@ -876,16 +908,16 @@ end
|
|
|
876
908
|
|
|
877
909
|
| | Interaction validation | Model validation |
|
|
878
910
|
|---|---|---|
|
|
879
|
-
| Asks | "Can I read this input?"
|
|
911
|
+
| Asks | "Can I read this input?" (present, parses, right type, plausible format) | "Is this record legal?" (invariants that hold no matter who calls) |
|
|
880
912
|
| Exists to | render a form error next to the field | protect the data from every caller, including ones with no form |
|
|
881
|
-
| Runs | before `execute`, never touching the model | inside `save!`/`update
|
|
913
|
+
| Runs | before `execute`, never touching the model | inside `save!`/`update!`, i.e. inside the model method |
|
|
882
914
|
|
|
883
915
|
They surface **differently**, and that should inform where a rule lives:
|
|
884
916
|
|
|
885
917
|
- An interaction validation attaches to a declared attribute → the re-rendered modal shows it inline against that input **and** in the summary.
|
|
886
918
|
- `failed(record.errors)` flattens `ActiveModel::Errors` to **full messages on `:base`** (`Array(errors)` → `errors.to_a` → `full_messages`) → error summary only, never against a field, phrased with the *model's* attribute names.
|
|
887
919
|
|
|
888
|
-
So it's fine
|
|
920
|
+
So it's fine, often right, to **duplicate** a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: when a job calls `post.publish!`, the interaction's validations aren't in the picture.
|
|
889
921
|
|
|
890
922
|
## Accessing context
|
|
891
923
|
|
|
@@ -897,7 +929,7 @@ def execute
|
|
|
897
929
|
end
|
|
898
930
|
```
|
|
899
931
|
|
|
900
|
-
This write is **correctly inline**. "Who clicked the button" is context only the presentation layer holds
|
|
932
|
+
This write is **correctly inline**. "Who clicked the button" is context only the presentation layer holds (a job has no answer for it), so there's no second caller to extract for.
|
|
901
933
|
|
|
902
934
|
A shorter `current_user` helper is conventional:
|
|
903
935
|
|
|
@@ -921,15 +953,15 @@ def current_user = view_context.controller.helpers.current_user
|
|
|
921
953
|
Use `resource_url_for` with the `interaction:` kwarg. Action type is inferred from the element and presence of `ids:`:
|
|
922
954
|
|
|
923
955
|
```ruby
|
|
924
|
-
# Record action
|
|
956
|
+
# Record action: instance argument
|
|
925
957
|
resource_url_for(@post, interaction: :publish)
|
|
926
958
|
# => /posts/:id/record_actions/publish
|
|
927
959
|
|
|
928
|
-
# Resource action
|
|
960
|
+
# Resource action: class, no ids
|
|
929
961
|
resource_url_for(Post, interaction: :import)
|
|
930
962
|
# => /posts/resource_actions/import
|
|
931
963
|
|
|
932
|
-
# Bulk action
|
|
964
|
+
# Bulk action: class + ids
|
|
933
965
|
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
|
|
934
966
|
# => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3
|
|
935
967
|
|
|
@@ -937,14 +969,14 @@ resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
|
|
|
937
969
|
resource_url_for(@post, parent: @user, interaction: :publish)
|
|
938
970
|
```
|
|
939
971
|
|
|
940
|
-
The same URL serves GET (form/confirmation) and POST (commit)
|
|
972
|
+
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`.
|
|
941
973
|
|
|
942
974
|
## Complete example
|
|
943
975
|
|
|
944
|
-
Inviting a user is a textbook second-caller case
|
|
976
|
+
Inviting a user is a textbook second-caller case: a seats-provisioning job, an admin rake task, and a signup API all need to send the same invitation. So the operation lives on `Company`; the interaction is the button in front of it.
|
|
945
977
|
|
|
946
978
|
```ruby
|
|
947
|
-
# app/models/company.rb
|
|
979
|
+
# app/models/company.rb: what inviting MEANS: the row, the mail, the audit trail
|
|
948
980
|
class Company < ApplicationRecord
|
|
949
981
|
has_many :user_invites
|
|
950
982
|
|
|
@@ -971,7 +1003,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
971
1003
|
input :email
|
|
972
1004
|
input :role, as: :select, choices: -> { UserInvite.roles.keys }
|
|
973
1005
|
|
|
974
|
-
# Input shape only
|
|
1006
|
+
# Input shape only: readable email? a role that exists?
|
|
975
1007
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
976
1008
|
validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
|
|
977
1009
|
validate :not_already_invited
|
|
@@ -1001,7 +1033,7 @@ end
|
|
|
1001
1033
|
|
|
1002
1034
|
## Related Skills
|
|
1003
1035
|
|
|
1004
|
-
- [[plutonium-resource]]
|
|
1005
|
-
- [[plutonium-tenancy]]
|
|
1006
|
-
- [[plutonium-ui]]
|
|
1007
|
-
- [[plutonium-testing]]
|
|
1036
|
+
- [[plutonium-resource]]: registering interactions as actions; field/input/display syntax
|
|
1037
|
+
- [[plutonium-tenancy]]: `relation_scope`, entity scoping, nested resources
|
|
1038
|
+
- [[plutonium-ui]]: custom interaction form templates, page classes
|
|
1039
|
+
- [[plutonium-testing]]: testing controllers, policies, interactions
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-dashboard
|
|
3
|
-
description: Use BEFORE building a dashboard, KPI page, metrics overview or chart page in a Plutonium app
|
|
3
|
+
description: 'Use BEFORE building a dashboard, KPI page, metrics overview or chart page in a Plutonium app: Plutonium::Dashboard::Base, the metric / chart / card DSL, register_dashboard, lazy turbo-frame cards, refresh, conditions, authorize?, and the pu:dashboard generator. The single source for "how do I add a dashboard with metric cards and charts".'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Dashboards
|
|
7
7
|
|
|
8
|
-
A dashboard is a class of cards (`metric`, `chart`, `card`) mounted in a portal with one routes line.
|
|
8
|
+
A dashboard is a class of cards (`metric`, `chart`, `card`) mounted in a portal with one routes line. By default every card loads in its own lazy turbo frame, so the page paints at once and each card's queries run in a separate request. Charts render with Chart.js through Chartkick, styled by Plutonium's design tokens.
|
|
9
9
|
|
|
10
10
|
For resources and their definitions see [[plutonium-resource]]; for portals and routes see [[plutonium-app]]; for custom Phlex markup inside a card see [[plutonium-ui]].
|
|
11
11
|
|
|
@@ -19,7 +19,8 @@ For resources and their definitions see [[plutonium-resource]]; for portals and
|
|
|
19
19
|
- **Root-qualify packaged models inside a portal dashboard.** Within `module AdminPortal`, `Blogging::Post` resolves to the portal's own `AdminPortal::Blogging` controller namespace and raises `NameError`; write `::Blogging::Post`.
|
|
20
20
|
- **`metric` and `chart` blocks return data; `card` blocks render Phlex markup.** A `card` block is `instance_exec`ed in a Phlex component: `div`, `ul`, `render` work, and unknown methods forward to the dashboard.
|
|
21
21
|
- **`condition:` gates the card endpoint too** (404 when false), unlike a field `condition:`. Page-level access is `authorize?` on the dashboard (403).
|
|
22
|
-
- **`refresh:` needs `lazy: true`** (the default). It reloads the frame; an inline card has no frame. `refresh: false` opts a lazy card out of the dashboard's `refresh`.
|
|
22
|
+
- **`refresh:` needs `lazy: true`** (the default). It reloads the frame; an inline card has no frame. `refresh: false` opts a lazy card out of the dashboard's `refresh`. A card is inline or refreshing, never both: when a request wants both for one card, say so and let the user pick (see [Lazy vs inline](#lazy-vs-inline-lazy-false)).
|
|
23
|
+
- **Work within the DSL in an app; don't patch the gem.** Removing the `refresh:` + `lazy: false` guard, teaching the board to refresh inline cards, or hand-rolling polling (a custom Stimulus timer, meta refresh, a Turbo stream broadcast) is not the fix for a dashboard request. Gem edits change behavior for every app and ship only with a release; a custom poller duplicates the built-in `frame-refresh` controller.
|
|
23
24
|
- **Chart options other than `type:` / `height:` pass straight to Chartkick.** Unknown metric options raise.
|
|
24
25
|
- **Ejected sidebars don't list dashboards automatically.** Portals generated before this feature have their own `_resource_sidebar.html.erb`; add the `registered_dashboards` block (below), which groups them under a "Dashboards" parent after the Home link (`plutonium.resource.nav.home`).
|
|
25
26
|
|
|
@@ -108,7 +109,7 @@ register_dashboard Reports::WeeklyDashboard, at: "reports/weekly", as: "weekly"
|
|
|
108
109
|
- Entity-scoped portals: URLs carry the scope segment; use `dashboard_path_for(Klass)` rather than a hand-built helper.
|
|
109
110
|
- Every non-root mount is drawn under `dashboards/` (`at: "sales"` → `/admin/dashboards/sales`), so it cannot collide with a resource route. Helper names carry no prefix. `prefix: nil` mounts at the bare path (`/admin/sales`, keeping it clear of resource routes is then on you); `prefix: "reports"` swaps the segment.
|
|
110
111
|
- Lazy vs inline: see the table below.
|
|
111
|
-
- Errors in a card: raised
|
|
112
|
+
- Errors in a card: raised when `config.consider_all_requests_local` is true (development and test by default). Otherwise (production) the card is replaced by a notice, and the error is logged and reported via `Rails.error` (`source: "plutonium.dashboard"`, `context: {dashboard:, card:}`).
|
|
112
113
|
|
|
113
114
|
## Lazy vs inline (`lazy: false`)
|
|
114
115
|
|
|
@@ -124,6 +125,12 @@ register_dashboard Reports::WeeklyDashboard, at: "reports/weekly", as: "weekly"
|
|
|
124
125
|
|
|
125
126
|
Default to lazy. Use `lazy: false` only for a cheap value (cached number, indexed count) near the top of the page; one slow inline card delays the whole page.
|
|
126
127
|
|
|
128
|
+
When someone asks for a card to "render with the page" (usually to stop the skeleton flash), make the trade explicit in your answer rather than just flipping the flag:
|
|
129
|
+
|
|
130
|
+
- **The page waits on it.** The block's query runs inside the page request, so its time is added to every page load.
|
|
131
|
+
- **An exception takes the page down in development and test.** The error is re-raised while rendering the page, so the whole dashboard 500s, not just that card. In production it degrades to the card's notice like a lazy card.
|
|
132
|
+
- **It stops refreshing.** If the same card should also auto-refresh, offer the two options: inline with no refresh (updates on page reload), or keep it lazy so it refreshes. A lazy card shows its skeleton only on the first load; each refresh reloads the frame with `refresh: "morph"` and swaps the content in place without a skeleton.
|
|
133
|
+
|
|
127
134
|
## Sidebar (ejected partial)
|
|
128
135
|
|
|
129
136
|
```erb
|