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
@@ -0,0 +1,68 @@
1
+ {
2
+ "planPath": "docs/superpowers/plans/2026-07-02-kanban-drop-interactions.md",
3
+ "tasks": [
4
+ {
5
+ "id": 0,
6
+ "subject": "Task 0: Column#drop_interaction + derived action key",
7
+ "status": "completed",
8
+ "description": "Add drop_interaction: option to Plutonium::Kanban::Column; expose reader, drop_interaction? predicate, drop_interaction_key derivation (MarkLostInteraction -> :mark_lost); raise ArgumentError on non-interaction. Verify dsl.rb passthrough.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/kanban/column.rb\", \"test/plutonium/kanban/column_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/column_test.rb\", \"requiresUserVerification\": false}\n```"
9
+ },
10
+ {
11
+ "id": 1,
12
+ "subject": "Task 1: Register drop_interaction as hidden record action",
13
+ "status": "completed",
14
+ "blockedBy": [0],
15
+ "description": "At kanban compile time register each column's drop_interaction as an interactive record action flagged kanban_drop: true; exclude kanban_drop? actions from toolbars.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/definition/index_views.rb\", \"lib/plutonium/action/base.rb\", \"lib/plutonium/action/interactive/factory.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/index_views_test.rb\", \"requiresUserVerification\": false}\n```"
16
+ },
17
+ {
18
+ "id": 2,
19
+ "subject": "Task 2: Add GET kanban_move_form member route",
20
+ "status": "completed",
21
+ "blockedBy": [1],
22
+ "description": "Add GET <member>/kanban_move_form -> kanban_actions#kanban_move_form, named. POST kanban_move unchanged.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/routing/mapper_extensions.rb\"], \"verifyCommand\": \"cd test/dummy && bin/rails routes | grep kanban_move\", \"requiresUserVerification\": false}\n```"
23
+ },
24
+ {
25
+ "id": 3,
26
+ "subject": "Task 3: kanban_move_form GET renders interaction modal",
27
+ "status": "completed",
28
+ "blockedBy": [2],
29
+ "description": "Build drop interaction as record action; render its form in remote modal; form action=kanban_move with hidden from/to_column, to_index; authorize transition; 422 when no drop_interaction.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/resource/controllers/kanban_actions.rb\", \"app/views/plutonium/resource/kanban_move_form.html.erb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/integration\", \"requiresUserVerification\": false}\n```"
30
+ },
31
+ {
32
+ "id": 4,
33
+ "subject": "Task 4: kanban_move POST — atomic interaction + on_drop + reposition",
34
+ "status": "completed",
35
+ "blockedBy": [2, 3],
36
+ "description": "Single transaction: authorize transition -> on_drop (membership) -> interaction.call (extras) -> reposition. Failure -> Rollback + 422 modal. Success -> column streams. Guards before interaction; plain columns + seed path unchanged.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/resource/controllers/kanban_actions.rb\", \"test/integration\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/integration\", \"requiresUserVerification\": false}\n```"
37
+ },
38
+ {
39
+ "id": 5,
40
+ "subject": "Task 5: Emit data-kanban-drop-* on drop-interaction columns",
41
+ "status": "completed",
42
+ "blockedBy": [4],
43
+ "description": "Column component renders data-kanban-drop-interaction=true + data-kanban-drop-form-url-template with __ID__; plain columns render neither.\n\n```json:metadata\n{\"files\": [\"lib/plutonium/ui/kanban/column.rb\", \"lib/plutonium/resource/controllers/kanban_actions.rb\", \"test/integration/admin_portal/kanban_dom_contract_test.rb\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/integration/admin_portal/kanban_dom_contract_test.rb\", \"requiresUserVerification\": false}\n```"
44
+ },
45
+ {
46
+ "id": 6,
47
+ "subject": "Task 6: Stimulus — open modal on drop (no snap-back; card never moves in DOM)",
48
+ "status": "completed",
49
+ "blockedBy": [5],
50
+ "description": "On drop into drop-interaction column navigate remote-modal frame to kanban_move_form with move params; hold pending; snap back on dismiss without success; plain columns keep direct POST. yarn build.\n\n```json:metadata\n{\"files\": [\"src/js/controllers/kanban_controller.js\", \"app/assets/plutonium.js\"], \"verifyCommand\": \"yarn build\", \"requiresUserVerification\": false}\n```"
51
+ },
52
+ {
53
+ "id": 7,
54
+ "subject": "Task 7: Document drop_interaction (guide + DSL ref + skill)",
55
+ "status": "completed",
56
+ "blockedBy": [6],
57
+ "description": "Guide section w/ worked example + MarkLostInteraction; two-flow model + author contract + def mark_lost? gate; DSL table row; skill Column-options + Authorization updates; yarn docs:build.\n\n```json:metadata\n{\"files\": [\"docs/guides/kanban.md\", \"docs/reference/kanban/dsl.md\", \".claude/skills/plutonium-kanban/SKILL.md\"], \"verifyCommand\": \"yarn docs:build\", \"requiresUserVerification\": false}\n```"
58
+ },
59
+ {
60
+ "id": 8,
61
+ "subject": "Task 8: E2E dummy drive + system test",
62
+ "status": "completed",
63
+ "blockedBy": [7, 6],
64
+ "description": "Dummy resource/interaction via generators + migration for lost_reason (inline index); system test covers commit-with-reason, blank-reason error, cancel snap-back; def mark_lost? on policy; manual browser drive.\n\n```json:metadata\n{\"files\": [\"test/system\", \"test/dummy\"], \"verifyCommand\": \"bundle exec appraisal rails-8.1 ruby -Itest test/system\", \"requiresUserVerification\": false}\n```"
65
+ }
66
+ ],
67
+ "lastUpdated": "2026-07-02"
68
+ }
@@ -0,0 +1,159 @@
1
+ # Kanban authorization & `accepts:` simplification
2
+
3
+ **Status:** implemented (full suite green — 0 failures; 12 pre-existing `model_name` errors unrelated)
4
+ **Branch:** `feat/kanban-drop-interactions` (PR #67)
5
+ **Supersedes:** the earlier `enter_interaction_key` collision fix idea (never shipped)
6
+
7
+ ## Why
8
+
9
+ The thread started from a real bug in how `enter_interaction`'s policy key is derived:
10
+
11
+ ```ruby
12
+ # Plutonium::Kanban::Column#enter_interaction_key (today)
13
+ @enter_interaction.name.demodulize.sub(/Interaction\z/, "").underscore.to_sym
14
+ # BlockTaskInteraction → :block_task
15
+ ```
16
+
17
+ That key is used **both** as the internal action-registration key **and** as the
18
+ authorization method name (`block_task?`). Because it's derived only from the
19
+ class basename, it isn't unique:
20
+
21
+ - **Same class on two columns** → same key → shared policy method, no per-column auth.
22
+ - **Different classes, same demodulized name** (`Leads::CloseInteraction` vs
23
+ `Deals::CloseInteraction` → `:close`) → last-writer-wins registration → one
24
+ column silently runs the other column's interaction. Genuine, silent bug.
25
+
26
+ Chasing a fix surfaced a deeper inconsistency: **per-column authorization only
27
+ exists as a side effect of declaring an interaction.** A plain column can't say
28
+ "only managers may drop here" without smuggling `current_user` into an `accepts:`
29
+ Proc — authorization in the wrong layer.
30
+
31
+ ## Decisions
32
+
33
+ ### 1. One authorization method: `kanban_move?`
34
+
35
+ Collapse all move authorization to a single `kanban_move?`. No `enter_<column>?`
36
+ convention, no per-interaction derived policy method.
37
+
38
+ - Default stays argument-less and delegates to `update?` (unchanged for existing boards).
39
+ - Advanced boards branch on `from`/`to`, supplied via **authorization context**
40
+ (ActionPolicy rules take no positional args — context is the sanctioned channel).
41
+
42
+ ```ruby
43
+ # controller (kanban_move)
44
+ authorize_current! record, to: :kanban_move?,
45
+ context: { kanban_from: from, kanban_to: to }
46
+
47
+ # policy — opt in only when you need it
48
+ def kanban_move?
49
+ return super unless authorization_context[:kanban_to]&.key == :closed_won
50
+ user.admin?
51
+ end
52
+ ```
53
+
54
+ Consequence: **`enter_interaction` stops needing a policy method entirely.** It's
55
+ authorized by `kanban_move?` like every other move. The class-derived key is
56
+ demoted to an internal form/param routing key only.
57
+
58
+ ### 2. `enter_interaction` registration key becomes internal + column-scoped
59
+
60
+ Since the key is no longer an authorization name, uniqueness only has to hold for
61
+ internal action routing. Scope it to the column so it's collision-free by
62
+ construction and never surfaces to the author:
63
+
64
+ ```ruby
65
+ def enter_interaction_key
66
+ return nil unless @enter_interaction
67
+ :"#{key}_enter_interaction" # :blocked → :blocked_enter_interaction
68
+ end
69
+ ```
70
+
71
+ The registered hidden action's policy check delegates to `kanban_move?` (it does
72
+ NOT get its own author-facing policy method).
73
+
74
+ ### 3. Strip the Proc form of `accepts:`
75
+
76
+ `accepts:` keeps only its structural, client-hintable forms:
77
+
78
+ - `true` / `false` / `Array` of source keys → workflow topology + drag-time drop
79
+ hints (the browser reads `data-kanban-accepts`; a policy can't run client-side).
80
+
81
+ The **Proc form is removed outright — no deprecation bridge.** It's server-only
82
+ (gives no client hint), record-based, and overlaps with `kanban_move?`.
83
+ Record-conditional rejection moves to `kanban_move?`, which now sees both the
84
+ record and `to` via context.
85
+
86
+ The constructor **raises** on a `Proc` accepts value (`ArgumentError`), in every
87
+ env. "Let it break" means fail loud — NOT silently fall through `accepts?`'s
88
+ `else` branch to `accepts: true`, which would quietly *open up* a column that used
89
+ to restrict drops. No `on_drop`-style env-gated bridge; a plain raise.
90
+
91
+ Delete `Column#accepts_record?` (the per-card evaluator) — with no Proc form the
92
+ move handler just calls `accepts?(source_key)`.
93
+
94
+ ### 4. No exit authorization method
95
+
96
+ Considered `exit_<column>?` for symmetry with `on_exit`. Rejected as
97
+ over-built: exit-gating is an order of magnitude rarer than entry-gating, and it
98
+ doubles the auth surface plus makes a denial ambiguous (from vs to). The
99
+ structural "never lets go" case stays as `locked:`; per-user exit rules are
100
+ expressible in `kanban_move?` via the `from` context when actually needed.
101
+
102
+ ## Net model
103
+
104
+ | Layer | Mechanism |
105
+ |---|---|
106
+ | Structure (definition, client-hintable) | `accepts:` (true/false/Array), `locked:`, `wip:` |
107
+ | Authorization (policy) | **`kanban_move?`** only — argument-less default → `update?`; reads `from`/`to` from context |
108
+ | Behavior | `on_enter` / `on_exit`; `enter_interaction` (input collection, authorized by `kanban_move?`) |
109
+
110
+ Deletes: `accepts:` Proc + `accepts_record?`, the `enter_<column>?` idea, the
111
+ class-derived interaction policy method. Adds: `from`/`to` in the move's auth
112
+ context. Simpler on every axis.
113
+
114
+ ## Migration (in-repo)
115
+
116
+ **`lib/plutonium/kanban/column.rb`**
117
+ - `enter_interaction_key` → column-scoped (decision 2).
118
+ - `accepts:` Proc → deprecate (raise local / warn+permissive deployed), drop `accepts_record?`.
119
+
120
+ **`lib/plutonium/resource/controllers/kanban_actions.rb`**
121
+ - Pass `context: { kanban_from: from, kanban_to: to }` to the `kanban_move?` authorize.
122
+ - Replace `accepts_record?(record, from.key)` with `accepts?(from.key)`.
123
+ - Registered enter_interaction action authorizes via `kanban_move?`, not a derived method.
124
+
125
+ **`lib/plutonium/definition/index_views.rb`**
126
+ - Register the enter_interaction under the column-scoped key.
127
+
128
+ **Dummy (`test/dummy/app/…`)**
129
+ - `task_definition.rb`: `:done` `accepts: ->(t){ t.status=="doing" }` → `accepts: [:doing]`.
130
+ - `task_policy.rb`: delete `mark_lost?`, `block_task?`, `archive_task?` and their
131
+ `deny_*` toggles; fold the "denied" test path into `kanban_move?` (context-aware) or
132
+ a `deny_kanban_move` toggle.
133
+
134
+ **Tests**
135
+ - `column_test.rb`: `enter_interaction_key` now `:lost_enter_interaction`; add an
136
+ `accepts:` Proc deprecation test (raise-in-test + warn-map-in-prod) mirroring `on_drop`.
137
+ - `kanban_drop_interaction_test.rb`: the denied-drop cases assert via `kanban_move?`
138
+ (no more per-interaction policy method).
139
+ - `kanban_move_test.rb` / dom-contract: drop `accepts:` Proc assertions; assert
140
+ `[:doing]` topology + `data-kanban-accepts` hint instead.
141
+
142
+ **Docs / skill**
143
+ - `docs/reference/kanban/{dsl,authorization}.md`, `docs/guides/kanban.md`,
144
+ `.claude/skills/plutonium-kanban/SKILL.md`: remove `accepts:` Proc; document
145
+ `from`/`to` context on `kanban_move?`; state enter_interaction has no policy method.
146
+
147
+ ## Open questions
148
+
149
+ 1. **Context key names — RESOLVED: declared optional targets.** ActionPolicy
150
+ *strips* undeclared context keys (verified: a per-call `context: {kanban_to:}`
151
+ comes back nil unless declared). So `authorize :kanban_from, optional: true` /
152
+ `authorize :kanban_to, optional: true` on the base `Resource::Policy` — same
153
+ shape as `parent` (nil for every non-move authorization), with clean readers
154
+ (`kanban_to&.key`). Pure per-call context is not possible.
155
+ 2. **`accepts: false` vs `locked:`** — both now purely structural. Keep both
156
+ (entry-refuse vs exit-refuse), they're not redundant.
157
+ 3. **`accepts:` Proc removal — DECIDED: hard-remove, raise, no bridge.** Delete the
158
+ Proc branch + `accepts_record?`; constructor raises `ArgumentError` on a `Proc`
159
+ value so broken definitions fail loud rather than silently going permissive.
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: ..
3
3
  specs:
4
- plutonium (0.60.4)
4
+ plutonium (0.61.0)
5
5
  action_policy (~> 0.7.0)
6
6
  csv
7
7
  listen (~> 3.8)
@@ -18,6 +18,11 @@ module Pu
18
18
 
19
19
  def start
20
20
  bundle "active_shrine"
21
+ # The generated shrine.rb determine_mime_type analyzer refines
22
+ # marcel's `text/plain` (e.g. CSV/TXT uploads) via Shrine's :mime_types
23
+ # analyzer, which does `require "mime/types"` — bundle it so the first
24
+ # text/plain upload doesn't 500 with `cannot load such file -- mime/types`.
25
+ bundle "mime-types"
21
26
  bundle "aws-sdk-s3" if options[:s3]
22
27
  bundle "fastimage" if options[:store_dimensions]
23
28
 
@@ -23,6 +23,7 @@ module Plutonium
23
23
  @collection_record_action = options[:collection_record_action] || false
24
24
  @record_action = options[:record_action] || false
25
25
  @resource_action = options[:resource_action] || false
26
+ @kanban_drop = options[:kanban_drop] || false
26
27
  @category = ActiveSupport::StringInquirer.new((options[:category] || :secondary).to_s)
27
28
  @position = options[:position] || 50
28
29
  @modal_mode = options[:modal]
@@ -68,6 +69,12 @@ module Plutonium
68
69
  def record_action? = @record_action
69
70
  def resource_action? = @resource_action
70
71
 
72
+ # True when this action was auto-registered for a kanban column's
73
+ # `enter_interaction`. Such actions exist only so their policy method,
74
+ # form, and params machinery are wired up — they are reachable by
75
+ # dropping a card, never rendered as a normal toolbar/row button.
76
+ def kanban_drop? = @kanban_drop
77
+
71
78
  def permitted_by?(policy)
72
79
  policy.allowed_to?(:"#{name}?")
73
80
  end
@@ -112,6 +119,7 @@ module Plutonium
112
119
  collection_record_action: @collection_record_action,
113
120
  record_action: @record_action,
114
121
  resource_action: @resource_action,
122
+ kanban_drop: @kanban_drop,
115
123
  category: @category.to_sym,
116
124
  position: @position,
117
125
  modal: @modal_mode,
@@ -40,6 +40,18 @@ module Plutonium
40
40
  # or proxy the service.
41
41
  attr_accessor :navii_host_url
42
42
 
43
+ # @return [String, false, nil] the currency unit (symbol) used when rendering
44
+ # a currency value that has no unit configured on `has_cents` or the display.
45
+ # `nil` (default) falls back to the i18n `number.currency.format.unit` when
46
+ # the locale defines it, otherwise no symbol. Set a literal like `"£"` to
47
+ # change the default, or `false` (or `""`) for no symbol application-wide.
48
+ attr_accessor :default_currency_unit
49
+
50
+ # @return [String, nil] the default country (ISO2 code, e.g. "gh") for phone
51
+ # (`as: :phone`) inputs that don't set their own `initial_country:`. `nil`
52
+ # (default) leaves it to the intl-tel-input library (no country preselected).
53
+ attr_accessor :default_phone_country
54
+
43
55
  # Map of version numbers to their default configurations
44
56
  VERSION_DEFAULTS = {
45
57
  1.0 => proc do |config|
@@ -132,6 +132,22 @@ module Plutonium
132
132
  confirmation: col_action.confirmation
133
133
  )
134
134
  end
135
+
136
+ # Register each column's enter_interaction as an interactive record
137
+ # action too, so its form and params extraction exist and route the
138
+ # standard way. It is flagged `kanban_drop: true` so it is excluded
139
+ # from the normal show/row/index toolbars — it is reachable only by
140
+ # dropping a card. The key is column-scoped (enter_interaction_key →
141
+ # :<column>_enter_interaction), so it is unique by construction and
142
+ # never collides with another column's interaction. It carries NO
143
+ # policy method of its own — the drop is authorized by kanban_move?.
144
+ if col.enter_interaction?
145
+ action(
146
+ col.enter_interaction_key,
147
+ interaction: col.enter_interaction,
148
+ kanban_drop: true
149
+ )
150
+ end
135
151
  end
136
152
  end
137
153
  end
@@ -5,27 +5,80 @@ module Plutonium
5
5
  class Column
6
6
  ROLE_PRESETS = {
7
7
  backlog: {add: true},
8
- done: {color: :green, collapsed: true}
8
+ # Terminal columns: collapsed by default, colour signals the outcome.
9
+ # :done is the positive close (green); :lost is the negative close
10
+ # (red) — the natural pair for won/lost pipelines (leads, deals, tickets).
11
+ done: {color: :green, collapsed: true},
12
+ lost: {color: :red, collapsed: true}
9
13
  }.freeze
10
14
 
11
- attr_reader :key, :label, :color, :wip, :scope, :on_drop, :accepts, :actions
15
+ attr_reader :key, :label, :color, :wip, :scope, :on_enter, :on_exit, :accepts, :actions, :enter_interaction
16
+
17
+ def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_enter: nil, on_exit: nil, on_drop: nil,
18
+ collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil, enter_interaction: nil, drop_interaction: nil)
19
+ # on_drop:/drop_interaction: were renamed to on_enter:/enter_interaction:.
20
+ # Resolve the deprecated aliases first (dev/test raise; deployed envs warn
21
+ # and map — see resolve_renamed_option) so the rest of initialize only
22
+ # ever sees the new names.
23
+ on_enter = resolve_renamed_option(:on_drop, on_drop, :on_enter, on_enter)
24
+ enter_interaction = resolve_renamed_option(:drop_interaction, drop_interaction, :enter_interaction, enter_interaction)
12
25
 
13
- def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_drop: nil,
14
- collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil)
15
26
  preset = role ? ROLE_PRESETS.fetch(role) { raise ArgumentError, "Unknown column role: #{role.inspect}. Valid: #{ROLE_PRESETS.keys.inspect}" } : {}
27
+ if enter_interaction && !(enter_interaction.is_a?(Class) && enter_interaction < Plutonium::Resource::Interaction)
28
+ raise ArgumentError, "enter_interaction: must be a Plutonium::Resource::Interaction subclass, got #{enter_interaction.inspect}"
29
+ end
30
+ # An enter_interaction acts on the SINGLE dropped card — the move handler
31
+ # binds it as `resource:`. It must therefore be record-shaped (declare a
32
+ # `resource` attribute), never collection/bulk-shaped (`resources`). Reject
33
+ # anything else at definition time so a mis-shaped interaction can't (a) blow
34
+ # up at drop time on the `resource=` assignment, or (b) get auto-classified
35
+ # by Action::Interactive::Factory as a bulk action and leak into the
36
+ # bulk-actions bar (which does not filter kanban_drop actions).
37
+ if enter_interaction && !enter_interaction.attribute_names.map(&:to_sym).include?(:resource)
38
+ raise ArgumentError, "enter_interaction: #{enter_interaction} must operate on a single record (declare `attribute :resource`); collection/bulk interactions cannot be used as an enter_interaction."
39
+ end
40
+ # accepts: is purely structural (topology + client drop hints): true/false
41
+ # or an Array of source keys. The Proc form was removed — record/user
42
+ # conditions belong in the kanban_move? policy, which sees the record and
43
+ # the from/to columns. Fail loud rather than silently treating a stale Proc
44
+ # as permissive (which would OPEN UP a column that used to restrict drops).
45
+ if accepts.is_a?(Proc)
46
+ raise ArgumentError, "kanban column `accepts:` no longer accepts a Proc; use true/false or an Array of source keys, and put record/user conditions in the kanban_move? policy."
47
+ end
16
48
  @key = key.to_sym
17
49
  @label = label || key.to_s.titleize
18
50
  @color = color.nil? ? preset[:color] : color
19
51
  @wip = wip
20
52
  @scope = scope
21
- @on_drop = on_drop
53
+ @on_enter = on_enter
54
+ @on_exit = on_exit
22
55
  @collapsed = collapsed.nil? ? preset[:collapsed] : collapsed
23
56
  @add = add.nil? ? preset[:add] : add
24
57
  @accepts = accepts.nil? || accepts
25
58
  @locked = locked || false
59
+ @enter_interaction = enter_interaction
26
60
  @actions = []
27
61
  end
28
62
 
63
+ # A column may run an input-collecting Interaction when a card ENTERS it
64
+ # (e.g. "mark lead as lost with a reason"). When set, the drop opens the
65
+ # interaction's form as a modal before the move is committed.
66
+ def enter_interaction? = !!@enter_interaction
67
+
68
+ # Internal action-registration key for the enter interaction, scoped to the
69
+ # column: :blocked → :blocked_enter_interaction. Nil when unset.
70
+ #
71
+ # Column-scoped (not class-name-derived) so it is unique by construction — a
72
+ # column has at most one enter_interaction, so two columns can never collide
73
+ # even if they reuse the same interaction class. This key is ONLY an internal
74
+ # form/param routing handle; it is NOT an authorization name. The move (and
75
+ # therefore the interaction) is authorized solely by kanban_move? — the
76
+ # interaction has no policy method of its own.
77
+ def enter_interaction_key
78
+ return nil unless @enter_interaction
79
+ :"#{key}_enter_interaction"
80
+ end
81
+
29
82
  def action(key, interaction:, on: :all, label: nil, icon: nil, confirmation: nil)
30
83
  @actions << Action.new(key: key.to_sym, interaction:, on:, label:, icon:, confirmation:)
31
84
  end
@@ -34,35 +87,35 @@ module Plutonium
34
87
  def add? = !!@add
35
88
  def locked? = @locked
36
89
 
37
- # Column-level accepts check used for client-side drop hints and as the
38
- # first gate in the move handler (before the record is needed).
39
- # Proc accepts: is treated as permissive at the column level; call
40
- # accepts_record? with the actual record to evaluate the predicate.
90
+ # Whether a card from `source_key` may be dropped into this column. Purely
91
+ # structural @accepts is normalized to true/false or an Array of source
92
+ # keys (the constructor rejects a Proc). Drives both the server-side gate in
93
+ # the move handler and the client-side drop hint (data-kanban-accepts).
41
94
  def accepts?(source_key)
42
95
  case @accepts
43
96
  when Array then @accepts.include?(source_key)
44
- when true, false then @accepts
45
- # Proc/predicate case: permit at the column level here; the move handler
46
- # evaluates the predicate per-card via accepts_record? with the actual record.
47
- else true
97
+ else @accepts # true or false
48
98
  end
49
99
  end
50
100
 
51
- # Per-card accepts check — evaluates a Proc accepts: against the actual
52
- # record. Called by the move handler after the record is loaded.
53
- #
54
- # Convention for Proc accepts:
55
- # accepts: ->(card) { } # receives the record, returns true/false
56
- #
57
- # For non-Proc values the behaviour matches accepts?(source_key) exactly,
58
- # so the move handler can unconditionally switch to accepts_record?.
59
- def accepts_record?(record, source_key)
60
- case @accepts
61
- when Array then @accepts.include?(source_key)
62
- when true, false then @accepts
63
- when Proc then @accepts.call(record)
64
- else true
101
+ private
102
+
103
+ # Bridge a renamed column option. `on_drop:`/`drop_interaction:` became
104
+ # `on_enter:`/`enter_interaction:`. To avoid breaking live deployments on
105
+ # upgrade we DON'T hard-fail in deployed envs we log a deprecation and map
106
+ # the old value onto the new name. But local envs (development/test) raise,
107
+ # so the rename is caught during development and can't silently ship. If
108
+ # both the old and new names are given, the new one wins.
109
+ def resolve_renamed_option(old_name, old_value, new_name, new_value)
110
+ return new_value if old_value.nil?
111
+
112
+ if Rails.env.local?
113
+ raise ArgumentError,
114
+ "kanban column `#{old_name}:` has been renamed to `#{new_name}:` — update your definition."
65
115
  end
116
+
117
+ Rails.logger.warn { "[plutonium] kanban column `#{old_name}:` is deprecated; rename it to `#{new_name}:`." }
118
+ new_value.nil? ? old_value : new_value
66
119
  end
67
120
  end
68
121
  end
@@ -76,6 +76,24 @@ module Plutonium
76
76
  class_attribute :has_cents_attributes, instance_writer: false, default: {}
77
77
  end
78
78
 
79
+ # Resolves the currency unit (symbol) configured for a has_cents decimal
80
+ # accessor, e.g. :price for `has_cents :price_cents, unit: "£"`.
81
+ #
82
+ # @param decimal_attribute [Symbol, String] The decimal accessor name.
83
+ # @return [String, nil] The unit — a String verbatim, the value of the
84
+ # method named by a Symbol unit (per-row currencies), or nil when the
85
+ # attribute has no unit configured / is not a has_cents accessor.
86
+ def has_cents_unit_for(decimal_attribute)
87
+ decimal_attribute = decimal_attribute.to_sym
88
+ config = self.class.has_cents_attributes.values.find { |opts| opts[:name] == decimal_attribute }
89
+ return unless config
90
+
91
+ case (unit = config[:unit])
92
+ when Symbol then public_send(unit)
93
+ else unit
94
+ end
95
+ end
96
+
79
97
  module ClassMethods
80
98
  # # Inherit validations from cents attribute to decimal attribute
81
99
  # def validate(*args, &block)
@@ -109,10 +127,20 @@ module Plutonium
109
127
  # - rate: 1000 for dollars/mils (1 dollar = 1000 mils)
110
128
  # - rate: 1 for a whole number representation
111
129
  # @param suffix [String] The suffix to append to the cents_name if name is not provided (default: "amount").
130
+ # @param unit [String, Symbol, nil] The currency unit used when the value is rendered as currency.
131
+ # A String is used verbatim as the symbol ("£"); a Symbol names a method read off the record for
132
+ # per-row currencies (`unit: :currency_symbol`); nil (default) renders with no symbol. Consumers
133
+ # (the Currency display component, grid/kanban cards) read this via {#has_cents_unit_for}.
112
134
  #
113
135
  # @example Standard currency (dollars and cents)
114
136
  # has_cents :price_cents
115
137
  #
138
+ # @example Static currency symbol
139
+ # has_cents :price_cents, unit: "£"
140
+ #
141
+ # @example Per-row currency symbol read off the record
142
+ # has_cents :price_cents, unit: :currency_symbol
143
+ #
116
144
  # @example Custom rate for a different currency division
117
145
  # has_cents :amount_cents, name: :cost, rate: 1000
118
146
  #
@@ -121,14 +149,14 @@ module Plutonium
121
149
  #
122
150
  # @example Using custom suffix
123
151
  # has_cents :total_cents, suffix: "value"
124
- def has_cents(cents_name, name: nil, rate: 100, suffix: "amount")
152
+ def has_cents(cents_name, name: nil, rate: 100, suffix: "amount", unit: nil)
125
153
  cents_name = cents_name.to_sym
126
154
  name ||= cents_name.to_s.gsub(/_cents$/, "")
127
155
  name = name.to_sym
128
156
  name = (name == cents_name) ? :"#{cents_name}_#{suffix}" : name
129
157
 
130
158
  self.has_cents_attributes = has_cents_attributes.merge(
131
- cents_name => {name: name, rate: rate}
159
+ cents_name => {name: name, rate: rate, unit: unit}
132
160
  )
133
161
 
134
162
  class_eval <<-RUBY, __FILE__, __LINE__ + 1
@@ -163,13 +163,34 @@ module Plutonium
163
163
  extraction_record.respond_to?("#{k}=") &&
164
164
  extraction_record.class.reflect_on_association(k.to_sym).nil?
165
165
  }
166
- extraction_record.assign_attributes(submitted.slice(*(base_keys | extra_keys)))
166
+ # Never assign attachment/file inputs to the throwaway extraction_record:
167
+ # the submitted value is a single-read Rack upload (UploadedFile), and a
168
+ # Shrine attacher consumes it to EOF on assign — so the later
169
+ # `resource_class.new(resource_params)` would re-read a closed stream
170
+ # ("IOError: closed stream"). Active Storage escaped this because its
171
+ # attachment reflects as an association (excluded above); Shrine's virtual
172
+ # `file=` accessor does not, so exclude file inputs explicitly. The value
173
+ # still reaches create/update via `extract_input` (which reads params, not
174
+ # this record), so nothing is dropped — the file is just read once, later.
175
+ assign_keys = (base_keys | extra_keys) - attachment_input_keys
176
+ extraction_record.assign_attributes(submitted.slice(*assign_keys))
167
177
  extracted = build_form(extraction_record, form_action: false)
168
178
  .extract_input(params, view_context:)[resource_param_key.to_sym].compact
169
179
  clean_structured_inputs(current_definition, extracted)
170
180
  end
171
181
  end
172
182
 
183
+ # Attachment/file input names (as Strings) declared on the current
184
+ # definition — inputs whose `as:` is a file type (`:file`/`:uppy`/
185
+ # `:attachment`). Excluded from the extraction-record pre-assignment so a
186
+ # single-read Rack upload isn't consumed before create/update reads it.
187
+ def attachment_input_keys
188
+ file_types = Plutonium::UI::Form::Base::Builder::FILE_INPUT_TYPES
189
+ current_definition.defined_inputs.filter_map { |name, config|
190
+ name.to_s if file_types.include?(config.dig(:options, :as)&.to_sym)
191
+ }
192
+ end
193
+
173
194
  # Returns the resource parameters, including scoped and parent parameters
174
195
  # @return [Hash] The resource parameters
175
196
  def resource_params
@@ -56,6 +56,7 @@ module Plutonium
56
56
  format.turbo_stream { render turbo_stream: turbo_stream.replace(helpers.turbo_scoped_dom_id("resource-form"), view_context.render(build_form(action: :new))) }
57
57
  format.html { render :new, status: :unprocessable_content }
58
58
  elsif resource_record!.save
59
+ after_create_persisted
59
60
  format.turbo_stream do
60
61
  flash.notice = "#{resource_class.model_name.human} was successfully created."
61
62
  render turbo_stream: stacked_modal_create_streams
@@ -164,6 +165,13 @@ module Plutonium
164
165
 
165
166
  private
166
167
 
168
+ # Hook fired once, immediately after a successful create-save and BEFORE
169
+ # the response is built. No-op by default. KanbanActions overrides it to
170
+ # apply a kanban quick-add column's on_enter + positioning to the freshly
171
+ # created (already-persisted) record; the reload/redirect response is built
172
+ # after this returns, so the board reflects the placement.
173
+ def after_create_persisted = nil
174
+
167
175
  # When the create came in through the secondary (stacked) modal
168
176
  # frame — i.e. the user clicked "+" next to an association field
169
177
  # while the parent form was already in a modal — we don't want to