Extending the Kanban board (using Rails and Hotwire)

In my previous article about building a Kanban board with Rails and Hotwire, I showed how to create a Kanban board using a Stimulus controller with less than 30 lines of code. But what good is a Kanban board if you can’t actually add new cards and columns? Let’s fix that.

In this follow-up, I will walk you through three key enhancements that build on top of the previous implementation. The code is available on GitHub, and these commits progressively add more functionality to make the board truly useful.

Adding New Cards and Columns

First up is the ability to create new cards within any column. This is surprisingly straightforward with Turbo Streams.

I started by adding a create action to the CardsController:

class CardsController < ApplicationController
+  def create
+    @card = Board::Card.create board_column_id: params[:column_id], resource: Message.create(title: "New message")
+  end
+
  def update
    Board::Card.find(params[:id]).update board_column_id: board_column_id, position: new_position
  end

Then updated the column partial to include a button at the bottom:

# app/views/boards/_column.html.erb
-<li draggable data-sortable-id-value="<%= column.id %>"class="w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto">
+<li draggable data-sortable-id-value="<%= column.id %>"class="relative w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto">
  <h2 class="sticky top-0 px-4 py-2 font-medium text-gray-800 bg-gray-100">
    <%= column.name %>
  </h2>

-  <ul data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="<%= column.id %>" data-sortable-endpoint-value="<%= card_path(id: "__ID__") %>" class="flex flex-col gap-y-2 px-4 pb-4 overflow-x-clip">
+  <ul id="<%= dom_id(column) %>" data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="<%= column.id %>" data-sortable-endpoint-value="<%= card_path(id: "__ID__") %>" class="flex flex-col gap-y-2 px-4 pb-4 overflow-x-clip">
    <%= render column.cards %>

    <li class="hidden px-3 py-2 bg-gray-200 rounded-md only:block">
      <p class="text-sm font-normal text-gray-600">No cards here…</p>
    </li>
  </ul>
+
+  <div class="sticky bottom-0 px-4 py-2 bg-gray-100/90">
+    <%= button_to "Add new card", cards_path, params: {column_id: column}, class: "block py-1 bg-gray-200 w-full text-sm font-medium text-gray-800 rounded-md hover:bg-gray-300" %>
+  </div>
</li>

Notice I added an id to the ul element using dom_id(column). This is needed for the Turbo Stream response to know where to append the new card. This Turbo Stream response is beautifully simple:

<%= turbo_stream.append @card.column, @card %>

Do not forget to update the routes:

# config/routes.rb
Rails.application.routes.draw do
  resources :columns, only: %w[update]
-  resources :cards, only: %w[update]
+  resources :cards, only: %w[create update]

  root to: "pages#show"
end

Now when you click the button, a new card appears instantly at the bottom of the column. No page refresh needed. This is Hotwire at its finest.

The same pattern works for adding new columns. Add a create action to the ColumnsController:

class ColumnsController < ApplicationController
+  def create
+    @column = Board::Column.create board_id: params[:board_id], name: "New column"
+  end
+
  def update
    Board::Column.find(params[:id]).update position: new_position
  end

The board partial needed a bit of restructuring to add the new column button:

# app/views/boards/_board.html.erb
<h1 class="mx-4 mt-2 text-lg font-bold text-gray-800">
  <%= board.name %>
</h1>

-<ul data-controller="sortable" data-sortable-endpoint-value="<%= column_path(id: "__ID__") %>" class="flex w-full gap-x-8 overflow-x-auto mt-2 px-4">
-  <%= render board.columns %>
-</ul>
+<div class="flex overflow-x-auto gap-x-6 mt-2 px-4 [--column-height:calc(100dvh-4rem)]">
+  <ul id="<%= dom_id(board) %>" data-controller="sortable" data-sortable-endpoint-value="<%= column_path(id: "__ID__") %>" class="flex gap-x-8">
+    <%= render board.columns %>
+  </ul>
+
+  <%= button_to "Add new column", columns_path, params: {board_id: board}, form: {class: "shrink-0 [writing-mode:vertical-lr] rotate-180"}, class: "block h-[var(--column-height)] py-1 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" %>
+</div>

I also introduced a CSS custom property --column-height to keep the height consistent across columns and the button. The vertical text for the button is a nice touch that saves horizontal space. The writing-mode: vertical-lr with rotate-180 flips the text (without writing-mode the button still takes up it required space) so it reads from bottom to top, which feels more natural when placed on the right side of the board. I updated the column partial to use this new CSS variable:

# app/views/boards/_column.html.erb
-<li draggable data-sortable-id-value="<%= column.id %>"class="relative w-80 h-[calc(100dvh-4rem)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto">
+<li draggable data-sortable-id-value="<%= column.id %>"class="relative w-80 h-[var(--column-height)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto">
  <h2 class="sticky top-0 px-4 py-2 font-medium text-gray-800 bg-gray-100">
    <%= column.name %>
  </h2>

The Turbo Stream response follows the same pattern:

<%= turbo_stream.append @column.board, @column %>

And the routes update:

# config/routes.rb
Rails.application.routes.draw do
-  resources :columns, only: %w[update]
+  resources :columns, only: %w[create update]
  resources :cards, only: %w[create update]

  root to: "pages#show"
end

All basic Rails + Hotwire love, right?

Multi-Select Drag and Drop

This is where things get more interesting. What if you need to move multiple cards at once? SortableJS has a MultiDrag plugin that handles this beautifully.

First, I refactored the repositioning logic into a dedicated controller. This makes sense because both cards and columns need the same repositioning behavior, and I wanted to handle multiple items at once:

# app/controllers/reposition_controller.rb
class RepositionController < ApplicationController
  def update
    resources.each_with_index do |resource, index|
      resource.update!({
        board_column_id: params[:board_column_id],
        position: params[:new_position].to_i
      }.compact_blank)
    end
  end

  private

  def resources
    resource_class = params[:resource_name].singularize.classify.constantize

    resource_class.where(id: Array(params[:ids]))
  end
end

This controller is generic enough to handle both cards and columns. It takes an array of IDs and updates them all in one go.

Now I could remove the update actions from both the CardsController and ColumnsController:

# app/controllers/cards_controller.rb
class CardsController < ApplicationController
  def create
    @card = Board::Card.create board_column_id: params[:column_id], resource: Message.create(title: "New message")
  end
-
-  def update
-    Board::Card.find(params[:id]).update board_column_id: board_column_id, position: new_position
-  end
-
-  private
-
-  def board_column_id = params[:new_list_id]
-
-  def new_position = params[:new_position]
end
# app/controllers/columns_controller.rb
class ColumnsController < ApplicationController
  def create
    @column = Board::Column.create board_id: params[:board_id], name: "New column"
  end
-
-  def update
-    Board::Column.find(params[:id]).update position: new_position
-  end
-
-  private
-
-  def new_position = params[:new_position]
end

Yay for removing code! 🎉

The routes needed a bit of Rails “magic” to wire this up cleanly. I used a routing concern to add the reposition endpoint to both cards and columns:

# config/routes.rb
Rails.application.routes.draw do
-  resources :columns, only: %w[create update]
-  resources :cards, only: %w[create update]
+  concern :reposition do
+    collection do
+      patch :reposition, controller: "reposition", action: "update", defaults: { resource_name: "board/#{@scope.frame.dig(:controller)}" }
+    end
+  end
+
+  resources :columns, only: %w[create update], concerns: :reposition
+  resources :cards, only: %w[create update], concerns: :reposition

  root to: "pages#show"
end

This routing concern is a neat trick. It adds a reposition route to any resource that includes it. The defaults hash automatically sets the resource_name parameter based on the controller name, so cards becomes board/cards and columns becomes board/columns. This way the RepositionController knows which model to work with. The @scope.frame.dig(:controller) is some more Rails “magic” that gives you the current controller name in the routing context.

Now for the Stimulus controller updates. I imported the MultiDrag plugin from SortableJS and handle multiple selected items:

# app/javascript/controllers/sortable_controller.js
import { Controller } from "@hotwired/stimulus"
-import Sortable from "sortablejs"
+import { Sortable, MultiDrag } from "sortablejs"
import { patch } from "@rails/request.js"

+Sortable.mount(new MultiDrag())
+
export default class extends Controller {
-  static values = { groupName: String, endpoint: String };
+  static values = { groupName: String, endpoint: String, multiDraggable: Boolean };

  connect() {
    Sortable.create(this.element,
      {
        group: this.groupNameValue,
        draggable: "[draggable]",
        animation: 250,
        easing: "cubic-bezier(1, 0, 0, 1)",

        ghostClass: "opacity-50",
+        selectedClass: "selected",

-        onEnd: this.#updatePosition.bind(this)
+        multiDrag: this.multiDraggableValue,
+        multiDragKey: "shift",
+
+        onEnd: (event) => this.#updatePosition(event)
      }
    )
  }

  // private

  async #updatePosition(event) {
+    const items = event.items?.length > 0 ? event.items : [event.item]
+    const ids = items.map(item => item.dataset.sortableIdValue)
+
    await patch(
-      this.endpointValue.replace(/__ID__/g, event.item.dataset.sortableIdValue),
-      { body: JSON.stringify({ new_list_id: event.to.dataset.sortableListIdValue, new_position: event.newIndex + 1 }) }
+      this.endpointValue,
+      {
+        body: JSON.stringify({
+          ids: ids,
+          board_column_id: event.to.dataset.sortableListIdValue,
+          new_position: event.newIndex + 1
+        })
+      }
    )
  }
}

The multiDragKey is set to "shift", so you hold Shift and click cards to select multiple. The selectedClass gets applied to selected items, which I styled with a simple ring:

/* app/assets/tailwind/application.css */
@import "tailwindcss";

@layer utilities {
  .selected {
    @apply ring ring-gray-400;
  }
}

This small utility class is needed, as Sortable does not support adding multiple classes.

Finally, I updated the views to use the new endpoints and enable multi-drag for cards:

# app/views/boards/_board.html.erb
<h1 class="mx-4 mt-2 text-lg font-bold text-gray-800">
  <%= board.name %>
</h1>

<div class="flex overflow-x-auto gap-x-6 mt-2 px-4 [--column-height:calc(100dvh-4rem)]">
-  <ul id="<%= dom_id(board) %>" data-controller="sortable" data-sortable-endpoint-value="<%= column_path(id: "__ID__") %>" class="flex gap-x-8">
+  <ul id="<%= dom_id(board) %>" data-controller="sortable" data-sortable-endpoint-value="<%= reposition_columns_path %>" class="flex gap-x-8">
    <%= render board.columns %>
  </ul>

  <%= button_to "Add new column", columns_path, params: {board_id: board}, form: {class: "shrink-0 [writing-mode:vertical-lr] rotate-180"}, class: "block h-[var(--column-height)] py-1 text-sm font-medium text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200" %>
</div>
# app/views/boards/_column.html.erb
<li draggable data-sortable-id-value="<%= column.id %>"class="relative w-80 h-[var(--column-height)] shrink-0 bg-gray-100 shadow-inner rounded-lg overflow-y-auto">
  <h2 class="sticky top-0 px-4 py-2 font-medium text-gray-800 bg-gray-100">
    <%= column.name %>
  </h2>

-  <ul id="<%= dom_id(column) %>" data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="<%= column.id %>" data-sortable-endpoint-value="<%= card_path(id: "__ID__") %>" class="flex flex-col gap-y-2 px-4 pb-4 overflow-x-clip">
+  <ul id="<%= dom_id(column) %>" data-controller="sortable" data-sortable-group-name-value="column" data-sortable-list-id-value="<%= column.id %>" data-sortable-multi-draggable-value="true" data-sortable-endpoint-value="<%= reposition_cards_path %>" class="flex flex-col gap-y-2 px-4 pt-0.25 pb-4 overflow-x-clip">
    <%= render column.cards %>

    <li class="hidden px-3 py-2 bg-gray-200 rounded-md only:block">
      <p class="text-sm font-normal text-gray-600">No cards here…</p>
    </li>
  </ul>

  <div class="sticky bottom-0 px-4 py-2 bg-gray-100/90">
    <%= button_to "Add new card", cards_path, params: {column_id: column}, class: "block py-1 bg-gray-200 w-full text-sm font-medium text-gray-800 rounded-md hover:bg-gray-300" %>
  </div>
</li>

Now you can hold Shift, click multiple cards, and drag them all at once to a new column. Pretty slick, right?! 😎


And that’s it! You now have a fully functional Kanban board where you can add new cards and columns on the fly, and even move multiple cards at once. The routing concern pattern keeps things DRY, and the generic RepositionController means you could easily extend this to other sortable resources in your app.

Published at . Have suggestions or improvements on this content? Do reach out.

More articles like this on modern Rails & frontend? Get them first in your inbox.
JavaScript for Rails Developers
Make JS your 2nd favorite language