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
|
@@ -5,11 +5,11 @@ The entry point from a Plutonium page into an operation. An interaction declares
|
|
|
5
5
|
## 🚨 Critical
|
|
6
6
|
|
|
7
7
|
- **`ActiveRecord::RecordInvalid` is NOT rescued automatically.** Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
|
|
8
|
-
- **Return `succeed(...)` or `failed(...)` from `execute
|
|
9
|
-
- **Redirect is automatic on success
|
|
10
|
-
- **Bulk actions use `attribute :resources` (plural).** Policy authorization is checked per record
|
|
8
|
+
- **Return `succeed(...)` or `failed(...)` from `execute`**: the controller can't tell what happened otherwise. Returning anything else raises.
|
|
9
|
+
- **Redirect is automatic on success**: only use `with_redirect_response` for a *different* destination.
|
|
10
|
+
- **Bulk actions use `attribute :resources` (plural).** Policy authorization is checked per record; if any fails, the whole request fails.
|
|
11
11
|
- **The shape of the action (record / bulk / resource) is inferred from the interaction's attributes.** See [Resource › Actions](/reference/resource/actions#inferred-visibility-interactive-actions).
|
|
12
|
-
- **An interaction is a presentation object.** Logic may *start* in `execute`; the **second caller**
|
|
12
|
+
- **An interaction is a presentation object.** Logic may *start* in `execute`; the **second caller** (a job, an API controller, a rake task, the console) is the signal to move it to the model. See [below](#what-an-interaction-is-for).
|
|
13
13
|
|
|
14
14
|
## What an interaction is for {#what-an-interaction-is-for}
|
|
15
15
|
|
|
@@ -17,20 +17,20 @@ An interaction is a **presentation object**. It exists so Plutonium can render a
|
|
|
17
17
|
|
|
18
18
|
| An interaction owns | An interaction does not own |
|
|
19
19
|
|---|---|
|
|
20
|
-
| The button
|
|
21
|
-
| The form
|
|
22
|
-
| **Input shape** validation: present? parses? right type? | **Business invariants
|
|
23
|
-
| The user-facing outcome
|
|
20
|
+
| The button: `presents label:` / `icon:` | *Who* may click it. That's the [policy](./policies). |
|
|
21
|
+
| The form: `attribute` + `input` declarations | - |
|
|
22
|
+
| **Input shape** validation: present? parses? right type? | **Business invariants**: they must hold for every caller, so they belong on the model |
|
|
23
|
+
| The user-facing outcome: `succeed` / `failed`, messages, redirect | The domain operation itself, once more than one caller needs it |
|
|
24
24
|
|
|
25
25
|
### Logic may start in `execute`
|
|
26
26
|
|
|
27
|
-
A one-off operation with exactly one caller is perfectly fine written inline. Don't pre-extract a service object for a two-line `update
|
|
27
|
+
A one-off operation with exactly one caller is perfectly fine written inline. Don't pre-extract a service object for a two-line `update!`; that's YAGNI, and Plutonium deliberately ships no service layer to put it in. The rule below is a **refactoring trigger**, not a prohibition.
|
|
28
28
|
|
|
29
29
|
### The second caller is the trigger to extract
|
|
30
30
|
|
|
31
31
|
The moment a background job, an API controller, a rake task, the console, or another interaction needs the same behaviour, move it to the model.
|
|
32
32
|
|
|
33
|
-
The deadline is *the second caller
|
|
33
|
+
The deadline is *the second caller*, not "as soon as it looks like business logic", because of one line in the base class:
|
|
34
34
|
|
|
35
35
|
```ruby
|
|
36
36
|
def initialize(view_context:, **attributes)
|
|
@@ -61,9 +61,9 @@ rescue ActiveRecord::RecordInvalid => e
|
|
|
61
61
|
end
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
Name it for the domain (`publish!`, `archive!`, `register!`), not for the persistence (`update_published_at`)
|
|
64
|
+
Name it for the domain (`publish!`, `archive!`, `register!`), not for the persistence (`update_published_at`); the point is that a scheduler job can now call `post.publish!` and read as if it meant it. And resist inventing a `PublishPostService`: the model is the destination, not a new layer.
|
|
65
65
|
|
|
66
|
-
### Worked counter-example
|
|
66
|
+
### Worked counter-example: chained interactions
|
|
67
67
|
|
|
68
68
|
```ruby
|
|
69
69
|
# 🚫 Every link demands a view_context that has nothing to do with the work
|
|
@@ -72,7 +72,7 @@ CreateUserInteraction.call(view_context:, **user_params)
|
|
|
72
72
|
.and_then { |user| LogActivity.call(view_context:, user:) }
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
Sending a welcome email and writing an audit row are precisely what a signup API endpoint, a seeds script, or a console session also has to do
|
|
75
|
+
Sending a welcome email and writing an audit row are precisely what a signup API endpoint, a seeds script, or a console session also has to do, none of which has a `view_context`. Modelled as interactions, they are unreachable from anywhere but a Plutonium page.
|
|
76
76
|
|
|
77
77
|
```ruby
|
|
78
78
|
# ✅ The model owns registering a user; the interaction presents it
|
|
@@ -82,12 +82,12 @@ def execute
|
|
|
82
82
|
end
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
Chaining three interactions is usually the signal that you have one model method wearing three presentation costumes. `and_then` is real API and stays [documented below](#chaining)
|
|
85
|
+
Chaining three interactions is usually the signal that you have one model method wearing three presentation costumes. `and_then` is real API and stays [documented below](#chaining), just don't reach for it to sequence business operations.
|
|
86
86
|
|
|
87
87
|
## Structure
|
|
88
88
|
|
|
89
89
|
```ruby
|
|
90
|
-
# app/interactions/resource_interaction.rb
|
|
90
|
+
# app/interactions/resource_interaction.rb: installed once
|
|
91
91
|
class ResourceInteraction < Plutonium::Resource::Interaction
|
|
92
92
|
end
|
|
93
93
|
|
|
@@ -107,7 +107,7 @@ class PublishPostInteraction < ResourceInteraction
|
|
|
107
107
|
private
|
|
108
108
|
|
|
109
109
|
def execute
|
|
110
|
-
resource.publish!(on: publish_date) # Post#publish
|
|
110
|
+
resource.publish!(on: publish_date) # Post#publish!, see above
|
|
111
111
|
succeed(resource).with_message("Post published!")
|
|
112
112
|
rescue ActiveRecord::RecordInvalid => e
|
|
113
113
|
failed(e.record.errors)
|
|
@@ -115,7 +115,7 @@ class PublishPostInteraction < ResourceInteraction
|
|
|
115
115
|
end
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-
Note the division: the interaction declares the input, validates that a date was supplied, and phrases the flash. `Post#publish!` decides what publishing a post *means
|
|
118
|
+
Note the division: the interaction declares the input, validates that a date was supplied, and phrases the flash. `Post#publish!` decides what publishing a post *means*, so the scheduled-publishing job can call it too.
|
|
119
119
|
|
|
120
120
|
## Attributes
|
|
121
121
|
|
|
@@ -132,11 +132,11 @@ attribute :metadata, :hash
|
|
|
132
132
|
attribute :date, :datetime
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
The presence of `:resource` / `:resources` / neither determines the action type
|
|
135
|
+
The presence of `:resource` / `:resources` / neither determines the action type, see [Resource › Actions › Inferred visibility](/reference/resource/actions#inferred-visibility-interactive-actions).
|
|
136
136
|
|
|
137
137
|
## Inputs
|
|
138
138
|
|
|
139
|
-
Same DSL as definition `input`. Auto-detection from the attribute type applies
|
|
139
|
+
Same DSL as definition `input`. Auto-detection from the attribute type applies; declare `as:` only when overriding.
|
|
140
140
|
|
|
141
141
|
```ruby
|
|
142
142
|
input :email # auto: :email type from name match
|
|
@@ -164,7 +164,7 @@ MyInteraction.description # => "Move to archive"
|
|
|
164
164
|
|
|
165
165
|
If `action :foo, interaction: FooInteraction` doesn't override `label:` / `icon:` etc., these `presents` values are used.
|
|
166
166
|
|
|
167
|
-
## `execute
|
|
167
|
+
## `execute`: outcomes
|
|
168
168
|
|
|
169
169
|
`execute` MUST return a `succeed(...)` or `failed(...)` outcome. Validations run automatically before `execute`; if they fail, the interaction short-circuits to `failed()`.
|
|
170
170
|
|
|
@@ -220,7 +220,7 @@ end
|
|
|
220
220
|
```
|
|
221
221
|
|
|
222
222
|
::: warning Don't use `and_then` to sequence business operations
|
|
223
|
-
A chain of three interactions is a chain of three things that each demand a `view_context`, none of which a job or an API controller can supply. That's one model method wearing three costumes
|
|
223
|
+
A chain of three interactions is a chain of three things that each demand a `view_context`, none of which a job or an API controller can supply. That's one model method wearing three costumes, see [Worked counter-example](#what-an-interaction-is-for). `and_then` earns its keep composing outcomes *within* one interaction, or in a test.
|
|
224
224
|
:::
|
|
225
225
|
|
|
226
226
|
## Validations
|
|
@@ -242,18 +242,18 @@ end
|
|
|
242
242
|
|
|
243
243
|
### Which validation goes where
|
|
244
244
|
|
|
245
|
-
Interactions have validations and so do models, and they are not competing
|
|
245
|
+
Interactions have validations and so do models, and they are not competing: they answer different questions:
|
|
246
246
|
|
|
247
247
|
| | Interaction validation | Model validation |
|
|
248
248
|
|---|---|---|
|
|
249
|
-
| Asks | "Can I read this input?"
|
|
249
|
+
| Asks | "Can I read this input?": present, parses, right type, plausible format | "Is this record legal?": invariants that hold no matter who is calling |
|
|
250
250
|
| Exists to | render a form error next to the field | protect the data from every caller, including the ones with no form |
|
|
251
|
-
| Runs | before `execute`, without ever touching the model | inside `save!` / `update
|
|
251
|
+
| Runs | before `execute`, without ever touching the model | inside `save!` / `update!`, i.e. inside your model method |
|
|
252
252
|
|
|
253
253
|
Both surface to the user, but **not identically**, and the difference should inform where you put a rule:
|
|
254
254
|
|
|
255
255
|
- An **interaction** validation attaches to a declared attribute. The re-rendered modal shows it inline against that input, and again in the summary at the top of the form.
|
|
256
|
-
- `failed(record.errors)` flattens `ActiveModel::Errors` into **full messages on `:base`** (`Array(errors)` calls `errors.to_a`, which is `full_messages`). Those land in the form's error summary only
|
|
256
|
+
- `failed(record.errors)` flattens `ActiveModel::Errors` into **full messages on `:base`** (`Array(errors)` calls `errors.to_a`, which is `full_messages`). Those land in the form's error summary only, never against a field, and they're phrased with the *model's* attribute names, which need not match your inputs.
|
|
257
257
|
|
|
258
258
|
So it is fine, and 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: the moment a job calls `post.publish!`, the interaction's validations are not in the picture at all.
|
|
259
259
|
|
|
@@ -268,7 +268,7 @@ def execute
|
|
|
268
268
|
end
|
|
269
269
|
```
|
|
270
270
|
|
|
271
|
-
This one is *correctly* inline. "Who clicked the button" is context the presentation layer holds and nothing else does
|
|
271
|
+
This one is *correctly* inline. "Who clicked the button" is context the presentation layer holds and nothing else does: `current_user` is read straight off the `view_context`. A job has no answer for it, so there is no second caller to extract for.
|
|
272
272
|
|
|
273
273
|
## Interaction types
|
|
274
274
|
|
|
@@ -306,7 +306,7 @@ class BulkArchiveInteraction < Plutonium::Resource::Interaction
|
|
|
306
306
|
end
|
|
307
307
|
```
|
|
308
308
|
|
|
309
|
-
`update_all` stays inline on purpose: it's a single-statement SQL update whose *whole point* is skipping per-record model machinery. If archiving means more than setting a column
|
|
309
|
+
`update_all` stays inline on purpose: it's a single-statement SQL update whose *whole point* is skipping per-record model machinery. If archiving means more than setting a column (callbacks, an audit row, a webhook), this is the wrong shape; call `resources.each(&:archive!)` and let the model own it.
|
|
310
310
|
|
|
311
311
|
Per-record authorization details in [Resource › Actions › Bulk action](/reference/resource/actions#bulk-action).
|
|
312
312
|
|
|
@@ -327,10 +327,10 @@ end
|
|
|
327
327
|
|
|
328
328
|
## Calling interactions directly
|
|
329
329
|
|
|
330
|
-
The controller handles this for interactive actions. You can also call one by hand
|
|
330
|
+
The controller handles this for interactive actions. You can also call one by hand, chiefly in **tests**, where you're exercising the interaction itself.
|
|
331
331
|
|
|
332
332
|
::: tip Needing this in a job or a rake task is the signal to refactor
|
|
333
|
-
Both entry points require `view_context:`, and a job doesn't have one. If you find yourself reaching for a stub to satisfy it, you don't want the interaction
|
|
333
|
+
Both entry points require `view_context:`, and a job doesn't have one. If you find yourself reaching for a stub to satisfy it, you don't want the interaction, you want the model method it wraps. See [What an interaction is for](#what-an-interaction-is-for).
|
|
334
334
|
:::
|
|
335
335
|
|
|
336
336
|
### Class method
|
|
@@ -352,14 +352,14 @@ interaction = PublishPost.new(view_context: view_context, resource: post)
|
|
|
352
352
|
outcome = interaction.call
|
|
353
353
|
```
|
|
354
354
|
|
|
355
|
-
The `view_context:` argument is required
|
|
355
|
+
The `view_context:` argument is required: interactions use it to access controller helpers and the current user. It is also the boundary marker: everything reachable *only* through an interaction is reachable only from a page.
|
|
356
356
|
|
|
357
357
|
## Immediate vs form
|
|
358
358
|
|
|
359
359
|
| Interaction shape | Behavior |
|
|
360
360
|
|---|---|
|
|
361
|
-
| Only `:resource` / `:resources` (no extra `attribute` or `input`) | **Immediate
|
|
362
|
-
| Additional `attribute` / `input` declared | **Form
|
|
361
|
+
| Only `:resource` / `:resources` (no extra `attribute` or `input`) | **Immediate**: browser confirmation (`"#{label}?"`, e.g. `"Archive?"`), then runs. Override with `confirmation: "Custom"` or `confirmation: false` on the action. |
|
|
362
|
+
| Additional `attribute` / `input` declared | **Form**: renders modal form first; no auto-confirmation. |
|
|
363
363
|
|
|
364
364
|
See [Resource › Actions › Immediate vs form](/reference/resource/actions#immediate-vs-form).
|
|
365
365
|
|
|
@@ -368,15 +368,15 @@ See [Resource › Actions › Immediate vs form](/reference/resource/actions#imm
|
|
|
368
368
|
`resource_url_for` with the `interaction:` kwarg. The action type (record / bulk / resource) is inferred from the element and the presence of `ids:`:
|
|
369
369
|
|
|
370
370
|
```ruby
|
|
371
|
-
# Record action
|
|
371
|
+
# Record action: instance argument
|
|
372
372
|
resource_url_for(@post, interaction: :publish)
|
|
373
373
|
# => /posts/:id/record_actions/publish
|
|
374
374
|
|
|
375
|
-
# Resource action
|
|
375
|
+
# Resource action: class, no ids
|
|
376
376
|
resource_url_for(Post, interaction: :import)
|
|
377
377
|
# => /posts/resource_actions/import
|
|
378
378
|
|
|
379
|
-
# Bulk action
|
|
379
|
+
# Bulk action: class + ids
|
|
380
380
|
resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
|
|
381
381
|
# => /posts/bulk_actions/archive?ids[]=1&ids[]=2&ids[]=3
|
|
382
382
|
|
|
@@ -384,11 +384,11 @@ resource_url_for(Post, interaction: :archive, ids: [1, 2, 3])
|
|
|
384
384
|
resource_url_for(@post, parent: @user, interaction: :publish)
|
|
385
385
|
```
|
|
386
386
|
|
|
387
|
-
The same URL serves GET (form/confirmation) and POST (commit)
|
|
387
|
+
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`.
|
|
388
388
|
|
|
389
389
|
## Complete example
|
|
390
390
|
|
|
391
|
-
Inviting a user is a textbook second-caller case
|
|
391
|
+
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`, and the interaction is the button in front of it.
|
|
392
392
|
|
|
393
393
|
```ruby
|
|
394
394
|
# app/models/company.rb
|
|
@@ -419,7 +419,7 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
419
419
|
input :email
|
|
420
420
|
input :role, as: :select, choices: -> { UserInvite.roles.keys }
|
|
421
421
|
|
|
422
|
-
# Input shape
|
|
422
|
+
# Input shape: is this a readable email, is this a role that exists?
|
|
423
423
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
424
424
|
validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
|
|
425
425
|
validate :not_already_invited
|
|
@@ -460,12 +460,12 @@ RSpec.describe PublishPost do
|
|
|
460
460
|
end
|
|
461
461
|
```
|
|
462
462
|
|
|
463
|
-
See [Testing](/reference/testing/) for Plutonium's built-in testing helpers
|
|
463
|
+
See [Testing](/reference/testing/) for Plutonium's built-in testing helpers. The `ResourceInteraction` concern wraps these patterns.
|
|
464
464
|
|
|
465
465
|
## Related
|
|
466
466
|
|
|
467
|
-
- [Async Interactions](./async-interactions)
|
|
468
|
-
- [Resource › Actions](/reference/resource/actions)
|
|
469
|
-
- [Policies](./policies)
|
|
470
|
-
- [Controllers](./controllers)
|
|
471
|
-
- [UI › Forms](/reference/ui/forms)
|
|
467
|
+
- [Async Interactions](./async-interactions): `async` a persisted run instead of running `execute` inline
|
|
468
|
+
- [Resource › Actions](/reference/resource/actions): registering interactions, inferred visibility, immediate vs form
|
|
469
|
+
- [Policies](./policies): `def <action>?` authorization methods
|
|
470
|
+
- [Controllers](./controllers): `resource_url_for(..., interaction: …)` URL generation
|
|
471
|
+
- [UI › Forms](/reference/ui/forms): customizing the modal form rendered for actions with inputs
|
|
@@ -11,20 +11,20 @@ Authorization for resources. Built on [ActionPolicy](https://actionpolicy.evilma
|
|
|
11
11
|
|
|
12
12
|
- **`create?` and `read?` default to `false`.** You MUST override them explicitly. Everything else (`update?`, `destroy?`, `index?`, `show?`, …) derives from one of those.
|
|
13
13
|
- **`permitted_attributes_for_*` must be explicit in production.** Dev auto-detects; production raises.
|
|
14
|
-
- **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly in your override. `super` is fine when extending a parent policy (e.g., a package base) that itself calls it. The runtime check verifies it was hit somewhere
|
|
14
|
+
- **`relation_scope` must end up calling `default_relation_scope(relation)` somewhere in the chain.** Prefer calling it explicitly in your override. `super` is fine when extending a parent policy (e.g., a package base) that itself calls it. The runtime check verifies it was hit somewhere, not in this specific class.
|
|
15
15
|
- **For `has_cents` fields, use the virtual name** (`:price`), NEVER `:price_cents`.
|
|
16
16
|
- **Don't put `*_attributes` hashes in `permitted_attributes_for_*`.** Nested forms are extracted from the form definition, not the policy. List the association name (`:variants`) and the `nested_input` in the definition handles the rest.
|
|
17
17
|
- **Custom action ⇒ policy method.** `action :publish` needs `def publish?`. Undefined methods return `false` → action silently disappears.
|
|
18
|
-
- **Index has no `record
|
|
18
|
+
- **Index has no `record` instance.** On collection routes `record` is the resource class. Record-dependent `_for_read` overrides need an explicit `_for_index` too (see [below](#index-has-no-record)).
|
|
19
19
|
|
|
20
20
|
## Base class
|
|
21
21
|
|
|
22
22
|
```ruby
|
|
23
|
-
# app/policies/resource_policy.rb
|
|
23
|
+
# app/policies/resource_policy.rb: installed once
|
|
24
24
|
class ResourcePolicy < Plutonium::Resource::Policy
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# app/policies/post_policy.rb
|
|
27
|
+
# app/policies/post_policy.rb: per resource, generated
|
|
28
28
|
class PostPolicy < ResourcePolicy
|
|
29
29
|
def create? = user.present?
|
|
30
30
|
def read? = true
|
|
@@ -46,7 +46,7 @@ Inside a policy:
|
|
|
46
46
|
| Variable | Description |
|
|
47
47
|
|---|---|
|
|
48
48
|
| `user` | Current authenticated user (required) |
|
|
49
|
-
| `record` | Resource being authorized |
|
|
49
|
+
| `record` | Resource being authorized (the resource class on collection routes such as `index` and `new`) |
|
|
50
50
|
| `entity_scope` | Current scoped entity (multi-tenancy) |
|
|
51
51
|
| `parent` | Parent record for nested resources (nil otherwise) |
|
|
52
52
|
| `parent_association` | Association name on parent (e.g. `:comments`) |
|
|
@@ -88,7 +88,7 @@ def archive? = create? && !record.archived?
|
|
|
88
88
|
def invite_user? = user.admin?
|
|
89
89
|
```
|
|
90
90
|
|
|
91
|
-
### Bulk actions
|
|
91
|
+
### Bulk actions: per-record authorization
|
|
92
92
|
|
|
93
93
|
```ruby
|
|
94
94
|
def bulk_archive?
|
|
@@ -101,7 +101,7 @@ How it works:
|
|
|
101
101
|
- Policy is checked **per record** in the selected set.
|
|
102
102
|
- **Backend:** if any record fails, the entire request is rejected.
|
|
103
103
|
- **UI:** only actions ALL selected records support are shown (intersection).
|
|
104
|
-
- Records come from `current_authorized_scope
|
|
104
|
+
- Records come from `current_authorized_scope`, so users can only select records they're allowed to access.
|
|
105
105
|
|
|
106
106
|
## Attribute permissions
|
|
107
107
|
|
|
@@ -122,6 +122,7 @@ end
|
|
|
122
122
|
|---|---|
|
|
123
123
|
| `permitted_attributes_for_update` | `permitted_attributes_for_create` |
|
|
124
124
|
| `permitted_attributes_for_index` | `permitted_attributes_for_read` |
|
|
125
|
+
| `permitted_attributes_for_export` | `permitted_attributes_for_index` |
|
|
125
126
|
| `permitted_attributes_for_show` | `permitted_attributes_for_read` |
|
|
126
127
|
| `permitted_attributes_for_new` | `permitted_attributes_for_create` |
|
|
127
128
|
| `permitted_attributes_for_edit` | `permitted_attributes_for_update` |
|
|
@@ -140,7 +141,7 @@ end
|
|
|
140
141
|
|
|
141
142
|
### Index has no `record`
|
|
142
143
|
|
|
143
|
-
🚨 `permitted_attributes_for_index` is evaluated at the **collection level**
|
|
144
|
+
🚨 `permitted_attributes_for_index` is evaluated at the **collection level**. There is no record instance: the policy subject is the resource **class**, so `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`) ARE evaluated per record.
|
|
144
145
|
|
|
145
146
|
If you write a record-dependent `_for_read`:
|
|
146
147
|
|
|
@@ -152,7 +153,7 @@ def permitted_attributes_for_read
|
|
|
152
153
|
end
|
|
153
154
|
```
|
|
154
155
|
|
|
155
|
-
…you MUST also define an explicit `permitted_attributes_for_index`
|
|
156
|
+
…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).
|
|
156
157
|
|
|
157
158
|
```ruby
|
|
158
159
|
def permitted_attributes_for_index
|
|
@@ -160,7 +161,7 @@ def permitted_attributes_for_index
|
|
|
160
161
|
end
|
|
161
162
|
```
|
|
162
163
|
|
|
163
|
-
Same rule for `permitted_attributes_for_create` vs `_for_new`
|
|
164
|
+
Same rule for `permitted_attributes_for_create` vs `_for_new`: `new` is a collection route too, so `record` is the class there.
|
|
164
165
|
|
|
165
166
|
### Conditional attribute access
|
|
166
167
|
|
|
@@ -184,7 +185,7 @@ end
|
|
|
184
185
|
|
|
185
186
|
`permitted_attributes_for_*` controls **which fields appear** on a view. The definition's `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_*`.
|
|
186
187
|
|
|
187
|
-
Common mistake: adding a definition declaration and wondering why the field doesn't show
|
|
188
|
+
Common mistake: adding a definition declaration and wondering why the field doesn't show. Check the policy.
|
|
188
189
|
|
|
189
190
|
### Anti-pattern: nested-attributes hashes
|
|
190
191
|
|
|
@@ -229,17 +230,34 @@ def permitted_associations
|
|
|
229
230
|
end
|
|
230
231
|
```
|
|
231
232
|
|
|
232
|
-
Declares which associations get their own **tab on the show page**. When non-empty, the show page renders a tablist: a "Details" tab (the main field card + metadata aside) plus one tab per association
|
|
233
|
+
Declares which associations get their own **tab on the show page**. When 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.
|
|
233
234
|
|
|
234
235
|
Each named association must:
|
|
235
236
|
|
|
236
237
|
- Exist on the model (raises `ArgumentError: unknown association ...` otherwise).
|
|
237
238
|
- Point to a class that's itself a registered Plutonium resource (raises `... is not a registered resource` otherwise).
|
|
238
239
|
|
|
240
|
+
🚨 **"Registered" means registered in the portal rendering the page.** Each portal engine keeps its own 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 instead of dropping the tab:
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
ArgumentError: Catalog::Product#product_metadata defined in #permitted_associations, but Catalog::ProductMetadata is not a registered resource
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
To give only one portal the tab, leave the shared policy alone and add the association in a [portal-specific policy](#portal-specific-policies):
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
# rails g pu:res:conn Catalog::Product --dest=admin_portal --policy
|
|
250
|
+
class AdminPortal::Catalog::ProductPolicy < ::Catalog::ProductPolicy
|
|
251
|
+
include AdminPortal::ResourcePolicy
|
|
252
|
+
|
|
253
|
+
def permitted_associations = [*super, :product_metadata]
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
239
257
|
This is **NOT** the same as:
|
|
240
258
|
|
|
241
|
-
- **Nested forms
|
|
242
|
-
- **Association fields on tables / show details
|
|
259
|
+
- **Nested forms**: declared with `nested_input :variants` in the definition, requires `accepts_nested_attributes_for` on the model. See [Resource › Definition › Nested inputs](/reference/resource/definition#nested-inputs).
|
|
260
|
+
- **Association fields on tables / show details**: controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
|
|
243
261
|
|
|
244
262
|
## Collection scoping (`relation_scope`)
|
|
245
263
|
|
|
@@ -247,10 +265,10 @@ Filter which records the user can see.
|
|
|
247
265
|
|
|
248
266
|
### Always compose with `default_relation_scope`
|
|
249
267
|
|
|
250
|
-
🚨 `relation_scope` MUST end up calling `default_relation_scope(relation)` somewhere in the chain. `super` works
|
|
268
|
+
🚨 `relation_scope` MUST end up calling `default_relation_scope(relation)` somewhere in the chain. `super` works: `Plutonium::Resource::Policy` defines a default scope block that calls `default_relation_scope`, so a subclass that does `super(relation).where(...)` is fine. Calling `default_relation_scope` explicitly is also fine (and required when you skip the parent chain). Plutonium enforces this at runtime via `verify_default_relation_scope_applied!`.
|
|
251
269
|
|
|
252
270
|
```ruby
|
|
253
|
-
# ✅ Best
|
|
271
|
+
# ✅ Best: don't override at all. The inherited scope already calls default_relation_scope.
|
|
254
272
|
|
|
255
273
|
# ✅ Extra filters on top
|
|
256
274
|
relation_scope do |relation|
|
|
@@ -267,13 +285,13 @@ end
|
|
|
267
285
|
### Wrong patterns
|
|
268
286
|
|
|
269
287
|
```ruby
|
|
270
|
-
# ❌ Manually filtering by entity
|
|
288
|
+
# ❌ Manually filtering by entity, bypasses default_relation_scope
|
|
271
289
|
relation_scope { |r| r.where(organization: current_scoped_entity) }
|
|
272
290
|
|
|
273
|
-
# ❌ Manual joins
|
|
291
|
+
# ❌ Manual joins, same problem
|
|
274
292
|
relation_scope { |r| r.joins(:project).where(projects: {organization_id: current_scoped_entity.id}) }
|
|
275
293
|
|
|
276
|
-
# ❌ Missing default_relation_scope entirely
|
|
294
|
+
# ❌ Missing default_relation_scope entirely, raises at runtime
|
|
277
295
|
relation_scope { |r| r.where(published: true) }
|
|
278
296
|
```
|
|
279
297
|
|
|
@@ -282,13 +300,13 @@ relation_scope { |r| r.where(published: true) }
|
|
|
282
300
|
1. If a **parent** is present (nested resource), scopes via the parent association.
|
|
283
301
|
2. Otherwise, applies `relation.associated_with(entity_scope)` for multi-tenancy.
|
|
284
302
|
|
|
285
|
-
Parent scoping takes precedence over entity scoping
|
|
303
|
+
Parent scoping takes precedence over entity scoping. The parent was already authorized and entity-scoped during its own authorization, so double-scoping isn't needed.
|
|
286
304
|
|
|
287
305
|
Full mechanics in [Tenancy › Entity scoping](/reference/tenancy/entity-scoping).
|
|
288
306
|
|
|
289
307
|
### Intentionally skipping
|
|
290
308
|
|
|
291
|
-
Rare. Use `skip_default_relation_scope!` explicitly
|
|
309
|
+
Rare. Use `skip_default_relation_scope!` explicitly, never silently bypass:
|
|
292
310
|
|
|
293
311
|
```ruby
|
|
294
312
|
relation_scope do |relation|
|
|
@@ -301,12 +319,14 @@ Before reaching for this, consider a separate, unscoped portal.
|
|
|
301
319
|
|
|
302
320
|
## Portal-specific policies
|
|
303
321
|
|
|
322
|
+
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.
|
|
323
|
+
|
|
304
324
|
```ruby
|
|
305
325
|
class PostPolicy < ResourcePolicy
|
|
306
326
|
def create? = user.present?
|
|
307
327
|
end
|
|
308
328
|
|
|
309
|
-
# Admin
|
|
329
|
+
# Admin: more permissive
|
|
310
330
|
class AdminPortal::PostPolicy < ::PostPolicy
|
|
311
331
|
include AdminPortal::ResourcePolicy
|
|
312
332
|
|
|
@@ -314,7 +334,7 @@ class AdminPortal::PostPolicy < ::PostPolicy
|
|
|
314
334
|
def permitted_attributes_for_create = %i[title content featured internal_notes]
|
|
315
335
|
end
|
|
316
336
|
|
|
317
|
-
# Public
|
|
337
|
+
# Public: read-only
|
|
318
338
|
class PublicPortal::PostPolicy < ::PostPolicy
|
|
319
339
|
include PublicPortal::ResourcePolicy
|
|
320
340
|
def create? = false
|
|
@@ -410,8 +430,8 @@ policy.permitted_attributes_for_update
|
|
|
410
430
|
|
|
411
431
|
## Related
|
|
412
432
|
|
|
413
|
-
- [Controllers](./controllers)
|
|
414
|
-
- [Interactions](./interactions)
|
|
415
|
-
- [Resource › Actions](/reference/resource/actions)
|
|
416
|
-
- [Tenancy › Entity scoping](/reference/tenancy/entity-scoping)
|
|
417
|
-
- [ActionPolicy docs](https://actionpolicy.evilmartians.io/)
|
|
433
|
+
- [Controllers](./controllers): call policies via `authorize_current!` and `authorized_resource_scope`
|
|
434
|
+
- [Interactions](./interactions): custom actions whose policy methods you define
|
|
435
|
+
- [Resource › Actions](/reference/resource/actions): registering actions that need policy methods
|
|
436
|
+
- [Tenancy › Entity scoping](/reference/tenancy/entity-scoping): `default_relation_scope`, three model shapes, custom scopes
|
|
437
|
+
- [ActionPolicy docs](https://actionpolicy.evilmartians.io/): the underlying library
|
|
@@ -37,7 +37,7 @@ Passing a version older than the earliest available raises rather than silently
|
|
|
37
37
|
|
|
38
38
|
| Option | Default | Description |
|
|
39
39
|
|--------|---------|-------------|
|
|
40
|
-
| `load_defaults(version)` |
|
|
40
|
+
| `load_defaults(version)` | - | Apply versioned framework defaults. Call first. |
|
|
41
41
|
| `development` | `ENV["PLUTONIUM_DEV"]` | Development mode for the framework itself (local assets, hot reload, verbose errors). Query with `config.development?`. Apps rarely set this, see [Development mode](#development-mode). |
|
|
42
42
|
| `cache_discovery` | `true` outside the `development` env | Cache resource/route discovery. Disable to pick up new resources without a reboot. |
|
|
43
43
|
| `enable_hotreload` | `true` in the `development` env | Hot-reload Plutonium components on change. |
|
|
@@ -116,8 +116,8 @@ export PLUTONIUM_DEV=1
|
|
|
116
116
|
|
|
117
117
|
## Related
|
|
118
118
|
|
|
119
|
-
- [Assets](./ui/assets)
|
|
120
|
-
- [Layouts](./ui/layouts)
|
|
121
|
-
- [Components › Avatar](./ui/components#avatar)
|
|
122
|
-
- [Wizards › Storage & config](./wizard/storage-config)
|
|
123
|
-
- [Async interactions](./behavior/async-interactions)
|
|
119
|
+
- [Assets](./ui/assets): stylesheet, script, Tailwind, and design tokens
|
|
120
|
+
- [Layouts](./ui/layouts): the `shell` option and ejecting chrome
|
|
121
|
+
- [Components › Avatar](./ui/components#avatar): `navii_host_url`
|
|
122
|
+
- [Wizards › Storage & config](./wizard/storage-config): the `wizards.*` settings in context
|
|
123
|
+
- [Async interactions](./behavior/async-interactions): the `async_interactions.*` settings in context
|
|
@@ -48,8 +48,8 @@ Keys are unique per dashboard; a duplicate raises at class load. Cards render in
|
|
|
48
48
|
| `description:` | String, lazy `t` | convention | Caption |
|
|
49
49
|
| `icon:` | Phlex icon class | none | Shown beside the title |
|
|
50
50
|
| `span:` | `1`..`12`, `:full` | metric `3`, chart `6`, card `6` | Columns of the 12-column grid. `:full` is `12`. On tablets (2 columns) a span of `6` or more takes the row; phones are one column |
|
|
51
|
-
| `lazy:` | Boolean | `true` | `true`: own lazy turbo frame, block runs in a separate request. `false`: inline, block runs in the page request; no frame, never refreshes. See the [guide](/guides/dashboards#lazy-and-inline-cards) |
|
|
52
|
-
| `refresh:` | Integer seconds, or `false` | dashboard `refresh` | Reload interval; requires `lazy: true
|
|
51
|
+
| `lazy:` | Boolean | `true` | `true`: own lazy turbo frame, block runs in a separate request. `false`: inline, block runs in the page request (adding its query time to every page load); no frame, never refreshes. When `config.consider_all_requests_local` is true (development and test), an inline card's exception fails the whole page. See the [guide](/guides/dashboards#lazy-and-inline-cards) |
|
|
52
|
+
| `refresh:` | Integer seconds, or `false` | dashboard `refresh` | Reload interval; requires `lazy: true` (a number on a `lazy: false` card raises `ArgumentError`). `false` opts the card out of the dashboard's `refresh` |
|
|
53
53
|
| `condition:` | Proc, Symbol | none | Hides the card and 404s its endpoint when false |
|
|
54
54
|
| `href:` | String, Proc | none | Links the title |
|
|
55
55
|
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
Dashboards are experimental: the DSL and behavior may change in a future release.
|
|
5
5
|
:::
|
|
6
6
|
|
|
7
|
-
Reference documentation for Plutonium dashboards: pages of metric, chart and free-form cards, each loaded in its own lazy turbo frame.
|
|
7
|
+
Reference documentation for Plutonium dashboards: pages of metric, chart and free-form cards, each loaded in its own lazy turbo frame by default.
|
|
8
8
|
|
|
9
9
|
## In this section
|
|
10
10
|
|
|
@@ -16,10 +16,10 @@ rails g pu:lite:tune
|
|
|
16
16
|
|
|
17
17
|
It writes a `pragmas:` mapping:
|
|
18
18
|
|
|
19
|
-
- `cache_size: -64000
|
|
20
|
-
- `temp_store: 2
|
|
21
|
-
- `mmap_size: 536870912
|
|
22
|
-
- `wal_autocheckpoint: 10000
|
|
19
|
+
- `cache_size: -64000`: 64 MB page cache (the ~2 MB default is too small).
|
|
20
|
+
- `temp_store: 2`: MEMORY; sorts and temp indexes stay off disk.
|
|
21
|
+
- `mmap_size: 536870912`: 512 MB memory-mapped I/O.
|
|
22
|
+
- `wal_autocheckpoint: 10000`: checkpoint roughly every 40 MB of WAL.
|
|
23
23
|
|
|
24
24
|
On Rails < 8.1 it also writes the baseline pragmas (`journal_mode: WAL`,
|
|
25
25
|
`synchronous: NORMAL`, `foreign_keys: true`, `journal_size_limit`) that Rails 8.1+
|
|
@@ -30,7 +30,7 @@ constant-poll busy handler (`busy_handler_timeout`), which has better tail-laten
|
|
|
30
30
|
than SQLite's internal exponential backoff. Setting a busy-timeout pragma would
|
|
31
31
|
replace the better handler with the worse one, so this generator never emits it.
|
|
32
32
|
|
|
33
|
-
The generator is idempotent
|
|
33
|
+
The generator is idempotent: re-running it detects the existing pragmas and skips.
|
|
34
34
|
It only ever touches the `default:` block, so a `pragmas:` mapping nested under
|
|
35
35
|
another environment is left untouched.
|
|
36
36
|
|
|
@@ -47,7 +47,7 @@ rails g pu:lite:maintenance --schedule="every day at 4am"
|
|
|
47
47
|
|
|
48
48
|
The job runs `PRAGMA optimize` on every configured SQLite database and `VACUUM`
|
|
49
49
|
only on databases without live 24/7 writers (`primary`, `errors`, `rails_pulse`
|
|
50
|
-
by default
|
|
50
|
+
by default; edit `VACUUM_DBS` in the generated job to suit your app).
|
|
51
51
|
|
|
52
52
|
**Why VACUUM only some databases?** SolidQueue, Solid Cache and Solid Cable write
|
|
53
53
|
to their databases constantly. `VACUUM` takes a global *exclusive* lock for its
|
|
@@ -61,5 +61,5 @@ Databases listed in the job that don't exist in `config/database.yml` are skippe
|
|
|
61
61
|
at runtime, so the same job is safe regardless of which `pu:lite:*` generators you
|
|
62
62
|
have run.
|
|
63
63
|
|
|
64
|
-
If `solid_queue` is not installed, the job file is still created but not scheduled
|
|
64
|
+
If `solid_queue` is not installed, the job file is still created but not scheduled:
|
|
65
65
|
add a `sqlite_maintenance` entry to whatever scheduler you use.
|
data/docs/reference/i18n.md
CHANGED
|
@@ -120,6 +120,29 @@ index_page_description t("blog.index.description")
|
|
|
120
120
|
|
|
121
121
|
When a definition sets no title, the page falls back to the resource's translated model name.
|
|
122
122
|
|
|
123
|
+
The setter's proc is called with no record context, so a title that depends on the record belongs in a `page_title` override on the nested page class (`class ShowPage < ShowPage`), where `object` is available.
|
|
124
|
+
|
|
125
|
+
## Your own components and pages
|
|
126
|
+
|
|
127
|
+
Text you add to the UI goes in a locale file too. Which `t` you call depends on where the code runs:
|
|
128
|
+
|
|
129
|
+
| Where | Call |
|
|
130
|
+
|---|---|
|
|
131
|
+
| Components, pages, and nested `Form` / `Display` / `Table` classes (render hooks included) | `t("full.key", **opts)`, a protected method from `Plutonium::UI::Component::Behaviour` |
|
|
132
|
+
| `display` / `input` / `column` blocks in a definition | `t("full.key")`, since the block runs inside the rendering component |
|
|
133
|
+
| Definition class body (`label:`, `hint:`, `placeholder:`, page titles) | the class-level lazy `t("full.key")` above |
|
|
134
|
+
| A bare Phlexi field component that does not include `Behaviour` | `Plutonium::Translation.t("full.key")` |
|
|
135
|
+
|
|
136
|
+
All of them take full keys only. Use one key per sentence with `%{name}` placeholders rather than concatenating fragments around a value, and `count:` for plurals:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
def render_before_content
|
|
140
|
+
div(class: "pu-alert pu-alert-info", role: "status") do
|
|
141
|
+
div(class: "pu-alert-message") { t("blog.posts.show.comment_count", count: object.comments.count) }
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
123
146
|
## Derived labels
|
|
124
147
|
|
|
125
148
|
Actions, scopes, filters, kanban columns and wizard steps default to a humanized version of their key. Each has a convention key that takes precedence over that fallback, and a `label:` option that takes precedence over both. For an action backed by an interaction, the interaction's explicit `presents label:` also counts as declared and wins over the convention; only the class-name default yields to it.
|
data/docs/reference/index.md
CHANGED
|
@@ -7,7 +7,7 @@ aside: false
|
|
|
7
7
|
<SectionLanding
|
|
8
8
|
eyebrow="Reference"
|
|
9
9
|
title="Every API, in one place."
|
|
10
|
-
lede="The full surface area of Plutonium
|
|
10
|
+
lede="The full surface area of Plutonium: controllers, policies, definitions, fields, interactions, generators."
|
|
11
11
|
mode="categorized"
|
|
12
12
|
:rail="[
|
|
13
13
|
{ group: 'App', items: [
|