Add drag-and-drop and sprint assignment

- 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
This commit is contained in:
OpenClaw Bot 2026-02-19 17:44:18 -06:00
parent 29ce1cce2a
commit 6f28828d5f
4 changed files with 359 additions and 117 deletions

56
package-lock.json generated
View File

@ -9,6 +9,9 @@
"version": "0.1.0", "version": "0.1.0",
"license": "ISC", "license": "ISC",
"dependencies": { "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-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@ -296,6 +299,59 @@
"node": ">=6.9.0" "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": { "node_modules/@emnapi/core": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz",

View File

@ -9,6 +9,9 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "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-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",

View File

@ -40,8 +40,10 @@ export default function Home() {
const { const {
projects, projects,
tasks, tasks,
sprints,
selectedProjectId, selectedProjectId,
selectedTaskId, selectedTaskId,
selectedSprintId,
selectProject, selectProject,
addProject, addProject,
deleteProject, deleteProject,
@ -416,7 +418,7 @@ export default function Home() {
rows={3} rows={3}
/> />
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<Label>Type</Label> <Label>Type</Label>
<select <select
@ -442,6 +444,8 @@ export default function Home() {
<option value="urgent">Urgent</option> <option value="urgent">Urgent</option>
</select> </select>
</div> </div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<Label>Status</Label> <Label>Status</Label>
<select <select
@ -454,6 +458,19 @@ export default function Home() {
))} ))}
</select> </select>
</div> </div>
<div>
<Label>Sprint (optional)</Label>
<select
value={newTask.sprintId || ""}
onChange={(e) => 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"
>
<option value="">No Sprint</option>
{sprints.filter(s => s.projectId === selectedProjectId).map((sprint) => (
<option key={sprint.id} value={sprint.id}>{sprint.name}</option>
))}
</select>
</div>
</div> </div>
<div> <div>
<Label>Tags (comma separated)</Label> <Label>Tags (comma separated)</Label>
@ -563,6 +580,21 @@ export default function Home() {
</div> </div>
</div> </div>
{/* Sprint */}
<div>
<Label className="text-slate-400">Sprint</Label>
<select
value={selectedTask.sprintId || ""}
onChange={(e) => updateTask(selectedTask.id, { sprintId: e.target.value || undefined })}
className="w-full mt-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white focus:outline-none focus:border-blue-500"
>
<option value="">No Sprint</option>
{sprints.filter(s => s.projectId === selectedProjectId).map((sprint) => (
<option key={sprint.id} value={sprint.id}>{sprint.name}</option>
))}
</select>
</div>
{/* Comments Section */} {/* Comments Section */}
<div className="border-t border-slate-800 pt-6"> <div className="border-t border-slate-800 pt-6">
<h4 className="font-medium text-white mb-4 flex items-center gap-2"> <h4 className="font-medium text-white mb-4 flex items-center gap-2">

View File

@ -1,11 +1,27 @@
"use client" "use client"
import { useState } from "react" 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 { 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 { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" 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" import { format, parseISO } from "date-fns"
const statusColumns = ["backlog", "in-progress", "review", "done"] as const const statusColumns = ["backlog", "in-progress", "review", "done"] as const
@ -18,12 +34,114 @@ const statusLabels: Record<string, string> = {
} }
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
backlog: "bg-zinc-700", backlog: "bg-slate-700",
"in-progress": "bg-blue-600", "in-progress": "bg-blue-600",
review: "bg-yellow-600", review: "bg-yellow-600",
done: "bg-green-600", done: "bg-green-600",
} }
const priorityColors: Record<string, string> = {
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 (
<Card
ref={setNodeRef}
style={style}
className="bg-slate-800 border-slate-700 cursor-pointer hover:border-slate-600 transition-colors group"
onClick={onClick}
>
<CardContent className="p-3">
<div className="flex items-start gap-2">
<div
{...attributes}
{...listeners}
className="mt-0.5 opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
>
<GripVertical className="w-4 h-4 text-slate-500" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
{task.title}
</h4>
</div>
<div className="flex items-center gap-2 mt-2">
<Badge
className={`${priorityColors[task.priority]} text-white text-xs`}
>
{task.priority}
</Badge>
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{task.type}
</Badge>
{task.comments.length > 0 && (
<span className="text-xs text-slate-500">
💬 {task.comments.length}
</span>
)}
</div>
</div>
</div>
</CardContent>
</Card>
)
}
// Drag Overlay Task Card (shown while dragging)
function DragOverlayTaskCard({ task }: { task: Task }) {
return (
<Card className="bg-slate-800 border-slate-600 shadow-xl rotate-2">
<CardContent className="p-3">
<div className="flex items-start gap-2">
<GripVertical className="w-4 h-4 text-slate-500 mt-0.5" />
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
{task.title}
</h4>
<div className="flex items-center gap-2 mt-2">
<Badge
className={`${priorityColors[task.priority]} text-white text-xs`}
>
{task.priority}
</Badge>
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{task.type}
</Badge>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
export function SprintBoard() { export function SprintBoard() {
const { const {
tasks, tasks,
@ -31,7 +149,10 @@ export function SprintBoard() {
selectedSprintId, selectedSprintId,
selectSprint, selectSprint,
selectedProjectId, selectedProjectId,
selectedTaskId,
updateTask, updateTask,
selectTask,
addSprint,
} = useTaskStore() } = useTaskStore()
const [isCreatingSprint, setIsCreatingSprint] = useState(false) const [isCreatingSprint, setIsCreatingSprint] = useState(false)
@ -41,6 +162,16 @@ export function SprintBoard() {
startDate: "", startDate: "",
endDate: "", endDate: "",
}) })
const [activeId, setActiveId] = useState<string | null>(null)
// Sensors for drag detection
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
)
// Get sprints for selected project // Get sprints for selected project
const projectSprints = sprints.filter( const projectSprints = sprints.filter(
@ -59,6 +190,9 @@ export function SprintBoard() {
return acc return acc
}, {} as Record<string, Task[]>) }, {} as Record<string, Task[]>)
// Get active task for drag overlay
const activeTask = activeId ? tasks.find((t) => t.id === activeId) : null
const handleCreateSprint = () => { const handleCreateSprint = () => {
if (!newSprint.name || !selectedProjectId) return if (!newSprint.name || !selectedProjectId) return
@ -71,16 +205,35 @@ export function SprintBoard() {
projectId: selectedProjectId, projectId: selectedProjectId,
} }
// Use the store's addSprint action
const { addSprint } = useTaskStore.getState()
addSprint(sprint) addSprint(sprint)
setIsCreatingSprint(false) setIsCreatingSprint(false)
setNewSprint({ name: "", goal: "", startDate: "", endDate: "" }) setNewSprint({ name: "", goal: "", startDate: "", endDate: "" })
} }
const handleMoveTask = (taskId: string, newStatus: string) => { const handleDragStart = (event: DragStartEvent) => {
updateTask(taskId, { status: newStatus as any }) 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) { if (projectSprints.length === 0 && !isCreatingSprint) {
@ -101,10 +254,8 @@ export function SprintBoard() {
return ( return (
<div className="max-w-md mx-auto py-8"> <div className="max-w-md mx-auto py-8">
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
<CardHeader> <CardContent className="p-6 space-y-4">
<CardTitle className="text-lg">Create New Sprint</CardTitle> <h3 className="text-lg font-medium text-slate-200">Create New Sprint</h3>
</CardHeader>
<CardContent className="space-y-4">
<div> <div>
<label className="text-sm text-slate-400">Sprint Name</label> <label className="text-sm text-slate-400">Sprint Name</label>
<input <input
@ -160,10 +311,16 @@ export function SprintBoard() {
} }
return ( return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-4"> <div className="space-y-4">
{/* Sprint Header */} {/* Sprint Header */}
<div className="flex items-center justify-between"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-4"> <div className="flex items-center gap-3">
<select <select
value={selectedSprintId || ""} value={selectedSprintId || ""}
onChange={(e) => selectSprint(e.target.value || null)} onChange={(e) => selectSprint(e.target.value || null)}
@ -219,48 +376,37 @@ export function SprintBoard() {
{/* Sprint Board */} {/* Sprint Board */}
{selectedSprintId ? ( {selectedSprintId ? (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
{statusColumns.map((status) => ( {statusColumns.map((status) => (
<div key={status} className="flex flex-col"> <div
key={status}
id={status}
className="flex flex-col bg-slate-900/50 rounded-lg p-3"
data-status={status}
>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${statusColors[status]}`} />
<h3 className="font-medium text-slate-300">{statusLabels[status]}</h3> <h3 className="font-medium text-slate-300">{statusLabels[status]}</h3>
</div>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{tasksByStatus[status]?.length || 0} {tasksByStatus[status]?.length || 0}
</Badge> </Badge>
</div> </div>
<div className="space-y-2"> <SortableContext
items={tasksByStatus[status]?.map((t) => t.id) || []}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 min-h-[100px]">
{tasksByStatus[status]?.map((task) => ( {tasksByStatus[status]?.map((task) => (
<Card <SortableTaskCard
key={task.id} key={task.id}
className="bg-slate-800 border-slate-700 cursor-pointer hover:border-slate-600 transition-colors" task={task}
draggable onClick={() => selectTask(task.id)}
onDragEnd={() => {}} />
>
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<h4 className="text-sm font-medium text-slate-200 line-clamp-2">
{task.title}
</h4>
<Badge
className={`${statusColors[task.priority]} text-white text-xs shrink-0`}
>
{task.priority}
</Badge>
</div>
<div className="flex items-center gap-2 mt-2">
<Badge variant="outline" className="text-xs">
{task.type}
</Badge>
{task.comments.length > 0 && (
<span className="text-xs text-slate-500">
💬 {task.comments.length}
</span>
)}
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
</SortableContext>
</div> </div>
))} ))}
</div> </div>
@ -270,5 +416,10 @@ export function SprintBoard() {
</div> </div>
)} )}
</div> </div>
<DragOverlay>
{activeTask ? <DragOverlayTaskCard task={activeTask} /> : null}
</DragOverlay>
</DndContext>
) )
} }