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
|
@@ -12,7 +12,7 @@ For field-level rendering on cards (card_fields slots), see [[plutonium-resource
|
|
|
12
12
|
## 🚨 Critical (read first)
|
|
13
13
|
|
|
14
14
|
- **`kanban do…end` in the Definition auto-enables `:kanban`** in `defined_index_views` — exactly like `grid_fields` enables `:grid`. You do not need to call `index_views :kanban` separately unless you want to remove the table view.
|
|
15
|
-
- **The model needs `include Plutonium::Positioning`** (and a decimal `position` column + `positioned_on` call) for drag ordering to work. Without it, cards render unordered and moves raise an error. Use `position_on false` to explicitly opt out.
|
|
15
|
+
- **The model needs `include Plutonium::Positioning::Model`** (and a decimal `position` column + `positioned_on` call) for drag ordering to work. Without it, cards render unordered and moves raise an error. Use `position_on false` to explicitly opt out.
|
|
16
16
|
- **Static column actions are auto-registered** as interactive resource actions at class-load time. Dynamic boards (`columns do…end`) cannot introspect their columns at load time — declare any column-action interactions separately with top-level `action` calls.
|
|
17
17
|
- **Moves bypass `permitted_attributes_for_update`** — the `on_enter` callback runs with full model access. Gate the move itself with `kanban_move?` in the policy.
|
|
18
18
|
- **Quick-add (`add: true`) only appears when `create?` is true** in the policy.
|
|
@@ -26,7 +26,7 @@ For field-level rendering on cards (card_fields slots), see [[plutonium-resource
|
|
|
26
26
|
|
|
27
27
|
```ruby
|
|
28
28
|
class Task < ApplicationRecord
|
|
29
|
-
include Plutonium::Positioning
|
|
29
|
+
include Plutonium::Positioning::Model
|
|
30
30
|
|
|
31
31
|
# position_on :position (default attr) scoped to the grouping column
|
|
32
32
|
positioned_on :position, scope: :status
|
|
@@ -103,9 +103,23 @@ Overrides the grid card layout for kanban cards. Uses the same slot keys as `gri
|
|
|
103
103
|
card_fields header: :title, meta: [:status, :priority], footer: :due_at
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
Every slot is optional and omitting it drops that line — **except `footer`, which
|
|
107
|
+
falls back to `:created_at`**. To render no footer at all, opt out explicitly:
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
card_fields header: :title, meta: [:status], footer: false
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Omitting `footer:` is the common cause of a card ending in a stray `—`: the
|
|
114
|
+
fallback lands on `:created_at`, and if that isn't in the policy's
|
|
115
|
+
`permitted_attributes_for_index` the value resolves to nil and renders as the
|
|
116
|
+
blank placeholder. Either permit `created_at`, point `footer:` at a permitted
|
|
117
|
+
field, or pass `footer: false`. (A *declared* slot that's merely blank still
|
|
118
|
+
shows `—` by design, so cards keep an even height.)
|
|
119
|
+
|
|
106
120
|
### `position_on` modes
|
|
107
121
|
|
|
108
|
-
- **Mode A (default)** — delegates to `record.reposition!(prev_record:, next_record:)` from `Plutonium::Positioning`. Requires the model concern and a decimal column.
|
|
122
|
+
- **Mode A (default)** — delegates to `record.reposition!(prev_record:, next_record:)` from `Plutonium::Positioning::Model`. Requires the model concern and a decimal column.
|
|
109
123
|
- **Mode B (block)** — you write the persistence. Plutonium still orders by the attribute; the block only persists the new value. Block receives a `Plutonium::Kanban::Positioning::Move` (fields: `record`, `column`, `prev`, `next`, `index`).
|
|
110
124
|
- **Mode C (`false`)** — no ordering, no repositioning. `on_enter` still fires.
|
|
111
125
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-resource
|
|
3
|
-
description: Use BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, index views, page customization. The single source for "what is a resource and how do I configure one".
|
|
3
|
+
description: Use BEFORE creating, scaffolding, or editing any Plutonium resource — model, definition, field types, scaffold options, has_cents, SGID, search/filters/scopes/sorting, custom actions, bulk actions, hidden actions, index views, drag-to-reorder (positioned_on / position_on), page customization. The single source for "what is a resource and how do I configure one".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Resources
|
|
@@ -484,8 +484,8 @@ end
|
|
|
484
484
|
|
|
485
485
|
# Portal override (per-portal customization)
|
|
486
486
|
class AdminPortal::PostDefinition < ::PostDefinition
|
|
487
|
-
input :internal_notes, as: :text
|
|
488
487
|
scope :pending_review
|
|
488
|
+
input :internal_notes, hint: "Not shown to the author"
|
|
489
489
|
end
|
|
490
490
|
```
|
|
491
491
|
|
|
@@ -602,6 +602,30 @@ field :debug_info, condition: -> { Rails.env.development? }
|
|
|
602
602
|
|
|
603
603
|
Use `condition` for UI state; use the policy for authorization.
|
|
604
604
|
|
|
605
|
+
## Options That Vary Per Render
|
|
606
|
+
|
|
607
|
+
Any option may be a **proc**, resolved on every render rather than frozen at class load. Holds across the whole form DSL — `field`, `input`, `section`/`ungrouped`, `structured_input`, nested inputs. Arity says **whether you want the form**:
|
|
608
|
+
|
|
609
|
+
```ruby
|
|
610
|
+
input :tier, as: :select, choices: ->(form) { form.object.account.available_tiers }
|
|
611
|
+
input :notes, placeholder: -> { "Updated #{Time.current.year}" }
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
- `-> { … }` is called as-is, keeping its own binding — it means what it reads like where you wrote it; nothing rebinds `self`. That is what makes `choices: -> { reviewer_choices }` work inside an interaction's `customize_inputs` (private helpers included).
|
|
615
|
+
- `->(form) { … }` gets the form — `object` (the record), `params`, view helpers.
|
|
616
|
+
|
|
617
|
+
Same rule on wizard steps — but a step block closes over an internal field recorder, so options there must take the form: `->(form) { form.wizard.anchor.tiers }`. See [[plutonium-wizard]].
|
|
618
|
+
|
|
619
|
+
**`condition:` is not an option — it follows a different rule, for a reason.** An option asks "what value should this have?", so it may not care about the render and defaults to meaning what it reads like. `condition:` asks "should this render *here, now*?" — a question about the render context by definition. So it always runs **against** that context and reads it with no argument, where "context" is whatever is rendering:
|
|
620
|
+
|
|
621
|
+
```ruby
|
|
622
|
+
input :notes, condition: -> { object.published? } # the form
|
|
623
|
+
display :audit_log, condition: -> { current_user.admin? } # the display component
|
|
624
|
+
step :billing, condition: -> { data.plan.tier == "pro" } # the wizard — no form exists yet
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
It cannot take a `form` argument the way an option does: for a `column`/`display`, a step, or an action there is no form.
|
|
628
|
+
|
|
605
629
|
## Dynamic Forms (`pre_submit`)
|
|
606
630
|
|
|
607
631
|
A `pre_submit: true` field triggers a server re-render on change, re-evaluating `condition:` procs. Use for cascading or context-dependent forms.
|
|
@@ -660,14 +684,14 @@ input :birth_date do |f|
|
|
|
660
684
|
end
|
|
661
685
|
```
|
|
662
686
|
|
|
663
|
-
**`
|
|
687
|
+
**`phlexi_render` for declarative custom display.** The `with:` option takes either a Phlex component class, or a proc whose body is **rendered inside a Phlex context** — so HTML tags (`span`, `div`, `a`, …) and Tailwind classes are first-class. The proc receives `(value, attrs)` where `value` is the field value and `attrs` are wrapper attributes.
|
|
664
688
|
|
|
665
689
|
```ruby
|
|
666
690
|
# Component class — preferred for anything reusable
|
|
667
|
-
display :status, as: :
|
|
691
|
+
display :status, as: :phlexi_render, with: StatusBadgeComponent
|
|
668
692
|
|
|
669
693
|
# Inline Phlex proc — `span` here is a Phlex tag method, not Ruby/Rails
|
|
670
|
-
display :priority, as: :
|
|
694
|
+
display :priority, as: :phlexi_render, with: ->(value, attrs) {
|
|
671
695
|
case value
|
|
672
696
|
when 'high' then span(class: "badge badge-danger") { "High" }
|
|
673
697
|
when 'medium' then span(class: "badge badge-warning") { "Medium" }
|
|
@@ -678,13 +702,21 @@ display :priority, as: :phlexi_tag, with: ->(value, attrs) {
|
|
|
678
702
|
|
|
679
703
|
See [[plutonium-ui]] for writing custom Phlex components.
|
|
680
704
|
|
|
681
|
-
**Custom component classes** (Phlex components — see [[plutonium-ui]])
|
|
705
|
+
**Custom component classes** (Phlex components — see [[plutonium-ui]]). `as:` takes a **field component**, constructed as `YourComponent.new(field, **attributes)` — subclass `Phlexi::Form::Components::Base` (inputs) or `Phlexi::Display::Components::Base` (displays) and read the value off `field`:
|
|
682
706
|
|
|
683
707
|
```ruby
|
|
684
708
|
input :color_picker, as: ColorPickerComponent
|
|
685
709
|
display :chart, as: ChartComponent
|
|
686
710
|
```
|
|
687
711
|
|
|
712
|
+
🚨 A component with its own constructor (`PostCardComponent.new(post:)`) is NOT an `as:` candidate — it raises `ArgumentError`. Build it in a block instead:
|
|
713
|
+
|
|
714
|
+
```ruby
|
|
715
|
+
display :card do |field|
|
|
716
|
+
PostCardComponent.new(post: field.object)
|
|
717
|
+
end
|
|
718
|
+
```
|
|
719
|
+
|
|
688
720
|
## Column Options
|
|
689
721
|
|
|
690
722
|
```ruby
|
|
@@ -883,7 +915,7 @@ class PostDefinition < ResourceDefinition
|
|
|
883
915
|
form_layout do
|
|
884
916
|
section :identity, :name, :email, label: "Identity", description: "Who this is"
|
|
885
917
|
section :address, :street, :city,
|
|
886
|
-
collapsible: true, collapsed: -> { object.persisted? }, columns: 2,
|
|
918
|
+
collapsible: true, collapsed: ->(form) { form.object.persisted? }, columns: 2,
|
|
887
919
|
condition: -> { object.requires_address? } # hide the whole section as a unit
|
|
888
920
|
ungrouped label: "Other" # bucket for unlisted fields; position = where it renders
|
|
889
921
|
end
|
|
@@ -891,13 +923,55 @@ end
|
|
|
891
923
|
```
|
|
892
924
|
|
|
893
925
|
- **Layout references field KEYS only** — all per-field config (`as:`, `hint:`, blocks, per-field `condition:`) stays on `input`. Never duplicated here.
|
|
894
|
-
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc
|
|
926
|
+
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `columns:` (positive Integer, literal only), `condition:`. Every option except `columns:` may be a **proc**, resolved at render under the same arity rule as any other option — take a `form` argument to read the render context.
|
|
927
|
+
- ⚠️ **Breaking in 0.63**: section options used to take a zero-arg proc run *against* the form. They now follow the shared rule, and a `form_layout` block is evaluated against the layout builder, so a bare `object` is a `NameError`. Migrate `collapsed: -> { object.persisted? }` → `collapsed: ->(form) { form.object.persisted? }`. `condition:` is unchanged (still form-evaluated, still reads `object` with no argument).
|
|
895
928
|
- **Absent fields are skipped.** A key the section lists that isn't in the permitted set (policy, per-action, scoping, nesting, or a typo) is silently dropped — never an error. The same layout serves a richly-permitted `edit` and a minimal `new`.
|
|
896
929
|
- **🚨 Zero-field sections drop entirely** — no heading, no grid. So `+ New` (fewer permitted attributes) won't sprout empty headings. This checks *field presence only*; per-field `condition:` runs later, so to hide a whole section by state, gate it with the **section's own `condition:`**, not by hiding every field inside it.
|
|
897
930
|
- **Works on interactions too** (`Plutonium::Interaction::Base`) — groups `attribute` declarations. There `object` is the interaction instance; for record actions the record is `object.resource`.
|
|
898
931
|
|
|
899
932
|
Full DSL reference: [Resource › Definition › Form layout](/reference/resource/definition#form-layout).
|
|
900
933
|
|
|
934
|
+
## Display Layout (`display_layout`)
|
|
935
|
+
|
|
936
|
+
The show page's counterpart to `form_layout`. Same DSL, same resolution (first-section-wins, unlisted permitted fields fall into `ungrouped`, absent fields skipped, zero-field sections dropped) — applied to the show page's fields instead of the form's.
|
|
937
|
+
|
|
938
|
+
```ruby
|
|
939
|
+
class PostDefinition < ResourceDefinition
|
|
940
|
+
display_layout do
|
|
941
|
+
section :profile, :name, :author, label: "Profile", description: "Identity and owner"
|
|
942
|
+
section :presentation, :cover, :body, collapsible: true
|
|
943
|
+
ungrouped label: "Other details"
|
|
944
|
+
end
|
|
945
|
+
end
|
|
946
|
+
```
|
|
947
|
+
|
|
948
|
+
- **Declare both independently.** `form_layout` and `display_layout` are separate registries — a resource can group its form one way and its show page another, or declare only one. Neither inherits from the other.
|
|
949
|
+
- **🚨 No `columns:`** — unlike `form_layout`, it **raises**. Every display section shares one responsive grid; field width is a per-field concern: `display :x, wrapper: {class: "col-span-2"}` (works identically inside a section and outside one). Raising rather than ignoring means a copied `form_layout` block fails loudly instead of silently doing nothing.
|
|
950
|
+
- **Options**: `label:`, `description:`, `collapsible:`, `collapsed:`, `condition:` — the same set as `form_layout` minus `columns:`. `collapsible: true, collapsed: true` works exactly as it does on forms. Every option except `condition:` may be a **proc**, resolved at render under the same arity rule as the form: take a `display` argument to read `object`.
|
|
951
|
+
- **Each section renders as its own card**, so the sectioned show page has no single outer card. Fields declared in `metadata` are excluded (they render in the metadata panel) — see below.
|
|
952
|
+
|
|
953
|
+
## Page Width (`page_width`)
|
|
954
|
+
|
|
955
|
+
Detail-style pages — the show page and resource forms — are width-constrained by default. Inputs and values stretch to their container, so at full content width you get ~1200px-long lines. Index/table pages are NOT affected.
|
|
956
|
+
|
|
957
|
+
```ruby
|
|
958
|
+
Plutonium.configure { |c| c.default_page_width = :md } # global default (:md)
|
|
959
|
+
|
|
960
|
+
class PostDefinition < ResourceDefinition
|
|
961
|
+
page_width :lg # form AND show page
|
|
962
|
+
display_width :full # ...but the show page opts out
|
|
963
|
+
form_width :sm # ...and the form goes narrow
|
|
964
|
+
end
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
- **Sizes**: `:sm` `:md` `:lg` `:xl` `:full`. `:full` means no constraint. An unknown value **raises** at declaration.
|
|
968
|
+
- **🚨 Tokens are relative to their surface** — the same *names* modals use, but NOT the same widths. Page `:md` is 896px; a centered modal's `:md` is 576px and a slideover's is 480px. A "small page" is deliberately larger than a "small dialog". Modals also have `:auto`; pages don't (nothing to hug).
|
|
969
|
+
- **Resolution**: surface-specific (`form_width` / `display_width`) → `page_width` → `Plutonium.configuration.default_page_width`. An explicit `:full` is honoured, not treated as unset.
|
|
970
|
+
- **Inherits** to subclasses, so a portal-specific definition keeps the parent's width unless it overrides.
|
|
971
|
+
- **Modals are unaffected** — the dialog sets its own width (`modal_size`).
|
|
972
|
+
- **Interactions support it too** (`Plutonium::Interaction::Base`), for interactive actions rendered as standalone pages.
|
|
973
|
+
- **Wizards are on their own axis** — `Plutonium.configuration.wizards.width` (default `:md`), overridden per wizard with `width`. It does NOT follow `default_page_width`, so changing resource page width leaves wizards untouched.
|
|
974
|
+
|
|
901
975
|
## Metadata Panel (show page)
|
|
902
976
|
|
|
903
977
|
Declares fields rendered in the show page's right-side aside as label/value rows.
|
|
@@ -908,8 +982,10 @@ metadata :author, :state, :created_at, :updated_at
|
|
|
908
982
|
|
|
909
983
|
- **Opt-in** — no call → show page is full-width with no aside.
|
|
910
984
|
- **Policy-aware** — fields the user can't see disappear; panel auto-hides if nothing's permitted.
|
|
911
|
-
- **Deduplicated** — listed fields are removed from the main details card.
|
|
985
|
+
- **Deduplicated** — listed fields are removed from the main details card (and from any `display_layout` section).
|
|
912
986
|
- **Responsive** — side-by-side at `lg+`, stacked below.
|
|
987
|
+
- **In a modal it stacks below the details**, not beside them: the rail is a fixed-width column on a *viewport* breakpoint, so in a dialog it would split regardless of how narrow the dialog is and crush the main column.
|
|
988
|
+
- **A kanban card's modal drops metadata entirely** — the fields are hidden, not folded into the main card.
|
|
913
989
|
|
|
914
990
|
Use for chrome (timestamps, ownership, system flags), keeping the main card focused on substance.
|
|
915
991
|
|
|
@@ -926,7 +1002,7 @@ class UserDefinition < ResourceDefinition
|
|
|
926
1002
|
subheader: :email,
|
|
927
1003
|
body: :bio,
|
|
928
1004
|
meta: [:role, :status], # rendered as small pills
|
|
929
|
-
footer: :last_seen_at # falls back to :created_at
|
|
1005
|
+
footer: :last_seen_at # falls back to :created_at; `false` to omit
|
|
930
1006
|
)
|
|
931
1007
|
|
|
932
1008
|
default_index_view :grid # optional — initial view when no cookie
|
|
@@ -949,6 +1025,123 @@ All grid slots are optional; slots pointing at unpermitted fields collapse silen
|
|
|
949
1025
|
|
|
950
1026
|
---
|
|
951
1027
|
|
|
1028
|
+
## Drag-to-Reorder (`positioned_on` + `position_on`)
|
|
1029
|
+
|
|
1030
|
+
Manual ordering on the index table, the card grid, and nested association tables. **Two verbs, never three** — the model says how positions are stored, the definition (and a kanban board) says the UI is orderable:
|
|
1031
|
+
|
|
1032
|
+
```ruby
|
|
1033
|
+
# Migration — t.position emits decimal(16,8), tuned for fractional ordering.
|
|
1034
|
+
create_table :tasks do |t|
|
|
1035
|
+
t.string :status, null: false, default: "todo"
|
|
1036
|
+
t.position
|
|
1037
|
+
t.index [:status, :position] # match the scope attribute
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
# Model — storage
|
|
1041
|
+
class Task < ApplicationRecord
|
|
1042
|
+
include Plutonium::Positioning::Model # NOT Plutonium::Positioning
|
|
1043
|
+
positioned_on :position, scope: :status # scope: nil = one global ordering
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
# Definition — "this UI can be reordered". Never restates the column or the scope.
|
|
1047
|
+
class TaskDefinition < ResourceDefinition
|
|
1048
|
+
position_on
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
# Existing rows need positions:
|
|
1052
|
+
Task.backfill_positions!(order: :created_at)
|
|
1053
|
+
```
|
|
1054
|
+
|
|
1055
|
+
- **`include Plutonium::Positioning::Model`** — the concern used to be `Plutonium::Positioning` itself. A bare `include Plutonium::Positioning` is now wrong (it's a pure namespace). Constants nested in an included concern join the model's constant lookup, so the old form let `Plutonium::Positioning::Config` shadow an app's own `::Config`.
|
|
1056
|
+
- **`positioned_on` is required**, not just the include — without it there's no `before_create`, so every row is created with a `NULL` position. `position_on` raises at class-load if you forget.
|
|
1057
|
+
|
|
1058
|
+
### `position_on` forms and modes
|
|
1059
|
+
|
|
1060
|
+
| Form | Mode | Notes |
|
|
1061
|
+
|---|---|---|
|
|
1062
|
+
| `position_on` | A (delegate) | Follows the model's `positioning_column`. **Prefer this** — it cannot disagree with the model. |
|
|
1063
|
+
| `position_on :sort_order` | A | Must **match** the model's column, else `ArgumentError` at class-load |
|
|
1064
|
+
| `position_on(:rank) { \|move\| … }` | B (block) | Escape hatch — another gem owns the write (`acts_as_list`). No model concern needed. **Prefer migrating to A.** |
|
|
1065
|
+
| `position_on false` | C (disabled) | No ordering, no route — the endpoint 404s |
|
|
1066
|
+
|
|
1067
|
+
**Default to Mode A.** It is one word in the definition. A *correct* Mode B block is ~15 lines of rank arithmetic, and getting it right requires knowing three non-obvious things: `move.index` is page-relative; removing a record shifts its neighbours' ranks by one, in a direction that depends on where it started; and a blank `move.prev` means "nothing above me *on screen*", not "top of the list". These docs got two of the three wrong until they were tested. Mode B is legitimate and tested — it just costs you semantics Mode A handles.
|
|
1068
|
+
|
|
1069
|
+
### Mode B — what the framework stops doing
|
|
1070
|
+
|
|
1071
|
+
Mode B block receives a `Plutonium::Positioning::Move`: `record`, `prev`, `next`, `index` (0-based, **relative to the visible page**), `column` (kanban only, `nil` on tables/grids). Called with `call`, not `instance_exec`.
|
|
1072
|
+
|
|
1073
|
+
Because the write is opaque, three Mode A behaviours are **not** provided:
|
|
1074
|
+
|
|
1075
|
+
- **No hidden boundary resolution** — `resolve_position_boundaries` returns early unless the config delegates, so the block gets the client's viewport verbatim, `nil`s and all.
|
|
1076
|
+
- **No server-side foreign-sort rejection** — Mode A rejects a drop under a foreign sort with 422 before writing; Mode B relies on the client-side gate only.
|
|
1077
|
+
- **Always a full repaint** — never 204. Gems like `acts_as_list` renumber the whole group on every move, so the client's optimistic DOM is stale by definition.
|
|
1078
|
+
|
|
1079
|
+
### Migrating off `acts_as_list` to Mode A
|
|
1080
|
+
|
|
1081
|
+
1. **Change the column** — `acts_as_list` uses contiguous integers; Plutonium uses fractional decimals (`t.position` emits `decimal(16,8)`; an integer column would round every midpoint onto a neighbour). `t.position` *adds* a column, so an existing one needs `change_column :tasks, :position, :decimal, precision: 16, scale: 8`.
|
|
1082
|
+
2. **Swap the macro** — drop `acts_as_list scope: [:status]`, add `include Plutonium::Positioning::Model` + `positioned_on :position, scope: :status` (bare Symbol; the Array trap is gem-specific).
|
|
1083
|
+
3. **Backfill** — `Task.backfill_positions!(order: :position)` numbers each scope group `1.0, 2.0, …` in the gem's existing order. `update_column`, no callbacks/validations/`updated_at` — run it once from a migration or `rails runner`.
|
|
1084
|
+
4. Drop the block from the definition; a bare `position_on` is the whole of Mode A.
|
|
1085
|
+
|
|
1086
|
+
### Staying on `acts_as_list` (the harder road)
|
|
1087
|
+
|
|
1088
|
+
For when the gem is not yours to remove. This recipe is correct and tested against the real gem (`test/plutonium/resource/controllers/position_actions_acts_as_list_test.rb`).
|
|
1089
|
+
|
|
1090
|
+
🚨 **Anchor off `move.prev` / `move.next`, never off `move.index`.** `move.index` counts the visible page; a positioning gem's `insert_at` addresses the whole group. `insert_at(move.index + 1)` is wrong on any list past 20 rows (Plutonium's default page size) — measured: dragging rank 25 into the middle of page 2 lands it at **rank 2**, and a page-2 top drop lands at **rank 1**. Same failures on a filtered list.
|
|
1091
|
+
|
|
1092
|
+
```ruby
|
|
1093
|
+
# Keeping acts_as_list. NOTE scope: [:status] — a bare Symbol scope is run
|
|
1094
|
+
# through acts_as_list's `idify`, which turns :status into :status_id and makes
|
|
1095
|
+
# every create raise NoMethodError.
|
|
1096
|
+
class Task < ApplicationRecord
|
|
1097
|
+
acts_as_list scope: [:status]
|
|
1098
|
+
end
|
|
1099
|
+
|
|
1100
|
+
class TaskDefinition < ResourceDefinition
|
|
1101
|
+
position_on :position do |move|
|
|
1102
|
+
record = move.record
|
|
1103
|
+
|
|
1104
|
+
target =
|
|
1105
|
+
if move.prev
|
|
1106
|
+
# Removing the record shifts prev up one when the record was above it.
|
|
1107
|
+
(record.position > move.prev.position) ? move.prev.position + 1 : move.prev.position
|
|
1108
|
+
elsif move.next
|
|
1109
|
+
# Blank prev means "nothing above me ON MY SCREEN" — rows may still sit
|
|
1110
|
+
# above off-page or behind a filter, so anchor off next rather than 1.
|
|
1111
|
+
(record.position < move.next.position) ? move.next.position - 1 : move.next.position
|
|
1112
|
+
else
|
|
1113
|
+
1 # the only row in the list
|
|
1114
|
+
end
|
|
1115
|
+
|
|
1116
|
+
record.insert_at(target)
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
1119
|
+
```
|
|
1120
|
+
|
|
1121
|
+
`insert_at` calls `save`, not `save!` — a failed move silently no-ops. Use `insert_at!` to surface it as a 422.
|
|
1122
|
+
|
|
1123
|
+
### 🚨 `position_on` expands to three things
|
|
1124
|
+
|
|
1125
|
+
```ruby
|
|
1126
|
+
sort <attr> # load-bearing: the only way back to position order
|
|
1127
|
+
default_sort <attr>, :asc # ⚠ ONLY when default_sort is still the framework default
|
|
1128
|
+
action :reposition, hidden: true # route + policy predicate, no button
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
**The `default_sort` claim is implicit.** A resource that listed newest-first will list in position order after you add `position_on`. Declare your own `default_sort` (above OR below `position_on` — resolution is order-independent) to keep it; note the list then opens **not** draggable.
|
|
1132
|
+
|
|
1133
|
+
### Behavior notes
|
|
1134
|
+
|
|
1135
|
+
- **Dragging is offered only while the collection is sorted ascending by the position attribute** (and nothing else). Under any other sort the grip renders as a **link that applies that sort**, and the server rejects the drop with 422 before writing.
|
|
1136
|
+
- **`reposition?` policy predicate**, defaulting to `update?`. Gates both the drop and whether the grip renders per row. `index?` is also required (you must be able to see a list to reorder it).
|
|
1137
|
+
- **A kanban board inherits the definition's `position_on`** (lazily, so order in the class body doesn't matter); a `position_on` inside `kanban do…end` overrides it.
|
|
1138
|
+
- **`scope:` is the model author's job.** A globally positioned model rendered under a parent still reorders correctly per parent — but a rebalance renumbers every row in the table, not just that parent's.
|
|
1139
|
+
- Native HTML5 drag doesn't fire on **touch** devices (same limitation as kanban). Keyboard works: focus the grip, <kbd>↑</kbd>/<kbd>↓</kbd>.
|
|
1140
|
+
|
|
1141
|
+
Full reference: `docs/reference/positioning.md`. Kanban specifics: `docs/reference/kanban/positioning.md`.
|
|
1142
|
+
|
|
1143
|
+
---
|
|
1144
|
+
|
|
952
1145
|
# Part 4 — Query: Search, Filters, Scopes, Sorting
|
|
953
1146
|
|
|
954
1147
|
```ruby
|
|
@@ -1090,6 +1283,7 @@ default_sort { |scope| scope.order(featured: :desc, created_at: :desc) }
|
|
|
1090
1283
|
| `record_action: true` | Show page | Edit, Delete, Archive |
|
|
1091
1284
|
| `collection_record_action: true` | Table rows | Quick per-row actions |
|
|
1092
1285
|
| `bulk_action: true` | Selected records | Bulk operations |
|
|
1286
|
+
| `hidden: true` | **Nowhere** | Suppresses all four; route + policy stay live (drag gestures, custom JS) |
|
|
1093
1287
|
|
|
1094
1288
|
🚨 **For interactive actions (`interaction:`), all four flags are inferred from the interaction's attributes — don't declare them manually:**
|
|
1095
1289
|
|
|
@@ -1132,6 +1326,10 @@ action :name,
|
|
|
1132
1326
|
# `-> { false }` keeps the route live but hides the button (e.g. API-only).
|
|
1133
1327
|
condition: -> { params[:beta] == "1" },
|
|
1134
1328
|
|
|
1329
|
+
# Never renders anywhere — route + policy stay live. For endpoints reached by
|
|
1330
|
+
# a gesture rather than a button (see Hidden Actions below). NOT authorization.
|
|
1331
|
+
hidden: true,
|
|
1332
|
+
|
|
1135
1333
|
# Grouping
|
|
1136
1334
|
category: :primary, # :primary, :secondary, :danger
|
|
1137
1335
|
position: 50,
|
|
@@ -1141,9 +1339,26 @@ action :name,
|
|
|
1141
1339
|
turbo_frame: "_top",
|
|
1142
1340
|
route_options: {action: :foo},
|
|
1143
1341
|
modal: :slideover, # :slideover / :centered — overrides definition's modal mode
|
|
1144
|
-
size: :lg
|
|
1342
|
+
size: :lg, # :sm / :md / :lg / :xl / :auto / :full — overrides definition's modal size
|
|
1343
|
+
|
|
1344
|
+
# HTML attributes — deep-merged over the framework's, author wins on every key
|
|
1345
|
+
link: {target: "_blank", rel: "noopener"}, # every <a> rendering: toolbar GET link, dropdown items (any method), bulk links, card show link
|
|
1346
|
+
button: {data: {analytics: "x"}} # the button_to <form> wrapper (non-GET toolbar rendering), NOT the inner <button>
|
|
1145
1347
|
```
|
|
1146
1348
|
|
|
1349
|
+
### HTML Attributes (`link:` / `button:`)
|
|
1350
|
+
|
|
1351
|
+
Per-element attribute bags for an action's rendered control. `link:` lands on every anchor the action renders as (dropdown items are anchors even for non-GET actions); `button:` lands on the `button_to` `<form>` element. The author wins on collisions — including `class:` (replaces, no token append) and `turbo_frame`. Pass `data:` as a hash: a scalar `data:` replaces the framework's data wholesale, dropping `turbo_confirm`/`turbo_frame`.
|
|
1352
|
+
|
|
1353
|
+
```ruby
|
|
1354
|
+
action :documentation,
|
|
1355
|
+
route_options: {url: "https://docs.example.com"},
|
|
1356
|
+
resource_action: true,
|
|
1357
|
+
link: {target: "_blank", rel: "noopener noreferrer"} # open in a new tab
|
|
1358
|
+
```
|
|
1359
|
+
|
|
1360
|
+
Both bags round-trip through `with(...)`: `defined_actions[:edit].with(link: {target: "_blank"})`.
|
|
1361
|
+
|
|
1147
1362
|
### Conditional Actions (`condition:`)
|
|
1148
1363
|
|
|
1149
1364
|
Like `condition:` on inputs/displays/columns — define an action but render its **button** only when a runtime proc is truthy. The action and its route stay live either way; `condition:` only toggles the UI.
|
|
@@ -1174,6 +1389,23 @@ def wipe? = current_user.admin?
|
|
|
1174
1389
|
|
|
1175
1390
|
The two compose: an action's button shows only when the policy permits **and** the condition is truthy; execution is gated by the policy alone. Use `object` in `condition:` for per-record *display*; use the policy for per-record *authorization*.
|
|
1176
1391
|
|
|
1392
|
+
### Hidden Actions (`hidden: true`)
|
|
1393
|
+
|
|
1394
|
+
```ruby
|
|
1395
|
+
action :reposition, hidden: true
|
|
1396
|
+
```
|
|
1397
|
+
|
|
1398
|
+
Renders in **no** toolbar, row dropdown, card, or bulk bar — regardless of visibility flags, policy, or `condition:`. Everything else stays live: the route, the policy predicate (`def reposition?`), and (for `interaction:` actions) the form + permitted-params machinery.
|
|
1399
|
+
|
|
1400
|
+
Use it for an endpoint reached by **something other than a button** — a drag gesture, a custom Stimulus controller. The framework uses it for exactly that: `position_on` expands to `action :reposition, hidden: true`, and the kanban drop endpoint is declared the same way.
|
|
1401
|
+
|
|
1402
|
+
| | `hidden: true` | `condition: -> { false }` |
|
|
1403
|
+
|---|---|---|
|
|
1404
|
+
| Decided | class-load, once | render time, per row/request |
|
|
1405
|
+
| Says | "never a button" | "a button, just not right now" |
|
|
1406
|
+
|
|
1407
|
+
🚨 **`hidden:` is a display gate, NOT an authorization boundary** — same trap as `condition:`. The route is live; authorization belongs in the policy.
|
|
1408
|
+
|
|
1177
1409
|
`Action#with(...)` — actions are frozen value objects; clone with overrides:
|
|
1178
1410
|
|
|
1179
1411
|
```ruby
|
|
@@ -1222,6 +1454,8 @@ class PostDefinition < ResourceDefinition
|
|
|
1222
1454
|
end
|
|
1223
1455
|
```
|
|
1224
1456
|
|
|
1457
|
+
⚠️ **An interaction is the button, not the operation.** It's a presentation object — it can only be built with a `view_context`, so anything reachable only through one is reachable only from a Plutonium page. Logic may *start* in `execute` (a one-off with a single caller is fine; don't pre-extract). The **second caller** — a job, an API controller, a rake task, the console — is the trigger to move it onto the **model**, in domain language (`publish!`, `archive!`, `register!`). Not a service layer. Full rule + the validation split: [[plutonium-behavior]] › Part 3 › Where the logic goes.
|
|
1458
|
+
|
|
1225
1459
|
### Single-record interaction
|
|
1226
1460
|
|
|
1227
1461
|
```ruby
|
|
@@ -1260,7 +1494,9 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
1260
1494
|
validates :role, presence: true, inclusion: {in: %w[admin member viewer]}
|
|
1261
1495
|
|
|
1262
1496
|
def execute
|
|
1263
|
-
|
|
1497
|
+
# Company#invite! creates the row AND sends the mail — a seat-provisioning
|
|
1498
|
+
# job needs both, and has no view_context to build an interaction with.
|
|
1499
|
+
resource.invite!(email: email, role: role, by: current_user)
|
|
1264
1500
|
succeed(resource).with_message("Invitation sent to #{email}.")
|
|
1265
1501
|
rescue ActiveRecord::RecordInvalid => e
|
|
1266
1502
|
failed(e.record.errors)
|
|
@@ -1296,6 +1532,17 @@ end
|
|
|
1296
1532
|
|
|
1297
1533
|
The UI only shows bulk action buttons that ALL selected records support. Records are fetched via `current_authorized_scope`.
|
|
1298
1534
|
|
|
1535
|
+
**Bulk over more than a screenful runs in the request and will time out.** Swap `execute` for `async` and the same interaction dispatches a background run instead, with its declaration and policy unchanged:
|
|
1536
|
+
|
|
1537
|
+
```ruby
|
|
1538
|
+
async do
|
|
1539
|
+
on_failure :continue # :halt (default) | :continue | :transactional
|
|
1540
|
+
def perform_on(record) = record.archived!
|
|
1541
|
+
end
|
|
1542
|
+
```
|
|
1543
|
+
|
|
1544
|
+
The block is the run's class body, not `execute`: it runs later in a job with no controller, so its inputs arrive through `options`. Load [[plutonium-async-interactions]] before building one.
|
|
1545
|
+
|
|
1299
1546
|
### Resource action (no record)
|
|
1300
1547
|
|
|
1301
1548
|
```ruby
|
|
@@ -323,26 +323,55 @@ Plutonium prefixes nested routes with `nested_` to avoid conflicts with the top-
|
|
|
323
323
|
|
|
324
324
|
For `has_one`: index redirects to show (or new if no record exists); only one record per parent.
|
|
325
325
|
|
|
326
|
+
### Choosing which associations get routes
|
|
327
|
+
|
|
328
|
+
Every routable association gets a nested route unless the registration narrows it:
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
register_resource ::Company, associations: %i[properties company_profile]
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`associations: []` draws none. A name that is not a `has_many`/`has_one`, or whose
|
|
335
|
+
child is not registered in that portal, raises at boot.
|
|
336
|
+
|
|
337
|
+
`config.nested_association_routes = :declared` (default `:detected`) makes a resource
|
|
338
|
+
that names none get none. The mode only changes what silence means; `associations:`
|
|
339
|
+
behaves the same either way, and top-level routes are unaffected.
|
|
340
|
+
|
|
341
|
+
**Before turning `:declared` on:** it is global, so every resource naming nothing
|
|
342
|
+
loses its nested routes, and a policy's `permitted_associations` panel links to the
|
|
343
|
+
nested route — permit an association there without declaring it here and the panel
|
|
344
|
+
points nowhere.
|
|
345
|
+
|
|
326
346
|
## Automatic behavior in nested routes
|
|
327
347
|
|
|
328
348
|
When the controller is hit through a nested route:
|
|
329
349
|
|
|
330
350
|
1. **Resolves the parent** via `current_parent`, authorized for `:read?`.
|
|
331
351
|
2. **Scopes queries** via parent association (e.g. `parent.properties` for `has_many`, `where(foreign_key => parent.id)` for `has_one`).
|
|
332
|
-
3. **Assigns parent** on create (injected into `resource_params`).
|
|
352
|
+
3. **Assigns parent** on create (injected into `resource_params`). The record is built
|
|
353
|
+
on the parent's association (`parent.properties.new`), so a scoped association
|
|
354
|
+
contributes its equality conditions as defaults.
|
|
333
355
|
4. **Hides parent field** in forms (already determined by URL).
|
|
334
356
|
|
|
335
357
|
You don't need to add hidden parent fields in forms or filter queries manually.
|
|
336
358
|
|
|
359
|
+
Expose a scoped association as a nested route only when its scope is equality-based
|
|
360
|
+
(`-> { where(published: true) }`). Rails derives create attributes from equality
|
|
361
|
+
conditions alone, so `-> { where("expires_at > ?", Time.current) }` creates records
|
|
362
|
+
that its own index, which honours the scope, will not list.
|
|
363
|
+
|
|
337
364
|
## Controller methods
|
|
338
365
|
|
|
339
366
|
```ruby
|
|
340
367
|
current_parent # Parent record
|
|
368
|
+
current_parent_class # Company
|
|
341
369
|
current_nested_association # :properties
|
|
342
|
-
parent_route_param # :company_id
|
|
343
370
|
parent_input_param # :company
|
|
344
371
|
```
|
|
345
372
|
|
|
373
|
+
The parent class and association are read from the **route** (each nested route carries its registration key), not inferred from the URL — which is why a `singular: true` parent works as a parent despite contributing no id segment. There is no `parent_route_param`.
|
|
374
|
+
|
|
346
375
|
## Parent vs entity scoping
|
|
347
376
|
|
|
348
377
|
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 isn't needed.
|
|
@@ -613,7 +642,7 @@ configure do
|
|
|
613
642
|
login_redirect "/welcome"
|
|
614
643
|
|
|
615
644
|
after_login do
|
|
616
|
-
session[:after_welcome_redirect] = session.delete(
|
|
645
|
+
session[:after_welcome_redirect] = session.delete(login_redirect_session_key)
|
|
617
646
|
end
|
|
618
647
|
end
|
|
619
648
|
```
|