plutonium 0.61.0 → 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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium-kanban/SKILL.md +89 -24
  3. data/CHANGELOG.md +27 -0
  4. data/app/assets/plutonium.css +1 -1
  5. data/app/assets/plutonium.js +315 -38
  6. data/app/assets/plutonium.js.map +4 -4
  7. data/app/assets/plutonium.min.js +31 -31
  8. data/app/assets/plutonium.min.js.map +4 -4
  9. data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
  10. data/app/views/resource/kanban_move_form.html.erb +1 -0
  11. data/config/brakeman.ignore +2 -2
  12. data/docs/.vitepress/config.ts +21 -1
  13. data/docs/.vitepress/sync-skills.mjs +45 -0
  14. data/docs/ai.md +99 -0
  15. data/docs/guides/kanban.md +128 -18
  16. data/docs/reference/kanban/authorization.md +25 -5
  17. data/docs/reference/kanban/dsl.md +49 -8
  18. data/docs/reference/kanban/index.md +3 -3
  19. data/docs/reference/kanban/positioning.md +1 -1
  20. data/docs/reference/resource/definition.md +10 -1
  21. data/docs/reference/resource/model.md +26 -0
  22. data/docs/reference/ui/forms.md +41 -0
  23. data/docs/reference/wizard/dsl.md +5 -0
  24. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md +714 -0
  25. data/docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md.tasks.json +68 -0
  26. data/docs/superpowers/specs/2026-07-03-kanban-auth-simplification.md +159 -0
  27. data/gemfiles/rails_8.1.gemfile.lock +1 -1
  28. data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
  29. data/lib/plutonium/action/base.rb +8 -0
  30. data/lib/plutonium/configuration.rb +12 -0
  31. data/lib/plutonium/definition/index_views.rb +16 -0
  32. data/lib/plutonium/kanban/column.rb +80 -27
  33. data/lib/plutonium/models/has_cents.rb +30 -2
  34. data/lib/plutonium/resource/controller.rb +22 -1
  35. data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
  36. data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
  37. data/lib/plutonium/resource/policy.rb +6 -0
  38. data/lib/plutonium/routing/mapper_extensions.rb +1 -0
  39. data/lib/plutonium/ui/display/components/currency.rb +41 -9
  40. data/lib/plutonium/ui/display/options/inferred_types.rb +2 -5
  41. data/lib/plutonium/ui/form/base.rb +6 -0
  42. data/lib/plutonium/ui/form/components/currency.rb +64 -0
  43. data/lib/plutonium/ui/form/components/intl_tel_input.rb +27 -1
  44. data/lib/plutonium/ui/form/components/uppy.rb +20 -2
  45. data/lib/plutonium/ui/form/kanban_move.rb +46 -0
  46. data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
  47. data/lib/plutonium/ui/form/resource.rb +12 -0
  48. data/lib/plutonium/ui/form/theme.rb +7 -0
  49. data/lib/plutonium/ui/grid/card.rb +40 -13
  50. data/lib/plutonium/ui/kanban/column.rb +111 -24
  51. data/lib/plutonium/ui/kanban/resource.rb +118 -11
  52. data/lib/plutonium/ui/layout/base.rb +1 -1
  53. data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
  54. data/lib/plutonium/ui/page/index.rb +1 -1
  55. data/lib/plutonium/ui/page/interactive_action.rb +12 -2
  56. data/lib/plutonium/ui/page/kanban_move.rb +20 -0
  57. data/lib/plutonium/ui/page/show.rb +7 -2
  58. data/lib/plutonium/ui/table/resource.rb +1 -1
  59. data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
  60. data/lib/plutonium/version.rb +1 -1
  61. data/package.json +5 -3
  62. data/src/css/components.css +5 -0
  63. data/src/js/controllers/currency_input_controller.js +39 -0
  64. data/src/js/controllers/intl_tel_input_controller.js +4 -0
  65. data/src/js/controllers/kanban_controller.js +442 -55
  66. data/src/js/controllers/register_controllers.js +2 -0
  67. data/yarn.lock +674 -4
  68. metadata +14 -2
@@ -10,6 +10,12 @@ module Plutonium
10
10
  authorize :entity_scope, allow_nil: true
11
11
  authorize :parent, optional: true
12
12
  authorize :parent_association, optional: true
13
+ # Supplied only during a kanban drag-move (kanban_move? authorization): the
14
+ # source and destination Plutonium::Kanban::Column. Optional — nil for every
15
+ # other authorization. Lets a policy gate a specific transition, e.g.
16
+ # def kanban_move? = kanban_to&.key == :closed_won ? user.admin? : super
17
+ authorize :kanban_from, optional: true
18
+ authorize :kanban_to, optional: true
13
19
 
14
20
  relation_scope do |relation|
15
21
  default_relation_scope(relation)
@@ -151,6 +151,7 @@ module Plutonium
151
151
  as: :interactive_record_action
152
152
  post "record_actions/:interactive_action", action: :commit_interactive_record_action,
153
153
  as: :commit_interactive_record_action
154
+ get "kanban_move_form", action: :kanban_move_form, as: :kanban_move_form
154
155
  post "kanban_move", action: :kanban_move, as: :kanban_move
155
156
  end
156
157
  end
@@ -6,16 +6,52 @@ module Plutonium
6
6
  module UI
7
7
  module Display
8
8
  module Components
9
- # Renders a numeric value as currency (delimited, 2 decimals). No symbol
10
- # by default; pass a literal `unit:` ("£") or a Symbol read off the
11
- # record (`unit: :currency_symbol`) for per-row currencies.
9
+ # Renders a numeric value as currency (delimited, 2 decimals). The symbol
10
+ # is resolved by {resolve_unit}: an explicit `unit:` (a literal "£", a
11
+ # Symbol read off the record for per-row currencies, or `false` for no
12
+ # symbol) → the record's `has_cents` unit → `default_currency_unit` /
13
+ # the i18n `number.currency.format.unit` ($ in en).
12
14
  #
13
- # display :price, as: :currency
15
+ # display :price, as: :currency # → configured/i18n default unit
14
16
  # display :price, as: :currency, unit: "£"
15
17
  # display :price, as: :currency, unit: :currency_symbol
18
+ # display :price, as: :currency, unit: false # no symbol
16
19
  class Currency < Phlexi::Display::Components::Base
17
20
  include Phlexi::Display::Components::Concerns::DisplaysValue
18
21
 
22
+ # Resolves the currency unit string for a value, shared by this
23
+ # component and the grid/kanban {Grid::Card} so both format currency
24
+ # identically. Precedence, where `nil` means "not set, keep looking"
25
+ # and `false` means "explicitly no symbol, stop":
26
+ # explicit unit → record's has_cents unit → configured/i18n default.
27
+ #
28
+ # @param explicit [String, Symbol, false, nil] a per-display `unit:`.
29
+ # @param record [Object] the record being rendered.
30
+ # @param key [Symbol] the attribute name.
31
+ # @return [String] the unit to pass to number_to_currency ("" for none).
32
+ def self.resolve_unit(explicit, record, key)
33
+ unit = explicit
34
+ unit = record.has_cents_unit_for(key) if unit.nil? && record.respond_to?(:has_cents_unit_for)
35
+ unit = default_unit if unit.nil?
36
+
37
+ case unit
38
+ when nil, false then ""
39
+ when Symbol then record.public_send(unit).to_s
40
+ else unit.to_s
41
+ end
42
+ end
43
+
44
+ # The unit used when nothing more specific is configured. Returns the
45
+ # `default_currency_unit` config verbatim when set (including `false`
46
+ # to disable the symbol); otherwise the i18n `number.currency.format.unit`
47
+ # *if the locale defines it*, else no symbol. We don't hardcode a "$".
48
+ def self.default_unit
49
+ config = Plutonium.configuration.default_currency_unit
50
+ return config unless config.nil?
51
+
52
+ I18n.t("number.currency.format.unit", default: "")
53
+ end
54
+
19
55
  def render_value(value)
20
56
  p(**attributes) { format_currency(value) }
21
57
  end
@@ -35,11 +71,7 @@ module Plutonium
35
71
  end
36
72
 
37
73
  def resolved_unit
38
- case @unit
39
- when nil then ""
40
- when Symbol then field.object.public_send(@unit)
41
- else @unit
42
- end
74
+ self.class.resolve_unit(@unit, field.object, field.key)
43
75
  end
44
76
 
45
77
  def normalize_value(value) = value
@@ -5,6 +5,8 @@ module Plutonium
5
5
  module Display
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
@@ -23,11 +25,6 @@ module Plutonium
23
25
  super
24
26
  end
25
27
  end
26
-
27
- def has_cents_field?
28
- klass = object.class
29
- klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(key)
30
- end
31
28
  end
32
29
  end
33
30
  end
@@ -63,6 +63,12 @@ module Plutonium
63
63
  create_component(Components::Flatpickr, :flatpickr, **, &)
64
64
  end
65
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
+
66
72
  def int_tel_input_tag(**, &)
67
73
  create_component(Components::IntlTelInput, :int_tel_input, **, &)
68
74
  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
@@ -80,8 +80,13 @@ module Plutonium
80
80
  },
81
81
  title: attachment.filename.to_s
82
82
  ) do
83
- # Hidden field to preserve the uploaded file
84
- 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
85
90
 
86
91
  render_preview_content(attachment)
87
92
  render_filename(attachment)
@@ -89,6 +94,19 @@ module Plutonium
89
94
  end
90
95
  end
91
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
+
92
110
  def render_preview_content(attachment)
93
111
  a(
94
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,6 +5,8 @@ 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
@@ -16,6 +18,10 @@ module Plutonium
16
18
  # `*_key`, `salt`, ...) — see #secret_field_name?.
17
19
  return :password if inferred_string_field_type == :password || secret_field_name?
18
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
+
19
25
  case inferred_field_type
20
26
  when :rich_text
21
27
  return :markdown
@@ -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,
@@ -127,14 +127,13 @@ module Plutonium
127
127
  end
128
128
 
129
129
  def render_meta_slot
130
- fields = Array(slots[:meta])
131
- values = fields.map { |f| field_value(f) }.reject(&:blank?)
130
+ pairs = Array(slots[:meta]).map { |name| [name, field_value(name)] }.reject { |_, value| value.blank? }
132
131
 
133
132
  div(class: "flex flex-wrap items-center gap-1.5 mt-1") do
134
- if values.empty?
133
+ if pairs.empty?
135
134
  render_blank_placeholder
136
135
  else
137
- values.each { |v| render_meta_badge(v) }
136
+ pairs.each { |name, value| render_meta_badge(name, value) }
138
137
  end
139
138
  end
140
139
  end
@@ -158,21 +157,38 @@ module Plutonium
158
157
  # rendered by the timeago Stimulus controller.
159
158
  raw safe(helpers.display_datetime_value(value))
160
159
  elsif currency_field?(name)
161
- plain helpers.number_to_currency(value, unit: "")
160
+ plain helpers.number_to_currency(value, unit: currency_unit_for(name))
162
161
  else
163
162
  plain helpers.display_name_of(value)
164
163
  end
165
164
  end
166
165
 
167
- # Renders a meta value as a colored pill, borrowing the Badge
168
- # display component's semantic color + humanize logic. Status-like
169
- # values (published, pending, failed…) get meaningful colors;
170
- # free-form values get a deterministic decorative color.
171
- def render_meta_badge(value)
166
+ # Renders a meta value as a colored pill, borrowing the Badge display
167
+ # component's semantic color + humanize logic. Non-string types are
168
+ # formatted by type first so they don't badge their raw value:
169
+ # - has_cents columns currency (matches render_formatted_value)
170
+ # - associations → display_name_of (label, not an object inspect)
171
+ # - everything else → humanized, with the RAW value driving the
172
+ # variant so status-like enums (in_progress, published…) still
173
+ # resolve to a semantic color.
174
+ # The variant hashes the stable formatted label for currency/associations,
175
+ # so the decorative color no longer churns on an object's memory address.
176
+ def render_meta_badge(name, value)
172
177
  badge = Plutonium::UI::Display::Components::Badge
173
- variant = badge.variant_for(value)
178
+
179
+ if currency_field?(name)
180
+ label = helpers.number_to_currency(value, unit: currency_unit_for(name))
181
+ variant = badge.variant_for(label)
182
+ elsif association_field?(name)
183
+ label = helpers.display_name_of(value)
184
+ variant = badge.variant_for(label)
185
+ else
186
+ label = badge.humanize(value)
187
+ variant = badge.variant_for(value)
188
+ end
189
+
174
190
  span(class: tokens("pu-badge", "pu-badge-#{variant}")) do
175
- plain badge.humanize(value)
191
+ plain label
176
192
  end
177
193
  end
178
194
 
@@ -181,6 +197,17 @@ module Plutonium
181
197
  klass.respond_to?(:has_cents_decimal_attribute?) && klass.has_cents_decimal_attribute?(name.to_sym)
182
198
  end
183
199
 
200
+ # Delegates to the shared resolver so cards format currency identically to
201
+ # the Currency display component — has_cents unit → configured/i18n default,
202
+ # with `false` meaning no symbol. Cards have no per-display unit (nil).
203
+ def currency_unit_for(name)
204
+ Plutonium::UI::Display::Components::Currency.resolve_unit(nil, record, name)
205
+ end
206
+
207
+ def association_field?(name)
208
+ record.class.reflect_on_association(name.to_sym).present?
209
+ end
210
+
184
211
  # A declared slot with no value renders a muted em-dash rather than
185
212
  # collapsing, so cards in a grid keep an even height instead of
186
213
  # ragged rows when some records lack the field.
@@ -243,7 +270,7 @@ module Plutonium
243
270
 
244
271
  def row_actions
245
272
  @row_actions ||= resource_definition.defined_actions.values.select { |a|
246
- a.collection_record_action? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
273
+ a.collection_record_action? && !a.kanban_drop? && a.permitted_by?(record_policy) && a.condition_met?(view_context, record:)
247
274
  }
248
275
  end
249
276