plutonium 0.65.0 → 0.66.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 +43 -43
- data/.claude/skills/plutonium-app/SKILL.md +101 -59
- data/.claude/skills/plutonium-async-interactions/SKILL.md +19 -19
- data/.claude/skills/plutonium-auth/SKILL.md +119 -53
- data/.claude/skills/plutonium-behavior/SKILL.md +110 -78
- data/.claude/skills/plutonium-dashboard/SKILL.md +11 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +81 -49
- data/.claude/skills/plutonium-resource/SKILL.md +144 -133
- data/.claude/skills/plutonium-tenancy/SKILL.md +104 -55
- data/.claude/skills/plutonium-testing/SKILL.md +130 -33
- data/.claude/skills/plutonium-ui/SKILL.md +151 -96
- data/.claude/skills/plutonium-wizard/SKILL.md +93 -82
- data/CHANGELOG.md +21 -0
- data/README.md +9 -9
- data/SECURITY.md +1 -1
- data/app/assets/plutonium.css +1 -1
- data/docs/.vitepress/sync-skills.mjs +6 -3
- data/docs/blog/introducing-plutonium-dashboards.md +4 -5
- data/docs/blog/introducing-plutonium-i18n.md +4 -5
- data/docs/getting-started/installation.md +5 -5
- data/docs/getting-started/tutorial/02-first-resource.md +3 -3
- data/docs/getting-started/tutorial/03-authentication.md +7 -7
- data/docs/getting-started/tutorial/04-authorization.md +21 -4
- data/docs/getting-started/tutorial/05-custom-actions.md +2 -2
- data/docs/getting-started/tutorial/06-nested-resources.md +5 -2
- data/docs/getting-started/tutorial/07-author-portal.md +2 -2
- data/docs/getting-started/tutorial/08-customizing-ui.md +45 -30
- data/docs/getting-started/tutorial/index.md +1 -1
- data/docs/guides/adding-resources.md +10 -7
- data/docs/guides/authentication.md +25 -25
- data/docs/guides/authorization.md +24 -24
- data/docs/guides/creating-packages.md +17 -17
- data/docs/guides/custom-actions.md +32 -32
- data/docs/guides/customizing-ui.md +29 -26
- data/docs/guides/dashboards.md +1 -1
- data/docs/guides/index.md +3 -3
- data/docs/guides/kanban.md +55 -55
- data/docs/guides/multi-tenancy.md +35 -22
- data/docs/guides/nested-resources.md +21 -21
- data/docs/guides/performance.md +3 -3
- data/docs/guides/search-filtering.md +13 -13
- data/docs/guides/testing.md +16 -12
- data/docs/guides/theming.md +32 -17
- data/docs/guides/troubleshooting.md +2 -2
- data/docs/guides/user-invites.md +17 -17
- data/docs/guides/user-profile.md +51 -24
- data/docs/guides/wizards.md +55 -55
- data/docs/reference/app/generators.md +22 -22
- data/docs/reference/app/index.md +15 -18
- data/docs/reference/app/packages.md +8 -8
- data/docs/reference/app/portals.md +75 -29
- data/docs/reference/auth/accounts.md +15 -15
- data/docs/reference/auth/index.md +12 -12
- data/docs/reference/auth/profile.md +67 -29
- data/docs/reference/behavior/async-interactions.md +24 -24
- data/docs/reference/behavior/controllers.md +28 -28
- data/docs/reference/behavior/index.md +5 -5
- data/docs/reference/behavior/interactions.md +44 -44
- data/docs/reference/behavior/policies.md +48 -28
- data/docs/reference/configuration.md +6 -6
- data/docs/reference/dashboard/dsl.md +2 -2
- data/docs/reference/dashboard/index.md +1 -1
- data/docs/reference/generators/lite.md +7 -7
- data/docs/reference/i18n.md +23 -0
- data/docs/reference/index.md +1 -1
- data/docs/reference/kanban/authorization.md +9 -9
- data/docs/reference/kanban/dsl.md +32 -32
- data/docs/reference/kanban/index.md +1 -1
- data/docs/reference/kanban/positioning.md +17 -15
- data/docs/reference/resource/actions.md +51 -51
- data/docs/reference/resource/definition.md +73 -73
- data/docs/reference/resource/export.md +6 -6
- data/docs/reference/resource/index.md +16 -16
- data/docs/reference/resource/model.md +24 -24
- data/docs/reference/resource/positioning.md +78 -76
- data/docs/reference/resource/query.md +13 -13
- data/docs/reference/tenancy/entity-scoping.md +65 -35
- data/docs/reference/tenancy/index.md +11 -11
- data/docs/reference/tenancy/invites.md +20 -20
- data/docs/reference/tenancy/nested-resources.md +13 -13
- data/docs/reference/testing/index.md +116 -22
- data/docs/reference/ui/assets.md +57 -25
- data/docs/reference/ui/components.md +20 -20
- data/docs/reference/ui/displays.md +14 -14
- data/docs/reference/ui/forms.md +35 -35
- data/docs/reference/ui/index.md +17 -15
- data/docs/reference/ui/layouts.md +21 -21
- data/docs/reference/ui/pages.md +22 -22
- data/docs/reference/ui/tables.md +7 -7
- data/docs/reference/wizard/anchoring-resume.md +33 -32
- data/docs/reference/wizard/dsl.md +44 -44
- data/docs/reference/wizard/index.md +6 -6
- data/docs/reference/wizard/one-time.md +18 -18
- data/docs/reference/wizard/registration-launch.md +32 -32
- data/docs/reference/wizard/storage-config.md +23 -23
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/profile/conn_generator.rb +6 -0
- data/lib/plutonium/resource/record/associated_with.rb +23 -2
- data/lib/plutonium/ui/form/concerns/typeahead_attributes.rb +7 -1
- data/lib/plutonium/version.rb +1 -1
- data/package.json +1 -1
- data/src/css/components.css +10 -10
- metadata +2 -2
|
@@ -4,7 +4,7 @@ How a wizard binds to an existing record (anchoring), how a running wizard is id
|
|
|
4
4
|
|
|
5
5
|
## Anchoring
|
|
6
6
|
|
|
7
|
-
An **anchored** wizard runs against an existing record
|
|
7
|
+
An **anchored** wizard runs against an existing record, the analogue of `attribute :resource` on an interaction. The anchor is read-only context, available from any step (and `condition:`/`on_submit`/`execute`) via the `anchor` accessor.
|
|
8
8
|
|
|
9
9
|
```ruby
|
|
10
10
|
class ConfigureCompanyWizard < Plutonium::Wizard::Base
|
|
@@ -28,12 +28,12 @@ end
|
|
|
28
28
|
| Declaration | Meaning |
|
|
29
29
|
|---|---|
|
|
30
30
|
| `anchored with: Company` | A single concrete type. |
|
|
31
|
-
| `anchored with: [Company, Organization]` | Polymorphic
|
|
32
|
-
| `anchored` (no `with:`) | Generic
|
|
33
|
-
| *(omit `anchored`)* | No anchor
|
|
31
|
+
| `anchored with: [Company, Organization]` | Polymorphic, accepts any listed type. |
|
|
32
|
+
| `anchored` (no `with:`) | Generic, the type binds at registration to whichever resource hosts it (shareable library wizard). |
|
|
33
|
+
| *(omit `anchored`)* | No anchor, a pure data → create flow. |
|
|
34
34
|
|
|
35
35
|
```ruby
|
|
36
|
-
# A generic, shareable wizard
|
|
36
|
+
# A generic, shareable wizard: bound to a concrete type at registration.
|
|
37
37
|
class ArchiveWithReasonWizard < Plutonium::Wizard::Base
|
|
38
38
|
anchored
|
|
39
39
|
|
|
@@ -59,15 +59,15 @@ anchor # => the record, for an anchored wizard
|
|
|
59
59
|
|
|
60
60
|
`anchor` never returns `nil`. Anchored-vs-not is a static property of the wizard, so reaching for `anchor` when the wizard isn't `anchored` is a programming error, not a runtime condition to guard.
|
|
61
61
|
|
|
62
|
-
The anchor is **not** part of `persisted
|
|
62
|
+
The anchor is **not** part of `persisted`; `persisted` holds only records the wizard creates. The anchor is an input the wizard was launched against.
|
|
63
63
|
|
|
64
64
|
### Anchor resolution per surface
|
|
65
65
|
|
|
66
66
|
The wizard body never cares where the anchor came from; the launch surface resolves it:
|
|
67
67
|
|
|
68
|
-
- **Record action** (`wizard :configure, ...` on a definition for a `with:`-anchored wizard)
|
|
69
|
-
- **Context anchor** (`anchored via: :method`)
|
|
70
|
-
- **Collection action / create flow
|
|
68
|
+
- **Record action** (`wizard :configure, ...` on a definition for a `with:`-anchored wizard), auto-mounted as a **member route** (`/companies/:id/wizards/configure/:step`) on the resource controller. The anchor is resolved through that controller's scoped, policy-gated `resource_record!`, never an unscoped `find_by`, so a record outside the portal's authorized scope (or a non-existent id) 404s instead of leaking another tenant's record.
|
|
69
|
+
- **Context anchor** (`anchored via: :method`), mounted **portal-level** with `register_wizard`; the anchor is resolved by calling that method on the controller (e.g. `via: :current_scoped_entity` for the tenant). No URL `:id`, IDOR-safe (trusted context). An optional `with:` type-asserts the result.
|
|
70
|
+
- **Collection action / create flow**: no anchor.
|
|
71
71
|
|
|
72
72
|
::: tip Anchored member routes are IDOR-safe by construction
|
|
73
73
|
Because the anchor comes from `resource_record!` (the same scoped lookup CRUD and interactive record actions use), a `with:`-anchored wizard can only ever operate on a record the current user is authorized to see in this portal. A `via:`-anchored wizard is IDOR-safe by trusting the resolved context.
|
|
@@ -75,7 +75,7 @@ Because the anchor comes from `resource_record!` (the same scoped lookup CRUD an
|
|
|
75
75
|
|
|
76
76
|
## Instance identity
|
|
77
77
|
|
|
78
|
-
Every running wizard has a deterministic **instance key
|
|
78
|
+
Every running wizard has a deterministic **instance key**, a digest the session row is uniquely keyed by. There are two recipes, by identity axis ([see Identity, concurrency & repeatability](/reference/wizard/dsl)):
|
|
79
79
|
|
|
80
80
|
```
|
|
81
81
|
# concurrency_key set:
|
|
@@ -86,11 +86,11 @@ instance_key = SHA256(JSON([secret_key_base, "tokened", wizard, wizard_token]))
|
|
|
86
86
|
|
|
87
87
|
The digest hashes the **JSON of a structured array**, not a flat-joined string, and is **salted with the app's `secret_key_base`**:
|
|
88
88
|
|
|
89
|
-
- **`concurrency_key`** is serialized records → GID, scalars → string, arrays kept as a **nested structure (not joined)
|
|
89
|
+
- **`concurrency_key`** is serialized records → GID, scalars → string, arrays kept as a **nested structure (not joined)**, so two distinct keys can never collide into one row (`["a", "b"]` ≠ `"a|b"`). The **tenant (`current_scoped_entity`) is folded in automatically**, so the same user running the same keyed wizard in two tenant portals gets two distinct rows.
|
|
90
90
|
- **The `secret_key_base` salt** makes the digest a MAC over otherwise-public identifiers (the wizard name + key GIDs), so a run's existence/state can't be probed by recomputing the digest off-app.
|
|
91
|
-
- **`wizard_token`** is the **per-run id** for runs with no `concurrency_key
|
|
91
|
+
- **`wizard_token`** is the **per-run id** for runs with no `concurrency_key`: a fresh, unguessable token per launch makes each run distinct and repeatable. Its source depends on the run identity: an **authenticated** repeatable run carries it in the URL `:token` segment (guarded by [owner-scoping](#authentication)); a **guest (`anonymous`)** run keys off the **Rails session** (never the URL), so there is no leak surface. It is **not** a pre-auth principal that survives login, and a wizard never crosses the auth boundary mid-flow.
|
|
92
92
|
|
|
93
|
-
The owner, anchor, and scope are also stored as plain polymorphic columns (`owner_type`/`owner_id`, etc.) for listing and querying
|
|
93
|
+
The owner, anchor, and scope are also stored as plain polymorphic columns (`owner_type`/`owner_id`, etc.) for listing and querying, but identity is the digest.
|
|
94
94
|
|
|
95
95
|
::: warning Rotating `secret_key_base` invalidates in-progress runs
|
|
96
96
|
Because the salt is `secret_key_base`, rotating it changes every instance-key digest. In-progress runs become unresumable (their rows no longer match the recomputed key) and **one-time gates re-open** (the retained `completed` marker no longer matches). This only affects rows live at rotation time; new runs key off the new secret. Drain or accept the reset when rotating.
|
|
@@ -100,7 +100,7 @@ Because the salt is `secret_key_base`, rotating it changes every instance-key di
|
|
|
100
100
|
|
|
101
101
|
A `concurrency_key`-keyed wizard's `in_progress` row **is the lock**: a second launch at the same key resumes it instead of forking. Look up the row by `instance_key`; if one exists, the user continues where they left off. A tokened (no `concurrency_key`) wizard resumes via its per-run id (carried in the URL `:token` segment for an authenticated run, or the Rails session for a guest run) and starts a fresh run otherwise.
|
|
102
102
|
|
|
103
|
-
For a non-`anonymous` (authenticated) wizard, **every resume is owner-scoped**: a row may only be resumed by the user that owns it. A run id leaked in a URL can't be picked up by another logged-in user
|
|
103
|
+
For a non-`anonymous` (authenticated) wizard, **every resume is owner-scoped**: a row may only be resumed by the user that owns it. A run id leaked in a URL can't be picked up by another logged-in user, the engine treats a foreign row as not-found (404). See [Authentication](#authentication).
|
|
104
104
|
|
|
105
105
|
### Listing in-progress wizards
|
|
106
106
|
|
|
@@ -109,39 +109,40 @@ Plutonium::Wizard.in_progress_for(view_context, anchor: nil, wizard: nil)
|
|
|
109
109
|
# → Array<Resume::Entry>, newest-first (delegates to Resume.entries_for)
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
Like interactions, it takes the `view_context` and derives everything from it
|
|
112
|
+
Like interactions, it takes the `view_context` and derives everything from it (the run **owner** (`current_user`), the **tenant scope** (`current_scoped_entity` when `scoped_to_entity?`, else `nil`), and the **portal**) returning that owner's in-progress runs **for the current portal**.
|
|
113
113
|
|
|
114
114
|
**Each `entry` exposes:** `label`, `icon`, `current_step` (+ `current_step_label`), `updated_at`, `resume_url` (or `nil`), `resume_unresolved_reason`, `wizard_class`, and the raw `session` row.
|
|
115
115
|
|
|
116
|
-
**Portal scoping.** A run is only ever listed (and linked) by the portal it was launched in
|
|
116
|
+
**Portal scoping.** A run is only ever listed (and linked) by the portal it was launched in; a non-scoped portal lists only unscoped runs; a scoped portal narrows to the current tenant. Two portals can share an entity scope, so the launching portal (the `engine` column) is recorded per-run because scope alone can't identify it.
|
|
117
117
|
|
|
118
118
|
**`resume_url`** is built through the current portal's routes:
|
|
119
119
|
- `wizard`-macro **anchored** mount → `resource_url_for(record, wizard:, step:)`.
|
|
120
120
|
- `register_wizard` mount → the named route (with the scope segment, and the `:token` for tokened runs).
|
|
121
121
|
- Unresolvable here (e.g. a non-anchored `wizard`-macro run, whose resource identity isn't on the row) → `resume_url: nil` + a `resume_unresolved_reason` string. Render those without a link rather than guessing.
|
|
122
122
|
|
|
123
|
-
**`anchor:` / `wizard:` filters** (for a per-record resume widget
|
|
124
|
-
- They narrow **in the query, before enrichment**, so discarded rows are never resume-URL-resolved or anchor-loaded
|
|
123
|
+
**`anchor:` / `wizard:` filters** (for a per-record resume widget, "does this record have an unfinished draft of wizard X?"):
|
|
124
|
+
- They narrow **in the query, before enrichment**, so discarded rows are never resume-URL-resolved or anchor-loaded, which is cheaper than filtering the returned array.
|
|
125
125
|
- They compose, and the `wizard + anchor` pair is index-covered: `in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: record).first`. `wizard:` takes the wizard **class**.
|
|
126
|
-
-
|
|
126
|
+
- Enrichment is the costly part: every returned row gets its resume and cancel URLs resolved and its anchor loaded. Fetching the full list and then `select { |e| e.wizard_class == X }` pays that cost for every draft you throw away, so pass `wizard:` instead. Avoid filtering on `e.session.anchor` (a polymorphic load per row); use `anchor:`.
|
|
127
|
+
- The one case for post-filtering: the **same render** already calls the unfiltered `in_progress_for` (a full "continue where you left off" list plus a per-wizard callout on one page). Those entries are already enriched, so `select` on `e.wizard_class` is free, while a second `wizard:` call would query and enrich those rows again.
|
|
127
128
|
|
|
128
129
|
See the [guide](/guides/wizards#listing-in-progress-wizards) for a worked dashboard example.
|
|
129
130
|
|
|
130
131
|
### The implied anchored key
|
|
131
132
|
|
|
132
|
-
An `anchored` wizard with **no explicit `concurrency_key`** is keyed by default
|
|
133
|
+
An `anchored` wizard with **no explicit `concurrency_key`** is keyed by default, `{ [anchor, current_user] }`, with the tenant folded in. So an anchored wizard, out of the box, is **one in-progress draft per user per record**: re-launching it for the same record resumes your draft, and two users editing the same record get **independent** runs (no collision).
|
|
133
134
|
|
|
134
|
-
This is the right default because anchoring without keying is a footgun in both directions: a *tokened* anchored wizard forks a new run on every launch, while `{ anchor }` (record-only) keys across users
|
|
135
|
+
This is the right default because anchoring without keying is a footgun in both directions: a *tokened* anchored wizard forks a new run on every launch, while `{ anchor }` (record-only) keys across users, so a second user editing the same record collides with the first and is owner-scoped out (a 404). The implied key threads the needle. The anchor's GlobalID is already globally unique (and pins the tenant for a tenant-scoped record), so `[anchor, current_user]` is the full identity; the auto-folded tenant is redundant there but load-bearing for non-anchor keys.
|
|
135
136
|
|
|
136
137
|
Override when you want different semantics:
|
|
137
|
-
- `concurrency_key { anchor }
|
|
138
|
-
- `concurrency_key { wizard_token }
|
|
138
|
+
- `concurrency_key { anchor }`: a true singleton: **one run per record, any user** ("configure this once, by anyone"). A concurrent second user is blocked (owner-scoped) until the first finishes.
|
|
139
|
+
- `concurrency_key { wizard_token }`: make the anchored wizard **repeatable** (a fresh run per launch).
|
|
139
140
|
|
|
140
|
-
Anonymous (guest) anchored wizards are exempt
|
|
141
|
+
Anonymous (guest) anchored wizards are exempt: a guest has no real user to key by, so they stay session-tokened.
|
|
141
142
|
|
|
142
143
|
### Relaunching a tokened wizard
|
|
143
144
|
|
|
144
|
-
A keyed wizard auto-resumes (its keyed row is the lock), so a bare launch always continues the single in-progress run. A **tokened** wizard has no such single run
|
|
145
|
+
A keyed wizard auto-resumes (its keyed row is the lock), so a bare launch always continues the single in-progress run. A **tokened** wizard has no such single run: each bare launch could mint a fresh one. By default it doesn't silently fork: it prompts. Opt out for flows that should always start clean:
|
|
145
146
|
|
|
146
147
|
```ruby
|
|
147
148
|
on_relaunch :new
|
|
@@ -149,13 +150,13 @@ on_relaunch :new
|
|
|
149
150
|
|
|
150
151
|
With the default (`on_relaunch :prompt`), a bare launch (e.g. `GET /onboarding`) checks the user's pending runs (owner- and tenant-scoped, via the same listing as above). If any exist, it renders a **"resume or start new" page** (each pending run with a Resume link, plus a **Start new** button) instead of silently discarding that in-progress work. With no pending runs it starts fresh as usual, and **Start new** (the bare launch URL with `?new=1`) always forces a fresh run. Because the chooser only appears when a pending run exists, `:prompt` is a safe superset of `:new`. Use `on_relaunch :new` to opt out (always fork a fresh run) for flows meant to be run repeatedly from scratch.
|
|
151
152
|
|
|
152
|
-
This only applies to authenticated tokened wizards: keyed wizards already auto-resume, and `anonymous` (guest) runs are session-keyed to a single run
|
|
153
|
+
This only applies to authenticated tokened wizards: keyed wizards already auto-resume, and `anonymous` (guest) runs are session-keyed to a single run; `on_relaunch` is a no-op for both.
|
|
153
154
|
|
|
154
155
|
On resume the engine:
|
|
155
156
|
|
|
156
157
|
- Restores the step cursor and `data` (typed snapshot rehydrated from the JSON column).
|
|
157
|
-
- Re-renders the current step's form seeded from staged `data
|
|
158
|
-
- Lazily rehydrates `persisted[:key]` from stored GlobalIDs on first access (memoized per request), so a per-step `on_submit` create flow returning later still sees records made by earlier steps
|
|
158
|
+
- Re-renders the current step's form seeded from staged `data`, including repeater rows (a `structured_input ..., repeat:` step re-renders the right number of filled rows, not one blank row).
|
|
159
|
+
- Lazily rehydrates `persisted[:key]` from stored GlobalIDs on first access (memoized per request), so a per-step `on_submit` create flow returning later still sees records made by earlier steps, without paying a `GlobalID.locate` on requests that never read `persisted`.
|
|
159
160
|
|
|
160
161
|
Navigation never loses data: **Back** moves the cursor without validating and never discards `data`. A step whose answer is later **un-chosen** (its `condition:` flips false) leaves the visible path and is **fully pruned**: its staged `data` is dropped, and if its `on_submit` had **persisted records** (save-as-you-go) those records are **rolled back**: its `on_rollback` runs first if declared (additive side-effect cleanup), then the engine **always** destroys them, so nothing is orphaned. The step's `persisted` / `data` / `visited` state is cleared, so re-entering that branch re-runs its `on_submit` from scratch. Pruning fires as soon as the branch is hidden (during the advance that flips it) and again as a safety net at finalize.
|
|
161
162
|
|
|
@@ -179,7 +180,7 @@ class GuestSignupWizard < Plutonium::Wizard::Base
|
|
|
179
180
|
|
|
180
181
|
def execute # the ONE boundary a guest wizard may cross
|
|
181
182
|
account = Account.create!(email: data.account.email)
|
|
182
|
-
# sign the account in here (the host calls Rodauth)
|
|
183
|
+
# sign the account in here (the host calls Rodauth), no special framework handling
|
|
183
184
|
succeed(account)
|
|
184
185
|
end
|
|
185
186
|
end
|
|
@@ -189,6 +190,6 @@ An `anonymous` wizard must be [mounted on a public route](/reference/wizard/regi
|
|
|
189
190
|
|
|
190
191
|
## Related
|
|
191
192
|
|
|
192
|
-
- [DSL reference](/reference/wizard/dsl)
|
|
193
|
-
- [Storage & config](/reference/wizard/storage-config)
|
|
194
|
-
- [One-time wizards](/reference/wizard/one-time)
|
|
193
|
+
- [DSL reference](/reference/wizard/dsl): `anchored`, `anchor`, `persisted`.
|
|
194
|
+
- [Storage & config](/reference/wizard/storage-config): the session table + columns.
|
|
195
|
+
- [One-time wizards](/reference/wizard/one-time): durable completion + the gate.
|
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
# Wizard DSL
|
|
2
2
|
|
|
3
3
|
::: warning Experimental
|
|
4
|
-
Wizards are experimental
|
|
4
|
+
Wizards are experimental, the DSL and behavior may change in a future release.
|
|
5
5
|
:::
|
|
6
6
|
|
|
7
|
-
A wizard is a Ruby class
|
|
7
|
+
A wizard is a Ruby class, `class X < Plutonium::Wizard::Base`. It declares ordered `step`s, an optional terminal `review` step, wizard-level options, and an `execute` commit hook. This page is the full reference for the author-facing DSL.
|
|
8
8
|
|
|
9
9
|
For task-oriented walkthroughs, start with the [Wizards guide](/guides/wizards).
|
|
10
10
|
|
|
11
11
|
## 🚨 Critical
|
|
12
12
|
|
|
13
|
-
- **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a raised exception
|
|
13
|
+
- **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a raised exception, a non-bang `false` return advances the wizard and silently loses data.
|
|
14
14
|
- **`condition:` lambdas must be nil-safe.** They run against the typed `data` snapshot at every transition, including before their deciding step is filled (`nil`).
|
|
15
15
|
- **`review` must be the last step.** Declaring a step after `review` raises at load time.
|
|
16
|
-
- **`using:` targets a model only
|
|
17
|
-
- **`execute` returns an Outcome
|
|
16
|
+
- **`using:` targets a model only**: not an interaction, not a bare definition.
|
|
17
|
+
- **`execute` returns an Outcome**: `succeed(...)` / `failed(...)`, or raise to fail.
|
|
18
18
|
|
|
19
19
|
## Wizard-level macros
|
|
20
20
|
|
|
21
21
|
| Macro | Meaning |
|
|
22
22
|
|---|---|
|
|
23
23
|
| `presents label:, icon:, description:` | The launch button's label + icon (same as interactions), plus an optional `description:` rendered as the wizard's header subheading. |
|
|
24
|
-
| `navigation :linear \| :free` | Stepper jump policy. `:linear` (default)
|
|
24
|
+
| `navigation :linear \| :free` | Stepper jump policy. `:linear` (default): back to any visited step; `:free`: any visible visited step. Forward jumps to unvisited steps are never allowed. |
|
|
25
25
|
| `stepper false` | Hide the top rail (the step indicator). On by default. |
|
|
26
26
|
| `on_relaunch :new` | Controls a bare relaunch of a **tokened** wizard when the user has pending (in-progress) runs. Default `:prompt` shows a "resume or start new" chooser instead of silently forking; `:new` opts out and always mints a fresh run. No-op for keyed/`anonymous` wizards (they already auto-resume their single run). See [Anchoring & resume](/reference/wizard/anchoring-resume#relaunching-a-tokened-wizard). |
|
|
27
27
|
| `anchored with: Model` / `anchored via: :method` | Run against an existing record; read via `anchor`. `with:` resolves from the URL `:id` (resource-mounted); `via:` resolves by calling a controller method (portal-level, context-anchored). See [Anchoring & resume](/reference/wizard/anchoring-resume). |
|
|
28
28
|
| `cleanup_after <ttl> \| :never` | Idle TTL before the abandonment sweep reaps a session and rolls back its tracked records. Defaults to `config.wizards.cleanup_after`. `:never` opts out. |
|
|
29
|
-
| `concurrency_key { … }` / `concurrency_key :method` | Key a run by the returned value(s) (records → GID, scalars → string, arrays serialized element-wise
|
|
29
|
+
| `concurrency_key { … }` / `concurrency_key :method` | Key a run by the returned value(s) (records → GID, scalars → string, arrays serialized element-wise, structured, **not** flat-joined; the tenant is folded in automatically). The keyed `in_progress` row is the lock, a second launch at the same key resumes, never forks. Omit → unlimited concurrent `wizard_token`-keyed runs, **except** an `anchored` wizard, which defaults to `{ [anchor, current_user] }` (one draft per user per record). See [Anchoring & resume](/reference/wizard/anchoring-resume#the-implied-anchored-key). |
|
|
30
30
|
| `one_time` | Retain the completed row at the `concurrency_key` (blocks restart, gate-able). **Requires a `concurrency_key`.** Omit → row deleted on completion (repeatable). See [One-time wizards](/reference/wizard/one-time). |
|
|
31
31
|
| `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). See [`completed`](#completed) below and [One-time wizards](/reference/wizard/one-time#re-opening-a-completed-wizard). |
|
|
32
|
-
| `encrypt_data` | Encrypt the staged `data` column at rest using ActiveRecord's encryption keys (off by default), for flows that stage PII. Requires `active_record.encryption` keys
|
|
33
|
-
| `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full`. Inherits to subclasses; otherwise `config.wizards.width` (default `:md`). Deliberately independent of `config.default_page_width
|
|
34
|
-
| `anonymous` | Opt into **guest (unauthenticated) access
|
|
32
|
+
| `encrypt_data` | Encrypt the staged `data` column at rest using ActiveRecord's encryption keys (off by default), for flows that stage PII. Requires `active_record.encryption` keys, see [Storage & config](/reference/wizard/storage-config#encryption). |
|
|
33
|
+
| `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full`. Inherits to subclasses; otherwise `config.wizards.width` (default `:md`). Deliberately independent of `config.default_page_width`, see [Storage & config](/reference/wizard/storage-config). |
|
|
34
|
+
| `anonymous` | Opt into **guest (unauthenticated) access**: the wizard runs pre-login (auth is required otherwise). The guest's identity is a server-minted run-id in the Rails session; it crosses the auth boundary only at its terminal `execute`. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`**: a guest is already session-keyed and repeatable, so declaring both raises (whichever is declared last). See [Authentication](/reference/wizard/anchoring-resume#authentication). |
|
|
35
35
|
|
|
36
36
|
```ruby
|
|
37
37
|
class CompanyOnboardingWizard < Plutonium::Wizard::Base
|
|
@@ -76,17 +76,17 @@ The block is optional only when `using:` supplies everything.
|
|
|
76
76
|
|
|
77
77
|
A step's block is the same field DSL used on definitions and interactions:
|
|
78
78
|
|
|
79
|
-
- `attribute :name, :type
|
|
80
|
-
- `input :name, as:,
|
|
81
|
-
- `validates :name,
|
|
82
|
-
- `structured_input :name, repeat: N do |f| ... end
|
|
83
|
-
- `form_layout do ... end
|
|
79
|
+
- `attribute :name, :type`: declares a typed attribute (feeds the `data` snapshot).
|
|
80
|
+
- `input :name, as:, ...`: how the field renders.
|
|
81
|
+
- `validates :name, ...`: ActiveModel validations, run on Next. These also drive the form's field affordances exactly like a resource form: a `presence` validation renders the required marker (`*`), and `length`/`numericality`/`format`/`inclusion` feed `maxlength`/`min`/`max`/`pattern`/auto-choices. Validations imported via `using:` surface these too.
|
|
82
|
+
- `structured_input :name, repeat: N do |f| ... end`: a repeatable/structured group → `data.<step>.name` is an array of typed sub-objects. The sub-fields can come from the block (above), or from a model via `using:` / `fields:` (same selectors as a step's `using:`) instead of a block.
|
|
83
|
+
- `form_layout do ... end`: section the step's fields (`section`, `columns:`, `collapsible:`, etc.), scoped to this step.
|
|
84
84
|
|
|
85
85
|
See [plutonium-resource › Definition](/reference/resource/definition) for the full field/input/layout vocabulary.
|
|
86
86
|
|
|
87
87
|
#### Runtime input options
|
|
88
88
|
|
|
89
|
-
A step's fields are fixed when the class loads, so a **proc** is how an option depends on the run. Proc-valued field/input options are resolved on every render, and the proc **takes the form
|
|
89
|
+
A step's fields are fixed when the class loads, so a **proc** is how an option depends on the run. Proc-valued field/input options are resolved on every render, and the proc **takes the form**: `form.wizard` is the run, `form.object` is that step's staged data:
|
|
90
90
|
|
|
91
91
|
```ruby
|
|
92
92
|
step :plan do
|
|
@@ -104,15 +104,15 @@ input :answers, as: MyManifestComponent, config: ->(form) { form.wizard.anchor.m
|
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
::: warning Take the argument
|
|
107
|
-
A **zero-argument** proc here will not work. It keeps its own binding
|
|
107
|
+
A **zero-argument** proc here will not work. It keeps its own binding (the same rule as [everywhere else](/reference/resource/definition#options-that-vary-per-render)), and a step block is `instance_exec`'d against an internal field recorder when the class loads, so `-> { anchor.available_tiers }` looks `anchor` up on that recorder and raises `NameError`.
|
|
108
108
|
|
|
109
109
|
That is deliberate: it is the same trade a `form_layout` section option makes, and it means an `input` line keeps its meaning when moved between a definition and a step. Nothing silently swaps `self`.
|
|
110
110
|
:::
|
|
111
111
|
|
|
112
112
|
Two limits worth knowing:
|
|
113
113
|
|
|
114
|
-
- The **field set** is still fixed at class load. A proc varies an option, not which fields exist. To collect a shape known only at runtime, declare one `structured_input` and let a custom component render the inner controls. For bespoke markup, pass a block to `input` instead
|
|
115
|
-
- `condition:` is **not** resolved this way, because it is not an option
|
|
114
|
+
- The **field set** is still fixed at class load. A proc varies an option, not which fields exist. To collect a shape known only at runtime, declare one `structured_input` and let a custom component render the inner controls. For bespoke markup, pass a block to `input` instead, it renders in the form's context with the field yielded.
|
|
115
|
+
- `condition:` is **not** resolved this way, because it is not an option (it asks "should this render here, now?", so it always runs *against* the thing doing the rendering and reads it with no argument. A *step's* `condition:` runs against the **wizard** (it is evaluated in the runner to decide which steps exist, before any form is built) which is exactly why it can't be handed a form); a *field's* runs against the **form**, where `object` is that step's staged data. See [Definition › `condition:` is not an option](/reference/resource/definition#condition-is-not-an-option).
|
|
116
116
|
|
|
117
117
|
### `using:` a model
|
|
118
118
|
|
|
@@ -132,7 +132,7 @@ What gets imported:
|
|
|
132
132
|
| Source | Imported |
|
|
133
133
|
|---|---|
|
|
134
134
|
| `Model.attribute_names` / `attribute_types` | The field universe + cast types. |
|
|
135
|
-
| `<Model>Definition` (auto-resolved) | Input styling (`as:`, options, labels). Best-effort
|
|
135
|
+
| `<Model>Definition` (auto-resolved) | Input styling (`as:`, options, labels). Best-effort, no definition is fine. |
|
|
136
136
|
| Transient `Model.new(slice).valid?` | Validations, keeping errors on imported fields + `:base`. |
|
|
137
137
|
| `<Model>Definition#form_layout` | Section layout, filtered to imported fields. |
|
|
138
138
|
|
|
@@ -144,10 +144,10 @@ What gets imported:
|
|
|
144
144
|
| `layout: false` | Skip inherited `form_layout` (default single grid). |
|
|
145
145
|
| `validation_context:` | Run `valid?(context)` for context-scoped model validations. |
|
|
146
146
|
|
|
147
|
-
**Declaration reuse only
|
|
147
|
+
**Declaration reuse only**: `using:` never pulls in the model's persistence or callbacks. Data stages into `data`; your `execute`/`on_submit` does the writes.
|
|
148
148
|
|
|
149
149
|
::: tip Why a model, not a definition
|
|
150
|
-
A `Plutonium::Resource::Definition` carries no link to its model
|
|
150
|
+
A `Plutonium::Resource::Definition` carries no link to its model; the controller binds them at request time. The only reliable direction is **model → definition**, so the model is the reuse target, and `<Model>Definition` is auto-resolved from it for styling.
|
|
151
151
|
:::
|
|
152
152
|
|
|
153
153
|
### Attachment fields
|
|
@@ -162,31 +162,31 @@ step :photo, label: "Photo" do
|
|
|
162
162
|
end
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
-
`data` is JSON staged across requests, so a file can't ride along
|
|
165
|
+
`data` is JSON staged across requests, so a file can't ride along: only its backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data). `execute` assigns the token to the model's attachment natively (`model.photo.attach(data.photo.photo)` for AS, `model.update!(photo: data.photo.photo)` for active_shrine). The review summary and the input preview (on Back/resume) resolve the token to a displayable attachment automatically.
|
|
166
166
|
|
|
167
167
|
| | Declare | Behaviour |
|
|
168
168
|
|---|---|---|
|
|
169
169
|
| **Server-side** (default) | `as: :file` | file submitted with the step; the wizard uploads it to the backend cache while staging. AS *and* active_shrine. |
|
|
170
170
|
| **Direct upload** | `as: :uppy, direct_upload: true, endpoint:` | browser uploads to the endpoint, posts a token (async UI). |
|
|
171
171
|
| **Backend** (server-side) | `backend: :active_storage` / `:shrine` | defaults to `config.wizards.attachment_backend` (auto-detects active_shrine, else AS). **Must match the model** `execute` assigns to. |
|
|
172
|
-
| **Uploader** (Shrine only) | `uploader: PhotoUploader` | cache the file through a specific Shrine uploader (its cache-stage plugins
|
|
172
|
+
| **Uploader** (Shrine only) | `uploader: PhotoUploader` | cache the file through a specific Shrine uploader (its cache-stage plugins, mime/dimension extraction, `generate_location`, processing, run instead of base `Shrine`'s). The minted token stays uploader-agnostic, so display + promotion are unaffected. Accepts a class or a class-name string; raises for the AS backend. Server-side staging only (direct upload configures the uploader at its endpoint). Its **validations are enforced on the step**, see the note below. |
|
|
173
173
|
| **Multiple** | array attribute + `multiple: true` | staged value is an array of tokens. |
|
|
174
174
|
|
|
175
175
|
::: tip Uploader validations are enforced on the step
|
|
176
|
-
A Shrine file field is validated **on its step** against the field's **effective uploader
|
|
176
|
+
A Shrine file field is validated **on its step** against the field's **effective uploader**, its `uploader:` if given, else base `Shrine` (whichever carries the `Attacher.validate` rules). A file that violates them is rejected right there (a field error + re-render), exactly like a `validates`, *not* deferred to `execute`. Mechanically: `Uploader.upload` caches the file (running no validations), then the step's validation pass runs the attacher's validations against the staged token. This needs Shrine's optional `validation`/`validation_helpers` plugin, without it there's nothing to enforce and it's a clean no-op. ActiveStorage fields are likewise unaffected.
|
|
177
177
|
:::
|
|
178
178
|
|
|
179
179
|
## Per-step hooks
|
|
180
180
|
|
|
181
|
-
`execute` is the default commit point (atomic, at the end). Per-step `on_submit` is opt-in save-as-you-go
|
|
181
|
+
`execute` is the default commit point (atomic, at the end). Per-step `on_submit` is opt-in save-as-you-go, use it only when a real record must exist mid-flow.
|
|
182
182
|
|
|
183
183
|
### `on_submit`
|
|
184
184
|
|
|
185
185
|
Runs in its own transaction when the step completes (after its fields validate). Inside it:
|
|
186
186
|
|
|
187
|
-
- `persist record` (or a list)
|
|
188
|
-
- `fail!("message")
|
|
189
|
-
- `fail!(:field, "message")
|
|
187
|
+
- `persist record` (or a list), register record(s) the engine tracks for resume + cleanup → `persisted[:step_key]`.
|
|
188
|
+
- `fail!("message")`: abort with a base (form-level) error.
|
|
189
|
+
- `fail!(:field, "message")`: abort with a field-level error.
|
|
190
190
|
|
|
191
191
|
```ruby
|
|
192
192
|
on_submit do
|
|
@@ -232,13 +232,13 @@ What it renders depends on completion state and the `summary:` / block options:
|
|
|
232
232
|
|---|---|
|
|
233
233
|
| `label:` | The review step's label (default `"Review"`). |
|
|
234
234
|
| `description:` | Optional sub-label under the review heading. |
|
|
235
|
-
| `summary:` | Show the auto-summary of completed steps (default `true`). When `false`, the complete-state body is your block
|
|
235
|
+
| `summary:` | Show the auto-summary of completed steps (default `true`). When `false`, the complete-state body is your block, or the built-in "ready to complete" panel if there's no block. The summary always renders in the incomplete state. |
|
|
236
236
|
| `header:` | Show the step-header section (the label plus the "check everything over" prompt, which only appears when the summary is shown) above the body (default `true`). `false` drops it for a chromeless finish. |
|
|
237
237
|
|
|
238
238
|
The auto-summary renders each field through the display pipeline, honoring the input's declared `as:`/`label:`. An input hidden by its own `condition:` is left out, just as it was left off the step, so the summary never shows an empty row for a question the user wasn't asked:
|
|
239
239
|
|
|
240
240
|
- a **choice input** (`select`/`radio_buttons` with `choices:`) resolves the stored value back to its label (`"pro"` → `"Pro"`) using the same choice mapper the form uses, so the recap matches what the user picked;
|
|
241
|
-
- an **`as: :currency`** input formats the value as currency (`"1500.5"` → `"$1,500.50"`) rather than echoing a bare decimal
|
|
241
|
+
- an **`as: :currency`** input formats the value as currency (`"1500.5"` → `"$1,500.50"`) rather than echoing a bare decimal, pass `unit:` on the input (the data snapshot has no `has_cents` reflection to infer it from).
|
|
242
242
|
|
|
243
243
|
```ruby
|
|
244
244
|
review label: "Review & submit" # auto-summary + gated finish
|
|
@@ -256,10 +256,10 @@ review summary: false, header: false # fully chromeless → "re
|
|
|
256
256
|
|
|
257
257
|
### The custom block's render context
|
|
258
258
|
|
|
259
|
-
The block runs **in the Phlex view context** (`self` is the rendering component), not the controller
|
|
259
|
+
The block runs **in the Phlex view context** (`self` is the rendering component), not the controller; that's what lets it emit markup. So you can:
|
|
260
260
|
|
|
261
|
-
- **return a String** (the simplest case)
|
|
262
|
-
- **emit Phlex** directly
|
|
261
|
+
- **return a String** (the simplest case); it renders as the block's text;
|
|
262
|
+
- **emit Phlex** directly, `div`, `span`, `plain`, `render SomeComponent.new(...)`;
|
|
263
263
|
- reach **view / route helpers** via `helpers.*` (e.g. `helpers.link_to`, `helpers.current_user`, a path helper).
|
|
264
264
|
|
|
265
265
|
The block is **yielded the wizard**, so `wizard.data`, `wizard.anchor`, `wizard.persisted`, and `wizard.current_user` are all in hand.
|
|
@@ -269,7 +269,7 @@ review label: "Review & submit" do |wizard|
|
|
|
269
269
|
div(class: "text-sm") do
|
|
270
270
|
plain "Billing to "
|
|
271
271
|
strong { wizard.data.company.name }
|
|
272
|
-
plain "
|
|
272
|
+
plain ", "
|
|
273
273
|
plain helpers.link_to("see our terms", helpers.terms_path)
|
|
274
274
|
end
|
|
275
275
|
end
|
|
@@ -285,9 +285,9 @@ completed do |wizard|
|
|
|
285
285
|
end
|
|
286
286
|
```
|
|
287
287
|
|
|
288
|
-
A custom body for the **"already completed" page
|
|
288
|
+
A custom body for the **"already completed" page**: what a finished [one-time wizard](/reference/wizard/one-time#re-opening-a-completed-wizard) shows when a user re-opens it. On completion a one-time wizard retains its row but clears the `data`, so there's nothing to review; re-entry renders this standalone page instead of re-running the flow. Only meaningful for one-time wizards (repeatable ones leave no completed row, so re-launching just starts fresh).
|
|
289
289
|
|
|
290
|
-
Without `completed`, a built-in confirmation renders (a success badge, the wizard's label, a short message, and a Continue button out). The block **replaces that body entirely
|
|
290
|
+
Without `completed`, a built-in confirmation renders (a success badge, the wizard's label, a short message, and a Continue button out). The block **replaces that body entirely**: you supply your own content (and your own way out):
|
|
291
291
|
|
|
292
292
|
```ruby
|
|
293
293
|
class WelcomeWizard < Plutonium::Wizard::Base
|
|
@@ -315,13 +315,13 @@ def execute
|
|
|
315
315
|
end
|
|
316
316
|
```
|
|
317
317
|
|
|
318
|
-
- Returns a `succeed(value)` / `failed(errors)` Outcome (the same Outcome interactions use
|
|
318
|
+
- Returns a `succeed(value)` / `failed(errors)` Outcome (the same Outcome interactions use, `.with_message`, `.with_redirect_response`, etc. all work).
|
|
319
319
|
- **Use bang methods** so a failure raises. The engine catches `ActiveRecord::RecordInvalid` (→ field errors) and `Plutonium::Wizard::StepError` (→ base error via `fail!`); any other error re-raises as a 500.
|
|
320
320
|
- On success the wizard marks the session completed, clears `data`/`persisted`, and redirects (PRG) so a back-button replay can't re-run `execute`.
|
|
321
321
|
|
|
322
|
-
## Entry authorization
|
|
322
|
+
## Entry authorization: `authorize?`
|
|
323
323
|
|
|
324
|
-
A portal-
|
|
324
|
+
A `register_wizard` wizard (portal or main-app) has no resource policy, so gate entry by defining an `authorize?` instance method. The controller checks it before each request; a falsy return → `ActionPolicy::Unauthorized` (403). The default is `def authorize? = true`, so define it on every `register_wizard` wizard, including gated `one_time` ones.
|
|
325
325
|
|
|
326
326
|
```ruby
|
|
327
327
|
def authorize?
|
|
@@ -330,7 +330,7 @@ end
|
|
|
330
330
|
```
|
|
331
331
|
|
|
332
332
|
::: warning As-built: `authorize?` is an instance method
|
|
333
|
-
Define `def authorize?` on the wizard. (Resource-attached wizards instead use their action's policy predicate
|
|
333
|
+
Define `def authorize?` on the wizard. (Resource-attached wizards instead use their action's policy predicate, see [Registration & launch](/reference/wizard/registration-launch).)
|
|
334
334
|
:::
|
|
335
335
|
|
|
336
336
|
## Accessors
|
|
@@ -339,12 +339,12 @@ Available inside steps, `condition:`, `on_submit`, `on_rollback`, and `execute`:
|
|
|
339
339
|
|
|
340
340
|
| Accessor | Returns |
|
|
341
341
|
|---|---|
|
|
342
|
-
| `data` | Typed, dot-accessible snapshot of everything entered so far, **step-keyed
|
|
342
|
+
| `data` | Typed, dot-accessible snapshot of everything entered so far, **step-keyed**: read a field through its owning step: `data.<step>.<field>`. Read-only; not-yet-collected fields read as `nil` or their `default:`. Each step has its own sub-object, so two steps may declare the same field name without colliding. |
|
|
343
343
|
| `data.<step>.<field>` | The cast value (real Boolean/Integer/Date, not raw string). `data.<step>.<structured>` → array of typed sub-objects. An unknown step key reads as `nil`. |
|
|
344
344
|
| `anchor` | The record the wizard was launched against. Raises `NotAnchoredError` if the wizard isn't `anchored`. |
|
|
345
345
|
| `persisted[:step_key]` | Record(s) a per-step `on_submit` registered via `persist`. Lazily rehydrated on first access (located from stored GlobalIDs the first time you read the key, memoized thereafter). |
|
|
346
346
|
|
|
347
|
-
A **proc-valued field/input option** on a step reaches the same accessors through `form.wizard
|
|
347
|
+
A **proc-valued field/input option** on a step reaches the same accessors through `form.wizard`, see [Runtime input options](#runtime-input-options).
|
|
348
348
|
|
|
349
349
|
## Outcome helpers
|
|
350
350
|
|
|
@@ -360,7 +360,7 @@ A **proc-valued field/input option** on a step reaches the same accessors throug
|
|
|
360
360
|
|---|---|
|
|
361
361
|
| `Plutonium::Wizard::NotAnchoredError` | `anchor` called on a non-anchored wizard (also raised when a `via:` anchor resolves to `nil` or the wrong type). |
|
|
362
362
|
| `Plutonium::Wizard::StepError` | Raised by `fail!` (or directly) for a custom, non-AR step failure → maps to a form error. |
|
|
363
|
-
| `Plutonium::Wizard::UnknownWizardError` | A mount's `wizard_class` doesn't resolve to a loaded `Plutonium::Wizard::Base` subclass
|
|
363
|
+
| `Plutonium::Wizard::UnknownWizardError` | A mount's `wizard_class` doesn't resolve to a loaded `Plutonium::Wizard::Base` subclass, a misconfigured mount or a tampered route param. |
|
|
364
364
|
|
|
365
365
|
## Related
|
|
366
366
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# Wizard Reference
|
|
2
2
|
|
|
3
|
-
The wizard subsystem builds **multi-step flows**
|
|
3
|
+
The wizard subsystem builds **multi-step flows** (onboarding, checkout, multi-model create, branching questionnaires) as a single declarative class (`< Plutonium::Wizard::Base`). It orchestrates Plutonium's existing field DSL, form rendering, actions, and policies rather than inventing a parallel stack.
|
|
4
4
|
|
|
5
5
|
For a task-oriented walkthrough, start with the [Wizards guide](/guides/wizards).
|
|
6
6
|
|
|
7
7
|
## In this section
|
|
8
8
|
|
|
9
|
-
- **[DSL](./dsl)
|
|
10
|
-
- **[Anchoring & resume](./anchoring-resume)
|
|
11
|
-
- **[Storage & config](./storage-config)
|
|
12
|
-
- **[Registration & launch](./registration-launch)
|
|
13
|
-
- **[One-time wizards](./one-time)
|
|
9
|
+
- **[DSL](./dsl)**: every author-facing macro and accessor, including `step`, `review`, `using:`, `condition:`, per-step `on_submit`/`persist`/`on_rollback`, `execute`, `data`/`anchor`/`persisted`.
|
|
10
|
+
- **[Anchoring & resume](./anchoring-resume)**: running against an existing record (`anchored` / `anchor`), instance identity, and how a user resumes where they left off.
|
|
11
|
+
- **[Storage & config](./storage-config)**: enabling the subsystem, the `plutonium_wizard_sessions` table, `config.wizards.*`, encryption, and the cleanup `SweepJob`.
|
|
12
|
+
- **[Registration & launch](./registration-launch)**: reaching a user via the `wizard` definition macro and portal-level `register_wizard`.
|
|
13
|
+
- **[One-time wizards](./one-time)**: `concurrency_key` + `one_time` durable completion markers and the `ensure_wizard_completed` gate.
|
|
14
14
|
|
|
15
15
|
## At a glance
|
|
16
16
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# One-time wizards
|
|
2
2
|
|
|
3
|
-
A **one-time** wizard runs once
|
|
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
4
|
|
|
5
5
|
## Declaring `one_time`
|
|
6
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
|
|
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
8
|
|
|
9
9
|
```ruby
|
|
10
10
|
class WelcomeWizard < Plutonium::Wizard::Base
|
|
@@ -28,15 +28,15 @@ end
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
- **Completion** = the instance row reaching `status: completed`, **retained** at the wizard's `instance_key`.
|
|
31
|
-
- **`one_time` requires a `concurrency_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
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
33
|
- On completion, the row is kept as the marker but its `data` / `tracked_records` are nulled out (privacy + size).
|
|
34
34
|
|
|
35
|
-
The completion marker is recorded by the wizard's own finalize, the same `execute` → PRG path as any wizard
|
|
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
36
|
|
|
37
37
|
## Re-opening a completed wizard
|
|
38
38
|
|
|
39
|
-
Navigating back to a finished one-time wizard (its URL, or its bare launch route) doesn't re-run it
|
|
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
40
|
|
|
41
41
|
Supply a [`completed` block](/reference/wizard/dsl#completed) on the wizard to replace that body with your own:
|
|
42
42
|
|
|
@@ -47,9 +47,9 @@ completed do |wizard|
|
|
|
47
47
|
end
|
|
48
48
|
```
|
|
49
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
|
|
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
51
|
|
|
52
|
-
## Gating a controller
|
|
52
|
+
## Gating a controller: `ensure_wizard_completed`
|
|
53
53
|
|
|
54
54
|
The `Plutonium::Wizard::Gate` concern installs a `before_action` that redirects users into the wizard until they complete it.
|
|
55
55
|
|
|
@@ -83,10 +83,10 @@ The gate **recomputes the wizard's `instance_key`** from its `concurrency_key`,
|
|
|
83
83
|
|
|
84
84
|
### Gating an anchored wizard
|
|
85
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
|
|
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
87
|
|
|
88
|
-
- **Automatic** for a `via:`-anchored wizard
|
|
89
|
-
- **Explicit** otherwise
|
|
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
90
|
|
|
91
91
|
```ruby
|
|
92
92
|
ensure_wizard_completed ConfigureWizard, anchor: :current_widget
|
|
@@ -95,10 +95,10 @@ An anchor-keyed wizard (explicit `{ anchor }`, or the [implied anchored key](/re
|
|
|
95
95
|
|
|
96
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
97
|
|
|
98
|
-
A wizard keyed by an anchor is only gateable **where that anchor can be reconstructed
|
|
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
99
|
|
|
100
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`)
|
|
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
102
|
:::
|
|
103
103
|
|
|
104
104
|
## The launch action hides itself once completed
|
|
@@ -112,10 +112,10 @@ end
|
|
|
112
112
|
```
|
|
113
113
|
|
|
114
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
|
|
116
|
-
- **Repeatable** (non-`one_time`) wizards get **no** completion condition
|
|
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
117
|
|
|
118
|
-
A custom `condition:` **composes** with the completion check (they're AND-ed)
|
|
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
119
|
|
|
120
120
|
```ruby
|
|
121
121
|
wizard :onboard, CompanyOnboardingWizard,
|
|
@@ -124,6 +124,6 @@ wizard :onboard, CompanyOnboardingWizard,
|
|
|
124
124
|
|
|
125
125
|
## Related
|
|
126
126
|
|
|
127
|
-
- [DSL reference](/reference/wizard/dsl)
|
|
128
|
-
- [Storage & config](/reference/wizard/storage-config)
|
|
129
|
-
- [Registration & launch](/reference/wizard/registration-launch)
|
|
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.
|