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.
- checksums.yaml +4 -4
- data/.claude/skills/plutonium-kanban/SKILL.md +89 -24
- data/CHANGELOG.md +27 -0
- data/app/assets/plutonium.css +1 -1
- data/app/assets/plutonium.js +315 -38
- data/app/assets/plutonium.js.map +4 -4
- data/app/assets/plutonium.min.js +31 -31
- data/app/assets/plutonium.min.js.map +4 -4
- data/app/views/resource/_kanban_move_action_form.html.erb +1 -0
- data/app/views/resource/kanban_move_form.html.erb +1 -0
- data/config/brakeman.ignore +2 -2
- data/docs/.vitepress/config.ts +21 -1
- data/docs/.vitepress/sync-skills.mjs +45 -0
- data/docs/ai.md +99 -0
- data/docs/guides/kanban.md +128 -18
- data/docs/reference/kanban/authorization.md +25 -5
- data/docs/reference/kanban/dsl.md +49 -8
- data/docs/reference/kanban/index.md +3 -3
- data/docs/reference/kanban/positioning.md +1 -1
- data/docs/reference/resource/definition.md +10 -1
- data/docs/reference/resource/model.md +26 -0
- data/docs/reference/ui/forms.md +41 -0
- data/docs/reference/wizard/dsl.md +5 -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-07-03-kanban-auth-simplification.md +159 -0
- data/gemfiles/rails_8.1.gemfile.lock +1 -1
- data/lib/generators/pu/gem/active_shrine/active_shrine_generator.rb +5 -0
- data/lib/plutonium/action/base.rb +8 -0
- data/lib/plutonium/configuration.rb +12 -0
- data/lib/plutonium/definition/index_views.rb +16 -0
- data/lib/plutonium/kanban/column.rb +80 -27
- data/lib/plutonium/models/has_cents.rb +30 -2
- data/lib/plutonium/resource/controller.rb +22 -1
- data/lib/plutonium/resource/controllers/crud_actions.rb +8 -0
- data/lib/plutonium/resource/controllers/kanban_actions.rb +489 -93
- data/lib/plutonium/resource/policy.rb +6 -0
- data/lib/plutonium/routing/mapper_extensions.rb +1 -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/form/base.rb +6 -0
- 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/uppy.rb +20 -2
- data/lib/plutonium/ui/form/kanban_move.rb +46 -0
- data/lib/plutonium/ui/form/options/inferred_types.rb +6 -0
- data/lib/plutonium/ui/form/resource.rb +12 -0
- data/lib/plutonium/ui/form/theme.rb +7 -0
- data/lib/plutonium/ui/grid/card.rb +40 -13
- data/lib/plutonium/ui/kanban/column.rb +111 -24
- data/lib/plutonium/ui/kanban/resource.rb +118 -11
- data/lib/plutonium/ui/layout/base.rb +1 -1
- data/lib/plutonium/ui/options/has_cents_field.rb +21 -0
- data/lib/plutonium/ui/page/index.rb +1 -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 +7 -2
- data/lib/plutonium/ui/table/resource.rb +1 -1
- data/lib/plutonium/ui/wizard/summary_display.rb +33 -0
- data/lib/plutonium/version.rb +1 -1
- data/package.json +5 -3
- data/src/css/components.css +5 -0
- data/src/js/controllers/currency_input_controller.js +39 -0
- data/src/js/controllers/intl_tel_input_controller.js +4 -0
- data/src/js/controllers/kanban_controller.js +442 -55
- data/src/js/controllers/register_controllers.js +2 -0
- data/yarn.lock +674 -4
- 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.
|
|
@@ -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
|
-
|
|
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, :
|
|
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
|
-
@
|
|
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
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|