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:
parent
4d5052d731
commit
0b84352b09
@ -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>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user