Files
kandev/apps/web/components/kanban-column.tsx
T
2026-09-13 21:44:28 +01:00

267 lines
9.2 KiB
TypeScript

"use client";
import { memo, useCallback, useMemo, type Ref } from "react";
import { useColumnNaturalHeight } from "@/hooks/domains/kanban/use-column-natural-height";
import { useDroppable } from "@dnd-kit/core";
import { Task, type KanbanPresentation } from "./kanban-card";
import { Badge } from "@kandev/ui/badge";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/components/state-provider";
import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability";
import type { Repository } from "@/lib/types/http";
import { countAdmittedTasks, formatWipCount, isOverWipLimit } from "@/lib/kanban/wip-limit";
import { partitionWipTasks } from "@/lib/kanban/wip-queue";
import { pickKanbanColumnComparator } from "@/lib/kanban/task-order";
import { useTranslation } from "react-i18next";
import {
VirtualizedColumnTaskList,
type KeyboardReorderDraft,
} from "./kanban/virtualized-column-task-list";
export interface WorkflowStep {
id: string;
title: string;
color: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
wip_limit?: number;
pull_from_step_id?: string | null;
}
interface KanbanColumnProps {
onNaturalHeightChange?: (stepId: string, height: number) => void;
step: WorkflowStep;
tasks: Task[];
presentation?: KanbanPresentation;
onPreviewTask: (task: Task) => void;
onOpenTask: (task: Task) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (task: Task) => void;
onArchiveTask?: (task: Task) => void;
onMoveTask?: (task: Task, targetStepId: string) => void;
steps?: WorkflowStep[];
showMaximizeButton?: boolean;
deletingTaskId?: string | null;
archivingTaskId?: string | null;
hideHeader?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
/** Shift-click range select; receives the clicked id + this column's ordered ids. */
onSelectRange?: (taskId: string, orderedIds: string[]) => void;
isMultiSelectMode?: boolean;
externalLinkAvailability: KanbanExternalLinkAvailability;
/** The task currently being dragged anywhere on the board, if any (AC.7's insertion indicator). */
activeTaskId?: string | null;
/** The card currently picked up via the keyboard, if any (AC.12). */
keyboardDraft?: KeyboardReorderDraft | null;
onCardKeyDown?: (event: React.KeyboardEvent, task: Task) => void;
}
function ColumnHeader({
step,
tasks,
headerRef,
}: {
step: WorkflowStep;
tasks: Task[];
headerRef: Ref<HTMLDivElement>;
}) {
const { t } = useTranslation();
const admittedTaskCount = countAdmittedTasks(tasks);
const overWipLimit = isOverWipLimit(admittedTaskCount, step.wip_limit);
const wipCountLabel = formatWipCount(admittedTaskCount, step.wip_limit);
const queuedCount = partitionWipTasks(tasks, step.id).queued.length;
return (
<div ref={headerRef} className="flex shrink-0 items-center justify-between pb-2 mb-3 px-1">
<div className="flex items-center gap-2">
<div className={cn("w-2 h-2 rounded-full", step.color)} />
<h2 className="font-semibold text-sm">{step.title}</h2>
<Badge
variant="secondary"
className={cn(
"text-xs tabular-nums",
overWipLimit &&
"border-amber-500/50 bg-amber-500/15 text-amber-700 dark:text-amber-300",
)}
aria-label={
overWipLimit ? t("kanban:tasksOverWipLimit", { label: wipCountLabel }) : undefined
}
title={overWipLimit ? t("kanban:overWipLimit") : undefined}
>
{wipCountLabel}
</Badge>
{queuedCount > 0 && (
<span className="text-xs text-muted-foreground" data-testid="kanban-queued-count">
{t("kanban:queuedCount", { count: queuedCount })}
</span>
)}
</div>
</div>
);
}
function taskItemsEqual(previous: Task[], next: Task[]): boolean {
return previous.length === next.length && previous.every((task, index) => task === next[index]);
}
function externalLinkAvailabilityEqual(
previous: KanbanExternalLinkAvailability,
next: KanbanExternalLinkAvailability,
): boolean {
return (
previous.gitlab === next.gitlab &&
previous.jira === next.jira &&
previous.linear === next.linear &&
previous.sentry === next.sentry
);
}
function columnCallbacksEqual(previous: KanbanColumnProps, next: KanbanColumnProps): boolean {
return (
previous.onNaturalHeightChange === next.onNaturalHeightChange &&
previous.onPreviewTask === next.onPreviewTask &&
previous.onOpenTask === next.onOpenTask &&
previous.onEditTask === next.onEditTask &&
previous.onDeleteTask === next.onDeleteTask &&
previous.onArchiveTask === next.onArchiveTask &&
previous.onMoveTask === next.onMoveTask &&
previous.onToggleSelect === next.onToggleSelect &&
previous.onSelectRange === next.onSelectRange &&
previous.onCardKeyDown === next.onCardKeyDown
);
}
function columnDisplayPropsEqual(previous: KanbanColumnProps, next: KanbanColumnProps): boolean {
return (
previous.presentation === next.presentation &&
previous.steps === next.steps &&
previous.showMaximizeButton === next.showMaximizeButton &&
previous.deletingTaskId === next.deletingTaskId &&
previous.archivingTaskId === next.archivingTaskId &&
previous.hideHeader === next.hideHeader &&
previous.selectedIds === next.selectedIds &&
previous.isMultiSelectMode === next.isMultiSelectMode
);
}
function kanbanColumnPropsEqual(previous: KanbanColumnProps, next: KanbanColumnProps): boolean {
return (
previous.step === next.step &&
taskItemsEqual(previous.tasks, next.tasks) &&
columnCallbacksEqual(previous, next) &&
columnDisplayPropsEqual(previous, next) &&
externalLinkAvailabilityEqual(
previous.externalLinkAvailability,
next.externalLinkAvailability,
) &&
previous.activeTaskId === next.activeTaskId &&
previous.keyboardDraft === next.keyboardDraft
);
}
export const KanbanColumn = memo(function KanbanColumn({
onNaturalHeightChange,
step,
tasks,
presentation = "desktop",
onPreviewTask,
onOpenTask,
onEditTask,
onDeleteTask,
onArchiveTask,
onMoveTask,
steps,
showMaximizeButton,
deletingTaskId,
archivingTaskId,
hideHeader = false,
selectedIds,
onToggleSelect,
onSelectRange,
isMultiSelectMode,
externalLinkAvailability,
activeTaskId,
keyboardDraft,
onCardKeyDown,
}: KanbanColumnProps) {
const { setNodeRef, isOver } = useDroppable({
id: step.id,
});
const { columnRef, headerRef, onContentHeightChange } = useColumnNaturalHeight(
step.id,
onNaturalHeightChange,
);
const setColumnRef = useCallback(
(element: HTMLDivElement | null) => {
columnRef.current = element;
setNodeRef(element);
},
[columnRef, setNodeRef],
);
const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId);
const kanbanSort = useAppStore((state) => state.userSettings.kanbanSort);
const taskComparator = useMemo(() => pickKanbanColumnComparator(kanbanSort), [kanbanSort]);
// Access repositories from store to pass repository names to cards
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const repositories = useMemo(
() => Object.values(repositoriesByWorkspace).flat() as Repository[],
[repositoriesByWorkspace],
);
// Ordered ids of the cards rendered in this column — the source of truth for
// shift-click range selection (matches exactly what the user sees).
const { admitted, queued } = useMemo(
() => partitionWipTasks(tasks, step.id, taskComparator),
[tasks, step.id, taskComparator],
);
const orderedTasks = useMemo(() => [...admitted, ...queued], [admitted, queued]);
return (
<div
ref={setColumnRef}
data-testid={`kanban-column-${step.id}`}
className={cn(
"flex flex-col flex-1 h-full min-w-0 px-3 py-2 sm:min-h-[200px]",
"border-r border-dashed border-border/50 last:border-r-0",
isOver && "bg-primary/5",
)}
>
{/* Column Header */}
{!hideHeader && <ColumnHeader step={step} tasks={tasks} headerRef={headerRef} />}
<VirtualizedColumnTaskList
onContentHeightChange={onContentHeightChange}
orderedTasks={orderedTasks}
queuedStartIndex={admitted.length}
queuedCount={queued.length}
step={step}
steps={steps}
presentation={presentation}
workspaceId={activeWorkspaceId}
repositories={repositories}
externalLinkAvailability={externalLinkAvailability}
showMaximizeButton={showMaximizeButton}
deletingTaskId={deletingTaskId}
archivingTaskId={archivingTaskId}
selectedIds={selectedIds}
activeTaskId={activeTaskId}
keyboardDraft={keyboardDraft}
onCardKeyDown={onCardKeyDown}
onPreviewTask={onPreviewTask}
onOpenTask={onOpenTask}
onEditTask={onEditTask}
onDeleteTask={onDeleteTask}
onArchiveTask={onArchiveTask}
onMoveTask={onMoveTask}
onToggleSelect={onToggleSelect}
onSelectRange={onSelectRange}
isMultiSelectMode={isMultiSelectMode}
/>
</div>
);
}, kanbanColumnPropsEqual);