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.
Files changed (175) 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 +313 -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 +549 -439
  15. data/CLAUDE.md +15 -7
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +895 -193
  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/_resource_kanban.html.erb +1 -0
  25. data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
  26. data/docs/.vitepress/config.ts +24 -0
  27. data/docs/guides/index.md +2 -0
  28. data/docs/guides/kanban.md +447 -0
  29. data/docs/guides/wizards.md +447 -0
  30. data/docs/public/images/guides/kanban-after-move.png +0 -0
  31. data/docs/public/images/guides/kanban-board-light.png +0 -0
  32. data/docs/public/images/guides/kanban-board.png +0 -0
  33. data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
  34. data/docs/public/images/guides/kanban-wip-toast.png +0 -0
  35. data/docs/public/images/guides/wizards-chooser.png +0 -0
  36. data/docs/public/images/guides/wizards-completed.png +0 -0
  37. data/docs/public/images/guides/wizards-index-action.png +0 -0
  38. data/docs/public/images/guides/wizards-repeater.png +0 -0
  39. data/docs/public/images/guides/wizards-review.png +0 -0
  40. data/docs/public/images/guides/wizards-step.png +0 -0
  41. data/docs/reference/behavior/policies.md +1 -1
  42. data/docs/reference/index.md +14 -0
  43. data/docs/reference/kanban/authorization.md +62 -0
  44. data/docs/reference/kanban/dsl.md +293 -0
  45. data/docs/reference/kanban/index.md +40 -0
  46. data/docs/reference/kanban/positioning.md +162 -0
  47. data/docs/reference/resource/definition.md +16 -0
  48. data/docs/reference/ui/forms.md +36 -0
  49. data/docs/reference/ui/pages.md +2 -0
  50. data/docs/reference/wizard/anchoring-resume.md +194 -0
  51. data/docs/reference/wizard/dsl.md +332 -0
  52. data/docs/reference/wizard/index.md +33 -0
  53. data/docs/reference/wizard/one-time.md +129 -0
  54. data/docs/reference/wizard/registration-launch.md +177 -0
  55. data/docs/reference/wizard/storage-config.md +151 -0
  56. data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
  57. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
  58. data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
  59. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
  60. data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
  61. data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
  62. data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
  63. data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
  64. data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
  65. data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
  66. data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
  67. data/gemfiles/postgres.gemfile +8 -0
  68. data/gemfiles/postgres.gemfile.lock +321 -0
  69. data/gemfiles/rails_7.gemfile +1 -0
  70. data/gemfiles/rails_7.gemfile.lock +1 -1
  71. data/gemfiles/rails_8.0.gemfile +1 -0
  72. data/gemfiles/rails_8.0.gemfile.lock +1 -1
  73. data/gemfiles/rails_8.1.gemfile +1 -0
  74. data/gemfiles/rails_8.1.gemfile.lock +14 -1
  75. data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
  76. data/lib/plutonium/action/base.rb +9 -0
  77. data/lib/plutonium/auth/rodauth.rb +1 -2
  78. data/lib/plutonium/configuration.rb +4 -0
  79. data/lib/plutonium/core/controller.rb +20 -1
  80. data/lib/plutonium/definition/base.rb +25 -0
  81. data/lib/plutonium/definition/form_layout.rb +54 -35
  82. data/lib/plutonium/definition/index_views.rb +54 -1
  83. data/lib/plutonium/definition/wizards.rb +209 -0
  84. data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
  85. data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
  86. data/lib/plutonium/invites/controller.rb +4 -1
  87. data/lib/plutonium/kanban/action.rb +7 -0
  88. data/lib/plutonium/kanban/board.rb +40 -0
  89. data/lib/plutonium/kanban/broadcaster.rb +54 -0
  90. data/lib/plutonium/kanban/column.rb +69 -0
  91. data/lib/plutonium/kanban/context.rb +15 -0
  92. data/lib/plutonium/kanban/dsl.rb +71 -0
  93. data/lib/plutonium/kanban/grouping.rb +51 -0
  94. data/lib/plutonium/kanban/positioning.rb +75 -0
  95. data/lib/plutonium/kanban.rb +11 -0
  96. data/lib/plutonium/migrations.rb +40 -0
  97. data/lib/plutonium/positioning.rb +146 -0
  98. data/lib/plutonium/railtie.rb +33 -0
  99. data/lib/plutonium/resource/controller.rb +2 -0
  100. data/lib/plutonium/resource/controllers/crud_actions.rb +1 -1
  101. data/lib/plutonium/resource/controllers/kanban_actions.rb +455 -0
  102. data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
  103. data/lib/plutonium/resource/policy.rb +8 -0
  104. data/lib/plutonium/routing/mapper_extensions.rb +44 -0
  105. data/lib/plutonium/routing/wizard_registration.rb +289 -0
  106. data/lib/plutonium/ui/display/resource.rb +17 -12
  107. data/lib/plutonium/ui/form/base.rb +19 -5
  108. data/lib/plutonium/ui/form/components/password.rb +126 -0
  109. data/lib/plutonium/ui/form/components/uppy.rb +6 -3
  110. data/lib/plutonium/ui/form/options/inferred_types.rb +20 -0
  111. data/lib/plutonium/ui/form/resource.rb +1 -1
  112. data/lib/plutonium/ui/form/wizard.rb +63 -0
  113. data/lib/plutonium/ui/grid/card.rb +16 -5
  114. data/lib/plutonium/ui/kanban/card.rb +67 -0
  115. data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
  116. data/lib/plutonium/ui/kanban/column.rb +324 -0
  117. data/lib/plutonium/ui/kanban/resource.rb +212 -0
  118. data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
  119. data/lib/plutonium/ui/modal/base.rb +30 -3
  120. data/lib/plutonium/ui/modal/centered.rb +5 -2
  121. data/lib/plutonium/ui/page/index.rb +1 -0
  122. data/lib/plutonium/ui/page/show.rb +23 -0
  123. data/lib/plutonium/ui/page/wizard.rb +371 -0
  124. data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
  125. data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
  126. data/lib/plutonium/ui/table/base.rb +1 -1
  127. data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
  128. data/lib/plutonium/ui/wizard/review.rb +196 -0
  129. data/lib/plutonium/ui/wizard/stepper.rb +122 -0
  130. data/lib/plutonium/ui/wizard/summary_display.rb +59 -0
  131. data/lib/plutonium/version.rb +1 -1
  132. data/lib/plutonium/wizard/attachment_data.rb +42 -0
  133. data/lib/plutonium/wizard/attachments.rb +226 -0
  134. data/lib/plutonium/wizard/base.rb +216 -0
  135. data/lib/plutonium/wizard/base_controller.rb +31 -0
  136. data/lib/plutonium/wizard/configuration.rb +42 -0
  137. data/lib/plutonium/wizard/controller.rb +162 -0
  138. data/lib/plutonium/wizard/data.rb +134 -0
  139. data/lib/plutonium/wizard/driving.rb +639 -0
  140. data/lib/plutonium/wizard/dsl.rb +336 -0
  141. data/lib/plutonium/wizard/errors.rb +27 -0
  142. data/lib/plutonium/wizard/field_capture.rb +157 -0
  143. data/lib/plutonium/wizard/field_importer.rb +208 -0
  144. data/lib/plutonium/wizard/gate.rb +171 -0
  145. data/lib/plutonium/wizard/instance_key.rb +97 -0
  146. data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
  147. data/lib/plutonium/wizard/resume.rb +250 -0
  148. data/lib/plutonium/wizard/review_step.rb +48 -0
  149. data/lib/plutonium/wizard/route_resolution.rb +40 -0
  150. data/lib/plutonium/wizard/runner.rb +684 -0
  151. data/lib/plutonium/wizard/session.rb +53 -0
  152. data/lib/plutonium/wizard/state.rb +35 -0
  153. data/lib/plutonium/wizard/step.rb +61 -0
  154. data/lib/plutonium/wizard/step_adapter.rb +103 -0
  155. data/lib/plutonium/wizard/store/active_record.rb +174 -0
  156. data/lib/plutonium/wizard/store/base.rb +42 -0
  157. data/lib/plutonium/wizard/store/memory.rb +44 -0
  158. data/lib/plutonium/wizard/sweep_job.rb +76 -0
  159. data/lib/plutonium/wizard.rb +86 -0
  160. data/lib/plutonium.rb +5 -0
  161. data/lib/rodauth/features/case_insensitive_login.rb +1 -1
  162. data/lib/tasks/release.rake +144 -191
  163. data/package.json +3 -3
  164. data/src/css/components.css +132 -0
  165. data/src/js/controllers/attachment_input_controller.js +15 -1
  166. data/src/js/controllers/dirty_form_guard_controller.js +155 -27
  167. data/src/js/controllers/kanban_controller.js +330 -0
  168. data/src/js/controllers/password_sentinel_controller.js +39 -0
  169. data/src/js/controllers/register_controllers.js +6 -0
  170. data/src/js/controllers/remote_modal_controller.js +10 -0
  171. data/src/js/controllers/row_click_controller.js +14 -1
  172. data/src/js/controllers/wizard_controller.js +54 -0
  173. data/src/js/turbo/turbo_confirm.js +1 -1
  174. data/yarn.lock +271 -282
  175. metadata +100 -1
@@ -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,59 @@
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
+ render field(name, **field_options).wrapped do |f|
37
+ render instance_exec(f, &summary_tag_block(name))
38
+ end
39
+ end
40
+
41
+ # Pick the display component the same way a resource display does — infer it
42
+ # from the value's TYPE (date, boolean, number, currency, …) instead of
43
+ # stringifying everything. The one override: an attachment field stages a
44
+ # string TOKEN, so inference can't tell it's an attachment — force it (the
45
+ # data object is decorated upstream to resolve the token to an attachment).
46
+ def summary_tag_block(name)
47
+ ->(f) {
48
+ tag = Plutonium::Wizard::Attachments.field?(@summary_inputs[name]) ? :attachment : f.inferred_field_component
49
+ if tag.is_a?(Class)
50
+ f.send(:create_component, tag, tag.name.demodulize.underscore.sub(/component$/, "").to_sym)
51
+ else
52
+ f.send(:"#{tag}_tag")
53
+ end
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,5 +1,5 @@
1
1
  module Plutonium
2
- VERSION = "0.60.5"
2
+ VERSION = "0.61.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
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module Wizard
5
+ # Bridges a wizard's staged attachment value to the displayable attachment the
6
+ # `Uppy` input and `Display::Components::Attachment` render — **model-free and
7
+ # backend-agnostic**, for a bare `attribute :photo, :string` + `input :photo,
8
+ # as: :uppy, direct_upload: true` field (no `using:` model needed).
9
+ #
10
+ # A wizard stages plain strings, so an attachment field holds its backend's own
11
+ # direct-upload token: ActiveStorage's **signed_id** (an opaque signed string)
12
+ # or active_shrine/Shrine's **cached-file data** (a JSON object). Those are the
13
+ # only two shapes, and they're distinguishable — a Shrine token parses as JSON,
14
+ # an AS signed_id doesn't — so we revive each through its own backend with no
15
+ # model and no per-field configuration.
16
+ #
17
+ # Resolution is **display-only**: staging and `execute` (which assigns the token
18
+ # straight to a model attachment — both AS and active_shrine accept it) never
19
+ # call it. The two backends' native objects answer DIFFERENT method names
20
+ # (`filename`/`content_type` vs `original_filename`/`mime_type`), so a resolved
21
+ # token is wrapped in {Resolved}, a uniform view exposing exactly what the
22
+ # display + preview components call. See the wizard-attachments design spec.
23
+ module Attachments
24
+ module_function
25
+
26
+ # Resolve a staged attachment token (or array of them) into uniform
27
+ # {Resolved} view(s).
28
+ #
29
+ # @param value [String, Array, nil] the staged token(s).
30
+ # @return [Array<Resolved>] resolved attachments; blank, tampered, or
31
+ # unrecognized tokens are dropped (never raised), so a bad token can't 500
32
+ # the form or the review.
33
+ def resolve(value)
34
+ Array(value).filter_map { |token| resolve_token(token) }
35
+ end
36
+
37
+ # Whether a step input renders as an attachment (its `as:` is a file alias),
38
+ # so its staged token should be resolved for display. Keys off the form's
39
+ # canonical file-input alias set, so the two never drift.
40
+ def field?(input_options)
41
+ as = input_options&.dig(:options, :as) || input_options&.dig(:as)
42
+ Plutonium::UI::Form::Base::Builder::FILE_INPUT_TYPES.include?(as&.to_sym)
43
+ end
44
+
45
+ # SERVER-SIDE staging: turn a submitted attachment value into a token string
46
+ # to stage in `data`, minting one from an uploaded file when needed.
47
+ #
48
+ # Handles every shape a step POST can carry for an attachment field:
49
+ # - an already-minted token String (direct upload, or the hidden preview field
50
+ # on re-submit) → kept verbatim;
51
+ # - an uploaded file (IO-like) → uploaded to the backend's cache, returning its
52
+ # token (an AS signed_id, or Shrine cached-file JSON);
53
+ # - blank / no selection → nil (the caller drops the key so the previously
54
+ # staged token survives a Back/re-submit);
55
+ # - an Array (multiple) → each element mapped, blanks dropped.
56
+ #
57
+ # @param backend [Symbol, nil] per-field override; nil → the configured default.
58
+ # @param uploader [Class, String, nil] a Shrine uploader to cache through
59
+ # (`:shrine` backend only) — its cache-stage plugins (mime/dimension
60
+ # extraction, `generate_location`, validations) run instead of base Shrine's.
61
+ # The minted token stays uploader-agnostic, so display + `execute` promotion
62
+ # are unaffected. Ignored shape for ActiveStorage (raises if given).
63
+ def stage_upload(value, backend: nil, uploader: nil)
64
+ if value.is_a?(Array)
65
+ value.filter_map { |v| stage_upload(v, backend:, uploader:) }.presence
66
+ elsif value.is_a?(String)
67
+ value.presence
68
+ elsif value.respond_to?(:read)
69
+ upload_to_cache(value, backend || attachment_backend, uploader:)
70
+ end
71
+ end
72
+
73
+ # The default server-side staging backend: the configured one, else
74
+ # auto-detected (active_shrine loaded → Shrine, else ActiveStorage).
75
+ def attachment_backend
76
+ Plutonium.configuration.wizards.attachment_backend ||
77
+ (defined?(ActiveShrine) ? :shrine : :active_storage)
78
+ end
79
+
80
+ # Run the EFFECTIVE Shrine uploader's attacher validations against a staged
81
+ # token (or array of them), returning the validation messages — so a file that
82
+ # violates the uploader's `validate_*` rules is rejected at the STEP (stage
83
+ # phase), not deferred to `execute`'s model assignment.
84
+ #
85
+ # The effective uploader is the field's `uploader:` if given, else base
86
+ # `Shrine` — both of which may carry `Attacher.validate` rules. Returns `[]`
87
+ # when the field isn't Shrine-backed (ActiveStorage has no attacher here), when
88
+ # nothing is staged, or when the effective uploader declares no validations.
89
+ #
90
+ # @param value [String, Array, nil] the staged token(s).
91
+ # @param backend [Symbol, nil] per-field override; nil → the configured default.
92
+ # @param uploader [Class, String, nil] the field's `uploader:` option.
93
+ # @return [Array<String>] validation messages (empty ⇒ valid).
94
+ def validation_errors(value, backend: nil, uploader: nil)
95
+ return [] unless (backend || attachment_backend).to_sym == :shrine
96
+
97
+ klass = shrine_uploader(uploader)
98
+ # Shrine's `validation` plugin is OPTIONAL — without it (or `validation_helpers`)
99
+ # the Attacher has no `#errors` and nothing to enforce. Detect it up front so a
100
+ # plugin-less app is a clean no-op, not a per-step rescued NoMethodError.
101
+ return [] unless klass::Attacher.method_defined?(:errors)
102
+
103
+ Array(value).flat_map { |token| token_validation_errors(klass, token) }
104
+ end
105
+
106
+ # Validate one cached token through an uploader's attacher. A broad rescue
107
+ # (like {resolve_token}) — a tampered/expired token shouldn't 500 the step; it
108
+ # surfaces at `execute` instead, where it's caught as a RecordInvalid.
109
+ def token_validation_errors(uploader_class, token)
110
+ return [] if token.blank?
111
+
112
+ attacher = uploader_class::Attacher.new
113
+ attacher.assign(token)
114
+ Array(attacher.errors)
115
+ rescue => e
116
+ Rails.logger.warn { "[Plutonium::Wizard] attachment validation skipped: #{e.class}: #{e.message}" }
117
+ []
118
+ end
119
+ private_class_method :token_validation_errors
120
+
121
+ # Upload a file to the backend's CACHE and return its re-postable token. The
122
+ # file lives in cache until `execute` assigns the token to a real attachment
123
+ # (which promotes it); an abandoned upload is reaped by the backend's own
124
+ # unattached-cache cleanup.
125
+ def upload_to_cache(file, backend, uploader: nil)
126
+ case backend.to_sym
127
+ when :shrine
128
+ shrine_uploader(uploader).upload(file, :cache).to_json
129
+ when :active_storage
130
+ raise ArgumentError, "input `uploader:` is only supported for the :shrine backend" if uploader
131
+ ActiveStorage::Blob.create_and_upload!(
132
+ io: file, filename: file.original_filename, content_type: file.content_type
133
+ ).signed_id
134
+ else
135
+ raise ArgumentError, "unknown wizard attachment backend: #{backend.inspect}"
136
+ end
137
+ end
138
+ private_class_method :upload_to_cache
139
+
140
+ # Resolve an `uploader:` option to the Shrine uploader class to cache through.
141
+ # nil → base `Shrine`; a class is used as-is; a String/Symbol is constantized.
142
+ # Anything that isn't a Shrine subclass is a configuration error (fail loud).
143
+ def shrine_uploader(uploader)
144
+ return Shrine if uploader.nil?
145
+
146
+ klass = uploader.is_a?(Class) ? uploader : uploader.to_s.safe_constantize
147
+ unless klass.is_a?(Class) && klass <= Shrine
148
+ raise ArgumentError, "input `uploader:` must be a Shrine uploader class, got #{uploader.inspect}"
149
+ end
150
+ klass
151
+ end
152
+ private_class_method :shrine_uploader
153
+
154
+ # Revive one token through whichever backend owns it, wrapped in {Resolved}. A
155
+ # broad rescue is warranted here (unlike elsewhere): the token is arbitrary,
156
+ # user-supplied input reconstituted at a render boundary, and the two backends
157
+ # raise different error classes for a tampered/expired token — none of which
158
+ # should take down the page.
159
+ def resolve_token(token)
160
+ return if token.blank?
161
+
162
+ source = shrine_uploaded_file(token) || active_storage_blob(token)
163
+ source && Resolved.new(source, token)
164
+ rescue => e
165
+ Rails.logger.warn { "[Plutonium::Wizard] could not resolve attachment token: #{e.class}: #{e.message}" }
166
+ nil
167
+ end
168
+ private_class_method :resolve_token
169
+
170
+ # A Shrine cached-file token is JSON (`{"id":…,"storage":"cache",…}`); an AS
171
+ # signed_id isn't. Parse-success → Shrine materializes it from the globally
172
+ # registered storages (no model, no per-field uploader needed for a `.url`).
173
+ def shrine_uploaded_file(token)
174
+ return unless defined?(Shrine)
175
+
176
+ data = begin
177
+ JSON.parse(token)
178
+ rescue JSON::ParserError, TypeError
179
+ nil
180
+ end
181
+ return unless data.is_a?(Hash)
182
+
183
+ Shrine.uploaded_file(data)
184
+ end
185
+ private_class_method :shrine_uploaded_file
186
+
187
+ def active_storage_blob(token)
188
+ ActiveStorage::Blob.find_signed(token) if defined?(ActiveStorage::Blob)
189
+ end
190
+ private_class_method :active_storage_blob
191
+
192
+ # A uniform view over a resolved attachment so the review display + the uppy
193
+ # preview don't care whether the source is an ActiveStorage `Blob`
194
+ # (`filename`/`content_type`/`representable?`) or a Shrine `UploadedFile`
195
+ # (`original_filename`/`mime_type`, none of the AS-only methods). Exposes
196
+ # exactly what those components call.
197
+ class Resolved
198
+ # @param source the backend object (AS Blob or Shrine UploadedFile).
199
+ # @param token [String] the ORIGINAL staged token — what the hidden preview
200
+ # field re-posts to preserve the upload across a Back/re-submit, and what
201
+ # `execute` assigns to the model attachment.
202
+ def initialize(source, token)
203
+ @source = source
204
+ @token = token
205
+ end
206
+
207
+ # `url` is lazy (called at render, inside a request, where AS url options
208
+ # exist) — never eager at resolve time.
209
+ def url(*args) = @source.url(*args)
210
+
211
+ # The re-postable token, surfaced under the name the uppy input reads.
212
+ def signed_id = @token
213
+
214
+ def filename = (@source.try(:filename) || @source.try(:original_filename)).to_s
215
+
216
+ def content_type = @source.try(:content_type) || @source.try(:mime_type)
217
+
218
+ def representable? = @source.try(:representable?) || content_type.to_s.start_with?("image/")
219
+
220
+ def extension = @source.try(:extension).presence || File.extname(filename).delete(".").presence
221
+
222
+ def present? = true
223
+ end
224
+ end
225
+ end
226
+ end