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,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
#
|
|
4
|
+
# Sample wizards illustrating the Plutonium::Wizard DSL.
|
|
5
|
+
# Companion to docs/superpowers/specs/2026-06-15-wizard-dsl-design.md.
|
|
6
|
+
# Illustrative only — the subsystem isn't built yet, so this won't load/run.
|
|
7
|
+
#
|
|
8
|
+
# ── DSL cheatsheet ───────────────────────────────────────────────────────────
|
|
9
|
+
# presents label:/icon: chrome (button label + icon), like interactions
|
|
10
|
+
# navigation :linear|:free stepper navigation policy (default :linear)
|
|
11
|
+
# anchored with: Model wizard runs against an existing record (URL :id); read via `anchor`
|
|
12
|
+
# anchored via: :method context anchor resolved by a controller method (portal-level)
|
|
13
|
+
# concurrency_key { … } key a run (tenant folded in); keyed in_progress row is the lock
|
|
14
|
+
# one_time retain the completed row at the key → run once (needs concurrency_key)
|
|
15
|
+
# cleanup_after 7.days|:never idle TTL before abandoned sessions are swept
|
|
16
|
+
# anonymous opt into guest (unauthenticated) access; mount public: true.
|
|
17
|
+
# auth is REQUIRED by default; authed lookups are owner-scoped.
|
|
18
|
+
# a guest wizard may authenticate ONLY at its terminal execute.
|
|
19
|
+
# def authorize? entry gate for portal-level wizards (no resource policy)
|
|
20
|
+
# step :key, label:, condition:, using: do ... end one screen (block declares its
|
|
21
|
+
# fields/hooks); condition: gates it (branching). The block
|
|
22
|
+
# is optional only when `using:` supplies everything.
|
|
23
|
+
# attribute/input/validates/structured_input/form_layout the existing field DSL (in the block)
|
|
24
|
+
# on_submit { ... } OPTIONAL per-step write hook (save-as-you-go)
|
|
25
|
+
# persist record inside on_submit: register record(s) → persisted[:key]
|
|
26
|
+
# fail!("msg") abort the step with an error (sugar over StepError)
|
|
27
|
+
# on_rollback { ... } OPTIONAL extra cleanup of untracked side effects on cancel/abandon
|
|
28
|
+
# (persist'd records are ALWAYS destroyed by the engine regardless)
|
|
29
|
+
# review label: terminal step: auto-summary + gated Finish
|
|
30
|
+
# def execute at-end hook; returns succeed(...) / failed(...) (an Outcome)
|
|
31
|
+
# data.<step>.<field> step-keyed, typed snapshot (e.g. data.company.name);
|
|
32
|
+
# two steps may share a field name without colliding
|
|
33
|
+
# anchor / persisted[:key] the launched-against record / records created via persist
|
|
34
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
# 1. Create flow — execute-only, multi-model, branching.
|
|
38
|
+
# Nothing is written until the end; `execute` does all writes in one
|
|
39
|
+
# transaction. Branching is subtractive via `condition:` (nil-safe).
|
|
40
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
class CompanyOnboardingWizard < Plutonium::Wizard::Base
|
|
42
|
+
# `presents` sets the launch button's label + icon (same as interactions).
|
|
43
|
+
presents label: "Onboard a company", icon: Phlex::TablerIcons::BuildingSkyscraper
|
|
44
|
+
|
|
45
|
+
# Stepper behaviour. :linear = forward + back to visited steps (default).
|
|
46
|
+
navigation :linear
|
|
47
|
+
|
|
48
|
+
# A `step` is one screen. The block declares its fields with the existing
|
|
49
|
+
# field DSL (attribute = typed data, input = how it renders, validates = rules).
|
|
50
|
+
step :company, label: "Company details" do
|
|
51
|
+
attribute :name, :string
|
|
52
|
+
attribute :subdomain, :string
|
|
53
|
+
input :name
|
|
54
|
+
input :subdomain
|
|
55
|
+
validates :name, :subdomain, presence: true
|
|
56
|
+
|
|
57
|
+
# Optional: section THIS step's fields (reuses form_layout, scoped to the step).
|
|
58
|
+
form_layout do
|
|
59
|
+
section :identity, :name, :subdomain, label: "Identity", columns: 2
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
step :plan, label: "Plan" do
|
|
64
|
+
attribute :plan, :string
|
|
65
|
+
input :plan, as: :radio_buttons, choices: %w[free pro]
|
|
66
|
+
validates :plan, presence: true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# `condition:` decides whether this step is included (subtractive branching).
|
|
70
|
+
# It runs against the step-keyed `data` snapshot; `data.plan.plan` is nil until
|
|
71
|
+
# the plan step is filled, and `nil == "pro"` is false — so it must be nil-safe.
|
|
72
|
+
step :billing, label: "Billing", condition: -> { data.plan.plan == "pro" } do
|
|
73
|
+
attribute :card_token, :string
|
|
74
|
+
input :card_token
|
|
75
|
+
validates :card_token, presence: true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
step :team, label: "Invite your team" do
|
|
79
|
+
# `structured_input ..., repeat:` = a repeatable group of sub-fields.
|
|
80
|
+
# Reachable later as data.team.invites (an array of typed sub-objects).
|
|
81
|
+
structured_input :invites, repeat: 5 do |f|
|
|
82
|
+
f.input :email, as: :email
|
|
83
|
+
f.input :role, as: :select, choices: %w[admin member]
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# `review` is a built-in terminal step: auto-summarises everything entered and
|
|
88
|
+
# gates the Finish button until all visible steps are valid. Must be last.
|
|
89
|
+
review label: "Review & submit"
|
|
90
|
+
|
|
91
|
+
# `execute` runs once at the end, in one transaction. Use bang methods so a
|
|
92
|
+
# failure raises → the engine rolls back and re-renders with the error.
|
|
93
|
+
# Return an Outcome: succeed(value) / failed(errors).
|
|
94
|
+
def execute
|
|
95
|
+
company = Company.create!(name: data.company.name, subdomain: data.company.subdomain, plan: data.plan.plan)
|
|
96
|
+
Billing.create!(company:, token: data.billing.card_token) if data.plan.plan == "pro"
|
|
97
|
+
data.team.invites.each { |i| company.invites.create!(email: i.email, role: i.role) }
|
|
98
|
+
succeed(company).with_message("You're all set!") # with_message → flash
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
103
|
+
# 2. Anchored, save-as-you-go flow with field reuse + cleanup.
|
|
104
|
+
# Launched against an existing Company (the `anchor`). Uses `using:` to import
|
|
105
|
+
# a definition's fields/validations/layout instead of re-declaring them, and
|
|
106
|
+
# per-step `on_submit`/`persist`/`on_rollback` to write as it goes.
|
|
107
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
class ConfigureCompanyWizard < Plutonium::Wizard::Base
|
|
109
|
+
presents label: "Configure", icon: Phlex::TablerIcons::Settings
|
|
110
|
+
|
|
111
|
+
# `anchored with:` binds the wizard to an existing record; `anchor` returns it.
|
|
112
|
+
# (Calling `anchor` on a non-anchored wizard raises NotAnchoredError.)
|
|
113
|
+
anchored with: Company
|
|
114
|
+
|
|
115
|
+
# Idle sessions are swept after this long (records created so far are rolled
|
|
116
|
+
# back). Use :never to keep partial records indefinitely.
|
|
117
|
+
cleanup_after 7.days
|
|
118
|
+
|
|
119
|
+
# `using:` imports a field surface from a model (types + validations from the
|
|
120
|
+
# model; input styling from its auto-resolved CompanyDefinition) — no need to
|
|
121
|
+
# re-declare. `fields:` selects a subset. Persistence stays wizard-side; only
|
|
122
|
+
# the declarations are reused.
|
|
123
|
+
step :branding, label: "Branding", using: Company, fields: %i[logo brand_color]
|
|
124
|
+
|
|
125
|
+
step :billing, label: "Billing", condition: -> { anchor.paid_plan? } do
|
|
126
|
+
attribute :card_token, :string
|
|
127
|
+
input :card_token
|
|
128
|
+
validates :card_token, presence: true
|
|
129
|
+
|
|
130
|
+
# `on_submit` runs when THIS step completes (opt-in save-as-you-go).
|
|
131
|
+
on_submit do
|
|
132
|
+
charge = PaymentApi.authorize!(anchor, data.billing.card_token)
|
|
133
|
+
fail!("Card was declined") unless charge.ok? # abort with a base error
|
|
134
|
+
# `persist` registers the record for resume + cleanup → persisted[:billing]
|
|
135
|
+
persist Billing.create!(company: anchor, token: data.billing.card_token, charge_id: charge.id)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# ADDITIONAL cleanup if the wizard is cancelled/abandoned. The engine ALWAYS
|
|
139
|
+
# destroys the persisted Billing record on rollback — on_rollback is only for
|
|
140
|
+
# side effects the engine can't see (here: refunding the external charge). It
|
|
141
|
+
# runs BEFORE the destroy, so `persisted[:billing]` is still alive to read.
|
|
142
|
+
on_rollback { PaymentApi.refund!(persisted[:billing].charge_id) }
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def execute
|
|
146
|
+
anchor.update!(configured_at: Time.current)
|
|
147
|
+
succeed(anchor).with_message("Company configured.")
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
# 3. One-time, standalone onboarding (no anchor).
|
|
153
|
+
# Mounted on its own route; gated so a user only sees it until completed.
|
|
154
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
155
|
+
class WelcomeWizard < Plutonium::Wizard::Base
|
|
156
|
+
presents label: "Welcome"
|
|
157
|
+
|
|
158
|
+
# Keyed per user (tenant folded in); `one_time` retains the completed row as a
|
|
159
|
+
# durable "done" marker the gate (below) checks — so it never re-runs.
|
|
160
|
+
concurrency_key { current_user }
|
|
161
|
+
one_time
|
|
162
|
+
|
|
163
|
+
# Portal-level wizards have no resource policy, so gate entry with `authorize?`.
|
|
164
|
+
def authorize? = current_user.present?
|
|
165
|
+
|
|
166
|
+
step :profile, label: "Your profile" do
|
|
167
|
+
attribute :full_name, :string
|
|
168
|
+
attribute :timezone, :string
|
|
169
|
+
input :full_name
|
|
170
|
+
input :timezone, as: :select, choices: ActiveSupport::TimeZone.all.map(&:name)
|
|
171
|
+
validates :full_name, presence: true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
step :preferences, label: "Preferences" do
|
|
175
|
+
attribute :newsletter, :boolean, default: true
|
|
176
|
+
input :newsletter, as: :toggle
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
review label: "All set?"
|
|
180
|
+
|
|
181
|
+
def execute
|
|
182
|
+
current_user.update!(
|
|
183
|
+
full_name: data.profile.full_name, timezone: data.profile.timezone,
|
|
184
|
+
newsletter: data.preferences.newsletter, onboarded_at: Time.current
|
|
185
|
+
)
|
|
186
|
+
succeed.with_message("Welcome aboard!")
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
191
|
+
# 4. Guest (anonymous) signup — runs PRE-LOGIN, mounted on a public route.
|
|
192
|
+
# Auth is required by default; `anonymous` opts out. A guest run's identity is
|
|
193
|
+
# a server-minted, unguessable cookie (httponly/secure/same_site, cleared on
|
|
194
|
+
# completion). The wizard NEVER crosses the auth boundary mid-flow — the only
|
|
195
|
+
# boundary it may cross is its terminal `execute` (here: create + sign in).
|
|
196
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
class GuestSignupWizard < Plutonium::Wizard::Base
|
|
198
|
+
presents label: "Sign up"
|
|
199
|
+
|
|
200
|
+
anonymous # may run without a current_user
|
|
201
|
+
|
|
202
|
+
step :account do
|
|
203
|
+
attribute :email, :string
|
|
204
|
+
attribute :password, :string
|
|
205
|
+
input :email, as: :email
|
|
206
|
+
input :password, as: :password
|
|
207
|
+
validates :email, :password, presence: true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
review label: "Review"
|
|
211
|
+
|
|
212
|
+
def execute
|
|
213
|
+
account = Account.create!(email: data.account.email, password: data.account.password)
|
|
214
|
+
# A real signup would sign the account in here (the host calls Rodauth, which
|
|
215
|
+
# rotates the Rails session) — no special framework handling is needed.
|
|
216
|
+
succeed(account).with_message("Welcome!")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
# Registration
|
|
222
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
# (a) On a resource definition — the `wizard` macro synthesizes the launch
|
|
225
|
+
# action. Placement mirrors interactions: anchored → record action (per row);
|
|
226
|
+
# no anchor → resource/collection action (index header).
|
|
227
|
+
class CompanyDefinition < Plutonium::Resource::Definition
|
|
228
|
+
wizard :configure, ConfigureCompanyWizard # anchored → record action
|
|
229
|
+
wizard :onboard, CompanyOnboardingWizard # no anchor → collection action
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# (b) Portal-level — inside a portal engine's routes (alongside register_resource).
|
|
233
|
+
# Runs within the portal (auth/scoping/layout inherited). Portal-relative path.
|
|
234
|
+
# register_wizard WelcomeWizard, at: "welcome"
|
|
235
|
+
#
|
|
236
|
+
# A guest (anonymous) wizard mounts on a PUBLIC route (drawn on the main app,
|
|
237
|
+
# outside the portal's auth constraint, so it's reachable pre-login).
|
|
238
|
+
# public: true is the default for an anonymous wizard.
|
|
239
|
+
# register_wizard GuestSignupWizard, at: "signup", public: true
|
|
240
|
+
|
|
241
|
+
# (c) Gating the one-time wizard — in a portal/ApplicationController. Redirects
|
|
242
|
+
# the user into the wizard until completed, then bounces them back.
|
|
243
|
+
# class DashboardController < PlutoniumController
|
|
244
|
+
# ensure_wizard_completed WelcomeWizard
|
|
245
|
+
# end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Wizard relaunch prompt — "resume or start new" on bare launch
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Tokened (repeatable) wizards — those with **no `concurrency_key`** — mint a fresh
|
|
6
|
+
run on every bare launch (`GET /onboarding`). A user with one or more pending
|
|
7
|
+
(in-progress) runs has no way to resume from the launch URL; each visit forks a
|
|
8
|
+
new run. Keyed wizards (incl. one-time and anchored) already auto-resume their
|
|
9
|
+
single keyed run, so this gap is specific to tokened wizards.
|
|
10
|
+
|
|
11
|
+
## Goal
|
|
12
|
+
|
|
13
|
+
Let a tokened wizard **opt in** so that a bare launch with ≥1 pending run shows a
|
|
14
|
+
chooser page — list the pending runs (resume any) or start a new one — instead of
|
|
15
|
+
silently forking. `/onboarding` becomes a chooser in that case.
|
|
16
|
+
|
|
17
|
+
## Decisions (settled in brainstorming)
|
|
18
|
+
|
|
19
|
+
- **Opt-in**, not default. A macro turns it on; default behaviour (fresh run) is
|
|
20
|
+
unchanged, so existing wizards and "run repeatedly" flows don't nag.
|
|
21
|
+
- **Chooser whenever ≥1 pending** (owner- and tenant-scoped). Even a single
|
|
22
|
+
pending run shows the page, so "Start new" stays reachable.
|
|
23
|
+
- **0 pending → fresh run**, exactly as today.
|
|
24
|
+
- **No-op unless it matters**: ignored for keyed wizards (already auto-resume) and
|
|
25
|
+
`anonymous`/guest wizards (session-keyed single run, no owner).
|
|
26
|
+
|
|
27
|
+
## Design
|
|
28
|
+
|
|
29
|
+
### 1. DSL — `on_relaunch`
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
on_relaunch :prompt # show the chooser when pending runs exist
|
|
33
|
+
# omit → :new (default) → always a fresh run
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`dsl.rb`: store `@on_relaunch` (default `:new`), inherit it, expose
|
|
37
|
+
`relaunch_prompt?` (`@on_relaunch == :prompt`).
|
|
38
|
+
|
|
39
|
+
### 2. Launch branch — `driving.rb#wizard_launch`
|
|
40
|
+
|
|
41
|
+
Before building the runner (which mints a token), decide:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
require auth
|
|
45
|
+
if relaunch_prompt? && !anonymous? && !concurrency_key?
|
|
46
|
+
&& params[:new].blank? && pending_entries.any?
|
|
47
|
+
→ render the chooser page (no token minted)
|
|
48
|
+
else
|
|
49
|
+
→ (today) build runner → mint token → PRG to first step
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `pending_entries` = `Plutonium::Wizard::Resume.entries_for(owner, scope:)`
|
|
53
|
+
filtered to `current_wizard_class` — reuses the existing listing module, which
|
|
54
|
+
already resolves owner/tenant scoping and per-run `resume_url`s.
|
|
55
|
+
- `params[:new]` present → skip the chooser (the "Start new" path).
|
|
56
|
+
|
|
57
|
+
### 3. Chooser page — `Plutonium::UI::Page::WizardChooser`
|
|
58
|
+
|
|
59
|
+
Same card aesthetic as `WizardCompleted`. Renders the wizard label/description,
|
|
60
|
+
then a list of pending runs — each row: current-step label + relative
|
|
61
|
+
`updated_at` + a **Resume** link (`entry.resume_url`) — and a primary **Start
|
|
62
|
+
new** button (→ bare launch URL with `?new=1`). No per-row discard in v1 (YAGNI;
|
|
63
|
+
`cancel` makes it easy to add later).
|
|
64
|
+
|
|
65
|
+
### 4. "Start new" path
|
|
66
|
+
|
|
67
|
+
The Start-new button links to the bare launch URL + `?new=1`. Re-enters
|
|
68
|
+
`wizard_launch`; the `params[:new]` guard skips the chooser; mints a fresh token
|
|
69
|
+
and redirects to the first step — identical to today's launch. GET, like the
|
|
70
|
+
existing launch (no DB row until the first step is submitted → no orphans).
|
|
71
|
+
|
|
72
|
+
## Testing
|
|
73
|
+
|
|
74
|
+
A tokened dummy wizard with `on_relaunch :prompt`:
|
|
75
|
+
|
|
76
|
+
- 0 pending → fresh redirect (unchanged).
|
|
77
|
+
- ≥1 pending → chooser lists them with resume links + a Start-new control.
|
|
78
|
+
- `?new=1` → fresh redirect even with pending.
|
|
79
|
+
- opt-out wizard → always fresh (no chooser).
|
|
80
|
+
- keyed / anonymous wizard declaring `on_relaunch` → no-op (still auto-resume / fork).
|
|
81
|
+
|
|
82
|
+
## Out of scope
|
|
83
|
+
|
|
84
|
+
- Per-row discard/cancel from the chooser (v2).
|
|
85
|
+
- Changing keyed/one-time/anchored behaviour (already auto-resume).
|
|
86
|
+
- A standalone dashboard widget (the `Resume` module already covers that).
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Wizard attachments: token → attachment bridge
|
|
2
|
+
|
|
3
|
+
Status: **design / implementing**
|
|
4
|
+
Date: 2026-06-18
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
A wizard step can render a file input — the real-world pattern (a colleague's):
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
step :photo, label: "Photo" do
|
|
12
|
+
attribute :photo, :string
|
|
13
|
+
input :photo, as: :uppy, direct_upload: true, hint: "Optional — …"
|
|
14
|
+
end
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
— but attachments don't display end to end:
|
|
18
|
+
|
|
19
|
+
- The `Uppy` input and `Display::Components::Attachment` expect a value that
|
|
20
|
+
**quacks like an attachment** (`.url`, `.filename`, `.content_type`, …).
|
|
21
|
+
- A wizard stages **plain typed scalars**. A direct-upload field submits its
|
|
22
|
+
backend's upload **token** (an ActiveStorage signed_id, or Shrine cached-file
|
|
23
|
+
JSON), so `data.photo` is a `String`.
|
|
24
|
+
- `SummaryDisplay` (the review auto-summary) hardcodes `f.string_tag` for every
|
|
25
|
+
field — an attachment shows as a raw token, never a preview.
|
|
26
|
+
- On Back/resume, the `Uppy` input calls `.url` on the staged `String` — wrong.
|
|
27
|
+
|
|
28
|
+
## Backends — there is more than ActiveStorage
|
|
29
|
+
|
|
30
|
+
Plutonium attachment fields can be backed by **ActiveStorage** *or* **active_shrine**
|
|
31
|
+
(Shrine) — see `Plutonium::UI::Avatar.resolve_image_src`, which branches (AS resolves
|
|
32
|
+
its URL via Rails routing; Shrine via its own `#url`). They carry different upload
|
|
33
|
+
tokens, and active_shrine is **not a core-gem dependency** (generator-installed), so
|
|
34
|
+
it isn't exercised by this repo's suite. The bridge must therefore be
|
|
35
|
+
backend-agnostic by construction; the dummy covers the AS path.
|
|
36
|
+
|
|
37
|
+
## Key decision — model-free resolution by token SHAPE
|
|
38
|
+
|
|
39
|
+
The field is bare (`attribute :photo, :string`, no `using:` model), so there is **no
|
|
40
|
+
model to resolve against** — and we don't need one. The staged token is one of two
|
|
41
|
+
shapes, and they're distinguishable:
|
|
42
|
+
|
|
43
|
+
- **Shrine** cached-file data is **JSON** (`{"id":…,"storage":"cache",…}`) →
|
|
44
|
+
`Shrine.uploaded_file(data)` revives it from the globally-registered storages.
|
|
45
|
+
- An **ActiveStorage** signed_id is **not** JSON → `ActiveStorage::Blob.find_signed`.
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Plutonium::Wizard::Attachments.resolve(token) # → [Shrine::UploadedFile | ActiveStorage::Blob]
|
|
49
|
+
# JSON.parse succeeds → Shrine ; else → ActiveStorage ; blank/tampered → dropped
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`data` stays plain strings (no custom ActiveModel type; `encrypt_data`, merge, sweep
|
|
53
|
+
untouched). Resolution is **display-only** — staging and `execute` never call it.
|
|
54
|
+
|
|
55
|
+
Why token-shape over a source model: the actual usage is a **modelless** `as: :uppy`
|
|
56
|
+
field, so there's no `using:` model to assign to; and the two token shapes are
|
|
57
|
+
unambiguous, so a model would be ceremony, not information. (A custom Shrine uploader
|
|
58
|
+
with derivative-specific URLs would want its own attacher, but a cached file's `.url`
|
|
59
|
+
from the base `Shrine` is correct for the in-flight preview/review.)
|
|
60
|
+
|
|
61
|
+
## The four boundaries
|
|
62
|
+
|
|
63
|
+
1. **Declaration.** `attribute :photo, :string` + `input :photo, as: :uppy`
|
|
64
|
+
(`/:file`/`:attachment`), `direct_upload: true`. Multiple → an array attribute +
|
|
65
|
+
`multiple: true`. An attachment field = a step input whose `as:` is a file alias
|
|
66
|
+
(`Attachments.field?`).
|
|
67
|
+
2. **Staging.** The submitted token(s) extract through the step form like any other
|
|
68
|
+
field and stage as the string/array — no special handling (verify in a request
|
|
69
|
+
test).
|
|
70
|
+
3. **Input rehydration (Back/resume).** The wizard step form wraps its data object so
|
|
71
|
+
an attachment field READS as the resolved attachment object(s) (for the `Uppy`
|
|
72
|
+
preview) while every other field reads normally. Render-only: the submitted value
|
|
73
|
+
is still the token the hidden preview field posts, so staging is unaffected.
|
|
74
|
+
4. **Review.** `SummaryDisplay` detects attachment fields (`field?`) and renders the
|
|
75
|
+
resolved attachment via `Display::Components::Attachment` instead of `string_tag`.
|
|
76
|
+
|
|
77
|
+
## Execute
|
|
78
|
+
|
|
79
|
+
Author code, unchanged shape: `succeed(Member.create!(photo: data.photo.photo))`.
|
|
80
|
+
The staged token round-trips into the model's attachment natively (AS or Shrine).
|
|
81
|
+
|
|
82
|
+
## Risks / dependencies
|
|
83
|
+
|
|
84
|
+
- **Direct-upload endpoint.** `Uppy` posts to `/upload` (AS DirectUploads, or Shrine's
|
|
85
|
+
`upload_endpoint`). A shell-less / main-app wizard must have it reachable. Host
|
|
86
|
+
concern; document it.
|
|
87
|
+
- **Orphaned uploads.** A token staged then abandoned (cancel/sweep) leaves an
|
|
88
|
+
unattached blob / cached Shrine file; each backend's own cleanup handles it. Note it.
|
|
89
|
+
- **Bad/expired token** → resolution drops the entry (no raise), so a tampered token
|
|
90
|
+
never 500s the review or the form.
|
|
91
|
+
- **active_shrine untestable in-repo** (not a dep) — the dummy proves the AS path; the
|
|
92
|
+
Shrine branch is unit-stubbed and rides the same `.url`/`.filename` contract
|
|
93
|
+
(confirmed for display by `Avatar.resolve_image_src`).
|
|
94
|
+
|
|
95
|
+
## Build order
|
|
96
|
+
|
|
97
|
+
1. `Plutonium::Wizard::Attachments` — `field?` + token-shape `resolve`. ✅ done
|
|
98
|
+
2. `SummaryDisplay`: attachment-aware rendering + a dummy `as: :uppy` step + review test.
|
|
99
|
+
3. Input rehydration wrapper + a Back/resume request test.
|
|
100
|
+
4. Full dummy flow (upload token → review → execute assigns the attachment) +
|
|
101
|
+
integration test. Requires AS tables provisioned in the dummy.
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# Wizard hosting: controller resolution, identity, and chrome
|
|
2
|
+
|
|
3
|
+
Status: **design / agreed direction** (no code yet)
|
|
4
|
+
Date: 2026-06-18
|
|
5
|
+
Supersedes the "wizards are portal-hosted only (v1)" framing.
|
|
6
|
+
|
|
7
|
+
## Problem
|
|
8
|
+
|
|
9
|
+
A wizard renders through the Plutonium controller stack (rendering, view-path
|
|
10
|
+
prefix, layout). Today the host controller is **synthesized** at route-draw time,
|
|
11
|
+
and the synthesis assumes a portal context. That leaves three things
|
|
12
|
+
under-specified once wizards run outside a single portal:
|
|
13
|
+
|
|
14
|
+
1. **Controller resolution** — which controller serves a wizard, and how an app
|
|
15
|
+
customizes it without a generator.
|
|
16
|
+
2. **Identity / auth** — where `current_user` comes from, and how a pre-login
|
|
17
|
+
(guest) wizard avoids requiring it.
|
|
18
|
+
3. **Chrome** — whether a wizard renders in the app shell, shell-less full page,
|
|
19
|
+
or embedded as a modal.
|
|
20
|
+
|
|
21
|
+
The guiding facts (verified against the code):
|
|
22
|
+
|
|
23
|
+
- **Authentication is enforced at the route constraint**, not the controller:
|
|
24
|
+
`constraints Rodauth::Rails.authenticate(:acct) { mount Engine }`. The gem adds
|
|
25
|
+
**no** `require_authentication` before_action; controller auth concerns
|
|
26
|
+
(`Plutonium::Auth::Rodauth(:acct)`) only *provide* `current_user`
|
|
27
|
+
(= `rodauth.rails_account`, `nil` when unauthenticated).
|
|
28
|
+
- **`Plutonium::Auth::Public` is just a `current_user => "Guest"` stub.**
|
|
29
|
+
- **The shell lives entirely in the layout** (`resource.html.erb` →
|
|
30
|
+
`ResourceLayout`: sidebar/header/topbar), not in the wizard page. The wizard
|
|
31
|
+
`Page` renders `header → stepper → body` and is layout-agnostic.
|
|
32
|
+
`Plutonium::Core::Controller` sets `layout -> { turbo_frame_request? ? false : "resource" }`.
|
|
33
|
+
|
|
34
|
+
## Catalogue of supported cases
|
|
35
|
+
|
|
36
|
+
| Surface | Enforcement | Identity (wizard) | Controller (else synthesize) | Chrome |
|
|
37
|
+
|---|---|---|---|---|
|
|
38
|
+
| **Portal standalone** (`register_wizard` in a portal) | portal constraint | authed (owner) | `<Portal>::PlutoniumController` | `:shell` (default) / `:standalone` |
|
|
39
|
+
| **Main-app standalone, authenticated** (`register_wizard` on the app) | route constraint or `require_wizard_authentication!` | authed (owner) | **app-defined** `::WizardsController` | `:shell`* / `:standalone` |
|
|
40
|
+
| **Main-app standalone, public** (`register_wizard public:`/`anonymous`) | none (outside constraints) | `anonymous` (guest token) | bare synthesized | `:standalone` (no shell to embed in) |
|
|
41
|
+
| **Resource-anchored** (`wizard` macro on a definition) | inherits the resource controller | authed (owner) | the **resource controller** (WizardActions concern) | **always embedded** (modal) |
|
|
42
|
+
|
|
43
|
+
\* a bare main-app base has no shell; "in-shell" only applies if the app's
|
|
44
|
+
`::WizardsController` provides one.
|
|
45
|
+
|
|
46
|
+
Two structural truths:
|
|
47
|
+
|
|
48
|
+
- **Portals are "auth or nothing."** You can't carve a public hole inside an
|
|
49
|
+
engine that's wholly behind a constraint, so a *public* wizard is **main-app
|
|
50
|
+
only**. (A portal *may* be mounted without its constraint — a "public portal" —
|
|
51
|
+
in which case `current_user` is still *available*, just possibly `nil`.)
|
|
52
|
+
- **Anchored wizards are not a wizard controller.** They are a concern
|
|
53
|
+
(`WizardActions`) mixed into the resource controller, inheriting its
|
|
54
|
+
auth/scope/layout. They stay out of the wizard-controller hierarchy entirely.
|
|
55
|
+
|
|
56
|
+
## 1. Controller resolution — override-first, no inheritance chain
|
|
57
|
+
|
|
58
|
+
There is **no single gem "global wizard controller" in the lineage.** A single
|
|
59
|
+
base class can't span portal and main-app contexts: the concrete controller's
|
|
60
|
+
superclass is *reserved* for the auth/context base (the portal's
|
|
61
|
+
`PlutoniumController`, the app's authenticated base, or a bare
|
|
62
|
+
`ActionController::Base`), so wizard behavior must compose via a **module**, not a
|
|
63
|
+
superclass.
|
|
64
|
+
|
|
65
|
+
Resolution per surface:
|
|
66
|
+
|
|
67
|
+
1. **Use the app's controller if it exists** (const check — works today):
|
|
68
|
+
- Portal: `<Portal>::WizardsController`
|
|
69
|
+
- Main app: `::WizardsController`
|
|
70
|
+
2. **Else synthesize:**
|
|
71
|
+
```ruby
|
|
72
|
+
Class.new(context_base) { include Plutonium::Wizard::Controller }
|
|
73
|
+
```
|
|
74
|
+
where `context_base` is:
|
|
75
|
+
- Portal → `<Portal>::PlutoniumController` (portal auth/scope/layout)
|
|
76
|
+
- Main-app → a **bare** base (`ActionController::Base`), **no auth** — an
|
|
77
|
+
authenticated main-app wizard therefore requires an app-defined
|
|
78
|
+
`::WizardsController` (same contract as `register_resource` controllers).
|
|
79
|
+
|
|
80
|
+
### The module is the contract
|
|
81
|
+
|
|
82
|
+
`Plutonium::Wizard::Controller` becomes the complete include surface. Including it
|
|
83
|
+
must yield a fully renderable wizard controller, so it pulls in
|
|
84
|
+
`Plutonium::Core::Controller` itself (asset/layout machinery) instead of the
|
|
85
|
+
synthesizer bolting Core on separately. Re-including Core on a portal parent that
|
|
86
|
+
already has it is a harmless no-op, so one module works everywhere.
|
|
87
|
+
|
|
88
|
+
**View-prefix note.** The gem's shared partials (`plutonium/_flash`, …) are looked
|
|
89
|
+
up by a `"plutonium"` view *prefix*, which normally comes from inheriting a
|
|
90
|
+
controller whose `controller_path` is `"plutonium"` (the app's
|
|
91
|
+
`PlutoniumController`) — *not* from `Core`'s `append_view_path` (that adds the
|
|
92
|
+
directory, not the prefix). A truly bare host lacks the ancestor, so the **module
|
|
93
|
+
contributes the `"plutonium"` prefix itself** (overriding `_prefixes`). This is
|
|
94
|
+
what makes "main-app can be bare" actually render.
|
|
95
|
+
|
|
96
|
+
- **Synthesizer:** `Class.new(context_base) { include Plutonium::Wizard::Controller }`
|
|
97
|
+
- **User override:** `class WizardsController < MyAuthBase; include Plutonium::Wizard::Controller; end`
|
|
98
|
+
|
|
99
|
+
### Convenience base class (sugar only)
|
|
100
|
+
|
|
101
|
+
For apps that need no custom auth base, ship a ready-made class:
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# Plutonium-provided
|
|
105
|
+
class Plutonium::Wizard::BaseController < ActionController::Base
|
|
106
|
+
include Plutonium::Wizard::Controller
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# app
|
|
110
|
+
class WizardsController < Plutonium::Wizard::BaseController; end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The class is convenience; the **module is the mechanism**.
|
|
114
|
+
|
|
115
|
+
## 2. Identity / auth — guest-ness belongs to the wizard
|
|
116
|
+
|
|
117
|
+
`current_user` has two distinct meanings:
|
|
118
|
+
|
|
119
|
+
- **Available** = a *controller* property: present wherever a `Rodauth(acct)`
|
|
120
|
+
concern is mixed in (returns `nil` when unauthenticated).
|
|
121
|
+
- **Expected** = a *wizard* property: a non-`anonymous` wizard owner-scopes off
|
|
122
|
+
`current_user`; an `anonymous` wizard *ignores it* and keys identity off the
|
|
123
|
+
session token (owner `nil`).
|
|
124
|
+
|
|
125
|
+
Therefore:
|
|
126
|
+
|
|
127
|
+
- Guest-ness is enforced **in the driving layer**: an `anonymous` wizard never
|
|
128
|
+
consults `current_user` for identity (it's session-token keyed, owner `nil`).
|
|
129
|
+
- A normal wizard with no `current_user` is rejected by the route constraint
|
|
130
|
+
(authenticated mounts) or by `require_wizard_authentication!` (the fallback that
|
|
131
|
+
also covers public mounts hosting a non-anonymous wizard).
|
|
132
|
+
- The wizard module supplies a **default `current_user`** that defers to the
|
|
133
|
+
host's auth concern when present (`defined?(super) ? super : nil`) and is `nil`
|
|
134
|
+
on a bare host — so a bare main-app/public controller has the method without
|
|
135
|
+
shadowing a real auth concern.
|
|
136
|
+
|
|
137
|
+
> **CORRECTION (was: "retire `Auth::Public`").** `Plutonium::Auth::Public` is
|
|
138
|
+
> **kept** — it is not wizard-specific: public *portals* (e.g. a storefront) use
|
|
139
|
+
> it, and a wizard can run inside one (a resource-anchored wizard on a public
|
|
140
|
+
> portal), where `current_user` is the `"Guest"` sentinel. So the driving layer's
|
|
141
|
+
> `current_user_present_for_wizard?` **keeps** its `!= "Guest"` check. What changed
|
|
142
|
+
> is only that the public *wizard* synthesis no longer *needs* `Auth::Public` for
|
|
143
|
+
> correctness (an `anonymous` wizard never reads `current_user`); it's retained
|
|
144
|
+
> there for a defined `current_user` and consistency with public portals.
|
|
145
|
+
|
|
146
|
+
## 3. Chrome — a `register_wizard` option, three modes
|
|
147
|
+
|
|
148
|
+
> **NAMING (as-built):** the option shipped as **`layout:` — a Rails layout NAME**,
|
|
149
|
+
> exactly like the controller `layout` macro (not a closed enum). It went `chrome:`
|
|
150
|
+
> → `shell:` → `layout:`: `chrome:` was disliked; `shell:` collided with the
|
|
151
|
+
> framework's `shell` chrome-variant enum (`:modern`/`:plain`/`:classic`), a
|
|
152
|
+
> different axis (*which* chrome vs *whether*); `layout:` names what it controls and
|
|
153
|
+
> the value *is* the layout (`:basic` ↔ `basic.html.erb`/`BasicLayout`, like
|
|
154
|
+
> `resource` ↔ `ResourceLayout`). The three "modes" below are: embedded (automatic,
|
|
155
|
+
> turbo-frame), `layout: :basic` (bare), and the host default / `:resource` (shell).
|
|
156
|
+
> Read "chrome" as the layout choice and `chrome: :standalone` as `layout: :basic`
|
|
157
|
+
> throughout this section.
|
|
158
|
+
|
|
159
|
+
The shell is a layout concern, so chrome = layout selection, made by the **driving
|
|
160
|
+
layer at render time** (works regardless of which controller serves the wizard,
|
|
161
|
+
and without touching the `Page` component):
|
|
162
|
+
|
|
163
|
+
| Mode | Appearance | `layout:` |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| **Embedded** | overlays the current page (launched from a button) | `false` (turbo-frame) — already automatic |
|
|
166
|
+
| **In-shell** | sidebar + topbar + wizard | `:resource` (or omit on a portal) — the `resource` layout |
|
|
167
|
+
| **Bare** | no sidebar/topbar — e.g. "set up your organization" | `:basic` — `BasicLayout` |
|
|
168
|
+
|
|
169
|
+
Rules:
|
|
170
|
+
|
|
171
|
+
- **Chrome is toggled only on `register_wizard`** (`chrome: :shell | :standalone`).
|
|
172
|
+
It travels with the mount, not the wizard class.
|
|
173
|
+
- **Resource-defined wizards (`wizard` macro) are always embedded** — launched
|
|
174
|
+
from a record/collection action, overlaying the page. No chrome option.
|
|
175
|
+
- **Turbo-frame requests are always layout-less** regardless of the setting (the
|
|
176
|
+
embedded path).
|
|
177
|
+
- **Default by mount context:** portal standalone → `:shell`; bare main-app
|
|
178
|
+
standalone → `:standalone` (no shell available). Both overridable.
|
|
179
|
+
|
|
180
|
+
## Resolved decisions
|
|
181
|
+
|
|
182
|
+
1. **Chrome surface:** `register_wizard` option only; resource wizards always
|
|
183
|
+
embedded. ✔
|
|
184
|
+
2. **Default full-page chrome:** portal → `:shell`, main-app → `:standalone`. ✔
|
|
185
|
+
3. **Retire `Auth::Public`:** yes — guest-ness moves to the `anonymous` wizard +
|
|
186
|
+
driving layer; guest = `current_user.nil?`. ✔
|
|
187
|
+
|
|
188
|
+
## Migration / current state
|
|
189
|
+
|
|
190
|
+
Already on the branch (keep — steps toward this design):
|
|
191
|
+
|
|
192
|
+
- `PublicWizardsController` split from `::WizardsController` (the const collision
|
|
193
|
+
fix). Under this design the public path is "a bare synthesized controller +
|
|
194
|
+
`anonymous` wizard," but the distinct const remains correct.
|
|
195
|
+
- The synthesized controller including `Plutonium::Core::Controller` itself — this
|
|
196
|
+
becomes "the module pulls in Core," i.e. moved into
|
|
197
|
+
`Plutonium::Wizard::Controller`.
|
|
198
|
+
|
|
199
|
+
To revert before implementing:
|
|
200
|
+
|
|
201
|
+
- The `Rodauth(:user)` added to the dummy's `::PlutoniumController` (wrong layer —
|
|
202
|
+
leaks auth into every portal base). Replace the dummy demo with an app-defined
|
|
203
|
+
`::WizardsController` that includes the wizard module + its own auth — which also
|
|
204
|
+
exercises the override hook.
|
|
205
|
+
|
|
206
|
+
## Implementation outline (for the follow-up, not this doc)
|
|
207
|
+
|
|
208
|
+
1. Fold `Core::Controller` into `Plutonium::Wizard::Controller`; ship
|
|
209
|
+
`Plutonium::Wizard::BaseController` convenience class.
|
|
210
|
+
2. Main-app synthesis: bare `ActionController::Base` base (drop the
|
|
211
|
+
`::PlutoniumController` parent); keep the const-check override.
|
|
212
|
+
3. `register_wizard … chrome:`; driving layer selects the layout
|
|
213
|
+
(`false` / `basic` / inherited) at render.
|
|
214
|
+
4. Remove `Auth::Public`; route guest identity through `anonymous` + the driving
|
|
215
|
+
layer; treat `current_user.nil?` as guest.
|
|
216
|
+
5. Dummy: app-defined `::WizardsController` (auth) for the authenticated main-app
|
|
217
|
+
demo; a separate `anonymous` public wizard for the guest demo; revert the
|
|
218
|
+
`::PlutoniumController` change.
|
|
219
|
+
6. Tests: override-hook is honored; bare main-app wizard is guest; chrome toggles
|
|
220
|
+
pick the right layout; anchored wizard stays embedded.
|