Module: Plutonium::Resource::Controllers::PositionActions
- Extended by:
- ActiveSupport::Concern
- Included in:
- Plutonium::Resource::Controller
- Defined in:
- lib/plutonium/resource/controllers/position_actions.rb
Overview
Drag-reorder endpoint for resources whose definition declares
position_on — index tables, nested association tables, and grids.
(The kanban board has its own richer endpoint: KanbanActions#kanban_move.)
The request
POST <member>/reposition?<the collection's own query string>
params: prev_id, next_id, to_index
prev_id/next_id are the ids of the dropped row's VISIBLE neighbours, either nullable for a drop at an end of the VIEWPORT. A blank id is a claim about the client's page, never about the positioning group: rows hidden by pagination or a filter may still sit beyond it, and Mode A looks the real boundary neighbour up rather than anchoring off nil (see #resolve_position_boundaries — this is what keeps a bottom-of-page drop from writing a duplicate position).
The trailing query string is load-bearing, not decoration: it is the
index's own query (search / filters / scope / sort / page / view), and it
is what lets this action re-render exactly the page the user is looking
at — same filters, same page of results — using the ordinary index
pipeline rather than a parallel one. Task 7's client must therefore post
to the member reposition path with window.location.search appended.
The response
The client moves the row optimistically, so a clean drop answers 204 and nothing repaints. The collection is streamed back only when the client's view is or may be stale:
clean Mode A drop, both neighbours resolved → 204 No Content
reposition! rebalanced the group → 200 + stream
a neighbour did not resolve (drift) → 200 + stream
a neighbour belongs to another positioning
group → 200 + stream + toast
Mode B (opaque block write) → 200 + stream
denied reposition? → 403 + stream + toast
denied index? → 403, no body
dropped under a foreign sort → 422 + stream + toast
validation failure / record gone → 422 + stream + toast
no position_on, Mode C, or a kanban view → 404
DOM contract (Tasks 7 & 8 depend on these)
#pu-collection-<plural> the wrapper around the rendered table/grid
(Plutonium::UI::Page::Index.collection_dom_id)
— the stream target, and the element the drag
controller scopes itself to.
#pu-flash the page's toast region (Plutonium::FLASH_REGION),
deliberately OUTSIDE the collection wrapper so
replacing the collection cannot destroy it.
Instance Method Summary collapse
-
#reposition ⇒ Object
POST
/reposition.
Instance Method Details
#reposition ⇒ Object
POST
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 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 |
# File 'lib/plutonium/resource/controllers/position_actions.rb', line 61 def reposition config = current_definition.defined_position_config # Not a reorderable surface — a 404, not an authorization failure. # Either the resource declares no ordering at all (or Mode C), or the # client is on the kanban board, which owns a richer endpoint of its # own (kanban_move) and renders no collection wrapper for the # reconciliation to target. Checked BEFORE the record is loaded (no # point querying for a route that doesn't apply), which means neither # verifier has been satisfied by the time we bail — so satisfy both. if config.nil? || config.disabled? || position_collection_view == :kanban head :not_found return end # You must be able to SEE a list to reorder it. Without this, a policy # with `index? == false, update? == true` would be handed the whole # listing by the reconciliation render below. Checked before the # record is loaded so the refusal never touches the collection. resource_class, to: :index? record = .find(params[:id]) record, to: :reposition? # Dropping "between the two rows either side of me" only means # something when the visual order IS the stored order. Task 7's client # doesn't offer the drag under a foreign sort; this is the server-side # authority behind that, and it rejects BEFORE any write rather than # writing a position derived from neighbours that never implied one. if config.delegate? && !current_query_object.sorted_ascending_only_by?(config.attribute) return render_position_reconciliation( reason: "Reordering is only available while the list is sorted by #{config.attribute.to_s.humanize.downcase}." ) end # Resolve neighbours WITHIN the authorized scope. A nil here would mean # "drop at the end", so an id that does not resolve must be treated as # drift and reconciled — never silently coerced to nil. prev_record, prev_ok, prev_drift = resolve_position_neighbour(params[:prev_id], record:, config:) next_record, next_ok, next_drift = resolve_position_neighbour(params[:next_id], record:, config:) foreign_group = [prev_drift, next_drift].include?(:foreign_group) prev_record, next_record = resolve_position_boundaries(record, config, prev_record, next_record) # Nothing left to anchor to. In Mode A that is never a real drag — a # list you can drag in has a row on one side of the drop, and the # boundary lookup above supplies the other. It means the client's view # is stale (both neighbours drifted), and writing anyway would send the # record to position 0.0 at the head of its group on the strength of a # request we've just decided not to trust. Reconcile instead: the row # snaps back to where it actually is. if config.delegate? && prev_record.nil? && next_record.nil? # A cross-group drop lands here with BOTH neighbours rejected, and is # the one kind of drift worth explaining: it is a standing property # of a list that spans groups, not transient staleness. Status stays # :ok — the reconciliation is unchanged, this only adds the toast. return render_position_reconciliation( reason: foreign_group ? position_foreign_group_reason(record) : nil, status: :ok ) end # Bind the return rather than branching on the call directly: # `if config.reposition!(...)` reads as a success check, which this is # NOT — failure raises. The boolean means "positions other than this # record's may have changed, so the client's optimistic view is stale". must_reconcile = config.reposition!( record:, prev_record:, next_record:, # Floor-clamped, mirroring the kanban drop (KanbanActions#kanban_move # clamps to [0, size]): a negative index reaching a Mode B block # would index its array from the END, silently anchoring the drop to # the wrong row. There is no card list here to give it a ceiling, so # the contract Move#index carries is "0 or greater". index: [params[:to_index].to_i, 0].max ) # No reason here, even when a neighbour was out of group: reposition! # has already run, so the record DID move — the surviving anchor # carried the drop and resolve_position_boundaries supplied the other. # Telling the user reordering "only works within the same group" on a # move that just worked contradicts what they can see. The refusal # above is the only place that claim is true. if must_reconcile || !prev_ok || !next_ok render_position_reconciliation else head :no_content end rescue ::ActionPolicy::Unauthorized => e # NOTE: the leading :: is REQUIRED — Plutonium::ActionPolicy exists, so # a bare ActionPolicy resolves to that namespace and never matches, # letting the exception reach the global rescue_from, which re-raises # for turbo_stream requests → an HTML error page morphed into the table. # # authorize_count only bumps after a SUCCESSFUL authorize, so a denial # leaves the verifier unsatisfied; we handled authorization by rejecting. # A denied index? is not a snap-back: the user may not see this listing # at all, so the refusal must not carry it back to them. Only a denied # reposition? gets the collection (the row has to return somewhere). if e.rule.to_sym == :index? head :forbidden return end render_position_reconciliation( reason: "You are not authorized to reorder this.", status: :forbidden ) rescue ActiveRecord::RecordNotFound # The row was destroyed between render and drop. `find` raised before # authorize_current!, so satisfy that verifier explicitly. render_position_reconciliation(reason: "This record no longer exists.") rescue ActiveRecord::RecordInvalid => e reason = e.record.errors..to_sentence.presence || "This record could not be moved." render_position_reconciliation(reason:) end |