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,371 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Page
|
|
6
|
+
# The full-page wizard step page (§7). Composes the stepper, the current
|
|
7
|
+
# step's form (or the terminal review summary), and the Back / Next / Finish /
|
|
8
|
+
# Cancel navigation strip — all carrying `_direction`. Rendered inside the
|
|
9
|
+
# portal layout exactly like a resource page; in a turbo frame (modal) the
|
|
10
|
+
# layout is dropped by the controller.
|
|
11
|
+
#
|
|
12
|
+
# The step form rides the existing resource-form pipeline through a per-step
|
|
13
|
+
# adapter, seeded from the wizard's typed `data` so inputs (including repeater
|
|
14
|
+
# rows) rehydrate from staged data on GET — the resume/back requirement.
|
|
15
|
+
class Wizard < Plutonium::UI::Page::Base
|
|
16
|
+
# @param runner [Plutonium::Wizard::Runner]
|
|
17
|
+
# @param step_url [String] the current step's POST/GET URL.
|
|
18
|
+
# @param errors [Hash{Symbol=>Array<String>}] runner errors (per-field + :base).
|
|
19
|
+
def initialize(runner:, step_url:, errors: nil, description: nil)
|
|
20
|
+
@runner = runner
|
|
21
|
+
@step_url = step_url
|
|
22
|
+
@errors = errors || {}
|
|
23
|
+
@description = description
|
|
24
|
+
super(page_title: wizard_title, page_description: nil)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def view_template(&)
|
|
28
|
+
DynaFrameContent() do
|
|
29
|
+
article(class: "pu-wizard mx-auto max-w-3xl", data: {controller: "wizard"}) do
|
|
30
|
+
render_header
|
|
31
|
+
render_stepper
|
|
32
|
+
render_body
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def wizard_title
|
|
40
|
+
step = @runner.current_step
|
|
41
|
+
[@runner.wizard.class.label, step&.label].compact.join(" — ").presence || "Wizard"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Centered wizard header: the title and the wizard-level description
|
|
45
|
+
# (`presents description:`). The per-step heading lives on the step card.
|
|
46
|
+
def render_header
|
|
47
|
+
div(class: "pu-wizard-header mb-7 text-center") do
|
|
48
|
+
h1(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") do
|
|
49
|
+
@runner.wizard.class.label
|
|
50
|
+
end
|
|
51
|
+
desc = @description.presence || @runner.wizard.class.description
|
|
52
|
+
if desc.present?
|
|
53
|
+
p(class: "mx-auto mt-1.5 max-w-prose text-[var(--pu-text-muted)]") { desc }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_stepper
|
|
59
|
+
return unless @runner.wizard.class.stepper?
|
|
60
|
+
|
|
61
|
+
render Plutonium::UI::Wizard::Stepper.new(
|
|
62
|
+
steps: @runner.visible_path,
|
|
63
|
+
current: @runner.current_step,
|
|
64
|
+
visited: @runner.visited_keys,
|
|
65
|
+
# Actually-complete (submitted + valid) steps drive the done-check —
|
|
66
|
+
# `visited` alone would mark a reached-but-unsubmitted branch step done.
|
|
67
|
+
complete: @runner.visible_path.reject(&:review?).select { |s| @runner.step_complete?(s) }.map { |s| s.key.to_s },
|
|
68
|
+
navigation: @runner.wizard.class.navigation,
|
|
69
|
+
step_url: method(:url_for_step)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def render_body
|
|
74
|
+
step = @runner.current_step
|
|
75
|
+
if step&.review?
|
|
76
|
+
render_review_body(step)
|
|
77
|
+
else
|
|
78
|
+
render_step_form(step)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The focused content card shared by step + review: a body holding the
|
|
83
|
+
# step heading and content, and a footer nav strip.
|
|
84
|
+
def card_classes
|
|
85
|
+
"pu-wizard-card overflow-hidden rounded-[var(--pu-radius-xl)] " \
|
|
86
|
+
"border border-[var(--pu-border)] bg-[var(--pu-surface)] shadow-[var(--pu-shadow-lg)]"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def card_body_classes = "p-6 sm:p-8"
|
|
90
|
+
|
|
91
|
+
# The per-step heading: "Step N of M" + the step's label + its description.
|
|
92
|
+
# The terminal review step is the "finish line", not a numbered step, so it
|
|
93
|
+
# carries no step count (here or in the rail) — just its label + description.
|
|
94
|
+
def render_step_header(step)
|
|
95
|
+
div(class: "mb-6") do
|
|
96
|
+
unless step.review?
|
|
97
|
+
span(class: "text-xs font-bold uppercase tracking-wide text-primary-600 dark:text-primary-400") do
|
|
98
|
+
"Step #{step_position(step)} of #{visible_step_count}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
h2(class: "mt-1 text-xl font-semibold tracking-tight text-[var(--pu-text)]") { step.label.to_s }
|
|
102
|
+
# The author's own description wins. Otherwise, on a review step, fall
|
|
103
|
+
# back to the "check everything over" prompt ONLY when the summary is
|
|
104
|
+
# shown — when summary is off (the ready panel / custom-only body), that
|
|
105
|
+
# prompt would contradict the body, so we omit it.
|
|
106
|
+
desc = step.description.presence
|
|
107
|
+
desc ||= "Check everything over before you finish." if step.review? && step.summary?
|
|
108
|
+
if desc
|
|
109
|
+
p(class: "mt-1.5 text-sm text-[var(--pu-text-muted)]") { desc }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Counts/positions exclude the review step — it's not a numbered step — so
|
|
115
|
+
# the last real step reads "Step N of N" (not "N of N+1" with a missing N+1).
|
|
116
|
+
def visible_step_count = @runner.visible_path.count { |s| !s.review? }
|
|
117
|
+
|
|
118
|
+
def step_position(step)
|
|
119
|
+
(@runner.visible_path.reject(&:review?).index { |s| s.key.to_s == step.key.to_s } || 0) + 1
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# --- step form --------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def render_step_form(step)
|
|
125
|
+
seed_errors!(step)
|
|
126
|
+
div(class: card_classes) do
|
|
127
|
+
div(class: card_body_classes) do
|
|
128
|
+
render_step_header(step)
|
|
129
|
+
render Plutonium::UI::Form::Wizard.new(
|
|
130
|
+
step:,
|
|
131
|
+
# Decorate so attachment fields read as resolved attachments (the
|
|
132
|
+
# Uppy preview rehydrates on Back/resume); other fields pass through.
|
|
133
|
+
data: Plutonium::Wizard::AttachmentData.wrap(@runner.wizard.data[step.key], step),
|
|
134
|
+
action: @step_url,
|
|
135
|
+
fields: step_fields(step),
|
|
136
|
+
errors: form_error_messages(step)
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
render_nav(finish: false)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The step's renderable field names: scalar attributes + structured inputs.
|
|
144
|
+
def step_fields(step)
|
|
145
|
+
step.attribute_schema.keys.map(&:to_sym) + step.structured_inputs.keys.map(&:to_sym)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# --- review -----------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def render_review_body(step)
|
|
151
|
+
seed_errors!(step)
|
|
152
|
+
div(class: card_classes) do
|
|
153
|
+
form(action: @step_url, method: "post", id: "wizard-form", data: {controller: "wizard"}) do
|
|
154
|
+
div(class: card_body_classes) do
|
|
155
|
+
render_step_header(step) if step.header?
|
|
156
|
+
render_review_errors
|
|
157
|
+
authenticity_field
|
|
158
|
+
input(type: :hidden, name: "_direction", value: "next", data: {wizard_target: "direction"})
|
|
159
|
+
render Plutonium::UI::Wizard::Review.new(runner: @runner, step_url: method(:url_for_step))
|
|
160
|
+
end
|
|
161
|
+
render_nav(finish: true, embedded: true)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# --- navigation strip -------------------------------------------------
|
|
167
|
+
|
|
168
|
+
# @param finish [Boolean] last visible step → primary button is Finish.
|
|
169
|
+
# @param embedded [Boolean] the strip is already inside a <form> (review);
|
|
170
|
+
# otherwise wrap each button in its own posting form.
|
|
171
|
+
def render_nav(finish:, embedded: false)
|
|
172
|
+
finish_disabled = finish && @runner.incomplete_visible_steps.any?
|
|
173
|
+
|
|
174
|
+
div(class: "pu-wizard-nav flex items-center justify-between gap-3 border-t border-[var(--pu-border)] bg-[var(--pu-surface-alt)] px-6 py-4 sm:px-8") do
|
|
175
|
+
div(class: "flex items-center gap-2") do
|
|
176
|
+
nav_button("Back", direction: "back", style: "pu-btn-outline", embedded:) if show_back?
|
|
177
|
+
nav_button("Cancel", direction: "cancel", style: "pu-btn-ghost", embedded:)
|
|
178
|
+
end
|
|
179
|
+
div(class: "flex items-center gap-2") do
|
|
180
|
+
if finish
|
|
181
|
+
nav_button("Finish", direction: "next", style: "pu-btn-primary", embedded:, disabled: finish_disabled, name: "finish")
|
|
182
|
+
else
|
|
183
|
+
render_forward_buttons(embedded:)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The forward action(s) on a non-review step. The primary button reads
|
|
190
|
+
# "Save & continue" when revisiting an already-submitted step (the edit is
|
|
191
|
+
# persisted), else "Next". Once EVERY visible step is complete — the
|
|
192
|
+
# post-completion edit case — a "Save & review" shortcut appears that stages
|
|
193
|
+
# this step and jumps straight to review (the primary action then; "Save &
|
|
194
|
+
# continue" steps to the next step as the secondary). The shortcut is hidden
|
|
195
|
+
# when the next step already IS review (it would be redundant).
|
|
196
|
+
def render_forward_buttons(embedded:)
|
|
197
|
+
step = @runner.current_step
|
|
198
|
+
# On a validation-error re-render the rejected input is staged IN MEMORY
|
|
199
|
+
# (so the form keeps what was typed), which flips `submitted?` true — but
|
|
200
|
+
# nothing was persisted, so this isn't a re-edit. Keep "Next" there; the
|
|
201
|
+
# presence of `@errors` is the error-render signal.
|
|
202
|
+
revisiting = @runner.submitted?(step) && @errors.blank?
|
|
203
|
+
continue_label = revisiting ? "Save & continue" : "Next"
|
|
204
|
+
|
|
205
|
+
if review_shortcut?(step)
|
|
206
|
+
nav_button(continue_label, direction: "next", style: "pu-btn-outline", embedded:, name: "next")
|
|
207
|
+
nav_button("Save & review", direction: "next", style: "pu-btn-primary", embedded:, name: "save_review", goto: "review")
|
|
208
|
+
else
|
|
209
|
+
nav_button(continue_label, direction: "next", style: "pu-btn-primary", embedded:, name: "next")
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Whether to offer the "Save & review" shortcut: every visible step is
|
|
214
|
+
# complete AND the next visible step isn't already the review (so the
|
|
215
|
+
# shortcut lands somewhere the plain Next wouldn't).
|
|
216
|
+
def review_shortcut?(step)
|
|
217
|
+
return false unless @runner.incomplete_visible_steps.empty?
|
|
218
|
+
|
|
219
|
+
path = @runner.visible_path
|
|
220
|
+
idx = path.index { |s| s.key.to_s == step.key.to_s }
|
|
221
|
+
next_step = path[idx + 1] if idx
|
|
222
|
+
next_step && !next_step.review?
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def show_back?
|
|
226
|
+
path = @runner.visible_path
|
|
227
|
+
current = @runner.current_step
|
|
228
|
+
idx = path.index { |s| s.key.to_s == current&.key.to_s }
|
|
229
|
+
idx && idx > 0
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# A nav button. When `embedded`, it is a plain submit inside the surrounding
|
|
233
|
+
# <form> (review). Otherwise it's its own mini-form posting to the step URL
|
|
234
|
+
# so the step form (Next) and Back/Cancel submit independently.
|
|
235
|
+
def nav_button(label, direction:, style:, embedded:, disabled: false, name: nil, goto: nil)
|
|
236
|
+
data = {wizard_nav: name || direction}
|
|
237
|
+
# Back/Cancel post WITHOUT the step's field values, so any unsaved edits
|
|
238
|
+
# on the current step are discarded. Mark them so the (already-attached)
|
|
239
|
+
# dirty-form-guard warns before that loss. Next/Finish save, so no guard.
|
|
240
|
+
data["dirty-form-guard-leave"] = leave_warning(direction) if %w[back cancel].include?(direction)
|
|
241
|
+
|
|
242
|
+
# Finish (→ the created resource) and Cancel (→ out of the flow) redirect
|
|
243
|
+
# AWAY from the wizard, to a page with a different structure. The layout
|
|
244
|
+
# opts into Turbo morphing (`turbo-refresh-method: morph`), and these post
|
|
245
|
+
# to the current URL, so Turbo treats the result as a page refresh and
|
|
246
|
+
# morphs the destination INTO the wizard DOM (nesting it) instead of
|
|
247
|
+
# replacing the page. Opt these submitters out of Turbo so they do a clean
|
|
248
|
+
# full navigation. In-wizard Next/Back stay on Turbo (same structure →
|
|
249
|
+
# morph is correct and smooth).
|
|
250
|
+
data["turbo"] = "false" if exits_wizard?(name, direction)
|
|
251
|
+
|
|
252
|
+
if embedded
|
|
253
|
+
button(
|
|
254
|
+
type: :submit, name: "_direction", value: direction,
|
|
255
|
+
class: "pu-btn pu-btn-md #{style}", disabled: disabled || nil,
|
|
256
|
+
data:
|
|
257
|
+
) { label }
|
|
258
|
+
elsif direction == "next"
|
|
259
|
+
# Next submits the step form (which holds the inputs). The page's nav
|
|
260
|
+
# Next button is associated with the wizard form via the `form` attr.
|
|
261
|
+
# A `goto` button instead carries `_goto` (and NO `_direction`, since only
|
|
262
|
+
# the clicked button's name/value is submitted) — a blank `_direction`
|
|
263
|
+
# still routes to advance, which honors the `goto` cursor override.
|
|
264
|
+
btn_name, btn_value = goto ? ["_goto", goto] : ["_direction", "next"]
|
|
265
|
+
button(
|
|
266
|
+
type: :submit, form: "wizard-form", name: btn_name, value: btn_value,
|
|
267
|
+
class: "pu-btn pu-btn-md #{style}", disabled: disabled || nil,
|
|
268
|
+
data:
|
|
269
|
+
) { label }
|
|
270
|
+
else
|
|
271
|
+
# Back/Cancel post on their own — no field validation, so an independent
|
|
272
|
+
# mini-form carrying only _direction is correct. Cancel exits the wizard,
|
|
273
|
+
# so the form itself opts out of Turbo (the submit button alone isn't the
|
|
274
|
+
# navigating element here — the form is).
|
|
275
|
+
form_data = exits_wizard?(name, direction) ? {turbo: "false"} : {}
|
|
276
|
+
form(action: @step_url, method: "post", class: "inline", data: form_data) do
|
|
277
|
+
authenticity_field
|
|
278
|
+
input(type: :hidden, name: "_direction", value: direction)
|
|
279
|
+
button(
|
|
280
|
+
type: :submit,
|
|
281
|
+
class: "pu-btn pu-btn-md #{style}",
|
|
282
|
+
data:
|
|
283
|
+
) { label }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Whether a nav control redirects OUT of the wizard (Finish → resource,
|
|
289
|
+
# Cancel → exit), as opposed to in-wizard navigation (Next/Back). Such
|
|
290
|
+
# controls must do a full navigation, not a Turbo morph (see {#nav_button}).
|
|
291
|
+
def exits_wizard?(name, direction)
|
|
292
|
+
name.to_s == "finish" || direction == "cancel"
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Confirmation copy shown by dirty-form-guard when the current step has
|
|
296
|
+
# unsaved edits and the user clicks a control that abandons them.
|
|
297
|
+
def leave_warning(direction)
|
|
298
|
+
case direction
|
|
299
|
+
when "back" then "You have unsaved changes on this step. Go back and lose them?"
|
|
300
|
+
when "cancel" then "You have unsaved changes. Cancel the wizard and lose them?"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# --- errors -----------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
# Push runner errors onto the CURRENT step's typed sub-object (the form
|
|
307
|
+
# object), so the form/field error chrome renders them. A review step has no
|
|
308
|
+
# sub-object (base errors render separately) — no-op there.
|
|
309
|
+
def seed_errors!(step)
|
|
310
|
+
return if @errors.blank?
|
|
311
|
+
|
|
312
|
+
obj = @runner.wizard.data[step.key]
|
|
313
|
+
return unless obj
|
|
314
|
+
|
|
315
|
+
obj.errors.clear
|
|
316
|
+
@errors.each do |attr, messages|
|
|
317
|
+
Array(messages).each { |m| obj.errors.add(attr.to_sym, m) }
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def form_error_messages(step)
|
|
322
|
+
return nil if @errors.blank?
|
|
323
|
+
|
|
324
|
+
obj = @runner.wizard.data[step.key]
|
|
325
|
+
obj&.errors&.full_messages
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Errors from a failed finalize (`execute`), surfaced on the review step —
|
|
329
|
+
# which has no field form to attach them to. Shows ALL messages, not just
|
|
330
|
+
# `:base`: a field-level error from `execute` (e.g. {name: ["has already
|
|
331
|
+
# been taken"]} when a uniqueness check fails) would otherwise be dropped,
|
|
332
|
+
# leaving Finish to silently re-render unchanged ("nothing happens").
|
|
333
|
+
def render_review_errors
|
|
334
|
+
messages = review_error_messages
|
|
335
|
+
return if messages.empty?
|
|
336
|
+
|
|
337
|
+
div(class: "rounded-lg border border-danger-200 bg-danger-50 dark:border-danger-800 dark:bg-danger-950/30 p-4 mb-4", role: "alert") do
|
|
338
|
+
ul(class: "space-y-1") do
|
|
339
|
+
messages.each { |m| li(class: "text-sm text-danger-700 dark:text-danger-400") { m } }
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Every finalize error as a full sentence: a `:base` error renders verbatim;
|
|
345
|
+
# a field error is prefixed with its humanized attribute, mirroring Rails'
|
|
346
|
+
# `full_messages` — so {name: ["has already been taken"]} → "Name has
|
|
347
|
+
# already been taken".
|
|
348
|
+
def review_error_messages
|
|
349
|
+
@errors.flat_map do |attr, msgs|
|
|
350
|
+
base = attr.to_s == "base"
|
|
351
|
+
Array(msgs).map { |m| base ? m : "#{attr.to_s.humanize} #{m}" }
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def authenticity_field
|
|
356
|
+
token = helpers.form_authenticity_token
|
|
357
|
+
input(type: :hidden, name: "authenticity_token", value: token)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Build the GET URL for another step by swapping the trailing :step segment
|
|
361
|
+
# of the current step URL.
|
|
362
|
+
def url_for_step(step_key)
|
|
363
|
+
base = @step_url.delete_suffix("/#{@runner.current_step&.key}")
|
|
364
|
+
"#{base}/#{step_key}"
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def page_type = :wizard_page
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Page
|
|
6
|
+
# The "resume or start new" chooser (§4.5), shown at the bare launch URL when
|
|
7
|
+
# a tokened wizard opts in with `on_relaunch :prompt` and the user already has
|
|
8
|
+
# pending (in-progress) runs. Lists each pending run with a Resume link and
|
|
9
|
+
# offers a Start-new button. Keyed/one-time/anchored wizards never reach here
|
|
10
|
+
# (they auto-resume their single keyed run); only tokened wizards can have
|
|
11
|
+
# several concurrent runs to choose between.
|
|
12
|
+
class WizardChooser < Plutonium::UI::Page::Base
|
|
13
|
+
# @param wizard_class [Class] the wizard being launched.
|
|
14
|
+
# @param entries [Array<Plutonium::Wizard::Resume::Entry>] pending runs.
|
|
15
|
+
# @param start_new_url [String] bare launch URL that forces a fresh run.
|
|
16
|
+
def initialize(wizard_class:, entries:, start_new_url:)
|
|
17
|
+
@wizard_class = wizard_class
|
|
18
|
+
@entries = entries
|
|
19
|
+
@start_new_url = start_new_url
|
|
20
|
+
super(page_title: wizard_class.label, page_description: nil)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def view_template
|
|
24
|
+
DynaFrameContent() do
|
|
25
|
+
article(class: "pu-wizard pu-wizard-chooser mx-auto max-w-2xl", data: {wizard_chooser: true}) do
|
|
26
|
+
render_header
|
|
27
|
+
div(class: card_classes) do
|
|
28
|
+
render_pending_list
|
|
29
|
+
render_start_new
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def render_header
|
|
38
|
+
div(class: "pu-wizard-header mb-7 text-center") do
|
|
39
|
+
h1(class: "text-2xl font-bold tracking-tight text-[var(--pu-text)]") { @wizard_class.label }
|
|
40
|
+
p(class: "mx-auto mt-1.5 max-w-prose text-[var(--pu-text-muted)]") do
|
|
41
|
+
"Pick up where you left off, or start a new one."
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def card_classes
|
|
47
|
+
"pu-wizard-card overflow-hidden rounded-[var(--pu-radius-xl)] " \
|
|
48
|
+
"border border-[var(--pu-border)] bg-[var(--pu-surface)] shadow-[var(--pu-shadow-lg)]"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The pending runs, capped in height and scrollable so a user with many
|
|
52
|
+
# drafts doesn't get an unbounded card (the Start-new footer below it stays
|
|
53
|
+
# in view).
|
|
54
|
+
def render_pending_list
|
|
55
|
+
ul(class: "max-h-96 overflow-y-auto divide-y divide-[var(--pu-border)]") do
|
|
56
|
+
@entries.each { |entry| render_entry(entry) }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# One pending run: its current step + when it was last touched, and a Resume
|
|
61
|
+
# link. A run whose mount can't be resolved (no `resume_url`) is shown as a
|
|
62
|
+
# disabled row rather than a dead link.
|
|
63
|
+
def render_entry(entry)
|
|
64
|
+
li(class: "flex items-center justify-between gap-4 px-5 py-4", data: {wizard_chooser_entry: entry.current_step}) do
|
|
65
|
+
div(class: "min-w-0") do
|
|
66
|
+
p(class: "truncate text-sm font-semibold text-[var(--pu-text)]") do
|
|
67
|
+
entry.current_step_label.presence || "In progress"
|
|
68
|
+
end
|
|
69
|
+
p(class: "mt-0.5 text-xs text-[var(--pu-text-muted)]") { "Updated #{updated_ago(entry)} ago" }
|
|
70
|
+
end
|
|
71
|
+
if entry.resume_url.present?
|
|
72
|
+
a(href: entry.resume_url, class: "pu-btn pu-btn-sm pu-btn-outline shrink-0", data: {wizard_chooser_resume: true}) { "Resume" }
|
|
73
|
+
else
|
|
74
|
+
span(class: "pu-btn pu-btn-sm pu-btn-outline shrink-0 opacity-50 cursor-not-allowed") { "Resume" }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_start_new
|
|
80
|
+
div(class: "border-t border-[var(--pu-border)] bg-[var(--pu-surface-alt)] px-5 py-4") do
|
|
81
|
+
a(href: @start_new_url, class: "pu-btn pu-btn-md pu-btn-primary w-full justify-center", data: {wizard_chooser_start_new: true}) do
|
|
82
|
+
"Start new"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def updated_ago(entry)
|
|
88
|
+
helpers.time_ago_in_words(entry.updated_at)
|
|
89
|
+
rescue
|
|
90
|
+
"a while"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def page_type = :wizard_chooser_page
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Page
|
|
6
|
+
# The "already completed" page for a one-time wizard (§9). Shown when a user
|
|
7
|
+
# re-opens a one-time wizard they've already finished: the completion marker
|
|
8
|
+
# is retained but its `data` is cleared, so there is nothing to review — just
|
|
9
|
+
# a confirmation that the flow is done.
|
|
10
|
+
#
|
|
11
|
+
# Authors can override the body entirely with a `completed do |wizard| … end`
|
|
12
|
+
# block on the wizard class (rendered in this component's Phlex context, with
|
|
13
|
+
# the wizard yielded); otherwise the built-in confirmation renders.
|
|
14
|
+
class WizardCompleted < Plutonium::UI::Page::Base
|
|
15
|
+
# @param runner [Plutonium::Wizard::Runner]
|
|
16
|
+
# @param exit_url [String] where the default "Continue" button points.
|
|
17
|
+
def initialize(runner:, exit_url:)
|
|
18
|
+
@runner = runner
|
|
19
|
+
@wizard = runner.wizard
|
|
20
|
+
@exit_url = exit_url
|
|
21
|
+
super(page_title: @wizard.class.label, page_description: nil)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def view_template
|
|
25
|
+
DynaFrameContent() do
|
|
26
|
+
article(class: "pu-wizard pu-wizard-completed mx-auto max-w-2xl") do
|
|
27
|
+
div(class: card_classes) do
|
|
28
|
+
div(class: "px-6 py-10 text-center sm:px-10") do
|
|
29
|
+
block = @wizard.class.completed_block
|
|
30
|
+
if block
|
|
31
|
+
instance_exec(@wizard, &block)
|
|
32
|
+
else
|
|
33
|
+
render_default
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def card_classes
|
|
44
|
+
"pu-wizard-card overflow-hidden rounded-[var(--pu-radius-xl)] " \
|
|
45
|
+
"border border-[var(--pu-border)] bg-[var(--pu-surface)] shadow-[var(--pu-shadow-lg)]"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The built-in confirmation body: a success badge, the wizard title, a short
|
|
49
|
+
# message, and a Continue button out.
|
|
50
|
+
def render_default
|
|
51
|
+
render_check_badge
|
|
52
|
+
h1(class: "mt-5 text-2xl font-bold tracking-tight text-[var(--pu-text)]") do
|
|
53
|
+
@wizard.class.label
|
|
54
|
+
end
|
|
55
|
+
p(class: "mx-auto mt-2 max-w-prose text-[var(--pu-text-muted)]") do
|
|
56
|
+
"You've already completed this — there's nothing more to do here."
|
|
57
|
+
end
|
|
58
|
+
div(class: "mt-7") do
|
|
59
|
+
# Exits the wizard to a different page; opt out of Turbo morph (which
|
|
60
|
+
# would otherwise nest the destination into this page — see Page::Wizard).
|
|
61
|
+
a(href: @exit_url, class: "pu-btn pu-btn-md pu-btn-primary", data: {wizard_completed: "exit", turbo: "false"}) { "Continue" }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A success-tinted circular checkmark badge.
|
|
66
|
+
def render_check_badge
|
|
67
|
+
div(class: "mx-auto grid h-16 w-16 place-items-center rounded-full bg-success-100 text-success-600 dark:bg-success-900/30 dark:text-success-400") do
|
|
68
|
+
svg(
|
|
69
|
+
class: "h-8 w-8",
|
|
70
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
71
|
+
fill: "none",
|
|
72
|
+
viewbox: "0 0 24 24",
|
|
73
|
+
stroke: "currentColor",
|
|
74
|
+
stroke_width: "2.5",
|
|
75
|
+
aria_hidden: "true"
|
|
76
|
+
) do |s|
|
|
77
|
+
s.path(stroke_linecap: "round", stroke_linejoin: "round", d: "M4.5 12.75l6 6 9-13.5")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def page_type = :wizard_completed_page
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -12,7 +12,7 @@ module Plutonium
|
|
|
12
12
|
# button). Rows without such a target become a no-op — no
|
|
13
13
|
# special-casing needed in this layer.
|
|
14
14
|
def table_body_row_attributes(wrapped_object)
|
|
15
|
-
super.merge(data: {controller: "row-click", action: "click->row-click#click"})
|
|
15
|
+
super.merge(data: {controller: "row-click", action: "click->row-click#click auxclick->row-click#click"})
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
# Use custom SelectionColumn with Stimulus data attributes
|
|
@@ -14,7 +14,8 @@ module Plutonium
|
|
|
14
14
|
class ViewSwitcher < Plutonium::UI::Component::Base
|
|
15
15
|
SEGMENT_LABELS = {
|
|
16
16
|
table: {label: "Table", icon: Phlex::TablerIcons::Table},
|
|
17
|
-
grid: {label: "Grid", icon: Phlex::TablerIcons::LayoutGrid}
|
|
17
|
+
grid: {label: "Grid", icon: Phlex::TablerIcons::LayoutGrid},
|
|
18
|
+
kanban: {label: "Board", icon: Phlex::TablerIcons::LayoutKanban}
|
|
18
19
|
}.freeze
|
|
19
20
|
|
|
20
21
|
def initialize(views:, current:, cookie_name:, cookie_path: "/")
|