plutonium 0.60.5 → 0.62.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium/SKILL.md +19 -1
- data/.claude/skills/plutonium-app/SKILL.md +41 -0
- data/.claude/skills/plutonium-auth/SKILL.md +40 -0
- data/.claude/skills/plutonium-behavior/SKILL.md +47 -1
- data/.claude/skills/plutonium-kanban/SKILL.md +378 -0
- data/.claude/skills/plutonium-resource/SKILL.md +40 -0
- data/.claude/skills/plutonium-tenancy/SKILL.md +43 -0
- data/.claude/skills/plutonium-testing/SKILL.md +38 -0
- data/.claude/skills/plutonium-ui/SKILL.md +51 -0
- data/.claude/skills/plutonium-wizard/SKILL.md +469 -0
- data/.cliff.toml +6 -0
- data/Appraisals +3 -0
- data/CHANGELOG.md +576 -439
- data/CLAUDE.md +15 -7
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +1173 -194
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +53 -53
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/layouts/basic.html.erb +7 -0
- data/app/views/plutonium/_flash_toasts.html.erb +2 -46
- data/app/views/plutonium/_toast.html.erb +52 -0
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/_resource_kanban.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/db/migrate/wizard/20260615000001_create_plutonium_wizard_sessions.rb +57 -0
- data/docs/.vitepress/config.ts +45 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/index.md +2 -0
- data/docs/guides/kanban.md +557 -0
- data/docs/guides/wizards.md +447 -0
- data/docs/public/images/guides/kanban-after-move.png +0 -0
- data/docs/public/images/guides/kanban-board-light.png +0 -0
- data/docs/public/images/guides/kanban-board.png +0 -0
- data/docs/public/images/guides/kanban-show-centered-modal.png +0 -0
- data/docs/public/images/guides/kanban-wip-toast.png +0 -0
- data/docs/public/images/guides/wizards-chooser.png +0 -0
- data/docs/public/images/guides/wizards-completed.png +0 -0
- data/docs/public/images/guides/wizards-index-action.png +0 -0
- data/docs/public/images/guides/wizards-repeater.png +0 -0
- data/docs/public/images/guides/wizards-review.png +0 -0
- data/docs/public/images/guides/wizards-step.png +0 -0
- data/docs/reference/behavior/policies.md +1 -1
- data/docs/reference/index.md +14 -0
- data/docs/reference/kanban/authorization.md +82 -0
- data/docs/reference/kanban/dsl.md +334 -0
- data/docs/reference/kanban/index.md +40 -0
- data/docs/reference/kanban/positioning.md +162 -0
- data/docs/reference/resource/definition.md +26 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +77 -0
- data/docs/reference/ui/pages.md +2 -0
- data/docs/reference/wizard/anchoring-resume.md +194 -0
- data/docs/reference/wizard/dsl.md +337 -0
- data/docs/reference/wizard/index.md +33 -0
- data/docs/reference/wizard/one-time.md +129 -0
- data/docs/reference/wizard/registration-launch.md +177 -0
- data/docs/reference/wizard/storage-config.md +151 -0
- data/docs/superpowers/plans/2026-06-14-form-sectioning.md +2 -2
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md +1619 -0
- data/docs/superpowers/plans/2026-06-15-wizard-dsl.md.tasks.json +68 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md +1128 -0
- data/docs/superpowers/plans/2026-06-26-kanban-dsl.md.tasks.json +24 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
- data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-design.md +836 -0
- data/docs/superpowers/specs/2026-06-15-wizard-dsl-examples.rb +245 -0
- data/docs/superpowers/specs/2026-06-17-wizard-relaunch-prompt-design.md +86 -0
- data/docs/superpowers/specs/2026-06-18-wizard-attachments-design.md +101 -0
- data/docs/superpowers/specs/2026-06-18-wizard-hosting-design.md +220 -0
- data/docs/superpowers/specs/2026-06-26-kanban-dsl-design.md +388 -0
- data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/postgres.gemfile +8 -0
- data/gemfiles/postgres.gemfile.lock +321 -0
- data/gemfiles/rails_7.gemfile +1 -0
- data/gemfiles/rails_7.gemfile.lock +1 -1
- data/gemfiles/rails_8.0.gemfile +1 -0
- data/gemfiles/rails_8.0.gemfile.lock +1 -1
- data/gemfiles/rails_8.1.gemfile +1 -0
- data/gemfiles/rails_8.1.gemfile.lock +14 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/generators/pu/invites/templates/packages/invites/app/controllers/invites/user_invitations_controller.rb.tt +6 -1
- data/lib/plutonium/action/base.rb +17 -0
- data/lib/plutonium/auth/rodauth.rb +1 -2
- data/lib/plutonium/configuration.rb +16 -0
- data/lib/plutonium/core/controller.rb +20 -1
- data/lib/plutonium/definition/base.rb +25 -0
- data/lib/plutonium/definition/form_layout.rb +54 -35
- data/lib/plutonium/definition/index_views.rb +70 -1
- data/lib/plutonium/definition/wizards.rb +209 -0
- data/lib/plutonium/invites/concerns/invite_token.rb +9 -0
- data/lib/plutonium/invites/concerns/invite_user.rb +9 -0
- data/lib/plutonium/invites/controller.rb +4 -1
- data/lib/plutonium/kanban/action.rb +7 -0
- data/lib/plutonium/kanban/board.rb +40 -0
- data/lib/plutonium/kanban/broadcaster.rb +54 -0
- data/lib/plutonium/kanban/column.rb +122 -0
- data/lib/plutonium/kanban/context.rb +15 -0
- data/lib/plutonium/kanban/dsl.rb +71 -0
- data/lib/plutonium/kanban/grouping.rb +51 -0
- data/lib/plutonium/kanban/positioning.rb +75 -0
- data/lib/plutonium/kanban.rb +11 -0
- data/lib/plutonium/migrations.rb +40 -0
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/positioning.rb +146 -0
- data/lib/plutonium/railtie.rb +33 -0
- data/lib/plutonium/resource/controller.rb +24 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +9 -1
- data/lib/plutonium/resource/controllers/kanban_actions.rb +851 -0
- data/lib/plutonium/resource/controllers/wizard_actions.rb +165 -0
- data/lib/plutonium/resource/policy.rb +14 -0
- data/lib/plutonium/routing/mapper_extensions.rb +45 -0
- data/lib/plutonium/routing/wizard_registration.rb +289 -0
- data/lib/plutonium/ui/display/components/currency.rb +41 -9
- data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
- data/lib/plutonium/ui/display/resource.rb +17 -12
- data/lib/plutonium/ui/form/base.rb +25 -5
- data/lib/plutonium/ui/form/components/currency.rb +64 -0
- data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
- data/lib/plutonium/ui/form/components/password.rb +126 -0
- data/lib/plutonium/ui/form/components/uppy.rb +26 -5
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +26 -0
- data/lib/plutonium/ui/form/resource.rb +13 -1
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/form/wizard.rb +63 -0
- data/lib/plutonium/ui/grid/card.rb +56 -18
- data/lib/plutonium/ui/kanban/card.rb +67 -0
- data/lib/plutonium/ui/kanban/color_dot.rb +36 -0
- data/lib/plutonium/ui/kanban/column.rb +411 -0
- data/lib/plutonium/ui/kanban/resource.rb +319 -0
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/layout/resource_layout.rb +7 -1
- data/lib/plutonium/ui/modal/base.rb +30 -3
- data/lib/plutonium/ui/modal/centered.rb +5 -2
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +2 -1
- data/lib/plutonium/ui/page/interactive_action.rb +12 -2
- data/lib/plutonium/ui/page/kanban_move.rb +20 -0
- data/lib/plutonium/ui/page/show.rb +29 -1
- data/lib/plutonium/ui/page/wizard.rb +371 -0
- data/lib/plutonium/ui/page/wizard_chooser.rb +97 -0
- data/lib/plutonium/ui/page/wizard_completed.rb +86 -0
- data/lib/plutonium/ui/table/base.rb +1 -1
- data/lib/plutonium/ui/table/components/view_switcher.rb +2 -1
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/review.rb +196 -0
- data/lib/plutonium/ui/wizard/stepper.rb +122 -0
- data/lib/plutonium/ui/wizard/summary_display.rb +92 -0
- data/lib/plutonium/version.rb +1 -1
- data/lib/plutonium/wizard/attachment_data.rb +42 -0
- data/lib/plutonium/wizard/attachments.rb +226 -0
- data/lib/plutonium/wizard/base.rb +216 -0
- data/lib/plutonium/wizard/base_controller.rb +31 -0
- data/lib/plutonium/wizard/configuration.rb +42 -0
- data/lib/plutonium/wizard/controller.rb +162 -0
- data/lib/plutonium/wizard/data.rb +134 -0
- data/lib/plutonium/wizard/driving.rb +639 -0
- data/lib/plutonium/wizard/dsl.rb +336 -0
- data/lib/plutonium/wizard/errors.rb +27 -0
- data/lib/plutonium/wizard/field_capture.rb +157 -0
- data/lib/plutonium/wizard/field_importer.rb +208 -0
- data/lib/plutonium/wizard/gate.rb +171 -0
- data/lib/plutonium/wizard/instance_key.rb +97 -0
- data/lib/plutonium/wizard/lazy_persisted.rb +77 -0
- data/lib/plutonium/wizard/resume.rb +250 -0
- data/lib/plutonium/wizard/review_step.rb +48 -0
- data/lib/plutonium/wizard/route_resolution.rb +40 -0
- data/lib/plutonium/wizard/runner.rb +684 -0
- data/lib/plutonium/wizard/session.rb +53 -0
- data/lib/plutonium/wizard/state.rb +35 -0
- data/lib/plutonium/wizard/step.rb +61 -0
- data/lib/plutonium/wizard/step_adapter.rb +103 -0
- data/lib/plutonium/wizard/store/active_record.rb +174 -0
- data/lib/plutonium/wizard/store/base.rb +42 -0
- data/lib/plutonium/wizard/store/memory.rb +44 -0
- data/lib/plutonium/wizard/sweep_job.rb +76 -0
- data/lib/plutonium/wizard.rb +86 -0
- data/lib/plutonium.rb +5 -0
- data/lib/rodauth/features/case_insensitive_login.rb +1 -1
- data/lib/tasks/release.rake +144 -191
- data/package.json +7 -5
- data/src/css/components.css +137 -0
- data/src/js/controllers/attachment_input_controller.js +15 -1
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/dirty_form_guard_controller.js +155 -27
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +717 -0
- data/src/js/controllers/password_sentinel_controller.js +39 -0
- data/src/js/controllers/register_controllers.js +8 -0
- data/src/js/controllers/remote_modal_controller.js +10 -0
- data/src/js/controllers/row_click_controller.js +14 -1
- data/src/js/controllers/wizard_controller.js +54 -0
- data/src/js/turbo/turbo_confirm.js +1 -1
- data/yarn.lock +945 -286
- metadata +113 -2
|
@@ -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 :
|
|
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
|
|
169
|
-
#
|
|
170
|
-
#
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|