plutonium 0.60.5 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -12,6 +12,44 @@ description: Use BEFORE writing tests for a Plutonium resource, running pu:test:
|
|
|
12
12
|
- **One file per (resource × portal).** Same model in admin and org portals = two test files. Each portal has different auth, scoping, and allowed actions.
|
|
13
13
|
- **Stub methods are required.** Concerns ship with `NotImplementedError` stubs — your test class supplies the test data via `create_resource!`, `valid_create_params`, `policy_roles`, etc.
|
|
14
14
|
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 🛑 Before you scaffold tests: confirm the shape (ASK — don't infer)
|
|
18
|
+
|
|
19
|
+
"Write tests for X" leaves out what actually drives the files. Resolve each — confirming by inspection (next section):
|
|
20
|
+
|
|
21
|
+
1. **Which concerns?** `crud` / `policy` / `definition` / `model` / `nested` / `interaction` / `portal_access`. Don't scaffold all blindly — pick what the resource needs.
|
|
22
|
+
2. **Which portals?** **One file per (resource × portal)** — each has different auth, scoping, and allowed actions. A resource in admin + org ⇒ two files.
|
|
23
|
+
3. **Nested?** A child resource needs `--parent=` **and** a real `parent_record!` stub.
|
|
24
|
+
4. **Auth flavor.** Rodauth (the default `login_as` POSTs the hardcoded `password123`) or custom (override `sign_in_for_tests`)?
|
|
25
|
+
|
|
26
|
+
**Never ship a guessed policy matrix, factory name, or field list** — read the model/definition/policy for the real actions, roles, and fields before filling stubs.
|
|
27
|
+
|
|
28
|
+
## ✅ Before you scaffold: verify the ground truth (CHECK — read it, don't ask for it)
|
|
29
|
+
|
|
30
|
+
You have file access — **inspect**; don't ask the user to describe their setup.
|
|
31
|
+
|
|
32
|
+
| Check | How | Why it matters |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| Harness installed | grep `test/test_helper.rb` for `require "plutonium/testing"` | Concerns never autoload — run `pu:test:install` first |
|
|
35
|
+
| Resource exposed in each named portal | The resource is `register_resource`'d in each portal | `--portals=` must match mounted engines |
|
|
36
|
+
| Portal engine names | `:admin` ⇒ `AdminPortal::Engine` | Mismatch ⇒ pass `path_prefix:` explicitly |
|
|
37
|
+
| Login password | Test accounts seeded with `password123` (fixtures/factories) | `login_as` POSTs that hardcoded value, or use `sign_in_for_tests` |
|
|
38
|
+
| Tenant binding | `create_resource!`/`policy_record` return `@tenant`-bound records | Else scope tests pass for the wrong reason |
|
|
39
|
+
|
|
40
|
+
Inspect with your own tools **before** scaffolding.
|
|
41
|
+
|
|
42
|
+
## 🛠 Use the generator — fill the stubs, don't hand-write
|
|
43
|
+
|
|
44
|
+
| Task | Generator | Verify first |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Install harness (once per app) | `pu:test:install` | Not already in `test_helper.rb` |
|
|
47
|
+
| Scaffold tests | `pu:test:scaffold Klass --portals=… --concerns=…` | Harness installed; resource exposed in those portals |
|
|
48
|
+
|
|
49
|
+
Hand-written test files drift from conventions — scaffold, then fill the `NotImplementedError` stubs with tenant-correct data.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
15
53
|
## Quick start
|
|
16
54
|
|
|
17
55
|
```bash
|
|
@@ -23,6 +23,44 @@ For field-level rendering (`field :foo, as: :markdown`, `display :status do |f|
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
+
## 🛑 Before you customize UI: pick the lightest seam (ASK — don't infer)
|
|
27
|
+
|
|
28
|
+
Plutonium gives you escalating levels of customization. Reach for the **lightest that fits** — jumping to `view_template` or an eject loses breadcrumbs/header/DynaFrame behavior and saddles you with maintaining copied markup forever.
|
|
29
|
+
|
|
30
|
+
| You want to… | Reach for | **NOT** |
|
|
31
|
+
|---|---|---|
|
|
32
|
+
| Add a banner / extra section to one page | nested page class + a **render hook** (`render_before_content`, `render_after_content`) | overriding `view_template` |
|
|
33
|
+
| Re-arrange the record's fields | a custom `Display` (`display_template`) | a hand-rolled `Form`/`view_template` |
|
|
34
|
+
| Group form fields into sections | the `form_layout` DSL in the definition | a `Form` subclass |
|
|
35
|
+
| Recolor / rebrand | `plutoniumTailwindConfig.merge(...)` in `tailwind.config.js` | a plain object spread (drops Plutonium's defaults) |
|
|
36
|
+
| Replace whole chrome per-portal | `pu:eject:shell` / `pu:eject:layout` — **last resort, you own it after** | ejecting when a hook/class/theme would do |
|
|
37
|
+
|
|
38
|
+
Then: is the change **global** (base `PostDefinition`) or **per-portal** (`AdminPortal::PostDefinition`)? And does it touch CSS/JS (⇒ the asset toolchain must be set up)? Don't guess field names or the banner copy — read the definition.
|
|
39
|
+
|
|
40
|
+
## ✅ Before you edit: verify the ground truth (CHECK — read it, don't ask for it)
|
|
41
|
+
|
|
42
|
+
You have file access — **inspect**; don't ask the user to describe their app.
|
|
43
|
+
|
|
44
|
+
| Check | How | Why it matters |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Custom page/Display already exists | Read the definition for nested `ShowPage`/`Display`/`Form` | Re-declaring clobbers an existing override |
|
|
47
|
+
| Global vs per-portal | Is it `::PostDefinition` or `AdminPortal::PostDefinition`? | Override the right one |
|
|
48
|
+
| Real field names | Read the model/definition | Don't invent fields in `display_template` |
|
|
49
|
+
| Asset toolchain wired | `ls tailwind.config.js`; the CSS `@import`; has `pu:core:assets` run? | Brand/CSS edits won't compile otherwise |
|
|
50
|
+
| Stimulus registered | grep `app/javascript/controllers/index.js` for `registerControllers` | Else the interactive layer is dead |
|
|
51
|
+
| Build watcher | Is `yarn dev` running? (`PLUTONIUM_DEV=1` when working on the gem) | CSS/JS changes need the rebuild |
|
|
52
|
+
|
|
53
|
+
Inspect with your own tools **before** proposing code.
|
|
54
|
+
|
|
55
|
+
## 🛠 Use the generator — and prefer hooks over ejecting
|
|
56
|
+
|
|
57
|
+
| Task | How | Verify first |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| Custom Tailwind + Stimulus toolchain | `pu:core:assets` | Not already run |
|
|
60
|
+
| Eject chrome (header/sidebar/layout) | `pu:eject:shell` / `pu:eject:layout --dest=portal` | A render hook / nested class / theme genuinely can't do it (last resort) |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
26
64
|
# Part 1 — Pages
|
|
27
65
|
|
|
28
66
|
Each definition has nested page classes. Override the ones you need to customize:
|
|
@@ -288,6 +326,17 @@ render field(:published_at).wrapped { |f| f.flatpickr_tag(min_date: Date.today,
|
|
|
288
326
|
render field(:avatar).wrapped { |f| f.uppy_tag(allowed_file_types: %w[.jpg .png], max_file_size: 5.megabytes) }
|
|
289
327
|
```
|
|
290
328
|
|
|
329
|
+
### Password & secret fields
|
|
330
|
+
|
|
331
|
+
`password_tag` masks the stored value — it **never emits the secret into the DOM**. A stored secret renders a sentinel; an untouched submit keeps it, an edit-to-new-value then failed re-render comes back blank + `required` (re-type — secrets are never echoed back), a *cleared* field comes back blank but **not** `required` (the clear may be intentional), a deliberately emptied field clears it (clear-by-blank), a typed value sets it. The sentinel is guarded by the `password-sentinel` Stimulus controller — the first edit (incl. **backspace**) wipes the whole field so a partial edit can't corrupt it.
|
|
332
|
+
|
|
333
|
+
Auto-detected by name: `password`/`token`/`salt`, `encrypted_*`, `*_password`/`*_digest`/`*_hash`/`*_token`/`*_key`/`*_salt`, or any name containing `secret`. A convenience, **not** a guarantee — odd-named secrets (`recovery_phrase`, `pin`) still leak unless masked explicitly.
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
field :api_token, as: :string # opt OUT — show a readable value (token to copy, checksum)
|
|
337
|
+
field :recovery_phrase, as: :password # opt IN — mask a secret the heuristic misses
|
|
338
|
+
```
|
|
339
|
+
|
|
291
340
|
## Submit buttons
|
|
292
341
|
|
|
293
342
|
Default `render_actions` produces the primary submit, plus an optional "Save and add another" / "Update and continue editing" secondary button.
|
|
@@ -508,6 +557,8 @@ Drives both framework `:new` / `:edit` and every interactive action on the defin
|
|
|
508
557
|
|
|
509
558
|
Show pages with `permitted_associations` (see [[plutonium-behavior]]) render a tablist: **Details** tab first, then one tab per association. The active tab is reflected in the URL hash (`#products`, `#refund-requests`) so the page deep-links and the active state survives reload / back navigation. Tab rows scroll horizontally on narrow viewports — they don't wrap.
|
|
510
559
|
|
|
560
|
+
If the policy permits **no fields**, the empty Details tab is dropped and the first association tab leads instead.
|
|
561
|
+
|
|
511
562
|
---
|
|
512
563
|
|
|
513
564
|
# Part 6 — Layout (Chrome) & Eject
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plutonium-wizard
|
|
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
|
+
---
|
|
5
|
+
|
|
6
|
+
# Plutonium Wizards
|
|
7
|
+
|
|
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
|
+
|
|
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
|
+
|
|
12
|
+
## 🚨 Critical (read first)
|
|
13
|
+
|
|
14
|
+
- **Enable the subsystem first.** `config.wizards.enabled = true` in `config/initializers/plutonium.rb`, then `rails db:migrate`. 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
|
+
- **`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
|
+
- **`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
|
+
- **`review` must be the LAST step.** A step declared after `review` raises at load.
|
|
19
|
+
- **`using:` targets a MODEL only** — not an interaction, not a bare definition. Selectors `fields:`/`only:`/`except:`.
|
|
20
|
+
- **No generator.** Author wizards by hand, like interactions. They live in `app/wizards/`.
|
|
21
|
+
- **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.
|
|
22
|
+
- **Schedule `SweepJob`** (a periodic job/cron). 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.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 🛑 Before you author: confirm the configuration (ASK — don't infer)
|
|
27
|
+
|
|
28
|
+
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.
|
|
29
|
+
|
|
30
|
+
**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:
|
|
31
|
+
|
|
32
|
+
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.
|
|
33
|
+
2. **Anchored or fresh?** Does it operate on an **existing** record or create something new?
|
|
34
|
+
- existing record from the URL → `anchored with: Model` (resource-mounted member route)
|
|
35
|
+
- existing context (the tenant, the current user) → `anchored via: :current_scoped_entity`
|
|
36
|
+
- brand new → non-anchored
|
|
37
|
+
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`.)
|
|
38
|
+
4. **Run identity.** Resume the user's **one** in-progress run (keyed — `concurrency_key`), or start a **fresh** run each launch (tokened/repeatable)? (Anchored wizards default to one run per `[anchor, current_user]`.)
|
|
39
|
+
5. **One-time?** Run at most once and keep a completed marker (`one_time`) — e.g. to **gate** a page behind it?
|
|
40
|
+
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)?
|
|
41
|
+
7. **Steps & branching.** Which steps/fields/validations? Any step shown only under a `condition:`?
|
|
42
|
+
8. **Tenancy.** Is the host portal entity-scoped? (The tenant folds into run identity automatically — don't thread it by hand.)
|
|
43
|
+
|
|
44
|
+
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.
|
|
45
|
+
|
|
46
|
+
## ✅ Before you author: verify the ground truth (CHECK — read it, don't ask for it)
|
|
47
|
+
|
|
48
|
+
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):
|
|
49
|
+
|
|
50
|
+
| Check | How | Why it matters |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| 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) |
|
|
53
|
+
| Anchor model exists & reachable | Read the model an `anchored` wizard runs against | Missing/unreadable anchor ⇒ 404 / `NotAnchoredError` |
|
|
54
|
+
| 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` |
|
|
55
|
+
| Guest-flow prereqs | AR encryption keys if `encrypt_data`; no `concurrency_key`/`one_time` with `anonymous` | First write raises otherwise |
|
|
56
|
+
| `on_submit` ⇒ SweepJob scheduled | The recurring-job config | Abandoned mid-flow records pile up forever |
|
|
57
|
+
|
|
58
|
+
**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.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Minimal wizard
|
|
63
|
+
|
|
64
|
+
The common case writes nothing until the end. Steps collect `data`; one `execute` does all writes atomically.
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# app/wizards/company_onboarding_wizard.rb
|
|
68
|
+
class CompanyOnboardingWizard < Plutonium::Wizard::Base
|
|
69
|
+
presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
|
|
70
|
+
|
|
71
|
+
step :company, label: "Company details" do
|
|
72
|
+
attribute :name, :string
|
|
73
|
+
attribute :subdomain, :string
|
|
74
|
+
input :name
|
|
75
|
+
input :subdomain
|
|
76
|
+
validates :name, :subdomain, presence: true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
step :plan, label: "Plan" do
|
|
80
|
+
attribute :plan, :string
|
|
81
|
+
input :plan, as: :radio_buttons, choices: %w[free pro]
|
|
82
|
+
validates :plan, presence: true
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
review label: "Review & submit"
|
|
86
|
+
|
|
87
|
+
def execute
|
|
88
|
+
company = Company.create!(name: data.company.name, subdomain: data.company.subdomain, plan: data.plan.plan)
|
|
89
|
+
succeed(company).with_message("You're all set!")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
- `presents label:/icon:/description:` — launch button label + icon (same as interactions); the optional `description:` renders as the wizard's header subheading.
|
|
95
|
+
- 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`.
|
|
96
|
+
- `data.<step>.<field>` reads the **typed** value (cast to declared type) for that step, e.g. `data.company.name`.
|
|
97
|
+
- `review` — built-in terminal step: auto-summary + gated Finish. Must be last.
|
|
98
|
+
- `execute` — runs once at the end in one transaction; returns `succeed(...)` / `failed(...)`.
|
|
99
|
+
|
|
100
|
+
## Wizard-level macros
|
|
101
|
+
|
|
102
|
+
| Macro | Meaning |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `presents label:, icon:, description:` | Launch button label + icon; the optional `description:` renders as the wizard's header subheading. |
|
|
105
|
+
| `navigation :linear \| :free` | Stepper jumps. `:linear` (default) = back to any visited step; `:free` = any visible visited step. Forward to unvisited is never allowed. |
|
|
106
|
+
| `stepper false` | Hide the top rail (step indicator). On by default. |
|
|
107
|
+
| `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). |
|
|
108
|
+
| `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). |
|
|
109
|
+
| `cleanup_after <ttl> \| :never` | Idle TTL before the sweep reaps the session + rolls back tracked records. Default `config.wizards.cleanup_after`. |
|
|
110
|
+
| `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. |
|
|
111
|
+
| `one_time` | Retain the completed row at the `concurrency_key` → run once (gate-able). **Requires `concurrency_key`.** Omit → row deleted on complete (repeatable). |
|
|
112
|
+
| `completed do \|wizard\| … end` | Custom body for the "already completed" page a finished **one-time** wizard shows when re-opened (replaces the default confirmation). |
|
|
113
|
+
| `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. |
|
|
114
|
+
| `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. |
|
|
115
|
+
|
|
116
|
+
## Branching — `condition:`
|
|
117
|
+
|
|
118
|
+
Subtractive: a falsy `condition:` removes the step from the visible path.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
step :billing, label: "Billing", condition: -> { data.plan.plan == "pro" } do
|
|
122
|
+
attribute :card_token, :string
|
|
123
|
+
input :card_token
|
|
124
|
+
validates :card_token, presence: true
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`condition:` can also read `anchor`. Branch-hidden steps' data is pruned before `execute`. **Must be nil-safe** (see Critical).
|
|
129
|
+
|
|
130
|
+
## Field reuse — `using:` a model
|
|
131
|
+
|
|
132
|
+
`using:` is a **step option** (not a block method) and targets a **model only**.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
# Whole-step import — no block needed.
|
|
136
|
+
step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
|
|
137
|
+
|
|
138
|
+
# Mix imported + wizard-local fields.
|
|
139
|
+
step :details, using: Company, only: %i[tagline] do
|
|
140
|
+
attribute :referral_code, :string
|
|
141
|
+
input :referral_code
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Imports: field universe + types from `Model.attribute_names`/`attribute_types`; input styling from the auto-resolved `<Model>Definition`; validations via transient `Model.new(slice).valid?` (errors kept on imported fields + `:base`); inherited `form_layout`.
|
|
146
|
+
|
|
147
|
+
| Selector / flag | Effect |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `fields:` (alias `only:`) | Import only these. |
|
|
150
|
+
| `except:` | Import all but these. |
|
|
151
|
+
| `validate: false` | Skip validation reuse (write inline `validates`). |
|
|
152
|
+
| `layout: false` | Skip inherited `form_layout`. |
|
|
153
|
+
| `validation_context:` | Run `valid?(context)`. |
|
|
154
|
+
|
|
155
|
+
**Declaration reuse only** — never the model's persistence. Data stages into `data`; `execute` does the writes.
|
|
156
|
+
|
|
157
|
+
## Step internals
|
|
158
|
+
|
|
159
|
+
Inside a `step` block, the field DSL from [[plutonium-resource]] applies verbatim:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
step :company, label: "Company details" do
|
|
163
|
+
attribute :name, :string # typed → feeds data.company.name
|
|
164
|
+
input :name # how it renders
|
|
165
|
+
validates :name, presence: true # ActiveModel, run on Next
|
|
166
|
+
|
|
167
|
+
structured_input :invites, repeat: 5 do |f| # data.company.invites → array of typed sub-objects
|
|
168
|
+
f.input :email, as: :email
|
|
169
|
+
f.input :role, as: :select, choices: %w[admin member]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
form_layout do # section THIS step's fields
|
|
173
|
+
section :identity, :name, label: "Identity", columns: 2
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Repeater rows rehydrate from staged `data` on GET (resume / back re-renders filled rows).
|
|
179
|
+
|
|
180
|
+
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.)
|
|
181
|
+
|
|
182
|
+
## Attachment fields (file uploads)
|
|
183
|
+
|
|
184
|
+
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:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
step :photo, label: "Photo" do
|
|
188
|
+
attribute :photo, :string
|
|
189
|
+
input :photo, as: :file # or as: :uppy / as: :attachment
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
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):
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
def execute
|
|
197
|
+
member = Member.create!(name: data.profile.name)
|
|
198
|
+
member.photo.attach(data.photo.photo) if data.photo.photo.present? # ActiveStorage
|
|
199
|
+
# active_shrine: Member.create!(photo: data.photo.photo)
|
|
200
|
+
succeed(member)
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
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.
|
|
205
|
+
|
|
206
|
+
**Two upload modes** — same field, differ only by `direct_upload:`:
|
|
207
|
+
|
|
208
|
+
| Mode | Declare | Notes |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| **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. |
|
|
211
|
+
| **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`). |
|
|
212
|
+
|
|
213
|
+
- **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).
|
|
214
|
+
- **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.)
|
|
215
|
+
- **Multiple:** an array attribute + `multiple: true` → the staged value is an array of tokens.
|
|
216
|
+
- **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).
|
|
217
|
+
|
|
218
|
+
## The review step
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
review label: "Review & submit" # auto-summary + gated finish
|
|
222
|
+
|
|
223
|
+
review label: "Review & submit" do |wizard| # custom content BELOW the summary
|
|
224
|
+
"By submitting you agree to the #{wizard.data.plan.plan} plan terms."
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
review summary: false, header: false # fully chromeless → "ready to complete" panel
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Always lists invalid/unvisited steps as fix-this jump links; Finish disabled until all visible steps valid. Declares no fields. The body is a small state machine:
|
|
231
|
+
|
|
232
|
+
| State | Body |
|
|
233
|
+
|---|---|
|
|
234
|
+
| **Incomplete** | outstanding fix-this links **+** auto-summary of what's entered |
|
|
235
|
+
| **Complete**, `summary: true` (default) | auto-summary; custom block (if any) renders **below** it |
|
|
236
|
+
| **Complete**, `summary: false` + block | block **replaces** the summary |
|
|
237
|
+
| **Complete**, `summary: false`, no block | built-in "ready to complete" panel |
|
|
238
|
+
|
|
239
|
+
- `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.
|
|
240
|
+
- `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.
|
|
241
|
+
|
|
242
|
+
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.
|
|
243
|
+
|
|
244
|
+
## Per-step writes — `on_submit` / `persist` / `on_rollback`
|
|
245
|
+
|
|
246
|
+
`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).
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
step :billing, label: "Billing" do
|
|
250
|
+
attribute :card_token, :string
|
|
251
|
+
input :card_token
|
|
252
|
+
validates :card_token, presence: true
|
|
253
|
+
|
|
254
|
+
on_submit do # runs when THIS step completes, own transaction
|
|
255
|
+
charge = PaymentApi.authorize!(anchor, data.billing.card_token)
|
|
256
|
+
fail!("Card was declined") unless charge.ok? # base error; fail!(:field, "msg") for field error
|
|
257
|
+
persist Billing.create!(company: anchor, token: data.billing.card_token) # → persisted[:billing]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# on_rollback = ADDITIONAL cleanup of UNTRACKED side effects (refund/external API).
|
|
261
|
+
# The engine ALWAYS destroys persisted[:billing] itself; this runs BEFORE that
|
|
262
|
+
# destroy (record still alive). Don't destroy the persist'd record here.
|
|
263
|
+
on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }
|
|
264
|
+
end
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**`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`.
|
|
268
|
+
|
|
269
|
+
`on_submit` is not atomic across steps (HTTP), which is why `cleanup_after` + `SweepJob` exist.
|
|
270
|
+
|
|
271
|
+
## Accessors
|
|
272
|
+
|
|
273
|
+
| Accessor | Returns |
|
|
274
|
+
|---|---|
|
|
275
|
+
| `data` / `data.<step>.<field>` | Typed snapshot of everything entered, **step-keyed** (read through the owning step). Not-yet-collected → `nil` / `default:`. Read-only. |
|
|
276
|
+
| `anchor` | The launched-against record. Raises `NotAnchoredError` if not `anchored` (never nil). |
|
|
277
|
+
| `persisted[:step_key]` | Record(s) registered via `persist` in `on_submit`. Rehydrated on resume. |
|
|
278
|
+
| `succeed(v)` / `failed(errs)` | Outcome helpers (alias `success`). `.with_message`, `.with_redirect_response` chainable. |
|
|
279
|
+
| `fail!(msg)` / `fail!(:field, msg)` | Raise a `StepError` from `on_submit`/`execute`. |
|
|
280
|
+
|
|
281
|
+
## Anchoring
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
anchored with: Company # single type
|
|
285
|
+
anchored with: [Company, Org] # polymorphic
|
|
286
|
+
anchored # generic — type bound at registration
|
|
287
|
+
# omit # pure create flow (no anchor)
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
`anchor` raises `Plutonium::Wizard::NotAnchoredError` when the wizard isn't `anchored`. The anchor is read-only context, **not** part of `persisted`.
|
|
291
|
+
|
|
292
|
+
## One-time wizards + gate
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
class WelcomeWizard < Plutonium::Wizard::Base
|
|
296
|
+
presents label: "Welcome"
|
|
297
|
+
|
|
298
|
+
concurrency_key { current_user } # stable row to retain (tenant folded in)
|
|
299
|
+
one_time # retain on complete → run once
|
|
300
|
+
|
|
301
|
+
step :greeting do
|
|
302
|
+
attribute :acknowledged, :string
|
|
303
|
+
input :acknowledged
|
|
304
|
+
validates :acknowledged, presence: true
|
|
305
|
+
end
|
|
306
|
+
review label: "Review"
|
|
307
|
+
|
|
308
|
+
def execute = succeed.with_message("Welcome aboard!")
|
|
309
|
+
|
|
310
|
+
def authorize? # standalone wizards have no resource policy
|
|
311
|
+
current_user.present?
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Gate a controller behind it:
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
module AdminPortal
|
|
320
|
+
class DashboardController < AdminPortal::PlutoniumController
|
|
321
|
+
include Plutonium::Wizard::Gate
|
|
322
|
+
ensure_wizard_completed ::WelcomeWizard
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
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".
|
|
328
|
+
|
|
329
|
+
**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.
|
|
330
|
+
|
|
331
|
+
**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).
|
|
332
|
+
|
|
333
|
+
## Registration & launch
|
|
334
|
+
|
|
335
|
+
**(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:
|
|
336
|
+
|
|
337
|
+
```ruby
|
|
338
|
+
class CompanyDefinition < Plutonium::Resource::Definition
|
|
339
|
+
wizard :configure, ConfigureCompanyWizard # anchored → record action: /companies/:id/wizards/configure/:step
|
|
340
|
+
wizard :onboard, CompanyOnboardingWizard # no anchor → resource action: /companies/wizards/onboard/:step
|
|
341
|
+
end
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
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?`).
|
|
345
|
+
|
|
346
|
+
**(b) Route-mounted** — `register_wizard`, in a **portal** engine's routes or on the **main app**, alongside `register_resource`:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
AdminPortal::Engine.routes.draw do
|
|
350
|
+
register_wizard ::OnboardOrganizationWizard, at: "onboarding" # portal, in-shell (default)
|
|
351
|
+
register_wizard ::SetupOrgWizard, at: "setup", layout: :basic # portal, bare (BasicLayout)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
Rails.application.routes.draw do
|
|
355
|
+
register_wizard ::AppOnboardingWizard, at: "onboarding" # main app, :basic (default)
|
|
356
|
+
end
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Draws `GET /onboarding` (canonical launch) + `GET/POST /onboarding(/:token)/:step` + an `onboarding_wizard_path` helper, within the host (portal or app).
|
|
360
|
+
|
|
361
|
+
| `register_wizard` option | Meaning |
|
|
362
|
+
|---|---|
|
|
363
|
+
| `at:` (required) | Host-relative base path for the steps. |
|
|
364
|
+
| `as:` | Override the route-helper prefix (defaults to `at:`, then the wizard's name). |
|
|
365
|
+
| `public:` | Mount on a **public (unauthenticated)** route for an `anonymous` wizard. Defaults to the wizard's `anonymous?` flag. |
|
|
366
|
+
| `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. |
|
|
367
|
+
|
|
368
|
+
### Hosting & the controller override hook
|
|
369
|
+
|
|
370
|
+
`register_wizard` dispatches to a wizard controller. **If you've defined one, it's used; otherwise it's synthesized** (same "app owns the controller" contract as `register_resource`):
|
|
371
|
+
|
|
372
|
+
| Host | Controller used | Auth |
|
|
373
|
+
|---|---|---|
|
|
374
|
+
| Portal | `<Portal>::WizardsController` if defined, else synthesized on the portal's `PlutoniumController` | the portal's (inherited) |
|
|
375
|
+
| Main app, **authenticated** | `::WizardsController` — **you must define it** | **yours** — `include Plutonium::Auth::Rodauth(:account)` |
|
|
376
|
+
| Main app, **public** (`anonymous`) | synthesized `::PublicWizardsController` (bare + `Auth::Public`) | none (guest) |
|
|
377
|
+
|
|
378
|
+
```ruby
|
|
379
|
+
# Authenticated main-app wizard ⇒ define the controller yourself.
|
|
380
|
+
class WizardsController < ApplicationController
|
|
381
|
+
include Plutonium::Wizard::Controller # the complete include surface (rendering + driving + view prefix)
|
|
382
|
+
include Plutonium::Auth::Rodauth(:user) # supplies current_user; the synthesized bare fallback has NONE
|
|
383
|
+
end
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
`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.
|
|
387
|
+
|
|
388
|
+
> [!TIP]
|
|
389
|
+
> **`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`).
|
|
390
|
+
|
|
391
|
+
> [!DANGER]
|
|
392
|
+
> **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).
|
|
393
|
+
|
|
394
|
+
## Authentication
|
|
395
|
+
|
|
396
|
+
**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).
|
|
397
|
+
|
|
398
|
+
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.
|
|
399
|
+
|
|
400
|
+
Opt into guest access with `anonymous`, and mount it on a **public route**:
|
|
401
|
+
|
|
402
|
+
```ruby
|
|
403
|
+
class GuestSignupWizard < Plutonium::Wizard::Base
|
|
404
|
+
anonymous # runs pre-login; identity = a server-minted run-id in the Rails session
|
|
405
|
+
step(:account) { attribute :email, :string; input :email; validates :email, presence: true }
|
|
406
|
+
review label: "Review"
|
|
407
|
+
def execute # the ONE boundary it may cross
|
|
408
|
+
succeed(Account.create!(email: data.account.email)) # may also sign the user in (host calls Rodauth)
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# in a portal's routes — drawn on a public (unauthenticated) route automatically
|
|
413
|
+
register_wizard ::GuestSignupWizard, at: "signup", public: true
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
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`.
|
|
417
|
+
|
|
418
|
+
## Listing in-progress wizards
|
|
419
|
+
|
|
420
|
+
`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.)
|
|
421
|
+
|
|
422
|
+
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 — `resource_url_for(record, wizard:, step:)` for a `wizard`-macro **anchored** mount, the named route for a `register_wizard` mount; `nil` + `resume_unresolved_reason` when the row can't be resolved here (e.g. a non-anchored `wizard`-macro run). **Narrowing.** For the per-record / per-wizard resume widget ("does this record have an unfinished draft of wizard X?"), pass the optional `anchor:`/`wizard:` filters — they narrow **in the query, before enrichment**, so discarded rows are never URL-resolved or anchor-loaded (cheaper than `select`-ing the array, which enriches every row first). They compose, and the `wizard + anchor` pair is index-covered: `…in_progress_for(vc, wizard: ConfigureCompanyWizard, anchor: company).first`. Don't reach into `e.session.anchor` to filter (a polymorphic load per row). For ad-hoc post-filtering the array still works — `e.wizard_class` is already on each entry.
|
|
423
|
+
|
|
424
|
+
## Storage & config
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
# config/initializers/plutonium.rb
|
|
428
|
+
Plutonium.configure do |config|
|
|
429
|
+
config.wizards.enabled = true # false by default — required
|
|
430
|
+
config.wizards.cleanup_after = 14.days # global default sweep TTL
|
|
431
|
+
config.wizards.encrypt_data = false # encrypt every wizard's data at rest (needs AR encryption keys)
|
|
432
|
+
config.wizards.database = :primary # reserved — v1 supports :primary only (else raises at boot)
|
|
433
|
+
config.wizards.attachment_backend = nil # server-side attachment staging backend (nil = auto-detect active_shrine/AS)
|
|
434
|
+
end
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
- One framework table `plutonium_wizard_sessions` (gem-shipped migration, runs in place on `rails db:migrate`). No changes to your models.
|
|
438
|
+
- DB-backed → resume across devices, in-progress listing, durable one-time markers.
|
|
439
|
+
- **`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`:
|
|
440
|
+
|
|
441
|
+
```yaml
|
|
442
|
+
# config/recurring.yml
|
|
443
|
+
wizard_sweep:
|
|
444
|
+
class: Plutonium::Wizard::SweepJob
|
|
445
|
+
schedule: every 15 minutes
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
(Or any recurring mechanism your app already has — sidekiq-cron, `whenever`, a cron'd rake task. `perform` takes no required args.)
|
|
449
|
+
|
|
450
|
+
## Common gotchas
|
|
451
|
+
|
|
452
|
+
- **Forgot `config.wizards.enabled`** → no table, nothing works.
|
|
453
|
+
- **Non-bang `create`/`update` in `execute`/`on_submit`** → silent advance, lost data. Use `create!`/`update!` or `fail!`.
|
|
454
|
+
- **`condition:` not nil-safe** → raises on the value before its step is filled.
|
|
455
|
+
- **`review` not last** → load-time error.
|
|
456
|
+
- **`using:` a definition or interaction** → `using:` is model-only.
|
|
457
|
+
- **`one_time` without `concurrency_key`** → raises (no stable row to retain).
|
|
458
|
+
- **`anonymous` + `concurrency_key`/`one_time`** → raises (a guest is already session-keyed; whichever is declared last raises).
|
|
459
|
+
- **`encrypt_data` without AR encryption keys** → first write raises (naming the wizard). Run `bin/rails db:encryption:init`.
|
|
460
|
+
- **Gating a non-one-time wizard** (`ensure_wizard_completed` on a repeatable wizard) → raises.
|
|
461
|
+
- **`on_submit` wizard without scheduled SweepJob** → abandoned partial records pile up.
|
|
462
|
+
- **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.
|
|
463
|
+
|
|
464
|
+
## Related Skills
|
|
465
|
+
|
|
466
|
+
- [[plutonium-resource]] — the `attribute`/`input`/`validates`/`structured_input`/`form_layout` field DSL used inside a step.
|
|
467
|
+
- [[plutonium-behavior]] — Outcomes (`succeed`/`failed`), the Action system the `wizard` macro builds on, policies.
|
|
468
|
+
- [[plutonium-app]] — portal engines and `register_wizard` placement (alongside `register_resource`).
|
|
469
|
+
- [[plutonium-testing]] — integration-testing wizard flows.
|
data/.cliff.toml
CHANGED
|
@@ -66,3 +66,9 @@ ignore_tags = ""
|
|
|
66
66
|
topo_order = false
|
|
67
67
|
# sort the commits inside sections by oldest/newest order
|
|
68
68
|
sort_commits = "oldest"
|
|
69
|
+
|
|
70
|
+
[bump]
|
|
71
|
+
# Pre-1.0 semver: a feature bumps the minor, a breaking change also bumps the
|
|
72
|
+
# minor (not the major) while we're on 0.x. Fixes bump the patch.
|
|
73
|
+
features_always_bump_minor = true
|
|
74
|
+
breaking_always_bump_major = false
|
data/Appraisals
CHANGED
|
@@ -13,6 +13,7 @@ appraise "rails-7" do
|
|
|
13
13
|
gem "bcrypt"
|
|
14
14
|
gem "rotp"
|
|
15
15
|
gem "rqrcode"
|
|
16
|
+
gem "active_shrine" # exercises the Shrine attachment backend in the dummy
|
|
16
17
|
gem "tzinfo-data", platforms: %i[windows jruby]
|
|
17
18
|
end
|
|
18
19
|
|
|
@@ -31,6 +32,7 @@ appraise "rails-8.0" do
|
|
|
31
32
|
gem "bcrypt"
|
|
32
33
|
gem "rotp"
|
|
33
34
|
gem "rqrcode"
|
|
35
|
+
gem "active_shrine" # exercises the Shrine attachment backend in the dummy
|
|
34
36
|
gem "tzinfo-data", platforms: %i[windows jruby]
|
|
35
37
|
end
|
|
36
38
|
|
|
@@ -49,5 +51,6 @@ appraise "rails-8.1" do
|
|
|
49
51
|
gem "bcrypt"
|
|
50
52
|
gem "rotp"
|
|
51
53
|
gem "rqrcode"
|
|
54
|
+
gem "active_shrine" # exercises the Shrine attachment backend in the dummy
|
|
52
55
|
gem "tzinfo-data", platforms: %i[windows jruby]
|
|
53
56
|
end
|