plutonium 0.62.2 → 0.63.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/CHANGELOG.md +67 -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 +322 -140
- data/gemfiles/rails_8.0.gemfile.lock +125 -115
- data/gemfiles/rails_8.1.gemfile.lock +126 -116
- 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 +29 -11
- 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 +103 -16
- data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
- data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
|
@@ -11,7 +11,8 @@ Custom buttons that go beyond standard CRUD — publish, archive, import, send i
|
|
|
11
11
|
- **For interactive actions, visibility is inferred from the interaction's attributes.** Don't declare `record_action: true` / `bulk_action: true` etc. by hand unless you're opting OUT.
|
|
12
12
|
- **Bulk action authorization is per-record.** If any selected record fails the policy check, the entire request is rejected.
|
|
13
13
|
- **Always pass `as:`** on custom routes — without it, `resource_url_for` can't generate URLs (critical for nested resources).
|
|
14
|
-
- **Prefer interactive actions over hand-written controller routes.** Anything
|
|
14
|
+
- **Prefer interactive actions over hand-written controller routes.** Anything a user triggers from a page belongs behind an interaction.
|
|
15
|
+
- **An interaction is the button, not the operation.** Logic may start in `execute`; once a job or an API also needs it, it moves to the model — see [Behavior › Interactions](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
15
16
|
|
|
16
17
|
## Action visibility flags
|
|
17
18
|
|
|
@@ -21,6 +22,7 @@ Custom buttons that go beyond standard CRUD — publish, archive, import, send i
|
|
|
21
22
|
| `record_action: true` | Show page — for actions on a single record (Edit, Archive, Delete) |
|
|
22
23
|
| `collection_record_action: true` | Per-row in the index table — for quick actions (Edit, Show) |
|
|
23
24
|
| `bulk_action: true` | Bulk-actions toolbar (shown when records are selected) |
|
|
25
|
+
| `hidden: true` | **Nowhere.** Suppresses all four surfaces at once, while keeping the route and policy live — see [Hidden actions](#hidden-actions) |
|
|
24
26
|
|
|
25
27
|
### Inferred visibility (interactive actions)
|
|
26
28
|
|
|
@@ -64,6 +66,9 @@ action :name,
|
|
|
64
66
|
# Conditional visibility — display-only proc, NOT authorization (see below)
|
|
65
67
|
condition: -> { params[:beta] == "1" },
|
|
66
68
|
|
|
69
|
+
# Never render, anywhere — route + policy stay live (see below)
|
|
70
|
+
hidden: true,
|
|
71
|
+
|
|
67
72
|
# Grouping
|
|
68
73
|
category: :primary, # :primary, :secondary, :danger
|
|
69
74
|
position: 50, # display order (lower = first)
|
|
@@ -74,9 +79,34 @@ action :name,
|
|
|
74
79
|
return_to: "/custom/path",
|
|
75
80
|
route_options: {action: :foo},
|
|
76
81
|
modal: :slideover, # :slideover / :centered — overrides the definition's modal mode
|
|
77
|
-
size: :lg
|
|
82
|
+
size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides the definition's modal size
|
|
83
|
+
|
|
84
|
+
# HTML attributes (see below)
|
|
85
|
+
link: {target: "_blank", rel: "noopener"}, # merged onto the action's <a> renderings
|
|
86
|
+
button: {data: {analytics: "archive"}} # merged onto the button_to <form> (non-GET)
|
|
78
87
|
```
|
|
79
88
|
|
|
89
|
+
### HTML attributes — `link:` / `button:`
|
|
90
|
+
|
|
91
|
+
Two per-element attribute bags, deep-merged over the framework's own attributes at render time — **the author wins on every key**, recursively through nested `data`:
|
|
92
|
+
|
|
93
|
+
- **`link:`** applies to every `<a>` rendered for the action: the toolbar link (GET), dropdown items (**any** HTTP method — dropdown items are always anchors, submitting via `data-turbo-method`), bulk-action links, kanban column action links, and the grid/kanban card's hidden show link (for `:show`).
|
|
94
|
+
- **`button:`** applies to the `button_to` **`<form>`** element of the non-GET toolbar rendering (the form wrapper, not the inner `<button>`).
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
action :documentation,
|
|
98
|
+
route_options: {url: "https://docs.example.com"},
|
|
99
|
+
resource_action: true,
|
|
100
|
+
link: {target: "_blank", rel: "noopener noreferrer", data: {analytics: "docs"}}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Because the author wins, you can override anything — `turbo_frame`, `class`, `data-*` — at your own risk. Two things to know:
|
|
104
|
+
|
|
105
|
+
- `class:` **replaces** the framework's classes (no token append) — a bare `link: {class: "mt-2"}` removes the button styling entirely.
|
|
106
|
+
- Pass `data:` as a **hash**. The merge only recurses when both sides are hashes, so a scalar `data:` replaces the framework's data wholesale (dropping `turbo_confirm`/`turbo_frame`).
|
|
107
|
+
|
|
108
|
+
Both bags round-trip through [`with(...)`](#deriving-variants-action-with), so `defined_actions[:edit].with(link: {target: "_blank"})` works in `customize_actions`.
|
|
109
|
+
|
|
80
110
|
### Deriving variants — `Action#with(...)`
|
|
81
111
|
|
|
82
112
|
Action records are frozen value objects. Inside `customize_actions`, derive a copy with overrides:
|
|
@@ -87,7 +117,7 @@ def customize_actions
|
|
|
87
117
|
end
|
|
88
118
|
```
|
|
89
119
|
|
|
90
|
-
## Conditional visibility — `condition:`
|
|
120
|
+
## Conditional visibility — `condition:` {#conditional-visibility}
|
|
91
121
|
|
|
92
122
|
Like the `condition:` proc on [inputs/displays/columns](/reference/resource/definition), an action can be **defined but only rendered when a runtime proc is truthy**. It's purely a toggle on whether the **button is shown** — the action (and its route) stays fully live either way.
|
|
93
123
|
|
|
@@ -139,6 +169,48 @@ end
|
|
|
139
169
|
`condition: -> { object.draft? }` is fine for **showing/hiding** a per-record button. But if the rule is about **who may run it** ("only while draft *and* nobody else has it locked"), put it in the policy — `def publish? = record.draft?` is also evaluated per record (per row), and unlike `condition:` it actually gates execution.
|
|
140
170
|
:::
|
|
141
171
|
|
|
172
|
+
## Hidden actions — `hidden: true` {#hidden-actions}
|
|
173
|
+
|
|
174
|
+
An action declared `hidden: true` renders in **no** toolbar, row dropdown, card, or bulk bar — regardless of its visibility flags, the policy, or `condition:`. Everything else about it stays live:
|
|
175
|
+
|
|
176
|
+
- the **route** is mounted;
|
|
177
|
+
- the **policy predicate** (`def name?`) is defined and enforced;
|
|
178
|
+
- for `interaction:`-based actions, the **form and permitted-params machinery** work exactly as they do for a visible action.
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
action :reposition, hidden: true
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The use case is an endpoint reached by **something other than a button** — a drag gesture, a custom Stimulus controller, a client-side widget you wrote yourself. The framework uses it for exactly that: [`position_on`](/reference/positioning) expands to `action :reposition, hidden: true`, and the kanban board's drop endpoint is declared the same way.
|
|
185
|
+
|
|
186
|
+
### `hidden:` vs `condition: -> { false }`
|
|
187
|
+
|
|
188
|
+
Both suppress the button, so pick by intent:
|
|
189
|
+
|
|
190
|
+
| | `hidden: true` | `condition: -> { false }` |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| Decided | at **class-load**, once | at **render time**, per row/request |
|
|
193
|
+
| Costs | nothing — the surfaces filter it out before any policy or condition runs | a proc evaluation per rendering |
|
|
194
|
+
| Says | "this is never a button" | "this is a button, just not right now" |
|
|
195
|
+
|
|
196
|
+
Use `hidden:` for an action that is *structurally* not a button. Use `condition:` when visibility genuinely depends on the record, the user, or the request.
|
|
197
|
+
|
|
198
|
+
::: danger `hidden:` is a display gate, NOT an authorization boundary
|
|
199
|
+
This is the same trap as [`condition:`](#conditional-visibility), and it bears repeating because "hidden" reads more absolute than it is. A hidden action has a **live route**: anyone who can construct the URL can call it.
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
# 🚫 WRONG — hiding the button does not stop the request.
|
|
203
|
+
action :purge_all, interaction: PurgeInteraction, hidden: true
|
|
204
|
+
|
|
205
|
+
# ✅ RIGHT — authorization belongs in the policy.
|
|
206
|
+
class WidgetPolicy < ResourcePolicy
|
|
207
|
+
def purge_all? = current_user.admin?
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**Rule of thumb, unchanged:** "who may run this" → **policy**. "does a button belong here" → `hidden:` / `condition:`.
|
|
212
|
+
:::
|
|
213
|
+
|
|
142
214
|
## Simple actions (navigation)
|
|
143
215
|
|
|
144
216
|
Link to an existing route. The target route MUST exist.
|
|
@@ -176,6 +248,8 @@ For anything with business logic, use an **interactive action** instead.
|
|
|
176
248
|
|
|
177
249
|
Run an [Interaction](/reference/behavior/interactions) — automatically renders a form if the interaction declares attributes beyond `:resource`/`:resources`, otherwise executes immediately with a confirmation.
|
|
178
250
|
|
|
251
|
+
The interactions below call named model methods (`archive!`, `invite!`) rather than doing the work inline. That's not mandatory for a one-off — the trigger to extract is the second caller, since an interaction can only be built with a `view_context`. See [Interactions › What an interaction is for](/reference/behavior/interactions#what-an-interaction-is-for).
|
|
252
|
+
|
|
179
253
|
```ruby
|
|
180
254
|
class PostDefinition < Plutonium::Resource::Definition
|
|
181
255
|
action :publish, interaction: PublishInteraction
|
|
@@ -233,7 +307,7 @@ class InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
233
307
|
validates :role, presence: true
|
|
234
308
|
|
|
235
309
|
def execute
|
|
236
|
-
|
|
310
|
+
resource.invite!(email: email, role: role, by: current_user)
|
|
237
311
|
succeed(resource).with_message("Invitation sent to #{email}.")
|
|
238
312
|
rescue ActiveRecord::RecordInvalid => e
|
|
239
313
|
failed(e.record.errors)
|
|
@@ -299,6 +373,25 @@ end
|
|
|
299
373
|
action :import, interaction: ImportInteraction
|
|
300
374
|
```
|
|
301
375
|
|
|
376
|
+
## Running the work in the background
|
|
377
|
+
|
|
378
|
+
An interactive action executes inside the request. When that is too slow — a bulk action over thousands of records, or a single call to something slow — replace `execute` with `async` and the interaction dispatches a persisted, resumable run instead:
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
class BulkArchiveInteraction < ResourceInteraction
|
|
382
|
+
attribute :resources
|
|
383
|
+
|
|
384
|
+
async do
|
|
385
|
+
on_failure :continue
|
|
386
|
+
def perform_on(record) = record.archive!
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Nothing else about the action changes: same `action` declaration, same policy method, same form. The user is returned where they were, and that index shows a banner linking to the run's progress page.
|
|
392
|
+
|
|
393
|
+
See [Async Interactions](/reference/behavior/async-interactions) for failure policies, file attributes, and what happens when a run crashes mid-batch.
|
|
394
|
+
|
|
302
395
|
## Immediate vs form
|
|
303
396
|
|
|
304
397
|
| Interaction shape | Behavior |
|
|
@@ -42,8 +42,8 @@ end
|
|
|
42
42
|
|
|
43
43
|
# packages/admin_portal/app/definitions/admin_portal/post_definition.rb (per-portal)
|
|
44
44
|
class AdminPortal::PostDefinition < ::PostDefinition
|
|
45
|
-
input :internal_notes, as: :text # admins see this; customers don't
|
|
46
45
|
scope :pending_review
|
|
46
|
+
input :internal_notes, hint: "Not shown to the author"
|
|
47
47
|
end
|
|
48
48
|
```
|
|
49
49
|
|
|
@@ -200,6 +200,39 @@ field :debug_info, condition: -> { Rails.env.development? }
|
|
|
200
200
|
`condition:` is for UI logic ("show this when published"). For "who can see this", use the policy's `permitted_attributes_for_*` — see [Behavior › Policy](/reference/behavior/policies).
|
|
201
201
|
:::
|
|
202
202
|
|
|
203
|
+
## Options that vary per render
|
|
204
|
+
|
|
205
|
+
Any option may be a **proc**, resolved on every render rather than frozen when the class loads. This holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input` and nested inputs. Arity says **whether you want the form**:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
|
|
209
|
+
input :notes, placeholder: -> { "Updated #{Time.current.year}" }
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- **`-> { … }`** is called as-is, keeping whatever it closed over — it means what it reads like where you wrote it. Nothing rebinds `self`. That is what lets an option declared inside an interaction's `customize_inputs` reach the interaction, private helpers included: `choices: -> { reviewer_choices }`.
|
|
213
|
+
- **`->(form) { … }`** is handed the form, so `object` (the record being edited), `params` and view helpers are reachable. Use it whenever the value depends on what is being rendered.
|
|
214
|
+
|
|
215
|
+
The rule holds on wizard steps too — but there a zero-argument proc closes over an internal field recorder, so options must take the form and read the run off it: `->(form) { form.wizard.anchor.tiers }`. See [Wizard DSL › Runtime input options](/reference/wizard/dsl#runtime-input-options).
|
|
216
|
+
|
|
217
|
+
### `condition:` is not an option
|
|
218
|
+
|
|
219
|
+
`condition:` follows a different rule, and it is worth knowing why rather than memorising it as an exception. The two are different kinds of thing:
|
|
220
|
+
|
|
221
|
+
| | asks | so it | receiver |
|
|
222
|
+
|---|---|---|---|
|
|
223
|
+
| an **option** (`choices:`, `label:`, `collapsed:`, …) | "what value should this have?" | may or may not care about the render — so it means what it reads like where you wrote it, and takes `form` when it does care | its own closure, or the form |
|
|
224
|
+
| **`condition:`** | "should this render *here, now*?" | is a question about the render context by definition — there is no useful reading of it that ignores that context | always the thing doing the rendering |
|
|
225
|
+
|
|
226
|
+
So `condition:` always runs **against** its context and reads it with no argument — and "its context" is whatever is rendering: the form for a field, section or nested input; the component for a `column` or `display`; the **wizard** for a step's `condition:` (evaluated in the runner to decide which steps exist, before any form is built); a condition context for an action or scope.
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
input :notes, condition: -> { object.published? } # form
|
|
230
|
+
display :audit_log, condition: -> { current_user.admin? } # display component
|
|
231
|
+
step :billing, condition: -> { data.plan.tier == "pro" } # wizard, no form exists yet
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
That is why it cannot take a `form` argument the way an option does: in several of those places there is no form.
|
|
235
|
+
|
|
203
236
|
## Dynamic forms (`pre_submit`)
|
|
204
237
|
|
|
205
238
|
A field with `pre_submit: true` triggers a server re-render on change, re-evaluating `condition:` procs. Use for cascading or context-dependent forms.
|
|
@@ -260,16 +293,16 @@ input :birth_date do |f|
|
|
|
260
293
|
end
|
|
261
294
|
```
|
|
262
295
|
|
|
263
|
-
### `
|
|
296
|
+
### `phlexi_render` for declarative custom display
|
|
264
297
|
|
|
265
|
-
`with:` takes either a Phlex component class OR a proc whose body is **rendered inside a Phlex context** — HTML tag methods (`span`, `div`, `a`) and Tailwind classes are first-class. The proc receives `(value, attrs)`.
|
|
298
|
+
`as: :phlexi_render` (or its shorthand `as: :phlexi`). `with:` takes either a Phlex component class OR a proc whose body is **rendered inside a Phlex context** — HTML tag methods (`span`, `div`, `a`) and Tailwind classes are first-class. The proc receives `(value, attrs)`.
|
|
266
299
|
|
|
267
300
|
```ruby
|
|
268
301
|
# Component — preferred for anything reusable
|
|
269
|
-
display :status, as: :
|
|
302
|
+
display :status, as: :phlexi_render, with: StatusBadgeComponent
|
|
270
303
|
|
|
271
304
|
# Inline proc — `span` here is a Phlex tag method, not a Rails helper
|
|
272
|
-
display :priority, as: :
|
|
305
|
+
display :priority, as: :phlexi_render, with: ->(value, attrs) {
|
|
273
306
|
case value
|
|
274
307
|
when 'high' then span(class: "badge badge-danger") { "High" }
|
|
275
308
|
when 'medium' then span(class: "badge badge-warning") { "Medium" }
|
|
@@ -282,11 +315,27 @@ See [UI › Components](/reference/ui/components) for writing reusable Phlex com
|
|
|
282
315
|
|
|
283
316
|
### Custom component class
|
|
284
317
|
|
|
318
|
+
`as:` takes a **field component** — Plutonium constructs it as
|
|
319
|
+
`YourComponent.new(field, **attributes)`, so it subclasses
|
|
320
|
+
`Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base`
|
|
321
|
+
(displays) and reads the value off `field`:
|
|
322
|
+
|
|
285
323
|
```ruby
|
|
286
324
|
input :color_picker, as: ColorPickerComponent
|
|
287
325
|
display :chart, as: ChartComponent
|
|
288
326
|
```
|
|
289
327
|
|
|
328
|
+
A component with its own constructor (e.g. `PostCardComponent.new(post:)`) is not
|
|
329
|
+
an `as:` candidate — it would raise `ArgumentError`. Build it in a block instead:
|
|
330
|
+
|
|
331
|
+
```ruby
|
|
332
|
+
display :card do |field|
|
|
333
|
+
PostCardComponent.new(post: field.object)
|
|
334
|
+
end
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
See [UI › Components › Field components](/reference/ui/components#field-components).
|
|
338
|
+
|
|
290
339
|
## Column options
|
|
291
340
|
|
|
292
341
|
```ruby
|
|
@@ -511,15 +560,26 @@ Groups a set of fields under an optional heading.
|
|
|
511
560
|
| `columns:` | Positive Integer. Overrides the section grid column count (e.g. `columns: 2`). Omit to use the form's default responsive grid. Must be a positive Integer — any other value raises. (Literal only — not dynamic.) |
|
|
512
561
|
| `condition:` | Lambda evaluated in the form instance context — same semantics as `input ..., condition:`. `object`, `current_user`, helpers etc. are all available. A falsey result hides the entire section and withholds its fields (they do not spill into `ungrouped`). |
|
|
513
562
|
|
|
514
|
-
Every option except `columns:` may be either a literal **or a proc** resolved at render time
|
|
563
|
+
Every option except `columns:` may be either a literal **or a proc** resolved at render time, following the same arity rule as every other option ([Options that vary per render](#options-that-vary-per-render)): take a `form` argument to read the render context. This makes the layout record-aware — e.g. collapse a section by default only for existing records:
|
|
515
564
|
|
|
516
565
|
```ruby
|
|
517
566
|
section :advanced, :seo_title, :notes,
|
|
518
567
|
collapsible: true,
|
|
519
|
-
collapsed: -> { object.persisted? }, # open for new, collapsed for edits
|
|
520
|
-
label: -> { object.new_record? ? "Set up" : "Advanced" }
|
|
568
|
+
collapsed: ->(form) { form.object.persisted? }, # open for new, collapsed for edits
|
|
569
|
+
label: ->(form) { form.object.new_record? ? "Set up" : "Advanced" }
|
|
521
570
|
```
|
|
522
571
|
|
|
572
|
+
::: warning Breaking change in 0.63
|
|
573
|
+
Section options previously took a **zero-argument** proc evaluated against the form (`collapsed: -> { object.persisted? }`). They now follow the same rule as every other option, where a zero-argument proc keeps its own binding — and a `form_layout` block is evaluated against the layout builder, so `object` there is a `NameError`.
|
|
574
|
+
|
|
575
|
+
```ruby
|
|
576
|
+
- collapsed: -> { object.persisted? }
|
|
577
|
+
+ collapsed: ->(form) { form.object.persisted? }
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
It fails loudly, never silently. `condition:` is unchanged — it is still evaluated against the form and still reads `object` with no argument.
|
|
581
|
+
:::
|
|
582
|
+
|
|
523
583
|
A section that resolves to **zero fields** — every declared field filtered out by the permitted set, or no field assigned — renders nothing at all (no heading, no grid). This keeps forms clean when fewer attributes are permitted than declared (notably `+ New`, where the create policy often permits a subset). The check is purely "are there fields to render"; it does **not** evaluate per-field `condition:` procs (those run later, at field render). So if you want a whole section to appear only under some state, gate it with the section's own `condition:` rather than relying on every field inside it being hidden:
|
|
524
584
|
|
|
525
585
|
```ruby
|
|
@@ -584,7 +644,7 @@ A `section` only renders the fields that are actually in the form's permitted se
|
|
|
584
644
|
|
|
585
645
|
`form_layout` is also available on `Plutonium::Interaction::Base`. The same DSL groups the interaction's `attribute` declarations into sections. Interaction forms (`Plutonium::UI::Form::Interaction`) pick up the layout automatically — no extra wiring needed.
|
|
586
646
|
|
|
587
|
-
Dynamic options and `condition:` work here too, with one difference:
|
|
647
|
+
Dynamic options and `condition:` work here too, with one difference: on an interaction form the form's `object` is the **interaction instance** (not a record). For a record action, the record is `object.resource` — so e.g. `collapsed: ->(form) { form.object.resource.archived? }`, and `condition: -> { object.resource.archived? }` (which is form-evaluated, so it needs no argument).
|
|
588
648
|
|
|
589
649
|
```ruby
|
|
590
650
|
class PublishPostInteraction < Plutonium::Interaction::Base
|
|
@@ -602,6 +662,116 @@ class PublishPostInteraction < Plutonium::Interaction::Base
|
|
|
602
662
|
end
|
|
603
663
|
```
|
|
604
664
|
|
|
665
|
+
## Display layout
|
|
666
|
+
|
|
667
|
+
The show page's counterpart to [`form_layout`](#form-layout). Same DSL and the same resolution rules — first-section-wins ownership, unlisted permitted fields collected into `ungrouped`, absent fields skipped, zero-field sections dropped entirely — applied to the show page instead of the form.
|
|
668
|
+
|
|
669
|
+
```ruby
|
|
670
|
+
class PostDefinition < ResourceDefinition
|
|
671
|
+
display_layout do
|
|
672
|
+
section :profile, :name, :author, label: "Profile", description: "Identity and owner"
|
|
673
|
+
section :presentation, :cover, :body,
|
|
674
|
+
collapsible: true,
|
|
675
|
+
condition: -> { object.published? }
|
|
676
|
+
ungrouped label: "Other details"
|
|
677
|
+
end
|
|
678
|
+
end
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
With no `display_layout` declared the show page renders unchanged as a single card holding one responsive grid — fully backwards-compatible.
|
|
682
|
+
|
|
683
|
+
### Independent of `form_layout`
|
|
684
|
+
|
|
685
|
+
The two are separate registries. A resource may declare either, both, or neither, and grouping its form one way has no effect on its show page. Both inherit to subclasses and are replaced as a unit when re-declared.
|
|
686
|
+
|
|
687
|
+
### No `columns:`
|
|
688
|
+
|
|
689
|
+
Unlike `form_layout`, `display_layout` **raises** on `columns:`:
|
|
690
|
+
|
|
691
|
+
```ruby
|
|
692
|
+
display_layout do
|
|
693
|
+
section :a, :x, columns: 2 # ArgumentError
|
|
694
|
+
end
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
Every display section renders into the same responsive grid. Field width is a per-field concern, set the same way inside a section as outside one:
|
|
698
|
+
|
|
699
|
+
```ruby
|
|
700
|
+
display :body, wrapper: {class: "col-span-2"}
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
Raising rather than ignoring the option means a `form_layout` block copied across fails immediately, instead of silently having no effect.
|
|
704
|
+
|
|
705
|
+
### Section options
|
|
706
|
+
|
|
707
|
+
`label:`, `description:`, `collapsible:`, `collapsed:`, `condition:` — the same set as [`section(key, *fields, **opts)`](#section-key-fields-opts) minus `columns:`. A collapsible display section behaves exactly as a form one does, `collapsed:` included.
|
|
708
|
+
|
|
709
|
+
Every option except `condition:` may be a proc, resolved at render under the same arity rule the form uses — a zero-arity proc keeps its own binding, a one-arity proc is handed the display:
|
|
710
|
+
|
|
711
|
+
```ruby
|
|
712
|
+
section :audit, :created_at, collapsible: true, collapsed: ->(display) { display.object.active? }
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
`condition:` is evaluated separately and against the display, where `object` is the record.
|
|
716
|
+
|
|
717
|
+
### Rendering
|
|
718
|
+
|
|
719
|
+
Each section renders as its own card, stacked by a `sections_wrapper` container — so a sectioned show page has **no single outer card**. Fields declared via [`metadata`](#metadata-panel-show-page) are excluded from the sections and render in the metadata panel instead. Section chrome is themeable; see [UI › Displays › Theming](/reference/ui/displays#theming).
|
|
720
|
+
|
|
721
|
+
## Page width
|
|
722
|
+
|
|
723
|
+
Detail-style pages — the show page and resource forms — are constrained to a readable column by default. Inputs and values stretch to their container, so at full content width they become ~1200px-wide text boxes: past a comfortable measure, and a long eye-travel between a label and the value beside it.
|
|
724
|
+
|
|
725
|
+
Index and table pages are deliberately **not** affected; a table wants every pixel.
|
|
726
|
+
|
|
727
|
+
```ruby
|
|
728
|
+
# config/initializers/plutonium.rb
|
|
729
|
+
Plutonium.configure { |config| config.default_page_width = :md }
|
|
730
|
+
|
|
731
|
+
class PostDefinition < ResourceDefinition
|
|
732
|
+
page_width :lg # this resource's form AND show page
|
|
733
|
+
end
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
### Sizes
|
|
737
|
+
|
|
738
|
+
`:sm` `:md` `:lg` `:xl` `:full`. `:full` opts out of any constraint. An unknown value raises `ArgumentError` at declaration rather than silently rendering at some other width.
|
|
739
|
+
|
|
740
|
+
::: warning Size tokens are relative to their surface
|
|
741
|
+
These are the same token *names* [modal sizes](#modals) use, but **not the same widths**. Each surface has its own scale, because the surfaces aren't comparable — a "small page" is reasonably larger than a "small dialog":
|
|
742
|
+
|
|
743
|
+
| Token | Page width | Centered modal | Slideover |
|
|
744
|
+
|---|---|---|---|
|
|
745
|
+
| `:sm` | 672px | 448px | 400px |
|
|
746
|
+
| `:md` | 896px | 576px | 480px |
|
|
747
|
+
| `:lg` | 1152px | 672px | 640px |
|
|
748
|
+
| `:xl` | 1280px | 896px | 800px |
|
|
749
|
+
| `:full` | unconstrained | 95vw | 95vw |
|
|
750
|
+
|
|
751
|
+
Modals additionally support `:auto` (hug the content); a page has nothing to hug, so it does not.
|
|
752
|
+
:::
|
|
753
|
+
|
|
754
|
+
### Per-surface overrides
|
|
755
|
+
|
|
756
|
+
`form_width` and `display_width` override `page_width` for one surface only:
|
|
757
|
+
|
|
758
|
+
```ruby
|
|
759
|
+
class PostDefinition < ResourceDefinition
|
|
760
|
+
page_width :lg
|
|
761
|
+
display_width :full # the show page runs full width; the form stays :lg
|
|
762
|
+
end
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
Resolution, most specific first: the surface-specific setting → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is a real choice and is honoured, not treated as "unset".
|
|
766
|
+
|
|
767
|
+
All three inherit to subclasses, so a portal-specific definition keeps its parent's width unless it says otherwise.
|
|
768
|
+
|
|
769
|
+
### Scope
|
|
770
|
+
|
|
771
|
+
- **Modals are unaffected** — a dialog sizes itself via `modal_size`.
|
|
772
|
+
- **Interactions** (`Plutonium::Interaction::Base`) support the same settings, for interactive actions rendered as standalone pages.
|
|
773
|
+
- **Wizards are configured separately**, via `Plutonium.configuration.wizards.width`. It defaults to `default_page_width`, so a wizard step carrying a `form_layout` renders those sections at the same width the identical sections get on a resource form.
|
|
774
|
+
|
|
605
775
|
## File uploads
|
|
606
776
|
|
|
607
777
|
```ruby
|
|
@@ -684,6 +854,8 @@ class PostDefinition < ResourceDefinition
|
|
|
684
854
|
# :centered — centered dialog
|
|
685
855
|
# false — full standalone pages (no modal)
|
|
686
856
|
# size: optional, one of :sm, :md (default), :lg, :xl, :auto, :full
|
|
857
|
+
# (widths are per-surface — see Page width; a slideover's :md is 480px,
|
|
858
|
+
# a centered dialog's is 576px, a page's is 896px)
|
|
687
859
|
modal :centered, size: :lg
|
|
688
860
|
end
|
|
689
861
|
```
|
|
@@ -45,6 +45,40 @@ For `has_one`:
|
|
|
45
45
|
- Only one record can exist per parent.
|
|
46
46
|
- Forms don't show the parent field (determined by URL).
|
|
47
47
|
|
|
48
|
+
## Declaring which associations get routes
|
|
49
|
+
|
|
50
|
+
By default every `has_many` and `has_one` whose child is a registered resource gets
|
|
51
|
+
a nested route. Name the ones you want and the rest are not drawn:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
register_resource ::Company, associations: %i[properties company_profile]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
`associations: []` draws none. Naming an association that is not a `has_many` or
|
|
58
|
+
`has_one`, or whose child is not registered in that portal, fails the boot rather
|
|
59
|
+
than quietly drawing one route fewer.
|
|
60
|
+
|
|
61
|
+
To make declaring them the rule rather than the exception, flip the default so that
|
|
62
|
+
a resource naming none gets none:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# config/initializers/plutonium.rb
|
|
66
|
+
Plutonium.configure do |config|
|
|
67
|
+
config.nested_association_routes = :declared # default: :detected
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The mode only decides what silence means. `associations:` works the same either way,
|
|
72
|
+
and top-level routes are untouched by both.
|
|
73
|
+
|
|
74
|
+
Two things to know before turning it on:
|
|
75
|
+
|
|
76
|
+
- It applies to every portal at once, and every resource that names nothing loses its
|
|
77
|
+
nested routes. On an existing app, expect to add `associations:` in several places.
|
|
78
|
+
- A policy's `permitted_associations` renders a panel on the show page that links to
|
|
79
|
+
the nested route. An association permitted there but omitted here leaves that panel
|
|
80
|
+
pointing at a route that does not exist. The two lists have to agree.
|
|
81
|
+
|
|
48
82
|
## Automatic behavior on nested routes
|
|
49
83
|
|
|
50
84
|
When the controller is hit via a nested route, Plutonium automatically:
|
|
@@ -53,7 +87,8 @@ When the controller is hit via a nested route, Plutonium automatically:
|
|
|
53
87
|
2. **Scopes queries** via the parent association:
|
|
54
88
|
- `has_many` → `parent.send(parent_association)` (e.g. `company.properties`)
|
|
55
89
|
- `has_one` → `relation.where(foreign_key => parent.id)` with limit
|
|
56
|
-
3. **Assigns the parent** on create (injected into `resource_params`)
|
|
90
|
+
3. **Assigns the parent** on create (injected into `resource_params`), building the
|
|
91
|
+
record on the parent's association so a scoped association supplies its defaults.
|
|
57
92
|
4. **Hides the parent field** in forms and displays (already determined by URL).
|
|
58
93
|
|
|
59
94
|
You don't add hidden parent fields or filter queries manually.
|
|
@@ -62,11 +97,13 @@ You don't add hidden parent fields or filter queries manually.
|
|
|
62
97
|
|
|
63
98
|
```ruby
|
|
64
99
|
current_parent # parent record (e.g. Company instance)
|
|
100
|
+
current_parent_class # parent class (e.g. Company)
|
|
65
101
|
current_nested_association # association name (e.g. :properties)
|
|
66
|
-
parent_route_param # URL param (e.g. :company_id)
|
|
67
102
|
parent_input_param # form param / association name (e.g. :company)
|
|
68
103
|
```
|
|
69
104
|
|
|
105
|
+
Each nested route carries the key of its own registration, so the parent class and association are **read from the route** rather than reconstructed from the URL. That is what lets a resource registered `singular: true` act as a parent at all — it contributes no id parameter, so there is nothing in the path to infer from.
|
|
106
|
+
|
|
70
107
|
## Parent vs entity scoping
|
|
71
108
|
|
|
72
109
|
When a parent is present, **parent scoping wins**: `default_relation_scope` scopes via the parent association, NOT `entity_scope`. The parent was already authorized and entity-scoped during its own authorization — double-scoping is redundant.
|
|
@@ -163,6 +200,27 @@ resource_params
|
|
|
163
200
|
|
|
164
201
|
No hidden parent fields needed in forms.
|
|
165
202
|
|
|
203
|
+
### Scoped associations
|
|
204
|
+
|
|
205
|
+
The record is built on the parent's association (`company.properties.new`), so an
|
|
206
|
+
association that carries a scope contributes its equality conditions as defaults:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
class Company < ResourceRecord
|
|
210
|
+
has_many :published_properties, -> { where(published: true) },
|
|
211
|
+
class_name: "Property"
|
|
212
|
+
end
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Creating through `/companies/123/nested_published_properties` sets `published: true`.
|
|
216
|
+
It has to: the index honours the same scope, so a record created without it is
|
|
217
|
+
filtered out of the list it was created from.
|
|
218
|
+
|
|
219
|
+
Only equality conditions become attributes. A scope like
|
|
220
|
+
`-> { where("expires_at > ?", Time.current) }` cannot supply one, so an association
|
|
221
|
+
scoped that way still creates records its own index will not list. Prefer an
|
|
222
|
+
equality scope for any association you expose as a nested route.
|
|
223
|
+
|
|
166
224
|
## Presentation hooks
|
|
167
225
|
|
|
168
226
|
Control whether the parent field appears in views/forms:
|
data/docs/reference/ui/assets.md
CHANGED
|
@@ -240,6 +240,10 @@ Plutonium uses a comprehensive CSS custom-property system for consistent, themea
|
|
|
240
240
|
}
|
|
241
241
|
```
|
|
242
242
|
|
|
243
|
+
::: warning Mirror every `:root` override in `.dark`
|
|
244
|
+
Your stylesheet loads after Plutonium's, and `:root` and `.dark` have equal specificity — so a token you override in `:root` beats Plutonium's `.dark` value even when dark mode is active. Any color token you customize in `:root` without re-asserting in `.dark` ships your light value into dark mode, where it's typically unreadable (e.g. a translucent dark `--pu-text-subtle` becomes invisible on a dark surface).
|
|
245
|
+
:::
|
|
246
|
+
|
|
243
247
|
### Using tokens in templates
|
|
244
248
|
|
|
245
249
|
```erb
|
|
@@ -101,20 +101,27 @@ class PostCardComponent < Plutonium::UI::Component::Base
|
|
|
101
101
|
end
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
::: tip
|
|
104
|
+
::: tip Inherit `Plutonium::UI::Component::Base`
|
|
105
105
|
It gives you:
|
|
106
106
|
- The component kit (`PageHeader`, `Panel`, `Block`, …)
|
|
107
107
|
- Resource helpers (`resource_url_for`, `current_user`, `current_record!`, `current_definition`)
|
|
108
108
|
- A `helpers` proxy for Rails helpers (`helpers.link_to`, `helpers.number_to_currency`)
|
|
109
109
|
- Token / class helpers (`tokens`, `classes`)
|
|
110
|
+
|
|
111
|
+
A **field** component (one you pass to `as:`) inherits its Phlexi base instead —
|
|
112
|
+
`include Plutonium::UI::Component::Behaviour` there to get the same helpers.
|
|
110
113
|
:::
|
|
111
114
|
|
|
112
115
|
### Use in a definition
|
|
113
116
|
|
|
117
|
+
A component like `PostCardComponent` above has its own constructor, so it reaches
|
|
118
|
+
a field through the **block form** — you build it yourself:
|
|
119
|
+
|
|
114
120
|
```ruby
|
|
115
121
|
class PostDefinition < ResourceDefinition
|
|
116
|
-
display :card
|
|
117
|
-
|
|
122
|
+
display :card do |field|
|
|
123
|
+
PostCardComponent.new(post: field.object)
|
|
124
|
+
end
|
|
118
125
|
|
|
119
126
|
display :metrics do |field|
|
|
120
127
|
MetricsChartComponent.new(data: field.value)
|
|
@@ -122,6 +129,52 @@ class PostDefinition < ResourceDefinition
|
|
|
122
129
|
end
|
|
123
130
|
```
|
|
124
131
|
|
|
132
|
+
`as: SomeComponent` is the other route, and it expects a **field component** —
|
|
133
|
+
Plutonium instantiates it with the field builder, not with your keyword
|
|
134
|
+
arguments. See [field components](#field-components) below.
|
|
135
|
+
|
|
136
|
+
::: warning `as:` does not take a keyword-argument component
|
|
137
|
+
`display :card, as: PostCardComponent` raises `ArgumentError: wrong number of
|
|
138
|
+
arguments` — the component is constructed as `PostCardComponent.new(field,
|
|
139
|
+
**attributes)`. Use the block form for those.
|
|
140
|
+
:::
|
|
141
|
+
|
|
142
|
+
### Field components
|
|
143
|
+
|
|
144
|
+
A field component subclasses the Phlexi component base for its surface and reads
|
|
145
|
+
everything off `field` (`field.value`, `field.object`, `field.dom`, plus
|
|
146
|
+
`attributes` — the themed id/name/class Plutonium already computed):
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
# app/components/color_picker_component.rb
|
|
150
|
+
class ColorPickerComponent < Phlexi::Form::Components::Base
|
|
151
|
+
include Phlexi::Form::Components::Concerns::HandlesInput # name/id/value plumbing
|
|
152
|
+
|
|
153
|
+
def view_template
|
|
154
|
+
input(**attributes, type: "color", value: field.value)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# app/components/chart_component.rb
|
|
159
|
+
class ChartComponent < Phlexi::Display::Components::Base
|
|
160
|
+
include Plutonium::UI::Component::Behaviour # optional: kit + resource helpers
|
|
161
|
+
|
|
162
|
+
def view_template
|
|
163
|
+
div(class: "h-40", data: {controller: "chart", chart_series_value: field.value.to_json})
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
class PostDefinition < ResourceDefinition
|
|
170
|
+
input :color, as: ColorPickerComponent # custom input component
|
|
171
|
+
display :chart, as: ChartComponent # custom display component
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
An `as:` component works in every surface that renders the field — form, show
|
|
176
|
+
page, index column, filter panel and wizard summary alike.
|
|
177
|
+
|
|
125
178
|
### Use in a page / form / display
|
|
126
179
|
|
|
127
180
|
```ruby
|
|
@@ -213,6 +266,6 @@ Inside any custom component, the same set of helpers as pages/forms/displays —
|
|
|
213
266
|
## Related
|
|
214
267
|
|
|
215
268
|
- [Pages](./pages) — `render_*` hooks call your components
|
|
216
|
-
- [Forms](./forms) —
|
|
269
|
+
- [Forms](./forms) — the built-in input tags and their `as:` aliases
|
|
217
270
|
- [Displays](./displays) — using custom display components
|
|
218
271
|
- [Assets](./assets) — design tokens (`var(--pu-*)`) and `.pu-*` component classes
|