Kanban boards
Boards, columns and cards in the admin, with dragging by pointer, by finger and by keyboard. It is also the plugin the authoring guide is written around, because a board is the smallest feature that needs every part of the plugin system at once.
grit plugin add kanbanpnpm installcd apps/api && go run cmd/migrate/main.go
Boards then appears in the admin sidebar, under Content. A new board starts with To do, In progress and Done, because landing on an empty screen with one button is a worse first thirty seconds than landing on a board.
What you get
Board,BoardColumnandBoardCardmodels, with migrations- CRUD routes for all three, plus a move endpoint for cards and one for columns
- A boards list page and a board page in the admin, with React Query hooks
- An optional work-in-progress limit per column, enforced on the server
- A
boards.card_movedevent on the domain bus, so webhooks, the audit log and realtime subscribers all hear about it without the handler calling them
Boards are visible to every signed-in user and record who created them. If boards need to be per-tenant, install the multitenant plugin, which scopes every model including these.
The ordering, and why it is a string
This is the part worth reading even if you never install the plugin, because every ordered list has the same problem: a playlist, a priority queue, a sortable table.
The obvious design is an integer position column. Moving a card then means:
UPDATE cards SET position = position + 1WHERE column_id = ? AND position >= ?
That rewrites every card below the drop. With one person on the board it works. With two, their rewrites interleave and the column ends up with duplicate or skipped positions, and on Postgres the two statements can deadlock outright because they touch the same rows in opposite orders. Gap numbering (100, 200, 300) only delays it: the gap closes after a few drags between the same pair and you are renumbering again.
So a rank here is a string, compared lexicographically, and there is always a string strictly between any two others. A move is:
UPDATE board_cards SET column_id = ?, rank = ? WHERE id = ?
One row. No renumbering, no transaction spanning cards somebody else is dragging, and no lock held while a user decides where to drop. The same ORDER BY works unchanged on Postgres, MySQL and SQLite, which matters because Grit targets all three and a scheme needing dialect-specific SQL is three bugs.
What the ranks look like
Append three cards to an empty column and they get a0, a1, a2. Drag the last one to the top and exactly one row changes:
before aftera0 Write the launch post Zz Email the list <- the only row writtena1 Record the demo a0 Write the launch posta2 Email the list a1 Record the demo
The keys stay short because a rank has an integer part that increments rather than a string that grows. Ten thousand appends leave the key four characters long. Only repeated insertion between the same two neighbours lengthens one, by a character at a time, and it never runs out of room.
Two people, one slot
Two people who drop a card into the same slot at the same moment compute the same rank. That is a tie, not corruption. Every read orders by (rank, id), so the tie breaks the same way for both of them and the board is stable.
Stale neighbours
The move request names the two cards the dragged card was dropped between, not an index. An index is stale the instant anybody else moves a card, and a stale index silently puts the card in the wrong place. Neighbour ids survive that: if one of them has moved away, the server notices and appends rather than computing a position from a row that is no longer where the browser thought it was.
POST /api/v1/board-cards/:id/move{"column_id": "01a1...","prev_id": "01a2...", // the card it now sits below, empty for the top"next_id": "01a3..." // the card it now sits above, empty for the bottom}
Keyboard and screen reader
Every card has a drag handle that is a real button, so it is in the tab order. Space picks a card up, the arrow keys move it within and between columns, Space drops it and Escape cancels. A live region announces the column by name: "Picked up Email the list from To do", "Over In progress", "Dropped Email the list in In progress".
This is the entire reason the plugin takes a dependency on @dnd-kit rather than using HTML5 drag events. Those do not fire on touch devices and cannot be driven from the keyboard at all, so a hand-rolled board works in a demo and excludes two groups of people who are not in the room when it is demonstrated.
Work-in-progress limits
A column can cap how many cards it holds. The limit is checked on the server, and a move that would exceed it is answered with 409 and the card goes back where it came from. Reordering inside a column that is already at its limit is allowed: a limit is about how much work is in progress, not about whether it can be rearranged.
curl -X PATCH /api/v1/board-columns/:id -d '{"wip_limit": 3}'
Removing it
grit plugin remove kanban
Removal replays the install backwards from the lockfile: the files it wrote are deleted, the lines it injected are taken back out, and the project compiles again. Your board data stays in the database until you drop the tables.
Using it, step by step
Putting a board on an existing project, from installing to dragging a card with the keyboard.
Install it
grit plugin add kanbanWrites the three models, the service holding the ordering, the routes, the hooks and two admin pages, and adds Boards to the sidebar under Content.
Install the admin dependency and migrate
pnpm installcd apps/api && go run cmd/migrate/main.goThe dependency is @dnd-kit, which is what makes dragging work with a finger and a keyboard as well as a mouse. The migration creates boards, board_columns and board_cards.
Create a board
Open Boards in the admin and name one. It starts with To do, In progress and Done, because landing on an empty screen with a single button is a worse first thirty seconds than landing on a board.
Add some cards and drag one
Add a card from the foot of a column. Drag it with the pointer, or Tab to its handle, press Space, use the arrows and press Space again. The column name is announced as you cross it.
Set a work-in-progress limit
curl -X PATCH localhost:8080/api/v1/board-columns/<id> -d '{"wip_limit": 3}'A move that would push the column past its limit is refused with 409 and the card returns where it came from. Rearranging inside a full column is still allowed: a limit is about how much work is in progress, not whether it can be ordered.
React to a move from your own code
events.On("boards.card_moved", func(ctx context.Context, e events.Event) error {// notify, re-score, sync to another systemreturn nil})The handler emits on the domain bus, so webhooks, the audit log and realtime subscribers all hear about it without the move endpoint knowing they exist.
Related plugins
Near neighbours, and what each one is for instead.
Everything else is on the marketplace, and four ways to extend says when a plugin is the wrong tool for the job.
