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
|
@@ -1,62 +1,63 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: plutonium-wizard
|
|
3
|
-
description: Use BEFORE building any multi-step Plutonium flow
|
|
3
|
+
description: 'Use BEFORE building any multi-step Plutonium flow: onboarding, checkout, multi-model create, branching questionnaire. Covers the wizard DSL (steps, branching, using:, review, attachment/file-upload fields, per-step on_submit/persist/rollback, execute), anchoring & resume, one-time wizards + gate, registration (wizard macro + register_wizard), guest/anonymous flows, at-rest data encryption, and storage/config. The single source for "how do I build a wizard".'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Plutonium Wizards
|
|
7
7
|
|
|
8
|
-
A wizard is a multi-step flow authored as a single class
|
|
8
|
+
A wizard is a multi-step flow authored as a single class: `class X < Plutonium::Wizard::Base`. It collects typed `data` across ordered `step`s, optionally branches with `condition:`, and commits at the end via `execute`. It reuses the existing field DSL, form rendering, actions, and policies, no parallel stack.
|
|
9
9
|
|
|
10
10
|
For the field/input vocabulary used inside a step, load [[plutonium-resource]]. For the Outcome / `succeed`/`failed` pattern and the Action system wizards register through, load [[plutonium-behavior]].
|
|
11
11
|
|
|
12
12
|
## 🚨 Critical (read first)
|
|
13
13
|
|
|
14
|
-
- **Enable the subsystem first.** `rails g pu:wizards:install` flips `config.wizards.enabled = true` and schedules `SweepJob`; then `rails db:migrate`. (By hand: the flag in `config/initializers/plutonium.rb`.) It's `false` by default
|
|
15
|
-
- **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a **raised exception
|
|
14
|
+
- **Enable the subsystem first.** `rails g pu:wizards:install` flips `config.wizards.enabled = true` and schedules `SweepJob`; then `rails db:migrate`. (By hand: the flag in `config/initializers/plutonium.rb`.) It's `false` by default; without it there's no `plutonium_wizard_sessions` table.
|
|
15
|
+
- **Use bang methods** (`create!`/`update!`/`save!`) in `on_submit` and `execute`. Failure is signalled by a **raised exception**: a non-bang `false` advances the wizard and silently loses data. Or call `fail!("msg")`.
|
|
16
16
|
- **`data` is step-keyed:** `data.<step>.<field>` (e.g. `data.company.name`, `data.plan.plan`). Each step has its own typed sub-object, so two steps may share a field name without colliding. Read a field through its owning step everywhere (`condition:`/`on_submit`/`execute`).
|
|
17
17
|
- **`condition:` lambdas must be nil-safe.** They run against `data` at every transition, including before their deciding step is filled (value is `nil`). `-> { data.plan.plan == "pro" }` ✓; `-> { data.plan.plan.upcase == "PRO" }` raises on nil ✗.
|
|
18
18
|
- **`review` must be the LAST step.** A step declared after `review` raises at load.
|
|
19
|
-
- **`using:` targets a MODEL only
|
|
19
|
+
- **`using:` targets a MODEL only**, not an interaction, not a bare definition. Selectors `fields:`/`only:`/`except:`.
|
|
20
20
|
- **No generator.** Author wizards by hand, like interactions. They live in `app/wizards/`.
|
|
21
|
-
- **A wizard is a presentation object
|
|
22
|
-
- **Wizards are portal- *or* main-app-hosted.** A `register_wizard` mount inside a portal inherits the portal's auth/scoping/layout. A `register_wizard` mount on the **main app** runs standalone
|
|
23
|
-
- **
|
|
21
|
+
- **A wizard is a presentation object**: it's built with `view_context:`, so anything reachable only through `execute`/`on_submit` is reachable only from a wizard run. Logic may *start* there; the **second caller** (a job, an API, a rake task, the console) is the trigger to move it onto the **model**. See [[plutonium-behavior]] › Part 3 › Where the logic goes.
|
|
22
|
+
- **Wizards are portal- *or* main-app-hosted.** A `register_wizard` mount inside a portal inherits the portal's auth/scoping/layout. A `register_wizard` mount on the **main app** runs standalone; for an **authenticated** main-app wizard you MUST define your own `::WizardsController` (include `Plutonium::Wizard::Controller` + your auth concern); the synthesized fallback is **bare (no auth)**. Resource-anchored (`wizard` macro) wizards always run embedded on the resource controller.
|
|
23
|
+
- **Define `def authorize?` on every `register_wizard` wizard** (portal or main-app). Its default is `def authorize? = true`, and a route mount has no resource policy behind it, so without the override any authenticated user of that host can launch it. A gated `one_time` wizard is no exception: the gate forces users *into* the wizard but doesn't decide who may run it. `false` → 403. Resource (`wizard` macro) mounts are also gated by the action policy.
|
|
24
|
+
- **Schedule `SweepJob`**: `pu:wizards:install` does it for you when Solid Queue is in the bundle; otherwise add a periodic job/cron yourself. It reaps abandoned/expired sessions, always good hygiene (stale `in_progress` rows pile up otherwise), and **load-bearing** for `on_submit`/`persist` wizards: it's the only thing that rolls back the partial domain records an abandoned save-as-you-go run leaves behind.
|
|
24
25
|
|
|
25
26
|
---
|
|
26
27
|
|
|
27
|
-
## 🛑 Before you author: confirm the configuration (ASK
|
|
28
|
+
## 🛑 Before you author: confirm the configuration (ASK: don't infer)
|
|
28
29
|
|
|
29
|
-
Wizard configuration is dense and the dimensions **interact
|
|
30
|
+
Wizard configuration is dense and the dimensions **interact**: guess wrong about mounting, anchoring, or run identity and you get a wizard that compiles but misbehaves: it forks a new run on every visit, 404s on resume, leaks across tenants, or can't be gated. A one-line request ("a checkout wizard", "onboarding") does **not** determine these.
|
|
30
31
|
|
|
31
|
-
**STOP and ask the user
|
|
32
|
+
**STOP and ask the user (use `AskUserQuestion`) before writing the class.** Resolve each decision below (skip one only when the user already stated it), then restate the resolved shape in a sentence and confirm:
|
|
32
33
|
|
|
33
|
-
1. **End result
|
|
34
|
+
1. **End result: what does `execute` do?** Create a new record, update an existing one, touch several models, or fire a side effect (email/charge/API)? This drives anchoring *and* persistence.
|
|
34
35
|
2. **Anchored or fresh?** Does it operate on an **existing** record or create something new?
|
|
35
36
|
- existing record from the URL → `anchored with: Model` (resource-mounted member route)
|
|
36
37
|
- existing context (the tenant, the current user) → `anchored via: :current_scoped_entity`
|
|
37
38
|
- brand new → non-anchored
|
|
38
39
|
3. **Mount, host & shell.** A resource action (`wizard` macro), a **portal** entry (`register_wizard` in a portal engine), or a **main-app** entry (`register_wizard` on the app)? Authenticated, or **public/guest pre-login** (`anonymous`, e.g. signup)? For a route mount, what **layout** (`layout: :basic` for a bare screen, `:resource` for the shell, or omit for the host default)? (Resource wizards are always embedded; an authenticated main-app wizard needs an app-defined `::WizardsController`.)
|
|
39
|
-
4. **Run identity.** Resume the user's **one** in-progress run (keyed
|
|
40
|
-
5. **One-time?** Run at most once and keep a completed marker (`one_time`)
|
|
40
|
+
4. **Run identity.** Resume the user's **one** in-progress run (keyed by `concurrency_key`), or start a **fresh** run each launch (tokened/repeatable)? (Anchored wizards default to one run per `[anchor, current_user]`.)
|
|
41
|
+
5. **One-time?** Run at most once and keep a completed marker (`one_time`), e.g. to **gate** a page behind it?
|
|
41
42
|
6. **Persistence model.** Write everything atomically at `execute` (default, simplest), or **save-as-you-go** with per-step `on_submit`/`persist` (then `SweepJob` must be scheduled, and `on_rollback` added for any *uncompensated* side effect)?
|
|
42
43
|
7. **Steps & branching.** Which steps/fields/validations? Any step shown only under a `condition:`?
|
|
43
|
-
8. **Tenancy.** Is the host portal entity-scoped? (The tenant folds into run identity automatically
|
|
44
|
+
8. **Tenancy.** Is the host portal entity-scoped? (The tenant folds into run identity automatically, so don't thread it by hand.)
|
|
44
45
|
|
|
45
46
|
These compound: *anchored ⇒ keyed by default*; *anonymous ⇒ no owner ⇒ tokened*; *one-time ⇒ keyed + gateable*; *save-as-you-go ⇒ SweepJob + rollback*. Surface the implication when you confirm ("public ⇒ guest ⇒ session-keyed, ownerless"). The DSL sections below map each decision to its macro.
|
|
46
47
|
|
|
47
|
-
## ✅ Before you author: verify the ground truth (CHECK
|
|
48
|
+
## ✅ Before you author: verify the ground truth (CHECK: read it, don't ask for it)
|
|
48
49
|
|
|
49
|
-
The ASK gate resolves the *design*; this confirms the app can actually *run* it. You have file access
|
|
50
|
+
The ASK gate resolves the *design*; this confirms the app can actually *run* it. You have file access: inspect these yourself before writing the class (don't ask the user to confirm what you can read):
|
|
50
51
|
|
|
51
52
|
| Check | How | Why it matters |
|
|
52
53
|
|---|---|---|
|
|
53
|
-
| Subsystem enabled | grep `config/initializers/plutonium.rb` for `wizards.enabled = true`; confirm `plutonium_wizard_sessions` exists (`db:migrate`) | **OFF by default
|
|
54
|
+
| Subsystem enabled | grep `config/initializers/plutonium.rb` for `wizards.enabled = true`; confirm `plutonium_wizard_sessions` exists (`db:migrate`) | **OFF by default: without it nothing works** (the #1 gotcha) |
|
|
54
55
|
| Anchor model exists & reachable | Read the model an `anchored` wizard runs against | Missing/unreadable anchor ⇒ 404 / `NotAnchoredError` |
|
|
55
56
|
| Host portal exists & its scoping | Read the portal engine (`scope_to_entity`?) + its real module name | Tenant folds into run identity; a guessed portal name breaks `register_wizard` |
|
|
56
57
|
| Guest-flow prereqs | AR encryption keys if `encrypt_data`; no `concurrency_key`/`one_time` with `anonymous` | First write raises otherwise |
|
|
57
58
|
| `on_submit` ⇒ SweepJob scheduled | grep `config/recurring.yml` for `sweep_abandoned_wizards` | Abandoned mid-flow records pile up forever |
|
|
58
59
|
|
|
59
|
-
**Don't author the class until `config.wizards.enabled` is confirmed and the anchor/target model + portal are read.** Until then, any class you show is provisional
|
|
60
|
+
**Don't author the class until `config.wizards.enabled` is confirmed and the anchor/target model + portal are read.** Until then, any class you show is provisional. Say so; don't present a guessed field/column mapping as final.
|
|
60
61
|
|
|
61
62
|
---
|
|
62
63
|
|
|
@@ -92,13 +93,13 @@ class CompanyOnboardingWizard < Plutonium::Wizard::Base
|
|
|
92
93
|
end
|
|
93
94
|
```
|
|
94
95
|
|
|
95
|
-
- `presents label:/icon:/description
|
|
96
|
-
- A `step :key, label: do ... end` is one screen; the block uses the field DSL ([[plutonium-resource]]). `step` (and `review`) also take an optional `description
|
|
96
|
+
- `presents label:/icon:/description:`: launch button label + icon (same as interactions); the optional `description:` renders as the wizard's header subheading.
|
|
97
|
+
- A `step :key, label: do ... end` is one screen; the block uses the field DSL ([[plutonium-resource]]). `step` (and `review`) also take an optional `description:`: a sub-label under the heading. `label:` defaults to `key.to_s.humanize`.
|
|
97
98
|
- `data.<step>.<field>` reads the **typed** value (cast to declared type) for that step, e.g. `data.company.name`.
|
|
98
|
-
- `review
|
|
99
|
-
- `execute
|
|
99
|
+
- `review`: built-in terminal step: auto-summary + gated Finish. Must be last.
|
|
100
|
+
- `execute`: runs once at the end in one transaction; returns `succeed(...)` / `failed(...)`.
|
|
100
101
|
|
|
101
|
-
⚠️ **`execute` is a presentation boundary, same as an interaction's.** A wizard is built with `view_context:` too, so anything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow
|
|
102
|
+
⚠️ **`execute` is a presentation boundary, same as an interaction's.** A wizard is built with `view_context:` too, so anything reachable only through `execute` is reachable only from a wizard run. Steps and `execute` own the *flow*: which screens, in what order, writing what. What the write **means** belongs on the model as soon as a second caller wants it (the API signup that skips onboarding, an admin backfill, an importer): `Company.create!(...)` inline is fine for a one-off; `Company.onboard!(...)` is what you reach for when it isn't. Same for `on_submit`/`on_rollback`: they're flow hooks, not a home for domain logic. Full rule: [[plutonium-behavior]] › Part 3 › Where the logic goes.
|
|
102
103
|
|
|
103
104
|
## Wizard-level macros
|
|
104
105
|
|
|
@@ -110,14 +111,14 @@ end
|
|
|
110
111
|
| `on_relaunch :new` | Bare-relaunching a **tokened** wizard with pending runs shows a "resume or start new" chooser by default (`:prompt`) instead of silently forking; `:new` opts out (always fresh). No-op for keyed/`anonymous` (already auto-resume). |
|
|
111
112
|
| `anchored with: Model` / `anchored via: :method` | Run against an existing record (read via `anchor`). `with:` = URL `:id` (resource-mounted); `via:` = a controller method (portal-level, context). |
|
|
112
113
|
| `cleanup_after <ttl> \| :never` | Idle TTL before the sweep reaps the session + rolls back tracked records. Default `config.wizards.cleanup_after`. |
|
|
113
|
-
| `concurrency_key { … }` | Key a run by the returned value(s) (tenant folded in). The keyed `in_progress` row is the lock
|
|
114
|
+
| `concurrency_key { … }` | Key a run by the returned value(s) (tenant folded in). The keyed `in_progress` row is the lock: a second launch resumes, never forks. Omit → unlimited `wizard_token`-keyed runs, **except `anchored`**, which defaults to `{ [anchor, current_user] }` (one draft per user per record). `{ anchor }` = one per record any-user; `{ wizard_token }` = repeatable. |
|
|
114
115
|
| `one_time` | Retain the completed row at the `concurrency_key` → run once (gate-able). **Requires `concurrency_key`.** Omit → row deleted on complete (repeatable). |
|
|
115
116
|
| `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). |
|
|
116
|
-
| `encrypt_data` | Encrypt the staged `data` column at rest via ActiveRecord's encryption keys (PII flows). Requires `active_record.encryption` keys
|
|
117
|
-
| `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full` (`:full` = unconstrained). Unset inherits `config.wizards.width` (default `:md`). Independent of `config.default_page_width
|
|
118
|
-
| `anonymous` | Opt into **guest (unauthenticated) access.** Default = auth required. A guest wizard may authenticate only at its terminal `execute`; never mid-flow. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time
|
|
117
|
+
| `encrypt_data` | Encrypt the staged `data` column at rest via ActiveRecord's encryption keys (PII flows). Requires `active_record.encryption` keys; first write raises (naming the wizard) if unconfigured. Unset inherits `config.wizards.encrypt_data` (global default, off); `encrypt_data false` opts out when that default is on. |
|
|
118
|
+
| `width <size>` | Width of this wizard's step pages: `:sm` `:md` `:lg` `:xl` `:full` (`:full` = unconstrained). Unset inherits `config.wizards.width` (default `:md`). Independent of `config.default_page_width`: resource page width does not move wizards. |
|
|
119
|
+
| `anonymous` | Opt into **guest (unauthenticated) access.** Default = auth required. A guest wizard may authenticate only at its terminal `execute`; never mid-flow. Mount it `public: true` (the default for `anonymous`). **Mutually exclusive with `concurrency_key`/`one_time`**: a guest's identity is its session token (already session-keyed/repeatable); whichever macro is declared last raises. |
|
|
119
120
|
|
|
120
|
-
## Branching
|
|
121
|
+
## Branching: `condition:`
|
|
121
122
|
|
|
122
123
|
Subtractive: a falsy `condition:` removes the step from the visible path.
|
|
123
124
|
|
|
@@ -131,11 +132,11 @@ end
|
|
|
131
132
|
|
|
132
133
|
`condition:` can also read `anchor`. Branch-hidden steps' data is pruned before `execute`. **Must be nil-safe** (see Critical).
|
|
133
134
|
|
|
134
|
-
Conditions are re-evaluated against the submission that was just staged, so a step (including `review`) may be gated on an answer from the step immediately before it
|
|
135
|
+
Conditions are re-evaluated against the submission that was just staged, so a step (including `review`) may be gated on an answer from the step immediately before it; the revealed steps become reachable on that same POST. When a step's answer reveals nothing after it, that POST ends the flow and runs `execute`.
|
|
135
136
|
|
|
136
|
-
### Revealing a field within a step
|
|
137
|
+
### Revealing a field within a step: `pre_submit:`
|
|
137
138
|
|
|
138
|
-
A step-level `condition:` branches whole steps against *stored* data. To show or hide a field as the user edits a **sibling field on the same step**, mark the deciding input `pre_submit: true
|
|
139
|
+
A step-level `condition:` branches whole steps against *stored* data. To show or hide a field as the user edits a **sibling field on the same step**, mark the deciding input `pre_submit: true`: changing it re-renders the step form from the **just-submitted** values (same mechanism as resource forms and interactive actions):
|
|
139
140
|
|
|
140
141
|
```ruby
|
|
141
142
|
step :details do
|
|
@@ -149,14 +150,14 @@ step :details do
|
|
|
149
150
|
end
|
|
150
151
|
```
|
|
151
152
|
|
|
152
|
-
A `pre_submit` is **render-only**: it never persists, never marks the step submitted, and never moves the cursor
|
|
153
|
+
A `pre_submit` is **render-only**: it never persists, never marks the step submitted, and never moves the cursor: abandon the page and nothing durable is left. **Attachment fields are exempt** from the re-render's seeding: a file input doesn't re-post on a sibling's `change`, so an already-staged upload survives untouched instead of being blanked out of the form. Uploads stage only on a real submit.
|
|
153
154
|
|
|
154
|
-
## Field reuse
|
|
155
|
+
## Field reuse: `using:` a model
|
|
155
156
|
|
|
156
157
|
`using:` is a **step option** (not a block method) and targets a **model only**.
|
|
157
158
|
|
|
158
159
|
```ruby
|
|
159
|
-
# Whole-step import
|
|
160
|
+
# Whole-step import, no block needed.
|
|
160
161
|
step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
|
|
161
162
|
|
|
162
163
|
# Mix imported + wizard-local fields.
|
|
@@ -176,7 +177,7 @@ Imports: field universe + types from `Model.attribute_names`/`attribute_types`;
|
|
|
176
177
|
| `layout: false` | Skip inherited `form_layout`. |
|
|
177
178
|
| `validation_context:` | Run `valid?(context)`. |
|
|
178
179
|
|
|
179
|
-
**Declaration reuse only
|
|
180
|
+
**Declaration reuse only**: never the model's persistence. Data stages into `data`; `execute` does the writes.
|
|
180
181
|
|
|
181
182
|
## Step internals
|
|
182
183
|
|
|
@@ -201,7 +202,7 @@ end
|
|
|
201
202
|
|
|
202
203
|
Repeater rows rehydrate from staged `data` on GET (resume / back re-renders filled rows).
|
|
203
204
|
|
|
204
|
-
Validations drive the form's field affordances just like a resource form: `presence` → the required marker (`*`); `length`/`numericality`/`format`/`inclusion` → `maxlength`/`min`/`max`/`pattern`/auto-choices. This holds for validations imported via `using:` too. (Structured-input sub-fields are the exception
|
|
205
|
+
Validations drive the form's field affordances just like a resource form: `presence` → the required marker (`*`); `length`/`numericality`/`format`/`inclusion` → `maxlength`/`min`/`max`/`pattern`/auto-choices. This holds for validations imported via `using:` too. (Structured-input sub-fields are the exception: they carry no validators, so no markers there.)
|
|
205
206
|
|
|
206
207
|
### Options that depend on the run
|
|
207
208
|
|
|
@@ -224,13 +225,13 @@ A one-argument proc still takes the form, as on any other form, for `object` (th
|
|
|
224
225
|
|
|
225
226
|
Three limits worth knowing:
|
|
226
227
|
|
|
227
|
-
- The **field set** is still fixed at class load
|
|
228
|
+
- 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.
|
|
228
229
|
- A **step's** `condition:` runs against the wizard; a **field's** `condition:` runs against the form (`object` = that step's staged data). Only the field-level one is excluded from the resolution above.
|
|
229
|
-
- **Proc options resolve on every form** ([[plutonium-resource]]), by one rule with no wizard exception: a zero-argument proc keeps its own binding, a one-argument one gets the form. A step block is `instance_exec`'d against an internal field recorder, so `-> { anchor.x }` raises `NameError
|
|
230
|
+
- **Proc options resolve on every form** ([[plutonium-resource]]), by one rule with no wizard exception: a zero-argument proc keeps its own binding, a one-argument one gets the form. A step block is `instance_exec`'d against an internal field recorder, so `-> { anchor.x }` raises `NameError`; take the form and use `form.wizard`. Same trade a `form_layout` section option makes; an `input` line keeps its meaning when moved between a definition and a step.
|
|
230
231
|
|
|
231
232
|
## Attachment fields (file uploads)
|
|
232
233
|
|
|
233
|
-
A step can collect a file. Declare it like any field
|
|
234
|
+
A step can collect a file. Declare it like any field: a **`:string`** attribute (it holds the upload **token**, not the bytes) + a file input:
|
|
234
235
|
|
|
235
236
|
```ruby
|
|
236
237
|
step :photo, label: "Photo" do
|
|
@@ -239,7 +240,7 @@ step :photo, label: "Photo" do
|
|
|
239
240
|
end
|
|
240
241
|
```
|
|
241
242
|
|
|
242
|
-
The file is staged into `data` as a backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data)
|
|
243
|
+
The file is staged into `data` as a backend **token** (an ActiveStorage signed_id, or active_shrine/Shrine cached-file data), never the bytes (they can't ride JSON `data` across steps). `execute` assigns that token to the model's attachment **natively** (both backends accept it):
|
|
243
244
|
|
|
244
245
|
```ruby
|
|
245
246
|
def execute
|
|
@@ -250,19 +251,19 @@ def execute
|
|
|
250
251
|
end
|
|
251
252
|
```
|
|
252
253
|
|
|
253
|
-
The review summary and the step's preview (on Back/resume) render the file automatically
|
|
254
|
+
The review summary and the step's preview (on Back/resume) render the file automatically: `data.<step>.<field>` resolves the token to a displayable attachment at the boundary; nothing extra to write.
|
|
254
255
|
|
|
255
|
-
**Two upload modes
|
|
256
|
+
**Two upload modes**: same field, differ only by `direct_upload:`:
|
|
256
257
|
|
|
257
258
|
| Mode | Declare | Notes |
|
|
258
259
|
|---|---|---|
|
|
259
260
|
| **Server-side** (default) | `input :file, as: :file` | the file rides the step POST; the wizard uploads it to the backend's cache while staging. Simplest; works for **AS *and* active_shrine**; no JS/endpoint needed. |
|
|
260
261
|
| **Direct upload** | `input :file, as: :uppy, direct_upload: true, endpoint: "/upload"` | the browser uploads to the endpoint and posts back a token (async progress UI). Needs that endpoint reachable (AS direct-uploads, or Shrine's `upload_endpoint`). |
|
|
261
262
|
|
|
262
|
-
- **Backend** (server-side mode): defaults to `config.wizards.attachment_backend`, which **auto-detects** active_shrine → `:shrine`, else `:active_storage`. Override per field with `backend:`. It **must match the model `execute` assigns to
|
|
263
|
-
- **Uploader** (Shrine only): `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader` caches through that uploader (running its cache-stage plugins
|
|
263
|
+
- **Backend** (server-side mode): defaults to `config.wizards.attachment_backend`, which **auto-detects** active_shrine → `:shrine`, else `:active_storage`. Override per field with `backend:`. It **must match the model `execute` assigns to**: an AS model ⇒ `:active_storage`, an active_shrine model ⇒ `:shrine` (an AS model won't accept a Shrine token, and vice-versa).
|
|
264
|
+
- **Uploader** (Shrine only): `input :photo, as: :file, backend: :shrine, uploader: PhotoUploader` caches through that uploader (running its cache-stage plugins (mime/dimension/location/processing) instead of base `Shrine`). The token stays uploader-agnostic, so display + `execute` promotion are unchanged. Server-side only; raises for `:active_storage`. The uploader's `:cache` storage must be the one the model's attacher promotes from (the default global `Shrine.storages[:cache]`). **Validations are enforced on the step** (when Shrine's optional `validation`/`validation_helpers` plugin is loaded, else a clean no-op): the file is validated against the field's effective uploader (its `uploader:`, else base `Shrine`), so a failing file is rejected with a field error, not deferred to `execute`. (`Uploader.upload` itself runs no validations; the step's validation pass does.)
|
|
264
265
|
- **Multiple:** an array attribute + `multiple: true` → the staged value is an array of tokens.
|
|
265
|
-
- **Cleanup:** a staged-then-abandoned upload is an unattached blob / cached Shrine file
|
|
266
|
+
- **Cleanup:** a staged-then-abandoned upload is an unattached blob / cached Shrine file: each backend's own unattached cleanup reaps it (the wizard `SweepJob` doesn't touch it).
|
|
266
267
|
|
|
267
268
|
## The review step
|
|
268
269
|
|
|
@@ -285,14 +286,14 @@ Always lists invalid/unvisited steps as fix-this jump links; Finish disabled unt
|
|
|
285
286
|
| **Complete**, `summary: false` + block | block **replaces** the summary |
|
|
286
287
|
| **Complete**, `summary: false`, no block | built-in "ready to complete" panel |
|
|
287
288
|
|
|
288
|
-
- `summary:` (default true)
|
|
289
|
-
- `header:` (default true)
|
|
289
|
+
- `summary:` (default true): show the auto-summary of completed steps. `false` hands the complete-state body to your block (or the "ready to complete" panel). The summary always shows in the incomplete state.
|
|
290
|
+
- `header:` (default true): the step-header section (label + the "check everything over" prompt, shown only when the summary is). `false` drops it for a chromeless finish. Pair with `stepper false` for no chrome at all.
|
|
290
291
|
|
|
291
|
-
The custom block runs **in the Phlex view context** (`self` is the component), so it may return a String, emit Phlex (`div`, `render Component.new(...)`), and reach helpers via `helpers.*`; it's yielded the `wizard` (`data`/`anchor`/`persisted`/`current_user`). Don't both emit markup and return a String
|
|
292
|
+
The custom block runs **in the Phlex view context** (`self` is the component), so it may return a String, emit Phlex (`div`, `render Component.new(...)`), and reach helpers via `helpers.*`; it's yielded the `wizard` (`data`/`anchor`/`persisted`/`current_user`). Don't both emit markup and return a String: Phlex renders the returned String too, double-rendering it.
|
|
292
293
|
|
|
293
|
-
**The summary resolves choice labels.** A field declared with the `choices:` option summarises as the label its `<option>` carried, not the stored value
|
|
294
|
+
**The summary resolves choice labels.** A field declared with the `choices:` option summarises as the label its `<option>` carried, not the stored value: `42` reads as "Alice", `"cash"` as "Cash". Every collection shape the input accepts works (pair arrays, `{value => label}` hashes, ranges, sets, AR relations, procs returning any of those), because resolution goes through the same `Phlexi::Form::SimpleChoicesMapper` the input uses. **Caveat:** choices supplied inside a *block* (`input(:x) { |f| f.select_tag choices: … }`) are computed at render time and aren't visible to the summary; those fields still show the raw value. Use the declarative `choices:` option when you want the review page to read well.
|
|
294
295
|
|
|
295
|
-
## Per-step writes
|
|
296
|
+
## Per-step writes: `on_submit` / `persist` / `on_rollback`
|
|
296
297
|
|
|
297
298
|
`execute` is the default (atomic). Use `on_submit` **only** when a real record must exist mid-flow (external handoff, reviewer sees partials, payload too large for the row).
|
|
298
299
|
|
|
@@ -315,11 +316,11 @@ step :billing, label: "Billing" do
|
|
|
315
316
|
end
|
|
316
317
|
```
|
|
317
318
|
|
|
318
|
-
**`persist` always cleans up.** On any rollback (Cancel, sweep, branch-prune) the engine **always** destroys every `persist`'d record via `destroy!` (respects a model's soft-delete override). `on_rollback` is **optional, additive
|
|
319
|
+
**`persist` always cleans up.** On any rollback (Cancel, sweep, branch-prune) the engine **always** destroys every `persist`'d record via `destroy!` (respects a model's soft-delete override). `on_rollback` is **optional, additive**: it compensates side effects the engine can't see, runs **before** the destroy, and a side-effect-only step (no `persist`) still runs its `on_rollback`. To keep a partial record, make the model soft-delete or use `cleanup_after :never`.
|
|
319
320
|
|
|
320
321
|
`on_submit` is not atomic across steps (HTTP), which is why `cleanup_after` + `SweepJob` exist.
|
|
321
322
|
|
|
322
|
-
**Keep the hook to flow, not domain.** `on_submit`/`on_rollback` belong to one wizard step and can't be called from anywhere else, so they should say *when* and *what gets tracked
|
|
323
|
+
**Keep the hook to flow, not domain.** `on_submit`/`on_rollback` belong to one wizard step and can't be called from anywhere else, so they should say *when* and *what gets tracked*: one call to a model, as above. Once "authorize a card and record the billing row" is something the API does too, it becomes `Billing.authorize!(company:, token:)` and the hook shrinks to `persist Billing.authorize!(...)`.
|
|
323
324
|
|
|
324
325
|
## Accessors
|
|
325
326
|
|
|
@@ -331,14 +332,14 @@ end
|
|
|
331
332
|
| `succeed(v)` / `failed(errs)` | Outcome helpers (alias `success`). `.with_message`, `.with_redirect_response` chainable. |
|
|
332
333
|
| `fail!(msg)` / `fail!(:field, msg)` | Raise a `StepError` from `on_submit`/`execute`. |
|
|
333
334
|
|
|
334
|
-
Available inside steps, `condition:`, `on_submit`, `on_rollback` and `execute
|
|
335
|
+
Available inside steps, `condition:`, `on_submit`, `on_rollback` and `execute`, and, via `form.wizard`, inside a step's **proc-valued field/input options** (see **Step internals → Options that depend on the run**).
|
|
335
336
|
|
|
336
337
|
## Anchoring
|
|
337
338
|
|
|
338
339
|
```ruby
|
|
339
340
|
anchored with: Company # single type
|
|
340
341
|
anchored with: [Company, Org] # polymorphic
|
|
341
|
-
anchored # generic
|
|
342
|
+
anchored # generic, type bound at registration
|
|
342
343
|
# omit # pure create flow (no anchor)
|
|
343
344
|
```
|
|
344
345
|
|
|
@@ -379,15 +380,15 @@ module AdminPortal
|
|
|
379
380
|
end
|
|
380
381
|
```
|
|
381
382
|
|
|
382
|
-
Un-completed user → redirected into the wizard (destination stashed); on completion → bounced back (PRG). Completed users pass through. The gate recomputes the wizard's `instance_key` from its `concurrency_key` (resolved on the host controller
|
|
383
|
+
Un-completed user → redirected into the wizard (destination stashed); on completion → bounced back (PRG). Completed users pass through. The gate recomputes the wizard's `instance_key` from its `concurrency_key` (resolved on the host controller: `current_user`/`current_scoped_entity`/custom available) and checks `completed?(instance_key:)`. Only one-time wizards are gateable. Use `concurrency_key { anchor }` for "set up this record once".
|
|
383
384
|
|
|
384
385
|
**Gating an anchored wizard:** the gate needs the anchor to recompute the key. A `via:`-anchored wizard is resolved automatically (the gate calls its `anchor_via` method on the controller); otherwise pass `ensure_wizard_completed Wizard, anchor: :method_or_proc`. An anchor it can't resolve raises (no silent loop). Anchor-keyed wizards are only gateable where the anchor is reconstructable.
|
|
385
386
|
|
|
386
|
-
**Re-opening a completed one-time wizard** doesn't re-run it (the retained row's `data` is cleared)
|
|
387
|
+
**Re-opening a completed one-time wizard** doesn't re-run it (the retained row's `data` is cleared); it renders an "already completed" page (success badge + label + Continue). Override the body with `completed do |wizard| … end`. Repeatable wizards have no completed page (re-launch starts fresh).
|
|
387
388
|
|
|
388
389
|
## Registration & launch
|
|
389
390
|
|
|
390
|
-
**(a) On a resource definition
|
|
391
|
+
**(a) On a resource definition**: the `wizard` macro synthesizes the launch action AND auto-mounts the wizard's routes on the resource's own controller. Placement mirrors interactions: anchored → record (member) action, non-anchored → resource (collection) action; no bulk:
|
|
391
392
|
|
|
392
393
|
```ruby
|
|
393
394
|
class CompanyDefinition < Plutonium::Resource::Definition
|
|
@@ -398,7 +399,7 @@ end
|
|
|
398
399
|
|
|
399
400
|
The anchored member action resolves its anchor through the resource controller's scoped, policy-gated `resource_record!` (IDOR-safe: out-of-scope / non-existent ids 404). Gate it with a policy predicate named after the wizard key (`def configure? = update?`).
|
|
400
401
|
|
|
401
|
-
**(b) Route-mounted
|
|
402
|
+
**(b) Route-mounted**: `register_wizard`, in a **portal** engine's routes or on the **main app**, alongside `register_resource`:
|
|
402
403
|
|
|
403
404
|
```ruby
|
|
404
405
|
AdminPortal::Engine.routes.draw do
|
|
@@ -418,7 +419,7 @@ Draws `GET /onboarding` (canonical launch) + `GET/POST /onboarding(/:token)/:ste
|
|
|
418
419
|
| `at:` (required) | Host-relative base path for the steps. |
|
|
419
420
|
| `as:` | Override the route-helper prefix (defaults to `at:`, then the wizard's name). |
|
|
420
421
|
| `public:` | Mount on a **public (unauthenticated)** route for an `anonymous` wizard. Defaults to the wizard's `anonymous?` flag. |
|
|
421
|
-
| `layout:` | The Rails layout to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Default by host
|
|
422
|
+
| `layout:` | The Rails layout to render in (a layout name, like the controller `layout` macro): `:basic` (bare), `:resource` (shell), or any app layout. Default by host: portal → the resource shell, main-app → `:basic`. Turbo-frame requests are always layout-less regardless. |
|
|
422
423
|
|
|
423
424
|
### Hosting & the controller override hook
|
|
424
425
|
|
|
@@ -427,7 +428,7 @@ Draws `GET /onboarding` (canonical launch) + `GET/POST /onboarding(/:token)/:ste
|
|
|
427
428
|
| Host | Controller used | Auth |
|
|
428
429
|
|---|---|---|
|
|
429
430
|
| Portal | `<Portal>::WizardsController` if defined, else synthesized on the portal's `PlutoniumController` | the portal's (inherited) |
|
|
430
|
-
| Main app, **authenticated** | `::WizardsController
|
|
431
|
+
| Main app, **authenticated** | `::WizardsController`: **you must define it** | **yours**: `include Plutonium::Auth::Rodauth(:account)` |
|
|
431
432
|
| Main app, **public** (`anonymous`) | synthesized `::PublicWizardsController` (bare + `Auth::Public`) | none (guest) |
|
|
432
433
|
|
|
433
434
|
```ruby
|
|
@@ -438,19 +439,19 @@ class WizardsController < ApplicationController
|
|
|
438
439
|
end
|
|
439
440
|
```
|
|
440
441
|
|
|
441
|
-
`Plutonium::Wizard::Controller` is the whole contract
|
|
442
|
+
`Plutonium::Wizard::Controller` is the whole contract: including it on any base yields a renderable wizard controller (it pulls in `Core::Controller` and contributes the `"plutonium"` view prefix, so even a bare `ActionController::Base` host renders the shared partials). For an app that needs no custom auth base there's a ready-made `Plutonium::Wizard::BaseController` (`< ActionController::Base` + the module) to subclass. The module is the mechanism; the class is sugar.
|
|
442
443
|
|
|
443
444
|
> [!TIP]
|
|
444
|
-
> **`with:`-anchored wizards mount on the resource, not portal-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro
|
|
445
|
+
> **`with:`-anchored wizards mount on the resource, not portal-level.** Register a `with:`-anchored wizard on the anchored resource's definition with the `wizard` macro: it auto-mounts a record (member) action whose anchor is the scoped `resource_record!`. Passing a `with:`-anchored wizard to `register_wizard` **raises** (no resource record). A **`via:`-anchored** (context) wizard mounts portal-level fine; its anchor is a controller method (e.g. `via: :current_scoped_entity`).
|
|
445
446
|
|
|
446
447
|
> [!DANGER]
|
|
447
|
-
> **A portal-level wizard with no `authorize?` is runnable by ANY authenticated portal user
|
|
448
|
+
> **A portal-level wizard with no `authorize?` is runnable by ANY authenticated portal user**: it has no resource policy and defaults to allowed. **Always define `def authorize?`** for anything privileged (admin-only, per-user gating, tenant checks).
|
|
448
449
|
|
|
449
450
|
## Authentication
|
|
450
451
|
|
|
451
|
-
**Auth is required by default
|
|
452
|
+
**Auth is required by default**: entry without a `current_user` is rejected. Authenticated lookups are **owner-scoped**: a run id leaked in a URL can't be resumed by another logged-in user (foreign row → 404).
|
|
452
453
|
|
|
453
|
-
Where `current_user` comes from depends on the host: a **portal** mount inherits the portal's auth concern; a **main-app authenticated** mount needs the auth on your own `::WizardsController` (see *Hosting & the controller override hook* above
|
|
454
|
+
Where `current_user` comes from depends on the host: a **portal** mount inherits the portal's auth concern; a **main-app authenticated** mount needs the auth on your own `::WizardsController` (see *Hosting & the controller override hook* above; a bare synthesized main-app controller has no `current_user`, so a non-anonymous wizard there would be rejected by the auth gate). The wizard module supplies a `current_user` default that **defers to the host's auth concern** when present and is `nil` on a bare host.
|
|
454
455
|
|
|
455
456
|
Opt into guest access with `anonymous`, and mount it on a **public route**:
|
|
456
457
|
|
|
@@ -464,48 +465,56 @@ class GuestSignupWizard < Plutonium::Wizard::Base
|
|
|
464
465
|
end
|
|
465
466
|
end
|
|
466
467
|
|
|
467
|
-
# in a portal's routes
|
|
468
|
+
# in a portal's routes, drawn on a public (unauthenticated) route automatically
|
|
468
469
|
register_wizard ::GuestSignupWizard, at: "signup", public: true
|
|
469
470
|
```
|
|
470
471
|
|
|
471
|
-
The guest run-id lives in the **Rails session** (`session["plutonium_wizards"][<wizard_key>]`)
|
|
472
|
+
The guest run-id lives in the **Rails session** (`session["plutonium_wizards"][<wizard_key>]`): **no cookie, no TTL** (the row's `cleanup_after` is the lifetime). It's browser-close ephemeral, **auto-cleared on login/logout** (Rodauth `reset_session`), cleared on completion, and **never in a URL**. Authenticated repeatable runs keep their URL `:token` segment instead (owner-scoped). **No mid-flow auth crossing**: a guest wizard never stamps an owner mid-flow or carries a token across login; it only ever authenticates at `execute`.
|
|
472
473
|
|
|
473
474
|
## Listing in-progress wizards
|
|
474
475
|
|
|
475
|
-
`Plutonium::Wizard.in_progress_for(view_context)` (→ `Resume.entries_for(view_context)`) takes the `view_context` (as interactions do) and derives the run owner (`current_user`), tenant scope, and **portal** from it
|
|
476
|
+
`Plutonium::Wizard.in_progress_for(view_context)` (→ `Resume.entries_for(view_context)`) takes the `view_context` (as interactions do) and derives the run owner (`current_user`), tenant scope, and **portal** from it, returning that user's in-progress runs **for the current portal**, newest-first, for a "continue where you left off" dashboard. 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`) is recorded per-run; scope alone can't identify it.)
|
|
476
477
|
|
|
477
|
-
Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes
|
|
478
|
+
Each entry exposes the wizard's `label`/`icon`, `current_step` (+ `current_step_label`), `updated_at`, the raw `session` row, and a `resume_url` built through the **current portal's** routes: the named route for a `register_wizard` mount, `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount, and for a **non-anchored** `wizard`-macro run the resource whose definition registers that wizard class; `nil` + `resume_unresolved_reason` when the row can't be resolved here.
|
|
478
479
|
|
|
479
|
-
Each entry also exposes a **`cancel_url
|
|
480
|
+
Each entry also exposes a **`cancel_url`**, the `DELETE` target that abandons the run, resolved from the *same* mount through its own named cancel route. Never derive it by string-munging `resume_url`: that drops query params and mis-resolves for a run with no `current_step` (whose resume URL is the bare launch path). The two resolve **independently**, so an unresumable row is still cancellable rather than stranded in the list. Render it as a **form**, not a link: cancelling runs every step's `on_rollback`, destroys its `persist`'d records, and deletes the row:
|
|
480
481
|
|
|
481
482
|
```ruby
|
|
482
483
|
form(action: entry.cancel_url, method: "post") do
|
|
483
484
|
input(type: "hidden", name: "_method", value: "delete")
|
|
484
485
|
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
|
|
485
|
-
# `turbo_confirm`, NOT `confirm
|
|
486
|
+
# `turbo_confirm`, NOT `confirm`: `data-confirm` is Rails UJS and never fires under Turbo.
|
|
486
487
|
button(type: "submit", data: {turbo_confirm: "Discard this draft? This can't be undone."}) { "Cancel" }
|
|
487
488
|
end
|
|
488
489
|
```
|
|
489
490
|
|
|
490
|
-
**Narrowing
|
|
491
|
+
**Narrowing to one wizard or one record.** The full signature is `in_progress_for(view_context, anchor: nil, wizard: nil)`. To list only one wizard's runs, pass the class:
|
|
492
|
+
|
|
493
|
+
```erb
|
|
494
|
+
<% Plutonium::Wizard.in_progress_for(self, wizard: ConfigureWidgetWizard).each do |entry| %>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
Both filters become `WHERE` clauses (`wizard: wizard.name`, `anchor: anchor`) **before** any row is enriched. Enrichment is the costly part: every returned row gets its resume URL and cancel URL 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. The filters compose, and `wizard + anchor` is index-covered: `in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: company).first` answers "does this record have an unfinished draft of wizard X?". Don't filter on `e.session.anchor` either (a polymorphic load per row).
|
|
498
|
+
|
|
499
|
+
The one case for post-filtering: the **same render** already calls the unfiltered `in_progress_for` (e.g. 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. If the callout stands alone, or the full list isn't on that page, use `wizard:`.
|
|
491
500
|
|
|
492
501
|
## Storage & config
|
|
493
502
|
|
|
494
503
|
```ruby
|
|
495
504
|
# config/initializers/plutonium.rb
|
|
496
505
|
Plutonium.configure do |config|
|
|
497
|
-
config.wizards.enabled = true # false by default
|
|
506
|
+
config.wizards.enabled = true # false by default, required
|
|
498
507
|
config.wizards.cleanup_after = 14.days # global default sweep TTL
|
|
499
508
|
config.wizards.encrypt_data = false # encrypt every wizard's data at rest (needs AR encryption keys)
|
|
500
|
-
config.wizards.database = :primary # reserved
|
|
509
|
+
config.wizards.database = :primary # reserved: v1 supports :primary only (else raises at boot)
|
|
501
510
|
config.wizards.attachment_backend = nil # server-side attachment staging backend (nil = auto-detect active_shrine/AS)
|
|
502
|
-
config.wizards.width = :md # default step page width (:sm/:md/:lg/:xl/:full)
|
|
511
|
+
config.wizards.width = :md # default step page width (:sm/:md/:lg/:xl/:full), NOT tied to default_page_width
|
|
503
512
|
end
|
|
504
513
|
```
|
|
505
514
|
|
|
506
515
|
- One framework table `plutonium_wizard_sessions` (gem-shipped migration, runs in place on `rails db:migrate`). No changes to your models.
|
|
507
516
|
- DB-backed → resume across devices, in-progress listing, durable one-time markers.
|
|
508
|
-
- **`Plutonium::Wizard::SweepJob`** (an `ActiveJob`) reaps idle/expired sessions and rolls back their tracked records. **Schedule it** for every wizard app
|
|
517
|
+
- **`Plutonium::Wizard::SweepJob`** (an `ActiveJob`) reaps idle/expired sessions and rolls back their tracked records. **Schedule it** for every wizard app: stale rows pile up otherwise, and for `on_submit`/`persist` wizards it's the *only* thing that rolls back abandoned mid-flow records. In a Solid Queue app (`rails g pu:lite:solid_queue` sets up the backend), add it to `config/recurring.yml`:
|
|
509
518
|
|
|
510
519
|
```yaml
|
|
511
520
|
# config/recurring.yml
|
|
@@ -514,7 +523,7 @@ end
|
|
|
514
523
|
schedule: every 15 minutes
|
|
515
524
|
```
|
|
516
525
|
|
|
517
|
-
(Or any recurring mechanism your app already has
|
|
526
|
+
(Or any recurring mechanism your app already has: sidekiq-cron, `whenever`, a cron'd rake task. `perform` takes no required args.)
|
|
518
527
|
|
|
519
528
|
## Common gotchas
|
|
520
529
|
|
|
@@ -526,13 +535,15 @@ end
|
|
|
526
535
|
- **`one_time` without `concurrency_key`** → raises (no stable row to retain).
|
|
527
536
|
- **`anonymous` + `concurrency_key`/`one_time`** → raises (a guest is already session-keyed; whichever is declared last raises).
|
|
528
537
|
- **`encrypt_data` without AR encryption keys** → first write raises (naming the wizard). Run `bin/rails db:encryption:init`.
|
|
538
|
+
- **`register_wizard` wizard with no `authorize?`** → runnable by any authenticated user of that portal/app (default `true`, no resource policy).
|
|
539
|
+
- **Filtering `in_progress_for` results with `select`** → enriches every draft first. Pass `wizard:`/`anchor:` instead, unless the same render already loaded the full list.
|
|
529
540
|
- **Gating a non-one-time wizard** (`ensure_wizard_completed` on a repeatable wizard) → raises.
|
|
530
541
|
- **`on_submit` wizard without scheduled SweepJob** → abandoned partial records pile up.
|
|
531
542
|
- **Rotating `secret_key_base`** → invalidates every `instance_key` digest (it's salted with the app secret): in-progress runs become unresumable and one-time gates re-open. Only affects rows live at rotation time.
|
|
532
543
|
|
|
533
544
|
## Related Skills
|
|
534
545
|
|
|
535
|
-
- [[plutonium-resource]]
|
|
536
|
-
- [[plutonium-behavior]]
|
|
537
|
-
- [[plutonium-app]]
|
|
538
|
-
- [[plutonium-testing]]
|
|
546
|
+
- [[plutonium-resource]]: the `attribute`/`input`/`validates`/`structured_input`/`form_layout` field DSL used inside a step.
|
|
547
|
+
- [[plutonium-behavior]]: Outcomes (`succeed`/`failed`), the Action system the `wizard` macro builds on, policies.
|
|
548
|
+
- [[plutonium-app]]: portal engines and `register_wizard` placement (alongside `register_resource`).
|
|
549
|
+
- [[plutonium-testing]]: integration-testing wizard flows.
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.66.0] - 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- [**breaking**] Raise when associated_with finds more than one association
|
|
10
|
+
- Generate update? in the pu:profile:conn policy
|
|
11
|
+
- Use a dark background for alerts in dark mode
|
|
12
|
+
- Deny inherited custom actions in the public storefront portal
|
|
13
|
+
- Dark close buttons on alerts and toasts; test profile policy update?
|
|
14
|
+
- Resolve typeahead URLs in path-scoped portals
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
- Publish dashboards and i18n announcements
|
|
19
|
+
- Correct and extend skills from eval findings
|
|
20
|
+
- Fix incorrect guidance and remove em dashes
|
|
21
|
+
|
|
22
|
+
### Miscellaneous Tasks
|
|
23
|
+
|
|
24
|
+
- Bump plutonium version in rails 8.1 appraisal lockfile
|
|
25
|
+
|
|
5
26
|
## [0.65.0] - 2026-10-02
|
|
6
27
|
|
|
7
28
|
### Bug Fixes
|
data/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Then scaffold a resource, create a portal, and connect them:
|
|
|
20
20
|
```bash
|
|
21
21
|
cd myapp
|
|
22
22
|
|
|
23
|
-
# Scaffold a resource
|
|
23
|
+
# Scaffold a resource: model, migration, definition, policy
|
|
24
24
|
rails g pu:res:scaffold Post title:string body:text published_at:datetime --dest=main_app
|
|
25
25
|
rails db:prepare
|
|
26
26
|
|
|
@@ -31,7 +31,7 @@ rails g pu:res:conn Post --dest=app_portal
|
|
|
31
31
|
bin/dev
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
Visit `http://localhost:3000/app/posts
|
|
34
|
+
Visit `http://localhost:3000/app/posts`: you have a complete CRUD interface.
|
|
35
35
|
|
|
36
36
|
## What You Stop Writing
|
|
37
37
|
|
|
@@ -44,7 +44,7 @@ rails g pu:res:scaffold Post ... # Plutonium: full CRUD + search + filters
|
|
|
44
44
|
|
|
45
45
|
And it doesn't stop at scaffolds:
|
|
46
46
|
|
|
47
|
-
**Resource-oriented architecture
|
|
47
|
+
**Resource-oriented architecture**: models, policies, definitions, and controllers that work together:
|
|
48
48
|
|
|
49
49
|
```ruby
|
|
50
50
|
# Policy controls WHO can do WHAT
|
|
@@ -66,7 +66,7 @@ class PostDefinition < ResourceDefinition
|
|
|
66
66
|
end
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
**Packages and portals
|
|
69
|
+
**Packages and portals**: split your app into feature engines and themed web interfaces:
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
rails g pu:pkg:package blogging # Business logic
|
|
@@ -107,10 +107,10 @@ end
|
|
|
107
107
|
|
|
108
108
|
## Why Plutonium
|
|
109
109
|
|
|
110
|
-
- **Convention over configuration
|
|
111
|
-
- **It's just Rails
|
|
112
|
-
- **Multi-tenant ready
|
|
113
|
-
- **AI-readable
|
|
110
|
+
- **Convention over configuration**: extended to resources, policies, portals, and tenancy, not just routes and views.
|
|
111
|
+
- **It's just Rails**: generated code lives in your repo. Edit it, override it, delete it. The "magic" is regular Ruby mixins you can read.
|
|
112
|
+
- **Multi-tenant ready**: path or domain tenancy, scoped relations, invites and memberships out of the box.
|
|
113
|
+
- **AI-readable**: predictable file layout and naming, plus built-in [Claude Code skills](.claude/skills) that teach AI assistants the patterns.
|
|
114
114
|
|
|
115
115
|
## Documentation
|
|
116
116
|
|
|
@@ -133,4 +133,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines.
|
|
|
133
133
|
|
|
134
134
|
## License
|
|
135
135
|
|
|
136
|
-
MIT License
|
|
136
|
+
MIT License, see [LICENSE.txt](LICENSE.txt).
|
data/SECURITY.md
CHANGED
|
@@ -12,7 +12,7 @@ please upgrade before reporting an issue to confirm it still reproduces.
|
|
|
12
12
|
|
|
13
13
|
| Version | Supported |
|
|
14
14
|
| ------- | ------------------ |
|
|
15
|
-
| Latest release (`0.
|
|
15
|
+
| Latest release (`0.66.x`) | :white_check_mark: |
|
|
16
16
|
| Older releases | :x: |
|
|
17
17
|
|
|
18
18
|
## Reporting a Vulnerability
|