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,714 @@
|
|
|
1
|
+
# Kanban `drop_interaction` Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (recommended) or superpowers-extended-cc:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Let a kanban column run an authorization-aware, input-collecting Interaction when a card is dropped into it — opening the interaction's modal form (e.g. "reason") and committing the move + interaction atomically — while leaving `on_drop` intact for membership + quick-add seeding.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Add a new `drop_interaction:` column option that references an Interaction class. On a drop into such a column the Stimulus controller opens the interaction's modal (holding the card in a pending state) instead of firing the fire-and-forget move POST. The modal form submits back to `kanban_move`, which — in a single transaction — authorizes the interaction's own policy method, runs the interaction, then runs `on_drop` and repositions. Interaction failure rolls the transaction back and re-renders the modal with errors; cancel snaps the card back. `on_drop` keeps its existing role: membership write on plain columns and the quick-add seed source everywhere.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Ruby / Rails (Plutonium engine), Phlex + ERB modal views, Hotwired Turbo Streams, Stimulus (`src/js/controllers/kanban_controller.js`), esbuild (`yarn build`), Minitest (`bundle exec appraisal rails-8.1 rake test`).
|
|
10
|
+
|
|
11
|
+
**User Verification:** NO — no user sign-off required; the original request is a feasibility-and-design question turned implementation. Verification is via automated tests + a manual dri(dummy app) smoke described in the final task.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Design contract (read once before Task 0)
|
|
16
|
+
|
|
17
|
+
- **`drop_interaction:` takes an Interaction class**, e.g. `drop_interaction: MarkLostInteraction`. It is a **record action** (its interaction declares `attribute :resource`).
|
|
18
|
+
- **Registration:** at `kanban` compile time each column's `drop_interaction` is registered as a hidden interactive record action, keyed by the interaction's conventional name (`MarkLostInteraction → :mark_lost`). This reuses the existing action/policy/form machinery, so the authorization gate is the natural `def mark_lost?` on the policy — layered on top of the board-wide `kanban_move?`.
|
|
19
|
+
- **Two flows, split cleanly:**
|
|
20
|
+
- **Move flow (drag):** `drop_interaction` opens the modal, then commits `on_drop` + interaction + reposition atomically.
|
|
21
|
+
- **Seed flow (`+ Add` quick-add):** unchanged — always uses `on_drop`'s dry-run (`kanban_column_on_drop_seed`). The interaction is never dry-run.
|
|
22
|
+
- **Commit order inside the move transaction:** `on_drop` (assign + save membership) → `interaction.call` (sees the updated record; persists extras like `reason`; `deliver_later` side-effects only fire post-commit) → `reposition!`. Any failure raises `ActiveRecord::Rollback`.
|
|
23
|
+
- **Contract for authors:** the interaction owns the *extras* (reason, mail, audit); `on_drop` owns the *membership attribute* (status). If the interaction also sets the membership attribute it must be to the same value `on_drop` set (idempotent) — document, don't enforce.
|
|
24
|
+
- **Failure surfaces:** move-guard rejections (`accepts`/`locked`/`wip`/`kanban_move?`) keep the existing `render_kanban_rejection` snap-back-toast path and are checked *before* the modal opens. Interaction failures re-render the modal at 422 with `@interaction.errors`.
|
|
25
|
+
|
|
26
|
+
### File map
|
|
27
|
+
|
|
28
|
+
| File | Responsibility | Task |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `lib/plutonium/kanban/column.rb` | Store + expose `drop_interaction`; derive its action key | 0 |
|
|
31
|
+
| `lib/plutonium/kanban/dsl.rb` | (no change — `**opts` already forwards; verify only) | 0 |
|
|
32
|
+
| `lib/plutonium/definition/index_views.rb` | Register each column's `drop_interaction` as a hidden record action at compile time | 1 |
|
|
33
|
+
| `lib/plutonium/action/base.rb` (+ factory) | Carry a `kanban_drop:` flag so drop actions don't render in toolbars | 1 |
|
|
34
|
+
| `lib/plutonium/routing/mapper_extensions.rb` | Add `GET kanban_move_form` member route | 2 |
|
|
35
|
+
| `lib/plutonium/resource/controllers/kanban_actions.rb` | `kanban_move_form` GET (render modal); `kanban_move` POST interaction branch | 3, 4 |
|
|
36
|
+
| `app/views/**/kanban_move_form` (ERB) | Modal wrapper rendering the interaction form, posting to `kanban_move` | 3 |
|
|
37
|
+
| `lib/plutonium/ui/kanban/column.rb` | Emit `data-kanban-drop-*` on drop-interaction columns | 5 |
|
|
38
|
+
| `src/js/controllers/kanban_controller.js` | On drop into a drop-interaction column, open the modal + pending/snap-back | 6 |
|
|
39
|
+
| `docs/guides/kanban.md`, `docs/reference/kanban/dsl.md`, `.claude/skills/plutonium-kanban/SKILL.md` | Document `drop_interaction` | 7 |
|
|
40
|
+
| `test/dummy/app/definitions/*` + system test | End-to-end drive | 8 |
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
### Task 0: `Column#drop_interaction` + derived action key
|
|
45
|
+
|
|
46
|
+
**Goal:** `Column` accepts and exposes `drop_interaction:`, derives its conventional action key, and rejects a non-interaction value.
|
|
47
|
+
|
|
48
|
+
**Files:**
|
|
49
|
+
- Modify: `lib/plutonium/kanban/column.rb:15-31`
|
|
50
|
+
- Verify (no edit): `lib/plutonium/kanban/dsl.rb:23-27`
|
|
51
|
+
- Test: `test/plutonium/kanban/column_test.rb`
|
|
52
|
+
|
|
53
|
+
**Acceptance Criteria:**
|
|
54
|
+
- [ ] `Column.new(:lost, drop_interaction: MarkLostInteraction).drop_interaction` returns the class.
|
|
55
|
+
- [ ] `#drop_interaction?` is true only when one is set.
|
|
56
|
+
- [ ] `#drop_interaction_key` returns `:mark_lost` for `MarkLostInteraction`.
|
|
57
|
+
- [ ] `Column.new(:x, drop_interaction: "nope")` raises `ArgumentError`.
|
|
58
|
+
|
|
59
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/column_test.rb` → all pass.
|
|
60
|
+
|
|
61
|
+
**Steps:**
|
|
62
|
+
|
|
63
|
+
- [ ] **Step 1: Write the failing test**
|
|
64
|
+
|
|
65
|
+
```ruby
|
|
66
|
+
# test/plutonium/kanban/column_test.rb (add these cases)
|
|
67
|
+
require "test_helper"
|
|
68
|
+
|
|
69
|
+
class DummyDropInteraction < Plutonium::Resource::Interaction
|
|
70
|
+
attribute :resource
|
|
71
|
+
def execute = succeed(resource)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class Plutonium::Kanban::ColumnDropInteractionTest < ActiveSupport::TestCase
|
|
75
|
+
test "stores and exposes drop_interaction" do
|
|
76
|
+
col = Plutonium::Kanban::Column.new(:lost, drop_interaction: DummyDropInteraction)
|
|
77
|
+
assert_equal DummyDropInteraction, col.drop_interaction
|
|
78
|
+
assert col.drop_interaction?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
test "derives conventional action key from the interaction class name" do
|
|
82
|
+
col = Plutonium::Kanban::Column.new(:lost, drop_interaction: DummyDropInteraction)
|
|
83
|
+
assert_equal :dummy_drop, col.drop_interaction_key
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
test "no drop_interaction by default" do
|
|
87
|
+
refute Plutonium::Kanban::Column.new(:todo).drop_interaction?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
test "rejects a non-interaction drop_interaction" do
|
|
91
|
+
assert_raises(ArgumentError) do
|
|
92
|
+
Plutonium::Kanban::Column.new(:x, drop_interaction: "nope")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
99
|
+
|
|
100
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/column_test.rb`
|
|
101
|
+
Expected: FAIL — `unknown keyword: :drop_interaction`.
|
|
102
|
+
|
|
103
|
+
- [ ] **Step 3: Implement in Column**
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# lib/plutonium/kanban/column.rb
|
|
107
|
+
attr_reader :key, :label, :color, :wip, :scope, :on_drop, :accepts, :actions, :drop_interaction
|
|
108
|
+
|
|
109
|
+
def initialize(key, label: nil, color: nil, wip: nil, scope: nil, on_drop: nil,
|
|
110
|
+
collapsed: nil, add: nil, accepts: nil, locked: nil, role: nil, drop_interaction: nil)
|
|
111
|
+
# ... existing assignments unchanged ...
|
|
112
|
+
@on_drop = on_drop
|
|
113
|
+
if drop_interaction && !(drop_interaction.is_a?(Class) && drop_interaction < Plutonium::Resource::Interaction)
|
|
114
|
+
raise ArgumentError, "drop_interaction: must be a Plutonium::Resource::Interaction subclass, got #{drop_interaction.inspect}"
|
|
115
|
+
end
|
|
116
|
+
@drop_interaction = drop_interaction
|
|
117
|
+
# ... rest unchanged ...
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def drop_interaction? = !@drop_interaction.nil?
|
|
121
|
+
|
|
122
|
+
# MarkLostInteraction → :mark_lost (strip trailing "Interaction", underscore).
|
|
123
|
+
def drop_interaction_key
|
|
124
|
+
return nil unless @drop_interaction
|
|
125
|
+
@drop_interaction.name.demodulize.sub(/Interaction\z/, "").underscore.to_sym
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
- [ ] **Step 4: Run test to verify it passes**
|
|
130
|
+
|
|
131
|
+
Run: `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/kanban/column_test.rb`
|
|
132
|
+
Expected: PASS.
|
|
133
|
+
|
|
134
|
+
- [ ] **Step 5: Confirm DSL passthrough needs no change**
|
|
135
|
+
|
|
136
|
+
`lib/plutonium/kanban/dsl.rb:23` is `def column(key, **opts, &blk) = Column.new(key, **opts)` — `drop_interaction:` flows through `**opts` untouched. No edit; just confirm by reading.
|
|
137
|
+
|
|
138
|
+
- [ ] **Step 6: Commit**
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
git add lib/plutonium/kanban/column.rb test/plutonium/kanban/column_test.rb
|
|
142
|
+
git commit -m "feat(kanban): add drop_interaction column option"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### Task 1: Register `drop_interaction` as a hidden record action at compile time
|
|
148
|
+
|
|
149
|
+
**Goal:** Each static column's `drop_interaction` is registered as an interactive **record** action (so its policy method + form + params machinery exist) but flagged `kanban_drop: true` so it never renders in a toolbar/row/show.
|
|
150
|
+
|
|
151
|
+
**Files:**
|
|
152
|
+
- Modify: `lib/plutonium/definition/index_views.rb:125-135`
|
|
153
|
+
- Modify: `lib/plutonium/action/base.rb` (add `kanban_drop?` reader + accept the option)
|
|
154
|
+
- Modify: `lib/plutonium/action/interactive/factory.rb` (pass `kanban_drop:` through) — confirm exact path first with `grep -rn "class Factory" lib/plutonium/action`
|
|
155
|
+
- Test: `test/plutonium/definition/index_views_test.rb` (or nearest existing kanban-registration test)
|
|
156
|
+
|
|
157
|
+
**Acceptance Criteria:**
|
|
158
|
+
- [ ] After a definition with `column :lost, drop_interaction: MarkLostInteraction` loads, `definition.defined_actions[:mark_lost]` is an `Action::Interactive` with `record_action? == true`.
|
|
159
|
+
- [ ] That action reports `kanban_drop? == true`.
|
|
160
|
+
- [ ] Actions the toolbar renders exclude `kanban_drop?` actions (assert the filter helper skips it).
|
|
161
|
+
- [ ] Dynamic `columns do…end` boards do **not** auto-register (documented constraint) — no crash, just nothing registered.
|
|
162
|
+
|
|
163
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/definition/index_views_test.rb`
|
|
164
|
+
|
|
165
|
+
**Steps:**
|
|
166
|
+
|
|
167
|
+
- [ ] **Step 1: Write the failing test**
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# test/plutonium/definition/index_views_test.rb (new case)
|
|
171
|
+
test "registers drop_interaction as a hidden record action" do
|
|
172
|
+
klass = Class.new(Plutonium::Resource::Definition) do
|
|
173
|
+
kanban do
|
|
174
|
+
column :lost, scope: -> { all }, on_drop: ->(r) { r.status = "lost" },
|
|
175
|
+
drop_interaction: MarkLostInteraction
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
action = klass.new.defined_actions[:mark_lost]
|
|
179
|
+
assert_kind_of Plutonium::Action::Interactive, action
|
|
180
|
+
assert action.record_action?
|
|
181
|
+
assert action.kanban_drop?
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Ensure a `MarkLostInteraction` test fixture exists (define in the test or `test/dummy/app/interactions/mark_lost_interaction.rb` with `attribute :resource`, `attribute :reason, :string`, `input :reason`, `validates :reason, presence: true`, `execute { resource.update!(status: "lost", lost_reason: reason); succeed(resource) }`).
|
|
186
|
+
|
|
187
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
188
|
+
|
|
189
|
+
Run the file. Expected: FAIL — `:mark_lost` not registered / `kanban_drop?` undefined.
|
|
190
|
+
|
|
191
|
+
- [ ] **Step 3: Add the `kanban_drop` flag to the Action base**
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# lib/plutonium/action/base.rb — in initialize, accept and store the flag
|
|
195
|
+
# alongside record_action/collection_record_action:
|
|
196
|
+
@kanban_drop = opts.fetch(:kanban_drop, false) # match the surrounding option-reading style
|
|
197
|
+
# and add the reader near record_action?:
|
|
198
|
+
def kanban_drop? = @kanban_drop
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Thread `kanban_drop:` through `Action::Interactive::Factory.create` (it already splats `**opts` to the action — confirm and, if it whitelists keys, add `:kanban_drop`).
|
|
202
|
+
|
|
203
|
+
- [ ] **Step 4: Register drop_interactions in the kanban compile block**
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
# lib/plutonium/definition/index_views.rb — inside kanban(&block),
|
|
207
|
+
# after the existing `board.columns.each { |col| col.actions.each … }` loop:
|
|
208
|
+
board.columns.each do |col|
|
|
209
|
+
next unless col.drop_interaction?
|
|
210
|
+
action(
|
|
211
|
+
col.drop_interaction_key,
|
|
212
|
+
interaction: col.drop_interaction,
|
|
213
|
+
record_action: true,
|
|
214
|
+
kanban_drop: true
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
- [ ] **Step 5: Exclude `kanban_drop?` actions from toolbars**
|
|
220
|
+
|
|
221
|
+
Find where record/collection actions are filtered for display (grep `record_action?` / `collection_record_action?` in `lib/plutonium/ui/` and `lib/plutonium/resource/`). Add `&& !action.kanban_drop?` to those display filters. Add an assertion in the test that the show/row action set excludes `:mark_lost`.
|
|
222
|
+
|
|
223
|
+
- [ ] **Step 6: Run tests**
|
|
224
|
+
|
|
225
|
+
Run the index_views test + `bundle exec appraisal rails-8.1 ruby -Itest test/plutonium/action/base_test.rb` (if present). Expected: PASS.
|
|
226
|
+
|
|
227
|
+
- [ ] **Step 7: Commit**
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
git add lib/plutonium/definition/index_views.rb lib/plutonium/action/ test/
|
|
231
|
+
git commit -m "feat(kanban): register drop_interaction as hidden record action"
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
### Task 2: Add the `GET kanban_move_form` member route
|
|
237
|
+
|
|
238
|
+
**Goal:** A member route that renders the drop interaction's modal form for a pending drop.
|
|
239
|
+
|
|
240
|
+
**Files:**
|
|
241
|
+
- Modify: `lib/plutonium/routing/mapper_extensions.rb:148-155`
|
|
242
|
+
- Test: `test/plutonium/routing/…` (nearest routing test) or assert via an integration test in Task 4.
|
|
243
|
+
|
|
244
|
+
**Acceptance Criteria:**
|
|
245
|
+
- [ ] `GET <member>/kanban_move_form` routes to `kanban_actions#kanban_move_form`, named `kanban_move_form`.
|
|
246
|
+
- [ ] Existing `POST kanban_move` unchanged.
|
|
247
|
+
|
|
248
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/dummy_routes_test.rb` (or `rails routes | grep kanban` in the dummy app).
|
|
249
|
+
|
|
250
|
+
**Steps:**
|
|
251
|
+
|
|
252
|
+
- [ ] **Step 1: Add the route**
|
|
253
|
+
|
|
254
|
+
```ruby
|
|
255
|
+
# lib/plutonium/routing/mapper_extensions.rb — in define_member_interactive_actions,
|
|
256
|
+
# next to the existing kanban_move POST (line 154):
|
|
257
|
+
get "kanban_move_form", action: :kanban_move_form, as: :kanban_move_form
|
|
258
|
+
post "kanban_move", action: :kanban_move, as: :kanban_move
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
- [ ] **Step 2: Verify routing**
|
|
262
|
+
|
|
263
|
+
Run in the dummy app: `cd test/dummy && bin/rails routes | grep kanban_move`
|
|
264
|
+
Expected: both `kanban_move_form` (GET) and `kanban_move` (POST) present for kanban resources.
|
|
265
|
+
|
|
266
|
+
- [ ] **Step 3: Commit**
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
git add lib/plutonium/routing/mapper_extensions.rb
|
|
270
|
+
git commit -m "feat(kanban): add kanban_move_form member route"
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
### Task 3: `kanban_move_form` GET — render the interaction modal posting to `kanban_move`
|
|
276
|
+
|
|
277
|
+
**Goal:** Build the drop interaction as a record action and render its form inside the remote modal, with the form action set to `kanban_move` and the move params (`from_column`, `to_column`, `to_index`) as hidden fields.
|
|
278
|
+
|
|
279
|
+
**Files:**
|
|
280
|
+
- Modify: `lib/plutonium/resource/controllers/kanban_actions.rb` (add `kanban_move_form` public action + helpers)
|
|
281
|
+
- Create: `app/views/plutonium/resource/kanban_move_form.html.erb` (confirm the resource views dir with `find app -path '*resource*' -name 'interactive_record_action*'`; co-locate the new template there)
|
|
282
|
+
- Test: covered by Task 4 integration test (GET assertions).
|
|
283
|
+
|
|
284
|
+
**Acceptance Criteria:**
|
|
285
|
+
- [ ] `GET kanban_move_form?to_column=lost&from_column=doing&to_index=0` on a card renders a modal containing the interaction's inputs (e.g. a `reason` field).
|
|
286
|
+
- [ ] The rendered `<form>` posts to the `kanban_move` path and carries hidden `from_column`, `to_column`, `to_index`.
|
|
287
|
+
- [ ] A column with no `drop_interaction` → `head :unprocessable_content` (the client should never call it for such columns, but guard anyway).
|
|
288
|
+
- [ ] Authorization: the request runs `authorize_current! record, to: :<drop_interaction_key>?` and 403s when denied.
|
|
289
|
+
|
|
290
|
+
**Verify:** Task 4's integration test `test_kanban_move_form_renders_interaction_modal`.
|
|
291
|
+
|
|
292
|
+
**Steps:**
|
|
293
|
+
|
|
294
|
+
- [ ] **Step 1: Add `kanban_move_form` to KanbanActions**
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
# lib/plutonium/resource/controllers/kanban_actions.rb
|
|
298
|
+
# GET <member>/kanban_move_form?from_column=&to_column=&to_index=
|
|
299
|
+
def kanban_move_form
|
|
300
|
+
record = kanban_base_relation.find(params[:id])
|
|
301
|
+
to = kanban_column_for(params[:to_column])
|
|
302
|
+
unless to&.drop_interaction?
|
|
303
|
+
head :unprocessable_content
|
|
304
|
+
return
|
|
305
|
+
end
|
|
306
|
+
# Authorize the specific transition (layered on kanban_move? at commit time).
|
|
307
|
+
authorize_current! record, to: :"#{to.drop_interaction_key}?"
|
|
308
|
+
|
|
309
|
+
@interaction = to.drop_interaction.new(view_context:)
|
|
310
|
+
@interaction.resource = record
|
|
311
|
+
@kanban_move_params = {
|
|
312
|
+
from_column: params[:from_column],
|
|
313
|
+
to_column: params[:to_column],
|
|
314
|
+
to_index: params[:to_index]
|
|
315
|
+
}
|
|
316
|
+
render :kanban_move_form, formats: [:html], **modal_render_options
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
private
|
|
320
|
+
|
|
321
|
+
# Resolve a column by key string against the current board (shared by
|
|
322
|
+
# kanban_move_form and kanban_move).
|
|
323
|
+
def kanban_column_for(key)
|
|
324
|
+
columns = Plutonium::Kanban::Grouping.resolve_columns(current_kanban_board, kanban_context)
|
|
325
|
+
columns.find { |c| c.key.to_s == key.to_s }
|
|
326
|
+
end
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`modal_render_options` and `authorize_current!` are already available (the controller includes `InteractiveActions`). Confirm `KanbanActions` is included after `InteractiveActions`, or reference `Plutonium::REMOTE_MODAL_FRAME` chrome directly in the view.
|
|
330
|
+
|
|
331
|
+
- [ ] **Step 2: Create the modal view**
|
|
332
|
+
|
|
333
|
+
```erb
|
|
334
|
+
<%# app/views/plutonium/resource/kanban_move_form.html.erb %>
|
|
335
|
+
<%# Mirrors interactive_record_action.html.erb chrome, but posts to kanban_move
|
|
336
|
+
and carries the move params so the commit is a single atomic request. %>
|
|
337
|
+
<%= turbo_frame_tag Plutonium::REMOTE_MODAL_FRAME do %>
|
|
338
|
+
<%= render "plutonium/modal", title: @interaction.class.label do %>
|
|
339
|
+
<%= form_with url: resource_url_for(resource_record!, action: :kanban_move),
|
|
340
|
+
method: :post,
|
|
341
|
+
data: { turbo_frame: "_top" } do |f| %>
|
|
342
|
+
<% @kanban_move_params.each do |k, v| %>
|
|
343
|
+
<%= f.hidden_field k, value: v %>
|
|
344
|
+
<% end %>
|
|
345
|
+
<%= render @interaction.build_form %>
|
|
346
|
+
<%= f.submit @interaction.class.label %>
|
|
347
|
+
<% end %>
|
|
348
|
+
<% end %>
|
|
349
|
+
<% end %>
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Confirm the exact modal partial + form component used by `interactive_record_action.html.erb` (read it first) and match it — reuse the same `@interaction.build_form` and submit styling so this modal is visually identical to a normal record-action modal. Confirm `resource_url_for(record, action: :kanban_move)` produces the member `kanban_move` path; if not, build it via the named route helper `resource_url_for` supports.
|
|
353
|
+
|
|
354
|
+
- [ ] **Step 3: Commit**
|
|
355
|
+
|
|
356
|
+
```bash
|
|
357
|
+
git add lib/plutonium/resource/controllers/kanban_actions.rb app/views/plutonium/resource/kanban_move_form.html.erb
|
|
358
|
+
git commit -m "feat(kanban): render drop interaction modal via kanban_move_form"
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
### Task 4: `kanban_move` POST — atomic interaction + on_drop + reposition
|
|
364
|
+
|
|
365
|
+
**Goal:** When the destination column has a `drop_interaction`, commit the interaction and the move in one transaction: authorize the transition, run `on_drop` (membership), run the interaction (extras), reposition. Interaction failure rolls back and re-renders the modal at 422; success returns column Turbo Streams (which also closes the modal).
|
|
366
|
+
|
|
367
|
+
**Files:**
|
|
368
|
+
- Modify: `lib/plutonium/resource/controllers/kanban_actions.rb:57-167`
|
|
369
|
+
- Test: `test/integration/**/kanban_drop_interaction_test.rb` (new)
|
|
370
|
+
|
|
371
|
+
**Acceptance Criteria:**
|
|
372
|
+
- [ ] Dropping onto a drop-interaction column with valid input persists `on_drop`'s membership change, the interaction's extras (e.g. `lost_reason`), and repositions — all committed together.
|
|
373
|
+
- [ ] Interaction validation failure (blank `reason`): nothing persists (membership unchanged), response is 422 re-rendering the modal with the error.
|
|
374
|
+
- [ ] Transition authorization: `authorize_current! record, to: :<key>?` runs; denial → 403 and no writes.
|
|
375
|
+
- [ ] Move guards (`accepts`/`locked`/`wip`) are checked before the interaction runs and still snap-back-toast via `render_kanban_rejection`.
|
|
376
|
+
- [ ] Plain columns (no `drop_interaction`) behave exactly as before (existing kanban_move tests still pass).
|
|
377
|
+
- [ ] Quick-add seed path (`kanban_column_on_drop_seed`) is unaffected — still uses `on_drop`.
|
|
378
|
+
|
|
379
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/**/kanban_drop_interaction_test.rb` and the existing kanban move test file both green.
|
|
380
|
+
|
|
381
|
+
**Steps:**
|
|
382
|
+
|
|
383
|
+
- [ ] **Step 1: Write the failing integration test**
|
|
384
|
+
|
|
385
|
+
```ruby
|
|
386
|
+
# test/integration/<portal>/kanban_drop_interaction_test.rb
|
|
387
|
+
require "test_helper"
|
|
388
|
+
|
|
389
|
+
class KanbanDropInteractionTest < ActionDispatch::IntegrationTest
|
|
390
|
+
# setup: sign in, seed a card in the "doing" column of a resource whose
|
|
391
|
+
# definition declares column :lost, drop_interaction: MarkLostInteraction.
|
|
392
|
+
|
|
393
|
+
test "commits interaction extras + membership + position atomically" do
|
|
394
|
+
post kanban_move_path(card),
|
|
395
|
+
params: { from_column: "doing", to_column: "lost", to_index: 0,
|
|
396
|
+
interaction: { reason: "budget cut" } },
|
|
397
|
+
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
398
|
+
assert_response :success
|
|
399
|
+
card.reload
|
|
400
|
+
assert_equal "lost", card.status
|
|
401
|
+
assert_equal "budget cut", card.lost_reason
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
test "blank reason rolls back and re-renders modal at 422" do
|
|
405
|
+
post kanban_move_path(card),
|
|
406
|
+
params: { from_column: "doing", to_column: "lost", to_index: 0,
|
|
407
|
+
interaction: { reason: "" } },
|
|
408
|
+
headers: { "Accept" => "text/vnd.turbo-stream.html" }
|
|
409
|
+
assert_response :unprocessable_content
|
|
410
|
+
card.reload
|
|
411
|
+
assert_equal "doing", card.status # membership NOT changed
|
|
412
|
+
assert_nil card.lost_reason
|
|
413
|
+
assert_match "can't be blank", @response.body
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
- [ ] **Step 2: Run to verify it fails**
|
|
419
|
+
|
|
420
|
+
Expected: FAIL — the interaction never runs (current `kanban_move` ignores `drop_interaction`); reason not persisted, no 422 modal.
|
|
421
|
+
|
|
422
|
+
- [ ] **Step 3: Branch `kanban_move` on `drop_interaction`**
|
|
423
|
+
|
|
424
|
+
Refactor the existing transaction (`kanban_actions.rb:111-144`) so the interaction runs inside it. After the existing guard checks (accepts/locked/wip stay unchanged and BEFORE this block), add:
|
|
425
|
+
|
|
426
|
+
```ruby
|
|
427
|
+
outcome = nil
|
|
428
|
+
ActiveRecord::Base.transaction do
|
|
429
|
+
# 1. Membership write (unchanged on_drop dispatch).
|
|
430
|
+
if to.on_drop.is_a?(Symbol)
|
|
431
|
+
record.public_send(to.on_drop)
|
|
432
|
+
elsif to.on_drop
|
|
433
|
+
kanban_context.instance_exec(record, &to.on_drop)
|
|
434
|
+
end
|
|
435
|
+
record.save! if record.changed?
|
|
436
|
+
|
|
437
|
+
# 2. Drop interaction (input + authz + errors), same record instance.
|
|
438
|
+
if to.drop_interaction?
|
|
439
|
+
authorize_current! record, to: :"#{to.drop_interaction_key}?"
|
|
440
|
+
interaction = to.drop_interaction.new(view_context:)
|
|
441
|
+
interaction.attributes = kanban_interaction_params(to).merge(resource: record)
|
|
442
|
+
outcome = interaction.call
|
|
443
|
+
if outcome.failure?
|
|
444
|
+
@interaction = interaction
|
|
445
|
+
@kanban_move_params = params.slice(:from_column, :to_column, :to_index).to_unsafe_h
|
|
446
|
+
raise ActiveRecord::Rollback
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# 3. Reposition (unchanged).
|
|
451
|
+
board.position_config.reposition!(
|
|
452
|
+
record:, column: to.key, prev_record:, next_record:, index: to_index
|
|
453
|
+
)
|
|
454
|
+
record.save! if record.changed?
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# Interaction failed → re-render the modal with errors (transaction rolled back).
|
|
458
|
+
if to.drop_interaction? && outcome&.failure?
|
|
459
|
+
return render :kanban_move_form, formats: [:html], **modal_render_options,
|
|
460
|
+
status: :unprocessable_content
|
|
461
|
+
end
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Add the params helper (reuses the interactive-action extraction so structured inputs / choices work identically):
|
|
465
|
+
|
|
466
|
+
```ruby
|
|
467
|
+
# Extract the submitted interaction params for a drop interaction. Mirrors
|
|
468
|
+
# InteractiveActions#interaction_params but keyed off the column's interaction.
|
|
469
|
+
def kanban_interaction_params(column)
|
|
470
|
+
action_key = column.drop_interaction_key
|
|
471
|
+
params[:interaction] ? params.require(:interaction).permit!.to_h : {}
|
|
472
|
+
# If structured inputs are used, swap this for the shared extract_input
|
|
473
|
+
# pipeline keyed on column.drop_interaction (see submitted_interaction_params).
|
|
474
|
+
end
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
> Prefer routing through the existing `submitted_interaction_params` machinery if the interaction uses `structured_input`/`choices:` — read `interactive_actions.rb:244-280` and reuse `build_form(instance).extract_input(...)` rather than a naive `permit!`. Keep the naive form only if the drop interactions in scope use plain scalar inputs.
|
|
478
|
+
|
|
479
|
+
- [ ] **Step 4: Keep the success response as-is**
|
|
480
|
+
|
|
481
|
+
The existing `respond_to { format.turbo_stream { … column updates … } }` block already re-renders the from/to column frames. Because the modal form posts with `data-turbo-frame="_top"`, the returned Turbo Streams replace the columns and the modal frame is left empty/closed by the stream. Confirm the modal actually dismisses on success in Task 8's manual drive; if it lingers, append `turbo_stream.update(Plutonium::REMOTE_MODAL_FRAME, "")` to the success streams.
|
|
482
|
+
|
|
483
|
+
- [ ] **Step 5: Run tests**
|
|
484
|
+
|
|
485
|
+
Run the new integration test AND the existing kanban move test file. Expected: both PASS.
|
|
486
|
+
|
|
487
|
+
- [ ] **Step 6: Commit**
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
git add lib/plutonium/resource/controllers/kanban_actions.rb test/integration/
|
|
491
|
+
git commit -m "feat(kanban): commit drop interaction + move atomically in kanban_move"
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
### Task 5: Emit `data-kanban-drop-*` on drop-interaction columns
|
|
497
|
+
|
|
498
|
+
**Goal:** The column component advertises, in the DOM, that a column requires the interaction modal on drop, and provides the form URL template.
|
|
499
|
+
|
|
500
|
+
**Files:**
|
|
501
|
+
- Modify: `lib/plutonium/ui/kanban/column.rb`
|
|
502
|
+
- Modify: `lib/plutonium/resource/controllers/kanban_actions.rb` `render_kanban_column_html` (pass the form URL template into the component)
|
|
503
|
+
- Test: `test/integration/**/kanban_dom_contract_test.rb` (the file the user has open — extend it)
|
|
504
|
+
|
|
505
|
+
**Acceptance Criteria:**
|
|
506
|
+
- [ ] A column with `drop_interaction` renders `data-kanban-drop-interaction="true"` on its `[data-kanban-col]` wrapper.
|
|
507
|
+
- [ ] It also renders `data-kanban-drop-form-url-template` = the `kanban_move_form` path with an `__ID__` placeholder (mirroring the existing `move-url-template`).
|
|
508
|
+
- [ ] Plain columns render neither attribute.
|
|
509
|
+
|
|
510
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/integration/**/kanban_dom_contract_test.rb`
|
|
511
|
+
|
|
512
|
+
**Steps:**
|
|
513
|
+
|
|
514
|
+
- [ ] **Step 1: Extend the DOM-contract test**
|
|
515
|
+
|
|
516
|
+
```ruby
|
|
517
|
+
test "drop-interaction column advertises the modal drop contract" do
|
|
518
|
+
# render a board whose :lost column has drop_interaction
|
|
519
|
+
assert_select "[data-kanban-col='lost'][data-kanban-drop-interaction='true']"
|
|
520
|
+
assert_select "[data-kanban-col='lost'][data-kanban-drop-form-url-template*='__ID__']"
|
|
521
|
+
assert_select "[data-kanban-col='todo']:not([data-kanban-drop-interaction])"
|
|
522
|
+
end
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
- [ ] **Step 2: Pass the form URL template into the component**
|
|
526
|
+
|
|
527
|
+
In `render_kanban_column_html` (`kanban_actions.rb:301-313`), compute and pass:
|
|
528
|
+
|
|
529
|
+
```ruby
|
|
530
|
+
drop_form_url_template: (column.drop_interaction? ?
|
|
531
|
+
resource_url_for(resource_class, action: :kanban_move_form).sub("kanban_move_form", "__ID__/kanban_move_form") :
|
|
532
|
+
nil),
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
Verify the exact shape of the member form URL and build the `__ID__` template the same way the board builds `move-url-template` (grep `move_url_template` / `__ID__` in `lib/plutonium/ui/kanban/`).
|
|
536
|
+
|
|
537
|
+
- [ ] **Step 3: Render the attributes in the column component**
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
# lib/plutonium/ui/kanban/column.rb — on the [data-kanban-col] wrapper div,
|
|
541
|
+
# add to its attribute hash:
|
|
542
|
+
data: {
|
|
543
|
+
kanban_col: column.key,
|
|
544
|
+
# ...existing accepts/locked/default-collapsed...
|
|
545
|
+
**(column.drop_interaction? ? {
|
|
546
|
+
kanban_drop_interaction: "true",
|
|
547
|
+
kanban_drop_form_url_template: @drop_form_url_template
|
|
548
|
+
} : {})
|
|
549
|
+
}
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
Add the `drop_form_url_template:` keyword to the component's `initialize` and store `@drop_form_url_template`.
|
|
553
|
+
|
|
554
|
+
- [ ] **Step 4: Run the test**
|
|
555
|
+
|
|
556
|
+
Expected: PASS.
|
|
557
|
+
|
|
558
|
+
- [ ] **Step 5: Commit**
|
|
559
|
+
|
|
560
|
+
```bash
|
|
561
|
+
git add lib/plutonium/ui/kanban/column.rb lib/plutonium/resource/controllers/kanban_actions.rb test/
|
|
562
|
+
git commit -m "feat(kanban): advertise drop interaction contract in column DOM"
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
---
|
|
566
|
+
|
|
567
|
+
### Task 6: Stimulus — open the modal on drop, hold pending, snap back on cancel
|
|
568
|
+
|
|
569
|
+
**Goal:** On a drop into a `data-kanban-drop-interaction` column, the controller navigates the remote-modal frame to the `kanban_move_form` URL (carrying the move params) instead of firing the move POST; the card stays visually pending; canceling/closing the modal without success snaps it back.
|
|
570
|
+
|
|
571
|
+
**Files:**
|
|
572
|
+
- Modify: `src/js/controllers/kanban_controller.js:430-487` (`#onDrop`)
|
|
573
|
+
- Build: `yarn build` → regenerates `app/assets/plutonium*.js`
|
|
574
|
+
- Test: system test in Task 8 (JS behavior is driven there).
|
|
575
|
+
|
|
576
|
+
**Acceptance Criteria:**
|
|
577
|
+
- [ ] Dropping into a drop-interaction column opens the modal (frame `src` set to the form URL with `from_column`/`to_column`/`to_index` in the query), no move POST fires yet.
|
|
578
|
+
- [ ] Successful modal submit → columns re-render via the returned streams (existing path), pending state cleared.
|
|
579
|
+
- [ ] Modal dismissed without success → source column reloaded (snap-back), pending state cleared.
|
|
580
|
+
- [ ] Plain columns keep the existing direct-POST behavior byte-for-byte.
|
|
581
|
+
|
|
582
|
+
**Verify:** `yarn build` succeeds; Task 8 system test drives it.
|
|
583
|
+
|
|
584
|
+
**Steps:**
|
|
585
|
+
|
|
586
|
+
- [ ] **Step 1: Branch `#onDrop` on the drop-interaction contract**
|
|
587
|
+
|
|
588
|
+
```js
|
|
589
|
+
// src/js/controllers/kanban_controller.js — inside #onDrop, after computing
|
|
590
|
+
// recordId / fromColumn / toColumn / toIndex, before the fetch():
|
|
591
|
+
const colWrapper = column.closest("[data-kanban-col]")
|
|
592
|
+
if (colWrapper?.dataset.kanbanDropInteraction === "true") {
|
|
593
|
+
return this.#openDropInteraction(colWrapper, { recordId, fromColumn, toColumn, toIndex })
|
|
594
|
+
}
|
|
595
|
+
// ...existing direct fetch(url, POST { from_column, to_column, to_index }) unchanged...
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
- [ ] **Step 2: Add the modal-opening + pending/snap-back handler**
|
|
599
|
+
|
|
600
|
+
```js
|
|
601
|
+
// Opens the drop interaction's modal by pointing the remote-modal frame at the
|
|
602
|
+
// kanban_move_form URL. The card is left where the user dropped it (pending);
|
|
603
|
+
// the modal's Turbo-Stream response re-renders the columns on success. If the
|
|
604
|
+
// modal closes without a successful commit, reload the source column to snap
|
|
605
|
+
// the card back.
|
|
606
|
+
#openDropInteraction(colWrapper, { recordId, fromColumn, toColumn, toIndex }) {
|
|
607
|
+
const tmpl = colWrapper.dataset.kanbanDropFormUrlTemplate
|
|
608
|
+
const params = new URLSearchParams({ from_column: fromColumn, to_column: toColumn, to_index: toIndex })
|
|
609
|
+
const src = `${tmpl.replace("__ID__", recordId)}?${params.toString()}`
|
|
610
|
+
|
|
611
|
+
const frame = document.getElementById("remote_modal") // confirm Plutonium::REMOTE_MODAL_FRAME id
|
|
612
|
+
if (!frame) return
|
|
613
|
+
this.pendingSnapBackColumn = fromColumn
|
|
614
|
+
|
|
615
|
+
// If the modal frame empties (closed) without a move stream having landed,
|
|
616
|
+
// snap the source column back.
|
|
617
|
+
const onFrameLoad = () => {
|
|
618
|
+
if (!frame.innerHTML.trim() && this.pendingSnapBackColumn) this.#reloadColumn(this.pendingSnapBackColumn)
|
|
619
|
+
}
|
|
620
|
+
frame.addEventListener("turbo:frame-load", onFrameLoad, { once: true })
|
|
621
|
+
frame.src = src
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
#reloadColumn(key) {
|
|
625
|
+
const frame = this.element.querySelector(`turbo-frame[data-kanban-col-frame='${key}']`)
|
|
626
|
+
if (frame) frame.src = this.#columnFrameSrc(key)
|
|
627
|
+
this.pendingSnapBackColumn = null
|
|
628
|
+
}
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
Clear `pendingSnapBackColumn` in `#onBeforeStreamRender` (a successful move stream landed → no snap-back needed). Confirm the actual remote-modal frame id/selector from `Plutonium::REMOTE_MODAL_FRAME` and the layout; adjust `getElementById` accordingly. Keep this handler minimal — the server owns all state; the controller only decides *reload source column* vs *let the move stream render*.
|
|
632
|
+
|
|
633
|
+
- [ ] **Step 3: Build assets**
|
|
634
|
+
|
|
635
|
+
Run: `yarn build`
|
|
636
|
+
Expected: `app/assets/plutonium.js` (+ min + map) regenerated, no build errors.
|
|
637
|
+
|
|
638
|
+
- [ ] **Step 4: Commit**
|
|
639
|
+
|
|
640
|
+
```bash
|
|
641
|
+
git add src/js/controllers/kanban_controller.js app/assets/plutonium*.js app/assets/plutonium*.map
|
|
642
|
+
git commit -m "feat(kanban): open drop interaction modal with pending/snap-back"
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
---
|
|
646
|
+
|
|
647
|
+
### Task 7: Documentation + skill
|
|
648
|
+
|
|
649
|
+
**Goal:** Document `drop_interaction` in the guide, DSL reference, and the plutonium-kanban skill, including the two-flow model and the author contract.
|
|
650
|
+
|
|
651
|
+
**Files:**
|
|
652
|
+
- Modify: `docs/guides/kanban.md`
|
|
653
|
+
- Modify: `docs/reference/kanban/dsl.md`
|
|
654
|
+
- Modify: `.claude/skills/plutonium-kanban/SKILL.md`
|
|
655
|
+
|
|
656
|
+
**Acceptance Criteria:**
|
|
657
|
+
- [ ] Guide has a "Interaction on drop" section with a `column :lost, on_drop:…, drop_interaction: MarkLostInteraction` example and the `MarkLostInteraction` body.
|
|
658
|
+
- [ ] The `on_drop:` vs `drop_interaction:` split (move flow vs seed flow), the author contract (interaction owns extras; on_drop owns membership), and the `def mark_lost?` authorization gate are all stated.
|
|
659
|
+
- [ ] DSL reference table lists `drop_interaction:` under Column options.
|
|
660
|
+
- [ ] Skill `Column options` block + Authorization section mention it.
|
|
661
|
+
- [ ] `yarn docs:build` passes (no broken links).
|
|
662
|
+
|
|
663
|
+
**Verify:** `yarn docs:build`
|
|
664
|
+
|
|
665
|
+
**Steps:**
|
|
666
|
+
|
|
667
|
+
- [ ] **Step 1: Write the guide section, DSL row, and skill edits** (prose — mirror the Design contract above). Include the worked example and the `MarkLostInteraction` class.
|
|
668
|
+
- [ ] **Step 2: Build docs** — `yarn docs:build`, fix any broken links.
|
|
669
|
+
- [ ] **Step 3: Commit**
|
|
670
|
+
|
|
671
|
+
```bash
|
|
672
|
+
git add docs/ .claude/skills/plutonium-kanban/SKILL.md
|
|
673
|
+
git commit -m "docs(kanban): document drop_interaction"
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
### Task 8: End-to-end drive in the dummy app + system test
|
|
679
|
+
|
|
680
|
+
**Goal:** Prove the whole flow (drag → modal → reason → atomic commit; blank reason → 422 modal; cancel → snap-back) in the real dummy app and lock it with a system test.
|
|
681
|
+
|
|
682
|
+
**Files:**
|
|
683
|
+
- Modify (via generators only — see memory `feedback_always_use_generators`): a dummy resource + interaction, OR reuse `test/dummy/app/definitions/task_definition.rb` by adding a `:lost` column with `drop_interaction`. Add `lost_reason` via a migration (inline index per CLAUDE.md).
|
|
684
|
+
- Create: `test/system/**/kanban_drop_interaction_test.rb`
|
|
685
|
+
|
|
686
|
+
**Acceptance Criteria:**
|
|
687
|
+
- [ ] System test: drag a card to `:lost`, modal opens, submit with reason → card lands in `:lost`, `lost_reason` persisted, modal closed.
|
|
688
|
+
- [ ] System test: submit with blank reason → modal shows error, card not moved.
|
|
689
|
+
- [ ] System test: open modal, cancel → card snaps back to source column.
|
|
690
|
+
- [ ] Manual drive (see memory `reference_driving_dummy_app_browser`): confirm the same three flows in a browser against `test/dummy`.
|
|
691
|
+
|
|
692
|
+
**Verify:** `bundle exec appraisal rails-8.1 ruby -Itest test/system/**/kanban_drop_interaction_test.rb` (headless), plus a manual browser drive.
|
|
693
|
+
|
|
694
|
+
**Steps:**
|
|
695
|
+
|
|
696
|
+
- [ ] **Step 1: Add the dummy fixture** — migration for `lost_reason` (inline any index in `create_table`/`change_table`), a `MarkLostInteraction`, and the `:lost` column with `drop_interaction:` on the task definition. Add `def mark_lost? = update?` to the task policy.
|
|
697
|
+
- [ ] **Step 2: Write the system test** covering the three flows above.
|
|
698
|
+
- [ ] **Step 3: Run it headless.** Expected: PASS.
|
|
699
|
+
- [ ] **Step 4: Manual browser drive** per the memory note (seed test DB, login alice/password123), confirm modal open/commit/cancel visually.
|
|
700
|
+
- [ ] **Step 5: Commit**
|
|
701
|
+
|
|
702
|
+
```bash
|
|
703
|
+
git add test/ test/dummy/
|
|
704
|
+
git commit -m "test(kanban): system + dummy coverage for drop_interaction"
|
|
705
|
+
```
|
|
706
|
+
|
|
707
|
+
---
|
|
708
|
+
|
|
709
|
+
## Self-Review notes
|
|
710
|
+
|
|
711
|
+
- **Spec coverage:** DSL option (T0), registration/authz (T1), routing (T2), modal GET (T3), atomic commit + failure 422 (T4), DOM contract (T5), Stimulus pending/snap-back (T6), docs (T7), e2e (T8). All design-contract points map to a task.
|
|
712
|
+
- **Type consistency:** `drop_interaction_key` derives `:mark_lost`; used identically in T1 registration, T3/T4 authorization (`:"#{key}?"`), and the policy method `def mark_lost?`. `data-kanban-drop-interaction` / `data-kanban-drop-form-url-template` names match between T5 (emit) and T6 (read).
|
|
713
|
+
- **Open confirmations flagged inline** (not placeholders — real "verify exact path" steps): the interactive-action view/modal partial to mirror (T3), the `Action::Interactive::Factory` signature (T1), the remote-modal frame id (T6), and whether to route params through `submitted_interaction_params` for structured inputs (T4).
|
|
714
|
+
- **Verification requirement scan:** the original prompt requires NO user sign-off → no `requiresUserVerification` task needed.
|