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
@@ -47,6 +47,13 @@ module Plutonium
47
47
  end
48
48
  alias_method :switch_tag, :toggle_tag
49
49
 
50
+ # Password / secret input that never renders the stored value.
51
+ # Routed to here for both explicit `as: :password` and every field
52
+ # inferred as a password (see Options::InferredTypes#infer_field_component).
53
+ def password_tag(**, &)
54
+ create_component(Components::Password, :password, **, &)
55
+ end
56
+
50
57
  def slim_select_tag(**attributes, &)
51
58
  attributes[:data_controller] = tokens(attributes[:data_controller], "slim-select")
52
59
  select_tag(**attributes, required: false, class!: "", &)
@@ -56,16 +63,27 @@ module Plutonium
56
63
  create_component(Components::Flatpickr, :flatpickr, **, &)
57
64
  end
58
65
 
66
+ # Money input: a number field with an optional currency-unit prefix.
67
+ # See {Components::Currency} for how the unit resolves.
68
+ def currency_tag(**, &)
69
+ create_component(Components::Currency, :currency, **, &)
70
+ end
71
+
59
72
  def int_tel_input_tag(**, &)
60
73
  create_component(Components::IntlTelInput, :int_tel_input, **, &)
61
74
  end
62
75
  alias_method :phone_tag, :int_tel_input_tag
63
76
 
77
+ # The `as:` values that render through the Uppy file-upload component —
78
+ # the single source of truth, also consulted by
79
+ # {Plutonium::Wizard::Attachments.field?} so a wizard can detect an
80
+ # attachment field model-free without re-listing the aliases.
81
+ FILE_INPUT_TYPES = %i[uppy file attachment].freeze
82
+
64
83
  def uppy_tag(**, &)
65
84
  create_component(Components::Uppy, :uppy, **, &)
66
85
  end
67
- alias_method :file_tag, :uppy_tag
68
- alias_method :attachment_tag, :uppy_tag
86
+ (FILE_INPUT_TYPES - [:uppy]).each { |name| alias_method :"#{name}_tag", :uppy_tag }
69
87
 
70
88
  def key_value_store_tag(**, &)
71
89
  create_component(Components::KeyValueStore, :key_value_store, **, &)
@@ -165,9 +183,11 @@ module Plutonium
165
183
  attributes["data-controller"] = form_data_controller
166
184
  end
167
185
 
168
- # `dirty-form-guard` is attached unconditionally — it self-disables
169
- # outside a <dialog>. Branching on `in_modal?` here would fail:
170
- # Phlex forbids view-context access before rendering begins.
186
+ # `dirty-form-guard` is attached unconditionally — it is inert until it has
187
+ # something to guard: a modal (Esc/close/cancel) or a control marked
188
+ # `data-dirty-form-guard-leave` posting without this form's fields. With
189
+ # neither it never prompts. Branching on `in_modal?` here would fail: Phlex
190
+ # forbids view-context access before rendering begins.
171
191
  def form_data_controller
172
192
  "form dirty-form-guard"
173
193
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # A numeric (money) input with an OPTIONAL currency-unit prefix. The unit
8
+ # is resolved by the SAME rules as the currency *display*
9
+ # ({Display::Components::Currency.resolve_unit}), so the input and the
10
+ # show/index/summary render the same symbol: an explicit `unit:` (a
11
+ # literal "£", a Symbol read off the record, or `false` for none) → the
12
+ # record's `has_cents` unit → `default_currency_unit` / the i18n default.
13
+ # When nothing resolves (or `unit: false`) the prefix is omitted and it's
14
+ # a plain number input.
15
+ #
16
+ # input :price, as: :currency # → configured/i18n default unit
17
+ # input :price, as: :currency, unit: "£"
18
+ # input :price, as: :currency, unit: false # no prefix
19
+ class Currency < Phlexi::Form::Components::Input
20
+ def view_template
21
+ return super if @unit_prefix.blank?
22
+
23
+ # Overlay the unit at the input's left edge; the currency-input
24
+ # Stimulus controller measures this prefix and sets the input's
25
+ # left padding to match, so digits always clear it whatever the
26
+ # symbol's width ("$" vs "GH₵").
27
+ div(class: "relative", data: {controller: "currency-input"}) do
28
+ span(
29
+ class: "pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-sm text-[var(--pu-text-muted)]",
30
+ aria_hidden: "true",
31
+ data: {currency_input_target: "prefix"}
32
+ ) { plain @unit_prefix }
33
+ super
34
+ end
35
+ end
36
+
37
+ protected
38
+
39
+ def build_input_attributes
40
+ @unit_prefix = resolve_unit_prefix
41
+ attributes[:type] = :number
42
+ attributes[:inputmode] = "decimal"
43
+ attributes[:step] ||= "0.01"
44
+ # Mark the input so the currency-input controller can measure the
45
+ # prefix and set the exact left padding at connect (see the JS).
46
+ if @unit_prefix.present?
47
+ attributes[:data] = (attributes[:data] || {}).merge(currency_input_target: "field")
48
+ end
49
+ super
50
+ end
51
+
52
+ private
53
+
54
+ # Resolve the prefix and strip `unit:` from the attributes so it never
55
+ # leaks onto the <input>. Returns "" when there's no unit to show.
56
+ def resolve_unit_prefix
57
+ explicit = attributes.delete(:unit)
58
+ Plutonium::UI::Display::Components::Currency.resolve_unit(explicit, field.object, field.key)
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -6,7 +6,10 @@ module Plutonium
6
6
  module Components
7
7
  class IntlTelInput < Phlexi::Form::Components::Input
8
8
  def view_template
9
- div(data_controller: "intl-tel-input") {
9
+ div(data: {
10
+ controller: "intl-tel-input",
11
+ intl_tel_input_options_value: @intl_options.to_json
12
+ }) {
10
13
  super
11
14
  }
12
15
  end
@@ -16,6 +19,29 @@ module Plutonium
16
19
  def build_input_attributes
17
20
  super
18
21
  attributes[:data_intl_tel_input_target] = tokens(attributes[:data_intl_tel_input_target], :input)
22
+ @intl_options = build_intl_options
23
+ end
24
+
25
+ # Options forwarded to the intl-tel-input library via the Stimulus
26
+ # controller's `options` value. Supports a convenient `initial_country:`
27
+ # shortcut plus an `intl_options:` hash for any other library option
28
+ # (keys are the library's own camelCase names, e.g. `separateDialCode`).
29
+ # Both are deleted from `attributes` so they don't leak onto the <input>.
30
+ #
31
+ # input :phone, as: :phone, initial_country: "gh"
32
+ # input :phone, as: :phone, intl_options: {separateDialCode: true, strictMode: false}
33
+ #
34
+ # When no country is given, falls back to
35
+ # `Plutonium.configuration.default_phone_country`.
36
+ def build_intl_options
37
+ options = {}
38
+ if (country = attributes.delete(:initial_country) || Plutonium.configuration.default_phone_country)
39
+ options[:initialCountry] = country
40
+ end
41
+ if (extra = attributes.delete(:intl_options))
42
+ options.merge!(extra)
43
+ end
44
+ options
19
45
  end
20
46
  end
21
47
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ module Components
7
+ # Password / secret input that never emits the stored value into the DOM.
8
+ #
9
+ # The generic Phlexi input renders `value=field.dom.value`, leaking the
10
+ # server-side secret (and its length) into the page source. This component
11
+ # instead renders a fixed SENTINEL whenever an *untouched* secret is
12
+ # stored, masking both the secret and its length, and renders an empty
13
+ # field otherwise.
14
+ #
15
+ # On submit the sentinel maps back to `nil`, which Plutonium's param
16
+ # extraction (`submitted_resource_params`) compacts away — leaving the
17
+ # stored value untouched. An empty field passes through as "":
18
+ #
19
+ # untouched field → sentinel submitted → nil → keep existing
20
+ # emptied field → "" submitted → "" → explicit clear (clear-by-blank)
21
+ # typed value → value submitted → value → set new value
22
+ #
23
+ # On a failed re-render of an *edited* secret the field comes back blank
24
+ # (we never echo a submitted secret). When the edit set a new value it is
25
+ # also marked `required`, so the browser forces a re-type rather than a
26
+ # silent blank resubmit clearing the stored secret. When the edit *cleared*
27
+ # the value we leave it blank and not required — the clear may be intended
28
+ # (clear-by-blank). Either guard is client-side UX only.
29
+ #
30
+ # The rendered sentinel is guarded client-side by the `password-sentinel`
31
+ # Stimulus controller: the first edit (keystroke, paste, backspace) wipes
32
+ # the whole field, so a partial edit can't corrupt the sentinel into a
33
+ # literal new password.
34
+ #
35
+ # New records and interaction forms (set-password, reset-password) render
36
+ # an honest empty field that invites input and lets password managers
37
+ # offer to generate a strong password.
38
+ class Password < Phlexi::Form::Components::Input
39
+ # Rendered in place of an existing secret. Masked in the UI; only ever
40
+ # visible (as this constant) in page source — never the real value.
41
+ SENTINEL = "__plutonium_password_unchanged__"
42
+
43
+ protected
44
+
45
+ def build_input_attributes
46
+ super
47
+ attributes[:type] = :password
48
+ value = masked_value
49
+ attributes[:value] = value
50
+ attributes[:autocomplete] ||= "new-password"
51
+ # A stored secret edited (to a new value) on a failed submit comes
52
+ # back blank. Force re-entry so an untouched resubmit can't silently
53
+ # clear it via the clear-by-blank path. Client-side UX guard only.
54
+ attributes[:required] = true if reentry_required?
55
+
56
+ # When the field renders the sentinel, guard it so the first edit
57
+ # wipes the whole value — a partial edit would corrupt the sentinel
58
+ # into a literal new password.
59
+ if value == SENTINEL
60
+ attributes[:data_controller] = tokens(attributes[:data_controller], :"password-sentinel")
61
+ attributes[:data_action] = tokens(attributes[:data_action], "beforeinput->password-sentinel#beforeinput")
62
+ attributes[:data_password_sentinel_sentinel_value] = SENTINEL
63
+ end
64
+
65
+ apply_default_hint(value)
66
+ end
67
+
68
+ # The masked field is otherwise opaque — the user can't tell what the
69
+ # dots mean or what a blank submit does. Supply a default hint
70
+ # explaining it, unless the author already set one (theirs wins). The
71
+ # wrapper renders the hint after this input, so setting it here is in
72
+ # time. No hint for a plain empty field (new record) — it speaks for
73
+ # itself.
74
+ def apply_default_hint(value)
75
+ return if field.has_hint?
76
+ if value == SENTINEL
77
+ field.hint("Leave blank to keep the current value.")
78
+ elsif reentry_required?
79
+ field.hint("Re-enter the new value to save it.")
80
+ end
81
+ end
82
+
83
+ def masked_value
84
+ key = field.key.to_s
85
+ # New records and interaction forms (set-password, reset-password)
86
+ # have nothing stored — render an empty field that invites input.
87
+ return nil unless field.object.persisted?
88
+ # Write-only attributes (e.g. has_secure_password's `password`) are
89
+ # not real columns, so there is nothing stored to keep.
90
+ return nil unless field.object.has_attribute?(key)
91
+ # Nothing stored yet — render blank.
92
+ return nil unless field.object.attribute_in_database(key).present?
93
+ # A secret is stored. On a failed re-render of an edit it is dirty:
94
+ # render blank so the user re-enters it (we never echo a submitted
95
+ # secret). Otherwise mask it (and its length) behind the sentinel,
96
+ # which submits back as "leave unchanged".
97
+ field.object.attribute_changed?(key) ? nil : SENTINEL
98
+ end
99
+
100
+ # True only when a stored secret was edited *to a new value* and
101
+ # therefore renders blank — the state where an untouched resubmit would
102
+ # silently clear a secret the user meant to change. We deliberately do
103
+ # NOT force re-entry when the edit blanked the value: that user may
104
+ # have intended to clear it (clear-by-blank), so let the blank stand.
105
+ def reentry_required?
106
+ key = field.key.to_s
107
+ field.object.persisted? &&
108
+ field.object.has_attribute?(key) &&
109
+ field.object.attribute_in_database(key).present? &&
110
+ field.object.attribute_changed?(key) &&
111
+ field.object.read_attribute(key).present?
112
+ end
113
+
114
+ def normalize_input(input_value)
115
+ # The sentinel means "leave unchanged" → nil, which
116
+ # `submitted_resource_params` compacts away so the stored secret is
117
+ # kept. An empty field passes through as "" → an explicit clear
118
+ # (clear-by-blank); a typed value is set as-is.
119
+ return nil if input_value == SENTINEL
120
+ super
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -13,13 +13,16 @@ module Plutonium
13
13
  # Hidden field for ensuring removal of esp. has_one_attached attachments
14
14
  input(type: :hidden, name: attributes[:name], multiple: attributes[:multiple], value: nil, autocomplete: "off", hidden: true)
15
15
 
16
- next if field.value.nil?
17
-
16
+ # Always render the preview container — even when empty — so the
17
+ # `attachment-input` controller's `attachment-preview-container`
18
+ # OUTLET exists on a fresh (valueless) field. Without it the FIRST
19
+ # upload can't inject its preview: Stimulus raises on the missing
20
+ # outlet and the uploaded token never reaches the form.
18
21
  div(
19
22
  class: "attachment-preview-container grid grid-cols-[repeat(auto-fill,minmax(0,180px))] gap-4",
20
23
  data_controller: "attachment-preview-container"
21
24
  ) do
22
- render_existing_attachments
25
+ render_existing_attachments unless field.value.nil?
23
26
  end
24
27
  end
25
28
 
@@ -77,8 +80,13 @@ module Plutonium
77
80
  },
78
81
  title: attachment.filename.to_s
79
82
  ) do
80
- # Hidden field to preserve the uploaded file
81
- input(type: :hidden, name: input_name, multiple: attributes[:multiple], value: attachment.signed_id, autocomplete: "off", hidden: true)
83
+ # Hidden field to preserve the uploaded file across a re-render.
84
+ # Only when we actually have a signed_id on a non-JS submit or a
85
+ # validation-failure re-render the blob may be unsaved, and asking
86
+ # for its signed_id would raise. Nothing to preserve → omit it.
87
+ if (signed_id = preservable_signed_id(attachment))
88
+ input(type: :hidden, name: input_name, multiple: attributes[:multiple], value: signed_id, autocomplete: "off", hidden: true)
89
+ end
82
90
 
83
91
  render_preview_content(attachment)
84
92
  render_filename(attachment)
@@ -86,6 +94,19 @@ module Plutonium
86
94
  end
87
95
  end
88
96
 
97
+ # ActiveStorage delegates signed_id to the blob, and a *new* blob raises
98
+ # "Cannot get a signed_id for a new record" — so skip it there. Other
99
+ # backends (active_shrine, the wizard's Resolved) sign the file data
100
+ # itself and work fine on an unsaved record, so we must NOT gate them on
101
+ # the parent being persisted (that would drop a still-valid token on a
102
+ # validation re-render). Guarding only the new-blob case covers all three.
103
+ def preservable_signed_id(attachment)
104
+ blob = attachment.try(:blob)
105
+ return if blob&.new_record?
106
+
107
+ attachment.signed_id
108
+ end
109
+
89
110
  def render_preview_content(attachment)
90
111
  a(
91
112
  href: attachment.url,
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ # The drop-interaction form used by the kanban card-drop modal.
7
+ #
8
+ # Behaves exactly like a standard interaction form (same fields, same
9
+ # namespacing under `interaction[...]`, same submit button) with two
10
+ # deliberate differences:
11
+ #
12
+ # 1. It POSTs to the record's `kanban_move` member route rather than the
13
+ # interaction's own commit URL, so Task 4's atomic handler runs the
14
+ # interaction AND repositions the card in one request.
15
+ # 2. It carries the move context (from_column/to_column/to_index) as
16
+ # top-level hidden fields so the move handler knows where the card
17
+ # came from and where it landed.
18
+ class KanbanMove < Interaction
19
+ private
20
+
21
+ # POST to <member>/kanban_move for the dropped record.
22
+ def form_action
23
+ resource_url_for(resource_record!, action: :kanban_move)
24
+ end
25
+
26
+ def form_template
27
+ render_kanban_move_context
28
+ super
29
+ end
30
+
31
+ # Emit the move context as top-level params (NOT namespaced under
32
+ # interaction[...]) — the move handler reads params[:from_column] etc.
33
+ # Values come from the GET request's query string.
34
+ def render_kanban_move_context
35
+ {
36
+ from_column: params[:from_column],
37
+ to_column: params[:to_column],
38
+ to_index: params[:to_index]
39
+ }.each do |name, value|
40
+ input(type: :hidden, name: name.to_s, value: value)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -5,9 +5,23 @@ module Plutonium
5
5
  module Form
6
6
  module Options
7
7
  module InferredTypes
8
+ include Plutonium::UI::Options::HasCentsField
9
+
8
10
  private
9
11
 
10
12
  def infer_field_component
13
+ # Password detection lives in the string-type inference, not the
14
+ # component-type inference (a `password` column infers as :string).
15
+ # Route every inferred password/secret field to the masking Password
16
+ # component so the stored value never reaches the DOM. We also widen
17
+ # the heuristic to secret-bearing names Phlexi misses (`*_secret`,
18
+ # `*_key`, `salt`, ...) — see #secret_field_name?.
19
+ return :password if inferred_string_field_type == :password || secret_field_name?
20
+
21
+ # has_cents decimal accessors render as a currency input (number field
22
+ # + unit prefix), mirroring the display — no explicit `as: :currency`.
23
+ return :currency if has_cents_field?
24
+
11
25
  case inferred_field_type
12
26
  when :rich_text
13
27
  return :markdown
@@ -25,6 +39,18 @@ module Plutonium
25
39
  inferred_field_component
26
40
  end
27
41
  end
42
+
43
+ # Secret-bearing names Phlexi's `is_password_field?` does not catch
44
+ # (it only handles `password`, `encrypted_*`, `*_password`, `*_digest`,
45
+ # `*_hash`, `*_token`). Mask these too so their value never reaches the
46
+ # DOM. Still a name heuristic, not a guarantee — opt in/out per field
47
+ # with `as: :password` / `as: :string`.
48
+ def secret_field_name?
49
+ name = key.to_s.downcase
50
+ name == "token" || name == "salt" ||
51
+ name.include?("secret") ||
52
+ name.end_with?("_key", "_salt")
53
+ end
28
54
  end
29
55
  end
30
56
  end
@@ -66,7 +66,7 @@ module Plutonium
66
66
  "w-full max-w-md p-0 " \
67
67
  "open:flex flex-col " \
68
68
  "opacity-0 scale-95 data-[open]:opacity-100 data-[open]:scale-100 " \
69
- "transition-[opacity,transform] duration-200 ease-out",
69
+ "transition-[opacity,scale] duration-200 ease-out",
70
70
  data: {"dirty-form-guard-target": "confirmDialog"},
71
71
  # Modern Chrome refuses user-agent close requests (Esc, backdrop);
72
72
  # older browsers fall back to the JS controller's interception.
@@ -202,6 +202,18 @@ module Plutonium
202
202
  hidden: true,
203
203
  data: {controller: "capture-url"}
204
204
 
205
+ # Kanban quick-add: carry the column key through to the create POST so
206
+ # the controller can apply that column's on_enter + positioning to the
207
+ # new record post-create (the "+ Add" link opens this form with
208
+ # ?kanban_column=<key>). Only emitted when present, so it never appears
209
+ # on ordinary new/edit forms.
210
+ if request.params[:kanban_column].present?
211
+ input name: "kanban_column",
212
+ value: request.params[:kanban_column],
213
+ type: :hidden,
214
+ hidden: true
215
+ end
216
+
205
217
  if in_modal?
206
218
  div(class: "shrink-0 px-6 py-3 " \
207
219
  "bg-[var(--pu-surface)] border-t border-[var(--pu-border)] " \
@@ -80,6 +80,13 @@ module Plutonium
80
80
  invalid_flatpickr: :invalid_input,
81
81
  neutral_flatpickr: :neutral_input,
82
82
 
83
+ # Currency (money input; the number field styles as a normal input,
84
+ # the unit prefix is overlaid by the component)
85
+ currency: :input,
86
+ valid_currency: :valid_input,
87
+ invalid_currency: :invalid_input,
88
+ neutral_currency: :neutral_input,
89
+
83
90
  # Int tel input
84
91
  int_tel_input: :input,
85
92
  valid_int_tel_input: :valid_input,
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Plutonium
4
+ module UI
5
+ module Form
6
+ # Renders the CURRENT wizard step through the existing resource-form pipeline
7
+ # (§7). It rides `Form::Resource` unchanged — the only wizard-specific wiring
8
+ # is the per-step adapter (`resource_definition`), the value source (the
9
+ # wizard's typed `data`, so inputs render seeded from staged data for
10
+ # resume/back, including repeater rows), the `wizard[...]` param namespace,
11
+ # the step POST URL, and a hidden `_direction` (default `next`).
12
+ class Wizard < Resource
13
+ # @param step [Plutonium::Wizard::Step]
14
+ # @param data [Object] the wizard's typed `data` snapshot (the form
15
+ # `object`; responds to every step attribute / structured input name).
16
+ # @param action [String] the current step's POST URL.
17
+ # @param fields [Array<Symbol>] the step's renderable field names
18
+ # (scalar attributes + structured inputs).
19
+ def initialize(step:, data:, action:, fields:, **options, &)
20
+ @step = step
21
+ options[:key] = :wizard
22
+ options[:as] = :wizard
23
+ options[:action] = action
24
+ options[:resource_fields] = fields
25
+ options[:resource_definition] = Plutonium::Wizard::StepAdapter.new(step)
26
+ options[:singular_resource] = true
27
+ super(data, **options, &)
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :step
33
+
34
+ def form_template
35
+ # The direction defaults to "next"; the nav buttons in the page override
36
+ # it per-button. The wizard Stimulus controller targets it.
37
+ input(type: :hidden, name: "_direction", value: "next", data: {wizard_target: "direction"})
38
+ render_fields
39
+ end
40
+
41
+ # The wizard form has no submit footer of its own — the page renders the
42
+ # Back/Next/Finish/Cancel strip. (We still override the resource actions
43
+ # away so no stray "Create"/"Update" button appears.)
44
+ def render_actions
45
+ end
46
+
47
+ # The step form sits INSIDE the wizard card body (which already supplies the
48
+ # surface + padding), so drop the default `pu-card my-4 p-8` form chrome —
49
+ # otherwise it reads as a card-in-card. Keep just the vertical field rhythm.
50
+ def form_class
51
+ "space-y-6"
52
+ end
53
+
54
+ attr_reader :form_action
55
+
56
+ def initialize_attributes
57
+ super
58
+ attributes[:id] = "wizard-form"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end