PathLogs UI
English

Виджеты

Kanban

Доска с перетаскиванием карточек и колонок и WIP-лимитами.

Доска с перетаскиванием карточек и колонок, WIP-лимитами, скрытыми колонками и оптимистичным состоянием. О домене не знает ничего: что показывать на карточке, решает renderCard.

Установка

terminal
npx @toimetdev/pathlogs-ui add kanban

Копирует три файла: Kanban.tsx, ColumnEditor.tsx и kanbanOrder.ts. Дальше это ваш код — правьте как обычные файлы проекта.

Пример

К выполнению

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

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

ДК
UI-16Фича

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

МТ

В работе

2/2
UI-12Баг

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

МТ
UI-14Фича

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

АСДК

На проверке

1
UI-17Фича

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

АС

Готово

1
UI-9Баг

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

ДК

Попробуйте: перетащите карточку между колонками и внутри колонки, переставьте колонку за ручку слева, откройте её настройки, поменяйте WIP-лимит. Колонка «Готово» отсортирована по дате — слот там встаёт туда, где карточка окажется на самом деле.

Данные

Доске нужен минимум полей — всё остальное ваше:

tsx
interface KanbanItem {
  id: string;
  columnId: string | null;
  order: number;
  createdAt: string;     // ISO — сравнивается лексикографически
  color?: string | null; // персональный цвет карточки
}

interface KanbanColumn {
  id: string;
  name: string;
  color: string;         // #rrggbb — им подкрашивается вся колонка
  order: number;
  wipLimit?: number | null;
  sort?: "MANUAL" | "CREATED_DESC" | "CREATED_ASC";
  hidden?: boolean;
}

Ваш тип просто расширяет базовый:

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

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

Пропсы

items*I[]

Карточки.

columns*C[]

Колонки.

renderCard*(item, ctx) => ReactNode

Содержимое карточки. В ctx приходит { dragging, column } — например, чтобы приглушить карточку во время переноса.

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

Карточка переехала. orderedIds — полный новый порядок целевой колонки.

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

Без него колонки не перетаскиваются — ручка не показывается.

onCreateColumn(name, color) => void | Promise

Без него нет кнопки «новая колонка».

onUpdateColumn(columnId, fields) => void | Promise

Без него нет кнопки настроек колонки.

onSetColumnHidden(columnId, hidden) => void | Promise

Скрытие колонки и полоса «скрытые колонки» внизу.

onDeleteColumn(columnId) => void | Promise

Удаление колонки.

onOpenItem(item: I) => void

Клик по карточке.

filter(item: I) => boolean

Фильтр карточек. Колонки остаются на месте — видно и структуру доски, и сколько в ней осталось.

canManageColumnsboolean

по умолчанию: false

Право менять состав колонок: создание и удаление.

palettereadonly string[]

Палитра цветов колонок.

toolbarReactNode

Полоса над доской: фильтр, индикатор обновлений.

labelsKanbanLabels

Подписи. По умолчанию английские.

Оптимистичное состояние

Доска применяет перенос сразу и держит своё состояние до ответа сервера. Свежие items заменяют его — но только когда все начатые действия завершились.

onMoveItem получает полный порядок колонки, а не одну позицию: сервер должен записать порядок целиком, иначе два одновременных переноса разъедутся.

Тонкости перетаскивания

Здесь несколько решений, каждое из которых — ответ на конкретную поломку:

  • Источник прячется не в dragstart, а на первом drag. dragstart — дискретное событие, React применил бы setState синхронно, карточка исчезла бы прямо в момент старта, и браузер отменил бы перенос.
  • Скрытая карточка остаётся в дереве (атрибут hidden, а не удаление). Иначе её onDragEnd не сработал бы при отмене переноса Escape — и доска осталась бы без этой карточки.
  • Слот повторяет высоту карточки, поэтому соседи не «прыгают» в момент захвата.
  • Лента подкручивается у края во время переноса: указатель принадлежит браузеру, и без автопрокрутки колонку за пределами экрана было бы нечем достать.
  • Границы колонки — четыре отдельных свойства. React обновляет сокращённое borderColor и borderTopColor независимо, и верхняя полоса «залипает» от прошлого состояния.

Порядок отдельно

Все правила порядка живут в kanbanOrder.ts — без React и без DOM, с тестами:

tsx
import {
  columnItems,     // карточки колонки в порядке показа
  dropSlotIndex,   // куда встанет карточка при разных режимах сортировки
  insertAt,        // новый порядок id после вставки
  reorderColumns,  // новый порядок колонок
  isOverWipLimit,
  applyOrder,
} from "@/components/ui/kanban/kanbanOrder";

Самое неочевидное здесь — dropSlotIndex. При ручном порядке слот встаёт под курсор; при сортировке по дате — туда, где карточка окажется на самом деле (иначе она прыгнула бы после отпускания); при активном фильтре — в конец, потому что «место под курсором» ничего не говорит о настоящем порядке, когда видно не все карточки.