From bfcd23049662c89d433ecc2ac7b924a5a3b2e419 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 30 Mar 2026 03:53:59 +0000 Subject: [PATCH] feat(beps): add drag-and-drop support for kanban card status migration - Install @dnd-kit/core and @dnd-kit/sortable for drag-and-drop functionality - Update BepKanbanCard to be draggable with a grip handle - Update BepList to support dropping cards between status columns - Cards can now be dragged from one column to another to change their status - Visual feedback shows when dragging over a valid drop target Co-authored-by: aaronvg --- typescript/apps/beps/package-lock.json | 55 ++++ typescript/apps/beps/package.json | 2 + .../src/components/bep/bep-kanban-card.tsx | 75 +++++- .../apps/beps/src/components/bep/bep-list.tsx | 254 +++++++++++++++--- 4 files changed, 333 insertions(+), 53 deletions(-) diff --git a/typescript/apps/beps/package-lock.json b/typescript/apps/beps/package-lock.json index ca2a3eb2df..36840108fe 100644 --- a/typescript/apps/beps/package-lock.json +++ b/typescript/apps/beps/package-lock.json @@ -10,6 +10,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.2", "@boundaryml/baml": "^0.217.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@lexical/code": "^0.39.0", "@lexical/list": "^0.39.0", "@lexical/markdown": "^0.39.0", @@ -731,6 +733,59 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "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/typescript/apps/beps/package.json b/typescript/apps/beps/package.json index 9899507567..2b57e0d0dd 100644 --- a/typescript/apps/beps/package.json +++ b/typescript/apps/beps/package.json @@ -16,6 +16,8 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.2", "@boundaryml/baml": "^0.217.0", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", "@lexical/code": "^0.39.0", "@lexical/list": "^0.39.0", "@lexical/markdown": "^0.39.0", diff --git a/typescript/apps/beps/src/components/bep/bep-kanban-card.tsx b/typescript/apps/beps/src/components/bep/bep-kanban-card.tsx index 715a70d59e..4c20370f84 100644 --- a/typescript/apps/beps/src/components/bep/bep-kanban-card.tsx +++ b/typescript/apps/beps/src/components/bep/bep-kanban-card.tsx @@ -2,8 +2,11 @@ import { useState, useEffect } from "react"; import Link from "next/link"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; import { Card, CardContent } from "@/components/ui/card"; -import { MessageSquare, AlertCircle } from "lucide-react"; +import { MessageSquare, AlertCircle, GripVertical } from "lucide-react"; +import { Id } from "../../../convex/_generated/dataModel"; type BepStatus = | "draft" @@ -14,6 +17,7 @@ type BepStatus = | "superseded"; interface BepKanbanCardProps { + id: Id<"beps">; number: number; title: string; status: BepStatus; @@ -21,6 +25,7 @@ interface BepKanbanCardProps { commentCount: number; openIssueCount: number; updatedAt: number; + isDragging?: boolean; } function formatRelativeTime(timestamp: number, now: number): string { @@ -37,6 +42,7 @@ function formatRelativeTime(timestamp: number, now: number): string { } export function BepKanbanCard({ + id, number, title, shepherdNames, @@ -46,29 +52,72 @@ export function BepKanbanCard({ }: BepKanbanCardProps) { const [relativeTime, setRelativeTime] = useState(""); + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: id, + data: { + type: "bep", + bepId: id, + number, + }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + useEffect(() => { setRelativeTime(formatRelativeTime(updatedAt, Date.now())); }, [updatedAt]); return ( - - +
+
-
- - BEP-{String(number).padStart(3, "0")} - -

- {title} -

+
+ + { + if (isDragging) { + e.preventDefault(); + } + }} + > + + BEP-{String(number).padStart(3, "0")} + +

+ {title} +

+
{shepherdNames.length > 0 && ( -

+

{shepherdNames.join(", ")}

)} -
+
@@ -86,6 +135,6 @@ export function BepKanbanCard({
- +
); } diff --git a/typescript/apps/beps/src/components/bep/bep-list.tsx b/typescript/apps/beps/src/components/bep/bep-list.tsx index eba580ac10..add6c5a118 100644 --- a/typescript/apps/beps/src/components/bep/bep-list.tsx +++ b/typescript/apps/beps/src/components/bep/bep-list.tsx @@ -1,8 +1,25 @@ "use client"; -import { useState } from "react"; -import { useQuery } from "convex/react"; +import { useState, useCallback } from "react"; +import { useQuery, useMutation } from "convex/react"; +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragOverlay, + DragStartEvent, + PointerSensor, + useSensor, + useSensors, + closestCorners, + useDroppable, +} from "@dnd-kit/core"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { api } from "../../../convex/_generated/api"; +import { Id } from "../../../convex/_generated/dataModel"; import { BepCard } from "./bep-card"; import { BepKanbanCard } from "./bep-kanban-card"; import { Badge } from "@/components/ui/badge"; @@ -40,16 +57,98 @@ const KANBAN_COLUMNS: { status: BepStatus; label: string; color: string }[] = [ { status: "superseded", label: "Superseded", color: "bg-orange-500" }, ]; +interface KanbanColumnProps { + status: BepStatus; + label: string; + color: string; + beps: Array<{ + _id: Id<"beps">; + number: number; + title: string; + status: BepStatus; + shepherdNames: string[]; + commentCount: number; + openIssueCount: number; + updatedAt: number; + }>; + isOver?: boolean; +} + +function KanbanColumn({ status, label, color, beps, isOver }: KanbanColumnProps) { + const { setNodeRef } = useDroppable({ + id: status, + data: { + type: "column", + status, + }, + }); + + return ( +
+
+
+

{label}

+ + {beps.length} + +
+ bep._id)} + strategy={verticalListSortingStrategy} + > +
+ {beps.length > 0 ? ( + beps.map((bep) => ( + + )) + ) : ( +
+ {isOver ? "Drop here" : "No BEPs"} +
+ )} +
+
+
+ ); +} + export function BepList() { const [statusFilter, setStatusFilter] = useState("all"); const [searchQuery, setSearchQuery] = useState(""); const [showOldestFirst, setShowOldestFirst] = useState(false); const [viewMode, setViewMode] = useState("kanban"); + const [activeId, setActiveId] = useState | null>(null); + const [overColumn, setOverColumn] = useState(null); const beps = useQuery(api.beps.list, { status: viewMode === "kanban" ? undefined : statusFilter === "all" ? undefined : statusFilter, }); + const updateStatus = useMutation(api.beps.updateStatus); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + const filteredBeps = beps ?.filter((bep) => { if (!searchQuery) return true; @@ -64,9 +163,82 @@ export function BepList() { showOldestFirst ? a.number - b.number : b.number - a.number ); - const getBepsByStatus = (status: BepStatus) => { - return filteredBeps?.filter((bep) => bep.status === status) || []; - }; + const getBepsByStatus = useCallback( + (status: BepStatus) => { + return filteredBeps?.filter((bep) => bep.status === status) || []; + }, + [filteredBeps] + ); + + const activeBep = activeId + ? filteredBeps?.find((bep) => bep._id === activeId) + : null; + + const handleDragStart = useCallback((event: DragStartEvent) => { + const { active } = event; + setActiveId(active.id as Id<"beps">); + }, []); + + const handleDragOver = useCallback((event: DragOverEvent) => { + const { over } = event; + if (!over) { + setOverColumn(null); + return; + } + + const overData = over.data.current; + if (overData?.type === "column") { + setOverColumn(overData.status as BepStatus); + } else if (overData?.type === "bep") { + const overBep = filteredBeps?.find((bep) => bep._id === over.id); + if (overBep) { + setOverColumn(overBep.status); + } + } + }, [filteredBeps]); + + const handleDragEnd = useCallback( + async (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + setOverColumn(null); + + if (!over) return; + + const activeBepId = active.id as Id<"beps">; + const activeBepData = filteredBeps?.find((bep) => bep._id === activeBepId); + if (!activeBepData) return; + + let targetStatus: BepStatus | null = null; + + const overData = over.data.current; + if (overData?.type === "column") { + targetStatus = overData.status as BepStatus; + } else if (overData?.type === "bep") { + const overBep = filteredBeps?.find((bep) => bep._id === over.id); + if (overBep) { + targetStatus = overBep.status; + } + } + + if (targetStatus && targetStatus !== activeBepData.status) { + try { + await updateStatus({ + id: activeBepId, + status: targetStatus, + }); + } catch (error) { + console.error("Failed to update BEP status:", error); + } + } + }, + [filteredBeps, updateStatus] + ); + + const handleDragCancel = useCallback(() => { + setActiveId(null); + setOverColumn(null); + }, []); return (
@@ -141,42 +313,44 @@ export function BepList() {
) : viewMode === "kanban" ? ( -
- {KANBAN_COLUMNS.map((column) => { - const columnBeps = getBepsByStatus(column.status); - return ( -
-
-
-

{column.label}

- - {columnBeps.length} - -
-
- {columnBeps.length > 0 ? ( - columnBeps.map((bep) => ( - - )) - ) : ( -
- No BEPs -
- )} -
+ +
+ {KANBAN_COLUMNS.map((column) => ( + + ))} +
+ + {activeBep ? ( +
+
- ); - })} -
+ ) : null} + + ) : filteredBeps && filteredBeps.length > 0 ? (
{filteredBeps.map((bep) => (