plutonium 0.60.5 → 0.61.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 +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- 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/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# One-time wizards
|
|
2
|
+
|
|
3
|
+
A **one-time** wizard runs once — for onboarding, a one-shot setup, a "welcome" flow. It needs a durable completion marker (you can't remember "done forever" in a session), which the DB store provides as a `completed` session row.
|
|
4
|
+
|
|
5
|
+
## Declaring `one_time`
|
|
6
|
+
|
|
7
|
+
A one-time wizard is a **keyed** wizard (`concurrency_key`) that **retains** its completed row instead of deleting it. So `one_time` always pairs with a `concurrency_key` — the stable key the retained marker lives at (and the key the gate recomputes).
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class WelcomeWizard < Plutonium::Wizard::Base
|
|
11
|
+
presents label: "Welcome"
|
|
12
|
+
|
|
13
|
+
concurrency_key { current_user } # the stable row to retain (tenant folded in)
|
|
14
|
+
one_time # retain the completed row → never again
|
|
15
|
+
|
|
16
|
+
step :greeting do
|
|
17
|
+
attribute :acknowledged, :string
|
|
18
|
+
input :acknowledged
|
|
19
|
+
validates :acknowledged, presence: true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
review label: "Review"
|
|
23
|
+
|
|
24
|
+
def execute
|
|
25
|
+
succeed.with_message("Welcome aboard!")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Completion** = the instance row reaching `status: completed`, **retained** at the wizard's `instance_key`.
|
|
31
|
+
- **`one_time` requires a `concurrency_key`** — declaring `one_time` without one raises (there's no stable row to retain). A wizard without `one_time` deletes its row on completion (repeatable).
|
|
32
|
+
- **`concurrency_key { current_user }`** keys completion per user; **`concurrency_key { anchor }`** keys it per anchored record ("set up *this* workspace once"). The **tenant (`current_scoped_entity`) is folded in automatically**, so in a tenant portal it's per-(user, tenant) for free.
|
|
33
|
+
- On completion, the row is kept as the marker but its `data` / `tracked_records` are nulled out (privacy + size).
|
|
34
|
+
|
|
35
|
+
The completion marker is recorded by the wizard's own finalize, the same `execute` → PRG path as any wizard — no extra code in `execute`.
|
|
36
|
+
|
|
37
|
+
## Re-opening a completed wizard
|
|
38
|
+
|
|
39
|
+
Navigating back to a finished one-time wizard (its URL, or its bare launch route) doesn't re-run it — the retained `completed` row has had its `data` cleared, so there's nothing to resume or review. Instead the wizard renders a standalone **"already completed" page**: a success badge, the wizard's label, a short message, and a Continue button out.
|
|
40
|
+
|
|
41
|
+
Supply a [`completed` block](/reference/wizard/dsl#completed) on the wizard to replace that body with your own:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
completed do |wizard|
|
|
45
|
+
h1 { "You're already set up." }
|
|
46
|
+
a(href: "/dashboard") { "Back to your dashboard" }
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This is a one-time-only concept: a **repeatable** wizard deletes its row on completion, so re-launching simply starts a fresh run — there's no completed page.
|
|
51
|
+
|
|
52
|
+
## Gating a controller — `ensure_wizard_completed`
|
|
53
|
+
|
|
54
|
+
The `Plutonium::Wizard::Gate` concern installs a `before_action` that redirects users into the wizard until they complete it.
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
module AdminPortal
|
|
58
|
+
class DashboardController < AdminPortal::PlutoniumController
|
|
59
|
+
include Plutonium::Wizard::Gate
|
|
60
|
+
ensure_wizard_completed ::WelcomeWizard
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Flow:
|
|
66
|
+
|
|
67
|
+
1. An un-completed user hits the gated page → the gate stashes their destination (`session[:return_to]`) and redirects to the wizard's first step.
|
|
68
|
+
2. The user completes the wizard → finalize records the durable completion marker.
|
|
69
|
+
3. The gate now passes them through; the controller bounces them back to the stashed destination (PRG).
|
|
70
|
+
4. A completed user passes straight through, every time.
|
|
71
|
+
|
|
72
|
+
Extra options (`only:` / `except:`) are forwarded to `before_action`:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
ensure_wizard_completed ::WelcomeWizard, only: %i[index show]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The entry URL is derived from the `register_wizard` route helper (`<name>_wizard_path(step: <first_step>)`). Override `wizard_entry_path` for a custom mount. When gating from **outside** the wizard's own portal, also override `wizard_gate_route_set` (default `current_engine.routes`) so the gate looks the launch route up in the route set where the wizard is actually mounted.
|
|
79
|
+
|
|
80
|
+
## How the gate keys completion
|
|
81
|
+
|
|
82
|
+
The gate **recomputes the wizard's `instance_key`** from its `concurrency_key`, resolving the key block against the **host controller**, so `current_user`, `current_scoped_entity` (folded automatically), `anchor`, and any custom host method are available. It then checks `completed?(instance_key:)`. This digest is computed by the same `Plutonium::Wizard.compute_instance_key` the runner uses, so the gate sees exactly the marker the wizard recorded.
|
|
83
|
+
|
|
84
|
+
### Gating an anchored wizard
|
|
85
|
+
|
|
86
|
+
An anchor-keyed wizard (explicit `{ anchor }`, or the [implied anchored key](/reference/wizard/anchoring-resume#the-implied-anchored-key)) keys completion by its anchor — so the gate needs that anchor to recompute the key. It resolves it two ways:
|
|
87
|
+
|
|
88
|
+
- **Automatic** for a `via:`-anchored wizard — the gate calls the wizard's own `anchor_via` method on the host controller. Gating a `via: :current_scoped_entity` wizard inside its own entity-scoped portal is zero-config (`ConfigureOrgWizard` gated on an org-portal controller just works).
|
|
89
|
+
- **Explicit** otherwise — pass `anchor:` (a method name or proc, evaluated on the controller) when the anchor isn't auto-resolvable (a `with:`-anchored wizard, or gating from a different context):
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
ensure_wizard_completed ConfigureWizard, anchor: :current_widget
|
|
93
|
+
ensure_wizard_completed ConfigureWizard, anchor: -> { current_account.widget }
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
If an anchor-keyed wizard's anchor can't be resolved and no `anchor:` is given, the gate **raises** (rather than silently mis-keying and looping you into the wizard forever). A wizard keyed by a *non-anchor* method the controller doesn't expose still gives the same clear error.
|
|
97
|
+
|
|
98
|
+
A wizard keyed by an anchor is only gateable **where that anchor can be reconstructed** — a tenant-anchored wizard, within that tenant's context. That's a property of the keying, not a gap in the gate.
|
|
99
|
+
|
|
100
|
+
::: warning Only one-time wizards are gateable
|
|
101
|
+
`ensure_wizard_completed` raises unless the wizard is `one_time` (a `concurrency_key` **plus** `one_time`) — only a retained marker can be checked. Repeatable wizards have nothing durable to gate on.
|
|
102
|
+
:::
|
|
103
|
+
|
|
104
|
+
## The launch action hides itself once completed
|
|
105
|
+
|
|
106
|
+
When you register a one-time wizard on a resource definition with the [`wizard` macro](/reference/wizard/registration-launch), the synthesized launch action (button/link) is **automatically hidden once the current user has completed it**. The macro attaches a render-time `condition:` to the action that recomputes the wizard's `instance_key` for the current context (the same `Plutonium::Wizard.compute_instance_key` the driving layer and the [gate](#how-the-gate-keys-completion) use) and returns false when a retained `completed` row exists at that key:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
class CompanyDefinition < Plutonium::Resource::Definition
|
|
110
|
+
wizard :onboard, CompanyOnboardingWizard # one_time → button vanishes after completion
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- It keys exactly like the wizard's own completion: per-user for `concurrency_key { current_user }`, per-anchor for `concurrency_key { anchor }` (the anchor is the record the action sits on), with the tenant folded in.
|
|
115
|
+
- This is **display-only** — like every action `condition:`, it hides the button but does **not** revoke the route. Keep authorization in the policy (`def onboard? = …`).
|
|
116
|
+
- **Repeatable** (non-`one_time`) wizards get **no** completion condition — their launch action always shows.
|
|
117
|
+
|
|
118
|
+
A custom `condition:` **composes** with the completion check (they're AND-ed) — the action shows only when your condition is met **and** the wizard isn't already completed:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
wizard :onboard, CompanyOnboardingWizard,
|
|
122
|
+
condition: -> { current_user.admin? } # admin AND not yet completed
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Related
|
|
126
|
+
|
|
127
|
+
- [DSL reference](/reference/wizard/dsl) — `concurrency_key`, `one_time`, `authorize?`.
|
|
128
|
+
- [Storage & config](/reference/wizard/storage-config) — the durable completion row.
|
|
129
|
+
- [Registration & launch](/reference/wizard/registration-launch) — mounting the wizard the gate redirects into.
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Registration & launch
|
|
2
|
+
|
|
3
|
+
A wizard reaches a user one of two ways: as a **resource action** (the `wizard` macro on a definition) or as a **route-mounted entry** (`register_wizard`) — inside a portal *or* on the main application. A portal mount inherits the portal's authentication, tenant scoping entity, layout, and Phlex rendering, exactly like a resource; a main-app mount runs standalone (you supply the auth — see [Hosting & the controller override hook](#hosting-the-controller-override-hook)).
|
|
4
|
+
|
|
5
|
+
## On a resource — the `wizard` macro
|
|
6
|
+
|
|
7
|
+
A `wizard` macro in a resource definition registers a wizard and synthesizes its launching action — sugar over the Action system.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
class CompanyDefinition < Plutonium::Resource::Definition
|
|
11
|
+
wizard :configure, ConfigureCompanyWizard # anchored → record action
|
|
12
|
+
wizard :onboard, CompanyOnboardingWizard # no anchor → resource action
|
|
13
|
+
wizard :archive, ArchiveWithReasonWizard, record_action: true # override placement
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- **Placement is dictated by the wizard, not chosen** — an **anchored** wizard is a **record** action (it needs a record, the anchor); a **non-anchored** wizard is a collection-level **resource** action (index header). A record-action wizard surfaces on BOTH the record's show page *and* each list row (`collection_record_action`, scoped to that row's record), exactly like `edit`/`destroy`. The only thing you configure is **where a record action shows** (see the table); a flag that doesn't apply to the wizard's kind (e.g. `resource_action:` on an anchored wizard) **raises**.
|
|
18
|
+
- **Auto-mounted on the resource controller** — the `wizard` macro's routes are drawn on the resource's own controller, exactly like interactive record/resource actions. There is nothing else to wire up.
|
|
19
|
+
- **The anchor is IDOR-safe** — an anchored (record) wizard resolves its anchor through the resource controller's scoped, policy-gated `resource_record!`. A record outside the portal's authorized scope (another tenant's, or a non-existent id) **404s**; it is never loaded via an unscoped `find_by`.
|
|
20
|
+
- **Bulk wizards are not supported** — wizards are inherently per-instance flows. Use a bulk interaction instead.
|
|
21
|
+
- **Authorization mirrors actions** — a resource policy predicate named after the wizard key gates it (`def configure? = update?`, `def onboard? = create?`).
|
|
22
|
+
|
|
23
|
+
| Option | Meaning |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `record_action:` | **(Record wizards only)** show on the record's **show page**. Default `true`; `false` removes it from there. |
|
|
26
|
+
| `collection_record_action:` | **(Record wizards only)** show on each **list row** (scoped to that row's record). Default `true`; `false` keeps it on the show page but off the list. |
|
|
27
|
+
| `label:` / `icon:` / `position:` / `category:` / `confirmation:` / `turbo_frame:` | Standard action chrome — any `action` option passes through. |
|
|
28
|
+
|
|
29
|
+
Placement isn't an option — it follows `anchored?`. Passing a flag that doesn't apply (`resource_action:` on a record wizard, or `record_action:`/`collection_record_action:` on a resource wizard) raises.
|
|
30
|
+
|
|
31
|
+
### Synthesized routes (resource-mounted)
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
# anchored → member route (anchor = the scoped resource_record!)
|
|
35
|
+
GET /companies/:id/wizards/:wizard_name → launch (redirect to step)
|
|
36
|
+
GET/POST /companies/:id/wizards/:wizard_name(/:token)/:step
|
|
37
|
+
|
|
38
|
+
# non-anchored → collection route (create flow)
|
|
39
|
+
GET /companies/wizards/:wizard_name → launch (redirect to step)
|
|
40
|
+
GET/POST /companies/wizards/:wizard_name(/:token)/:step
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The synthesized launch action points at the **bare** wizard URL (no step). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed identity) and redirects to its current step: the **resumed cursor** for an in-progress keyed run, else the **first step**, with the token already in the URL. So clicking the launch button resumes where the user left off (rather than jumping back to step 1) and never forks a fresh run on a first-step reload.
|
|
44
|
+
|
|
45
|
+
## Route-mounted — `register_wizard`
|
|
46
|
+
|
|
47
|
+
For a wizard not tied to a single resource (onboarding, welcome, set-up), register it with `register_wizard` — **inside a portal engine's routes** (most common) or **on the main application's routes**, alongside `register_resource`:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# packages/admin_portal/config/routes.rb
|
|
51
|
+
AdminPortal::Engine.routes.draw do
|
|
52
|
+
register_wizard ::OnboardOrganizationWizard, at: "onboarding" # in-shell (portal default)
|
|
53
|
+
register_wizard ::SetupOrgWizard, at: "setup", layout: :basic # bare (BasicLayout)
|
|
54
|
+
|
|
55
|
+
register_resource ::Company
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# config/routes.rb — a main-app (portal-less) wizard
|
|
59
|
+
Rails.application.routes.draw do
|
|
60
|
+
register_wizard ::AppOnboardingWizard, at: "onboarding" # main-app default → :basic
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
| Argument | Meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `at:` (required) | The host-relative base path for the wizard's steps. |
|
|
67
|
+
| `as:` | Override the route helper name prefix (defaults to `at:`, then the wizard's name, e.g. `OnboardOrganizationWizard` → `onboarding`). |
|
|
68
|
+
| `public:` | Mount on a **public (unauthenticated) route** for an [`anonymous`](#public-mount-for-anonymous-wizards) wizard. Defaults to the wizard's own `anonymous?` flag. |
|
|
69
|
+
| `layout:` | The Rails [layout](#layout) to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Defaults by host — **portal → the resource shell**, **main-app → `:basic`**. |
|
|
70
|
+
|
|
71
|
+
This draws the wizard's step routes within the host (so a portal mount inherits the portal's scope/auth/layout) and dispatches them to a wizard controller. It provides `<name>_wizard_path` / `_url` helpers.
|
|
72
|
+
|
|
73
|
+
### Hosting & the controller override hook
|
|
74
|
+
|
|
75
|
+
`register_wizard` resolves the controller it dispatches to **override-first**: if you've defined the conventional controller it is used as-is; otherwise one is synthesized. This is the same "app owns the controller" contract as `register_resource` — there is no hand-written file unless you want to customize.
|
|
76
|
+
|
|
77
|
+
| Host | Controller | Base / auth |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| **Portal** | `<Portal>::WizardsController` if defined, else synthesized | the portal's `PlutoniumController` (inherits its auth/scope/layout) |
|
|
80
|
+
| **Main app, authenticated** | `::WizardsController` — **you define it** | yours: a base + `include Plutonium::Auth::Rodauth(:account)` |
|
|
81
|
+
| **Main app, public** (`anonymous`) | `::PublicWizardsController` (synthesized) | a bare base + `Plutonium::Auth::Public` (guest) |
|
|
82
|
+
|
|
83
|
+
The synthesized **main-app** controller is **bare** — rooted in `ApplicationController`/`ActionController::Base`, deliberately *not* in `::PlutoniumController` (which portals inherit and may carry auth, which would leak into a guest flow). A bare controller has **no `current_user`**, so an **authenticated** main-app wizard requires you to define `::WizardsController` yourself with the auth concern:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
# app/controllers/wizards_controller.rb
|
|
87
|
+
class WizardsController < ApplicationController
|
|
88
|
+
include Plutonium::Wizard::Controller # the complete include surface
|
|
89
|
+
include Plutonium::Auth::Rodauth(:user) # supplies current_user
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`Plutonium::Wizard::Controller` is the whole mechanism: including it on any base yields a renderable wizard controller. It pulls in `Plutonium::Core::Controller` (asset/layout machinery) and **contributes the `"plutonium"` view-lookup prefix itself**, so even a bare `ActionController::Base` host resolves the gem's shared partials (`plutonium/_flash`, …). For an app that needs no custom auth base, subclass the ready-made `Plutonium::Wizard::BaseController` (`< ActionController::Base` with the module already included). The module is the contract; the class is sugar.
|
|
94
|
+
|
|
95
|
+
### Layout
|
|
96
|
+
|
|
97
|
+
`layout:` is the **Rails layout** the wizard renders in — a layout *name*, exactly like the controller `layout` macro. It's applied at render time, so it works regardless of which controller serves the wizard (without touching the `Page` component):
|
|
98
|
+
|
|
99
|
+
| `layout:` | Appearance | Layout |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `:basic` | no sidebar/topbar — e.g. "set up your organization" | `basic` (`BasicLayout`) |
|
|
102
|
+
| `:resource` | sidebar + topbar + wizard (in-app) | the `resource` shell layout |
|
|
103
|
+
| *(any app layout)* | whatever that layout renders | passed straight to Rails |
|
|
104
|
+
| *(omitted)* | host default — see below | — |
|
|
105
|
+
|
|
106
|
+
- **Defaults by host:** portal → the `resource` shell; main-app → `:basic` (a bare main-app host has no shell to embed in). Pass `layout:` to override.
|
|
107
|
+
- **Turbo-frame requests are always layout-less** regardless of the setting — that's the embedded/modal path (how resource-anchored `wizard`-macro launches render).
|
|
108
|
+
- `layout:` is a **`register_wizard` option only**; it travels with the mount, not the wizard class. Resource-defined wizards take no `layout:` (always embedded).
|
|
109
|
+
|
|
110
|
+
### Synthesized routes
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
GET /onboarding → launch: resolve/mint the run, redirect to its step
|
|
114
|
+
GET /onboarding(/:token)/:step → renders the step
|
|
115
|
+
POST /onboarding(/:token)/:step → advances (the `_direction` param carries next/back/cancel)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The bare **`/onboarding`** is the canonical entry point (helper `onboarding_wizard_launch_path`). A `GET` there resolves the run (minting the per-run `:token` for a tokened wizard, or resolving the keyed/guest identity) and `303`-redirects to its entry step: the **resumed cursor** for an in-progress keyed/guest run, else the **first visible step**. Because the redirect target already carries the token, the address bar shows a stable, shareable run URL from the first paint (the token no longer "appears" only after the first submit, and reloading the first step can't fork a second run). Link to `/onboarding` from menus/dashboards; the stepped `/onboarding(/:token)/:step` URLs are built for you by the engine.
|
|
119
|
+
|
|
120
|
+
The POST `_direction` param carries `next` / `back` / `cancel`. **Where Cancel sends the user** is captured at launch from a `?return_to=` query param (or the referer), sanitized to a same-host local path that isn't the wizard's own mount (open-redirect-safe); Cancel returns there, falling back to the host root. So linking to `/onboarding?return_to=/dashboard` lands a cancelled run back on the dashboard.
|
|
121
|
+
|
|
122
|
+
`scope_gid` (folded into the instance key) comes from the portal's scoping entity when the portal is entity-scoped.
|
|
123
|
+
|
|
124
|
+
## Entry authorization
|
|
125
|
+
|
|
126
|
+
A portal-level wizard has no resource policy, so gate entry with an `authorize?` instance method on the wizard — checked before each request:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class WelcomeWizard < Plutonium::Wizard::Base
|
|
130
|
+
def authorize?
|
|
131
|
+
current_user.present? && !current_user.onboarded?
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
A falsy return → `ActionPolicy::Unauthorized` (403). Resource-attached wizards instead use their action's policy predicate (`def onboard?` etc.).
|
|
137
|
+
|
|
138
|
+
::: danger A portal-level wizard with no `authorize?` is open to ANY authenticated portal user
|
|
139
|
+
A portal-level wizard (registered with `register_wizard`) has **no resource policy** and **defaults to allowed** — so if you omit `authorize?`, every authenticated user of that portal can run it. **Always define `def authorize?`** for anything privileged (admin-only flows, per-user gating, tenant checks). The default-allow is only safe for flows that are genuinely fine for any signed-in portal user (e.g. self-service onboarding).
|
|
140
|
+
:::
|
|
141
|
+
|
|
142
|
+
::: warning As-built: `authorize?` is an instance method
|
|
143
|
+
Define `def authorize?` on the wizard. The controller checks it only when the wizard responds to it (default: allowed).
|
|
144
|
+
:::
|
|
145
|
+
|
|
146
|
+
## Public mount for `anonymous` wizards
|
|
147
|
+
|
|
148
|
+
**Wizards require authentication by default.** A guest ([`anonymous`](/reference/wizard/anchoring-resume#authentication)) wizard needs a route reachable **before** login. Because a portal engine is mounted *inside* the host's authentication constraint (`constraints Rodauth::Rails.authenticate(:user) { mount ... }`), a route drawn in the portal is unreachable pre-login. So `register_wizard` draws an `anonymous` wizard's route on the **main application's** route set, outside that constraint:
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
# in the portal engine's routes (register_wizard is available there)
|
|
152
|
+
register_wizard ::GuestSignupWizard, at: "signup", public: true
|
|
153
|
+
# → GET/POST /signup(/:token)/:step (a top-level, unauthenticated route)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `public: true` is the **default for an `anonymous` wizard**; you can pass it explicitly for clarity.
|
|
157
|
+
- A **non-`anonymous`** wizard may **not** be mounted public (raises) — and an **`anonymous`** wizard may **not** be mounted authenticated (it would be unreachable pre-login).
|
|
158
|
+
- The public route dispatches to a synthesized top-level `::PublicWizardsController` (a **distinct** const from an authenticated main-app `::WizardsController`, so the two never collapse onto each other) that renders **full-page** with a standalone layout (no resource sidebar / user menu) and treats the request as a guest via `Plutonium::Auth::Public`.
|
|
159
|
+
|
|
160
|
+
## One-time gating
|
|
161
|
+
|
|
162
|
+
To make a wizard run once and gate a controller behind it, see [One-time wizards](/reference/wizard/one-time) — a `concurrency_key` + `one_time` + the `Plutonium::Wizard::Gate` concern (`ensure_wizard_completed`).
|
|
163
|
+
|
|
164
|
+
For a **one-time** wizard, the launch action this macro synthesizes also **hides itself once the current user has completed it** (via a render-time action `condition:`); a custom `condition:` composes with that check. See [The launch action hides itself once completed](/reference/wizard/one-time#the-launch-action-hides-itself-once-completed).
|
|
165
|
+
|
|
166
|
+
## Constraints
|
|
167
|
+
|
|
168
|
+
- **`with:`-anchored wizards mount on the resource, not route-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro (it auto-mounts a member action whose anchor is the scoped `resource_record!`). Passing a `with:`-anchored wizard to **`register_wizard`** raises: a route-level mount has no resource record to anchor to. A **`via:`-anchored** (context-anchored) wizard *does* mount route-level; its anchor is resolved by a controller method, not a URL `:id`.
|
|
169
|
+
- **An authenticated main-app wizard needs an app-defined controller.** The synthesized main-app controller is bare (no `current_user`); supply your own `::WizardsController` with an auth concern (see [Hosting & the controller override hook](#hosting-the-controller-override-hook)). Portal mounts and `anonymous` public mounts need nothing.
|
|
170
|
+
- **Route-helper names must be unique across public mounts.** A public (`anonymous`) wizard's route is drawn on the main app, keyed by its helper name (`as:` → `at:` → class-derived). Two distinct public wizards resolving to the **same** helper name **raise** at draw time — give one an explicit `as:`. (Re-drawing the *same* wizard on a route reload is a no-op, not a collision.)
|
|
171
|
+
|
|
172
|
+
## Related
|
|
173
|
+
|
|
174
|
+
- [DSL reference](/reference/wizard/dsl) — `authorize?`, the wizard body.
|
|
175
|
+
- [Anchoring & resume](/reference/wizard/anchoring-resume) — anchors, instance keys.
|
|
176
|
+
- [One-time wizards](/reference/wizard/one-time) — completion + gating.
|
|
177
|
+
- [Custom actions guide](/guides/custom-actions) — the Action system the `wizard` macro builds on.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Storage & config
|
|
2
|
+
|
|
3
|
+
The wizard subsystem is DB-backed by a single framework table, gated by an opt-in config flag. This page covers enabling it, the table, configuration, encryption, and the cleanup sweep.
|
|
4
|
+
|
|
5
|
+
## Enabling the subsystem
|
|
6
|
+
|
|
7
|
+
Wizards are core code, but the storage table is **opt-in** so apps that don't use wizards stay schema-clean.
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# config/initializers/plutonium.rb
|
|
11
|
+
Plutonium.configure do |config|
|
|
12
|
+
config.wizards.enabled = true # false by default
|
|
13
|
+
config.wizards.cleanup_after = 14.days # global default idle TTL for the sweep
|
|
14
|
+
config.wizards.encrypt_data = true # encrypt every wizard's data at rest (needs AR encryption keys)
|
|
15
|
+
config.wizards.attachment_backend = nil # server-side attachment staging backend (nil = auto-detect)
|
|
16
|
+
end
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
rails db:migrate
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Config | Default | Meaning |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| `config.wizards.enabled` | `false` | The subsystem's master switch. Registers the gem migration (so `rails db:migrate` creates the table) **and** draws wizard routes — both `register_wizard` and the resource-mounted `wizard`-macro actions. While `false`, `register_wizard` is a no-op (it logs a warning so a registered-but-disabled wizard isn't a silent 404) and no wizard routes are mounted. Required to use wizards. |
|
|
26
|
+
| `config.wizards.cleanup_after` | `14.days` | Global default idle TTL for the abandonment sweep; overridable per wizard via `cleanup_after`. |
|
|
27
|
+
| `config.wizards.database` | `:primary` | Which database connection the wizard table lives on. **v1 supports the primary database only** — see below. |
|
|
28
|
+
| `config.wizards.encrypt_data` | `false` | Encrypt **every** wizard's staged `data` at rest by default. Off by default because it needs ActiveRecord encryption keys; a wizard still overrides it individually with `encrypt_data` / `encrypt_data false`. See [Encryption](#encryption). |
|
|
29
|
+
| `config.wizards.attachment_backend` | `nil` | Storage backend for **server-side** [attachment](/reference/wizard/dsl#attachment-fields) staging (a plain `as: :file` field). `nil` auto-detects — active_shrine installed → `:shrine`, else `:active_storage`. Override per field with `input …, backend:`. Direct-upload fields ignore it (they arrive as a token). |
|
|
30
|
+
|
|
31
|
+
## Gem-shipped migration
|
|
32
|
+
|
|
33
|
+
The migration ships **in the gem** and Rails runs it **in place** — there is no copy-into-your-app step (unlike `pu:rodauth`/`pu:invites`, which are app-customized templates). Enabling `config.wizards.enabled` registers the gem migration path; `rails db:migrate` then runs it.
|
|
34
|
+
|
|
35
|
+
- Once run, the table is dumped into your `schema.rb` / `structure.sql` like any other, so `db:schema:load` on fresh/CI databases recreates it normally.
|
|
36
|
+
- Disable later → the path isn't registered; the existing table is left alone (never auto-dropped).
|
|
37
|
+
- `db:migrate:status` shows the migration's file living in the gem (cosmetic; reads "file missing" if the gem is later removed) — standard for gem-shipped migrations.
|
|
38
|
+
|
|
39
|
+
::: warning v1 supports the primary database only
|
|
40
|
+
The wizard table lives on your app's **primary** database in v1. `config.wizards.database` is **reserved for future use** — multi-database routing for wizard sessions is a roadmap follow-up. Setting it to anything other than `:primary` (while wizards are enabled) **raises at boot**, rather than silently registering the migration on the primary database.
|
|
41
|
+
:::
|
|
42
|
+
|
|
43
|
+
## The table — `plutonium_wizard_sessions`
|
|
44
|
+
|
|
45
|
+
One framework-owned table serves everything; **no changes to your models.**
|
|
46
|
+
|
|
47
|
+
| Column | Purpose |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `wizard` | The wizard class name. |
|
|
50
|
+
| `status` | `in_progress` \| `completing` \| `completed` (see the note below). |
|
|
51
|
+
| `current_step` | The step cursor. |
|
|
52
|
+
| `instance_key` (unique) | The deterministic identity digest (see [Anchoring & resume](/reference/wizard/anchoring-resume#instance-identity)). |
|
|
53
|
+
| `owner_type` / `owner_id` | The user (nullable — `null` for an `anonymous`/guest run). Authenticated lookups are owner-scoped against this. |
|
|
54
|
+
| `anchor_type` / `anchor_id` | The anchor record (nullable). |
|
|
55
|
+
| `scope_type` / `scope_id` | The portal scoping entity / tenant (nullable). |
|
|
56
|
+
| `engine` | The portal (engine class name) the run was launched in, e.g. `"OrgPortal::Engine"`. The "continue where you left off" listing only shows (and links) runs whose `engine` matches the portal being viewed (two portals can share an entity scope, so `scope` alone can't identify the portal). |
|
|
57
|
+
| `token` | The per-run id for guest/tokened (no-`concurrency_key`) instances (nullable). |
|
|
58
|
+
| `data` | Staged field values (JSON; `jsonb` on PostgreSQL). |
|
|
59
|
+
| `tracked_records` | GlobalIDs of records registered via `persist`, by step key. Exposed to authors as `persisted[:key]`. |
|
|
60
|
+
| `visited` | Visited step keys. |
|
|
61
|
+
| `expires_at` | Concrete expiry, stamped `now + cleanup_after` on every write (`nil` = `:never`). |
|
|
62
|
+
| `completed_at` | Completion timestamp. |
|
|
63
|
+
|
|
64
|
+
What the single table powers:
|
|
65
|
+
|
|
66
|
+
- **Resume** — look up the `in_progress` row by `instance_key`.
|
|
67
|
+
- **One-time check** — does a `completed` row exist for `(wizard, owner)` or `(wizard, anchor)`.
|
|
68
|
+
- **In-progress listing** — by owner, portal (`engine`), and tenant scope, so a run is only ever listed by the portal it was launched in.
|
|
69
|
+
- **Multi-tenancy** — the portal scoping entity is folded into `instance_key` and stored as `scope_*`, so the same user's same non-anchored wizard doesn't collide across tenants.
|
|
70
|
+
- **Sweep** — idle `in_progress`/`completing` rows past `expires_at`.
|
|
71
|
+
|
|
72
|
+
::: tip The `persisted` / `tracked_records` naming
|
|
73
|
+
The column is `tracked_records`, not `persisted` — an AR attribute named `persisted` collides with `ActiveRecord::Persistence#persisted?`. The author-facing accessor stays `persisted[:key]`; the store maps it to the column.
|
|
74
|
+
:::
|
|
75
|
+
|
|
76
|
+
## Encryption
|
|
77
|
+
|
|
78
|
+
A wizard may opt into encrypting its staged field values at rest, for flows that stage PII:
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class CheckoutWizard < Plutonium::Wizard::Base
|
|
82
|
+
encrypt_data
|
|
83
|
+
# ...
|
|
84
|
+
end
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This encrypts the `data` column (the staged step values) — off by default. The `tracked_records` column (record GlobalIDs only) and the queried `owner`/`anchor`/`scope`/`token` columns stay plaintext.
|
|
88
|
+
|
|
89
|
+
**Encrypt everything by default.** Once your app has ActiveRecord encryption keys, you can flip encryption on for *all* wizards with one global flag, then override per wizard:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
config.wizards.encrypt_data = true # every wizard's `data` is encrypted at rest
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
class PublicSurveyWizard < Plutonium::Wizard::Base
|
|
97
|
+
encrypt_data false # explicit opt-OUT, even when the global default is on
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Resolution: an explicit `encrypt_data` / `encrypt_data false` on the wizard always wins; a wizard that declares neither inherits `config.wizards.encrypt_data` (off unless you set it). It stays opt-in globally because it requires keys — see the warning below.
|
|
102
|
+
|
|
103
|
+
**How it works.** Because `data` is one shared `jsonb` column across all wizards (some opting in, some not), a static model-level `encrypts :data` doesn't fit (it would encrypt every row, and fights the `jsonb` type). Instead, the store encrypts at write time using **ActiveRecord's configured encryptor** (`ActiveRecord::Encryption.encryptor`, the same keys as `encrypts`) and stores a self-describing envelope inside the column:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{ "_enc": "<ciphertext>" }
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
A row therefore decrypts based on its **own shape**, independent of the wizard's current `encrypt_data?` — so toggling the flag never strands existing runs.
|
|
110
|
+
|
|
111
|
+
::: warning Requires ActiveRecord encryption keys
|
|
112
|
+
`encrypt_data` reuses your app's ActiveRecord encryption keys (`active_record.encryption.primary_key` / `deterministic_key` / `key_derivation_salt`, typically via credentials). If a wizard declares `encrypt_data` but no keys are configured, the **first write raises** a `Configuration` error naming the wizard — rather than ActiveRecord's later, context-free failure. Set the keys (`bin/rails db:encryption:init`) before enabling it.
|
|
113
|
+
:::
|
|
114
|
+
|
|
115
|
+
## Files
|
|
116
|
+
|
|
117
|
+
A file can't sit in the JSON `data` column, so a wizard stages only the backend's **upload token** (an ActiveStorage `signed_id`, or Shrine cached-file data) and `execute` assigns it to the model's attachment. This works for both server-side and direct uploads, ActiveStorage and active_shrine. See [DSL › Attachment fields](/reference/wizard/dsl#attachment-fields) and the [guide](/guides/wizards#file-uploads-attachments) for the full surface (`backend:`, `multiple:`, direct upload).
|
|
118
|
+
|
|
119
|
+
A staged-then-abandoned upload is an unattached blob / cached Shrine file. **Each storage backend's own unattached-cache cleanup reaps it — the wizard `SweepJob` does not** (it only tracks records registered via `persist`). Ensure that backend cleanup runs.
|
|
120
|
+
|
|
121
|
+
## Cleanup & the SweepJob
|
|
122
|
+
|
|
123
|
+
`cleanup_after` stamps a concrete `expires_at` (`now + ttl`) on every write, so an actively-progressing wizard keeps pushing its expiry forward. A later change to the wizard's TTL never retroactively shifts existing rows. `cleanup_after :never` stores a null `expires_at`, opting out of sweeping (partial records persist by design).
|
|
124
|
+
|
|
125
|
+
`Plutonium::Wizard::SweepJob` reaps idle `in_progress` / `completing` rows past `expires_at`: for each it runs the wizard's cleanup (each step's `on_rollback` if declared, then always destroy every tracked record, in reverse order) and deletes the row. Completed rows are never touched. The job is idempotent and safe to re-run.
|
|
126
|
+
|
|
127
|
+
::: tip The `completing` state and its grace window
|
|
128
|
+
A healthy finalize flips the row to `completing` and runs `execute` **outside** the completion lock (so a long `execute` doesn't block other requests), without bumping `expires_at`. To avoid sweeping a finalize that's still running, the sweep only reaps a `completing` row once it's been idle for a 15-minute grace window — long enough that a still-`completing` row past it is a *crashed* finalize, not a slow one. Keep individual `execute`s well under 15 minutes (offload long work to a job).
|
|
129
|
+
:::
|
|
130
|
+
|
|
131
|
+
### SweepJob is load-bearing for save-as-you-go
|
|
132
|
+
|
|
133
|
+
::: warning Schedule the SweepJob
|
|
134
|
+
- For **`execute`-only** wizards, an unscheduled sweep merely leaves stale session rows (harmless).
|
|
135
|
+
- For **`on_submit` (save-as-you-go)** wizards, the sweep is the **only** thing that cleans up abandoned real domain records. Without it, partial records accumulate forever.
|
|
136
|
+
|
|
137
|
+
Schedule `Plutonium::Wizard::SweepJob` as a recurring job (e.g. via your scheduler / cron / `solid_queue` recurring tasks) for any app that uses `on_submit`.
|
|
138
|
+
:::
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
# e.g. a recurring job
|
|
142
|
+
Plutonium::Wizard::SweepJob.perform_later
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
On completion of a one-time wizard, the row is kept as the durable marker but its `data` / `tracked_records` are nulled out (privacy + size).
|
|
146
|
+
|
|
147
|
+
## Related
|
|
148
|
+
|
|
149
|
+
- [Anchoring & resume](/reference/wizard/anchoring-resume) — `instance_key`, resume.
|
|
150
|
+
- [DSL reference](/reference/wizard/dsl) — `cleanup_after`, `encrypt_data`, `persist`.
|
|
151
|
+
- [One-time wizards](/reference/wizard/one-time) — durable completion markers.
|
|
@@ -169,7 +169,7 @@ module Plutonium
|
|
|
169
169
|
UNGROUPED_KEY = :ungrouped
|
|
170
170
|
|
|
171
171
|
# One declared section, or the implicit `ungrouped` bucket (empty `fields`).
|
|
172
|
-
Section = Struct.new(:key, :fields, :options
|
|
172
|
+
Section = Struct.new(:key, :fields, :options) do
|
|
173
173
|
def ungrouped? = key == UNGROUPED_KEY
|
|
174
174
|
def label = options.fetch(:label) { key.to_s.humanize }
|
|
175
175
|
def description = options[:description]
|
|
@@ -181,7 +181,7 @@ module Plutonium
|
|
|
181
181
|
|
|
182
182
|
# A section paired with the concrete fields it will render (after policy
|
|
183
183
|
# filtering). Produced by #resolve_form_sections.
|
|
184
|
-
ResolvedSection = Struct.new(:section, :fields
|
|
184
|
+
ResolvedSection = Struct.new(:section, :fields)
|
|
185
185
|
|
|
186
186
|
# Collects section/ungrouped calls from a form_layout block in order.
|
|
187
187
|
class Builder
|