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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Interaction
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The entry point from a Plutonium page into an operation. An interaction declares the inputs, renders as a button and a form, is gated by a policy, and returns an outcome the controller turns into a flash message and a redirect. Built on ActiveModel attributes + validations.
|
|
4
4
|
|
|
5
5
|
## 🚨 Critical
|
|
6
6
|
|
|
@@ -9,6 +9,80 @@ Encapsulate business logic into testable, reusable units. Registered as [actions
|
|
|
9
9
|
- **Redirect is automatic on success** — only use `with_redirect_response` for a *different* destination.
|
|
10
10
|
- **Bulk actions use `attribute :resources` (plural).** Policy authorization is checked per record — if any fails, the whole request fails.
|
|
11
11
|
- **The shape of the action (record / bulk / resource) is inferred from the interaction's attributes.** See [Resource › Actions](/reference/resource/actions#inferred-visibility-interactive-actions).
|
|
12
|
+
- **An interaction is a presentation object.** Logic may *start* in `execute`; the **second caller** — a job, an API controller, a rake task, the console — is the signal to move it to the model. See [below](#what-an-interaction-is-for).
|
|
13
|
+
|
|
14
|
+
## What an interaction is for {#what-an-interaction-is-for}
|
|
15
|
+
|
|
16
|
+
An interaction is a **presentation object**. It exists so Plutonium can render a button, check a policy, bind a form, and turn the result into a message and a redirect. That is the whole job:
|
|
17
|
+
|
|
18
|
+
| An interaction owns | An interaction does not own |
|
|
19
|
+
|---|---|
|
|
20
|
+
| The button — `presents label:` / `icon:` | *Who* may click it. That's the [policy](./policies). |
|
|
21
|
+
| The form — `attribute` + `input` declarations | — |
|
|
22
|
+
| **Input shape** validation: present? parses? right type? | **Business invariants** — they must hold for every caller, so they belong on the model |
|
|
23
|
+
| The user-facing outcome — `succeed` / `failed`, messages, redirect | The domain operation itself, once more than one caller needs it |
|
|
24
|
+
|
|
25
|
+
### Logic may start in `execute`
|
|
26
|
+
|
|
27
|
+
A one-off operation with exactly one caller is perfectly fine written inline. Don't pre-extract a service object for a two-line `update!` — that's YAGNI, and Plutonium deliberately ships no service layer to put it in. The rule below is a **refactoring trigger**, not a prohibition.
|
|
28
|
+
|
|
29
|
+
### The second caller is the trigger to extract
|
|
30
|
+
|
|
31
|
+
The moment a background job, an API controller, a rake task, the console, or another interaction needs the same behaviour, move it to the model.
|
|
32
|
+
|
|
33
|
+
The deadline is *the second caller* — and not "as soon as it looks like business logic" — because of one line in the base class:
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
def initialize(view_context:, **attributes)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`view_context:` is required. So a caller that isn't a Plutonium page has exactly two options: duplicate the logic, or manufacture a `view_context` it has no business owning. **`view_context` is the tell.** If reaching some behaviour would force a caller to conjure one, that behaviour is on the wrong side of the boundary.
|
|
40
|
+
|
|
41
|
+
### The destination is the model
|
|
42
|
+
|
|
43
|
+
Rails convention: fat models. Give the operation a name in domain language and hang it off the record.
|
|
44
|
+
|
|
45
|
+
```ruby
|
|
46
|
+
# app/models/post.rb
|
|
47
|
+
class Post < ApplicationRecord
|
|
48
|
+
def publish!(on: Time.current)
|
|
49
|
+
update!(published: true, published_at: on)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
# the interaction presents it
|
|
56
|
+
def execute
|
|
57
|
+
resource.publish!(on: publish_date)
|
|
58
|
+
succeed(resource).with_message("Post published!")
|
|
59
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
60
|
+
failed(e.record.errors)
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Name it for the domain (`publish!`, `archive!`, `register!`), not for the persistence (`update_published_at`) — the point is that a scheduler job can now call `post.publish!` and read as if it meant it. And resist inventing a `PublishPostService`: the model is the destination, not a new layer.
|
|
65
|
+
|
|
66
|
+
### Worked counter-example — chained interactions
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# 🚫 Every link demands a view_context that has nothing to do with the work
|
|
70
|
+
CreateUserInteraction.call(view_context:, **user_params)
|
|
71
|
+
.and_then { |user| SendWelcomeEmail.call(view_context:, user:) }
|
|
72
|
+
.and_then { |user| LogActivity.call(view_context:, user:) }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Sending a welcome email and writing an audit row are precisely what a signup API endpoint, a seeds script, or a console session also has to do — none of which has a `view_context`. Modelled as interactions, they are unreachable from anywhere but a Plutonium page.
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# ✅ The model owns registering a user; the interaction presents it
|
|
79
|
+
def execute
|
|
80
|
+
user = User.register!(**attributes) # welcome email + audit row live in here
|
|
81
|
+
succeed(user).with_message("Welcome aboard!")
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Chaining three interactions is usually the signal that you have one model method wearing three presentation costumes. `and_then` is real API and stays [documented below](#chaining) — just don't reach for it to sequence business operations.
|
|
12
86
|
|
|
13
87
|
## Structure
|
|
14
88
|
|
|
@@ -33,7 +107,7 @@ class PublishPostInteraction < ResourceInteraction
|
|
|
33
107
|
private
|
|
34
108
|
|
|
35
109
|
def execute
|
|
36
|
-
resource.
|
|
110
|
+
resource.publish!(on: publish_date) # Post#publish! — see above
|
|
37
111
|
succeed(resource).with_message("Post published!")
|
|
38
112
|
rescue ActiveRecord::RecordInvalid => e
|
|
39
113
|
failed(e.record.errors)
|
|
@@ -41,6 +115,8 @@ class PublishPostInteraction < ResourceInteraction
|
|
|
41
115
|
end
|
|
42
116
|
```
|
|
43
117
|
|
|
118
|
+
Note the division: the interaction declares the input, validates that a date was supplied, and phrases the flash. `Post#publish!` decides what publishing a post *means* — so the scheduled-publishing job can call it too.
|
|
119
|
+
|
|
44
120
|
## Attributes
|
|
45
121
|
|
|
46
122
|
ActiveModel-style:
|
|
@@ -123,22 +199,33 @@ def execute
|
|
|
123
199
|
end
|
|
124
200
|
```
|
|
125
201
|
|
|
126
|
-
### Chaining
|
|
202
|
+
### Chaining {#chaining}
|
|
127
203
|
|
|
128
|
-
`and_then`
|
|
204
|
+
`and_then` composes `Outcome`s. On a `Success` it yields **the value** (not the outcome) and returns whatever the block returns; on a `Failure` it short-circuits, returning the failure untouched.
|
|
129
205
|
|
|
130
206
|
```ruby
|
|
131
207
|
def execute
|
|
132
|
-
|
|
133
|
-
.
|
|
134
|
-
.
|
|
135
|
-
|
|
208
|
+
unlocked_resource.and_then do |post|
|
|
209
|
+
post.publish!(on: publish_date)
|
|
210
|
+
succeed(post).with_message("Post published!")
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
# a guard expressed as an Outcome, so the failure carries its own message
|
|
217
|
+
def unlocked_resource
|
|
218
|
+
resource.locked? ? failed("This post is locked") : succeed(resource)
|
|
136
219
|
end
|
|
137
220
|
```
|
|
138
221
|
|
|
222
|
+
::: warning Don't use `and_then` to sequence business operations
|
|
223
|
+
A chain of three interactions is a chain of three things that each demand a `view_context`, none of which a job or an API controller can supply. That's one model method wearing three costumes — see [Worked counter-example](#what-an-interaction-is-for). `and_then` earns its keep composing outcomes *within* one interaction, or in a test.
|
|
224
|
+
:::
|
|
225
|
+
|
|
139
226
|
## Validations
|
|
140
227
|
|
|
141
|
-
Standard ActiveModel. Run automatically before `execute
|
|
228
|
+
Standard ActiveModel. Run automatically before `execute`; if they fail, `execute` never runs.
|
|
142
229
|
|
|
143
230
|
```ruby
|
|
144
231
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
@@ -153,6 +240,23 @@ def custom_check
|
|
|
153
240
|
end
|
|
154
241
|
```
|
|
155
242
|
|
|
243
|
+
### Which validation goes where
|
|
244
|
+
|
|
245
|
+
Interactions have validations and so do models, and they are not competing — they answer different questions:
|
|
246
|
+
|
|
247
|
+
| | Interaction validation | Model validation |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| Asks | "Can I read this input?" — present, parses, right type, plausible format | "Is this record legal?" — invariants that hold no matter who is calling |
|
|
250
|
+
| Exists to | render a form error next to the field | protect the data from every caller, including the ones with no form |
|
|
251
|
+
| Runs | before `execute`, without ever touching the model | inside `save!` / `update!` — i.e. inside your model method |
|
|
252
|
+
|
|
253
|
+
Both surface to the user, but **not identically**, and the difference should inform where you put a rule:
|
|
254
|
+
|
|
255
|
+
- An **interaction** validation attaches to a declared attribute. The re-rendered modal shows it inline against that input, and again in the summary at the top of the form.
|
|
256
|
+
- `failed(record.errors)` flattens `ActiveModel::Errors` into **full messages on `:base`** (`Array(errors)` calls `errors.to_a`, which is `full_messages`). Those land in the form's error summary only — never against a field — and they're phrased with the *model's* attribute names, which need not match your inputs.
|
|
257
|
+
|
|
258
|
+
So it is fine, and often right, to *duplicate* a cheap invariant as an interaction validation purely for the better error placement, while the model keeps the authoritative copy. What must not happen is the model-side copy going missing: the moment a job calls `post.publish!`, the interaction's validations are not in the picture at all.
|
|
259
|
+
|
|
156
260
|
## Accessing context
|
|
157
261
|
|
|
158
262
|
`current_user` is provided by the base class (`view_context.controller.helpers.current_user`):
|
|
@@ -164,6 +268,8 @@ def execute
|
|
|
164
268
|
end
|
|
165
269
|
```
|
|
166
270
|
|
|
271
|
+
This one is *correctly* inline. "Who clicked the button" is context the presentation layer holds and nothing else does — `current_user` is read straight off the `view_context`. A job has no answer for it, so there is no second caller to extract for.
|
|
272
|
+
|
|
167
273
|
## Interaction types
|
|
168
274
|
|
|
169
275
|
| Attribute pattern | Action type | Where it shows up |
|
|
@@ -179,7 +285,7 @@ class ArchiveInteraction < Plutonium::Resource::Interaction
|
|
|
179
285
|
attribute :resource
|
|
180
286
|
|
|
181
287
|
def execute
|
|
182
|
-
resource.
|
|
288
|
+
resource.archive!
|
|
183
289
|
succeed(resource).with_message("Archived")
|
|
184
290
|
rescue ActiveRecord::RecordInvalid => e
|
|
185
291
|
failed(e.record.errors)
|
|
@@ -200,6 +306,8 @@ class BulkArchiveInteraction < Plutonium::Resource::Interaction
|
|
|
200
306
|
end
|
|
201
307
|
```
|
|
202
308
|
|
|
309
|
+
`update_all` stays inline on purpose: it's a single-statement SQL update whose *whole point* is skipping per-record model machinery. If archiving means more than setting a column — callbacks, an audit row, a webhook — this is the wrong shape; call `resources.each(&:archive!)` and let the model own it.
|
|
310
|
+
|
|
203
311
|
Per-record authorization details in [Resource › Actions › Bulk action](/reference/resource/actions#bulk-action).
|
|
204
312
|
|
|
205
313
|
### Resource action (no record)
|
|
@@ -219,7 +327,11 @@ end
|
|
|
219
327
|
|
|
220
328
|
## Calling interactions directly
|
|
221
329
|
|
|
222
|
-
The controller handles this for interactive actions.
|
|
330
|
+
The controller handles this for interactive actions. You can also call one by hand — chiefly in **tests**, where you're exercising the interaction itself.
|
|
331
|
+
|
|
332
|
+
::: tip Needing this in a job or a rake task is the signal to refactor
|
|
333
|
+
Both entry points require `view_context:`, and a job doesn't have one. If you find yourself reaching for a stub to satisfy it, you don't want the interaction — you want the model method it wraps. See [What an interaction is for](#what-an-interaction-is-for).
|
|
334
|
+
:::
|
|
223
335
|
|
|
224
336
|
### Class method
|
|
225
337
|
|
|
@@ -240,7 +352,7 @@ interaction = PublishPost.new(view_context: view_context, resource: post)
|
|
|
240
352
|
outcome = interaction.call
|
|
241
353
|
```
|
|
242
354
|
|
|
243
|
-
The `view_context:` argument is required — interactions use it to access controller helpers and the current user.
|
|
355
|
+
The `view_context:` argument is required — interactions use it to access controller helpers and the current user. It is also the boundary marker: everything reachable *only* through an interaction is reachable only from a page.
|
|
244
356
|
|
|
245
357
|
## Immediate vs form
|
|
246
358
|
|
|
@@ -276,7 +388,26 @@ The same URL serves GET (form/confirmation) and POST (commit) — the HTTP verb
|
|
|
276
388
|
|
|
277
389
|
## Complete example
|
|
278
390
|
|
|
391
|
+
Inviting a user is a textbook second-caller case — a seats-provisioning job, an admin rake task and a signup API all need to send the same invitation. So the operation lives on `Company`, and the interaction is the button in front of it.
|
|
392
|
+
|
|
279
393
|
```ruby
|
|
394
|
+
# app/models/company.rb
|
|
395
|
+
class Company < ApplicationRecord
|
|
396
|
+
has_many :user_invites
|
|
397
|
+
|
|
398
|
+
# Everything inviting means: the row, the mail, the audit trail.
|
|
399
|
+
def invite!(email:, role:, by:)
|
|
400
|
+
user_invites.create!(email: email, role: role, invited_by: by).tap do |invite|
|
|
401
|
+
UserInviteMailer.invitation(invite).deliver_later
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def pending_invite_for?(email) = user_invites.exists?(email: email, state: :pending)
|
|
406
|
+
end
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
# app/interactions/company/invite_user_interaction.rb
|
|
280
411
|
class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
281
412
|
presents label: "Invite User",
|
|
282
413
|
icon: Phlex::TablerIcons::UserPlus
|
|
@@ -288,28 +419,26 @@ class Company::InviteUserInteraction < Plutonium::Resource::Interaction
|
|
|
288
419
|
input :email
|
|
289
420
|
input :role, as: :select, choices: -> { UserInvite.roles.keys }
|
|
290
421
|
|
|
422
|
+
# Input shape — is this a readable email, is this a role that exists?
|
|
291
423
|
validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
|
|
292
424
|
validates :role, presence: true, inclusion: {in: UserInvite.roles.keys}
|
|
293
|
-
validate
|
|
425
|
+
validate :not_already_invited
|
|
294
426
|
|
|
295
427
|
private
|
|
296
428
|
|
|
297
429
|
def execute
|
|
298
|
-
invite
|
|
299
|
-
company: resource, email: email, role: role,
|
|
300
|
-
invited_by: current_user
|
|
301
|
-
)
|
|
302
|
-
UserInviteMailer.invitation(invite).deliver_later
|
|
430
|
+
resource.invite!(email: email, role: role, by: current_user)
|
|
303
431
|
succeed(resource).with_message("Invitation sent to #{email}")
|
|
304
432
|
rescue ActiveRecord::RecordInvalid => e
|
|
305
433
|
failed(e.record.errors)
|
|
306
434
|
end
|
|
307
435
|
|
|
436
|
+
# Deliberately duplicated. `UserInvite` enforces uniqueness for real (a job
|
|
437
|
+
# calling `company.invite!` must hit it too); this copy exists only so the
|
|
438
|
+
# message lands on the :email field instead of in the base error summary.
|
|
308
439
|
def not_already_invited
|
|
309
|
-
return
|
|
310
|
-
|
|
311
|
-
errors.add(:email, "already has a pending invitation")
|
|
312
|
-
end
|
|
440
|
+
return if email.blank?
|
|
441
|
+
errors.add(:email, "already has a pending invitation") if resource.pending_invite_for?(email)
|
|
313
442
|
end
|
|
314
443
|
end
|
|
315
444
|
```
|
|
@@ -335,6 +464,7 @@ See [Testing](/reference/testing/) for Plutonium's built-in testing helpers —
|
|
|
335
464
|
|
|
336
465
|
## Related
|
|
337
466
|
|
|
467
|
+
- [Async Interactions](./async-interactions) — `async` a persisted run instead of running `execute` inline
|
|
338
468
|
- [Resource › Actions](/reference/resource/actions) — registering interactions, inferred visibility, immediate vs form
|
|
339
469
|
- [Policies](./policies) — `def <action>?` authorization methods
|
|
340
470
|
- [Controllers](./controllers) — `resource_url_for(..., interaction: …)` URL generation
|
|
@@ -9,6 +9,7 @@ Plutonium.configure do |config|
|
|
|
9
9
|
|
|
10
10
|
# config.shell = :modern
|
|
11
11
|
# config.navii_host_url = "https://api.navii.dev"
|
|
12
|
+
# config.auto_eager_load_collections = true
|
|
12
13
|
|
|
13
14
|
config.assets.logo = "plutonium.png"
|
|
14
15
|
config.assets.favicon = "plutonium.ico"
|
|
@@ -37,6 +38,10 @@ Loads the baseline defaults for a given framework version. Call this first; late
|
|
|
37
38
|
| `enable_hotreload` | `true` in `development` env | Hot-reload Plutonium components on change. |
|
|
38
39
|
| `shell` | `:modern` | Chrome style: `:modern` (topbar + icon rail), `:plain` (topbar, no icon rail), or `:classic` (legacy header + sidebar, only for upgrades). See [Layouts](./ui/layouts). |
|
|
39
40
|
| `navii_host_url` | `"https://api.navii.dev"` | Host of the [Navii](https://navii.dev) avatar service used by [`Avatar`](./ui/components#avatar). The component appends `/avatar/:seed`. Repoint to self-host or proxy. |
|
|
41
|
+
| `auto_eager_load_collections` | `true` | Index pages, kanban boards and CSV exports preload the associations and attachments they render. Set `false` to disable globally, or override `auto_eager_load_collections?` in a controller. See [Performance](/guides/performance). |
|
|
42
|
+
| `default_page_width` | `:md` | Width of detail-style pages — the show page and resource forms. One of `:sm :md :lg :xl :full` (`:full` = unconstrained). Index and table pages are unaffected. Override per-resource with `page_width` / `form_width` / `display_width`; see [Definition › Page width](./resource/definition#page-width). |
|
|
43
|
+
| `wizards.width` | `:md` | Default width of wizard step pages. **Independent of `default_page_width`** — a wizard is a self-contained flow, so widening resource pages leaves wizards alone. Override per wizard with `width`. Same size tokens. |
|
|
44
|
+
| `nested_association_routes` | `:detected` | Where a resource's nested routes come from. `:detected` draws one for every `has_many` / `has_one` whose child is registered. `:declared` draws only what `register_resource ..., associations:` names, so a resource that names none gets none. See [Nested resources › Declaring which associations get routes](./tenancy/nested-resources#declaring-which-associations-get-routes). |
|
|
40
45
|
| `assets.logo` | `"plutonium.png"` | Brand logo asset. See [Assets](./ui/assets). |
|
|
41
46
|
| `assets.favicon` | `"plutonium.ico"` | Favicon asset. |
|
|
42
47
|
| `assets.stylesheet` | `"plutonium.css"` | Stylesheet entry. |
|
data/docs/reference/index.md
CHANGED
|
@@ -22,6 +22,7 @@ aside: false
|
|
|
22
22
|
{ name: 'Definition', link: '/plutonium-core/reference/resource/definition' },
|
|
23
23
|
{ name: 'Query', link: '/plutonium-core/reference/resource/query' },
|
|
24
24
|
{ name: 'Actions', link: '/plutonium-core/reference/resource/actions' },
|
|
25
|
+
{ name: 'Positioning', link: '/plutonium-core/reference/positioning' },
|
|
25
26
|
]},
|
|
26
27
|
{ group: 'Behavior', items: [
|
|
27
28
|
{ name: 'Overview', link: '/plutonium-core/reference/behavior/' },
|
|
@@ -49,18 +49,20 @@ Default: `nil` (unlimited).
|
|
|
49
49
|
|
|
50
50
|
Controls how card positions are persisted after a drag-and-drop. Three modes:
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
**Inherited from the definition.** `position_on` is the same verb outside `kanban do…end`, where it makes the resource's [table and grid drag-reorderable](/reference/positioning). A board with no `position_on` of its own uses the **definition's**, falling back to the historic default (`:position`, Mode A). Declaring it inside the board overrides that. Resolution is lazy, so declaration order in the class body does not matter.
|
|
53
|
+
|
|
54
|
+
#### Mode A — delegate to `Plutonium::Positioning::Model` (default)
|
|
53
55
|
|
|
54
56
|
```ruby
|
|
55
57
|
# Default: uses :position attribute
|
|
56
|
-
# (no explicit call needed if the model includes Plutonium::Positioning)
|
|
58
|
+
# (no explicit call needed if the model includes Plutonium::Positioning::Model)
|
|
57
59
|
|
|
58
60
|
# Custom attribute name:
|
|
59
61
|
position_on :sort_order
|
|
60
62
|
```
|
|
61
63
|
|
|
62
64
|
Requires the model to:
|
|
63
|
-
1. `include Plutonium::Positioning`
|
|
65
|
+
1. `include Plutonium::Positioning::Model`
|
|
64
66
|
2. Call `positioned_on :position, scope: :grouping_attribute`
|
|
65
67
|
3. Have a `decimal` column for the position attribute — add it with the `t.position` migration helper (a tuned `decimal(16,8)`) — see [Positioning › Migration](/reference/kanban/positioning#migration)
|
|
66
68
|
|
|
@@ -70,7 +72,8 @@ On drop, calls `record.reposition!(prev_record:, next_record:)` which computes t
|
|
|
70
72
|
|
|
71
73
|
```ruby
|
|
72
74
|
position_on :sort_order do |move|
|
|
73
|
-
# move is a Plutonium::
|
|
75
|
+
# move is a Plutonium::Positioning::Move value object
|
|
76
|
+
# (also reachable as Plutonium::Kanban::Positioning::Move):
|
|
74
77
|
# move.record — the dropped ActiveRecord record
|
|
75
78
|
# move.column — destination column key (Symbol)
|
|
76
79
|
# move.prev — record immediately before the insertion slot, or nil
|
|
@@ -7,7 +7,7 @@ Reference documentation for the Plutonium kanban board feature.
|
|
|
7
7
|
| Page | What it covers |
|
|
8
8
|
|------|---------------|
|
|
9
9
|
| [DSL](/reference/kanban/dsl) | Complete `kanban do…end` DSL — board options, columns, column actions, static vs. dynamic, lazy loading, realtime |
|
|
10
|
-
| [Positioning](/reference/kanban/positioning) | `Plutonium::Positioning` concern, `positioned_on`, `position_on` modes, `reposition!`, rebalancing |
|
|
10
|
+
| [Positioning](/reference/kanban/positioning) | `Plutonium::Positioning::Model` concern, `positioned_on`, `position_on` modes, `reposition!`, rebalancing |
|
|
11
11
|
| [Authorization](/reference/kanban/authorization) | `kanban_move?` policy predicate, read-only fallback, separating move rights from edit rights |
|
|
12
12
|
|
|
13
13
|
## Quick start
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
# Kanban Positioning
|
|
2
2
|
|
|
3
|
+
::: tip Positioning is not kanban-only
|
|
4
|
+
The model concern, the arithmetic and the `position_on` modes on this page are shared with **table and grid drag-to-reorder** — see [Positioning & drag-to-reorder](/reference/positioning) for the index-surface half of the feature (the grip, the `reposition` endpoint, `reposition?`, and board inheritance).
|
|
5
|
+
:::
|
|
6
|
+
|
|
3
7
|
Plutonium uses **decimal fractional positioning** for kanban card ordering. A drop writes a single decimal position (the midpoint between its neighbors), so the common case touches exactly one row — no bulk renumbering. The one exception is rare **rebalancing**: when the same slot has been subdivided ~20 times and the gap between two neighbors shrinks below `1e-6`, Plutonium renumbers that one scope group back to clean integers before inserting (see [Gap exhaustion](#rebalancing)).
|
|
4
8
|
|
|
5
|
-
## `Plutonium::Positioning` concern
|
|
9
|
+
## `Plutonium::Positioning::Model` concern
|
|
6
10
|
|
|
7
11
|
Include this concern in any model you want to position:
|
|
8
12
|
|
|
9
13
|
```ruby
|
|
10
14
|
class Task < ApplicationRecord
|
|
11
|
-
include Plutonium::Positioning
|
|
15
|
+
include Plutonium::Positioning::Model
|
|
12
16
|
|
|
13
17
|
positioned_on :position, scope: :status
|
|
14
18
|
end
|
|
@@ -103,6 +107,22 @@ Task.backfill_positions!(order: :created_at)
|
|
|
103
107
|
|
|
104
108
|
The `position_on` call inside `kanban do…end` controls how Plutonium persists positions after a drag-and-drop. Three modes are available:
|
|
105
109
|
|
|
110
|
+
::: tip A board inherits the definition's `position_on`
|
|
111
|
+
`position_on` is the **same verb** at both levels. A board resolves its strategy as: its own `position_on`, else the **definition's**, else the historic default (`:position`, Mode A). So a resource whose definition already declares `position_on` for its table and grid needs nothing inside `kanban do…end` — the board picks up the same attribute and the same mode.
|
|
112
|
+
|
|
113
|
+
Resolution is lazy, so a `kanban do…end` written **above** `position_on` in the class body still sees it.
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
class TaskDefinition < Plutonium::Resource::Definition
|
|
117
|
+
position_on :sort_order # table, grid AND board
|
|
118
|
+
|
|
119
|
+
kanban do
|
|
120
|
+
position_on :board_rank # …unless the board overrides it
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
:::
|
|
125
|
+
|
|
106
126
|
### Mode A — delegate (default)
|
|
107
127
|
|
|
108
128
|
```ruby
|
|
@@ -113,7 +133,7 @@ kanban do
|
|
|
113
133
|
end
|
|
114
134
|
```
|
|
115
135
|
|
|
116
|
-
On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning` and call `positioned_on`.
|
|
136
|
+
On drop, Plutonium calls `record.reposition!(prev_record:, next_record:)`. Requires the model to include `Plutonium::Positioning::Model` and call `positioned_on`.
|
|
117
137
|
|
|
118
138
|
### Mode B — BYO block
|
|
119
139
|
|
|
@@ -130,7 +150,9 @@ kanban do
|
|
|
130
150
|
end
|
|
131
151
|
```
|
|
132
152
|
|
|
133
|
-
Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Kanban::Positioning::Move`
|
|
153
|
+
Plutonium orders the column by `sort_order` for display; your block is responsible only for persisting the new value. The block is called with a single `Plutonium::Positioning::Move` argument (still reachable under its original name, `Plutonium::Kanban::Positioning::Move`) — it is NOT `instance_exec`'d, so `self` is the proc's original binding.
|
|
154
|
+
|
|
155
|
+
On a table or grid the same block runs with `move.column` set to `nil`, since those surfaces have no columns. See [Mode B](/reference/positioning#mode-b) for a worked `acts_as_list` example.
|
|
134
156
|
|
|
135
157
|
### Mode C — disabled
|
|
136
158
|
|