Module: Plutonium::Resource::Controllers::KanbanActions

Extended by:
ActiveSupport::Concern
Included in:
Plutonium::Resource::Controller
Defined in:
lib/plutonium/resource/controllers/kanban_actions.rb

Overview

Provides kanban-board endpoints for resources that declare a kanban block.

Lazy column frame endpoint (Task 6)

When a request hits the index action with view=kanban AND column=, this concern intercepts via a before_action, renders ONLY the column's frame body (Plutonium::UI::Kanban::Column), and halts the normal index render. Unknown/absent column keys produce an empty frame body.

Kanban move action (Task 7)

POST /kanban_move with params to_column:, to_index: moves the member record to a new column and/or position. The action:

1. Authorizes via kanban_move? policy predicate.
2. Validates the drop (accepts? + locked?).
3. Enforces the destination WIP limit (cross-column drops only).
4. Applies the column's on_enter callback (Symbol or 1-arg Proc).
5. Repositions within the destination column via position_config.
6. Responds with Turbo Stream updates for the from + to column frames.
 On rejection responds 422 and re-renders the unchanged source frame
 so the Stimulus controller can snap the card back.

Seam for Task 10 (full board shell):

maybe_render_kanban_column only fires when params[:column] is present.
Task 10 should handle the view=kanban case WITHOUT params[:column].

Defined Under Namespace

Modules: ReloadRedirects

Instance Method Summary collapse

Instance Method Details

#kanban_moveObject

POST /kanban_move

Params:

from_column [String] source column key
to_column   [String] destination column key
to_index    [Integer] 0-based insertion index within destination

Responds with Turbo Streams updating the from + to column frames on success, or 422 re-rendering the unchanged source frame on rejection.



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
# File 'lib/plutonium/resource/controllers/kanban_actions.rb', line 77

def kanban_move
  # Find record within authorized scope (satisfies scope verifier).
  record = kanban_base_relation.find(params[:id])

  unless current_definition.defined_kanban_block
    # Not a kanban resource — a 404, not an authorized action; satisfy the
    # authorize verifier explicitly since we skip the authorize below.
    skip_verify_authorize_current!
    head :not_found
    return
  end

  board = current_kanban_board
  columns = Plutonium::Kanban::Grouping.resolve_columns(board, kanban_context)
  from = columns.find { |c| c.key.to_s == params[:from_column].to_s }
  to = columns.find { |c| c.key.to_s == params[:to_column].to_s }

  # Single authorization point for the whole move (satisfies the authorize
  # verifier). kanban_move? defaults to update?; the from/to columns are
  # supplied via the authorization context so a policy can gate specific
  # transitions (e.g. "only admins may enter :closed_won") without a
  # per-column method. Hoisted above the accepts/WIP checks so a denied
  # move is a clean 403 before any structural rejection. The enter
  # interaction (if any) rides on THIS check — it has no policy of its own.
  authorize_current! record, to: :kanban_move?,
    context: {kanban_from: from, kanban_to: to}

  # params[:from_column] is client-supplied and is passed to the policy as
  # kanban_from. Verify the record ACTUALLY resides in the claimed source
  # column before the move proceeds. This (a) makes kanban_from safe to
  # authorize on — a spoofed from can't drive a move because the membership
  # check rejects it — and (b) snaps back a stale-board drag whose card was
  # moved out of `from` by someone else. Skipped when from is nil (the
  # accepts check below handles an unknown source column).
  if from && !record_in_kanban_column?(record, from)
    return render_kanban_rejection(
      params[:from_column],
      reason: "This card is no longer in “#{from.label}”."
    )
  end

  # accepts?/locked? are purely structural (source-column topology), the
  # server-side authority behind the client-side data-kanban-accepts hint.
  unless from && to&.accepts?(from.key) && !from.locked?
    reason =
      if from&.locked?
        "Cards can't be moved out of “#{from.label}”."
      elsif to
        "Cards can't be moved into “#{to.label}”."
      else
        "This card can't be moved there."
      end
    return render_kanban_rejection(params[:from_column], reason:)
  end

  # Build the destination card list excluding the moved record so the
  # neighbor computation and WIP count are correct in all cases
  # (cross-column, same-column reorder, record already in destination).
  dest_scoped = Plutonium::Kanban::Grouping.apply_scope(kanban_base_relation, to.scope)
  dest_cards = board.position_config.order(dest_scoped).where.not(id: record.id).to_a
  # to_index is client-supplied. Clamp to [0, dest_cards.size] so a negative
  # value can't wrap via Ruby's negative array indexing (dest_cards[-1] would
  # silently anchor the drop to the LAST card) and an over-large value simply
  # appends. The real client only ever sends 0..dest_cards.size; this hardens
  # the crafted-request path.
  to_index = params[:to_index].to_i.clamp(0, dest_cards.size)

  # WIP limit only applies to cross-column drops (reordering within the
  # same column does not change its cardinality). This is a
  # pre-transaction read — benign TOCTOU: two concurrent moves could
  # momentarily push the column one over wip. Acceptable for a UI guard.
  if to.wip && from.key != to.key && dest_cards.size + 1 > to.wip
    return render_kanban_rejection(
      params[:from_column],
      reason: "#{to.label}” is at its WIP limit (#{to.wip})."
    )
  end

  prev_record = (to_index > 0) ? dest_cards[to_index - 1] : nil
  next_record = dest_cards[to_index]

  # Holds the enter_interaction outcome (when the destination declares
  # one) so the post-transaction branch can distinguish a rolled-back
  # failure from a successful atomic commit.
  outcome = nil

  # A same-column reorder (from.key == to.key) changes only rank, not
  # membership, so it runs ONLY the positioning code — no on_enter, no
  # enter_interaction. Both on_enter (the membership write) and
  # enter_interaction (the transition) represent ENTERING a column; neither
  # should fire when the card is already in it. Task 6 (client) must mirror
  # this: a same-column drop posts a plain reposition and opens no modal.
  cross_column = from.key != to.key
  run_enter_interaction = to.enter_interaction? && cross_column

  # A dynamic board (`columns do…end`) can't register its enter_interaction
  # as an action at class-load time (its columns only exist per-request), so
  # defined_actions has no entry for the column-scoped key and the interactive
  # machinery below (build_interactive_record_action_interaction) would blow
  # up. Reject gracefully with a clear log instead of a 500 morphing onto the
  # board. (Static boards always register — see Definition::IndexViews.)
  if run_enter_interaction && current_definition.defined_actions[to.enter_interaction_key].nil?
    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." }
    return render_kanban_rejection(params[:from_column], reason: "This drop can’t be completed.")
  end

  # An input-less drop interaction is `immediate` — the client commits it
  # via a DIRECT POST (no modal), so the response must be a Turbo Stream,
  # not modal-form HTML (see the failure branch below).
  drop_immediate = run_enter_interaction &&
    !!current_definition.defined_actions[to.enter_interaction_key]&.immediate

  # Bind the enter_interaction's auto-registered hidden record action (so
  # interaction_params / build_interactive_record_action_interaction
  # resolve it) and reuse the already-loaded record as resource_record!
  # (param-extraction subject + form URL) — no re-query, no divergent copy.
  # No authorize here: the move was already authorized by kanban_move?
  # above, and the interaction has no policy method of its own.
  if run_enter_interaction
    params[:interactive_action] = to.enter_interaction_key
    @resource_record = record
  end

  ActiveRecord::Base.transaction do
    # (1) Apply on_exit (SOURCE column) then on_enter (DESTINATION column),
    # CROSS-column moves only. A same-column reorder skips both (see
    # cross_column above) and only repositions; on_exit/on_enter represent
    # LEAVING and ENTERING a column, which a reorder does not do.
    #
    # on_exit runs FIRST, so it sees the pre-move state (still "in" from) —
    # the counterpart hook for source-tied side effects (stop a timer,
    # release a slot) that the destination's on_enter can't own.
    #
    #   Symbol → record.public_send(sym) (named method on the record)
    #   Proc   → evaluated with self = kanban_context (delegates to
    #            view_context so `current_user` etc. work as bare calls)
    #            and the record as the single block arg, matching the
    #            public 1-arg DSL form: on_enter: ->(task) { task.status = … }
    if cross_column
      if from.on_exit.is_a?(Symbol)
        record.public_send(from.on_exit)
      elsif from.on_exit
        kanban_context.instance_exec(record, &from.on_exit)
      end

      if to.on_enter.is_a?(Symbol)
        record.public_send(to.on_enter)
      elsif to.on_enter
        kanban_context.instance_exec(record, &to.on_enter)
      end

      # Persist any in-memory attribute changes from on_exit/on_enter
      # (blocks that call update! directly are already saved; this is a
      # safety net for blocks that only assign attributes).
      record.save! if record.changed?
    end

    # (2) Drop interaction — runs against the SAME record instance,
    # atomic with the move. Only fires on a CROSS-column move (entering a
    # new column is the transition the interaction represents); a
    # same-column reorder skips it (see run_enter_interaction above). The
    # interactive_action binding + @resource_record + authorize were
    # hoisted out above the transaction.
    if run_enter_interaction
      # build_interactive_record_action_interaction renders the action
      # form to EXTRACT its params (structured inputs / choices). It runs
      # INSIDE the transaction on purpose: so a `choices:` proc (or any
      # form logic) sees the post-on_enter record state. Do NOT hoist this
      # out — doing so would change the record state the form is built
      # against and silently alter param-extraction semantics.
      build_interactive_record_action_interaction
      outcome = @interaction.call
      # Interaction validation failed → undo the on_enter write (and any
      # partial execute) so nothing persists. The re-render happens
      # after the transaction so the rollback is fully applied first.
      raise ActiveRecord::Rollback if outcome.failure?
    end

    # (3) Reposition within the destination column.
    # Mode A delegates to record.reposition! (calls update! for position).
    # Mode B calls the user-supplied block.
    # Mode C is a no-op (no ordering; position unchanged).
    board.position_config.reposition!(
      record:,
      column: to.key,
      prev_record:,
      next_record:,
      index: to_index
    )

    # Final save covers Mode C where reposition! is a no-op but on_enter
    # only assigned in memory, or any other unsaved attribute changes.
    record.save! if record.changed?
  end

  # Interaction failed → the transaction rolled back. Re-render the SAME
  # modal (422) with the validation errors + the submitted hidden move
  # fields, so the user can correct the input and resubmit the move.
  if run_enter_interaction && outcome&.failure?
    # Immediate interactions were committed via a direct POST (no modal
    # open), so the client is processing a Turbo Stream response — render a
    # snap-back rejection toast rather than modal-form HTML, which the
    # stream-expecting fetch would silently drop. Input-collecting
    # interactions re-render their modal with the errors so the user can
    # fix the input and resubmit.
    if drop_immediate
      reason = @interaction.errors.full_messages.to_sentence.presence ||
        "#{to.label}” could not be applied."
      return render_kanban_rejection(from.key, reason:)
    end
    return render :kanban_move_form, formats: [:html], **modal_render_options, status: :unprocessable_content
  end

  respond_to do |format|
    format.turbo_stream do
      # The column-frame updates are what other viewers need to see — this
      # is the shared, broadcastable payload.
      column_streams = [turbo_stream.update("kanban-col-#{from.key}", render_kanban_column_html(from))]
      column_streams << turbo_stream.update("kanban-col-#{to.key}", render_kanban_column_html(to)) if from.key != to.key

      # Broadcast the frame updates to other connected viewers of this
      # board, when realtime broadcasting is enabled. The mover will also
      # receive this broadcast (they are subscribed to the stream too) — but
      # re-rendering the same frames is idempotent, so the double update is
      # harmless. The modal-close stream is deliberately EXCLUDED: only the
      # mover has this modal open, so closing it for everyone would blow
      # away an unrelated modal another viewer might have open.
      if board.realtime?
        Plutonium::Kanban::Broadcaster.broadcast(
          resource_class: resource_class,
          scoped_entity: scoped_to_entity? ? current_scoped_entity : nil,
          content: column_streams.join
        )
      end

      streams = column_streams
      # When the move arrived via the drop-interaction modal (cross-column
      # only), close that modal by emptying the remote-modal frame AND
      # surface the interaction's success message(s) as toast(s). Both are
      # mover-only: only this viewer has the modal open, so they are
      # deliberately EXCLUDED from the realtime broadcast above (appended
      # to `streams`, never to `column_streams`). Plain moves and
      # same-column reorders aren't in a modal, so nothing is appended.
      if run_enter_interaction
        streams += [turbo_stream.update(Plutonium::REMOTE_MODAL_FRAME, "")]
        outcome.messages.each do |msg, type|
          streams += [turbo_stream.append("kanban-flash", partial: "plutonium/toast",
            locals: {type: ((type == :notice) ? :success : type), msg:})]
        end
      end

      render turbo_stream: streams
    end
  end
rescue ::ActionPolicy::Unauthorized
  # NOTE: the leading :: is required — Plutonium::ActionPolicy exists
  # (action_policy/sti_policy_lookup.rb), so a bare ActionPolicy would
  # resolve to that namespace and never match the raised exception,
  # letting it fall through to the global rescue_from (which re-raises for
  # turbo_stream requests → the HTML error page morph this fix prevents).
  #
  # A denied transition — the kanban_move? gate (the single move
  # authorization; the enter_interaction has no policy of its own). Snap
  # the source column back with a toast at 403 instead of letting the HTML
  # error page reach the client: the drag POST expects a Turbo Stream, so
  # a raw error page would be morphed into the board (the "page turns red"
  # bug). Rendering a stream here keeps rejection feedback consistent with
  # the WIP / accepts snap-backs above.
  #
  # authorize_count only bumps AFTER a successful authorize, so a denial
  # raised by the board-wide gate (before any successful check) leaves the
  # verifier unsatisfied — we've handled authorization by rejecting, so
  # skip it explicitly.
  skip_verify_authorize_current!
  render_kanban_rejection(
    params[:from_column],
    reason: "You are not authorized to move this card there.",
    status: :forbidden
  )
rescue ActiveRecord::RecordNotFound
  # The card was destroyed (e.g. concurrently) between board render and
  # drop, so `find` raised. Snap the source column back — re-rendering it
  # drops the now-gone card — instead of letting Rails' 404 HTML page get
  # morphed into the board (same class of bug as the ActionPolicy rescue).
  # `find` raised before authorize_current!, so satisfy that verifier; the
  # scope verifier is already satisfied by kanban_base_relation.
  skip_verify_authorize_current!
  render_kanban_rejection(params[:from_column], reason: "This card no longer exists.")
rescue ActiveRecord::RecordInvalid => e
  # An on_exit/on_enter hook (or the interaction) left the record invalid,
  # so save! raised and the transaction rolled back. Snap back with the
  # validation reason rather than let a 500 HTML page morph into the board.
  reason = e.record.errors.full_messages.to_sentence.presence ||
    "This card could not be moved."
  render_kanban_rejection(params[:from_column], reason:)
end

#kanban_move_formObject

GET /kanban_move_form?from_column=&to_column=&to_index=

Renders the drop-interaction modal for a card dropped into a column that declares a enter_interaction:. The modal shows the interaction's normal form, but wired to POST to kanban_move (Task 4) carrying the move context as hidden fields so the interaction runs AND the card is repositioned in one atomic request.



381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
# File 'lib/plutonium/resource/controllers/kanban_actions.rb', line 381

def kanban_move_form
  @resource_record = kanban_base_relation.find(params[:id])
  record = @resource_record
  from = kanban_column_for(params[:from_column])
  to = kanban_column_for(params[:to_column])

  # No interaction to open a form for (invalid drop), OR the interaction is
  # unregistered because this is a dynamic (`columns do…end`) board — which
  # can't register enter_interactions and so can't render the modal chrome
  # (current_interactive_action would be nil). Either way the drop is not
  # actionable on this path — satisfy the verifier and bail cleanly instead
  # of 500-ing in the view.
  unless to&.enter_interaction? && current_definition.defined_actions[to.enter_interaction_key]
    skip_verify_authorize_current!
    head :unprocessable_content
    return
  end

  # Same single gate as kanban_move: authorize the move via kanban_move?
  # with the from/to columns in context (the interaction has no policy of
  # its own). Opening the form is authorizing the move it will commit.
  authorize_current! record, to: :kanban_move?,
    context: {kanban_from: from, kanban_to: to}

  # Belt-and-suspenders structural gate, mirroring kanban_move (POST) in
  # the same order (membership → accepts?/locked?). Don't open a modal for
  # a drop the commit will inevitably reject: the user would fill in the
  # interaction form only to eat a 422 snap-back on submit. The client's
  # accepts hint normally blocks this before the modal opens, but a stale
  # board or a crafted request can still reach here. On rejection we render
  # the SAME turbo-stream snap-back the POST does (Turbo processes it from
  # the frame.src navigation) instead of the doomed form. authorize already
  # succeeded (counter bumped), so these returns need no skip_verify.
  if from && !record_in_kanban_column?(record, from)
    return render_kanban_rejection(
      params[:from_column],
      reason: "This card is no longer in “#{from.label}”."
    )
  end
  unless from && to.accepts?(from.key) && !from.locked?
    reason =
      if from&.locked?
        "Cards can't be moved out of “#{from.label}”."
      else
        "Cards can't be moved into “#{to.label}”."
      end
    return render_kanban_rejection(params[:from_column], reason:)
  end

  # Bind the enter_interaction's auto-registered record action as the
  # current interactive action so the modal chrome (title, description,
  # modal mode/size) resolves exactly like a standard record action.
  params[:interactive_action] = to.enter_interaction_key

  @interaction = to.enter_interaction.new(view_context:)
  @interaction.resource = record

  render :kanban_move_form, formats: [:html], **modal_render_options
rescue ::ActionPolicy::Unauthorized
  # A denied transition into a enter_interaction column. Without this the
  # exception reaches the global rescue_from, which RE-RAISES for html/
  # turbo_stream (core/controller.rb) → a 403 HTML error page. The client
  # opened this form via `frame.src`, so that error page lands in the
  # remote-modal frame → a broken "content missing" modal. Render the same
  # turbo-stream rejection the kanban_move POST does instead: the card was
  # never moved (native DnD doesn't re-parent), so this just empties the
  # modal frame, re-asserts the source column, and toasts the denial.
  # authorize_current! raised before bumping its counter, so satisfy the
  # verifier explicitly (mirrors the RecordNotFound branch below).
  skip_verify_authorize_current!
  render_kanban_rejection(
    params[:from_column],
    reason: "You are not authorized to move this card there.",
    status: :forbidden
  )
rescue ActiveRecord::RecordNotFound
  # Card destroyed between board render and the modal-open request. `find`
  # raised before authorize, so satisfy that verifier; return a plain 404
  # (this GET only loads the modal frame — there is no board to morph).
  skip_verify_authorize_current!
  head :not_found
end