PathLogs UI
Русский

Widgets

Kanban

A board with draggable cards and columns, and WIP limits.

A board with draggable cards and columns, WIP limits, hidden columns and optimistic state. It knows nothing about your domain: what to show on a card is decided by renderCard.

Installation

terminal
npx @toimetdev/pathlogs-ui add kanban

Copies three files: Kanban.tsx, ColumnEditor.tsx and kanbanOrder.ts. From then on it is your code — edit it like any other file in the project.

Example

К выполнению

2
UI-15Рефакторинг

Вынести разбор Markdown в отдельный модуль

ДК
UI-16Фича

WIP-лимиты у колонок

МТ

В работе

2/2
UI-12Баг

Импорт досок из Trello падает на больших проектах

МТ
UI-14Фича

Живые обновления доски по SSE

АСДК

На проверке

1
UI-17Фича

Критический путь на диаграмме Ганта

АС

Готово

1
UI-9Баг

Тултипы обрезались в колонках доски

ДК

Try it: drag a card between columns and within a column, move a column by the handle on the left, open its settings, change the WIP limit. The «Done» column is sorted by date — the slot there appears where the card will actually land.

The data

The board needs a minimum of fields — everything else is yours:

tsx
interface KanbanItem {
  id: string;
  columnId: string | null;
  order: number;
  createdAt: string;     // ISO — compared lexicographically
  color?: string | null; // the card's personal colour
}

interface KanbanColumn {
  id: string;
  name: string;
  color: string;         // #rrggbb — tints the whole column
  order: number;
  wipLimit?: number | null;
  sort?: "MANUAL" | "CREATED_DESC" | "CREATED_ASC";
  hidden?: boolean;
}

Your own type simply extends the base one:

tsx
interface Task extends KanbanItem {
  number: number;
  title: string;
  priority: 1 | 2 | 3 | 4;
  assignees: Member[];
}

<Kanban<Task, KanbanColumn> items={tasks}  />

Props

items*I[]

The cards.

columns*C[]

The columns.

renderCard*(item, ctx) => ReactNode

The card's contents. ctx carries { dragging, column } — to dim the card while it is being moved, for instance.

onMoveItem*(itemId, columnId, orderedIds) => void | Promise

A card moved. orderedIds is the complete new order of the target column.

onReorderColumns(orderedIds: string[]) => void | Promise

Without it columns cannot be dragged — no handle is shown.

onCreateColumn(name, color) => void | Promise

Without it there is no «new column» button.

onUpdateColumn(columnId, fields) => void | Promise

Without it there is no column settings button.

onSetColumnHidden(columnId, hidden) => void | Promise

Hiding a column, plus the «hidden columns» bar at the bottom.

onDeleteColumn(columnId) => void | Promise

Deleting a column.

onOpenItem(item: I) => void

A click on a card.

filter(item: I) => boolean

A card filter. Columns stay in place — you can see both the board's structure and how much is left in it.

canManageColumnsboolean

defaults to: false

Permission to change the set of columns: creating and deleting.

palettereadonly string[]

The palette of column colours.

toolbarReactNode

The bar above the board: a filter, an update indicator.

labelsKanbanLabels

Captions. English by default.

Optimistic state

The board applies a move immediately and holds its own state until the server answers. Fresh items replace it — but only once every action that started has finished.

onMoveItem receives the complete order of the column, not a single position: the server must write the whole order, otherwise two simultaneous moves diverge.

Drag-and-drop subtleties

Several decisions here, each an answer to a specific breakage:

  • The source hides on the first drag, not on dragstart. dragstart is a discrete event, so React would apply setState synchronously, the card would vanish at the very moment of the start, and the browser would cancel the drag.
  • The hidden card stays in the tree (the hidden attribute rather than removal). Otherwise its onDragEnd would not fire when the drag is cancelled with Escape — and the board would be left without that card.
  • The slot matches the card's height, so neighbours do not «jump» at the moment of pick-up.
  • The strip auto-scrolls near the edge during a drag: the pointer belongs to the browser, and without auto-scroll there would be no way to reach a column off-screen.
  • The column's borders are four separate properties. React updates the shorthand borderColor and borderTopColor independently, and the top stripe «sticks» from the previous state.

The ordering, separately

All the ordering rules live in kanbanOrder.ts — no React, no DOM, with tests:

tsx
import {
  columnItems,     // a column's cards in display order
  dropSlotIndex,   // where a card will land under each sort mode
  insertAt,        // the new order of ids after an insertion
  reorderColumns,  // the new order of columns
  isOverWipLimit,
  applyOrder,
} from "@/components/ui/kanban/kanbanOrder";

The least obvious of these is dropSlotIndex. Under manual ordering the slot appears under the cursor; under date sorting, where the card will actually end up (otherwise it would jump after release); and with an active filter, at the end — because «the place under the cursor» says nothing about the real order when not all cards are visible.