From 6f28828d5f5c5934fe5dec5507c921f62ccf68dc Mon Sep 17 00:00:00 2001 From: OpenClaw Bot Date: Thu, 19 Feb 2026 17:44:18 -0600 Subject: [PATCH] Add drag-and-drop and sprint assignment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integrated @dnd-kit for drag-and-drop in Sprint Board - Tasks can be dragged between columns (To Do → In Progress → Review → Done) - Added sprint selector to new task dialog - Added sprint selector to task detail view - Visual drag overlay during drag operations - Grip handle appears on hover for drag initiation --- package-lock.json | 56 +++++ package.json | 3 + src/app/page.tsx | 34 ++- src/components/SprintBoard.tsx | 383 +++++++++++++++++++++++---------- 4 files changed, 359 insertions(+), 117 deletions(-) diff --git a/package-lock.json b/package-lock.json index 113346d..1e2dd8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.1.0", "license": "ISC", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", @@ -296,6 +299,59 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emnapi/core": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", diff --git a/package.json b/package.json index 3b6983e..36d8a40 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "lint": "eslint" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", diff --git a/src/app/page.tsx b/src/app/page.tsx index bcac201..310aaec 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -40,8 +40,10 @@ export default function Home() { const { projects, tasks, + sprints, selectedProjectId, selectedTaskId, + selectedSprintId, selectProject, addProject, deleteProject, @@ -416,7 +418,7 @@ export default function Home() { rows={3} /> -
+
+
+
setNewTask({ ...newTask, sprintId: e.target.value || undefined })} + className="w-full mt-1.5 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500" + > + + {sprints.filter(s => s.projectId === selectedProjectId).map((sprint) => ( + + ))} + +
@@ -563,6 +580,21 @@ export default function Home() {
+ {/* Sprint */} +
+ + +
+ {/* Comments Section */}

diff --git a/src/components/SprintBoard.tsx b/src/components/SprintBoard.tsx index bebd01c..2d3569a 100644 --- a/src/components/SprintBoard.tsx +++ b/src/components/SprintBoard.tsx @@ -1,11 +1,27 @@ "use client" import { useState } from "react" +import { + DndContext, + DragEndEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + closestCorners, +} from "@dnd-kit/core" +import { + SortableContext, + verticalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable" +import { CSS } from "@dnd-kit/utilities" import { useTaskStore, Task, Sprint } from "@/stores/useTaskStore" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Card, CardContent } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" -import { Plus, Calendar, Flag } from "lucide-react" +import { Plus, Calendar, Flag, GripVertical } from "lucide-react" import { format, parseISO } from "date-fns" const statusColumns = ["backlog", "in-progress", "review", "done"] as const @@ -18,12 +34,114 @@ const statusLabels: Record = { } const statusColors: Record = { - backlog: "bg-zinc-700", + backlog: "bg-slate-700", "in-progress": "bg-blue-600", review: "bg-yellow-600", done: "bg-green-600", } +const priorityColors: Record = { + low: "bg-slate-600", + medium: "bg-blue-600", + high: "bg-orange-600", + urgent: "bg-red-600", +} + +// Sortable Task Card Component +function SortableTaskCard({ + task, + onClick, +}: { + task: Task + onClick: () => void +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: task.id }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + } + + return ( + + +
+
+ +
+
+
+

+ {task.title} +

+
+
+ + {task.priority} + + + {task.type} + + {task.comments.length > 0 && ( + + 💬 {task.comments.length} + + )} +
+
+
+
+
+ ) +} + +// Drag Overlay Task Card (shown while dragging) +function DragOverlayTaskCard({ task }: { task: Task }) { + return ( + + +
+ +
+

+ {task.title} +

+
+ + {task.priority} + + + {task.type} + +
+
+
+
+
+ ) +} + export function SprintBoard() { const { tasks, @@ -31,7 +149,10 @@ export function SprintBoard() { selectedSprintId, selectSprint, selectedProjectId, + selectedTaskId, updateTask, + selectTask, + addSprint, } = useTaskStore() const [isCreatingSprint, setIsCreatingSprint] = useState(false) @@ -41,6 +162,16 @@ export function SprintBoard() { startDate: "", endDate: "", }) + const [activeId, setActiveId] = useState(null) + + // Sensors for drag detection + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ) // Get sprints for selected project const projectSprints = sprints.filter( @@ -59,6 +190,9 @@ export function SprintBoard() { return acc }, {} as Record) + // Get active task for drag overlay + const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null + const handleCreateSprint = () => { if (!newSprint.name || !selectedProjectId) return @@ -71,16 +205,35 @@ export function SprintBoard() { projectId: selectedProjectId, } - // Use the store's addSprint action - const { addSprint } = useTaskStore.getState() addSprint(sprint) - setIsCreatingSprint(false) setNewSprint({ name: "", goal: "", startDate: "", endDate: "" }) } - const handleMoveTask = (taskId: string, newStatus: string) => { - updateTask(taskId, { status: newStatus as any }) + const handleDragStart = (event: DragStartEvent) => { + setActiveId(event.active.id as string) + } + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + setActiveId(null) + + if (!over) return + + const taskId = active.id as string + const overId = over.id as string + + // Check if dropped over a column + if (statusColumns.includes(overId as any)) { + updateTask(taskId, { status: overId as Task["status"] }) + return + } + + // Check if dropped over another task + const overTask = tasks.find((t) => t.id === overId) + if (overTask && overTask.status !== tasks.find((t) => t.id === taskId)?.status) { + updateTask(taskId, { status: overTask.status }) + } } if (projectSprints.length === 0 && !isCreatingSprint) { @@ -101,10 +254,8 @@ export function SprintBoard() { return (
- - Create New Sprint - - + +

Create New Sprint

- {/* Sprint Header */} -
-
- - + +
+ {/* Sprint Header */} +
+
+ + +
+ + {currentSprint && ( +
+
+ + + {format(parseISO(currentSprint.startDate), "MMM d")} -{" "} + {format(parseISO(currentSprint.endDate), "MMM d, yyyy")} + +
+ + {currentSprint.status} + +
+ )}
- {currentSprint && ( -
-
- - - {format(parseISO(currentSprint.startDate), "MMM d")} -{" "} - {format(parseISO(currentSprint.endDate), "MMM d, yyyy")} - -
- - {currentSprint.status} - + {/* Sprint Goal */} + {currentSprint?.goal && ( +
+ Goal: {currentSprint.goal} +
+ )} + + {/* Sprint Board */} + {selectedSprintId ? ( +
+ {statusColumns.map((status) => ( +
+
+
+
+

{statusLabels[status]}

+
+ + {tasksByStatus[status]?.length || 0} + +
+ t.id) || []} + strategy={verticalListSortingStrategy} + > +
+ {tasksByStatus[status]?.map((task) => ( + selectTask(task.id)} + /> + ))} +
+
+
+ ))} +
+ ) : ( +
+

Select a sprint to view the sprint board

)}
- {/* Sprint Goal */} - {currentSprint?.goal && ( -
- Goal: {currentSprint.goal} -
- )} - - {/* Sprint Board */} - {selectedSprintId ? ( -
- {statusColumns.map((status) => ( -
-
-

{statusLabels[status]}

- - {tasksByStatus[status]?.length || 0} - -
-
- {tasksByStatus[status]?.map((task) => ( - {}} - > - -
-

- {task.title} -

- - {task.priority} - -
-
- - {task.type} - - {task.comments.length > 0 && ( - - 💬 {task.comments.length} - - )} -
-
-
- ))} -
-
- ))} -
- ) : ( -
-

Select a sprint to view the sprint board

-
- )} -
+ + {activeTask ? : null} + + ) }