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.
Files changed (199) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +19 -1
  3. data/.claude/skills/plutonium-app/SKILL.md +41 -0
  4. data/.claude/skills/plutonium-auth/SKILL.md +40 -0
  5. data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
  6. data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
  7. data/.claude/skills/plutonium-resource/SKILL.md +40 -0
  8. data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
  9. data/.claude/skills/plutonium-testing/SKILL.md +38 -0
  10. data/.claude/skills/plutonium-ui/SKILL.md +51 -0
  11. data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
  12. data/.cliff.toml +6 -0
  13. data/Appraisals +3 -0
  14. data/CHANGELOG.md +576 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +1173 -194
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/layouts/basic.html.erb +7 -0
  22. data/app/views/plutonium/_flash_toasts.html.erb +2 -46
  23. data/app/views/plutonium/_toast.html.erb +52 -0
  24. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  25. data/app/views/resource/_resource_kanban.html.erb +1 -0
  26. data/app/views/resource/kanban_move_form.html.erb +1 -0
  27. data/config/brakeman.ignore +2 -2
  28. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  29. data/docs/.vitepress/config.ts +45 -1
  30. data/docs/.vitepress/sync-skills.mjs +45 -0
  31. data/docs/ai.md +99 -0
  32. data/docs/guides/index.md +2 -0
  33. data/docs/guides/kanban.md +557 -0
  34. data/docs/guides/wizards.md +447 -0
  35. data/docs/public/images/guides/kanban-after-move.png +0 -0
  36. data/docs/public/images/guides/kanban-board-light.png +0 -0
  37. data/docs/public/images/guides/kanban-board.png +0 -0
  38. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  39. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  40. data/docs/public/images/guides/wizards-chooser.png +0 -0
  41. data/docs/public/images/guides/wizards-completed.png +0 -0
  42. data/docs/public/images/guides/wizards-index-action.png +0 -0
  43. data/docs/public/images/guides/wizards-repeater.png +0 -0
  44. data/docs/public/images/guides/wizards-review.png +0 -0
  45. data/docs/public/images/guides/wizards-step.png +0 -0
  46. data/docs/reference/behavior/policies.md +1 -1
  47. data/docs/reference/index.md +14 -0
  48. data/docs/reference/kanban/authorization.md +82 -0
  49. data/docs/reference/kanban/dsl.md +334 -0
  50. data/docs/reference/kanban/index.md +40 -0
  51. data/docs/reference/kanban/positioning.md +162 -0
  52. data/docs/reference/resource/definition.md +26 -1
  53. data/docs/reference/resource/model.md +26 -0
  54. data/docs/reference/ui/forms.md +77 -0
  55. data/docs/reference/ui/pages.md +2 -0
  56. data/docs/reference/wizard/anchoring-resume.md +194 -0
  57. data/docs/reference/wizard/dsl.md +337 -0
  58. data/docs/reference/wizard/index.md +33 -0
  59. data/docs/reference/wizard/one-time.md +129 -0
  60. data/docs/reference/wizard/registration-launch.md +177 -0
  61. data/docs/reference/wizard/storage-config.md +151 -0
  62. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  63. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  64. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  65. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  66. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  67. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  68. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  69. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  70. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  71. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  72. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  73. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  74. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  75. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  76. data/gemfiles/postgres.gemfile +8 -0
  77. data/gemfiles/postgres.gemfile.lock +321 -0
  78. data/gemfiles/rails_7.gemfile +1 -0
  79. data/gemfiles/rails_7.gemfile.lock +1 -1
  80. data/gemfiles/rails_8.0.gemfile +1 -0
  81. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  82. data/gemfiles/rails_8.1.gemfile +1 -0
  83. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  84. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  85. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  86. data/lib/plutonium/action/base.rb +17 -0
  87. data/lib/plutonium/auth/rodauth.rb +1 -2
  88. data/lib/plutonium/configuration.rb +16 -0
  89. data/lib/plutonium/core/controller.rb +20 -1
  90. data/lib/plutonium/definition/base.rb +25 -0
  91. data/lib/plutonium/definition/form_layout.rb +54 -35
  92. data/lib/plutonium/definition/index_views.rb +70 -1
  93. data/lib/plutonium/definition/wizards.rb +209 -0
  94. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  95. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  96. data/lib/plutonium/invites/controller.rb +4 -1
  97. data/lib/plutonium/kanban/action.rb +7 -0
  98. data/lib/plutonium/kanban/board.rb +40 -0
  99. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  100. data/lib/plutonium/kanban/column.rb +122 -0
  101. data/lib/plutonium/kanban/context.rb +15 -0
  102. data/lib/plutonium/kanban/dsl.rb +71 -0
  103. data/lib/plutonium/kanban/grouping.rb +51 -0
  104. data/lib/plutonium/kanban/positioning.rb +75 -0
  105. data/lib/plutonium/kanban.rb +11 -0
  106. data/lib/plutonium/migrations.rb +40 -0
  107. data/lib/plutonium/models/has_cents.rb +30 -2
  108. data/lib/plutonium/positioning.rb +146 -0
  109. data/lib/plutonium/railtie.rb +33 -0
  110. data/lib/plutonium/resource/controller.rb +24 -1
  111. data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
  112. data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
  113. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  114. data/lib/plutonium/resource/policy.rb +14 -0
  115. data/lib/plutonium/routing/mapper_extensions.rb +45 -0
  116. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  117. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  118. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  119. data/lib/plutonium/ui/display/resource.rb +17 -12
  120. data/lib/plutonium/ui/form/base.rb +25 -5
  121. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  122. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  123. data/lib/plutonium/ui/form/components/password.rb +126 -0
  124. data/lib/plutonium/ui/form/components/uppy.rb +26 -5
  125. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  126. data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
  127. data/lib/plutonium/ui/form/resource.rb +13 -1
  128. data/lib/plutonium/ui/form/theme.rb +7 -0
  129. data/lib/plutonium/ui/form/wizard.rb +63 -0
  130. data/lib/plutonium/ui/grid/card.rb +56 -18
  131. data/lib/plutonium/ui/kanban/card.rb +67 -0
  132. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  133. data/lib/plutonium/ui/kanban/column.rb +411 -0
  134. data/lib/plutonium/ui/kanban/resource.rb +319 -0
  135. data/lib/plutonium/ui/layout/base.rb +1 -1
  136. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  137. data/lib/plutonium/ui/modal/base.rb +30 -3
  138. data/lib/plutonium/ui/modal/centered.rb +5 -2
  139. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  140. data/lib/plutonium/ui/page/index.rb +2 -1
  141. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  142. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  143. data/lib/plutonium/ui/page/show.rb +29 -1
  144. data/lib/plutonium/ui/page/wizard.rb +371 -0
  145. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  146. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  147. data/lib/plutonium/ui/table/base.rb +1 -1
  148. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  149. data/lib/plutonium/ui/table/resource.rb +1 -1
  150. data/lib/plutonium/ui/wizard/review.rb +196 -0
  151. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  152. data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
  153. data/lib/plutonium/version.rb +1 -1
  154. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  155. data/lib/plutonium/wizard/attachments.rb +226 -0
  156. data/lib/plutonium/wizard/base.rb +216 -0
  157. data/lib/plutonium/wizard/base_controller.rb +31 -0
  158. data/lib/plutonium/wizard/configuration.rb +42 -0
  159. data/lib/plutonium/wizard/controller.rb +162 -0
  160. data/lib/plutonium/wizard/data.rb +134 -0
  161. data/lib/plutonium/wizard/driving.rb +639 -0
  162. data/lib/plutonium/wizard/dsl.rb +336 -0
  163. data/lib/plutonium/wizard/errors.rb +27 -0
  164. data/lib/plutonium/wizard/field_capture.rb +157 -0
  165. data/lib/plutonium/wizard/field_importer.rb +208 -0
  166. data/lib/plutonium/wizard/gate.rb +171 -0
  167. data/lib/plutonium/wizard/instance_key.rb +97 -0
  168. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  169. data/lib/plutonium/wizard/resume.rb +250 -0
  170. data/lib/plutonium/wizard/review_step.rb +48 -0
  171. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  172. data/lib/plutonium/wizard/runner.rb +684 -0
  173. data/lib/plutonium/wizard/session.rb +53 -0
  174. data/lib/plutonium/wizard/state.rb +35 -0
  175. data/lib/plutonium/wizard/step.rb +61 -0
  176. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  177. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  178. data/lib/plutonium/wizard/store/base.rb +42 -0
  179. data/lib/plutonium/wizard/store/memory.rb +44 -0
  180. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  181. data/lib/plutonium/wizard.rb +86 -0
  182. data/lib/plutonium.rb +5 -0
  183. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  184. data/lib/tasks/release.rake +144 -191
  185. data/package.json +7 -5
  186. data/src/css/components.css +137 -0
  187. data/src/js/controllers/attachment_input_controller.js +15 -1
  188. data/src/js/controllers/currency_input_controller.js +39 -0
  189. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  190. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  191. data/src/js/controllers/kanban_controller.js +717 -0
  192. data/src/js/controllers/password_sentinel_controller.js +39 -0
  193. data/src/js/controllers/register_controllers.js +8 -0
  194. data/src/js/controllers/remote_modal_controller.js +10 -0
  195. data/src/js/controllers/row_click_controller.js +14 -1
  196. data/src/js/controllers/wizard_controller.js +54 -0
  197. data/src/js/turbo/turbo_confirm.js +1 -1
  198. data/yarn.lock +945 -286
  199. 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
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.60.5"
2
+ VERSION = "0.62.0"
3
3
  NEXT_MAJOR_VERSION = VERSION.split(".").tap { |v|
4
4
  v[1] = v[1].to_i + 1
5
5
  v[2] = 0
@@ -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