Lesson 4 — Drag and drop card and column ordering
Morphing handles CRUD beautifully — create a card, redirect back, the board updates. But drag and drop is a different kind of interaction. It’s inherently client-side: the user picks up an element, moves it across the screen, and drops it somewhere new. The server can’t orchestrate this in real time. We need JavaScript.
The approach: Sortable.js handles the visual drag interaction and DOM
movement. When a drag ends, a Stimulus controller sends a PATCH request
to persist the new position. The server updates the database and
responds with 200 OK. The board stays as-is — no redirect, no morph.
Why not Turbo?
It’s worth being explicit about why Turbo isn’t involved here. Turbo is for navigating between pages and updating HTML from the server. Drag and drop is a client-side gesture — the user is manipulating the DOM directly. Turbo has no role until the gesture is complete and we want to persist the result. At that point we make a simple JSON PATCH request, the same way any JavaScript app would.
This is a clean separation: Sortable owns the drag, Stimulus owns the wiring, Rails owns the persistence.
Adding position to cards and columns
Cards and columns need a position integer column to store their order:
|
|
Add default ordering scopes to the models so queries always return records in position order:
|
|
|
|
before_create :set_position appends new records to the end of their
list. column.cards.count gives the current count — if there are three
cards, the new card gets position 3 (zero-indexed, so it comes after
positions 0, 1, 2).
Pinning Sortable.js
With importmaps there’s no npm or node_modules. We pin Sortable.js
directly from a CDN. The key detail is using the +esm suffix to get
the ES module build — the default CDN build doesn’t have a proper
default export and will fail to import:
|
|
Position endpoints
The naive position update — find the card, set its position to newIndex, save — breaks down quickly. Sortable’s newIndex is the visual index in the list, but database positions are rarely contiguous zero-based integers. Cards added at different times, moved between columns, or created with before_create :set_position accumulate gaps and inconsistencies. If two cards end up with the same position value, ordered returns them unpredictably and every subsequent drag makes things worse.
The correct approach is to rebuild the full ordered list for the destination column on every drag. Remove the dragged card from the list, insert it at the new index, then reassign contiguous zero-based positions to every card in the column:
|
|
update_columns skips callbacks and touch: true — preventing a double broadcast. broadcast_refresh_later is called explicitly so other users see the reorder. The transaction ensures all position updates are atomic.
Apply the same full-reorder approach to Columns::PositionsController:
|
|
head :ok returns a 200 response with no body. The client doesn’t need
any HTML back — the DOM is already correct because Sortable moved the
element before the request fired.
Route ordering matters
Add the position routes to config/routes.rb before the nested
board resources. This is important — Rails matches routes in order, and
/cards/positions would otherwise be matched by the shallow resources :cards route as cards#update with id: "positions", causing a 404.
|
|
Verify the routes are correct:
|
|
Shallow nesting and member routes
While we’re on routes — shallow nesting changes which arguments route helpers expect for member actions (edit, show, update, destroy). With shallow nesting, member routes only need the record itself:
|
|
The second form generates malformed URLs — the column id gets
concatenated with the card id rather than treated as separate route
segments. Update KanbanCard#render_actions:
|
|
Collection routes (new, create, index) still need the parent:
|
|
The board Stimulus controller
Now the interesting part. We need two kinds of dragging:
- Card dragging — cards can be dragged within a column (reordering) and between columns (moving)
- Column dragging — columns can be dragged left and right to reorder the board
These are two distinct drag behaviours that need careful separation. If a single Sortable instance managed everything, dragging a card would also trigger column reordering logic, and vice versa.
The approach is two Sortable instances:
- One instance per card list, all sharing the group name
"cards"— this enables cross-list card dragging - One instance on the board div itself, restricted to column handles
via Sortable’s
handleoption — this enables column reordering
Both instances live in a single board_controller.js:
|
|
initCardSort — finds all elements with data-card-list and
creates a Sortable instance on each. All instances share the group name
"cards" with pull: true, put: true, which is what enables
cross-list dragging. When cards in different lists share a group name,
Sortable knows they can accept each other’s cards.
initColumnSort — creates one Sortable instance on the board div
itself. Two options do the critical work here:
handle: "[data-column-handle]"— only elements matching this selector can initiate a drag. This means dragging only starts when you grab the column header, not when you grab a card inside the columndraggable: "[data-column-id]"— only elements matching this selector can be dragged. This prevents the “Add column” button at the end of the board from becoming draggable
onCardEnd — reads the card id from event.item.dataset.cardId,
the new position from event.newIndex, and the destination column id
from event.item.parentElement.dataset.cardList. At the point onEnd
fires, Sortable has already moved the card to its new position in the
DOM — so parentElement is the destination list, not the source.
onColumnEnd — simpler, just reads column id and new position.
patch — a shared helper for the fetch call. Both onCardEnd and
onColumnEnd call it with different URLs and data. The X-CSRF-Token
header is required by Rails for non-GET requests — without it the
request is rejected with a 422.
Wiring up the components
The controller needs data-* attributes in the right places. The board
div gets the controller and both URLs:
|
|
The column wrapper gets data-column-id so onColumnEnd can read
which column moved, and the header gets data-column-handle so it
acts as the drag handle:
|
|
The card list gets data-card-list with the column id — this is what
onCardEnd reads to know which column a card was dropped into:
|
|
Each card gets data-card-id so onCardEnd can identify which card
moved:
|
|
How cross-column dragging works
When you drag a card from column 1 to column 2, this is the sequence:
- You grab the card — Sortable detects the mousedown on a card inside
a
[data-card-list]list - You drag toward column 2 — Sortable shows a ghost preview in column 2’s list as you hover over it (this is the semi-transparent duplicate you see — it’s intentional, the same behaviour as Trello and Linear)
- You release — Sortable moves the card element into column 2’s list
in the DOM and fires
onEnd onCardEndreadsevent.item.parentElement.dataset.cardList— because the DOM move has already happened, this is column 2’s id- A PATCH fires to
/cards/positionswith the card id, new position, and column 2’s id - Rails updates
card.column_idandcard.position - The server responds
200 OK— no HTML, the DOM is already correct
The ghost preview (two copies visible during drag) is correct and
expected behaviour. The opacity-50 ghostClass makes the destination
preview semi-transparent to distinguish it from the dragged card.
Verifying it works
Test each scenario in order:
- Drag a card up and down within a column — position should persist on refresh
- Drag a card to a different column — it should appear in the new column and persist on refresh
- Drag a column left or right by its header — column order should persist on refresh
Check the Rails log as you drag — you should see PATCH requests to
/cards/positions and /columns/positions with the correct ids and
positions.