Fix KanbanBoard: actually wire TouchSensor into useSensors

The import was added but the sensors config replacement failed silently
due to line ending mismatch. TouchSensor now properly registered with
200ms delay / 5px tolerance alongside PointerSensor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-07 17:42:27 +08:00
parent 4d5052d731
commit 0b84352b09

View File

@ -1,218 +1,219 @@
import { import {
DndContext, DndContext,
closestCorners, closestCorners,
PointerSensor, PointerSensor,
TouchSensor, TouchSensor,
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
useDroppable, useDroppable,
useDraggable, useDraggable,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { format, parseISO } from 'date-fns'; import { format, parseISO } from 'date-fns';
import type { ProjectTask } from '@/types'; import type { ProjectTask } from '@/types';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
const COLUMNS: { id: string; label: string; color: string }[] = [ const COLUMNS: { id: string; label: string; color: string }[] = [
{ id: 'pending', label: 'Pending', color: 'text-gray-400' }, { id: 'pending', label: 'Pending', color: 'text-gray-400' },
{ id: 'in_progress', label: 'In Progress', color: 'text-blue-400' }, { id: 'in_progress', label: 'In Progress', color: 'text-blue-400' },
{ id: 'blocked', label: 'Blocked', color: 'text-red-400' }, { id: 'blocked', label: 'Blocked', color: 'text-red-400' },
{ id: 'on_hold', label: 'On Hold', color: 'text-orange-400' }, { id: 'on_hold', label: 'On Hold', color: 'text-orange-400' },
{ id: 'review', label: 'Review', color: 'text-yellow-400' }, { id: 'review', label: 'Review', color: 'text-yellow-400' },
{ id: 'completed', label: 'Completed', color: 'text-green-400' }, { id: 'completed', label: 'Completed', color: 'text-green-400' },
]; ];
const priorityColors: Record<string, string> = { const priorityColors: Record<string, string> = {
none: 'bg-gray-500/20 text-gray-400', none: 'bg-gray-500/20 text-gray-400',
low: 'bg-green-500/20 text-green-400', low: 'bg-green-500/20 text-green-400',
medium: 'bg-yellow-500/20 text-yellow-400', medium: 'bg-yellow-500/20 text-yellow-400',
high: 'bg-red-500/20 text-red-400', high: 'bg-red-500/20 text-red-400',
}; };
interface KanbanBoardProps { interface KanbanBoardProps {
tasks: ProjectTask[]; tasks: ProjectTask[];
selectedTaskId: number | null; selectedTaskId: number | null;
kanbanParentTask?: ProjectTask | null; kanbanParentTask?: ProjectTask | null;
onSelectTask: (taskId: number) => void; onSelectTask: (taskId: number) => void;
onStatusChange: (taskId: number, status: string) => void; onStatusChange: (taskId: number, status: string) => void;
onBackToAllTasks?: () => void; onBackToAllTasks?: () => void;
} }
function KanbanColumn({ function KanbanColumn({
column, column,
tasks, tasks,
selectedTaskId, selectedTaskId,
onSelectTask, onSelectTask,
}: { }: {
column: (typeof COLUMNS)[0]; column: (typeof COLUMNS)[0];
tasks: ProjectTask[]; tasks: ProjectTask[];
selectedTaskId: number | null; selectedTaskId: number | null;
onSelectTask: (taskId: number) => void; onSelectTask: (taskId: number) => void;
}) { }) {
const { setNodeRef, isOver } = useDroppable({ id: column.id }); const { setNodeRef, isOver } = useDroppable({ id: column.id });
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${ className={`flex-1 min-w-[160px] md:min-w-[200px] rounded-lg border transition-colors duration-150 ${
isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50' isOver ? 'border-accent/40 bg-accent/5' : 'border-border bg-card/50'
}`} }`}
> >
{/* Column header */} {/* Column header */}
<div className="px-3 py-2.5 border-b border-border"> <div className="px-3 py-2.5 border-b border-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className={`text-sm font-semibold font-heading ${column.color}`}> <span className={`text-sm font-semibold font-heading ${column.color}`}>
{column.label} {column.label}
</span> </span>
<span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5"> <span className="text-[11px] text-muted-foreground tabular-nums bg-secondary rounded-full px-2 py-0.5">
{tasks.length} {tasks.length}
</span> </span>
</div> </div>
</div> </div>
{/* Cards */} {/* Cards */}
<div className="p-2 space-y-2 min-h-[100px]"> <div className="p-2 space-y-2 min-h-[100px]">
{tasks.map((task) => ( {tasks.map((task) => (
<KanbanCard <KanbanCard
key={task.id} key={task.id}
task={task} task={task}
isSelected={selectedTaskId === task.id} isSelected={selectedTaskId === task.id}
onSelect={() => onSelectTask(task.id)} onSelect={() => onSelectTask(task.id)}
/> />
))} ))}
</div> </div>
</div> </div>
); );
} }
function KanbanCard({ function KanbanCard({
task, task,
isSelected, isSelected,
onSelect, onSelect,
}: { }: {
task: ProjectTask; task: ProjectTask;
isSelected: boolean; isSelected: boolean;
onSelect: () => void; onSelect: () => void;
}) { }) {
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
id: task.id, id: task.id,
data: { task }, data: { task },
}); });
const style = transform const style = transform
? { ? {
transform: `translate(${transform.x}px, ${transform.y}px)`, transform: `translate(${transform.x}px, ${transform.y}px)`,
opacity: isDragging ? 0.5 : 1, opacity: isDragging ? 0.5 : 1,
} }
: undefined; : undefined;
const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0; const completedSubtasks = task.subtasks?.filter((s) => s.status === 'completed').length ?? 0;
const totalSubtasks = task.subtasks?.length ?? 0; const totalSubtasks = task.subtasks?.length ?? 0;
return ( return (
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
{...listeners} {...listeners}
{...attributes} {...attributes}
onClick={onSelect} onClick={onSelect}
className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${ className={`rounded-md border p-3 cursor-pointer transition-all duration-150 ${
isSelected isSelected
? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10' ? 'border-accent/40 bg-accent/5 shadow-sm shadow-accent/10'
: 'border-border bg-card hover:bg-card-elevated hover:border-accent/20' : 'border-border bg-card hover:bg-card-elevated hover:border-accent/20'
}`} }`}
> >
<p className="text-sm font-medium leading-tight mb-2">{task.title}</p> <p className="text-sm font-medium leading-tight mb-2">{task.title}</p>
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<Badge <Badge
className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`} className={`text-[9px] px-1.5 py-0.5 rounded-full ${priorityColors[task.priority] ?? priorityColors.none}`}
> >
{task.priority} {task.priority}
</Badge> </Badge>
{task.due_date && ( {task.due_date && (
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
{format(parseISO(task.due_date), 'MMM d')} {format(parseISO(task.due_date), 'MMM d')}
</span> </span>
)} )}
{totalSubtasks > 0 && ( {totalSubtasks > 0 && (
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
{completedSubtasks}/{totalSubtasks} {completedSubtasks}/{totalSubtasks}
</span> </span>
)} )}
</div> </div>
</div> </div>
); );
} }
export default function KanbanBoard({ export default function KanbanBoard({
tasks, tasks,
selectedTaskId, selectedTaskId,
kanbanParentTask, kanbanParentTask,
onSelectTask, onSelectTask,
onStatusChange, onStatusChange,
onBackToAllTasks, onBackToAllTasks,
}: KanbanBoardProps) { }: KanbanBoardProps) {
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) ,
); useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } })
);
// Subtask view is driven by kanbanParentTask (decoupled from selected task)
const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0; // Subtask view is driven by kanbanParentTask (decoupled from selected task)
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks; const isSubtaskView = kanbanParentTask != null && (kanbanParentTask.subtasks?.length ?? 0) > 0;
const activeTasks: ProjectTask[] = isSubtaskView ? (kanbanParentTask.subtasks ?? []) : tasks;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event; const handleDragEnd = (event: DragEndEvent) => {
if (!over) return; const { active, over } = event;
if (!over) return;
const taskId = active.id as number;
const newStatus = over.id as string; const taskId = active.id as number;
const newStatus = over.id as string;
const task = activeTasks.find((t) => t.id === taskId);
if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) { const task = activeTasks.find((t) => t.id === taskId);
onStatusChange(taskId, newStatus); if (task && task.status !== newStatus && COLUMNS.some((c) => c.id === newStatus)) {
} onStatusChange(taskId, newStatus);
}; }
};
const tasksByStatus = COLUMNS.map((col) => ({
column: col, const tasksByStatus = COLUMNS.map((col) => ({
tasks: activeTasks.filter((t) => t.status === col.id), column: col,
})); tasks: activeTasks.filter((t) => t.status === col.id),
}));
return (
<div className="flex flex-col gap-3"> return (
{/* Subtask view header */} <div className="flex flex-col gap-3">
{isSubtaskView && kanbanParentTask && ( {/* Subtask view header */}
<div className="flex items-center gap-3 px-1"> {isSubtaskView && kanbanParentTask && (
<button <div className="flex items-center gap-3 px-1">
onClick={onBackToAllTasks} <button
className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2" onClick={onBackToAllTasks}
> className="text-xs text-muted-foreground hover:text-foreground transition-colors underline underline-offset-2"
Back to all tasks >
</button> Back to all tasks
<span className="text-muted-foreground text-xs">/</span> </button>
<span className="text-xs text-foreground font-medium"> <span className="text-muted-foreground text-xs">/</span>
Subtasks of: {kanbanParentTask.title} <span className="text-xs text-foreground font-medium">
</span> Subtasks of: {kanbanParentTask.title}
</div> </span>
)} </div>
)}
<DndContext
sensors={sensors} <DndContext
collisionDetection={closestCorners} sensors={sensors}
onDragEnd={handleDragEnd} collisionDetection={closestCorners}
> onDragEnd={handleDragEnd}
<div className="flex gap-3 overflow-x-auto pb-2"> >
{tasksByStatus.map(({ column, tasks: colTasks }) => ( <div className="flex gap-3 overflow-x-auto pb-2">
<KanbanColumn {tasksByStatus.map(({ column, tasks: colTasks }) => (
key={column.id} <KanbanColumn
column={column} key={column.id}
tasks={colTasks} column={column}
selectedTaskId={selectedTaskId} tasks={colTasks}
onSelectTask={onSelectTask} selectedTaskId={selectedTaskId}
/> onSelectTask={onSelectTask}
))} />
</div> ))}
</DndContext> </div>
</div> </DndContext>
); </div>
} );
}