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

Instance Method Details

#repositionObject

POST /reposition



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
    skip_verify_authorize_current!
    skip_verify_current_authorized_scope!
    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.
  authorize_current! resource_class, to: :index?

  record = current_authorized_scope.find(params[:id])
  authorize_current! 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.
  skip_verify_authorize_current!

  # 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?
    skip_verify_current_authorized_scope!
    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.
  skip_verify_authorize_current!
  render_position_reconciliation(reason: "This record no longer exists.")
rescue ActiveRecord::RecordInvalid => e
  reason = e.record.errors.full_messages.to_sentence.presence ||
    "This record could not be moved."
  render_position_reconciliation(reason:)
end