Skip to content

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:

1
2
3
bin/rails generate migration AddPositionToCards position:integer
bin/rails generate migration AddPositionToColumns position:integer
bin/rails db:migrate

Add default ordering scopes to the models so queries always return records in position order:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# app/models/card.rb
scope :ordered, -> { order(:position) }

before_create :set_position

private

def set_position
  self.position = column.cards.count
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# app/models/column.rb
scope :ordered, -> { order(:position) }

before_create :set_position

private

def set_position
  self.position = board.columns.count
end

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:

1
2
# config/importmap.rb
pin "sortablejs", to: "https://cdn.jsdelivr.net/npm/sortablejs@1.15.2/+esm"

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# app/controllers/cards/positions_controller.rb
module Cards
  class PositionsController < ApplicationController
    def update
      card      = Card.find(params[:id])
      column_id = params[:column_id] || card.column_id
      position  = params[:position].to_i

      Card.transaction do
        card.update_columns(column_id: column_id)
        cards = Card.where(column_id: column_id)
                    .where.not(id: card.id)
                    .order(:position)
                    .to_a
        cards.insert(position, card)
        cards.each_with_index do |c, i|
          c.update_columns(position: i)
        end
      end

      card.column.board.broadcast_refresh_later
      head :ok
    end
  end
end

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
module Columns
  class PositionsController < ApplicationController
    def update
      column   = Column.find(params[:id])
      position = params[:position].to_i

      Column.transaction do
        columns = Column.where(board_id: column.board_id)
                        .where.not(id: column.id)
                        .order(:position)
                        .to_a
        columns.insert(position, column)
        columns.each_with_index do |c, i|
          c.update_columns(position: i)
        end
      end

      column.board.broadcast_refresh_later
      head :ok
    end
  end
end

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# config/routes.rb
Rails.application.routes.draw do
  if Rails.env.development?
    mount Lookbook::Engine, at: "/lookbook"
  end

  # Position endpoints must come before shallow card/column resources
  namespace :cards   do resource :positions, only: [:update] end
  namespace :columns do resource :positions, only: [:update] end

  resources :boards do
    resources :columns, shallow: true do
      resources :cards, shallow: true
    end
  end

  root "boards#index"
end

Verify the routes are correct:

1
2
3
bin/rails routes | grep positions
# cards_positions  PATCH  /cards/positions(.:format)    cards/positions#update
# columns_positions PATCH /columns/positions(.:format)  columns/positions#update

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:

1
2
3
4
5
6
7
# Correct — shallow member routes take only the record
edit_card_path(@card)
card_path(@card)

# Wrong — this was the pre-shallow pattern, now breaks
edit_card_path(@card.column, @card)   # generates /cards/column_id/edit.card_id
card_path(@card.column, @card)        # generates /cards/column_id.card_id

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:

1
2
3
4
5
6
def render_actions
  Dropdown(label: "⋯", align: :right) do |d|
    d.item "Edit",   url: edit_card_path(@card)
    d.item "Delete", url: card_path(@card), method: :delete
  end
end

Collection routes (new, create, index) still need the parent:

1
2
new_column_card_path(@column)   # correct — collection route needs parent
column_cards_path(@column)      # correct — collection route needs 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:

  1. One instance per card list, all sharing the group name "cards" — this enables cross-list card dragging
  2. One instance on the board div itself, restricted to column handles via Sortable’s handle option — this enables column reordering

Both instances live in a single board_controller.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// app/javascript/controllers/board_controller.js
import { Controller } from "@hotwired/stimulus"
import Sortable from "sortablejs"

export default class extends Controller {
  static values = {
    cardsUrl:   String,
    columnsUrl: String
  }

  connect() {
    this.initCardSort()
    this.initColumnSort()
  }

  disconnect() {
    this.cardSortables?.forEach(s => s.destroy())
    this.columnSortable?.destroy()
  }

  initCardSort() {
    this.cardSortables = Array.from(
      this.element.querySelectorAll("[data-card-list]")
    ).map(list =>
      Sortable.create(list, {
        group:      { name: "cards", pull: true, put: true },
        animation:  150,
        ghostClass: "opacity-50",
        onEnd:      this.onCardEnd.bind(this)
      })
    )
  }

  initColumnSort() {
    this.columnSortable = Sortable.create(this.element, {
      group:      { name: "columns" },
      animation:  150,
      ghostClass: "opacity-50",
      handle:     "[data-column-handle]",
      draggable:  "[data-column-id]",
      onEnd:      this.onColumnEnd.bind(this)
    })
  }

  onCardEnd(event) {
    const id       = event.item.dataset.cardId
    const position = event.newIndex
    const columnId = event.item.parentElement.dataset.cardList
    if (!id) return
    this.patch(this.cardsUrlValue, { id, position, column_id: columnId })
  }

  onColumnEnd(event) {
    const id       = event.item.dataset.columnId
    const position = event.newIndex
    if (!id) return
    this.patch(this.columnsUrlValue, { id, position })
  }

  patch(url, data) {
    fetch(url, {
      method:  "PATCH",
      headers: {
        "Content-Type": "application/json",
        "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content
      },
      body: JSON.stringify(data)
    })
  }
}

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 column
  • draggable: "[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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# app/views/boards/show.rb
def render_board
  div(
    id:    dom_id(@board),
    class: "flex gap-4 overflow-x-auto pb-4",
    data:  {
      controller:              "board",
      board_cards_url_value:   cards_positions_path,
      board_columns_url_value: columns_positions_path
    }
  ) do
    @board.columns.ordered.each { |column| KanbanColumn(column: column) }
    render_add_column
  end
end

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
# app/components/kanban_column.rb
def view_template
  div(
    id:    dom_id(@column),
    class: "flex flex-col bg-surface-alt rounded-lg p-3 w-72 shrink-0",
    data:  { column_id: @column.id }
  ) do
    render_header
    render_cards
    render_add_card
  end
end

def render_header
  div(
    class: "flex items-center justify-between mb-3 cursor-grab active:cursor-grabbing",
    data:  { column_handle: true }
  ) do
    h2(class: "font-semibold text-text text-sm") { @column.name }
    Badge(label: @column.cards.count.to_s)
  end
end

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:

1
2
3
4
5
6
7
8
9
def render_cards
  div(
    id:    "cards_#{@column.id}",
    class: "flex flex-col gap-2 min-h-8 flex-1",
    data:  { card_list: @column.id }
  ) do
    @column.cards.ordered.each { |card| KanbanCard(card: card) }
  end
end

Each card gets data-card-id so onCardEnd can identify which card moved:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# app/components/kanban_card.rb
def view_template
  div(
    id:    dom_id(@card),
    class: "bg-surface rounded-md border border-border p-3 " \
           "shadow-sm cursor-grab active:cursor-grabbing " \
           "hover:shadow-md transition-shadow",
    data:  { card_id: @card.id }
  ) do
    render_content
  end
end

How cross-column dragging works

When you drag a card from column 1 to column 2, this is the sequence:

  1. You grab the card — Sortable detects the mousedown on a card inside a [data-card-list] list
  2. 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)
  3. You release — Sortable moves the card element into column 2’s list in the DOM and fires onEnd
  4. onCardEnd reads event.item.parentElement.dataset.cardList — because the DOM move has already happened, this is column 2’s id
  5. A PATCH fires to /cards/positions with the card id, new position, and column 2’s id
  6. Rails updates card.column_id and card.position
  7. 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:

  1. Drag a card up and down within a column — position should persist on refresh
  2. Drag a card to a different column — it should appear in the new column and persist on refresh
  3. 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.