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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +43 -43
  3. data/.claude/skills/plutonium-app/SKILL.md +101 -59
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
  5. data/.claude/skills/plutonium-auth/SKILL.md +119 -53
  6. data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
  7. data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
  8. data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
  9. data/.claude/skills/plutonium-resource/SKILL.md +144 -133
  10. data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
  11. data/.claude/skills/plutonium-testing/SKILL.md +130 -33
  12. data/.claude/skills/plutonium-ui/SKILL.md +151 -96
  13. data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
  14. data/CHANGELOG.md +21 -0
  15. data/README.md +9 -9
  16. data/SECURITY.md +1 -1
  17. data/app/assets/plutonium.css +1 -1
  18. data/docs/.vitepress/sync-skills.mjs +6 -3
  19. data/docs/blog/introducing-plutonium-dashboards.md +4 -5
  20. data/docs/blog/introducing-plutonium-i18n.md +4 -5
  21. data/docs/getting-started/installation.md +5 -5
  22. data/docs/getting-started/tutorial/02-first-resource.md +3 -3
  23. data/docs/getting-started/tutorial/03-authentication.md +7 -7
  24. data/docs/getting-started/tutorial/04-authorization.md +21 -4
  25. data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
  26. data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
  27. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  28. data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
  29. data/docs/getting-started/tutorial/index.md +1 -1
  30. data/docs/guides/adding-resources.md +10 -7
  31. data/docs/guides/authentication.md +25 -25
  32. data/docs/guides/authorization.md +24 -24
  33. data/docs/guides/creating-packages.md +17 -17
  34. data/docs/guides/custom-actions.md +32 -32
  35. data/docs/guides/customizing-ui.md +29 -26
  36. data/docs/guides/dashboards.md +1 -1
  37. data/docs/guides/index.md +3 -3
  38. data/docs/guides/kanban.md +55 -55
  39. data/docs/guides/multi-tenancy.md +35 -22
  40. data/docs/guides/nested-resources.md +21 -21
  41. data/docs/guides/performance.md +3 -3
  42. data/docs/guides/search-filtering.md +13 -13
  43. data/docs/guides/testing.md +16 -12
  44. data/docs/guides/theming.md +32 -17
  45. data/docs/guides/troubleshooting.md +2 -2
  46. data/docs/guides/user-invites.md +17 -17
  47. data/docs/guides/user-profile.md +51 -24
  48. data/docs/guides/wizards.md +55 -55
  49. data/docs/reference/app/generators.md +22 -22
  50. data/docs/reference/app/index.md +15 -18
  51. data/docs/reference/app/packages.md +8 -8
  52. data/docs/reference/app/portals.md +75 -29
  53. data/docs/reference/auth/accounts.md +15 -15
  54. data/docs/reference/auth/index.md +12 -12
  55. data/docs/reference/auth/profile.md +67 -29
  56. data/docs/reference/behavior/async-interactions.md +24 -24
  57. data/docs/reference/behavior/controllers.md +28 -28
  58. data/docs/reference/behavior/index.md +5 -5
  59. data/docs/reference/behavior/interactions.md +44 -44
  60. data/docs/reference/behavior/policies.md +48 -28
  61. data/docs/reference/configuration.md +6 -6
  62. data/docs/reference/dashboard/dsl.md +2 -2
  63. data/docs/reference/dashboard/index.md +1 -1
  64. data/docs/reference/generators/lite.md +7 -7
  65. data/docs/reference/i18n.md +23 -0
  66. data/docs/reference/index.md +1 -1
  67. data/docs/reference/kanban/authorization.md +9 -9
  68. data/docs/reference/kanban/dsl.md +32 -32
  69. data/docs/reference/kanban/index.md +1 -1
  70. data/docs/reference/kanban/positioning.md +17 -15
  71. data/docs/reference/resource/actions.md +51 -51
  72. data/docs/reference/resource/definition.md +73 -73
  73. data/docs/reference/resource/export.md +6 -6
  74. data/docs/reference/resource/index.md +16 -16
  75. data/docs/reference/resource/model.md +24 -24
  76. data/docs/reference/resource/positioning.md +78 -76
  77. data/docs/reference/resource/query.md +13 -13
  78. data/docs/reference/tenancy/entity-scoping.md +65 -35
  79. data/docs/reference/tenancy/index.md +11 -11
  80. data/docs/reference/tenancy/invites.md +20 -20
  81. data/docs/reference/tenancy/nested-resources.md +13 -13
  82. data/docs/reference/testing/index.md +116 -22
  83. data/docs/reference/ui/assets.md +57 -25
  84. data/docs/reference/ui/components.md +20 -20
  85. data/docs/reference/ui/displays.md +14 -14
  86. data/docs/reference/ui/forms.md +35 -35
  87. data/docs/reference/ui/index.md +17 -15
  88. data/docs/reference/ui/layouts.md +21 -21
  89. data/docs/reference/ui/pages.md +22 -22
  90. data/docs/reference/ui/tables.md +7 -7
  91. data/docs/reference/wizard/anchoring-resume.md +33 -32
  92. data/docs/reference/wizard/dsl.md +44 -44
  93. data/docs/reference/wizard/index.md +6 -6
  94. data/docs/reference/wizard/one-time.md +18 -18
  95. data/docs/reference/wizard/registration-launch.md +32 -32
  96. data/docs/reference/wizard/storage-config.md +23 -23
  97. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  98. data/lib/generators/pu/profile/conn_generator.rb +6 -0
  99. data/lib/plutonium/resource/record/associated_with.rb +23 -2
  100. data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
  101. data/lib/plutonium/version.rb +1 -1
  102. data/package.json +1 -1
  103. data/src/css/components.css +10 -10
  104. 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`** — 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.
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** — 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).
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 — `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 |
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!` — that's YAGNI, and Plutonium deliberately ships no service layer to put it in. The rule below is a **refactoring trigger**, not a prohibition.
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* — and not "as soon as it looks like business logic" — because of one line in the base class:
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`) — 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.
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 — chained interactions
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 — none of which has a `view_context`. Modelled as interactions, they are unreachable from anywhere but a Plutonium page.
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) — just don't reach for it to sequence business operations.
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 — installed once
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! — see above
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* — so the scheduled-publishing job can call it too.
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 — see [Resource › Actions › Inferred visibility](/reference/resource/actions#inferred-visibility-interactive-actions).
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 — declare `as:` only when overriding.
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` — outcomes
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 — see [Worked counter-example](#what-an-interaction-is-for). `and_then` earns its keep composing outcomes *within* one interaction, or in a test.
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 — they answer different questions:
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?" — present, parses, right type, plausible format | "Is this record legal?" — invariants that hold no matter who is calling |
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!` — i.e. inside your model method |
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 — never against a field — and they're phrased with the *model's* attribute names, which need not match your inputs.
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 — `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.
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 — callbacks, an audit row, a webhook — this is the wrong shape; call `resources.each(&:archive!)` and let the model own it.
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 — chiefly in **tests**, where you're exercising the interaction itself.
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 — you want the model method it wraps. See [What an interaction is for](#what-an-interaction-is-for).
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 — 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.
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** — 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. |
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 — instance argument
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 — class, no ids
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 — class + ids
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) — the HTTP verb routes to the right controller action. Passing both `interaction:` and `action:` raises `ArgumentError`.
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 — 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.
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 — is this a readable email, is this a role that exists?
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 — `ResourceInteraction` concern wraps these patterns.
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) — `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
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 — not in this specific class.
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`.** Record-dependent `_for_read` overrides need an explicit `_for_index` too (see [below](#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 — installed once
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 — per resource, generated
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 — per-record authorization
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` — users can only select records they're allowed to access.
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** — `record` is `nil`. `permitted_attributes_for_show` (and `_for_read`) ARE evaluated per record.
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` — otherwise inheritance kicks in, runs the `_for_read` body during the table render, and `record.archived?` blows up on `NoMethodError: undefined method 'archived?' for nil`.
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` (new has no persisted record).
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 — check the policy.
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 — 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
+ 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** — 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).
242
- - **Association fields on tables / show details** — controlled by `permitted_attributes_for_index` / `_for_show` listing the association name.
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 — `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!`.
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 — don't override at all. The inherited scope already calls default_relation_scope.
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 — bypasses default_relation_scope
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 — same problem
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 — raises at runtime
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 — the parent was already authorized and entity-scoped during its own authorization, so double-scoping isn't needed.
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 — never silently bypass:
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 — more permissive
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 — read-only
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) — call policies via `authorize_current!` and `authorized_resource_scope`
414
- - [Interactions](./interactions) — custom actions whose policy methods you define
415
- - [Resource › Actions](/reference/resource/actions) — registering actions that need policy methods
416
- - [Tenancy › Entity scoping](/reference/tenancy/entity-scoping) — `default_relation_scope`, three model shapes, custom scopes
417
- - [ActionPolicy docs](https://actionpolicy.evilmartians.io/) — the underlying library
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)` | — | Apply versioned framework defaults. Call first. |
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) — 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
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`. `false` opts the card out of the dashboard's `refresh` |
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` — 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.
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 &lt; 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 — re-running it detects the existing pragmas and skips.
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 — edit `VACUUM_DBS` in the generated job to suit your app).
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.
@@ -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.
@@ -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 — controllers, policies, definitions, fields, interactions, generators."
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: [