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=
Kanban move action (Task 7)
POST
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
-
#kanban_move ⇒ Object
POST
/kanban_move. -
#kanban_move_form ⇒ Object
GET
/kanban_move_form?from_column=&to_column=&to_index=.
Instance Method Details
#kanban_move ⇒ Object
POST
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. 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. 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..to_sentence.presence || "“#{to.label}” could not be applied." return render_kanban_rejection(from.key, reason:) end return render :kanban_move_form, formats: [:html], **, 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..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. 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. 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..to_sentence.presence || "This card could not be moved." render_kanban_rejection(params[:from_column], reason:) end |
#kanban_move_form ⇒ Object
GET
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] 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. 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], ** 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). 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). head :not_found end |