plutonium 0.60.5 → 0.62.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 +378 -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 +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- 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/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -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 +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -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/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -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/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -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/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -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 +70 -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 +122 -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/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- 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 +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- 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/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- 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/table/resource.rb +1 -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 +92 -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 +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -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 +945 -286
- metadata +113 -2
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Wizard
|
|
6
|
+
# The terminal review step's body (§2.5), a small state machine (see
|
|
7
|
+
# {#render_mode}):
|
|
8
|
+
#
|
|
9
|
+
# - INCOMPLETE (a visible step is unvisited/invalid) → a "fix this" jump
|
|
10
|
+
# link per outstanding step + an auto-summary of what's entered so far.
|
|
11
|
+
# - COMPLETE + custom block → the author's block, rendered bare.
|
|
12
|
+
# - COMPLETE + no block, `summary: true` (default) → the auto-summary of
|
|
13
|
+
# every visible step's collected `data` (via SummaryDisplay).
|
|
14
|
+
# - COMPLETE + no block, `summary: false` → the built-in "ready to
|
|
15
|
+
# complete" panel (for a fully author-owned / chromeless review).
|
|
16
|
+
#
|
|
17
|
+
# The Finish button is rendered by the page (gated: disabled while any
|
|
18
|
+
# visible step is incomplete); this component only renders the body.
|
|
19
|
+
class Review < Plutonium::UI::Component::Base
|
|
20
|
+
# @param runner [Plutonium::Wizard::Runner]
|
|
21
|
+
# @param step_url [Proc] step_key → GET url, for the fix-this links.
|
|
22
|
+
def initialize(runner:, step_url:)
|
|
23
|
+
@runner = runner
|
|
24
|
+
@step_url = step_url
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def view_template
|
|
28
|
+
render_outstanding if show_outstanding?
|
|
29
|
+
render_summary if show_summary?
|
|
30
|
+
render_custom_block if show_custom?
|
|
31
|
+
render_ready if show_ready?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# The review body composes from these pieces (see the `review summary:`
|
|
37
|
+
# macro). Order of render: outstanding → summary → custom → ready.
|
|
38
|
+
#
|
|
39
|
+
# - outstanding banner — while any visible step is incomplete.
|
|
40
|
+
# - summary cards — whenever the auto-summary is on: the
|
|
41
|
+
# review-and-fix view when incomplete, the check-before-finish view
|
|
42
|
+
# when complete.
|
|
43
|
+
# - custom block — author content, ONLY once complete; sits BELOW
|
|
44
|
+
# the summary when summary is on, and REPLACES it when summary is off.
|
|
45
|
+
# - ready panel — complete + summary off + no custom block: the
|
|
46
|
+
# built-in "ready to complete" confirmation (chromeless finish).
|
|
47
|
+
def show_outstanding? = !complete?
|
|
48
|
+
|
|
49
|
+
def show_summary? = summary?
|
|
50
|
+
|
|
51
|
+
def show_custom? = complete? && !custom_block.nil?
|
|
52
|
+
|
|
53
|
+
def show_ready? = complete? && !summary? && custom_block.nil?
|
|
54
|
+
|
|
55
|
+
def complete? = @runner.incomplete_visible_steps.empty?
|
|
56
|
+
|
|
57
|
+
def summary? = @runner.current_step.summary?
|
|
58
|
+
|
|
59
|
+
def custom_block = @runner.current_step.block
|
|
60
|
+
|
|
61
|
+
def steps = @runner.visible_path.reject(&:review?)
|
|
62
|
+
|
|
63
|
+
# The step's typed `data` sub-object (`data.<step>`), the summary's source.
|
|
64
|
+
def step_data(step) = @runner.wizard.data[step.key]
|
|
65
|
+
|
|
66
|
+
def render_outstanding
|
|
67
|
+
incomplete = @runner.incomplete_visible_steps
|
|
68
|
+
return if incomplete.empty?
|
|
69
|
+
|
|
70
|
+
div(class: "pu-wizard-review-outstanding rounded-lg border border-warning-300 bg-warning-50 dark:border-warning-800 dark:bg-warning-950/30 p-4 mb-6", role: "alert") do
|
|
71
|
+
p(class: "text-sm font-medium text-[var(--pu-text)] mb-2") do
|
|
72
|
+
"Some steps still need attention before you can finish:"
|
|
73
|
+
end
|
|
74
|
+
ul(class: "space-y-1") do
|
|
75
|
+
incomplete.each do |step|
|
|
76
|
+
li do
|
|
77
|
+
a(
|
|
78
|
+
href: @step_url.call(step.key),
|
|
79
|
+
class: "text-sm font-medium text-primary-600 dark:text-primary-400 hover:underline",
|
|
80
|
+
data: {wizard_review_fix: step.key}
|
|
81
|
+
) { "Fix #{step.label}" }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def render_summary
|
|
89
|
+
div(class: "space-y-4") do
|
|
90
|
+
steps.each do |step|
|
|
91
|
+
fields = summary_fields(step)
|
|
92
|
+
structured = step.structured_inputs
|
|
93
|
+
next if fields.empty? && structured.empty?
|
|
94
|
+
|
|
95
|
+
render_step_card(step, fields, structured)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# One step rendered as a card: a titled header strip (label + Edit) over a
|
|
101
|
+
# body that holds the scalar summary and any structured collections.
|
|
102
|
+
def render_step_card(step, fields, structured)
|
|
103
|
+
section(
|
|
104
|
+
class: "pu-wizard-review-step overflow-hidden rounded-[var(--pu-radius-lg)] border border-[var(--pu-border)] bg-[var(--pu-surface)] shadow-[var(--pu-shadow-sm)]",
|
|
105
|
+
data: {wizard_review_step: step.key}
|
|
106
|
+
) do
|
|
107
|
+
div(class: "flex items-center justify-between gap-3 border-b border-[var(--pu-border)] bg-[var(--pu-surface-alt)] px-5 py-3") do
|
|
108
|
+
h3(class: "text-sm font-semibold text-[var(--pu-text)]") { step.label.to_s }
|
|
109
|
+
a(
|
|
110
|
+
href: @step_url.call(step.key),
|
|
111
|
+
class: "shrink-0 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
|
112
|
+
) { "Edit" }
|
|
113
|
+
end
|
|
114
|
+
div(class: "px-5 py-4") do
|
|
115
|
+
# Decorate so attachment fields resolve to displayable attachments —
|
|
116
|
+
# the SummaryDisplay then renders them through the normal attachment
|
|
117
|
+
# display component, not the raw token string.
|
|
118
|
+
if fields.any?
|
|
119
|
+
render SummaryDisplay.new(
|
|
120
|
+
Plutonium::Wizard::AttachmentData.wrap(step_data(step), step),
|
|
121
|
+
fields:, inputs: step.inputs
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
render_structured(step) if structured.any?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def summary_fields(step)
|
|
130
|
+
step.attribute_schema.keys.map(&:to_sym)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Repeatable `structured_input` collections aren't scalar `data` attributes,
|
|
134
|
+
# so the SummaryDisplay can't render them — summarise each collection as a
|
|
135
|
+
# labelled list of its non-empty rows (blank trailing rows are dropped).
|
|
136
|
+
def render_structured(step)
|
|
137
|
+
data = step_data(step)
|
|
138
|
+
step.structured_inputs.each_key do |name|
|
|
139
|
+
rows = Array(data.public_send(name))
|
|
140
|
+
.map(&:to_h)
|
|
141
|
+
.reject { |row| row.values.all?(&:blank?) }
|
|
142
|
+
|
|
143
|
+
div(class: "pu-wizard-review-collection mt-4 first:mt-0", data: {wizard_review_collection: name}) do
|
|
144
|
+
h4(class: "mb-2 text-xs font-semibold uppercase tracking-wide text-[var(--pu-text-muted)]") do
|
|
145
|
+
name.to_s.humanize
|
|
146
|
+
end
|
|
147
|
+
if rows.empty?
|
|
148
|
+
p(class: "text-sm text-[var(--pu-text-subtle)]") { "None" }
|
|
149
|
+
else
|
|
150
|
+
ul(class: "space-y-1.5") do
|
|
151
|
+
rows.each do |row|
|
|
152
|
+
li(class: "flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-[var(--pu-radius-md)] bg-[var(--pu-surface-alt)] px-3 py-2 text-sm text-[var(--pu-text)]") do
|
|
153
|
+
row.each do |key, value|
|
|
154
|
+
span do
|
|
155
|
+
span(class: "text-[var(--pu-text-muted)]") { "#{key.to_s.humanize}: " }
|
|
156
|
+
plain value.to_s
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# The review step's custom block, rendered BARE — just a spacing + targeting
|
|
168
|
+
# wrapper, no surface/border/typography of its own — so the author has full
|
|
169
|
+
# control over the body's look.
|
|
170
|
+
def render_custom_block
|
|
171
|
+
div(class: "pu-wizard-review-custom", data: {wizard_review_custom: true}) do
|
|
172
|
+
instance_exec(@runner.wizard, &custom_block)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The built-in "ready to complete" panel: shown when everything is valid,
|
|
177
|
+
# there's no custom block, and the auto-summary is turned off (`summary:
|
|
178
|
+
# false`). A clean confirmation so a chromeless review still reads as done.
|
|
179
|
+
def render_ready
|
|
180
|
+
div(
|
|
181
|
+
class: "pu-wizard-review-ready flex flex-col items-center text-center py-6",
|
|
182
|
+
data: {wizard_review_ready: true}
|
|
183
|
+
) do
|
|
184
|
+
div(class: "mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-success-100 text-success-600 dark:bg-success-900/30 dark:text-success-400") do
|
|
185
|
+
render Phlex::TablerIcons::Check.new(class: "h-7 w-7")
|
|
186
|
+
end
|
|
187
|
+
h3(class: "text-lg font-semibold tracking-tight text-[var(--pu-text)]") { "You're all set" }
|
|
188
|
+
p(class: "mt-1.5 max-w-prose text-sm text-[var(--pu-text-muted)]") do
|
|
189
|
+
"Everything looks good. Click Finish to complete."
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Wizard
|
|
6
|
+
# The horizontal step indicator (§7). Renders the visible step path with a
|
|
7
|
+
# completed / current / upcoming state per step. Navigation mode gates which
|
|
8
|
+
# steps are clickable:
|
|
9
|
+
#
|
|
10
|
+
# - :linear → only already-visited steps link back (no forward jumps).
|
|
11
|
+
# - :free → any visited step links (still no forward jumps to unvisited).
|
|
12
|
+
#
|
|
13
|
+
# Branch-hidden steps are simply absent from `visible_path`, so they never
|
|
14
|
+
# appear here.
|
|
15
|
+
class Stepper < Plutonium::UI::Component::Base
|
|
16
|
+
# @param steps [Array<Step>] the visible path.
|
|
17
|
+
# @param current [Step] the current step.
|
|
18
|
+
# @param visited [Array<String>] visited (reached) step keys.
|
|
19
|
+
# @param complete [Array<String>] keys of steps that are actually complete
|
|
20
|
+
# (submitted AND valid) — distinct from `visited`. A step can be reached
|
|
21
|
+
# (e.g. the cursor landed on a branch step) without being completed.
|
|
22
|
+
# @param navigation [Symbol] :linear or :free.
|
|
23
|
+
# @param step_url [Proc] step_key → GET url.
|
|
24
|
+
def initialize(steps:, current:, visited:, navigation:, step_url:, complete: [])
|
|
25
|
+
@steps = steps
|
|
26
|
+
@current = current
|
|
27
|
+
@visited = visited.map(&:to_s)
|
|
28
|
+
@complete = complete.map(&:to_s).to_set
|
|
29
|
+
@navigation = navigation
|
|
30
|
+
@step_url = step_url
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def view_template
|
|
34
|
+
nav(class: "pu-wizard-stepper mb-7", aria_label: "Progress") do
|
|
35
|
+
ol(class: "pu-wizard-steps") do
|
|
36
|
+
@steps.each_with_index do |step, index|
|
|
37
|
+
render_step(step, index)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# One step: a numbered node on the connector track + a label beneath. Both
|
|
46
|
+
# are wrapped in a link when the step is reachable (CSS draws the track,
|
|
47
|
+
# node states, and the done check via [data-state]).
|
|
48
|
+
def render_step(step, index)
|
|
49
|
+
state = step_state(step)
|
|
50
|
+
# `data-terminal` marks the review node so the CSS suppresses the
|
|
51
|
+
# "completed" checkmark for it — it's the finish flag, never a done-check.
|
|
52
|
+
li(data: {state:, wizard_stepper_state: state, terminal: step.review? ? "true" : nil}) do
|
|
53
|
+
if clickable?(step, state)
|
|
54
|
+
a(href: @step_url.call(step.key), class: "pu-step-link",
|
|
55
|
+
aria_current: (state == :current) ? "step" : nil) do
|
|
56
|
+
render_node(step, index)
|
|
57
|
+
span(class: "pu-step-label") { step.label.to_s }
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
span(class: "pu-step-link") do
|
|
61
|
+
render_node(step, index)
|
|
62
|
+
span(class: "pu-step-label",
|
|
63
|
+
aria_current: (state == :current) ? "step" : nil) { step.label.to_s }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The node: a numbered badge for a real step. The terminal review step isn't
|
|
70
|
+
# a numbered step (it's the "finish line"), so it shows a flag icon instead —
|
|
71
|
+
# no step number, here or in the card header (see Page::Wizard).
|
|
72
|
+
def render_node(step, index)
|
|
73
|
+
span(class: "pu-step-node") do
|
|
74
|
+
if step.review?
|
|
75
|
+
render_review_icon
|
|
76
|
+
else
|
|
77
|
+
# Review is always last, so a real step's index in the full path is
|
|
78
|
+
# also its position among real steps → a clean 1-based badge.
|
|
79
|
+
span(class: "pu-step-number") { (index + 1).to_s }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def render_review_icon
|
|
85
|
+
render Phlex::TablerIcons::Flag.new(class: "pu-step-flag w-4 h-4")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A step's visual state. `:completed` (the done-check) means actually
|
|
89
|
+
# complete — submitted AND valid — NOT merely reached: a branch step the
|
|
90
|
+
# cursor landed on but that was never submitted (or is now invalid) is
|
|
91
|
+
# `:incomplete`, so the rail never claims a step is done when the review
|
|
92
|
+
# still lists it under "needs attention". The terminal review node is the
|
|
93
|
+
# finish flag, so it reads `:completed` once reached (its check is suppressed
|
|
94
|
+
# by `data-terminal`).
|
|
95
|
+
def step_state(step)
|
|
96
|
+
return :current if step.key.to_s == @current&.key.to_s
|
|
97
|
+
if @visited.include?(step.key.to_s)
|
|
98
|
+
return :completed if step.review? || @complete.include?(step.key.to_s)
|
|
99
|
+
return :incomplete
|
|
100
|
+
end
|
|
101
|
+
:upcoming
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Which step headers link. Mirrors the engine's `Runner#go_to` reachability
|
|
105
|
+
# so the stepper never offers (or withholds) a jump the engine would reject:
|
|
106
|
+
#
|
|
107
|
+
# - the current step never links to itself;
|
|
108
|
+
# - the terminal review step links once the flow has started (any step
|
|
109
|
+
# visited) — it's never itself "visited" (you Finish from it, you don't
|
|
110
|
+
# advance past it), so without this it would be permanently unclickable,
|
|
111
|
+
# stranding a user who navigated back from it;
|
|
112
|
+
# - every other step links once visited. Forward jumps to unvisited steps
|
|
113
|
+
# stay disallowed.
|
|
114
|
+
def clickable?(step, state)
|
|
115
|
+
return false if state == :current
|
|
116
|
+
return @visited.any? if step.review?
|
|
117
|
+
@visited.include?(step.key.to_s)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module UI
|
|
5
|
+
module Wizard
|
|
6
|
+
# A tiny read-only display over the wizard's typed `data` snapshot, used by
|
|
7
|
+
# the review step's auto-summary (§2.5). Reuses the Plutonium display
|
|
8
|
+
# pipeline (`Display::Base` + its inferred-type builder + `field(...).wrapped`)
|
|
9
|
+
# so each field's label and value formatting match the rest of the app,
|
|
10
|
+
# rather than re-implementing value rendering.
|
|
11
|
+
class SummaryDisplay < Plutonium::UI::Display::Base
|
|
12
|
+
# @param object [Object] the wizard `data` snapshot.
|
|
13
|
+
# @param fields [Array<Symbol>] scalar field names to summarize.
|
|
14
|
+
# @param inputs [Hash] the step's input config ({name => {options:}}), so a
|
|
15
|
+
# field's declared `as:` informs the display component (e.g. a `:text`
|
|
16
|
+
# input renders via the markdown/text display tag).
|
|
17
|
+
def initialize(object, fields:, inputs: {}, **options)
|
|
18
|
+
options[:key] = :wizard
|
|
19
|
+
@summary_fields = fields
|
|
20
|
+
@summary_inputs = inputs
|
|
21
|
+
super(object, **options)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def display_template
|
|
25
|
+
dl(class: "grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3") do
|
|
26
|
+
@summary_fields.each { |name| render_summary_field(name) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def render_summary_field(name)
|
|
33
|
+
input_options = @summary_inputs[name]&.dig(:options) || {}
|
|
34
|
+
field_options = input_options[:label] ? {label: input_options[:label]} : {}
|
|
35
|
+
|
|
36
|
+
# A currency input stages a plain decimal; the data snapshot carries no
|
|
37
|
+
# has_cents reflection, so inference would render it as a bare number.
|
|
38
|
+
# Force the currency display and thread the declared `unit:` so the
|
|
39
|
+
# recap reads "$1,234.56", matching the input's prefix.
|
|
40
|
+
if input_options[:as]&.to_sym == :currency
|
|
41
|
+
return render field(name, **field_options).wrapped { |f|
|
|
42
|
+
render f.send(:create_component, Plutonium::UI::Display::Components::Currency, :currency, unit: input_options[:unit])
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A choice input (select/radio_buttons) stages the raw value; its
|
|
47
|
+
# value -> label map lives only in `choices:`. Resolve it and render the
|
|
48
|
+
# label as a string, rather than inferring a plain-string tag off the
|
|
49
|
+
# raw value (which would show "female" instead of "Female").
|
|
50
|
+
if input_options[:choices]
|
|
51
|
+
label = resolve_choice_label(name, input_options)
|
|
52
|
+
return render field(name, value: label, **field_options).wrapped { |f| render f.string_tag }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
render field(name, **field_options).wrapped do |f|
|
|
56
|
+
render instance_exec(f, &summary_tag_block(name))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Map the raw value(s) to their choice label(s) using the SAME mapper the
|
|
61
|
+
# form's select uses, so summary labels always match the form. Falls back
|
|
62
|
+
# to the raw value for anything not in `choices:`; nil for an empty field
|
|
63
|
+
# (so the display renders its placeholder).
|
|
64
|
+
def resolve_choice_label(name, input_options)
|
|
65
|
+
mapper = Phlexi::Form::SimpleChoicesMapper.new(
|
|
66
|
+
input_options[:choices],
|
|
67
|
+
label_method: input_options[:label_method],
|
|
68
|
+
value_method: input_options[:value_method]
|
|
69
|
+
)
|
|
70
|
+
raw = Phlexi::Field::Support::Value.from(object, name)
|
|
71
|
+
Array(raw).map { |value| mapper[value] || value }.join(", ").presence
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Pick the display component the same way a resource display does — infer it
|
|
75
|
+
# from the value's TYPE (date, boolean, number, currency, …) instead of
|
|
76
|
+
# stringifying everything. The one override: an attachment field stages a
|
|
77
|
+
# string TOKEN, so inference can't tell it's an attachment — force it (the
|
|
78
|
+
# data object is decorated upstream to resolve the token to an attachment).
|
|
79
|
+
def summary_tag_block(name)
|
|
80
|
+
->(f) {
|
|
81
|
+
tag = Plutonium::Wizard::Attachments.field?(@summary_inputs[name]) ? :attachment : f.inferred_field_component
|
|
82
|
+
if tag.is_a?(Class)
|
|
83
|
+
f.send(:create_component, tag, tag.name.demodulize.underscore.sub(/component$/, "").to_sym)
|
|
84
|
+
else
|
|
85
|
+
f.send(:"#{tag}_tag")
|
|
86
|
+
end
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/plutonium/version.rb
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Plutonium
|
|
4
|
+
module Wizard
|
|
5
|
+
# Decorates a step's typed `data` so ATTACHMENT fields READ as resolved,
|
|
6
|
+
# displayable attachments ({Attachments::Resolved}) rather than the raw staged
|
|
7
|
+
# token string — so the step form's `Uppy` preview rehydrates on Back/resume
|
|
8
|
+
# (it calls `.url`/`.signed_id`/`.filename`, which a bare token string can't
|
|
9
|
+
# answer). Every other field delegates unchanged.
|
|
10
|
+
#
|
|
11
|
+
# Read-only: the SUBMITTED value is still the token the hidden preview field
|
|
12
|
+
# re-posts (`Resolved#signed_id` == the original token), so staging is
|
|
13
|
+
# unaffected.
|
|
14
|
+
class AttachmentData < SimpleDelegator
|
|
15
|
+
# Wrap only when the step actually has an attachment field — otherwise return
|
|
16
|
+
# the data untouched, so the overwhelming majority of steps are unaffected.
|
|
17
|
+
def self.wrap(data, step)
|
|
18
|
+
attachment_fields = step.inputs.select { |_name, config| Attachments.field?(config) }
|
|
19
|
+
attachment_fields.any? ? new(data, attachment_fields) : data
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(data, attachment_fields)
|
|
23
|
+
super(data)
|
|
24
|
+
attachment_fields.each do |name, config|
|
|
25
|
+
multiple = config.dig(:options, :multiple)
|
|
26
|
+
define_singleton_method(name) do
|
|
27
|
+
resolved = Attachments.resolve(__getobj__.public_send(name))
|
|
28
|
+
multiple ? resolved : resolved.first
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Masquerade as the wrapped object's class so phlexi still infers field
|
|
34
|
+
# affordances (required marker, maxlength, …) from its validators — it reads
|
|
35
|
+
# `object.class.validators_on(...)`, and a `SimpleDelegator` would otherwise
|
|
36
|
+
# report its own class and drop every marker on the step.
|
|
37
|
+
def class
|
|
38
|
+
__getobj__.class
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|