plutonium 0.62.2 → 0.64.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 +44 -0
- data/.claude/skills/plutonium-app/SKILL.md +3 -3
- data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
- data/.claude/skills/plutonium-auth/SKILL.md +36 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
- data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
- data/.claude/skills/plutonium-resource/SKILL.md +259 -12
- data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
- data/.claude/skills/plutonium-ui/SKILL.md +115 -14
- data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
- data/.standard.yml +1 -1
- data/CHANGELOG.md +73 -0
- data/CLAUDE.md +87 -0
- data/Rakefile +34 -0
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +685 -102
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/rodauth/_login_form.html.erb +13 -0
- data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
- data/docs/.vitepress/config.ts +81 -3
- data/docs/.vitepress/theme/blog.data.ts +44 -0
- data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
- data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
- data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
- data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
- data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
- data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
- data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
- data/docs/.vitepress/theme/index.ts +8 -4
- data/docs/blog/association-inputs-post-signed-ids.md +70 -0
- data/docs/blog/fix-the-model-not-the-policy.md +122 -0
- data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
- data/docs/blog/half-finished-forms-are-pii.md +76 -0
- data/docs/blog/index.md +13 -0
- data/docs/blog/interactions-are-presentation-objects.md +152 -0
- data/docs/blog/introducing-plutonium.md +253 -0
- data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
- data/docs/blog/plutonium-and-ai-agents.md +48 -0
- data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
- data/docs/blog/two-forms-one-dom-id.md +69 -0
- data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
- data/docs/getting-started/tutorial/04-authorization.md +12 -3
- data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/guides/authentication.md +73 -0
- data/docs/guides/authorization.md +2 -0
- data/docs/guides/creating-packages.md +5 -3
- data/docs/guides/custom-actions.md +74 -12
- data/docs/guides/customizing-ui.md +9 -2
- data/docs/guides/index.md +1 -0
- data/docs/guides/kanban.md +7 -5
- data/docs/guides/nested-resources.md +11 -1
- data/docs/guides/performance.md +104 -0
- data/docs/guides/user-invites.md +1 -1
- data/docs/guides/wizards.md +9 -1
- data/docs/index.md +3 -3
- data/docs/public/images/home/tour-actions.png +0 -0
- data/docs/public/images/home/tour-async.png +0 -0
- data/docs/public/images/home/tour-kanban.png +0 -0
- data/docs/public/images/home/tour-tenancy.png +0 -0
- data/docs/public/images/home/tour-wizard.png +0 -0
- data/docs/public/images/reference/async-progress-page.png +0 -0
- data/docs/public/images/reference/async-running-banner.png +0 -0
- data/docs/public/templates/experimental.rb +34 -0
- data/docs/public/templates/pluton8.rb +14 -0
- data/docs/reference/app/portals.md +15 -3
- data/docs/reference/auth/accounts.md +19 -0
- data/docs/reference/behavior/async-interactions.md +295 -0
- data/docs/reference/behavior/controllers.md +17 -4
- data/docs/reference/behavior/index.md +7 -1
- data/docs/reference/behavior/interactions.md +152 -22
- data/docs/reference/configuration.md +5 -0
- data/docs/reference/index.md +1 -0
- data/docs/reference/kanban/dsl.md +7 -4
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +26 -4
- data/docs/reference/positioning.md +568 -0
- data/docs/reference/resource/actions.md +97 -4
- data/docs/reference/resource/definition.md +181 -9
- data/docs/reference/tenancy/invites.md +1 -1
- data/docs/reference/tenancy/nested-resources.md +60 -2
- data/docs/reference/ui/assets.md +4 -0
- data/docs/reference/ui/components.md +57 -4
- data/docs/reference/ui/displays.md +20 -10
- data/docs/reference/ui/index.md +1 -1
- data/docs/reference/wizard/dsl.md +33 -0
- data/docs/reference/wizard/storage-config.md +1 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
- data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
- data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
- data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
- data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
- data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
- data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
- data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
- data/gemfiles/postgres.gemfile.lock +85 -85
- data/gemfiles/rails_7.gemfile.lock +323 -141
- data/gemfiles/rails_8.0.gemfile.lock +126 -116
- data/gemfiles/rails_8.1.gemfile.lock +127 -117
- data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
- data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
- data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
- data/lib/generators/pu/invites/install_generator.rb +3 -3
- data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
- data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
- data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
- data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
- data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
- data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
- data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
- data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
- data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
- data/lib/generators/pu/wizards/install_generator.rb +78 -0
- data/lib/plutonium/action/base.rb +71 -9
- data/lib/plutonium/action/interactive.rb +9 -0
- data/lib/plutonium/attachments.rb +254 -0
- data/lib/plutonium/configuration.rb +82 -1
- data/lib/plutonium/core/controller.rb +50 -7
- data/lib/plutonium/core/controllers/authorizable.rb +16 -0
- data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
- data/lib/plutonium/definition/base.rb +51 -0
- data/lib/plutonium/definition/display_layout.rb +112 -0
- data/lib/plutonium/definition/index_views.rb +8 -7
- data/lib/plutonium/definition/input_aliases.rb +38 -0
- data/lib/plutonium/definition/page_widths.rb +65 -0
- data/lib/plutonium/definition/positioning.rb +126 -0
- data/lib/plutonium/definition/sorting.rb +17 -2
- data/lib/plutonium/helpers/turbo_helper.rb +7 -0
- data/lib/plutonium/interaction/README.md +61 -24
- data/lib/plutonium/interaction/async/configuration.rb +38 -0
- data/lib/plutonium/interaction/async/context.rb +419 -0
- data/lib/plutonium/interaction/async/executor.rb +422 -0
- data/lib/plutonium/interaction/async/job.rb +80 -0
- data/lib/plutonium/interaction/async/reap_job.rb +81 -0
- data/lib/plutonium/interaction/async/run.rb +394 -0
- data/lib/plutonium/interaction/async/run_definition.rb +155 -0
- data/lib/plutonium/interaction/async/run_policy.rb +86 -0
- data/lib/plutonium/interaction/base.rb +34 -7
- data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
- data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
- data/lib/plutonium/interaction/response/redirect.rb +11 -3
- data/lib/plutonium/kanban/board.rb +14 -0
- data/lib/plutonium/kanban/column.rb +4 -2
- data/lib/plutonium/kanban/dsl.rb +4 -1
- data/lib/plutonium/kanban/grouping.rb +9 -22
- data/lib/plutonium/kanban/positioning.rb +5 -65
- data/lib/plutonium/positioning/config.rb +94 -0
- data/lib/plutonium/positioning/model.rb +128 -0
- data/lib/plutonium/positioning.rb +25 -86
- data/lib/plutonium/railtie.rb +1 -0
- data/lib/plutonium/resource/controller.rb +118 -38
- data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
- data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
- data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
- data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
- data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
- data/lib/plutonium/resource/controllers/presentable.rb +19 -13
- data/lib/plutonium/resource/controllers/queryable.rb +5 -1
- data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
- data/lib/plutonium/resource/policy.rb +33 -0
- data/lib/plutonium/resource/query_object.rb +36 -0
- data/lib/plutonium/routing/mapper_extensions.rb +100 -8
- data/lib/plutonium/routing/route_set_extensions.rb +15 -1
- data/lib/plutonium/routing/wizard_registration.rb +4 -0
- data/lib/plutonium/testing/resource_policy.rb +6 -2
- data/lib/plutonium/ui/action_button.rb +12 -7
- data/lib/plutonium/ui/actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/block.rb +21 -1
- data/lib/plutonium/ui/breadcrumbs.rb +187 -55
- data/lib/plutonium/ui/component/methods.rb +5 -0
- data/lib/plutonium/ui/component/positionable.rb +112 -0
- data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
- data/lib/plutonium/ui/component/section.rb +185 -0
- data/lib/plutonium/ui/display/base.rb +13 -1
- data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
- data/lib/plutonium/ui/display/components/section.rb +18 -0
- data/lib/plutonium/ui/display/resource.rb +141 -22
- data/lib/plutonium/ui/display/theme.rb +20 -1
- data/lib/plutonium/ui/export_button.rb +1 -1
- data/lib/plutonium/ui/form/base.rb +8 -7
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
- data/lib/plutonium/ui/form/components/section.rb +7 -62
- data/lib/plutonium/ui/form/components/uppy.rb +12 -1
- data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
- data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
- data/lib/plutonium/ui/form/query.rb +2 -4
- data/lib/plutonium/ui/form/resource.rb +92 -15
- data/lib/plutonium/ui/form/theme.rb +17 -0
- data/lib/plutonium/ui/form/wizard.rb +25 -1
- data/lib/plutonium/ui/grid/card.rb +79 -11
- data/lib/plutonium/ui/grid/resource.rb +47 -5
- data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
- data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
- data/lib/plutonium/ui/kanban/card.rb +2 -1
- data/lib/plutonium/ui/kanban/column.rb +12 -6
- data/lib/plutonium/ui/kanban/resource.rb +6 -7
- data/lib/plutonium/ui/layout/base.rb +10 -3
- data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
- data/lib/plutonium/ui/page/base.rb +19 -0
- data/lib/plutonium/ui/page/edit.rb +4 -1
- data/lib/plutonium/ui/page/index.rb +69 -18
- data/lib/plutonium/ui/page/interactive_action.rb +5 -1
- data/lib/plutonium/ui/page/new.rb +4 -1
- data/lib/plutonium/ui/page/show.rb +27 -10
- data/lib/plutonium/ui/page/wizard.rb +10 -1
- data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
- data/lib/plutonium/ui/page_width.rb +58 -0
- data/lib/plutonium/ui/table/base.rb +34 -1
- data/lib/plutonium/ui/table/components/attachment.rb +1 -1
- data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
- data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
- data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
- data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
- data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
- data/lib/plutonium/ui/table/resource.rb +50 -5
- data/lib/plutonium/ui/table/theme.rb +59 -2
- data/lib/plutonium/ui/wizard/review.rb +4 -2
- data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachments.rb +32 -197
- data/lib/plutonium/wizard/base.rb +6 -1
- data/lib/plutonium/wizard/configuration.rb +12 -0
- data/lib/plutonium/wizard/controller.rb +14 -0
- data/lib/plutonium/wizard/driving.rb +99 -15
- data/lib/plutonium/wizard/dsl.rb +23 -0
- data/lib/plutonium/wizard/resume.rb +127 -49
- data/lib/plutonium/wizard/runner.rb +46 -2
- data/lib/plutonium/wizard/step_adapter.rb +1 -1
- data/lib/plutonium/wizard/sweep_job.rb +16 -0
- data/lib/plutonium.rb +21 -0
- data/lib/rodauth/features/session_isolation.rb +92 -0
- data/lib/rodauth/plugins.rb +1 -0
- data/package.json +2 -1
- data/plutonium.gemspec +20 -20
- data/src/css/components.css +89 -1
- data/src/css/slim_select.css +20 -0
- data/src/js/controllers/breadcrumbs_controller.js +112 -0
- data/src/js/controllers/bulk_actions_controller.js +10 -2
- data/src/js/controllers/kanban_controller.js +30 -21
- data/src/js/controllers/positioned_controller.js +452 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/resource_drop_down_controller.js +5 -0
- data/src/js/controllers/run_progress_controller.js +73 -0
- data/src/js/drag/sortable.js +186 -0
- data/yarn.lock +108 -63
- metadata +101 -22
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -11,7 +11,7 @@ A button appears in the right place (show page / table row / index header / bulk
|
|
|
11
11
|
| Flavor | Use for |
|
|
12
12
|
|---|---|
|
|
13
13
|
| **Simple action** — navigate to a URL | Linking to external docs, jumping to a custom page that does its own thing |
|
|
14
|
-
| **Interactive action** — run an interaction class | Anything
|
|
14
|
+
| **Interactive action** — run an interaction class | Anything that *does* something (the common case) |
|
|
15
15
|
|
|
16
16
|
Prefer interactive actions. They handle authorization, form rendering, modal chrome, success/failure messaging, and automatic redirects — all for free.
|
|
17
17
|
|
|
@@ -20,7 +20,16 @@ Prefer interactive actions. They handle authorization, form rendering, modal chr
|
|
|
20
20
|
### 1. Write the interaction
|
|
21
21
|
|
|
22
22
|
```ruby
|
|
23
|
-
# app/
|
|
23
|
+
# app/models/post.rb — what publishing actually means
|
|
24
|
+
class Post < ApplicationRecord
|
|
25
|
+
def publish!(on: Time.current)
|
|
26
|
+
update!(published: true, published_at: on)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# app/interactions/publish_post_interaction.rb — the button in front of it
|
|
24
33
|
class PublishPostInteraction < ResourceInteraction
|
|
25
34
|
presents label: "Publish",
|
|
26
35
|
icon: Phlex::TablerIcons::Send,
|
|
@@ -29,7 +38,7 @@ class PublishPostInteraction < ResourceInteraction
|
|
|
29
38
|
attribute :resource
|
|
30
39
|
|
|
31
40
|
def execute
|
|
32
|
-
resource.
|
|
41
|
+
resource.publish!
|
|
33
42
|
succeed(resource).with_message("Post published!")
|
|
34
43
|
rescue ActiveRecord::RecordInvalid => e
|
|
35
44
|
failed(e.record.errors)
|
|
@@ -41,6 +50,10 @@ end
|
|
|
41
50
|
Plutonium doesn't rescue it automatically. Always rescue when using `create!` / `update!` / `save!`, return `failed(e.record.errors)`.
|
|
42
51
|
:::
|
|
43
52
|
|
|
53
|
+
::: tip Why `publish!` is on the model
|
|
54
|
+
An interaction can only be built with a `view_context` — it's a presentation object. A two-line `update!` inline in `execute` is fine while the button is the only caller; the moment a scheduled-publishing job wants the same behaviour it has to duplicate it or fake a view context. Full rule: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
55
|
+
:::
|
|
56
|
+
|
|
44
57
|
### 2. Register it in the definition
|
|
45
58
|
|
|
46
59
|
```ruby
|
|
@@ -106,7 +119,7 @@ class Company::InviteUserInteraction < ResourceInteraction
|
|
|
106
119
|
validates :role, presence: true
|
|
107
120
|
|
|
108
121
|
def execute
|
|
109
|
-
|
|
122
|
+
resource.invite!(email: email, role: role, by: current_user)
|
|
110
123
|
succeed(resource).with_message("Invitation sent to #{email}.")
|
|
111
124
|
rescue ActiveRecord::RecordInvalid => e
|
|
112
125
|
failed(e.record.errors)
|
|
@@ -114,6 +127,8 @@ class Company::InviteUserInteraction < ResourceInteraction
|
|
|
114
127
|
end
|
|
115
128
|
```
|
|
116
129
|
|
|
130
|
+
`Company#invite!` creates the row *and* sends the mail. Both are things a seat-provisioning job needs to do without a browser anywhere in sight — see the [full worked example](/reference/behavior/interactions#complete-example).
|
|
131
|
+
|
|
117
132
|
## Bulk actions
|
|
118
133
|
|
|
119
134
|
Plural `attribute :resources` automatically becomes a bulk action. The table gets checkboxes and a bulk-actions toolbar.
|
|
@@ -146,6 +161,36 @@ Two related behaviors:
|
|
|
146
161
|
|
|
147
162
|

|
|
148
163
|
|
|
164
|
+
## When the work is too slow for a request
|
|
165
|
+
|
|
166
|
+
The bulk action above updates every selected record inside the request. That is fine for a screenful; it is not fine for a few thousand, or for anything that calls a slow third party. Swap `execute` for `async`, and the same interaction dispatches the work to a background run instead:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
class BulkArchiveInteraction < ResourceInteraction
|
|
170
|
+
presents label: "Archive Selected", icon: Phlex::TablerIcons::Archive
|
|
171
|
+
|
|
172
|
+
attribute :resources
|
|
173
|
+
attribute :reason, :string
|
|
174
|
+
|
|
175
|
+
async do
|
|
176
|
+
on_failure :continue # :halt (default) | :continue | :transactional
|
|
177
|
+
def perform_on(record)
|
|
178
|
+
record.archive!(reason: options["reason"])
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Nothing else about the action changes — the definition, the policy method and the form are the same. Only the work moves.
|
|
185
|
+
|
|
186
|
+
Three things worth knowing:
|
|
187
|
+
|
|
188
|
+
- **The block is the run's class body, not `execute`.** The work runs later, in a job with no controller, so it cannot close over anything in the interaction. Its inputs arrive through `options`.
|
|
189
|
+
- **The user is sent back where they were**, and the index they land on shows a banner for the run with a link to its progress page.
|
|
190
|
+
- **Permissions are re-checked per record, at perform time** — not replayed from dispatch. A permission revoked while the run is working stops applying to the rest of it.
|
|
191
|
+
|
|
192
|
+
Full detail, including file attributes, failure policies and resuming a crashed run: [Async Interactions](/reference/behavior/async-interactions).
|
|
193
|
+
|
|
149
194
|
## Resource action (no specific record)
|
|
150
195
|
|
|
151
196
|
Neither `:resource` nor `:resources` → resource action on the index page:
|
|
@@ -187,7 +232,11 @@ action :name,
|
|
|
187
232
|
# Behavior
|
|
188
233
|
confirmation: "Are you sure?",
|
|
189
234
|
modal: :slideover, # :slideover / :centered — overrides definition's modal mode
|
|
190
|
-
size: :lg
|
|
235
|
+
size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
|
|
236
|
+
|
|
237
|
+
# HTML attributes — author wins over the framework's on every key
|
|
238
|
+
link: {target: "_blank", rel: "noopener"}, # every <a> rendering (toolbar GET link, dropdown items, bulk links, card show link)
|
|
239
|
+
button: {data: {analytics: "archive"}} # the button_to <form> wrapper (non-GET toolbar rendering)
|
|
191
240
|
```
|
|
192
241
|
|
|
193
242
|
Full options: [Reference › Resource › Actions › Action options](/reference/resource/actions#action-options).
|
|
@@ -201,7 +250,8 @@ action :documentation,
|
|
|
201
250
|
label: "Docs",
|
|
202
251
|
route_options: {url: "https://docs.example.com"},
|
|
203
252
|
icon: Phlex::TablerIcons::Book,
|
|
204
|
-
resource_action: true
|
|
253
|
+
resource_action: true,
|
|
254
|
+
link: {target: "_blank", rel: "noopener noreferrer"} # open in a new tab
|
|
205
255
|
|
|
206
256
|
action :reports,
|
|
207
257
|
route_options: {action: :reports}, # links to PostsController#reports
|
|
@@ -231,18 +281,28 @@ end
|
|
|
231
281
|
|
|
232
282
|
Every resource gets `:archive` automatically.
|
|
233
283
|
|
|
234
|
-
##
|
|
284
|
+
## Where the logic goes
|
|
285
|
+
|
|
286
|
+
Interactions are presentation objects — they need a `view_context` to exist at all. So the reflex to reach for when an operation grows:
|
|
235
287
|
|
|
236
288
|
```ruby
|
|
289
|
+
# 🚫 Three interactions, three view contexts, none of it callable from a job
|
|
290
|
+
CreateUserInteraction.call(view_context:, **user_params)
|
|
291
|
+
.and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
|
|
292
|
+
.and_then { |user| LogActivity.call(view_context:, user:) }
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```ruby
|
|
296
|
+
# ✅ One model method; the interaction just presents it
|
|
237
297
|
def execute
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
.and_then { |r| LogActivity.call(view_context:, user: r.value) }
|
|
241
|
-
.with_message("User created and welcomed!")
|
|
298
|
+
user = User.register!(**attributes) # welcome email + audit row live in here
|
|
299
|
+
succeed(user).with_message("Welcome aboard!")
|
|
242
300
|
end
|
|
243
301
|
```
|
|
244
302
|
|
|
245
|
-
|
|
303
|
+
Sending a welcome email and writing an audit row are exactly what a signup API endpoint or a rake task also does — and neither has a view context to hand.
|
|
304
|
+
|
|
305
|
+
The rule isn't "never put logic in an interaction". A single-caller operation can stay inline in `execute`; don't pre-extract. **The second caller is the trigger** — and the destination is the model, Rails-style, not a new service layer. Chaining three interactions is usually the tell that you already crossed it. Full explanation: [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
246
306
|
|
|
247
307
|
## Common issues
|
|
248
308
|
|
|
@@ -250,10 +310,12 @@ The chain short-circuits on the first failure.
|
|
|
250
310
|
- **`ActiveRecord::RecordInvalid` crashes the action** — not rescued automatically. Wrap with `rescue`, return `failed(e.record.errors)`.
|
|
251
311
|
- **Bulk action fails on some records** — that's by design. Bulk policy is checked per-record; if any fails, the whole request is rejected. Either fix authorization or pre-filter the selection.
|
|
252
312
|
- **Confirmation prompt shows when you don't want one** — pass `confirmation: false` on the action.
|
|
313
|
+
- **The action times out on a large selection** — the work is running inside the request. Move it to a background run with `async`, above.
|
|
253
314
|
|
|
254
315
|
## Related
|
|
255
316
|
|
|
256
317
|
- [Reference › Resource › Actions](/reference/resource/actions) — full action options and bulk patterns
|
|
257
318
|
- [Reference › Behavior › Interactions](/reference/behavior/interactions) — interaction class anatomy
|
|
319
|
+
- [Reference › Behavior › Async Interactions](/reference/behavior/async-interactions) — `async`, progress pages, resuming a crashed run
|
|
258
320
|
- [Reference › Behavior › Policies](/reference/behavior/policies) — `def <action>?` methods
|
|
259
321
|
- [Authorization](./authorization) — policy patterns
|
|
@@ -132,12 +132,19 @@ class PostCardComponent < Plutonium::UI::Component::Base
|
|
|
132
132
|
end
|
|
133
133
|
```
|
|
134
134
|
|
|
135
|
-
Use it directly in a page, or wire it as a field in the definition
|
|
135
|
+
Use it directly in a page, or wire it as a field in the definition. A component
|
|
136
|
+
with its own constructor takes the block form — you build it, so you decide what
|
|
137
|
+
it receives:
|
|
136
138
|
|
|
137
139
|
```ruby
|
|
138
|
-
display :card
|
|
140
|
+
display :card do |field|
|
|
141
|
+
PostCardComponent.new(post: field.object)
|
|
142
|
+
end
|
|
139
143
|
```
|
|
140
144
|
|
|
145
|
+
`as: SomeComponent` is for a *field* component, which Plutonium constructs with
|
|
146
|
+
the field builder (`Phlexi::Form::Components::Base` / `Phlexi::Display::Components::Base`) — see the reference below.
|
|
147
|
+
|
|
141
148
|
→ See [Reference › UI › Components](/reference/ui/components).
|
|
142
149
|
|
|
143
150
|
## Phlexi themes (recolor without rewriting)
|
data/docs/guides/index.md
CHANGED
|
@@ -34,6 +34,7 @@ aside: false
|
|
|
34
34
|
]},
|
|
35
35
|
{ group: 'Quality', items: [
|
|
36
36
|
{ name: 'Testing', link: '/plutonium-core/guides/testing' },
|
|
37
|
+
{ name: 'Performance', desc: 'N+1 queries and eager loading.', link: '/plutonium-core/guides/performance' },
|
|
37
38
|
{ name: 'Troubleshooting', link: '/plutonium-core/guides/troubleshooting' },
|
|
38
39
|
]},
|
|
39
40
|
]"
|
data/docs/guides/kanban.md
CHANGED
|
@@ -44,7 +44,7 @@ end
|
|
|
44
44
|
|
|
45
45
|
```ruby
|
|
46
46
|
class Task < ApplicationRecord
|
|
47
|
-
include Plutonium::Positioning
|
|
47
|
+
include Plutonium::Positioning::Model
|
|
48
48
|
|
|
49
49
|
positioned_on :position, scope: :status
|
|
50
50
|
# ^^ auto-assigns position on create; reposition! scopes to the same status
|
|
@@ -390,15 +390,17 @@ There is no card "snap-back" to worry about on cancel — native drag never move
|
|
|
390
390
|
|
|
391
391
|
By default Plutonium uses decimal fractional positioning: cards always slot exactly where you drop them without ever renumbering the whole column. You need:
|
|
392
392
|
|
|
393
|
-
1. A `decimal` database column (precision
|
|
394
|
-
2. `include Plutonium::Positioning` in the model.
|
|
393
|
+
1. A `decimal` database column — use the `t.position` helper (`precision: 16, scale: 8`). Hand-rolling it, keep `scale` at **8 or more**: `scale: 6` exactly matches the `1e-6` rebalance threshold and the last subdivision can round into a neighbour.
|
|
394
|
+
2. `include Plutonium::Positioning::Model` in the model.
|
|
395
395
|
3. `positioned_on :position, scope: :status` — the `scope:` option groups positions by the grouping attribute so cards in different columns don't compete.
|
|
396
396
|
|
|
397
397
|
### Position modes
|
|
398
398
|
|
|
399
|
+
`position_on` is the same verb inside and outside `kanban do…end`. Declared on the **definition** it makes the resource's index table and card grid [drag-reorderable](/reference/positioning); declared inside the board it configures the board. A board that declares none **inherits the definition's** (falling back to `:position`, Mode A), so a resource that already reorders in its table needs nothing extra here. Declaration order in the class body doesn't matter — the board resolves this lazily.
|
|
400
|
+
|
|
399
401
|
```ruby
|
|
400
402
|
kanban do
|
|
401
|
-
# Mode A (default) — delegate to Plutonium::Positioning.
|
|
403
|
+
# Mode A (default) — delegate to Plutonium::Positioning::Model.
|
|
402
404
|
# Uses :position attribute, requires the model concern.
|
|
403
405
|
position_on :position
|
|
404
406
|
|
|
@@ -422,7 +424,7 @@ kanban do
|
|
|
422
424
|
end
|
|
423
425
|
```
|
|
424
426
|
|
|
425
|
-
See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted.
|
|
427
|
+
See [Positioning reference](/reference/kanban/positioning) for the full API and the rebalancing behavior when the decimal gap is exhausted, and [Positioning & drag-to-reorder](/reference/positioning) for the table/grid side of the same feature.
|
|
426
428
|
|
|
427
429
|
---
|
|
428
430
|
|
|
@@ -67,11 +67,21 @@ Plutonium prefixes nested routes with `nested_` so they don't conflict with top-
|
|
|
67
67
|
|
|
68
68
|
`has_one` associations get singular routes — index redirects to show (or new if no record exists).
|
|
69
69
|
|
|
70
|
+
Every routable association gets one by default. To draw only some of them:
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
register_resource ::Company, associations: %i[properties company_profile]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Set `config.nested_association_routes = :declared` to make that the rule, so a
|
|
77
|
+
resource naming none gets none. See [Reference › Tenancy › Nested resources](/reference/tenancy/nested-resources#declaring-which-associations-get-routes).
|
|
78
|
+
|
|
70
79
|
## What Plutonium does automatically
|
|
71
80
|
|
|
72
81
|
1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
|
|
73
82
|
2. **Scopes queries** via the parent association (`company.properties` for `has_many`; `where(company_id: ...)` for `has_one`).
|
|
74
|
-
3. **Assigns the parent** on create (injected into `resource_params`)
|
|
83
|
+
3. **Assigns the parent** on create (injected into `resource_params`), building the
|
|
84
|
+
record on the parent's association so a scoped association supplies its defaults.
|
|
75
85
|
4. **Hides the parent field** in forms and displays.
|
|
76
86
|
|
|
77
87
|
No hidden fields. No manual scoping.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Performance: N+1 Queries
|
|
2
|
+
|
|
3
|
+
Plutonium renders association values for you: a `display :author`, a table `column :organization`, a grid or kanban card showing a related name. Reading an association off a record that wasn't loaded with it costs a query, so an index page runs one query for the page plus one per row per association.
|
|
4
|
+
|
|
5
|
+
## Spotting it
|
|
6
|
+
|
|
7
|
+
Tail the log and reload an index. A run of near-identical `SELECT * FROM users WHERE id = ?` lines, one per row, is the signature.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
tail -f log/development.log
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
[`bullet`](https://github.com/flyerhzm/bullet) reports them in development if you'd rather be told than look.
|
|
14
|
+
|
|
15
|
+
The cost matters most where each query is a network round trip. On Postgres or MySQL an index page can spend most of its time waiting; on SQLite the same page is cheaper, though the query count is identical. Either way the count grows with page size, so a listing that is fine at 20 rows may not be at 200.
|
|
16
|
+
|
|
17
|
+
## Collections preload themselves
|
|
18
|
+
|
|
19
|
+
Index pages, kanban boards and CSV exports already eager-load the associations and attachments they render. The field set comes from the policy, so the framework knows it before the collection loads and can preload exactly those. Nothing to declare, and nothing to keep in step when a field is added or removed.
|
|
20
|
+
|
|
21
|
+
Each rendering passes its own field set, because they differ: the index renders its permitted attributes, an export renders `permitted_attributes_for_export`, and a kanban card renders its `card_fields`.
|
|
22
|
+
|
|
23
|
+
It covers every association kind — `belongs_to`, `has_one`, `has_many` — and attachments on both ActiveStorage and Shrine.
|
|
24
|
+
|
|
25
|
+
Turn it off globally:
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# config/initializers/plutonium.rb
|
|
29
|
+
Plutonium.configure do |config|
|
|
30
|
+
config.auto_eager_load_collections = false
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Or per resource:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
class PostsController < ::ResourceController
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def auto_eager_load_collections? = false
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Eager loading by hand
|
|
45
|
+
|
|
46
|
+
Anything the framework can't see still needs declaring: an association read inside a custom column block, or one rendered on a show page.
|
|
47
|
+
|
|
48
|
+
### One listing
|
|
49
|
+
|
|
50
|
+
Override `filtered_resource_collection`. `super` keeps authorization scoping, search, filters, scopes and sorting:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
class PostsController < ::ResourceController
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def filtered_resource_collection = super.includes(:author, :category)
|
|
57
|
+
end
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See [Behavior › Controllers](/reference/behavior/controllers#index-query-hook).
|
|
61
|
+
|
|
62
|
+
### Everywhere
|
|
63
|
+
|
|
64
|
+
When an association is read on a show page, an export or a typeahead, put it in the policy's `relation_scope`:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class PostPolicy < ResourcePolicy
|
|
68
|
+
relation_scope do |relation|
|
|
69
|
+
default_relation_scope(relation).includes(:author, :category)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A custom `relation_scope` must still call `default_relation_scope`, or scoping is dropped. See [Behavior › Policies](/reference/behavior/policies).
|
|
75
|
+
|
|
76
|
+
## Automatic eager loading
|
|
77
|
+
|
|
78
|
+
For the cases above that the framework can't resolve for you, [Goldiloader](https://github.com/salsify/goldiloader) removes the bookkeeping. It hooks association traversal: reading `post.author` on a record from a collection loads that association for the whole collection in one query. No `includes` anywhere.
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
# Gemfile
|
|
82
|
+
gem "goldiloader"
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
It works without per-model configuration. Before adding it:
|
|
86
|
+
|
|
87
|
+
- It assumes uniform access. Reading an association on one record loads it for every record, which over-fetches when only one row needed it.
|
|
88
|
+
- `has_one` with an order and a limit is a sharp edge: eager loading applies `LIMIT 1` to the whole query rather than per parent.
|
|
89
|
+
- It disables itself for associations declared with `limit`, `offset` or `finder_sql`.
|
|
90
|
+
- Opt out per query (`Post.all.auto_include(false)`), per association (`has_many :comments, -> { auto_include(false) }`) or globally (`Goldiloader.globally_enabled = false`).
|
|
91
|
+
|
|
92
|
+
[`ar_lazy_preload`](https://github.com/DmitryTsepelev/ar_lazy_preload) takes the same idea from the other end, preloading lazily on first access, with `ArLazyPreload.config.auto_preload = true` for automatic behaviour everywhere. Its docs warn that enabling it on an existing app can surface edge cases.
|
|
93
|
+
|
|
94
|
+
## Work that does not belong in a request at all
|
|
95
|
+
|
|
96
|
+
Eager loading fixes a page whose queries are wasteful. It does nothing for one that is simply doing too much work: archiving thousands of records, building a report, waiting on a third party.
|
|
97
|
+
|
|
98
|
+
Those need to leave the request rather than be optimised inside it. An interaction declares `async` and dispatches a background run, and the user gets a progress page instead of a spinner they cannot leave: [Async Interactions](/reference/behavior/async-interactions).
|
|
99
|
+
|
|
100
|
+
## Related
|
|
101
|
+
|
|
102
|
+
- **Search fallback.** A resource with no `search` block falls back to a leading-wildcard `LIKE`, which cannot use a b-tree index. Write an explicit `search` block for large tables — see [Resource › Query](/reference/resource/query#search).
|
|
103
|
+
- **Page size.** Query cost scales with rows per page.
|
|
104
|
+
- [Async Interactions](/reference/behavior/async-interactions) — moving slow work out of the request
|
data/docs/guides/user-invites.md
CHANGED
data/docs/guides/wizards.md
CHANGED
|
@@ -79,6 +79,10 @@ end
|
|
|
79
79
|
The engine detects failure by a **raised exception**. Non-bang `create`/`save`/`update` return `false` on failure without raising — the engine can't see that, treats the step as successful, and advances, silently losing the data. Always use `create!`/`update!`/`save!`, or call `fail!("message")`.
|
|
80
80
|
:::
|
|
81
81
|
|
|
82
|
+
::: tip `execute` is a presentation boundary, same as an interaction's
|
|
83
|
+
A wizard is built with `view_context:` too, so everything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow* — which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it: the API signup that skips onboarding, the admin backfill, the importer. `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. See [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for) — the rule is identical.
|
|
84
|
+
:::
|
|
85
|
+
|
|
82
86
|
Each step renders as a focused card with a numbered stepper rail (the terminal `review` shows a finish flag, not a number) and a Back / Next / Cancel strip:
|
|
83
87
|
|
|
84
88
|

|
|
@@ -281,6 +285,7 @@ end
|
|
|
281
285
|
- `fail!("msg")` aborts the step with a base (form-level) error; `fail!(:field, "msg")` attaches it to a field. Both roll back the step's transaction and re-render with input intact.
|
|
282
286
|
- The engine **always** destroys every `persist`'d record on rollback (Cancel, abandonment-sweep, branch-prune), in reverse order, via `destroy!` (which respects a model's own soft-delete override). `on_rollback` is an **optional, additive** compensating block for side effects the engine can't see (refund a charge, call an external API), and runs **before** the destroy, so `persisted[:key]` is still alive inside it. Don't destroy the tracked record yourself; the engine does.
|
|
283
287
|
- Because `on_submit` writes mid-flow, it isn't atomic across steps — that's why `cleanup_after` + the SweepJob exist. See [Storage & config](/reference/wizard/storage-config) and the [DSL reference](/reference/wizard/dsl#per-step-hooks).
|
|
288
|
+
- `on_submit` / `on_rollback` are **wizard-flow hooks**, not a home for domain logic. They belong to a single wizard step and can't be called from anywhere else, so keep them to *when* and *what gets tracked* — `persist Billing.create!(...)` above is one call to a model. Once "authorize a card and record the billing row" is something the API also does, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
|
|
284
289
|
|
|
285
290
|
## Anchored wizards
|
|
286
291
|
|
|
@@ -325,7 +330,10 @@ class WelcomeWizard < Plutonium::Wizard::Base
|
|
|
325
330
|
review label: "All set?"
|
|
326
331
|
|
|
327
332
|
def execute
|
|
328
|
-
|
|
333
|
+
# User#complete_onboarding! sets the name and stamps onboarded_at. It lives on
|
|
334
|
+
# the model because the gate below, an admin backfill, and the invite-accept
|
|
335
|
+
# flow all need to mark a user onboarded — and none of them is a wizard.
|
|
336
|
+
current_user.complete_onboarding!(full_name: data.profile.full_name)
|
|
329
337
|
succeed.with_message("Welcome aboard!")
|
|
330
338
|
end
|
|
331
339
|
|
data/docs/index.md
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Plutonium's experimental subsystems: wizards and async interactions.
|
|
2
|
+
#
|
|
3
|
+
# Both are off by default — their flags gate their MIGRATIONS as well as their
|
|
4
|
+
# behaviour, so an app that has not opted in has neither table. This turns them
|
|
5
|
+
# on and schedules the recurring jobs each one needs to stay healthy.
|
|
6
|
+
#
|
|
7
|
+
# Kept out of plutonium.rb because it is the baseline install, and these two are
|
|
8
|
+
# marked experimental: their DSL and behaviour may change in a future release.
|
|
9
|
+
# Opting a new app in is a choice, not a default.
|
|
10
|
+
after_bundle do
|
|
11
|
+
# SweepJob is the only thing that cleans up the partial domain records an
|
|
12
|
+
# abandoned `on_submit` wizard leaves behind. Unscheduled, those accumulate.
|
|
13
|
+
unless ENV["SKIP_WIZARDS"]
|
|
14
|
+
generate "pu:wizards:install"
|
|
15
|
+
git add: "."
|
|
16
|
+
git commit: %( -m 'chore: enable wizards') if `git status --porcelain`.present?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# --skip-portal: a fresh app has no portals yet, only main_app — the wrong home
|
|
20
|
+
# for the run resource in an app about to grow them. This turns the subsystem
|
|
21
|
+
# on; `rails g pu:async_interactions:install --dest=<portal>` connects the
|
|
22
|
+
# progress page and running banner once there is a portal worth naming.
|
|
23
|
+
unless ENV["SKIP_ASYNC_INTERACTIONS"]
|
|
24
|
+
generate "pu:async_interactions:install --skip-portal"
|
|
25
|
+
git add: "."
|
|
26
|
+
git commit: %( -m 'chore: enable async interactions') if `git status --porcelain`.present?
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Both gate their migration paths on the flags just written, and this process
|
|
30
|
+
# booted before either existed.
|
|
31
|
+
rails_command "db:migrate"
|
|
32
|
+
git add: "."
|
|
33
|
+
git commit: %( -m 'chore: migrate wizard + async run tables') if `git status --porcelain`.present?
|
|
34
|
+
end
|
|
@@ -14,4 +14,18 @@ after_bundle do
|
|
|
14
14
|
"https://radioactive-labs.github.io/plutonium-core/templates/lite.rb"
|
|
15
15
|
end
|
|
16
16
|
rails_command "app:template LOCATION=#{lite_location}"
|
|
17
|
+
|
|
18
|
+
# Wizards + async interactions, last.
|
|
19
|
+
#
|
|
20
|
+
# After lite rather than straight after plutonium, because both of these
|
|
21
|
+
# schedule a recurring job and solid_queue — which lite installs — is what
|
|
22
|
+
# there is to schedule into. Run earlier and each would print "schedule it
|
|
23
|
+
# yourself", leaving the app with a subsystem nothing maintains and no second
|
|
24
|
+
# pass to fix it.
|
|
25
|
+
experimental_location = if ENV["LOCAL"]
|
|
26
|
+
"/Users/stefan/Documents/plutonium/plutonium-core/docs/public/templates/experimental.rb"
|
|
27
|
+
else
|
|
28
|
+
"https://radioactive-labs.github.io/plutonium-core/templates/experimental.rb"
|
|
29
|
+
end
|
|
30
|
+
rails_command "app:template LOCATION=#{experimental_location}"
|
|
17
31
|
end
|
|
@@ -128,6 +128,16 @@ For each call, Plutonium auto-generates:
|
|
|
128
128
|
|
|
129
129
|
You list every resource the portal exposes. If a resource isn't registered, it has no URLs in that portal — `resource_url_for` will fail.
|
|
130
130
|
|
|
131
|
+
### Choosing nested associations
|
|
132
|
+
|
|
133
|
+
Name the associations that get nested routes, and the rest are not drawn:
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
register_resource ::Post, associations: %i[comments post_detail]
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`associations: []` draws none, and a name that is not a routable association fails the boot. Set `config.nested_association_routes = :declared` to make naming them the rule — see [Tenancy › Nested resources](../tenancy/nested-resources#declaring-which-associations-get-routes).
|
|
140
|
+
|
|
131
141
|
### Singular (singleton) resources
|
|
132
142
|
|
|
133
143
|
For resources with no collection — a single per-user `Profile`, app-wide `Settings`, etc.:
|
|
@@ -276,13 +286,15 @@ end
|
|
|
276
286
|
## Per-portal overrides
|
|
277
287
|
|
|
278
288
|
```ruby
|
|
279
|
-
# Definition —
|
|
289
|
+
# Definition — how fields render per portal
|
|
280
290
|
class AdminPortal::PostDefinition < ::PostDefinition
|
|
281
|
-
input :internal_notes, as: :text # admins see this; customers don't
|
|
282
291
|
scope :pending_review
|
|
292
|
+
input :internal_notes, hint: "Not shown to the author"
|
|
283
293
|
end
|
|
284
294
|
|
|
285
|
-
# Policy —
|
|
295
|
+
# Policy — which fields exist, and who may act
|
|
296
|
+
# `internal_notes` appears for admins because THIS permits it,
|
|
297
|
+
# not because the definition above mentions it.
|
|
286
298
|
class AdminPortal::PostPolicy < ::PostPolicy
|
|
287
299
|
include AdminPortal::ResourcePolicy
|
|
288
300
|
|
|
@@ -228,6 +228,25 @@ before_create_account_route do
|
|
|
228
228
|
end
|
|
229
229
|
```
|
|
230
230
|
|
|
231
|
+
### Session isolation (multiple account types)
|
|
232
|
+
|
|
233
|
+
Emitted by the generators; both parts are required for concurrent portal logins.
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
# app/rodauth/rodauth_plugin.rb — the shared base, once
|
|
237
|
+
enable :session_isolation
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# app/rodauth/<name>_rodauth_plugin.rb — once per account type
|
|
242
|
+
session_key_prefix "admin_" # namespaces EVERY key, account id included
|
|
243
|
+
remember_cookie_key "_admin_remember"
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Drop either and signing into one portal evicts the others.
|
|
247
|
+
|
|
248
|
+
Do **not** also set `session_key`: explicit values bypass `convert_session_key` and so are not prefixed, leaving the account id rotating separately from every other key. Rodauth then raises on any session holding an account id with no `authenticated_by`. See [Guides › Authentication › Multiple portals in one browser](/guides/authentication#multiple-portals-in-one-browser).
|
|
249
|
+
|
|
231
250
|
## Related
|
|
232
251
|
|
|
233
252
|
- [Profile](./profile) — profile resource + SecuritySection component
|