plutonium 0.60.5 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +313 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +549 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +895 -193
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +24 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +447 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +62 -0
- data/docs/reference/kanban/dsl.md +293 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +16 -0
- data/docs/reference/ui/forms.md +36 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +332 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +9 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +4 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +54 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +69 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +2 -0
- data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +8 -0
- data/lib/plutonium/routing/mapper_extensions.rb +44 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +19 -5
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +6 -3
- data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
- data/lib/plutonium/ui/form/resource.rb +1 -1
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +16 -5
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +324 -0
- data/lib/plutonium/ui/kanban/resource.rb +212 -0
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/page/index.rb +1 -0
- data/lib/plutonium/ui/page/show.rb +23 -0
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +3 -3
- data/src/css/components.css +132 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/kanban_controller.js +330 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +6 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +271 -282
- metadata +100 -1
|
@@ -0,0 +1,1619 @@
|
|
|
1
|
+
# Wizard DSL Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build the `Plutonium::Wizard` subsystem — a declarative, DB-backed, multi-step data-capture wizard — per `docs/superpowers/specs/2026-06-15-wizard-dsl-design.md`.
|
|
6
|
+
|
|
7
|
+
**Architecture:** A self-contained data-capture wizard. A wizard class declares ordered `step`s (own fields, or `using:` an interaction/definition), branches via `condition:`, stages typed `data` in one framework table (`plutonium_wizard_sessions`), and commits at the end via `execute` (or per-step `on_submit`/`persist`/`on_rollback`). A single controller drives all surfaces (record action, collection, standalone, one-time); identity is a derived `instance_key` digest; cleanup is TTL-swept.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby, Rails (7.2/8.0/8.1 via Appraisal), ActiveModel::Attributes, Phlex (Phlexi forms/display), Stimulus, Minitest. Reuses `Plutonium::Interaction`, `Plutonium::Definition` (DefineableProps/FormLayout/StructuredInputs), `Plutonium::Action`, `Plutonium::Routing`, `Plutonium::UI`.
|
|
10
|
+
|
|
11
|
+
**User Verification:** NO — the originating request ("explore a DSL for creating wizards") requires no human-in-the-loop validation of outcomes; correctness is verified by the test suite.
|
|
12
|
+
|
|
13
|
+
**Spec reference:** `docs/superpowers/specs/2026-06-15-wizard-dsl-design.md` — section numbers (§N) below point into it. Read it before starting.
|
|
14
|
+
|
|
15
|
+
**Conventions for every task:** TDD (write failing test → run red → implement → run green → commit). Run a focused test with `bundle exec appraisal rails-8.1 ruby -Itest <file>`; run the suite with `bundle exec appraisal rails-8.1 rake test`. Use `with_connection` for DB access; bang methods (`create!`) in examples; register Stimulus controllers; inline indexes in `create_table`.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## File Structure
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
lib/plutonium/wizard.rb # namespace + autoloads + error classes
|
|
23
|
+
lib/plutonium/wizard/errors.rb # NotAnchoredError, StepError
|
|
24
|
+
lib/plutonium/wizard/configuration.rb # WizardConfiguration (enabled/cleanup_after/database)
|
|
25
|
+
lib/plutonium/migrations.rb # per-feature migration-path registry
|
|
26
|
+
db/migrate/wizard/<ts>_create_plutonium_wizard_sessions.rb
|
|
27
|
+
app/models/plutonium/wizard/session.rb # AR model (polymorphic owner/anchor/scope, instance_key, json, encrypts)
|
|
28
|
+
lib/plutonium/wizard/state.rb # value object: wizard, current_step, data, persisted, owner/anchor/scope/token
|
|
29
|
+
lib/plutonium/wizard/instance_key.rb # digest recipe
|
|
30
|
+
lib/plutonium/wizard/store/base.rb # port
|
|
31
|
+
lib/plutonium/wizard/store/active_record.rb # shipped store
|
|
32
|
+
lib/plutonium/wizard/store/memory.rb # test store
|
|
33
|
+
lib/plutonium/wizard/step.rb # step metadata value object
|
|
34
|
+
lib/plutonium/wizard/review_step.rb # terminal review step
|
|
35
|
+
lib/plutonium/wizard/data.rb # typed, dot-accessible snapshot builder
|
|
36
|
+
lib/plutonium/wizard/field_importer.rb # resolves using: (interaction/definition)
|
|
37
|
+
lib/plutonium/wizard/dsl.rb # step/review/anchored/navigation/cleanup_after/one_time/encrypt_data macros
|
|
38
|
+
lib/plutonium/wizard/base.rb # author class
|
|
39
|
+
lib/plutonium/wizard/runner.rb # path computation, validation, on_submit/execute, completeness/prune, lock, cleanup
|
|
40
|
+
lib/plutonium/wizard/sweep_job.rb # abandonment sweep
|
|
41
|
+
lib/plutonium/wizard/gate.rb # ensure_wizard_completed controller concern
|
|
42
|
+
lib/plutonium/definition/wizards.rb # `wizard` DSL macro (mixed into Definition::Base)
|
|
43
|
+
lib/plutonium/routing/wizard_registration.rb # register_wizard + per-resource wizard routes
|
|
44
|
+
app/controllers/plutonium/wizard/controller.rb # single controller mixin
|
|
45
|
+
lib/plutonium/ui/page/wizard.rb # page class
|
|
46
|
+
lib/plutonium/ui/wizard/stepper.rb # stepper component
|
|
47
|
+
lib/plutonium/ui/wizard/review.rb # review auto-summary component
|
|
48
|
+
test/plutonium/wizard/*_test.rb # unit tests (Memory store)
|
|
49
|
+
test/integration/.../wizard_*_test.rb # dummy-app integration tests
|
|
50
|
+
.claude/skills/plutonium-wizard/SKILL.md # skill
|
|
51
|
+
docs/guides/wizards.md + docs/reference/wizard/*.md
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Each task below is a coherent, committable unit. TDD cycles happen inside a task.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
### Task 0: Namespace, errors, configuration, migrations registry
|
|
59
|
+
|
|
60
|
+
**Goal:** The plumbing every later task depends on — namespace + autoloading, error classes, namespaced `config.wizards`, and the per-feature migration registry + Railtie hook (no table yet).
|
|
61
|
+
|
|
62
|
+
**Files:**
|
|
63
|
+
- Create: `lib/plutonium/wizard.rb`, `lib/plutonium/wizard/errors.rb`, `lib/plutonium/wizard/configuration.rb`, `lib/plutonium/migrations.rb`
|
|
64
|
+
- Modify: `lib/plutonium/configuration.rb` (add `wizards` nested config), `lib/plutonium/railtie.rb` (migrations initializer), `lib/plutonium.rb` (require wizard namespace if not zeitwerk-autoloaded)
|
|
65
|
+
- Test: `test/plutonium/wizard/configuration_test.rb`, `test/plutonium/migrations_test.rb`
|
|
66
|
+
|
|
67
|
+
**Acceptance Criteria:**
|
|
68
|
+
- [ ] `Plutonium.configuration.wizards.enabled` defaults to `false`; `.cleanup_after` defaults to `30.days`; `.database` defaults to `:primary`.
|
|
69
|
+
- [ ] `Plutonium::Wizard::NotAnchoredError` and `Plutonium::Wizard::StepError` exist (both `< StandardError`).
|
|
70
|
+
- [ ] `Plutonium::Migrations.register(:wizard, path)` + `Plutonium::Migrations.enabled_paths` returns the wizard path only when `config.wizards.enabled`.
|
|
71
|
+
- [ ] The Railtie initializer is declared `after: :load_config_initializers`.
|
|
72
|
+
|
|
73
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/configuration_test.rb` → PASS
|
|
74
|
+
|
|
75
|
+
**Steps:**
|
|
76
|
+
|
|
77
|
+
- [ ] **Step 1: Failing test — config defaults + migrations gating**
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# test/plutonium/wizard/configuration_test.rb
|
|
81
|
+
require "test_helper"
|
|
82
|
+
|
|
83
|
+
class Plutonium::Wizard::ConfigurationTest < Minitest::Test
|
|
84
|
+
def setup
|
|
85
|
+
@config = Plutonium::Wizard::Configuration.new
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_defaults
|
|
89
|
+
refute @config.enabled
|
|
90
|
+
assert_equal 30.days, @config.cleanup_after
|
|
91
|
+
assert_equal :primary, @config.database
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_error_classes
|
|
95
|
+
assert Plutonium::Wizard::NotAnchoredError < StandardError
|
|
96
|
+
assert Plutonium::Wizard::StepError < StandardError
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
# test/plutonium/migrations_test.rb
|
|
103
|
+
require "test_helper"
|
|
104
|
+
|
|
105
|
+
class Plutonium::MigrationsTest < Minitest::Test
|
|
106
|
+
def setup
|
|
107
|
+
Plutonium::Migrations.reset!
|
|
108
|
+
Plutonium::Migrations.register(:wizard, "/gem/db/migrate/wizard")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def test_enabled_paths_gated_by_config
|
|
112
|
+
Plutonium.configuration.wizards.enabled = false
|
|
113
|
+
assert_empty Plutonium::Migrations.enabled_paths
|
|
114
|
+
Plutonium.configuration.wizards.enabled = true
|
|
115
|
+
assert_includes Plutonium::Migrations.enabled_paths, "/gem/db/migrate/wizard"
|
|
116
|
+
ensure
|
|
117
|
+
Plutonium.configuration.wizards.enabled = false
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- [ ] **Step 2: Run red** — `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/configuration_test.rb` → FAIL (NameError).
|
|
123
|
+
|
|
124
|
+
- [ ] **Step 3: Implement errors + namespace**
|
|
125
|
+
|
|
126
|
+
```ruby
|
|
127
|
+
# lib/plutonium/wizard/errors.rb
|
|
128
|
+
module Plutonium
|
|
129
|
+
module Wizard
|
|
130
|
+
# Raised by `anchor` on a wizard that was not declared `anchored`.
|
|
131
|
+
class NotAnchoredError < StandardError; end
|
|
132
|
+
|
|
133
|
+
# Raise inside on_submit/execute (usually via `fail!`) for a custom,
|
|
134
|
+
# non-ActiveRecord::RecordInvalid step failure. `attribute` defaults to :base.
|
|
135
|
+
class StepError < StandardError
|
|
136
|
+
attr_reader :attribute
|
|
137
|
+
|
|
138
|
+
def initialize(message = nil, attribute: :base)
|
|
139
|
+
@attribute = attribute
|
|
140
|
+
super(message)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
# lib/plutonium/wizard.rb
|
|
149
|
+
module Plutonium
|
|
150
|
+
module Wizard
|
|
151
|
+
# Eager-required; the rest is zeitwerk-autoloaded by the host engine/app.
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
require_relative "wizard/errors"
|
|
155
|
+
require_relative "wizard/configuration"
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- [ ] **Step 4: Implement configuration + wire into Configuration**
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# lib/plutonium/wizard/configuration.rb
|
|
162
|
+
module Plutonium
|
|
163
|
+
module Wizard
|
|
164
|
+
class Configuration
|
|
165
|
+
attr_accessor :enabled, :cleanup_after, :database
|
|
166
|
+
|
|
167
|
+
def initialize
|
|
168
|
+
@enabled = false
|
|
169
|
+
@cleanup_after = 30.days
|
|
170
|
+
@database = :primary
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
In `lib/plutonium/configuration.rb`, add to the `Configuration` class (mirroring the `@assets` precedent at lines 102-122):
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
attr_reader :wizards
|
|
181
|
+
|
|
182
|
+
# inside initialize:
|
|
183
|
+
@wizards = Plutonium::Wizard::Configuration.new
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Ensure `require_relative "wizard"` (or the errors/configuration files) loads before `Configuration#initialize` runs.
|
|
187
|
+
|
|
188
|
+
- [ ] **Step 5: Implement migrations registry**
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
# lib/plutonium/migrations.rb
|
|
192
|
+
module Plutonium
|
|
193
|
+
# Registry mapping a feature → its gem-shipped migration directory.
|
|
194
|
+
# The Railtie appends only enabled features' paths (see railtie.rb).
|
|
195
|
+
module Migrations
|
|
196
|
+
@registry = {}
|
|
197
|
+
|
|
198
|
+
class << self
|
|
199
|
+
# feature → gem subdir path
|
|
200
|
+
def register(feature, path)
|
|
201
|
+
@registry[feature.to_sym] = path
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def reset! = @registry = {}
|
|
205
|
+
|
|
206
|
+
# Paths whose feature flag is enabled. Each feature's flag lives under
|
|
207
|
+
# config.<feature> with an `.enabled` reader.
|
|
208
|
+
def enabled_paths
|
|
209
|
+
@registry.filter_map do |feature, path|
|
|
210
|
+
cfg = Plutonium.configuration.public_send(feature) if Plutonium.configuration.respond_to?(feature)
|
|
211
|
+
path if cfg&.respond_to?(:enabled) && cfg.enabled
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- [ ] **Step 6: Railtie initializer + register the wizard feature**
|
|
220
|
+
|
|
221
|
+
In `lib/plutonium/railtie.rb` add (per spec §10):
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
require "plutonium/migrations"
|
|
225
|
+
|
|
226
|
+
initializer "plutonium.register_migrations" do
|
|
227
|
+
Plutonium::Migrations.register(:wizards, Plutonium.root.join("db/migrate/wizard").to_s)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Runs AFTER config/initializers/* so config.wizards.enabled is set (railtie inits run before app inits).
|
|
231
|
+
initializer "plutonium.migrations", after: :load_config_initializers do |app|
|
|
232
|
+
Plutonium::Migrations.enabled_paths.each do |path|
|
|
233
|
+
db = Plutonium.configuration.wizards.database
|
|
234
|
+
if db == :primary
|
|
235
|
+
app.config.paths["db/migrate"] << path
|
|
236
|
+
end
|
|
237
|
+
ActiveRecord::Migrator.migrations_paths << path unless
|
|
238
|
+
ActiveRecord::Migrator.migrations_paths.include?(path)
|
|
239
|
+
# Multi-db: also register on the named database's migrations_paths if not primary.
|
|
240
|
+
# (Resolved lazily; see spec §10. For :primary the global path above suffices.)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
> NOTE: the registry keys on the config method name — register under `:wizards` (matching `config.wizards`), not `:wizard`. Update the test's `register(:wizard, ...)` → `register(:wizards, ...)` and the assertion accordingly.
|
|
246
|
+
|
|
247
|
+
- [ ] **Step 7: Run green** — `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/configuration_test.rb test/plutonium/migrations_test.rb` → PASS
|
|
248
|
+
|
|
249
|
+
- [ ] **Step 8: Commit**
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
git add lib/plutonium/wizard.rb lib/plutonium/wizard/errors.rb lib/plutonium/wizard/configuration.rb lib/plutonium/migrations.rb lib/plutonium/configuration.rb lib/plutonium/railtie.rb test/plutonium/wizard/configuration_test.rb test/plutonium/migrations_test.rb
|
|
253
|
+
git commit -m "feat(wizard): namespace, errors, config.wizards, migrations registry"
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
```json:metadata
|
|
257
|
+
{"files": ["lib/plutonium/wizard.rb", "lib/plutonium/wizard/errors.rb", "lib/plutonium/wizard/configuration.rb", "lib/plutonium/migrations.rb", "lib/plutonium/configuration.rb", "lib/plutonium/railtie.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/configuration_test.rb test/plutonium/migrations_test.rb", "acceptanceCriteria": ["config.wizards defaults (enabled=false, cleanup_after=30.days, database=:primary)", "NotAnchoredError + StepError exist", "Migrations.enabled_paths gated by config", "railtie initializer after :load_config_initializers"], "requiresUserVerification": false}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
---
|
|
261
|
+
|
|
262
|
+
### Task 1: Migration, Session model, instance_key, State, Store (Memory + ActiveRecord)
|
|
263
|
+
|
|
264
|
+
**Goal:** Persistence layer — the table, the AR model, the identity digest, the `State` value object, and the Store port with both adapters. This is the foundation the runner uses.
|
|
265
|
+
|
|
266
|
+
**Files:**
|
|
267
|
+
- Create: `db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb`, `app/models/plutonium/wizard/session.rb`, `lib/plutonium/wizard/instance_key.rb`, `lib/plutonium/wizard/state.rb`, `lib/plutonium/wizard/store/base.rb`, `lib/plutonium/wizard/store/memory.rb`, `lib/plutonium/wizard/store/active_record.rb`
|
|
268
|
+
- Test: `test/plutonium/wizard/instance_key_test.rb`, `test/plutonium/wizard/store/memory_test.rb`, `test/plutonium/wizard/store/active_record_test.rb`
|
|
269
|
+
|
|
270
|
+
**Acceptance Criteria:**
|
|
271
|
+
- [ ] Migration creates `plutonium_wizard_sessions` with all columns/indexes from spec §8.1 (polymorphic owner/anchor/scope, `instance_key` unique, `status`, `current_step`, `data`/`persisted` json, `expires_at`, `completed_at`, timestamps; sweep/listing/once-per indexes).
|
|
272
|
+
- [ ] `InstanceKey.for(wizard:, scope:, anchor:, token:, owner:)` == `SHA256("#{wizard}|#{scope_gid}|#{anchor_gid}|#{token.presence || owner_gid}")`, blanks for nils, **owner excluded when token present**.
|
|
273
|
+
- [ ] `Store::Memory` and `Store::ActiveRecord` both satisfy the port: `read/write/complete/clear/completed?/in_progress_for` with identical behavior (shared test module).
|
|
274
|
+
- [ ] `write` upserts by `instance_key`, sets owner/anchor/scope/token columns and `expires_at = now + cleanup_after`.
|
|
275
|
+
- [ ] `complete` sets `status: "completed"`, `completed_at`, nulls `data`/`persisted`.
|
|
276
|
+
|
|
277
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/store/active_record_test.rb` → PASS
|
|
278
|
+
|
|
279
|
+
**Steps:**
|
|
280
|
+
|
|
281
|
+
- [ ] **Step 1: Failing test — instance_key recipe**
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
# test/plutonium/wizard/instance_key_test.rb
|
|
285
|
+
require "test_helper"
|
|
286
|
+
|
|
287
|
+
class Plutonium::Wizard::InstanceKeyTest < Minitest::Test
|
|
288
|
+
def key(**kw) = Plutonium::Wizard::InstanceKey.for(**kw)
|
|
289
|
+
|
|
290
|
+
def test_token_excludes_owner
|
|
291
|
+
with_token = key(wizard: "W", scope: nil, anchor: nil, token: "abc", owner: nil)
|
|
292
|
+
after_auth = key(wizard: "W", scope: nil, anchor: nil, token: "abc", owner: gid("User", 1))
|
|
293
|
+
assert_equal with_token, after_auth, "owner must not change the digest when a token is present"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def test_owner_principal_without_token
|
|
297
|
+
a = key(wizard: "W", scope: nil, anchor: nil, token: nil, owner: gid("User", 1))
|
|
298
|
+
b = key(wizard: "W", scope: nil, anchor: nil, token: nil, owner: gid("User", 2))
|
|
299
|
+
refute_equal a, b
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def test_scope_distinguishes
|
|
303
|
+
a = key(wizard: "W", scope: gid("Org", 1), anchor: nil, token: nil, owner: gid("User", 1))
|
|
304
|
+
b = key(wizard: "W", scope: gid("Org", 2), anchor: nil, token: nil, owner: gid("User", 1))
|
|
305
|
+
refute_equal a, b
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def gid(type, id) = "gid://dummy/#{type}/#{id}"
|
|
309
|
+
end
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
- [ ] **Step 2: Run red** → FAIL.
|
|
313
|
+
|
|
314
|
+
- [ ] **Step 3: Implement instance_key**
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
# lib/plutonium/wizard/instance_key.rb
|
|
318
|
+
require "digest"
|
|
319
|
+
|
|
320
|
+
module Plutonium
|
|
321
|
+
module Wizard
|
|
322
|
+
module InstanceKey
|
|
323
|
+
# Identity digest. Token is the principal when present (so pre-auth→auth
|
|
324
|
+
# doesn't rekey); otherwise the owner GID is the principal. Scope + anchor
|
|
325
|
+
# always participate when present. Spec §4 / §17.13.
|
|
326
|
+
def self.for(wizard:, scope:, anchor:, token:, owner:)
|
|
327
|
+
principal = token.presence || gid(owner)
|
|
328
|
+
Digest::SHA256.hexdigest([wizard, gid(scope), gid(anchor), principal].map(&:to_s).join("|"))
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def self.gid(obj)
|
|
332
|
+
return obj if obj.nil? || obj.is_a?(String)
|
|
333
|
+
obj.to_global_id.to_s
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
- [ ] **Step 4: Run green (instance_key)** → PASS. Commit-worthy checkpoint.
|
|
341
|
+
|
|
342
|
+
- [ ] **Step 5: Migration** (per spec §8.1 — inline indexes; `jsonb` on PG via `connection.adapter_name`)
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
# db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb
|
|
346
|
+
class CreatePlutoniumWizardSessions < ActiveRecord::Migration[7.2]
|
|
347
|
+
def change
|
|
348
|
+
json_type = (connection.adapter_name =~ /postgres/i) ? :jsonb : :json
|
|
349
|
+
|
|
350
|
+
create_table :plutonium_wizard_sessions do |t|
|
|
351
|
+
t.string :wizard, null: false
|
|
352
|
+
t.string :status, null: false, default: "in_progress" # in_progress | completing | completed
|
|
353
|
+
t.string :current_step
|
|
354
|
+
|
|
355
|
+
t.string :instance_key, null: false
|
|
356
|
+
|
|
357
|
+
t.string :owner_type
|
|
358
|
+
t.string :owner_id
|
|
359
|
+
t.string :anchor_type
|
|
360
|
+
t.string :anchor_id
|
|
361
|
+
t.string :scope_type
|
|
362
|
+
t.string :scope_id
|
|
363
|
+
t.string :token
|
|
364
|
+
|
|
365
|
+
t.public_send(json_type, :data, null: false, default: {})
|
|
366
|
+
t.public_send(json_type, :persisted, null: false, default: {})
|
|
367
|
+
|
|
368
|
+
t.datetime :expires_at
|
|
369
|
+
t.datetime :completed_at
|
|
370
|
+
t.timestamps
|
|
371
|
+
|
|
372
|
+
t.index :instance_key, unique: true
|
|
373
|
+
t.index [:status, :expires_at]
|
|
374
|
+
t.index [:owner_type, :owner_id, :status]
|
|
375
|
+
t.index [:scope_type, :scope_id, :status]
|
|
376
|
+
t.index [:wizard, :anchor_type, :anchor_id, :status]
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Add to a dummy-app/CI setup: ensure the dummy app enables `config.wizards.enabled = true` so the migration runs in the test DB (mirror how other features are toggled in `test/dummy`).
|
|
383
|
+
|
|
384
|
+
- [ ] **Step 6: Session model**
|
|
385
|
+
|
|
386
|
+
```ruby
|
|
387
|
+
# app/models/plutonium/wizard/session.rb
|
|
388
|
+
module Plutonium
|
|
389
|
+
module Wizard
|
|
390
|
+
class Session < ActiveRecord::Base
|
|
391
|
+
self.table_name = "plutonium_wizard_sessions"
|
|
392
|
+
|
|
393
|
+
belongs_to :owner, polymorphic: true, optional: true
|
|
394
|
+
belongs_to :anchor, polymorphic: true, optional: true
|
|
395
|
+
belongs_to :scope, polymorphic: true, optional: true
|
|
396
|
+
|
|
397
|
+
enum :status, { in_progress: "in_progress", completing: "completing", completed: "completed" },
|
|
398
|
+
prefix: true
|
|
399
|
+
|
|
400
|
+
scope :sweepable, ->(now) {
|
|
401
|
+
where(status: %w[in_progress completing]).where.not(expires_at: nil).where(expires_at: ..now)
|
|
402
|
+
}
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
end
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
> Encryption (`encrypt_data`) is applied conditionally by the wizard class, not statically here — see Task 2/4. The model stays plaintext by default.
|
|
409
|
+
|
|
410
|
+
- [ ] **Step 7: State value object**
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
# lib/plutonium/wizard/state.rb
|
|
414
|
+
module Plutonium
|
|
415
|
+
module Wizard
|
|
416
|
+
# In-memory snapshot of one wizard instance's stored state.
|
|
417
|
+
State = Struct.new(
|
|
418
|
+
:wizard, :instance_key, :current_step, :status,
|
|
419
|
+
:data, :persisted, :owner, :anchor, :scope, :token
|
|
420
|
+
) do
|
|
421
|
+
def data = super || {}
|
|
422
|
+
def persisted = super || {}
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
- [ ] **Step 8: Store port + Memory + ActiveRecord (shared behavior test)**
|
|
429
|
+
|
|
430
|
+
```ruby
|
|
431
|
+
# lib/plutonium/wizard/store/base.rb
|
|
432
|
+
module Plutonium
|
|
433
|
+
module Wizard
|
|
434
|
+
module Store
|
|
435
|
+
class Base
|
|
436
|
+
def read(instance_key) = raise NotImplementedError
|
|
437
|
+
def write(instance_key, state, cleanup_after:) = raise NotImplementedError
|
|
438
|
+
def complete(instance_key) = raise NotImplementedError
|
|
439
|
+
def clear(instance_key) = raise NotImplementedError
|
|
440
|
+
def completed?(wizard:, owner: nil, anchor: nil) = raise NotImplementedError
|
|
441
|
+
def in_progress_for(owner) = raise NotImplementedError
|
|
442
|
+
end
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
# lib/plutonium/wizard/store/active_record.rb
|
|
450
|
+
module Plutonium
|
|
451
|
+
module Wizard
|
|
452
|
+
module Store
|
|
453
|
+
class ActiveRecord < Base
|
|
454
|
+
def read(instance_key)
|
|
455
|
+
row = Session.find_by(instance_key:)
|
|
456
|
+
row && to_state(row)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def write(instance_key, state, cleanup_after:)
|
|
460
|
+
row = Session.find_or_initialize_by(instance_key:)
|
|
461
|
+
row.wizard = state.wizard
|
|
462
|
+
row.current_step = state.current_step
|
|
463
|
+
row.status ||= "in_progress"
|
|
464
|
+
row.data = state.data
|
|
465
|
+
row.persisted = state.persisted
|
|
466
|
+
row.owner = state.owner
|
|
467
|
+
row.anchor = state.anchor
|
|
468
|
+
row.scope = state.scope
|
|
469
|
+
row.token = state.token
|
|
470
|
+
row.expires_at = cleanup_after ? Time.current + cleanup_after : nil
|
|
471
|
+
row.save!
|
|
472
|
+
to_state(row)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def complete(instance_key)
|
|
476
|
+
row = Session.find_by!(instance_key:)
|
|
477
|
+
row.update!(status: "completed", completed_at: Time.current, data: {}, persisted: {})
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def clear(instance_key) = Session.where(instance_key:).delete_all
|
|
481
|
+
|
|
482
|
+
def completed?(wizard:, owner: nil, anchor: nil)
|
|
483
|
+
scope = Session.status_completed.where(wizard: wizard.to_s)
|
|
484
|
+
scope = scope.where(owner:) if owner
|
|
485
|
+
scope = scope.where(anchor:) if anchor
|
|
486
|
+
scope.exists?
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
def in_progress_for(owner) = Session.status_in_progress.where(owner:).map { to_state(_1) }
|
|
490
|
+
|
|
491
|
+
private
|
|
492
|
+
|
|
493
|
+
def to_state(row)
|
|
494
|
+
State.new(
|
|
495
|
+
wizard: row.wizard, instance_key: row.instance_key, current_step: row.current_step,
|
|
496
|
+
status: row.status, data: row.data, persisted: row.persisted,
|
|
497
|
+
owner: row.owner, anchor: row.anchor, scope: row.scope, token: row.token
|
|
498
|
+
)
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
end
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
```ruby
|
|
507
|
+
# lib/plutonium/wizard/store/memory.rb
|
|
508
|
+
module Plutonium
|
|
509
|
+
module Wizard
|
|
510
|
+
module Store
|
|
511
|
+
class Memory < Base
|
|
512
|
+
def initialize = @rows = {}
|
|
513
|
+
def read(k) = @rows[k]&.dup
|
|
514
|
+
def write(k, state, cleanup_after:)
|
|
515
|
+
state = state.dup
|
|
516
|
+
state.instance_key = k
|
|
517
|
+
state.status ||= "in_progress"
|
|
518
|
+
@rows[k] = state
|
|
519
|
+
end
|
|
520
|
+
def complete(k)
|
|
521
|
+
s = @rows.fetch(k); s.status = "completed"; s.data = {}; s.persisted = {}; s
|
|
522
|
+
end
|
|
523
|
+
def clear(k) = @rows.delete(k)
|
|
524
|
+
def completed?(wizard:, owner: nil, anchor: nil)
|
|
525
|
+
@rows.values.any? { _1.status == "completed" && _1.wizard == wizard.to_s &&
|
|
526
|
+
(owner.nil? || _1.owner == owner) && (anchor.nil? || _1.anchor == anchor) }
|
|
527
|
+
end
|
|
528
|
+
def in_progress_for(owner) = @rows.values.select { _1.status == "in_progress" && _1.owner == owner }
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
```ruby
|
|
536
|
+
# test/plutonium/wizard/store/shared.rb (shared behavior)
|
|
537
|
+
module WizardStoreBehavior
|
|
538
|
+
def test_write_then_read_roundtrip
|
|
539
|
+
st = build_state(data: {"a" => 1})
|
|
540
|
+
@store.write(st.instance_key, st, cleanup_after: 1.day)
|
|
541
|
+
got = @store.read(st.instance_key)
|
|
542
|
+
assert_equal({"a" => 1}, got.data)
|
|
543
|
+
assert_equal "in_progress", got.status
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def test_complete_nulls_payload
|
|
547
|
+
st = build_state(data: {"a" => 1})
|
|
548
|
+
@store.write(st.instance_key, st, cleanup_after: 1.day)
|
|
549
|
+
@store.complete(st.instance_key)
|
|
550
|
+
assert_equal "completed", @store.read(st.instance_key).status
|
|
551
|
+
assert_empty @store.read(st.instance_key).data
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def test_completed_query
|
|
555
|
+
st = build_state
|
|
556
|
+
@store.write(st.instance_key, st, cleanup_after: 1.day)
|
|
557
|
+
@store.complete(st.instance_key)
|
|
558
|
+
assert @store.completed?(wizard: "W")
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def build_state(data: {})
|
|
562
|
+
Plutonium::Wizard::State.new(wizard: "W", instance_key: "key-#{data.hash}",
|
|
563
|
+
current_step: "one", data: data, persisted: {})
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
```ruby
|
|
569
|
+
# test/plutonium/wizard/store/memory_test.rb
|
|
570
|
+
require "test_helper"; require_relative "shared"
|
|
571
|
+
class Plutonium::Wizard::Store::MemoryTest < Minitest::Test
|
|
572
|
+
include WizardStoreBehavior
|
|
573
|
+
def setup = @store = Plutonium::Wizard::Store::Memory.new
|
|
574
|
+
end
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
```ruby
|
|
578
|
+
# test/plutonium/wizard/store/active_record_test.rb
|
|
579
|
+
require "test_helper"; require_relative "shared"
|
|
580
|
+
class Plutonium::Wizard::Store::ActiveRecordTest < ActiveSupport::TestCase
|
|
581
|
+
include WizardStoreBehavior
|
|
582
|
+
setup { @store = Plutonium::Wizard::Store::ActiveRecord.new }
|
|
583
|
+
# NOTE: build_state in shared uses fixed instance_key strings; AR store keys on instance_key column — OK.
|
|
584
|
+
end
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
- [ ] **Step 9: Run green** — both store tests + instance_key → PASS.
|
|
588
|
+
|
|
589
|
+
- [ ] **Step 10: Commit**
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
git add db/migrate/wizard app/models/plutonium/wizard/session.rb lib/plutonium/wizard/instance_key.rb lib/plutonium/wizard/state.rb lib/plutonium/wizard/store test/plutonium/wizard/instance_key_test.rb test/plutonium/wizard/store
|
|
593
|
+
git commit -m "feat(wizard): sessions table, Session model, instance_key, State, Store (memory + AR)"
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
```json:metadata
|
|
597
|
+
{"files": ["db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb", "app/models/plutonium/wizard/session.rb", "lib/plutonium/wizard/instance_key.rb", "lib/plutonium/wizard/state.rb", "lib/plutonium/wizard/store/base.rb", "lib/plutonium/wizard/store/memory.rb", "lib/plutonium/wizard/store/active_record.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/store/active_record_test.rb test/plutonium/wizard/instance_key_test.rb", "acceptanceCriteria": ["table + indexes per spec 8.1", "instance_key recipe (token principal, owner excluded when token present)", "Memory + AR stores satisfy shared behavior", "write upserts + stamps expires_at", "complete nulls payload"], "requiresUserVerification": false}
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
---
|
|
601
|
+
|
|
602
|
+
### Task 2: Wizard DSL — Base, Step, ReviewStep, typed `data`, anchoring, navigation, cleanup_after, one_time, encrypt_data
|
|
603
|
+
|
|
604
|
+
**Goal:** The author-facing class. Declaring `step`/`review`/`anchored`/`navigation`/`cleanup_after`/`one_time`/`encrypt_data`/`presents` works; a wizard exposes its ordered steps, union attribute schema, and a typed `data` snapshot; `anchor`/`persisted`/`fail!` accessors behave per spec. (No HTTP/runner yet — pure object behavior.)
|
|
605
|
+
|
|
606
|
+
**Files:**
|
|
607
|
+
- Create: `lib/plutonium/wizard/step.rb`, `lib/plutonium/wizard/review_step.rb`, `lib/plutonium/wizard/data.rb`, `lib/plutonium/wizard/dsl.rb`, `lib/plutonium/wizard/base.rb`
|
|
608
|
+
- Test: `test/plutonium/wizard/base_test.rb`, `test/plutonium/wizard/data_test.rb`
|
|
609
|
+
|
|
610
|
+
**Acceptance Criteria:**
|
|
611
|
+
- [ ] `step :k, label:, condition:` registers an ordered `Step`; the step block evaluates `attribute`/`input`/`validates`/`structured_input` into a per-step field surface (reusing `Definition::StructuredInputs::FieldsDefinition`-style capture).
|
|
612
|
+
- [ ] `review label:` registers a terminal `ReviewStep`; declaring any step after `review` raises at class-eval time (spec §2.5 terminality).
|
|
613
|
+
- [ ] `anchored with: T` / `with: [A, B]` / `anchored` / omit recorded; `anchor` accessor returns the bound record or raises `NotAnchoredError` when not anchored.
|
|
614
|
+
- [ ] `navigation` (default `:linear`), `cleanup_after` (default from config), `one_time once_per:`, `encrypt_data` recorded and readable.
|
|
615
|
+
- [ ] `data` is an ActiveModel::Attributes-backed snapshot over the **union** of all steps' attributes, cast to declared types, with uncollected fields `nil`; `data.foo` works; structured arrays yield typed sub-objects (`data.invites.first.email`).
|
|
616
|
+
- [ ] `fail!("m")` raises `StepError` (base); `fail!(:f, "m")` raises `StepError` with `attribute: :f`.
|
|
617
|
+
|
|
618
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/base_test.rb` → PASS
|
|
619
|
+
|
|
620
|
+
**Steps:**
|
|
621
|
+
|
|
622
|
+
- [ ] **Step 1: Failing test — DSL + data typing + anchor**
|
|
623
|
+
|
|
624
|
+
```ruby
|
|
625
|
+
# test/plutonium/wizard/base_test.rb
|
|
626
|
+
require "test_helper"
|
|
627
|
+
|
|
628
|
+
class Plutonium::Wizard::BaseTest < Minitest::Test
|
|
629
|
+
class CreateCo < Plutonium::Wizard::Base
|
|
630
|
+
step :company do
|
|
631
|
+
attribute :name, :string
|
|
632
|
+
attribute :employees, :integer
|
|
633
|
+
input :name
|
|
634
|
+
validates :name, presence: true
|
|
635
|
+
end
|
|
636
|
+
step :plan, condition: -> { data.name.present? } do
|
|
637
|
+
attribute :plan, :string
|
|
638
|
+
input :plan
|
|
639
|
+
end
|
|
640
|
+
review label: "Review"
|
|
641
|
+
def execute = succeed(true)
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def test_steps_ordered_and_terminal_review
|
|
645
|
+
keys = CreateCo.steps.map(&:key)
|
|
646
|
+
assert_equal %i[company plan review], keys
|
|
647
|
+
assert CreateCo.steps.last.review?
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def test_union_attribute_schema_and_typed_data
|
|
651
|
+
w = CreateCo.new
|
|
652
|
+
w.data_attributes = {"name" => "Acme", "employees" => "12"}
|
|
653
|
+
assert_equal "Acme", w.data.name
|
|
654
|
+
assert_equal 12, w.data.employees # cast to Integer
|
|
655
|
+
assert_nil w.data.plan # uncollected → nil
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
def test_review_must_be_last
|
|
659
|
+
err = assert_raises(ArgumentError) do
|
|
660
|
+
Class.new(Plutonium::Wizard::Base) do
|
|
661
|
+
review label: "R"
|
|
662
|
+
step(:after) { attribute :x, :string }
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
assert_match(/review.*last/i, err.message)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def test_anchor_raises_when_not_anchored
|
|
669
|
+
assert_raises(Plutonium::Wizard::NotAnchoredError) { CreateCo.new.anchor }
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def test_fail_bang
|
|
673
|
+
w = CreateCo.new
|
|
674
|
+
e = assert_raises(Plutonium::Wizard::StepError) { w.send(:fail!, "nope") }
|
|
675
|
+
assert_equal :base, e.attribute
|
|
676
|
+
e2 = assert_raises(Plutonium::Wizard::StepError) { w.send(:fail!, :name, "bad") }
|
|
677
|
+
assert_equal :name, e2.attribute
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
- [ ] **Step 2: Run red** → FAIL.
|
|
683
|
+
|
|
684
|
+
- [ ] **Step 3: Step + ReviewStep value objects**
|
|
685
|
+
|
|
686
|
+
```ruby
|
|
687
|
+
# lib/plutonium/wizard/step.rb
|
|
688
|
+
module Plutonium
|
|
689
|
+
module Wizard
|
|
690
|
+
class Step
|
|
691
|
+
attr_reader :key, :label, :condition, :fields, :on_submit, :on_rollback, :using_spec, :form_layout
|
|
692
|
+
|
|
693
|
+
def initialize(key:, label: nil, condition: nil, fields:, on_submit: nil,
|
|
694
|
+
on_rollback: nil, using_spec: nil, form_layout: nil)
|
|
695
|
+
@key = key
|
|
696
|
+
@label = label || key.to_s.humanize
|
|
697
|
+
@condition = condition
|
|
698
|
+
@fields = fields # FieldsDefinition-like: attributes/inputs/validations/structured
|
|
699
|
+
@on_submit = on_submit
|
|
700
|
+
@on_rollback = on_rollback
|
|
701
|
+
@using_spec = using_spec # FieldImporter::Spec or nil (Task 3)
|
|
702
|
+
@form_layout = form_layout
|
|
703
|
+
end
|
|
704
|
+
|
|
705
|
+
def review? = false
|
|
706
|
+
def attribute_schema = fields.attribute_schema # { name => type } (Task 2 fields capture)
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
```ruby
|
|
713
|
+
# lib/plutonium/wizard/review_step.rb
|
|
714
|
+
module Plutonium
|
|
715
|
+
module Wizard
|
|
716
|
+
class ReviewStep < Step
|
|
717
|
+
attr_reader :block
|
|
718
|
+
def initialize(key: :review, label: "Review", condition: nil, block: nil)
|
|
719
|
+
super(key:, label:, condition:, fields: EmptyFields.new)
|
|
720
|
+
@block = block
|
|
721
|
+
end
|
|
722
|
+
def review? = true
|
|
723
|
+
|
|
724
|
+
class EmptyFields
|
|
725
|
+
def attribute_schema = {}
|
|
726
|
+
end
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
- [ ] **Step 4: Field capture + typed `data`**
|
|
733
|
+
|
|
734
|
+
The step block is evaluated against a capture object that records `attribute`/`input`/`validates`/`structured_input`. Reuse the existing `Plutonium::Definition::StructuredInputs::FieldsDefinition` shape (it already includes DefineableProps for `field`/`input`); extend a small capture that *also* records `attribute :name, :type` (for the schema) and `validates` (for inline validation). Build it as:
|
|
735
|
+
|
|
736
|
+
```ruby
|
|
737
|
+
# lib/plutonium/wizard/data.rb
|
|
738
|
+
module Plutonium
|
|
739
|
+
module Wizard
|
|
740
|
+
# Builds a typed, dot-accessible snapshot class from a union attribute schema
|
|
741
|
+
# ({ name => type }). Backed by ActiveModel::Attributes so values are cast.
|
|
742
|
+
module Data
|
|
743
|
+
def self.class_for(schema)
|
|
744
|
+
Class.new do
|
|
745
|
+
include ActiveModel::Model
|
|
746
|
+
include ActiveModel::Attributes
|
|
747
|
+
schema.each { |name, type| attribute(name, type) }
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
In `Base`, the union schema = merge of every step's `attribute_schema` (inline + imported). `data` builds (memoized) an instance of `Data.class_for(union_schema)` from the staged `data_attributes` hash. Structured inputs declared with `repeat:` are typed as arrays of nested snapshot objects — back them with an ActiveModel attribute whose type casts an array of hashes into nested `Data` instances (use a small custom `ActiveModel::Type` or map in the reader). For v1, implement structured arrays as: store raw array-of-hashes, and expose `data.invites` as an array of `OpenStruct`-like typed wrappers built from the structured_input's sub-schema. Keep the wrapper minimal but typed per the sub-field declarations.
|
|
756
|
+
|
|
757
|
+
- [ ] **Step 5: DSL module + Base**
|
|
758
|
+
|
|
759
|
+
```ruby
|
|
760
|
+
# lib/plutonium/wizard/dsl.rb
|
|
761
|
+
module Plutonium
|
|
762
|
+
module Wizard
|
|
763
|
+
module DSL
|
|
764
|
+
extend ActiveSupport::Concern
|
|
765
|
+
|
|
766
|
+
class_methods do
|
|
767
|
+
def steps = @steps ||= []
|
|
768
|
+
|
|
769
|
+
def step(key, label: nil, condition: nil, using: nil, **using_opts, &block)
|
|
770
|
+
assert_not_after_review!(key)
|
|
771
|
+
fields = capture_fields(using:, using_opts:, &block)
|
|
772
|
+
on_submit = fields.delete_hook(:on_submit)
|
|
773
|
+
on_rollback = fields.delete_hook(:on_rollback)
|
|
774
|
+
steps << Step.new(key:, label:, condition:, fields:,
|
|
775
|
+
on_submit:, on_rollback:, using_spec: fields.using_spec, form_layout: fields.form_layout)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def review(label: "Review", condition: nil, &block)
|
|
779
|
+
steps << ReviewStep.new(label:, condition:, block:)
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
def anchored(with: nil, &resolver)
|
|
783
|
+
@anchored = true
|
|
784
|
+
@anchor_types = Array(with).presence
|
|
785
|
+
@anchor_resolver = resolver
|
|
786
|
+
end
|
|
787
|
+
def anchored? = !!@anchored
|
|
788
|
+
def anchor_types = @anchor_types
|
|
789
|
+
def anchor_resolver = @anchor_resolver
|
|
790
|
+
|
|
791
|
+
def navigation(mode = nil) = mode ? (@navigation = mode) : (@navigation || :linear)
|
|
792
|
+
def cleanup_after(ttl = :__read__)
|
|
793
|
+
return (@cleanup_after.nil? ? Plutonium.configuration.wizards.cleanup_after : @cleanup_after) if ttl == :__read__
|
|
794
|
+
@cleanup_after = (ttl == :never ? nil : ttl)
|
|
795
|
+
end
|
|
796
|
+
def one_time(once_per: :user) = @one_time = once_per
|
|
797
|
+
def one_time? = !@one_time.nil?
|
|
798
|
+
def one_time_scope = @one_time
|
|
799
|
+
def encrypt_data(flag = true) = @encrypt_data = flag
|
|
800
|
+
def encrypt_data? = !!@encrypt_data
|
|
801
|
+
|
|
802
|
+
private
|
|
803
|
+
|
|
804
|
+
def assert_not_after_review!(key)
|
|
805
|
+
if steps.any?(&:review?)
|
|
806
|
+
raise ArgumentError, "`review` must be the last step; cannot declare step :#{key} after it"
|
|
807
|
+
end
|
|
808
|
+
end
|
|
809
|
+
|
|
810
|
+
# Returns a fields-capture object; see Task 2 Step 4 + Task 3 for using:.
|
|
811
|
+
def capture_fields(using:, using_opts:, &block) = FieldCapture.build(using:, using_opts:, &block)
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
end
|
|
816
|
+
```
|
|
817
|
+
|
|
818
|
+
```ruby
|
|
819
|
+
# lib/plutonium/wizard/base.rb
|
|
820
|
+
module Plutonium
|
|
821
|
+
module Wizard
|
|
822
|
+
class Base
|
|
823
|
+
include ActiveModel::Model
|
|
824
|
+
include Plutonium::Definition::Presentable # presents label:/icon:/description:
|
|
825
|
+
include DSL
|
|
826
|
+
|
|
827
|
+
attr_accessor :data_attributes, :view_context
|
|
828
|
+
attr_writer :anchor, :scope, :token
|
|
829
|
+
|
|
830
|
+
def initialize(view_context: nil, **)
|
|
831
|
+
@view_context = view_context
|
|
832
|
+
@data_attributes = {}
|
|
833
|
+
super()
|
|
834
|
+
end
|
|
835
|
+
|
|
836
|
+
# Union schema across all (non-review) steps.
|
|
837
|
+
def self.union_attribute_schema
|
|
838
|
+
steps.reject(&:review?).reduce({}) { |acc, s| acc.merge(s.attribute_schema) }
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
def data
|
|
842
|
+
@data ||= Data.class_for(self.class.union_attribute_schema).new(data_attributes)
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
def anchor
|
|
846
|
+
raise NotAnchoredError, "#{self.class} is not `anchored`" unless self.class.anchored?
|
|
847
|
+
@anchor
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
def persisted = @persisted ||= {} # populated by the runner from on_submit/persist
|
|
851
|
+
|
|
852
|
+
def execute = raise NotImplementedError, "#{self.class} must implement #execute"
|
|
853
|
+
|
|
854
|
+
private
|
|
855
|
+
|
|
856
|
+
# Raise a StepError from on_submit/execute. fail!("msg") or fail!(:field, "msg").
|
|
857
|
+
def fail!(attribute_or_message, message = nil)
|
|
858
|
+
if message.nil?
|
|
859
|
+
raise StepError.new(attribute_or_message, attribute: :base)
|
|
860
|
+
else
|
|
861
|
+
raise StepError.new(message, attribute: attribute_or_message)
|
|
862
|
+
end
|
|
863
|
+
end
|
|
864
|
+
|
|
865
|
+
def succeed(value = nil) = Plutonium::Interaction::Outcome::Success.new(value:)
|
|
866
|
+
def failed(errors = nil, attribute = :base)
|
|
867
|
+
self.errors.add(attribute, errors.to_s) if errors.is_a?(String)
|
|
868
|
+
Plutonium::Interaction::Outcome::Failure.new(errors: self.errors)
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
```
|
|
874
|
+
|
|
875
|
+
> The `FieldCapture` object (referenced above) wraps a `FieldsDefinition`-style recorder that supports `attribute`, `input`, `validates`, `structured_input`, `on_submit`, `on_rollback`, `using`, `form_layout`, and exposes `attribute_schema`, `delete_hook`, `using_spec`, `form_layout`. Implement it in this task for inline fields; the `using:` resolution is filled in Task 3 (here it can accept a spec and merge later). Keep `succeed`/`failed` aligned with `Plutonium::Interaction::Outcome` (verify the exact constructor — `Outcome::Success.new(value:)` per Task-research; adjust if the real signature differs).
|
|
876
|
+
|
|
877
|
+
- [ ] **Step 6: Run green** → PASS.
|
|
878
|
+
|
|
879
|
+
- [ ] **Step 7: Commit**
|
|
880
|
+
|
|
881
|
+
```bash
|
|
882
|
+
git add lib/plutonium/wizard/step.rb lib/plutonium/wizard/review_step.rb lib/plutonium/wizard/data.rb lib/plutonium/wizard/dsl.rb lib/plutonium/wizard/base.rb test/plutonium/wizard/base_test.rb test/plutonium/wizard/data_test.rb
|
|
883
|
+
git commit -m "feat(wizard): Base DSL — step/review/anchored/navigation/one_time, typed data, fail!"
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
```json:metadata
|
|
887
|
+
{"files": ["lib/plutonium/wizard/step.rb", "lib/plutonium/wizard/review_step.rb", "lib/plutonium/wizard/data.rb", "lib/plutonium/wizard/dsl.rb", "lib/plutonium/wizard/base.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/base_test.rb test/plutonium/wizard/data_test.rb", "acceptanceCriteria": ["step/review/anchored/navigation/cleanup_after/one_time/encrypt_data DSL", "review terminality raises", "typed union data snapshot, nil for uncollected", "anchor raises NotAnchoredError", "fail! raises StepError with attribute"], "requiresUserVerification": false}
|
|
888
|
+
```
|
|
889
|
+
|
|
890
|
+
---
|
|
891
|
+
|
|
892
|
+
### Task 3: FieldImporter — `using:` an interaction or resource definition
|
|
893
|
+
|
|
894
|
+
**Goal:** A step can `using:` an interaction or resource definition to import attributes (with types), inputs, validations (run-and-filter to imported fields + `:base`), and `form_layout` — with `fields:`/`only:`/`except:`, `validate: false`, `layout: false`, `validation_context:`. Definition targets read base from the record class and overlay the definition's customizations (spec §2.4).
|
|
895
|
+
|
|
896
|
+
**Files:**
|
|
897
|
+
- Create: `lib/plutonium/wizard/field_importer.rb`
|
|
898
|
+
- Modify: `lib/plutonium/wizard/dsl.rb` (wire `using:` into `capture_fields`/`FieldCapture`)
|
|
899
|
+
- Test: `test/plutonium/wizard/field_importer_test.rb`
|
|
900
|
+
|
|
901
|
+
**Acceptance Criteria:**
|
|
902
|
+
- [ ] `using: SomeInteraction, only: %i[a b]` imports those attributes (types from the interaction's `attribute` declarations), inputs, and validations.
|
|
903
|
+
- [ ] `using: SomeDefinition, fields: %i[a]` resolves the field's **type from the definition's record class** (`Model.attribute_types`), overlays the definition's input config, and validates via `Model.new(slice).valid?`.
|
|
904
|
+
- [ ] Imported validation keeps errors only on imported fields **+ `:base`**; errors on other attributes are dropped.
|
|
905
|
+
- [ ] `validate: false` skips validation reuse; `layout: false` skips form_layout inheritance; `validation_context:` is passed to `valid?`.
|
|
906
|
+
- [ ] Inline declarations in the same step compose with imported ones (inline wins on conflict).
|
|
907
|
+
|
|
908
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/field_importer_test.rb` → PASS
|
|
909
|
+
|
|
910
|
+
**Steps:**
|
|
911
|
+
|
|
912
|
+
- [ ] **Step 1: Failing test** — define a tiny interaction + a dummy resource/definition in the test, import subsets, assert schema/validation/error-filtering. (Use the dummy app's existing models/definitions for the definition-target case.)
|
|
913
|
+
|
|
914
|
+
```ruby
|
|
915
|
+
# test/plutonium/wizard/field_importer_test.rb (sketch — fill concretely against dummy models)
|
|
916
|
+
require "test_helper"
|
|
917
|
+
|
|
918
|
+
class Plutonium::Wizard::FieldImporterTest < ActiveSupport::TestCase
|
|
919
|
+
class ContactInteraction < Plutonium::Interaction::Base
|
|
920
|
+
attribute :phone, :string
|
|
921
|
+
attribute :email, :string
|
|
922
|
+
validates :email, presence: true
|
|
923
|
+
private def execute = succeed(true)
|
|
924
|
+
end
|
|
925
|
+
|
|
926
|
+
test "interaction import: types + filtered validation" do
|
|
927
|
+
spec = Plutonium::Wizard::FieldImporter.resolve(using: ContactInteraction, opts: {only: %i[email]})
|
|
928
|
+
assert_equal({email: :string}, spec.attribute_schema)
|
|
929
|
+
errors = spec.validate({"email" => ""}) # runs ContactInteraction.new(email:"").valid?
|
|
930
|
+
assert errors.key?(:email)
|
|
931
|
+
refute errors.key?(:phone) # not imported → dropped
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
test "validate: false skips validation" do
|
|
935
|
+
spec = Plutonium::Wizard::FieldImporter.resolve(using: ContactInteraction, opts: {only: %i[email], validate: false})
|
|
936
|
+
assert_empty spec.validate({"email" => ""})
|
|
937
|
+
end
|
|
938
|
+
end
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
- [ ] **Step 2: Run red** → FAIL.
|
|
942
|
+
|
|
943
|
+
- [ ] **Step 3: Implement FieldImporter**
|
|
944
|
+
|
|
945
|
+
```ruby
|
|
946
|
+
# lib/plutonium/wizard/field_importer.rb
|
|
947
|
+
module Plutonium
|
|
948
|
+
module Wizard
|
|
949
|
+
module FieldImporter
|
|
950
|
+
Spec = Struct.new(:attribute_schema, :inputs, :form_layout, :validate_fn) do
|
|
951
|
+
def validate(data_slice) = validate_fn ? validate_fn.call(data_slice) : {}
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
def self.resolve(using:, opts:)
|
|
955
|
+
only = Array(opts[:fields] || opts[:only]).map(&:to_sym).presence
|
|
956
|
+
except = Array(opts[:except]).map(&:to_sym)
|
|
957
|
+
do_validate = opts.fetch(:validate, true)
|
|
958
|
+
do_layout = opts.fetch(:layout, true)
|
|
959
|
+
context = opts[:validation_context]
|
|
960
|
+
|
|
961
|
+
if interaction?(using)
|
|
962
|
+
from_interaction(using, only:, except:, do_validate:, do_layout:, context:)
|
|
963
|
+
else
|
|
964
|
+
from_definition(using, only:, except:, do_validate:, do_layout:, context:)
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
def self.interaction?(klass) = klass < Plutonium::Interaction::Base
|
|
969
|
+
|
|
970
|
+
def self.select(names, only:, except:)
|
|
971
|
+
names = names & only if only
|
|
972
|
+
names - except
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
def self.from_interaction(klass, only:, except:, do_validate:, do_layout:, context:)
|
|
976
|
+
names = select(klass.attribute_names.map(&:to_sym), only:, except:)
|
|
977
|
+
schema = names.index_with { |n| klass.attribute_types[n.to_s]&.type || :string }
|
|
978
|
+
validate_fn = build_validate(do_validate) do |slice|
|
|
979
|
+
obj = klass.new
|
|
980
|
+
obj.attributes = slice.slice(*names.map(&:to_s))
|
|
981
|
+
run_and_filter(obj, names, context)
|
|
982
|
+
end
|
|
983
|
+
Spec.new(attribute_schema: schema, inputs: klass.defined_inputs.slice(*names),
|
|
984
|
+
form_layout: (do_layout ? layout_for(klass, names) : nil), validate_fn:)
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
def self.from_definition(defn, only:, except:, do_validate:, do_layout:, context:)
|
|
988
|
+
model = defn.model_class # resolve the backing record class (verify exact accessor on Definition)
|
|
989
|
+
names = select(defn.defined_inputs.keys.map(&:to_sym), only:, except:)
|
|
990
|
+
# Type from record (base), input config overlaid by definition (handled at render time).
|
|
991
|
+
schema = names.index_with { |n| model.attribute_types[n.to_s]&.type || :string }
|
|
992
|
+
validate_fn = build_validate(do_validate) do |slice|
|
|
993
|
+
rec = model.new(slice.slice(*names.map(&:to_s)))
|
|
994
|
+
run_and_filter(rec, names, context)
|
|
995
|
+
end
|
|
996
|
+
Spec.new(attribute_schema: schema, inputs: defn.defined_inputs.slice(*names),
|
|
997
|
+
form_layout: (do_layout ? layout_for(defn, names) : nil), validate_fn:)
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
def self.build_validate(do_validate)
|
|
1001
|
+
return nil unless do_validate
|
|
1002
|
+
->(slice) { yield(slice) }
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
# Run valid? and keep errors only on imported fields + :base.
|
|
1006
|
+
def self.run_and_filter(obj, names, context)
|
|
1007
|
+
context ? obj.valid?(context) : obj.valid?
|
|
1008
|
+
keep = names.map(&:to_s) << "base"
|
|
1009
|
+
obj.errors.group_by_attribute.slice(*keep.map(&:to_sym))
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
def self.layout_for(source, names)
|
|
1013
|
+
layout = source.respond_to?(:defined_form_layout) ? source.defined_form_layout : nil
|
|
1014
|
+
return nil unless layout
|
|
1015
|
+
# Filter sections to imported fields (reuse Section resolution semantics).
|
|
1016
|
+
layout.map { |sec| Plutonium::Definition::FormLayout::ResolvedSection.new(sec, sec.fields & names) }
|
|
1017
|
+
.reject { |rs| rs.fields.empty? && !rs.section.ungrouped? }
|
|
1018
|
+
end
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
end
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
> Verify exact accessors against the real codebase: `Interaction.attribute_types`/`attribute_names`/`defined_inputs`, definition's record-class accessor (likely `model_class` or similar — confirm), and `errors.group_by_attribute` availability (Rails 6.1+). Adjust names to match.
|
|
1025
|
+
|
|
1026
|
+
- [ ] **Step 4: Wire into the DSL** — in `FieldCapture`, when `using:` is given, call `FieldImporter.resolve` and merge its `attribute_schema`/`inputs`/`form_layout`/`validate_fn` with inline declarations (inline overrides imported). Expose `using_spec` on the Step so the runner (Task 4) and form (Task 6) can use it.
|
|
1027
|
+
|
|
1028
|
+
- [ ] **Step 5: Run green** → PASS.
|
|
1029
|
+
|
|
1030
|
+
- [ ] **Step 6: Commit**
|
|
1031
|
+
|
|
1032
|
+
```bash
|
|
1033
|
+
git add lib/plutonium/wizard/field_importer.rb lib/plutonium/wizard/dsl.rb test/plutonium/wizard/field_importer_test.rb
|
|
1034
|
+
git commit -m "feat(wizard): using: import of fields/validations/form_layout from interaction or definition"
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
```json:metadata
|
|
1038
|
+
{"files": ["lib/plutonium/wizard/field_importer.rb", "lib/plutonium/wizard/dsl.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/field_importer_test.rb", "acceptanceCriteria": ["interaction import: types+inputs+validations", "definition import: type from record class + overlay", "validation run-and-filter to imported fields + :base", "validate:false / layout:false / validation_context:", "inline composes with imported"], "requiresUserVerification": false}
|
|
1039
|
+
```
|
|
1040
|
+
|
|
1041
|
+
---
|
|
1042
|
+
|
|
1043
|
+
### Task 4: Runner — navigation, validation, on_submit/persist/on_rollback, execute, completeness/prune, lock, cleanup, resume
|
|
1044
|
+
|
|
1045
|
+
**Goal:** The pure engine that, given a wizard + State + Store, computes the visible path, validates a step, advances/back/cancel, runs `on_submit` (tracking persisted GIDs) and `on_rollback`, finalizes via `execute` with completeness assertion + branch-hidden pruning + the locked `completing` transition, performs cleanup, and rehydrates `persisted` on resume. No HTTP yet — drive it directly in unit tests with the Memory store.
|
|
1046
|
+
|
|
1047
|
+
**Files:**
|
|
1048
|
+
- Create: `lib/plutonium/wizard/runner.rb`
|
|
1049
|
+
- Test: `test/plutonium/wizard/runner_test.rb`
|
|
1050
|
+
|
|
1051
|
+
**Acceptance Criteria:**
|
|
1052
|
+
- [ ] `visible_path` evaluates each step's `condition:` against `data` (subtractive); branch-hidden steps excluded; `review` always last.
|
|
1053
|
+
- [ ] `advance(step, params)` validates the step (inline + imported), stages `data`, runs `on_submit` (in a transaction), tracks records passed to `persist` as GIDs in `state.persisted[step_key]`, moves cursor to the next visible step; on validation/`on_submit` failure returns errors and does not advance.
|
|
1054
|
+
- [ ] `back` moves cursor without validating; never discards `data`.
|
|
1055
|
+
- [ ] `cancel` runs `on_rollback`/destroy of tracked records (reverse order) then clears the row.
|
|
1056
|
+
- [ ] `finalize` asserts every visible non-review step is visited+valid (else returns the first offending step), prunes `data` for branch-hidden steps, performs the locked `in_progress → completing` transition (bails if already moved), runs `execute`; on success `complete`s the row, on failure reverts to `in_progress`.
|
|
1057
|
+
- [ ] On load, `persisted` is rehydrated from stored GIDs (GlobalID.locate).
|
|
1058
|
+
- [ ] `on_submit` failure: `RecordInvalid` → field errors; `StepError` → `attribute` error; other `StandardError` re-raised.
|
|
1059
|
+
|
|
1060
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/runner_test.rb` → PASS
|
|
1061
|
+
|
|
1062
|
+
**Steps:**
|
|
1063
|
+
|
|
1064
|
+
- [ ] **Step 1: Failing tests** — branching path, advance happy/invalid, back-no-validate, on_submit persist+track, on_rollback on cancel, finalize completeness+prune, double-submit lock (simulate concurrent by calling finalize twice). Use a wizard with `condition:` and an `on_submit` that `persist`s a dummy AR record.
|
|
1065
|
+
|
|
1066
|
+
```ruby
|
|
1067
|
+
# test/plutonium/wizard/runner_test.rb (key cases — expand)
|
|
1068
|
+
require "test_helper"
|
|
1069
|
+
|
|
1070
|
+
class Plutonium::Wizard::RunnerTest < ActiveSupport::TestCase
|
|
1071
|
+
class W < Plutonium::Wizard::Base
|
|
1072
|
+
step(:a) { attribute :go, :string; validates :go, presence: true }
|
|
1073
|
+
step(:b, condition: -> { data.go == "yes" }) { attribute :note, :string }
|
|
1074
|
+
review label: "R"
|
|
1075
|
+
def execute = succeed(:done)
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
setup do
|
|
1079
|
+
@store = Plutonium::Wizard::Store::Memory.new
|
|
1080
|
+
@runner = Plutonium::Wizard::Runner.new(wizard_class: W, store: @store, instance_key: "k")
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
test "branching hides b until go=yes" do
|
|
1084
|
+
assert_equal %i[a review], @runner.visible_path.map(&:key)
|
|
1085
|
+
@runner.advance(:a, {"go" => "yes"})
|
|
1086
|
+
assert_equal %i[a b review], @runner.visible_path.map(&:key)
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
test "advance invalid does not move" do
|
|
1090
|
+
res = @runner.advance(:a, {"go" => ""})
|
|
1091
|
+
refute res.ok?
|
|
1092
|
+
assert res.errors.key?(:go)
|
|
1093
|
+
assert_equal :a, @runner.current_step.key
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
test "finalize completeness redirects to first gap" do
|
|
1097
|
+
res = @runner.finalize
|
|
1098
|
+
refute res.completed?
|
|
1099
|
+
assert_equal :a, res.redirect_step
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
test "concurrent finalize: loser bails" do
|
|
1103
|
+
@runner.advance(:a, {"go" => "no"}) # b hidden; only a + review
|
|
1104
|
+
first = @runner.finalize
|
|
1105
|
+
assert first.completed?
|
|
1106
|
+
second = Plutonium::Wizard::Runner.new(wizard_class: W, store: @store, instance_key: "k").finalize
|
|
1107
|
+
refute second.completed? # already completed/cleared
|
|
1108
|
+
end
|
|
1109
|
+
end
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
- [ ] **Step 2: Run red** → FAIL.
|
|
1113
|
+
|
|
1114
|
+
- [ ] **Step 3: Implement Runner** — core methods (concrete):
|
|
1115
|
+
|
|
1116
|
+
```ruby
|
|
1117
|
+
# lib/plutonium/wizard/runner.rb
|
|
1118
|
+
module Plutonium
|
|
1119
|
+
module Wizard
|
|
1120
|
+
class Runner
|
|
1121
|
+
Result = Struct.new(:ok, :errors, :completed, :redirect_step, :value) do
|
|
1122
|
+
def ok? = !!ok
|
|
1123
|
+
def completed? = !!completed
|
|
1124
|
+
end
|
|
1125
|
+
|
|
1126
|
+
def initialize(wizard_class:, store:, instance_key:, view_context: nil, owner: nil, anchor: nil, scope: nil, token: nil)
|
|
1127
|
+
@wizard_class = wizard_class
|
|
1128
|
+
@store = store
|
|
1129
|
+
@instance_key = instance_key
|
|
1130
|
+
@state = store.read(instance_key) || new_state(owner:, anchor:, scope:, token:)
|
|
1131
|
+
@wizard = wizard_class.new(view_context:)
|
|
1132
|
+
@wizard.data_attributes = @state.data
|
|
1133
|
+
@wizard.anchor = (@state.anchor || anchor) if wizard_class.anchored?
|
|
1134
|
+
rehydrate_persisted
|
|
1135
|
+
end
|
|
1136
|
+
|
|
1137
|
+
def visible_path
|
|
1138
|
+
@wizard.data_attributes = @state.data
|
|
1139
|
+
@wizard_class.steps.select { |s| s.condition.nil? || @wizard.instance_exec(&s.condition) }
|
|
1140
|
+
end
|
|
1141
|
+
|
|
1142
|
+
def current_step = visible_path.find { _1.key.to_s == @state.current_step } || visible_path.first
|
|
1143
|
+
|
|
1144
|
+
def advance(step_key, params)
|
|
1145
|
+
step = step_for(step_key)
|
|
1146
|
+
errors = validate(step, params)
|
|
1147
|
+
return Result.new(ok: false, errors:) if errors.any?
|
|
1148
|
+
merge_data(params)
|
|
1149
|
+
run_on_submit(step) if step.on_submit
|
|
1150
|
+
@state.current_step = next_visible_after(step)&.key.to_s
|
|
1151
|
+
persist_state
|
|
1152
|
+
Result.new(ok: true)
|
|
1153
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
1154
|
+
Result.new(ok: false, errors: e.record.errors.group_by_attribute)
|
|
1155
|
+
rescue StepError => e
|
|
1156
|
+
Result.new(ok: false, errors: {e.attribute => [e.message]})
|
|
1157
|
+
end
|
|
1158
|
+
|
|
1159
|
+
def back
|
|
1160
|
+
prev = previous_visible
|
|
1161
|
+
@state.current_step = prev&.key.to_s
|
|
1162
|
+
persist_state
|
|
1163
|
+
Result.new(ok: true)
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1166
|
+
def cancel
|
|
1167
|
+
run_cleanup
|
|
1168
|
+
@store.clear(@instance_key)
|
|
1169
|
+
Result.new(ok: true)
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
def finalize
|
|
1173
|
+
gap = first_incomplete_visible
|
|
1174
|
+
return Result.new(ok: false, redirect_step: gap.key) if gap
|
|
1175
|
+
|
|
1176
|
+
prune_hidden!
|
|
1177
|
+
return Result.new(ok: false, completed: false) unless lock_for_completion!
|
|
1178
|
+
|
|
1179
|
+
outcome = ActiveRecord::Base.transaction do
|
|
1180
|
+
run_deferred_nothing # (all on_submit already ran per-step; execute does at-end writes)
|
|
1181
|
+
@wizard.data_attributes = @state.data
|
|
1182
|
+
@wizard.execute
|
|
1183
|
+
end
|
|
1184
|
+
|
|
1185
|
+
if outcome.success?
|
|
1186
|
+
@store.complete(@instance_key)
|
|
1187
|
+
Result.new(ok: true, completed: true, value: outcome.value)
|
|
1188
|
+
else
|
|
1189
|
+
revert_completing!
|
|
1190
|
+
Result.new(ok: false, completed: false, errors: outcome_errors(outcome))
|
|
1191
|
+
end
|
|
1192
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
1193
|
+
revert_completing!
|
|
1194
|
+
Result.new(ok: false, errors: e.record.errors.group_by_attribute)
|
|
1195
|
+
rescue StepError => e
|
|
1196
|
+
revert_completing!
|
|
1197
|
+
Result.new(ok: false, errors: {e.attribute => [e.message]})
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
private
|
|
1201
|
+
|
|
1202
|
+
def lock_for_completion!
|
|
1203
|
+
row = Session.find_by(instance_key: @instance_key) or return true # memory store: no row
|
|
1204
|
+
row.with_lock do
|
|
1205
|
+
return false unless row.status_in_progress?
|
|
1206
|
+
row.update!(status: "completing")
|
|
1207
|
+
end
|
|
1208
|
+
true
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
def revert_completing!
|
|
1212
|
+
Session.where(instance_key: @instance_key, status: "completing").update_all(status: "in_progress")
|
|
1213
|
+
end
|
|
1214
|
+
|
|
1215
|
+
def run_on_submit(step)
|
|
1216
|
+
ActiveRecord::Base.transaction do
|
|
1217
|
+
tracker = PersistTracker.new
|
|
1218
|
+
@wizard.data_attributes = @state.data
|
|
1219
|
+
@wizard.define_singleton_method(:persist) { |*recs| tracker.add(recs.flatten) }
|
|
1220
|
+
@wizard.instance_exec(&step.on_submit)
|
|
1221
|
+
@state.persisted[step.key.to_s] = tracker.gids
|
|
1222
|
+
@wizard.instance_variable_get(:@persisted)&.merge!(step.key => tracker.records)
|
|
1223
|
+
end
|
|
1224
|
+
end
|
|
1225
|
+
|
|
1226
|
+
def run_cleanup
|
|
1227
|
+
@wizard_class.steps.reverse_each do |step|
|
|
1228
|
+
recs = (@state.persisted[step.key.to_s] || []).filter_map { GlobalID.locate(_1) }
|
|
1229
|
+
next if recs.empty?
|
|
1230
|
+
if step.on_rollback
|
|
1231
|
+
@wizard.instance_variable_set(:@persisted, {step.key => recs})
|
|
1232
|
+
@wizard.instance_exec(&step.on_rollback)
|
|
1233
|
+
else
|
|
1234
|
+
recs.reverse_each(&:destroy!)
|
|
1235
|
+
end
|
|
1236
|
+
end
|
|
1237
|
+
end
|
|
1238
|
+
|
|
1239
|
+
def validate(step, params)
|
|
1240
|
+
merged = @state.data.merge(params)
|
|
1241
|
+
errors = {}
|
|
1242
|
+
# imported validation (run-and-filter)
|
|
1243
|
+
errors.merge!(step.using_spec.validate(merged)) if step.using_spec
|
|
1244
|
+
# inline validation: build a small ActiveModel from the step's inline attrs + validators
|
|
1245
|
+
errors.merge!(step.fields.validate_inline(merged)) if step.fields.respond_to?(:validate_inline)
|
|
1246
|
+
errors.reject { |_, msgs| msgs.blank? }
|
|
1247
|
+
end
|
|
1248
|
+
|
|
1249
|
+
def merge_data(params) = @state.data = @state.data.merge(params)
|
|
1250
|
+
def persist_state = @store.write(@instance_key, @state, cleanup_after: @wizard_class.cleanup_after)
|
|
1251
|
+
def step_for(key) = @wizard_class.steps.find { _1.key.to_s == key.to_s }
|
|
1252
|
+
def next_visible_after(step) = (vp = visible_path; vp[vp.index { _1.key == step.key }.to_i + 1])
|
|
1253
|
+
def previous_visible = (vp = visible_path; i = vp.index { _1.key.to_s == @state.current_step }.to_i; vp[[i - 1, 0].max])
|
|
1254
|
+
def first_incomplete_visible = visible_path.reject(&:review?).find { |s| !step_visited_and_valid?(s) }
|
|
1255
|
+
def step_visited_and_valid?(s) = validate(s, {}).empty? # visited rows have data staged; empty errors == valid
|
|
1256
|
+
def prune_hidden!
|
|
1257
|
+
visible = visible_path.flat_map { _1.attribute_schema.keys.map(&:to_s) }
|
|
1258
|
+
@state.data = @state.data.slice(*visible)
|
|
1259
|
+
end
|
|
1260
|
+
def rehydrate_persisted
|
|
1261
|
+
return unless @state.persisted.present?
|
|
1262
|
+
recs = @state.persisted.transform_values { |gids| Array(gids).filter_map { GlobalID.locate(_1) } }
|
|
1263
|
+
@wizard.instance_variable_set(:@persisted, recs.transform_keys(&:to_sym))
|
|
1264
|
+
end
|
|
1265
|
+
def new_state(owner:, anchor:, scope:, token:)
|
|
1266
|
+
State.new(wizard: @wizard_class.name, instance_key: @instance_key,
|
|
1267
|
+
current_step: @wizard_class.steps.first&.key.to_s, status: "in_progress",
|
|
1268
|
+
data: {}, persisted: {}, owner:, anchor:, scope:, token:)
|
|
1269
|
+
end
|
|
1270
|
+
def outcome_errors(o) = o.respond_to?(:errors) ? o.errors.group_by_attribute : {}
|
|
1271
|
+
def run_deferred_nothing = nil
|
|
1272
|
+
|
|
1273
|
+
class PersistTracker
|
|
1274
|
+
def initialize = (@records = [])
|
|
1275
|
+
def add(recs) = @records.concat(Array(recs))
|
|
1276
|
+
def records = @records
|
|
1277
|
+
def gids = @records.map { _1.to_global_id.to_s }
|
|
1278
|
+
end
|
|
1279
|
+
end
|
|
1280
|
+
end
|
|
1281
|
+
end
|
|
1282
|
+
```
|
|
1283
|
+
|
|
1284
|
+
> This is the most intricate task — verify the `persist` macro binding (it must be available only inside `on_submit`/`execute`; the singleton-method approach above is one way; an alternative is a `PersistContext` the block is `instance_exec`'d against). Confirm `errors.group_by_attribute` shape and `Outcome` error access. Keep each behavior under its own test.
|
|
1285
|
+
|
|
1286
|
+
- [ ] **Step 4: Run green** → PASS.
|
|
1287
|
+
|
|
1288
|
+
- [ ] **Step 5: Commit**
|
|
1289
|
+
|
|
1290
|
+
```bash
|
|
1291
|
+
git add lib/plutonium/wizard/runner.rb test/plutonium/wizard/runner_test.rb
|
|
1292
|
+
git commit -m "feat(wizard): runner — navigation, on_submit/persist/rollback, finalize lock + completeness/prune"
|
|
1293
|
+
```
|
|
1294
|
+
|
|
1295
|
+
```json:metadata
|
|
1296
|
+
{"files": ["lib/plutonium/wizard/runner.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/runner_test.rb", "acceptanceCriteria": ["visible_path subtractive branching", "advance validates+stages+on_submit+tracks GIDs", "back no-validate", "cancel runs rollback+clears", "finalize completeness+prune+lock+execute", "persisted rehydrated on resume", "failure mapping RecordInvalid/StepError"], "requiresUserVerification": false}
|
|
1297
|
+
```
|
|
1298
|
+
|
|
1299
|
+
---
|
|
1300
|
+
|
|
1301
|
+
### Task 5: Controller, routing, and registration (`register_wizard` + `wizard` definition DSL) + `authorize?`
|
|
1302
|
+
|
|
1303
|
+
**Goal:** HTTP surface. One controller drives GET (render step), POST (`_direction` next/back/cancel, `pre_submit`), resolving the instance (scope/anchor/token/owner) and delegating to the Runner. Routes are synthesized two ways: standalone `register_wizard ... at:` and the in-definition `wizard` macro (which mirrors the action system). Entry checks `authorize?` (standalone) / the action policy (resource).
|
|
1304
|
+
|
|
1305
|
+
**Files:**
|
|
1306
|
+
- Create: `app/controllers/plutonium/wizard/controller.rb`, `lib/plutonium/routing/wizard_registration.rb`, `lib/plutonium/definition/wizards.rb`
|
|
1307
|
+
- Modify: `lib/plutonium/routing/mapper_extensions.rb` (draw per-resource wizard routes + `register_wizard`), `lib/plutonium/definition/base.rb` (include `Definition::Wizards`)
|
|
1308
|
+
- Test: `test/integration/.../wizard_flow_test.rb` (dummy app, all surfaces)
|
|
1309
|
+
|
|
1310
|
+
**Acceptance Criteria:**
|
|
1311
|
+
- [ ] `register_wizard OnboardingWizard, at: "/welcome"` draws `GET/POST /welcome/:step` (+ token variant) → the wizard controller; provides `welcome_wizard_path`.
|
|
1312
|
+
- [ ] `wizard :configure, ConfigureCompanyWizard` in a definition synthesizes a record action (anchored) / resource action (no anchor) that links to the wizard's GET route; placement mirrors interactions (record vs resource; **no bulk**).
|
|
1313
|
+
- [ ] GET renders the current step's form; POST `_direction=next` advances (or re-renders with errors `:unprocessable_content`), `back` goes back, `cancel` runs cleanup; `pre_submit` re-renders the form via turbo_stream (mirror interactive_actions.rb).
|
|
1314
|
+
- [ ] On finalize success → PRG redirect to the outcome target; one-time completion recorded.
|
|
1315
|
+
- [ ] Standalone entry calls `wizard.authorize?` (403 on false); resource entry uses the action's policy predicate.
|
|
1316
|
+
- [ ] Anchor injected from `:id` (record action); scope from the portal scoped entity; pre-auth token minted in a signed cookie.
|
|
1317
|
+
|
|
1318
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/<portal>/wizard_flow_test.rb` → PASS
|
|
1319
|
+
|
|
1320
|
+
**Steps:**
|
|
1321
|
+
|
|
1322
|
+
- [ ] **Step 1: Failing integration test** — in the dummy app, hand-write a small wizard (per project convention, dummy wizards are authored by hand like interactions), register it both standalone and on a resource definition, then drive: GET step 1 → POST next → GET step 2 → POST finish → assert redirect + records created; POST back; POST cancel → assert cleanup. Mirror `test/integration/org_portal/structured_input_interaction_test.rb` for request shape, `login_as`, and Turbo-Frame headers.
|
|
1323
|
+
|
|
1324
|
+
- [ ] **Step 2: Run red** → FAIL.
|
|
1325
|
+
|
|
1326
|
+
- [ ] **Step 3: Controller** — concrete skeleton (mirrors `interactive_actions.rb` flow §6):
|
|
1327
|
+
|
|
1328
|
+
```ruby
|
|
1329
|
+
# app/controllers/plutonium/wizard/controller.rb
|
|
1330
|
+
module Plutonium
|
|
1331
|
+
module Wizard
|
|
1332
|
+
module Controller
|
|
1333
|
+
extend ActiveSupport::Concern
|
|
1334
|
+
|
|
1335
|
+
def show # GET .../:step
|
|
1336
|
+
runner = build_runner
|
|
1337
|
+
authorize_wizard!(runner)
|
|
1338
|
+
@wizard_view = runner # expose to the page
|
|
1339
|
+
render Plutonium::UI::Page::Wizard.new(runner:), **modal_render_options
|
|
1340
|
+
end
|
|
1341
|
+
|
|
1342
|
+
def update # POST .../:step
|
|
1343
|
+
runner = build_runner
|
|
1344
|
+
authorize_wizard!(runner)
|
|
1345
|
+
step_key = params[:step]
|
|
1346
|
+
|
|
1347
|
+
if params[:pre_submit]
|
|
1348
|
+
return render_pre_submit(runner, step_key)
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
result =
|
|
1352
|
+
case params[:_direction]
|
|
1353
|
+
when "back" then runner.back
|
|
1354
|
+
when "cancel" then runner.cancel
|
|
1355
|
+
else
|
|
1356
|
+
adv = runner.advance(step_key, wizard_params)
|
|
1357
|
+
adv.ok? && runner.current_step&.review? == false && last_step?(runner) ? runner.finalize : adv
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
respond_to_result(runner, result)
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1363
|
+
private
|
|
1364
|
+
|
|
1365
|
+
def build_runner
|
|
1366
|
+
Plutonium::Wizard::Runner.new(
|
|
1367
|
+
wizard_class: current_wizard_class, store: wizard_store, instance_key: resolved_instance_key,
|
|
1368
|
+
view_context:, owner: current_user, anchor: resolved_anchor, scope: resolved_scope, token: resolved_token
|
|
1369
|
+
)
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
def authorize_wizard!(runner)
|
|
1373
|
+
wiz = runner.wizard # expose reader on Runner
|
|
1374
|
+
if wiz.respond_to?(:authorize?) && !wiz.authorize?
|
|
1375
|
+
raise ActionPolicy::Unauthorized.new(nil, nil)
|
|
1376
|
+
end
|
|
1377
|
+
# resource-attached wizards additionally go through the action policy (mirror interactive actions)
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
def wizard_store = Plutonium::Wizard::Store::ActiveRecord.new
|
|
1381
|
+
# resolved_instance_key/anchor/scope/token: from params + current scope + signed cookie (see spec §4)
|
|
1382
|
+
end
|
|
1383
|
+
end
|
|
1384
|
+
end
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
Implement: `respond_to_result` (success → `turbo_stream_redirect` / `redirect_to ..., status: :see_other`; failure → re-render step `:unprocessable_content`), `render_pre_submit` (turbo_stream replace of the form, mirroring interactive_actions.rb lines 37-60), `resolved_instance_key` (via `InstanceKey.for`), `resolved_scope` (portal `scoped_entity` if `scoped_to_entity?`), `resolved_token` (signed cookie, mint if absent for non-anchored), `last_step?`.
|
|
1388
|
+
|
|
1389
|
+
- [ ] **Step 4: Routing** — `register_wizard` + per-resource routes:
|
|
1390
|
+
|
|
1391
|
+
```ruby
|
|
1392
|
+
# lib/plutonium/routing/wizard_registration.rb
|
|
1393
|
+
module Plutonium
|
|
1394
|
+
module Routing
|
|
1395
|
+
module WizardRegistration
|
|
1396
|
+
def register_wizard(wizard_class, at:)
|
|
1397
|
+
slug = wizard_class.name.demodulize.underscore.sub(/_wizard$/, "")
|
|
1398
|
+
scope path: at do
|
|
1399
|
+
get "(/:token)/:step", to: "plutonium/wizard#show", as: :"#{slug}_wizard", defaults: {wizard: wizard_class.name}
|
|
1400
|
+
post "(/:token)/:step", to: "plutonium/wizard#update", defaults: {wizard: wizard_class.name}
|
|
1401
|
+
end
|
|
1402
|
+
end
|
|
1403
|
+
end
|
|
1404
|
+
end
|
|
1405
|
+
end
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
Per-resource wizard routes mirror the interactive `record_actions`/`resource_actions` block in `mapper_extensions.rb` (lines 146-169): add `wizards/:wizard_slug(/:step)` GET/POST under member (anchored) and collection (non-anchored). Prepend `WizardRegistration` to `ActionDispatch::Routing::Mapper` in the Railtie alongside the existing `MapperExtensions`.
|
|
1409
|
+
|
|
1410
|
+
- [ ] **Step 5: `wizard` definition DSL** — synthesize actions:
|
|
1411
|
+
|
|
1412
|
+
```ruby
|
|
1413
|
+
# lib/plutonium/definition/wizards.rb
|
|
1414
|
+
module Plutonium
|
|
1415
|
+
module Definition
|
|
1416
|
+
module Wizards
|
|
1417
|
+
extend ActiveSupport::Concern
|
|
1418
|
+
class_methods do
|
|
1419
|
+
def wizard(name, wizard_class, record_action: nil, collection: nil, **opts)
|
|
1420
|
+
anchored = wizard_class.anchored?
|
|
1421
|
+
is_record = record_action.nil? ? anchored : record_action
|
|
1422
|
+
action(name,
|
|
1423
|
+
route_options: Plutonium::Action::RouteOptions.new(method: :get, action: :show,
|
|
1424
|
+
url_resolver: wizard_url_resolver(wizard_class, is_record)),
|
|
1425
|
+
record_action: is_record, resource_action: !is_record,
|
|
1426
|
+
category: opts.fetch(:category, :primary),
|
|
1427
|
+
icon: opts[:icon], position: opts[:position],
|
|
1428
|
+
label: wizard_class.respond_to?(:presents) ? wizard_class.label : name.to_s.humanize)
|
|
1429
|
+
end
|
|
1430
|
+
end
|
|
1431
|
+
end
|
|
1432
|
+
end
|
|
1433
|
+
end
|
|
1434
|
+
```
|
|
1435
|
+
|
|
1436
|
+
Include `Plutonium::Definition::Wizards` in `Definition::Base` (next to `Actions`). Implement `wizard_url_resolver` to build the wizard GET path for the subject. Confirm `Action::RouteOptions` constructor + `url_resolver` against `action/interactive.rb`.
|
|
1437
|
+
|
|
1438
|
+
- [ ] **Step 6: Run green** → PASS (iterate on routing/url helpers against the dummy app).
|
|
1439
|
+
|
|
1440
|
+
- [ ] **Step 7: Commit**
|
|
1441
|
+
|
|
1442
|
+
```bash
|
|
1443
|
+
git add app/controllers/plutonium/wizard/controller.rb lib/plutonium/routing/wizard_registration.rb lib/plutonium/definition/wizards.rb lib/plutonium/routing/mapper_extensions.rb lib/plutonium/definition/base.rb lib/plutonium/railtie.rb test/integration
|
|
1444
|
+
git commit -m "feat(wizard): controller, register_wizard routing, wizard definition DSL, authorize?"
|
|
1445
|
+
```
|
|
1446
|
+
|
|
1447
|
+
```json:metadata
|
|
1448
|
+
{"files": ["app/controllers/plutonium/wizard/controller.rb", "lib/plutonium/routing/wizard_registration.rb", "lib/plutonium/definition/wizards.rb", "lib/plutonium/routing/mapper_extensions.rb", "lib/plutonium/definition/base.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration", "acceptanceCriteria": ["register_wizard draws routes + helper", "wizard DSL synthesizes record/resource action (no bulk)", "GET renders step; POST next/back/cancel; pre_submit", "PRG on finalize success", "authorize? gate (standalone) / policy (resource)", "anchor/scope/token resolution"], "requiresUserVerification": false}
|
|
1449
|
+
```
|
|
1450
|
+
|
|
1451
|
+
---
|
|
1452
|
+
|
|
1453
|
+
### Task 6: UI — Page::Wizard, Stepper, nav buttons, review auto-summary, form rendering + repeater rehydration
|
|
1454
|
+
|
|
1455
|
+
**Goal:** Render a step (reusing the interaction form pipeline), the stepper (with disabled/branch-hidden behavior), Back/Next/Finish/Cancel buttons carrying `_direction`, and the review step's auto-summary (display components + outstanding-item jump links). Repeater rows rehydrate from staged `data` on GET.
|
|
1456
|
+
|
|
1457
|
+
**Files:**
|
|
1458
|
+
- Create: `lib/plutonium/ui/page/wizard.rb`, `lib/plutonium/ui/wizard/stepper.rb`, `lib/plutonium/ui/wizard/review.rb`, `lib/plutonium/ui/form/wizard_step.rb` (subclass of `UI::Form::Interaction`)
|
|
1459
|
+
- Test: `test/integration/.../wizard_rendering_test.rb`
|
|
1460
|
+
- Possibly: a small Stimulus controller for nav (mirror keystone's `wizard_nav_controller.js` intent — submit with `_direction`), registered per project convention.
|
|
1461
|
+
|
|
1462
|
+
**Acceptance Criteria:**
|
|
1463
|
+
- [ ] The current step renders its fields (inline + `using:`-imported, honoring inherited/inline `form_layout`) via a `UI::Form::Wizard` form posting to the step's POST route with `_direction`.
|
|
1464
|
+
- [ ] Stepper shows visible steps with completed/current/upcoming state; `:linear` allows clicking visited steps, disables upcoming; branch-hidden steps absent. `:free` allows any visited step.
|
|
1465
|
+
- [ ] Repeatable `structured_input` rows rehydrate from staged `data` on GET (not only on failed submit).
|
|
1466
|
+
- [ ] The `review` step renders an auto-summary of visible steps' `data` via display components + lists invalid/unvisited steps as jump links; Finish disabled until valid.
|
|
1467
|
+
- [ ] Resource-action wizards render in the modal/turbo-frame; standalone render full-page.
|
|
1468
|
+
|
|
1469
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/<portal>/wizard_rendering_test.rb` → PASS
|
|
1470
|
+
|
|
1471
|
+
**Steps:**
|
|
1472
|
+
|
|
1473
|
+
- [ ] **Step 1: Failing rendering test** — GET a step, assert the form fields + stepper markup; GET a wizard mid-flow with staged repeater data, assert N rows rendered; GET the review step, assert summary + outstanding links.
|
|
1474
|
+
- [ ] **Step 2: Run red** → FAIL.
|
|
1475
|
+
- [ ] **Step 3: Implement `UI::Form::Wizard`** subclassing `Plutonium::UI::Form::Interaction` — set `resource_fields` to the current step's field names, `resource_definition` to a per-step adapter exposing `defined_inputs`/`resolve_form_sections` from the step (inline + imported), `form_action` to the step's POST URL, and render a hidden `_direction` defaulting to `next`. Seed repeater values from `runner` staged data (override the value source so rows rehydrate on GET).
|
|
1476
|
+
- [ ] **Step 4: Implement `Page::Wizard`** — composes the stepper + the step form (or the review component for a review step) + nav buttons; chooses modal vs full-page from the surface (mirror `UI::Page::InteractiveAction` for modal).
|
|
1477
|
+
- [ ] **Step 5: Implement `Wizard::Stepper`** (Phlex) — renders visible path with state; clickable rules per `navigation`.
|
|
1478
|
+
- [ ] **Step 6: Implement `Wizard::Review`** (Phlex) — iterate visible non-review steps, render each field via the display pipeline (reuse `UI::Display`), and list `runner.first_incomplete_visible`-style gaps as links to each step's GET route; render Finish (disabled unless complete).
|
|
1479
|
+
- [ ] **Step 7: Stimulus nav** (if needed) — a controller that sets `_direction` and submits; register it.
|
|
1480
|
+
- [ ] **Step 8: Run green** → PASS.
|
|
1481
|
+
- [ ] **Step 9: Commit**
|
|
1482
|
+
|
|
1483
|
+
```bash
|
|
1484
|
+
git add lib/plutonium/ui/page/wizard.rb lib/plutonium/ui/wizard lib/plutonium/ui/form/wizard_step.rb app/assets test/integration
|
|
1485
|
+
git commit -m "feat(wizard): UI — page, stepper, review auto-summary, step form + repeater rehydration"
|
|
1486
|
+
```
|
|
1487
|
+
|
|
1488
|
+
```json:metadata
|
|
1489
|
+
{"files": ["lib/plutonium/ui/page/wizard.rb", "lib/plutonium/ui/wizard/stepper.rb", "lib/plutonium/ui/wizard/review.rb", "lib/plutonium/ui/form/wizard_step.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/integration", "acceptanceCriteria": ["step renders fields + form_layout via wizard form", "stepper states + linear/free click rules", "repeater rows rehydrate on GET", "review auto-summary + outstanding jump links + gated finish", "modal vs full-page by surface"], "requiresUserVerification": false}
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
---
|
|
1493
|
+
|
|
1494
|
+
### Task 7: One-time wizards (gate + completion) and SweepJob
|
|
1495
|
+
|
|
1496
|
+
**Goal:** `one_time once_per: :user/:anchor` records a durable completion and an `ensure_wizard_completed` controller concern redirects un-completed users into the wizard and bounces completed ones. `SweepJob` reaps idle `in_progress`/`completing` rows (running cleanup) past `expires_at`.
|
|
1497
|
+
|
|
1498
|
+
**Files:**
|
|
1499
|
+
- Create: `lib/plutonium/wizard/gate.rb`, `lib/plutonium/wizard/sweep_job.rb`
|
|
1500
|
+
- Modify: controller finalize path to record one-time completion (already `complete`s the row; add the once-per assertion so a second run short-circuits).
|
|
1501
|
+
- Test: `test/plutonium/wizard/sweep_job_test.rb`, `test/integration/.../wizard_one_time_test.rb`
|
|
1502
|
+
|
|
1503
|
+
**Acceptance Criteria:**
|
|
1504
|
+
- [ ] A `one_time once_per: :user` wizard, once completed, is detected by `store.completed?(wizard:, owner:)`; `ensure_wizard_completed WizardClass` redirects to the wizard until done, then bounces to the original destination (PRG); completed users pass through.
|
|
1505
|
+
- [ ] `once_per: :anchor` keys completion on the anchor.
|
|
1506
|
+
- [ ] `SweepJob.perform_now` deletes idle `in_progress`/`completing` rows past `expires_at`, running each wizard's cleanup (`on_rollback`/destroy tracked records); never touches `completed`; skips null `expires_at`.
|
|
1507
|
+
|
|
1508
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/sweep_job_test.rb` → PASS
|
|
1509
|
+
|
|
1510
|
+
**Steps:**
|
|
1511
|
+
|
|
1512
|
+
- [ ] **Step 1: Failing tests** — sweep deletes expired in_progress + runs rollback on tracked records; leaves completed + null-expiry; gate concern redirects/bounces.
|
|
1513
|
+
- [ ] **Step 2: Run red** → FAIL.
|
|
1514
|
+
- [ ] **Step 3: SweepJob**
|
|
1515
|
+
|
|
1516
|
+
```ruby
|
|
1517
|
+
# lib/plutonium/wizard/sweep_job.rb
|
|
1518
|
+
module Plutonium
|
|
1519
|
+
module Wizard
|
|
1520
|
+
class SweepJob < ActiveJob::Base
|
|
1521
|
+
def perform(now: Time.current)
|
|
1522
|
+
Session.sweepable(now).find_each do |row|
|
|
1523
|
+
wizard_class = row.wizard.safe_constantize
|
|
1524
|
+
Runner.new(wizard_class:, store: Store::ActiveRecord.new, instance_key: row.instance_key)
|
|
1525
|
+
.cancel if wizard_class # cancel runs cleanup + clears the row
|
|
1526
|
+
row.destroy! if Session.exists?(id: row.id)
|
|
1527
|
+
end
|
|
1528
|
+
end
|
|
1529
|
+
end
|
|
1530
|
+
end
|
|
1531
|
+
end
|
|
1532
|
+
```
|
|
1533
|
+
|
|
1534
|
+
- [ ] **Step 4: Gate concern**
|
|
1535
|
+
|
|
1536
|
+
```ruby
|
|
1537
|
+
# lib/plutonium/wizard/gate.rb
|
|
1538
|
+
module Plutonium
|
|
1539
|
+
module Wizard
|
|
1540
|
+
module Gate
|
|
1541
|
+
extend ActiveSupport::Concern
|
|
1542
|
+
class_methods do
|
|
1543
|
+
def ensure_wizard_completed(wizard_class, **opts)
|
|
1544
|
+
before_action(**opts) do
|
|
1545
|
+
store = Plutonium::Wizard::Store::ActiveRecord.new
|
|
1546
|
+
key = wizard_completion_key(wizard_class) # owner or anchor per once_per
|
|
1547
|
+
unless store.completed?(**key)
|
|
1548
|
+
session[:return_to] ||= request.fullpath
|
|
1549
|
+
redirect_to wizard_entry_path(wizard_class) and return
|
|
1550
|
+
end
|
|
1551
|
+
end
|
|
1552
|
+
end
|
|
1553
|
+
end
|
|
1554
|
+
end
|
|
1555
|
+
end
|
|
1556
|
+
end
|
|
1557
|
+
```
|
|
1558
|
+
|
|
1559
|
+
Implement `wizard_completion_key` (owner: current_user for `:user`; anchor for `:anchor`) and `wizard_entry_path`. On finalize, after `complete`, redirect to `session.delete(:return_to)` if present.
|
|
1560
|
+
|
|
1561
|
+
- [ ] **Step 5: Run green** → PASS.
|
|
1562
|
+
- [ ] **Step 6: Commit**
|
|
1563
|
+
|
|
1564
|
+
```bash
|
|
1565
|
+
git add lib/plutonium/wizard/gate.rb lib/plutonium/wizard/sweep_job.rb test/plutonium/wizard/sweep_job_test.rb test/integration
|
|
1566
|
+
git commit -m "feat(wizard): one-time gate + completion, SweepJob for abandoned cleanup"
|
|
1567
|
+
```
|
|
1568
|
+
|
|
1569
|
+
```json:metadata
|
|
1570
|
+
{"files": ["lib/plutonium/wizard/gate.rb", "lib/plutonium/wizard/sweep_job.rb"], "verifyCommand": "bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/wizard/sweep_job_test.rb", "acceptanceCriteria": ["one_time completion detected (user/anchor)", "ensure_wizard_completed redirect+bounce", "SweepJob reaps expired in_progress/completing with cleanup, keeps completed/null-expiry"], "requiresUserVerification": false}
|
|
1571
|
+
```
|
|
1572
|
+
|
|
1573
|
+
---
|
|
1574
|
+
|
|
1575
|
+
### Task 8: Documentation and skill
|
|
1576
|
+
|
|
1577
|
+
**Goal:** A user guide, reference pages, and the `plutonium-wizard` AI skill, wired into nav and the umbrella skill map.
|
|
1578
|
+
|
|
1579
|
+
**Files:**
|
|
1580
|
+
- Create: `.claude/skills/plutonium-wizard/SKILL.md`, `docs/guides/wizards.md`, `docs/reference/wizard/{dsl,anchoring-resume,storage-config,registration-launch,one-time}.md`
|
|
1581
|
+
- Modify: VitePress nav config (`docs/.vitepress/config.*`), umbrella skill `.claude/skills/plutonium/SKILL.md` skill-map
|
|
1582
|
+
- Test: `yarn docs:build` (no broken links)
|
|
1583
|
+
|
|
1584
|
+
**Acceptance Criteria:**
|
|
1585
|
+
- [ ] Guide covers: a minimal `execute`-only wizard; branching with `condition:`; `using:` reuse; per-step `on_submit`/`persist`/`on_rollback`; one-time onboarding; registration (resource + standalone); config (`config.wizards.*`).
|
|
1586
|
+
- [ ] Skill mirrors other `plutonium-*` skills' frontmatter/structure and is added to the umbrella skill map.
|
|
1587
|
+
- [ ] `yarn docs:build` passes (no broken links).
|
|
1588
|
+
|
|
1589
|
+
**Verify:** `yarn docs:build` → success
|
|
1590
|
+
|
|
1591
|
+
**Steps:**
|
|
1592
|
+
|
|
1593
|
+
- [ ] **Step 1:** Write `docs/guides/wizards.md` (task-oriented, from spec §2–§9 examples).
|
|
1594
|
+
- [ ] **Step 2:** Write the `docs/reference/wizard/*.md` pages; add all to VitePress nav.
|
|
1595
|
+
- [ ] **Step 3:** Write `.claude/skills/plutonium-wizard/SKILL.md`; add to umbrella skill map.
|
|
1596
|
+
- [ ] **Step 4:** `yarn docs:build` → fix any broken links.
|
|
1597
|
+
- [ ] **Step 5: Commit**
|
|
1598
|
+
|
|
1599
|
+
```bash
|
|
1600
|
+
git add .claude/skills/plutonium-wizard docs/guides/wizards.md docs/reference/wizard .claude/skills/plutonium/SKILL.md docs/.vitepress
|
|
1601
|
+
git commit -m "docs(wizard): guide, reference pages, plutonium-wizard skill"
|
|
1602
|
+
```
|
|
1603
|
+
|
|
1604
|
+
```json:metadata
|
|
1605
|
+
{"files": [".claude/skills/plutonium-wizard/SKILL.md", "docs/guides/wizards.md", "docs/reference/wizard/dsl.md"], "verifyCommand": "yarn docs:build", "acceptanceCriteria": ["guide covers core flows", "skill mirrors plutonium-* + added to umbrella map", "docs:build passes"], "requiresUserVerification": false}
|
|
1606
|
+
```
|
|
1607
|
+
|
|
1608
|
+
---
|
|
1609
|
+
|
|
1610
|
+
## Self-Review notes
|
|
1611
|
+
|
|
1612
|
+
- **Spec coverage:** §2 (DSL) → Tasks 2–3, 6; §3 (anchoring) → Task 2,5; §4 (identity/resume) → Tasks 1,4,5; §5 (registration) → Task 5; §6 (runtime) → Task 4–5; §7 (UI) → Task 6; §8 (storage) → Task 1; §9 (one-time) → Task 7; §10 (migrations/config) → Task 0; §14 (testing) → every task; §15 (docs) → Task 8.
|
|
1613
|
+
- **Verification requirement scan:** the originating request requires no human-in-the-loop verification → **NO**; no `requiresUserVerification: true` tasks. (Confirmed in header.)
|
|
1614
|
+
- **Learnings from executed tasks (carry forward):**
|
|
1615
|
+
- **Plutonium is a Railtie, not an Engine** — the gem's Zeitwerk loader is rooted at `lib/`, so wizard classes live under `lib/plutonium/wizard/` (the `Session` AR model went to `lib/plutonium/wizard/session.rb`, NOT `app/models`). **Task 5 (controller) and Task 6 (UI components):** before placing files under `app/`, verify how existing Plutonium controllers/Phlex components are exposed to host apps (they may be base classes mixed into host controllers, or `app/` may be added to paths by the Railtie). Place wizard controller/components consistently with existing Plutonium controllers/UI — do not assume `app/` autoloads.
|
|
1616
|
+
- **Task 4 (runner):** the AR store's `find_or_initialize_by + save!` upsert has a TOCTOU window; the unique `instance_key` index is the backstop. The runner must **rescue `ActiveRecord::RecordNotUnique` on concurrent session creation** (re-read and proceed). Also: on cancel/sweep, run `on_rollback`/destroy of tracked records **before** `store.clear` (which is `delete_all`, no callbacks) — never rely on `dependent:`. Step validation must call `step.imported_validate_fn` (model-only now — `Model.new(slice).valid?`, returns `{attr => [msgs]}` filtered to imported + `:base`; **may be nil** when `validate: false` → nil-guard) and **merge** with inline `validates` errors. The earlier interaction/`view_context` validation concern is moot (interaction targets were dropped — `using:` is model-only). The corrected Outcome API: `succeed`→`Success.new(value)`, `failed`→`Failure.new` + ActiveModel errors. The `data` memo is invalidated on `data_attributes=` (fixed in Task 2).
|
|
1617
|
+
- Optional polish (non-blocking): make `Store#complete` return type consistent across adapters (document as void in `Base`).
|
|
1618
|
+
- **Open follow-up after Task 5:** per-resource **anchored member routes** (`/<resource>/:id/wizards/<name>/:step`) are NOT yet drawn in `register_resource` — only portal-level `register_wizard` routes exist. The `wizard` definition macro + controller support anchors, but the anchored-resource launch needs member-route nesting in `mapper_extensions.rb` + an anchored integration test. Fold into Task 6 or a small routing task. Also: Task 6 should route param extraction through the form's `extract_input` (Task 5 uses `params[:wizard].to_unsafe_h`, safe only because the typed `data` snapshot ignores undeclared keys) and flag for the final review: signed-cookie token handling, `authorize?` default-allow, and pre_submit turbo_stream.
|
|
1619
|
+
- **Known risk points flagged for the implementer (verify against real APIs before/while coding):** the `persist` macro binding inside `on_submit` (Task 4); `Outcome::Success.new`/`Failure.new` exact constructor + error access (Tasks 2,4); definition record-class accessor + `defined_inputs` (Task 3); `errors.group_by_attribute` (Tasks 3,4); `Action::RouteOptions`/`url_resolver` shape (Task 5); reusing `UI::Form::Interaction` internals for per-step forms (Task 6). These are integration seams — each has a test that will surface a mismatch immediately.
|