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
@@ -20,7 +20,7 @@ module Plutonium
20
20
  # 1. Authorizes via kanban_move? policy predicate.
21
21
  # 2. Validates the drop (accepts? + locked?).
22
22
  # 3. Enforces the destination WIP limit (cross-column drops only).
23
- # 4. Applies the column's on_drop callback (Symbol or 1-arg Proc).
23
+ # 4. Applies the column's on_enter callback (Symbol or 1-arg Proc).
24
24
  # 5. Repositions within the destination column via position_config.
25
25
  # 6. Responds with Turbo Stream updates for the from + to column frames.
26
26
  # On rejection responds 422 and re-renders the unchanged source frame
@@ -32,15 +32,35 @@ module Plutonium
32
32
  module KanbanActions
33
33
  extend ActiveSupport::Concern
34
34
 
35
+ # Tags board-bound redirects with kanban_reload=1 so the permanent board
36
+ # refreshes its cached column frames on arrival (see #kanban_reload_url).
37
+ # Wraps ALL three redirect helpers — create/update (after_submit), destroy
38
+ # (after_destroy), and interactive record/resource/bulk actions
39
+ # (after_action_on). PREPENDED, not a plain override: after_action_on
40
+ # lives in InteractiveActions, which is included AFTER this concern, so a
41
+ # normal override here would be shadowed. Prepending wins regardless of
42
+ # include order, and `super` still reaches the real implementation.
43
+ module ReloadRedirects
44
+ def redirect_url_after_submit
45
+ kanban_reload_url(super)
46
+ end
47
+
48
+ def redirect_url_after_destroy
49
+ kanban_reload_url(super)
50
+ end
51
+
52
+ def redirect_url_after_action_on(*)
53
+ kanban_reload_url(super)
54
+ end
55
+ end
56
+
35
57
  included do
58
+ prepend ReloadRedirects
59
+
36
60
  # Intercept index when view=kanban + column=<key> is present.
37
61
  # Runs BEFORE setup_index_action! so no wasteful pagination query.
38
62
  before_action :maybe_render_kanban_column, only: :index
39
63
 
40
- # Pre-fill the new form with the column's seed attributes when the
41
- # user clicks "+ Add" on a kanban column (kanban_column= query param).
42
- before_action :apply_kanban_column_defaults!, only: :new
43
-
44
64
  # Exposed to views/partials so _resource_kanban.html.erb can call it.
45
65
  helper_method :build_kanban_board_shell
46
66
  end
@@ -57,10 +77,11 @@ module Plutonium
57
77
  def kanban_move
58
78
  # Find record within authorized scope (satisfies scope verifier).
59
79
  record = kanban_base_relation.find(params[:id])
60
- # Check move permission (satisfies authorize verifier).
61
- authorize_current! record, to: :kanban_move?
62
80
 
63
81
  unless current_definition.defined_kanban_block
82
+ # Not a kanban resource — a 404, not an authorized action; satisfy the
83
+ # authorize verifier explicitly since we skip the authorize below.
84
+ skip_verify_authorize_current!
64
85
  head :not_found
65
86
  return
66
87
  end
@@ -70,12 +91,33 @@ module Plutonium
70
91
  from = columns.find { |c| c.key.to_s == params[:from_column].to_s }
71
92
  to = columns.find { |c| c.key.to_s == params[:to_column].to_s }
72
93
 
73
- # accepts_record? evaluates Proc accepts: against the actual record
74
- # (returning the Proc's boolean result) while delegating to accepts?
75
- # semantics for true/false/Array values. This is the server-side
76
- # authority; the client-side data-kanban-accepts attribute (which
77
- # treats Proc as "all") is only a drop-hint.
78
- unless from && to&.accepts_record?(record, from.key) && !from.locked?
94
+ # Single authorization point for the whole move (satisfies the authorize
95
+ # verifier). kanban_move? defaults to update?; the from/to columns are
96
+ # supplied via the authorization context so a policy can gate specific
97
+ # transitions (e.g. "only admins may enter :closed_won") without a
98
+ # per-column method. Hoisted above the accepts/WIP checks so a denied
99
+ # move is a clean 403 before any structural rejection. The enter
100
+ # interaction (if any) rides on THIS check — it has no policy of its own.
101
+ authorize_current! record, to: :kanban_move?,
102
+ context: {kanban_from: from, kanban_to: to}
103
+
104
+ # params[:from_column] is client-supplied and is passed to the policy as
105
+ # kanban_from. Verify the record ACTUALLY resides in the claimed source
106
+ # column before the move proceeds. This (a) makes kanban_from safe to
107
+ # authorize on — a spoofed from can't drive a move because the membership
108
+ # check rejects it — and (b) snaps back a stale-board drag whose card was
109
+ # moved out of `from` by someone else. Skipped when from is nil (the
110
+ # accepts check below handles an unknown source column).
111
+ if from && !record_in_kanban_column?(record, from)
112
+ return render_kanban_rejection(
113
+ params[:from_column],
114
+ reason: "This card is no longer in “#{from.label}”."
115
+ )
116
+ end
117
+
118
+ # accepts?/locked? are purely structural (source-column topology), the
119
+ # server-side authority behind the client-side data-kanban-accepts hint.
120
+ unless from && to&.accepts?(from.key) && !from.locked?
79
121
  reason =
80
122
  if from&.locked?
81
123
  "Cards can't be moved out of “#{from.label}”."
@@ -92,7 +134,12 @@ module Plutonium
92
134
  # (cross-column, same-column reorder, record already in destination).
93
135
  dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, to.scope)
94
136
  dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
95
- to_index = params[:to_index].to_i
137
+ # to_index is client-supplied. Clamp to [0, dest_cards.size] so a negative
138
+ # value can't wrap via Ruby's negative array indexing (dest_cards[-1] would
139
+ # silently anchor the drop to the LAST card) and an over-large value simply
140
+ # appends. The real client only ever sends 0..dest_cards.size; this hardens
141
+ # the crafted-request path.
142
+ to_index = params[:to_index].to_i.clamp(0, dest_cards.size)
96
143
 
97
144
  # WIP limit only applies to cross-column drops (reordering within the
98
145
  # same column does not change its cardinality). This is a
@@ -108,25 +155,104 @@ module Plutonium
108
155
  prev_record = (to_index > 0) ? dest_cards[to_index - 1] : nil
109
156
  next_record = dest_cards[to_index]
110
157
 
158
+ # Holds the enter_interaction outcome (when the destination declares
159
+ # one) so the post-transaction branch can distinguish a rolled-back
160
+ # failure from a successful atomic commit.
161
+ outcome = nil
162
+
163
+ # A same-column reorder (from.key == to.key) changes only rank, not
164
+ # membership, so it runs ONLY the positioning code — no on_enter, no
165
+ # enter_interaction. Both on_enter (the membership write) and
166
+ # enter_interaction (the transition) represent ENTERING a column; neither
167
+ # should fire when the card is already in it. Task 6 (client) must mirror
168
+ # this: a same-column drop posts a plain reposition and opens no modal.
169
+ cross_column = from.key != to.key
170
+ run_enter_interaction = to.enter_interaction? && cross_column
171
+
172
+ # A dynamic board (`columns do…end`) can't register its enter_interaction
173
+ # as an action at class-load time (its columns only exist per-request), so
174
+ # defined_actions has no entry for the column-scoped key and the interactive
175
+ # machinery below (build_interactive_record_action_interaction) would blow
176
+ # up. Reject gracefully with a clear log instead of a 500 morphing onto the
177
+ # board. (Static boards always register — see Definition::IndexViews.)
178
+ if run_enter_interaction && current_definition.defined_actions[to.enter_interaction_key].nil?
179
+ Rails.logger.warn { "[plutonium] kanban enter_interaction on column `#{to.key}` is not registered — enter_interaction is unsupported on dynamic (`columns do…end`) boards; rejecting the drop." }
180
+ return render_kanban_rejection(params[:from_column], reason: "This drop can’t be completed.")
181
+ end
182
+
183
+ # An input-less drop interaction is `immediate` — the client commits it
184
+ # via a DIRECT POST (no modal), so the response must be a Turbo Stream,
185
+ # not modal-form HTML (see the failure branch below).
186
+ drop_immediate = run_enter_interaction &&
187
+ !!current_definition.defined_actions[to.enter_interaction_key]&.immediate
188
+
189
+ # Bind the enter_interaction's auto-registered hidden record action (so
190
+ # interaction_params / build_interactive_record_action_interaction
191
+ # resolve it) and reuse the already-loaded record as resource_record!
192
+ # (param-extraction subject + form URL) — no re-query, no divergent copy.
193
+ # No authorize here: the move was already authorized by kanban_move?
194
+ # above, and the interaction has no policy method of its own.
195
+ if run_enter_interaction
196
+ params[:interactive_action] = to.enter_interaction_key
197
+ @resource_record = record
198
+ end
199
+
111
200
  ActiveRecord::Base.transaction do
112
- # Apply on_drop:
201
+ # (1) Apply on_exit (SOURCE column) then on_enter (DESTINATION column),
202
+ # CROSS-column moves only. A same-column reorder skips both (see
203
+ # cross_column above) and only repositions; on_exit/on_enter represent
204
+ # LEAVING and ENTERING a column, which a reorder does not do.
205
+ #
206
+ # on_exit runs FIRST, so it sees the pre-move state (still "in" from) —
207
+ # the counterpart hook for source-tied side effects (stop a timer,
208
+ # release a slot) that the destination's on_enter can't own.
209
+ #
113
210
  # Symbol → record.public_send(sym) (named method on the record)
114
211
  # Proc → evaluated with self = kanban_context (delegates to
115
212
  # view_context so `current_user` etc. work as bare calls)
116
213
  # and the record as the single block arg, matching the
117
- # public 1-arg DSL form: on_drop: ->(task) { task.status = … }
118
- if to.on_drop.is_a?(Symbol)
119
- record.public_send(to.on_drop)
120
- elsif to.on_drop
121
- kanban_context.instance_exec(record, &to.on_drop)
214
+ # public 1-arg DSL form: on_enter: ->(task) { task.status = … }
215
+ if cross_column
216
+ if from.on_exit.is_a?(Symbol)
217
+ record.public_send(from.on_exit)
218
+ elsif from.on_exit
219
+ kanban_context.instance_exec(record, &from.on_exit)
220
+ end
221
+
222
+ if to.on_enter.is_a?(Symbol)
223
+ record.public_send(to.on_enter)
224
+ elsif to.on_enter
225
+ kanban_context.instance_exec(record, &to.on_enter)
226
+ end
227
+
228
+ # Persist any in-memory attribute changes from on_exit/on_enter
229
+ # (blocks that call update! directly are already saved; this is a
230
+ # safety net for blocks that only assign attributes).
231
+ record.save! if record.changed?
122
232
  end
123
233
 
124
- # Persist any in-memory attribute changes from on_drop (on_drop
125
- # blocks that call update! directly are already saved; this is a
126
- # safety net for blocks that only assign attributes).
127
- record.save! if record.changed?
234
+ # (2) Drop interaction runs against the SAME record instance,
235
+ # atomic with the move. Only fires on a CROSS-column move (entering a
236
+ # new column is the transition the interaction represents); a
237
+ # same-column reorder skips it (see run_enter_interaction above). The
238
+ # interactive_action binding + @resource_record + authorize were
239
+ # hoisted out above the transaction.
240
+ if run_enter_interaction
241
+ # build_interactive_record_action_interaction renders the action
242
+ # form to EXTRACT its params (structured inputs / choices). It runs
243
+ # INSIDE the transaction on purpose: so a `choices:` proc (or any
244
+ # form logic) sees the post-on_enter record state. Do NOT hoist this
245
+ # out — doing so would change the record state the form is built
246
+ # against and silently alter param-extraction semantics.
247
+ build_interactive_record_action_interaction
248
+ outcome = @interaction.call
249
+ # Interaction validation failed → undo the on_enter write (and any
250
+ # partial execute) so nothing persists. The re-render happens
251
+ # after the transaction so the rollback is fully applied first.
252
+ raise ActiveRecord::Rollback if outcome.failure?
253
+ end
128
254
 
129
- # Reposition within the destination column.
255
+ # (3) Reposition within the destination column.
130
256
  # Mode A delegates to record.reposition! (calls update! for position).
131
257
  # Mode B calls the user-supplied block.
132
258
  # Mode C is a no-op (no ordering; position unchanged).
@@ -138,36 +264,212 @@ module Plutonium
138
264
  index: to_index
139
265
  )
140
266
 
141
- # Final save covers Mode C where reposition! is a no-op but on_drop
267
+ # Final save covers Mode C where reposition! is a no-op but on_enter
142
268
  # only assigned in memory, or any other unsaved attribute changes.
143
269
  record.save! if record.changed?
144
270
  end
145
271
 
272
+ # Interaction failed → the transaction rolled back. Re-render the SAME
273
+ # modal (422) with the validation errors + the submitted hidden move
274
+ # fields, so the user can correct the input and resubmit the move.
275
+ if run_enter_interaction && outcome&.failure?
276
+ # Immediate interactions were committed via a direct POST (no modal
277
+ # open), so the client is processing a Turbo Stream response — render a
278
+ # snap-back rejection toast rather than modal-form HTML, which the
279
+ # stream-expecting fetch would silently drop. Input-collecting
280
+ # interactions re-render their modal with the errors so the user can
281
+ # fix the input and resubmit.
282
+ if drop_immediate
283
+ reason = @interaction.errors.full_messages.to_sentence.presence ||
284
+ "“#{to.label}” could not be applied."
285
+ return render_kanban_rejection(from.key, reason:)
286
+ end
287
+ return render :kanban_move_form, formats: [:html], **modal_render_options, status: :unprocessable_content
288
+ end
289
+
146
290
  respond_to do |format|
147
291
  format.turbo_stream do
148
- streams = [turbo_stream.update("kanban-col-#{from.key}", render_kanban_column_html(from))]
149
- streams << turbo_stream.update("kanban-col-#{to.key}", render_kanban_column_html(to)) if from.key != to.key
292
+ # The column-frame updates are what other viewers need to see — this
293
+ # is the shared, broadcastable payload.
294
+ column_streams = [turbo_stream.update("kanban-col-#{from.key}", render_kanban_column_html(from))]
295
+ column_streams << turbo_stream.update("kanban-col-#{to.key}", render_kanban_column_html(to)) if from.key != to.key
150
296
 
151
- # Broadcast the same frame updates to other connected viewers of this
297
+ # Broadcast the frame updates to other connected viewers of this
152
298
  # board, when realtime broadcasting is enabled. The mover will also
153
299
  # receive this broadcast (they are subscribed to the stream too) — but
154
300
  # re-rendering the same frames is idempotent, so the double update is
155
- # harmless.
301
+ # harmless. The modal-close stream is deliberately EXCLUDED: only the
302
+ # mover has this modal open, so closing it for everyone would blow
303
+ # away an unrelated modal another viewer might have open.
156
304
  if board.realtime?
157
305
  Plutonium::Kanban::Broadcaster.broadcast(
158
306
  resource_class: resource_class,
159
307
  scoped_entity: scoped_to_entity? ? current_scoped_entity : nil,
160
- content: streams.join
308
+ content: column_streams.join
161
309
  )
162
310
  end
163
311
 
312
+ streams = column_streams
313
+ # When the move arrived via the drop-interaction modal (cross-column
314
+ # only), close that modal by emptying the remote-modal frame AND
315
+ # surface the interaction's success message(s) as toast(s). Both are
316
+ # mover-only: only this viewer has the modal open, so they are
317
+ # deliberately EXCLUDED from the realtime broadcast above (appended
318
+ # to `streams`, never to `column_streams`). Plain moves and
319
+ # same-column reorders aren't in a modal, so nothing is appended.
320
+ if run_enter_interaction
321
+ streams += [turbo_stream.update(Plutonium::REMOTE_MODAL_FRAME, "")]
322
+ outcome.messages.each do |msg, type|
323
+ streams += [turbo_stream.append("kanban-flash", partial: "plutonium/toast",
324
+ locals: {type: ((type == :notice) ? :success : type), msg:})]
325
+ end
326
+ end
327
+
164
328
  render turbo_stream: streams
165
329
  end
166
330
  end
331
+ rescue ::ActionPolicy::Unauthorized
332
+ # NOTE: the leading :: is required — Plutonium::ActionPolicy exists
333
+ # (action_policy/sti_policy_lookup.rb), so a bare ActionPolicy would
334
+ # resolve to that namespace and never match the raised exception,
335
+ # letting it fall through to the global rescue_from (which re-raises for
336
+ # turbo_stream requests → the HTML error page morph this fix prevents).
337
+ #
338
+ # A denied transition — the kanban_move? gate (the single move
339
+ # authorization; the enter_interaction has no policy of its own). Snap
340
+ # the source column back with a toast at 403 instead of letting the HTML
341
+ # error page reach the client: the drag POST expects a Turbo Stream, so
342
+ # a raw error page would be morphed into the board (the "page turns red"
343
+ # bug). Rendering a stream here keeps rejection feedback consistent with
344
+ # the WIP / accepts snap-backs above.
345
+ #
346
+ # authorize_count only bumps AFTER a successful authorize, so a denial
347
+ # raised by the board-wide gate (before any successful check) leaves the
348
+ # verifier unsatisfied — we've handled authorization by rejecting, so
349
+ # skip it explicitly.
350
+ skip_verify_authorize_current!
351
+ render_kanban_rejection(
352
+ params[:from_column],
353
+ reason: "You are not authorized to move this card there.",
354
+ status: :forbidden
355
+ )
356
+ rescue ActiveRecord::RecordNotFound
357
+ # The card was destroyed (e.g. concurrently) between board render and
358
+ # drop, so `find` raised. Snap the source column back — re-rendering it
359
+ # drops the now-gone card — instead of letting Rails' 404 HTML page get
360
+ # morphed into the board (same class of bug as the ActionPolicy rescue).
361
+ # `find` raised before authorize_current!, so satisfy that verifier; the
362
+ # scope verifier is already satisfied by kanban_base_relation.
363
+ skip_verify_authorize_current!
364
+ render_kanban_rejection(params[:from_column], reason: "This card no longer exists.")
365
+ rescue ActiveRecord::RecordInvalid => e
366
+ # An on_exit/on_enter hook (or the interaction) left the record invalid,
367
+ # so save! raised and the transaction rolled back. Snap back with the
368
+ # validation reason rather than let a 500 HTML page morph into the board.
369
+ reason = e.record.errors.full_messages.to_sentence.presence ||
370
+ "This card could not be moved."
371
+ render_kanban_rejection(params[:from_column], reason:)
372
+ end
373
+
374
+ # GET <member>/kanban_move_form?from_column=&to_column=&to_index=
375
+ #
376
+ # Renders the drop-interaction modal for a card dropped into a column
377
+ # that declares a `enter_interaction:`. The modal shows the interaction's
378
+ # normal form, but wired to POST to `kanban_move` (Task 4) carrying the
379
+ # move context as hidden fields so the interaction runs AND the card is
380
+ # repositioned in one atomic request.
381
+ def kanban_move_form
382
+ @resource_record = kanban_base_relation.find(params[:id])
383
+ record = @resource_record
384
+ from = kanban_column_for(params[:from_column])
385
+ to = kanban_column_for(params[:to_column])
386
+
387
+ # No interaction to open a form for (invalid drop), OR the interaction is
388
+ # unregistered because this is a dynamic (`columns do…end`) board — which
389
+ # can't register enter_interactions and so can't render the modal chrome
390
+ # (current_interactive_action would be nil). Either way the drop is not
391
+ # actionable on this path — satisfy the verifier and bail cleanly instead
392
+ # of 500-ing in the view.
393
+ unless to&.enter_interaction? && current_definition.defined_actions[to.enter_interaction_key]
394
+ skip_verify_authorize_current!
395
+ head :unprocessable_content
396
+ return
397
+ end
398
+
399
+ # Same single gate as kanban_move: authorize the move via kanban_move?
400
+ # with the from/to columns in context (the interaction has no policy of
401
+ # its own). Opening the form is authorizing the move it will commit.
402
+ authorize_current! record, to: :kanban_move?,
403
+ context: {kanban_from: from, kanban_to: to}
404
+
405
+ # Belt-and-suspenders structural gate, mirroring kanban_move (POST) in
406
+ # the same order (membership → accepts?/locked?). Don't open a modal for
407
+ # a drop the commit will inevitably reject: the user would fill in the
408
+ # interaction form only to eat a 422 snap-back on submit. The client's
409
+ # accepts hint normally blocks this before the modal opens, but a stale
410
+ # board or a crafted request can still reach here. On rejection we render
411
+ # the SAME turbo-stream snap-back the POST does (Turbo processes it from
412
+ # the frame.src navigation) instead of the doomed form. authorize already
413
+ # succeeded (counter bumped), so these returns need no skip_verify.
414
+ if from && !record_in_kanban_column?(record, from)
415
+ return render_kanban_rejection(
416
+ params[:from_column],
417
+ reason: "This card is no longer in “#{from.label}”."
418
+ )
419
+ end
420
+ unless from && to.accepts?(from.key) && !from.locked?
421
+ reason =
422
+ if from&.locked?
423
+ "Cards can't be moved out of “#{from.label}”."
424
+ else
425
+ "Cards can't be moved into “#{to.label}”."
426
+ end
427
+ return render_kanban_rejection(params[:from_column], reason:)
428
+ end
429
+
430
+ # Bind the enter_interaction's auto-registered record action as the
431
+ # current interactive action so the modal chrome (title, description,
432
+ # modal mode/size) resolves exactly like a standard record action.
433
+ params[:interactive_action] = to.enter_interaction_key
434
+
435
+ @interaction = to.enter_interaction.new(view_context:)
436
+ @interaction.resource = record
437
+
438
+ render :kanban_move_form, formats: [:html], **modal_render_options
439
+ rescue ::ActionPolicy::Unauthorized
440
+ # A denied transition into a enter_interaction column. Without this the
441
+ # exception reaches the global rescue_from, which RE-RAISES for html/
442
+ # turbo_stream (core/controller.rb) → a 403 HTML error page. The client
443
+ # opened this form via `frame.src`, so that error page lands in the
444
+ # remote-modal frame → a broken "content missing" modal. Render the same
445
+ # turbo-stream rejection the kanban_move POST does instead: the card was
446
+ # never moved (native DnD doesn't re-parent), so this just empties the
447
+ # modal frame, re-asserts the source column, and toasts the denial.
448
+ # authorize_current! raised before bumping its counter, so satisfy the
449
+ # verifier explicitly (mirrors the RecordNotFound branch below).
450
+ skip_verify_authorize_current!
451
+ render_kanban_rejection(
452
+ params[:from_column],
453
+ reason: "You are not authorized to move this card there.",
454
+ status: :forbidden
455
+ )
456
+ rescue ActiveRecord::RecordNotFound
457
+ # Card destroyed between board render and the modal-open request. `find`
458
+ # raised before authorize, so satisfy that verifier; return a plain 404
459
+ # (this GET only loads the modal frame — there is no board to morph).
460
+ skip_verify_authorize_current!
461
+ head :not_found
167
462
  end
168
463
 
169
464
  private
170
465
 
466
+ # Resolves a kanban column by its key (String/Symbol). Compares keys as
467
+ # strings so arbitrary request input isn't interned into symbols.
468
+ def kanban_column_for(key)
469
+ columns = Plutonium::Kanban::Grouping.resolve_columns(current_kanban_board, kanban_context)
470
+ columns.find { |c| c.key.to_s == key.to_s }
471
+ end
472
+
171
473
  # Builds the kanban board shell component for the index page.
172
474
  #
173
475
  # Used by the _resource_kanban partial (Task 10). The shell renders one
@@ -180,7 +482,13 @@ module Plutonium
180
482
  def build_kanban_board_shell
181
483
  board = current_kanban_board
182
484
  columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
183
- grouped_data = columns.map { |col| {column: col, cards: [], total: 0} }
485
+ # collapsed: the effective (cookie-resolved) state, so the lazy-frame
486
+ # placeholder renders in the SAME shape the loaded column will — a
487
+ # collapsed column shows a strip from the first paint instead of a full
488
+ # header that then snaps to a strip.
489
+ grouped_data = columns.map do |col|
490
+ {column: col, cards: [], total: 0, collapsed: kanban_effective_collapsed(col)}
491
+ end
184
492
  Plutonium::UI::Kanban::Resource.new(
185
493
  board:,
186
494
  grouped_data:,
@@ -289,7 +597,29 @@ module Plutonium
289
597
  end
290
598
 
291
599
  column_add_url = if column.add? && current_policy.allowed_to?(:create?)
292
- resource_url_for(resource_class, action: :new, kanban_column: column.key)
600
+ resource_url_for(resource_class, action: :new, kanban_column: column.key,
601
+ return_to: kanban_board_url)
602
+ end
603
+
604
+ # Drop-interaction columns advertise the kanban_move_form member URL
605
+ # (with __ID__ for the dragged card's id) so the client opens the
606
+ # interaction modal on drop. Mirrors the board's move-url template
607
+ # (Kanban::Resource#kanban_move_url_template) — same collection path +
608
+ # __ID__ shape, only the trailing route segment differs (kanban_move_form
609
+ # vs kanban_move). Derived from resource_url_for (not request.path)
610
+ # because this method also runs under the kanban_move POST, whose
611
+ # request.path is the member move URL, not the collection path.
612
+ drop_form_url_template = nil
613
+ drop_immediate = false
614
+ drop_confirm = nil
615
+ if column.enter_interaction?
616
+ drop_form_url_template = "#{resource_url_for(resource_class).delete_suffix("/")}/__ID__/kanban_move_form"
617
+ # Look up the auto-registered drop action to honour its shape: an
618
+ # input-less interaction is `immediate` (commit directly, no modal),
619
+ # and carries an auto "<label>?" confirmation unless one was set.
620
+ registered_drop = current_definition.defined_actions[column.enter_interaction_key]
621
+ drop_immediate = registered_drop&.immediate || false
622
+ drop_confirm = registered_drop&.confirmation
293
623
  end
294
624
 
295
625
  component = Plutonium::UI::Kanban::Column.new(
@@ -301,12 +631,72 @@ module Plutonium
301
631
  resource_fields: permitted_attributes_for("index"),
302
632
  column_action_data:,
303
633
  column_add_url:,
634
+ board_url: kanban_board_url,
304
635
  card_fields: board.card_fields,
305
- card_show_frame: kanban_card_show_frame(board)
636
+ card_show_frame: kanban_card_show_frame(board),
637
+ collapsed: kanban_effective_collapsed(column),
638
+ drop_form_url_template:,
639
+ drop_immediate:,
640
+ drop_confirm:
306
641
  )
307
642
  view_context.render(component).html_safe
308
643
  end
309
644
 
645
+ # The board's own URL — the collection path with view=kanban and any
646
+ # active board query (search / filter / scope), minus the per-column
647
+ # frame param. Used as the quick-add return_to so creating a card returns
648
+ # the user to the board (at their scroll + filters) instead of the new
649
+ # record's show page. Built from resource_url_for rather than request.path
650
+ # because this also runs under the kanban_move POST, whose request path is
651
+ # the member move URL, not the collection path.
652
+ def kanban_board_url
653
+ board_params = request.query_parameters.except("column", "format").merge("view" => "kanban")
654
+ "#{resource_url_for(resource_class)}?#{board_params.to_query}"
655
+ end
656
+
657
+ # Tags a board-bound redirect with the one-shot kanban_reload marker (and
658
+ # normalizes it to the FULL board) so a write/action that returns to the
659
+ # permanent board doesn't show stale columns (a new card missing, a
660
+ # deleted/archived one lingering). The kanban Stimulus controller consumes
661
+ # the marker on connect, re-fetches the column frames, and strips it from
662
+ # the URL. No-op for non-kanban resources and for redirects that don't
663
+ # land on the board (a show page, the table index). Called from
664
+ # ReloadRedirects for all three redirect helpers.
665
+ #
666
+ # Also strips column= so the redirect targets the full board rather than
667
+ # the bare single-column frame endpoint (maybe_render_kanban_column
668
+ # intercepts view=kanban + column=<key>). A card's edit/delete button
669
+ # rendered inside a lazy column frame defaults its return_to to that
670
+ # frame's URL, which carries column=<key>; without this strip the write
671
+ # would redirect onto that fragment instead of the board.
672
+ def kanban_reload_url(url)
673
+ return url unless current_definition.defined_kanban_board
674
+ uri = URI.parse(url.to_s)
675
+ params = Rack::Utils.parse_query(uri.query)
676
+ return url unless params["view"] == "kanban"
677
+ params.delete("column")
678
+ params["kanban_reload"] = "1"
679
+ uri.query = params.to_query
680
+ uri.to_s
681
+ end
682
+
683
+ # The user's persisted collapse choice for this column, resolved against
684
+ # the column default. The cookie stores only columns flipped FROM their
685
+ # default (see Kanban::Resource.collapse_cookie_name), so a listed key
686
+ # means "the opposite of the default". Rendering this server-side is what
687
+ # keeps the board in the user's state across morph/stream/reload with no
688
+ # client re-apply — and therefore no flash.
689
+ def kanban_effective_collapsed(column)
690
+ flipped = kanban_collapse_flips.include?(column.key.to_s)
691
+ flipped ? !column.collapsed? : column.collapsed?
692
+ end
693
+
694
+ def kanban_collapse_flips
695
+ @kanban_collapse_flips ||= Plutonium::UI::Kanban::Resource.collapse_flips(
696
+ cookies[Plutonium::UI::Kanban::Resource.collapse_cookie_name(resource_class)]
697
+ )
698
+ end
699
+
310
700
  # Resolves the turbo-frame a card's show link targets, from the board's
311
701
  # effective show_in (the board's own value, or the definition's when the
312
702
  # board doesn't override it):
@@ -347,73 +737,58 @@ module Plutonium
347
737
  end
348
738
  end
349
739
 
350
- # Injects the column's seed attributes into params so the new form
351
- # pre-fills the grouping attribute (e.g. status="todo").
740
+ # Kanban quick-add, POST-create. The column "+ Add" link opens the normal
741
+ # New form carrying kanban_column (threaded to the create POST as a hidden
742
+ # field). After the record is created — with the model's DEFAULT grouping
743
+ # value, which the app author is responsible for setting — this applies the
744
+ # column's on_enter to the freshly-persisted record and positions it into
745
+ # the column. on_enter therefore runs against a REAL record exactly as it
746
+ # does for a drag; there is no dry-run / no stubbing.
352
747
  #
353
- # Triggered by the kanban_column= query param that the "+ Add" link
354
- # carries. The seed is extracted by running a DRY-RUN of on_drop against
355
- # a sentinel record whose save/update! methods are intercepted to prevent
356
- # any DB write. The resulting attribute changes are merged into the
357
- # resource params so maybe_apply_submitted_resource_params! sees them and
358
- # pre-populates @resource_record before the form renders.
359
- def apply_kanban_column_defaults!
748
+ # Failure policy (see after_create_persisted rescue): the create is NEVER
749
+ # rolled back. If on_enter/positioning raises, the record is KEPT in its
750
+ # default column (validly positioned there by the model's before_create
751
+ # hook) and the failure is logged + toasted.
752
+ def after_create_persisted
753
+ super
360
754
  return unless params[:kanban_column].present?
361
755
  return unless current_definition.defined_kanban_block
362
756
 
363
- board = current_kanban_board
364
- columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
365
- column = columns.find { |c| c.key.to_s == params[:kanban_column].to_s }
757
+ column = kanban_column_for(params[:kanban_column])
366
758
  return unless column&.add?
367
759
 
368
- # A raising on_drop must not 500 the new form — degrade to an unseeded
369
- # form so the user can still create the record (and set the grouping
370
- # field manually).
371
- seed_attrs = begin
372
- kanban_column_on_drop_seed(column)
373
- rescue => e
374
- Rails.logger.warn { "kanban quick-add seed failed for column #{column.key}: #{e.message}" }
375
- return
376
- end
377
- return if seed_attrs.blank?
378
-
379
- # Inject into params (indifferent access — string key is fine).
380
- # Use ||= so an explicit user-provided value in the URL is preserved.
381
- params[resource_param_key] ||= ActionController::Parameters.new({})
382
- seed_attrs.stringify_keys.each { |k, v| params[resource_param_key][k] ||= v }
760
+ apply_kanban_column_enter!(resource_record!, column)
761
+ rescue => e
762
+ Rails.logger.error { "[plutonium] kanban quick-add on_enter failed for column #{params[:kanban_column].inspect}: #{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}" }
763
+ flash[:alert] = "Couldn’t place it in “#{column&.label || params[:kanban_column]}” — it stayed in its default column."
383
764
  end
384
765
 
385
- # Runs on_drop against a sentinel record that intercepts save/update!
386
- # calls so no row is written to the DB. Returns the attribute changes
387
- # the on_drop block would have applied (e.g. {"status" => "todo"}).
388
- #
389
- # NOTE: this only stubs save/save!/update/update! on the sentinel record.
390
- # An on_drop that has external side effects (enqueuing jobs, API calls,
391
- # touching OTHER records) would fire those side effects on every "+ Add"
392
- # click, since they bypass the stubbed methods. This is acceptable for
393
- # the common `attr = value` / `update!(attr: value)` pattern but is a
394
- # footgun for exotic on_drop callbacks.
395
- def kanban_column_on_drop_seed(column)
396
- return {} unless column.on_drop
397
-
398
- seed = resource_class.new
399
- seed.define_singleton_method(:update!) { |attrs = {}|
400
- assign_attributes(attrs)
401
- self
402
- }
403
- seed.define_singleton_method(:update) { |attrs = {}|
404
- assign_attributes(attrs)
405
- true
406
- }
407
- seed.define_singleton_method(:save!) { |**| true }
408
- seed.define_singleton_method(:save) { |**| true }
409
-
410
- if column.on_drop.is_a?(Symbol)
411
- seed.public_send(column.on_drop)
412
- else
413
- kanban_context.instance_exec(seed, &column.on_drop)
766
+ # Applies a column's on_enter to an already-persisted record, then appends
767
+ # it to the END of that column. Mirrors kanban_move's on_enter + reposition
768
+ # steps (Symbol record.public_send; Proc instance_exec in kanban_context),
769
+ # against the real record, so update!-style callbacks behave exactly as they
770
+ # do on a drag.
771
+ def apply_kanban_column_enter!(record, column)
772
+ if column.on_enter.is_a?(Symbol)
773
+ record.public_send(column.on_enter)
774
+ elsif column.on_enter
775
+ kanban_context.instance_exec(record, &column.on_enter)
414
776
  end
777
+ record.save! if record.changed?
415
778
 
416
- seed.changes.transform_values { |(_, new_val)| new_val }
779
+ # Append to the end of the destination column (prev = last card, next =
780
+ # nil), via the board's position_config — the same path a drag-drop uses.
781
+ board = current_kanban_board
782
+ dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, column.scope)
783
+ dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
784
+ board.position_config.reposition!(
785
+ record:,
786
+ column: column.key,
787
+ prev_record: dest_cards.last,
788
+ next_record: nil,
789
+ index: dest_cards.size
790
+ )
791
+ record.save! if record.changed?
417
792
  end
418
793
 
419
794
  # Renders a 422 turbo stream response that re-renders the source column
@@ -425,7 +800,7 @@ module Plutonium
425
800
  # so a stale, undisplayed flash from an earlier request can't leak into
426
801
  # the turbo_stream response — these move POSTs never render the layout
427
802
  # that would otherwise consume the flash.
428
- def render_kanban_rejection(from_key, reason: nil)
803
+ def render_kanban_rejection(from_key, reason: nil, status: :unprocessable_content)
429
804
  streams = [
430
805
  turbo_stream.update(
431
806
  "kanban-col-#{from_key}",
@@ -441,7 +816,28 @@ module Plutonium
441
816
  )
442
817
  end
443
818
 
444
- render turbo_stream: streams, status: :unprocessable_content
819
+ # Close the drop-interaction modal if one is open. A rejection can arrive
820
+ # AFTER an enter_interaction modal submit (a non-immediate column that also
821
+ # has accepts:/wip:, or an auth/validation failure), and the success and
822
+ # interaction-failure paths close the modal but the structural rejections
823
+ # did not — leaving it open behind the snap-back toast. Emptying the frame
824
+ # is idempotent for plain drags (no modal is open during a drag, so the
825
+ # remote-modal frame is already empty → no-op), so it is safe to always
826
+ # append here.
827
+ streams << turbo_stream.update(Plutonium::REMOTE_MODAL_FRAME, "")
828
+
829
+ render turbo_stream: streams, status:
830
+ end
831
+
832
+ # Whether `record` currently resides in `column`'s scope. Used to verify a
833
+ # client-claimed source column before trusting it for authorization / the
834
+ # move. A nil column or a scope-less column contains everything (true), so
835
+ # the caller's own nil/accepts handling takes over.
836
+ def record_in_kanban_column?(record, column)
837
+ return true if column.nil? || column.scope.nil?
838
+ Plutonium::Kanban::Grouping
839
+ .apply_scope(kanban_base_relation, column.scope)
840
+ .exists?(record.id)
445
841
  end
446
842
 
447
843
  # Evaluation context for dynamic `columns do…end` blocks — delegates to